diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 5a3c4467..f1f8139b 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -126,7 +126,7 @@ export class BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); const transformedBody = this.transformRequest(model, body, stream, credentials); - const headers = this.buildHeaders(credentials, stream, url); + const headers = this.buildHeaders(credentials, stream, url, model); if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 48be8350..cbd1ccce 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -1,9 +1,8 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js"; -import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js"; +import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE, selectAnthropicBeta } from "../providers/shared.js"; import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js"; import { buildClineHeaders } from "../shared/clineAuth.js"; -import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js"; @@ -42,21 +41,6 @@ const HEADER_HOOKS = { kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)), clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)), kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; }, - claudeOverlay: (h) => { - const cached = getCachedClaudeHeaders(); - if (!cached) return; - for (const lcKey of Object.keys(cached)) { - const titleKey = lcKey.replace(/(^|-)([a-z])/g, (_, sep, ch) => sep + ch.toUpperCase()); - if (lcKey === "anthropic-beta") { - const staticBetaStr = h[titleKey] || h[lcKey] || ""; - const flags = new Set(staticBetaStr.split(",").map(f => f.trim()).filter(Boolean)); - for (const f of cached[lcKey].split(",").map(f => f.trim()).filter(Boolean)) flags.add(f); - cached[lcKey] = Array.from(flags).join(","); - } - if (titleKey !== lcKey && h[titleKey] !== undefined) delete h[titleKey]; - } - Object.assign(h, cached); - }, }; // Config-driven OAuth refresh grants — derived from registry oauth.refresh. @@ -161,14 +145,18 @@ export class DefaultExecutor extends BaseExecutor { return BEARER; } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, url, model) { const rt = credentials?.runtimeTransport; const headers = { "Content-Type": "application/json", ...(rt ? rt.headers : this.config.headers) }; const desc = rt?.auth || AUTH_DESCRIPTORS[this.provider] || this.resolveAuthDescriptor(); - // Hooks run BEFORE auth so dynamic overlays (claude cached headers) can't clobber the token. + // Hooks run BEFORE auth so dynamic overlays can't clobber the token. for (const hook of desc.hooks || []) HEADER_HOOKS[hook]?.(headers, credentials); applyAuth(headers, desc, credentials); + if (this.provider === "claude" && model) { + headers["Anthropic-Beta"] = selectAnthropicBeta(model); + } + // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams if (this.provider?.startsWith?.("anthropic-compatible-")) { const baseUrl = credentials?.providerSpecificData?.baseUrl || ""; diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js index 8b030e5a..8c47a6b3 100644 --- a/open-sse/providers/registry/claude.js +++ b/open-sse/providers/registry/claude.js @@ -49,9 +49,6 @@ export default { header: "Authorization", scheme: "bearer", }, - hooks: [ - "claudeOverlay", - ], }, usage: { oauthUrl: "https://api.anthropic.com/api/oauth/usage", diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index 85b256a1..fdd76c40 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -47,6 +47,26 @@ export const CLAUDE_CLI_SPOOF_HEADERS = { "X-Stainless-Timeout": "600" }; +const ANTHROPIC_BETA_BASE = [ + "claude-code-20250219", + "oauth-2025-04-20", + "interleaved-thinking-2025-05-14", + "context-management-2025-06-27", + "prompt-caching-scope-2026-01-05", + "structured-outputs-2025-12-15", + "fast-mode-2026-02-01", + "redact-thinking-2026-02-12", + "token-efficient-tools-2026-03-28", +]; +const ANTHROPIC_BETA_HEAVY_AGENT = ["advanced-tool-use-2025-11-20", "effort-2025-11-24"]; + +// Heavy-agent beta flags are gated to opus/sonnet — cheaper models don't need them. +export function selectAnthropicBeta(model = "") { + const flags = [...ANTHROPIC_BETA_BASE]; + if (/^claude-(opus|sonnet)/.test(model)) flags.push(...ANTHROPIC_BETA_HEAVY_AGENT); + return flags.join(","); +} + // Shared baseUrls export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages"; diff --git a/open-sse/utils/claudeHeaderCache.js b/open-sse/utils/claudeHeaderCache.js deleted file mode 100644 index 11b2eb81..00000000 --- a/open-sse/utils/claudeHeaderCache.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Singleton cache for real Claude Code client headers. - * Captures headers from authentic Claude Code requests and makes them available - * for forwarding to api.anthropic.com, replacing static hardcoded values. - */ - -const CLAUDE_IDENTITY_HEADERS = [ - "user-agent", - "anthropic-beta", - "anthropic-version", - "anthropic-dangerous-direct-browser-access", - "x-app", - "x-stainless-helper-method", - "x-stainless-retry-count", - "x-stainless-runtime-version", - "x-stainless-package-version", - "x-stainless-runtime", - "x-stainless-lang", - "x-stainless-arch", - "x-stainless-os", - "x-stainless-timeout", - "x-claude-code-session-id", - "package-version", - "runtime-version", - "os", - "arch", -]; - -let cachedHeaders = null; - -/** - * Detect if request headers look like a real Claude Code client. - * @param {object} headers - Lowercase header key/value object - */ -function isClaudeCodeClient(headers) { - const ua = (headers["user-agent"] || "").toLowerCase(); - const xApp = (headers["x-app"] || "").toLowerCase(); - return ua.includes("claude-cli") || ua.includes("claude-code") || xApp === "cli"; -} - -/** - * Store Claude Code identity headers if this looks like a real client request. - * Called at the entry point before any translation/forwarding. - * @param {object} headers - Lowercase header key/value object (from request.headers.entries()) - */ -export function cacheClaudeHeaders(headers) { - if (!headers || typeof headers !== "object") return; - if (!isClaudeCodeClient(headers)) return; - - const captured = {}; - for (const key of CLAUDE_IDENTITY_HEADERS) { - if (headers[key] !== undefined && headers[key] !== null) { - captured[key] = headers[key]; - } - } - - if (Object.keys(captured).length > 0) { - cachedHeaders = captured; - console.log(`[ClaudeHeaders] Cached ${Object.keys(captured).length} identity headers from Claude Code client`); - } -} - -/** - * Get the most recently cached Claude Code identity headers. - * Returns null if no authentic client request has been seen yet (cold start). - * @returns {object|null} - */ -export function getCachedClaudeHeaders() { - return cachedHeaders; -} diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index af2914a4..b44727ab 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -7,7 +7,6 @@ import { extractApiKey, isValidApiKey, } from "../services/auth.js"; -import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js"; import { getSettings } from "@/lib/localDb"; import { getModelInfo, getComboModels } from "../services/model.js"; import { handleChatCore } from "open-sse/handlers/chatCore.js"; @@ -46,8 +45,6 @@ export async function handleChat(request, clientRawRequest = null) { headers: Object.fromEntries(request.headers.entries()) }; } - cacheClaudeHeaders(clientRawRequest.headers); - const modelStr = body.model; // Request summary is emitted as the unified "▶" line in chatCore (has fmt/thinking/account) diff --git a/tests/unit/claude-header-forwarding.test.js b/tests/unit/claude-header-forwarding.test.js index acad66a9..52318782 100644 --- a/tests/unit/claude-header-forwarding.test.js +++ b/tests/unit/claude-header-forwarding.test.js @@ -1,10 +1,8 @@ /** - * Unit tests for Anthropic header caching + forwarding pipeline + * Unit tests for Anthropic header forwarding pipeline * * Tests cover: - * - claudeHeaderCache: detection, capture, and retrieval of Claude Code headers - * - default.js buildHeaders(): live header overlay for "claude" provider - * - default.js buildHeaders(): cold-start fallback when cache is empty + * - default.js buildHeaders(): static provider defaults + model-gated anthropic-beta * - default.js buildHeaders(): anthropic-compatible non-Anthropic host stripping * - default.js buildHeaders(): anthropic-compatible official host keeps headers * - proxyFetch.js: api.anthropic.com routes through anthropicFetch path @@ -12,109 +10,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -// ─── claudeHeaderCache ──────────────────────────────────────────────────────── - -describe("claudeHeaderCache", () => { - let cacheModule; - - beforeEach(async () => { - // Re-import fresh module each time to reset singleton state - vi.resetModules(); - cacheModule = await import("open-sse/utils/claudeHeaderCache.js"); - }); - - it("returns null before any headers are cached (cold start)", () => { - expect(cacheModule.getCachedClaudeHeaders()).toBeNull(); - }); - - it("caches headers when user-agent contains 'claude-code'", () => { - cacheModule.cacheClaudeHeaders({ - "user-agent": "claude-code/2.1.63 node/24.3.0", - "anthropic-beta": "claude-code-20250219,oauth-2025-04-20", - "anthropic-version": "2023-06-01", - "x-app": "cli", - "x-stainless-os": "MacOS", - "x-stainless-arch": "arm64", - "x-stainless-lang": "js", - "x-stainless-runtime": "node", - "x-stainless-runtime-version": "v24.3.0", - "x-stainless-package-version": "0.74.0", - "x-stainless-helper-method": "stream", - "x-stainless-retry-count": "0", - "x-stainless-timeout": "600", - "anthropic-dangerous-direct-browser-access": "true", - // Non-identity header — should NOT be captured - "content-type": "application/json", - }); - - const cached = cacheModule.getCachedClaudeHeaders(); - expect(cached).not.toBeNull(); - expect(cached["user-agent"]).toBe("claude-code/2.1.63 node/24.3.0"); - expect(cached["anthropic-beta"]).toBe("claude-code-20250219,oauth-2025-04-20"); - expect(cached["x-app"]).toBe("cli"); - expect(cached["x-stainless-os"]).toBe("MacOS"); - // Non-identity header must not leak in - expect(cached["content-type"]).toBeUndefined(); - }); - - it("caches headers when user-agent contains 'claude-cli'", () => { - cacheModule.cacheClaudeHeaders({ - "user-agent": "claude-cli/1.0.0", - "anthropic-version": "2023-06-01", - }); - expect(cacheModule.getCachedClaudeHeaders()).not.toBeNull(); - expect(cacheModule.getCachedClaudeHeaders()["user-agent"]).toBe("claude-cli/1.0.0"); - }); - - it("caches headers when x-app is 'cli' (regardless of user-agent)", () => { - cacheModule.cacheClaudeHeaders({ - "user-agent": "axios/1.7.0", - "x-app": "cli", - "anthropic-version": "2023-06-01", - }); - expect(cacheModule.getCachedClaudeHeaders()).not.toBeNull(); - }); - - it("does NOT cache headers for non-Claude clients", () => { - cacheModule.cacheClaudeHeaders({ - "user-agent": "PostmanRuntime/7.43.0", - "anthropic-version": "2023-06-01", - }); - expect(cacheModule.getCachedClaudeHeaders()).toBeNull(); - }); - - it("refreshes cache on each matching request", () => { - cacheModule.cacheClaudeHeaders({ - "user-agent": "claude-code/2.0.0", - "x-stainless-package-version": "0.70.0", - }); - cacheModule.cacheClaudeHeaders({ - "user-agent": "claude-code/2.1.63", - "x-stainless-package-version": "0.74.0", - }); - const cached = cacheModule.getCachedClaudeHeaders(); - expect(cached["user-agent"]).toBe("claude-code/2.1.63"); - expect(cached["x-stainless-package-version"]).toBe("0.74.0"); - }); - - it("ignores calls with null or non-object headers", () => { - cacheModule.cacheClaudeHeaders(null); - cacheModule.cacheClaudeHeaders(undefined); - cacheModule.cacheClaudeHeaders("string"); - expect(cacheModule.getCachedClaudeHeaders()).toBeNull(); - }); - - it("only stores keys that are actually present in the headers object", () => { - cacheModule.cacheClaudeHeaders({ - "user-agent": "claude-code/2.1.63", - // Most stainless headers absent - }); - const cached = cacheModule.getCachedClaudeHeaders(); - expect(cached["x-stainless-os"]).toBeUndefined(); - expect(cached["user-agent"]).toBe("claude-code/2.1.63"); - }); -}); - // ─── DefaultExecutor.buildHeaders() ────────────────────────────────────────── describe("DefaultExecutor.buildHeaders() — claude provider", () => { @@ -122,55 +17,51 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => { beforeEach(async () => { vi.resetModules(); - // Prime the cache with live client headers before importing executor - const cache = await import("open-sse/utils/claudeHeaderCache.js"); - cache.cacheClaudeHeaders({ - "user-agent": "claude-code/2.1.63 node/24.3.0", - "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14", - "anthropic-version": "2023-06-01", - "anthropic-dangerous-direct-browser-access": "true", - "x-app": "cli", - "x-stainless-os": "MacOS", - "x-stainless-arch": "arm64", - "x-stainless-lang": "js", - "x-stainless-runtime": "node", - "x-stainless-runtime-version": "v24.3.0", - "x-stainless-package-version": "0.74.0", - "x-stainless-helper-method": "stream", - "x-stainless-retry-count": "0", - "x-stainless-timeout": "600", - }); const mod = await import("open-sse/executors/default.js"); DefaultExecutor = mod.DefaultExecutor || mod.default; }); - it("overlays live cached headers over static provider defaults", () => { + it("uses static provider defaults when no model is given", () => { const executor = new DefaultExecutor("claude"); const headers = executor.buildHeaders({ apiKey: "sk-test" }, true); - // Live values should win over static providers.js values - expect(headers["user-agent"]).toBe("claude-code/2.1.63 node/24.3.0"); - // Beta flags are MERGED (static + cached) to preserve required flags like oauth - const betaFlags = headers["anthropic-beta"].split(",").map(s => s.trim()); - expect(betaFlags).toContain("claude-code-20250219"); - expect(betaFlags).toContain("oauth-2025-04-20"); - expect(betaFlags).toContain("interleaved-thinking-2025-05-14"); - expect(headers["x-stainless-package-version"]).toBe("0.74.0"); - expect(headers["x-stainless-os"]).toBe("MacOS"); + const hasVersion = + headers["Anthropic-Version"] === "2023-06-01" || + headers["anthropic-version"] === "2023-06-01"; + expect(hasVersion).toBe(true); }); - it("removes conflicting Title-Case static keys when cached lowercase keys exist", () => { + it("includes heavy-agent beta flags for claude-opus-5", () => { const executor = new DefaultExecutor("claude"); - const headers = executor.buildHeaders({ apiKey: "sk-test" }, true); + const headers = executor.buildHeaders({ apiKey: "sk-test" }, true, undefined, "claude-opus-5"); + const betaFlags = headers["Anthropic-Beta"].split(",").map(s => s.trim()); + expect(betaFlags).toContain("advanced-tool-use-2025-11-20"); + expect(betaFlags).toContain("effort-2025-11-24"); + }); - // Title-Case variants from providers.js must be gone - expect(headers["Anthropic-Version"]).toBeUndefined(); - expect(headers["Anthropic-Beta"]).toBeUndefined(); - expect(headers["User-Agent"]).toBeUndefined(); - expect(headers["X-App"]).toBeUndefined(); - // Lowercase variants must be present - expect(headers["anthropic-version"]).toBe("2023-06-01"); - expect(headers["x-app"]).toBe("cli"); + it("includes heavy-agent beta flags for claude-sonnet-5", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ apiKey: "sk-test" }, true, undefined, "claude-sonnet-5"); + const betaFlags = headers["Anthropic-Beta"].split(",").map(s => s.trim()); + expect(betaFlags).toContain("advanced-tool-use-2025-11-20"); + expect(betaFlags).toContain("effort-2025-11-24"); + }); + + it("omits heavy-agent beta flags for claude-haiku-4-5-20251001", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ apiKey: "sk-test" }, true, undefined, "claude-haiku-4-5-20251001"); + const betaFlags = headers["Anthropic-Beta"].split(",").map(s => s.trim()); + expect(betaFlags).not.toContain("advanced-tool-use-2025-11-20"); + expect(betaFlags).not.toContain("effort-2025-11-24"); + expect(betaFlags).toContain("claude-code-20250219"); + }); + + it("omits heavy-agent beta flags for claude-fable-5", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ apiKey: "sk-test" }, true, undefined, "claude-fable-5"); + const betaFlags = headers["Anthropic-Beta"].split(",").map(s => s.trim()); + expect(betaFlags).not.toContain("advanced-tool-use-2025-11-20"); + expect(betaFlags).not.toContain("effort-2025-11-24"); }); it("sets x-api-key auth when apiKey is provided", () => { @@ -198,31 +89,8 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => { const headers = executor.buildHeaders({ apiKey: "k" }, false); expect(headers["Accept"]).toBeUndefined(); }); -}); -describe("DefaultExecutor.buildHeaders() — claude provider cold start (no cache)", () => { - let DefaultExecutor; - - beforeEach(async () => { - vi.resetModules(); - // Do NOT prime cache — simulate cold start - const mod = await import("open-sse/executors/default.js"); - DefaultExecutor = mod.DefaultExecutor || mod.default; - }); - - it("falls back to static provider headers when cache is empty", () => { - const executor = new DefaultExecutor("claude"); - const headers = executor.buildHeaders({ apiKey: "sk-test" }, true); - - // Static fallback values from providers.js must still be present - // They may be Title-Case since no cache to conflict with them - const hasVersion = - headers["Anthropic-Version"] === "2023-06-01" || - headers["anthropic-version"] === "2023-06-01"; - expect(hasVersion).toBe(true); - }); - - it("does not throw when cache returns null", () => { + it("does not throw when no model is given", () => { const executor = new DefaultExecutor("claude"); expect(() => executor.buildHeaders({ apiKey: "sk" }, false)).not.toThrow(); });