diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1b142c4d8..d671a901e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/README.md b/packages/ai/README.md index 3c8129ae9..52bcb1138 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -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` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 83f4c925d..e73f5edfc 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -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[]> { } } +async function fetchRadiusModels(): Promise[]> { + 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[]> { try { console.log("Fetching models from Vercel AI Gateway API..."); @@ -2548,16 +2568,18 @@ async function loadModelsDevData(): Promise[]> { } 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"), diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 129520459..5d051c119 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -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, diff --git a/packages/ai/src/providers/radius.models.ts b/packages/ai/src/providers/radius.models.ts new file mode 100644 index 000000000..fbba39057 --- /dev/null +++ b/packages/ai/src/providers/radius.models.ts @@ -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 = + flattenModelCatalog("radius", values); diff --git a/packages/ai/src/providers/radius.ts b/packages/ai/src/providers/radius.ts index 21db78527..785962560 100644 --- a/packages/ai/src/providers/radius.ts +++ b/packages/ai/src/providers/radius.ts @@ -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; }, }); }, diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index e6fa4c4fc..b6a960c38 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -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", () => { diff --git a/packages/ai/test/radius-provider.test.ts b/packages/ai/test/radius-provider.test.ts new file mode 100644 index 000000000..3d184ec8f --- /dev/null +++ b/packages/ai/test/radius-provider.test.ts @@ -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(); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c1410182d..479aada7d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 4fea738fa..bae375e08 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -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 diff --git a/packages/coding-agent/test/radius.test.ts b/packages/coding-agent/test/radius.test.ts index cec1a15e8..b905c1798 100644 --- a/packages/coding-agent/test/radius.test.ts +++ b/packages/coding-agent/test/radius.test.ts @@ -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); });