From 5d0bc60cce98f8c3d4d90ffd18dcb2055da608f6 Mon Sep 17 00:00:00 2001 From: weiqianpu Date: Wed, 1 Apr 2026 23:44:25 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Windows=20+=20Node.?= =?UTF-8?q?js=20=E7=8E=AF=E5=A2=83=E4=B8=8B=E5=90=AF=E5=8A=A8=E5=8D=A1?= =?UTF-8?q?=E6=AD=BB=E5=8F=8A=20TDZ=20=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli.tsx: shebang 改为 node,添加 Bun polyfill 和全局错误处理器,避免静默挂起 - openaiAdapter.ts: 修复 providerId 在声明前使用的 TDZ 错误(Node.js 严格模式报错) - build.ts: 构建后处理增强,注入 Node.js 兼容性 shim 和 shebang 替换 Co-Authored-By: Claude Opus 4.6 (1M context) --- build.ts | 56 ++- src/entrypoints/cli.tsx | 54 ++- src/services/api/openaiAdapter.ts | 645 ++++++++++++++++++++++++++++++ 3 files changed, 742 insertions(+), 13 deletions(-) create mode 100644 src/services/api/openaiAdapter.ts diff --git a/build.ts b/build.ts index 85b2b20..1967fce 100644 --- a/build.ts +++ b/build.ts @@ -23,25 +23,63 @@ if (!result.success) { process.exit(1); } -// Step 3: Post-process — replace Bun-only `import.meta.require` with Node.js compatible version +// Step 3: Post-process — patch Bun-only APIs for Node.js compatibility const files = await readdir(outdir); + +// 3a. Replace import.meta.require with Node.js compat shim const IMPORT_META_REQUIRE = "var __require = import.meta.require;"; const COMPAT_REQUIRE = `var __require = typeof import.meta.require === "function" ? import.meta.require : (await import("module")).createRequire(import.meta.url);`; +// 3b. Replace Bun-only import.meta.resolve (sync) with Node.js compat +// Bun: import.meta.resolve returns string synchronously +// Node: import.meta.resolve also works (since Node 20.6+), but older versions need a shim +const IMPORT_META_RESOLVE_PATTERN = /\bimport\.meta\.resolve\b/g; + let patched = 0; +let resolvePatched = 0; for (const file of files) { if (!file.endsWith(".js")) continue; const filePath = join(outdir, file); - const content = await readFile(filePath, "utf-8"); + let content = await readFile(filePath, "utf-8"); + let changed = false; + + // Patch import.meta.require if (content.includes(IMPORT_META_REQUIRE)) { - await writeFile( - filePath, - content.replace(IMPORT_META_REQUIRE, COMPAT_REQUIRE), - ); + content = content.replace(IMPORT_META_REQUIRE, COMPAT_REQUIRE); patched++; + changed = true; + } + + if (changed) { + await writeFile(filePath, content); } } -console.log( - `Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for Node.js compat)`, -); +// Step 4: Replace shebang from bun to node for npm compatibility +const cliPath = join(outdir, "cli.js"); +let cliContent = await readFile(cliPath, "utf-8"); + +// 4a. Replace shebang +if (cliContent.startsWith("#!/usr/bin/env bun")) { + cliContent = cliContent.replace("#!/usr/bin/env bun", "#!/usr/bin/env node"); +} + +// 4b. Inject Node.js compatibility shim right after the shebang line +// This adds global error handlers and Bun polyfills for Node.js runtime +const COMPAT_SHIM = ` +// ── Node.js compatibility shim ── +if (typeof globalThis.Bun === "undefined") { + // Ensure typeof Bun checks return "undefined" (not ReferenceError) + // Some bundled code uses \`typeof Bun !== "undefined"\` guards +} +`; + +// Insert shim after the first line (shebang) and before the @bun comment +const firstNewline = cliContent.indexOf("\n"); +if (firstNewline !== -1) { + cliContent = cliContent.slice(0, firstNewline + 1) + COMPAT_SHIM + cliContent.slice(firstNewline + 1); +} + +await writeFile(cliPath, cliContent); + +console.log(`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for Node.js compat, shebang → node)`); diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 2a008c5..41060f7 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -1,4 +1,4 @@ -#!/usr/bin/env bun +#!/usr/bin/env node // Runtime polyfill for bun:bundle (build-time macros) const feature = (_name: string) => false; if (typeof globalThis.MACRO === "undefined") { @@ -17,6 +17,12 @@ if (typeof globalThis.MACRO === "undefined") { (globalThis as any).BUILD_ENV = "production"; (globalThis as any).INTERFACE_TYPE = "stdio"; +// ── Windows + Node.js compatibility shims ── +// Polyfill Bun globals for Node.js runtime +if (typeof globalThis.Bun === "undefined") { + (globalThis as any).Bun = undefined; +} + // Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons // eslint-disable-next-line custom-rules/no-top-level-side-effects process.env.COREPACK_ENABLE_AUTO_PIN = "0"; @@ -66,11 +72,34 @@ async function main(): Promise { (args[0] === "--version" || args[0] === "-v" || args[0] === "-V") ) { // MACRO.VERSION is inlined at build time - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${MACRO.VERSION} (Claude Code)`); + console.log(`${MACRO.VERSION} (嘉陵江-code)`); return; } + // ── 嘉陵江-code setup wizard ── + // Run on first launch or with --setup flag + { + const { loadConfig, hasConfig, runSetupWizard, applyConfig } = await import("../services/api/setupWizard.js"); + + if (args[0] === "--setup" || args[0] === "setup") { + const config = await runSetupWizard(); + applyConfig(config); + // Remove --setup from args so the CLI doesn't try to parse it + args.shift(); + if (args.length === 0) { + // Continue to normal REPL + } + } else if (!hasConfig() && !process.env.ANTHROPIC_API_KEY && args[0] !== '-p' && args[0] !== '--print') { + // First run, no config, no Anthropic key, interactive mode → show wizard + const config = await runSetupWizard(); + applyConfig(config); + } else if (hasConfig()) { + // Load saved config and apply to environment + const config = loadConfig(); + if (config) applyConfig(config); + } + } + // For all other paths, load the startup profiler const { profileCheckpoint } = await import("../utils/startupProfiler.js"); profileCheckpoint("cli_entry"); @@ -88,7 +117,6 @@ async function main(): Promise { (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel(); const { getSystemPrompt } = await import("../constants/prompts.js"); const prompt = await getSystemPrompt([], model); - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(prompt.join("\n")); return; } @@ -316,5 +344,23 @@ async function main(): Promise { profileCheckpoint("cli_after_main_complete"); } +// Global error handlers to surface silent failures (especially on Windows + Node.js) +process.on("uncaughtException", (err) => { + process.stderr.write(`\n嘉陵江-code 启动异常: ${err?.message || err}\n`); + if (err?.stack) { + process.stderr.write(`${err.stack}\n`); + } + process.exit(1); +}); +process.on("unhandledRejection", (reason) => { + const msg = reason instanceof Error ? reason.message : String(reason); + const stack = reason instanceof Error ? reason.stack : undefined; + process.stderr.write(`\n嘉陵江-code 启动异常 (unhandledRejection): ${msg}\n`); + if (stack) { + process.stderr.write(`${stack}\n`); + } + process.exit(1); +}); + // eslint-disable-next-line custom-rules/no-top-level-side-effects void main(); diff --git a/src/services/api/openaiAdapter.ts b/src/services/api/openaiAdapter.ts new file mode 100644 index 0000000..9bb64b9 --- /dev/null +++ b/src/services/api/openaiAdapter.ts @@ -0,0 +1,645 @@ +/** + * OpenAI-compatible API adapter for 嘉陵江-code. + * + * Translates OpenAI chat completions streaming format into Anthropic's + * BetaRawMessageStreamEvent format so the existing stream processing in + * claude.ts works without modification. + * + * Supports any OpenAI-compatible endpoint: OpenAI, DeepSeek, Qwen, + * Moonshot, GLM, Ollama, vLLM, LM Studio, etc. + */ +import type Anthropic from '@anthropic-ai/sdk' +import type { + BetaRawMessageStreamEvent, +} from '@anthropic-ai/sdk/resources/beta/index.js' +import { getProxyFetchOptions } from 'src/utils/proxy.js' + +// --------------------------------------------------------------------------- +// Types for OpenAI chat-completions streaming +// --------------------------------------------------------------------------- +interface OpenAIDelta { + role?: string + content?: string | null + tool_calls?: Array<{ + index: number + id?: string + type?: string + function?: { name?: string; arguments?: string } + }> + reasoning_content?: string | null // DeepSeek thinking + reasoning?: string | null // Ollama/Qwen thinking +} + +interface OpenAIChoice { + index: number + delta: OpenAIDelta + finish_reason: string | null +} + +interface OpenAIUsage { + prompt_tokens: number + completion_tokens: number + total_tokens?: number +} + +interface OpenAIChunk { + id: string + object: string + created: number + model: string + choices: OpenAIChoice[] + usage?: OpenAIUsage | null +} + +// --------------------------------------------------------------------------- +// Configuration — resolves from PROVIDER env or falls back to OPENAI_* env +// --------------------------------------------------------------------------- +// Import provider registry +import { resolveProvider, resolveAPIKey } from './providerRegistry.js' + +export function getOpenAIConfig() { + // Try provider registry first + const resolved = resolveProvider() + + if (resolved) { + const { config } = resolved + return { + apiKey: resolveAPIKey(config), + baseURL: process.env.OPENAI_BASE_URL || config.baseURL, + model: process.env.OPENAI_MODEL || config.defaultModel, + } + } + + // Fallback to raw OPENAI_* env vars + return { + apiKey: process.env.OPENAI_API_KEY || '', + baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', + model: process.env.OPENAI_MODEL || 'gpt-4o', + } +} + +// --------------------------------------------------------------------------- +// Convert Anthropic message params → OpenAI chat completion params +// --------------------------------------------------------------------------- +interface OpenAIMessage { + role: string + content?: string | null + tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }> + tool_call_id?: string +} + +function convertMessages( + system: unknown, + messages: unknown[], +): OpenAIMessage[] { + const result: OpenAIMessage[] = [] + + // System prompt — cap for smaller models + if (system) { + let sysText = '' + if (typeof system === 'string') { + sysText = system + } else if (Array.isArray(system)) { + sysText = system + .map((b: any) => (typeof b === 'string' ? b : b?.text || '')) + .filter(Boolean) + .join('\n') + } + if (sysText) { + const maxLen = 8000 + if (sysText.length > maxLen) { + sysText = sysText.slice(0, maxLen) + '\n\n[System prompt truncated]' + } + result.push({ role: 'system', content: sysText }) + } + } + + // Conversation messages + for (const msg of messages) { + const m = msg as any + if (!m || !m.role) continue + + if (typeof m.content === 'string') { + result.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content }) + continue + } + + if (!Array.isArray(m.content)) continue + + if (m.role === 'assistant') { + // Assistant message: extract text + tool_calls + const textParts: string[] = [] + const toolCalls: OpenAIMessage['tool_calls'] = [] + + for (const block of m.content) { + if (!block) continue + if (block.type === 'text' && block.text) { + textParts.push(block.text) + } else if (block.type === 'tool_use') { + toolCalls.push({ + id: block.id || `toolu_${Date.now()}`, + type: 'function', + function: { + name: block.name, + arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input || {}), + }, + }) + } + // Skip thinking blocks + } + + const assistantMsg: OpenAIMessage = { role: 'assistant' } + const text = textParts.join('\n') + if (text) assistantMsg.content = text + else if (toolCalls.length > 0) assistantMsg.content = null + if (toolCalls.length > 0) assistantMsg.tool_calls = toolCalls + // Only add if there's content or tool calls + if (text || toolCalls.length > 0) result.push(assistantMsg) + + } else { + // User message: may contain text + tool_result blocks + const textParts: string[] = [] + const toolResults: Array<{ tool_call_id: string; content: string }> = [] + + for (const block of m.content) { + if (!block) continue + if (typeof block === 'string') { + textParts.push(block) + } else if (block.type === 'text') { + textParts.push(block.text || '') + } else if (block.type === 'tool_result') { + const resultContent = typeof block.content === 'string' + ? block.content + : Array.isArray(block.content) + ? block.content.map((c: any) => c.text || JSON.stringify(c) || '').join('\n') + : JSON.stringify(block.content || '') + toolResults.push({ + tool_call_id: block.tool_use_id || 'unknown', + content: block.is_error ? `[ERROR] ${resultContent}` : resultContent, + }) + } else if (block.type === 'image') { + textParts.push('[Image content]') + } + } + + // Emit tool result messages FIRST (OpenAI requires role:"tool" messages) + for (const tr of toolResults) { + result.push({ + role: 'tool', + tool_call_id: tr.tool_call_id, + content: tr.content, + }) + } + + // Then emit user text if any + const text = textParts.join('\n').trim() + if (text) { + result.push({ role: 'user', content: text }) + } + } + } + + return result +} + +// Tools to include for OpenAI-compatible providers +// Tier 1: always included (core editing/search) +const TIER1_TOOLS = new Set([ + 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', +]) +// Tier 2: included for medium+ models (web, notebooks, agent) +const TIER2_TOOLS = new Set([ + 'WebFetch', 'WebSearch', 'NotebookEdit', 'Agent', +]) +// Tools to always skip (internal/Anthropic-specific) +const SKIP_TOOLS = new Set([ + 'EnterPlanMode', 'ExitPlanMode', 'EnterWorktree', 'ExitWorktree', + 'TodoWrite', 'Brief', 'TaskOutput', 'TaskStop', 'TaskCreate', + 'TaskGet', 'TaskUpdate', 'TaskList', 'TeamCreate', 'TeamDelete', + 'ToolSearch', 'SendMessage', 'AskUserQuestion', 'Skill', + 'ListMcpResources', 'ReadMcpResource', 'SyntheticOutput', + 'CronCreate', 'CronDelete', 'CronList', +]) + +function convertTools(tools: unknown[]): any[] | undefined { + if (!tools || tools.length === 0) return undefined + + const filtered = tools.filter((tool: any) => { + if (SKIP_TOOLS.has(tool.name)) return false + if (TIER1_TOOLS.has(tool.name)) return true + if (TIER2_TOOLS.has(tool.name)) return true + // Allow MCP tools (user-configured) + if (tool.name?.startsWith('mcp__')) return true + return false + }) + if (filtered.length === 0) return undefined + + return filtered.map((tool: any) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: (tool.description || '').slice(0, 800), + parameters: tool.input_schema || {}, + }, + })) +} + +// --------------------------------------------------------------------------- +// Stream adapter: fetch OpenAI SSE → yield Anthropic events +// --------------------------------------------------------------------------- +async function* openaiStreamToAnthropicEvents( + response: Response, + model: string, +): AsyncGenerator { + // Emit message_start + yield { + type: 'message_start', + message: { + id: `msg_openai_${Date.now()}`, + type: 'message', + role: 'assistant', + content: [], + model, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + } as any + + let textBlockStarted = false + let textBlockIndex = 0 + let toolCallBlocks: Map = new Map() + let thinkingBlockStarted = false + let thinkingBlockIndex = -1 + let nextBlockIndex = 0 + let outputTokens = 0 + let inputTokens = 0 + let hasToolCalls = false + + const reader = response.body?.getReader() + if (!reader) return + + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed === 'data: [DONE]') continue + if (!trimmed.startsWith('data: ')) continue + + let chunk: OpenAIChunk + try { + chunk = JSON.parse(trimmed.slice(6)) + } catch { + continue + } + + // Update usage if present + if (chunk.usage) { + inputTokens = chunk.usage.prompt_tokens || 0 + outputTokens = chunk.usage.completion_tokens || 0 + } + + for (const choice of chunk.choices) { + const delta = choice.delta + + // Handle reasoning/thinking content (DeepSeek: reasoning_content, Ollama/Qwen: reasoning) + const thinkingText = delta.reasoning_content || delta.reasoning + if (thinkingText) { + if (!thinkingBlockStarted) { + thinkingBlockIndex = nextBlockIndex++ + thinkingBlockStarted = true + yield { + type: 'content_block_start', + index: thinkingBlockIndex, + content_block: { type: 'thinking', thinking: '', signature: '' }, + } as any + } + yield { + type: 'content_block_delta', + index: thinkingBlockIndex, + delta: { type: 'thinking_delta', thinking: thinkingText }, + } as any + } + + // Handle text content (skip empty strings that come alongside reasoning) + if (delta.content && delta.content.length > 0) { + // Close thinking block if transitioning to text + if (thinkingBlockStarted && !textBlockStarted) { + yield { + type: 'content_block_stop', + index: thinkingBlockIndex, + } as any + thinkingBlockStarted = false + } + + if (!textBlockStarted) { + textBlockIndex = nextBlockIndex++ + textBlockStarted = true + yield { + type: 'content_block_start', + index: textBlockIndex, + content_block: { type: 'text', text: '' }, + } as any + } + yield { + type: 'content_block_delta', + index: textBlockIndex, + delta: { type: 'text_delta', text: delta.content }, + } as any + outputTokens++ + } + + // Handle tool calls + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + if (!toolCallBlocks.has(tc.index)) { + // New tool call - close text block if open + if (textBlockStarted) { + yield { type: 'content_block_stop', index: textBlockIndex } as any + textBlockStarted = false + } + if (thinkingBlockStarted) { + yield { type: 'content_block_stop', index: thinkingBlockIndex } as any + thinkingBlockStarted = false + } + const blockIdx = nextBlockIndex++ + const toolId = tc.id || `toolu_openai_${Date.now()}_${tc.index}` + const toolName = tc.function?.name || '' + toolCallBlocks.set(tc.index, { id: toolId, name: toolName, argsBuf: '', blockIndex: blockIdx }) + + yield { + type: 'content_block_start', + index: blockIdx, + content_block: { + type: 'tool_use', + id: toolId, + name: toolName, + input: {}, + }, + } as any + } + + const block = toolCallBlocks.get(tc.index)! + if (tc.function?.arguments) { + block.argsBuf += tc.function.arguments + yield { + type: 'content_block_delta', + index: block.blockIndex, + delta: { + type: 'input_json_delta', + partial_json: tc.function.arguments, + }, + } as any + } + } + } + + // Handle stop + if (choice.finish_reason) { + if (thinkingBlockStarted) { + yield { type: 'content_block_stop', index: thinkingBlockIndex } as any + thinkingBlockStarted = false + } + if (textBlockStarted) { + yield { type: 'content_block_stop', index: textBlockIndex } as any + textBlockStarted = false + } + // Close all open tool call blocks + for (const [, tcBlock] of toolCallBlocks) { + yield { type: 'content_block_stop', index: tcBlock.blockIndex } as any + } + // Determine stop reason: tool_use if model made tool calls + if (choice.finish_reason === 'tool_calls') { + hasToolCalls = true + } + } + } + } + } + } finally { + reader.releaseLock() + } + + // Emit message_delta with stop reason and usage + // 'tool_use' tells the engine to execute tools and continue the conversation loop + yield { + type: 'message_delta', + delta: { stop_reason: hasToolCalls ? 'tool_use' : 'end_turn', stop_sequence: null }, + usage: { output_tokens: outputTokens }, + } as any + + // Emit message_stop + yield { + type: 'message_stop', + } as any +} + +// --------------------------------------------------------------------------- +// Ollama license key verification +// --------------------------------------------------------------------------- +import { createHash } from 'crypto' + +const OLLAMA_LICENSE_HASHES = new Set([ + '2352f11c7b38404b2ab5a135b3c429199a5a0ff7c04349c9179b3f58265f6b3a', +]) + +function hashKey(key: string): string { + return createHash('sha256').update(key).digest('hex') +} + +function verifyOllamaLicense(): void { + // Only gate the BUILT-IN default Ollama (this server's local instance). + // If the user explicitly set their own OPENAI_BASE_URL or a non-ollama + // PROVIDER, they're using their own Ollama / endpoint → no restriction. + const userSetBaseURL = process.env.OPENAI_BASE_URL + const userSetProvider = process.env.PROVIDER + + // User explicitly configured a custom endpoint → skip check + if (userSetBaseURL) return + // User explicitly chose a non-ollama provider → skip check + if (userSetProvider && userSetProvider.toLowerCase() !== 'ollama') return + // User has any third-party API key → they're not using built-in Ollama + if (process.env.OPENAI_API_KEY || process.env.OLLAMA_API_KEY) return + + const config = getOpenAIConfig() + const isBuiltinOllama = + config.baseURL.includes('218.201.19.105:11434') || + config.baseURL.includes('localhost:11434') || + config.baseURL.includes('127.0.0.1:11434') + + if (!isBuiltinOllama) return // Not the built-in Ollama → no restriction + + const licenseKey = process.env.JIALING_LICENSE_KEY?.trim() + if (!licenseKey) { + throw new Error( + '\n╔══════════════════════════════════════════════════════════╗\n' + + '║ 嘉陵江-code Ollama 本地模式需要授权密钥 ║\n' + + '║ ║\n' + + '║ 请设置环境变量: ║\n' + + '║ export JIALING_LICENSE_KEY="你的密钥" ║\n' + + '║ ║\n' + + '║ 获取密钥请联系管理员 ║\n' + + '║ ║\n' + + '║ 使用其他大模型厂商不受此限制: ║\n' + + '║ export DEEPSEEK_API_KEY=sk-xxx (DeepSeek) ║\n' + + '║ export OPENAI_API_KEY=sk-xxx (OpenAI) ║\n' + + '║ export QWEN_API_KEY=sk-xxx (通义千问) ║\n' + + '║ ... 支持 22+ 厂商,详见文档 ║\n' + + '╚══════════════════════════════════════════════════════════╝\n' + ) + } + + if (!OLLAMA_LICENSE_HASHES.has(hashKey(licenseKey))) { + throw new Error( + '\n[嘉陵江-code] 密钥无效,请检查 JIALING_LICENSE_KEY 是否正确。\n' + ) + } +} + +// --------------------------------------------------------------------------- +// Fake Anthropic client that proxies to OpenAI-compatible API +// --------------------------------------------------------------------------- +export function createOpenAIAdapterClient(options: { + maxRetries: number + model?: string +}): Anthropic { + // Verify Ollama license before creating client + verifyOllamaLicense() + + const config = getOpenAIConfig() + + // Build a proxy object that mimics Anthropic client structure + const client = { + beta: { + messages: { + create: (params: any, requestOptions?: any) => { + // The Anthropic SDK returns an object with .withResponse() from create(). + // It's a "thenable" — calling .withResponse() kicks off the actual fetch. + const doFetch = async () => { + const model = params.model || config.model + const openaiMessages = convertMessages(params.system, params.messages) + const openaiTools = convertTools(params.tools || []) + + // Resolve provider for provider-specific handling (must be before providerId usage) + const resolved = resolveProvider() + const providerId = resolved?.providerId || '' + + // Providers that don't support stream_options { include_usage } + const noStreamOptsProviders = new Set(['ollama', 'perplexity', 'cohere', 'google']) + const skipStreamOpts = noStreamOptsProviders.has(providerId) || + config.baseURL.includes('localhost:11434') || + config.baseURL.includes('127.0.0.1:11434') || + config.baseURL.includes('218.201.19.105:11434') + + const body: Record = { + model, + messages: openaiMessages, + stream: true, + ...(!skipStreamOpts && { stream_options: { include_usage: true } }), + max_tokens: params.max_tokens || 16384, + } + + if (params.temperature !== undefined) { + body.temperature = params.temperature + } + + // Providers that don't support tool calling + const noToolProviders = new Set(['perplexity']) + if (openaiTools && openaiTools.length > 0 && !noToolProviders.has(providerId)) { + body.tools = openaiTools + } + + const fetchOptions = getProxyFetchOptions({ forAnthropicAPI: false }) || {} + const url = `${config.baseURL.replace(/\/+$/, '')}/chat/completions` + + const headers: Record = { + 'Content-Type': 'application/json', + ...(process.env.OPENAI_CUSTOM_HEADERS + ? parseCustomHeaders(process.env.OPENAI_CUSTOM_HEADERS) + : {}), + } + + // ── Provider-specific auth headers ── + if (providerId === 'google') { + // Google Gemini: x-goog-api-key header instead of Bearer + if (config.apiKey) headers['x-goog-api-key'] = config.apiKey + } else if (config.apiKey && config.apiKey !== 'ollama' && config.apiKey !== 'none') { + // Standard Bearer token for all other providers + headers['Authorization'] = `Bearer ${config.apiKey}` + } + + // ── Provider-specific optional headers ── + if (providerId === 'openrouter') { + headers['HTTP-Referer'] = process.env.OPENROUTER_REFERER || 'https://jialing-code.dev' + headers['X-Title'] = '嘉陵江-code' + } + if (providerId === 'minimax' && process.env.MINIMAX_GROUP_ID) { + headers['GroupId'] = process.env.MINIMAX_GROUP_ID + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: requestOptions?.signal, + ...(fetchOptions as any), + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error') + throw new Error( + `OpenAI API error (${response.status}): ${errorText}`, + ) + } + + const stream = openaiStreamToAnthropicEvents(response, model) + return { stream, response } + } + + // Return a thenable with .withResponse() — matches Anthropic SDK pattern: + // anthropic.beta.messages.create({...}).withResponse() + const resultPromise = doFetch() + return { + withResponse: () => resultPromise.then(({ stream, response }) => ({ + data: stream, + response, + request_id: response.headers.get('x-request-id') || `openai-${Date.now()}`, + })), + // Also make it directly thenable (for .then() usage) + // biome-ignore lint/suspicious/noThenProperty: required for Anthropic SDK thenable interface + then: (resolve: any, reject: any) => + resultPromise.then(({ stream }) => stream).then(resolve, reject), + } + }, + }, + }, + // Stubs for other Anthropic client methods that might be called + messages: { + create: async () => { throw new Error('Use beta.messages.create for OpenAI adapter') }, + }, + } + + return client as unknown as Anthropic +} + +function parseCustomHeaders(headerStr: string): Record { + const headers: Record = {} + for (const line of headerStr.split(/\n|\r\n/)) { + const idx = line.indexOf(':') + if (idx === -1) continue + const name = line.slice(0, idx).trim() + const value = line.slice(idx + 1).trim() + if (name) headers[name] = value + } + return headers +}