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.
- package/dist/acp/adapter.js +4 -72
- package/dist/agent/agent.js +61 -2247
- package/dist/agent/compact-rules.js +0 -53
- package/dist/agent/editorconfig.js +4 -56
- package/dist/agent/goal-audit.js +0 -34
- package/dist/agent/key-pool.js +0 -52
- package/dist/agent/kill.js +1 -13
- package/dist/agent/llm.js +9 -310
- package/dist/agent/mcp-oauth-manager.js +2 -255
- package/dist/agent/mcp-registry.js +1 -188
- package/dist/agent/mcp.js +1 -85
- package/dist/agent/meta-tools.js +0 -26
- package/dist/agent/plugins.js +1 -81
- package/dist/agent/provider-headers.js +1 -28
- package/dist/agent/skills.js +8 -94
- package/dist/agent/tools.js +16 -809
- package/dist/bus.js +1 -2
- package/dist/config-schema.js +0 -40
- package/dist/home.js +1 -19
- package/dist/index.js +0 -136
- package/dist/log/events.js +3 -195
- package/dist/master.js +31 -1180
- package/dist/migrations/index.js +0 -55
- package/dist/migrations/providers.js +0 -13
- package/dist/migrations/registry.js +0 -9
- package/dist/migrations/session-titles.js +3 -43
- package/dist/migrations/sessions.js +2 -20
- package/dist/model-meta.js +166 -142
- package/dist/scheduler/cron.js +2 -17
- package/dist/server/api.js +12 -778
- package/dist/server/image-resize.js +1 -64
- package/dist/server/term-size.js +1 -120
- package/dist/server/terminals.js +0 -159
- package/dist/session-title.js +2 -35
- package/package.json +1 -1
- package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
- package/public/assets/index-C1eqcSbW.js.map +1 -0
- package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
- package/public/assets/shiki-DaLn_bCe.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-KzHw4Sqo.js.map +0 -1
- 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;
|
|
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;
|
|
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;
|
|
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,
|
|
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;
|
|
158
|
+
continue;
|
|
351
159
|
if (!issuedBefore(messages, m))
|
|
352
|
-
continue;
|
|
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 [];
|
|
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;
|
|
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
|
}
|