@kkelly-offical/kkcode 0.1.7 → 0.2.3-preview.1

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 (166) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +474 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/commands/update.mjs +32 -0
  29. package/src/config/defaults.mjs +289 -260
  30. package/src/config/import-config.mjs +1 -1
  31. package/src/config/load-config.mjs +61 -4
  32. package/src/config/schema.mjs +604 -574
  33. package/src/context.mjs +4 -1
  34. package/src/core/constants.mjs +97 -91
  35. package/src/core/types.mjs +1 -1
  36. package/src/github/api.mjs +78 -78
  37. package/src/github/auth.mjs +294 -286
  38. package/src/github/flow.mjs +298 -298
  39. package/src/github/workspace.mjs +225 -212
  40. package/src/index.mjs +87 -82
  41. package/src/knowledge/frontend-aesthetics.txt +38 -38
  42. package/src/mcp/client-http.mjs +139 -141
  43. package/src/mcp/client-sse.mjs +297 -288
  44. package/src/mcp/client-stdio.mjs +534 -533
  45. package/src/mcp/constants.mjs +4 -2
  46. package/src/mcp/registry.mjs +498 -479
  47. package/src/mcp/stdio-framing.mjs +135 -133
  48. package/src/mcp/tool-result.mjs +24 -24
  49. package/src/observability/edit-diagnostics.mjs +449 -0
  50. package/src/observability/index.mjs +42 -42
  51. package/src/observability/metrics.mjs +165 -137
  52. package/src/observability/tracer.mjs +137 -137
  53. package/src/onboarding.mjs +209 -0
  54. package/src/orchestration/background-manager.mjs +567 -372
  55. package/src/orchestration/background-worker.mjs +419 -305
  56. package/src/orchestration/interruption-reason.mjs +21 -0
  57. package/src/orchestration/longagent-manager.mjs +197 -171
  58. package/src/orchestration/stage-scheduler.mjs +733 -728
  59. package/src/orchestration/subagent-router.mjs +7 -1
  60. package/src/orchestration/task-scheduler.mjs +219 -7
  61. package/src/permission/engine.mjs +1 -1
  62. package/src/permission/exec-policy.mjs +370 -370
  63. package/src/permission/file-edit-policy.mjs +108 -0
  64. package/src/permission/prompt.mjs +1 -1
  65. package/src/permission/rules.mjs +116 -7
  66. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  67. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  68. package/src/plugin/hook-bus.mjs +19 -5
  69. package/src/plugin/manifest-loader.mjs +222 -0
  70. package/src/provider/anthropic.mjs +396 -390
  71. package/src/provider/ollama.mjs +7 -1
  72. package/src/provider/openai.mjs +382 -340
  73. package/src/provider/retry-policy.mjs +74 -68
  74. package/src/provider/router.mjs +242 -241
  75. package/src/provider/sse.mjs +104 -104
  76. package/src/provider/wizard.mjs +556 -0
  77. package/src/repl/capability-facade.mjs +30 -0
  78. package/src/repl/command-surface.mjs +23 -0
  79. package/src/repl/controller-entry.mjs +40 -0
  80. package/src/repl/core-shell.mjs +208 -0
  81. package/src/repl/dialog-router.mjs +87 -0
  82. package/src/repl/input-engine.mjs +76 -0
  83. package/src/repl/keymap.mjs +7 -0
  84. package/src/repl/operator-surface.mjs +15 -0
  85. package/src/repl/permission-flow.mjs +49 -0
  86. package/src/repl/runtime-facade.mjs +36 -0
  87. package/src/repl/slash-router.mjs +62 -0
  88. package/src/repl/state-store.mjs +29 -0
  89. package/src/repl/turn-controller.mjs +58 -0
  90. package/src/repl/verification.mjs +23 -0
  91. package/src/repl.mjs +3371 -2981
  92. package/src/rules/load-rules.mjs +3 -3
  93. package/src/runtime.mjs +1 -1
  94. package/src/session/agent-transaction.mjs +86 -0
  95. package/src/session/checkpoint.mjs +302 -302
  96. package/src/session/compaction.mjs +298 -298
  97. package/src/session/engine.mjs +417 -232
  98. package/src/session/longagent-4stage.mjs +467 -460
  99. package/src/session/longagent-hybrid.mjs +1344 -1097
  100. package/src/session/longagent-plan.mjs +376 -365
  101. package/src/session/longagent-project-memory.mjs +53 -53
  102. package/src/session/longagent-scaffold.mjs +291 -291
  103. package/src/session/longagent-task-bus.mjs +138 -54
  104. package/src/session/longagent-utils.mjs +828 -472
  105. package/src/session/longagent.mjs +911 -900
  106. package/src/session/loop.mjs +1005 -930
  107. package/src/session/prompt/agent.txt +25 -25
  108. package/src/session/prompt/anthropic.txt +150 -150
  109. package/src/session/prompt/beast.txt +1 -1
  110. package/src/session/prompt/plan.txt +31 -31
  111. package/src/session/prompt/qwen.txt +46 -46
  112. package/src/session/recovery.mjs +21 -0
  113. package/src/session/rollback.mjs +196 -195
  114. package/src/session/routing-observability.mjs +72 -0
  115. package/src/session/runtime-state.mjs +47 -0
  116. package/src/session/store.mjs +523 -519
  117. package/src/session/system-prompt.mjs +308 -273
  118. package/src/session/task-validator.mjs +267 -267
  119. package/src/session/usability-gates.mjs +2 -2
  120. package/src/skill/builtin/commit.mjs +64 -64
  121. package/src/skill/builtin/design.mjs +76 -76
  122. package/src/skill/generator.mjs +18 -2
  123. package/src/skill/registry.mjs +642 -390
  124. package/src/storage/audit-store.mjs +18 -11
  125. package/src/storage/event-log.mjs +7 -1
  126. package/src/storage/ghost-commit-store.mjs +243 -245
  127. package/src/storage/paths.mjs +17 -0
  128. package/src/theme/default-theme.mjs +1 -1
  129. package/src/theme/markdown.mjs +4 -0
  130. package/src/theme/schema.mjs +1 -1
  131. package/src/theme/status-bar.mjs +162 -158
  132. package/src/tool/audit-wrapper.mjs +18 -2
  133. package/src/tool/edit-transaction.mjs +23 -0
  134. package/src/tool/executor.mjs +26 -1
  135. package/src/tool/file-read-state.mjs +65 -0
  136. package/src/tool/git-auto.mjs +526 -526
  137. package/src/tool/git-full-auto.mjs +487 -478
  138. package/src/tool/mutation-guard.mjs +54 -0
  139. package/src/tool/prompt/edit.txt +3 -3
  140. package/src/tool/prompt/multiedit.txt +1 -0
  141. package/src/tool/prompt/notebookedit.txt +2 -1
  142. package/src/tool/prompt/patch.txt +25 -24
  143. package/src/tool/prompt/read.txt +3 -3
  144. package/src/tool/prompt/sysinfo.txt +29 -0
  145. package/src/tool/prompt/task.txt +66 -4
  146. package/src/tool/prompt/write.txt +2 -2
  147. package/src/tool/question-prompt.mjs +99 -93
  148. package/src/tool/registry.mjs +1701 -1343
  149. package/src/tool/task-tool.mjs +14 -6
  150. package/src/ui/activity-renderer.mjs +667 -664
  151. package/src/ui/repl-background-panel.mjs +7 -0
  152. package/src/ui/repl-capability-panel.mjs +9 -0
  153. package/src/ui/repl-dashboard.mjs +54 -4
  154. package/src/ui/repl-help.mjs +110 -0
  155. package/src/ui/repl-operator-panel.mjs +12 -0
  156. package/src/ui/repl-route-feedback.mjs +35 -0
  157. package/src/ui/repl-status-view.mjs +76 -0
  158. package/src/ui/repl-task-panel.mjs +5 -0
  159. package/src/ui/repl-transcript-panel.mjs +56 -0
  160. package/src/ui/repl-turn-summary.mjs +135 -0
  161. package/src/update/checker.mjs +184 -0
  162. package/src/usage/pricing.mjs +122 -121
  163. package/src/usage/usage-meter.mjs +1 -0
  164. package/src/util/git.mjs +562 -519
  165. package/src/util/template.mjs +6 -1
  166. package/src/version.mjs +3 -0
@@ -1,340 +1,382 @@
1
- import { ProviderError } from "../core/errors.mjs"
2
- import { requestWithRetry } from "./retry-policy.mjs"
3
- import { parseSSE } from "./sse.mjs"
4
-
5
- function sleep(ms) {
6
- return new Promise((resolve) => setTimeout(resolve, ms))
7
- }
8
-
9
- function mapTools(tools) {
10
- if (!tools || !tools.length) return undefined
11
- return tools.map((tool) => ({
12
- type: "function",
13
- function: {
14
- name: tool.name,
15
- description: tool.description,
16
- parameters: tool.inputSchema
17
- }
18
- }))
19
- }
20
-
21
- function mapContentBlock(block) {
22
- if (block.type === "image" && block.data) {
23
- return {
24
- type: "image_url",
25
- image_url: {
26
- url: `data:${block.mediaType || "image/png"};base64,${block.data}`
27
- }
28
- }
29
- }
30
- return { type: "text", text: String(block.text || block.content || "") }
31
- }
32
-
33
- function mapMessages(messages) {
34
- const mapped = []
35
- for (const message of messages) {
36
- const content = message.content
37
- if (!Array.isArray(content)) {
38
- mapped.push({ role: message.role, content: String(content || "") })
39
- continue
40
- }
41
-
42
- // Check for native tool_use blocks (assistant message with tool calls)
43
- const toolUseBlocks = content.filter((b) => b.type === "tool_use")
44
- if (toolUseBlocks.length > 0 && message.role === "assistant") {
45
- const textParts = content.filter((b) => b.type === "text").map((b) => b.text || "").join("\n")
46
- mapped.push({
47
- role: "assistant",
48
- content: textParts || null,
49
- tool_calls: toolUseBlocks.map((b) => ({
50
- id: b.id,
51
- type: "function",
52
- function: {
53
- name: b.name,
54
- arguments: JSON.stringify(b.input || {})
55
- }
56
- }))
57
- })
58
- continue
59
- }
60
-
61
- // Check for tool_result blocks (user message with tool results)
62
- const toolResultBlocks = content.filter((b) => b.type === "tool_result")
63
- if (toolResultBlocks.length > 0) {
64
- for (const result of toolResultBlocks) {
65
- mapped.push({
66
- role: "tool",
67
- tool_call_id: result.tool_use_id,
68
- content: String(result.content || "")
69
- })
70
- }
71
- continue
72
- }
73
-
74
- // Regular array content (images, text)
75
- mapped.push({ role: message.role, content: content.map(mapContentBlock) })
76
- }
77
- return mapped
78
- }
79
-
80
- function parseToolCalls(message) {
81
- if (!Array.isArray(message?.tool_calls)) return []
82
- return message.tool_calls
83
- .filter((call) => call?.function?.name)
84
- .map((call) => {
85
- const raw = call.function.arguments || "{}"
86
- let args = {}
87
- try {
88
- args = JSON.parse(raw)
89
- } catch (parseErr) {
90
- console.error(`[openai] tool_call JSON parse failed for "${call.function.name}": ${parseErr.message} (${raw.length} chars, first 200: ${raw.slice(0, 200)})`)
91
- args = { __parse_error: true, __raw_length: raw.length, __error: parseErr.message }
92
- }
93
- return {
94
- id: call.id || `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
95
- name: call.function.name,
96
- args
97
- }
98
- })
99
- }
100
-
101
- // Build system messages from structured blocks for optimal prefix caching.
102
- // OpenAI auto-caches matching prefixes — stable content first, dynamic last.
103
- function buildSystemMessages(system) {
104
- if (!system) return []
105
- if (system.blocks && Array.isArray(system.blocks)) {
106
- const stable = []
107
- const dynamic = []
108
- for (const block of system.blocks) {
109
- if (block.cacheable) stable.push(block.text)
110
- else dynamic.push(block.text)
111
- }
112
- const msgs = []
113
- if (stable.length) msgs.push({ role: "system", content: stable.join("\n\n") })
114
- if (dynamic.length) msgs.push({ role: "system", content: dynamic.join("\n\n") })
115
- return msgs
116
- }
117
- const text = typeof system === "string" ? system : system.text || String(system)
118
- return text ? [{ role: "system", content: text }] : []
119
- }
120
-
121
- function timeoutSignal(ms, parentSignal = null) {
122
- const own = AbortSignal.timeout(ms)
123
- if (!parentSignal) return own
124
- return AbortSignal.any([parentSignal, own])
125
- }
126
-
127
- export async function countTokensOpenAI(input) {
128
- const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 10000 } = input
129
- if (!apiKey) return null
130
- const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`
131
- const payload = {
132
- model,
133
- messages: [...buildSystemMessages(system), ...mapMessages(messages)],
134
- tools: mapTools(tools),
135
- max_tokens: 1,
136
- stream: false
137
- }
138
- try {
139
- const res = await fetch(endpoint, {
140
- method: "POST",
141
- headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
142
- body: JSON.stringify(payload),
143
- signal: AbortSignal.timeout(timeoutMs)
144
- })
145
- if (!res.ok) return null
146
- const json = await res.json()
147
- return json?.usage?.prompt_tokens ?? null
148
- } catch {
149
- return null
150
- }
151
- }
152
-
153
- export async function requestOpenAI(input) {
154
- const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 120000, maxTokens, retry = {}, signal = null } = input
155
- if (!apiKey) {
156
- throw new ProviderError(`missing API key for openai provider (env: ${input.apiKeyEnv || "unknown"})`, {
157
- provider: "openai"
158
- })
159
- }
160
-
161
- const payload = {
162
- model,
163
- messages: [...buildSystemMessages(system), ...mapMessages(messages)],
164
- tools: mapTools(tools),
165
- tool_choice: tools?.length ? "auto" : undefined,
166
- ...(maxTokens ? { max_tokens: maxTokens } : {})
167
- }
168
- const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`
169
-
170
- return requestWithRetry({
171
- attempts: Number(retry.attempts ?? 3),
172
- baseDelayMs: Number(retry.baseDelayMs ?? 800),
173
- signal,
174
- execute: async () => {
175
- const response = await fetch(endpoint, {
176
- method: "POST",
177
- headers: {
178
- "content-type": "application/json",
179
- authorization: `Bearer ${apiKey}`
180
- },
181
- body: JSON.stringify(payload),
182
- signal: timeoutSignal(timeoutMs, signal)
183
- })
184
-
185
- if (!response.ok) {
186
- const text = await response.text().catch(() => "")
187
- const error = new ProviderError(`openai request failed: ${response.status} ${text}`, {
188
- provider: "openai",
189
- model,
190
- endpoint
191
- })
192
- error.httpStatus = response.status
193
- throw error
194
- }
195
-
196
- const json = await response.json()
197
- const message = json?.choices?.[0]?.message ?? {}
198
- const promptTokens = json?.usage?.prompt_tokens ?? 0
199
- const cachedTokens = json?.usage?.prompt_tokens_details?.cached_tokens ?? 0
200
- const usage = {
201
- input: promptTokens - cachedTokens,
202
- output: json?.usage?.completion_tokens ?? 0,
203
- cacheRead: cachedTokens,
204
- cacheWrite: 0
205
- }
206
- const toolCalls = parseToolCalls(message)
207
- const text = typeof message.content === "string" ? message.content : ""
208
- return { text, usage, toolCalls }
209
- }
210
- })
211
- }
212
-
213
- export async function* requestOpenAIStream(input) {
214
- const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 120000, streamIdleTimeoutMs = 120000, maxTokens, retry = {}, signal = null } = input
215
- if (!apiKey) {
216
- throw new ProviderError(`missing API key for openai provider (env: ${input.apiKeyEnv || "unknown"})`, {
217
- provider: "openai"
218
- })
219
- }
220
-
221
- const payload = {
222
- model,
223
- messages: [...buildSystemMessages(system), ...mapMessages(messages)],
224
- tools: mapTools(tools),
225
- tool_choice: tools?.length ? "auto" : undefined,
226
- ...(maxTokens ? { max_tokens: maxTokens } : {}),
227
- stream: true,
228
- stream_options: { include_usage: true }
229
- }
230
- const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`
231
- const attempts = Number(retry.attempts ?? 3)
232
- const baseDelayMs = Number(retry.baseDelayMs ?? 800)
233
-
234
- let response
235
- for (let attempt = 1; attempt <= attempts; attempt++) {
236
- try {
237
- // Use a connection-only timeout for the initial fetch.
238
- // Once headers arrive, clear it — the SSE idle timeout handles the streaming phase.
239
- const connController = new AbortController()
240
- const connTimer = setTimeout(() => connController.abort(), timeoutMs)
241
- const fetchSignal = signal
242
- ? AbortSignal.any([signal, connController.signal])
243
- : connController.signal
244
-
245
- response = await fetch(endpoint, {
246
- method: "POST",
247
- headers: {
248
- "content-type": "application/json",
249
- authorization: `Bearer ${apiKey}`
250
- },
251
- body: JSON.stringify(payload),
252
- signal: fetchSignal
253
- })
254
- clearTimeout(connTimer)
255
-
256
- if (!response.ok) {
257
- const text = await response.text().catch(() => "")
258
- const error = new ProviderError(`openai stream failed: ${response.status} ${text}`, {
259
- provider: "openai", model, endpoint
260
- })
261
- error.httpStatus = response.status
262
- throw error
263
- }
264
- break
265
- } catch (err) {
266
- clearTimeout(connTimer)
267
- if (signal?.aborted) throw err
268
- const isNetwork = err?.code === "ETIMEDOUT" || err?.code === "ECONNRESET" || err?.name === "AbortError"
269
- if (!isNetwork || attempt >= attempts) throw err
270
- await sleep(baseDelayMs * Math.pow(2, attempt - 1))
271
- }
272
- }
273
-
274
- const toolBuffers = new Map()
275
- let finishReason = null
276
-
277
- for await (const { data } of parseSSE(response.body, signal, { idleTimeoutMs: streamIdleTimeoutMs })) {
278
- let json
279
- try { json = JSON.parse(data) } catch { continue }
280
-
281
- if (json.usage) {
282
- const pt = json.usage.prompt_tokens ?? 0
283
- const ct = json.usage.prompt_tokens_details?.cached_tokens ?? 0
284
- yield {
285
- type: "usage",
286
- usage: { input: pt - ct, output: json.usage.completion_tokens ?? 0, cacheRead: ct, cacheWrite: 0 }
287
- }
288
- }
289
-
290
- const choice = json.choices?.[0]
291
- if (choice?.finish_reason) {
292
- finishReason = choice.finish_reason
293
- }
294
- const delta = choice?.delta
295
- if (!delta) continue
296
-
297
- if (delta.content) {
298
- yield { type: "text", content: delta.content }
299
- }
300
-
301
- if (delta.tool_calls) {
302
- for (const tc of delta.tool_calls) {
303
- const idx = tc.index ?? 0
304
- if (!toolBuffers.has(idx)) {
305
- toolBuffers.set(idx, { id: "", name: "", argsJson: "" })
306
- }
307
- const buf = toolBuffers.get(idx)
308
- if (tc.id) buf.id = tc.id
309
- if (tc.function?.name) buf.name = tc.function.name
310
- if (tc.function?.arguments) buf.argsJson += tc.function.arguments
311
- }
312
- }
313
- }
314
-
315
- for (const [, buf] of toolBuffers) {
316
- const raw = buf.argsJson || "{}"
317
- let args = {}
318
- try {
319
- args = JSON.parse(raw)
320
- } catch (parseErr) {
321
- console.error(`[openai] tool_call JSON parse failed for "${buf.name}": ${parseErr.message} (${raw.length} chars, first 200: ${raw.slice(0, 200)})`)
322
- args = { __parse_error: true, __raw_length: raw.length, __error: parseErr.message }
323
- }
324
- yield {
325
- type: "tool_call",
326
- call: {
327
- id: buf.id || `tc_${Date.now()}`,
328
- name: buf.name,
329
- args
330
- }
331
- }
332
- }
333
-
334
- // Normalize: "stop" → "end_turn", "length" → "max_tokens", "tool_calls" → "tool_use"
335
- const normalizedReason = finishReason === "length" ? "max_tokens"
336
- : finishReason === "tool_calls" ? "tool_use"
337
- : finishReason === "stop" ? "end_turn"
338
- : finishReason || "end_turn"
339
- yield { type: "stop", reason: normalizedReason }
340
- }
1
+ import { ProviderError } from "../core/errors.mjs"
2
+ import { requestWithRetry } from "./retry-policy.mjs"
3
+ import { parseSSE } from "./sse.mjs"
4
+
5
+ function sleep(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms))
7
+ }
8
+
9
+ function mapTools(tools) {
10
+ if (!tools || !tools.length) return undefined
11
+ const mapped = tools.map((tool) => ({
12
+ type: "function",
13
+ function: {
14
+ name: tool.name,
15
+ description: tool.description,
16
+ parameters: tool.inputSchema
17
+ }
18
+ }))
19
+ // Cache tool definitions — they rarely change within a session
20
+ if (mapped.length > 0) {
21
+ mapped[mapped.length - 1].cache_control = { type: "ephemeral" }
22
+ }
23
+ return mapped
24
+ }
25
+
26
+ function mapContentBlock(block) {
27
+ if (block.type === "image" && block.data) {
28
+ return {
29
+ type: "image_url",
30
+ image_url: {
31
+ url: `data:${block.mediaType || "image/png"};base64,${block.data}`
32
+ }
33
+ }
34
+ }
35
+ return { type: "text", text: String(block.text || block.content || "") }
36
+ }
37
+
38
+ function mapMessages(messages) {
39
+ const mapped = []
40
+ for (const message of messages) {
41
+ const content = message.content
42
+ if (!Array.isArray(content)) {
43
+ mapped.push({ role: message.role, content: String(content || "") })
44
+ continue
45
+ }
46
+
47
+ // Check for native tool_use blocks (assistant message with tool calls)
48
+ const toolUseBlocks = content.filter((b) => b.type === "tool_use")
49
+ if (toolUseBlocks.length > 0 && message.role === "assistant") {
50
+ const textParts = content.filter((b) => b.type === "text").map((b) => b.text || "").join("\n")
51
+ mapped.push({
52
+ role: "assistant",
53
+ content: textParts || null,
54
+ tool_calls: toolUseBlocks.map((b) => ({
55
+ id: b.id,
56
+ type: "function",
57
+ function: {
58
+ name: b.name,
59
+ arguments: JSON.stringify(b.input || {})
60
+ }
61
+ }))
62
+ })
63
+ continue
64
+ }
65
+
66
+ // Check for tool_result blocks (user message with tool results)
67
+ const toolResultBlocks = content.filter((b) => b.type === "tool_result")
68
+ if (toolResultBlocks.length > 0) {
69
+ for (const result of toolResultBlocks) {
70
+ mapped.push({
71
+ role: "tool",
72
+ tool_call_id: result.tool_use_id,
73
+ content: String(result.content || "")
74
+ })
75
+ }
76
+ continue
77
+ }
78
+
79
+ // Regular array content (images, text)
80
+ mapped.push({ role: message.role, content: content.map(mapContentBlock) })
81
+ }
82
+
83
+ // Add cache_control to the last user message for multi-turn caching
84
+ for (let i = mapped.length - 1; i >= 0; i--) {
85
+ if (mapped[i].role === "user") {
86
+ const c = mapped[i].content
87
+ if (Array.isArray(c) && c.length) {
88
+ c[c.length - 1].cache_control = { type: "ephemeral" }
89
+ } else if (typeof c === "string") {
90
+ mapped[i].content = [{ type: "text", text: c, cache_control: { type: "ephemeral" } }]
91
+ }
92
+ break
93
+ }
94
+ }
95
+
96
+ return mapped
97
+ }
98
+
99
+ function parseToolCalls(message) {
100
+ if (!Array.isArray(message?.tool_calls)) return []
101
+ return message.tool_calls
102
+ .filter((call) => call?.function?.name)
103
+ .map((call) => {
104
+ const raw = call.function.arguments || "{}"
105
+ let args = {}
106
+ try {
107
+ args = JSON.parse(raw)
108
+ } catch (parseErr) {
109
+ console.error(`[openai] tool_call JSON parse failed for "${call.function.name}": ${parseErr.message} (${raw.length} chars, first 200: ${raw.slice(0, 200)})`)
110
+ args = { __parse_error: true, __raw_length: raw.length, __error: parseErr.message }
111
+ }
112
+ return {
113
+ id: call.id || `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
114
+ name: call.function.name,
115
+ args
116
+ }
117
+ })
118
+ }
119
+
120
+ // Build system messages from structured blocks with cache_control markers.
121
+ // Stable content gets cache_control for prompt caching (OpenAI auto-cache + Qwen/compatible explicit cache).
122
+ function buildSystemMessages(system) {
123
+ if (!system) return []
124
+ if (system.blocks && Array.isArray(system.blocks)) {
125
+ const stable = []
126
+ const dynamic = []
127
+ for (const block of system.blocks) {
128
+ if (block.cacheable) stable.push(block.text)
129
+ else dynamic.push(block.text)
130
+ }
131
+ const msgs = []
132
+ if (stable.length) {
133
+ msgs.push({
134
+ role: "system",
135
+ content: [{
136
+ type: "text",
137
+ text: stable.join("\n\n"),
138
+ cache_control: { type: "ephemeral" }
139
+ }]
140
+ })
141
+ }
142
+ if (dynamic.length) msgs.push({ role: "system", content: dynamic.join("\n\n") })
143
+ return msgs
144
+ }
145
+ const text = typeof system === "string" ? system : system.text || String(system)
146
+ if (!text) return []
147
+ return [{
148
+ role: "system",
149
+ content: [{ type: "text", text, cache_control: { type: "ephemeral" } }]
150
+ }]
151
+ }
152
+
153
+ function timeoutSignal(ms, parentSignal = null) {
154
+ const own = AbortSignal.timeout(ms)
155
+ if (!parentSignal) return own
156
+ return AbortSignal.any([parentSignal, own])
157
+ }
158
+
159
+ export async function countTokensOpenAI(input) {
160
+ const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 10000 } = input
161
+ if (!apiKey) return null
162
+ const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`
163
+ const payload = {
164
+ model,
165
+ messages: [...buildSystemMessages(system), ...mapMessages(messages)],
166
+ tools: mapTools(tools),
167
+ max_tokens: 1,
168
+ stream: false
169
+ }
170
+ try {
171
+ const res = await fetch(endpoint, {
172
+ method: "POST",
173
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
174
+ body: JSON.stringify(payload),
175
+ signal: AbortSignal.timeout(timeoutMs)
176
+ })
177
+ if (!res.ok) return null
178
+ const json = await res.json()
179
+ return json?.usage?.prompt_tokens ?? null
180
+ } catch {
181
+ return null
182
+ }
183
+ }
184
+
185
+ export async function requestOpenAI(input) {
186
+ const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 120000, maxTokens, retry = {}, signal = null } = input
187
+ if (!apiKey) {
188
+ throw new ProviderError(`missing API key for openai provider (env: ${input.apiKeyEnv || "unknown"})`, {
189
+ provider: "openai"
190
+ })
191
+ }
192
+
193
+ const payload = {
194
+ model,
195
+ messages: [...buildSystemMessages(system), ...mapMessages(messages)],
196
+ tools: mapTools(tools),
197
+ tool_choice: tools?.length ? "auto" : undefined,
198
+ ...(maxTokens ? { max_tokens: maxTokens } : {})
199
+ }
200
+ const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`
201
+
202
+ return requestWithRetry({
203
+ attempts: Number(retry.attempts ?? 3),
204
+ baseDelayMs: Number(retry.baseDelayMs ?? 800),
205
+ signal,
206
+ execute: async () => {
207
+ const response = await fetch(endpoint, {
208
+ method: "POST",
209
+ headers: {
210
+ "content-type": "application/json",
211
+ authorization: `Bearer ${apiKey}`
212
+ },
213
+ body: JSON.stringify(payload),
214
+ signal: timeoutSignal(timeoutMs, signal)
215
+ })
216
+
217
+ if (!response.ok) {
218
+ const text = await response.text().catch(() => "")
219
+ const error = new ProviderError(`openai request failed: ${response.status} ${text}`, {
220
+ provider: "openai",
221
+ model,
222
+ endpoint
223
+ })
224
+ error.httpStatus = response.status
225
+ throw error
226
+ }
227
+
228
+ let json
229
+ try {
230
+ json = await response.json()
231
+ } catch (parseErr) {
232
+ throw new ProviderError(`openai response JSON parse failed: ${parseErr.message}`, { provider: "openai", model, endpoint })
233
+ }
234
+ const message = json?.choices?.[0]?.message ?? {}
235
+ const promptTokens = json?.usage?.prompt_tokens ?? 0
236
+ const details = json?.usage?.prompt_tokens_details || {}
237
+ const cachedTokens = details.cached_tokens ?? 0
238
+ const cacheWriteTokens = details.cache_creation_input_tokens ?? 0
239
+ const usage = {
240
+ input: promptTokens - cachedTokens,
241
+ output: json?.usage?.completion_tokens ?? 0,
242
+ cacheRead: cachedTokens,
243
+ cacheWrite: cacheWriteTokens
244
+ }
245
+ const toolCalls = parseToolCalls(message)
246
+ const text = typeof message.content === "string" ? message.content : ""
247
+ return { text, usage, toolCalls }
248
+ }
249
+ })
250
+ }
251
+
252
+ export async function* requestOpenAIStream(input) {
253
+ const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 120000, streamIdleTimeoutMs = 120000, maxTokens, retry = {}, signal = null } = input
254
+ if (!apiKey) {
255
+ throw new ProviderError(`missing API key for openai provider (env: ${input.apiKeyEnv || "unknown"})`, {
256
+ provider: "openai"
257
+ })
258
+ }
259
+
260
+ const payload = {
261
+ model,
262
+ messages: [...buildSystemMessages(system), ...mapMessages(messages)],
263
+ tools: mapTools(tools),
264
+ tool_choice: tools?.length ? "auto" : undefined,
265
+ ...(maxTokens ? { max_tokens: maxTokens } : {}),
266
+ stream: true,
267
+ stream_options: { include_usage: true }
268
+ }
269
+ const endpoint = `${baseUrl.replace(/\/$/, "")}/chat/completions`
270
+ const attempts = Number(retry.attempts ?? 3)
271
+ const baseDelayMs = Number(retry.baseDelayMs ?? 800)
272
+
273
+ let response
274
+ for (let attempt = 1; attempt <= attempts; attempt++) {
275
+ let connTimer = null
276
+ const connController = new AbortController()
277
+ try {
278
+ // Use a connection-only timeout for the initial fetch.
279
+ // Once headers arrive, clear it the SSE idle timeout handles the streaming phase.
280
+ connTimer = setTimeout(() => connController.abort(), timeoutMs)
281
+ const fetchSignal = signal
282
+ ? AbortSignal.any([signal, connController.signal])
283
+ : connController.signal
284
+
285
+ response = await fetch(endpoint, {
286
+ method: "POST",
287
+ headers: {
288
+ "content-type": "application/json",
289
+ authorization: `Bearer ${apiKey}`
290
+ },
291
+ body: JSON.stringify(payload),
292
+ signal: fetchSignal
293
+ })
294
+ clearTimeout(connTimer)
295
+
296
+ if (!response.ok) {
297
+ const text = await response.text().catch(() => "")
298
+ const error = new ProviderError(`openai stream failed: ${response.status} ${text}`, {
299
+ provider: "openai", model, endpoint
300
+ })
301
+ error.httpStatus = response.status
302
+ throw error
303
+ }
304
+ break
305
+ } catch (err) {
306
+ clearTimeout(connTimer)
307
+ if (signal?.aborted) throw err
308
+ const isNetwork = err?.code === "ETIMEDOUT" || err?.code === "ECONNRESET" || err?.name === "AbortError"
309
+ if (!isNetwork || attempt >= attempts) throw err
310
+ await sleep(baseDelayMs * Math.pow(2, attempt - 1))
311
+ }
312
+ }
313
+
314
+ const toolBuffers = new Map()
315
+ let finishReason = null
316
+
317
+ for await (const { data } of parseSSE(response.body, signal, { idleTimeoutMs: streamIdleTimeoutMs })) {
318
+ let json
319
+ try { json = JSON.parse(data) } catch { continue }
320
+
321
+ if (json.usage) {
322
+ const pt = json.usage.prompt_tokens ?? 0
323
+ const details = json.usage.prompt_tokens_details || {}
324
+ const ct = details.cached_tokens ?? 0
325
+ const cw = details.cache_creation_input_tokens ?? 0
326
+ yield {
327
+ type: "usage",
328
+ usage: { input: pt - ct, output: json.usage.completion_tokens ?? 0, cacheRead: ct, cacheWrite: cw }
329
+ }
330
+ }
331
+
332
+ const choice = json.choices?.[0]
333
+ if (choice?.finish_reason) {
334
+ finishReason = choice.finish_reason
335
+ }
336
+ const delta = choice?.delta
337
+ if (!delta) continue
338
+
339
+ if (delta.content) {
340
+ yield { type: "text", content: delta.content }
341
+ }
342
+
343
+ if (delta.tool_calls) {
344
+ for (const tc of delta.tool_calls) {
345
+ const idx = tc.index ?? 0
346
+ if (!toolBuffers.has(idx)) {
347
+ toolBuffers.set(idx, { id: "", name: "", argsJson: "" })
348
+ }
349
+ const buf = toolBuffers.get(idx)
350
+ if (tc.id) buf.id = tc.id
351
+ if (tc.function?.name) buf.name = tc.function.name
352
+ if (tc.function?.arguments) buf.argsJson += tc.function.arguments
353
+ }
354
+ }
355
+ }
356
+
357
+ for (const [, buf] of toolBuffers) {
358
+ const raw = buf.argsJson || "{}"
359
+ let args = {}
360
+ try {
361
+ args = JSON.parse(raw)
362
+ } catch (parseErr) {
363
+ console.error(`[openai] tool_call JSON parse failed for "${buf.name}": ${parseErr.message} (${raw.length} chars, first 200: ${raw.slice(0, 200)})`)
364
+ args = { __parse_error: true, __raw_length: raw.length, __error: parseErr.message }
365
+ }
366
+ yield {
367
+ type: "tool_call",
368
+ call: {
369
+ id: buf.id || `tc_${Date.now()}`,
370
+ name: buf.name,
371
+ args
372
+ }
373
+ }
374
+ }
375
+
376
+ // Normalize: "stop" → "end_turn", "length" → "max_tokens", "tool_calls" → "tool_use"
377
+ const normalizedReason = finishReason === "length" ? "max_tokens"
378
+ : finishReason === "tool_calls" ? "tool_use"
379
+ : finishReason === "stop" ? "end_turn"
380
+ : finishReason || "end_turn"
381
+ yield { type: "stop", reason: normalizedReason }
382
+ }