From 3cd84966fa3246f7bbd9223155ecaa01825e7d77 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Mon, 26 Oct 2026 23:16:13 -0400 Subject: [PATCH] fix(ai): remove Radius authentication and access - Remove built-in OAuth and API-key integration so saved state cannot restore Radius access - Reject custom Radius OAuth configuration and cover direct login and stale model requests --- docs/release/provider-branding-validation.md | 5 +- packages/ai/src/auth/oauth/load.ts | 10 - packages/ai/src/auth/oauth/radius.ts | 403 ------------------ packages/ai/src/bun-oauth.ts | 2 - packages/ai/src/env-api-keys.ts | 1 - packages/ai/src/providers/all.ts | 8 +- packages/ai/src/providers/radius-config.ts | 99 ----- packages/ai/src/providers/radius.ts | 82 ---- packages/ai/src/types.ts | 1 - packages/ai/test/providers.test.ts | 14 +- packages/ai/test/radius-oauth.test.ts | 129 ------ packages/coding-agent/docs/models.md | 1 - packages/coding-agent/docs/providers.md | 6 - .../coding-agent/src/core/model-config.ts | 3 +- .../coding-agent/src/core/model-resolver.ts | 1 - .../coding-agent/src/core/model-runtime.ts | 32 +- .../src/core/provider-composer.ts | 6 +- packages/coding-agent/src/core/radius.ts | 1 - .../test/model-runtime-auth-options.test.ts | 8 - packages/coding-agent/test/radius.test.ts | 266 +++++------- scripts/runtime-identity-allowlist.json | 18 - scripts/verify-candidate-artifacts.py | 7 +- 22 files changed, 135 insertions(+), 968 deletions(-) delete mode 100644 packages/ai/src/auth/oauth/radius.ts delete mode 100644 packages/ai/src/providers/radius-config.ts delete mode 100644 packages/ai/src/providers/radius.ts delete mode 100644 packages/ai/test/radius-oauth.test.ts delete mode 100644 packages/coding-agent/src/core/radius.ts diff --git a/docs/release/provider-branding-validation.md b/docs/release/provider-branding-validation.md index eb5d95ede..0d697c929 100644 --- a/docs/release/provider-branding-validation.md +++ b/docs/release/provider-branding-validation.md @@ -2,7 +2,8 @@ Diffex controls the local callback presentation and default application attribution. Registered OAuth client IDs, provider consent pages, authentication protocols, stored credentials, inference endpoints, and payloads remain unchanged. -The separate Diffex account feature and Radius implementation are excluded. +The separate Diffex account feature is excluded. +Radius was excluded from the original branding change and subsequently removed from the built-in provider and authentication integrations. ## Implemented defaults @@ -37,7 +38,7 @@ Attribution tests cover enabled and disabled settings, environment overrides, co A browser-bundled Codex stream also completed against a mock with `process` absent and the expected `diffex (browser)` User-Agent. `npm run check` passes, including the runtime identity inventory and browser bundle smoke check. -The inventory retains four occurrences across three documented exceptions: the Radius client ID, the Radius gateway, and two occurrences in the original DOOM integration credit. +The inventory retains only two occurrences in one documented exception for the original DOOM integration credit. The audit found no inherited Pi application labels in other built-in provider login implementations or shared login UI. Provider protocol labels such as GitHub Copilot and Claude CLI headers remain unchanged. diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index e9c72d87d..c95abdc31 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -18,7 +18,6 @@ type OAuthFlowLoaders = { openrouter: () => OAuthAuth | Promise; kimiCoding: () => OAuthAuth | Promise; xai: () => OAuthAuth | Promise; - radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise; }; let bundledLoaders: OAuthFlowLoaders | undefined; @@ -57,12 +56,3 @@ export const loadXaiOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.xai(); return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; }; - -export const loadRadiusOAuth = async (options: { name: string; gateway: string }): Promise => { - if (bundledLoaders) return bundledLoaders.radius(options); - return ( - (await importOAuthModule("./radius.ts")) as { - createRadiusOAuth: (input: { name: string; gateway: string }) => OAuthAuth; - } - ).createRadiusOAuth(options); -}; diff --git a/packages/ai/src/auth/oauth/radius.ts b/packages/ai/src/auth/oauth/radius.ts deleted file mode 100644 index 450025d6e..000000000 --- a/packages/ai/src/auth/oauth/radius.ts +++ /dev/null @@ -1,403 +0,0 @@ -/** - * Radius gateway OAuth flow. - * - * Radius is a diffex-messages gateway. OAuth client APIs live on the configured - * gateway; only the interactive browser authorization endpoint is discovered. - * Model catalog loading is owned by the Radius provider. - * - * NOTE: This module uses node:http for the OAuth callback server. - * It is only intended for CLI use, not browser environments. - */ - -// NEVER convert to top-level imports - breaks browser/Vite builds -let _http: typeof import("node:http") | null = null; -if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { - import("node:http").then((m) => { - _http = m; - }); -} - -import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.ts"; -import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; -import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; -import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; -import { generatePKCE } from "./pkce.ts"; - -const CALLBACK_HOST = "127.0.0.1"; -const CALLBACK_PORT = 1456; -const CALLBACK_PATH = "/oauth/callback"; -const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`; -const TOKEN_EXPIRY_SKEW_MS = 60_000; -const LOGIN_METHOD_BROWSER = "browser"; -const LOGIN_METHOD_DEVICE_CODE = "device-code"; -const OAUTH_CLIENT_ID = "pi-gateway"; -const OAUTH_SCOPE = "gateway offline_access"; -const OAUTH_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; - -type RadiusOAuthDiscovery = { - authorizationEndpoint: string; -}; - -type DeviceAuthorizationResponse = { - device_code: string; - user_code: string; - verification_uri: string; - expires_in: number; - interval?: number; -}; - -async function loadRadiusOAuthDiscovery(gateway: string, signal: AbortSignal): Promise { - const response = await fetch(new URL("/v1/oauth", gateway), { - headers: { accept: "application/json" }, - signal, - }); - - if (!response.ok) { - throw new Error( - `Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`, - ); - } - - const discovery = (await response.json()) as Partial; - if (typeof discovery.authorizationEndpoint !== "string") { - throw new Error(`Invalid Radius OAuth config from ${gateway}`); - } - return { authorizationEndpoint: discovery.authorizationEndpoint }; -} - -class OAuthResponseError extends Error { - readonly status: number; - readonly oauthError?: string; - - constructor(status: number, oauthError: string | undefined, description: string | undefined, message: string) { - const detail = oauthError - ? description - ? `${oauthError}: ${description}` - : oauthError - : description || String(status); - super(`${message}: ${detail}`); - this.status = status; - this.oauthError = oauthError; - } -} - -async function readOAuthResponseError(response: Response, message: string): Promise { - const text = await response.text().catch(() => ""); - let oauthError: string | undefined; - let description: string | undefined; - - if (text) { - try { - const data = JSON.parse(text) as { error?: unknown; error_description?: unknown }; - oauthError = typeof data.error === "string" ? data.error : undefined; - description = typeof data.error_description === "string" ? data.error_description : undefined; - } catch { - description = text; - } - } - - return new OAuthResponseError(response.status, oauthError, description, message); -} - -async function requestOAuthToken( - gateway: string, - body: URLSearchParams, - signal: AbortSignal, -): Promise { - let response: Response; - try { - response = await fetch(new URL("/v1/oauth/token", gateway), { - method: "POST", - headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, - body, - signal, - }); - } catch (error) { - if (signal.aborted) { - throw new Error("Login cancelled"); - } - throw error; - } - - if (!response.ok) { - throw await readOAuthResponseError(response, "Radius OAuth token request failed"); - } - - const data = (await response.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - scope?: string; - }; - - return { - type: "oauth", - access: data.access_token, - refresh: data.refresh_token, - expires: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS, - scope: data.scope, - }; -} - -type OAuthCallbackServer = { - waitForCode(): Promise; - close(): void; -}; - -function startOAuthCallbackServer(expectedState: string, signal: AbortSignal): Promise { - if (!_http) { - throw new Error("Radius OAuth is only available in Node.js environments"); - } - - let settle: (code: string | null) => void = () => {}; - let settled = false; - const wait = new Promise((resolve) => { - settle = resolve; - }); - const finish = (code: string | null) => { - if (settled) { - return; - } - settled = true; - signal.removeEventListener("abort", onAbort); - settle(code); - }; - const onAbort = () => finish(null); - signal.addEventListener("abort", onAbort, { once: true }); - - const sendPage = (response: import("node:http").ServerResponse, status: number, html: string) => { - response.statusCode = status; - response.setHeader("content-type", "text/html; charset=utf-8"); - response.end(html); - }; - - const server = _http.createServer((request, response) => { - const url = new URL(request.url ?? "/", REDIRECT_URI); - if (url.pathname !== CALLBACK_PATH) { - sendPage(response, 404, oauthErrorHtml("Callback route not found.")); - return; - } - if (url.searchParams.get("state") !== expectedState) { - sendPage(response, 400, oauthErrorHtml("OAuth state mismatch.")); - return; - } - - const error = url.searchParams.get("error"); - if (error) { - sendPage(response, 400, oauthErrorHtml(url.searchParams.get("error_description") ?? error)); - finish(null); - return; - } - - const code = url.searchParams.get("code"); - if (!code) { - sendPage(response, 400, oauthErrorHtml("Missing authorization code.")); - return; - } - - sendPage(response, 200, oauthSuccessHtml("Signed in to Radius. You may now close this page.")); - finish(code); - }); - - return new Promise((resolve) => { - server - .listen(CALLBACK_PORT, CALLBACK_HOST, () => { - resolve({ - waitForCode: () => wait, - close: () => { - finish(null); - server.close(); - }, - }); - }) - .once("error", () => { - finish(null); - resolve({ waitForCode: async () => null, close: () => {} }); - }); - }); -} - -async function loginWithBrowser( - gateway: string, - authorizationEndpoint: string, - interaction: ProviderAuthInteraction, -): Promise { - const { verifier, challenge } = await generatePKCE(); - const state = crypto.randomUUID(); - const authorizeUrl = new URL(authorizationEndpoint); - authorizeUrl.search = new URLSearchParams({ - response_type: "code", - client_id: OAUTH_CLIENT_ID, - redirect_uri: REDIRECT_URI, - scope: OAUTH_SCOPE, - code_challenge: challenge, - code_challenge_method: "S256", - handoff: "url", - state, - }).toString(); - - const callbackServer = await startOAuthCallbackServer(state, interaction.signal); - interaction.notify({ type: "progress", message: `Listening for OAuth callback on ${REDIRECT_URI}` }); - interaction.notify({ - type: "auth_url", - url: authorizeUrl.toString(), - instructions: "Continue in your browser.", - }); - - try { - const code = await callbackServer.waitForCode(); - if (!code) { - if (interaction.signal.aborted) { - throw new Error("Login cancelled"); - } - throw new Error("OAuth callback did not complete."); - } - return await requestOAuthToken( - gateway, - new URLSearchParams({ - grant_type: "authorization_code", - client_id: OAUTH_CLIENT_ID, - redirect_uri: REDIRECT_URI, - code, - code_verifier: verifier, - }), - interaction.signal, - ); - } finally { - callbackServer.close(); - } -} - -async function requestDeviceAuthorization(gateway: string, signal: AbortSignal): Promise { - let response: Response; - try { - response = await fetch(new URL("/v1/oauth/device", gateway), { - method: "POST", - headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }), - signal, - }); - } catch (error) { - if (signal.aborted) { - throw new Error("Login cancelled"); - } - throw error; - } - - if (!response.ok) { - throw await readOAuthResponseError(response, "Radius OAuth device authorization failed"); - } - - const data = (await response.json()) as Partial; - if (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) { - throw new Error("Radius OAuth device authorization response is missing required fields"); - } - - return { - device_code: data.device_code, - user_code: data.user_code, - verification_uri: data.verification_uri, - expires_in: data.expires_in, - interval: data.interval, - }; -} - -async function loginWithDeviceCode(gateway: string, interaction: ProviderAuthInteraction): Promise { - const device = await requestDeviceAuthorization(gateway, interaction.signal); - interaction.notify({ - type: "device_code", - userCode: device.user_code, - verificationUri: device.verification_uri, - intervalSeconds: device.interval, - expiresInSeconds: device.expires_in, - }); - - return pollOAuthDeviceCodeFlow({ - intervalSeconds: device.interval, - expiresInSeconds: device.expires_in, - signal: interaction.signal, - poll: async () => { - try { - const credentials = await requestOAuthToken( - gateway, - new URLSearchParams({ - grant_type: OAUTH_DEVICE_CODE_GRANT_TYPE, - client_id: OAUTH_CLIENT_ID, - device_code: device.device_code, - }), - interaction.signal, - ); - return { status: "complete", value: credentials }; - } catch (error) { - if (!(error instanceof OAuthResponseError)) { - throw error; - } - switch (error.oauthError) { - case "authorization_pending": - return { status: "pending" }; - case "slow_down": - return { status: "slow_down" }; - case "expired_token": - return { status: "failed", message: "Device authorization expired." }; - case "access_denied": - return { status: "failed", message: "Device authorization was denied." }; - default: - throw error; - } - } - }, - }); -} - -export interface RadiusOAuthOptions { - name: string; - gateway: string; -} - -export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth { - const gateway = normalizeRadiusGatewayUrl(options.gateway); - - return { - name: options.name, - - async login(interaction): Promise { - const loginMethod = await interaction.prompt({ - type: "select", - message: `Sign in to ${options.name}:`, - options: [ - { id: LOGIN_METHOD_BROWSER, label: "Sign in with browser (recommended)" }, - { - id: LOGIN_METHOD_DEVICE_CODE, - label: "Sign in with device code (when signing in from another device)", - }, - ], - }); - - if (loginMethod === LOGIN_METHOD_DEVICE_CODE) { - return loginWithDeviceCode(gateway, interaction); - } - if (loginMethod === LOGIN_METHOD_BROWSER) { - const discovery = await loadRadiusOAuthDiscovery(gateway, interaction.signal); - return loginWithBrowser(gateway, discovery.authorizationEndpoint, interaction); - } - throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`); - }, - - async refresh(credential, signal): Promise { - const refreshed = await requestOAuthToken( - gateway, - new URLSearchParams({ - grant_type: "refresh_token", - client_id: OAUTH_CLIENT_ID, - refresh_token: credential.refresh, - }), - signal, - ); - return refreshed; - }, - - async toAuth(credential) { - return { apiKey: credential.access }; - }, - }; -} diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts index d1370214d..7c7fd3779 100644 --- a/packages/ai/src/bun-oauth.ts +++ b/packages/ai/src/bun-oauth.ts @@ -4,7 +4,6 @@ import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts"; import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts"; import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; import { openRouterOAuth } from "./auth/oauth/openrouter.ts"; -import { createRadiusOAuth } from "./auth/oauth/radius.ts"; import { xaiOAuth } from "./auth/oauth/xai.ts"; /** Register OAuth flows statically embedded in the standalone Bun binary. */ @@ -16,6 +15,5 @@ export function registerBunOAuthFlows(): void { openrouter: () => openRouterOAuth, kimiCoding: () => kimiCodingOAuth, xai: () => xaiOAuth, - radius: createRadiusOAuth, }); } diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 5b952b7b5..e64478541 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -90,7 +90,6 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { groq: "GROQ_API_KEY", cerebras: "CEREBRAS_API_KEY", xai: "XAI_API_KEY", - radius: "RADIUS_API_KEY", openrouter: "OPENROUTER_API_KEY", "vercel-ai-gateway": "AI_GATEWAY_API_KEY", zai: "ZAI_API_KEY", diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 8785e94ae..ffa52fb34 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -34,7 +34,6 @@ import { openrouterImagesProvider } from "./openrouter-images.ts"; import { qwenTokenPlanProvider } from "./qwen-token-plan.ts"; import { qwenTokenPlanCnProvider } from "./qwen-token-plan-cn.ts"; import { qwenTokenPlanIndividualProvider } from "./qwen-token-plan-individual.ts"; -import { radiusProvider } from "./radius.ts"; import { togetherProvider } from "./together.ts"; import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts"; import { xaiProvider } from "./xai.ts"; @@ -45,11 +44,7 @@ import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp.ts"; import { zaiProvider } from "./zai.ts"; import { zaiCodingCnProvider } from "./zai-coding-cn.ts"; -export { radiusProvider }; - -/** Providers present in the generated catalog. `KnownProvider` additionally - * includes purely dynamic providers (e.g. "radius") that have no static - * catalog entry. */ +/** Providers present in the generated catalog. */ export type BuiltinProvider = keyof typeof MODELS; type BuiltinModelApi< @@ -118,7 +113,6 @@ export function builtinProviders(): Provider[] { qwenTokenPlanProvider(), qwenTokenPlanCnProvider(), qwenTokenPlanIndividualProvider(), - radiusProvider(), togetherProvider(), vercelAIGatewayProvider(), xaiProvider(), diff --git a/packages/ai/src/providers/radius-config.ts b/packages/ai/src/providers/radius-config.ts deleted file mode 100644 index 23f486b28..000000000 --- a/packages/ai/src/providers/radius-config.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { OAuthCredential } from "../auth/types.ts"; -import type { Model, ThinkingLevelMap } from "../types.ts"; - -export const DEFAULT_RADIUS_GATEWAY = "https://radius.pi.dev"; - -export type RadiusGatewayModel = { - id: string; - name: string; - reasoning: boolean; - thinkingLevelMap?: ThinkingLevelMap; - input: ("text" | "image")[]; - cost: Model<"diffex-messages">["cost"]; - contextWindow: number; - maxTokens: number; -}; - -export type RadiusGatewayConfig = { - baseUrl: string; - models: RadiusGatewayModel[]; -}; - -export type RadiusOAuthCredential = OAuthCredential & { - gatewayConfig?: RadiusGatewayConfig; -}; - -function isRadiusGatewayModel(value: unknown): value is RadiusGatewayModel { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const model = value as Partial; - return ( - typeof model.id === "string" && - typeof model.name === "string" && - typeof model.reasoning === "boolean" && - Array.isArray(model.input) && - typeof model.cost === "object" && - model.cost !== null && - !Array.isArray(model.cost) && - typeof model.contextWindow === "number" && - typeof model.maxTokens === "number" - ); -} - -function sanitizeRadiusGatewayConfig(config: unknown): RadiusGatewayConfig | undefined { - if (typeof config !== "object" || config === null || Array.isArray(config)) return undefined; - const { baseUrl, models } = config as Partial; - if (typeof baseUrl !== "string" || !Array.isArray(models)) return undefined; - return { - baseUrl, - models: models.filter(isRadiusGatewayModel).map((model) => ({ ...model })), - }; -} - -export function normalizeRadiusGatewayUrl(value: string): string { - const withScheme = /^https?:\/\//iu.test(value) ? value : `https://${value}`; - return withScheme.replace(/\/+$/u, ""); -} - -export function getRadiusCredentialConfig(credential: OAuthCredential | undefined): RadiusGatewayConfig | undefined { - return sanitizeRadiusGatewayConfig((credential as RadiusOAuthCredential | undefined)?.gatewayConfig); -} - -export function getRadiusModelsFromConfig(providerId: string, config: RadiusGatewayConfig): Model<"diffex-messages">[] { - return config.models.map((model) => ({ - ...model, - api: "diffex-messages", - provider: providerId, - baseUrl: config.baseUrl, - })); -} - -export function getRadiusModels( - providerId: string, - credential: OAuthCredential | undefined, -): Model<"diffex-messages">[] { - const config = getRadiusCredentialConfig(credential); - return config ? getRadiusModelsFromConfig(providerId, config) : []; -} - -function truncateHttpBody(body: string): string { - const trimmed = body.trim(); - return trimmed.length > 512 ? `${trimmed.slice(0, 512)}…` : trimmed; -} - -export async function loadRadiusGatewayConfig( - gateway: string, - apiKey?: string, - signal?: AbortSignal, -): Promise { - const headers: Record = { accept: "application/json" }; - if (apiKey) headers.authorization = `Bearer ${apiKey}`; - const response = await fetch(new URL("/v1/config", gateway), { headers, signal }); - if (!response.ok) { - throw new Error( - `Could not load Radius config from ${gateway}: ${response.status}: ${truncateHttpBody(await response.text())}`, - ); - } - const config = sanitizeRadiusGatewayConfig(await response.json()); - if (!config) throw new Error(`Invalid Radius config from ${gateway}`); - return config; -} diff --git a/packages/ai/src/providers/radius.ts b/packages/ai/src/providers/radius.ts deleted file mode 100644 index 3f5ea0e53..000000000 --- a/packages/ai/src/providers/radius.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { diffexMessagesApi } from "../api/diffex-messages.lazy.ts"; -import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; -import { loadRadiusOAuth } from "../auth/oauth/load.ts"; -import type { Provider } from "../models.ts"; -import { - DEFAULT_RADIUS_GATEWAY, - getRadiusModels, - getRadiusModelsFromConfig, - loadRadiusGatewayConfig, - normalizeRadiusGatewayUrl, -} from "./radius-config.ts"; - -export interface RadiusProviderOptions { - id?: string; - name?: string; - gateway?: string; -} - -/** Radius gateway provider with a persisted, dynamically refreshed catalog. */ -export function radiusProvider(options: RadiusProviderOptions = {}): Provider<"diffex-messages"> { - const id = options.id ?? "radius"; - const name = options.name ?? "Radius"; - const gateway = normalizeRadiusGatewayUrl(options.gateway ?? DEFAULT_RADIUS_GATEWAY); - let models = getRadiusModels(id, undefined); - const streams = diffexMessagesApi(); - - return { - id, - name, - auth: { - apiKey: envApiKeyAuth("Radius API key", ["RADIUS_API_KEY"]), - oauth: lazyOAuth({ name, load: () => loadRadiusOAuth({ name, gateway }) }), - }, - getModels: () => models, - refreshModels: async (context) => { - const stored = context.stored; - if (stored) { - const restored = stored.models.filter((model) => model.provider === id) as typeof models; - if ( - !(await context.publish({ - update: () => { - models = restored; - }, - })) - ) { - return; - } - } - - // Import catalogs cached by the pre-ModelsStore Radius implementation. - if (!stored && context.credential?.type === "oauth") { - const legacy = getRadiusModels(id, context.credential); - if (legacy.length > 0) { - if ( - !(await context.publish({ - persist: { models: legacy, checkedAt: Date.now() }, - update: () => { - models = legacy; - }, - })) - ) { - return; - } - } - } - - if (!context.allowNetwork || context.signal.aborted) return; - const apiKey = context.credential?.type === "oauth" ? context.credential.access : context.credential?.key; - const config = await loadRadiusGatewayConfig(gateway, apiKey, context.signal); - if (context.signal.aborted) return; - const refreshed = getRadiusModelsFromConfig(id, config); - await context.publish({ - persist: { models: refreshed, checkedAt: Date.now() }, - update: () => { - models = refreshed; - }, - }); - }, - stream: (model, context, streamOptions) => streams.stream(model, context, streamOptions), - streamSimple: (model, context, streamOptions) => streams.streamSimple(model, context, streamOptions), - }; -} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7806a3f10..ad2536bcb 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -41,7 +41,6 @@ export type KnownProvider = | "openai" | "azure-openai-responses" | "openai-codex" - | "radius" | "nvidia" | "deepseek" | "github-copilot" diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index b0680b258..c47e086b2 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { lazyApi } from "../src/api/lazy.ts"; import { envApiKeyAuth } from "../src/auth/helpers.ts"; import type { AuthContext, AuthEvent } from "../src/auth/types.ts"; +import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; import { createModels, createProvider } from "../src/models.ts"; import { InMemoryModelsStore } from "../src/models-store.ts"; import { builtinModels, builtinProviders, getBuiltinModel } from "../src/providers/all.ts"; @@ -47,15 +48,22 @@ 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); } }); + it("does not expose Radius or resolve its former environment key", async () => { + const models = builtinModels(); + expect(models.getProvider("radius")).toBeUndefined(); + expect(models.getModels("radius")).toEqual([]); + await expect(models.getAuth("radius")).resolves.toBeUndefined(); + expect(findEnvKeys("radius", { RADIUS_API_KEY: "unused-key" })).toBeUndefined(); + expect(getEnvApiKey("radius", { RADIUS_API_KEY: "unused-key" })).toBeUndefined(); + }); + it("stores native constrained-sampling capabilities in model metadata", () => { const gpt4o = getBuiltinModel("openai", "gpt-4o"); expect(gpt4o.compat?.supportsStrictMode).toBe(true); diff --git a/packages/ai/test/radius-oauth.test.ts b/packages/ai/test/radius-oauth.test.ts deleted file mode 100644 index ac613520d..000000000 --- a/packages/ai/test/radius-oauth.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createRadiusOAuth } from "../src/auth/oauth/radius.ts"; -import type { AuthEvent, ProviderAuthInteraction } from "../src/auth/types.ts"; - -const GATEWAY = "https://radius.example"; - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -function requestUrl(input: unknown): string { - if (typeof input === "string") return input; - if (input instanceof URL) return input.toString(); - if (input instanceof Request) return input.url; - throw new Error(`Unsupported request input: ${String(input)}`); -} - -function interaction(loginMethod: "browser" | "device-code", events: AuthEvent[] = []): ProviderAuthInteraction { - return { - signal: new AbortController().signal, - prompt: async () => loginMethod, - notify: (event) => events.push(event), - }; -} - -describe("Radius OAuth", () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("uses gateway endpoints directly for device login", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-24T00:00:00Z")); - const events: AuthEvent[] = []; - const urls: string[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (input: unknown, init?: RequestInit) => { - const url = requestUrl(input); - urls.push(url); - const form = new URLSearchParams(String(init?.body)); - if (url === `${GATEWAY}/v1/oauth/device`) { - expect(form.get("client_id")).toBe("pi-gateway"); - expect(form.get("scope")).toBe("gateway offline_access"); - return jsonResponse({ - device_code: "device-code", - user_code: "ABCD-1234", - verification_uri: "https://radius-ui.example/pair", - expires_in: 600, - interval: 5, - }); - } - if (url === `${GATEWAY}/v1/oauth/token`) { - expect(form.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code"); - expect(form.get("client_id")).toBe("pi-gateway"); - expect(form.get("device_code")).toBe("device-code"); - return jsonResponse({ - access_token: "access-token", - refresh_token: "refresh-token", - expires_in: 3600, - scope: "gateway offline_access", - }); - } - throw new Error(`Unexpected request: ${url}`); - }), - ); - - const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY }); - await expect(oauth.login(interaction("device-code", events))).resolves.toEqual({ - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 3600 * 1000 - 60_000, - scope: "gateway offline_access", - }); - expect(events).toEqual([ - { - type: "device_code", - userCode: "ABCD-1234", - verificationUri: "https://radius-ui.example/pair", - intervalSeconds: 5, - expiresInSeconds: 600, - }, - ]); - expect(urls).toEqual([`${GATEWAY}/v1/oauth/device`, `${GATEWAY}/v1/oauth/token`]); - }); - - it("refreshes directly through the gateway without discovery", async () => { - const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => { - expect(requestUrl(input)).toBe(`${GATEWAY}/v1/oauth/token`); - const form = new URLSearchParams(String(init?.body)); - expect(form.get("grant_type")).toBe("refresh_token"); - expect(form.get("client_id")).toBe("pi-gateway"); - expect(form.get("refresh_token")).toBe("old-refresh"); - return jsonResponse({ - access_token: "new-access", - refresh_token: "new-refresh", - expires_in: 3600, - }); - }); - vi.stubGlobal("fetch", fetchMock); - - const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY }); - await expect( - oauth.refresh( - { type: "oauth", access: "old-access", refresh: "old-refresh", expires: 0 }, - new AbortController().signal, - ), - ).resolves.toMatchObject({ access: "new-access", refresh: "new-refresh" }); - expect(fetchMock).toHaveBeenCalledOnce(); - }); - - it("discovers only the interactive browser authorization endpoint", async () => { - const fetchMock = vi.fn(async (input: unknown) => { - expect(requestUrl(input)).toBe(`${GATEWAY}/v1/oauth`); - return jsonResponse({ issuer: "https://radius-ui.example" }); - }); - vi.stubGlobal("fetch", fetchMock); - - const oauth = createRadiusOAuth({ name: "Radius", gateway: GATEWAY }); - await expect(oauth.login(interaction("browser"))).rejects.toThrow(`Invalid Radius OAuth config from ${GATEWAY}`); - expect(fetchMock).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 96db225b9..64944e329 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -136,7 +136,6 @@ Set `api` at provider level (default for all models) or model level (override pe | `baseUrl` | API endpoint URL | | `api` | API type (see above) | | `apiKey` | Optional API key config (see value resolution below). Omit it when auth is provided by `/login`/`auth.json` or CLI `--api-key`. | -| `oauth` | Dynamic OAuth provider type. Currently supports `"radius"`; requires the gateway `baseUrl`. | | `headers` | Custom headers (see value resolution below) | | `authHeader` | Set `true` to add `Authorization: Bearer ` automatically | | `models` | Array of model configurations | diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 95b559444..01c15a4e2 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -21,7 +21,6 @@ Use `/login` in interactive mode, then select a provider: - GitHub Copilot - xAI (Grok/X subscription) - OpenRouter (OAuth-minted API key billed from OpenRouter credits) -- Radius Use `/logout` to clear credentials. Tokens are stored in `~/.diffex/agent/auth.json` and auto-refresh when expired. OpenRouter instead mints a user-controlled API key that does not expire automatically. @@ -51,10 +50,6 @@ Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party h - On remote/headless machines (e.g. over SSH) the browser cannot reach the loopback callback; paste the final redirect URL (or the authorization code) into the login prompt instead - `OPENROUTER_API_KEY` remains available through **Use an API key** -### Radius - -Radius is a dynamic `diffex-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`. - ## API Keys ### Environment Variables or Auth File @@ -88,7 +83,6 @@ diffex | ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | `zai-coding-cn` | | OpenCode Zen | `OPENCODE_API_KEY` | `opencode` | | OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` | -| Radius | `RADIUS_API_KEY` | `radius` | | Hugging Face | `HF_TOKEN` | `huggingface` | | Fireworks | `FIREWORKS_API_KEY` | `fireworks` | | Together AI | `TOGETHER_API_KEY` | `together` | diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 7f679a494..418ba7f8d 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -195,7 +195,8 @@ 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: Type.Optional(Type.Literal("radius")), + // OAuth flows cannot be registered through models.json. + oauth: Type.Optional(Type.Never()), headers: Type.Optional(Type.Record(Type.String(), Type.String())), compat: Type.Optional(ProviderCompatSchema), authHeader: Type.Optional(Type.Boolean()), diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 2b5246f16..12735b4ae 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -24,7 +24,6 @@ export const defaultModelPerProvider: Record = { 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", diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index da58971b9..e395b7627 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -130,8 +130,7 @@ function mergeHeaders( export class ModelRuntime implements Models { private readonly models: MutableModels; private readonly credentials: RuntimeCredentials; - private readonly defaultBuiltins: ReadonlyMap; - private readonly builtins = new Map(); + private readonly builtins: ReadonlyMap; private readonly nativeExtensionProviders = new Map(); private readonly extensionProviders = new Map(); private readonly compositionErrors = new Map(); @@ -163,8 +162,7 @@ export class ModelRuntime implements Models { this.config = config; this.modelsPath = modelsPath; this.modelNetworkEnabled = modelNetworkEnabled; - this.defaultBuiltins = new Map(providers.map((provider) => [provider.id, provider])); - for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider); + this.builtins = new Map(providers.map((provider) => [provider.id, provider])); this.models = createModels({ credentials, modelsStore }); this.rebuildProviders(); } @@ -182,11 +180,7 @@ export class ModelRuntime implements Models { const builtinModelDataGeneratedAt = builtinProviderCatalog.getBuiltinModelDataGeneratedAt(); const providers = builtinProviderCatalog .builtinProviders() - .map((provider) => - provider.id === "radius" - ? provider - : withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt), - ); + .map((provider) => withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt)); const runtime = new ModelRuntime( credentials, config, @@ -195,8 +189,6 @@ export class ModelRuntime implements Models { providers, process.env.DIFFEX_OFFLINE === undefined, ); - runtime.configureRadiusProviders(); - runtime.rebuildProviders(); const refreshFromNetwork = runtime.modelNetworkEnabled && options.allowModelNetwork === true; const controller = refreshFromNetwork && options.modelRefreshTimeoutMs !== undefined ? new AbortController() : undefined; @@ -216,23 +208,6 @@ export class ModelRuntime implements Models { return runtime; } - private configureRadiusProviders(): void { - this.builtins.clear(); - for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider); - for (const providerId of this.config.getProviderIds()) { - const config = this.config.getProvider(providerId); - if (config?.oauth !== "radius" || !config.baseUrl) continue; - this.builtins.set( - providerId, - builtinProviderCatalog.radiusProvider({ - id: providerId, - name: config.name ?? providerId, - gateway: config.baseUrl.replace(/\/v1\/?$/u, ""), - }), - ); - } - } - private providerIds(): Set { return new Set([ ...this.builtins.keys(), @@ -701,7 +676,6 @@ export class ModelRuntime implements Models { async refresh(options: ModelsRefreshOptions = {}): Promise { this.config = await ModelConfig.load(this.modelsPath); - this.configureRadiusProviders(); if (options.providers) { for (const providerId of new Set(options.providers)) this.recomposeProvider(providerId); this.updateModelSnapshot(); diff --git a/packages/coding-agent/src/core/provider-composer.ts b/packages/coding-agent/src/core/provider-composer.ts index 7d7e75ccf..376dcfb00 100644 --- a/packages/coding-agent/src/core/provider-composer.ts +++ b/packages/coding-agent/src/core/provider-composer.ts @@ -171,9 +171,6 @@ function applyModelsJson( config: ModelsJsonProvider | undefined, ): Model[] { if (!config) return [...baseModels]; - if (config.oauth && !config.baseUrl) { - throw new Error(`Provider ${providerId}: "baseUrl" is required when "oauth" is set.`); - } const hasOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0; if ( !config.models?.length && @@ -182,7 +179,6 @@ function applyModelsJson( !config.compat && !hasOverrides && !config.apiKey && - !config.oauth && config.authHeader === undefined ) { throw new Error( @@ -192,7 +188,7 @@ function applyModelsJson( const models: Model[] = baseModels.map((model) => ({ ...model, - baseUrl: config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl), + baseUrl: config.baseUrl ?? model.baseUrl, compat: mergeCompat(model.compat, config.compat), })); for (const definition of config.models ?? []) { diff --git a/packages/coding-agent/src/core/radius.ts b/packages/coding-agent/src/core/radius.ts deleted file mode 100644 index b8b89da07..000000000 --- a/packages/coding-agent/src/core/radius.ts +++ /dev/null @@ -1 +0,0 @@ -export const RADIUS_PROVIDER_ID = "radius"; diff --git a/packages/coding-agent/test/model-runtime-auth-options.test.ts b/packages/coding-agent/test/model-runtime-auth-options.test.ts index 3a78cbe60..bae3be86f 100644 --- a/packages/coding-agent/test/model-runtime-auth-options.test.ts +++ b/packages/coding-agent/test/model-runtime-auth-options.test.ts @@ -138,12 +138,6 @@ describe("ModelRuntime auth options", () => { refresh: "", expires: Number.MAX_SAFE_INTEGER, }, - radius: { - type: "oauth", - access: "radius-access", - refresh: "radius-refresh", - expires: Date.now() + 60 * 60_000, - }, }), modelsPath: null, }); @@ -152,8 +146,6 @@ describe("ModelRuntime auth options", () => { expect(runtime.isUsingSubscription("anthropic")).toBe(true); expect(runtime.isUsingOAuth("openrouter")).toBe(true); expect(runtime.isUsingSubscription("openrouter")).toBe(false); - expect(runtime.isUsingOAuth("radius")).toBe(true); - expect(runtime.isUsingSubscription("radius")).toBe(false); }); it("constructs an API key method for an extension API-key provider", async () => { diff --git a/packages/coding-agent/test/radius.test.ts b/packages/coding-agent/test/radius.test.ts index bd943db4b..77265e1a5 100644 --- a/packages/coding-agent/test/radius.test.ts +++ b/packages/coding-agent/test/radius.test.ts @@ -1,171 +1,125 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { InMemoryModelsStore } from "@diffexai/diffex-ai"; +import { type Credential, InMemoryModelsStore, type Model } from "@diffexai/diffex-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; -import { RADIUS_PROVIDER_ID } from "../src/core/radius.ts"; -import { allowNetwork } from "./test-network-env.ts"; - -function radiusOAuthCredential(gatewayBaseUrl: string) { - return { - type: "oauth" as const, - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60 * 60 * 1000, - gatewayConfig: radiusConfig(gatewayBaseUrl), - }; -} - -function radiusConfig(baseUrl: string) { - return { - baseUrl, - models: [ - { - id: "auto", - name: "Radius Auto", - reasoning: false, - input: ["text" as const], - cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 }, - contextWindow: 128000, - maxTokens: 16384, - }, - ], - }; -} +const staleModel: Model<"diffex-messages"> = { + id: "auto", + name: "Radius Auto", + api: "diffex-messages", + provider: "radius", + baseUrl: "https://radius.example/v1", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, +}; let tempDir: string; beforeEach(() => { - allowNetwork(); - tempDir = join(tmpdir(), `pi-test-radius-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = mkdtempSync(join(tmpdir(), "diffex-radius-removal-")); + vi.stubEnv("RADIUS_API_KEY", "unused-radius-key"); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("Unexpected provider request"); + }), + ); }); - afterEach(() => { - vi.restoreAllMocks(); - if (tempDir && existsSync(tempDir)) rmSync(tempDir, { recursive: true }); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + rmSync(tempDir, { recursive: true, force: true }); }); -describe("Radius provider", () => { - it("restores the legacy credential catalog without network access", async () => { - const runtime = await ModelRuntime.create({ - credentials: AuthStorage.inMemory({ - [RADIUS_PROVIDER_ID]: radiusOAuthCredential("https://radius.example.com/v1"), - }), - modelsStore: new InMemoryModelsStore(), - modelsPath: null, - allowModelNetwork: false, - }); - - const model = runtime.getModel(RADIUS_PROVIDER_ID, "auto"); - expect(model).toMatchObject({ api: "diffex-messages", baseUrl: "https://radius.example.com/v1" }); - expect(runtime.getProvider(RADIUS_PROVIDER_ID)?.name).toBe("Radius"); - expect(runtime.hasConfiguredAuth(RADIUS_PROVIDER_ID)).toBe(true); - }); - - it("fetches and stores the catalog for configured Radius auth", async () => { - vi.spyOn(globalThis, "fetch").mockImplementation( - async () => - new Response(JSON.stringify(radiusConfig("https://radius.example.com/v1")), { - status: 200, - headers: { "content-type": "application/json" }, +describe("removed Radius integration", () => { + it.each([ + { type: "api_key", key: "stored-key" }, + { type: "oauth", access: "access", refresh: "refresh", expires: Number.MAX_SAFE_INTEGER }, + { + type: "oauth", + access: "expired", + refresh: "refresh", + expires: 0, + gatewayConfig: { baseUrl: staleModel.baseUrl, models: [staleModel] }, + }, + ] satisfies Credential[])( + "does not reactivate Radius from saved credentials or cached models: %j", + async (credential) => { + const credentials = AuthStorage.inMemory({ radius: credential }); + const modelsStore = new InMemoryModelsStore(); + await modelsStore.write("radius", { models: [staleModel] }); + const runtime = await ModelRuntime.create({ + credentials, + modelsStore, + modelsPath: null, + allowModelNetwork: false, + }); + expect(runtime.getProviders().some((provider) => provider.id === "radius")).toBe(false); + expect(runtime.getProvider("radius")).toBeUndefined(); + expect(runtime.getModels("radius")).toEqual([]); + expect(runtime.hasConfiguredAuth("radius")).toBe(false); + expect(runtime.isUsingOAuth("radius")).toBe(false); + await expect(runtime.getAuth("radius")).resolves.toBeUndefined(); + await expect(runtime.checkAuth("radius")).resolves.toBeUndefined(); + await expect(runtime.getAvailable("radius")).resolves.toEqual([]); + await runtime.refresh({ providers: ["radius"], allowNetwork: true }); + const result = await runtime.completeSimple(staleModel, { messages: [] }); + expect(result).toMatchObject({ stopReason: "error", errorMessage: "Unknown provider: radius" }); + expect(fetch).not.toHaveBeenCalled(); + await expect(credentials.read("radius")).resolves.toEqual(credential); + }, + ); + + it.each(["oauth", "api_key"] as const)( + "rejects direct Radius %s login before prompting or making requests", + async (type) => { + const runtime = await ModelRuntime.create({ credentials: AuthStorage.inMemory(), modelsPath: null }); + const prompt = vi.fn(async () => { + throw new Error("Unexpected prompt"); + }); + const notify = vi.fn(); + await expect( + runtime.login("radius", type, { signal: new AbortController().signal, prompt, notify }), + ).rejects.toThrow("Unknown provider: radius"); + expect(prompt).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + it.each(["radius", "radius-dev"])( + "rejects the Radius models.json OAuth configuration under %s", + async (providerId) => { + const modelsPath = join(tempDir, "models.json"); + writeFileSync( + modelsPath, + JSON.stringify({ + providers: { + [providerId]: { + oauth: "radius", + baseUrl: "https://radius.example", + apiKey: "stored-key", + api: "diffex-messages", + models: [{ id: "auto" }], + }, + }, }), - ); - const modelsStore = new InMemoryModelsStore(); - const credentials = AuthStorage.inMemory({ - [RADIUS_PROVIDER_ID]: { - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60 * 60 * 1000, - }, - }); - const runtime = await ModelRuntime.create({ - credentials, - modelsStore, - modelsPath: null, - allowModelNetwork: true, - }); - - expect(runtime.getModel(RADIUS_PROVIDER_ID, "auto")).toBeDefined(); - expect((await modelsStore.read(RADIUS_PROVIDER_ID))?.models).toHaveLength(1); - const radiusRequest = vi - .mocked(fetch) - .mock.calls.find(([url]) => String(url) === "https://radius.pi.dev/v1/config"); - expect(radiusRequest?.[1]?.headers).toMatchObject({ authorization: "Bearer access-token" }); - }); - - it("does not refresh catalogs over the network by default", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("unexpected catalog fetch")); - const runtime = await ModelRuntime.create({ - credentials: AuthStorage.inMemory({ - [RADIUS_PROVIDER_ID]: radiusOAuthCredential("https://radius.example.com/v1"), - }), - modelsStore: new InMemoryModelsStore(), - modelsPath: null, - }); - - expect(runtime.getModel(RADIUS_PROVIDER_ID, "auto")).toBeDefined(); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it("does not fetch or expose Radius models without configured auth", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch"); - const runtime = await ModelRuntime.create({ - credentials: AuthStorage.inMemory(), - modelsStore: new InMemoryModelsStore(), - modelsPath: null, - allowModelNetwork: true, - }); - - expect(runtime.getModels(RADIUS_PROVIDER_ID)).toEqual([]); - expect(fetchSpy.mock.calls.some(([url]) => String(url).includes("radius.pi.dev/v1/config"))).toBe(false); - }); - - it("supports custom Radius gateways from models.json", async () => { - vi.spyOn(globalThis, "fetch").mockImplementation( - async () => new Response(JSON.stringify(radiusConfig("http://localhost:8788/v1")), { status: 200 }), - ); - const modelsPath = join(tempDir, "models.json"); - writeFileSync( - modelsPath, - JSON.stringify({ - providers: { "radius-dev": { name: "Radius (dev)", baseUrl: "http://localhost:8788", oauth: "radius" } }, - }), - ); - const runtime = await ModelRuntime.create({ - credentials: AuthStorage.inMemory({ - "radius-dev": { - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60 * 60 * 1000, - }, - }), - modelsStore: new InMemoryModelsStore(), - modelsPath, - allowModelNetwork: true, - }); - - expect(runtime.getModel("radius-dev", "auto")).toMatchObject({ - api: "diffex-messages", - baseUrl: "http://localhost:8788/v1", - }); - expect(runtime.getProvider("radius-dev")?.name).toBe("Radius (dev)"); - }); - - it("requires baseUrl for custom Radius gateways", async () => { - const modelsPath = join(tempDir, "models.json"); - writeFileSync(modelsPath, JSON.stringify({ providers: { "radius-dev": { oauth: "radius" } } })); - const runtime = await ModelRuntime.create({ - credentials: AuthStorage.inMemory(), - modelsStore: new InMemoryModelsStore(), - modelsPath, - allowModelNetwork: false, - }); - - expect(runtime.getError()).toContain('"baseUrl" is required when "oauth" is set'); - }); + ); + const runtime = await ModelRuntime.create({ + credentials: AuthStorage.inMemory(), + modelsPath, + modelsStore: new InMemoryModelsStore(), + }); + expect(runtime.getError()).toContain("oauth"); + expect(runtime.getProvider(providerId)).toBeUndefined(); + await runtime.refresh({ providers: [providerId], allowNetwork: true }); + expect(runtime.getProvider(providerId)).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }, + ); }); diff --git a/scripts/runtime-identity-allowlist.json b/scripts/runtime-identity-allowlist.json index 0b9d10d0e..0369978fa 100644 --- a/scripts/runtime-identity-allowlist.json +++ b/scripts/runtime-identity-allowlist.json @@ -1,22 +1,4 @@ [ - { - "path": "packages/ai/src/auth/oauth/radius.ts", - "context": "const OAUTH_CLIENT_ID = \"pi-gateway\";", - "identities": [ - "pi-gateway" - ], - "category": "approved integration", - "reason": "Radius gateway OAuth client retained by the release audit." - }, - { - "path": "packages/ai/src/providers/radius-config.ts", - "context": "export const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";", - "identities": [ - "radius.pi.dev" - ], - "category": "approved integration", - "reason": "Default Radius gateway retained by the release audit." - }, { "path": "packages/coding-agent/examples/extensions/doom-overlay/README.md", "context": "- [pi-doom](https://github.com/badlogic/pi-doom) for the original diffex integration", diff --git a/scripts/verify-candidate-artifacts.py b/scripts/verify-candidate-artifacts.py index f8516d2da..118810e3e 100644 --- a/scripts/verify-candidate-artifacts.py +++ b/scripts/verify-candidate-artifacts.py @@ -120,11 +120,12 @@ os.write(fd, b"/login\r") stage = "login" elif stage == "login" and "Select authentication method:" in text: - os.write(fd, b"\x1b[B\r") + os.write(fd, b"\r") stage = "accounts" elif stage == "accounts" and all( - name in text for name in ["OpenAI Codex", "Radius", "xAI"] + name in text for name in ["OpenAI Codex", "OpenRouter", "xAI"] ): + assert not re.search(r"\bRadius\b", text), text mark = len(raw) os.write(fd, b"\x03") stage = "back" @@ -161,7 +162,7 @@ "command": "interactive prompt, login selector, exit", "exitCode": 0, "events": events, - "accounts": ["OpenAI Codex", "Radius", "xAI"], + "accounts": ["OpenAI Codex", "OpenRouter", "xAI"], "cleanup": "process exited and isolated directories removed", } )