feat(ai): add OpenRouter OAuth support (#6927)

Closes #6814
This commit is contained in:
Saryev Rustam
2026-07-22 16:48:39 +03:00
committed by GitHub
parent fe42ba5b38
commit 7b52cef2e6
9 changed files with 522 additions and 6 deletions
+2 -1
View File
@@ -1422,8 +1422,9 @@ Several providers support OAuth authentication instead of static API keys:
- **Anthropic** (Claude Pro/Max subscription) - **Anthropic** (Claude Pro/Max subscription)
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models) - **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
- **GitHub Copilot** (Copilot subscription) - **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 ```typescript
import { createModels } from '@earendil-works/pi-ai'; import { createModels } from '@earendil-works/pi-ai';
+6
View File
@@ -15,6 +15,7 @@ type OAuthFlowLoaders = {
anthropic: () => OAuthAuth | Promise<OAuthAuth>; anthropic: () => OAuthAuth | Promise<OAuthAuth>;
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>; openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>; githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>; kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
xai: () => OAuthAuth | Promise<OAuthAuth>; xai: () => OAuthAuth | Promise<OAuthAuth>;
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>; radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
@@ -42,6 +43,11 @@ export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
}; };
export const loadOpenRouterOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.openrouter();
return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth;
};
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => { export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.kimiCoding(); if (bundledLoaders) return bundledLoaders.kimiCoding();
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth; return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
+246
View File
@@ -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<string, unknown>;
type OpenRouterCallbackServer = {
callbackUrl: string;
credential: Promise<OAuthCredential>;
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<OAuthCredential> {
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<OpenRouterCallbackServer> {
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<OAuthCredential>((resolve, reject) => {
resolveCredential = resolve;
rejectCredential = reject;
});
let server: Server;
let claimed = false;
let settled = false;
let timeout: ReturnType<typeof setTimeout> | 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<void>((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<OAuthCredential> {
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 };
},
};
+2
View File
@@ -3,6 +3,7 @@ import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts"; import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts"; import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
import { openRouterOAuth } from "./auth/oauth/openrouter.ts";
import { createRadiusOAuth } from "./auth/oauth/radius.ts"; import { createRadiusOAuth } from "./auth/oauth/radius.ts";
import { xaiOAuth } from "./auth/oauth/xai.ts"; import { xaiOAuth } from "./auth/oauth/xai.ts";
@@ -12,6 +13,7 @@ export function registerBunOAuthFlows(): void {
anthropic: () => anthropicOAuth, anthropic: () => anthropicOAuth,
openaiCodex: () => openaiCodexOAuth, openaiCodex: () => openaiCodexOAuth,
githubCopilot: () => githubCopilotOAuth, githubCopilot: () => githubCopilotOAuth,
openrouter: () => openRouterOAuth,
kimiCoding: () => kimiCodingOAuth, kimiCoding: () => kimiCodingOAuth,
xai: () => xaiOAuth, xai: () => xaiOAuth,
radius: createRadiusOAuth, radius: createRadiusOAuth,
+10 -2
View File
@@ -1,5 +1,6 @@
import { openrouterImagesApi } from "../api/openrouter-images.lazy.ts"; 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 { IMAGE_MODELS } from "../image-models.generated.ts";
import { createImagesProvider, type ImagesProvider } from "../images-models.ts"; import { createImagesProvider, type ImagesProvider } from "../images-models.ts";
@@ -7,7 +8,14 @@ export function openrouterImagesProvider(): ImagesProvider {
return createImagesProvider({ return createImagesProvider({
id: "openrouter", id: "openrouter",
name: "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), models: Object.values(IMAGE_MODELS.openrouter),
api: openrouterImagesApi(), api: openrouterImagesApi(),
}); });
+10 -2
View File
@@ -1,5 +1,6 @@
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; 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 { createProvider, type Provider } from "../models.ts";
import { OPENROUTER_MODELS } from "./openrouter.models.ts"; import { OPENROUTER_MODELS } from "./openrouter.models.ts";
@@ -8,7 +9,14 @@ export function openrouterProvider(): Provider<"openai-completions"> {
id: "openrouter", id: "openrouter",
name: "OpenRouter", name: "OpenRouter",
baseUrl: "https://openrouter.ai/api/v1", 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), models: Object.values(OPENROUTER_MODELS),
api: openAICompletionsApi(), api: openAICompletionsApi(),
}); });
+7
View File
@@ -3,6 +3,7 @@ import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts"; import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts"; import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.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 { xaiOAuth } from "../src/auth/oauth/xai.ts";
import { createModels } from "../src/models.ts"; import { createModels } from "../src/models.ts";
import * as extensionOAuthCompatibility from "../src/oauth.ts"; import * as extensionOAuthCompatibility from "../src/oauth.ts";
@@ -33,6 +34,12 @@ describe.sequential("OAuthAuth adapters", () => {
expect(auth).toEqual({ apiKey: "token" }); 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 () => { 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 }); const auth = await xaiOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
expect(auth).toEqual({ apiKey: "token" }); expect(auth).toEqual({ apiKey: "token" });
+231
View File
@@ -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<string, unknown> | 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<string, unknown>;
return jsonResponse({ key: "sk-or-test" });
});
vi.stubGlobal("fetch", fetchMock);
let authorizeUrl: URL | undefined;
let callbackResponse: Promise<Response> | 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<Response> | 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<Response>((resolve) => {
completeExchange = resolve;
}),
);
vi.stubGlobal("fetch", fetchMock);
let callbackUrl: URL | undefined;
let firstCallback: Promise<Response> | 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<Response> | 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");
});
});
+8 -1
View File
@@ -20,9 +20,10 @@ Use `/login` in interactive mode, then select a provider:
- Claude Pro/Max - Claude Pro/Max
- GitHub Copilot - GitHub Copilot
- xAI (Grok/X subscription) - xAI (Grok/X subscription)
- OpenRouter (OAuth-minted API key billed from OpenRouter credits)
- Radius - 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 ### 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** - Run `/login xai`, then select **Use a subscription**
- `XAI_API_KEY` remains available through **Use an API key** - `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
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`. 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`.