teapot-coding-agent 0.37.59 → 0.37.61

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 (42) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +61 -2247
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +9 -310
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1180
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +166 -142
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
  37. package/public/assets/index-C1eqcSbW.js.map +1 -0
  38. package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
  39. package/public/assets/shiki-DaLn_bCe.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-KzHw4Sqo.js.map +0 -1
  42. package/public/assets/shiki-BwDjoZdu.js.map +0 -1
package/dist/agent/llm.js CHANGED
@@ -1,118 +1,40 @@
1
- /**
2
- * LLM access via the official `openai` npm client (OpenAI-compatible APIs:
3
- * OpenAI, OpenRouter, local vLLM/Ollama, ...).
4
- *
5
- * Retries, timeouts and response-shape handling are delegated to the SDK rather
6
- * than hand-rolled.
7
- */
8
1
  import OpenAI from "openai";
9
2
  import { providerHeaders } from "./provider-headers.js";
10
3
  export { providerHeaders };
11
4
  import { isRateLimitStatus, nextKey } from "./key-pool.js";
12
5
  const clients = new WeakMap();
13
- /**
14
- * Rotate to another key after a KEY-level failure (#170), returning a config that
15
- * uses it — or null when there is nothing to rotate to.
16
- *
17
- * Only the `apiKey` differs; the pool (and therefore the shared cooldown state) is
18
- * the same object, so the key we just failed stays parked for every other agent too.
19
- *
20
- * Returns null when the pool has one key, has no pool at all, or the only other
21
- * candidates are the failed one: retrying with the SAME credential cannot help, and
22
- * the caller's own staircase is the right response then.
23
- */
24
6
  export function rotateKey(cfg) {
25
7
  const pool = cfg.keyPool;
26
8
  if (!pool)
27
9
  return null;
28
10
  const next = nextKey(pool);
29
11
  if (!next)
30
- return null; // every key has had its turn
31
- // a new apiKey means a new OpenAI client below, which is correct: the credential is
32
- // baked into the client at construction
12
+ return null;
33
13
  return { ...cfg, apiKey: next };
34
14
  }
35
- /**
36
- * Reasoning effort for a request body (#47, #150).
37
- *
38
- * MEASURED against the live endpoints (one probe each, `max_tokens: 3` with
39
- * `reasoning_effort: "low"`):
40
- *
41
- * commandcode-goat 200 OK, reply carried `reasoning_content` -> accepts
42
- * google 400 "Thinking level is not supported for this model."
43
- * openrouter documented, catalogue advertises the field
44
- *
45
- * and — the fact that decides the design — what each endpoint PUBLISHES in
46
- * `/models`:
47
- *
48
- * openrouter `supported_parameters: [… "reasoning_effort" …]` per model
49
- * commandcode-goat only `id`, `name`, `context_length`, `supported_endpoints`
50
- * google no capability list either
51
- *
52
- * So for a gateway that publishes nothing there is NO per-model signal to gate on,
53
- * and the only honest way to find out whether it accepts the field is to TRY it.
54
- * A wrong guess is cheap to recover from: the endpoint answers 400 with a
55
- * recognisable message, and `isEffortUnsupported()` + the retry in `chat()` drop
56
- * the field for that request. That is better than a config flag the operator has to
57
- * know about, and better than hiding the control behind a capability list most
58
- * endpoints do not publish.
59
- *
60
- * Consequences, in order:
61
- * 1. a catalogue that explicitly EXCLUDES `reasoning_effort` is respected (no
62
- * point provoking a 400 for a model that tells us up front it will refuse);
63
- * 2. a catalogue that INCLUDES it, or publishes no capability list at all, gets
64
- * the field — and self-corrects on refusal;
65
- * 3. an invalid or absent level is never sent, whatever the endpoint says.
66
- */
67
15
  export function effortForRequest(cfg, supported = cfg.supportedParameters) {
68
16
  const e = cfg.reasoningEffort;
69
17
  if (!e || !REASONING_EFFORTS.includes(e))
70
18
  return {};
71
- // a catalogue that names the parameter it supports and does NOT list this one is
72
- // a definite "no" — only the ones that omit the question are worth a probe
73
19
  if (supported && !supported.includes("reasoning_effort"))
74
20
  return {};
75
- // Clamp into the model's own levels when it publishes them (#150). The USER's input
76
- // is validated against the four levels we offer, but the CLAMPED result may be a
77
- // provider-specific one (`xhigh`, `max`) — deepseek advertises `["max","high","low"]`,
78
- // so refusing to send `max` because it is not in our own four would throw away the
79
- // model's real top tier.
80
21
  const clamped = clampEffort(e, cfg.supportedEfforts);
81
22
  if (!EFFORT_LADDER.includes(clamped))
82
23
  return {};
83
24
  return { reasoning_effort: clamped };
84
25
  }
85
- /**
86
- * Did the endpoint refuse `reasoning_effort` as an unsupported PARAMETER? (#150)
87
- *
88
- * Google's wording is "Thinking level is not supported for this model."; other
89
- * gateways phrase it as an unknown/unsupported parameter or field. Matched
90
- * narrowly: a retry that drops the field is only correct when THIS is the
91
- * complaint, and misreading an unrelated 400 as one would silently discard the
92
- * operator's choice instead of surfacing the real error.
93
- */
94
26
  export function isEffortUnsupported(err) {
95
27
  const msg = String(err?.message ?? err ?? "");
96
28
  if (!/reasoning_effort|thinking level|effort/i.test(msg))
97
29
  return false;
98
30
  return /not supported|unsupported|unknown (parameter|field)|invalid/i.test(msg);
99
31
  }
100
- /**
101
- * Is this model OFFERED a reasoning-effort control? (#150)
102
- *
103
- * The right panel shows the row when it can do something. A model whose catalogue
104
- * says it does not take the parameter hides it; one whose catalogue is silent
105
- * still shows it, because the endpoints that publish nothing (measured: commandcode,
106
- * google) accept the field for some models and refuse it for others, and a silent
107
- * 400 with an automatic retry is a better experience than a control that is missing
108
- * where it would have worked.
109
- */
110
32
  export function effortChoosable(supported) {
111
33
  if (!supported)
112
- return true; // no capability list published: worth a try
34
+ return true;
113
35
  return supported.includes("reasoning_effort");
114
36
  }
115
- function isOpenRouterEndpoint(baseUrl) {
37
+ export function isOpenRouterEndpoint(baseUrl) {
116
38
  try {
117
39
  return /(^|\.)openrouter\.ai$/i.test(new URL(String(baseUrl ?? "")).host);
118
40
  }
@@ -120,20 +42,6 @@ function isOpenRouterEndpoint(baseUrl) {
120
42
  return false;
121
43
  }
122
44
  }
123
- /**
124
- * Ask the endpoint NOT to retain a one-off request (Pi does the same with
125
- * `cacheRetention: "none"`; its docs say "summarization requests disable
126
- * prompt-cache writes because these one-off prompts are unlikely to be reused").
127
- *
128
- * Sent ONLY to OpenRouter, which is the one endpoint in use here that both
129
- * accepts the field and documents it. Every other OpenAI-compatible endpoint gets
130
- * nothing: an unrecognised body field is a 400 on strict providers, and "send
131
- * nothing" is exactly today's behaviour, so this can only ever add a saving.
132
- *
133
- * `24h` is the value that means "do not retain beyond this request's lifetime" for
134
- * an organisation with zero-data-retention off; with ZDR on the default is already
135
- * `in_memory` and this is a no-op.
136
- */
137
45
  export function cacheControlForRequest(cfg) {
138
46
  if (!cfg.oneOff)
139
47
  return {};
@@ -142,36 +50,7 @@ export function cacheControlForRequest(cfg) {
142
50
  return { prompt_cache_retention: "24h" };
143
51
  }
144
52
  const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
145
- /**
146
- * Canonical low→high ordering used for clamping, including the levels this harness
147
- * does not itself offer (`none`, `xhigh`, `max`) because a provider's own list is
148
- * spelled in those terms and the comparison needs the full ladder.
149
- *
150
- * Same ladder and the same rules as Hermes' `EFFORT_LADDER` / `clamp_effort`, which
151
- * is the clearest statement of the problem: a provider that does not have the
152
- * requested level should not receive a request it will refuse, and should not be
153
- * silently escalated either.
154
- */
155
53
  const EFFORT_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
156
- /**
157
- * Map a requested level onto the levels a MODEL actually accepts (#150).
158
- *
159
- * Measured: OpenRouter publishes `reasoning.supported_efforts` per model (198 of 458
160
- * models), and the levels are not the four a naive UI would offer — deepseek-v4.1-flash
161
- * advertises `["max","high","low"]`, so `minimal` and `medium` do not exist for it and
162
- * `max` does. Sending an absent level is a refused turn; offering one is a control that
163
- * does nothing.
164
- *
165
- * Rules, matching Hermes:
166
- * - unknown or empty support → the request passes through (we cannot judge it)
167
- * - already supported → unchanged
168
- * - a level that is not on the ladder at all → unchanged (custom providers use
169
- * bespoke names, and rewriting one would be guesswork)
170
- * - otherwise the nearest WEAKER level, so a clamp never escalates cost; if nothing
171
- * weaker exists, the weakest supported level
172
- * - never to `none`, which would silently switch thinking OFF for a caller who asked
173
- * for it to be on
174
- */
175
54
  export function clampEffort(requested, supported) {
176
55
  if (!requested || !supported?.length)
177
56
  return requested;
@@ -180,7 +59,7 @@ export function clampEffort(requested, supported) {
180
59
  return requested;
181
60
  const reqIdx = EFFORT_LADDER.indexOf(requested);
182
61
  if (reqIdx < 0)
183
- return requested; // not a ladder level: not ours to rewrite
62
+ return requested;
184
63
  const candidates = norm.filter((l) => l !== "none" && EFFORT_LADDER.includes(l));
185
64
  if (!candidates.length)
186
65
  return requested;
@@ -193,40 +72,22 @@ export function clampEffort(requested, supported) {
193
72
  function client(cfg) {
194
73
  let c = clients.get(cfg);
195
74
  if (!c) {
196
- // #170: the SDK's retry ladder is deliberately KEPT for a pooled provider too.
197
- // The operator's rule is "規定のリトライ回数を超えたら次の鍵にローテ", i.e. each key gets
198
- // its own retries before the pool moves on — so `maxRetries` is the per-key budget
199
- // and must not be zeroed.
200
75
  c = new OpenAI({
201
76
  baseURL: cfg.baseUrl,
202
77
  apiKey: cfg.apiKey,
203
78
  timeout: cfg.timeoutMs ?? 120_000,
204
- maxRetries: cfg.maxRetries ?? 4, // per-key: rotation happens after these are spent
79
+ maxRetries: cfg.maxRetries ?? 4,
205
80
  defaultHeaders: providerHeaders(cfg.baseUrl, cfg.sessionId),
206
81
  });
207
82
  clients.set(cfg, c);
208
83
  }
209
84
  return c;
210
85
  }
211
- /**
212
- * Run a request, rotating to another key when the failure is KEY-level (#170).
213
- *
214
- * Bounded by the pool size: each rotation parks the key that just failed, so the walk
215
- * cannot revisit one and cannot loop forever. A non-key failure (a 500, a malformed
216
- * request, an abort) is rethrown untouched — rotating on those would burn every key
217
- * for a problem none of them caused.
218
- *
219
- * The LAST failure is the one propagated, so the operator sees the error from the
220
- * final key rather than whichever happened to fail first.
221
- */
222
86
  async function withKeyRotation(cfg, run) {
223
87
  let cur = cfg;
224
88
  let lastErr;
225
89
  for (let i = 0; i < Math.max(1, cfg.keyPool?.keys.length ?? 1); i++) {
226
90
  try {
227
- // The client's own retry ladder runs inside `run`, so by the time this catch
228
- // fires the FIRST key has already been retried its configured number of times.
229
- // Only then do we move on — that is the operator's "規定のリトライ回数を超えたら".
230
91
  return { value: await run(cur), cfg: cur };
231
92
  }
232
93
  catch (err) {
@@ -235,7 +96,6 @@ async function withKeyRotation(cfg, run) {
235
96
  if (typeof status !== "number" || !isRateLimitStatus(status))
236
97
  throw err;
237
98
  const rotated = rotateKey(cur);
238
- // no key left: "全キーを回ってもまだレートリミットだったらエラーで停止"
239
99
  if (!rotated)
240
100
  throw err;
241
101
  console.warn(`[teapot] rate limited (HTTP ${status}) on this key — moving to the next of ${cur.keyPool?.keys.length ?? 1} for this provider`);
@@ -244,17 +104,8 @@ async function withKeyRotation(cfg, run) {
244
104
  }
245
105
  throw lastErr;
246
106
  }
247
- /**
248
- * Providers occasionally glue MULTIPLE tool calls into one entry, or leak
249
- * their internal framing into the name field (seen live: a name of
250
- * `get_goal\uFFFD\uFFFDlist_dir` whose raw bytes were
251
- * `get_goal</tool_call><…><tool_call><…>`). Detect the tag boundaries and
252
- * split the blob back into individual calls so the round stays usable
253
- * instead of failing with "unknown tool".
254
- */
255
107
  function repairMangledToolName(raw) {
256
108
  const calls = [];
257
- // Form 1 — framing tags survived: <name></tool_call>[<id>…]<tool_call><name>
258
109
  const tagRe = /([a-zA-Z_][\w.]*)<\/tool_call>|<tool_call>\s*<?([a-zA-Z_][\w.]*)?/g;
259
110
  let m;
260
111
  while ((m = tagRe.exec(raw))) {
@@ -264,54 +115,20 @@ function repairMangledToolName(raw) {
264
115
  }
265
116
  if (calls.length)
266
117
  return calls;
267
- // Form 2 — tags were already destroyed into U+FFFD replacement chars:
268
- // "get_goal\uFFFD\uFFFDlist_dir" → split on the replacement runs
269
118
  const parts = raw.split(/[\ufffd]+/).filter((p) => /^[a-zA-Z_][\w.]*$/.test(p));
270
119
  for (const p of parts)
271
120
  calls.push({ name: p, arguments: "{}" });
272
121
  return calls;
273
122
  }
274
- /** true when a tool name can't possibly be one of ours (framing leaked in) */
275
123
  function looksMangled(name) {
276
124
  return !name || /[\ufffd<>]/.test(name) || /<tool_call>|<\/tool/.test(name);
277
125
  }
278
- /**
279
- * Repair tool-call pairing so a provider will accept the request (#152).
280
- *
281
- * MEASURED against commandcode/deepseek: a request is rejected with
282
- *
283
- * {"message":"invalid request error","type":"invalid_request_error"}
284
- *
285
- * — a message that names neither the call nor the message — in three cases, all the
286
- * same defect:
287
- *
288
- * - an assistant `tool_calls` entry with NO matching `tool` result
289
- * - a `tool` result whose `tool_calls` entry is absent (e.g. the prefix that issued
290
- * it was compacted away)
291
- * - a leading `tool` message, which cannot be answered by anything before it
292
- *
293
- * The consequences are worse than a bad status code: the failure is not recognisable
294
- * as a context overflow, so recovery never runs, and the generic message gives the
295
- * operator nothing to act on. It also costs the full retry staircase first (5s + 5s +
296
- * 30s) for a request that could never succeed.
297
- *
298
- * Dropping is the only safe repair for a result with no call: there is no id to
299
- * synthesise a call from, and keeping it re-creates the rejection. An unanswered CALL
300
- * gets a placeholder result instead, which preserves the exchange's meaning.
301
- *
302
- * Returns the input unchanged when it is already valid, so the common path allocates
303
- * nothing and the prompt stays byte-identical for prefix caching.
304
- */
305
126
  export function repairToolPairing(messages) {
306
- // which call ids exist, in the order they are issued
307
127
  const callOrder = new Map();
308
128
  for (const m of messages)
309
129
  for (const t of m.tool_calls ?? [])
310
130
  if (t.id && !callOrder.has(t.id))
311
131
  callOrder.set(t.id, callOrder.size);
312
- // nothing to repair when every result follows its own call exactly once and every
313
- // call is answered — checked first so the common path returns the SAME array and the
314
- // prompt stays byte-identical for prefix caching
315
132
  const seen = new Set();
316
133
  let ok = true;
317
134
  for (const m of messages) {
@@ -323,21 +140,12 @@ export function repairToolPairing(messages) {
323
140
  seen.add(id);
324
141
  continue;
325
142
  }
326
- // a call must not be answered before it is issued
327
143
  for (const t of m.tool_calls ?? [])
328
144
  if (t.id && seen.has(t.id))
329
145
  ok = false;
330
146
  }
331
147
  if (ok && seen.size === callOrder.size)
332
148
  return messages;
333
- // Rebuild. Rules, in the order they are applied:
334
- // - a result with no call, or for a call not yet issued, is DROPPED (it cannot be
335
- // attributed; a result placed BEFORE its call is the same defect and the
336
- // provider refuses both with the same "invalid request error")
337
- // - a second result for one call is DROPPED (a call gets exactly one answer)
338
- // - an unanswered call gets a placeholder, so the exchange survives
339
- // MEASURED: dropping only the unattributable ones left two shapes still failing, and
340
- // reporting success on a request the provider refuses is the worst outcome for a guard.
341
149
  const out = [];
342
150
  const answered = new Set();
343
151
  for (const m of messages) {
@@ -347,13 +155,12 @@ export function repairToolPairing(messages) {
347
155
  }
348
156
  const id = m.tool_call_id;
349
157
  if (!id || !callOrder.has(id) || answered.has(id))
350
- continue; // unattributable or duplicate
158
+ continue;
351
159
  if (!issuedBefore(messages, m))
352
- continue; // out of order
160
+ continue;
353
161
  answered.add(id);
354
162
  out.push(m);
355
163
  }
356
- // answer every call that no result covered, right after its call
357
164
  const filled = [];
358
165
  for (const m of out) {
359
166
  filled.push(m);
@@ -367,13 +174,11 @@ export function repairToolPairing(messages) {
367
174
  });
368
175
  }
369
176
  }
370
- // a prefix drop can leave the kept tail starting on a result, which nothing justifies
371
177
  let first = 0;
372
178
  while (first < filled.length && filled[first].role === "tool")
373
179
  first++;
374
180
  return first ? filled.slice(first) : filled;
375
181
  }
376
- /** was this result's call issued earlier in the array? */
377
182
  function issuedBefore(messages, result) {
378
183
  for (const m of messages) {
379
184
  if (m === result)
@@ -384,18 +189,13 @@ function issuedBefore(messages, result) {
384
189
  return false;
385
190
  }
386
191
  function sanitize(messages) {
387
- // #152: repair pairing BEFORE mapping — every request path goes through here, and a
388
- // violated pairing is refused with an unhelpful "invalid request error"
389
192
  return repairToolPairing(messages).map((m) => {
390
- // multimodal user message → OpenAI content-parts array (text + images);
391
- // providers without vision reject it, but those models were never a fit
392
193
  if (m.content_parts?.length) {
393
194
  const parts = m.content_parts.map((p) => p.type === "image_url"
394
195
  ? { type: "image_url", image_url: { url: p.image_url.url } }
395
196
  : { type: "text", text: p.text ?? "" });
396
197
  return { role: m.role, content: parts };
397
198
  }
398
- // empty text content makes many providers 400 — send a harmless placeholder
399
199
  if ((m.role === "user" || m.role === "assistant") && !m.content) {
400
200
  return { ...m, content: m.tool_calls?.length ? "(tool call)" : "(no content)" };
401
201
  }
@@ -405,26 +205,6 @@ function sanitize(messages) {
405
205
  return { ...m };
406
206
  });
407
207
  }
408
- /**
409
- * Why did the response carry no usable message? (#50)
410
- *
411
- * The leading text is kept stable at "LLM API returned no choices" so existing
412
- * matching (and the retry notes operators have learned to read) still works; the
413
- * diagnosis follows after a colon.
414
- */
415
- /**
416
- * The chain-of-thought text a provider attached, whichever field it used (#153).
417
- *
418
- * MEASURED against commandcode / deepseek-v4.1-flash: the reasoning arrives as
419
- * `reasoning_content` (115 chars non-streaming, 181 via the stream) and teapot read only
420
- * `reasoning`, so the thinking was received and DISCARDED — the reported "全然think
421
- * しない". DeepSeek Harness shows it because it reads the field this provider actually
422
- * sends.
423
- *
424
- * Both spellings are accepted because the endpoints in use disagree: OpenRouter uses
425
- * `reasoning`, DeepSeek/commandcode use `reasoning_content`. Neither is in the OpenAI SDK's
426
- * types, so both arrive as extra fields and must be read defensively.
427
- */
428
208
  function reasoningTextOf(m) {
429
209
  for (const key of ["reasoning", "reasoning_content"]) {
430
210
  const v = m?.[key];
@@ -435,17 +215,12 @@ function reasoningTextOf(m) {
435
215
  }
436
216
  function noChoicesDetail(res) {
437
217
  const base = "LLM API returned no choices";
438
- // a gateway that answers 200 with an error object instead of failing: the
439
- // actionable message is right there
440
218
  const err = res?.error;
441
219
  if (err && typeof err === "object") {
442
220
  const msg = typeof err.message === "string" ? err.message : JSON.stringify(err);
443
221
  const code = err.code ?? err.type;
444
222
  return `${base}: provider returned an error in a 200 response${code ? ` (${String(code)})` : ""} — ${String(msg).slice(0, 300)}`;
445
223
  }
446
- // the SDK returned raw text — e.g. an SSE body served to a non-streaming
447
- // request. JSON.parse succeeds for a real object, so a failure here means we
448
- // were handed something that is not a completion at all.
449
224
  if (typeof res === "string") {
450
225
  const head = res.slice(0, 160).replace(/\s+/g, " ").trim();
451
226
  return `${base}: response was not a completion object (${res.length} bytes of ${res.trimStart().startsWith("data:") || res.trimStart().startsWith("event:") ? "SSE" : "text"})${head ? ` — ${head}` : ""}`;
@@ -465,7 +240,6 @@ function noChoicesDetail(res) {
465
240
  }
466
241
  return `${base}: the response had no choices array (${safeShape(res)})`;
467
242
  }
468
- /** a short type summary of an unexpected response, for the log line */
469
243
  function safeShape(res) {
470
244
  try {
471
245
  const o = res;
@@ -477,23 +251,9 @@ function safeShape(res) {
477
251
  return "unreadable response";
478
252
  }
479
253
  }
480
- /**
481
- * HTTP statuses that mean "this request shape is not supported here", so
482
- * re-issuing it without `stream: true` is a genuinely different request.
483
- *
484
- * Deliberately an allow-list rather than the whole 4xx range. 408/425/429 are
485
- * TRANSIENT: replaying them immediately against an already-saturated provider
486
- * just doubles the request count without changing anything. 401/403 mean the
487
- * credentials are wrong, which is not fixed by dropping `stream` either — they
488
- * are excluded on purpose, since a fallback there would replace one honest
489
- * auth error with the same one and a wasted round trip.
490
- */
491
254
  const STREAM_REJECTED_STATUS = new Set([400, 404, 405, 406, 415, 422, 501]);
492
255
  export async function chat(cfg, messages, tools, signal, onDelta) {
493
256
  try {
494
- // #170: a KEY-level failure (429 especially) rotates to the provider's next key
495
- // and retries immediately, rather than handing a rate limit straight to the
496
- // caller's backoff ladder — with several keys this usually just works.
497
257
  const { value: res } = await withKeyRotation(cfg, (c) => client(c).chat.completions.create({
498
258
  model: c.model,
499
259
  messages: sanitize(messages),
@@ -501,16 +261,10 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
501
261
  ...effortForRequest(c),
502
262
  ...cacheControlForRequest(c),
503
263
  }, { signal }));
504
- // #50: a gateway may answer with `null` (or any non-object) on some proxy
505
- // error paths, so the guard is on `rm`, not on `res` being truthy —
506
- // reading `res.choices` unguarded threw a bare TypeError that named neither
507
- // the provider nor the shape.
508
264
  const raw = res?.choices?.[0];
509
265
  const rm = raw?.message;
510
266
  if (!rm)
511
267
  throw new Error(noChoicesDetail(res));
512
- // normalize: providers attach extra fields (reasoning, refusal, ...) and
513
- // nullable content — keep only what our protocol understands
514
268
  const message = {
515
269
  role: "assistant",
516
270
  content: typeof rm.content === "string" ? rm.content : "",
@@ -520,8 +274,6 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
520
274
  if (calls?.length) {
521
275
  message.tool_calls = calls.flatMap((c, i) => {
522
276
  const name = c.function?.name ?? "";
523
- // provider glued several calls together / leaked framing into the
524
- // name — split the blob back into real calls so the round survives
525
277
  if (looksMangled(name)) {
526
278
  const repaired = repairMangledToolName(name);
527
279
  if (repaired.length)
@@ -530,7 +282,7 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
530
282
  type: "function",
531
283
  function: { name: r.name, arguments: r.arguments },
532
284
  }));
533
- return []; // unsalvageable — drop rather than feed "unknown tool"
285
+ return [];
534
286
  }
535
287
  return [{
536
288
  id: c.id ?? `call_${i}`,
@@ -539,9 +291,6 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
539
291
  }];
540
292
  });
541
293
  }
542
- // some gateways answer 200 with finish_reason "error"; those tool calls /
543
- // text are often truncated garbage — discard the whole completion so the
544
- // caller retries cleanly instead of poisoning history
545
294
  const finishReason = raw?.finish_reason;
546
295
  if (finishReason === "error") {
547
296
  throw new Error("LLM API error: provider returned an errored completion");
@@ -549,7 +298,6 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
549
298
  if (!message.content && !message.tool_calls) {
550
299
  throw new Error("LLM API error: empty completion");
551
300
  }
552
- // non-streaming fallback still feeds the UI one final snapshot
553
301
  onDelta?.({ text: message.content ?? "", reasoning: reasoning ?? "" });
554
302
  return {
555
303
  message,
@@ -558,7 +306,6 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
558
306
  ? {
559
307
  inputTokens: res.usage.prompt_tokens,
560
308
  outputTokens: res.usage.completion_tokens,
561
- // OpenAI-compatible providers attach cache details here (OpenRouter included)
562
309
  cachedInputTokens: res.usage
563
310
  .prompt_tokens_details?.cached_tokens ?? undefined,
564
311
  }
@@ -566,40 +313,23 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
566
313
  };
567
314
  }
568
315
  catch (err) {
569
- // #150: retry ONCE without the effort field when the endpoint says it does not
570
- // take it, so a level that a particular model refuses never costs a turn
571
316
  if (!signal?.aborted && isEffortUnsupported(err) && effortForRequest(cfg).reasoning_effort) {
572
317
  return chat({ ...cfg, reasoningEffort: "" }, messages, tools, signal);
573
318
  }
574
319
  const e = err;
575
- // #50: our own shape-diagnosis reads as a full sentence but carries no HTTP
576
- // status (the request SUCCEEDED — that is the whole problem), so the generic
577
- // wrapper below would relabel it "LLM API error ?: …".
578
320
  if (String(err.message ?? "").startsWith("LLM API returned no choices"))
579
321
  throw err;
580
322
  if (e.status === undefined && !String(err.message).includes("provider returned"))
581
- throw err; // not an API error (abort, bug, ...)
323
+ throw err;
582
324
  const detail = e.error?.message ?? e.message ?? "unknown provider error";
583
325
  throw new Error(`LLM API error ${e.status ?? "?"}: ${String(detail).slice(0, 500)}`);
584
326
  }
585
327
  }
586
- /**
587
- * Streaming variant of chat(): same result shape, but calls onDelta with
588
- * cumulative {text, reasoning} snapshots as chunks arrive so the UI can show
589
- * the response live. Falls back to plain chat() once if the provider rejects
590
- * streaming before producing any chunk.
591
- */
592
328
  export async function chatStream(cfg, messages, tools, signal, onDelta) {
593
329
  let gotChunk = false;
594
- // hoisted so the abort handler below can attach whatever streamed so far
595
330
  let text = "";
596
331
  let reasoning = "";
597
332
  try {
598
- // #170: the KEY-level rotation covers this path the same way as chat(). It can
599
- // only start a NEW stream — if a key fails MID-stream the text already sent to
600
- // the UI stands, and `gotChunk` below stops us from silently restarting and
601
- // duplicating it. A 429 on a stream is a REJECTION (no chunks yet), which is the
602
- // case rotation is for.
603
333
  const { value: stream } = await withKeyRotation(cfg, (c) => client(c).chat.completions.create({
604
334
  model: c.model,
605
335
  messages: sanitize(messages),
@@ -607,8 +337,6 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
607
337
  ...effortForRequest(c),
608
338
  ...cacheControlForRequest(c),
609
339
  stream: true,
610
- // ask providers to include the final usage chunk in streaming mode
611
- // (OpenAI-compatible; harmless where unsupported)
612
340
  stream_options: { include_usage: true },
613
341
  }, { signal }));
614
342
  const calls = {};
@@ -621,9 +349,6 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
621
349
  const d = c?.delta;
622
350
  if (d?.content)
623
351
  text += d.content;
624
- // both spellings: OpenRouter streams `reasoning`, DeepSeek/commandcode stream
625
- // `reasoning_content` (#153 — reading only the first discarded the thinking).
626
- // The helper takes a loose record because neither field is in the SDK's types.
627
352
  reasoning += reasoningTextOf(d) ?? "";
628
353
  for (const tc of d?.tool_calls ?? []) {
629
354
  const i = tc.index ?? 0;
@@ -672,9 +397,6 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
672
397
  }];
673
398
  });
674
399
  if (!message.content && !message.tool_calls) {
675
- // #50: a stream that opened, produced NOTHING, and closed cleanly. The
676
- // marker lets the handler below tell this apart from a request the provider
677
- // actively rejected — re-issuing THIS one without `stream` usually succeeds.
678
400
  const e = new Error("LLM API error: empty completion");
679
401
  e.emptyStream = true;
680
402
  throw e;
@@ -682,28 +404,7 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
682
404
  return { message, reasoning: reasoning || undefined, usage };
683
405
  }
684
406
  catch (err) {
685
- // #50 — a provider that doesn't support streaming at all still gets ONE clean
686
- // fallback, but ONLY when the failure could plausibly be the stream itself.
687
- // The condition must NOT be `!gotChunk && !signal?.aborted`: that also matches
688
- // a request that FAILED (a 200 carrying an SSE `error` frame, a context-length
689
- // rejection), and the fallback then re-issues the same oversized request,
690
- // replacing the real diagnosis — a restored 1500-message session reports "no
691
- // choices" instead of "context length exceeded", so isContextOverflow() never
692
- // runs compact-and-retry.
693
- //
694
- // 1. A status in STREAM_REJECTED_STATUS: only a request rejected outright is a
695
- // genuinely DIFFERENT request when re-issued without `stream`.
696
- // 2. `emptyStream`: a 200 that streams zero chunks and closes cleanly is a
697
- // broken/flaky proxy — precisely the case the retry rescues. Requiring a
698
- // 4xx here would delete that safety net, a regression this file's own
699
- // tests caught.
700
- //
701
- // Everything else — 5xx, an error frame, a mid-stream failure — is the ANSWER,
702
- // rethrown untouched so overflow recovery can see it.
703
407
  const status = err?.status;
704
- // #150: the endpoint refused `reasoning_effort` itself. Retry ONCE without it,
705
- // so choosing a level never costs a turn — this is what makes the control safe
706
- // to offer on an endpoint whose capabilities cannot be known in advance.
707
408
  if (!signal?.aborted && isEffortUnsupported(err) && effortForRequest(cfg).reasoning_effort) {
708
409
  return chatStream({ ...cfg, reasoningEffort: "" }, messages, tools, signal, onDelta);
709
410
  }
@@ -712,8 +413,6 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
712
413
  if (!gotChunk && !signal?.aborted && (rejectedByProvider || emptyStream)) {
713
414
  return chat(cfg, messages, tools, signal, onDelta);
714
415
  }
715
- // user interrupt: hand back whatever streamed so far so the harness can
716
- // keep the partial output visible instead of losing it
717
416
  if (signal?.aborted && (text || reasoning)) {
718
417
  err.partial = { text, reasoning };
719
418
  }