Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
429
extensions/openrouter/oauth.ts
Normal file
429
extensions/openrouter/oauth.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
// OpenRouter OAuth support exchanges PKCE browser login codes for API keys.
|
||||
import { createServer } from "node:http";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { ProviderAuthContext, ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
buildApiKeyCredential,
|
||||
generatePkceVerifierChallenge,
|
||||
type ProviderAuthResult,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { generateOAuthState } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
|
||||
const PROVIDER_ID = "openrouter";
|
||||
export const OPENROUTER_OAUTH_METHOD_ID = "oauth";
|
||||
export const OPENROUTER_OAUTH_CHOICE_ID = "openrouter-oauth";
|
||||
export const OPENROUTER_OAUTH_AUTHORIZE_URL = "https://openrouter.ai/auth";
|
||||
export const OPENROUTER_OAUTH_TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
|
||||
export const OPENROUTER_OAUTH_CALLBACK_HOST = "localhost";
|
||||
export const OPENROUTER_OAUTH_CALLBACK_PORT = 3000;
|
||||
export const OPENROUTER_OAUTH_CALLBACK_PATH = "/openrouter-oauth/callback";
|
||||
export const OPENROUTER_OAUTH_REDIRECT_URI = `http://${OPENROUTER_OAUTH_CALLBACK_HOST}:${OPENROUTER_OAUTH_CALLBACK_PORT}${OPENROUTER_OAUTH_CALLBACK_PATH}`;
|
||||
export const OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD = "S256";
|
||||
|
||||
const OPENROUTER_OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const OPENROUTER_OAUTH_FETCH_TIMEOUT_MS = 30 * 1000;
|
||||
const OPENROUTER_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
const OPENROUTER_OAUTH_PROFILE_ID = "openrouter:default";
|
||||
|
||||
type OpenRouterOAuthCallbackResult = {
|
||||
code: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
type OpenRouterOAuthKeyResult = {
|
||||
key: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
type OpenRouterOAuthLoginOptions = {
|
||||
createPkce?: () => { verifier: string; challenge: string };
|
||||
createState?: () => string;
|
||||
fetchImpl?: typeof fetch;
|
||||
waitForCallback?: typeof waitForOpenRouterOAuthCallback;
|
||||
};
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function extractOpenRouterError(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const direct = readString(value.message) ?? readString(value.error_description);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const error = value.error;
|
||||
if (typeof error === "string") {
|
||||
return error.trim() || undefined;
|
||||
}
|
||||
if (isRecord(error)) {
|
||||
return readString(error.message) ?? readString(error.code);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readResponseBody(response: Response): Promise<unknown> {
|
||||
const text = response.ok
|
||||
? await response.text()
|
||||
: await readResponseTextLimited(response, OPENROUTER_OAUTH_ERROR_BODY_LIMIT_BYTES).catch(
|
||||
() => "",
|
||||
);
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOpenRouterKeyResponse(value: unknown): OpenRouterOAuthKeyResult {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("OpenRouter OAuth key exchange returned an unexpected response.");
|
||||
}
|
||||
const key = readString(value.key);
|
||||
if (!key) {
|
||||
throw new Error("OpenRouter OAuth key exchange returned no API key.");
|
||||
}
|
||||
const userId = readString(value.user_id) ?? readString(value.userId);
|
||||
return {
|
||||
key,
|
||||
...(userId ? { userId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenRouterOAuthRedirectUri(params: { state: string }): string {
|
||||
const url = new URL(OPENROUTER_OAUTH_REDIRECT_URI);
|
||||
url.searchParams.set("state", params.state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildOpenRouterOAuthAuthorizeUrl(params: {
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
}): string {
|
||||
const url = new URL(OPENROUTER_OAUTH_AUTHORIZE_URL);
|
||||
url.searchParams.set("callback_url", buildOpenRouterOAuthRedirectUri({ state: params.state }));
|
||||
url.searchParams.set("code_challenge", params.codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function requireOpenRouterOAuthState(state: string | undefined, expectedState: string): string {
|
||||
if (!state) {
|
||||
throw new Error("Missing OpenRouter OAuth state. Paste the full redirect URL.");
|
||||
}
|
||||
if (state !== expectedState) {
|
||||
throw new Error("OpenRouter OAuth state mismatch. Please retry login.");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function parseOpenRouterOAuthCallbackInput(
|
||||
input: string,
|
||||
expectedState: string,
|
||||
): OpenRouterOAuthCallbackResult {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("No input provided.");
|
||||
}
|
||||
|
||||
const parseParams = (params: URLSearchParams): OpenRouterOAuthCallbackResult => {
|
||||
const code = readString(params.get("code"));
|
||||
if (!code) {
|
||||
throw new Error("Missing 'code' parameter in redirect URL.");
|
||||
}
|
||||
const state = requireOpenRouterOAuthState(readString(params.get("state")), expectedState);
|
||||
return { code, state };
|
||||
};
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return parseParams(url.searchParams);
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
if (trimmed.includes("code=")) {
|
||||
return parseParams(new URLSearchParams(trimmed));
|
||||
}
|
||||
throw new Error("Paste the full OpenRouter redirect URL, not just the code.", {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function exchangeOpenRouterOAuthCode(params: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<OpenRouterOAuthKeyResult> {
|
||||
const fetchImpl = params.fetchImpl ?? fetch;
|
||||
const response = await fetchImpl(OPENROUTER_OAUTH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: params.code,
|
||||
code_verifier: params.codeVerifier,
|
||||
code_challenge_method: OPENROUTER_OAUTH_CODE_CHALLENGE_METHOD,
|
||||
}),
|
||||
signal: AbortSignal.timeout(OPENROUTER_OAUTH_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
const body = await readResponseBody(response);
|
||||
if (!response.ok) {
|
||||
const message = extractOpenRouterError(body);
|
||||
throw new Error(
|
||||
`OpenRouter OAuth key exchange failed (${response.status})${message ? `: ${message}` : ""}`,
|
||||
);
|
||||
}
|
||||
return parseOpenRouterKeyResponse(body);
|
||||
}
|
||||
|
||||
export async function waitForOpenRouterOAuthCallback(params: {
|
||||
expectedState: string;
|
||||
timeoutMs?: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<OpenRouterOAuthCallbackResult> {
|
||||
const timeoutMs = params.timeoutMs ?? OPENROUTER_OAUTH_TIMEOUT_MS;
|
||||
return new Promise<OpenRouterOAuthCallbackResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
finish(new Error("OpenRouter OAuth callback timeout"));
|
||||
}, timeoutMs);
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(
|
||||
req.url ?? "/",
|
||||
`http://${OPENROUTER_OAUTH_CALLBACK_HOST}:${OPENROUTER_OAUTH_CALLBACK_PORT}`,
|
||||
);
|
||||
if (requestUrl.pathname !== OPENROUTER_OAUTH_CALLBACK_PATH) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
if (req.method !== "GET") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "GET");
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const error = readString(requestUrl.searchParams.get("error"));
|
||||
if (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`OpenRouter authentication failed: ${error}`);
|
||||
finish(new Error(`OpenRouter OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const code = readString(requestUrl.searchParams.get("code"));
|
||||
if (!code) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Missing OAuth code");
|
||||
finish(new Error("Missing OpenRouter OAuth code"));
|
||||
return;
|
||||
}
|
||||
const state = readString(requestUrl.searchParams.get("state"));
|
||||
try {
|
||||
requireOpenRouterOAuthState(state, params.expectedState);
|
||||
} catch (err) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Invalid OAuth state");
|
||||
finish(err instanceof Error ? err : new Error("OpenRouter OAuth state mismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(
|
||||
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
|
||||
"<body><h2>OpenRouter OAuth complete</h2>" +
|
||||
"<p>You can close this window and return to OpenClaw.</p></body></html>",
|
||||
);
|
||||
finish(undefined, { code, state: params.expectedState });
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error("OpenRouter OAuth callback failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const finish = (err?: Error, result?: OpenRouterOAuthCallbackResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// Best-effort cleanup after callback completion or timeout.
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
server.once("error", (err) => {
|
||||
finish(err instanceof Error ? err : new Error("OpenRouter OAuth callback server error"));
|
||||
});
|
||||
server.listen(OPENROUTER_OAUTH_CALLBACK_PORT, OPENROUTER_OAUTH_CALLBACK_HOST, () => {
|
||||
params.onProgress?.(
|
||||
`Waiting for OpenRouter OAuth callback on ${OPENROUTER_OAUTH_REDIRECT_URI}...`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function promptForOpenRouterRedirect(
|
||||
ctx: ProviderAuthContext,
|
||||
expectedState: string,
|
||||
): Promise<string> {
|
||||
const input = await ctx.prompter.text({
|
||||
message: "Paste the OpenRouter redirect URL",
|
||||
placeholder: `${OPENROUTER_OAUTH_REDIRECT_URI}?state=...&code=...`,
|
||||
validate: (value: string) => (value.trim().length > 0 ? undefined : "Required"),
|
||||
});
|
||||
return parseOpenRouterOAuthCallbackInput(input, expectedState).code;
|
||||
}
|
||||
|
||||
async function resolveOpenRouterOAuthCode(
|
||||
ctx: ProviderAuthContext,
|
||||
params: {
|
||||
authorizeUrl: string;
|
||||
state: string;
|
||||
waitForCallback: typeof waitForOpenRouterOAuthCallback;
|
||||
onProgress: (message: string) => void;
|
||||
},
|
||||
): Promise<string> {
|
||||
await ctx.prompter.note(
|
||||
ctx.isRemote
|
||||
? [
|
||||
"Open this URL in your LOCAL browser.",
|
||||
"After signing in, paste the redirect URL back here.",
|
||||
"",
|
||||
`Redirect URI: ${OPENROUTER_OAUTH_REDIRECT_URI}`,
|
||||
].join("\n")
|
||||
: [
|
||||
"Browser will open for OpenRouter authentication.",
|
||||
"If the callback does not auto-complete, paste the redirect URL.",
|
||||
"",
|
||||
`Redirect URI: ${OPENROUTER_OAUTH_REDIRECT_URI}`,
|
||||
].join("\n"),
|
||||
"OpenRouter OAuth",
|
||||
);
|
||||
|
||||
if (ctx.isRemote) {
|
||||
ctx.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${params.authorizeUrl}\n`);
|
||||
return await promptForOpenRouterRedirect(ctx, params.state);
|
||||
}
|
||||
|
||||
const callbackPromise = params
|
||||
.waitForCallback({ expectedState: params.state, onProgress: params.onProgress })
|
||||
.catch(async () => {
|
||||
params.onProgress("OAuth callback not detected; waiting for redirect URL...");
|
||||
return { code: await promptForOpenRouterRedirect(ctx, params.state), state: params.state };
|
||||
});
|
||||
void callbackPromise.catch(() => undefined);
|
||||
|
||||
try {
|
||||
await ctx.openUrl(params.authorizeUrl);
|
||||
ctx.runtime.log(`Open: ${params.authorizeUrl}`);
|
||||
} catch {
|
||||
ctx.runtime.log(`Open manually: ${params.authorizeUrl}`);
|
||||
}
|
||||
|
||||
return (await callbackPromise).code;
|
||||
}
|
||||
|
||||
export async function loginOpenRouterOAuth(
|
||||
ctx: ProviderAuthContext,
|
||||
options: OpenRouterOAuthLoginOptions = {},
|
||||
): Promise<ProviderAuthResult> {
|
||||
const progress = ctx.prompter.progress("Starting OpenRouter OAuth...");
|
||||
try {
|
||||
const pkce = options.createPkce?.() ?? generatePkceVerifierChallenge();
|
||||
const state = options.createState?.() ?? generateOAuthState();
|
||||
const authorizeUrl = buildOpenRouterOAuthAuthorizeUrl({
|
||||
codeChallenge: pkce.challenge,
|
||||
state,
|
||||
});
|
||||
const code = await resolveOpenRouterOAuthCode(ctx, {
|
||||
authorizeUrl,
|
||||
state,
|
||||
waitForCallback: options.waitForCallback ?? waitForOpenRouterOAuthCallback,
|
||||
onProgress: (message) => progress.update(message),
|
||||
});
|
||||
progress.update("Exchanging OpenRouter OAuth code...");
|
||||
const token = await exchangeOpenRouterOAuthCode({
|
||||
code,
|
||||
codeVerifier: pkce.verifier,
|
||||
fetchImpl: options.fetchImpl,
|
||||
});
|
||||
progress.stop("OpenRouter OAuth complete");
|
||||
|
||||
const metadata = {
|
||||
authFlow: "oauth-pkce",
|
||||
...(token.userId ? { userId: token.userId } : {}),
|
||||
};
|
||||
const credential = {
|
||||
...buildApiKeyCredential(PROVIDER_ID, token.key, metadata),
|
||||
displayName: token.userId ? `OpenRouter ${token.userId}` : "OpenRouter OAuth",
|
||||
};
|
||||
|
||||
return {
|
||||
profiles: [{ profileId: OPENROUTER_OAUTH_PROFILE_ID, credential }],
|
||||
configPatch: applyOpenrouterConfig(ctx.config),
|
||||
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
|
||||
notes: [
|
||||
"OpenRouter OAuth issued an OpenRouter API key and stored it in the default OpenRouter auth profile.",
|
||||
"Re-run OpenRouter OAuth to rotate that key or use the API-key setup path for a key you manage manually.",
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
progress.stop("OpenRouter OAuth failed");
|
||||
throw new Error(`OpenRouter OAuth failed: ${formatErrorMessage(err)}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
export function createOpenRouterOAuthAuthMethod(): ProviderAuthMethod {
|
||||
return {
|
||||
id: OPENROUTER_OAUTH_METHOD_ID,
|
||||
label: "OpenRouter OAuth",
|
||||
hint: "Browser sign-in",
|
||||
kind: "oauth",
|
||||
wizard: {
|
||||
choiceId: OPENROUTER_OAUTH_CHOICE_ID,
|
||||
choiceLabel: "OpenRouter OAuth",
|
||||
choiceHint: "Browser sign-in",
|
||||
groupId: PROVIDER_ID,
|
||||
groupLabel: "OpenRouter",
|
||||
groupHint: "OAuth or API key",
|
||||
methodId: OPENROUTER_OAUTH_METHOD_ID,
|
||||
onboardingScopes: ["text-inference", "music-generation"],
|
||||
onboardingFeatured: true,
|
||||
},
|
||||
run: async (ctx) => await loginOpenRouterOAuth(ctx),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user