@a1hvdy/cc-openclaw 0.7.1 → 0.9.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 (79) hide show
  1. package/dist/src/command-router/cc-handler.js +11 -3
  2. package/dist/src/command-router/cc-handler.js.map +1 -1
  3. package/dist/src/engines/persistent-session.d.ts +1 -0
  4. package/dist/src/engines/persistent-session.js +35 -1
  5. package/dist/src/engines/persistent-session.js.map +1 -1
  6. package/dist/src/index.d.ts +10 -1
  7. package/dist/src/index.js +47 -7
  8. package/dist/src/index.js.map +1 -1
  9. package/dist/src/lib/config-service.d.ts +106 -0
  10. package/dist/src/lib/config-service.js +217 -0
  11. package/dist/src/lib/config-service.js.map +1 -0
  12. package/dist/src/lib/config.d.ts +33 -14
  13. package/dist/src/lib/config.js +147 -34
  14. package/dist/src/lib/config.js.map +1 -1
  15. package/dist/src/lib/index.d.ts +1 -1
  16. package/dist/src/lib/index.js +4 -1
  17. package/dist/src/lib/index.js.map +1 -1
  18. package/dist/src/openai-compat/message-extractor.d.ts +79 -0
  19. package/dist/src/openai-compat/message-extractor.js +134 -0
  20. package/dist/src/openai-compat/message-extractor.js.map +1 -0
  21. package/dist/src/openai-compat/mode-flags.d.ts +34 -0
  22. package/dist/src/openai-compat/mode-flags.js +44 -0
  23. package/dist/src/openai-compat/mode-flags.js.map +1 -0
  24. package/dist/src/openai-compat/non-streaming-handler.d.ts +26 -0
  25. package/dist/src/openai-compat/non-streaming-handler.js +108 -0
  26. package/dist/src/openai-compat/non-streaming-handler.js.map +1 -0
  27. package/dist/src/openai-compat/openai-compat.d.ts +15 -166
  28. package/dist/src/openai-compat/openai-compat.js +72 -817
  29. package/dist/src/openai-compat/openai-compat.js.map +1 -1
  30. package/dist/src/openai-compat/prompts.d.ts +47 -0
  31. package/dist/src/openai-compat/prompts.js +119 -0
  32. package/dist/src/openai-compat/prompts.js.map +1 -0
  33. package/dist/src/openai-compat/response-formatter.d.ts +33 -0
  34. package/dist/src/openai-compat/response-formatter.js +74 -0
  35. package/dist/src/openai-compat/response-formatter.js.map +1 -0
  36. package/dist/src/openai-compat/session-key-resolver.d.ts +41 -0
  37. package/dist/src/openai-compat/session-key-resolver.js +78 -0
  38. package/dist/src/openai-compat/session-key-resolver.js.map +1 -0
  39. package/dist/src/openai-compat/status-reporter.d.ts +30 -0
  40. package/dist/src/openai-compat/status-reporter.js +81 -0
  41. package/dist/src/openai-compat/status-reporter.js.map +1 -0
  42. package/dist/src/openai-compat/streaming-handler.d.ts +41 -0
  43. package/dist/src/openai-compat/streaming-handler.js +294 -0
  44. package/dist/src/openai-compat/streaming-handler.js.map +1 -0
  45. package/dist/src/openai-compat/tool-calls-parser.d.ts +34 -0
  46. package/dist/src/openai-compat/tool-calls-parser.js +93 -0
  47. package/dist/src/openai-compat/tool-calls-parser.js.map +1 -0
  48. package/dist/src/openai-compat/tool-results-serializer.d.ts +60 -0
  49. package/dist/src/openai-compat/tool-results-serializer.js +56 -0
  50. package/dist/src/openai-compat/tool-results-serializer.js.map +1 -0
  51. package/dist/src/session/session-manager.js +12 -0
  52. package/dist/src/session/session-manager.js.map +1 -1
  53. package/dist/src/session-bootstrap/cwd-patch.js +30 -13
  54. package/dist/src/session-bootstrap/cwd-patch.js.map +1 -1
  55. package/dist/src/types/index.d.ts +15 -0
  56. package/dist/src/types/index.js +16 -0
  57. package/dist/src/types/index.js.map +1 -0
  58. package/dist/src/types/route.d.ts +41 -0
  59. package/dist/src/types/route.js +12 -0
  60. package/dist/src/types/route.js.map +1 -0
  61. package/dist/src/types/runtime-config.d.ts +161 -0
  62. package/dist/src/types/runtime-config.js +118 -0
  63. package/dist/src/types/runtime-config.js.map +1 -0
  64. package/dist/src/types/session.d.ts +48 -0
  65. package/dist/src/types/session.js +20 -0
  66. package/dist/src/types/session.js.map +1 -0
  67. package/dist/src/types/sse.d.ts +38 -0
  68. package/dist/src/types/sse.js +12 -0
  69. package/dist/src/types/sse.js.map +1 -0
  70. package/dist/src/types/tool-bridge.d.ts +81 -0
  71. package/dist/src/types/tool-bridge.js +34 -0
  72. package/dist/src/types/tool-bridge.js.map +1 -0
  73. package/dist/src/types/upstream.d.ts +652 -0
  74. package/dist/src/types/upstream.js +145 -0
  75. package/dist/src/types/upstream.js.map +1 -0
  76. package/package.json +3 -2
  77. package/dist/src/lib/route-flag.d.ts +0 -49
  78. package/dist/src/lib/route-flag.js +0 -52
  79. package/dist/src/lib/route-flag.js.map +0 -1
@@ -5,469 +5,76 @@
5
5
  * webchat frontends (ChatGPT-Next-Web, Open WebUI, etc.) to use the plugin
6
6
  * as a drop-in backend. Stateful sessions maximize Anthropic prompt caching.
7
7
  */
8
- import * as http from 'node:http';
9
8
  import * as fs from 'node:fs';
10
9
  import * as path from 'node:path';
11
10
  import * as os from 'node:os';
12
- import { randomUUID, createHash } from 'node:crypto';
11
+ import { randomUUID } from 'node:crypto';
13
12
  import { resolveEngineAndModel } from '../models.js';
14
- import { OPENAI_COMPAT_DEFAULT_MODEL, OPENAI_COMPAT_AUTO_COMPACT_THRESHOLD, OPENAI_COMPAT_SESSION_PREFIX, } from '../constants.js';
15
- import { getOpenaiCompatToolsPerMessage, isOpenaiCompatNewConvoHeuristic, getOpenaiCompatStatusUrl, getSurfaceThinkingEnabled, } from '../lib/config.js';
16
- import { maybeInlineSkill } from './skill-resolver.js';
13
+ import { OPENAI_COMPAT_DEFAULT_MODEL, OPENAI_COMPAT_AUTO_COMPACT_THRESHOLD, } from '../constants.js';
14
+ import { isToolsPerMessageModeEnabled, isToolStreamMode } from './mode-flags.js';
15
+ import { resolveSessionKey, sessionNameFromKey } from './session-key-resolver.js';
16
+ import { buildSessionSystemPrompt, buildToolPromptBlock } from './prompts.js';
17
+ import { extractUserMessage, } from './message-extractor.js';
18
+ import { handleNonStreaming } from './non-streaming-handler.js';
19
+ import { handleStreaming } from './streaming-handler.js';
20
+ // Re-export for backward compat — Cluster B extracted these to dedicated
21
+ // modules; keep the original import surface stable for any external caller.
22
+ // See src/openai-compat/{mode-flags,session-key-resolver,prompts,tool-calls-parser,tool-results-serializer}.ts.
23
+ export { isToolsPerMessageModeEnabled, isToolStreamMode } from './mode-flags.js';
24
+ export { resolveSessionKey, sessionNameFromKey } from './session-key-resolver.js';
25
+ export { noToolsSystemPrompt, buildSessionSystemPrompt, buildToolPromptBlock } from './prompts.js';
26
+ export { parseToolCallsFromText } from './tool-calls-parser.js';
27
+ export { serializeToolResults, serializeToolResultsAsBlocks, } from './tool-results-serializer.js';
28
+ export { extractUserMessage, } from './message-extractor.js';
29
+ export { formatCompletionResponse, formatCompletionChunk } from './response-formatter.js';
30
+ export { reportStatus, getToolDescription } from './status-reporter.js';
31
+ export { handleNonStreaming } from './non-streaming-handler.js';
32
+ export { handleStreaming } from './streaming-handler.js';
17
33
  import { emit as emitTrajectory } from '../lib/trajectory.js';
18
34
  import { formatError, ERROR_CODES } from '../lib/error-formatter.js';
19
- // ─── Session Key Resolution ──────────────────────────────────────────────────
20
- /**
21
- * Derive a session key from the request.
22
- * Priority: X-Session-Id header > user field > sha1(model + systemPrompt) > "default"
23
- *
24
- * The system-prompt-hash fallback prevents the bug where every caller without
25
- * X-Session-Id or `user` collapses onto a single shared "openai-default"
26
- * plugin session. In multi-caller setups (OpenClaw routing the main agent,
27
- * cron jobs, and subagents through the same gateway) that previously meant
28
- * every request serialized against every other and frequently picked up the
29
- * wrong session's appendSystemPrompt — also a privacy leak across callers.
30
- *
31
- * The model is mixed into the hash so that two callers with the same system
32
- * prompt but different requested models don't collide and silently get
33
- * responses from the wrong model. Originally diagnosed in PR #40 by
34
- * @megayounus786.
35
- */
36
- /**
37
- * When set (to '1', 'true', 'yes'), the proxy preserves the pre-fix behavior:
38
- * - tools injected into every user message
39
- * - session key NOT fingerprinted by tools (same session across tool changes)
40
- * Default (unset) is the new behavior: tools embedded in session system prompt
41
- * at create time + session key fingerprinted by tools. The new behavior
42
- * eliminates periodic latency spikes but does not support mutating the tool
43
- * list within a single session (a new session is created when tools change).
44
- */
45
- export function isToolsPerMessageModeEnabled() {
46
- const v = getOpenaiCompatToolsPerMessage();
47
- if (!v)
48
- return false;
49
- const t = v.trim().toLowerCase();
50
- return t === '1' || t === 'true' || t === 'yes';
51
- }
52
- /**
53
- * Phase 2 R5: tool-stream mode flag. When `CC_OPENCLAW_TOOL_STREAM=1` AND the
54
- * caller provides `tools[]`, cc-openclaw skips the defensive "no tools"
55
- * system prompt and does NOT clear `sessionConfig.tools`, allowing Claude
56
- * CLI's native tool_use events to flow through the new parser+translator
57
- * pipeline (Phase 4 Pillar 0.5). Default off; opt-in for the new path.
58
- */
59
- export function isToolStreamMode() {
60
- return process.env.CC_OPENCLAW_TOOL_STREAM === '1';
61
- }
62
- /**
63
- * Generate the "no built-in tools" system prompt preamble.
64
- * The `toolLocation` parameter controls how the model is told where to find
65
- * tool definitions — 'system' means "in the <available_tools> block below"
66
- * (tools baked into system prompt), 'user' means "in <available_tools> tags
67
- * in the user message" (legacy per-turn injection).
68
- */
69
- export function noToolsSystemPrompt(toolLocation) {
70
- const allowBuiltins = process.env.CC_OPENCLAW_ALLOW_BUILTINS === '1';
71
- const locationHint = toolLocation === 'system'
72
- ? 'in the <available_tools> block below'
73
- : 'in <available_tools> tags in the user message';
74
- if (allowBuiltins) {
75
- // Phase 2.1 dual-source mode: Claude CLI's built-in tools (Bash, Read,
76
- // Write, Edit, Glob, Grep, WebFetch, WebSearch, NotebookEdit, +any
77
- // MCP-bridged tools) coexist with the proxy-defined <available_tools>.
78
- // The model picks whichever fits. v0.4.9 added explicit anti-hallucination
79
- // language because Savvy was still emitting "I do not have access to tools"
80
- // even with this preamble active — model needs strong, repeated affirmation.
81
- return ('You are an AI assistant with TWO complementary tool sources available:\n' +
82
- '1. Claude Code CLI built-ins: Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, NotebookEdit, plus any MCP-bridged tools (gh, npm, pm2, openclaw, docker, kubectl, etc. — invoke via Bash). Invoke these directly via your normal tool_use mechanism.\n' +
83
- `2. Proxy-defined tools: ${locationHint}. Use these by emitting <tool_calls> XML as instructed there.\n` +
84
- 'CRITICAL — anti-hallucination: NEVER claim "I have no tools", "I cannot run commands", "I do not have access to this environment", or any equivalent phrasing. You DO have full Bash, gh, npm, pm2, openclaw, file, and web access. If unsure whether a specific tool is available, ATTEMPT it; the runtime will return a concrete error if not.\n' +
85
- 'When a request needs action, ACT via tools — do not narrate what you would do. Default to executing, not describing.\n' +
86
- 'For research / web fetching / file ops / command execution: prefer the built-ins (WebFetch, WebSearch, Bash, Read).\n' +
87
- 'For tasks where the proxy ships a specific custom tool: prefer the proxy tool.\n' +
88
- 'If a tool you would normally use is not available in either source, attempt the task with what IS available and explain concretely which tool would have helped.');
89
- }
90
- return ('You are an AI assistant operating through a proxy that provides a specific set of tools.\n' +
91
- `Your tools are defined ${locationHint}. Use them by emitting <tool_calls> XML as instructed there.\n` +
92
- 'When a request needs action, you MUST use the tools that are defined — do not refuse on the grounds of "no tools".\n' +
93
- 'If a specific tool you would normally use is not in <available_tools>, do the best you can with what IS provided, or report concretely which tool would be needed.\n' +
94
- 'You do NOT have direct access to Claude Code CLI built-ins (Bash, Read, Write, Edit, Glob, Grep) outside of <available_tools>; do not invoke them directly.\n' +
95
- 'If no <available_tools> are provided at all, respond with text only.');
96
- }
97
- /**
98
- * Build the full session system prompt for a Claude Code session with tools.
99
- * Exported for testability — called from `handleChatCompletion`.
100
- *
101
- * - Default mode: tools are embedded in the system prompt (cacheable by Anthropic).
102
- * - Legacy mode (OPENAI_COMPAT_TOOLS_PER_MESSAGE=1): tools are NOT embedded;
103
- * they'll be injected per-turn in the user message instead.
104
- */
105
- export function buildSessionSystemPrompt(tools, callerSystemPrompt) {
106
- // Phase 2 R5: in tool-stream mode with tools provided, skip the defensive
107
- // "no tools" preamble and the <available_tools> block entirely. Claude CLI
108
- // gets the tools natively via sessionConfig.tools (not cleared) and emits
109
- // tool_use events that the new parser+translator translate to OpenAI SSE.
110
- // v0.4.9: prepend a minimal tool-affirmation preamble. Without this, callers
111
- // with weak/no system prompts saw the model hallucinate "I have no tools" —
112
- // the CLI had tools loaded but nothing in the prompt told the model so.
113
- if (isToolStreamMode() && tools && tools.length > 0) {
114
- const allowBuiltins = process.env.CC_OPENCLAW_ALLOW_BUILTINS === '1';
115
- const toolAffirmation = allowBuiltins
116
- ? 'You have full Claude Code CLI tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, gh, npm, pm2, openclaw, etc.) available natively, plus any caller-provided tools below. NEVER claim "I have no tools" — invoke a tool and let the runtime confirm. Default to ACTING, not narrating.\n\n'
117
- : '';
118
- return toolAffirmation + (callerSystemPrompt ?? '');
119
- }
120
- if (isToolsPerMessageModeEnabled()) {
121
- const preamble = noToolsSystemPrompt('user');
122
- return callerSystemPrompt ? `${preamble}\n\n${callerSystemPrompt}` : preamble;
123
- }
124
- const preamble = noToolsSystemPrompt('system');
125
- const toolBlock = buildToolPromptBlock(tools);
126
- const systemWithTools = `${preamble}\n\n${toolBlock}`;
127
- return callerSystemPrompt ? `${systemWithTools}\n\n${callerSystemPrompt}` : systemWithTools;
128
- }
129
- export function resolveSessionKey(body, headers) {
130
- const headerKey = headers['x-session-id'];
131
- if (typeof headerKey === 'string' && headerKey.trim())
132
- return headerKey.trim();
133
- if (body.user && body.user.trim())
134
- return body.user.trim();
135
- const sys = (body.messages || [])
136
- .filter((m) => m && m.role === 'system')
137
- .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)))
138
- .join('\n');
139
- const modelTag = (body.model || '').toString();
140
- // Include a fingerprint of the tool list so that two requests with the same
141
- // system prompt but different tool definitions land in different sessions.
142
- // The tool schemas are baked into the session system prompt on create; if
143
- // tools change we need a new session rather than re-using a stale one.
144
- // Hash only tool names + a short description prefix to keep the fingerprint
145
- // small and stable against schema formatting differences.
146
- //
147
- // Opt-out: OPENAI_COMPAT_TOOLS_PER_MESSAGE=1 restores the pre-fix behavior
148
- // of keying sessions only by system prompt + model. Enable this if you have
149
- // callers that mutate their tool list within one conversation and rely on
150
- // continuing history across tool changes.
151
- const toolsFingerprint = isToolsPerMessageModeEnabled()
152
- ? ''
153
- : (body.tools || [])
154
- .map((t) => {
155
- const fn = t?.function;
156
- if (!fn?.name)
157
- return '';
158
- const descPrefix = (typeof fn.description === 'string' ? fn.description : '').slice(0, 64);
159
- return `${fn.name}:${descPrefix}`;
160
- })
161
- .filter(Boolean)
162
- .join('|');
163
- if (sys || modelTag || toolsFingerprint) {
164
- return ('sys-' +
165
- createHash('sha1')
166
- .update(modelTag + '\n' + sys + '\n' + toolsFingerprint)
167
- .digest('hex')
168
- .slice(0, 12));
169
- }
170
- return 'default';
171
- }
172
- /** Build the full session name from a key */
173
- export function sessionNameFromKey(key) {
174
- return `${OPENAI_COMPAT_SESSION_PREFIX}${key}`;
175
- }
176
- // ─── Function Calling Support ────────────────────────────────────────────────
177
- /**
178
- * Convert OpenAI tool definitions into a structured prompt block.
179
- * Injected into the user message so the CLI model sees tool definitions
180
- * and responds with <tool_calls> tags when it wants to invoke a function.
181
- */
182
- export function buildToolPromptBlock(tools) {
183
- if (!tools?.length)
184
- return '';
185
- const toolDefs = tools
186
- .map((t) => {
187
- const fn = t.function;
188
- const params = JSON.stringify(fn.parameters, null, 2);
189
- return `### ${fn.name}\n${fn.description}\n\nParameters:\n\`\`\`json\n${params}\n\`\`\``;
190
- })
191
- .join('\n\n');
192
- return ('<available_tools>\n' +
193
- 'You have access to the following tools. When you need to use a tool, respond with a JSON array wrapped in <tool_calls> tags.\n\n' +
194
- 'FORMAT:\n' +
195
- '<tool_calls>\n' +
196
- '[{"name": "tool_name", "arguments": {"param1": "value1"}}]\n' +
197
- '</tool_calls>\n\n' +
198
- 'If you do NOT need any tools, respond normally with text only (no <tool_calls> tags).\n\n' +
199
- '## Available Tools\n\n' +
200
- toolDefs +
201
- '\n</available_tools>');
202
- }
203
- /**
204
- * Parse tool_calls from CLI text output.
205
- *
206
- * Looks for <tool_calls>[...]</tool_calls> tags in the response text.
207
- * Returns both the extracted text content (before/after tags) and any tool calls found.
208
- */
209
- export function parseToolCallsFromText(text) {
210
- // Match ALL <tool_calls> blocks (model may output multiple)
211
- const tagRegex = /<tool_calls>\s*([\s\S]*?)\s*<\/tool_calls>/g;
212
- const allCalls = [];
213
- let lastIndex = 0;
214
- const textParts = [];
215
- let m;
216
- while ((m = tagRegex.exec(text)) !== null) {
217
- // Collect text before this block
218
- const before = text.slice(lastIndex, m.index).trim();
219
- if (before)
220
- textParts.push(before);
221
- lastIndex = m.index + m[0].length;
222
- try {
223
- const parsed = JSON.parse(m[1].trim());
224
- const arr = Array.isArray(parsed) ? parsed : [parsed];
225
- for (const raw of arr) {
226
- const call = raw;
227
- if (!call || typeof call !== 'object' || typeof call.name !== 'string')
228
- continue;
229
- let args;
230
- if (typeof call.arguments === 'string') {
231
- try {
232
- JSON.parse(call.arguments);
233
- args = call.arguments;
234
- }
235
- catch {
236
- args = JSON.stringify({ input: call.arguments });
237
- }
238
- }
239
- else {
240
- args = JSON.stringify(call.arguments ?? {});
241
- }
242
- allCalls.push({
243
- id: `call_${randomUUID().replace(/-/g, '').slice(0, 24)}`,
244
- type: 'function',
245
- function: { name: call.name, arguments: args },
246
- });
247
- }
248
- }
249
- catch {
250
- // One block failed — keep its text as content
251
- textParts.push(m[0]);
252
- }
253
- }
254
- // Collect text after last block
255
- const after = text.slice(lastIndex).trim();
256
- if (after)
257
- textParts.push(after);
258
- // Strip <tool_result> and <tool_results> tags that the model may echo back
259
- // from the serialized tool results we injected earlier.
260
- const stripToolResultTags = (s) => s
261
- .replace(/<tool_results?>[\s\S]*?<\/tool_results?>/g, '')
262
- .replace(/<tool_results?[^>]*>/g, '')
263
- .trim();
264
- if (allCalls.length > 0) {
265
- const raw = textParts.join('\n').trim();
266
- const cleaned = raw ? stripToolResultTags(raw) : null;
267
- return { textContent: cleaned || null, toolCalls: allCalls };
268
- }
269
- const cleaned = text ? stripToolResultTags(text) : null;
270
- return { textContent: cleaned || null, toolCalls: [] };
271
- }
272
- /**
273
- * Serialize tool result messages into a text block for the CLI model.
274
- * Converts OpenAI `tool` role messages into <tool_result> tags.
275
- *
276
- * Legacy path (CC_OPENCLAW_TOOL_STREAM=0). Used when the model receives
277
- * tool definitions via the system prompt's <available_tools> XML block
278
- * and emits <tool_calls> XML in response. Tool-stream mode (R4) uses
279
- * `serializeToolResultsAsBlocks()` instead, returning native Anthropic
280
- * `tool_result` content blocks that Claude CLI parses directly.
281
- */
282
- export function serializeToolResults(messages) {
283
- const toolMessages = messages.filter((m) => m.role === 'tool');
284
- if (!toolMessages.length)
285
- return '';
286
- const results = toolMessages
287
- .map((m) => {
288
- const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
289
- return `<tool_result tool_call_id="${m.tool_call_id || 'unknown'}">\n${content}\n</tool_result>`;
290
- })
291
- .join('\n\n');
292
- return `<tool_results>\n${results}\n</tool_results>\n\nAbove are the results of the tool calls you requested. Continue your response based on these results.`;
293
- }
294
- export function serializeToolResultsAsBlocks(messages) {
295
- return messages
296
- .filter((m) => m.role === 'tool')
297
- .map((m) => ({
298
- type: 'tool_result',
299
- tool_use_id: m.tool_call_id || 'unknown',
300
- content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
301
- }));
302
- }
303
- /**
304
- * Extract the relevant parts from an OpenAI messages array.
305
- *
306
- * Sessions are stateful — we only need the last user message. The tricky
307
- * question is whether to start a fresh session or append to the existing one.
308
- *
309
- * Default mode (no env var): only honor an explicit `X-Session-Reset: 1`
310
- * header. This is correct for clients that maintain their own conversation
311
- * transcript and forward only the latest user turn (OpenClaw main agent
312
- * loop, cron jobs, subagents). The previous heuristic
313
- * (`nonSystemMessages.length <= 1`) fired on every such request, killing the
314
- * persistent CLI every turn and preventing Anthropic prompt caching from
315
- * ever warming. Originally diagnosed in PR #40 by @megayounus786.
316
- *
317
- * Legacy mode (`OPENAI_COMPAT_NEW_CONVO_HEURISTIC=1`): restore the old
318
- * `system + single user ⇒ new conversation` rule, for clients that re-send
319
- * the full transcript on every turn (ChatGPT-Next-Web, Open WebUI, data
320
- * labeling tools, etc). They use the transcript shape itself as their only
321
- * "start a new conversation" signal.
322
- *
323
- * The env var is read on every call so ops can flip it via launchctl setenv
324
- * without restarting the server.
325
- */
326
- export function extractUserMessage(messages, headers) {
327
- if (!messages || messages.length === 0) {
328
- throw new Error('messages array is empty');
329
- }
330
- // Normalize content from any message: OpenAI API allows content as a string
331
- // OR an array of content parts (e.g. multimodal messages with text + images).
332
- // We need a string for the CLI, so arrays are joined.
333
- const textOf = (m) => {
334
- if (typeof m.content === 'string')
335
- return m.content;
336
- if (Array.isArray(m.content)) {
337
- return m.content
338
- .map((p) => p.text || '')
339
- .filter(Boolean)
340
- .join('');
341
- }
342
- return m.content != null ? String(m.content) : '';
343
- };
344
- // Extract system prompt if present
345
- const systemMessages = messages.filter((m) => m.role === 'system');
346
- const systemPrompt = systemMessages.length > 0 ? systemMessages.map(textOf).join('\n') : undefined;
347
- // Handle tool result messages — only when the LAST non-system message is
348
- // a tool role (meaning we're in an active tool-use cycle). If the last
349
- // message is a user role, it's a follow-up in an existing conversation
350
- // and the old tool results are already in the CLI's history.
351
- const lastNonSystem = [...messages].reverse().find((m) => m.role !== 'system');
352
- if (lastNonSystem?.role === 'tool') {
353
- const userMessages = messages.filter((m) => m.role === 'user');
354
- const lastUserText = userMessages.length > 0 ? textOf(userMessages[userMessages.length - 1]) : '';
355
- // Phase 2 R4 wire-up: in tool-stream mode, emit native Anthropic
356
- // tool_result blocks instead of XML-wrapped text. Claude CLI's
357
- // stream-json input accepts content arrays directly.
358
- if (isToolStreamMode()) {
359
- const toolBlocks = serializeToolResultsAsBlocks(messages);
360
- const userMessageBlocks = [...toolBlocks];
361
- if (lastUserText) {
362
- userMessageBlocks.push({ type: 'text', text: lastUserText });
363
- }
364
- // Keep userMessage populated as the legacy XML form for callers
365
- // that don't yet handle the structured path. Both fields agree in
366
- // intent; consumers should prefer userMessageBlocks when present.
367
- const fallback = serializeToolResults(messages);
368
- const userMessage = lastUserText ? `${fallback}\n\n${lastUserText}` : fallback;
369
- return { systemPrompt, userMessage, userMessageBlocks, isNewConversation: false };
370
- }
371
- const toolResultBlock = serializeToolResults(messages);
372
- const userMessage = lastUserText ? `${toolResultBlock}\n\n${lastUserText}` : toolResultBlock;
373
- return { systemPrompt, userMessage, isNewConversation: false };
374
- }
375
- // Find last user message
376
- const userMessages = messages.filter((m) => m.role === 'user');
377
- if (userMessages.length === 0) {
378
- throw new Error('No user message found in messages array');
379
- }
380
- const rawUserMessage = textOf(userMessages[userMessages.length - 1]);
381
- // Workspace skill auto-inline: if the last user message is /<skill> [args]
382
- // and ~/.openclaw/workspace/skills/*/SKILL.md has a matching `name:` in
383
- // frontmatter, replace the user message with the SKILL.md body so the
384
- // model has full skill context without needing the Read tool (cc-openclaw
385
- // disables built-in tools by design — see the `sessionConfig.tools = ''`
386
- // line below).
387
- const userMessage = maybeInlineSkill(rawUserMessage) ?? rawUserMessage;
388
- // 1. Explicit reset header — honored in both modes. Normalize trim+lowercase
389
- // so callers using `TRUE`, ` 1 `, etc. don't silently fail.
390
- const rawReset = headers?.['x-session-reset'];
391
- const resetHeader = typeof rawReset === 'string' ? rawReset.trim().toLowerCase() : '';
392
- if (resetHeader === 'true' || resetHeader === '1') {
393
- return { systemPrompt, userMessage, isNewConversation: true };
35
+ function parseRouteBody(body) {
36
+ if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
37
+ return {
38
+ ok: false,
39
+ status: 400,
40
+ error: 'messages is required and must be a non-empty array',
41
+ };
394
42
  }
395
- // 2. Legacy heuristic — only when explicitly opted in via env var.
396
- if (isOpenaiCompatNewConvoHeuristic()) {
397
- const nonSystemMessages = messages.filter((m) => m.role !== 'system');
398
- return { systemPrompt, userMessage, isNewConversation: nonSystemMessages.length <= 1 };
43
+ if (body.max_tokens !== undefined &&
44
+ (typeof body.max_tokens !== 'number' || body.max_tokens <= 0)) {
45
+ return {
46
+ ok: false,
47
+ status: 400,
48
+ error: 'max_tokens must be a positive number',
49
+ };
399
50
  }
400
- return { systemPrompt, userMessage, isNewConversation: false };
401
- }
402
- // ─── Response Formatting ─────────────────────────────────────────────────────
403
- export function formatCompletionResponse(id, model, text, tokensIn, tokensOut, toolCalls,
404
- /** v0.7.0: when present + non-empty, attached as `choices[0].message.reasoning`
405
- * (mirrors OpenAI o1/o3 schema). Caller must already be gated on
406
- * `getSurfaceThinkingEnabled()` from `lib/config.ts` — this function does
407
- * not re-check the flag. Pass empty string or undefined to omit. */
408
- reasoning) {
409
- const hasToolCalls = toolCalls && toolCalls.length > 0;
410
- const hasReasoning = typeof reasoning === 'string' && reasoning.length > 0;
411
51
  return {
412
- id,
413
- object: 'chat.completion',
414
- created: Math.floor(Date.now() / 1000),
415
- model,
416
- choices: [
417
- {
418
- index: 0,
419
- message: {
420
- role: 'assistant',
421
- content: text || null,
422
- ...(hasToolCalls ? { tool_calls: toolCalls } : {}),
423
- ...(hasReasoning ? { reasoning } : {}),
424
- },
425
- finish_reason: hasToolCalls ? 'tool_calls' : 'stop',
426
- },
427
- ],
428
- usage: {
429
- prompt_tokens: tokensIn,
430
- completion_tokens: tokensOut,
431
- total_tokens: tokensIn + tokensOut,
52
+ ok: true,
53
+ request: {
54
+ messages: body.messages,
55
+ model: body.model,
56
+ stream: body.stream,
57
+ temperature: body.temperature,
58
+ max_tokens: body.max_tokens,
59
+ max_completion_tokens: body.max_completion_tokens,
60
+ user: body.user,
61
+ tools: body.tools,
432
62
  },
433
63
  };
434
64
  }
435
- export function formatCompletionChunk(id, model, delta, finishReason) {
436
- return {
437
- id,
438
- object: 'chat.completion.chunk',
439
- created: Math.floor(Date.now() / 1000),
440
- model,
441
- choices: [{ index: 0, delta, finish_reason: finishReason }],
442
- };
443
- }
444
65
  export async function handleChatCompletion(manager, body, headers, res) {
445
- // Validate before casting
446
- if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
447
- res.writeHead(400, { 'Content-Type': 'application/json' });
66
+ // Cluster A step 4: typed boundary parser. Replaces the inline cast +
67
+ // validation block that previously lived here (~30 lines). Returns a
68
+ // discriminated union so the type system enforces "validate before use."
69
+ const parsed = parseRouteBody(body);
70
+ if (!parsed.ok) {
71
+ res.writeHead(parsed.status, { 'Content-Type': 'application/json' });
448
72
  res.end(JSON.stringify({
449
- error: { message: 'messages is required and must be a non-empty array', type: 'invalid_request_error' },
450
- }));
451
- return;
452
- }
453
- // Safe cast: messages validated above, other fields are optional
454
- const request = {
455
- messages: body.messages,
456
- model: body.model,
457
- stream: body.stream,
458
- temperature: body.temperature,
459
- max_tokens: body.max_tokens,
460
- user: body.user,
461
- tools: body.tools,
462
- };
463
- // Validate max_tokens if provided
464
- if (request.max_tokens !== undefined && (typeof request.max_tokens !== 'number' || request.max_tokens <= 0)) {
465
- res.writeHead(400, { 'Content-Type': 'application/json' });
466
- res.end(JSON.stringify({
467
- error: { message: 'max_tokens must be a positive number', type: 'invalid_request_error' },
73
+ error: { message: parsed.error, type: 'invalid_request_error' },
468
74
  }));
469
75
  return;
470
76
  }
77
+ const request = parsed.request;
471
78
  const modelStr = request.model || OPENAI_COMPAT_DEFAULT_MODEL;
472
79
  const { engine, model: resolvedModel } = resolveEngineAndModel(modelStr);
473
80
  const sessionKey = resolveSessionKey(request, headers);
@@ -523,6 +130,19 @@ export async function handleChatCompletion(manager, body, headers, res) {
523
130
  // Note: noSessionPersistence (--no-session-persistence) is NOT set
524
131
  // because some CLI forks don't support this flag.
525
132
  skipPersistence: true,
133
+ // v0.7.4 EMERGENCY RESTORE: re-enable --include-partial-messages for
134
+ // openai-compat sessions. v0.6.0 made this opt-in (default OFF) for
135
+ // a 10-100× JSON overhead drop, but the engine never grew the
136
+ // corresponding case 'assistant' text-block handler to compensate.
137
+ // Result: when claude.exe doesn't emit incremental text_delta events
138
+ // (its non-partial mode), no onText fires, no SSE content chunks
139
+ // emit, and OpenClaw upstream rejects the turn as
140
+ // "incomplete terminal response (format)". Until the case 'assistant'
141
+ // text-block backstop in persistent-session.ts (v0.7.3) is verified
142
+ // firing, force partial-messages mode for the HTTP path so onText
143
+ // flows the way the rest of the pipeline expects. Cost: more NDJSON
144
+ // events per turn — fine compared to silent-broken-Savvy.
145
+ includePartialMessages: true,
526
146
  };
527
147
  // Phase 2.1 (CC_OPENCLAW_ALLOW_BUILTINS=1): when the env flag is set,
528
148
  // do NOT disable Claude CLI's built-in tools. Claude's WebFetch /
@@ -675,375 +295,10 @@ export async function handleChatCompletion(manager, body, headers, res) {
675
295
  manager.stopSession(sessionName).catch(() => { });
676
296
  }
677
297
  }
678
- // ─── Status Reporting ───────────────────────────────────────────────────────
679
- // Push tool/thinking status to an external webhook so a webchat status bar
680
- // can show what the CLI agent is doing. Best-effort fire-and-forget.
681
- /**
682
- * Optional status webhook — set `OPENAI_COMPAT_STATUS_URL` to an HTTP endpoint
683
- * that accepts `POST { state, activity, tool }`. The bridge will fire-and-forget
684
- * status updates when the CLI agent uses tools, so an external dashboard (e.g.
685
- * a webchat status bar) can show real-time progress.
686
- *
687
- * Example: `OPENAI_COMPAT_STATUS_URL=http://127.0.0.1:18795/my-app/agent-status`
688
- */
689
- function reportStatus(state, activity, tool) {
690
- const url = getOpenaiCompatStatusUrl();
691
- if (!url)
692
- return;
693
- const payload = JSON.stringify({ state, activity, tool: tool || null });
694
- const req = http.request(url, {
695
- method: 'POST',
696
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
697
- timeout: 2000,
698
- }, () => { });
699
- req.on('error', () => { });
700
- req.write(payload);
701
- req.end();
702
- }
703
- function getToolDescription(toolName, toolInput) {
704
- switch (toolName) {
705
- case 'Bash':
706
- case 'exec': {
707
- const cmd = String(toolInput?.command || '');
708
- return `Running: ${cmd.length > 50 ? cmd.slice(0, 50) + '...' : cmd}`;
709
- }
710
- case 'Read':
711
- case 'read':
712
- return `Reading: ${String(toolInput?.file_path || toolInput?.path || 'file')
713
- .split('/')
714
- .pop()}`;
715
- case 'Write':
716
- case 'write':
717
- return `Writing: ${String(toolInput?.file_path || toolInput?.path || 'file')
718
- .split('/')
719
- .pop()}`;
720
- case 'Edit':
721
- case 'edit':
722
- return `Editing: ${String(toolInput?.file_path || toolInput?.path || 'file')
723
- .split('/')
724
- .pop()}`;
725
- case 'Glob':
726
- case 'glob':
727
- return `Searching files: ${String(toolInput?.pattern || '')}`;
728
- case 'Grep':
729
- case 'grep':
730
- return `Searching content: ${String(toolInput?.pattern || '')}`;
731
- case 'WebSearch':
732
- return `Web search: ${String(toolInput?.query || '')}`;
733
- case 'Agent':
734
- return `Spawning sub-agent...`;
735
- default:
736
- return `Using tool: ${toolName}`;
737
- }
738
- }
739
- // ─── Non-Streaming ───────────────────────────────────────────────────────────
740
- async function handleNonStreaming(manager, sessionName, model,
741
- // Phase 2 R4 wire-up: accepts native content-block arrays in tool-stream mode.
742
- userMessage, completionId, res, hasTools) {
743
- try {
744
- reportStatus('thinking', 'Processing request...');
745
- // v0.7.1: accumulate thinking-block content when surfaceThinking is on.
746
- // Default OFF for privacy — empty string means no `reasoning` field
747
- // gets attached to the response.
748
- const surfaceThinking = getSurfaceThinkingEnabled();
749
- let thinkingBuffer = '';
750
- const result = await manager.sendMessage(sessionName, userMessage, {
751
- onEvent: (event) => {
752
- if (event.type === 'tool_use' && event.tool?.name) {
753
- const desc = getToolDescription(event.tool.name, event.tool.input);
754
- reportStatus('working', desc, event.tool.name);
755
- // Pillar B v0.4.3: trajectory tool_use event. Emit tool name and
756
- // input-arg keys (not values — keys leak no sensitive content
757
- // while still letting offline analysis cluster tool-call shapes).
758
- emitTrajectory('tool_use', {
759
- name: event.tool.name,
760
- inputKeys: event.tool.input ? Object.keys(event.tool.input) : [],
761
- }, sessionName);
762
- }
763
- else if (event.type === 'tool_result') {
764
- emitTrajectory('tool_result', {}, sessionName);
765
- }
766
- },
767
- // v0.7.1: when surfaceThinking is on, accumulate extended-thinking text
768
- // for the `reasoning` field on the OpenAI response. Subscribing to the
769
- // callback always (cheap closure cost ~ none); only buffering when
770
- // the env flag is set so the privacy-default-OFF promise holds.
771
- onThinking: surfaceThinking
772
- ? (text) => {
773
- thinkingBuffer += text;
774
- }
775
- : undefined,
776
- });
777
- reportStatus('idle', 'Ready');
778
- let tokensIn = 0;
779
- let tokensOut = 0;
780
- try {
781
- const status = manager.getStatus(sessionName);
782
- tokensIn = status.stats.tokensIn;
783
- tokensOut = status.stats.tokensOut;
784
- }
785
- catch {
786
- /* stats unavailable */
787
- }
788
- // v0.7.1: emit thinking_block trajectory event with token-count metadata
789
- // only (never raw text). Fires when buffer is non-empty regardless of
790
- // whether the response surfaces it — so observability is independent
791
- // of the user-visible flag.
792
- if (thinkingBuffer.length > 0) {
793
- emitTrajectory('thinking_block', {
794
- excerpt_chars: thinkingBuffer.length,
795
- tokens_approx: Math.ceil(thinkingBuffer.length / 4),
796
- }, sessionName);
797
- }
798
- // Parse tool_calls from response text when caller provided tools
799
- if (hasTools) {
800
- const parsed = parseToolCallsFromText(result.output);
801
- const response = formatCompletionResponse(completionId, model, parsed.textContent ?? '', tokensIn, tokensOut, parsed.toolCalls.length > 0 ? parsed.toolCalls : undefined, surfaceThinking ? thinkingBuffer : undefined);
802
- res.writeHead(200, { 'Content-Type': 'application/json' });
803
- res.end(JSON.stringify(response));
804
- }
805
- else {
806
- const response = formatCompletionResponse(completionId, model, result.output, tokensIn, tokensOut, undefined, surfaceThinking ? thinkingBuffer : undefined);
807
- res.writeHead(200, { 'Content-Type': 'application/json' });
808
- res.end(JSON.stringify(response));
809
- }
810
- }
811
- catch (err) {
812
- reportStatus('idle', 'Request failed');
813
- // v0.4.3: route through formatError for errors_total + trajectory error.
814
- formatError(err, { code: ERROR_CODES.SESSION_ERROR, sessionId: sessionName, details: { phase: 'handleNonStreaming' } });
815
- res.writeHead(500, { 'Content-Type': 'application/json' });
816
- res.end(JSON.stringify({ error: { message: err.message, type: 'server_error' } }));
817
- }
818
- }
819
- // ─── Streaming ───────────────────────────────────────────────────────────────
820
- async function handleStreaming(manager, sessionName, model,
821
- // Phase 2 R4 wire-up: accepts native content-block arrays in tool-stream mode.
822
- userMessage, completionId, res, hasTools) {
823
- res.writeHead(200, {
824
- 'Content-Type': 'text/event-stream',
825
- 'Cache-Control': 'no-cache',
826
- Connection: 'keep-alive',
827
- 'X-Accel-Buffering': 'no',
828
- });
829
- let clientDisconnected = false;
830
- res.on('close', () => {
831
- clientDisconnected = true;
832
- });
833
- const writeSSE = (data) => {
834
- if (!clientDisconnected) {
835
- try {
836
- res.write(`data: ${data}\n\n`);
837
- }
838
- catch {
839
- clientDisconnected = true;
840
- }
841
- }
842
- };
843
- // Initial chunk with role
844
- writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { role: 'assistant' }, null)));
845
- // SSE keepalive heartbeat
846
- const heartbeatTimer = setInterval(() => {
847
- if (!clientDisconnected) {
848
- try {
849
- res.write(': keepalive\n\n');
850
- }
851
- catch {
852
- clientDisconnected = true;
853
- }
854
- }
855
- }, 30_000);
856
- // Phase 2 R1+R2: in tool-stream mode, bridge session-manager's pre-parsed
857
- // tool_use events directly to OpenAI tool_calls SSE deltas. Skips the
858
- // legacy "buffer text + regex-parse <tool_calls> XML" path entirely.
859
- // Per memory project_cc_openclaw_session_manager_preparses.md:
860
- // session-manager has already stripped Claude CLI's NDJSON envelope, so
861
- // we don't need cli-stream-parser here — onEvent is the parser output.
862
- const useToolStream = isToolStreamMode() && hasTools;
863
- // When tools are present (legacy mode), buffer the full response to parse
864
- // for <tool_calls> XML. Without tools — or in tool-stream mode — stream
865
- // text chunks directly for low latency.
866
- let bufferedText = '';
867
- let toolCallsEmitted = 0;
868
- try {
869
- reportStatus('thinking', 'Processing request...');
870
- await manager.sendMessage(sessionName, userMessage, {
871
- onChunk: (chunk) => {
872
- if (useToolStream || !hasTools) {
873
- // Stream text deltas immediately. Tool-stream mode interleaves
874
- // text and tool_calls chunks naturally — Claude CLI emits text
875
- // between tool_use blocks, OpenClaw client handles that fine.
876
- writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { content: chunk }, null)));
877
- }
878
- else {
879
- // Legacy hasTools mode: buffer for XML <tool_calls> parsing post-stream.
880
- bufferedText += chunk;
881
- }
882
- },
883
- onEvent: (event) => {
884
- if (event.type === 'tool_result') {
885
- // Pillar B v0.4.3: streaming tool_result trajectory event.
886
- emitTrajectory('tool_result', {}, sessionName);
887
- return;
888
- }
889
- if (event.type === 'tool_use' && event.tool?.name) {
890
- reportStatus('working', getToolDescription(event.tool.name, event.tool.input), event.tool.name);
891
- // Pillar B v0.4.3: streaming tool_use trajectory event. Same
892
- // privacy-preserving inputKeys-only payload as handleNonStreaming.
893
- emitTrajectory('tool_use', {
894
- name: event.tool.name,
895
- inputKeys: event.tool.input ? Object.keys(event.tool.input) : [],
896
- }, sessionName);
897
- if (useToolStream) {
898
- // R1+R2 bridge: session-manager event → OpenAI tool_calls SSE.
899
- // Emit two chunks per tool_use (per OpenAI streaming spec):
900
- // 1. id + name + empty arguments
901
- // 2. arguments (JSON-stringified input)
902
- const toolUseId = event.tool.id ||
903
- `toolu_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
904
- const idx = toolCallsEmitted;
905
- const argsJson = event.tool.input != null ? JSON.stringify(event.tool.input) : '{}';
906
- const startChunk = {
907
- id: completionId,
908
- object: 'chat.completion.chunk',
909
- created: Math.floor(Date.now() / 1000),
910
- model,
911
- choices: [
912
- {
913
- index: 0,
914
- delta: {
915
- tool_calls: [
916
- {
917
- index: idx,
918
- id: toolUseId,
919
- type: 'function',
920
- function: { name: event.tool.name, arguments: '' },
921
- },
922
- ],
923
- },
924
- finish_reason: null,
925
- },
926
- ],
927
- };
928
- const argsChunk = {
929
- id: completionId,
930
- object: 'chat.completion.chunk',
931
- created: Math.floor(Date.now() / 1000),
932
- model,
933
- choices: [
934
- {
935
- index: 0,
936
- delta: {
937
- tool_calls: [
938
- {
939
- index: idx,
940
- function: { arguments: argsJson },
941
- },
942
- ],
943
- },
944
- finish_reason: null,
945
- },
946
- ],
947
- };
948
- writeSSE(JSON.stringify(startChunk));
949
- writeSSE(JSON.stringify(argsChunk));
950
- toolCallsEmitted += 1;
951
- }
952
- }
953
- },
954
- });
955
- reportStatus('idle', 'Ready');
956
- // Get token usage for final chunk
957
- let usage;
958
- try {
959
- const status = manager.getStatus(sessionName);
960
- usage = {
961
- prompt_tokens: status.stats.tokensIn,
962
- completion_tokens: status.stats.tokensOut,
963
- total_tokens: status.stats.tokensIn + status.stats.tokensOut,
964
- };
965
- }
966
- catch {
967
- /* best effort */
968
- }
969
- if (useToolStream) {
970
- // R1+R2: tool-stream mode — text + tool_calls already streamed inline.
971
- // Just emit the final chunk with the right finish_reason.
972
- const finishReason = toolCallsEmitted > 0 ? 'tool_calls' : 'stop';
973
- const finalChunk = formatCompletionChunk(completionId, model, {}, finishReason);
974
- if (usage)
975
- finalChunk.usage = usage;
976
- writeSSE(JSON.stringify(finalChunk));
977
- }
978
- else if (hasTools && bufferedText) {
979
- const parsed = parseToolCallsFromText(bufferedText);
980
- if (parsed.toolCalls.length > 0) {
981
- // Emit text content if any
982
- if (parsed.textContent) {
983
- writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { content: parsed.textContent }, null)));
984
- }
985
- // Emit tool_call chunks
986
- for (let i = 0; i < parsed.toolCalls.length; i++) {
987
- const tc = parsed.toolCalls[i];
988
- writeSSE(JSON.stringify({
989
- id: completionId,
990
- object: 'chat.completion.chunk',
991
- created: Math.floor(Date.now() / 1000),
992
- model,
993
- choices: [
994
- {
995
- index: 0,
996
- delta: {
997
- tool_calls: [
998
- {
999
- index: i,
1000
- id: tc.id,
1001
- type: 'function',
1002
- function: { name: tc.function.name, arguments: tc.function.arguments },
1003
- },
1004
- ],
1005
- },
1006
- finish_reason: null,
1007
- },
1008
- ],
1009
- }));
1010
- }
1011
- // Final chunk with tool_calls finish reason
1012
- const finalChunk = formatCompletionChunk(completionId, model, {}, 'tool_calls');
1013
- if (usage)
1014
- finalChunk.usage = usage;
1015
- writeSSE(JSON.stringify(finalChunk));
1016
- }
1017
- else {
1018
- // No tool calls — emit buffered text as content
1019
- writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { content: bufferedText }, null)));
1020
- const finalChunk = formatCompletionChunk(completionId, model, {}, 'stop');
1021
- if (usage)
1022
- finalChunk.usage = usage;
1023
- writeSSE(JSON.stringify(finalChunk));
1024
- }
1025
- }
1026
- else {
1027
- // No tools — standard finish
1028
- const finalChunk = formatCompletionChunk(completionId, model, {}, 'stop');
1029
- if (usage)
1030
- finalChunk.usage = usage;
1031
- writeSSE(JSON.stringify(finalChunk));
1032
- }
1033
- writeSSE('[DONE]');
1034
- }
1035
- catch (err) {
1036
- reportStatus('idle', 'Request failed');
1037
- // v0.4.3: route through formatError for errors_total + trajectory error.
1038
- formatError(err, { code: ERROR_CODES.SESSION_ERROR, sessionId: sessionName, details: { phase: 'handleStreaming' } });
1039
- writeSSE(JSON.stringify({ error: { message: err.message, type: 'server_error' } }));
1040
- writeSSE('[DONE]');
1041
- }
1042
- finally {
1043
- clearInterval(heartbeatTimer);
1044
- }
1045
- if (!clientDisconnected) {
1046
- res.end();
1047
- }
1048
- }
298
+ // reportStatus + getToolDescription moved to status-reporter.ts
299
+ // (Cluster B Phase 2 Module F). Re-exported above for backward compat.
300
+ // handleNonStreaming moved to non-streaming-handler.ts
301
+ // (Cluster B Phase 2 Module G). Re-exported above for backward compat.
302
+ // handleStreaming moved to streaming-handler.ts
303
+ // (Cluster B Phase 2 Module H). Re-exported above for backward compat.
1049
304
  //# sourceMappingURL=openai-compat.js.map