@xelauvas/xela-cli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/bin/xela.js +0 -12
  2. package/local/README.md +112 -0
  3. package/local/start-qwen.sh +47 -0
  4. package/local/start-tunnel.sh +36 -0
  5. package/local/xela-local.sh +139 -0
  6. package/package.json +9 -5
  7. package/run.sh +33 -0
  8. package/src/Tool.ts +1 -1
  9. package/src/_shims/_generated_stubs/_universal_stub.mjs +168 -0
  10. package/src/_shims/_generated_stubs/stub_346f96741420f62caece15b196e233c6.mjs +19 -0
  11. package/src/_shims/_generated_stubs/stub_4b79af2a6ac1af5a53a55fd666b2bbb4.mjs +3 -0
  12. package/src/_shims/_generated_stubs/stub_94345ddeadacc184ff895597ec5abf71.mjs +6 -0
  13. package/src/_shims/_generated_stubs/stub_99fa0bc272643c549d8a2ec6d578436f.mjs +3 -0
  14. package/src/_shims/_generated_stubs/stub_e3451b131fcaf272a2a75270b9a427a3.mjs +9 -0
  15. package/src/_shims/_generated_stubs/text_stub.mjs +1 -0
  16. package/src/_shims/bun_bundle.js +79 -4
  17. package/src/_shims/cjs_stub.cjs +32 -10
  18. package/src/_shims/loader.js +104 -5
  19. package/src/_shims/openai_adapter.ts +446 -230
  20. package/src/_shims/register.js +16 -3
  21. package/src/assistant/gate.ts +15 -0
  22. package/src/assistant/index.ts +89 -0
  23. package/src/bootstrap/state.ts +6 -1
  24. package/src/bridge/peerSessions.ts +18 -0
  25. package/src/cli/print.ts +5 -0
  26. package/src/commands/assistant/assistant.ts +17 -0
  27. package/src/commands/assistant/index.ts +11 -0
  28. package/src/commands/brief.ts +3 -1
  29. package/src/commands/buddy/buddy.ts +88 -0
  30. package/src/commands/buddy/index.ts +12 -0
  31. package/src/commands/model/model.tsx +8 -23
  32. package/src/commands/peers/index.ts +11 -0
  33. package/src/commands/peers/peers.ts +24 -0
  34. package/src/commands/proactive/proactive.ts +64 -0
  35. package/src/commands/proactive.ts +12 -0
  36. package/src/commands/workflows/index.ts +12 -0
  37. package/src/commands/workflows/workflows.ts +36 -0
  38. package/src/commands.ts +4 -8
  39. package/src/components/Messages.tsx +1 -1
  40. package/src/components/ModelPicker.tsx +4 -34
  41. package/src/components/Spinner.tsx +54 -1
  42. package/src/components/StructuredDiff/colorDiff.ts +3 -1
  43. package/src/components/messages/SnipBoundaryMessage.tsx +15 -0
  44. package/src/components/messages/UserCrossSessionMessage.tsx +26 -0
  45. package/src/components/permissions/PermissionRequest.tsx +4 -2
  46. package/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx +96 -0
  47. package/src/constants/prompts.ts +26 -0
  48. package/src/ink/terminal.ts +9 -1
  49. package/src/jobs/classifier.ts +103 -0
  50. package/src/memdir/paths.ts +13 -1
  51. package/src/proactive/index.ts +158 -0
  52. package/src/proactive/useProactive.ts +78 -0
  53. package/src/screens/REPL.tsx +28 -8
  54. package/src/services/api/claude.ts +6 -1
  55. package/src/services/compact/autoCompact.ts +18 -4
  56. package/src/services/compact/cachedMCConfig.ts +1 -0
  57. package/src/services/compact/cachedMicrocompact.ts +113 -0
  58. package/src/services/compact/compact.ts +22 -2
  59. package/src/services/compact/reactiveCompact.ts +251 -0
  60. package/src/services/compact/snipCompact.ts +282 -0
  61. package/src/services/compact/snipProjection.ts +50 -0
  62. package/src/services/contextCollapse/index.ts +322 -0
  63. package/src/services/contextCollapse/operations.ts +187 -0
  64. package/src/services/contextCollapse/persist.ts +40 -0
  65. package/src/services/extractMemories/extractMemories.ts +11 -2
  66. package/src/services/skillSearch/featureCheck.ts +12 -0
  67. package/src/services/skillSearch/localSearch.ts +98 -0
  68. package/src/services/skillSearch/prefetch.ts +125 -0
  69. package/src/services/skillSearch/remoteSkillLoader.ts +9 -0
  70. package/src/services/skillSearch/remoteSkillState.ts +21 -0
  71. package/src/services/skillSearch/signals.ts +7 -0
  72. package/src/services/skillSearch/telemetry.ts +9 -0
  73. package/src/setup.ts +10 -5
  74. package/src/skills/bundled/dream.ts +31 -0
  75. package/src/skills/bundled/hunter.ts +45 -0
  76. package/src/skills/bundled/index.ts +10 -23
  77. package/src/skills/bundled/runSkillGenerator.ts +61 -0
  78. package/src/skills/mcpSkills.ts +101 -0
  79. package/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts +301 -0
  80. package/src/tasks/MonitorMcpTask/MonitorMcpTask.ts +273 -0
  81. package/src/tasks.ts +22 -8
  82. package/src/tools/AgentTool/runAgent.ts +11 -0
  83. package/src/tools/BriefTool/BriefTool.ts +2 -0
  84. package/src/tools/CtxInspectTool/CtxInspectTool.ts +102 -0
  85. package/src/tools/DiscoverSkillsTool/DiscoverSkillsTool.ts +78 -0
  86. package/src/tools/DiscoverSkillsTool/prompt.ts +3 -0
  87. package/src/tools/ListPeersTool/ListPeersTool.ts +89 -0
  88. package/src/tools/MCPTool/MCPTool.ts +6 -0
  89. package/src/tools/MonitorTool/MonitorTool.ts +273 -0
  90. package/src/tools/MonitorTool/UI.tsx +25 -0
  91. package/src/tools/MonitorTool/events.ts +84 -0
  92. package/src/tools/MonitorTool/prompt.ts +45 -0
  93. package/src/tools/OverflowTestTool/OverflowTestTool.ts +73 -0
  94. package/src/tools/PushNotificationTool/PushNotificationTool.ts +119 -0
  95. package/src/tools/ReviewArtifactTool/ReviewArtifactTool.ts +93 -0
  96. package/src/tools/ReviewArtifactTool/prompt.ts +5 -0
  97. package/src/tools/SendUserFileTool/SendUserFileTool.ts +87 -0
  98. package/src/tools/SendUserFileTool/prompt.ts +7 -0
  99. package/src/tools/SleepTool/SleepTool.ts +126 -0
  100. package/src/tools/SnipTool/SnipTool.ts +90 -0
  101. package/src/tools/SnipTool/prompt.ts +5 -0
  102. package/src/tools/SubscribePRTool/SubscribePRTool.ts +115 -0
  103. package/src/tools/TerminalCaptureTool/TerminalCaptureTool.ts +129 -0
  104. package/src/tools/TerminalCaptureTool/prompt.ts +8 -0
  105. package/src/tools/ToolSearchTool/prompt.ts +1 -1
  106. package/src/tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.ts +99 -0
  107. package/src/tools/WebBrowserTool/WebBrowserPanel.tsx +10 -0
  108. package/src/tools/WebBrowserTool/WebBrowserTool.ts +190 -0
  109. package/src/tools/WebBrowserTool/prompt.ts +16 -0
  110. package/src/tools/WorkflowTool/UI.tsx +27 -0
  111. package/src/tools/WorkflowTool/WorkflowTool.ts +401 -0
  112. package/src/tools/WorkflowTool/bundled/index.ts +88 -0
  113. package/src/tools/WorkflowTool/constants.ts +7 -0
  114. package/src/tools/WorkflowTool/createWorkflowCommand.ts +84 -0
  115. package/src/tools/WorkflowTool/prompt.ts +38 -0
  116. package/src/tools/WorkflowTool/runtime.ts +331 -0
  117. package/src/tools.ts +36 -31
  118. package/src/utils/betas.ts +5 -0
  119. package/src/utils/context.ts +9 -0
  120. package/src/utils/conversationRecovery.ts +1 -1
  121. package/src/utils/forkedAgent.ts +4 -0
  122. package/src/utils/logoV2Utils.ts +7 -3
  123. package/src/utils/messageQueueManager.ts +23 -9
  124. package/src/utils/model/model.ts +10 -9
  125. package/src/utils/model/modelOptions.ts +0 -21
  126. package/src/utils/model/validateModel.ts +0 -19
  127. package/src/utils/permissions/permissionSetup.ts +5 -0
  128. package/src/utils/permissions/permissions.ts +25 -0
  129. package/src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt +38 -0
  130. package/src/utils/permissions/yolo-classifier-prompts/permissions_external.txt +25 -0
  131. package/src/utils/permissions/yoloClassifier.ts +28 -6
  132. package/src/utils/plugins/officialMarketplaceStartupCheck.ts +9 -0
  133. package/src/utils/systemThemeWatcher.ts +62 -0
  134. package/src/utils/udsClient.ts +115 -0
  135. package/src/utils/udsMessaging.ts +157 -0
  136. package/src/utils/yaml.ts +251 -5
  137. package/src/utils/model/providerModels.ts +0 -168
@@ -2,12 +2,17 @@
2
2
  * Multi-Provider Adapter - Makes any OpenAI-compatible API look like Anthropic SDK.
3
3
  *
4
4
  * Supports: OpenAI, Groq, OpenRouter, Moonshot/Kimi, DeepSeek, Ollama, Mistral,
5
- * Together AI, Fireworks, and any OpenAI-compatible endpoint.
5
+ * Together AI, Fireworks, llama.cpp and any OpenAI-compatible endpoint.
6
6
  *
7
7
  * Environment variables:
8
- * OPENAI_API_KEY - API key (required)
9
- * OPENAI_BASE_URL - Custom base URL (optional, auto-detected from key prefix)
10
- * OPENAI_MODEL - Model name (optional, auto-detected from provider)
8
+ * OPENAI_API_KEY - API key (required)
9
+ * OPENAI_BASE_URL - Custom base URL (optional, auto-detected from key prefix)
10
+ * OPENAI_MODEL - Model name (optional, auto-detected from provider)
11
+ * OPENAI_MAX_TOKENS - Cap on output tokens per request (optional, per-provider default)
12
+ * OPENAI_TEMPERATURE - Force a sampling temperature (optional; local servers keep their own default)
13
+ * OPENAI_THINKING - "on" | "off" to force reasoning for local servers (optional; follows the app otherwise)
14
+ * OPENAI_WEB_SEARCH - "off" to disable the DuckDuckGo fallback for the WebSearch tool (optional)
15
+ * OPENAI_DEBUG_LOG - Path of a JSONL file that receives every request/response summary (optional)
11
16
  *
12
17
  * Quick start examples:
13
18
  * # OpenAI (default)
@@ -19,23 +24,14 @@
19
24
  * # OpenRouter (many models, some free)
20
25
  * OPENAI_API_KEY=sk-or-... OPENAI_MODEL=google/gemini-2.0-flash-001
21
26
  *
22
- * # Moonshot/Kimi (cheap)
23
- * OPENAI_API_KEY=sk-... OPENAI_BASE_URL=https://api.moonshot.ai/v1 OPENAI_MODEL=moonshot-v1-32k
24
- *
25
27
  * # DeepSeek (very cheap)
26
28
  * OPENAI_API_KEY=sk-... OPENAI_BASE_URL=https://api.deepseek.com OPENAI_MODEL=deepseek-chat
27
29
  *
28
- * # Ollama (local, free)
29
- * OPENAI_API_KEY=ollama OPENAI_BASE_URL=http://localhost:11434/v1 OPENAI_MODEL=llama3.2
30
- *
31
- * # Mistral
32
- * OPENAI_API_KEY=... OPENAI_BASE_URL=https://api.mistral.ai/v1 OPENAI_MODEL=mistral-large-latest
33
- *
34
- * # Together AI
35
- * OPENAI_API_KEY=... OPENAI_BASE_URL=https://api.together.xyz/v1 OPENAI_MODEL=meta-llama/Llama-3.3-70B-Instruct-Turbo
30
+ * # llama.cpp / Ollama / LM Studio (local, free)
31
+ * OPENAI_API_KEY=local OPENAI_BASE_URL=http://localhost:8080/v1 OPENAI_MODEL=qwen3.6-27b
36
32
  */
37
33
  import OpenAI from 'openai'
38
- import { isClaudeModelName } from '../utils/model/providerModels.js'
34
+ import { appendFileSync } from 'fs'
39
35
 
40
36
  // Auto-detect provider from API key prefix or base URL
41
37
  function detectProvider(): { baseURL?: string; model: string; maxTokens: number } {
@@ -49,7 +45,8 @@ function detectProvider(): { baseURL?: string; model: string; maxTokens: number
49
45
  if (baseURL.includes('openrouter.ai')) return { baseURL, model: model || 'google/gemini-2.0-flash-001', maxTokens: 8192 }
50
46
  if (baseURL.includes('moonshot.cn') || baseURL.includes('moonshot.ai')) return { baseURL, model: model || 'moonshot-v1-32k', maxTokens: 4096 }
51
47
  if (baseURL.includes('deepseek.com')) return { baseURL, model: model || 'deepseek-chat', maxTokens: 8192 }
52
- if (baseURL.includes('localhost') || baseURL.includes('127.0.0.1')) return { baseURL, model: model || 'llama3.2', maxTokens: 4096 }
48
+ // Local servers (llama.cpp, Ollama, LM Studio): reasoning models spend thousands of tokens thinking before answering
49
+ if (isLocalUrl(baseURL)) return { baseURL, model: model || 'llama3.2', maxTokens: 32768 }
53
50
  if (baseURL.includes('mistral.ai')) return { baseURL, model: model || 'mistral-large-latest', maxTokens: 8192 }
54
51
  if (baseURL.includes('together.xyz')) return { baseURL, model: model || 'meta-llama/Llama-3.3-70B-Instruct-Turbo', maxTokens: 8192 }
55
52
  if (baseURL.includes('fireworks.ai')) return { baseURL, model: model || 'accounts/fireworks/models/llama-v3p3-70b-instruct', maxTokens: 8192 }
@@ -64,20 +61,57 @@ function detectProvider(): { baseURL?: string; model: string; maxTokens: number
64
61
  return { model: model || 'gpt-4o', maxTokens: 16384 }
65
62
  }
66
63
 
64
+ function isLocalUrl(url: string): boolean {
65
+ return /localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0/.test(url)
66
+ }
67
+
67
68
  const PROVIDER = detectProvider()
68
69
  const MODEL = PROVIDER.model
69
- const MAX_TOKENS = PROVIDER.maxTokens
70
+ const IS_LOCAL = !!PROVIDER.baseURL && isLocalUrl(PROVIDER.baseURL)
70
71
 
71
- /**
72
- * Resolve which model to send to the OpenAI-compatible provider.
73
- * - Non-Claude names pass through directly (e.g. 'gpt-4o', 'deepseek-chat')
74
- * - Claude names/aliases fall back to the provider default
75
- * - Null/undefined uses the startup default
76
- */
77
- function resolveModel(paramsModel: string | undefined): string {
78
- if (!paramsModel) return MODEL
79
- if (isClaudeModelName(paramsModel)) return MODEL
80
- return paramsModel
72
+ function envNumber(name: string): number | undefined {
73
+ const n = Number(process.env[name])
74
+ return process.env[name] && Number.isFinite(n) && n > 0 ? n : undefined
75
+ }
76
+
77
+ // OPENAI_MAX_TOKENS overrides the per-provider output cap
78
+ const MAX_TOKENS = envNumber('OPENAI_MAX_TOKENS') ?? PROVIDER.maxTokens
79
+
80
+ // OPENAI_TEMPERATURE forces a temperature. Local servers otherwise keep their own
81
+ // sampling defaults (tuned per model), instead of the Claude-oriented value the app sends.
82
+ function resolveTemperature(requested: number | undefined): number | undefined {
83
+ const forced = process.env.OPENAI_TEMPERATURE
84
+ if (forced !== undefined && forced !== '' && Number.isFinite(Number(forced))) return Number(forced)
85
+ return IS_LOCAL ? undefined : requested
86
+ }
87
+
88
+ // Reasoning on/off for local servers. Follows the app's thinking config (and the
89
+ // effort level) unless OPENAI_THINKING forces it. Sent as chat_template_kwargs,
90
+ // which llama.cpp understands; hosted providers would reject the unknown field.
91
+ function resolveThinking(params: any): boolean | undefined {
92
+ if (!IS_LOCAL) return undefined
93
+ const forced = (process.env.OPENAI_THINKING || '').toLowerCase()
94
+ if (forced === 'on' || forced === 'true' || forced === '1') return true
95
+ if (forced === 'off' || forced === 'false' || forced === '0') return false
96
+ if (!params.thinking || params.thinking.type === 'disabled') return false
97
+ if (params.output_config?.effort === 'low') return false
98
+ // Conversation compaction is a summarization job: reasoning adds minutes on a
99
+ // local model and nothing to the summary
100
+ if (isCompactionRequest(params)) return false
101
+ return true
102
+ }
103
+
104
+ function isCompactionRequest(params: any): boolean {
105
+ const last = [...(params.messages || [])].reverse().find((m: any) => m.role === 'user')
106
+ return !!last && /Your task is to create a detailed summary/.test(blockText(last.content))
107
+ }
108
+
109
+ function debugLog(entry: Record<string, unknown>): void {
110
+ const path = process.env.OPENAI_DEBUG_LOG
111
+ if (!path) return
112
+ try {
113
+ appendFileSync(path, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n')
114
+ } catch {}
81
115
  }
82
116
 
83
117
  let _openaiClient: OpenAI | null = null
@@ -96,6 +130,12 @@ function getOpenAI(): OpenAI {
96
130
  return _openaiClient
97
131
  }
98
132
 
133
+ function blockText(content: any): string {
134
+ if (typeof content === 'string') return content
135
+ if (Array.isArray(content)) return content.map((c: any) => (c?.type === 'text' ? c.text : c?.text || '')).join('\n')
136
+ return content == null ? '' : JSON.stringify(content)
137
+ }
138
+
99
139
  // Convert Anthropic-style messages to OpenAI format
100
140
  function convertMessages(messages: any[]): any[] {
101
141
  const result: any[] = []
@@ -111,17 +151,12 @@ function convertMessages(messages: any[]): any[] {
111
151
  parts.push({ type: 'text', text: block.text })
112
152
  } else if (block.type === 'tool_result') {
113
153
  // Tool results in Anthropic are content blocks, in OpenAI they're separate messages
114
- const content = typeof block.content === 'string'
115
- ? block.content
116
- : Array.isArray(block.content)
117
- ? block.content.map((c: any) => c.text || '').join('\n')
118
- : JSON.stringify(block.content || '')
154
+ // (they must directly follow the assistant tool_calls message, before any user text)
119
155
  result.push({
120
156
  role: 'tool',
121
157
  tool_call_id: block.tool_use_id,
122
- content,
158
+ content: blockText(block.content),
123
159
  })
124
- continue
125
160
  } else if (block.type === 'image') {
126
161
  parts.push({
127
162
  type: 'image_url',
@@ -138,10 +173,13 @@ function convertMessages(messages: any[]): any[] {
138
173
  result.push({ role: 'assistant', content: msg.content })
139
174
  } else if (Array.isArray(msg.content)) {
140
175
  let text = ''
176
+ let reasoning = ''
141
177
  const toolCalls: any[] = []
142
178
  for (const block of msg.content) {
143
179
  if (block.type === 'text') {
144
180
  text += block.text
181
+ } else if (block.type === 'thinking') {
182
+ reasoning += block.thinking || ''
145
183
  } else if (block.type === 'tool_use') {
146
184
  toolCalls.push({
147
185
  id: block.id,
@@ -152,11 +190,15 @@ function convertMessages(messages: any[]): any[] {
152
190
  },
153
191
  })
154
192
  }
155
- // Skip 'thinking' blocks
193
+ // redacted_thinking is dropped
156
194
  }
157
195
  const assistantMsg: any = { role: 'assistant' }
158
- if (text) assistantMsg.content = text
196
+ // A turn with only thinking still needs content, or strict servers reject the message
197
+ if (text || toolCalls.length === 0) assistantMsg.content = text
159
198
  if (toolCalls.length > 0) assistantMsg.tool_calls = toolCalls
199
+ // Reasoning models (Qwen3, DeepSeek) do better in multi-step tool loops when they
200
+ // see their own earlier reasoning; llama.cpp passes reasoning_content to the template
201
+ if (IS_LOCAL && reasoning) assistantMsg.reasoning_content = reasoning
160
202
  result.push(assistantMsg)
161
203
  }
162
204
  }
@@ -165,11 +207,17 @@ function convertMessages(messages: any[]): any[] {
165
207
  return result
166
208
  }
167
209
 
210
+ // Anthropic server-side tools (web_search_20250305, computer_20241022, ...) run inside
211
+ // Anthropic's API. They have no input schema and nothing on the other end here.
212
+ function isServerTool(tool: any): boolean {
213
+ return typeof tool?.type === 'string' && tool.type !== 'custom' && !tool.input_schema
214
+ }
215
+
168
216
  // Convert Anthropic tool schemas to OpenAI function format
169
217
  function convertTools(tools: any[]): any[] {
170
218
  if (!tools || tools.length === 0) return []
171
219
 
172
- return tools.map((tool: any) => {
220
+ return tools.filter((t: any) => !isServerTool(t)).map((tool: any) => {
173
221
  // Handle different Anthropic tool formats
174
222
  const name = tool.name || tool.function?.name
175
223
  const description = tool.description || tool.function?.description || ''
@@ -186,6 +234,17 @@ function convertTools(tools: any[]): any[] {
186
234
  }).filter((t: any) => t.function.name)
187
235
  }
188
236
 
237
+ function convertToolChoice(choice: any): any {
238
+ if (!choice) return undefined
239
+ switch (choice.type) {
240
+ case 'auto': return 'auto'
241
+ case 'any': return 'required'
242
+ case 'none': return 'none'
243
+ case 'tool': return { type: 'function', function: { name: choice.name } }
244
+ default: return undefined
245
+ }
246
+ }
247
+
189
248
  // Map OpenAI finish reason to Anthropic stop reason
190
249
  function mapStopReason(finishReason: string | null): string {
191
250
  switch (finishReason) {
@@ -197,15 +256,28 @@ function mapStopReason(finishReason: string | null): string {
197
256
  }
198
257
  }
199
258
 
200
- // Create a streaming response that mimics Anthropic's stream format
201
- async function* createAnthropicStyleStream(params: any, signal?: AbortSignal): AsyncGenerator<any> {
202
- const openai = getOpenAI()
203
- // Always use the configured MODEL — internal Claude model names don't exist on OpenAI-compatible providers
204
- const model = resolveModel(params.model)
259
+ function emptyUsage(): any {
260
+ return {
261
+ input_tokens: 0,
262
+ output_tokens: 0,
263
+ cache_creation_input_tokens: 0,
264
+ cache_read_input_tokens: 0,
265
+ server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
266
+ service_tier: 'standard',
267
+ cache_creation: {
268
+ ephemeral_1h_input_tokens: 0,
269
+ ephemeral_5m_input_tokens: 0,
270
+ },
271
+ inference_geo: '',
272
+ iterations: [],
273
+ speed: 'standard',
274
+ }
275
+ }
205
276
 
277
+ // Build the OpenAI request body from Anthropic-style params
278
+ function buildRequest(params: any, stream: boolean): any {
206
279
  const openaiMessages: any[] = []
207
280
 
208
- // Add system prompt
209
281
  if (params.system) {
210
282
  const systemText = typeof params.system === 'string'
211
283
  ? params.system
@@ -217,168 +289,333 @@ async function* createAnthropicStyleStream(params: any, signal?: AbortSignal): A
217
289
  }
218
290
  }
219
291
 
220
- // Convert messages
221
292
  openaiMessages.push(...convertMessages(params.messages || []))
222
293
 
223
- // Convert tools
224
- const tools = convertTools(params.tools || [])
225
-
226
- const requestParams: any = {
227
- model,
294
+ const request: any = {
295
+ // Always use the configured MODEL — internal Claude model names don't exist on OpenAI-compatible providers
296
+ model: MODEL,
228
297
  messages: openaiMessages,
229
- stream: true,
230
298
  max_completion_tokens: Math.min(params.max_tokens || 8192, MAX_TOKENS),
231
299
  }
300
+ if (stream) {
301
+ request.stream = true
302
+ // Ask for a final usage chunk so context tracking and auto-compact see real token counts
303
+ request.stream_options = { include_usage: true }
304
+ }
232
305
 
306
+ const tools = convertTools(params.tools || [])
233
307
  if (tools.length > 0) {
234
- requestParams.tools = tools
308
+ request.tools = tools
309
+ const toolChoice = convertToolChoice(params.tool_choice)
310
+ if (toolChoice !== undefined) request.tool_choice = toolChoice
235
311
  }
236
312
 
237
- if (params.temperature !== undefined) {
238
- requestParams.temperature = params.temperature
313
+ const temperature = resolveTemperature(params.temperature)
314
+ if (temperature !== undefined) request.temperature = temperature
315
+
316
+ const thinking = resolveThinking(params)
317
+ if (thinking !== undefined) request.chat_template_kwargs = { enable_thinking: thinking }
318
+
319
+ return request
320
+ }
321
+
322
+ function summarizeRequest(request: any): Record<string, unknown> {
323
+ const messages: any[] = request.messages || []
324
+ // OPENAI_DEBUG_FULL=1 logs the complete message list (large)
325
+ if (process.env.OPENAI_DEBUG_FULL) {
326
+ return request
239
327
  }
328
+ return {
329
+ ...request,
330
+ messages: `${messages.length} messages`,
331
+ reasoning_msgs: messages.filter(m => m.reasoning_content).length,
332
+ tools: (request.tools || []).map((t: any) => t.function.name),
333
+ }
334
+ }
335
+
336
+ // Re-throw provider errors as Anthropic APIError so the app's retry and
337
+ // compaction logic recognizes them
338
+ async function toAnthropicError(err: any): Promise<Error> {
339
+ const { APIError } = await import('@anthropic-ai/sdk')
340
+ const status = err?.status || 500
341
+ let message: string = err?.message || 'OpenAI API error'
342
+ // llama.cpp: "request (70123 tokens) exceeds the available context size (65536 tokens)"
343
+ // The app triggers auto-compact on Anthropic's wording, so translate it.
344
+ const ctx = /\((\d+) tokens\) exceeds the available context size \((\d+)/i.exec(message)
345
+ || /(\d+) tokens? .*?exceed.*?(\d+)/i.exec(message)
346
+ if (ctx) {
347
+ message = `prompt is too long: ${ctx[1]} tokens > ${ctx[2]} maximum`
348
+ }
349
+ debugLog({ error: message, status })
350
+ // APIError builds its .message from the body's `message` when present, so
351
+ // the translated text must go into the body too, or the raw provider text
352
+ // wins and prompt-too-long detection misses it.
353
+ const body = { ...(typeof err?.error === 'object' && err.error ? err.error : {}), message }
354
+ return new APIError(status, body, message, new Headers())
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // WebSearch fallback. The app's WebSearch tool asks the model to run Anthropic's
359
+ // server-side web_search tool and parses the resulting content blocks. No other
360
+ // provider has that tool, so the search runs here (DuckDuckGo Lite, no API key)
361
+ // and the answer is shaped exactly like the Anthropic response.
362
+ // ---------------------------------------------------------------------------
363
+
364
+ type SearchHit = { title: string; url: string; snippet: string }
365
+
366
+ function decodeEntities(s: string): string {
367
+ return s
368
+ .replace(/<[^>]+>/g, '')
369
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
370
+ .replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").replace(/&nbsp;/g, ' ')
371
+ .replace(/\s+/g, ' ').trim()
372
+ }
373
+
374
+ async function duckDuckGoSearch(query: string, signal?: AbortSignal): Promise<SearchHit[]> {
375
+ const url = `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`
376
+ const res = await fetch(url, {
377
+ signal,
378
+ headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36' },
379
+ })
380
+ if (!res.ok) throw new Error(`DuckDuckGo returned HTTP ${res.status}`)
381
+ const html = await res.text()
382
+
383
+ const hits: SearchHit[] = []
384
+ const linkRe = /<a[^>]*href="([^"]+)"[^>]*class='result-link'[^>]*>([\s\S]*?)<\/a>/g
385
+ const snippetRe = /<td class='result-snippet'>([\s\S]*?)<\/td>/g
386
+ const snippets: string[] = []
387
+ let m: RegExpExecArray | null
388
+ while ((m = snippetRe.exec(html)) !== null) snippets.push(decodeEntities(m[1]!))
389
+ while ((m = linkRe.exec(html)) !== null) {
390
+ let href = m[1]!.replace(/&amp;/g, '&')
391
+ const uddg = /[?&]uddg=([^&]+)/.exec(href)
392
+ if (uddg) href = decodeURIComponent(uddg[1]!)
393
+ else if (href.startsWith('//')) href = 'https:' + href
394
+ if (/duckduckgo\.com\/y\.js|ad_domain=/.test(href)) continue // sponsored
395
+ hits.push({ title: decodeEntities(m[2]!), url: href, snippet: snippets[hits.length] || '' })
396
+ if (hits.length >= 10) break
397
+ }
398
+ return hits
399
+ }
400
+
401
+ function extractSearchQuery(params: any): string {
402
+ const last = [...(params.messages || [])].reverse().find((m: any) => m.role === 'user')
403
+ const text = last ? blockText(last.content) : ''
404
+ const m = /web search for the query:\s*([\s\S]+)$/i.exec(text)
405
+ return (m ? m[1] : text).trim()
406
+ }
407
+
408
+ async function* webSearchStream(params: any, signal?: AbortSignal): AsyncGenerator<any> {
409
+ const query = extractSearchQuery(params)
410
+ const toolUseId = `srvtoolu_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
411
+
412
+ yield { type: 'message_start', message: { id: `msg_${Date.now()}`, type: 'message', role: 'assistant', content: [], model: MODEL, stop_reason: null, stop_sequence: null, usage: emptyUsage() } }
413
+
414
+ let index = 0
415
+ yield { type: 'content_block_start', index, content_block: { type: 'server_tool_use', id: toolUseId, name: 'web_search', input: {} } }
416
+ yield { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: JSON.stringify({ query }) } }
417
+ yield { type: 'content_block_stop', index }
418
+ index++
419
+
420
+ let hits: SearchHit[] = []
421
+ let error: string | null = null
422
+ try {
423
+ hits = await duckDuckGoSearch(query, signal)
424
+ } catch (e: any) {
425
+ error = e?.message || String(e)
426
+ }
427
+ debugLog({ web_search: query, hits: hits.length, error })
428
+
429
+ const resultBlock: any = {
430
+ type: 'web_search_tool_result',
431
+ tool_use_id: toolUseId,
432
+ content: error
433
+ ? { type: 'web_search_tool_result_error', error_code: 'unavailable' }
434
+ : hits.map(h => ({ type: 'web_search_result', title: h.title, url: h.url, encrypted_content: '', page_age: null })),
435
+ }
436
+ yield { type: 'content_block_start', index, content_block: resultBlock }
437
+ yield { type: 'content_block_stop', index }
438
+ index++
439
+
440
+ const text = error
441
+ ? `Web search failed: ${error}`
442
+ : hits.length === 0
443
+ ? `No results found for "${query}".`
444
+ : `Search results for "${query}":\n\n` + hits.map((h, i) => `${i + 1}. ${h.title}\n ${h.url}${h.snippet ? `\n ${h.snippet}` : ''}`).join('\n\n')
445
+ yield { type: 'content_block_start', index, content_block: { type: 'text', text: '' } }
446
+ yield { type: 'content_block_delta', index, delta: { type: 'text_delta', text } }
447
+ yield { type: 'content_block_stop', index }
448
+
449
+ yield { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { input_tokens: 0, output_tokens: 0, server_tool_use: { web_search_requests: 1, web_fetch_requests: 0 } } }
450
+ yield { type: 'message_stop' }
451
+ }
452
+
453
+ function wantsWebSearch(params: any): boolean {
454
+ if ((process.env.OPENAI_WEB_SEARCH || '').toLowerCase() === 'off') return false
455
+ return (params.tools || []).some((t: any) => typeof t?.type === 'string' && t.type.startsWith('web_search'))
456
+ }
457
+
458
+ // Create a streaming response that mimics Anthropic's stream format
459
+ async function* createAnthropicStyleStream(params: any, signal?: AbortSignal): AsyncGenerator<any> {
460
+ if (wantsWebSearch(params)) {
461
+ yield* webSearchStream(params, signal)
462
+ return
463
+ }
464
+
465
+ const openai = getOpenAI()
466
+ const requestParams = buildRequest(params, true)
467
+ debugLog({ request: summarizeRequest(requestParams) })
240
468
 
241
469
  let stream: any
242
470
  try {
243
471
  stream = await openai.chat.completions.create(requestParams, { signal })
244
472
  } catch (err: any) {
245
- // Re-throw as Anthropic APIError so retry logic works
246
- const { APIError } = await import('@anthropic-ai/sdk')
247
- const status = err?.status || 500
248
- throw new APIError(status, err?.error || {}, err?.message || 'OpenAI API error', new Headers())
473
+ throw await toAnthropicError(err)
249
474
  }
250
475
 
251
476
  // Emit message_start
252
- const messageId = `msg_${Date.now()}`
253
477
  yield {
254
478
  type: 'message_start',
255
479
  message: {
256
- id: messageId,
480
+ id: `msg_${Date.now()}`,
257
481
  type: 'message',
258
482
  role: 'assistant',
259
483
  content: [],
260
- model,
484
+ model: MODEL,
261
485
  stop_reason: null,
262
486
  stop_sequence: null,
263
- usage: {
264
- input_tokens: 0,
265
- output_tokens: 0,
266
- cache_creation_input_tokens: 0,
267
- cache_read_input_tokens: 0,
268
- server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
269
- service_tier: 'standard',
270
- cache_creation: {
271
- ephemeral_1h_input_tokens: 0,
272
- ephemeral_5m_input_tokens: 0,
273
- },
274
- inference_geo: '',
275
- iterations: [],
276
- speed: 'standard',
277
- },
487
+ usage: emptyUsage(),
278
488
  },
279
489
  }
280
490
 
491
+ // One content block is open at a time: thinking, text, or a tool call
281
492
  let contentBlockIndex = 0
282
- let currentToolCallId: string | null = null
283
- let currentToolName: string | null = null
493
+ let openBlock: 'thinking' | 'text' | 'tool_use' | null = null
284
494
  let toolInputAccum = ''
285
- let hasTextBlock = false
495
+ // OpenAI streams parallel tool calls keyed by index; the id only arrives on the first chunk
496
+ let currentToolIndex: number | null = null
497
+ let finishReason: string | null = null
498
+ let usage: any = null
499
+
500
+ function* closeBlock(): Generator<any> {
501
+ if (!openBlock) return
502
+ if (openBlock === 'tool_use' && toolInputAccum.trim() === '') {
503
+ // Tools called without arguments must still carry a valid JSON object
504
+ yield {
505
+ type: 'content_block_delta',
506
+ index: contentBlockIndex,
507
+ delta: { type: 'input_json_delta', partial_json: '{}' },
508
+ }
509
+ }
510
+ yield { type: 'content_block_stop', index: contentBlockIndex }
511
+ contentBlockIndex++
512
+ openBlock = null
513
+ currentToolIndex = null
514
+ toolInputAccum = ''
515
+ }
286
516
 
287
- for await (const chunk of stream) {
288
- const choice = chunk.choices?.[0]
289
- if (!choice) continue
517
+ function* openBlockOf(type: 'thinking' | 'text'): Generator<any> {
518
+ if (openBlock === type) return
519
+ yield* closeBlock()
520
+ openBlock = type
521
+ yield {
522
+ type: 'content_block_start',
523
+ index: contentBlockIndex,
524
+ content_block: type === 'thinking'
525
+ ? { type: 'thinking', thinking: '', signature: '' }
526
+ : { type: 'text', text: '' },
527
+ }
528
+ }
529
+
530
+ try {
531
+ for await (const chunk of stream) {
532
+ // The usage chunk arrives last, with an empty choices array
533
+ if (chunk.usage) usage = chunk.usage
534
+
535
+ const choice = chunk.choices?.[0]
536
+ if (!choice) continue
290
537
 
291
- const delta = choice.delta
538
+ const delta: any = choice.delta
292
539
 
293
- // Handle text content
294
- if (delta?.content) {
295
- if (!hasTextBlock) {
296
- hasTextBlock = true
540
+ // Reasoning models: llama.cpp/DeepSeek send reasoning_content, OpenRouter sends reasoning
541
+ const reasoning = delta?.reasoning_content ?? delta?.reasoning
542
+ if (typeof reasoning === 'string' && reasoning) {
543
+ yield* openBlockOf('thinking')
297
544
  yield {
298
- type: 'content_block_start',
545
+ type: 'content_block_delta',
299
546
  index: contentBlockIndex,
300
- content_block: { type: 'text', text: '' },
547
+ delta: { type: 'thinking_delta', thinking: reasoning },
301
548
  }
302
549
  }
303
- yield {
304
- type: 'content_block_delta',
305
- index: contentBlockIndex,
306
- delta: { type: 'text_delta', text: delta.content },
550
+
551
+ // Handle text content
552
+ if (delta?.content) {
553
+ yield* openBlockOf('text')
554
+ yield {
555
+ type: 'content_block_delta',
556
+ index: contentBlockIndex,
557
+ delta: { type: 'text_delta', text: delta.content },
558
+ }
307
559
  }
308
- }
309
560
 
310
- // Handle tool calls
311
- if (delta?.tool_calls) {
312
- for (const tc of delta.tool_calls) {
313
- if (tc.id && tc.id !== currentToolCallId) {
314
- // Close previous text block if open
315
- if (hasTextBlock && !currentToolCallId) {
316
- yield { type: 'content_block_stop', index: contentBlockIndex }
317
- contentBlockIndex++
318
- hasTextBlock = false
319
- }
320
- // Close previous tool call if open
321
- if (currentToolCallId) {
561
+ // Handle tool calls
562
+ if (delta?.tool_calls) {
563
+ for (const tc of delta.tool_calls) {
564
+ const index = tc.index ?? 0
565
+ if (openBlock !== 'tool_use' || index !== currentToolIndex) {
566
+ yield* closeBlock()
567
+ openBlock = 'tool_use'
568
+ currentToolIndex = index
322
569
  yield {
323
- type: 'content_block_delta',
570
+ type: 'content_block_start',
324
571
  index: contentBlockIndex,
325
- delta: { type: 'input_json_delta', partial_json: '' },
572
+ content_block: {
573
+ type: 'tool_use',
574
+ id: tc.id || `call_${Date.now()}_${index}`,
575
+ name: tc.function?.name || '',
576
+ input: '',
577
+ },
326
578
  }
327
- yield { type: 'content_block_stop', index: contentBlockIndex }
328
- contentBlockIndex++
329
579
  }
330
580
 
331
- currentToolCallId = tc.id
332
- currentToolName = tc.function?.name || ''
333
- toolInputAccum = tc.function?.arguments || ''
334
-
335
- yield {
336
- type: 'content_block_start',
337
- index: contentBlockIndex,
338
- content_block: {
339
- type: 'tool_use',
340
- id: tc.id,
341
- name: currentToolName,
342
- input: "",
343
- },
581
+ if (tc.function?.arguments) {
582
+ toolInputAccum += tc.function.arguments
583
+ yield {
584
+ type: 'content_block_delta',
585
+ index: contentBlockIndex,
586
+ delta: { type: 'input_json_delta', partial_json: tc.function.arguments },
587
+ }
344
588
  }
345
589
  }
590
+ }
346
591
 
347
- if (tc.function?.arguments) {
348
- toolInputAccum += tc.function.arguments
349
- yield {
350
- type: 'content_block_delta',
351
- index: contentBlockIndex,
352
- delta: { type: 'input_json_delta', partial_json: tc.function.arguments },
353
- }
354
- }
592
+ if (choice.finish_reason) {
593
+ finishReason = choice.finish_reason
355
594
  }
356
595
  }
596
+ } catch (err: any) {
597
+ throw await toAnthropicError(err)
598
+ }
357
599
 
358
- // Handle finish
359
- if (choice.finish_reason) {
360
- // Close any open blocks
361
- if (hasTextBlock && !currentToolCallId) {
362
- yield { type: 'content_block_stop', index: contentBlockIndex }
363
- contentBlockIndex++
364
- }
365
- if (currentToolCallId) {
366
- yield { type: 'content_block_stop', index: contentBlockIndex }
367
- contentBlockIndex++
368
- }
600
+ // Close blocks and report stop reason + usage only after the stream ends,
601
+ // so the trailing usage chunk is included
602
+ yield* closeBlock()
369
603
 
370
- yield {
371
- type: 'message_delta',
372
- delta: {
373
- stop_reason: mapStopReason(choice.finish_reason),
374
- stop_sequence: null,
375
- },
376
- usage: { output_tokens: chunk.usage?.completion_tokens || 0 },
377
- }
604
+ debugLog({ response: { stop: finishReason, usage } })
378
605
 
379
- yield { type: 'message_stop' }
380
- }
606
+ yield {
607
+ type: 'message_delta',
608
+ delta: {
609
+ stop_reason: mapStopReason(finishReason),
610
+ stop_sequence: null,
611
+ },
612
+ usage: {
613
+ input_tokens: usage?.prompt_tokens || 0,
614
+ output_tokens: usage?.completion_tokens || 0,
615
+ },
381
616
  }
617
+
618
+ yield { type: 'message_stop' }
382
619
  }
383
620
 
384
621
  // Create a fake Anthropic client that uses OpenAI
@@ -391,8 +628,13 @@ export function createOpenAIBackedAnthropicClient(): any {
391
628
  // Return an APIPromise-like object: a Promise with .withResponse()
392
629
  const abortController = new AbortController()
393
630
  const generator = createAnthropicStyleStream(params, options?.signal || abortController.signal)
631
+ let iterators = 0
394
632
  const streamObj: any = {
395
- [Symbol.asyncIterator]: () => generator,
633
+ [Symbol.asyncIterator]: () => {
634
+ iterators++
635
+ debugLog({ stream_iterator: iterators })
636
+ return generator
637
+ },
396
638
  controller: abortController,
397
639
  }
398
640
 
@@ -407,86 +649,60 @@ export function createOpenAIBackedAnthropicClient(): any {
407
649
  return promise
408
650
  }
409
651
 
410
- // Non-streaming: wrap in async IIFE
411
- const nonStreamingResult = (async () => {
412
- const openai = getOpenAI()
413
- // Always use the configured MODEL
414
- const model = resolveModel(params.model)
415
-
416
- const openaiMessages: any[] = []
417
- if (params.system) {
418
- const systemText = typeof params.system === 'string'
419
- ? params.system
420
- : Array.isArray(params.system)
421
- ? params.system.map((s: any) => typeof s === 'string' ? s : s.text || '').join('\n')
422
- : ''
423
- if (systemText) openaiMessages.push({ role: 'system', content: systemText })
424
- }
425
- openaiMessages.push(...convertMessages(params.messages || []))
426
-
427
- const tools = convertTools(params.tools || [])
428
- const requestParams: any = {
429
- model,
430
- messages: openaiMessages,
431
- max_completion_tokens: Math.min(params.max_tokens || 8192, MAX_TOKENS),
432
- }
433
- if (tools.length > 0) requestParams.tools = tools
434
- if (params.temperature !== undefined) requestParams.temperature = params.temperature
435
-
436
- let response: any
437
- try {
438
- response = await openai.chat.completions.create(requestParams, { signal: options?.signal })
439
- } catch (err: any) {
440
- const { APIError } = await import('@anthropic-ai/sdk')
441
- const status = err?.status || 500
442
- throw new APIError(status, err?.error || {}, err?.message || 'OpenAI API error', new Headers())
443
- }
444
- const choice = response.choices[0]
445
-
446
- // Convert to Anthropic format
447
- const content: any[] = []
448
- if (choice?.message?.content) {
449
- content.push({ type: 'text', text: choice.message.content })
450
- }
451
- if (choice?.message?.tool_calls) {
452
- for (const tc of choice.message.tool_calls) {
453
- let input = {}
454
- try { input = JSON.parse(tc.function.arguments) } catch {}
455
- content.push({
456
- type: 'tool_use',
457
- id: tc.id,
458
- name: tc.function.name,
459
- input,
460
- })
652
+ // Non-streaming
653
+ return (async () => {
654
+ const openai = getOpenAI()
655
+ const requestParams = buildRequest(params, false)
656
+ debugLog({ request: summarizeRequest(requestParams) })
657
+
658
+ let response: any
659
+ try {
660
+ response = await openai.chat.completions.create(requestParams, { signal: options?.signal })
661
+ } catch (err: any) {
662
+ throw await toAnthropicError(err)
663
+ }
664
+ const choice = response.choices[0]
665
+ const message: any = choice?.message || {}
666
+
667
+ // Convert to Anthropic format
668
+ const content: any[] = []
669
+ const reasoning = message.reasoning_content ?? message.reasoning
670
+ if (typeof reasoning === 'string' && reasoning) {
671
+ content.push({ type: 'thinking', thinking: reasoning, signature: '' })
672
+ }
673
+ if (message.content) {
674
+ content.push({ type: 'text', text: message.content })
675
+ }
676
+ if (message.tool_calls) {
677
+ for (const tc of message.tool_calls) {
678
+ let input = {}
679
+ try { input = JSON.parse(tc.function.arguments) } catch {}
680
+ content.push({
681
+ type: 'tool_use',
682
+ id: tc.id,
683
+ name: tc.function.name,
684
+ input,
685
+ })
686
+ }
461
687
  }
462
- }
463
688
 
464
- return {
465
- id: messageId(),
466
- type: 'message',
467
- role: 'assistant',
468
- content,
469
- model,
470
- stop_reason: mapStopReason(choice?.finish_reason || null),
471
- stop_sequence: null,
472
- usage: {
473
- input_tokens: response.usage?.prompt_tokens || 0,
474
- output_tokens: response.usage?.completion_tokens || 0,
475
- cache_creation_input_tokens: 0,
476
- cache_read_input_tokens: 0,
477
- server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
478
- service_tier: 'standard',
479
- cache_creation: {
480
- ephemeral_1h_input_tokens: 0,
481
- ephemeral_5m_input_tokens: 0,
689
+ debugLog({ response: { stop: choice?.finish_reason, usage: response.usage } })
690
+
691
+ return {
692
+ id: messageId(),
693
+ type: 'message',
694
+ role: 'assistant',
695
+ content,
696
+ model: MODEL,
697
+ stop_reason: mapStopReason(choice?.finish_reason || null),
698
+ stop_sequence: null,
699
+ usage: {
700
+ ...emptyUsage(),
701
+ input_tokens: response.usage?.prompt_tokens || 0,
702
+ output_tokens: response.usage?.completion_tokens || 0,
482
703
  },
483
- inference_geo: '',
484
- iterations: [],
485
- speed: 'standard',
486
- },
487
- }
488
- })(); // end async IIFE
489
- return nonStreamingResult;
704
+ }
705
+ })()
490
706
  },
491
707
  }
492
708