From 049694f579c84c0b77a75d742b88fef96d56c668 Mon Sep 17 00:00:00 2001 From: Jason Barnett Date: Tue, 10 Feb 2026 16:11:46 -0700 Subject: [PATCH 1/5] feat: add Anthropic Claude Max/Pro OAuth support Allow users to authenticate with their Claude Max/Pro subscription instead of paying per-token via API key. Uses Anthropic's OAuth code-paste flow (PKCE + S256). The implementation mirrors the existing Codex (OpenAI ChatGPT) OAuth architecture: - New constants file with OAuth endpoints, client ID, URL builders - AnthropicOauthService: startFlow, submitCode, token refresh with mutex, auto-disconnect on invalid_grant - Provider model factory: OAuth routing decision, Bearer token injection, beta headers, tool name mcp_ prefixing/stripping - Settings UI: Connect button, code paste input, disconnect - Provider config: anthropicOauthSet flag, isConfigured override - IPC schema + router for the OAuth flow - Costs marked as included when using subscription billing Co-Authored-By: Claude Opus 4.6 --- .../Settings/sections/ProvidersSection.tsx | 206 ++++++++++ src/cli/cli.test.ts | 1 + src/cli/run.ts | 4 + src/cli/server.test.ts | 1 + src/common/constants/anthropicOAuth.ts | 75 ++++ src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/api.ts | 23 ++ src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 26 ++ src/node/services/aiService.ts | 4 + src/node/services/anthropicOauthService.ts | 366 ++++++++++++++++++ src/node/services/providerModelFactory.ts | 217 ++++++++++- src/node/services/providerService.ts | 15 + src/node/services/serviceContainer.ts | 10 + src/node/utils/anthropicOauthAuth.ts | 41 ++ tests/ipc/setup.ts | 1 + 16 files changed, 979 insertions(+), 14 deletions(-) create mode 100644 src/common/constants/anthropicOAuth.ts create mode 100644 src/node/services/anthropicOauthService.ts create mode 100644 src/node/utils/anthropicOauthAuth.ts diff --git a/src/browser/components/Settings/sections/ProvidersSection.tsx b/src/browser/components/Settings/sections/ProvidersSection.tsx index 46e557f203..8722e9558d 100644 --- a/src/browser/components/Settings/sections/ProvidersSection.tsx +++ b/src/browser/components/Settings/sections/ProvidersSection.tsx @@ -539,6 +539,119 @@ export function ProvidersSection() { setCodexOauthError(null); }; + // --- Anthropic OAuth (Claude Max/Pro subscription) --- + type AnthropicOauthFlowStatus = "idle" | "starting" | "waiting_for_code" | "submitting" | "error"; + const [anthropicOauthStatus, setAnthropicOauthStatus] = + useState("idle"); + const [anthropicOauthError, setAnthropicOauthError] = useState(null); + const [anthropicOauthFlowId, setAnthropicOauthFlowId] = useState(null); + const [anthropicOauthCodeInput, setAnthropicOauthCodeInput] = useState(""); + const anthropicOauthAttemptRef = useRef(0); + + const anthropicOauthIsConnected = config?.anthropic?.anthropicOauthSet === true; + const anthropicOauthInProgress = + anthropicOauthStatus === "starting" || + anthropicOauthStatus === "waiting_for_code" || + anthropicOauthStatus === "submitting"; + + const startAnthropicOauthConnect = async () => { + if (!api) return; + const attempt = ++anthropicOauthAttemptRef.current; + + if (anthropicOauthFlowId) { + void api.anthropicOauth.cancelFlow({ flowId: anthropicOauthFlowId }); + } + + setAnthropicOauthStatus("starting"); + setAnthropicOauthError(null); + setAnthropicOauthCodeInput(""); + + try { + const result = await api.anthropicOauth.startFlow(); + if (attempt !== anthropicOauthAttemptRef.current) return; + + if (!result.success) { + setAnthropicOauthStatus("error"); + setAnthropicOauthError(result.error); + return; + } + + setAnthropicOauthFlowId(result.data.flowId); + setAnthropicOauthStatus("waiting_for_code"); + window.open(result.data.authorizeUrl, "_blank"); + } catch (err) { + if (attempt !== anthropicOauthAttemptRef.current) return; + setAnthropicOauthStatus("error"); + setAnthropicOauthError(err instanceof Error ? err.message : String(err)); + } + }; + + const submitAnthropicOauthCode = async () => { + if (!api || !anthropicOauthFlowId || !anthropicOauthCodeInput.trim()) return; + const attempt = ++anthropicOauthAttemptRef.current; + + setAnthropicOauthStatus("submitting"); + + try { + const result = await api.anthropicOauth.submitCode({ + flowId: anthropicOauthFlowId, + code: anthropicOauthCodeInput.trim(), + }); + if (attempt !== anthropicOauthAttemptRef.current) return; + + if (!result.success) { + setAnthropicOauthStatus("error"); + setAnthropicOauthError(result.error); + return; + } + + updateOptimistically("anthropic", { anthropicOauthSet: true }); + setAnthropicOauthStatus("idle"); + setAnthropicOauthCodeInput(""); + setAnthropicOauthFlowId(null); + await refresh(); + } catch (err) { + if (attempt !== anthropicOauthAttemptRef.current) return; + setAnthropicOauthStatus("error"); + setAnthropicOauthError(err instanceof Error ? err.message : String(err)); + } + }; + + const disconnectAnthropicOauth = async () => { + if (!api) return; + const attempt = ++anthropicOauthAttemptRef.current; + + try { + const result = await api.anthropicOauth.disconnect(); + if (attempt !== anthropicOauthAttemptRef.current) return; + + if (!result.success) { + setAnthropicOauthStatus("error"); + setAnthropicOauthError(result.error); + return; + } + + updateOptimistically("anthropic", { anthropicOauthSet: false }); + setAnthropicOauthStatus("idle"); + await refresh(); + } catch (err) { + if (attempt !== anthropicOauthAttemptRef.current) return; + setAnthropicOauthStatus("error"); + setAnthropicOauthError(err instanceof Error ? err.message : String(err)); + } + }; + + const cancelAnthropicOauth = () => { + anthropicOauthAttemptRef.current++; + if (anthropicOauthFlowId && api) { + void api.anthropicOauth.cancelFlow({ flowId: anthropicOauthFlowId }); + } + setAnthropicOauthStatus("idle"); + setAnthropicOauthFlowId(null); + setAnthropicOauthCodeInput(""); + setAnthropicOauthError(null); + }; + const [muxGatewayLoginError, setMuxGatewayLoginError] = useState(null); const muxGatewayApplyDefaultModelsOnSuccessRef = useRef(false); @@ -1415,6 +1528,99 @@ export function ProvidersSection() { ); })} + {/* Anthropic: Claude Max/Pro OAuth */} + {provider === "anthropic" && ( +
+
+ + + {anthropicOauthStatus === "starting" + ? "Starting..." + : anthropicOauthStatus === "waiting_for_code" + ? "Waiting for authorization code..." + : anthropicOauthStatus === "submitting" + ? "Submitting..." + : anthropicOauthIsConnected + ? "Connected" + : "Not connected"} + +
+ +
+ + + {anthropicOauthInProgress && ( + + )} + + {anthropicOauthIsConnected && ( + + )} +
+ + {anthropicOauthStatus === "waiting_for_code" && ( +
+

+ Authorize in the browser, then paste the code below: +

+
+ setAnthropicOauthCodeInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + void submitAnthropicOauthCode(); + } + }} + placeholder="Paste authorization code here" + className="border-border bg-background text-foreground flex-1 rounded border px-2 py-1 text-sm" + autoFocus + /> + +
+
+ )} + + {anthropicOauthStatus === "error" && anthropicOauthError && ( +

{anthropicOauthError}

+ )} + +

+ Claude Max/Pro OAuth uses subscription billing (costs included). +

+
+ )} + {/* OpenAI: ChatGPT OAuth + service tier */} {provider === "openai" && (
diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index cf06d17140..acd4310076 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -64,6 +64,7 @@ async function createTestServer(authToken?: string): Promise { muxGatewayOauthService: services.muxGatewayOauthService, muxGovernorOauthService: services.muxGovernorOauthService, codexOauthService: services.codexOauthService, + anthropicOauthService: services.anthropicOauthService, copilotOauthService: services.copilotOauthService, taskService: services.taskService, providerService: services.providerService, diff --git a/src/cli/run.ts b/src/cli/run.ts index 90b72ae874..380d13c482 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -18,6 +18,7 @@ import { Config } from "@/node/config"; import { DisposableTempDir } from "@/node/services/tempDir"; import { AgentSession, type AgentSessionChatEvent } from "@/node/services/agentSession"; import { CodexOauthService } from "@/node/services/codexOauthService"; +import { AnthropicOauthService } from "@/node/services/anthropicOauthService"; import { createCoreServices } from "@/node/services/coreServices"; import { isCaughtUpMessage, @@ -448,6 +449,9 @@ async function main(): Promise { const codexOauthService = new CodexOauthService(config, providerService); aiService.setCodexOauthService(codexOauthService); + const anthropicOauthService = new AnthropicOauthService(config, providerService); + aiService.setAnthropicOauthService(anthropicOauthService); + // CLI-only exit code control: allows agent to set the process exit code // Useful for CI workflows where the agent should block merge on failure let agentExitCode: number | undefined; diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index 4c2fa39845..1f6f8aa306 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -67,6 +67,7 @@ async function createTestServer(): Promise { muxGatewayOauthService: services.muxGatewayOauthService, muxGovernorOauthService: services.muxGovernorOauthService, codexOauthService: services.codexOauthService, + anthropicOauthService: services.anthropicOauthService, copilotOauthService: services.copilotOauthService, taskService: services.taskService, providerService: services.providerService, diff --git a/src/common/constants/anthropicOAuth.ts b/src/common/constants/anthropicOAuth.ts new file mode 100644 index 0000000000..e5054f92fb --- /dev/null +++ b/src/common/constants/anthropicOAuth.ts @@ -0,0 +1,75 @@ +/** + * Anthropic OAuth constants and helpers. + * + * Anthropic (Claude Max/Pro subscription) authentication uses OAuth tokens + * rather than a standard Anthropic API key. + * + * This module is intentionally shared (common/) so both the backend and + * UI can reference the same endpoints. + */ + +// Public OAuth client id for Claude Max/Pro flows. +export const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; + +export const ANTHROPIC_OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; +export const ANTHROPIC_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; + +// Redirect URI -- Anthropic's code-paste flow uses this fixed value. +// The server displays the auth code on this page for the user to copy. +export const ANTHROPIC_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; + +// Scopes needed for inference via subscription. +export const ANTHROPIC_OAUTH_SCOPE = "org:create_api_key user:profile user:inference"; + +// Beta header value required for OAuth-authed API requests. +export const ANTHROPIC_OAUTH_BETA_HEADER = "oauth-2025-04-20"; + +// Additional beta for interleaved thinking support. +export const ANTHROPIC_OAUTH_THINKING_BETA = "interleaved-thinking-2025-05-14"; + +// User-agent string to send with OAuth-authed requests. +export const ANTHROPIC_OAUTH_USER_AGENT = "claude-cli/2.1.2 (external, cli)"; + +// Tool name prefix required by Anthropic's OAuth API. +export const ANTHROPIC_OAUTH_TOOL_PREFIX = "mcp_"; + +export function buildAnthropicAuthorizeUrl(input: { + state: string; + codeChallenge: string; +}): string { + const url = new URL(ANTHROPIC_OAUTH_AUTHORIZE_URL); + // code=true tells the server to display a code for the user to copy/paste + // instead of performing a redirect to localhost. + url.searchParams.set("code", "true"); + url.searchParams.set("client_id", ANTHROPIC_OAUTH_CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", ANTHROPIC_OAUTH_REDIRECT_URI); + url.searchParams.set("scope", ANTHROPIC_OAUTH_SCOPE); + url.searchParams.set("code_challenge", input.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", input.state); + return url.toString(); +} + +export function buildAnthropicTokenExchangeBody(input: { + code: string; + state: string; + codeVerifier: string; +}): string { + return JSON.stringify({ + code: input.code, + state: input.state, + grant_type: "authorization_code", + client_id: ANTHROPIC_OAUTH_CLIENT_ID, + redirect_uri: ANTHROPIC_OAUTH_REDIRECT_URI, + code_verifier: input.codeVerifier, + }); +} + +export function buildAnthropicRefreshBody(input: { refreshToken: string }): string { + return JSON.stringify({ + grant_type: "refresh_token", + refresh_token: input.refreshToken, + client_id: ANTHROPIC_OAUTH_CLIENT_ID, + }); +} diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 223f4cd32d..8ecdda0f40 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -190,6 +190,7 @@ export { copilotOauth, muxGovernorOauth, codexOauth, + anthropicOauth, policy, providers, ProvidersConfigMapSchema, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index b6a384e505..f9e31ad5e2 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -138,6 +138,8 @@ export const ProviderConfigInfoSchema = z.object({ codexOauthDefaultAuth: z.enum(["oauth", "apiKey"]).optional(), /** AWS-specific fields (only present for bedrock provider) */ aws: AWSCredentialStatusSchema.optional(), + /** Anthropic-only: whether Anthropic OAuth tokens are present in providers.jsonc */ + anthropicOauthSet: z.boolean().optional(), /** Mux Gateway-specific fields */ couponCodeSet: z.boolean().optional(), }); @@ -326,6 +328,27 @@ export const codexOauth = { output: ResultSchema(z.void(), z.string()), }, }; + +// Anthropic OAuth (Claude Max/Pro subscription auth) +export const anthropicOauth = { + startFlow: { + input: z.void(), + output: ResultSchema(z.object({ flowId: z.string(), authorizeUrl: z.string() }), z.string()), + }, + submitCode: { + input: z.object({ flowId: z.string(), code: z.string() }).strict(), + output: ResultSchema(z.void(), z.string()), + }, + cancelFlow: { + input: z.object({ flowId: z.string() }).strict(), + output: z.void(), + }, + disconnect: { + input: z.void(), + output: ResultSchema(z.void(), z.string()), + }, +}; + // Mux Gateway export const muxGateway = { getAccountStatus: { diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 61eee9391d..7e36efbca3 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -6,6 +6,7 @@ import type { WorkspaceService } from "@/node/services/workspaceService"; import type { MuxGatewayOauthService } from "@/node/services/muxGatewayOauthService"; import type { MuxGovernorOauthService } from "@/node/services/muxGovernorOauthService"; import type { CodexOauthService } from "@/node/services/codexOauthService"; +import type { AnthropicOauthService } from "@/node/services/anthropicOauthService"; import type { CopilotOauthService } from "@/node/services/copilotOauthService"; import type { ProviderService } from "@/node/services/providerService"; import type { TerminalService } from "@/node/services/terminalService"; @@ -40,6 +41,7 @@ export interface ORPCContext { muxGatewayOauthService: MuxGatewayOauthService; muxGovernorOauthService: MuxGovernorOauthService; codexOauthService: CodexOauthService; + anthropicOauthService: AnthropicOauthService; copilotOauthService: CopilotOauthService; terminalService: TerminalService; editorService: EditorService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 05fc5b9733..a11205daf1 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1242,6 +1242,32 @@ export const router = (authToken?: string) => { return context.codexOauthService.disconnect(); }), }, + anthropicOauth: { + startFlow: t + .input(schemas.anthropicOauth.startFlow.input) + .output(schemas.anthropicOauth.startFlow.output) + .handler(({ context }) => { + return context.anthropicOauthService.startFlow(); + }), + submitCode: t + .input(schemas.anthropicOauth.submitCode.input) + .output(schemas.anthropicOauth.submitCode.output) + .handler(({ context, input }) => { + return context.anthropicOauthService.submitCode(input); + }), + cancelFlow: t + .input(schemas.anthropicOauth.cancelFlow.input) + .output(schemas.anthropicOauth.cancelFlow.output) + .handler(({ context, input }) => { + context.anthropicOauthService.cancelFlow(input.flowId); + }), + disconnect: t + .input(schemas.anthropicOauth.disconnect.input) + .output(schemas.anthropicOauth.disconnect.output) + .handler(({ context }) => { + return context.anthropicOauthService.disconnect(); + }), + }, general: { listDirectory: t .input(schemas.general.listDirectory.input) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 39c91008b7..621be2fa4a 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -26,6 +26,7 @@ import type { MuxProviderOptions } from "@/common/types/providerOptions"; import type { PolicyService } from "@/node/services/policyService"; import type { ProviderService } from "@/node/services/providerService"; import type { CodexOauthService } from "@/node/services/codexOauthService"; +import type { AnthropicOauthService } from "@/node/services/anthropicOauthService"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { FileState, EditedFileAttachment } from "@/node/services/agentSession"; import { log } from "./log"; @@ -172,6 +173,9 @@ export class AIService extends EventEmitter { setCodexOauthService(service: CodexOauthService): void { this.providerModelFactory.codexOauthService = service; } + setAnthropicOauthService(service: AnthropicOauthService): void { + this.providerModelFactory.anthropicOauthService = service; + } setMCPServerManager(manager: MCPServerManager): void { this.mcpServerManager = manager; this.streamManager.setMCPServerManager(manager); diff --git a/src/node/services/anthropicOauthService.ts b/src/node/services/anthropicOauthService.ts new file mode 100644 index 0000000000..4afc0552d3 --- /dev/null +++ b/src/node/services/anthropicOauthService.ts @@ -0,0 +1,366 @@ +/** + * Anthropic OAuth service for Claude Max/Pro subscription authentication. + * + * Uses a code-paste flow: user opens an auth URL, authorizes, and pastes + * back the displayed code. Simpler than the Codex OAuth flow (no local + * HTTP server, no device flow). + */ + +import * as crypto from "crypto"; +import type { Result } from "@/common/types/result"; +import { Err, Ok } from "@/common/types/result"; +import { + buildAnthropicAuthorizeUrl, + buildAnthropicTokenExchangeBody, + buildAnthropicRefreshBody, + ANTHROPIC_OAUTH_TOKEN_URL, +} from "@/common/constants/anthropicOAuth"; +import type { Config } from "@/node/config"; +import type { ProviderService } from "@/node/services/providerService"; +import type { WindowService } from "@/node/services/windowService"; +import { log } from "@/node/services/log"; +import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; +import { + isAnthropicOauthAuthExpired, + parseAnthropicOauthAuth, + type AnthropicOauthAuth, +} from "@/node/utils/anthropicOauthAuth"; + +const DEFAULT_FLOW_TIMEOUT_MS = 5 * 60 * 1000; + +interface PendingCodePasteFlow { + flowId: string; + /** Random state for CSRF validation. */ + state: string; + /** PKCE code verifier. */ + codeVerifier: string; + timeout: ReturnType; +} + +function sha256Base64Url(value: string): string { + return crypto.createHash("sha256").update(value).digest().toString("base64url"); +} + +function randomBase64Url(bytes = 32): string { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function parseOptionalNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function isInvalidGrantError(errorText: string): boolean { + const trimmed = errorText.trim(); + if (trimmed.length === 0) return false; + + try { + const json = JSON.parse(trimmed) as unknown; + if (isPlainObject(json) && json.error === "invalid_grant") { + return true; + } + } catch { + // Fall through to substring check. + } + + const lower = trimmed.toLowerCase(); + return lower.includes("invalid_grant") || lower.includes("revoked"); +} + +export class AnthropicOauthService { + private pendingFlow: PendingCodePasteFlow | null = null; + private readonly refreshMutex = new AsyncMutex(); + + // In-memory cache so getValidAuth() skips disk reads when tokens are valid. + // Invalidated on every write (exchange, refresh, disconnect). + private cachedAuth: AnthropicOauthAuth | null = null; + + constructor( + private readonly config: Config, + private readonly providerService: ProviderService, + private readonly windowService?: WindowService + ) {} + + /** + * Start the OAuth code-paste flow: generate PKCE, build authorize URL. + * The frontend opens the URL in the browser and shows a code input field. + */ + startFlow(): Result<{ flowId: string; authorizeUrl: string }, string> { + // Cancel any existing pending flow. + if (this.pendingFlow) { + clearTimeout(this.pendingFlow.timeout); + this.pendingFlow = null; + } + + const flowId = randomBase64Url(); + const state = randomBase64Url(); + const codeVerifier = randomBase64Url(); + const codeChallenge = sha256Base64Url(codeVerifier); + + const authorizeUrl = buildAnthropicAuthorizeUrl({ state, codeChallenge }); + + const timeout = setTimeout(() => { + if (this.pendingFlow?.flowId === flowId) { + log.debug(`[Anthropic OAuth] Flow timed out (flowId=${flowId})`); + this.pendingFlow = null; + } + }, DEFAULT_FLOW_TIMEOUT_MS); + + this.pendingFlow = { flowId, state, codeVerifier, timeout }; + + log.debug(`[Anthropic OAuth] Flow started (flowId=${flowId})`); + return Ok({ flowId, authorizeUrl }); + } + + /** + * Submit the pasted authorization code from the user. + * Expected format: "code#state" (as displayed by Anthropic's callback page). + */ + async submitCode(input: { flowId: string; code: string }): Promise> { + const flow = this.pendingFlow; + if (!flow || flow.flowId !== input.flowId) { + return Err("No pending OAuth flow with that ID"); + } + + // Parse "code#state" format + const hashIndex = input.code.indexOf("#"); + if (hashIndex === -1) { + return Err("Invalid authorization code format (expected code#state)"); + } + + const code = input.code.slice(0, hashIndex); + const state = input.code.slice(hashIndex + 1); + + if (!code) { + return Err("Authorization code is empty"); + } + + // Clear the pending flow before exchange (one-shot). + clearTimeout(flow.timeout); + this.pendingFlow = null; + + const exchangeResult = await this.exchangeCodeForTokens({ + code, + state, + codeVerifier: flow.codeVerifier, + }); + + if (!exchangeResult.success) { + return Err(exchangeResult.error); + } + + const persistResult = this.persistAuth(exchangeResult.data); + if (!persistResult.success) { + return Err(persistResult.error); + } + + log.debug("[Anthropic OAuth] Successfully connected"); + + // Focus the main window so the user sees the updated settings. + this.windowService?.focusMainWindow(); + + return Ok(undefined); + } + + cancelFlow(flowId: string): void { + if (this.pendingFlow?.flowId === flowId) { + clearTimeout(this.pendingFlow.timeout); + this.pendingFlow = null; + log.debug(`[Anthropic OAuth] Flow cancelled (flowId=${flowId})`); + } + } + + disconnect(): Result { + this.cachedAuth = null; + return this.providerService.setConfigValue("anthropic", ["anthropicOauth"], undefined); + } + + async getValidAuth(): Promise> { + const stored = this.readStoredAuth(); + if (!stored) { + return Err("Anthropic OAuth is not configured"); + } + + if (!isAnthropicOauthAuthExpired(stored)) { + return Ok(stored); + } + + await using _lock = await this.refreshMutex.acquire(); + + // Re-read after acquiring lock in case another caller refreshed first. + const latest = this.readStoredAuth(); + if (!latest) { + return Err("Anthropic OAuth is not configured"); + } + + if (!isAnthropicOauthAuthExpired(latest)) { + return Ok(latest); + } + + const refreshed = await this.refreshTokens(latest); + if (!refreshed.success) { + return Err(refreshed.error); + } + + return Ok(refreshed.data); + } + + dispose(): void { + if (this.pendingFlow) { + clearTimeout(this.pendingFlow.timeout); + this.pendingFlow = null; + } + } + + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + + private readStoredAuth(): AnthropicOauthAuth | null { + if (this.cachedAuth) { + return this.cachedAuth; + } + const providersConfig = this.config.loadProvidersConfig() ?? {}; + const anthropicConfig = providersConfig.anthropic as Record | undefined; + const auth = parseAnthropicOauthAuth(anthropicConfig?.anthropicOauth); + this.cachedAuth = auth; + return auth; + } + + private persistAuth(auth: AnthropicOauthAuth): Result { + const result = this.providerService.setConfigValue("anthropic", ["anthropicOauth"], auth); + // Invalidate cache so the next read picks up the persisted value from disk. + this.cachedAuth = null; + return result; + } + + private async exchangeCodeForTokens(input: { + code: string; + state: string; + codeVerifier: string; + }): Promise> { + try { + const response = await fetch(ANTHROPIC_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: buildAnthropicTokenExchangeBody({ + code: input.code, + state: input.state, + codeVerifier: input.codeVerifier, + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + const prefix = `Anthropic OAuth exchange failed (${response.status})`; + return Err(errorText ? `${prefix}: ${errorText}` : prefix); + } + + const json = (await response.json()) as unknown; + if (!isPlainObject(json)) { + return Err("Anthropic OAuth exchange returned an invalid JSON payload"); + } + + const accessToken = typeof json.access_token === "string" ? json.access_token : null; + const refreshToken = typeof json.refresh_token === "string" ? json.refresh_token : null; + const expiresIn = parseOptionalNumber(json.expires_in); + + if (!accessToken) { + return Err("Anthropic OAuth exchange response missing access_token"); + } + + if (!refreshToken) { + return Err("Anthropic OAuth exchange response missing refresh_token"); + } + + if (expiresIn === null) { + return Err("Anthropic OAuth exchange response missing expires_in"); + } + + return Ok({ + type: "oauth", + access: accessToken, + refresh: refreshToken, + expires: Date.now() + Math.max(0, Math.floor(expiresIn * 1000)), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Anthropic OAuth exchange failed: ${message}`); + } + } + + private async refreshTokens( + current: AnthropicOauthAuth + ): Promise> { + try { + const response = await fetch(ANTHROPIC_OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: buildAnthropicRefreshBody({ refreshToken: current.refresh }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + + // When the refresh token is invalid/revoked, clear persisted auth so + // subsequent requests fall back to "not connected" behavior. + if (isInvalidGrantError(errorText)) { + log.debug("[Anthropic OAuth] Refresh token rejected; clearing stored auth"); + const disconnectResult = this.disconnect(); + if (!disconnectResult.success) { + log.warn( + `[Anthropic OAuth] Failed to clear stored auth after refresh failure: ${disconnectResult.error}` + ); + } + } + + const prefix = `Anthropic OAuth refresh failed (${response.status})`; + return Err(errorText ? `${prefix}: ${errorText}` : prefix); + } + + const json = (await response.json()) as unknown; + if (!isPlainObject(json)) { + return Err("Anthropic OAuth refresh returned an invalid JSON payload"); + } + + const accessToken = typeof json.access_token === "string" ? json.access_token : null; + const refreshToken = typeof json.refresh_token === "string" ? json.refresh_token : null; + const expiresIn = parseOptionalNumber(json.expires_in); + + if (!accessToken) { + return Err("Anthropic OAuth refresh response missing access_token"); + } + + if (expiresIn === null) { + return Err("Anthropic OAuth refresh response missing expires_in"); + } + + const next: AnthropicOauthAuth = { + type: "oauth", + access: accessToken, + refresh: refreshToken ?? current.refresh, + expires: Date.now() + Math.max(0, Math.floor(expiresIn * 1000)), + }; + + const persistResult = this.persistAuth(next); + if (!persistResult.success) { + return Err(persistResult.error); + } + + return Ok(next); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Anthropic OAuth refresh failed: ${message}`); + } + } +} diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 4d0c001b0f..4d21afab29 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -17,6 +17,14 @@ import { isCodexOauthRequiredModelId, } from "@/common/constants/codexOAuth"; import { parseCodexOauthAuth } from "@/node/utils/codexOauthAuth"; +import { parseAnthropicOauthAuth } from "@/node/utils/anthropicOauthAuth"; +import type { AnthropicOauthService } from "@/node/services/anthropicOauthService"; +import { + ANTHROPIC_OAUTH_BETA_HEADER, + ANTHROPIC_OAUTH_THINKING_BETA, + ANTHROPIC_OAUTH_USER_AGENT, + ANTHROPIC_OAUTH_TOOL_PREFIX, +} from "@/common/constants/anthropicOAuth"; import type { Config, ProviderConfig } from "@/node/config"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import type { PolicyService } from "@/node/services/policyService"; @@ -86,6 +94,45 @@ if (typeof globalFetchWithExtras.certificate === "function") { globalFetchWithExtras.certificate.bind(globalFetchWithExtras); } +// --------------------------------------------------------------------------- +// Anthropic OAuth tool prefix stripping +// --------------------------------------------------------------------------- + +/** + * Strip the mcp_ prefix from tool names in Anthropic streaming responses. + * Anthropic's OAuth API requires tool names to be prefixed with "mcp_" in + * requests; we strip them from responses so the SDK sees the original names. + */ +function stripAnthropicOauthToolPrefix(response: Response): Response { + const body = response.body; + if (!body) return response; + + const reader = body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + + let text = decoder.decode(value, { stream: true }); + // Strip mcp_ prefix from tool name fields in SSE JSON payloads. + text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name":"$1"'); + controller.enqueue(encoder.encode(text)); + }, + }); + + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + // --------------------------------------------------------------------------- // Fetch wrappers // --------------------------------------------------------------------------- @@ -366,6 +413,7 @@ export class ProviderModelFactory { private readonly providerService: ProviderService; private readonly policyService?: PolicyService; codexOauthService?: CodexOauthService; + anthropicOauthService?: AnthropicOauthService; constructor( config: Config, @@ -459,16 +507,33 @@ export class ProviderModelFactory { // Handle Anthropic provider if (providerName === "anthropic") { - // Resolve credentials from config + env (single source of truth) const creds = resolveProviderCredentials("anthropic", providerConfig); - if (!creds.isConfigured) { + + // Check for stored Anthropic OAuth tokens (Claude Max/Pro subscription). + const storedAnthropicOauth = parseAnthropicOauthAuth( + (providerConfig as { anthropicOauth?: unknown }).anthropicOauth + ); + + // Route through OAuth when connected and no API key is set. + // When both are available, prefer the API key (user explicitly configured it). + const shouldRouteThroughAnthropicOauth = (() => { + if (!storedAnthropicOauth) return false; + if (!creds.isConfigured) return true; + return false; + })(); + + if (!shouldRouteThroughAnthropicOauth && !creds.isConfigured) { return Err({ type: "api_key_not_found", provider: providerName }); } - // Build config with resolved credentials - const configWithApiKey = creds.apiKey - ? { ...providerConfig, apiKey: creds.apiKey } - : providerConfig; + // Build config with resolved credentials. + // When using OAuth, pass a placeholder API key so the SDK never reads env vars. + const configWithApiKey = { + ...providerConfig, + apiKey: shouldRouteThroughAnthropicOauth + ? (creds.apiKey ?? "anthropic-oauth") + : creds.apiKey, + }; // Normalize base URL to ensure /v1 suffix (SDK expects it) const effectiveBaseURL = configWithApiKey.baseURL ?? creds.baseUrl?.trim(); @@ -477,7 +542,6 @@ export class ProviderModelFactory { : configWithApiKey; // Add 1M context beta header if requested and model supports it. - // Check both per-model list (use1MContextModels) and legacy global flag (use1MContext). const fullModelId = `anthropic:${modelId}`; const is1MEnabled = ((muxProviderOptions?.anthropic?.use1MContextModels?.includes(fullModelId) ?? false) || @@ -485,19 +549,144 @@ export class ProviderModelFactory { supports1MContext(fullModelId); const headers = buildAnthropicHeaders(normalizedConfig.headers, is1MEnabled); - // Lazy-load Anthropic provider to reduce startup time const { createAnthropic } = await PROVIDER_REGISTRY.anthropic(); - // Wrap fetch to inject cache_control on tools and messages - // (SDK doesn't translate providerOptions to cache_control for these) - // Use getProviderFetch to preserve any user-configured custom fetch (e.g., proxies) const baseFetch = getProviderFetch(providerConfig); - const fetchWithCacheControl = wrapFetchWithAnthropicCacheControl(baseFetch); + const anthropicOauthService = this.anthropicOauthService; + + let effectiveFetch: typeof fetch; + + if (shouldRouteThroughAnthropicOauth) { + // Wrap fetch for OAuth: inject Bearer token, beta headers, tool prefixing. + effectiveFetch = Object.assign( + async ( + input: Parameters[0], + init?: Parameters[1] + ): Promise => { + if (!anthropicOauthService) { + throw new Error("Anthropic OAuth service not initialized"); + } + + const authResult = await anthropicOauthService.getValidAuth(); + if (!authResult.success) { + throw new Error(authResult.error); + } + + const urlString = (() => { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (typeof input === "object" && input !== null && "url" in input) { + const possibleUrl = (input as { url?: unknown }).url; + if (typeof possibleUrl === "string") return possibleUrl; + } + return ""; + })(); + + const method = (init?.method ?? "GET").toUpperCase(); + const isMessagesEndpoint = /\/v1\/messages(\?|$)/.test(urlString); + + let nextInput: Parameters[0] = input; + let nextInit: Parameters[1] | undefined = init; + + // Prefix tool names with mcp_ in request bodies (required by Anthropic OAuth API). + if (isMessagesEndpoint && method === "POST" && typeof init?.body === "string") { + try { + const json = JSON.parse(init.body) as Record; + + // Prefix tool definitions + if (Array.isArray(json.tools)) { + for (const tool of json.tools as Array>) { + if (typeof tool.name === "string" && !tool.name.startsWith(ANTHROPIC_OAUTH_TOOL_PREFIX)) { + tool.name = `${ANTHROPIC_OAUTH_TOOL_PREFIX}${tool.name}`; + } + } + } + + // Prefix tool_use blocks in messages + if (Array.isArray(json.messages)) { + for (const msg of json.messages as Array>) { + if (Array.isArray(msg.content)) { + for (const block of msg.content as Array>) { + if ( + block.type === "tool_use" && + typeof block.name === "string" && + !block.name.startsWith(ANTHROPIC_OAUTH_TOOL_PREFIX) + ) { + block.name = `${ANTHROPIC_OAUTH_TOOL_PREFIX}${block.name}`; + } + } + } + } + } + + const newBody = JSON.stringify(json); + const newHeaders = new Headers(init?.headers); + newHeaders.delete("content-length"); + nextInit = { ...init, headers: newHeaders, body: newBody }; + } catch { + // If body parsing fails, proceed without modification. + } + } + + // Rewrite URL: add ?beta=true for OAuth-authed messages requests. + if (isMessagesEndpoint) { + try { + const url = new URL(urlString); + if (!url.searchParams.has("beta")) { + url.searchParams.set("beta", "true"); + } + nextInput = url.toString(); + } catch { + // If URL parsing fails, proceed with original. + } + } + + // Set auth headers. + const reqHeaders = new Headers(nextInit?.headers); + reqHeaders.set("Authorization", `Bearer ${authResult.data.access}`); + reqHeaders.delete("x-api-key"); + + // Merge anthropic-beta header (preserve existing beta values like 1M context). + const existingBeta = reqHeaders.get("anthropic-beta") ?? ""; + const existingValues = existingBeta.split(",").map((v) => v.trim()).filter(Boolean); + const oauthBetas = [ANTHROPIC_OAUTH_BETA_HEADER, ANTHROPIC_OAUTH_THINKING_BETA]; + const merged = [...new Set([...oauthBetas, ...existingValues])].join(","); + reqHeaders.set("anthropic-beta", merged); + + reqHeaders.set("user-agent", ANTHROPIC_OAUTH_USER_AGENT); + + nextInit = { ...(nextInit ?? {}), headers: reqHeaders }; + + const response = await baseFetch(nextInput, nextInit); + + // Strip mcp_ prefix from tool names in the streaming response. + if (isMessagesEndpoint && response.body) { + return stripAnthropicOauthToolPrefix(response); + } + + return response; + }, + "preconnect" in baseFetch && typeof baseFetch.preconnect === "function" + ? { preconnect: baseFetch.preconnect.bind(baseFetch) } + : {} + ) as typeof fetch; + + // Apply cache control wrapping on top of the OAuth fetch. + effectiveFetch = wrapFetchWithAnthropicCacheControl(effectiveFetch); + } else { + effectiveFetch = wrapFetchWithAnthropicCacheControl(baseFetch); + } + const provider = createAnthropic({ ...normalizedConfig, headers, - fetch: fetchWithCacheControl, + fetch: effectiveFetch, }); - return Ok(provider(modelId)); + + const model = provider(modelId); + if (shouldRouteThroughAnthropicOauth) { + markModelCostsIncluded(model); + } + return Ok(model); } // Handle OpenAI provider (using Responses API) diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index 33538f35af..ad9bd2eb5c 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -10,6 +10,7 @@ import type { import { log } from "@/node/services/log"; import { checkProviderConfigured } from "@/node/utils/providerRequirements"; import { parseCodexOauthAuth } from "@/node/utils/codexOauthAuth"; +import { parseAnthropicOauthAuth } from "@/node/utils/anthropicOauthAuth"; import type { PolicyService } from "@/node/services/policyService"; // Re-export types for backward compatibility @@ -80,6 +81,8 @@ export class ProviderService { secretAccessKey?: string; /** OpenAI-only: stored Codex OAuth tokens (never sent to frontend). */ codexOauth?: unknown; + /** Anthropic-only: stored Anthropic OAuth tokens (never sent to frontend). */ + anthropicOauth?: unknown; }; const forcedBaseUrl = this.policyService?.isEnforced() @@ -99,6 +102,9 @@ export class ProviderService { const codexOauthSet = provider === "openai" && parseCodexOauthAuth(config.codexOauth) !== null; + const anthropicOauthSet = + provider === "anthropic" && parseAnthropicOauthAuth(config.anthropicOauth) !== null; + const providerInfo: ProviderConfigInfo = { apiKeySet: !!config.apiKey, isConfigured: false, // computed below @@ -126,6 +132,11 @@ export class ProviderService { providerInfo.codexOauthDefaultAuth = codexOauthDefaultAuth; } } + // Anthropic-specific fields + if (provider === "anthropic") { + providerInfo.anthropicOauthSet = anthropicOauthSet; + } + // AWS/Bedrock-specific fields if (provider === "bedrock") { providerInfo.aws = { @@ -150,6 +161,10 @@ export class ProviderService { providerInfo.isConfigured = true; } + if (provider === "anthropic" && anthropicOauthSet) { + providerInfo.isConfigured = true; + } + result[provider] = providerInfo; } diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index bb41baeab7..900217e54d 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -17,6 +17,7 @@ import { ProjectService } from "@/node/services/projectService"; import { MuxGatewayOauthService } from "@/node/services/muxGatewayOauthService"; import { MuxGovernorOauthService } from "@/node/services/muxGovernorOauthService"; import { CodexOauthService } from "@/node/services/codexOauthService"; +import { AnthropicOauthService } from "@/node/services/anthropicOauthService"; import { CopilotOauthService } from "@/node/services/copilotOauthService"; import { TerminalService } from "@/node/services/terminalService"; import { EditorService } from "@/node/services/editorService"; @@ -94,6 +95,7 @@ export class ServiceContainer { public readonly muxGatewayOauthService: MuxGatewayOauthService; public readonly muxGovernorOauthService: MuxGovernorOauthService; public readonly codexOauthService: CodexOauthService; + public readonly anthropicOauthService: AnthropicOauthService; public readonly copilotOauthService: CopilotOauthService; public readonly terminalService: TerminalService; public readonly editorService: EditorService; @@ -187,6 +189,12 @@ export class ServiceContainer { this.windowService ); this.aiService.setCodexOauthService(this.codexOauthService); + this.anthropicOauthService = new AnthropicOauthService( + config, + this.providerService, + this.windowService + ); + this.aiService.setAnthropicOauthService(this.anthropicOauthService); this.copilotOauthService = new CopilotOauthService(this.providerService, this.windowService); // Terminal services - PTYService is cross-platform this.ptyService = new PTYService(); @@ -414,6 +422,7 @@ export class ServiceContainer { muxGatewayOauthService: this.muxGatewayOauthService, muxGovernorOauthService: this.muxGovernorOauthService, codexOauthService: this.codexOauthService, + anthropicOauthService: this.anthropicOauthService, copilotOauthService: this.copilotOauthService, terminalService: this.terminalService, editorService: this.editorService, @@ -465,6 +474,7 @@ export class ServiceContainer { await this.muxGatewayOauthService.dispose(); await this.muxGovernorOauthService.dispose(); await this.codexOauthService.dispose(); + this.anthropicOauthService.dispose(); this.copilotOauthService.dispose(); await this.backgroundProcessManager.terminateAll(); diff --git a/src/node/utils/anthropicOauthAuth.ts b/src/node/utils/anthropicOauthAuth.ts new file mode 100644 index 0000000000..254e671936 --- /dev/null +++ b/src/node/utils/anthropicOauthAuth.ts @@ -0,0 +1,41 @@ +/** + * Anthropic OAuth token parsing and validation. + */ + +export interface AnthropicOauthAuth { + type: "oauth"; + /** OAuth access token. */ + access: string; + /** OAuth refresh token. */ + refresh: string; + /** Unix epoch milliseconds when the access token expires. */ + expires: number; +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function parseAnthropicOauthAuth(value: unknown): AnthropicOauthAuth | null { + if (!isPlainObject(value)) { + return null; + } + + const { type, access, refresh, expires } = value; + + if (type !== "oauth") return null; + if (typeof access !== "string" || !access) return null; + if (typeof refresh !== "string" || !refresh) return null; + if (typeof expires !== "number" || !Number.isFinite(expires)) return null; + + return { type: "oauth", access, refresh, expires }; +} + +export function isAnthropicOauthAuthExpired( + auth: AnthropicOauthAuth, + opts?: { nowMs?: number; skewMs?: number } +): boolean { + const now = opts?.nowMs ?? Date.now(); + const skew = opts?.skewMs ?? 30_000; + return now + skew >= auth.expires; +} diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 31c5ae7dd7..bc7ba8720b 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -89,6 +89,7 @@ export async function createTestEnvironment(): Promise { muxGatewayOauthService: services.muxGatewayOauthService, muxGovernorOauthService: services.muxGovernorOauthService, codexOauthService: services.codexOauthService, + anthropicOauthService: services.anthropicOauthService, copilotOauthService: services.copilotOauthService, taskService: services.taskService, providerService: services.providerService, From 85db18f1d636cb6d1c6df66601c45928c4c088df Mon Sep 17 00:00:00 2001 From: Jason Barnett Date: Tue, 10 Feb 2026 16:30:48 -0700 Subject: [PATCH 2/5] fix: skip mcp_ prefix on Anthropic built-in server-side tools Anthropic's built-in tools (web_search, code_execution, text_editor) have versioned types like "web_search_20250305" and their names must remain unchanged. Only prefix custom tools (type "custom" or no type) with mcp_ when routing through OAuth. Previously all tools were prefixed, causing the API to reject requests with: "tools.16.web_search_20250305.name: Input should be 'web_search'" Co-Authored-By: Claude Opus 4.6 --- src/node/services/providerModelFactory.ts | 27 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 4d21afab29..a9f9f66ebd 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -592,16 +592,34 @@ export class ProviderModelFactory { try { const json = JSON.parse(init.body) as Record; - // Prefix tool definitions + // Prefix custom tool definitions only. Anthropic's built-in + // server-side tools (web_search, code_execution, text_editor, etc.) + // have a versioned type like "web_search_20250305" and their names + // must remain unchanged. Custom tools have type "custom" or no type. if (Array.isArray(json.tools)) { for (const tool of json.tools as Array>) { - if (typeof tool.name === "string" && !tool.name.startsWith(ANTHROPIC_OAUTH_TOOL_PREFIX)) { + const isCustomTool = !tool.type || tool.type === "custom"; + if ( + isCustomTool && + typeof tool.name === "string" && + !tool.name.startsWith(ANTHROPIC_OAUTH_TOOL_PREFIX) + ) { tool.name = `${ANTHROPIC_OAUTH_TOOL_PREFIX}${tool.name}`; } } } - // Prefix tool_use blocks in messages + // Collect built-in tool names so we skip them in messages too. + const builtInToolNames = new Set(); + if (Array.isArray(json.tools)) { + for (const tool of json.tools as Array>) { + if (tool.type && tool.type !== "custom" && typeof tool.name === "string") { + builtInToolNames.add(tool.name); + } + } + } + + // Prefix tool_use blocks in messages (skip built-in tools). if (Array.isArray(json.messages)) { for (const msg of json.messages as Array>) { if (Array.isArray(msg.content)) { @@ -609,7 +627,8 @@ export class ProviderModelFactory { if ( block.type === "tool_use" && typeof block.name === "string" && - !block.name.startsWith(ANTHROPIC_OAUTH_TOOL_PREFIX) + !block.name.startsWith(ANTHROPIC_OAUTH_TOOL_PREFIX) && + !builtInToolNames.has(block.name) ) { block.name = `${ANTHROPIC_OAUTH_TOOL_PREFIX}${block.name}`; } From b0c589fc2c647a1463d48a29fa71796ca3134393 Mon Sep 17 00:00:00 2001 From: Jason Barnett Date: Tue, 10 Feb 2026 16:51:06 -0700 Subject: [PATCH 3/5] fix: prepend Claude Code system prompt prefix for Anthropic OAuth Anthropic's server validates that requests made with Claude Code OAuth tokens include the Claude Code identity prefix in the system prompt. Without it, the credential is rejected with "This credential is only authorized for use with Claude Code." This matches the behavior of the opencode-anthropic-auth plugin. Co-Authored-By: Claude Opus 4.6 --- src/common/constants/anthropicOAuth.ts | 6 ++++++ src/node/services/providerModelFactory.ts | 20 +++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/common/constants/anthropicOAuth.ts b/src/common/constants/anthropicOAuth.ts index e5054f92fb..af6f9f11a3 100644 --- a/src/common/constants/anthropicOAuth.ts +++ b/src/common/constants/anthropicOAuth.ts @@ -33,6 +33,12 @@ export const ANTHROPIC_OAUTH_USER_AGENT = "claude-cli/2.1.2 (external, cli)"; // Tool name prefix required by Anthropic's OAuth API. export const ANTHROPIC_OAUTH_TOOL_PREFIX = "mcp_"; +// System prompt prefix required by Anthropic's OAuth API. +// The server validates that Claude Code OAuth requests include this identity +// prefix in the system prompt; without it the credential is rejected. +export const ANTHROPIC_OAUTH_SYSTEM_PREFIX = + "You are Claude Code, Anthropic's official CLI for Claude."; + export function buildAnthropicAuthorizeUrl(input: { state: string; codeChallenge: string; diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index a9f9f66ebd..8339ebe6e6 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -24,6 +24,7 @@ import { ANTHROPIC_OAUTH_THINKING_BETA, ANTHROPIC_OAUTH_USER_AGENT, ANTHROPIC_OAUTH_TOOL_PREFIX, + ANTHROPIC_OAUTH_SYSTEM_PREFIX, } from "@/common/constants/anthropicOAuth"; import type { Config, ProviderConfig } from "@/node/config"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; @@ -587,11 +588,28 @@ export class ProviderModelFactory { let nextInput: Parameters[0] = input; let nextInit: Parameters[1] | undefined = init; - // Prefix tool names with mcp_ in request bodies (required by Anthropic OAuth API). + // Transform request body for Anthropic OAuth API compatibility. if (isMessagesEndpoint && method === "POST" && typeof init?.body === "string") { try { const json = JSON.parse(init.body) as Record; + // Prepend the Claude Code identity prefix to the system prompt. + // Anthropic's server validates that OAuth requests include this; + // without it the credential is rejected as unauthorized. + if (Array.isArray(json.system)) { + const systemArr = json.system as Array>; + // Prepend the prefix as a new text block, and also concatenate + // it with the first existing text block (matching Claude Code's + // system prompt format). + if (systemArr.length > 0 && systemArr[0].type === "text" && typeof systemArr[0].text === "string") { + systemArr[0] = { ...systemArr[0], text: ANTHROPIC_OAUTH_SYSTEM_PREFIX + "\n\n" + systemArr[0].text }; + } else { + systemArr.unshift({ type: "text", text: ANTHROPIC_OAUTH_SYSTEM_PREFIX }); + } + } else if (typeof json.system === "string") { + json.system = ANTHROPIC_OAUTH_SYSTEM_PREFIX + "\n\n" + json.system; + } + // Prefix custom tool definitions only. Anthropic's built-in // server-side tools (web_search, code_execution, text_editor, etc.) // have a versioned type like "web_search_20250305" and their names From 26ed24e6487d8af6d89677e497069dbaf46b1007 Mon Sep 17 00:00:00 2001 From: Jason Barnett Date: Tue, 10 Feb 2026 17:19:15 -0700 Subject: [PATCH 4/5] fix: correct Anthropic OAuth request format for Claude Code API Three issues causing "credential only authorized for Claude Code" rejection: 1. System prompt prefix must be a SEPARATE text block at system[0], not concatenated with existing text. The server checks that the first block is exactly the Claude Code identity string. 2. App attribution headers (http-referer, x-title) identified the request as coming from a non-Claude-Code client. Now stripped for OAuth requests. 3. Restored beta header merging so SDK betas (structured-outputs, effort) are preserved alongside the required OAuth betas. Co-Authored-By: Claude Opus 4.6 --- src/node/services/providerModelFactory.ts | 34 +++++++++++++---------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 8339ebe6e6..07991ac773 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -593,21 +593,20 @@ export class ProviderModelFactory { try { const json = JSON.parse(init.body) as Record; - // Prepend the Claude Code identity prefix to the system prompt. - // Anthropic's server validates that OAuth requests include this; - // without it the credential is rejected as unauthorized. + // Prepend the Claude Code identity prefix as a SEPARATE text block. + // The server validates that the first system text block is EXACTLY + // the Claude Code identity string. Concatenating it with other text + // causes the check to fail. It must be its own standalone block. if (Array.isArray(json.system)) { const systemArr = json.system as Array>; - // Prepend the prefix as a new text block, and also concatenate - // it with the first existing text block (matching Claude Code's - // system prompt format). - if (systemArr.length > 0 && systemArr[0].type === "text" && typeof systemArr[0].text === "string") { - systemArr[0] = { ...systemArr[0], text: ANTHROPIC_OAUTH_SYSTEM_PREFIX + "\n\n" + systemArr[0].text }; - } else { - systemArr.unshift({ type: "text", text: ANTHROPIC_OAUTH_SYSTEM_PREFIX }); - } + systemArr.unshift({ type: "text", text: ANTHROPIC_OAUTH_SYSTEM_PREFIX }); } else if (typeof json.system === "string") { - json.system = ANTHROPIC_OAUTH_SYSTEM_PREFIX + "\n\n" + json.system; + json.system = [ + { type: "text", text: ANTHROPIC_OAUTH_SYSTEM_PREFIX }, + { type: "text", text: json.system }, + ]; + } else if (!json.system) { + json.system = [{ type: "text", text: ANTHROPIC_OAUTH_SYSTEM_PREFIX }]; } // Prefix custom tool definitions only. Anthropic's built-in @@ -677,12 +676,19 @@ export class ProviderModelFactory { } } - // Set auth headers. + // Set auth headers for Claude Code OAuth impersonation. const reqHeaders = new Headers(nextInit?.headers); reqHeaders.set("Authorization", `Bearer ${authResult.data.access}`); reqHeaders.delete("x-api-key"); - // Merge anthropic-beta header (preserve existing beta values like 1M context). + // Remove app attribution headers that identify this as a non-Claude-Code + // client. The server checks these and rejects OAuth credentials when + // they come from an unrecognized application. + reqHeaders.delete("http-referer"); + reqHeaders.delete("x-title"); + + // Merge anthropic-beta: ensure required OAuth betas are present + // while preserving SDK betas (e.g. structured-outputs, effort). const existingBeta = reqHeaders.get("anthropic-beta") ?? ""; const existingValues = existingBeta.split(",").map((v) => v.trim()).filter(Boolean); const oauthBetas = [ANTHROPIC_OAUTH_BETA_HEADER, ANTHROPIC_OAUTH_THINKING_BETA]; From 3f35d9978a049ab7f97fe63e39c9a89fd78d40c9 Mon Sep 17 00:00:00 2001 From: Jason Barnett Date: Tue, 10 Feb 2026 17:22:15 -0700 Subject: [PATCH 5/5] fix: prefix tool_choice.name and handle chunk boundaries in OAuth 1. Prefix tool_choice.name with mcp_ when a specific tool is forced via tool policy. Without this, the SDK sends { type: "tool", name: "bash" } while the tool definition says mcp_bash, causing a validation mismatch on forced-tool requests. 2. Add carry-over buffer to streaming response prefix stripping. Stream chunks can split a JSON token like "name":"mcp_foo" across reads. The carry buffer holds back trailing fragments with unclosed strings and prepends them to the next chunk before applying the regex, preventing missed replacements. Co-Authored-By: Claude Opus 4.6 --- src/node/services/providerModelFactory.ts | 60 +++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 07991ac773..0174e35533 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -103,6 +103,11 @@ if (typeof globalFetchWithExtras.certificate === "function") { * Strip the mcp_ prefix from tool names in Anthropic streaming responses. * Anthropic's OAuth API requires tool names to be prefixed with "mcp_" in * requests; we strip them from responses so the SDK sees the original names. + * + * Uses a carry-over buffer to handle chunk boundaries: if a chunk ends with + * a partial match (e.g. `"name":"mcp_` split across two reads), the trailing + * fragment is held back and prepended to the next chunk before applying the + * regex, preventing missed replacements. */ function stripAnthropicOauthToolPrefix(response: Response): Response { const body = response.body; @@ -112,18 +117,54 @@ function stripAnthropicOauthToolPrefix(response: Response): Response { const decoder = new TextDecoder(); const encoder = new TextEncoder(); + // Maximum number of bytes a partial match can span. The longest pattern + // we need to match is `"name" : "mcp_"`. A conservative upper + // bound for the non-toolname portion is ~20 chars (`"name" : "mcp_`). + const CARRY_LIMIT = 30; + let carry = ""; + const stream = new ReadableStream({ async pull(controller) { const { done, value } = await reader.read(); if (done) { + // Flush any remaining carry buffer on stream end. + if (carry.length > 0) { + controller.enqueue(encoder.encode(carry)); + carry = ""; + } controller.close(); return; } - let text = decoder.decode(value, { stream: true }); + const chunk = carry + decoder.decode(value, { stream: true }); + carry = ""; + // Strip mcp_ prefix from tool name fields in SSE JSON payloads. - text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name":"$1"'); - controller.enqueue(encoder.encode(text)); + const replaced = chunk.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name":"$1"'); + + // Hold back a trailing fragment that could be a partial match split + // across chunks. We only carry if the tail looks like it could be the + // start of a `"name"` JSON key (i.e. contains an unmatched `"`). + const lastQuote = replaced.lastIndexOf('"'); + if (lastQuote >= 0 && replaced.length - lastQuote <= CARRY_LIMIT) { + // Check if there's an unclosed string at the tail by counting quotes + // after the last newline (SSE is line-delimited, so newlines are safe + // flush points). + const lastNewline = replaced.lastIndexOf("\n"); + const tail = lastNewline >= 0 ? replaced.slice(lastNewline + 1) : replaced; + const quoteCount = (tail.match(/"/g) || []).length; + if (quoteCount % 2 !== 0) { + // Odd number of quotes means an unclosed string -- carry the tail. + const emit = lastNewline >= 0 ? replaced.slice(0, lastNewline + 1) : ""; + carry = lastNewline >= 0 ? replaced.slice(lastNewline + 1) : replaced; + if (emit.length > 0) { + controller.enqueue(encoder.encode(emit)); + } + return; + } + } + + controller.enqueue(encoder.encode(replaced)); }, }); @@ -654,6 +695,19 @@ export class ProviderModelFactory { } } + // Prefix tool_choice.name when a specific tool is forced. + // The SDK sends { type: "tool", name: "bash" } but the tool + // definition is now mcp_bash, so the name must match. + const tc = json.tool_choice as Record | undefined; + if ( + tc?.type === "tool" && + typeof tc.name === "string" && + !tc.name.startsWith(ANTHROPIC_OAUTH_TOOL_PREFIX) && + !builtInToolNames.has(tc.name) + ) { + tc.name = `${ANTHROPIC_OAUTH_TOOL_PREFIX}${tc.name}`; + } + const newBody = JSON.stringify(json); const newHeaders = new Headers(init?.headers); newHeaders.delete("content-length");