@actor0/harness 0.0.0-stage → 0.5.2
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/answer-filter.d.ts +36 -0
- package/dist/answer-filter.d.ts.map +1 -0
- package/dist/answer-filter.js +308 -0
- package/dist/harness.d.ts +154 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +750 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/model-client.d.ts +165 -0
- package/dist/model-client.d.ts.map +1 -0
- package/dist/model-client.js +654 -0
- package/dist/plan-prefix.d.ts +8 -0
- package/dist/plan-prefix.d.ts.map +1 -0
- package/dist/plan-prefix.js +59 -0
- package/dist/protocol.d.ts +21 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +70 -0
- package/dist/reasoning-summary.d.ts +15 -0
- package/dist/reasoning-summary.d.ts.map +1 -0
- package/dist/reasoning-summary.js +48 -0
- package/dist/tool-call-block.d.ts +141 -0
- package/dist/tool-call-block.d.ts.map +1 -0
- package/dist/tool-call-block.js +377 -0
- package/dist/tool-host.d.ts +31 -0
- package/dist/tool-host.d.ts.map +1 -0
- package/dist/tool-host.js +57 -0
- package/dist/types.d.ts +241 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/package.json +30 -6
- package/README.md +0 -3
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
export class ModelTransportError extends Error {
|
|
2
|
+
retriable;
|
|
3
|
+
retryAfterMs;
|
|
4
|
+
/**
|
|
5
|
+
* @param retryAfterMs how long the server asked us to wait, when it said.
|
|
6
|
+
* A retriable error that carries one has a floor under its next attempt:
|
|
7
|
+
* see `retryDelay` in harness.ts.
|
|
8
|
+
*/
|
|
9
|
+
constructor(message, retriable = false, retryAfterMs) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.retriable = retriable;
|
|
12
|
+
this.retryAfterMs = retryAfterMs;
|
|
13
|
+
this.name = "ModelTransportError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The longest server-requested wait this harness will sit through.
|
|
18
|
+
*
|
|
19
|
+
* A windowed rate limit can name a reset twenty minutes out. Honouring that
|
|
20
|
+
* in a terminal means the turn disappears for twenty minutes with nothing on
|
|
21
|
+
* screen, which the user reads as a hang; ignoring it means the retries land
|
|
22
|
+
* inside the window and the turn fails having proved nothing. So the wait is
|
|
23
|
+
* honoured up to here, and past it the failure is surfaced instead, with the
|
|
24
|
+
* time the limit lifts in the message the user reads.
|
|
25
|
+
*/
|
|
26
|
+
export const MAX_HONOURED_RETRY_AFTER_MS = 120_000;
|
|
27
|
+
/**
|
|
28
|
+
* How long the server asked us to wait, in milliseconds.
|
|
29
|
+
*
|
|
30
|
+
* `Retry-After` is a count of seconds per RFC 9110, and an HTTP-date in the
|
|
31
|
+
* dialect several providers actually send. Both are honoured; a value that is
|
|
32
|
+
* neither is ignored rather than guessed at, because a misread directive is
|
|
33
|
+
* worse than no directive.
|
|
34
|
+
*/
|
|
35
|
+
export function retryAfterMs(headers, now = Date.now()) {
|
|
36
|
+
const value = headers.get("retry-after")?.trim();
|
|
37
|
+
if (!value)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (/^\d+$/.test(value))
|
|
40
|
+
return Number(value) * 1_000;
|
|
41
|
+
// `Date.parse` is a parser for humans and it is lenient: "-5" comes back as
|
|
42
|
+
// April 2001 and "12.5.6" as December 2006, both of which would be read as a
|
|
43
|
+
// reset that has already passed and turn a server that said nothing usable
|
|
44
|
+
// into a claim that it asked us to come back immediately. Every one of the
|
|
45
|
+
// three date formats HTTP allows begins with a weekday, so that is the gate.
|
|
46
|
+
if (!/^(?:mon|tue|wed|thu|fri|sat|sun)/i.test(value))
|
|
47
|
+
return undefined;
|
|
48
|
+
const at = Date.parse(value);
|
|
49
|
+
if (Number.isNaN(at))
|
|
50
|
+
return undefined;
|
|
51
|
+
return Math.max(0, at - now);
|
|
52
|
+
}
|
|
53
|
+
export class OpenAiCompatibleModel {
|
|
54
|
+
endpoint;
|
|
55
|
+
apiKey;
|
|
56
|
+
model;
|
|
57
|
+
fetchImpl;
|
|
58
|
+
headers;
|
|
59
|
+
reasoningEffort;
|
|
60
|
+
constructor(options) {
|
|
61
|
+
if (!options.model.trim())
|
|
62
|
+
throw new Error("model is required");
|
|
63
|
+
const url = new URL(options.baseUrl);
|
|
64
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
65
|
+
throw new Error("base URL must use http or https");
|
|
66
|
+
const explicitPath = options.path?.trim();
|
|
67
|
+
if (explicitPath) {
|
|
68
|
+
url.pathname = explicitPath.startsWith("/") ? explicitPath : `/${explicitPath}`;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
72
|
+
if (!url.pathname.endsWith("/chat/completions"))
|
|
73
|
+
url.pathname = `${url.pathname}/chat/completions`;
|
|
74
|
+
}
|
|
75
|
+
url.search = "";
|
|
76
|
+
url.hash = "";
|
|
77
|
+
this.endpoint = url.toString();
|
|
78
|
+
this.apiKey = options.apiKey?.trim() || undefined;
|
|
79
|
+
this.model = options.model.trim();
|
|
80
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
81
|
+
this.headers = options.headers ?? {};
|
|
82
|
+
this.reasoningEffort = options.reasoningEffort;
|
|
83
|
+
}
|
|
84
|
+
async *stream(messages, tools, signal) {
|
|
85
|
+
let response;
|
|
86
|
+
try {
|
|
87
|
+
response = await this.fetchImpl(this.endpoint, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
Accept: "text/event-stream",
|
|
92
|
+
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
|
93
|
+
...this.headers,
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify({ model: this.model, messages, stream: true, tools, ...(this.reasoningEffort ? { reasoning_effort: this.reasoningEffort } : {}) }),
|
|
96
|
+
signal,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (signal.aborted)
|
|
101
|
+
throw error;
|
|
102
|
+
throw new ModelTransportError(`network request failed: ${errorMessage(error)}`, true);
|
|
103
|
+
}
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
const text = await response.text().catch(() => "");
|
|
106
|
+
const wait = retryAfterMs(response.headers);
|
|
107
|
+
// A rate limit whose reset is beyond anything worth waiting out is not
|
|
108
|
+
// retriable: the retries would land inside the window and only prove
|
|
109
|
+
// the limit is still in force.
|
|
110
|
+
const transient = response.status === 429 || response.status >= 500;
|
|
111
|
+
const retriable = transient && (wait === undefined || wait <= MAX_HONOURED_RETRY_AFTER_MS);
|
|
112
|
+
throw new ModelTransportError(describeHttpFailure(this.endpoint, response, text, retriable, wait), retriable, wait);
|
|
113
|
+
}
|
|
114
|
+
// A 200 carrying an HTML page is the same failure as a 500 carrying one,
|
|
115
|
+
// and it is the worse of the two: read as a stream it has no `data:` frames,
|
|
116
|
+
// so the turn produced no answer, no tool call and no error — a silent empty
|
|
117
|
+
// reply. A response that *claims* to be an event stream is taken at its
|
|
118
|
+
// word; anything else has its head sniffed, because that is cheap and the
|
|
119
|
+
// cost of guessing wrong is an empty answer with no explanation.
|
|
120
|
+
const contentType = response.headers.get("content-type");
|
|
121
|
+
if (!isEventStream(contentType)) {
|
|
122
|
+
const head = await response.clone().text().catch(() => "");
|
|
123
|
+
if (looksLikeHtml(contentType, head)) {
|
|
124
|
+
throw new ModelTransportError(`${this.endpoint} answered 200 with an HTML page, not an event stream. ` +
|
|
125
|
+
`That is a platform-level error rather than a model failure. The harness will retry with backoff.`, true);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!response.body)
|
|
129
|
+
throw new ModelTransportError("response has no body", true);
|
|
130
|
+
const reader = response.body.getReader();
|
|
131
|
+
const decoder = new TextDecoder();
|
|
132
|
+
const calls = new ToolCallBuffer();
|
|
133
|
+
let buffer = "";
|
|
134
|
+
let sawDone = false;
|
|
135
|
+
// Frames are handed on one at a time, in order, rather than collected and
|
|
136
|
+
// yielded afterwards. A single network chunk routinely carries several
|
|
137
|
+
// frames, and one of them can be the last thing a stream ever sends: when
|
|
138
|
+
// the frames of a chunk were buffered and only yielded once the whole
|
|
139
|
+
// chunk had been handled, an error frame in that chunk threw first and
|
|
140
|
+
// took every token before it with it — the model answered, the tokens
|
|
141
|
+
// were already paid for, and the user was shown nothing.
|
|
142
|
+
const endpoint = this.endpoint;
|
|
143
|
+
let truncation;
|
|
144
|
+
const frames = async function* (parsed) {
|
|
145
|
+
for (const frame of parsed) {
|
|
146
|
+
if (frame.done) {
|
|
147
|
+
sawDone = true;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// First truncation wins. Servers repeat the reason on the final
|
|
151
|
+
// frame, and a later `stop` (or a second stream) must not overwrite
|
|
152
|
+
// the fact that this answer was cut short.
|
|
153
|
+
if (truncation === undefined && frame.finishReason !== undefined && TRUNCATION_REASONS.has(frame.finishReason)) {
|
|
154
|
+
truncation = frame.finishReason;
|
|
155
|
+
}
|
|
156
|
+
if (frame.hasError)
|
|
157
|
+
throw streamFrameError(endpoint, frame);
|
|
158
|
+
if (frame.toolCalls)
|
|
159
|
+
calls.push(frame.toolCalls);
|
|
160
|
+
if (frame.content)
|
|
161
|
+
yield { type: "token", delta: frame.content };
|
|
162
|
+
if (frame.reasoning)
|
|
163
|
+
yield { type: "reasoning", delta: frame.reasoning };
|
|
164
|
+
if (frame.completeToolCalls) {
|
|
165
|
+
for (const call of frame.completeToolCalls)
|
|
166
|
+
yield { type: "tool_call", tool_calls: [call] };
|
|
167
|
+
}
|
|
168
|
+
if (frame.usage)
|
|
169
|
+
yield { type: "usage", usage: frame.usage };
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
while (true) {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
if (done)
|
|
176
|
+
break;
|
|
177
|
+
buffer += decoder.decode(value, { stream: true });
|
|
178
|
+
const parsed = drainFrames(buffer);
|
|
179
|
+
buffer = parsed.rest;
|
|
180
|
+
yield* frames(parsed.frames);
|
|
181
|
+
}
|
|
182
|
+
buffer += decoder.decode();
|
|
183
|
+
const parsedTail = drainFrames(buffer);
|
|
184
|
+
yield* frames(parsedTail.frames);
|
|
185
|
+
const completeCalls = calls.finish();
|
|
186
|
+
if (completeCalls.length > 0)
|
|
187
|
+
yield { type: "tool_call", tool_calls: completeCalls };
|
|
188
|
+
// A cut-off answer still ends the stream, so it is still a `done` —
|
|
189
|
+
// one that says what it is. Emitted even without a `[DONE]` frame,
|
|
190
|
+
// because the reason arrived and is not going to arrive again.
|
|
191
|
+
if (sawDone || truncation !== undefined) {
|
|
192
|
+
yield truncation === undefined
|
|
193
|
+
? { type: "done" }
|
|
194
|
+
: { type: "done", truncated: true, reason: truncation };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await reader.cancel().catch(() => { });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
class ToolCallBuffer {
|
|
203
|
+
calls = new Map();
|
|
204
|
+
push(deltas) {
|
|
205
|
+
for (const [position, delta] of deltas.entries()) {
|
|
206
|
+
const index = Number.isInteger(delta.index) ? Number(delta.index) : this.calls.size || position;
|
|
207
|
+
const current = this.calls.get(index) ?? { id: "", type: "function", function: { name: "", arguments: "" } };
|
|
208
|
+
if (delta.id)
|
|
209
|
+
current.id = delta.id;
|
|
210
|
+
if (delta.type)
|
|
211
|
+
current.type = delta.type;
|
|
212
|
+
if (delta.function?.name)
|
|
213
|
+
current.function.name += delta.function.name;
|
|
214
|
+
if (delta.function?.arguments)
|
|
215
|
+
current.function.arguments += delta.function.arguments;
|
|
216
|
+
this.calls.set(index, current);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
finish() {
|
|
220
|
+
return [...this.calls.entries()].sort(([a], [b]) => a - b).map(([index, call], position) => ({ ...call, id: call.id || `call_${index}_${position}` }));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Split an SSE buffer into whole frames. Pure: nothing is thrown, nothing is
|
|
225
|
+
* emitted, and the caller decides what each frame means and in what order. A
|
|
226
|
+
* parser that also ran the frames had to buffer them, which is how a frame
|
|
227
|
+
* that failed to be yielded ended up discarded along with the ones before it.
|
|
228
|
+
*/
|
|
229
|
+
function drainFrames(buffer) {
|
|
230
|
+
const frames = [];
|
|
231
|
+
let rest = buffer;
|
|
232
|
+
const handleFrame = (frame) => {
|
|
233
|
+
frames.push(frame);
|
|
234
|
+
};
|
|
235
|
+
while (true) {
|
|
236
|
+
const boundary = findBoundary(rest);
|
|
237
|
+
if (!boundary)
|
|
238
|
+
break;
|
|
239
|
+
const block = rest.slice(0, boundary.index);
|
|
240
|
+
rest = rest.slice(boundary.index + boundary.length);
|
|
241
|
+
const data = block.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).filter(Boolean).join("\n");
|
|
242
|
+
if (!data)
|
|
243
|
+
continue;
|
|
244
|
+
if (data === "[DONE]") {
|
|
245
|
+
handleFrame({ done: true });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
let value;
|
|
249
|
+
try {
|
|
250
|
+
value = JSON.parse(data);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
throw new ModelTransportError("endpoint returned invalid SSE JSON", true);
|
|
254
|
+
}
|
|
255
|
+
const choices = value.choices;
|
|
256
|
+
const choice = Array.isArray(choices) && choices.length > 0 && typeof choices[0] === "object" && choices[0] !== null
|
|
257
|
+
? choices[0]
|
|
258
|
+
: undefined;
|
|
259
|
+
const delta = choice?.delta;
|
|
260
|
+
handleFrame({ finishReason: finishReasonOf(choice, value), content: typeof delta?.content === "string" ? delta.content : undefined, reasoning: typeof delta?.reasoning_content === "string" ? delta.reasoning_content : typeof delta?.reasoning === "string" ? delta.reasoning : undefined, toolCalls: Array.isArray(delta?.tool_calls) ? delta.tool_calls : undefined, completeToolCalls: Array.isArray(value.tool_calls) ? value.tool_calls : undefined, usage: value.usage, ...errorFields(value) });
|
|
261
|
+
}
|
|
262
|
+
return { frames, rest };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The endpoint's reason for stopping, in whichever of the spellings it uses.
|
|
266
|
+
*
|
|
267
|
+
* OpenAI-compatible servers put `finish_reason` on the choice; the
|
|
268
|
+
* Anthropic-shaped streams actor0 also reads put `stop_reason` on the
|
|
269
|
+
* message; relays copy either onto the top level. All three are read
|
|
270
|
+
* because the one that is missing reads as "the model finished", which is
|
|
271
|
+
* the exact belief this path exists to break — a stream cut off by the
|
|
272
|
+
* output cap is otherwise indistinguishable from a stream that ended.
|
|
273
|
+
*/
|
|
274
|
+
function finishReasonOf(choice, value) {
|
|
275
|
+
for (const candidate of [choice?.finish_reason, choice?.stop_reason, value.stop_reason, value.finish_reason]) {
|
|
276
|
+
if (typeof candidate === "string" && candidate)
|
|
277
|
+
return candidate.toLowerCase();
|
|
278
|
+
}
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Reasons that mean "this answer was cut off", not "this answer is done".
|
|
283
|
+
*
|
|
284
|
+
* `length` is OpenAI's spelling of the output-token cap, `max_tokens` is
|
|
285
|
+
* Anthropic's, and `model_context_window_exceeded` is what the same API
|
|
286
|
+
* emits when the model's own window ran out mid-generation. They differ in
|
|
287
|
+
* which limit was hit and not at all in what a caller must do about it:
|
|
288
|
+
* the text so far is the start of an answer, and asking again is how the
|
|
289
|
+
* rest of it arrives. `content_filter` is deliberately absent — a cut made
|
|
290
|
+
* by a filter is not resumed by being asked again.
|
|
291
|
+
*/
|
|
292
|
+
const TRUNCATION_REASONS = new Set([
|
|
293
|
+
"length",
|
|
294
|
+
"max_tokens",
|
|
295
|
+
"model_length",
|
|
296
|
+
"model_context_window_exceeded",
|
|
297
|
+
]);
|
|
298
|
+
function findBoundary(buffer) {
|
|
299
|
+
const lf = buffer.indexOf("\n\n");
|
|
300
|
+
const crlf = buffer.indexOf("\r\n\r\n");
|
|
301
|
+
if (lf === -1 && crlf === -1)
|
|
302
|
+
return null;
|
|
303
|
+
if (crlf !== -1 && (lf === -1 || crlf < lf))
|
|
304
|
+
return { index: crlf, length: 4 };
|
|
305
|
+
return { index: lf, length: 2 };
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* True when a response is an HTML document rather than a stream or a JSON
|
|
309
|
+
* error. Gateways, edge runtimes and bot checks all answer this way, and none
|
|
310
|
+
* of them say so in the body: the caller has to read the shape.
|
|
311
|
+
*/
|
|
312
|
+
export function looksLikeHtml(contentType, body) {
|
|
313
|
+
if (contentType && /\btext\/html\b|\bapplication\/xhtml/i.test(contentType))
|
|
314
|
+
return true;
|
|
315
|
+
// A declared type that is merely wrong must not veto the body. Gateways
|
|
316
|
+
// routinely answer an error page as `text/plain`, and trusting the header
|
|
317
|
+
// there is how a 200 carrying HTML turned into a silent empty answer.
|
|
318
|
+
return body !== null && /^\s*(?:<!doctype\s+html|<html[\s>])/i.test(body);
|
|
319
|
+
} /** True when the response claims to be the event stream we asked for. */
|
|
320
|
+
function isEventStream(contentType) {
|
|
321
|
+
return contentType !== null && /\btext\/event-stream\b/i.test(contentType);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The identifying fields of an in-stream error frame.
|
|
325
|
+
*
|
|
326
|
+
* Presence and text are separate. `{"error": "upstream reset"}` and
|
|
327
|
+
* `{"error": {}}` are both errors and only the first carries a message, so
|
|
328
|
+
* gating on the message is what let `{"error": {}}` through as silence: the
|
|
329
|
+
* stream ended, the turn finished with no answer, no tool call and no stated
|
|
330
|
+
* reason — the same silent empty reply a 200 carrying HTML produces.
|
|
331
|
+
*/
|
|
332
|
+
function errorFields(value) {
|
|
333
|
+
const field = value.error;
|
|
334
|
+
if (field === undefined || field === null)
|
|
335
|
+
return { hasError: false };
|
|
336
|
+
if (typeof field !== "object") {
|
|
337
|
+
return { hasError: true, error: typeof field === "string" && field ? field : undefined };
|
|
338
|
+
}
|
|
339
|
+
const record = field;
|
|
340
|
+
const text = (key) => typeof record[key] === "string" ? record[key] : undefined;
|
|
341
|
+
return { hasError: true, error: text("message"), errorCode: text("code"), errorType: text("type") };
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Whether an error delivered *inside* a 200 response can be fixed by asking
|
|
345
|
+
* again.
|
|
346
|
+
*
|
|
347
|
+
* A 200 means the request was accepted, so the status classification above
|
|
348
|
+
* never runs — there is no status left to read. Everything then arrives in
|
|
349
|
+
* one shape, `data: {"error": {...}}`: a dropped upstream connection, a rate
|
|
350
|
+
* limit, a rejected key, a request that does not fit the context window.
|
|
351
|
+
* Calling all of them retriable, which is what this did, replays a
|
|
352
|
+
* deterministic failure three times with 1s/2s/4s of backoff, at four times the
|
|
353
|
+
* token cost, and ends in exactly the same error.
|
|
354
|
+
*
|
|
355
|
+
* Code and type are read first because they are exact, and because they are
|
|
356
|
+
* the part OpenAI, Anthropic, Gemini and OpenRouter all agree on. The message
|
|
357
|
+
* is the fallback for the servers that send neither. An unrecognised error
|
|
358
|
+
* stays retriable: a stream that died mid-flight is far more often a dropped
|
|
359
|
+
* connection than a request the server will never accept, and guessing wrong
|
|
360
|
+
* in that direction costs one wasted attempt, while guessing wrong the other
|
|
361
|
+
* way refuses to start the turn at all.
|
|
362
|
+
*/
|
|
363
|
+
export function isRetriableStreamError(message, code, type) {
|
|
364
|
+
if (code && RETRIABLE_STREAM_CODES.test(code))
|
|
365
|
+
return true;
|
|
366
|
+
if (code && PERMANENT_STREAM_CODES.test(code))
|
|
367
|
+
return false;
|
|
368
|
+
if (type && RETRIABLE_STREAM_CODES.test(type))
|
|
369
|
+
return true;
|
|
370
|
+
if (type && PERMANENT_STREAM_TYPES.test(type))
|
|
371
|
+
return false;
|
|
372
|
+
if (isContextOverflow(message))
|
|
373
|
+
return false;
|
|
374
|
+
if (PERMANENT_STREAM_TEXT.test(message))
|
|
375
|
+
return false;
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Is this failure the conversation not fitting the model?
|
|
380
|
+
*
|
|
381
|
+
* Named because it is the one transport failure with a real recovery, and
|
|
382
|
+
* recovery is not the transport layer's job: the caller has to shorten the
|
|
383
|
+
* history and ask again, which is a decision about the conversation.
|
|
384
|
+
*/
|
|
385
|
+
export function isContextOverflow(message) {
|
|
386
|
+
return OVERFLOW_TEXT.test(message);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Is this thrown value the model saying the request is too big?
|
|
390
|
+
*
|
|
391
|
+
* Asked of the error rather than of the message so a host does not have to
|
|
392
|
+
* know which layer raised it, or re-derive that a retriable failure of the
|
|
393
|
+
* same shape must not be compacted around. Both spellings land here: a 400
|
|
394
|
+
* whose body is the diagnosis, and an error frame inside a 200.
|
|
395
|
+
*/
|
|
396
|
+
/**
|
|
397
|
+
* A request the endpoint will never accept, however often it is
|
|
398
|
+
* sent — the one transport failure with a host-level recovery
|
|
399
|
+
* (compact the history and ask again), which is why this is a
|
|
400
|
+
* classifier and not just a message match.
|
|
401
|
+
*
|
|
402
|
+
* It also looks through a `FailedTurnError`: a turn that failed
|
|
403
|
+
* mid-flight wraps the transport error that ended it (see
|
|
404
|
+
* `failedTurn`), and without the unwrap every such failure reads
|
|
405
|
+
* as an unknown error, so the recovery never fires and a turn the
|
|
406
|
+
* endpoint merely refused rewinds the session instead of being
|
|
407
|
+
* asked again. That makes the return a plain boolean rather than
|
|
408
|
+
* a type predicate — in the wrapped case the error is a
|
|
409
|
+
* `FailedTurnError`, not the `ModelTransportError` the old
|
|
410
|
+
* signature claimed — and its one caller reads it as a gate.
|
|
411
|
+
*/
|
|
412
|
+
export function isContextOverflowError(error) {
|
|
413
|
+
const transport = transportError(error);
|
|
414
|
+
return transport !== undefined && !transport.retriable && isContextOverflow(transport.message);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* The context window the endpoint said it has, in tokens, or undefined.
|
|
418
|
+
*
|
|
419
|
+
* The overflow message is not only a diagnosis, it is the one measurement of
|
|
420
|
+
* the limit the provider will ever volunteer, and reading it as a boolean
|
|
421
|
+
* throws the number away. A host recovering from an overflow has to decide
|
|
422
|
+
* how much history to keep, and a guess cannot be smaller than the truth: on
|
|
423
|
+
* a 4k-window model a recovery that targets 8,000 tokens does not fit by
|
|
424
|
+
* construction, and the one replay it is allowed fails exactly like the
|
|
425
|
+
* request it was meant to fix.
|
|
426
|
+
*
|
|
427
|
+
* Anchored on the number *and* the word "token" on purpose. The messages that
|
|
428
|
+
* carry a limit also carry the count that broke it — "your messages resulted
|
|
429
|
+
* in 91234 tokens" — and a looser pattern reads the wrong number, which is
|
|
430
|
+
* worse than reading none. A per-message char limit is not a window and is
|
|
431
|
+
* not returned for one; `OVERFLOW_TEXT` matches that shape too, deliberately,
|
|
432
|
+
* because it is a different failure with its own advice.
|
|
433
|
+
*
|
|
434
|
+
* Returned only for a failure that is a real refusal, so a retriable error
|
|
435
|
+
* carrying a similar string cannot talk a host into compacting a turn that
|
|
436
|
+
* was never the problem.
|
|
437
|
+
*/
|
|
438
|
+
export function contextWindowTokens(error) {
|
|
439
|
+
const transport = transportError(error);
|
|
440
|
+
if (!transport || transport.retriable)
|
|
441
|
+
return undefined;
|
|
442
|
+
const found = CONTEXT_WINDOW_TEXT.exec(transport.message);
|
|
443
|
+
// Two shapes, so two groups; whichever matched is the window.
|
|
444
|
+
const digits = found?.[1] ?? found?.[2];
|
|
445
|
+
if (!digits)
|
|
446
|
+
return undefined;
|
|
447
|
+
const tokens = Number.parseInt(digits.replace(/[,_]/g, ""), 10);
|
|
448
|
+
return Number.isFinite(tokens) && tokens > 0 ? tokens : undefined;
|
|
449
|
+
}
|
|
450
|
+
/** The transport error a thrown value carries, however deeply it was wrapped. */
|
|
451
|
+
function transportError(error) {
|
|
452
|
+
if (error instanceof ModelTransportError)
|
|
453
|
+
return error;
|
|
454
|
+
const wrapped = error?.cause;
|
|
455
|
+
return wrapped instanceof ModelTransportError ? wrapped : undefined;
|
|
456
|
+
}
|
|
457
|
+
/** The in-stream error as a failure a person can act on. */
|
|
458
|
+
function streamFrameError(endpoint, frame) {
|
|
459
|
+
const message = frame.error ?? "";
|
|
460
|
+
const retriable = isRetriableStreamError(message, frame.errorCode, frame.errorType);
|
|
461
|
+
const advice = !retriable && isContextOverflow(message) ? OVERFLOW_ADVICE : undefined;
|
|
462
|
+
const detail = frame.error ? `: ${frame.error}` : " and gave no message";
|
|
463
|
+
return new ModelTransportError(`${endpoint} ended the stream with an error${detail}. ${retryAdvice(retriable, advice)}`, retriable);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Codes and types that name a transient fault. Matched loosely because
|
|
467
|
+
* providers disagree on separator and wording: `rate_limit_exceeded`,
|
|
468
|
+
* `rate limit`, `too_many_requests`, `overloaded_error`, `server_error`.
|
|
469
|
+
*/
|
|
470
|
+
const RETRIABLE_STREAM_CODES = /\b(?:rate[_\s-]?limit(?:ed)?|too[_\s-]?many[_\s-]?requests|429|server[_\s-]?error|internal[_\s-]?error|overloaded(?:[_\s-]?error)?|api[_\s-]?error|service[_\s-]?unavailable|temporarily[_\s-]?unavailable|bad[_\s-]?gateway|upstream[_\s-]?error|capacity[_\s-]?exceeded|timeout|timed[_\s-]?out|transient)\b/i;
|
|
471
|
+
/** Codes that name a request the server will never accept, however often it is sent. */
|
|
472
|
+
const PERMANENT_STREAM_CODES = /\b(?:context[_\s-]?length[_\s-]?exceeded|context[_\s-]?window[_\s-]?exceeded|prompt[_\s-]?too[_\s-]?long|request[_\s-]?too[_\s-]?large|payload[_\s-]?too[_\s-]?large|invalid[_\s-]?api[_\s-]?key|invalid[_\s-]?authentication|permission[_\s-]?denied|insufficient[_\s-]?quota|quota[_\s-]?exceeded|billing[_\s-]?hard[_\s-]?limit[_\s-]?reached|account[_\s-]?deactivated|model[_\s-]?not[_\s-]?found|model[_\s-]?not[_\s-]?available|invalid[_\s-]?model|content[_\s-]?policy[_\s-]?violation)\b/i;
|
|
473
|
+
/**
|
|
474
|
+
* Types, which are coarser than codes. `invalid_request_error` is OpenAI's
|
|
475
|
+
* 400, and `insufficient_quota` is a rate-limit-shaped failure that is
|
|
476
|
+
* actually about the account rather than the request; neither improves on a
|
|
477
|
+
* third attempt.
|
|
478
|
+
*/
|
|
479
|
+
const PERMANENT_STREAM_TYPES = /\b(?:invalid[_\s-]?request(?:[_\s-]?error)?|authentication[_\s-]?error|permission[_\s-]?error|not[_\s-]?found[_\s-]?error|billing[_\s-]?error|account[_\s-]?error|insufficient[_\s-]?quota|request[_\s-]?too[_\s-]?large)\b/i;
|
|
480
|
+
/**
|
|
481
|
+
* Text that means "this conversation does not fit", across the providers that
|
|
482
|
+
* say it.
|
|
483
|
+
*
|
|
484
|
+
* Every provider words this differently and no two agree: Anthropic says
|
|
485
|
+
* `prompt is too long: 137500 tokens > 135000 maximum`, vLLM and DeepSeek say
|
|
486
|
+
* `This model's maximum context length is 32768 tokens. However, your
|
|
487
|
+
* messages resulted in 40000 tokens`, Groq says `Requested token count
|
|
488
|
+
* exceeds the model's maximum context length`, and OpenAI puts the diagnosis
|
|
489
|
+
* in the code while the message says only `Please reduce the length of the
|
|
490
|
+
* messages`.
|
|
491
|
+
*
|
|
492
|
+
* The first four clauses of this pattern were written against a provider that
|
|
493
|
+
* does not serve this CLI. They match, and the four tests that cover them
|
|
494
|
+
* pass, and none of them is a string the endpoint has ever sent — so the
|
|
495
|
+
* whole overflow recovery path was unreachable in production: a real request
|
|
496
|
+
* that did not fit came back as a bare failure, with no compaction, no retry,
|
|
497
|
+
* and the raw body as the only clue.
|
|
498
|
+
*
|
|
499
|
+
* The last two clauses are what it actually answers, measured against
|
|
500
|
+
* `https://aestral-chat.vercel.app/api/chat`: a 400 carrying exactly
|
|
501
|
+
* `{"error":"Message too large (50k char limit)"}`. The limit is 50,000
|
|
502
|
+
* characters per message, not per request and not in tokens, which is a
|
|
503
|
+
* different failure from the one the rest of this pattern describes and needs
|
|
504
|
+
* its own words rather than a shared one.
|
|
505
|
+
*
|
|
506
|
+
* `message too large` and not a bare `too large`, on purpose: "output too
|
|
507
|
+
* large" is a truncated answer, not a conversation that does not fit, and
|
|
508
|
+
* reading it as an overflow would compact the history and ask again for a
|
|
509
|
+
* request that was never the problem.
|
|
510
|
+
*/
|
|
511
|
+
const OVERFLOW_TEXT = /\b(?:maximum context length|context length|context[_\s-]?length|context window|context size|prompt is too long|prompt too long|input is too long|input length|exceeds the (?:model'?s? )?maximum|exceeds the available|requested token count exceeds|messages resulted in|reduce the length of the messages|request entity too large|too many tokens|token limit|char(?:acter)?s? limit|message too large)\b/i;
|
|
512
|
+
/** Message text for the permanent failures that arrive without a code. */
|
|
513
|
+
const PERMANENT_STREAM_TEXT = /\b(?:invalid api key|incorrect api key|api key (?:is )?(?:invalid|missing|expired)|no api key|unauthori[sz]ed|invalid authentication|permission denied|insufficient (?:quota|credit|balance)|quota exceeded|out of credits|payment required|model (?:not found|does not exist|is not available)|account (?:is )?(?:deactivated|suspended)|content policy)\b/i;
|
|
514
|
+
/**
|
|
515
|
+
* The two shapes an endpoint states its window in, both naming tokens.
|
|
516
|
+
*
|
|
517
|
+
* "maximum context length is 8192 tokens" is OpenAI's and the most common
|
|
518
|
+
* form; the reversed "exceeds the 8192-token context window" is vLLM and
|
|
519
|
+
* friends. Neither clause reaches across a sentence boundary, so the count
|
|
520
|
+
* that broke the request — which lives in the next sentence, and is larger —
|
|
521
|
+
* cannot be mistaken for the window.
|
|
522
|
+
*/
|
|
523
|
+
const CONTEXT_WINDOW_TEXT = /\b(?:maximum context (?:length|window)|context (?:length|window|size))\b[^.\d]{0,32}?([\d][\d,_]*)\s*tokens?\b|\b([\d][\d,_]*)\s*tokens?\b[^.\d]{0,24}?context window\b/i;
|
|
524
|
+
const OVERFLOW_ADVICE = "Retrying will not help — this conversation does not fit the model's context window. Start a new one with /clear, or switch to a model with a larger window.";
|
|
525
|
+
/** The closing line of a failure, derived from what actually happens next. */
|
|
526
|
+
function retryAdvice(retriable, permanentAdvice) {
|
|
527
|
+
if (retriable)
|
|
528
|
+
return "The harness will retry with backoff.";
|
|
529
|
+
return permanentAdvice ?? "Retrying will not help — check the URL, the key, and the model name.";
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* A failure message a person can act on.
|
|
533
|
+
*
|
|
534
|
+
* The endpoint is included because "request failed" tells the reader nothing
|
|
535
|
+
* when three of them are configured, and the status because a body alone
|
|
536
|
+
* cannot distinguish a rate limit from a crash. An HTML body is summarised
|
|
537
|
+
* rather than quoted: the previous behaviour pasted its first 600 characters
|
|
538
|
+
* into the transcript, which buried a one-line diagnosis under a wall of markup
|
|
539
|
+
* and still omitted the two facts that would have identified the fault.
|
|
540
|
+
*
|
|
541
|
+
* The closing line is derived from `retriable`, never from a guess about what
|
|
542
|
+
* "usually" happens. Telling someone a 403 will be retried when it will not is
|
|
543
|
+
* worse than saying nothing, because they wait for a retry that never comes.
|
|
544
|
+
*
|
|
545
|
+
* The URL is safe to print — the constructor clears any query string, so a key
|
|
546
|
+
* passed as a query parameter is already gone by this point.
|
|
547
|
+
*/
|
|
548
|
+
export function describeHttpFailure(endpoint, response, body, retriable, retryAfterMs) {
|
|
549
|
+
const status = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
|
|
550
|
+
const contentType = response.headers.get("content-type") ?? "no content-type";
|
|
551
|
+
const head = `${status} from ${endpoint} (${contentType})`;
|
|
552
|
+
// The closing line goes on this path too. It used to be attached only to
|
|
553
|
+
// the shapeless-body case below, which meant that a provider that sends a
|
|
554
|
+
// tidy one-line error -- the common case, and the one a user is most likely
|
|
555
|
+
// to read -- got no statement of what happens next at all. That is the thing
|
|
556
|
+
// this function exists to say.
|
|
557
|
+
const detail = parseErrorMessage(body);
|
|
558
|
+
const misconfigured = misconfigurationAdvice(response.status);
|
|
559
|
+
if (detail)
|
|
560
|
+
return `${head}: ${detail.replace(/[.\s]+$/, "")}. ${closeLine(retriable, retryAfterMs, misconfigured)}`;
|
|
561
|
+
// Nothing usable in the body. Say what shape arrived instead of quoting it.
|
|
562
|
+
const html = looksLikeHtml(response.headers.get("content-type"), body);
|
|
563
|
+
const server = response.status >= 500;
|
|
564
|
+
const shape = html
|
|
565
|
+
? server
|
|
566
|
+
? "it answered with an HTML error page, which means the server or its platform failed before the route ran"
|
|
567
|
+
: "it answered with an HTML page, which usually means a login wall, a bot check, or the wrong URL"
|
|
568
|
+
: body.trim()
|
|
569
|
+
? `it answered with a body that is not a usable error (${contentType}, ${body.length} bytes)`
|
|
570
|
+
: `it answered with an empty body (${contentType})`;
|
|
571
|
+
return `${head}: ${shape}. ${closeLine(retriable, retryAfterMs, misconfigured)}`;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* What a status means when the endpoint is one the user chose.
|
|
575
|
+
*
|
|
576
|
+
* The two failures a misconfigured endpoint produces every single time are a
|
|
577
|
+
* rejected credential and a URL that is not there, and both arrive wrapped in
|
|
578
|
+
* a tidy provider sentence describing the *server's* opinion: "Invalid API
|
|
579
|
+
* key", "Not Found". That is accurate and useless, because the user needs to
|
|
580
|
+
* know that the thing to check is the configuration they just wrote rather
|
|
581
|
+
* than the model they asked for — a distinction the body never makes and the
|
|
582
|
+
* status always does.
|
|
583
|
+
*
|
|
584
|
+
* A complete closing line, not a prefix, because it *replaces* the generic
|
|
585
|
+
* one rather than preceding it. Appended to the fallback it contradicted
|
|
586
|
+
* itself: the message said to check the key and, in the next sentence, to
|
|
587
|
+
* check the URL, the key *and the model* — sending the user past the one thing
|
|
588
|
+
* that was actually wrong.
|
|
589
|
+
*
|
|
590
|
+
* Said without naming a mechanism to fix it, so the transport stays a
|
|
591
|
+
* transport. Which knob to turn is the host's business.
|
|
592
|
+
*/
|
|
593
|
+
function misconfigurationAdvice(status) {
|
|
594
|
+
switch (status) {
|
|
595
|
+
case 401:
|
|
596
|
+
case 403:
|
|
597
|
+
return "The endpoint rejected the credential — the API key for this endpoint is missing, wrong, or expired. Retrying will not help.";
|
|
598
|
+
case 404:
|
|
599
|
+
return "There is no OpenAI-compatible chat endpoint at that URL and path — check the base URL, and whether the path is /chat/completions. Retrying will not help.";
|
|
600
|
+
case 400:
|
|
601
|
+
return "The endpoint rejected the request itself — if this is not an OpenAI-compatible API, it may need a different path. Retrying will not help.";
|
|
602
|
+
default:
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* What happens next, said plainly.
|
|
608
|
+
*
|
|
609
|
+
* A rate limit with a reset time is neither of the two defaults: it will be
|
|
610
|
+
* waited out, and the wait is the server's number rather than the harness's.
|
|
611
|
+
* Saying "the harness will retry with backoff" about a window that resets in
|
|
612
|
+
* forty minutes is advice the reader will act on and find false.
|
|
613
|
+
*/
|
|
614
|
+
function closeLine(retriable, wait, permanentAdvice) {
|
|
615
|
+
if (wait === undefined)
|
|
616
|
+
return retryAdvice(retriable, permanentAdvice);
|
|
617
|
+
const seconds = Math.ceil(wait / 1_000);
|
|
618
|
+
return retriable
|
|
619
|
+
? `Rate limited: the harness will wait ${seconds}s and try again.`
|
|
620
|
+
: `Rate limited until the window resets in about ${seconds}s. The harness will not keep retrying before then.`;
|
|
621
|
+
}
|
|
622
|
+
function parseErrorMessage(body) {
|
|
623
|
+
if (!body.trim())
|
|
624
|
+
return undefined;
|
|
625
|
+
let value;
|
|
626
|
+
try {
|
|
627
|
+
value = JSON.parse(body);
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
// Not JSON. An HTML page is never worth quoting, and a truncated JSON
|
|
631
|
+
// document is a symptom rather than a message. A short, printable,
|
|
632
|
+
// non-brace-prefixed body is a real message and is worth keeping.
|
|
633
|
+
if (looksLikeHtml(null, body))
|
|
634
|
+
return undefined;
|
|
635
|
+
const text = body.trim();
|
|
636
|
+
if (/^[{[]/.test(text))
|
|
637
|
+
return undefined;
|
|
638
|
+
// eslint-disable-next-line no-control-regex -- this range is a screenful of C0 control characters, and detecting them is the point
|
|
639
|
+
if (text.length <= 300 && !/[\u0000-\u0008\u000e-\u001f]/.test(text))
|
|
640
|
+
return text;
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
const record = value;
|
|
644
|
+
if (typeof record.error === "string")
|
|
645
|
+
return record.error;
|
|
646
|
+
if (typeof record.error?.message === "string")
|
|
647
|
+
return record.error.message;
|
|
648
|
+
if (typeof record.message === "string")
|
|
649
|
+
return record.message;
|
|
650
|
+
if (typeof record.detail === "string")
|
|
651
|
+
return record.detail;
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PlanPrefix } from "./types.js";
|
|
2
|
+
export declare function parsePlanPrefix(buffer: string): PlanPrefix | null;
|
|
3
|
+
export declare function needsMorePlanData(buffer: string): boolean;
|
|
4
|
+
export declare function extractPlanPrefix(buffer: string): {
|
|
5
|
+
plan: PlanPrefix | null;
|
|
6
|
+
rest: string;
|
|
7
|
+
};
|
|
8
|
+
//# sourceMappingURL=plan-prefix.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-prefix.d.ts","sourceRoot":"","sources":["../src/plan-prefix.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAa7C,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CA2BjE;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAMzD;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,UAAU,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAK3F"}
|