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
+4
View File
@@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const piMessagesApi = (): ProviderStreams => lazyApi(() => import("./pi-messages.ts"));
+436
View File
@@ -0,0 +1,436 @@
/**
* pi-messages API implementation.
*
* Streams pi's own message protocol directly to a backend: the request is a
* single POST of `{ model, context, options }` to `<baseUrl>/messages`, the
* response is an SSE stream of serialized assistant-message events plus a
* terminal `done`/`error` event. This is the wire protocol spoken by the
* Radius gateway, but any backend implementing it can be used, e.g. via a
* models.json custom provider with `"api": "pi-messages"`.
*/
import type {
AssistantMessage,
AssistantMessageEvent,
CacheRetention,
Context,
Model,
ProviderEnv,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
ThinkingLevel,
ToolCall,
} from "../types.ts";
import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
export interface PiMessagesOptions extends StreamOptions {
reasoning?: ThinkingLevel;
toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } };
/** Ask the backend for debug metadata (e.g. routing response headers). */
debug?: boolean;
}
type PiMessagesUsage = AssistantMessage["usage"];
type PiMessagesStopReason = AssistantMessage["stopReason"];
/** Impact summary of a server-side message rewrite (e.g. a gateway policy). */
export type PiMessagesRewriteImpact = {
policyId: string;
policyVersion: number;
changed: boolean;
tokenCountChange: number;
messageCountChange: number;
systemPromptChanged: boolean;
};
/** Serialized assistant-message event as sent by a pi-messages backend. */
export type PiMessagesEvent =
| { type: "start" }
| { type: "text_start"; contentIndex: number }
| { type: "text_delta"; contentIndex: number; delta: string }
| { type: "text_end"; contentIndex: number; content: string; contentSignature?: string }
| { type: "thinking_start"; contentIndex: number }
| { type: "thinking_delta"; contentIndex: number; delta: string }
| {
type: "thinking_end";
contentIndex: number;
content: string;
contentSignature?: string;
redacted?: boolean;
}
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
| { type: "toolcall_delta"; contentIndex: number; delta: string }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall }
| {
type: "done";
reason: Extract<PiMessagesStopReason, "stop" | "length" | "toolUse">;
usage: PiMessagesUsage;
responseId?: string;
rewrite?: PiMessagesRewriteImpact;
}
| {
type: "error";
reason: Extract<PiMessagesStopReason, "aborted" | "error">;
usage: PiMessagesUsage;
errorMessage?: string;
responseId?: string;
rewrite?: PiMessagesRewriteImpact;
};
type PiMessagesErrorBody = {
error?: {
message?: unknown;
code?: unknown;
details?: unknown;
[key: string]: unknown;
};
};
export class PiMessagesResponseError extends Error {
code?: string;
readonly diagnosticDetails: Record<string, unknown>;
constructor(message: string, code: string | undefined, diagnosticDetails: Record<string, unknown>) {
super(message);
this.name = "PiMessagesResponseError";
this.code = code;
this.diagnosticDetails = diagnosticDetails;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parsePiMessagesErrorBody(body: string): PiMessagesErrorBody | undefined {
try {
const parsed = JSON.parse(body) as unknown;
return isRecord(parsed) && isRecord(parsed.error) ? (parsed as PiMessagesErrorBody) : undefined;
} catch {
return undefined;
}
}
function truncateDiagnosticString(value: string): string {
const maxLength = 8192;
return value.length > maxLength ? `${value.slice(0, maxLength)}` : value;
}
function formatPiMessagesResponseError(
response: Response,
body: string,
errorBody: PiMessagesErrorBody | undefined,
): string {
const message = typeof errorBody?.error?.message === "string" ? errorBody.error.message : undefined;
const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined;
const suffix = message ?? body;
const codeSuffix = code ? ` (${code})` : "";
return `${response.status} ${response.statusText}: ${suffix}${codeSuffix}`;
}
function createPiMessagesResponseError(
model: Model<"pi-messages">,
url: URL,
response: Response,
body: string,
): PiMessagesResponseError {
const errorBody = parsePiMessagesErrorBody(body);
const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined;
return new PiMessagesResponseError(formatPiMessagesResponseError(response, body, errorBody), code, {
version: 1,
provider: model.provider,
model: model.id,
url: url.toString(),
status: response.status,
statusText: response.statusText,
error: errorBody?.error,
body: errorBody ? undefined : truncateDiagnosticString(body),
timestampMs: Date.now(),
});
}
function createEmptyUsage(): PiMessagesUsage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function appendRewriteDiagnostic(message: AssistantMessage, rewrite: PiMessagesRewriteImpact | undefined): void {
if (!rewrite) {
return;
}
appendAssistantMessageDiagnostic(message, {
type: "pi_messages_rewrite",
timestamp: Date.now(),
details: { ...rewrite },
});
}
function createEventConverter(model: Model<"pi-messages">) {
const partial: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: createEmptyUsage(),
stopReason: "stop",
timestamp: Date.now(),
};
const toolJson = new Map<number, string>();
return (event: PiMessagesEvent): AssistantMessageEvent => {
switch (event.type) {
case "done":
Object.assign(partial, {
stopReason: event.reason,
usage: event.usage,
responseId: event.responseId,
});
appendRewriteDiagnostic(partial, event.rewrite);
return { type: "done", reason: event.reason, message: partial };
case "error":
Object.assign(partial, {
stopReason: event.reason,
usage: event.usage,
errorMessage: event.errorMessage,
responseId: event.responseId,
});
appendRewriteDiagnostic(partial, event.rewrite);
return { type: "error", reason: event.reason, error: partial };
case "start":
break;
case "text_start":
partial.content[event.contentIndex] = { type: "text", text: "" };
break;
case "text_delta":
(partial.content[event.contentIndex] as { text: string }).text += event.delta;
break;
case "text_end":
Object.assign(partial.content[event.contentIndex]!, {
text: event.content,
textSignature: event.contentSignature,
});
break;
case "thinking_start":
partial.content[event.contentIndex] = { type: "thinking", thinking: "" };
break;
case "thinking_delta":
(partial.content[event.contentIndex] as { thinking: string }).thinking += event.delta;
break;
case "thinking_end":
Object.assign(partial.content[event.contentIndex]!, {
thinking: event.content,
thinkingSignature: event.contentSignature,
redacted: event.redacted,
});
break;
case "toolcall_start":
partial.content[event.contentIndex] = {
type: "toolCall",
id: event.id,
name: event.toolName,
arguments: {},
};
toolJson.set(event.contentIndex, "");
break;
case "toolcall_delta": {
const json = `${toolJson.get(event.contentIndex) ?? ""}${event.delta}`;
toolJson.set(event.contentIndex, json);
(partial.content[event.contentIndex] as ToolCall).arguments =
parseStreamingJson<ToolCall["arguments"]>(json);
break;
}
case "toolcall_end":
Object.assign(partial.content[event.contentIndex]!, event.toolCall);
toolJson.delete(event.contentIndex);
return {
type: "toolcall_end",
contentIndex: event.contentIndex,
toolCall: partial.content[event.contentIndex] as ToolCall,
partial,
};
}
return { ...event, partial } as AssistantMessageEvent;
};
}
async function* readPiMessagesEvents(stream: ReadableStream<Uint8Array>): AsyncGenerator<PiMessagesEvent> {
const decoder = new TextDecoder();
const reader = stream.getReader();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
let split = buffer.indexOf("\n\n");
while (split !== -1) {
const event = parsePiMessagesEvent(buffer.slice(0, split));
if (event) {
yield event;
}
buffer = buffer.slice(split + 2);
split = buffer.indexOf("\n\n");
}
if (done) {
break;
}
}
if (buffer.trim()) {
const event = parsePiMessagesEvent(buffer);
if (event) {
yield event;
}
}
} finally {
reader.releaseLock();
}
}
function parsePiMessagesEvent(raw: string): PiMessagesEvent | undefined {
const data = raw
.split("\n")
.find((line) => line.startsWith("data:"))
?.slice(5)
.trim();
return data && data !== "[DONE]" ? (JSON.parse(data) as PiMessagesEvent) : undefined;
}
function createErrorEvent(model: Model<"pi-messages">, error: unknown, aborted: boolean): AssistantMessageEvent {
const reason = aborted ? "aborted" : "error";
const assistantMessage: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: createEmptyUsage(),
stopReason: reason,
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
if (!aborted && error instanceof PiMessagesResponseError) {
appendAssistantMessageDiagnostic(
assistantMessage,
createAssistantMessageDiagnostic("pi_messages_response_failure", error, error.diagnosticDetails),
);
}
return { type: "error", reason, error: assistantMessage };
}
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention | undefined {
if (cacheRetention) {
return cacheRetention;
}
// Backend defaults apply when unset; only the legacy env opt-in is mapped.
return getProviderEnvValue("PI_CACHE_RETENTION", env) === "long" ? "long" : undefined;
}
export const stream: StreamFunction<"pi-messages", PiMessagesOptions> = (
model: Model<"pi-messages">,
context: Context,
options?: PiMessagesOptions,
): AssistantMessageEventStream => {
const eventStream = new AssistantMessageEventStream();
const convertEvent = createEventConverter(model);
void (async () => {
try {
const apiKey = options?.apiKey;
if (!apiKey) {
throw new Error(`No API key provided for provider "${model.provider}"`);
}
const url = new URL(`${model.baseUrl.replace(/\/+$/u, "")}/messages`);
if (options?.debug) {
url.searchParams.set("debug", "1");
}
let payload: unknown = {
model: model.id,
context,
options: {
temperature: options?.temperature,
maxTokens: options?.maxTokens,
reasoning: options?.reasoning,
cacheRetention: resolveCacheRetention(options?.cacheRetention, options?.env),
sessionId: options?.sessionId,
toolChoice: options?.toolChoice,
},
};
const nextPayload = await options?.onPayload?.(payload, model);
if (nextPayload !== undefined) {
payload = nextPayload;
}
const response = await fetch(url, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
accept: "text/event-stream",
"content-type": "application/json",
...providerHeadersToRecord(options?.headers),
},
body: JSON.stringify(payload),
signal: options?.signal,
});
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
if (!response.ok) {
const body = await response.text();
throw createPiMessagesResponseError(model, url, response, body);
}
if (!response.body) {
throw new Error(`${model.provider} response has no body`);
}
for await (const piEvent of readPiMessagesEvents(response.body)) {
const event = convertEvent(piEvent);
eventStream.push(event);
if (event.type === "done" || event.type === "error") {
return;
}
}
throw new Error(`${model.provider} stream ended without a terminal event`);
} catch (error) {
eventStream.push(createErrorEvent(model, error, options?.signal?.aborted ?? false));
}
})();
return eventStream;
};
export const streamSimple: StreamFunction<"pi-messages", SimpleStreamOptions> = (
model: Model<"pi-messages">,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream => {
const extra = options as PiMessagesOptions | undefined;
return stream(model, context, {
...options,
reasoning: options?.reasoning,
toolChoice: extra?.toolChoice,
debug: extra?.debug,
});
};
+6
View File
@@ -19,6 +19,7 @@ export * from "./api/mistral-conversations.lazy.ts";
export * from "./api/openai-codex-responses.lazy.ts";
export * from "./api/openai-completions.lazy.ts";
export * from "./api/openai-responses.lazy.ts";
export * from "./api/pi-messages.lazy.ts";
export * from "./env-api-keys.ts";
export * from "./image-models.ts";
export * from "./images.ts";
@@ -36,8 +37,12 @@ import { mistralConversationsApi } from "./api/mistral-conversations.lazy.ts";
import { openAICodexResponsesApi } from "./api/openai-codex-responses.lazy.ts";
import { openAICompletionsApi } from "./api/openai-completions.lazy.ts";
import { openAIResponsesApi } from "./api/openai-responses.lazy.ts";
import { piMessagesApi } from "./api/pi-messages.lazy.ts";
import { getEnvApiKey } from "./env-api-keys.ts";
import { builtinModels, getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "./providers/all.ts";
export type { BuiltinProvider } from "./providers/all.ts";
import { createFauxCore, type FauxProviderRegistration, type RegisterFauxProviderOptions } from "./providers/faux.ts";
import type {
Api,
@@ -179,6 +184,7 @@ const BUILTIN_APIS: [Api, ProviderStreams][] = [
["google-vertex", googleVertexApi()],
["mistral-conversations", mistralConversationsApi()],
["bedrock-converse-stream", bedrockConverseStreamApi()],
["pi-messages", piMessagesApi()],
];
const builtinApiProviderInstances = new Map<Api, ReturnType<typeof getApiProvider>>();
+1
View File
@@ -82,6 +82,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
radius: "PI_GATEWAY_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
zai: "ZAI_API_KEY",
+1
View File
@@ -17,6 +17,7 @@ export type { MistralOptions } from "./api/mistral-conversations.ts";
export type { OpenAICodexResponsesOptions, OpenAICodexWebSocketDebugStats } from "./api/openai-codex-responses.ts";
export type { OpenAICompletionsOptions } from "./api/openai-completions.ts";
export type { OpenAIResponsesOptions } from "./api/openai-responses.ts";
export type { PiMessagesEvent, PiMessagesOptions, PiMessagesRewriteImpact } from "./api/pi-messages.ts";
export * from "./auth/context.ts";
export * from "./auth/credential-store.ts";
export * from "./auth/helpers.ts";
+11 -6
View File
@@ -1,7 +1,7 @@
import { createImagesModels, type ImagesProvider, type MutableImagesModels } from "../images-models.ts";
import { MODELS } from "../models.generated.ts";
import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.ts";
import type { Api, KnownProvider, Model } from "../types.ts";
import type { Api, Model } from "../types.ts";
import { amazonBedrockProvider } from "./amazon-bedrock.ts";
import { antLingProvider } from "./ant-ling.ts";
import { anthropicProvider } from "./anthropic.ts";
@@ -39,13 +39,18 @@ import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp.ts";
import { zaiProvider } from "./zai.ts";
import { zaiCodingCnProvider } from "./zai-coding-cn.ts";
/** Providers present in the generated catalog. `KnownProvider` additionally
* includes purely dynamic providers (e.g. "radius") that have no static
* catalog entry. */
export type BuiltinProvider = keyof typeof MODELS;
type BuiltinModelApi<
TProvider extends KnownProvider,
TProvider extends BuiltinProvider,
TModelId extends keyof (typeof MODELS)[TProvider],
> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never;
/** Typed read of the generated built-in catalog. */
export function getBuiltinModel<TProvider extends KnownProvider, TModelId extends keyof (typeof MODELS)[TProvider]>(
export function getBuiltinModel<TProvider extends BuiltinProvider, TModelId extends keyof (typeof MODELS)[TProvider]>(
provider: TProvider,
modelId: TModelId,
): Model<BuiltinModelApi<TProvider, TModelId>> {
@@ -53,11 +58,11 @@ export function getBuiltinModel<TProvider extends KnownProvider, TModelId extend
return models?.[modelId as string] as Model<BuiltinModelApi<TProvider, TModelId>>;
}
export function getBuiltinProviders(): KnownProvider[] {
return Object.keys(MODELS) as KnownProvider[];
export function getBuiltinProviders(): BuiltinProvider[] {
return Object.keys(MODELS) as BuiltinProvider[];
}
export function getBuiltinModels<TProvider extends KnownProvider>(
export function getBuiltinModels<TProvider extends BuiltinProvider>(
provider: TProvider,
): Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
const models = MODELS[provider] as Record<string, Model<Api>> | undefined;
+5 -1
View File
@@ -7,6 +7,7 @@ import type { MistralOptions } from "./api/mistral-conversations.ts";
import type { OpenAICodexResponsesOptions } from "./api/openai-codex-responses.ts";
import type { OpenAICompletionsOptions } from "./api/openai-completions.ts";
import type { OpenAIResponsesOptions } from "./api/openai-responses.ts";
import type { PiMessagesOptions } from "./api/pi-messages.ts";
import type { AssistantMessageDiagnostic } from "./utils/diagnostics.ts";
import type { AssistantMessageEventStream } from "./utils/event-stream.ts";
@@ -21,7 +22,8 @@ export type KnownApi =
| "anthropic-messages"
| "bedrock-converse-stream"
| "google-generative-ai"
| "google-vertex";
| "google-vertex"
| "pi-messages";
export type Api = KnownApi | (string & {});
@@ -38,6 +40,7 @@ export type KnownProvider =
| "openai"
| "azure-openai-responses"
| "openai-codex"
| "radius"
| "nvidia"
| "deepseek"
| "github-copilot"
@@ -202,6 +205,7 @@ export interface ApiOptionsMap {
"google-vertex": GoogleVertexOptions;
"mistral-conversations": MistralOptions;
"bedrock-converse-stream": BedrockOptions;
"pi-messages": PiMessagesOptions;
}
/**
+16
View File
@@ -28,21 +28,37 @@ export {
refreshOpenAICodexToken,
} from "./openai-codex.ts";
// Radius (pi-messages gateway)
export {
createRadiusOAuthProvider,
DEFAULT_RADIUS_GATEWAY,
type RadiusGatewayConfig,
type RadiusGatewayModel,
type RadiusOAuthCredentials,
type RadiusOAuthProviderOptions,
} from "./radius.ts";
export * from "./types.ts";
// ============================================================================
// Provider Registry
// ============================================================================
import { getProviderEnvValue } from "../provider-env.ts";
import { anthropicOAuthProvider } from "./anthropic.ts";
import { githubCopilotOAuthProvider } from "./github-copilot.ts";
import { openaiCodexOAuthProvider } from "./openai-codex.ts";
import { createRadiusOAuthProvider, DEFAULT_RADIUS_GATEWAY } from "./radius.ts";
import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts";
const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [
anthropicOAuthProvider,
githubCopilotOAuthProvider,
openaiCodexOAuthProvider,
createRadiusOAuthProvider({
id: "radius",
name: "Radius",
gateway: getProviderEnvValue("PI_GATEWAY") || DEFAULT_RADIUS_GATEWAY,
}),
];
const oauthProviderRegistry = new Map<string, OAuthProviderInterface>(
+557
View File
@@ -0,0 +1,557 @@
/**
* Radius gateway OAuth flow and model catalog loading.
*
* Radius is a pi-messages gateway. OAuth endpoints are discovered from the
* gateway (`/v1/oauth`); the model catalog comes from `/v1/config` and is
* cached on the stored credential (`gatewayConfig`) so models are available
* at startup and refreshed whenever the token refreshes.
*
* 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 type { Api, Model, ThinkingLevelMap } from "../../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts";
export const DEFAULT_RADIUS_GATEWAY = "https://radius.pi.dev";
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";
/** Model metadata served by the gateway config endpoint. */
export type RadiusGatewayModel = {
id: string;
name: string;
reasoning: boolean;
thinkingLevelMap?: ThinkingLevelMap;
input: ("text" | "image")[];
cost: Model<Api>["cost"];
contextWindow: number;
maxTokens: number;
};
export type RadiusGatewayConfig = {
baseUrl: string;
models: RadiusGatewayModel[];
};
export type RadiusOAuthCredentials = OAuthCredentials & {
gatewayConfig?: RadiusGatewayConfig;
};
type RadiusOAuthConfig = {
issuer: string;
authorizationEndpoint: string;
tokenEndpoint: string;
deviceAuthorizationEndpoint: string;
deviceAuthorizationEventsEndpoint: string;
verificationEndpoint: string;
clientId: string;
scope: string;
deviceCodeGrantType: string;
};
type DeviceAuthorizationResponse = {
device_code: string;
user_code: string;
verification_uri?: string;
verification_uri_complete?: string;
expires_in: number;
interval?: number;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeRadiusGatewayUrl(value: string): string {
const withScheme = /^https?:\/\//iu.test(value) ? value : `https://${value}`;
return withScheme.replace(/\/+$/u, "");
}
// The gateway is a trusted first-party service. The shape checks below only
// guard against version skew and stale credential caches: malformed entries
// are dropped rather than failing the whole catalog, and nested fields (e.g.
// `input` members, `cost` rates) are intentionally not validated in depth.
// Do not turn this into strict validation.
function isRadiusGatewayModel(value: unknown): value is RadiusGatewayModel {
if (!isRecord(value)) {
return false;
}
return (
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.reasoning === "boolean" &&
Array.isArray(value.input) &&
isRecord(value.cost) &&
typeof value.contextWindow === "number" &&
typeof value.maxTokens === "number"
);
}
function sanitizeRadiusGatewayConfig(config: unknown): RadiusGatewayConfig | undefined {
if (!isRecord(config)) {
return undefined;
}
const baseUrl = config.baseUrl;
const models = config.models;
if (typeof baseUrl !== "string" || !Array.isArray(models)) {
return undefined;
}
return {
baseUrl,
models: models.filter(isRadiusGatewayModel).map((model) => ({ ...model })),
};
}
function getRadiusCredentialConfig(credentials: OAuthCredentials | undefined): RadiusGatewayConfig | undefined {
return sanitizeRadiusGatewayConfig((credentials as RadiusOAuthCredentials | undefined)?.gatewayConfig);
}
function truncateHttpBody(body: string): string {
const trimmed = body.trim();
return trimmed.length > 512 ? `${trimmed.slice(0, 512)}` : trimmed;
}
async function loadRadiusGatewayConfig(gateway: string, apiKey?: string): Promise<RadiusGatewayConfig> {
const headers: Record<string, string> = { accept: "application/json" };
if (apiKey) {
headers.authorization = `Bearer ${apiKey}`;
}
const response = await fetch(new URL("/v1/config", gateway), { headers });
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;
}
async function loadRadiusOAuthConfig(gateway: string): Promise<RadiusOAuthConfig> {
const response = await fetch(new URL("/v1/oauth", gateway), {
headers: { accept: "application/json" },
});
if (!response.ok) {
throw new Error(
`Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`,
);
}
return (await response.json()) as RadiusOAuthConfig;
}
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<OAuthResponseError> {
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(
oauth: RadiusOAuthConfig,
body: URLSearchParams,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
let response: Response;
try {
response = await fetch(oauth.tokenEndpoint, {
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 {
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<string | null>;
close(): void;
};
function startOAuthCallbackServer(
expectedState: string,
signal: AbortSignal | undefined,
): Promise<OAuthCallbackServer> {
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<string | null>((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(oauth: RadiusOAuthConfig, callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID();
const authorizeUrl = new URL(oauth.authorizationEndpoint);
authorizeUrl.search = new URLSearchParams({
response_type: "code",
client_id: oauth.clientId,
redirect_uri: REDIRECT_URI,
scope: oauth.scope,
code_challenge: challenge,
code_challenge_method: "S256",
handoff: "url",
state,
}).toString();
const callbackServer = await startOAuthCallbackServer(state, callbacks.signal);
callbacks.onProgress?.(`Listening for OAuth callback on ${REDIRECT_URI}`);
callbacks.onAuth({
url: authorizeUrl.toString(),
instructions: "Continue in your browser.",
});
try {
const code = await callbackServer.waitForCode();
if (!code) {
if (callbacks.signal?.aborted) {
throw new Error("Login cancelled");
}
throw new Error("OAuth callback did not complete.");
}
return await requestOAuthToken(
oauth,
new URLSearchParams({
grant_type: "authorization_code",
client_id: oauth.clientId,
redirect_uri: REDIRECT_URI,
code,
code_verifier: verifier,
}),
callbacks.signal,
);
} finally {
callbackServer.close();
}
}
async function requestDeviceAuthorization(
oauth: RadiusOAuthConfig,
signal: AbortSignal | undefined,
): Promise<DeviceAuthorizationResponse> {
let response: Response;
try {
response = await fetch(oauth.deviceAuthorizationEndpoint, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: oauth.clientId, 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<DeviceAuthorizationResponse>;
if (!data.device_code || !data.user_code || !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,
verification_uri_complete: data.verification_uri_complete,
expires_in: data.expires_in,
interval: data.interval,
};
}
async function loginWithDeviceCode(
oauth: RadiusOAuthConfig,
callbacks: OAuthLoginCallbacks,
): Promise<OAuthCredentials> {
const device = await requestDeviceAuthorization(oauth, callbacks.signal);
callbacks.onDeviceCode({
userCode: device.user_code,
verificationUri: device.verification_uri || oauth.verificationEndpoint,
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
});
return pollOAuthDeviceCodeFlow<OAuthCredentials>({
intervalSeconds: device.interval,
expiresInSeconds: device.expires_in,
signal: callbacks.signal,
poll: async () => {
try {
const credentials = await requestOAuthToken(
oauth,
new URLSearchParams({
grant_type: oauth.deviceCodeGrantType,
client_id: oauth.clientId,
device_code: device.device_code,
}),
callbacks.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;
}
}
},
});
}
async function attachGatewayConfig(
gateway: string,
credentials: OAuthCredentials,
previous?: OAuthCredentials,
): Promise<RadiusOAuthCredentials> {
try {
const config = await loadRadiusGatewayConfig(gateway, credentials.access);
return { ...credentials, gatewayConfig: config };
} catch (error) {
// Keep the previous catalog so models do not vanish on transient
// config failures; the next token refresh retries.
const previousConfig = getRadiusCredentialConfig(previous);
if (previousConfig) {
return { ...credentials, gatewayConfig: previousConfig };
}
// No catalog to retain (e.g. initial login): fail loudly instead of
// completing a sign-in that would register no models.
throw error;
}
}
export interface RadiusOAuthProviderOptions {
id: string;
name: string;
gateway: string;
}
export function createRadiusOAuthProvider(options: RadiusOAuthProviderOptions): OAuthProviderInterface {
const gateway = normalizeRadiusGatewayUrl(options.gateway);
return {
id: options.id,
name: options.name,
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const oauth = await loadRadiusOAuthConfig(gateway);
const loginMethod = await callbacks.onSelect({
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) {
throw new Error("Login cancelled");
}
let credentials: OAuthCredentials;
if (loginMethod === LOGIN_METHOD_DEVICE_CODE) {
credentials = await loginWithDeviceCode(oauth, callbacks);
} else if (loginMethod === LOGIN_METHOD_BROWSER) {
credentials = await loginWithBrowser(oauth, callbacks);
} else {
throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
}
return attachGatewayConfig(gateway, credentials);
},
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
const oauth = await loadRadiusOAuthConfig(gateway);
const refreshed = await requestOAuthToken(
oauth,
new URLSearchParams({
grant_type: "refresh_token",
client_id: oauth.clientId,
refresh_token: credentials.refresh,
}),
);
return attachGatewayConfig(gateway, refreshed, credentials);
},
getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
},
modifyModels(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[] {
const config = getRadiusCredentialConfig(credentials);
if (!config) {
return models;
}
// Keep models already registered for this provider (e.g. models.json
// custom entries) and add catalog models that are not present.
const existingIds = new Set(models.filter((model) => model.provider === options.id).map((model) => model.id));
const added = config.models
.filter((model) => !existingIds.has(model.id))
.map(
(model) =>
({
...model,
api: "pi-messages",
provider: options.id,
baseUrl: config.baseUrl,
}) as Model<Api>,
);
return [...models, ...added];
},
};
}