feat(ai): ship Radius model catalog

This commit is contained in:
Armin Ronacher 2026-09-19 19:23:10 +02:00
parent de2de549bc
commit 4d38031fbd
11 changed files with 162 additions and 18 deletions

View file

@ -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

View file

@ -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` |

View file

@ -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"),

View file

@ -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,

View 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);

View file

@ -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;
},
});
},

View file

@ -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", () => {

View 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();
});
});

View file

@ -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)).

View file

@ -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

View file

@ -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);
});