feat(ai): add Radius gateway support

This commit is contained in:
Armin Ronacher
2026-07-14 11:01:21 +02:00
parent 0e6909f050
commit 961fa6c142
16 changed files with 1483 additions and 11 deletions
@@ -6,10 +6,10 @@ import {
type AnthropicMessagesCompat,
type Api,
type AssistantMessageEventStream,
type BuiltinProvider,
type Context,
getModels,
getProviders,
type KnownProvider,
type Model,
type OAuthProviderInterface,
type OpenAICompletionsCompat,
@@ -29,6 +29,7 @@ import { stripJsonComments } from "../utils/json.ts";
import { normalizePath } from "../utils/paths.ts";
import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts";
import { registerCustomRadiusOAuthProvider } from "./radius.ts";
import {
clearConfigValueCache,
getConfigValueEnvVarNames,
@@ -224,6 +225,9 @@ const ProviderConfigSchema = Type.Object({
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
apiKey: Type.Optional(Type.String({ minLength: 1 })),
api: Type.Optional(Type.String({ minLength: 1 })),
/** OAuth flavor spoken by this provider's endpoint. Registers a sign-in
* provider with a dynamic model catalog (e.g. a custom Radius gateway). */
oauth: Type.Optional(Type.Literal("radius")),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(ProviderCompatSchema),
authHeader: Type.Optional(Type.Boolean()),
@@ -453,7 +457,7 @@ export class ModelRegistry {
modelOverrides: Map<string, Map<string, ModelOverride>>,
): Model<Api>[] {
return getProviders().flatMap((provider) => {
const models = getModels(provider as KnownProvider) as Model<Api>[];
const models = getModels(provider as BuiltinProvider) as Model<Api>[];
const providerOverride = overrides.get(provider);
const perModelOverrides = modelOverrides.get(provider);
@@ -537,6 +541,12 @@ export class ModelRegistry {
});
}
if (providerConfig.oauth === "radius") {
// Must run before the modifyModels loop in loadModels() so the
// credential-cached catalog is injected on this load.
registerCustomRadiusOAuthProvider(providerName, providerConfig.name, providerConfig.baseUrl!);
}
this.storeProviderRequestConfig(providerName, providerConfig);
if (providerConfig.modelOverrides) {
@@ -568,7 +578,11 @@ export class ModelRegistry {
const hasModelOverrides =
providerConfig.modelOverrides && Object.keys(providerConfig.modelOverrides).length > 0;
if (models.length === 0) {
if (providerConfig.oauth && !providerConfig.baseUrl) {
throw new Error(`Provider ${providerName}: "baseUrl" is required when "oauth" is set.`);
}
if (models.length === 0 && !providerConfig.oauth) {
// Override-only config: needs baseUrl, headers, compat, modelOverrides, or some combination.
if (!providerConfig.baseUrl && !providerConfig.headers && !providerConfig.compat && !hasModelOverrides) {
throw new Error(
@@ -614,7 +628,7 @@ export class ModelRegistry {
const getBuiltInDefaults = (providerName: string): { api: string; baseUrl: string } | undefined => {
if (!builtInProviders.has(providerName)) return undefined;
if (builtInDefaultsCache.has(providerName)) return builtInDefaultsCache.get(providerName);
const builtIn = getModels(providerName as KnownProvider) as Model<Api>[];
const builtIn = getModels(providerName as BuiltinProvider) as Model<Api>[];
if (builtIn.length === 0) return undefined;
const defaults = { api: builtIn[0].api, baseUrl: builtIn[0].baseUrl };
builtInDefaultsCache.set(providerName, defaults);
@@ -18,6 +18,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
openai: "gpt-5.5",
"azure-openai-responses": "gpt-5.4",
"openai-codex": "gpt-5.5",
radius: "auto",
nvidia: "nvidia/nemotron-3-super-120b-a12b",
deepseek: "deepseek-v4-pro",
google: "gemini-3.1-pro-preview",
@@ -23,6 +23,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
"opencode-go": "OpenCode Go",
openai: "OpenAI",
openrouter: "OpenRouter",
radius: "Radius",
together: "Together AI",
"vercel-ai-gateway": "Vercel AI Gateway",
xai: "xAI",
+34
View File
@@ -0,0 +1,34 @@
/**
* Radius (pi-messages gateway) provider wiring.
*
* The main Radius provider is a built-in OAuth provider in pi-ai; models are
* dynamic, cached on the stored OAuth credential (`gatewayConfig`) and
* injected via the OAuth provider's `modifyModels` hook, so startup, /reload,
* and registry refreshes work without network access. The catalog refreshes
* on login and on every token refresh.
*
* Additional gateways (e.g. a local dev gateway) can be declared in
* models.json with `"oauth": "radius"`; each entry is an independent Radius
* instance with its own credentials and catalog.
*/
import { createRadiusOAuthProvider, registerOAuthProvider } from "@earendil-works/pi-ai/oauth";
export const RADIUS_PROVIDER_ID = "radius";
/**
* Register a Radius-style OAuth provider for a custom gateway declared in
* models.json (`"oauth": "radius"`). Runs on every models.json load so the
* registration survives `resetOAuthProviders()` during registry refreshes.
*/
export function registerCustomRadiusOAuthProvider(id: string, name: string | undefined, gateway: string): void {
registerOAuthProvider(
createRadiusOAuthProvider({
id,
name: name ?? id,
// Tolerate an API base URL: the gateway root is what the OAuth and
// config discovery endpoints hang off.
gateway: gateway.replace(/\/v1\/?$/u, ""),
}),
);
}
+146
View File
@@ -0,0 +1,146 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { RADIUS_PROVIDER_ID } from "../src/core/radius.ts";
function radiusOAuthCredential(gatewayBaseUrl: string) {
return {
type: "oauth" as const,
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60 * 60 * 1000,
gatewayConfig: {
baseUrl: gatewayBaseUrl,
models: [
{
id: "auto",
name: "Radius Auto",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 },
contextWindow: 128000,
maxTokens: 16384,
},
{
id: "byok/gpt-5.5",
name: "GPT-5.5 (BYOK)",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 32000,
},
],
},
};
}
let tempDir: string;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-test-radius-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
resetOAuthProviders();
});
afterEach(() => {
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true });
}
resetOAuthProviders();
});
describe("radius oauth provider", () => {
it("is registered as a built-in OAuth provider", () => {
expect(getOAuthProvider(RADIUS_PROVIDER_ID)?.name).toBe("Radius");
});
});
describe("radius models via ModelRegistry", () => {
it("injects catalog models from the stored credential", () => {
const registry = ModelRegistry.inMemory(
AuthStorage.inMemory({ radius: radiusOAuthCredential("https://radius.example.com/v1") }),
);
const auto = registry.find(RADIUS_PROVIDER_ID, "auto");
expect(auto).toBeDefined();
expect(auto?.api).toBe("pi-messages");
expect(auto?.baseUrl).toBe("https://radius.example.com/v1");
expect(auto?.name).toBe("Radius Auto");
// byok ids are registered verbatim
const byok = registry.find(RADIUS_PROVIDER_ID, "byok/gpt-5.5");
expect(byok).toBeDefined();
expect(byok?.contextWindow).toBe(256000);
expect(registry.hasConfiguredAuth(auto!)).toBe(true);
expect(registry.getProviderDisplayName(RADIUS_PROVIDER_ID)).toBe("Radius");
});
it("exposes no radius models without credentials", () => {
const registry = ModelRegistry.inMemory(AuthStorage.inMemory());
expect(registry.getAll().filter((model) => model.provider === RADIUS_PROVIDER_ID)).toHaveLength(0);
expect(getOAuthProvider(RADIUS_PROVIDER_ID)).toBeDefined();
});
it("keeps radius models across registry refresh", () => {
const registry = ModelRegistry.inMemory(
AuthStorage.inMemory({ radius: radiusOAuthCredential("https://radius.example.com/v1") }),
);
registry.refresh();
expect(registry.find(RADIUS_PROVIDER_ID, "auto")).toBeDefined();
});
});
describe("custom radius gateways via models.json", () => {
function createRegistry(providers: Record<string, unknown>, authStorage: AuthStorage): ModelRegistry {
const modelsJsonPath = join(tempDir, "models.json");
writeFileSync(modelsJsonPath, JSON.stringify({ providers }));
return ModelRegistry.create(authStorage, modelsJsonPath);
}
it("registers an independent radius-style provider", () => {
const registry = createRegistry(
{ "radius-dev": { name: "Radius (dev)", baseUrl: "http://localhost:8788", oauth: "radius" } },
AuthStorage.inMemory({ "radius-dev": radiusOAuthCredential("http://localhost:8788/v1") }),
);
expect(registry.getError()).toBeUndefined();
expect(getOAuthProvider("radius-dev")?.name).toBe("Radius (dev)");
expect(getOAuthProvider(RADIUS_PROVIDER_ID)?.name).toBe("Radius");
// Dev gateway models are injected under the custom provider id only.
const devAuto = registry.find("radius-dev", "auto");
expect(devAuto).toBeDefined();
expect(devAuto?.api).toBe("pi-messages");
expect(devAuto?.baseUrl).toBe("http://localhost:8788/v1");
expect(registry.find(RADIUS_PROVIDER_ID, "auto")).toBeUndefined();
expect(registry.getProviderDisplayName("radius-dev")).toBe("Radius (dev)");
});
it("survives registry refresh", () => {
const registry = createRegistry(
{ "radius-dev": { baseUrl: "http://localhost:8788", oauth: "radius" } },
AuthStorage.inMemory({ "radius-dev": radiusOAuthCredential("http://localhost:8788/v1") }),
);
registry.refresh();
expect(getOAuthProvider("radius-dev")).toBeDefined();
expect(registry.find("radius-dev", "auto")).toBeDefined();
});
it("requires baseUrl when oauth is set", () => {
const registry = createRegistry({ "radius-dev": { oauth: "radius" } }, AuthStorage.inMemory());
expect(registry.getError()).toContain('"baseUrl" is required when "oauth" is set');
});
});