@hncode/hncode 0.4.1 → 0.5.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.
- package/README.md +3 -16
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +691 -43
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +317 -0
- package/src/remote.js +289 -0
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/tasks-browser.js +54 -11
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/bash.js +17 -3
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +134 -0
- package/src/tui.js +16682 -11665
- package/src/web-public/app.css +22 -9
- package/src/web-public/app.js +107 -54
- package/src/web-public/home.css +3 -3
- package/src/web-public/login.css +1 -1
package/src/llm.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { llmTools } from './tools/index.js';
|
|
11
11
|
import { effortWire } from './config.js';
|
|
12
12
|
import { applyPromptCache } from './cache.js';
|
|
13
|
+
import { runPatch, runPatchSync } from './plugin.js';
|
|
13
14
|
|
|
14
15
|
export function openAiToolDefs(tools) {
|
|
15
16
|
// Guard: a caller that passes null/undefined (e.g. setToolsList(null)) used to
|
|
@@ -147,6 +148,75 @@ export function toAnthropic(messages) {
|
|
|
147
148
|
return { system: sys.join('\n\n').trim(), messages: out };
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
/** Responses: content parts use `input_text` / `input_image`. */
|
|
152
|
+
function toResponsesContent(c) {
|
|
153
|
+
const parts = [];
|
|
154
|
+
const text = mediaText(c);
|
|
155
|
+
if (text) parts.push({ type: 'input_text', text });
|
|
156
|
+
for (const m of c.media) {
|
|
157
|
+
if (m && m.type === 'image' && m.data) {
|
|
158
|
+
parts.push({ type: 'input_image', image_url: `data:${m.mimeType || 'image/png'};base64,${m.data}` });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!parts.length) return text || '';
|
|
162
|
+
return parts;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Internal canonical messages -> OpenAI RESPONSES API request shape.
|
|
166
|
+
//
|
|
167
|
+
// The Responses API is NOT chat/completions with a different name: the message
|
|
168
|
+
// array becomes a flat `input` ITEM list, the system prompt moves to a top-level
|
|
169
|
+
// `instructions`, tools are FLAT (no nested `function` object), a tool call is its
|
|
170
|
+
// own item (`function_call`) and its result is another (`function_call_output`,
|
|
171
|
+
// keyed by `call_id` rather than `tool_call_id`). Returns { instructions, input }.
|
|
172
|
+
export function toResponsesInput(messages) {
|
|
173
|
+
const sys = [];
|
|
174
|
+
const input = [];
|
|
175
|
+
for (const m of messages) {
|
|
176
|
+
if (m.role === 'system') { sys.push(m.content); continue; }
|
|
177
|
+
if (m.role === 'user') {
|
|
178
|
+
input.push({ role: 'user', content: isMediaContent(m.content) ? toResponsesContent(m.content) : [{ type: 'input_text', text: String(m.content == null ? '' : m.content) }] });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (m.role === 'tool') {
|
|
182
|
+
// Tool output is a top-level item, not a role:'tool' message.
|
|
183
|
+
const out = isMediaContent(m.content)
|
|
184
|
+
? toResponsesContent(m.content)
|
|
185
|
+
: String(m.content == null ? '' : m.content);
|
|
186
|
+
input.push({ type: 'function_call_output', call_id: m.toolCallId, output: out });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (m.role === 'assistant') {
|
|
190
|
+
const hasText = typeof m.content === 'string' && m.content.trim();
|
|
191
|
+
const hasCalls = Array.isArray(m.toolCalls) && m.toolCalls.length;
|
|
192
|
+
if (!hasText && !hasCalls) continue;
|
|
193
|
+
if (hasText) {
|
|
194
|
+
input.push({ role: 'assistant', content: [{ type: 'output_text', text: m.content }] });
|
|
195
|
+
}
|
|
196
|
+
for (const tc of (m.toolCalls || [])) {
|
|
197
|
+
input.push({
|
|
198
|
+
type: 'function_call',
|
|
199
|
+
call_id: tc.id,
|
|
200
|
+
name: tc.name,
|
|
201
|
+
arguments: JSON.stringify(tc.args || {}),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { instructions: sys.join('\n\n').trim(), input };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Responses tools are FLAT: { type:'function', name, description, parameters }.
|
|
210
|
+
// (chat/completions nests all of that under a `function` key.)
|
|
211
|
+
export function responsesToolDefs(tools) {
|
|
212
|
+
return (Array.isArray(tools) ? tools : []).map((t) => ({
|
|
213
|
+
type: 'function',
|
|
214
|
+
name: t.name,
|
|
215
|
+
description: t.description,
|
|
216
|
+
parameters: t.parameters,
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
150
220
|
function authHeaders(cfg) {
|
|
151
221
|
const h = { 'content-type': 'application/json' };
|
|
152
222
|
if (cfg.apiKey) {
|
|
@@ -163,7 +233,12 @@ function authHeaders(cfg) {
|
|
|
163
233
|
function buildBody(cfg, messages, streaming = true, opts = {}) {
|
|
164
234
|
const common = { stream: streaming, max_tokens: cfg.maxOutputTokens };
|
|
165
235
|
if (cfg.temperature != null) common.temperature = cfg.temperature;
|
|
166
|
-
//
|
|
236
|
+
// Ask for the token accounting on the FINAL stream frame. Without this the
|
|
237
|
+
// OpenAI-compatible stream carries no `usage` at all, so the only token numbers
|
|
238
|
+
// available were our own estimates (see /usage, which divided the transcript's
|
|
239
|
+
// JSON length by 3.5). `includeUsage` is cleared after a 400 (see request):
|
|
240
|
+
// servers that predate the field reject the whole request.
|
|
241
|
+
if (streaming && opts.includeUsage !== false) common.stream_options = { include_usage: true };
|
|
167
242
|
// effort (see config.effortWire): OpenAI-compatible sends `reasoning_effort`,
|
|
168
243
|
// Anthropic sends a `thinking` budget. Models that think by default need no
|
|
169
244
|
// flag, so nothing is sent when the effort is off/unset.
|
|
@@ -193,6 +268,30 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
|
|
|
193
268
|
sessionKey: cfg.sessionId,
|
|
194
269
|
}));
|
|
195
270
|
}
|
|
271
|
+
if (cfg.protocol === 'responses') {
|
|
272
|
+
const { instructions, input } = toResponsesInput(messages);
|
|
273
|
+
const rbody = {
|
|
274
|
+
model: cfg.innerModel,
|
|
275
|
+
// `instructions` carries the system prompt; `input` is the flat item list.
|
|
276
|
+
input,
|
|
277
|
+
// NOTE: the cap is `max_output_tokens` here, NOT chat/completions'
|
|
278
|
+
// `max_tokens` — sending the wrong name is silently ignored by the API.
|
|
279
|
+
max_output_tokens: cfg.maxOutputTokens,
|
|
280
|
+
stream: streaming,
|
|
281
|
+
...wire,
|
|
282
|
+
};
|
|
283
|
+
if (instructions) rbody.instructions = instructions;
|
|
284
|
+
if (cfg.temperature != null) rbody.temperature = cfg.temperature;
|
|
285
|
+
if (!opts.noTools) {
|
|
286
|
+
const defsR = Object.prototype.hasOwnProperty.call(opts, 'tools')
|
|
287
|
+
? responsesToolDefs(opts.tools) : responsesToolDefs(llmToolsList);
|
|
288
|
+
if (defsR.length) rbody.tools = defsR;
|
|
289
|
+
}
|
|
290
|
+
return JSON.stringify(applyPromptCache('responses', rbody, {
|
|
291
|
+
enabled: cfg.promptCache !== false,
|
|
292
|
+
sessionKey: cfg.sessionId,
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
196
295
|
const body = {
|
|
197
296
|
model: cfg.innerModel, messages: toOpenAi(messages),
|
|
198
297
|
...common, ...wire,
|
|
@@ -247,8 +346,8 @@ export class LLM {
|
|
|
247
346
|
try {
|
|
248
347
|
const res = await fetch(cfg.endpoint, {
|
|
249
348
|
method: 'POST',
|
|
250
|
-
headers: authHeaders(cfg),
|
|
251
|
-
body: buildBody(cfg, messages, false, opts),
|
|
349
|
+
headers: (() => { const h = authHeaders(cfg); try { const out = runPatchSync('llmHeaders', { headers: h }, (c) => c); if (out && out.headers) return out.headers; } catch (e) {} return h; })(),
|
|
350
|
+
body: (() => { let b = buildBody(cfg, messages, false, opts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
|
|
252
351
|
signal: this.controller.signal,
|
|
253
352
|
});
|
|
254
353
|
if (!res || !res.ok) {
|
|
@@ -263,6 +362,17 @@ export class LLM {
|
|
|
263
362
|
for (const cb of (json.content || [])) {
|
|
264
363
|
if (cb.type === 'text' && cb.text) text += cb.text;
|
|
265
364
|
}
|
|
365
|
+
} else if (cfg.protocol === 'responses') {
|
|
366
|
+
// Responses: text sits in `message` items' `output_text` parts. Some
|
|
367
|
+
// gateways also expose a top-level `output_text` convenience field.
|
|
368
|
+
if (typeof json.output_text === 'string') text = json.output_text;
|
|
369
|
+
else {
|
|
370
|
+
for (const item of (json.output || [])) {
|
|
371
|
+
if (item && item.type === 'message' && Array.isArray(item.content)) {
|
|
372
|
+
for (const c of item.content) if (c && c.type === 'output_text' && c.text) text += c.text;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
266
376
|
} else {
|
|
267
377
|
const msg = json.choices && json.choices[0] && json.choices[0].message;
|
|
268
378
|
if (msg && msg.content) text = msg.content;
|
|
@@ -275,7 +385,9 @@ export class LLM {
|
|
|
275
385
|
if (!text) {
|
|
276
386
|
const truncated = cfg.protocol === 'anthropic'
|
|
277
387
|
? (json.stop_reason === 'max_tokens')
|
|
278
|
-
:
|
|
388
|
+
: cfg.protocol === 'responses'
|
|
389
|
+
? !!(json.incomplete_details && json.incomplete_details.reason === 'max_output_tokens')
|
|
390
|
+
: !!(json.choices && json.choices[0] && json.choices[0].finish_reason === 'length');
|
|
279
391
|
return truncated ? '' : null;
|
|
280
392
|
}
|
|
281
393
|
return text;
|
|
@@ -286,17 +398,26 @@ export class LLM {
|
|
|
286
398
|
}
|
|
287
399
|
|
|
288
400
|
async request(messages, onEvent) {
|
|
401
|
+
// Mixin seam: let plugins mutate the messages sent to the model.
|
|
402
|
+
try { const out = await runPatch('llmRequest', { messages, cfg: this.cfg }, (c) => c); if (out && out.messages) messages = out.messages; } catch (e) { /* patch failed; proceed with original */ }
|
|
289
403
|
const cfg = this.cfg;
|
|
290
404
|
let res = null;
|
|
291
405
|
let lastError = null;
|
|
292
406
|
let lastStatus = 0;
|
|
293
407
|
let attempts = 0;
|
|
294
408
|
|
|
295
|
-
// Retry the HTTP round-trip
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
409
|
+
// Retry the HTTP round-trip. Any non-200 response is treated as a problem and
|
|
410
|
+
// retried (5xx, 429, and unexpected 4xx alike), as is a network/connection
|
|
411
|
+
// error. The only exceptions are aborts (Esc/Ctrl-C) — those end the turn —
|
|
412
|
+
// and a response that is not ok on the final attempt. The count is
|
|
413
|
+
// `cfg.maxRetries` when set (the `--retries` flag), else 3.
|
|
414
|
+
const RETRIES = Number.isInteger(this.cfg && this.cfg.maxRetries) && this.cfg.maxRetries > 0
|
|
415
|
+
? this.cfg.maxRetries : 3;
|
|
416
|
+
// Whether this attempt asks for streaming usage. A server that does not know
|
|
417
|
+
// `stream_options` rejects the whole request with a 400, so the first 400
|
|
418
|
+
// turns it off and the retry goes out without it — degrading to an estimate
|
|
419
|
+
// rather than failing the request over an accounting nicety.
|
|
420
|
+
const reqOpts = {};
|
|
300
421
|
|
|
301
422
|
for (let attempt = 1; attempt <= RETRIES; attempt++) {
|
|
302
423
|
attempts = attempt;
|
|
@@ -307,8 +428,8 @@ export class LLM {
|
|
|
307
428
|
try {
|
|
308
429
|
res = await fetch(cfg.endpoint, {
|
|
309
430
|
method: 'POST',
|
|
310
|
-
headers: authHeaders(cfg),
|
|
311
|
-
body: buildBody(cfg, messages),
|
|
431
|
+
headers: (() => { const h = authHeaders(cfg); try { const out = runPatchSync('llmHeaders', { headers: h }, (c) => c); if (out && out.headers) return out.headers; } catch (e) {} return h; })(),
|
|
432
|
+
body: (() => { let b = buildBody(cfg, messages, true, reqOpts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
|
|
312
433
|
signal,
|
|
313
434
|
});
|
|
314
435
|
} catch (e) {
|
|
@@ -318,7 +439,12 @@ export class LLM {
|
|
|
318
439
|
lastStatus = 0; // network error
|
|
319
440
|
}
|
|
320
441
|
if (res && res.ok) break;
|
|
321
|
-
if (res)
|
|
442
|
+
if (res) {
|
|
443
|
+
lastStatus = res.status;
|
|
444
|
+
// Drop `stream_options` and try again: a 400 here is the one error this
|
|
445
|
+
// request can fix by itself, and the accounting is optional.
|
|
446
|
+
if (res.status === 400 && reqOpts.includeUsage !== false) reqOpts.includeUsage = false;
|
|
447
|
+
}
|
|
322
448
|
if (attempt < RETRIES) {
|
|
323
449
|
// Small exponential backoff (200ms, 400ms). If the user hit Esc during
|
|
324
450
|
// the wait, stop retrying.
|
|
@@ -392,30 +518,40 @@ export class LLM {
|
|
|
392
518
|
}
|
|
393
519
|
}
|
|
394
520
|
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
521
|
+
// A model that follows OUR prompt emits `<|thinking|>…<|/thinking|>` (agent.js and
|
|
522
|
+
// prompt-presets.js both instruct it). Three more spellings have to be accepted,
|
|
523
|
+
// because they are not ours to change:
|
|
524
|
+
// `<|think|>` / `<|/think|>` — the short form of the same convention, for models
|
|
525
|
+
// that abbreviate it, and for sessions recorded before
|
|
526
|
+
// the longer spelling became the prompt's
|
|
527
|
+
// `</|thinking|>` / `</|think|>`
|
|
528
|
+
// — the same two with the slash written before the bar.
|
|
529
|
+
// Accepted so the delimiter ORDER cannot be the thing
|
|
530
|
+
// that decides whether reasoning stays out of the answer
|
|
531
|
+
// `<|begin▁of▁thinking|>` / `<|end▁of▁thinking|>`
|
|
532
|
+
// — DeepSeek-R1's own token markers, as emitted by
|
|
533
|
+
// vLLM / llama.cpp builds. The bars are U+FF5C and the
|
|
534
|
+
// separators U+2581, so no prose can be mistaken for one.
|
|
535
|
+
// The OPENING marker was missing while the closing one
|
|
536
|
+
// was present, which left the format inside-out: the
|
|
537
|
+
// begin marker went out as the ANSWER, and the end marker
|
|
538
|
+
// then flipped everything after it into reasoning.
|
|
406
539
|
//
|
|
407
|
-
// The
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
540
|
+
// The BARE forms are deliberately GONE: `' thinking'`, `<thinking>`, `<think>`.
|
|
541
|
+
// feedContent locates a tag with `indexOf` over the whole buffer and then TOGGLES, so a
|
|
542
|
+
// tag that also occurs in ordinary writing reclassifies everything after it. On `'
|
|
543
|
+
// thinking'` that is a sentence like "I am thinking about this", "stop thinking and
|
|
544
|
+
// answer", "after thinking it over" — and because such a reply never reaches a closing
|
|
545
|
+
// tag, the remainder was emitted as REASONING and the answer disappeared into the
|
|
546
|
+
// thinking block. `<think>` / `<thinking>` are legal prose too (HTML, a tutorial, a
|
|
547
|
+
// quotation of this very file), which is what agent.js means when it warns that "a plain
|
|
548
|
+
// <think> is ordinary text that may appear in code or prose".
|
|
411
549
|
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
// tag is held back until the next chunk decides what it is.
|
|
416
|
-
const THINK_OPEN_TAGS = [' thinking', '<thinking>', '<think>'];
|
|
550
|
+
// Mixing is intended and already works: the parser toggles on ANY opening tag and ANY
|
|
551
|
+
// closing one, so `<|thinking|>…<|/think|>` closes correctly.
|
|
552
|
+
const THINK_OPEN_TAGS = ['<|thinking|>', '<|think|>', '<|begin▁of▁thinking|>'];
|
|
417
553
|
// Closing tags. If none of these appear, the block stays in think mode to EOI.
|
|
418
|
-
const THINK_CLOSE_TAGS = ['
|
|
554
|
+
const THINK_CLOSE_TAGS = ['<|/thinking|>', '</|thinking|>', '<|/think|>', '</|think|>', '<|end▁of▁thinking|>'];
|
|
419
555
|
|
|
420
556
|
// Longest suffix of `buf` that is a proper prefix of one of `tags`.
|
|
421
557
|
// A single trailing space is deliberately NOT a candidate: `' '` is a prefix of
|
|
@@ -489,10 +625,75 @@ function processEvent(protocol, raw, onEvent, state) {
|
|
|
489
625
|
let d;
|
|
490
626
|
try { d = JSON.parse(raw); } catch { return 'continue'; }
|
|
491
627
|
if (protocol === 'anthropic') return processAnthropic(d, onEvent, state);
|
|
628
|
+
if (protocol === 'responses') return processResponses(d, onEvent, state);
|
|
492
629
|
return processOpenAI(d, onEvent, state);
|
|
493
630
|
}
|
|
494
631
|
|
|
632
|
+
// ---- token accounting -------------------------------------------------------
|
|
633
|
+
// Every protocol reports the same facts under different names, and the only
|
|
634
|
+
// numbers that are not estimates come from here. Normalised shape:
|
|
635
|
+
// { input, output, cached, cacheWrite, reasoning }
|
|
636
|
+
// `cached` is the part of `input` that was a cache READ (billed far cheaper), and
|
|
637
|
+
// `cacheWrite` the part written to the cache (billed dearer on Anthropic). Keeping
|
|
638
|
+
// them separate is what makes the cost estimate match a real invoice.
|
|
639
|
+
//
|
|
640
|
+
// A field left undefined means "not reported", which is different from zero: the
|
|
641
|
+
// caller adds what it gets and does not invent the rest.
|
|
642
|
+
|
|
643
|
+
/** OpenAI chat/completions `usage`. */
|
|
644
|
+
function normalizeOpenAIUsage(u) {
|
|
645
|
+
if (!u || typeof u !== 'object') return null;
|
|
646
|
+
const cached = u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens;
|
|
647
|
+
const reasoning = u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens;
|
|
648
|
+
return {
|
|
649
|
+
input: numOrUndef(u.prompt_tokens ?? u.input_tokens),
|
|
650
|
+
output: numOrUndef(u.completion_tokens ?? u.output_tokens),
|
|
651
|
+
cached: numOrUndef(cached),
|
|
652
|
+
reasoning: numOrUndef(reasoning),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Anthropic `usage`, which splits cache reads and writes out. */
|
|
657
|
+
function normalizeAnthropicUsage(u) {
|
|
658
|
+
if (!u || typeof u !== 'object') return null;
|
|
659
|
+
return {
|
|
660
|
+
input: numOrUndef(u.input_tokens),
|
|
661
|
+
output: numOrUndef(u.output_tokens),
|
|
662
|
+
cached: numOrUndef(u.cache_read_input_tokens),
|
|
663
|
+
cacheWrite: numOrUndef(u.cache_creation_input_tokens),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** OpenAI Responses `usage`. */
|
|
668
|
+
function normalizeResponsesUsage(u) {
|
|
669
|
+
if (!u || typeof u !== 'object') return null;
|
|
670
|
+
const cached = u.input_tokens_details && u.input_tokens_details.cached_tokens;
|
|
671
|
+
const reasoning = u.output_tokens_details && u.output_tokens_details.reasoning_tokens;
|
|
672
|
+
return {
|
|
673
|
+
input: numOrUndef(u.input_tokens),
|
|
674
|
+
output: numOrUndef(u.output_tokens),
|
|
675
|
+
cached: numOrUndef(cached),
|
|
676
|
+
reasoning: numOrUndef(reasoning),
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function numOrUndef(v) {
|
|
681
|
+
const n = Number(v);
|
|
682
|
+
return Number.isFinite(n) ? n : undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Emit one `usage` event if the frame carried any numbers. */
|
|
686
|
+
function emitUsage(onEvent, u) {
|
|
687
|
+
if (!u) return;
|
|
688
|
+
if (u.input === undefined && u.output === undefined && u.cached === undefined && u.cacheWrite === undefined) return;
|
|
689
|
+
onEvent({ type: 'usage', usage: u });
|
|
690
|
+
}
|
|
691
|
+
|
|
495
692
|
function processOpenAI(d, onEvent, state) {
|
|
693
|
+
// The accounting arrives on its own frame, with an EMPTY `choices` array, right
|
|
694
|
+
// before `[DONE]` — so it has to be read before the delta checks below, which
|
|
695
|
+
// would otherwise return early on it.
|
|
696
|
+
emitUsage(onEvent, normalizeOpenAIUsage(d.usage));
|
|
496
697
|
const choice = d.choices && d.choices[0];
|
|
497
698
|
const delta = choice && choice.delta;
|
|
498
699
|
const finishReason = choice && choice.finish_reason;
|
|
@@ -561,6 +762,13 @@ function processAnthropic(d, onEvent, state) {
|
|
|
561
762
|
const t = d.type;
|
|
562
763
|
if (t === 'message_start' || t === 'message_delta' || t === 'message_stop') {
|
|
563
764
|
if (t === 'message_stop') { onEvent({ type: 'end' }); return 'end'; }
|
|
765
|
+
// Anthropic splits the accounting in two: `message_start` carries the INPUT
|
|
766
|
+
// (including cache reads and writes) and `message_delta` the OUTPUT. Both are
|
|
767
|
+
// reported, and the accumulator adds them.
|
|
768
|
+
if (t === 'message_start' && d.message && d.message.usage) {
|
|
769
|
+
emitUsage(onEvent, normalizeAnthropicUsage(d.message.usage));
|
|
770
|
+
}
|
|
771
|
+
if (t === 'message_delta' && d.usage) emitUsage(onEvent, normalizeAnthropicUsage(d.usage));
|
|
564
772
|
// `message_delta` carries the stop reason; `max_tokens` there means the reply
|
|
565
773
|
// was cut off, not finished (see processOpenAI for the OpenAI spelling).
|
|
566
774
|
if (t === 'message_delta' && d.delta && d.delta.stop_reason === 'max_tokens') {
|
|
@@ -579,8 +787,12 @@ function processAnthropic(d, onEvent, state) {
|
|
|
579
787
|
}
|
|
580
788
|
if (t === 'content_block_delta') {
|
|
581
789
|
const delta = d.delta || {};
|
|
790
|
+
// A thinking delta can arrive EMPTY (the protocol sends a delta frame with no
|
|
791
|
+
// text for a block that produced none). Emitting it created an empty reasoning
|
|
792
|
+
// block, which renders as a bare `●` row. The renderer drops those as well — this
|
|
793
|
+
// is the cheaper place to stop it, and it keeps `state.chat` free of them.
|
|
582
794
|
if (delta.type === 'text_delta') feedContent(delta.text || '', onEvent, state);
|
|
583
|
-
else if (delta.type === 'thinking_delta') onEvent({ type: 'think', text: delta.thinking
|
|
795
|
+
else if (delta.type === 'thinking_delta' && delta.thinking) onEvent({ type: 'think', text: delta.thinking });
|
|
584
796
|
else if (delta.type === 'input_json') {
|
|
585
797
|
const blk = state.tools.get(d.index);
|
|
586
798
|
if (blk) { blk.argsJson += delta.partial_json || ''; onEvent({ type: 'tool_args', id: blk.id, chunk: delta.partial_json || '' }); }
|
|
@@ -595,9 +807,91 @@ function processAnthropic(d, onEvent, state) {
|
|
|
595
807
|
return 'continue';
|
|
596
808
|
}
|
|
597
809
|
|
|
810
|
+
// OpenAI Responses API streaming. The wire is a TYPED event stream (much closer to
|
|
811
|
+
// Anthropic than to chat/completions' `choices[].delta`):
|
|
812
|
+
// response.output_text.delta -> answer text
|
|
813
|
+
// response.reasoning_summary_text.delta -> thinking summary
|
|
814
|
+
// response.output_item.added (function_call)-> tool_start
|
|
815
|
+
// response.function_call_arguments.delta -> tool_args (keyed by item_id)
|
|
816
|
+
// response.output_item.done (function_call) -> tool_end
|
|
817
|
+
// response.completed / response.incomplete -> end (incomplete = truncated)
|
|
818
|
+
// Tool calls are addressed by `item_id` (the item's own id), and the `call_id`
|
|
819
|
+
// that goes back on `function_call_output` is carried on the item — the agent keys
|
|
820
|
+
// its tools by the id we emit here, so we emit the call_id and remember it.
|
|
821
|
+
function processResponses(d, onEvent, state) {
|
|
822
|
+
const t = d.type;
|
|
823
|
+
if (t === 'response.output_text.delta') {
|
|
824
|
+
if (d.delta) feedContent(d.delta, onEvent, state);
|
|
825
|
+
return 'continue';
|
|
826
|
+
}
|
|
827
|
+
if (t === 'response.reasoning_summary_text.delta' || t === 'response.reasoning_text.delta') {
|
|
828
|
+
if (d.delta) onEvent({ type: 'think', text: d.delta });
|
|
829
|
+
return 'continue';
|
|
830
|
+
}
|
|
831
|
+
if (t === 'response.output_item.added' || t === 'response.output_item.done') {
|
|
832
|
+
const item = d.item || {};
|
|
833
|
+
if (item.type === 'function_call') {
|
|
834
|
+
const id = item.call_id || item.id;
|
|
835
|
+
if (t === 'response.output_item.added') {
|
|
836
|
+
if (id && !state.tools.has(id)) {
|
|
837
|
+
state.tools.set(id, { id, name: item.name, argsJson: '', done: false });
|
|
838
|
+
onEvent({ type: 'tool_start', id, name: item.name });
|
|
839
|
+
}
|
|
840
|
+
} else {
|
|
841
|
+
// done: the item carries the FULL arguments, so emit any that never
|
|
842
|
+
// streamed (some servers skip the argument deltas for short calls).
|
|
843
|
+
const entry = state.tools.get(id);
|
|
844
|
+
if (entry && !entry.done) {
|
|
845
|
+
if (typeof item.arguments === 'string' && item.arguments && !entry.argsJson) {
|
|
846
|
+
entry.argsJson = item.arguments;
|
|
847
|
+
onEvent({ type: 'tool_args', id, chunk: item.arguments });
|
|
848
|
+
}
|
|
849
|
+
entry.done = true;
|
|
850
|
+
onEvent({ type: 'tool_end', id });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return 'continue';
|
|
855
|
+
}
|
|
856
|
+
if (t === 'response.function_call_arguments.delta') {
|
|
857
|
+
const id = d.item_id || d.call_id;
|
|
858
|
+
const entry = id ? state.tools.get(id) : null;
|
|
859
|
+
if (entry) {
|
|
860
|
+
entry.argsJson += d.delta || '';
|
|
861
|
+
onEvent({ type: 'tool_args', id, chunk: d.delta || '' });
|
|
862
|
+
}
|
|
863
|
+
return 'continue';
|
|
864
|
+
}
|
|
865
|
+
if (t === 'response.completed') {
|
|
866
|
+
// The accounting rides on the terminal event's `response` object.
|
|
867
|
+
emitUsage(onEvent, normalizeResponsesUsage(d.response && d.response.usage));
|
|
868
|
+
onEvent({ type: 'end' });
|
|
869
|
+
return 'end';
|
|
870
|
+
}
|
|
871
|
+
if (t === 'response.incomplete') {
|
|
872
|
+
// `incomplete_details.reason === 'max_output_tokens'` means the same thing as
|
|
873
|
+
// chat/completions' finish_reason:'length' — the reply was cut, not finished.
|
|
874
|
+
emitUsage(onEvent, normalizeResponsesUsage(d.response && d.response.usage));
|
|
875
|
+
const reason = d.response && d.response.incomplete_details && d.response.incomplete_details.reason;
|
|
876
|
+
if (reason === 'max_output_tokens') onEvent({ type: 'truncated' });
|
|
877
|
+
onEvent({ type: 'end' });
|
|
878
|
+
return 'end';
|
|
879
|
+
}
|
|
880
|
+
if (t === 'response.failed' || t === 'error') {
|
|
881
|
+
const msg = (d.response && d.response.error && d.response.error.message) || d.message || 'response failed';
|
|
882
|
+
onEvent({ type: 'error', error: new Error(msg) });
|
|
883
|
+
return 'end';
|
|
884
|
+
}
|
|
885
|
+
return 'continue';
|
|
886
|
+
}
|
|
887
|
+
|
|
598
888
|
// --- Non-streaming path ---
|
|
599
889
|
export function consumeNonStreaming(protocol, json, onEvent) {
|
|
600
890
|
const state = { tagPending: '', inThink: false };
|
|
891
|
+
// Same accounting, one frame. Everything below this line is unchanged.
|
|
892
|
+
emitUsage(onEvent, protocol === 'anthropic' ? normalizeAnthropicUsage(json.usage)
|
|
893
|
+
: protocol === 'responses' ? normalizeResponsesUsage(json.usage)
|
|
894
|
+
: normalizeOpenAIUsage(json.usage));
|
|
601
895
|
if (protocol === 'anthropic') {
|
|
602
896
|
const content = json.content || [];
|
|
603
897
|
if (json.stop_reason === 'max_tokens') onEvent({ type: 'truncated' });
|
|
@@ -609,6 +903,33 @@ export function consumeNonStreaming(protocol, json, onEvent) {
|
|
|
609
903
|
onEvent({ type: 'tool_end', id: cb.id });
|
|
610
904
|
}
|
|
611
905
|
}
|
|
906
|
+
} else if (protocol === 'responses') {
|
|
907
|
+
// Responses non-streaming: one object with an `output` ITEM list. Text lives in
|
|
908
|
+
// a `message` item's `content[]` (`output_text`), reasoning in a `reasoning`
|
|
909
|
+
// item's summary, and tool calls are their own `function_call` items.
|
|
910
|
+
const output = json.output || [];
|
|
911
|
+
if (json.incomplete_details && json.incomplete_details.reason === 'max_output_tokens') {
|
|
912
|
+
onEvent({ type: 'truncated' });
|
|
913
|
+
}
|
|
914
|
+
if (json.status === 'failed' && json.error) {
|
|
915
|
+
onEvent({ type: 'error', error: new Error(json.error.message || 'response failed') });
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
for (const item of output) {
|
|
919
|
+
if (!item) continue;
|
|
920
|
+
if (item.type === 'reasoning' && Array.isArray(item.summary)) {
|
|
921
|
+
for (const s of item.summary) if (s && typeof s.text === 'string') onEvent({ type: 'think', text: s.text });
|
|
922
|
+
} else if (item.type === 'message' && Array.isArray(item.content)) {
|
|
923
|
+
for (const c of item.content) {
|
|
924
|
+
if (c && c.type === 'output_text' && c.text) feedContent(c.text, onEvent, state);
|
|
925
|
+
}
|
|
926
|
+
} else if (item.type === 'function_call') {
|
|
927
|
+
const id = item.call_id || item.id;
|
|
928
|
+
onEvent({ type: 'tool_start', id, name: item.name });
|
|
929
|
+
if (item.arguments) onEvent({ type: 'tool_args', id, chunk: item.arguments });
|
|
930
|
+
onEvent({ type: 'tool_end', id });
|
|
931
|
+
}
|
|
932
|
+
}
|
|
612
933
|
} else {
|
|
613
934
|
const choice = json.choices && json.choices[0];
|
|
614
935
|
const msg = choice && choice.message;
|