UNPKG

@earendil-works/pi-ai

Version:

Unified LLM API with automatic model discovery and provider configuration

257 lines • 9.94 kB
/** * Anthropic OAuth flow (Claude Pro/Max) * * NOTE: This module uses Node.js http.createServer for the OAuth callback server. * It is only intended for CLI use, not browser environments. */ import { getProviderEnvValue } from "../../utils/provider-env.js"; import { startOAuthCallbackServer, waitForCallbackOrManualInput } from "./callback-server.js"; import { generatePKCE } from "./pkce.js"; const decode = (s) => atob(s); const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1"; // Preferred so the port can be forwarded into containers or over SSH. Anthropic accepts any loopback port, // so login falls back to a free port when this one cannot be bound (#10571). const CALLBACK_PORT = 53692; const CALLBACK_PATH = "/callback"; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; const COPY_CODE_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; const ANTHROPIC_BROWSER_LOGIN_METHOD = "browser"; const ANTHROPIC_COPY_CODE_LOGIN_METHOD = "copy_code"; const SCOPES = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; function parseAuthorizationInput(input) { const value = input.trim(); if (!value) return {}; try { const url = new URL(value); return { code: url.searchParams.get("code") ?? undefined, state: url.searchParams.get("state") ?? undefined, }; } catch { // not a URL } if (value.includes("#")) { const [code, state] = value.split("#", 2); return { code, state }; } if (value.includes("code=")) { const params = new URLSearchParams(value); return { code: params.get("code") ?? undefined, state: params.get("state") ?? undefined, }; } return { code: value }; } function formatErrorDetails(error) { if (error instanceof Error) { const details = [`${error.name}: ${error.message}`]; const errorWithCode = error; if (errorWithCode.code) details.push(`code=${errorWithCode.code}`); if (typeof errorWithCode.errno !== "undefined") details.push(`errno=${String(errorWithCode.errno)}`); if (typeof error.cause !== "undefined") { details.push(`cause=${formatErrorDetails(error.cause)}`); } if (error.stack) { details.push(`stack=${error.stack}`); } return details.join("; "); } return String(error); } async function postJson(url, body, signal) { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify(body), signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]), }); const responseBody = await response.text(); if (!response.ok) { throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`); } return responseBody; } async function exchangeAuthorizationCode(code, state, verifier, redirectUri, signal) { let responseBody; try { responseBody = await postJson(TOKEN_URL, { grant_type: "authorization_code", client_id: CLIENT_ID, code, state, redirect_uri: redirectUri, code_verifier: verifier, }, signal); } catch (error) { throw new Error(`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`); } let tokenData; try { tokenData = JSON.parse(responseBody); } catch (error) { throw new Error(`Token exchange returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`); } return { type: "oauth", refresh: tokenData.refresh_token, access: tokenData.access_token, expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000, }; } async function loginAnthropic(interaction) { const { verifier, challenge } = await generatePKCE(); const startCallbackServer = (port) => startOAuthCallbackServer({ providerName: "Anthropic", host: CALLBACK_HOST, port, path: CALLBACK_PATH, redirectHost: "localhost", state: verifier, complete: async (code) => code, signal: interaction.signal, }); // Without a callback server, login continues with the pasted redirect URL. const callback = await startCallbackServer(CALLBACK_PORT) .catch(() => startCallbackServer(0)) .catch(() => undefined); const redirectUri = callback?.redirectUri ?? REDIRECT_URI; try { const authParams = new URLSearchParams({ code: "true", client_id: CLIENT_ID, response_type: "code", redirect_uri: redirectUri, scope: SCOPES, code_challenge: challenge, code_challenge_method: "S256", state: verifier, }); interaction.notify({ type: "auth_url", url: `${AUTHORIZE_URL}?${authParams.toString()}`, instructions: "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.", }); const result = await waitForCallbackOrManualInput(interaction, callback, { message: "Complete login in your browser, or paste the authorization code / redirect URL here:", placeholder: redirectUri, }); let code; let state = verifier; if (result.type === "callback") { code = result.value; } else { const parsed = parseAuthorizationInput(result.input); if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch"); code = parsed.code; state = parsed.state ?? verifier; } if (!code) throw new Error("Missing authorization code"); interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." }); return await exchangeAuthorizationCode(code, state, verifier, redirectUri, interaction.signal); } finally { callback?.close(); } } async function loginAnthropicCopyCode(interaction) { const { verifier, challenge } = await generatePKCE(); const authParams = new URLSearchParams({ code: "true", client_id: CLIENT_ID, response_type: "code", redirect_uri: COPY_CODE_REDIRECT_URI, scope: SCOPES, code_challenge: challenge, code_challenge_method: "S256", state: verifier, }); interaction.notify({ type: "auth_url", url: `${AUTHORIZE_URL}?${authParams.toString()}`, instructions: "Complete login in your browser, then copy the code Anthropic shows and paste it here.", }); const input = await interaction.prompt({ type: "manual_code", message: "Paste the code Anthropic shows after you sign in:", placeholder: "code#state", signal: interaction.signal, }); const parsed = parseAuthorizationInput(input); if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch"); if (!parsed.code) throw new Error("Missing authorization code"); interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." }); return await exchangeAuthorizationCode(parsed.code, parsed.state ?? verifier, verifier, COPY_CODE_REDIRECT_URI, interaction.signal); } /** * Refresh Anthropic OAuth token */ async function refreshAnthropicToken(refreshToken, signal) { let responseBody; try { responseBody = await postJson(TOKEN_URL, { grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken, }, signal); } catch (error) { throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`); } let data; try { data = JSON.parse(responseBody); } catch (error) { throw new Error(`Anthropic token refresh returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`); } return { type: "oauth", refresh: data.refresh_token, access: data.access_token, expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, }; } export const anthropicOAuth = { name: "Anthropic (Claude Pro/Max)", isSubscription: true, async login(interaction) { const method = await interaction.prompt({ type: "select", message: "Select Anthropic login method:", options: [ { id: ANTHROPIC_BROWSER_LOGIN_METHOD, label: "Browser login (default)" }, { id: ANTHROPIC_COPY_CODE_LOGIN_METHOD, label: "Copy code login (headless)" }, ], }); if (method === ANTHROPIC_COPY_CODE_LOGIN_METHOD) { return loginAnthropicCopyCode(interaction); } if (method !== ANTHROPIC_BROWSER_LOGIN_METHOD) { throw new Error(`Unknown Anthropic login method: ${method}`); } return loginAnthropic(interaction); }, refresh: (credential, signal) => refreshAnthropicToken(credential.refresh, signal), async toAuth(credential) { return { apiKey: credential.access }; }, }; //# sourceMappingURL=anthropic.js.map