mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(ai): ship Radius model catalog
This commit is contained in:
parent
de2de549bc
commit
4d38031fbd
11 changed files with 162 additions and 18 deletions
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
- Added a generated public Radius model catalog for synchronous API lookup, with cached and live gateway catalogs overlaid at runtime.
|
||||
- Enabled native deferred tool loading for Fireworks Messages models. Use `ToolSearch` or `tool_search` as the loader name for prompt-prefix deferral ([#9323](https://github.com/earendil-works/pi/issues/9323)).
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
|
|||
- **Ant Ling**
|
||||
- **Azure OpenAI (Responses)**
|
||||
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
|
||||
- **Radius** (API key or OAuth, with a dynamically refreshed gateway catalog)
|
||||
- **DeepSeek**
|
||||
- **NVIDIA NIM**
|
||||
- **Anthropic**
|
||||
|
|
@ -306,6 +307,7 @@ For tooling that wants the generated built-in catalog with full literal typing (
|
|||
import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all';
|
||||
|
||||
const model = getBuiltinModel('openai', 'gpt-4o-mini'); // typed Model<'openai-responses'>
|
||||
const radius = getBuiltinModel('radius', 'balanced'); // typed Model<'pi-messages'>
|
||||
const providers = getBuiltinProviders();
|
||||
const anthropic = getBuiltinModels('anthropic');
|
||||
```
|
||||
|
|
@ -321,7 +323,7 @@ await models.refresh(); // refresh all providers conc
|
|||
const fresh = models.getModel('llamacpp', 'qwen3-30b');
|
||||
```
|
||||
|
||||
Static built-in providers are no-ops for `refresh()`. See [createProvider()](#createprovider) for building a dynamic provider.
|
||||
Static built-in providers are no-ops for `refresh()`. Radius is both static and dynamic: it ships the public `radius.pi.dev` catalog for synchronous API lookup, then overlays cached and freshly fetched `/v1/config` models when refreshed with configured auth. See [createProvider()](#createprovider) for building a dynamic provider.
|
||||
|
||||
## Auth
|
||||
|
||||
|
|
@ -418,6 +420,7 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex
|
|||
| Ant Ling | `ANT_LING_API_KEY` |
|
||||
| Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (e.g. `https://{resource}.ai.azure.com`) or `AZURE_OPENAI_RESOURCE_NAME`. Supports `*.openai.azure.com`, `*.cognitiveservices.azure.com` and `*.ai.azure.com`; root endpoints auto-normalize to `/openai/v1`. Optional: `AZURE_OPENAI_API_VERSION` (default `v1`), `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` |
|
||||
| Radius | `RADIUS_API_KEY` |
|
||||
| DeepSeek | `DEEPSEEK_API_KEY` |
|
||||
| NVIDIA NIM | `NVIDIA_API_KEY` |
|
||||
| Google | `GEMINI_API_KEY` |
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ import {
|
|||
validateGeneratedModelData,
|
||||
validateModelDataDirectory,
|
||||
} from "./model-data.ts";
|
||||
import {
|
||||
DEFAULT_RADIUS_GATEWAY,
|
||||
getRadiusModelsFromConfig,
|
||||
loadRadiusGatewayConfig,
|
||||
} from "../src/providers/radius-config.ts";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -1270,6 +1275,21 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchRadiusModels(): Promise<Model<"pi-messages">[]> {
|
||||
try {
|
||||
console.log("Fetching models from Radius API...");
|
||||
const config = await loadRadiusGatewayConfig(DEFAULT_RADIUS_GATEWAY);
|
||||
const models = getRadiusModelsFromConfig("radius", config);
|
||||
if (models.length === 0) throw new Error("Radius API returned no models");
|
||||
console.log(`Fetched ${models.length} models from Radius`);
|
||||
return models;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Radius models:", error);
|
||||
if (generatorOptions.strict) throw error;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAiGatewayModels(): Promise<Model<any>[]> {
|
||||
try {
|
||||
console.log("Fetching models from Vercel AI Gateway API...");
|
||||
|
|
@ -2548,16 +2568,18 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
}
|
||||
|
||||
async function generateModels() {
|
||||
// Fetch models from both sources
|
||||
// models.dev: Anthropic, Google, OpenAI, Groq, Cerebras
|
||||
// OpenRouter: xAI and other providers (excluding Anthropic, Google, OpenAI)
|
||||
// Fetch models from all upstream catalogs.
|
||||
// models.dev: Anthropic, Google, OpenAI, Groq, Cerebras, and others
|
||||
// OpenRouter: its tool-capable routed catalog
|
||||
// AI Gateway: OpenAI-compatible catalog with tool-capable models
|
||||
// Radius: its unauthenticated public catalog; authenticated clients overlay it at runtime
|
||||
const modelsDevModels = await loadModelsDevData();
|
||||
const openRouterModels = await fetchOpenRouterModels();
|
||||
const aiGatewayModels = await fetchAiGatewayModels();
|
||||
const radiusModels = await fetchRadiusModels();
|
||||
|
||||
// Combine models (models.dev has priority)
|
||||
const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels].filter(
|
||||
// Combine models (models.dev has priority where sources overlap).
|
||||
const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels, ...radiusModels].filter(
|
||||
(model) =>
|
||||
!(model.provider === "xai" && XAI_BUILTIN_EXCLUDED_MODEL_IDS.has(model.id)) &&
|
||||
!((model.provider === "opencode" || model.provider === "opencode-go") && model.id === "gpt-5.3-codex-spark"),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { OPENROUTER_MODELS } from "./providers/openrouter.models.ts";
|
|||
import { QWEN_TOKEN_PLAN_MODELS } from "./providers/qwen-token-plan.models.ts";
|
||||
import { QWEN_TOKEN_PLAN_CN_MODELS } from "./providers/qwen-token-plan-cn.models.ts";
|
||||
import { QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS } from "./providers/qwen-token-plan-individual.models.ts";
|
||||
import { RADIUS_MODELS } from "./providers/radius.models.ts";
|
||||
import { TOGETHER_MODELS } from "./providers/together.models.ts";
|
||||
import { VERCEL_AI_GATEWAY_MODELS } from "./providers/vercel-ai-gateway.models.ts";
|
||||
import { XAI_MODELS } from "./providers/xai.models.ts";
|
||||
|
|
@ -72,6 +73,7 @@ export const MODELS: {
|
|||
readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_MODELS;
|
||||
readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_MODELS;
|
||||
readonly "qwen-token-plan-individual": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS;
|
||||
readonly "radius": typeof RADIUS_MODELS;
|
||||
readonly "together": typeof TOGETHER_MODELS;
|
||||
readonly "vercel-ai-gateway": typeof VERCEL_AI_GATEWAY_MODELS;
|
||||
readonly "xai": typeof XAI_MODELS;
|
||||
|
|
@ -112,6 +114,7 @@ export const MODELS: {
|
|||
"qwen-token-plan": QWEN_TOKEN_PLAN_MODELS,
|
||||
"qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_MODELS,
|
||||
"qwen-token-plan-individual": QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS,
|
||||
"radius": RADIUS_MODELS,
|
||||
"together": TOGETHER_MODELS,
|
||||
"vercel-ai-gateway": VERCEL_AI_GATEWAY_MODELS,
|
||||
"xai": XAI_MODELS,
|
||||
|
|
|
|||
8
packages/ai/src/providers/radius.models.ts
Normal file
8
packages/ai/src/providers/radius.models.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import values from "./data/radius.json" with { type: "json" };
|
||||
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
||||
|
||||
export const RADIUS_MODELS: ModelCatalog<typeof values, "radius"> =
|
||||
flattenModelCatalog("radius", values);
|
||||
|
|
@ -2,6 +2,8 @@ import { piMessagesApi } from "../api/pi-messages.lazy.ts";
|
|||
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
|
||||
import { loadRadiusOAuth } from "../auth/oauth/load.ts";
|
||||
import type { Provider } from "../models.ts";
|
||||
import type { Model } from "../types.ts";
|
||||
import { RADIUS_MODELS } from "./radius.models.ts";
|
||||
import {
|
||||
DEFAULT_RADIUS_GATEWAY,
|
||||
getRadiusModels,
|
||||
|
|
@ -21,7 +23,11 @@ export function radiusProvider(options: RadiusProviderOptions = {}): Provider<"p
|
|||
const id = options.id ?? "radius";
|
||||
const name = options.name ?? "Radius";
|
||||
const gateway = normalizeRadiusGatewayUrl(options.gateway ?? DEFAULT_RADIUS_GATEWAY);
|
||||
let models = getRadiusModels(id, undefined);
|
||||
const baselineModels: Model<"pi-messages">[] =
|
||||
gateway === normalizeRadiusGatewayUrl(DEFAULT_RADIUS_GATEWAY)
|
||||
? Object.values(RADIUS_MODELS).map((model) => ({ ...model, provider: id }))
|
||||
: [];
|
||||
let dynamicModels = getRadiusModels(id, undefined);
|
||||
const streams = piMessagesApi();
|
||||
|
||||
return {
|
||||
|
|
@ -31,15 +37,23 @@ export function radiusProvider(options: RadiusProviderOptions = {}): Provider<"p
|
|||
apiKey: envApiKeyAuth("Radius API key", ["RADIUS_API_KEY"]),
|
||||
oauth: lazyOAuth({ name, load: () => loadRadiusOAuth({ name, gateway }) }),
|
||||
},
|
||||
getModels: () => models,
|
||||
getModels: () => {
|
||||
const merged = [...baselineModels];
|
||||
for (const model of dynamicModels) {
|
||||
const index = merged.findIndex((entry) => entry.id === model.id);
|
||||
if (index >= 0) merged[index] = model;
|
||||
else merged.push(model);
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
refreshModels: async (context) => {
|
||||
const stored = context.stored;
|
||||
if (stored) {
|
||||
const restored = stored.models.filter((model) => model.provider === id) as typeof models;
|
||||
const restored = stored.models.filter((model) => model.provider === id) as typeof dynamicModels;
|
||||
if (
|
||||
!(await context.publish({
|
||||
update: () => {
|
||||
models = restored;
|
||||
dynamicModels = restored;
|
||||
},
|
||||
}))
|
||||
) {
|
||||
|
|
@ -55,7 +69,7 @@ export function radiusProvider(options: RadiusProviderOptions = {}): Provider<"p
|
|||
!(await context.publish({
|
||||
persist: { models: legacy, checkedAt: Date.now() },
|
||||
update: () => {
|
||||
models = legacy;
|
||||
dynamicModels = legacy;
|
||||
},
|
||||
}))
|
||||
) {
|
||||
|
|
@ -72,7 +86,7 @@ export function radiusProvider(options: RadiusProviderOptions = {}): Provider<"p
|
|||
await context.publish({
|
||||
persist: { models: refreshed, checkedAt: Date.now() },
|
||||
update: () => {
|
||||
models = refreshed;
|
||||
dynamicModels = refreshed;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -46,13 +46,15 @@ describe("builtin providers", () => {
|
|||
const all = models.getModels();
|
||||
expect(all.length).toBeGreaterThan(500);
|
||||
|
||||
// Static providers list models immediately; Radius is purely dynamic.
|
||||
for (const provider of providers) {
|
||||
const list = models.getModels(provider.id);
|
||||
if (provider.id === "radius") expect(list).toEqual([]);
|
||||
else expect(list.length).toBeGreaterThan(0);
|
||||
expect(list.length).toBeGreaterThan(0);
|
||||
expect(list.every((m) => m.provider === provider.id)).toBe(true);
|
||||
}
|
||||
expect(getBuiltinModel("radius", "balanced")).toMatchObject({
|
||||
api: "pi-messages",
|
||||
provider: "radius",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores native constrained-sampling capabilities in model metadata", () => {
|
||||
|
|
|
|||
90
packages/ai/test/radius-provider.test.ts
Normal file
90
packages/ai/test/radius-provider.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
|
||||
import { createModels } from "../src/models.ts";
|
||||
import { InMemoryModelsStore } from "../src/models-store.ts";
|
||||
import { radiusProvider } from "../src/providers/radius.ts";
|
||||
import { getRadiusModelsFromConfig, type RadiusGatewayConfig } from "../src/providers/radius-config.ts";
|
||||
|
||||
function radiusConfig(): RadiusGatewayConfig {
|
||||
return {
|
||||
baseUrl: "https://radius.example/v1",
|
||||
models: [
|
||||
{
|
||||
id: "balanced",
|
||||
name: "Fresh Balanced",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 },
|
||||
contextWindow: 424242,
|
||||
maxTokens: 32000,
|
||||
},
|
||||
{
|
||||
id: "organization-only",
|
||||
name: "Organization Only",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Radius provider catalogs", () => {
|
||||
it("ships a static public catalog for the default gateway", () => {
|
||||
const provider = radiusProvider();
|
||||
expect(provider.getModels().length).toBeGreaterThan(0);
|
||||
expect(provider.getModels()).toContainEqual(
|
||||
expect.objectContaining({ id: "balanced", provider: "radius", api: "pi-messages" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply the public Radius catalog to custom gateways", () => {
|
||||
const provider = radiusProvider({ id: "radius-dev", gateway: "http://localhost:8788" });
|
||||
expect(provider.getModels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("overlays refreshed models on the static public catalog", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify(radiusConfig()), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const credentials = new InMemoryCredentialStore();
|
||||
await credentials.modify("radius", async () => ({ type: "api_key", key: "radius-key" }));
|
||||
const models = createModels({ credentials });
|
||||
models.setProvider(radiusProvider());
|
||||
|
||||
const result = await models.refresh({ providers: ["radius"] });
|
||||
|
||||
expect(result.errors).toEqual(new Map());
|
||||
expect(models.getModel("radius", "balanced")).toMatchObject({
|
||||
name: "Fresh Balanced",
|
||||
baseUrl: "https://radius.example/v1",
|
||||
contextWindow: 424242,
|
||||
});
|
||||
expect(models.getModel("radius", "organization-only")).toBeDefined();
|
||||
expect(models.getModels("radius").length).toBeGreaterThan(radiusConfig().models.length);
|
||||
});
|
||||
|
||||
it("overlays a cached effective catalog without network access", async () => {
|
||||
const store = new InMemoryModelsStore();
|
||||
await store.write("radius", {
|
||||
models: getRadiusModelsFromConfig("radius", radiusConfig()),
|
||||
checkedAt: Date.now(),
|
||||
});
|
||||
const models = createModels({ modelsStore: store });
|
||||
models.setProvider(radiusProvider());
|
||||
|
||||
await models.refresh({ providers: ["radius"], allowNetwork: false });
|
||||
|
||||
expect(models.getModel("radius", "balanced")?.name).toBe("Fresh Balanced");
|
||||
expect(models.getModel("radius", "organization-only")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
- Added the public Radius model catalog for immediate and offline model selection, with cached and live gateway catalogs overlaid when available.
|
||||
- Added `ctx.modelRegistry.stream()` and `streamSimple()` for extension model calls through configured providers with resolved authentication ([#8964](https://github.com/earendil-works/pi/issues/8964)).
|
||||
- Added per-model `reserveTokens` and `keepRecentTokens` settings through `compaction.modelOverrides`, with ordinary compaction settings as fallback ([#8133](https://github.com/earendil-works/pi-mono/issues/8133)).
|
||||
- Added `compat.allowedFallbackModels` configuration for overriding or disabling Anthropic server-side fallback models ([#9294](https://github.com/earendil-works/pi/issues/9294)).
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party h
|
|||
|
||||
### Radius
|
||||
|
||||
Radius is a dynamic `pi-messages` gateway. `/login radius` stores OAuth tokens in `auth.json`; the gateway catalog is refreshed independently and cached in `models-store.json`. Custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` and a gateway `baseUrl`.
|
||||
Radius is a `pi-messages` gateway. Pi ships the public Radius model catalog for immediate and offline model lookup, then overlays it with the effective gateway catalog after authentication. `/login radius` stores OAuth tokens in `auth.json`; refreshed catalogs are cached in `models-store.json`. Custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` and a gateway `baseUrl`; they do not inherit the public `radius.pi.dev` catalog.
|
||||
|
||||
## API Keys
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ describe("Radius provider", () => {
|
|||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fetch or expose Radius models without configured auth", async () => {
|
||||
it("does not fetch or make Radius models available without configured auth", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const runtime = await ModelRuntime.create({
|
||||
credentials: AuthStorage.inMemory(),
|
||||
|
|
@ -120,7 +120,7 @@ describe("Radius provider", () => {
|
|||
allowModelNetwork: true,
|
||||
});
|
||||
|
||||
expect(runtime.getModels(RADIUS_PROVIDER_ID)).toEqual([]);
|
||||
expect(runtime.getAvailableSnapshot().filter((model) => model.provider === RADIUS_PROVIDER_ID)).toEqual([]);
|
||||
expect(fetchSpy.mock.calls.some(([url]) => String(url).includes("radius.pi.dev/v1/config"))).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue