diff --git a/packages/ai/README.md b/packages/ai/README.md index 8dc07d06..74277a7b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1422,8 +1422,9 @@ Several providers support OAuth authentication instead of static API keys: - **Anthropic** (Claude Pro/Max subscription) - **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models) - **GitHub Copilot** (Copilot subscription) +- **OpenRouter** (OAuth PKCE that mints a user-controlled API key) -Each of these providers carries an `OAuthAuth` on `provider.auth.oauth` with three operations: `login(interaction)` uses the provider-neutral `AuthInteraction.prompt()`/`notify()` protocol and returns a credential, `refresh(credential)` exchanges the refresh token, and `toAuth(credential)` derives request auth (GitHub Copilot's per-account base URL comes from here). Refresh is automatic: `models.getAuth(providerId)` and request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh. +Each of these providers carries an `OAuthAuth` on `provider.auth.oauth` with three operations: `login(interaction)` uses the provider-neutral `AuthInteraction.prompt()`/`notify()` protocol and returns a credential, `refresh(credential)` refreshes expiring credentials when applicable, and `toAuth(credential)` derives request auth (GitHub Copilot's per-account base URL comes from here). Refresh is automatic: `models.getAuth(providerId)` and request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh. OpenRouter's OAuth flow instead returns a permanent API key, so its refresh operation is a no-op. ```typescript import { createModels } from '@earendil-works/pi-ai'; diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index 011e93e1..b21b6b3a 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -15,6 +15,7 @@ type OAuthFlowLoaders = { anthropic: () => OAuthAuth | Promise; openaiCodex: () => OAuthAuth | Promise; githubCopilot: () => OAuthAuth | Promise; + openrouter: () => OAuthAuth | Promise; kimiCoding: () => OAuthAuth | Promise; xai: () => OAuthAuth | Promise; radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise; @@ -42,6 +43,11 @@ export const loadGitHubCopilotOAuth = async (): Promise => { return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; }; +export const loadOpenRouterOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.openrouter(); + return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth; +}; + export const loadKimiCodingOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.kimiCoding(); return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth; diff --git a/packages/ai/src/auth/oauth/openrouter.ts b/packages/ai/src/auth/oauth/openrouter.ts new file mode 100644 index 00000000..5045421e --- /dev/null +++ b/packages/ai/src/auth/oauth/openrouter.ts @@ -0,0 +1,246 @@ +/** + * OpenRouter OAuth PKCE flow. + * + * OpenRouter exchanges an authorization code for a permanent, user-controlled + * API key rather than an expiring access/refresh token pair. The callback is + * handled by a one-shot loopback server on an ephemeral port. + * + * NOTE: This module uses Node.js http.createServer for the OAuth callback server. + * It is only intended for CLI use, not browser environments. + */ + +import { createServer, type Server, type ServerResponse } from "node:http"; +import { getProviderEnvValue } from "../../utils/provider-env.ts"; +import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; +import { generatePKCE } from "./pkce.ts"; + +const AUTHORIZE_URL = "https://openrouter.ai/auth"; +const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys"; +const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; +const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000; + +function getCallbackHost(): string { + return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1"; +} + +type JsonObject = Record; + +type OpenRouterCallbackServer = { + callbackUrl: string; + credential: Promise; + close(): void; +}; + +function sendHtml(response: ServerResponse, status: number, html: string): void { + response.statusCode = status; + response.setHeader("content-type", "text/html; charset=utf-8"); + response.setHeader("cache-control", "no-store"); + response.end(html); +} + +function errorDetail(body: JsonObject): string | undefined { + if (typeof body.error_description === "string") return body.error_description; + if (typeof body.message === "string") return body.message; + if (typeof body.error === "string") return body.error; + if (body.error && typeof body.error === "object" && !Array.isArray(body.error)) { + const message = (body.error as JsonObject).message; + if (typeof message === "string") return message; + } + return undefined; +} + +async function exchangeAuthorizationCode( + code: string, + verifier: string, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) throw new Error("Login cancelled"); + const controller = new AbortController(); + const onAbort = () => controller.abort(signal?.reason); + signal?.addEventListener("abort", onAbort, { once: true }); + const timeout = setTimeout( + () => controller.abort(new Error("OpenRouter OAuth token exchange timed out")), + TOKEN_EXCHANGE_TIMEOUT_MS, + ); + + let response: Response; + let body: JsonObject = {}; + try { + response = await fetch(TOKEN_URL, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }), + signal: controller.signal, + }); + try { + const parsed = (await response.json()) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) body = parsed as JsonObject; + } catch { + if (response.ok) throw new Error("OpenRouter OAuth returned invalid JSON"); + } + } catch (error) { + if (signal?.aborted) throw new Error("Login cancelled"); + if (controller.signal.aborted) throw new Error("OpenRouter OAuth token exchange timed out"); + throw error; + } finally { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + } + + if (!response.ok) { + const detail = errorDetail(body); + throw new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`); + } + + if (typeof body.key !== "string" || body.key.length === 0) { + throw new Error('OpenRouter OAuth response carries no "key"'); + } + + return { + type: "oauth", + access: body.key, + refresh: "", + expires: Number.MAX_SAFE_INTEGER, + }; +} + +async function startCallbackServer( + callbackPath: string, + verifier: string, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) throw new Error("Login cancelled"); + const callbackHost = getCallbackHost(); + let resolveCredential: (credential: OAuthCredential) => void = () => {}; + let rejectCredential: (error: Error) => void = () => {}; + const credential = new Promise((resolve, reject) => { + resolveCredential = resolve; + rejectCredential = reject; + }); + + let server: Server; + let claimed = false; + let settled = false; + let timeout: ReturnType | undefined; + let onAbort: (() => void) | undefined; + + const finish = (result: { credential: OAuthCredential } | { error: Error }): void => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + if (onAbort) signal?.removeEventListener("abort", onAbort); + server.close(); + if ("credential" in result) resolveCredential(result.credential); + else rejectCredential(result.error); + }; + + server = createServer((request, response) => { + void (async () => { + const requestUrl = new URL(request.url ?? "/", `http://${callbackHost}`); + if (request.method !== "GET" || requestUrl.pathname !== callbackPath) { + sendHtml(response, 404, oauthErrorHtml("OAuth callback route not found.")); + return; + } + if (claimed || settled) { + sendHtml(response, 409, oauthErrorHtml("This OAuth callback has already been used.")); + return; + } + + const oauthError = requestUrl.searchParams.get("error"); + if (oauthError) { + const description = requestUrl.searchParams.get("error_description") ?? oauthError; + sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.", description)); + finish({ error: new Error(`OpenRouter authorization failed: ${description}`) }); + return; + } + + const code = requestUrl.searchParams.get("code"); + if (!code) { + sendHtml(response, 400, oauthErrorHtml("OpenRouter returned no authorization code.")); + return; + } + claimed = true; + + try { + const result = await exchangeAuthorizationCode(code, verifier, signal); + sendHtml(response, 200, oauthSuccessHtml("Signed in to OpenRouter. You may now close this page.")); + finish({ credential: result }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown token exchange error"; + sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed.", message)); + finish({ error: error instanceof Error ? error : new Error(message) }); + } + })(); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, callbackHost, () => { + server.removeListener("error", reject); + resolve(); + }); + }); + + server.on("error", (error) => finish({ error })); + onAbort = () => finish({ error: new Error("Login cancelled") }); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) { + signal.removeEventListener("abort", onAbort); + server.close(); + throw new Error("Login cancelled"); + } + timeout = setTimeout(() => finish({ error: new Error("OpenRouter OAuth login timed out") }), LOGIN_TIMEOUT_MS); + + const address = server.address(); + if (!address || typeof address === "string") { + finish({ error: new Error("Could not determine the OpenRouter OAuth callback port") }); + throw new Error("Could not determine the OpenRouter OAuth callback port"); + } + + return { + callbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`, + credential, + close: () => finish({ error: new Error("Login cancelled") }), + }; +} + +async function loginOpenRouter(interaction: AuthInteraction): Promise { + const { verifier, challenge } = await generatePKCE(); + const callbackPath = `/oauth/callback/${crypto.randomUUID()}`; + const callback = await startCallbackServer(callbackPath, verifier, interaction.signal); + const authorizeUrl = new URL(AUTHORIZE_URL); + authorizeUrl.search = new URLSearchParams({ + callback_url: callback.callbackUrl, + code_challenge: challenge, + code_challenge_method: "S256", + }).toString(); + + interaction.notify({ + type: "progress", + message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`, + }); + interaction.notify({ + type: "auth_url", + url: authorizeUrl.toString(), + instructions: "Complete sign-in in your browser.", + }); + + try { + return await callback.credential; + } finally { + callback.close(); + } +} + +export const openRouterOAuth: OAuthAuth = { + name: "OpenRouter OAuth", + loginLabel: "Sign in with OpenRouter", + login: loginOpenRouter, + async refresh(credential) { + return credential; + }, + async toAuth(credential) { + return { apiKey: credential.access }; + }, +}; diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts index 8c3be5be..d1370214 100644 --- a/packages/ai/src/bun-oauth.ts +++ b/packages/ai/src/bun-oauth.ts @@ -3,6 +3,7 @@ import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts"; import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts"; import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts"; import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; +import { openRouterOAuth } from "./auth/oauth/openrouter.ts"; import { createRadiusOAuth } from "./auth/oauth/radius.ts"; import { xaiOAuth } from "./auth/oauth/xai.ts"; @@ -12,6 +13,7 @@ export function registerBunOAuthFlows(): void { anthropic: () => anthropicOAuth, openaiCodex: () => openaiCodexOAuth, githubCopilot: () => githubCopilotOAuth, + openrouter: () => openRouterOAuth, kimiCoding: () => kimiCodingOAuth, xai: () => xaiOAuth, radius: createRadiusOAuth, diff --git a/packages/ai/src/providers/openrouter-images.ts b/packages/ai/src/providers/openrouter-images.ts index 7047cf0e..bdbc2147 100644 --- a/packages/ai/src/providers/openrouter-images.ts +++ b/packages/ai/src/providers/openrouter-images.ts @@ -1,5 +1,6 @@ import { openrouterImagesApi } from "../api/openrouter-images.lazy.ts"; -import { envApiKeyAuth } from "../auth/helpers.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadOpenRouterOAuth } from "../auth/oauth/load.ts"; import { IMAGE_MODELS } from "../image-models.generated.ts"; import { createImagesProvider, type ImagesProvider } from "../images-models.ts"; @@ -7,7 +8,14 @@ export function openrouterImagesProvider(): ImagesProvider { return createImagesProvider({ id: "openrouter", name: "OpenRouter", - auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) }, + auth: { + apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]), + oauth: lazyOAuth({ + name: "OpenRouter OAuth", + loginLabel: "Sign in with OpenRouter", + load: loadOpenRouterOAuth, + }), + }, models: Object.values(IMAGE_MODELS.openrouter), api: openrouterImagesApi(), }); diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 8c3f254d..2a6db8d0 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -1,5 +1,6 @@ import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; -import { envApiKeyAuth } from "../auth/helpers.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadOpenRouterOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; import { OPENROUTER_MODELS } from "./openrouter.models.ts"; @@ -8,7 +9,14 @@ export function openrouterProvider(): Provider<"openai-completions"> { id: "openrouter", name: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", - auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) }, + auth: { + apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]), + oauth: lazyOAuth({ + name: "OpenRouter OAuth", + loginLabel: "Sign in with OpenRouter", + load: loadOpenRouterOAuth, + }), + }, models: Object.values(OPENROUTER_MODELS), api: openAICompletionsApi(), }); diff --git a/packages/ai/test/oauth-auth.test.ts b/packages/ai/test/oauth-auth.test.ts index 6620002d..712f7cb9 100644 --- a/packages/ai/test/oauth-auth.test.ts +++ b/packages/ai/test/oauth-auth.test.ts @@ -3,6 +3,7 @@ import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts"; import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts"; import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts"; +import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts"; import { xaiOAuth } from "../src/auth/oauth/xai.ts"; import { createModels } from "../src/models.ts"; import * as extensionOAuthCompatibility from "../src/oauth.ts"; @@ -33,6 +34,12 @@ describe.sequential("OAuthAuth adapters", () => { expect(auth).toEqual({ apiKey: "token" }); }); + it("openrouter derives the api key and keeps the permanent credential on refresh", async () => { + const credential = { type: "oauth" as const, access: "token", refresh: "", expires: Number.MAX_SAFE_INTEGER }; + expect(await openRouterOAuth.toAuth(credential)).toEqual({ apiKey: "token" }); + expect(await openRouterOAuth.refresh(credential)).toBe(credential); + }); + it("xAI toAuth derives the api key from the access token", async () => { const auth = await xaiOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 }); expect(auth).toEqual({ apiKey: "token" }); diff --git a/packages/ai/test/openrouter-oauth.test.ts b/packages/ai/test/openrouter-oauth.test.ts new file mode 100644 index 00000000..5fa20843 --- /dev/null +++ b/packages/ai/test/openrouter-oauth.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; +import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts"; +import { createImagesModels } from "../src/images-models.ts"; +import { createModels } from "../src/models.ts"; +import { openrouterProvider } from "../src/providers/openrouter.ts"; +import { openrouterImagesProvider } from "../src/providers/openrouter-images.ts"; + +const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys"; +const nativeFetch = globalThis.fetch; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function base64url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +describe.sequential("OpenRouter OAuth", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("is exposed by both OpenRouter providers alongside API-key auth", () => { + for (const provider of [openrouterProvider(), openrouterImagesProvider()]) { + expect(provider.auth.apiKey).toBeDefined(); + expect(provider.auth.oauth).toBeDefined(); + expect(provider.auth.oauth?.loginLabel).toBe("Sign in with OpenRouter"); + } + }); + + it("resolves the same stored OAuth key for text and image providers", async () => { + const credentials = new InMemoryCredentialStore(); + await credentials.modify("openrouter", async () => ({ + type: "oauth", + access: "sk-or-stored", + refresh: "", + expires: Number.MAX_SAFE_INTEGER, + })); + + const textModels = createModels({ credentials }); + textModels.setProvider(openrouterProvider()); + const imageModels = createImagesModels({ credentials }); + imageModels.setProvider(openrouterImagesProvider()); + + expect((await textModels.getAuth("openrouter"))?.auth.apiKey).toBe("sk-or-stored"); + expect((await imageModels.getAuth("openrouter"))?.auth.apiKey).toBe("sk-or-stored"); + }); + + it("runs PKCE on a one-shot loopback callback and exchanges the code for a permanent API key", async () => { + let exchangeBody: Record | undefined; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== TOKEN_URL) return nativeFetch(input, init); + exchangeBody = JSON.parse(String(init?.body)) as Record; + return jsonResponse({ key: "sk-or-test" }); + }); + vi.stubGlobal("fetch", fetchMock); + + let authorizeUrl: URL | undefined; + let callbackResponse: Promise | undefined; + const credential = await openRouterOAuth.login({ + prompt: async () => { + throw new Error("OpenRouter login must not prompt for a code"); + }, + notify: (event) => { + if (event.type !== "auth_url") return; + authorizeUrl = new URL(event.url); + const callbackUrl = new URL(authorizeUrl.searchParams.get("callback_url") ?? ""); + callbackUrl.searchParams.set("code", "authorization-code"); + callbackResponse = nativeFetch(callbackUrl); + }, + }); + + expect(credential).toEqual({ + type: "oauth", + access: "sk-or-test", + refresh: "", + expires: Number.MAX_SAFE_INTEGER, + }); + expect((await callbackResponse)?.status).toBe(200); + expect(authorizeUrl?.origin).toBe("https://openrouter.ai"); + expect(authorizeUrl?.pathname).toBe("/auth"); + expect(authorizeUrl?.searchParams.get("code_challenge_method")).toBe("S256"); + + const callbackUrl = new URL(authorizeUrl?.searchParams.get("callback_url") ?? ""); + expect(callbackUrl.hostname).toBe("127.0.0.1"); + expect(callbackUrl.pathname).toMatch(/^\/oauth\/callback\/[0-9a-f-]+$/); + + expect(exchangeBody).toMatchObject({ + code: "authorization-code", + code_challenge_method: "S256", + }); + const verifier = exchangeBody?.code_verifier; + expect(typeof verifier).toBe("string"); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(verifier))); + expect(authorizeUrl?.searchParams.get("code_challenge")).toBe(base64url(new Uint8Array(digest))); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reports token exchange failures through both the callback page and login", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ error: { message: "invalid code" } }, 403)), + ); + + let callbackResponse: Promise | undefined; + const login = openRouterOAuth.login({ + prompt: async () => "", + notify: (event) => { + if (event.type !== "auth_url") return; + const callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? ""); + callbackUrl.searchParams.set("code", "bad-code"); + callbackResponse = nativeFetch(callbackUrl); + }, + }); + + await expect(login).rejects.toThrow("OpenRouter OAuth key exchange failed (HTTP 403): invalid code"); + expect((await callbackResponse)?.status).toBe(502); + }); + + it("allows only one token exchange for a callback", async () => { + let completeExchange = (_response: Response): void => { + throw new Error("Token exchange did not start"); + }; + const fetchMock = vi.fn( + async () => + new Promise((resolve) => { + completeExchange = resolve; + }), + ); + vi.stubGlobal("fetch", fetchMock); + + let callbackUrl: URL | undefined; + let firstCallback: Promise | undefined; + const login = openRouterOAuth.login({ + prompt: async () => "", + notify: (event) => { + if (event.type !== "auth_url") return; + callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? ""); + callbackUrl.searchParams.set("code", "authorization-code"); + firstCallback = nativeFetch(callbackUrl); + }, + }); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + if (!callbackUrl) throw new Error("OpenRouter did not provide a callback URL"); + expect((await nativeFetch(callbackUrl)).status).toBe(409); + expect(fetchMock).toHaveBeenCalledTimes(1); + completeExchange(jsonResponse({ key: "sk-or-test" })); + + await expect(login).resolves.toMatchObject({ access: "sk-or-test" }); + expect((await firstCallback)?.status).toBe(200); + }); + + it("rejects a successful response that does not contain a key", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ user_id: "user-1" })), + ); + + let callbackResponse: Promise | undefined; + const login = openRouterOAuth.login({ + prompt: async () => "", + notify: (event) => { + if (event.type !== "auth_url") return; + const callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? ""); + callbackUrl.searchParams.set("code", "code-without-key"); + callbackResponse = nativeFetch(callbackUrl); + }, + }); + + await expect(login).rejects.toThrow('OpenRouter OAuth response carries no "key"'); + expect((await callbackResponse)?.status).toBe(502); + }); + + it("closes the pending callback when login is cancelled", async () => { + const controller = new AbortController(); + let callbackUrl: URL | undefined; + const login = openRouterOAuth.login({ + signal: controller.signal, + prompt: async () => "", + notify: (event) => { + if (event.type !== "auth_url") return; + callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? ""); + controller.abort(); + }, + }); + + await expect(login).rejects.toThrow("Login cancelled"); + expect(callbackUrl).toBeDefined(); + await expect(nativeFetch(callbackUrl!)).rejects.toThrow(); + }); + + it("rejects before opening a callback server when login is already cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + openRouterOAuth.login({ + signal: controller.signal, + prompt: async () => "", + notify: () => { + throw new Error("Cancelled login must not emit events"); + }, + }), + ).rejects.toThrow("Login cancelled"); + }); + + it("uses the configured OAuth callback host", async () => { + vi.stubEnv("PI_OAUTH_CALLBACK_HOST", "localhost"); + const controller = new AbortController(); + let callbackUrl: URL | undefined; + const login = openRouterOAuth.login({ + signal: controller.signal, + prompt: async () => "", + notify: (event) => { + if (event.type !== "auth_url") return; + callbackUrl = new URL(new URL(event.url).searchParams.get("callback_url") ?? ""); + controller.abort(); + }, + }); + + await expect(login).rejects.toThrow("Login cancelled"); + expect(callbackUrl?.hostname).toBe("localhost"); + }); +}); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index e8758a3d..905bfcc4 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -20,9 +20,10 @@ Use `/login` in interactive mode, then select a provider: - Claude Pro/Max - GitHub Copilot - xAI (Grok/X subscription) +- OpenRouter (OAuth-minted API key billed from OpenRouter credits) - Radius -Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired. +Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired. OpenRouter instead mints a user-controlled API key that does not expire automatically. ### OpenAI Codex @@ -43,6 +44,12 @@ Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party h - Run `/login xai`, then select **Use a subscription** - `XAI_API_KEY` remains available through **Use an API key** +### OpenRouter + +- Run `/login openrouter`, then select **Sign in with OpenRouter** to open the OpenRouter PKCE authorization flow +- The authorization creates a user-controlled OpenRouter API key billed from your OpenRouter credits +- `OPENROUTER_API_KEY` remains available through **Use an API key** + ### Radius Radius is a dynamic `pi-messages` gateway. `/login radius` stores OAuth tokens in `auth.json`; the gateway catalog is refreshed independently and cached in `models-store.json`. Custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` and a gateway `baseUrl`.