From c3bfd689ef9ee58f61b2cb2bc3703a51612491be Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 30 Oct 2026 23:12:37 +0100 Subject: [PATCH] feat(ai): bundle OAuth flows for browsers and share the remote catalog OAuth flows load node:http only when a callback server starts and use Web Crypto, so they bundle for browsers. Every flow request goes through one replaceable fetch. The new oauth-flows entry registers the flows statically (registerOAuthFlows) and finds a built-in provider's flow (getBuiltinOAuthFlow). Sign in with ChatGPT completes with the pasted address where no server can run. The pi.dev catalog overlay moves from coding-agent into the new remote-catalog entry, with a configurable request function; coding-agent keeps its retries and User-Agent through a wrapper. --- packages/ai/package.json | 8 + packages/ai/src/auth/oauth/anthropic.ts | 3 +- packages/ai/src/auth/oauth/callback-server.ts | 21 +- packages/ai/src/auth/oauth/fetch.ts | 23 ++ packages/ai/src/auth/oauth/github-copilot.ts | 5 +- packages/ai/src/auth/oauth/kimi-coding.ts | 7 +- packages/ai/src/auth/oauth/meta.ts | 7 +- packages/ai/src/auth/oauth/openai-chatgpt.ts | 27 +- packages/ai/src/auth/oauth/openai-codex.ts | 22 +- packages/ai/src/auth/oauth/openrouter.ts | 3 +- packages/ai/src/auth/oauth/pkce.ts | 2 +- packages/ai/src/auth/oauth/radius.ts | 7 +- packages/ai/src/auth/oauth/xai.ts | 3 +- packages/ai/src/bun-oauth.ts | 23 +- packages/ai/src/oauth-flows.ts | 77 ++++++ packages/ai/src/remote-catalog.ts | 249 ++++++++++++++++++ packages/ai/test/oauth-flows.test.ts | 63 +++++ .../test/openai-chatgpt-oauth-paste.test.ts | 53 ++++ packages/ai/test/remote-catalog.test.ts | 56 ++++ .../src/core/remote-catalog-provider.ts | 244 ++--------------- vitest.base.ts | 4 + 21 files changed, 623 insertions(+), 284 deletions(-) create mode 100644 packages/ai/src/auth/oauth/fetch.ts create mode 100644 packages/ai/src/oauth-flows.ts create mode 100644 packages/ai/src/remote-catalog.ts create mode 100644 packages/ai/test/oauth-flows.test.ts create mode 100644 packages/ai/test/openai-chatgpt-oauth-paste.test.ts create mode 100644 packages/ai/test/remote-catalog.test.ts diff --git a/packages/ai/package.json b/packages/ai/package.json index 6e04980f0..a06a684a0 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -46,6 +46,14 @@ "./bun-oauth": { "types": "./dist/bun-oauth.d.ts", "import": "./dist/bun-oauth.js" + }, + "./oauth-flows": { + "types": "./dist/oauth-flows.d.ts", + "import": "./dist/oauth-flows.js" + }, + "./remote-catalog": { + "types": "./dist/remote-catalog.d.ts", + "import": "./dist/remote-catalog.js" } }, "bin": { diff --git a/packages/ai/src/auth/oauth/anthropic.ts b/packages/ai/src/auth/oauth/anthropic.ts index b3d8499b2..513f8fe48 100644 --- a/packages/ai/src/auth/oauth/anthropic.ts +++ b/packages/ai/src/auth/oauth/anthropic.ts @@ -8,6 +8,7 @@ import { getProviderEnvValue } from "../../utils/provider-env.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts"; +import { oauthFetch } from "./fetch.ts"; import { generatePKCE } from "./pkce.ts"; const decode = (s: string) => atob(s); @@ -74,7 +75,7 @@ function formatErrorDetails(error: unknown): string { } async function postJson(url: string, body: Record, signal: AbortSignal): Promise { - const response = await fetch(url, { + const response = await oauthFetch(url, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/packages/ai/src/auth/oauth/callback-server.ts b/packages/ai/src/auth/oauth/callback-server.ts index 00aa6aea1..6cc70b318 100644 --- a/packages/ai/src/auth/oauth/callback-server.ts +++ b/packages/ai/src/auth/oauth/callback-server.ts @@ -1,14 +1,27 @@ /** * Loopback OAuth redirect handler shared by the browser sign-in flows. * - * NOTE: This module uses node:http. It is only reachable through the lazily - * loaded OAuth flow modules, never from browser-facing entry points. + * The server needs node:http, which this module loads only when a server starts. Flow modules + * therefore bundle for browsers, where starting a server fails and flows that support it fall back + * to the pasted redirect URL. */ -import { createServer, type ServerResponse } from "node:http"; +import type { createServer as CreateServer, ServerResponse } from "node:http"; import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.ts"; import type { ProviderAuthInteraction } from "../types.ts"; +// Variable specifier so browser bundlers do not try to resolve node:http. +const importNodeModule = (specifier: string): Promise => import(specifier); + +/** Load node:http's `createServer`, or undefined where no local server can run (browsers). */ +export async function loadCreateServer(): Promise { + try { + return ((await importNodeModule("node:http")) as { createServer: typeof CreateServer }).createServer; + } catch { + return undefined; + } +} + export interface OAuthCallbackServerOptions { /** Provider name used on the browser page, for example `OpenAI`. */ providerName: string; @@ -52,6 +65,8 @@ export async function startOAuthCallbackServer( ): Promise> { const { providerName, signal } = options; if (signal?.aborted) throw new Error("Login cancelled"); + const createServer = await loadCreateServer(); + if (!createServer) throw new Error("OAuth callback servers need Node.js"); let resolveWait: (value: T | undefined) => void = () => {}; let rejectWait: (error: Error) => void = () => {}; diff --git a/packages/ai/src/auth/oauth/fetch.ts b/packages/ai/src/auth/oauth/fetch.ts new file mode 100644 index 000000000..207f3d12c --- /dev/null +++ b/packages/ai/src/auth/oauth/fetch.ts @@ -0,0 +1,23 @@ +/** + * The fetch that every OAuth flow sends its requests with. + * + * Browsers cannot reach some providers' auth endpoints because those endpoints send no CORS headers. + * A runtime that routes such requests elsewhere, for example through a relay, replaces this fetch once + * through `registerOAuthFlows`. Until then, flows use the global fetch, looked up on every call so that + * tests and wrappers that replace `globalThis.fetch` keep working. + */ + +/** How flows call fetch: always with a URL and an init, never with a `Request`. */ +export type OAuthFetch = (input: string | URL, init?: RequestInit) => Promise; + +let replacement: OAuthFetch | undefined; + +/** Replace the fetch of every OAuth flow, or restore the global fetch with `undefined`. */ +export function setOAuthFetch(fetchImpl: OAuthFetch | undefined): void { + replacement = fetchImpl; +} + +/** Send one OAuth flow request. */ +export function oauthFetch(input: string | URL, init?: RequestInit): Promise { + return replacement ? replacement(input, init) : fetch(input, init); +} diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index 5620bba9e..09536cfc3 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -6,6 +6,7 @@ import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts" import { sleep } from "../../utils/sleep.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { oauthFetch } from "./fetch.ts"; import { trustedHttpUrl } from "./trusted-url.ts"; const decode = (s: string) => atob(s); @@ -146,7 +147,7 @@ async function fetchWithRateLimitRetry( const requestSignal = retryBudgetSignal ? AbortSignal.any([signal, retryBudgetSignal]) : signal; const retryDeadline = retryBudgetSignal ? Date.now() + retryPolicy.maxElapsedMs : undefined; for (let retry = 0; ; retry++) { - const response = await fetch(url, { + const response = await oauthFetch(url, { ...init, signal: AbortSignal.any([requestSignal, AbortSignal.timeout(5000)]), }); @@ -196,7 +197,7 @@ async function fetchGitHubCopilotModels( } async function fetchJson(url: string, init: RequestInit): Promise { - const response = await fetch(url, init); + const response = await oauthFetch(url, init); if (!response.ok) { const text = await response.text(); throw new Error(`${response.status} ${response.statusText}: ${text}`); diff --git a/packages/ai/src/auth/oauth/kimi-coding.ts b/packages/ai/src/auth/oauth/kimi-coding.ts index a7748c824..e378f82ac 100644 --- a/packages/ai/src/auth/oauth/kimi-coding.ts +++ b/packages/ai/src/auth/oauth/kimi-coding.ts @@ -10,6 +10,7 @@ import { getProviderEnvValue } from "../../utils/provider-env.ts"; import { sleep } from "../../utils/sleep.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { oauthFetch } from "./fetch.ts"; import { trustedHttpUrl } from "./trusted-url.ts"; const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; @@ -57,7 +58,7 @@ async function readJson(response: Response): Promise | n } async function startDeviceAuthorization(oauthHost: string, signal: AbortSignal): Promise { - const response = await fetch(`${oauthHost}/api/oauth/device_authorization`, { + const response = await oauthFetch(`${oauthHost}/api/oauth/device_authorization`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -132,7 +133,7 @@ async function pollForToken( waitBeforeFirstPoll: true, signal, poll: async () => { - const response = await fetch(`${oauthHost}/api/oauth/token`, { + const response = await oauthFetch(`${oauthHost}/api/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -205,7 +206,7 @@ async function refreshToken(oauthHost: string, refreshTokenValue: string, signal let response: Response; try { - response = await fetch(`${oauthHost}/api/oauth/token`, { + response = await oauthFetch(`${oauthHost}/api/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", diff --git a/packages/ai/src/auth/oauth/meta.ts b/packages/ai/src/auth/oauth/meta.ts index 24a692f8f..f2fbb89d3 100644 --- a/packages/ai/src/auth/oauth/meta.ts +++ b/packages/ai/src/auth/oauth/meta.ts @@ -15,6 +15,7 @@ import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { oauthFetch } from "./fetch.ts"; import { trustedHttpUrl } from "./trusted-url.ts"; // Muse Code CLI client id. @@ -60,7 +61,7 @@ function positiveNumber(value: unknown): number | undefined { } async function startDeviceAuthorization(signal: AbortSignal): Promise { - const response = await fetch(DEVICE_AUTHORIZATION_URL, { + const response = await oauthFetch(DEVICE_AUTHORIZATION_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -95,7 +96,7 @@ async function pollForIdentityToken(device: DeviceAuthorization, signal: AbortSi waitBeforeFirstPoll: true, signal, poll: async () => { - const response = await fetch(DEVICE_TOKEN_URL, { + const response = await oauthFetch(DEVICE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -133,7 +134,7 @@ async function pollForIdentityToken(device: DeviceAuthorization, signal: AbortSi /** Exchange an identity token for a Model API key. Keys are valid for about a day. */ async function mintApiKey(identityToken: string, signal: AbortSignal): Promise { - const response = await fetch(API_KEY_MINT_URL, { + const response = await oauthFetch(API_KEY_MINT_URL, { method: "POST", headers: { Accept: "application/json", diff --git a/packages/ai/src/auth/oauth/openai-chatgpt.ts b/packages/ai/src/auth/oauth/openai-chatgpt.ts index 65e037135..cd1bc8208 100644 --- a/packages/ai/src/auth/oauth/openai-chatgpt.ts +++ b/packages/ai/src/auth/oauth/openai-chatgpt.ts @@ -2,15 +2,18 @@ * OpenAI Responses API token sharing through Sign in with ChatGPT. * * This public-client flow uses no client secret and sends the resulting user - * access token directly to api.openai.com. + * access token directly to api.openai.com. OpenAI redirects only to the loopback + * callback; where no local server can run (browsers), the user pastes the + * address of the redirect page instead. */ -import { randomBytes } from "node:crypto"; -import { createServer, type Server, type ServerResponse } from "node:http"; +import type { Server, ServerResponse } from "node:http"; import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.ts"; import { getProviderEnvValue } from "../../utils/provider-env.ts"; import type { LoginOptions, OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; -import { generatePKCE } from "./pkce.ts"; +import { loadCreateServer } from "./callback-server.ts"; +import { oauthFetch } from "./fetch.ts"; +import { base64urlEncode, generatePKCE } from "./pkce.ts"; // every login registers a new client with this ID; OpenAI returns the issued client ID in the callback const DYNAMIC_CLIENT_ID = "dynamic_agent_client"; @@ -47,7 +50,7 @@ type TokenResponse = { }; function randomValue(): string { - return randomBytes(32).toString("base64url"); + return base64urlEncode(crypto.getRandomValues(new Uint8Array(32))); } function authorizationResultFromCallback(url: URL, expectedState: string): AuthorizationResult { @@ -82,7 +85,10 @@ function sendHtml(response: ServerResponse, status: number, body: string): void response.end(body); } -function startCallbackServer(expectedState: string): Promise { +/** Start the loopback callback server, or resolve undefined where no local server can run. */ +async function startCallbackServer(expectedState: string): Promise { + const createServer = await loadCreateServer(); + if (!createServer) return undefined; return new Promise((resolve, reject) => { let resolveResult!: (result: AuthorizationResult) => void; let rejectResult!: (error: Error) => void; @@ -132,7 +138,7 @@ function startCallbackServer(expectedState: string): Promise { } async function requestToken(body: URLSearchParams, signal: AbortSignal): Promise { - const response = await fetch(TOKEN_URL, { + const response = await oauthFetch(TOKEN_URL, { method: "POST", headers: { accept: "application/json", @@ -240,6 +246,7 @@ async function loginOpenAIChatGPT( const nonce = randomValue(); // Without this server, the browser's callback would reach whatever else holds the port (another // pending login or the Codex CLI), which rejects it as a state mismatch. Fail with a clear error instead. + // Where no local server can run at all (browsers), `callback` is undefined and only pasting works. const callback = await startCallbackServer(state).catch((error: unknown) => { if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error; throw new Error( @@ -279,7 +286,7 @@ async function loginOpenAIChatGPT( .then((input) => authorizationResultFromManualInput(input, state)); try { - const result = await Promise.race([callback.result, manualCode]); + const result = await (callback ? Promise.race([callback.result, manualCode]) : manualCode); interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." }); return await exchangeAuthorizationCode(result.code, verifier, result.clientId, interaction.signal); } catch (error) { @@ -287,13 +294,13 @@ async function loginOpenAIChatGPT( throw error; } finally { manualAbort.abort(); - callback.server.close(); + callback?.server.close(); // close() only stops accepting new connections. Browsers open spare connections ahead of // time, and one that has not sent a request yet stays open and attached to this server. // A later login in the same process starts a new server with a new state, but the browser // may send that login's callback over the spare connection. This server would then handle // it and reject it with "OAuth state mismatch", and the new login would never see it. - callback.server.closeAllConnections(); + callback?.server.closeAllConnections(); } } diff --git a/packages/ai/src/auth/oauth/openai-codex.ts b/packages/ai/src/auth/oauth/openai-codex.ts index 860ea7554..831c5901b 100644 --- a/packages/ai/src/auth/oauth/openai-codex.ts +++ b/packages/ai/src/auth/oauth/openai-codex.ts @@ -1,22 +1,15 @@ /** * OpenAI Codex (ChatGPT OAuth) flow * - * NOTE: This module uses Node.js crypto and http (via callback-server.ts) for the OAuth callback. - * It is only intended for CLI use, not browser environments. + * Browser login listens for the redirect on a loopback server (Node only, via callback-server.ts); + * without one, it continues with the pasted redirect URL. Device code login needs only fetch. */ -// NEVER convert to top-level imports - breaks browser/Vite builds -let _randomBytes: typeof import("node:crypto").randomBytes | null = null; -if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { - import("node:crypto").then((m) => { - _randomBytes = m.randomBytes; - }); -} - import { getProviderEnvValue } from "../../utils/provider-env.ts"; import type { LoginOptions, OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { oauthFetch } from "./fetch.ts"; import { generatePKCE } from "./pkce.ts"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -60,10 +53,7 @@ type JwtPayload = { }; function createState(): string { - if (!_randomBytes) { - throw new Error("OpenAI Codex OAuth is only available in Node.js environments"); - } - return _randomBytes(16).toString("hex"); + return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join(""); } function parseAuthorizationInput(input: string): { code?: string; state?: string } { @@ -110,7 +100,7 @@ function decodeJwt(token: string): JwtPayload | null { async function fetchWithLoginCancellation(input: string, init: RequestInit): Promise { try { - return await fetch(input, init); + return await oauthFetch(input, init); } catch (error) { if (init.signal?.aborted) { throw new Error("Login cancelled"); @@ -167,7 +157,7 @@ async function exchangeAuthorizationCode( async function refreshAccessToken(refreshToken: string, signal: AbortSignal): Promise { let response: Response; try { - response = await fetch(TOKEN_URL, { + response = await oauthFetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ diff --git a/packages/ai/src/auth/oauth/openrouter.ts b/packages/ai/src/auth/oauth/openrouter.ts index 7d6e2863c..53641eded 100644 --- a/packages/ai/src/auth/oauth/openrouter.ts +++ b/packages/ai/src/auth/oauth/openrouter.ts @@ -14,6 +14,7 @@ import { getProviderEnvValue } from "../../utils/provider-env.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.ts"; +import { oauthFetch } from "./fetch.ts"; import { generatePKCE } from "./pkce.ts"; const AUTHORIZE_URL = "https://openrouter.ai/auth"; @@ -72,7 +73,7 @@ async function exchangeAuthorizationCode( let response: Response; let body: JsonObject = {}; try { - response = await fetch(TOKEN_URL, { + response = await oauthFetch(TOKEN_URL, { method: "POST", headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }), diff --git a/packages/ai/src/auth/oauth/pkce.ts b/packages/ai/src/auth/oauth/pkce.ts index bf7ac7d58..905c9dc2d 100644 --- a/packages/ai/src/auth/oauth/pkce.ts +++ b/packages/ai/src/auth/oauth/pkce.ts @@ -6,7 +6,7 @@ /** * Encode bytes as base64url string. */ -function base64urlEncode(bytes: Uint8Array): string { +export function base64urlEncode(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); diff --git a/packages/ai/src/auth/oauth/radius.ts b/packages/ai/src/auth/oauth/radius.ts index 811410d8a..f4acae073 100644 --- a/packages/ai/src/auth/oauth/radius.ts +++ b/packages/ai/src/auth/oauth/radius.ts @@ -13,6 +13,7 @@ import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { startOAuthCallbackServer } from "./callback-server.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { oauthFetch } from "./fetch.ts"; import { generatePKCE } from "./pkce.ts"; import { trustedHttpUrl } from "./trusted-url.ts"; @@ -40,7 +41,7 @@ type DeviceAuthorizationResponse = { }; async function loadRadiusOAuthDiscovery(gateway: string, signal: AbortSignal): Promise { - const response = await fetch(new URL("/v1/oauth", gateway), { + const response = await oauthFetch(new URL("/v1/oauth", gateway), { headers: { accept: "application/json" }, signal, }); @@ -100,7 +101,7 @@ async function requestOAuthToken( ): Promise { let response: Response; try { - response = await fetch(new URL("/v1/oauth/token", gateway), { + response = await oauthFetch(new URL("/v1/oauth/token", gateway), { method: "POST", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, body, @@ -191,7 +192,7 @@ async function loginWithBrowser( async function requestDeviceAuthorization(gateway: string, signal: AbortSignal): Promise { let response: Response; try { - response = await fetch(new URL("/v1/oauth/device", gateway), { + response = await oauthFetch(new URL("/v1/oauth/device", gateway), { method: "POST", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }), diff --git a/packages/ai/src/auth/oauth/xai.ts b/packages/ai/src/auth/oauth/xai.ts index bf999c34c..5bfc0a6b9 100644 --- a/packages/ai/src/auth/oauth/xai.ts +++ b/packages/ai/src/auth/oauth/xai.ts @@ -4,6 +4,7 @@ import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { oauthFetch } from "./fetch.ts"; const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access"; @@ -64,7 +65,7 @@ function validateVerificationUri(raw: string): string { async function postForm(url: string, fields: Record, signal: AbortSignal): Promise { let response: Response; try { - response = await fetch(url, { + response = await oauthFetch(url, { method: "POST", headers: { Accept: "application/json", diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts index 5e77cf1b6..045beab8a 100644 --- a/packages/ai/src/bun-oauth.ts +++ b/packages/ai/src/bun-oauth.ts @@ -1,25 +1,6 @@ -import { anthropicOAuth } from "./auth/oauth/anthropic.ts"; -import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts"; -import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts"; -import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts"; -import { metaOAuth } from "./auth/oauth/meta.ts"; -import { openaiChatGPTOAuth } from "./auth/oauth/openai-chatgpt.ts"; -import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; -import { openRouterOAuth } from "./auth/oauth/openrouter.ts"; -import { createRadiusOAuth } from "./auth/oauth/radius.ts"; -import { xaiOAuth } from "./auth/oauth/xai.ts"; +import { registerOAuthFlows } from "./oauth-flows.ts"; /** Register OAuth flows statically embedded in the standalone Bun binary. */ export function registerBunOAuthFlows(): void { - registerBundledOAuthFlowLoaders({ - anthropic: () => anthropicOAuth, - openaiCodex: () => openaiCodexOAuth, - openaiChatGPT: () => openaiChatGPTOAuth, - githubCopilot: () => githubCopilotOAuth, - openrouter: () => openRouterOAuth, - kimiCoding: () => kimiCodingOAuth, - meta: () => metaOAuth, - xai: () => xaiOAuth, - radius: createRadiusOAuth, - }); + registerOAuthFlows(); } diff --git a/packages/ai/src/oauth-flows.ts b/packages/ai/src/oauth-flows.ts new file mode 100644 index 000000000..bee8af6a1 --- /dev/null +++ b/packages/ai/src/oauth-flows.ts @@ -0,0 +1,77 @@ +/** + * Every built-in OAuth flow, bundled statically. + * + * Built-in providers load their OAuth flow lazily, through an import path that bundlers cannot follow, + * so Node-only flow code never reaches a bundle by accident. Runtimes that bundle everything up front + * (browsers, standalone binaries) import this entry instead and call `registerOAuthFlows()` once, + * before the first login or token refresh. Importing it loads every flow. + */ + +import { anthropicOAuth } from "./auth/oauth/anthropic.ts"; +import { type OAuthFetch, setOAuthFetch } from "./auth/oauth/fetch.ts"; +import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts"; +import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts"; +import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts"; +import { metaOAuth } from "./auth/oauth/meta.ts"; +import { openaiChatGPTOAuth } from "./auth/oauth/openai-chatgpt.ts"; +import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; +import { openRouterOAuth } from "./auth/oauth/openrouter.ts"; +import { createRadiusOAuth } from "./auth/oauth/radius.ts"; +import { xaiOAuth } from "./auth/oauth/xai.ts"; +import type { OAuthAuth } from "./auth/types.ts"; +import { DEFAULT_RADIUS_GATEWAY } from "./providers/radius-config.ts"; + +export type { OAuthFetch } from "./auth/oauth/fetch.ts"; + +export interface OAuthFlowOptions { + /** + * Sends every request of every flow, for example one that routes the requests a browser cannot + * make directly through a relay. Default: the global fetch. + */ + fetch?: OAuthFetch; +} + +/** Make the lazily loaded OAuth flows of the built-in providers load from this bundle. */ +export function registerOAuthFlows(options: OAuthFlowOptions = {}): void { + setOAuthFetch(options.fetch); + registerBundledOAuthFlowLoaders({ + anthropic: () => anthropicOAuth, + openaiCodex: () => openaiCodexOAuth, + openaiChatGPT: () => openaiChatGPTOAuth, + githubCopilot: () => githubCopilotOAuth, + openrouter: () => openRouterOAuth, + kimiCoding: () => kimiCodingOAuth, + meta: () => metaOAuth, + xai: () => xaiOAuth, + radius: createRadiusOAuth, + }); +} + +/** + * The OAuth flow of a built-in provider, by provider ID, for code that signs in without a `Models` + * collection. Radius uses its default gateway. + */ +export function getBuiltinOAuthFlow(providerId: string): OAuthAuth | undefined { + switch (providerId) { + case "anthropic": + return anthropicOAuth; + case "openai": + return openaiChatGPTOAuth; + case "openai-codex": + return openaiCodexOAuth; + case "github-copilot": + return githubCopilotOAuth; + case "openrouter": + return openRouterOAuth; + case "kimi-coding": + return kimiCodingOAuth; + case "meta": + return metaOAuth; + case "xai": + return xaiOAuth; + case "radius": + return createRadiusOAuth({ name: "Radius", gateway: DEFAULT_RADIUS_GATEWAY }); + default: + return undefined; + } +} diff --git a/packages/ai/src/remote-catalog.ts b/packages/ai/src/remote-catalog.ts new file mode 100644 index 000000000..3d5ca76b4 --- /dev/null +++ b/packages/ai/src/remote-catalog.ts @@ -0,0 +1,249 @@ +/** + * Model catalogs published by pi.dev, layered over a provider's built-in models. + * + * pi.dev serves one catalog per provider at `/api/models/providers/`. A wrapped provider restores + * the catalog persisted in the `ModelsStore` before any network access, then refreshes it at most + * every `REMOTE_CATALOG_REFRESH_INTERVAL_MS`, revalidating with the stored ETag. A catalog older than + * the built-in model data is ignored, so a stale pi.dev response never replaces newer shipped data. + */ + +import type { Provider, RefreshModelsContext } from "./models.ts"; +import type { ModelsStoreEntry } from "./models-store.ts"; +import type { AnyModel, ModelType } from "./types.ts"; +import { getModelType, isModelType } from "./utils/model-operations.ts"; + +export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000; + +/** + * Model types this client can consume. Sent as `?types=` so the catalog server + * returns the full-type shard instead of the chat-only one served to clients + * that predate model types. A server that ignores the parameter still returns + * the chat-only shard, which this client handles unchanged. + */ +export const REMOTE_CATALOG_MODEL_TYPES: readonly ModelType[] = ["chat", "image", "classifier"]; + +/** Sends one catalog request. Apps add retries or headers here (browsers cannot set User-Agent). */ +export type RemoteCatalogRequest = ( + url: URL, + init: { headers: Record; signal: AbortSignal }, +) => Promise; + +export interface RemoteCatalogOptions { + /** Origin of the catalog service. Default: https://pi.dev */ + baseUrl?: string; + /** When the built-in model data was generated; older remote catalogs are ignored. */ + localGeneratedAt?: number; + /** Default: the global fetch. */ + request?: RemoteCatalogRequest; +} + +/** Replaces the catalog with a credential-specific model list. */ +export type PersonalizeCatalog = ( + catalog: readonly AnyModel[], + context: RefreshModelsContext, +) => Promise; + +const DEFAULT_CATALOG_BASE_URL = "https://pi.dev"; + +/** Add a persisted pi.dev catalog overlay to a static built-in provider. */ +export function withRemoteCatalog(provider: Provider, options: RemoteCatalogOptions = {}): Provider { + const { localGeneratedAt } = options; + let dynamicModels: readonly AnyModel[] = []; + + return { + ...provider, + getModels: () => + mergeModels( + provider.getModels(), + dynamicModels.filter((model) => isModelType(model, "chat")), + ), + getAllModels: () => mergeModels(provider.getAllModels?.() ?? provider.getModels(), dynamicModels), + refreshModels: async (context) => { + const stored = context.stored; + const restored = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id); + if ( + !(await context.publish({ + update: () => { + dynamicModels = restored; + }, + })) + ) { + return; + } + if (!context.allowNetwork || context.signal.aborted) return; + // Skip the request if pi.dev was checked less than REMOTE_CATALOG_REFRESH_INTERVAL_MS ago. + // An entry without lastModified (saved by an older pi, or after a failed first request) is + // always refetched: remoteModels() ignores it, so waiting would leave the overlay empty. + if ( + !context.force && + stored?.checkedAt !== undefined && + stored.lastModified !== undefined && + Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS + ) { + return; + } + + // Only revalidate when a cached body backs the validator, so a 304 can never + // leave the overlay empty. + const validator = stored && stored.models.length > 0 ? stored.etag : undefined; + const response = await requestCatalog(provider.id, options, context.signal, validator); + if (context.signal.aborted) return; + const checkedAt = Date.now(); + // Unchanged: dynamicModels already holds the stored overlay, so only the + // freshness window moves. + if (response.status === 304 && stored) { + await context.publish({ persist: { ...stored, checkedAt } }); + return; + } + if (response.status === 404 || response.status === 501) { + await context.publish({ + persist: { + ...(stored ?? { models: [] }), + checkedAt, + lastModified: 0, + etag: undefined, + }, + }); + return; + } + if (!response.ok) { + // Transient failure: the cached body and its validator stay valid, so keep the + // etag and let the next refresh revalidate instead of downloading the catalog. + await context.publish({ persist: { ...(stored ?? { models: [] }), checkedAt } }); + throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`); + } + const catalog = await readCatalog(provider.id, response); + if (context.signal.aborted) return; + const entry: ModelsStoreEntry = { ...catalog, checkedAt }; + const published = remoteModels(entry, localGeneratedAt); + await context.publish({ + persist: entry, + update: () => { + dynamicModels = published; + }, + }); + }, + }; +} + +/** + * Like `withRemoteCatalog`, but on each network refresh the catalog (built-in models plus a newer + * pi.dev catalog) is passed through `personalize` and the result is saved. The saved entry holds + * the personalized list, not the pi.dev catalog, and replaces the catalog, including offline. + * The refresh interval and etag revalidation are skipped. An empty list (nothing saved yet, or an + * empty catalog saved by an older pi after a failed or missing pi.dev catalog) falls back to the + * built-in models. + */ +export function withPersonalizedCatalog( + provider: Provider, + personalize: PersonalizeCatalog, + options: RemoteCatalogOptions = {}, +): Provider { + let personalModels: readonly AnyModel[] = []; + const builtinModels = (): readonly AnyModel[] => provider.getAllModels?.() ?? provider.getModels(); + const getAllModels = (): readonly AnyModel[] => (personalModels.length > 0 ? personalModels : builtinModels()); + + return { + ...provider, + getModels: () => getAllModels().filter((model) => isModelType(model, "chat")), + getAllModels, + refreshModels: async (context) => { + const restored = (context.stored?.models ?? []).filter((model) => model.provider === provider.id); + if ( + !(await context.publish({ + update: () => { + personalModels = restored; + }, + })) + ) { + return; + } + if (!context.allowNetwork || context.signal.aborted) return; + + const response = await requestCatalog(provider.id, options, context.signal); + if (context.signal.aborted) return; + let remote: readonly AnyModel[] = []; + if (response.status !== 404 && response.status !== 501) { + if (!response.ok) throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`); + const catalog = await readCatalog(provider.id, response); + if (isNewer(catalog.lastModified, options.localGeneratedAt)) remote = catalog.models; + } + const personalized = await personalize(mergeModels(builtinModels(), remote), context); + if (context.signal.aborted) return; + await context.publish({ + persist: { models: personalized, checkedAt: Date.now() }, + update: () => { + personalModels = personalized; + }, + }); + }, + }; +} + +function mergeModels(baseline: readonly TModel[], dynamic: readonly TModel[]): TModel[] { + const merged = new Map(); + for (const model of [...baseline, ...dynamic]) merged.set(`${getModelType(model)}\0${model.id}`, model); + return [...merged.values()]; +} + +/** Whether a pi.dev catalog is newer than the built-in model data. */ +function isNewer(lastModified: number | undefined, localGeneratedAt: number | undefined): boolean { + return localGeneratedAt === undefined || (lastModified !== undefined && lastModified > localGeneratedAt); +} + +function remoteModels(entry: ModelsStoreEntry | undefined, localGeneratedAt: number | undefined): readonly AnyModel[] { + return entry && isNewer(entry.lastModified, localGeneratedAt) ? entry.models : []; +} + +function requestCatalog( + providerId: string, + options: RemoteCatalogOptions, + signal: AbortSignal, + validator?: string, +): Promise { + const url = new URL( + `/api/models/providers/${encodeURIComponent(providerId)}`, + options.baseUrl ?? DEFAULT_CATALOG_BASE_URL, + ); + url.searchParams.set("types", REMOTE_CATALOG_MODEL_TYPES.join(",")); + const init = { + headers: { accept: "application/json", ...(validator ? { "if-none-match": validator } : {}) }, + signal, + }; + return options.request ? options.request(url, init) : fetch(url, init); +} + +/** Read a successful pi.dev catalog response. A missing or invalid Last-Modified header is 0. */ +async function readCatalog( + providerId: string, + response: Response, +): Promise<{ models: AnyModel[]; lastModified: number; etag: string | undefined }> { + const lastModified = Date.parse(response.headers.get("last-modified") ?? ""); + return { + models: parseCatalog(providerId, await response.json()), + lastModified: Number.isNaN(lastModified) ? 0 : lastModified, + etag: response.headers.get("etag") ?? undefined, + }; +} + +function parseCatalog(providerId: string, value: unknown): AnyModel[] { + const entries = Array.isArray(value) + ? value + : typeof value === "object" && value !== null && "models" in value && Array.isArray(value.models) + ? value.models + : typeof value === "object" && value !== null + ? Object.values(value) + : undefined; + if (!entries) throw new Error(`Invalid model catalog for provider "${providerId}"`); + return entries + .filter((entry): entry is Record => typeof entry === "object" && entry !== null && "id" in entry) + .filter(isSupportedModelType) + .map((model) => ({ ...model, provider: providerId }) as AnyModel); +} + +function isSupportedModelType(model: { type?: unknown }): boolean { + return ( + model.type === undefined || + (typeof model.type === "string" && REMOTE_CATALOG_MODEL_TYPES.includes(model.type as ModelType)) + ); +} diff --git a/packages/ai/test/oauth-flows.test.ts b/packages/ai/test/oauth-flows.test.ts new file mode 100644 index 000000000..6e8fa82a6 --- /dev/null +++ b/packages/ai/test/oauth-flows.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadCreateServer } from "../src/auth/oauth/callback-server.ts"; +import type { OAuthCredential } from "../src/auth/types.ts"; +import { getBuiltinOAuthFlow, registerOAuthFlows } from "../src/oauth-flows.ts"; +import { anthropicProvider } from "../src/providers/anthropic.ts"; + +const tokenResponse = () => + new Response(JSON.stringify({ access_token: "fresh", refresh_token: "rotated", expires_in: 3600 }), { + headers: { "content-type": "application/json" }, + }); + +const stale: OAuthCredential = { type: "oauth", access: "stale", refresh: "old", expires: 0 }; + +afterEach(() => { + registerOAuthFlows(); + vi.unstubAllGlobals(); +}); + +describe("registerOAuthFlows", () => { + it("sends every flow request through the registered fetch", async () => { + const relay = vi.fn(async () => tokenResponse()); + registerOAuthFlows({ fetch: relay }); + + const refreshed = await anthropicProvider().auth.oauth?.refresh(stale, new AbortController().signal); + expect(refreshed).toMatchObject({ access: "fresh", refresh: "rotated" }); + expect(relay).toHaveBeenCalledWith("https://platform.claude.com/v1/oauth/token", expect.anything()); + }); + + it("restores the global fetch without one", async () => { + registerOAuthFlows({ fetch: async () => new Response("relay is gone", { status: 500 }) }); + registerOAuthFlows(); + const global = vi.fn(async () => tokenResponse()); + vi.stubGlobal("fetch", global); + + await anthropicProvider().auth.oauth?.refresh(stale, new AbortController().signal); + expect(global).toHaveBeenCalledOnce(); + }); +}); + +describe("getBuiltinOAuthFlow", () => { + it("finds the flow of each built-in OAuth provider", () => { + for (const id of [ + "anthropic", + "openai", + "openai-codex", + "github-copilot", + "openrouter", + "kimi-coding", + "meta", + "xai", + "radius", + ]) { + expect(getBuiltinOAuthFlow(id), id).toBeDefined(); + } + expect(getBuiltinOAuthFlow("google")).toBeUndefined(); + }); +}); + +describe("loadCreateServer", () => { + it("loads node:http where Node runs", async () => { + expect(typeof (await loadCreateServer())).toBe("function"); + }); +}); diff --git a/packages/ai/test/openai-chatgpt-oauth-paste.test.ts b/packages/ai/test/openai-chatgpt-oauth-paste.test.ts new file mode 100644 index 000000000..36a603afa --- /dev/null +++ b/packages/ai/test/openai-chatgpt-oauth-paste.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openaiChatGPTOAuth } from "../src/auth/oauth/openai-chatgpt.ts"; + +// Browsers cannot start a local server; this file runs the sign-in as a browser would. +vi.mock("../src/auth/oauth/callback-server.ts", async (importOriginal) => ({ + ...(await importOriginal()), + loadCreateServer: async () => undefined, +})); + +const SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"; + +describe("OpenAI ChatGPT OAuth without a local server", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("completes with the pasted redirect address", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + access_token: "access", + refresh_token: "refresh", + expires_in: 3600, + id_token: "id", + scope: SCOPE, + }), + { headers: { "content-type": "application/json" } }, + ), + ), + ); + let authorizeUrl: URL | undefined; + const credential = await openaiChatGPTOAuth.login( + { + signal: new AbortController().signal, + notify: (event) => { + if (event.type === "auth_url") authorizeUrl = new URL(event.url); + }, + prompt: async () => { + const callback = new URL(authorizeUrl?.searchParams.get("redirect_uri") ?? ""); + callback.searchParams.set("code", "code"); + callback.searchParams.set("state", authorizeUrl?.searchParams.get("state") ?? ""); + callback.searchParams.set("client_id", "oaiapp_issued"); + return callback.toString(); + }, + }, + { getDeviceId: () => "e61bbe28-07ef-466d-8e5d-a344f94ab305" }, + ); + expect(credential).toMatchObject({ access: "access", refresh: "refresh", clientId: "oaiapp_issued" }); + }); +}); diff --git a/packages/ai/test/remote-catalog.test.ts b/packages/ai/test/remote-catalog.test.ts new file mode 100644 index 000000000..da6d0490c --- /dev/null +++ b/packages/ai/test/remote-catalog.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { createModels, createProvider, type Model } from "../src/index.ts"; +import { REMOTE_CATALOG_MODEL_TYPES, withRemoteCatalog } from "../src/remote-catalog.ts"; + +function model(id: string): Model<"openai-completions"> { + return { + id, + name: id, + api: "openai-completions", + provider: "example", + baseUrl: "https://example.test/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }; +} + +const builtin = () => + createProvider({ + id: "example", + auth: { apiKey: { name: "Example", resolve: async () => ({ auth: {} }) } }, + models: [model("builtin")], + api: { + stream: () => { + throw new Error("not used"); + }, + streamSimple: () => { + throw new Error("not used"); + }, + }, + }); + +describe("withRemoteCatalog", () => { + it("asks the configured catalog service through the app's request function", async () => { + const request = vi.fn( + async () => + new Response(JSON.stringify([model("remote")]), { + headers: { "last-modified": new Date(2_000).toUTCString(), etag: '"v1"' }, + }), + ); + const models = createModels(); + models.setProvider( + withRemoteCatalog(builtin(), { baseUrl: "https://catalog.test", localGeneratedAt: 1_000, request }), + ); + + await models.refresh(); + const [url, init] = request.mock.calls[0] as unknown as [URL, { headers: Record }]; + expect(url.href).toBe( + `https://catalog.test/api/models/providers/example?types=${encodeURIComponent(REMOTE_CATALOG_MODEL_TYPES.join(","))}`, + ); + expect(init.headers).toEqual({ accept: "application/json" }); + expect(models.getModels("example").map((entry) => entry.id)).toEqual(["builtin", "remote"]); + }); +}); diff --git a/packages/coding-agent/src/core/remote-catalog-provider.ts b/packages/coding-agent/src/core/remote-catalog-provider.ts index 8f945f7d8..ec308743d 100644 --- a/packages/coding-agent/src/core/remote-catalog-provider.ts +++ b/packages/coding-agent/src/core/remote-catalog-provider.ts @@ -1,240 +1,46 @@ +import type { Provider } from "@earendil-works/pi-ai"; import { - type AnyModel, - getModelType, - isModelType, - type ModelsStoreEntry, - type ModelType, - type Provider, - type RefreshModelsContext, -} from "@earendil-works/pi-ai"; + type PersonalizeCatalog, + type RemoteCatalogOptions, + type RemoteCatalogRequest, + withPersonalizedCatalog as withPersonalizedRemoteCatalog, + withRemoteCatalog as withRemoteCatalogOverlay, +} from "@earendil-works/pi-ai/remote-catalog"; import { VERSION } from "../config.ts"; import { fetchWithRetry } from "../utils/management-http.ts"; import { getPiUserAgent } from "../utils/pi-user-agent.ts"; -const DEFAULT_CATALOG_BASE_URL = "https://pi.dev"; +export { + type PersonalizeCatalog, + REMOTE_CATALOG_MODEL_TYPES, + REMOTE_CATALOG_REFRESH_INTERVAL_MS, +} from "@earendil-works/pi-ai/remote-catalog"; + const REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS = 4_000; -export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000; -/** - * Model types this client can consume. Sent as `?types=` so the catalog server - * returns the full-type shard instead of the chat-only one served to clients - * that predate model types. A server that ignores the parameter still returns - * the chat-only shard, which this client handles unchanged. - */ -export const REMOTE_CATALOG_MODEL_TYPES: readonly ModelType[] = ["chat", "image", "classifier"]; -function isSupportedModelType(model: { type?: unknown }): boolean { - return ( - model.type === undefined || - (typeof model.type === "string" && REMOTE_CATALOG_MODEL_TYPES.includes(model.type as ModelType)) - ); -} - -function mergeModels(baseline: readonly TModel[], dynamic: readonly TModel[]): TModel[] { - const merged = new Map(); - for (const model of [...baseline, ...dynamic]) merged.set(`${getModelType(model)}\0${model.id}`, model); - return [...merged.values()]; -} - -function parseCatalog(providerId: string, value: unknown): AnyModel[] { - const entries = Array.isArray(value) - ? value - : typeof value === "object" && value !== null && "models" in value && Array.isArray(value.models) - ? value.models - : typeof value === "object" && value !== null - ? Object.values(value) - : undefined; - if (!entries) throw new Error(`Invalid model catalog for provider "${providerId}"`); - return entries - .filter((entry): entry is Record => typeof entry === "object" && entry !== null && "id" in entry) - .filter(isSupportedModelType) - .map((model) => ({ ...model, provider: providerId }) as AnyModel); -} - -/** Read a successful pi.dev catalog response. A missing or invalid Last-Modified header is 0. */ -async function readCatalog( - providerId: string, - response: Response, -): Promise<{ models: AnyModel[]; lastModified: number; etag: string | undefined }> { - const lastModified = Date.parse(response.headers.get("last-modified") ?? ""); - return { - models: parseCatalog(providerId, await response.json()), - lastModified: Number.isNaN(lastModified) ? 0 : lastModified, - etag: response.headers.get("etag") ?? undefined, - }; -} - -/** Whether a pi.dev catalog is newer than the model data shipped with pi. */ -function isNewer(lastModified: number | undefined, localGeneratedAt: number | undefined): boolean { - return localGeneratedAt === undefined || (lastModified !== undefined && lastModified > localGeneratedAt); -} - -function remoteModels(entry: ModelsStoreEntry | undefined, localGeneratedAt: number | undefined): readonly AnyModel[] { - return entry && isNewer(entry.lastModified, localGeneratedAt) ? entry.models : []; -} - -function requestCatalog( - providerId: string, - catalogBaseUrl: string, - signal: AbortSignal, - validator?: string, -): Promise { - const url = new URL(`/api/models/providers/${encodeURIComponent(providerId)}`, catalogBaseUrl); - url.searchParams.set("types", REMOTE_CATALOG_MODEL_TYPES.join(",")); - return fetchWithRetry( +/** Catalog requests from pi: retried, with pi's User-Agent. */ +const requestCatalog: RemoteCatalogRequest = (url, init) => + fetchWithRetry( url, - { - headers: { - accept: "application/json", - "User-Agent": getPiUserAgent(VERSION), - ...(validator ? { "if-none-match": validator } : {}), - }, - signal, - }, + { ...init, headers: { ...init.headers, "User-Agent": getPiUserAgent(VERSION) } }, { attemptTimeoutMs: REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS }, ); -} -/** Replaces the catalog with a credential-specific model list. */ -export type PersonalizeCatalog = ( - catalog: readonly AnyModel[], - context: RefreshModelsContext, -) => Promise; +function catalogOptions(baseUrl: string | undefined, localGeneratedAt: number | undefined): RemoteCatalogOptions { + return { baseUrl, localGeneratedAt, request: requestCatalog }; +} /** Add a persisted pi.dev catalog overlay to a static built-in provider. */ -export function withRemoteCatalog( - provider: Provider, - catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL, - localGeneratedAt?: number, -): Provider { - let dynamicModels: readonly AnyModel[] = []; - - return { - ...provider, - getModels: () => - mergeModels( - provider.getModels(), - dynamicModels.filter((model) => isModelType(model, "chat")), - ), - getAllModels: () => mergeModels(provider.getAllModels?.() ?? provider.getModels(), dynamicModels), - refreshModels: async (context) => { - const stored = context.stored; - const restored = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id); - if ( - !(await context.publish({ - update: () => { - dynamicModels = restored; - }, - })) - ) { - return; - } - if (!context.allowNetwork || context.signal.aborted) return; - // Skip the request if pi.dev was checked less than REMOTE_CATALOG_REFRESH_INTERVAL_MS ago. - // An entry without lastModified (saved by an older pi, or after a failed first request) is - // always refetched: remoteModels() ignores it, so waiting would leave the overlay empty. - if ( - !context.force && - stored?.checkedAt !== undefined && - stored.lastModified !== undefined && - Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS - ) { - return; - } - - // Only revalidate when a cached body backs the validator, so a 304 can never - // leave the overlay empty. - const validator = stored && stored.models.length > 0 ? stored.etag : undefined; - const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal, validator); - if (context.signal.aborted) return; - const checkedAt = Date.now(); - // Unchanged: dynamicModels already holds the stored overlay, so only the - // freshness window moves. - if (response.status === 304 && stored) { - await context.publish({ persist: { ...stored, checkedAt } }); - return; - } - if (response.status === 404 || response.status === 501) { - await context.publish({ - persist: { - ...(stored ?? { models: [] }), - checkedAt, - lastModified: 0, - etag: undefined, - }, - }); - return; - } - if (!response.ok) { - // Transient failure: the cached body and its validator stay valid, so keep the - // etag and let the next refresh revalidate instead of downloading the catalog. - await context.publish({ persist: { ...(stored ?? { models: [] }), checkedAt } }); - throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`); - } - const catalog = await readCatalog(provider.id, response); - if (context.signal.aborted) return; - const entry: ModelsStoreEntry = { ...catalog, checkedAt }; - const published = remoteModels(entry, localGeneratedAt); - await context.publish({ - persist: entry, - update: () => { - dynamicModels = published; - }, - }); - }, - }; +export function withRemoteCatalog(provider: Provider, catalogBaseUrl?: string, localGeneratedAt?: number): Provider { + return withRemoteCatalogOverlay(provider, catalogOptions(catalogBaseUrl, localGeneratedAt)); } -/** - * Like `withRemoteCatalog`, but on each network refresh the catalog (built-in models plus a newer - * pi.dev catalog) is passed through `personalize` and the result is saved. The saved entry holds - * the personalized list, not the pi.dev catalog, and replaces the catalog, including offline. - * The refresh interval and etag revalidation are skipped. An empty list (nothing saved yet, or an - * empty catalog saved by an older pi after a failed or missing pi.dev catalog) falls back to the - * built-in models. - */ +/** Like `withRemoteCatalog`, but with a credential-specific catalog; see pi-ai's `withPersonalizedCatalog`. */ export function withPersonalizedCatalog( provider: Provider, personalize: PersonalizeCatalog, - catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL, + catalogBaseUrl?: string, localGeneratedAt?: number, ): Provider { - let personalModels: readonly AnyModel[] = []; - const builtinModels = (): readonly AnyModel[] => provider.getAllModels?.() ?? provider.getModels(); - const getAllModels = (): readonly AnyModel[] => (personalModels.length > 0 ? personalModels : builtinModels()); - - return { - ...provider, - getModels: () => getAllModels().filter((model) => isModelType(model, "chat")), - getAllModels, - refreshModels: async (context) => { - const restored = (context.stored?.models ?? []).filter((model) => model.provider === provider.id); - if ( - !(await context.publish({ - update: () => { - personalModels = restored; - }, - })) - ) { - return; - } - if (!context.allowNetwork || context.signal.aborted) return; - - const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal); - if (context.signal.aborted) return; - let remote: readonly AnyModel[] = []; - if (response.status !== 404 && response.status !== 501) { - if (!response.ok) throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`); - const catalog = await readCatalog(provider.id, response); - if (isNewer(catalog.lastModified, localGeneratedAt)) remote = catalog.models; - } - const personalized = await personalize(mergeModels(builtinModels(), remote), context); - if (context.signal.aborted) return; - await context.publish({ - persist: { models: personalized, checkedAt: Date.now() }, - update: () => { - personalModels = personalized; - }, - }); - }, - }; + return withPersonalizedRemoteCatalog(provider, personalize, catalogOptions(catalogBaseUrl, localGeneratedAt)); } diff --git a/vitest.base.ts b/vitest.base.ts index dacac8bfe..6b3141a43 100644 --- a/vitest.base.ts +++ b/vitest.base.ts @@ -15,6 +15,8 @@ export const workspaceSourcePaths = { aiCompat: fileURLToPath(new URL("./packages/ai/src/compat.ts", import.meta.url)), aiModels: fileURLToPath(new URL("./packages/ai/src/models.ts", import.meta.url)), aiOAuth: fileURLToPath(new URL("./packages/ai/src/oauth.ts", import.meta.url)), + aiOAuthFlows: fileURLToPath(new URL("./packages/ai/src/oauth-flows.ts", import.meta.url)), + aiRemoteCatalog: fileURLToPath(new URL("./packages/ai/src/remote-catalog.ts", import.meta.url)), aiApi: fileURLToPath(new URL("./packages/ai/src/api", import.meta.url)), aiProviders: fileURLToPath(new URL("./packages/ai/src/providers", import.meta.url)), aiUtils: fileURLToPath(new URL("./packages/ai/src/utils", import.meta.url)), @@ -42,6 +44,8 @@ export default defineConfig({ { find: /^@earendil-works\/pi-ai\/compat$/, replacement: workspaceSourcePaths.aiCompat }, { find: /^@earendil-works\/pi-ai\/models$/, replacement: workspaceSourcePaths.aiModels }, { find: /^@earendil-works\/pi-ai\/oauth$/, replacement: workspaceSourcePaths.aiOAuth }, + { find: /^@earendil-works\/pi-ai\/oauth-flows$/, replacement: workspaceSourcePaths.aiOAuthFlows }, + { find: /^@earendil-works\/pi-ai\/remote-catalog$/, replacement: workspaceSourcePaths.aiRemoteCatalog }, { find: /^@earendil-works\/pi-ai\/utils\/(.+)$/, replacement: `${workspaceSourcePaths.aiUtils}/$1.ts`,