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
+6
View File
@@ -15,6 +15,7 @@ type OAuthFlowLoaders = {
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
xai: () => 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;
};
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> => {
if (bundledLoaders) return bundledLoaders.kimiCoding();
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 { 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,
+10 -2
View File
@@ -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(),
});
+10 -2
View File
@@ -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(),
});