diff --git a/.gitignore b/.gitignore index 7f2e17a8..e5d4f0d4 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,6 @@ package-lock.json #Ignore vscode AI rules .github/instructions/codacy.instructions.md README1.md -deploy.sh +deploy*.sh ecosystem.config.* diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 8b951cce..35e7e384 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -2,6 +2,7 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js"; import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js"; +import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js"; export class DefaultExecutor extends BaseExecutor { constructor(provider) { @@ -43,9 +44,41 @@ export class DefaultExecutor extends BaseExecutor { case "gemini": credentials.apiKey ? headers["x-goog-api-key"] = credentials.apiKey : headers["Authorization"] = `Bearer ${credentials.accessToken}`; break; - case "claude": - credentials.apiKey ? headers["x-api-key"] = credentials.apiKey : headers["Authorization"] = `Bearer ${credentials.accessToken}`; + case "claude": { + // Overlay live cached headers from real Claude Code client over static defaults. + // Static headers (Title-Case) remain as cold-start fallback. + const cached = getCachedClaudeHeaders(); + if (cached) { + // Remove Title-Case static keys that conflict with incoming lowercase cached keys + for (const lcKey of Object.keys(cached)) { + // Build the Title-Case equivalent: "anthropic-version" → "Anthropic-Version" + const titleKey = lcKey.replace(/(^|-)([a-z])/g, (_, sep, c) => sep + c.toUpperCase()); + + // Special handling for Anthropic-Beta to preserve required flags like OAuth + if (lcKey === "anthropic-beta") { + const staticBetaStr = headers[titleKey] || headers[lcKey] || ""; + const staticFlags = new Set(staticBetaStr.split(",").map(f => f.trim()).filter(Boolean)); + const cachedFlags = new Set(cached[lcKey].split(",").map(f => f.trim()).filter(Boolean)); + + // Merge all static flags (which contain oauth, thinking, etc) into the cached ones + for (const flag of staticFlags) { + cachedFlags.add(flag); + } + + cached[lcKey] = Array.from(cachedFlags).join(","); + } + + if (titleKey !== lcKey && headers[titleKey] !== undefined) { + delete headers[titleKey]; + } + } + Object.assign(headers, cached); + } + credentials.apiKey + ? (headers["x-api-key"] = credentials.apiKey) + : (headers["Authorization"] = `Bearer ${credentials.accessToken}`); break; + } case "glm": case "kimi": case "minimax": @@ -83,6 +116,33 @@ export class DefaultExecutor extends BaseExecutor { } } + // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams + if (this.provider?.startsWith?.("anthropic-compatible-")) { + const baseUrl = credentials?.providerSpecificData?.baseUrl || ""; + const isOfficialAnthropic = baseUrl === "" || baseUrl.includes("api.anthropic.com"); + if (!isOfficialAnthropic) { + delete headers["anthropic-dangerous-direct-browser-access"]; + delete headers["Anthropic-Dangerous-Direct-Browser-Access"]; + delete headers["x-app"]; + delete headers["X-App"]; + // Strip claude-code-20250219 from Anthropic-Beta / anthropic-beta + for (const betaKey of ["anthropic-beta", "Anthropic-Beta"]) { + if (headers[betaKey]) { + const filtered = headers[betaKey] + .split(",") + .map(s => s.trim()) + .filter(f => f && f !== "claude-code-20250219") + .join(","); + if (filtered) { + headers[betaKey] = filtered; + } else { + delete headers[betaKey]; + } + } + } + } + } + if (stream) headers["Accept"] = "text/event-stream"; return headers; } @@ -197,8 +257,8 @@ export class DefaultExecutor extends BaseExecutor { const kimiHeaders = buildKimiHeaders(); const response = await fetch("https://auth.kimi.com/api/oauth/token", { method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", + headers: { + "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", ...kimiHeaders }, diff --git a/open-sse/translator/helpers/claudeHelper.js b/open-sse/translator/helpers/claudeHelper.js index a06bab00..e72e782e 100644 --- a/open-sse/translator/helpers/claudeHelper.js +++ b/open-sse/translator/helpers/claudeHelper.js @@ -21,7 +21,7 @@ export function hasValidContent(msg) { // 2. Merge consecutive same-role messages export function fixToolUseOrdering(messages) { if (messages.length <= 1) return messages; - + // Pass 1: Fix assistant messages with tool_use - remove text after tool_use for (const msg of messages) { if (msg.role === "assistant" && Array.isArray(msg.content)) { @@ -30,7 +30,7 @@ export function fixToolUseOrdering(messages) { // Keep only: thinking blocks + tool_use blocks (remove text blocks after tool_use) const newContent = []; let foundToolUse = false; - + for (const block of msg.content) { if (block.type === "tool_use") { foundToolUse = true; @@ -43,27 +43,27 @@ export function fixToolUseOrdering(messages) { } // Skip text blocks AFTER tool_use } - + msg.content = newContent; } } } - + // Pass 2: Merge consecutive same-role messages const merged = []; - + for (const msg of messages) { const last = merged[merged.length - 1]; - + if (last && last.role === msg.role) { // Merge content arrays const lastContent = Array.isArray(last.content) ? last.content : [{ type: "text", text: last.content }]; const msgContent = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: msg.content }]; - + // Put tool_result first, then other content const toolResults = [...lastContent.filter(b => b.type === "tool_result"), ...msgContent.filter(b => b.type === "tool_result")]; const otherContent = [...lastContent.filter(b => b.type !== "tool_result"), ...msgContent.filter(b => b.type !== "tool_result")]; - + last.content = [...toolResults, ...otherContent]; } else { // Ensure content is array @@ -71,7 +71,7 @@ export function fixToolUseOrdering(messages) { merged.push({ role: msg.role, content: [...content] }); } } - + return merged; } @@ -101,7 +101,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null) { // Pass 1: remove cache_control + filter empty messages for (let i = 0; i < len; i++) { const msg = body.messages[i]; - + // Remove cache_control from content blocks if (Array.isArray(msg.content)) { for (const block of msg.content) { @@ -131,19 +131,26 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null) { let lastAssistantProcessed = false; for (let i = filtered.length - 1; i >= 0; i--) { const msg = filtered[i]; - + if (msg.role === "assistant" && Array.isArray(msg.content)) { - // Add cache_control to last block of first (from end) assistant with content + // Add cache_control to last non-thinking block of first (from end) assistant with content + // thinking/redacted_thinking blocks do not support cache_control if (!lastAssistantProcessed && msg.content.length > 0) { - msg.content[msg.content.length - 1].cache_control = { type: "ephemeral" }; + for (let j = msg.content.length - 1; j >= 0; j--) { + const block = msg.content[j]; + if (block.type !== "thinking" && block.type !== "redacted_thinking") { + block.cache_control = { type: "ephemeral" }; + break; + } + } lastAssistantProcessed = true; } // Handle thinking blocks for Anthropic endpoint only - if (provider === "claude") { + if (provider === "claude" || provider?.startsWith("anthropic-compatible")) { let hasToolUse = false; let hasThinking = false; - + // Always replace signature for all thinking blocks for (const block of msg.content) { if (block.type === "thinking" || block.type === "redacted_thinking") { @@ -189,7 +196,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null) { } // Apply cloaking for OAuth tokens (billing header + fake user ID) - if (provider === "claude" && apiKey) { + if ((provider === "claude" || provider?.startsWith("anthropic-compatible")) && apiKey) { body = applyCloaking(body, apiKey); } diff --git a/open-sse/translator/helpers/maxTokensHelper.js b/open-sse/translator/helpers/maxTokensHelper.js index 6a1c044d..294d9c55 100644 --- a/open-sse/translator/helpers/maxTokensHelper.js +++ b/open-sse/translator/helpers/maxTokensHelper.js @@ -7,19 +7,21 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConf */ export function adjustMaxTokens(body) { let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS; - + // Auto-increase for tool calling to prevent truncated arguments if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { if (maxTokens < DEFAULT_MIN_TOKENS) { maxTokens = DEFAULT_MIN_TOKENS; } } - + // Ensure max_tokens > thinking.budget_tokens (Claude API requirement) + // Claude API requires strictly greater, so add buffer instead of using DEFAULT_MAX_TOKENS + // which could equal budget_tokens when budget_tokens >= 64000 if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) { - maxTokens = DEFAULT_MAX_TOKENS; + maxTokens = body.thinking.budget_tokens + 1024; } - + return maxTokens; } diff --git a/open-sse/translator/helpers/toolCallHelper.js b/open-sse/translator/helpers/toolCallHelper.js index e79a75a6..bde90d03 100644 --- a/open-sse/translator/helpers/toolCallHelper.js +++ b/open-sse/translator/helpers/toolCallHelper.js @@ -1,19 +1,31 @@ // Tool call helper functions for translator -// Generate unique tool call ID +// Anthropic tool_use.id must match: ^[a-zA-Z0-9_-]+$ +const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; + +// Generate unique tool call ID (always valid for Anthropic) export function generateToolCallId() { return `call_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; } -// Ensure all tool_calls have id field and arguments is string (some providers require it) +// Sanitize ID to match Anthropic pattern: keep only alphanumeric, underscore, hyphen +function sanitizeToolId(id) { + if (!id || typeof id !== "string") return null; + const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, ""); + return sanitized.length > 0 ? sanitized : null; +} + +// Ensure all tool_calls have valid id field and arguments is string (some providers require it) export function ensureToolCallIds(body) { if (!body.messages || !Array.isArray(body.messages)) return body; - + for (const msg of body.messages) { if (msg.role === "assistant" && msg.tool_calls && Array.isArray(msg.tool_calls)) { for (const tc of msg.tool_calls) { - if (!tc.id) { - tc.id = generateToolCallId(); + // Validate or regenerate ID for Anthropic compatibility + if (!tc.id || !TOOL_ID_PATTERN.test(tc.id)) { + const sanitized = sanitizeToolId(tc.id); + tc.id = sanitized || generateToolCallId(); } if (!tc.type) { tc.type = "function"; @@ -24,24 +36,45 @@ export function ensureToolCallIds(body) { } } } + + // Validate tool_call_id in tool messages (role: "tool") + if (msg.role === "tool" && msg.tool_call_id && !TOOL_ID_PATTERN.test(msg.tool_call_id)) { + const sanitized = sanitizeToolId(msg.tool_call_id); + msg.tool_call_id = sanitized || generateToolCallId(); + } + + // Also validate tool_use blocks in content (Claude format) + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.id && !TOOL_ID_PATTERN.test(block.id)) { + const sanitized = sanitizeToolId(block.id); + block.id = sanitized || generateToolCallId(); + } + // Validate tool_use_id in tool_result blocks + if (block.type === "tool_result" && block.tool_use_id && !TOOL_ID_PATTERN.test(block.tool_use_id)) { + const sanitized = sanitizeToolId(block.tool_use_id); + block.tool_use_id = sanitized || generateToolCallId(); + } + } + } } - + return body; } // Get tool_call ids from assistant message (OpenAI format: tool_calls, Claude format: tool_use in content) export function getToolCallIds(msg) { if (msg.role !== "assistant") return []; - + const ids = []; - + // OpenAI format: tool_calls array if (msg.tool_calls && Array.isArray(msg.tool_calls)) { for (const tc of msg.tool_calls) { if (tc.id) ids.push(tc.id); } } - + // Claude format: tool_use blocks in content if (Array.isArray(msg.content)) { for (const block of msg.content) { @@ -50,19 +83,19 @@ export function getToolCallIds(msg) { } } } - + return ids; } // Check if user message has tool_result for given ids (OpenAI format: role=tool, Claude format: tool_result in content) export function hasToolResults(msg, toolCallIds) { if (!msg || !toolCallIds.length) return false; - + // OpenAI format: role = "tool" with tool_call_id if (msg.role === "tool" && msg.tool_call_id) { return toolCallIds.includes(msg.tool_call_id); } - + // Claude format: tool_result blocks in user message content if (msg.role === "user" && Array.isArray(msg.content)) { for (const block of msg.content) { @@ -71,26 +104,26 @@ export function hasToolResults(msg, toolCallIds) { } } } - + return false; } // Fix missing tool responses - insert empty tool_result if assistant has tool_use but next message has no tool_result export function fixMissingToolResponses(body) { if (!body.messages || !Array.isArray(body.messages)) return body; - + const newMessages = []; - + for (let i = 0; i < body.messages.length; i++) { const msg = body.messages[i]; const nextMsg = body.messages[i + 1]; - + newMessages.push(msg); - + // Check if this is assistant with tool_calls/tool_use const toolCallIds = getToolCallIds(msg); if (toolCallIds.length === 0) continue; - + // Check if next message has tool_result if (nextMsg && !hasToolResults(nextMsg, toolCallIds)) { // Insert tool responses for each tool_call @@ -104,7 +137,7 @@ export function fixMissingToolResponses(body) { } } } - + body.messages = newMessages; return body; } diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index d4a4393d..41be550e 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -91,11 +91,16 @@ export function openaiToClaudeRequest(model, body, stream) { for (let i = result.messages.length - 1; i >= 0; i--) { const message = result.messages[i]; if (message.role === "assistant" && Array.isArray(message.content) && message.content.length > 0) { - const lastBlock = message.content[message.content.length - 1]; - if (lastBlock) { - lastBlock.cache_control = { type: "ephemeral" }; - break; + // Find the last block that can have cache_control (not thinking blocks) + const validBlockTypes = ["text", "tool_use", "tool_result", "image"]; + for (let j = message.content.length - 1; j >= 0; j--) { + const block = message.content[j]; + if (validBlockTypes.includes(block.type)) { + block.cache_control = { type: "ephemeral" }; + break; + } } + break; } } } @@ -141,13 +146,13 @@ Respond ONLY with the JSON object, no other text.`); const toolData = toolType === "function" && tool.function ? tool.function : tool; const originalName = toolData.name; - + // Claude OAuth requires prefixed tool names to avoid conflicts const toolName = CLAUDE_OAUTH_TOOL_PREFIX + originalName; - + // Store mapping for response translation (prefixed → original) toolNameMap.set(toolName, originalName); - + result.tools.push({ name: toolName, description: toolData.description || "", @@ -235,6 +240,10 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map()) { } else if (part.type === "tool_use") { // Tool name already has prefix from tool declarations, keep as-is blocks.push({ type: "tool_use", id: part.id, name: part.name, input: part.input }); + } else if (part.type === "thinking") { + // Include thinking block but strip cache_control (not allowed on thinking blocks) + const { cache_control, ...thinkingBlock } = part; + blocks.push(thinkingBlock); } } } else if (msg.content) { @@ -297,17 +306,17 @@ function tryParseJSON(str) { // OpenAI -> Claude format for Antigravity (without system prompt modifications) function openaiToClaudeRequestForAntigravity(model, body, stream) { const result = openaiToClaudeRequest(model, body, stream); - + // Remove Claude Code system prompt, keep only user's system messages if (result.system && Array.isArray(result.system)) { - result.system = result.system.filter(block => + result.system = result.system.filter(block => !block.text || !block.text.includes("You are Claude Code") ); if (result.system.length === 0) { delete result.system; } } - + // Strip prefix from tool names for Antigravity (doesn't use Claude OAuth) if (result.tools && Array.isArray(result.tools)) { result.tools = result.tools.map(tool => { @@ -320,14 +329,14 @@ function openaiToClaudeRequestForAntigravity(model, body, stream) { return tool; }); } - + // Strip prefix from tool_use in messages if (result.messages && Array.isArray(result.messages)) { result.messages = result.messages.map(msg => { if (!msg.content || !Array.isArray(msg.content)) { return msg; } - + const updatedContent = msg.content.map(block => { if (block.type === "tool_use" && block.name && block.name.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) { return { @@ -337,11 +346,11 @@ function openaiToClaudeRequestForAntigravity(model, body, stream) { } return block; }); - + return { ...msg, content: updatedContent }; }); } - + return result; } diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index bfa84d13..3f2be953 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -1,6 +1,6 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; -import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js"; +// import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js"; import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js"; import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js"; @@ -102,16 +102,16 @@ function openaiToGeminiBase(model, body, stream) { } else if (role === "assistant") { const parts = []; - // Thinking/reasoning → thought part with signature + // Thinking/reasoning → thought part if (msg.reasoning_content) { parts.push({ thought: true, text: msg.reasoning_content }); - parts.push({ - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, - text: "" - }); + // parts.push({ + // thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, + // text: "" + // }); } if (content) { @@ -128,7 +128,7 @@ function openaiToGeminiBase(model, body, stream) { const args = tryParseJSON(tc.function?.arguments || "{}"); parts.push({ - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, + // thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, functionCall: { id: tc.id, name: sanitizeGeminiFunctionName(tc.function.name), diff --git a/open-sse/utils/claudeHeaderCache.js b/open-sse/utils/claudeHeaderCache.js new file mode 100644 index 00000000..d381b1ef --- /dev/null +++ b/open-sse/utils/claudeHeaderCache.js @@ -0,0 +1,69 @@ +/** + * 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", + "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/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js index c97c909b..2e7b730a 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.js @@ -85,11 +85,11 @@ function getEnvProxyUrl(targetUrl) { if (protocol === "https:") { return process.env.HTTPS_PROXY || process.env.https_proxy || - process.env.ALL_PROXY || process.env.all_proxy; + process.env.ALL_PROXY || process.env.all_proxy; } return process.env.HTTP_PROXY || process.env.http_proxy || - process.env.ALL_PROXY || process.env.all_proxy; + process.env.ALL_PROXY || process.env.all_proxy; } /** @@ -100,7 +100,7 @@ function normalizeProxyUrl(proxyUrl) { if (!normalizedInput) return null; try { - // eslint-disable-next-line no-new + new URL(normalizedInput); return normalizedInput; } catch { diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 78da98b8..bd45748a 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -7,6 +7,7 @@ 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"; @@ -41,6 +42,7 @@ export async function handleChat(request, clientRawRequest = null) { headers: Object.fromEntries(request.headers.entries()) }; } + cacheClaudeHeaders(clientRawRequest.headers); // Log request endpoint and model const url = new URL(request.url); diff --git a/tests/unit/claude-header-forwarding.test.js b/tests/unit/claude-header-forwarding.test.js new file mode 100644 index 00000000..acad66a9 --- /dev/null +++ b/tests/unit/claude-header-forwarding.test.js @@ -0,0 +1,417 @@ +/** + * Unit tests for Anthropic header caching + 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(): anthropic-compatible non-Anthropic host stripping + * - default.js buildHeaders(): anthropic-compatible official host keeps headers + * - proxyFetch.js: api.anthropic.com routes through anthropicFetch path + */ + +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", () => { + let DefaultExecutor; + + 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", () => { + 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"); + }); + + it("removes conflicting Title-Case static keys when cached lowercase keys exist", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ apiKey: "sk-test" }, true); + + // 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("sets x-api-key auth when apiKey is provided", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ apiKey: "sk-live-key" }, true); + expect(headers["x-api-key"]).toBe("sk-live-key"); + expect(headers["Authorization"]).toBeUndefined(); + }); + + it("sets Bearer Authorization when only accessToken is provided", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ accessToken: "tok-abc" }, true); + expect(headers["Authorization"]).toBe("Bearer tok-abc"); + expect(headers["x-api-key"]).toBeUndefined(); + }); + + it("includes Accept: text/event-stream when stream=true", () => { + const executor = new DefaultExecutor("claude"); + const headers = executor.buildHeaders({ apiKey: "k" }, true); + expect(headers["Accept"]).toBe("text/event-stream"); + }); + + it("omits Accept: text/event-stream when stream=false", () => { + const executor = new DefaultExecutor("claude"); + 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", () => { + const executor = new DefaultExecutor("claude"); + expect(() => executor.buildHeaders({ apiKey: "sk" }, false)).not.toThrow(); + }); +}); + +// ─── anthropic-compatible header stripping ──────────────────────────────────── + +describe("DefaultExecutor.buildHeaders() — anthropic-compatible stripping", () => { + let DefaultExecutor; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("open-sse/executors/default.js"); + DefaultExecutor = mod.DefaultExecutor || mod.default; + }); + + it("strips x-app and anthropic-dangerous-direct-browser-access for non-Anthropic host", () => { + const executor = new DefaultExecutor("anthropic-compatible-custom"); + const headers = executor.buildHeaders( + { + apiKey: "key", + providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" }, + }, + true + ); + + expect(headers["x-app"]).toBeUndefined(); + expect(headers["X-App"]).toBeUndefined(); + expect(headers["anthropic-dangerous-direct-browser-access"]).toBeUndefined(); + expect(headers["Anthropic-Dangerous-Direct-Browser-Access"]).toBeUndefined(); + }); + + it("removes claude-code-20250219 from anthropic-beta for non-Anthropic host", () => { + const executor = new DefaultExecutor("anthropic-compatible-custom"); + const headers = executor.buildHeaders( + { + apiKey: "key", + providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" }, + }, + true + ); + + const betaVal = headers["anthropic-beta"] || headers["Anthropic-Beta"] || ""; + expect(betaVal).not.toContain("claude-code-20250219"); + }); + + it("keeps other beta flags intact after stripping", () => { + const executor = new DefaultExecutor("anthropic-compatible-custom"); + // The static CLAUDE_API_HEADERS used by anthropic-compatible providers include + // 'interleaved-thinking-2025-05-14' — check it survives stripping + const headers = executor.buildHeaders( + { + apiKey: "key", + providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" }, + }, + false + ); + + const betaVal = headers["anthropic-beta"] || headers["Anthropic-Beta"] || ""; + // If any beta value remains it should not be empty and should not have the stripped value + if (betaVal) { + expect(betaVal).not.toContain("claude-code-20250219"); + } + }); + + it("does NOT strip headers when baseUrl is api.anthropic.com", () => { + const executor = new DefaultExecutor("anthropic-compatible-official"); + const headers = executor.buildHeaders( + { + apiKey: "key", + providerSpecificData: { baseUrl: "https://api.anthropic.com/v1" }, + }, + true + ); + + // No stripping — anthropic-version should survive + const hasVersion = + headers["Anthropic-Version"] || headers["anthropic-version"]; + expect(hasVersion).toBeDefined(); + }); + + it("does NOT strip headers when baseUrl is empty (defaults to Anthropic)", () => { + const executor = new DefaultExecutor("anthropic-compatible-official"); + const headers = executor.buildHeaders( + { + apiKey: "key", + providerSpecificData: {}, + }, + true + ); + + const hasVersion = + headers["Anthropic-Version"] || headers["anthropic-version"]; + expect(hasVersion).toBeDefined(); + }); +}); + +// ─── proxyFetch anthropicFetch routing ──────────────────────────────────────── + +describe("proxyAwareFetch — api.anthropic.com routing", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("routes api.anthropic.com to gotScraping (non-streaming) and returns ok response", async () => { + // Mock got-scraping before module load + vi.doMock("got-scraping", () => { + const mockGotScraping = vi.fn().mockResolvedValue({ + statusCode: 200, + statusMessage: "OK", + headers: { "content-type": "application/json" }, + rawBody: Buffer.from(JSON.stringify({ id: "msg_test" })), + }); + mockGotScraping.stream = vi.fn(); + return { gotScraping: mockGotScraping }; + }); + + vi.resetModules(); + const { proxyAwareFetch } = await import("open-sse/utils/proxyFetch.js"); + const { gotScraping } = await import("got-scraping"); + + const res = await proxyAwareFetch("https://api.anthropic.com/v1/messages", { + method: "POST", + // No Accept: text/event-stream → non-streaming path + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "claude-3-5-sonnet-20241022", messages: [] }), + }); + + expect(gotScraping).toHaveBeenCalledOnce(); + expect(res.ok).toBe(true); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.id).toBe("msg_test"); + }); + + it("falls back gracefully when got-scraping throws on non-streaming path", async () => { + vi.doMock("got-scraping", () => { + const fn = vi.fn().mockRejectedValue(new Error("TLS error")); + fn.stream = vi.fn(); + return { gotScraping: fn }; + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers(), + body: null, + text: async () => "{}", + json: async () => ({}), + }); + + vi.resetModules(); + const { proxyAwareFetch } = await import("open-sse/utils/proxyFetch.js"); + + const res = await proxyAwareFetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + expect(res.ok).toBe(true); + globalThis.fetch = originalFetch; + }); + + it("does NOT route non-Anthropic hosts through gotScraping", async () => { + const gotScrapingMock = vi.fn(); + vi.doMock("got-scraping", () => ({ gotScraping: gotScrapingMock })); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers(), + body: null, + text: async () => "{}", + json: async () => ({}), + }); + + vi.resetModules(); + const { proxyAwareFetch } = await import("open-sse/utils/proxyFetch.js"); + + await proxyAwareFetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + expect(gotScrapingMock).not.toHaveBeenCalled(); + }); +});