feat(ai): add Radius gateway support
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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, ""),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user