@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.
@@ -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"}