feat(ai): add Radius gateway support
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
import type { ProviderStreams } from "../types.ts";
|
||||||
|
import { lazyApi } from "./lazy.ts";
|
||||||
|
|
||||||
|
export const piMessagesApi = (): ProviderStreams => lazyApi(() => import("./pi-messages.ts"));
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ export * from "./api/mistral-conversations.lazy.ts";
|
|||||||
export * from "./api/openai-codex-responses.lazy.ts";
|
export * from "./api/openai-codex-responses.lazy.ts";
|
||||||
export * from "./api/openai-completions.lazy.ts";
|
export * from "./api/openai-completions.lazy.ts";
|
||||||
export * from "./api/openai-responses.lazy.ts";
|
export * from "./api/openai-responses.lazy.ts";
|
||||||
|
export * from "./api/pi-messages.lazy.ts";
|
||||||
export * from "./env-api-keys.ts";
|
export * from "./env-api-keys.ts";
|
||||||
export * from "./image-models.ts";
|
export * from "./image-models.ts";
|
||||||
export * from "./images.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 { openAICodexResponsesApi } from "./api/openai-codex-responses.lazy.ts";
|
||||||
import { openAICompletionsApi } from "./api/openai-completions.lazy.ts";
|
import { openAICompletionsApi } from "./api/openai-completions.lazy.ts";
|
||||||
import { openAIResponsesApi } from "./api/openai-responses.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 { getEnvApiKey } from "./env-api-keys.ts";
|
||||||
import { builtinModels, getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "./providers/all.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 { createFauxCore, type FauxProviderRegistration, type RegisterFauxProviderOptions } from "./providers/faux.ts";
|
||||||
import type {
|
import type {
|
||||||
Api,
|
Api,
|
||||||
@@ -179,6 +184,7 @@ const BUILTIN_APIS: [Api, ProviderStreams][] = [
|
|||||||
["google-vertex", googleVertexApi()],
|
["google-vertex", googleVertexApi()],
|
||||||
["mistral-conversations", mistralConversationsApi()],
|
["mistral-conversations", mistralConversationsApi()],
|
||||||
["bedrock-converse-stream", bedrockConverseStreamApi()],
|
["bedrock-converse-stream", bedrockConverseStreamApi()],
|
||||||
|
["pi-messages", piMessagesApi()],
|
||||||
];
|
];
|
||||||
|
|
||||||
const builtinApiProviderInstances = new Map<Api, ReturnType<typeof getApiProvider>>();
|
const builtinApiProviderInstances = new Map<Api, ReturnType<typeof getApiProvider>>();
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
|
|||||||
groq: "GROQ_API_KEY",
|
groq: "GROQ_API_KEY",
|
||||||
cerebras: "CEREBRAS_API_KEY",
|
cerebras: "CEREBRAS_API_KEY",
|
||||||
xai: "XAI_API_KEY",
|
xai: "XAI_API_KEY",
|
||||||
|
radius: "PI_GATEWAY_API_KEY",
|
||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
||||||
zai: "ZAI_API_KEY",
|
zai: "ZAI_API_KEY",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type { MistralOptions } from "./api/mistral-conversations.ts";
|
|||||||
export type { OpenAICodexResponsesOptions, OpenAICodexWebSocketDebugStats } from "./api/openai-codex-responses.ts";
|
export type { OpenAICodexResponsesOptions, OpenAICodexWebSocketDebugStats } from "./api/openai-codex-responses.ts";
|
||||||
export type { OpenAICompletionsOptions } from "./api/openai-completions.ts";
|
export type { OpenAICompletionsOptions } from "./api/openai-completions.ts";
|
||||||
export type { OpenAIResponsesOptions } from "./api/openai-responses.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/context.ts";
|
||||||
export * from "./auth/credential-store.ts";
|
export * from "./auth/credential-store.ts";
|
||||||
export * from "./auth/helpers.ts";
|
export * from "./auth/helpers.ts";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createImagesModels, type ImagesProvider, type MutableImagesModels } from "../images-models.ts";
|
import { createImagesModels, type ImagesProvider, type MutableImagesModels } from "../images-models.ts";
|
||||||
import { MODELS } from "../models.generated.ts";
|
import { MODELS } from "../models.generated.ts";
|
||||||
import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.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 { amazonBedrockProvider } from "./amazon-bedrock.ts";
|
||||||
import { antLingProvider } from "./ant-ling.ts";
|
import { antLingProvider } from "./ant-ling.ts";
|
||||||
import { anthropicProvider } from "./anthropic.ts";
|
import { anthropicProvider } from "./anthropic.ts";
|
||||||
@@ -39,13 +39,18 @@ import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp.ts";
|
|||||||
import { zaiProvider } from "./zai.ts";
|
import { zaiProvider } from "./zai.ts";
|
||||||
import { zaiCodingCnProvider } from "./zai-coding-cn.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<
|
type BuiltinModelApi<
|
||||||
TProvider extends KnownProvider,
|
TProvider extends BuiltinProvider,
|
||||||
TModelId extends keyof (typeof MODELS)[TProvider],
|
TModelId extends keyof (typeof MODELS)[TProvider],
|
||||||
> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never;
|
> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never;
|
||||||
|
|
||||||
/** Typed read of the generated built-in catalog. */
|
/** 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,
|
provider: TProvider,
|
||||||
modelId: TModelId,
|
modelId: TModelId,
|
||||||
): Model<BuiltinModelApi<TProvider, 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>>;
|
return models?.[modelId as string] as Model<BuiltinModelApi<TProvider, TModelId>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBuiltinProviders(): KnownProvider[] {
|
export function getBuiltinProviders(): BuiltinProvider[] {
|
||||||
return Object.keys(MODELS) as KnownProvider[];
|
return Object.keys(MODELS) as BuiltinProvider[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBuiltinModels<TProvider extends KnownProvider>(
|
export function getBuiltinModels<TProvider extends BuiltinProvider>(
|
||||||
provider: TProvider,
|
provider: TProvider,
|
||||||
): Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
|
): Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
|
||||||
const models = MODELS[provider] as Record<string, Model<Api>> | undefined;
|
const models = MODELS[provider] as Record<string, Model<Api>> | undefined;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { MistralOptions } from "./api/mistral-conversations.ts";
|
|||||||
import type { OpenAICodexResponsesOptions } from "./api/openai-codex-responses.ts";
|
import type { OpenAICodexResponsesOptions } from "./api/openai-codex-responses.ts";
|
||||||
import type { OpenAICompletionsOptions } from "./api/openai-completions.ts";
|
import type { OpenAICompletionsOptions } from "./api/openai-completions.ts";
|
||||||
import type { OpenAIResponsesOptions } from "./api/openai-responses.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 { AssistantMessageDiagnostic } from "./utils/diagnostics.ts";
|
||||||
import type { AssistantMessageEventStream } from "./utils/event-stream.ts";
|
import type { AssistantMessageEventStream } from "./utils/event-stream.ts";
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ export type KnownApi =
|
|||||||
| "anthropic-messages"
|
| "anthropic-messages"
|
||||||
| "bedrock-converse-stream"
|
| "bedrock-converse-stream"
|
||||||
| "google-generative-ai"
|
| "google-generative-ai"
|
||||||
| "google-vertex";
|
| "google-vertex"
|
||||||
|
| "pi-messages";
|
||||||
|
|
||||||
export type Api = KnownApi | (string & {});
|
export type Api = KnownApi | (string & {});
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ export type KnownProvider =
|
|||||||
| "openai"
|
| "openai"
|
||||||
| "azure-openai-responses"
|
| "azure-openai-responses"
|
||||||
| "openai-codex"
|
| "openai-codex"
|
||||||
|
| "radius"
|
||||||
| "nvidia"
|
| "nvidia"
|
||||||
| "deepseek"
|
| "deepseek"
|
||||||
| "github-copilot"
|
| "github-copilot"
|
||||||
@@ -202,6 +205,7 @@ export interface ApiOptionsMap {
|
|||||||
"google-vertex": GoogleVertexOptions;
|
"google-vertex": GoogleVertexOptions;
|
||||||
"mistral-conversations": MistralOptions;
|
"mistral-conversations": MistralOptions;
|
||||||
"bedrock-converse-stream": BedrockOptions;
|
"bedrock-converse-stream": BedrockOptions;
|
||||||
|
"pi-messages": PiMessagesOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,21 +28,37 @@ export {
|
|||||||
refreshOpenAICodexToken,
|
refreshOpenAICodexToken,
|
||||||
} from "./openai-codex.ts";
|
} 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";
|
export * from "./types.ts";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Provider Registry
|
// Provider Registry
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
import { getProviderEnvValue } from "../provider-env.ts";
|
||||||
import { anthropicOAuthProvider } from "./anthropic.ts";
|
import { anthropicOAuthProvider } from "./anthropic.ts";
|
||||||
import { githubCopilotOAuthProvider } from "./github-copilot.ts";
|
import { githubCopilotOAuthProvider } from "./github-copilot.ts";
|
||||||
import { openaiCodexOAuthProvider } from "./openai-codex.ts";
|
import { openaiCodexOAuthProvider } from "./openai-codex.ts";
|
||||||
|
import { createRadiusOAuthProvider, DEFAULT_RADIUS_GATEWAY } from "./radius.ts";
|
||||||
import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts";
|
import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts";
|
||||||
|
|
||||||
const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [
|
const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [
|
||||||
anthropicOAuthProvider,
|
anthropicOAuthProvider,
|
||||||
githubCopilotOAuthProvider,
|
githubCopilotOAuthProvider,
|
||||||
openaiCodexOAuthProvider,
|
openaiCodexOAuthProvider,
|
||||||
|
createRadiusOAuthProvider({
|
||||||
|
id: "radius",
|
||||||
|
name: "Radius",
|
||||||
|
gateway: getProviderEnvValue("PI_GATEWAY") || DEFAULT_RADIUS_GATEWAY,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const oauthProviderRegistry = new Map<string, OAuthProviderInterface>(
|
const oauthProviderRegistry = new Map<string, OAuthProviderInterface>(
|
||||||
|
|||||||
@@ -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];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { type PiMessagesOptions, stream, streamSimple } from "../src/api/pi-messages.ts";
|
||||||
|
import type { Api, AssistantMessageEvent, Context, Model } from "../src/types.ts";
|
||||||
|
|
||||||
|
type RecordedRequest = {
|
||||||
|
url: string;
|
||||||
|
headers: IncomingMessage["headers"];
|
||||||
|
body: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResponderOptions = {
|
||||||
|
status?: number;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
events?: unknown[];
|
||||||
|
rawBody?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: Server | undefined;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server?.close();
|
||||||
|
server = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startServer(options: ResponderOptions): Promise<{ baseUrl: string; requests: RecordedRequest[] }> {
|
||||||
|
const requests: RecordedRequest[] = [];
|
||||||
|
|
||||||
|
server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
request.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
request.on("end", () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString("utf-8");
|
||||||
|
requests.push({
|
||||||
|
url: request.url ?? "",
|
||||||
|
headers: request.headers,
|
||||||
|
body: raw ? JSON.parse(raw) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.status && options.status !== 200) {
|
||||||
|
response.statusCode = options.status;
|
||||||
|
response.setHeader("content-type", "application/json");
|
||||||
|
response.end(options.rawBody ?? "{}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.setHeader("content-type", "text/event-stream");
|
||||||
|
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
||||||
|
response.setHeader(name, value);
|
||||||
|
}
|
||||||
|
for (const event of options.events ?? []) {
|
||||||
|
response.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||||
|
}
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server!.listen(0, "127.0.0.1", () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server!.address() as AddressInfo;
|
||||||
|
return { baseUrl: `http://127.0.0.1:${address.port}/v1`, requests };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModel(baseUrl: string): Model<"pi-messages"> {
|
||||||
|
return {
|
||||||
|
id: "auto",
|
||||||
|
name: "Radius Auto",
|
||||||
|
api: "pi-messages",
|
||||||
|
provider: "radius",
|
||||||
|
baseUrl,
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 },
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 16384,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: Context = {
|
||||||
|
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const usage = {
|
||||||
|
input: 10,
|
||||||
|
output: 5,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 15,
|
||||||
|
cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("pi-messages", () => {
|
||||||
|
it("streams text and tool calls and resolves the terminal message", async () => {
|
||||||
|
const { baseUrl, requests } = await startServer({
|
||||||
|
events: [
|
||||||
|
{ type: "start" },
|
||||||
|
{ type: "text_start", contentIndex: 0 },
|
||||||
|
{ type: "text_delta", contentIndex: 0, delta: "Hel" },
|
||||||
|
{ type: "text_delta", contentIndex: 0, delta: "lo" },
|
||||||
|
{ type: "text_end", contentIndex: 0, content: "Hello" },
|
||||||
|
{ type: "toolcall_start", contentIndex: 1, id: "call_1", toolName: "read" },
|
||||||
|
{ type: "toolcall_delta", contentIndex: 1, delta: '{"path":' },
|
||||||
|
{ type: "toolcall_delta", contentIndex: 1, delta: '"a.txt"}' },
|
||||||
|
{
|
||||||
|
type: "toolcall_end",
|
||||||
|
contentIndex: 1,
|
||||||
|
toolCall: { type: "toolCall", id: "call_1", name: "read", arguments: { path: "a.txt" } },
|
||||||
|
},
|
||||||
|
{ type: "done", reason: "toolUse", usage, responseId: "resp_1" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const model = createModel(baseUrl);
|
||||||
|
|
||||||
|
const events: AssistantMessageEvent[] = [];
|
||||||
|
const eventStream = stream(model, context, {
|
||||||
|
apiKey: "test-key",
|
||||||
|
sessionId: "session-1",
|
||||||
|
toolChoice: "auto",
|
||||||
|
maxTokens: 100,
|
||||||
|
headers: { "x-custom": "1" },
|
||||||
|
});
|
||||||
|
for await (const event of eventStream) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
const message = await eventStream.result();
|
||||||
|
|
||||||
|
expect(message.stopReason).toBe("toolUse");
|
||||||
|
expect(message.usage).toEqual(usage);
|
||||||
|
expect(message.responseId).toBe("resp_1");
|
||||||
|
expect(message.model).toBe("auto");
|
||||||
|
expect(message.provider).toBe("radius");
|
||||||
|
expect(message.content).toEqual([
|
||||||
|
{ type: "text", text: "Hello", textSignature: undefined },
|
||||||
|
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "a.txt" } },
|
||||||
|
]);
|
||||||
|
expect(events.some((event) => event.type === "text_delta")).toBe(true);
|
||||||
|
expect(events.filter((event) => event.type === "toolcall_end")).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
const request = requests[0];
|
||||||
|
expect(request.url).toBe("/v1/messages");
|
||||||
|
expect(request.headers.authorization).toBe("Bearer test-key");
|
||||||
|
expect(request.headers["x-custom"]).toBe("1");
|
||||||
|
expect(request.body).toEqual({
|
||||||
|
model: "auto",
|
||||||
|
context,
|
||||||
|
options: { maxTokens: 100, sessionId: "session-1", toolChoice: "auto" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends debug=1 and reports response headers via onResponse", async () => {
|
||||||
|
const { baseUrl, requests } = await startServer({
|
||||||
|
headers: { "x-pi-gateway-upstream-provider": "anthropic" },
|
||||||
|
events: [{ type: "done", reason: "stop", usage }],
|
||||||
|
});
|
||||||
|
const model = createModel(baseUrl);
|
||||||
|
|
||||||
|
let observedHeaders: Record<string, string> | undefined;
|
||||||
|
const options: PiMessagesOptions = {
|
||||||
|
apiKey: "test-key",
|
||||||
|
debug: true,
|
||||||
|
onResponse: (response) => {
|
||||||
|
observedHeaders = response.headers;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const message = await streamSimple(model, context, options).result();
|
||||||
|
|
||||||
|
expect(message.stopReason).toBe("stop");
|
||||||
|
expect(requests[0].url).toBe("/v1/messages?debug=1");
|
||||||
|
expect(observedHeaders?.["x-pi-gateway-upstream-provider"]).toBe("anthropic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces backend error responses with diagnostics", async () => {
|
||||||
|
const { baseUrl } = await startServer({
|
||||||
|
status: 401,
|
||||||
|
rawBody: JSON.stringify({ error: { message: "Token expired", code: "unauthorized" } }),
|
||||||
|
});
|
||||||
|
const model = createModel(baseUrl);
|
||||||
|
|
||||||
|
const message = await stream(model, context, { apiKey: "stale" }).result();
|
||||||
|
|
||||||
|
expect(message.stopReason).toBe("error");
|
||||||
|
expect(message.errorMessage).toContain("401");
|
||||||
|
expect(message.errorMessage).toContain("Token expired");
|
||||||
|
expect(message.errorMessage).toContain("unauthorized");
|
||||||
|
expect(message.diagnostics?.[0]?.type).toBe("pi_messages_response_failure");
|
||||||
|
expect(message.diagnostics?.[0]?.details?.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates server-sent error events", async () => {
|
||||||
|
const { baseUrl } = await startServer({
|
||||||
|
events: [{ type: "start" }, { type: "error", reason: "error", usage, errorMessage: "Upstream failed" }],
|
||||||
|
});
|
||||||
|
const model = createModel(baseUrl);
|
||||||
|
|
||||||
|
const message = await stream(model, context, { apiKey: "test-key" }).result();
|
||||||
|
|
||||||
|
expect(message.stopReason).toBe("error");
|
||||||
|
expect(message.errorMessage).toBe("Upstream failed");
|
||||||
|
expect(message.usage).toEqual(usage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when no API key is provided", async () => {
|
||||||
|
const model = createModel("http://127.0.0.1:1/v1");
|
||||||
|
|
||||||
|
const message = await stream(model, context).result();
|
||||||
|
|
||||||
|
expect(message.stopReason).toBe("error");
|
||||||
|
expect(message.errorMessage).toContain("No API key provided");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when the stream ends without a terminal event", async () => {
|
||||||
|
const { baseUrl } = await startServer({
|
||||||
|
events: [
|
||||||
|
{ type: "start" },
|
||||||
|
{ type: "text_start", contentIndex: 0 },
|
||||||
|
{ type: "text_delta", contentIndex: 0, delta: "partial" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const model = createModel(baseUrl);
|
||||||
|
|
||||||
|
const message = await stream(model, context, { apiKey: "test-key" }).result();
|
||||||
|
|
||||||
|
expect(message.stopReason).toBe("error");
|
||||||
|
expect(message.errorMessage).toContain("stream ended without a terminal event");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pi-messages api registration", () => {
|
||||||
|
it("is registered as a builtin api provider", async () => {
|
||||||
|
const { getApiProvider } = await import("../src/compat.ts");
|
||||||
|
expect(getApiProvider("pi-messages")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a known api usable on models", () => {
|
||||||
|
const api: Api = "pi-messages";
|
||||||
|
expect(api).toBe("pi-messages");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,10 +6,10 @@ import {
|
|||||||
type AnthropicMessagesCompat,
|
type AnthropicMessagesCompat,
|
||||||
type Api,
|
type Api,
|
||||||
type AssistantMessageEventStream,
|
type AssistantMessageEventStream,
|
||||||
|
type BuiltinProvider,
|
||||||
type Context,
|
type Context,
|
||||||
getModels,
|
getModels,
|
||||||
getProviders,
|
getProviders,
|
||||||
type KnownProvider,
|
|
||||||
type Model,
|
type Model,
|
||||||
type OAuthProviderInterface,
|
type OAuthProviderInterface,
|
||||||
type OpenAICompletionsCompat,
|
type OpenAICompletionsCompat,
|
||||||
@@ -29,6 +29,7 @@ import { stripJsonComments } from "../utils/json.ts";
|
|||||||
import { normalizePath } from "../utils/paths.ts";
|
import { normalizePath } from "../utils/paths.ts";
|
||||||
import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
|
import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
|
||||||
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts";
|
import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts";
|
||||||
|
import { registerCustomRadiusOAuthProvider } from "./radius.ts";
|
||||||
import {
|
import {
|
||||||
clearConfigValueCache,
|
clearConfigValueCache,
|
||||||
getConfigValueEnvVarNames,
|
getConfigValueEnvVarNames,
|
||||||
@@ -224,6 +225,9 @@ const ProviderConfigSchema = Type.Object({
|
|||||||
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
|
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
apiKey: Type.Optional(Type.String({ minLength: 1 })),
|
apiKey: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
api: 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())),
|
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||||
compat: Type.Optional(ProviderCompatSchema),
|
compat: Type.Optional(ProviderCompatSchema),
|
||||||
authHeader: Type.Optional(Type.Boolean()),
|
authHeader: Type.Optional(Type.Boolean()),
|
||||||
@@ -453,7 +457,7 @@ export class ModelRegistry {
|
|||||||
modelOverrides: Map<string, Map<string, ModelOverride>>,
|
modelOverrides: Map<string, Map<string, ModelOverride>>,
|
||||||
): Model<Api>[] {
|
): Model<Api>[] {
|
||||||
return getProviders().flatMap((provider) => {
|
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 providerOverride = overrides.get(provider);
|
||||||
const perModelOverrides = modelOverrides.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);
|
this.storeProviderRequestConfig(providerName, providerConfig);
|
||||||
|
|
||||||
if (providerConfig.modelOverrides) {
|
if (providerConfig.modelOverrides) {
|
||||||
@@ -568,7 +578,11 @@ export class ModelRegistry {
|
|||||||
const hasModelOverrides =
|
const hasModelOverrides =
|
||||||
providerConfig.modelOverrides && Object.keys(providerConfig.modelOverrides).length > 0;
|
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.
|
// Override-only config: needs baseUrl, headers, compat, modelOverrides, or some combination.
|
||||||
if (!providerConfig.baseUrl && !providerConfig.headers && !providerConfig.compat && !hasModelOverrides) {
|
if (!providerConfig.baseUrl && !providerConfig.headers && !providerConfig.compat && !hasModelOverrides) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -614,7 +628,7 @@ export class ModelRegistry {
|
|||||||
const getBuiltInDefaults = (providerName: string): { api: string; baseUrl: string } | undefined => {
|
const getBuiltInDefaults = (providerName: string): { api: string; baseUrl: string } | undefined => {
|
||||||
if (!builtInProviders.has(providerName)) return undefined;
|
if (!builtInProviders.has(providerName)) return undefined;
|
||||||
if (builtInDefaultsCache.has(providerName)) return builtInDefaultsCache.get(providerName);
|
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;
|
if (builtIn.length === 0) return undefined;
|
||||||
const defaults = { api: builtIn[0].api, baseUrl: builtIn[0].baseUrl };
|
const defaults = { api: builtIn[0].api, baseUrl: builtIn[0].baseUrl };
|
||||||
builtInDefaultsCache.set(providerName, defaults);
|
builtInDefaultsCache.set(providerName, defaults);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
|||||||
openai: "gpt-5.5",
|
openai: "gpt-5.5",
|
||||||
"azure-openai-responses": "gpt-5.4",
|
"azure-openai-responses": "gpt-5.4",
|
||||||
"openai-codex": "gpt-5.5",
|
"openai-codex": "gpt-5.5",
|
||||||
|
radius: "auto",
|
||||||
nvidia: "nvidia/nemotron-3-super-120b-a12b",
|
nvidia: "nvidia/nemotron-3-super-120b-a12b",
|
||||||
deepseek: "deepseek-v4-pro",
|
deepseek: "deepseek-v4-pro",
|
||||||
google: "gemini-3.1-pro-preview",
|
google: "gemini-3.1-pro-preview",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|||||||
"opencode-go": "OpenCode Go",
|
"opencode-go": "OpenCode Go",
|
||||||
openai: "OpenAI",
|
openai: "OpenAI",
|
||||||
openrouter: "OpenRouter",
|
openrouter: "OpenRouter",
|
||||||
|
radius: "Radius",
|
||||||
together: "Together AI",
|
together: "Together AI",
|
||||||
"vercel-ai-gateway": "Vercel AI Gateway",
|
"vercel-ai-gateway": "Vercel AI Gateway",
|
||||||
xai: "xAI",
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,9 @@ unset XIAOMI_API_KEY
|
|||||||
unset XIAOMI_TOKEN_PLAN_CN_API_KEY
|
unset XIAOMI_TOKEN_PLAN_CN_API_KEY
|
||||||
unset XIAOMI_TOKEN_PLAN_AMS_API_KEY
|
unset XIAOMI_TOKEN_PLAN_AMS_API_KEY
|
||||||
unset XIAOMI_TOKEN_PLAN_SGP_API_KEY
|
unset XIAOMI_TOKEN_PLAN_SGP_API_KEY
|
||||||
|
unset PI_GATEWAY_API_KEY
|
||||||
|
unset PI_GATEWAY
|
||||||
|
unset PI_EXPERIMENTAL
|
||||||
unset COPILOT_GITHUB_TOKEN
|
unset COPILOT_GITHUB_TOKEN
|
||||||
unset GH_TOKEN
|
unset GH_TOKEN
|
||||||
unset GITHUB_TOKEN
|
unset GITHUB_TOKEN
|
||||||
|
|||||||
Reference in New Issue
Block a user