@juno-ai/bind 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -19
- package/completion/defects.d.ts +138 -0
- package/completion/defects.js +128 -0
- package/completion/index.d.ts +2 -0
- package/completion/index.js +2 -0
- package/completion/watchdog.d.ts +165 -0
- package/completion/watchdog.js +211 -0
- package/index.d.ts +9 -6
- package/index.js +9 -6
- package/loop/tool-loop.js +17 -5
- package/package.json +6 -2
- package/routing/attempt-errors.d.ts +122 -0
- package/routing/attempt-errors.js +176 -0
- package/routing/executor.js +16 -1
- package/routing/index.d.ts +1 -0
- package/routing/index.js +1 -0
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ losing it.
|
|
|
104
104
|
|
|
105
105
|
### What stays with your application
|
|
106
106
|
|
|
107
|
-
Transports' request construction and
|
|
107
|
+
Transports' request construction and your own error classes, credentials and
|
|
108
108
|
environment parsing, your routing policy configuration, billing accounting
|
|
109
109
|
(persistence and charging), inference logging, authorization, prompt rendering,
|
|
110
110
|
and run orchestration — the queue a run is scheduled on, and the enqueuing and
|
|
@@ -379,6 +379,91 @@ should mint its own, so a turn is never unbounded. Handle caller-driven
|
|
|
379
379
|
cancellation *before* calling `classifyRunFailure` — a cancellation is not a
|
|
380
380
|
timeout, and `timedOut` stays false when only a combined external signal fires.
|
|
381
381
|
|
|
382
|
+
### How to stop a stalled stream from hanging forever
|
|
383
|
+
|
|
384
|
+
Your SDK's `timeout` bounds establishing the request, not the gap between
|
|
385
|
+
streamed chunks. Hand the watchdog's signal to the transport, tell it what each
|
|
386
|
+
chunk carried, and ask it afterwards whether it was the one that tore the stream
|
|
387
|
+
down.
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
import { createStreamWatchdog } from "@juno-ai/bind/completion";
|
|
391
|
+
|
|
392
|
+
const watchdog = createStreamWatchdog({ external: cancellationSignal });
|
|
393
|
+
try {
|
|
394
|
+
const stream = await client.chat.completions.create(body, { signal: watchdog.signal });
|
|
395
|
+
watchdog.open(); // arms the first-token budget and the absolute cap
|
|
396
|
+
|
|
397
|
+
for await (const chunk of stream) {
|
|
398
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
399
|
+
// Only ANSWER output arms the tight inter-chunk budget. Reasoning deltas —
|
|
400
|
+
// and the pause between reasoning and the first answer token — must stay on
|
|
401
|
+
// the generous budget, or a reasoning model's normal think-then-answer gap
|
|
402
|
+
// fails healthy turns.
|
|
403
|
+
// Providers disagree on the reasoning field's name, and it is off-spec for
|
|
404
|
+
// the OpenAI types either way — read both spellings.
|
|
405
|
+
const reasoning = delta?.reasoning_content ?? delta?.reasoning;
|
|
406
|
+
watchdog.observedChunk(
|
|
407
|
+
delta?.content || delta?.refusal || delta?.tool_calls
|
|
408
|
+
? "answer"
|
|
409
|
+
: reasoning
|
|
410
|
+
? "reasoning"
|
|
411
|
+
: "none",
|
|
412
|
+
);
|
|
413
|
+
// …accumulate…
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
const stall = watchdog.stall();
|
|
417
|
+
if (stall !== null) throw new MyRetriableError(describe(stall)); // your wording
|
|
418
|
+
throw error; // a caller abort, or a real transport failure
|
|
419
|
+
} finally {
|
|
420
|
+
watchdog.dispose();
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
`stall()` returns `null` when your own `external` signal aborted, so a
|
|
425
|
+
cancellation is never reported as a retriable upstream stall. Call it in the
|
|
426
|
+
`catch`, before `dispose()`. Budgets are validated at construction: a `NaN` or
|
|
427
|
+
`Infinity` budget throws rather than silently tearing down every healthy stream
|
|
428
|
+
(`setTimeout` coerces a non-finite delay to ~1ms — it does not disable the
|
|
429
|
+
timer).
|
|
430
|
+
|
|
431
|
+
### How to map your transport errors onto the routing taxonomy
|
|
432
|
+
|
|
433
|
+
`failureDisposition` decides what the router does with a classified failure, but
|
|
434
|
+
something has to produce the classification. Give `classifyAttemptError` two
|
|
435
|
+
ports — one that recognizes your error class, one that overrides the neutral
|
|
436
|
+
HTTP mapping where your provider disagrees with it — and it does the rest.
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import { classifyAttemptError, type AttemptClassification } from "@juno-ai/bind/routing";
|
|
440
|
+
|
|
441
|
+
const CLASSIFICATION: AttemptClassification = {
|
|
442
|
+
asTransportFailure: (error) =>
|
|
443
|
+
error instanceof MyLLMError
|
|
444
|
+
? { kind: error.kind, statusCode: error.statusCode, retryAfterMs: error.retryAfterMs }
|
|
445
|
+
: null,
|
|
446
|
+
// This gateway answers 403 for moderation-flagged INPUT. The neutral mapping
|
|
447
|
+
// reads 403 as a credential failure and opens the circuit immediately —
|
|
448
|
+
// degrading a healthy shared endpoint for every tenant over one prompt.
|
|
449
|
+
categorizeStatus: (status, providerId) =>
|
|
450
|
+
status === 403 && providerId === MY_GATEWAY ? "provider_bad_request" : null,
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// `target` is the `AttemptTarget` you assemble in your `attempt` callback from
|
|
454
|
+
// the candidate and cursor it was handed — see the tutorial's step 4.
|
|
455
|
+
const attemptError = classifyAttemptError(error, target, CLASSIFICATION);
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
An `AbortError` is classified first, whatever else is true of it. Anything
|
|
459
|
+
`asTransportFailure` does not recognize becomes a propagating `client_error`: an
|
|
460
|
+
error that escaped your transport without becoming one of its own is a bug, and
|
|
461
|
+
retrying it against every provider and your fallback model arrives at the same
|
|
462
|
+
exception having spent a whole plan. `retryAfterMsFromHeaders` lives alongside
|
|
463
|
+
it and feeds the `retryAfterMs` the breaker uses to extend a cooldown — clamp
|
|
464
|
+
it before using it as a delay anywhere else, since it is a value the upstream
|
|
465
|
+
chose and RFC 9110 puts no ceiling on it.
|
|
466
|
+
|
|
382
467
|
### How to stop a run's progress writes from stampeding
|
|
383
468
|
|
|
384
469
|
```ts
|
|
@@ -522,7 +607,8 @@ keeps a consumer who only wants routing from pulling in the rest.
|
|
|
522
607
|
|
|
523
608
|
| Import | Owns | Reach for it when |
|
|
524
609
|
|---|---|---|
|
|
525
|
-
| `@juno-ai/bind/routing` | Route plans, the planner, the failure taxonomy, the plan executor, the circuit breaker, billing-basis arithmetic, config degradation | You call more than one provider or model, or you want retries and fallback governed by one table |
|
|
610
|
+
| `@juno-ai/bind/routing` | Route plans, the planner, the failure taxonomy and the classifier that maps your errors onto it, the plan executor, the circuit breaker, billing-basis arithmetic, config degradation | You call more than one provider or model, or you want retries and fallback governed by one table |
|
|
611
|
+
| `@juno-ai/bind/completion` | Streaming idle watchdog (time-to-first-token, inter-chunk, absolute cap), completion-defect detection, and the structured-output retry predicates | You read a streamed completion |
|
|
526
612
|
| `@juno-ai/bind/contracts` | Turn vocabulary — `TurnFn`, `ModelTurnResult`, `StopReason`, `RunStats` and its folds | You want one seam between your loop and any LLM client, and comparable per-run metrics |
|
|
527
613
|
| `@juno-ai/bind/loop` | `runToolLoop` — the iteration engine: model turn, two-phase tool batch, activation, compaction, interrupts, suspend | You want the agent loop itself, not just the pieces to build one |
|
|
528
614
|
| `@juno-ai/bind/run` | Wall-clock deadline, failure classification, coalesced heartbeat, tool-batch pooling, child-run lineage and admission, poll backoff | A run must be bounded, observable, and able to say *why* it stopped — or it can spawn runs of its own |
|
|
@@ -785,7 +871,15 @@ transport reports **facts**, the executor decides order.
|
|
|
785
871
|
// else if (status === 401) throw;
|
|
786
872
|
// else tryNextModel();
|
|
787
873
|
|
|
788
|
-
// After: the transport only classifies
|
|
874
|
+
// After: the transport only classifies — and `classifyAttemptError` does that
|
|
875
|
+
// for you, so the only thing you write is how to recognize your own error class.
|
|
876
|
+
const CLASSIFICATION: AttemptClassification = {
|
|
877
|
+
asTransportFailure: (error) =>
|
|
878
|
+
error instanceof MyLLMError
|
|
879
|
+
? { kind: error.kind, statusCode: error.statusCode, retryAfterMs: error.retryAfterMs }
|
|
880
|
+
: null,
|
|
881
|
+
};
|
|
882
|
+
|
|
789
883
|
const attempt: AttemptFn<Completion> = async (candidate, cursor) => {
|
|
790
884
|
const startedAt = performance.now();
|
|
791
885
|
try {
|
|
@@ -798,27 +892,23 @@ const attempt: AttemptFn<Completion> = async (candidate, cursor) => {
|
|
|
798
892
|
providerInvocationModel: candidate.providerInvocationModel,
|
|
799
893
|
durationMs: Math.round(performance.now() - startedAt),
|
|
800
894
|
};
|
|
801
|
-
|
|
802
|
-
if (!cause.status) return { kind: "failure", error: { kind: "network", target, cause } };
|
|
803
|
-
return {
|
|
804
|
-
kind: "failure",
|
|
805
|
-
error: {
|
|
806
|
-
kind: "http",
|
|
807
|
-
category: categorizeHttpStatus(cause.status),
|
|
808
|
-
statusCode: cause.status,
|
|
809
|
-
retryAfterMs: parseRetryAfter(cause),
|
|
810
|
-
target,
|
|
811
|
-
cause,
|
|
812
|
-
},
|
|
813
|
-
};
|
|
895
|
+
return { kind: "failure", error: classifyAttemptError(cause, target, CLASSIFICATION) };
|
|
814
896
|
}
|
|
815
897
|
};
|
|
816
898
|
```
|
|
817
899
|
|
|
900
|
+
Do not hand-roll that `catch` from the neutral pieces — mapping statuses
|
|
901
|
+
yourself is where the consequential mistakes live. The neutral mapping reads
|
|
902
|
+
**403 as a credential failure**, which opens the breaker immediately; if your
|
|
903
|
+
gateway also answers 403 for a moderation-flagged *prompt*, that one tenant's
|
|
904
|
+
request takes a healthy endpoint out of rotation for everyone sharing the
|
|
905
|
+
breaker key. Supply a `categorizeStatus` port instead — see
|
|
906
|
+
[How to map your transport errors onto the routing taxonomy](#how-to-map-your-transport-errors-onto-the-routing-taxonomy).
|
|
907
|
+
|
|
818
908
|
Two behaviours you get for free and probably did not have: a request-shaped
|
|
819
|
-
rejection (a 400, a moderation refusal) traverses to
|
|
820
|
-
opening the breaker, and an empty or truncated
|
|
821
|
-
endpoint before any fallback.
|
|
909
|
+
rejection (a 400, a correctly-classified moderation refusal) traverses to
|
|
910
|
+
another provider **without** opening the breaker, and an empty or truncated
|
|
911
|
+
completion is retried on the same endpoint before any fallback.
|
|
822
912
|
|
|
823
913
|
### Adapting a non-SDK client to `TurnFn`
|
|
824
914
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transient defects in an assembled completion, and the retry predicates that
|
|
3
|
+
* decide whether to ask the same endpoint again.
|
|
4
|
+
*
|
|
5
|
+
* A completion can come back structurally intact at the transport layer and
|
|
6
|
+
* still be unusable: the provider cut the stream mid-arguments, or closed it
|
|
7
|
+
* having emitted nothing at all. Neither is an HTTP failure — there is no
|
|
8
|
+
* status code to classify — so nothing upstream in the routing taxonomy sees
|
|
9
|
+
* them. Left undetected they reach the agent loop, where a truncated tool call
|
|
10
|
+
* is rejected as "invalid JSON in tool arguments" and burns a whole recovery
|
|
11
|
+
* turn on something a plain retry fixes.
|
|
12
|
+
*
|
|
13
|
+
* The two defect kinds here are exactly the two that feed
|
|
14
|
+
* `InferenceAttemptError`'s `completion_defect` arm, which the disposition
|
|
15
|
+
* matrix routes to a same-endpoint retry before traversing providers.
|
|
16
|
+
*
|
|
17
|
+
* Everything in this module is pure. Types are structural rather than tied to
|
|
18
|
+
* any SDK's message class, so a host assembling chunks by hand and a host
|
|
19
|
+
* handing over an `openai` message both fit without a cast.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Additional attempts when a structured-output call returns content that will
|
|
23
|
+
* not parse as JSON: the initial call plus this many retries. Providers
|
|
24
|
+
* occasionally truncate or malform JSON even under a strict schema, and a fresh
|
|
25
|
+
* attempt almost always comes back valid.
|
|
26
|
+
*/
|
|
27
|
+
export declare const DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES = 3;
|
|
28
|
+
/**
|
|
29
|
+
* Same-endpoint retry budget for a transiently-corrupt completion. A fresh
|
|
30
|
+
* attempt on the SAME endpoint usually recovers an intact response; a
|
|
31
|
+
* persistently-broken endpoint exhausts these, and the route executor then
|
|
32
|
+
* traverses to the next provider or model.
|
|
33
|
+
*/
|
|
34
|
+
export declare const DEFAULT_COMPLETION_DEFECT_MAX_RETRIES = 2;
|
|
35
|
+
/** The part of a streamed tool call this module reads. */
|
|
36
|
+
export interface StreamedToolCall {
|
|
37
|
+
readonly function: {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly arguments: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The outputs of an assembled completion. `tool_calls` is `unknown[]` here
|
|
44
|
+
* because the checks that only need "did the model call a tool" must accept a
|
|
45
|
+
* host's full SDK union (which may include non-function tool calls);
|
|
46
|
+
* {@link detectCompletionDefect} narrows it where it actually reads arguments.
|
|
47
|
+
*/
|
|
48
|
+
export interface CompletionOutputs {
|
|
49
|
+
readonly content: string | null;
|
|
50
|
+
readonly refusal?: string | null | undefined;
|
|
51
|
+
readonly tool_calls?: readonly unknown[] | undefined;
|
|
52
|
+
}
|
|
53
|
+
/** {@link CompletionOutputs} with tool calls narrowed to the readable shape. */
|
|
54
|
+
export interface AssembledCompletion<TToolCall extends StreamedToolCall = StreamedToolCall> extends CompletionOutputs {
|
|
55
|
+
readonly tool_calls?: readonly TToolCall[] | undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The defect vocabulary, named once so the routing taxonomy can reference it
|
|
59
|
+
* rather than re-spelling the same two literals. `InferenceAttemptError`'s
|
|
60
|
+
* `completion_defect` arm carries exactly these, and a third kind added here
|
|
61
|
+
* must widen that arm too — which it will, by type error, only because both
|
|
62
|
+
* sides read this one declaration.
|
|
63
|
+
*/
|
|
64
|
+
export type CompletionDefectKind = "empty_completion" | "truncated_tool_call";
|
|
65
|
+
export type CompletionDefect<TToolCall extends StreamedToolCall = StreamedToolCall> =
|
|
66
|
+
/** The provider closed the stream having emitted no content, tool call, or refusal. */
|
|
67
|
+
Readonly<{
|
|
68
|
+
kind: "empty_completion";
|
|
69
|
+
}>
|
|
70
|
+
/** A tool call whose arguments never arrived intact. */
|
|
71
|
+
| Readonly<{
|
|
72
|
+
kind: "truncated_tool_call";
|
|
73
|
+
toolCall: TToolCall;
|
|
74
|
+
}>;
|
|
75
|
+
/** True when `s` parses as JSON. */
|
|
76
|
+
export declare function jsonParses(s: string): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Find the transient defect in an assembled completion, or `null` when it is
|
|
79
|
+
* usable.
|
|
80
|
+
*
|
|
81
|
+
* **Empty completion.** No content, no tool calls, no refusal. A refusal alone
|
|
82
|
+
* is a legitimate output and is not a defect.
|
|
83
|
+
*
|
|
84
|
+
* **Truncated tool call.** Non-empty arguments that do not parse as JSON are
|
|
85
|
+
* always a truncation — the provider cut the stream mid-arguments. *Empty*
|
|
86
|
+
* arguments are the normal zero-arg shape and are not a defect **unless** the
|
|
87
|
+
* stream was `cutByTokenLimit`: a length-truncated empty-args call is an
|
|
88
|
+
* incomplete message, not a deliberate no-arg call, and exempting it would let
|
|
89
|
+
* a dispatcher execute a tool off a half-streamed turn.
|
|
90
|
+
*
|
|
91
|
+
* `cutByTokenLimit` is the *fact*, not the wire spelling of it — OpenAI says
|
|
92
|
+
* `finish_reason: "length"`, Anthropic says `max_tokens`, Gemini says
|
|
93
|
+
* `MAX_TOKENS`. Taking the fact keeps the one behavioural rule in this module
|
|
94
|
+
* from silently reading `false` for every host that isn't on an
|
|
95
|
+
* OpenAI-compatible gateway, which would apply the zero-arg exemption to
|
|
96
|
+
* exactly the truncated calls it exists to exclude.
|
|
97
|
+
*
|
|
98
|
+
* A tool call whose `arguments` are not a readable string is skipped rather
|
|
99
|
+
* than inspected: {@link CompletionOutputs.tool_calls} accepts a host's full
|
|
100
|
+
* SDK union, and a non-function member (OpenAI's `type: "custom"`) has no
|
|
101
|
+
* arguments to truncate.
|
|
102
|
+
*/
|
|
103
|
+
export declare function detectCompletionDefect<TToolCall extends StreamedToolCall>(completion: AssembledCompletion<TToolCall>, cutByTokenLimit: boolean): CompletionDefect<TToolCall> | null;
|
|
104
|
+
/**
|
|
105
|
+
* The part of a request's `response_format` that decides whether output is JSON
|
|
106
|
+
* we can validate by parsing. Structural so any SDK's type fits.
|
|
107
|
+
*
|
|
108
|
+
* The two JSON spellings are the OpenAI-compatible wire values. The open
|
|
109
|
+
* `(string & {})` arm keeps any other value assignable — a gateway with its own
|
|
110
|
+
* vocabulary is not a type error — while still letting an editor complete the
|
|
111
|
+
* two that {@link expectsJsonOutput} actually recognizes, so a near-miss like
|
|
112
|
+
* `"json"` is visible at the call site rather than silently falling through to
|
|
113
|
+
* "no JSON expected".
|
|
114
|
+
*/
|
|
115
|
+
export interface ResponseFormatShape {
|
|
116
|
+
readonly type?: "text" | "json_object" | "json_schema" | (string & {}) | undefined;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* True when `response_format` asks the model for JSON. Free-form output
|
|
120
|
+
* (`text`, or omitted) is never retried on a parse failure — there is nothing
|
|
121
|
+
* to parse, so every completion would look like a defect.
|
|
122
|
+
*/
|
|
123
|
+
export declare function expectsJsonOutput(responseFormat: ResponseFormatShape | null | undefined): boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Whether a structured-output completion holds parseable JSON.
|
|
126
|
+
*
|
|
127
|
+
* Tool calls and refusals are valid non-JSON outcomes and pass. A wholly empty
|
|
128
|
+
* completion is a {@link detectCompletionDefect} concern, but a *whitespace-only*
|
|
129
|
+
* one is truthy there and slips through — so this deliberately does NOT
|
|
130
|
+
* short-circuit on empty content: an empty or whitespace string is not valid
|
|
131
|
+
* JSON, fails the parse, and earns a retry.
|
|
132
|
+
*
|
|
133
|
+
* The ``` / ```json fences some models wrap JSON in are stripped first,
|
|
134
|
+
* mirroring what structured consumers do before parsing. A bare `true` / `false`
|
|
135
|
+
* is itself valid JSON, so a model that answers a boolean schema with the bare
|
|
136
|
+
* literal still passes; only genuinely malformed or truncated JSON fails.
|
|
137
|
+
*/
|
|
138
|
+
export declare function structuredOutputParses(completion: CompletionOutputs): boolean;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transient defects in an assembled completion, and the retry predicates that
|
|
3
|
+
* decide whether to ask the same endpoint again.
|
|
4
|
+
*
|
|
5
|
+
* A completion can come back structurally intact at the transport layer and
|
|
6
|
+
* still be unusable: the provider cut the stream mid-arguments, or closed it
|
|
7
|
+
* having emitted nothing at all. Neither is an HTTP failure — there is no
|
|
8
|
+
* status code to classify — so nothing upstream in the routing taxonomy sees
|
|
9
|
+
* them. Left undetected they reach the agent loop, where a truncated tool call
|
|
10
|
+
* is rejected as "invalid JSON in tool arguments" and burns a whole recovery
|
|
11
|
+
* turn on something a plain retry fixes.
|
|
12
|
+
*
|
|
13
|
+
* The two defect kinds here are exactly the two that feed
|
|
14
|
+
* `InferenceAttemptError`'s `completion_defect` arm, which the disposition
|
|
15
|
+
* matrix routes to a same-endpoint retry before traversing providers.
|
|
16
|
+
*
|
|
17
|
+
* Everything in this module is pure. Types are structural rather than tied to
|
|
18
|
+
* any SDK's message class, so a host assembling chunks by hand and a host
|
|
19
|
+
* handing over an `openai` message both fit without a cast.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Additional attempts when a structured-output call returns content that will
|
|
23
|
+
* not parse as JSON: the initial call plus this many retries. Providers
|
|
24
|
+
* occasionally truncate or malform JSON even under a strict schema, and a fresh
|
|
25
|
+
* attempt almost always comes back valid.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES = 3;
|
|
28
|
+
/**
|
|
29
|
+
* Same-endpoint retry budget for a transiently-corrupt completion. A fresh
|
|
30
|
+
* attempt on the SAME endpoint usually recovers an intact response; a
|
|
31
|
+
* persistently-broken endpoint exhausts these, and the route executor then
|
|
32
|
+
* traverses to the next provider or model.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_COMPLETION_DEFECT_MAX_RETRIES = 2;
|
|
35
|
+
/** True when `s` parses as JSON. */
|
|
36
|
+
export function jsonParses(s) {
|
|
37
|
+
try {
|
|
38
|
+
JSON.parse(s);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Find the transient defect in an assembled completion, or `null` when it is
|
|
47
|
+
* usable.
|
|
48
|
+
*
|
|
49
|
+
* **Empty completion.** No content, no tool calls, no refusal. A refusal alone
|
|
50
|
+
* is a legitimate output and is not a defect.
|
|
51
|
+
*
|
|
52
|
+
* **Truncated tool call.** Non-empty arguments that do not parse as JSON are
|
|
53
|
+
* always a truncation — the provider cut the stream mid-arguments. *Empty*
|
|
54
|
+
* arguments are the normal zero-arg shape and are not a defect **unless** the
|
|
55
|
+
* stream was `cutByTokenLimit`: a length-truncated empty-args call is an
|
|
56
|
+
* incomplete message, not a deliberate no-arg call, and exempting it would let
|
|
57
|
+
* a dispatcher execute a tool off a half-streamed turn.
|
|
58
|
+
*
|
|
59
|
+
* `cutByTokenLimit` is the *fact*, not the wire spelling of it — OpenAI says
|
|
60
|
+
* `finish_reason: "length"`, Anthropic says `max_tokens`, Gemini says
|
|
61
|
+
* `MAX_TOKENS`. Taking the fact keeps the one behavioural rule in this module
|
|
62
|
+
* from silently reading `false` for every host that isn't on an
|
|
63
|
+
* OpenAI-compatible gateway, which would apply the zero-arg exemption to
|
|
64
|
+
* exactly the truncated calls it exists to exclude.
|
|
65
|
+
*
|
|
66
|
+
* A tool call whose `arguments` are not a readable string is skipped rather
|
|
67
|
+
* than inspected: {@link CompletionOutputs.tool_calls} accepts a host's full
|
|
68
|
+
* SDK union, and a non-function member (OpenAI's `type: "custom"`) has no
|
|
69
|
+
* arguments to truncate.
|
|
70
|
+
*/
|
|
71
|
+
export function detectCompletionDefect(completion, cutByTokenLimit) {
|
|
72
|
+
const toolCalls = completion.tool_calls;
|
|
73
|
+
const hasToolCalls = toolCalls !== undefined && toolCalls.length > 0;
|
|
74
|
+
if (!completion.content && !hasToolCalls && !completion.refusal) {
|
|
75
|
+
return { kind: "empty_completion" };
|
|
76
|
+
}
|
|
77
|
+
if (hasToolCalls) {
|
|
78
|
+
const truncated = toolCalls.find((toolCall) => {
|
|
79
|
+
const args = toolCall?.function?.arguments;
|
|
80
|
+
if (typeof args !== "string")
|
|
81
|
+
return false;
|
|
82
|
+
const rawArgs = args.trim();
|
|
83
|
+
if (rawArgs.length === 0)
|
|
84
|
+
return cutByTokenLimit;
|
|
85
|
+
return !jsonParses(rawArgs);
|
|
86
|
+
});
|
|
87
|
+
if (truncated !== undefined) {
|
|
88
|
+
return { kind: "truncated_tool_call", toolCall: truncated };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* True when `response_format` asks the model for JSON. Free-form output
|
|
95
|
+
* (`text`, or omitted) is never retried on a parse failure — there is nothing
|
|
96
|
+
* to parse, so every completion would look like a defect.
|
|
97
|
+
*/
|
|
98
|
+
export function expectsJsonOutput(responseFormat) {
|
|
99
|
+
return (responseFormat?.type === "json_schema" || responseFormat?.type === "json_object");
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether a structured-output completion holds parseable JSON.
|
|
103
|
+
*
|
|
104
|
+
* Tool calls and refusals are valid non-JSON outcomes and pass. A wholly empty
|
|
105
|
+
* completion is a {@link detectCompletionDefect} concern, but a *whitespace-only*
|
|
106
|
+
* one is truthy there and slips through — so this deliberately does NOT
|
|
107
|
+
* short-circuit on empty content: an empty or whitespace string is not valid
|
|
108
|
+
* JSON, fails the parse, and earns a retry.
|
|
109
|
+
*
|
|
110
|
+
* The ``` / ```json fences some models wrap JSON in are stripped first,
|
|
111
|
+
* mirroring what structured consumers do before parsing. A bare `true` / `false`
|
|
112
|
+
* is itself valid JSON, so a model that answers a boolean schema with the bare
|
|
113
|
+
* literal still passes; only genuinely malformed or truncated JSON fails.
|
|
114
|
+
*/
|
|
115
|
+
export function structuredOutputParses(completion) {
|
|
116
|
+
const toolCalls = completion.tool_calls;
|
|
117
|
+
if (toolCalls !== undefined && toolCalls !== null && toolCalls.length > 0) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (completion.refusal)
|
|
121
|
+
return true;
|
|
122
|
+
const content = (completion.content ?? "").trim();
|
|
123
|
+
const unfenced = content
|
|
124
|
+
.replace(/^```(?:json)?\s*/i, "")
|
|
125
|
+
.replace(/\s*```$/, "")
|
|
126
|
+
.trim();
|
|
127
|
+
return jsonParses(unfenced);
|
|
128
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { createStreamWatchdog, DEFAULT_TIME_TO_FIRST_TOKEN_MS, DEFAULT_INTER_CHUNK_MS, DEFAULT_MAX_CALL_DURATION_MS, type ChunkOutput, type StreamStall, type StreamWatchdog, type StreamWatchdogOptions, } from "./watchdog.js";
|
|
2
|
+
export { detectCompletionDefect, expectsJsonOutput, jsonParses, structuredOutputParses, DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES, DEFAULT_COMPLETION_DEFECT_MAX_RETRIES, type AssembledCompletion, type CompletionDefect, type CompletionOutputs, type ResponseFormatShape, type StreamedToolCall, } from "./defects.js";
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { createStreamWatchdog, DEFAULT_TIME_TO_FIRST_TOKEN_MS, DEFAULT_INTER_CHUNK_MS, DEFAULT_MAX_CALL_DURATION_MS, } from "./watchdog.js";
|
|
2
|
+
export { detectCompletionDefect, expectsJsonOutput, jsonParses, structuredOutputParses, DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES, DEFAULT_COMPLETION_DEFECT_MAX_RETRIES, } from "./defects.js";
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-completion idle watchdog.
|
|
3
|
+
*
|
|
4
|
+
* A chat-completions SDK's `timeout` bounds *establishing* the request and (for
|
|
5
|
+
* a non-streaming call) the whole response — but once a stream has started
|
|
6
|
+
* yielding, nothing bounds the gap between chunks. A provider that opens the
|
|
7
|
+
* stream and then stalls leaves the consumer's `for await` awaiting the next
|
|
8
|
+
* chunk forever, holding its worker slot until some outer deadline fires, or
|
|
9
|
+
* never. Downstream that is the agent that says "thinking…" and never answers.
|
|
10
|
+
*
|
|
11
|
+
* The budget is split into three because the phases of a streaming completion
|
|
12
|
+
* have very different normal latencies, and one number cannot serve all of them:
|
|
13
|
+
*
|
|
14
|
+
* - **Time to first token** — from opening the stream to the first *answer*
|
|
15
|
+
* token. Generous: a high-reasoning model can legitimately think for a while
|
|
16
|
+
* before emitting anything, and even once reasoning deltas are flowing it can
|
|
17
|
+
* sit silent for seconds between finishing its reasoning and emitting the
|
|
18
|
+
* first content/tool token. This budget therefore covers the reasoning phase
|
|
19
|
+
* AND the reasoning→answer gap — {@link ChunkOutput} `"reasoning"` does NOT
|
|
20
|
+
* switch to the tight budget. Arming the tight budget on a reasoning token is
|
|
21
|
+
* a real observed failure: the normal reasoning→content pause of a frontier
|
|
22
|
+
* reasoning model routinely approaches several seconds and tripped
|
|
23
|
+
* "no chunk mid-stream" on healthy turns.
|
|
24
|
+
* - **Inter-chunk** — the gap between chunks once the model's ANSWER output
|
|
25
|
+
* (content / tool-call arguments / refusal) is flowing. Tight: answer chunks
|
|
26
|
+
* normally arrive sub-second, so a multi-second silence mid-answer is already
|
|
27
|
+
* abnormal, and a shorter budget recovers a mid-stream stall far sooner.
|
|
28
|
+
* - **Overall cap** — an absolute wall-clock ceiling, armed at construction and
|
|
29
|
+
* never re-armed. Defense in depth, not a duplicate of the two idle budgets:
|
|
30
|
+
* the idle watchdog only bounds the *gap* between chunks, so a provider that
|
|
31
|
+
* drip-feeds a chunk just under the idle budget forever (a reasoning delta a
|
|
32
|
+
* minute, never reaching an answer) never trips it. The proper bound on total
|
|
33
|
+
* work is the caller's own deadline signal, but not every caller has one, so
|
|
34
|
+
* this guarantees a single call cannot hold a slot indefinitely regardless.
|
|
35
|
+
*
|
|
36
|
+
* It is armed by the constructor rather than by {@link StreamWatchdog.open}
|
|
37
|
+
* because it is the guarantee of last resort: a host that forgets `open()`
|
|
38
|
+
* gets a watchdog that silently never fires, with no type error and no test
|
|
39
|
+
* failure, and the symptom appears only under a stalling provider. Counting
|
|
40
|
+
* the connection handshake against a ceiling this generous costs nothing;
|
|
41
|
+
* leaving the ceiling to an imperative call the host may skip costs a worker
|
|
42
|
+
* slot. The two *idle* budgets genuinely must wait for `open()` — arming them
|
|
43
|
+
* earlier would charge connection time to the first-token budget.
|
|
44
|
+
*
|
|
45
|
+
* The watchdog owns timers and abort signals and nothing else: it does not read
|
|
46
|
+
* the stream, does not know the wire format, and does not phrase the error. It
|
|
47
|
+
* reports a structured {@link StreamStall} and the host words it — the same
|
|
48
|
+
* split the routing modules use, since a stall message is usually product copy.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* What a streamed chunk carried, from the watchdog's point of view. The
|
|
52
|
+
* distinction that matters is `"reasoning"` vs `"answer"`: only answer output
|
|
53
|
+
* arms the tight inter-chunk budget. A chunk carrying both is `"answer"`.
|
|
54
|
+
* A role/metadata-only opening chunk is `"none"` — the model has not produced a
|
|
55
|
+
* token yet, so it must not shorten the budget either.
|
|
56
|
+
*/
|
|
57
|
+
export type ChunkOutput = "none" | "reasoning" | "answer";
|
|
58
|
+
/** Why the watchdog tore the stream down. */
|
|
59
|
+
export type StreamStall =
|
|
60
|
+
/** The absolute wall-clock ceiling elapsed before the stream finished. */
|
|
61
|
+
Readonly<{
|
|
62
|
+
kind: "overall_cap";
|
|
63
|
+
limitMs: number;
|
|
64
|
+
}>
|
|
65
|
+
/**
|
|
66
|
+
* No answer token within the generous budget. `sawReasoning` distinguishes
|
|
67
|
+
* "the provider never said anything" from "it streamed reasoning and never
|
|
68
|
+
* reached an answer" — different upstream faults with the same budget.
|
|
69
|
+
*/
|
|
70
|
+
| Readonly<{
|
|
71
|
+
kind: "time_to_first_token";
|
|
72
|
+
limitMs: number;
|
|
73
|
+
sawReasoning: boolean;
|
|
74
|
+
}>
|
|
75
|
+
/** Answer output was flowing and then stopped mid-stream. */
|
|
76
|
+
| Readonly<{
|
|
77
|
+
kind: "inter_chunk";
|
|
78
|
+
limitMs: number;
|
|
79
|
+
}>;
|
|
80
|
+
export interface StreamWatchdogOptions {
|
|
81
|
+
/** Generous first-answer-token budget. Default {@link DEFAULT_TIME_TO_FIRST_TOKEN_MS}. */
|
|
82
|
+
readonly timeToFirstTokenMs?: number;
|
|
83
|
+
/** Tight budget between chunks once answer output flows. Default {@link DEFAULT_INTER_CHUNK_MS}. */
|
|
84
|
+
readonly interChunkMs?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Absolute ceiling on the whole call, measured from **watchdog
|
|
87
|
+
* construction** — not from {@link StreamWatchdog.open}, which only arms the
|
|
88
|
+
* idle budgets. Size it to include whatever the host does between
|
|
89
|
+
* constructing the watchdog and opening the stream (connecting, sending the
|
|
90
|
+
* request, waiting on response headers), since all of that is inside the
|
|
91
|
+
* budget. Default {@link DEFAULT_MAX_CALL_DURATION_MS}.
|
|
92
|
+
*/
|
|
93
|
+
readonly maxCallDurationMs?: number;
|
|
94
|
+
/**
|
|
95
|
+
* The caller's own abort signal (a run deadline, a cancellation). Composed
|
|
96
|
+
* into {@link StreamWatchdog.signal}, and — critically — consulted by
|
|
97
|
+
* {@link StreamWatchdog.stall}: when the caller aborted, the teardown is the
|
|
98
|
+
* caller's, not a stall, and must not be reclassified as a retriable
|
|
99
|
+
* upstream fault.
|
|
100
|
+
*/
|
|
101
|
+
readonly external?: AbortSignal | null;
|
|
102
|
+
/**
|
|
103
|
+
* Timer port, defaulting to the ambient globals. Inject a controllable clock
|
|
104
|
+
* to test budget behaviour exactly, rather than sleeping a real interval and
|
|
105
|
+
* hoping the machine keeps up — a watchdog test asserting "nothing fired yet"
|
|
106
|
+
* against a real timer is a flake waiting for a loaded CI box.
|
|
107
|
+
*
|
|
108
|
+
* The handle is opaque (`unknown`) so a fake can hand back whatever it likes;
|
|
109
|
+
* only {@link StreamTimers.clear} ever consumes it.
|
|
110
|
+
*/
|
|
111
|
+
readonly timers?: StreamTimers;
|
|
112
|
+
}
|
|
113
|
+
/** The subset of the timer API the watchdog needs. */
|
|
114
|
+
export interface StreamTimers {
|
|
115
|
+
set(callback: () => void, ms: number): unknown;
|
|
116
|
+
clear(handle: unknown): void;
|
|
117
|
+
}
|
|
118
|
+
export interface StreamWatchdog {
|
|
119
|
+
/** Hand this to the transport as the request's abort signal. */
|
|
120
|
+
readonly signal: AbortSignal;
|
|
121
|
+
/**
|
|
122
|
+
* The stream is open: arm the time-to-first-token budget. Call immediately
|
|
123
|
+
* after the transport returns the stream — arming before that would count
|
|
124
|
+
* connection time against the first-token budget. The absolute cap is already
|
|
125
|
+
* running (see {@link createStreamWatchdog}); this only starts the idle
|
|
126
|
+
* budgets. Idempotent, and a no-op after {@link dispose}.
|
|
127
|
+
*/
|
|
128
|
+
open(): void;
|
|
129
|
+
/**
|
|
130
|
+
* A chunk arrived: re-arm for the gap to the *next* one. Call for every
|
|
131
|
+
* chunk, including metadata-only ones — a chunk that carried nothing still
|
|
132
|
+
* proves the stream is alive.
|
|
133
|
+
*/
|
|
134
|
+
observedChunk(output: ChunkOutput): void;
|
|
135
|
+
/**
|
|
136
|
+
* Why the stream was torn down, or `null` if this watchdog did not do it.
|
|
137
|
+
* Returns `null` whenever the caller's own signal aborted, even if a budget
|
|
138
|
+
* also elapsed: a cancelled call is cancelled, not stalled.
|
|
139
|
+
*
|
|
140
|
+
* Call from the `catch` that saw the abort, before {@link dispose}.
|
|
141
|
+
*/
|
|
142
|
+
stall(): StreamStall | null;
|
|
143
|
+
/**
|
|
144
|
+
* Clear the timers, forget any budget that elapsed, and settle the composite
|
|
145
|
+
* signal. Idempotent; call from a `finally` on every path.
|
|
146
|
+
*
|
|
147
|
+
* Settling matters as much as clearing: the composite holds a listener on a
|
|
148
|
+
* possibly long-lived caller signal, and one per call across a run's many
|
|
149
|
+
* calls is a leak.
|
|
150
|
+
*
|
|
151
|
+
* Forgetting matters because a timer can fire in the moment between the last
|
|
152
|
+
* chunk and the stream ending — the abort loses the race, the read completes
|
|
153
|
+
* normally, and nothing was torn down. If that stale flag survived, a *later*
|
|
154
|
+
* failure on the same call (a defect found while assembling the message, a
|
|
155
|
+
* billing read) would reach {@link stall} and be reported as an upstream
|
|
156
|
+
* stall it had nothing to do with. Call `stall()` before `dispose()`, which
|
|
157
|
+
* is the documented order and the only order in which a real stall is
|
|
158
|
+
* observable anyway.
|
|
159
|
+
*/
|
|
160
|
+
dispose(): void;
|
|
161
|
+
}
|
|
162
|
+
export declare const DEFAULT_TIME_TO_FIRST_TOKEN_MS = 120000;
|
|
163
|
+
export declare const DEFAULT_INTER_CHUNK_MS = 5000;
|
|
164
|
+
export declare const DEFAULT_MAX_CALL_DURATION_MS = 600000;
|
|
165
|
+
export declare function createStreamWatchdog(options?: StreamWatchdogOptions): StreamWatchdog;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-completion idle watchdog.
|
|
3
|
+
*
|
|
4
|
+
* A chat-completions SDK's `timeout` bounds *establishing* the request and (for
|
|
5
|
+
* a non-streaming call) the whole response — but once a stream has started
|
|
6
|
+
* yielding, nothing bounds the gap between chunks. A provider that opens the
|
|
7
|
+
* stream and then stalls leaves the consumer's `for await` awaiting the next
|
|
8
|
+
* chunk forever, holding its worker slot until some outer deadline fires, or
|
|
9
|
+
* never. Downstream that is the agent that says "thinking…" and never answers.
|
|
10
|
+
*
|
|
11
|
+
* The budget is split into three because the phases of a streaming completion
|
|
12
|
+
* have very different normal latencies, and one number cannot serve all of them:
|
|
13
|
+
*
|
|
14
|
+
* - **Time to first token** — from opening the stream to the first *answer*
|
|
15
|
+
* token. Generous: a high-reasoning model can legitimately think for a while
|
|
16
|
+
* before emitting anything, and even once reasoning deltas are flowing it can
|
|
17
|
+
* sit silent for seconds between finishing its reasoning and emitting the
|
|
18
|
+
* first content/tool token. This budget therefore covers the reasoning phase
|
|
19
|
+
* AND the reasoning→answer gap — {@link ChunkOutput} `"reasoning"` does NOT
|
|
20
|
+
* switch to the tight budget. Arming the tight budget on a reasoning token is
|
|
21
|
+
* a real observed failure: the normal reasoning→content pause of a frontier
|
|
22
|
+
* reasoning model routinely approaches several seconds and tripped
|
|
23
|
+
* "no chunk mid-stream" on healthy turns.
|
|
24
|
+
* - **Inter-chunk** — the gap between chunks once the model's ANSWER output
|
|
25
|
+
* (content / tool-call arguments / refusal) is flowing. Tight: answer chunks
|
|
26
|
+
* normally arrive sub-second, so a multi-second silence mid-answer is already
|
|
27
|
+
* abnormal, and a shorter budget recovers a mid-stream stall far sooner.
|
|
28
|
+
* - **Overall cap** — an absolute wall-clock ceiling, armed at construction and
|
|
29
|
+
* never re-armed. Defense in depth, not a duplicate of the two idle budgets:
|
|
30
|
+
* the idle watchdog only bounds the *gap* between chunks, so a provider that
|
|
31
|
+
* drip-feeds a chunk just under the idle budget forever (a reasoning delta a
|
|
32
|
+
* minute, never reaching an answer) never trips it. The proper bound on total
|
|
33
|
+
* work is the caller's own deadline signal, but not every caller has one, so
|
|
34
|
+
* this guarantees a single call cannot hold a slot indefinitely regardless.
|
|
35
|
+
*
|
|
36
|
+
* It is armed by the constructor rather than by {@link StreamWatchdog.open}
|
|
37
|
+
* because it is the guarantee of last resort: a host that forgets `open()`
|
|
38
|
+
* gets a watchdog that silently never fires, with no type error and no test
|
|
39
|
+
* failure, and the symptom appears only under a stalling provider. Counting
|
|
40
|
+
* the connection handshake against a ceiling this generous costs nothing;
|
|
41
|
+
* leaving the ceiling to an imperative call the host may skip costs a worker
|
|
42
|
+
* slot. The two *idle* budgets genuinely must wait for `open()` — arming them
|
|
43
|
+
* earlier would charge connection time to the first-token budget.
|
|
44
|
+
*
|
|
45
|
+
* The watchdog owns timers and abort signals and nothing else: it does not read
|
|
46
|
+
* the stream, does not know the wire format, and does not phrase the error. It
|
|
47
|
+
* reports a structured {@link StreamStall} and the host words it — the same
|
|
48
|
+
* split the routing modules use, since a stall message is usually product copy.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_TIME_TO_FIRST_TOKEN_MS = 120_000;
|
|
51
|
+
export const DEFAULT_INTER_CHUNK_MS = 5_000;
|
|
52
|
+
export const DEFAULT_MAX_CALL_DURATION_MS = 600_000;
|
|
53
|
+
/**
|
|
54
|
+
* The largest delay `setTimeout` can represent. Above this the delay overflows
|
|
55
|
+
* its 32-bit signed field and the timer fires almost immediately instead.
|
|
56
|
+
*/
|
|
57
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
58
|
+
/**
|
|
59
|
+
* A budget must be a delay `setTimeout` can actually honour.
|
|
60
|
+
*
|
|
61
|
+
* This is checked at construction, and it fails closed in the direction that
|
|
62
|
+
* actually bites. Every value rejected here would otherwise make the watchdog
|
|
63
|
+
* fire on the next tick and tear down every *healthy* stream, surfacing as a
|
|
64
|
+
* total inference outage that looks like a provider incident:
|
|
65
|
+
*
|
|
66
|
+
* - `NaN` / `Infinity`: `setTimeout` coerces a non-finite delay to 1ms rather
|
|
67
|
+
* than ignoring it, so a bad budget does not disable the watchdog — it makes
|
|
68
|
+
* it instantaneous.
|
|
69
|
+
* - Zero or negative: fires on the next tick by definition.
|
|
70
|
+
* - Above {@link MAX_TIMEOUT_MS}: overflows the 32-bit delay and, per spec,
|
|
71
|
+
* clamps to 1ms. This is the trap that reads as reasonable — `maxCallDuration`
|
|
72
|
+
* of 30 days is a plausible config value and the units are milliseconds, so
|
|
73
|
+
* it is one `* 1000` away from a healthy-looking number that disables the
|
|
74
|
+
* backstop entirely.
|
|
75
|
+
*
|
|
76
|
+
* Refusing the value at construction turns a silent outage into a stack trace
|
|
77
|
+
* at the call site that supplied it.
|
|
78
|
+
*/
|
|
79
|
+
function requirePositiveMs(value, field) {
|
|
80
|
+
if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMEOUT_MS) {
|
|
81
|
+
throw new RangeError(`StreamWatchdog ${field} must be a positive number of ms no greater than ${MAX_TIMEOUT_MS}, got ${String(value)}`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Call `unref()` on a timer when the runtime exposes it (Node/Bun) so a
|
|
87
|
+
* forgotten `dispose()` cannot hold the event loop open; a no-op under a DOM
|
|
88
|
+
* `number` timer or an injected fake. Feature-detected rather than `as`-cast to
|
|
89
|
+
* keep type safety.
|
|
90
|
+
*
|
|
91
|
+
* Duplicated from `../run/harness` deliberately: importing across module
|
|
92
|
+
* folders would make `@juno-ai/bind/completion` drag in the run mechanics for
|
|
93
|
+
* five lines, and a consumer that wants only the watchdog should get only the
|
|
94
|
+
* watchdog.
|
|
95
|
+
*/
|
|
96
|
+
function unrefTimer(timer) {
|
|
97
|
+
if (typeof timer === "object" &&
|
|
98
|
+
timer !== null &&
|
|
99
|
+
"unref" in timer &&
|
|
100
|
+
typeof timer.unref === "function") {
|
|
101
|
+
timer.unref();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const AMBIENT_TIMERS = {
|
|
105
|
+
set: (callback, ms) => setTimeout(callback, ms),
|
|
106
|
+
clear: (handle) => {
|
|
107
|
+
clearTimeout(handle);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
export function createStreamWatchdog(options = {}) {
|
|
111
|
+
const timeToFirstTokenMs = requirePositiveMs(options.timeToFirstTokenMs ?? DEFAULT_TIME_TO_FIRST_TOKEN_MS, "timeToFirstTokenMs");
|
|
112
|
+
const interChunkMs = requirePositiveMs(options.interChunkMs ?? DEFAULT_INTER_CHUNK_MS, "interChunkMs");
|
|
113
|
+
const maxCallDurationMs = requirePositiveMs(options.maxCallDurationMs ?? DEFAULT_MAX_CALL_DURATION_MS, "maxCallDurationMs");
|
|
114
|
+
const external = options.external ?? null;
|
|
115
|
+
const timers = options.timers ?? AMBIENT_TIMERS;
|
|
116
|
+
// Two controllers rather than one, so `stall()` can name which bound tripped
|
|
117
|
+
// without the caller having to correlate timers.
|
|
118
|
+
const idleController = new AbortController();
|
|
119
|
+
const overallController = new AbortController();
|
|
120
|
+
let idleFired = false;
|
|
121
|
+
let overallFired = false;
|
|
122
|
+
let disposed = false;
|
|
123
|
+
// True once any chunk carried model output; only phrases the diagnostic.
|
|
124
|
+
let sawOutput = false;
|
|
125
|
+
// True once ANSWER output flowed. Only this arms the tight budget.
|
|
126
|
+
let sawAnswerOutput = false;
|
|
127
|
+
let idleTimer = null;
|
|
128
|
+
let overallTimer = null;
|
|
129
|
+
function armIdle(ms) {
|
|
130
|
+
if (idleTimer !== null)
|
|
131
|
+
timers.clear(idleTimer);
|
|
132
|
+
idleTimer = timers.set(() => {
|
|
133
|
+
idleFired = true;
|
|
134
|
+
idleController.abort();
|
|
135
|
+
}, ms);
|
|
136
|
+
unrefTimer(idleTimer);
|
|
137
|
+
}
|
|
138
|
+
function clearTimers() {
|
|
139
|
+
if (idleTimer !== null) {
|
|
140
|
+
timers.clear(idleTimer);
|
|
141
|
+
idleTimer = null;
|
|
142
|
+
}
|
|
143
|
+
if (overallTimer !== null) {
|
|
144
|
+
timers.clear(overallTimer);
|
|
145
|
+
overallTimer = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const signal = AbortSignal.any(external
|
|
149
|
+
? [external, idleController.signal, overallController.signal]
|
|
150
|
+
: [idleController.signal, overallController.signal]);
|
|
151
|
+
// Armed here, not in `open()`, so the ceiling holds even for a host that
|
|
152
|
+
// never calls `open()`. See the module header.
|
|
153
|
+
overallTimer = timers.set(() => {
|
|
154
|
+
overallFired = true;
|
|
155
|
+
overallController.abort();
|
|
156
|
+
}, maxCallDurationMs);
|
|
157
|
+
unrefTimer(overallTimer);
|
|
158
|
+
return {
|
|
159
|
+
signal,
|
|
160
|
+
open() {
|
|
161
|
+
if (disposed)
|
|
162
|
+
return;
|
|
163
|
+
armIdle(timeToFirstTokenMs);
|
|
164
|
+
},
|
|
165
|
+
observedChunk(output) {
|
|
166
|
+
if (disposed)
|
|
167
|
+
return;
|
|
168
|
+
if (output !== "none")
|
|
169
|
+
sawOutput = true;
|
|
170
|
+
if (output === "answer")
|
|
171
|
+
sawAnswerOutput = true;
|
|
172
|
+
armIdle(sawAnswerOutput ? interChunkMs : timeToFirstTokenMs);
|
|
173
|
+
},
|
|
174
|
+
stall() {
|
|
175
|
+
// A caller abort wins over every budget: the stream may well have been
|
|
176
|
+
// idle when cancellation landed, but reporting that as an upstream stall
|
|
177
|
+
// would make a deliberate cancellation look retriable.
|
|
178
|
+
if (external?.aborted)
|
|
179
|
+
return null;
|
|
180
|
+
// Checked before the idle budget because the cap is the stronger claim:
|
|
181
|
+
// when both elapsed, the call ran past its absolute ceiling, and the
|
|
182
|
+
// trailing idle gap is a symptom of that rather than a separate fault.
|
|
183
|
+
if (overallFired) {
|
|
184
|
+
return { kind: "overall_cap", limitMs: maxCallDurationMs };
|
|
185
|
+
}
|
|
186
|
+
if (idleFired) {
|
|
187
|
+
if (sawAnswerOutput)
|
|
188
|
+
return { kind: "inter_chunk", limitMs: interChunkMs };
|
|
189
|
+
return {
|
|
190
|
+
kind: "time_to_first_token",
|
|
191
|
+
limitMs: timeToFirstTokenMs,
|
|
192
|
+
sawReasoning: sawOutput,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
},
|
|
197
|
+
dispose() {
|
|
198
|
+
disposed = true;
|
|
199
|
+
clearTimers();
|
|
200
|
+
// Drop any budget that elapsed. A timer that fired in the moment before
|
|
201
|
+
// the stream ended lost the race — it tore nothing down — and leaving the
|
|
202
|
+
// flag set would let it be blamed for a later, unrelated failure on the
|
|
203
|
+
// same call. See the `dispose` doc comment.
|
|
204
|
+
idleFired = false;
|
|
205
|
+
overallFired = false;
|
|
206
|
+
// Abort the idle controller (not the overall one) purely to settle the
|
|
207
|
+
// composite so it releases its listener on `external`.
|
|
208
|
+
idleController.abort();
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
* the harness that runs that chain.
|
|
7
7
|
*
|
|
8
8
|
* Current surface: the tool-calling turn kernel (`src/loop/` — the iteration
|
|
9
|
-
* engine itself), the deterministic LLM provider-routing core
|
|
10
|
-
* vocabulary, the run mechanics (deadline,
|
|
11
|
-
* classification, tool-batch pooling, child-run
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* engine itself), the deterministic LLM provider-routing core with its
|
|
10
|
+
* transport-error classifier, the turn vocabulary, the run mechanics (deadline,
|
|
11
|
+
* coalesced heartbeat, failure classification, tool-batch pooling, child-run
|
|
12
|
+
* lineage and admission), the streaming-completion watchdog and completion
|
|
13
|
+
* defect detection (`src/completion/`), transcript validation/healing, provider
|
|
14
|
+
* tool-schema sanitization, and the plugin/tool vocabulary with its registry and
|
|
15
|
+
* progressive-disclosure activation — generic over the host's invocation
|
|
16
|
+
* context. What is NOT here is
|
|
15
17
|
* the run driver: starting a run, recording what it did, and delivering its
|
|
16
18
|
* output. See the README for the rest of what is deliberately absent.
|
|
17
19
|
*/
|
|
18
20
|
export * from "./routing/index.js";
|
|
21
|
+
export * from "./completion/index.js";
|
|
19
22
|
export * from "./contracts/index.js";
|
|
20
23
|
export * from "./run/index.js";
|
|
21
24
|
export * from "./transcript/index.js";
|
package/index.js
CHANGED
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
* the harness that runs that chain.
|
|
7
7
|
*
|
|
8
8
|
* Current surface: the tool-calling turn kernel (`src/loop/` — the iteration
|
|
9
|
-
* engine itself), the deterministic LLM provider-routing core
|
|
10
|
-
* vocabulary, the run mechanics (deadline,
|
|
11
|
-
* classification, tool-batch pooling, child-run
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* engine itself), the deterministic LLM provider-routing core with its
|
|
10
|
+
* transport-error classifier, the turn vocabulary, the run mechanics (deadline,
|
|
11
|
+
* coalesced heartbeat, failure classification, tool-batch pooling, child-run
|
|
12
|
+
* lineage and admission), the streaming-completion watchdog and completion
|
|
13
|
+
* defect detection (`src/completion/`), transcript validation/healing, provider
|
|
14
|
+
* tool-schema sanitization, and the plugin/tool vocabulary with its registry and
|
|
15
|
+
* progressive-disclosure activation — generic over the host's invocation
|
|
16
|
+
* context. What is NOT here is
|
|
15
17
|
* the run driver: starting a run, recording what it did, and delivering its
|
|
16
18
|
* output. See the README for the rest of what is deliberately absent.
|
|
17
19
|
*/
|
|
18
20
|
export * from "./routing/index.js";
|
|
21
|
+
export * from "./completion/index.js";
|
|
19
22
|
export * from "./contracts/index.js";
|
|
20
23
|
export * from "./run/index.js";
|
|
21
24
|
export * from "./transcript/index.js";
|
package/loop/tool-loop.js
CHANGED
|
@@ -43,17 +43,29 @@ export async function runToolLoop(params) {
|
|
|
43
43
|
await ensureNotCancelled?.();
|
|
44
44
|
const tools = buildTools();
|
|
45
45
|
onStatus?.(iteration === 0 ? "thinking" : "thinking_with_tools");
|
|
46
|
-
// Stream live token progress: the per-call estimate is added to the
|
|
47
|
-
// from prior iterations so a caller's counter rises
|
|
48
|
-
// multi-iteration run. The real cumulative is published right after the
|
|
49
|
-
// returns (below), reconciling any estimate drift.
|
|
46
|
+
// Stream live token progress: the per-call estimate is added to the
|
|
47
|
+
// cumulative from prior iterations so a caller's counter rises across a
|
|
48
|
+
// multi-iteration run. The real cumulative is published right after the
|
|
49
|
+
// call returns (below), reconciling any estimate drift.
|
|
50
|
+
//
|
|
51
|
+
// Clamped to a per-call high-water mark because `callModel` may internally
|
|
52
|
+
// retry — a defect retry, another provider, the fallback model — and each
|
|
53
|
+
// attempt restarts its own estimate at zero. Unclamped, a caller's counter
|
|
54
|
+
// visibly runs backwards mid-turn ("1.2k tokens" → blank → "120 tokens"),
|
|
55
|
+
// which reads as lost work at exactly the moment the system is recovering
|
|
56
|
+
// from a fault. The mark is per-call, so the post-call reconciliation to
|
|
57
|
+
// the real total below is free to correct downward.
|
|
50
58
|
const baseOutputTokens = state.outputTokens;
|
|
59
|
+
let progressHighWater = baseOutputTokens;
|
|
51
60
|
const result = await callModel(state.messages, tools.length > 0 ? tools : undefined,
|
|
52
61
|
// Carry the cumulative tool count alongside the streamed token estimate so
|
|
53
62
|
// the pill shows both; no tools run *during* a model call, so the count is
|
|
54
63
|
// whatever has accumulated from prior iterations.
|
|
55
64
|
onProgressUpdate
|
|
56
|
-
? (estCallTokens) =>
|
|
65
|
+
? (estCallTokens) => {
|
|
66
|
+
progressHighWater = Math.max(progressHighWater, baseOutputTokens + estCallTokens);
|
|
67
|
+
onProgressUpdate(progressHighWater, state.toolCalls);
|
|
68
|
+
}
|
|
57
69
|
: undefined);
|
|
58
70
|
state.inputTokens += result.inputTokens;
|
|
59
71
|
state.outputTokens += result.outputTokens;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juno-ai/bind",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, and the plugin/tool vocabulary. MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
|
|
3
|
+
"version": "5.0.0",
|
|
4
|
+
"description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, and the plugin/tool vocabulary. MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./index.js",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"types": "./routing/index.d.ts",
|
|
16
16
|
"import": "./routing/index.js"
|
|
17
17
|
},
|
|
18
|
+
"./completion": {
|
|
19
|
+
"types": "./completion/index.d.ts",
|
|
20
|
+
"import": "./completion/index.js"
|
|
21
|
+
},
|
|
18
22
|
"./contracts": {
|
|
19
23
|
"types": "./contracts/index.d.ts",
|
|
20
24
|
"import": "./contracts/index.js"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification: a thrown transport error → the {@link InferenceAttemptError}
|
|
3
|
+
* taxonomy the executor routes on.
|
|
4
|
+
*
|
|
5
|
+
* `errors.ts` owns what the router *does* with a classified failure
|
|
6
|
+
* (`failureDisposition`) and `executor.ts` applies it. This module is the step
|
|
7
|
+
* before both — the one every host was writing itself. The PRD's split is
|
|
8
|
+
* "transports classify facts; the executor decides," and shipping only the
|
|
9
|
+
* decider left each consumer to hand-roll the classifier, where the mistakes
|
|
10
|
+
* are not obvious: misfile a moderation refusal as a credential failure and the
|
|
11
|
+
* circuit breaker opens the endpoint for everyone sharing that breaker key —
|
|
12
|
+
* which, for a host that does not set `credentialScope`, is every tenant on the
|
|
13
|
+
* process.
|
|
14
|
+
*
|
|
15
|
+
* The host keeps the two things that are genuinely its own — recognizing its
|
|
16
|
+
* error class ({@link AttemptClassification.asTransportFailure}) and any
|
|
17
|
+
* provider-specific status reading ({@link AttemptClassification.categorizeStatus}).
|
|
18
|
+
* Neither can live here: the first is a class this package must not import, and
|
|
19
|
+
* the second is provider-registry knowledge.
|
|
20
|
+
*/
|
|
21
|
+
import type { AttemptTarget, HttpFailureCategory, InferenceAttemptError } from "./errors.js";
|
|
22
|
+
import type { ProviderId } from "./canonical-model.js";
|
|
23
|
+
import type { CompletionDefectKind } from "../completion/defects.js";
|
|
24
|
+
/**
|
|
25
|
+
* What a transport observed, in the vocabulary the taxonomy needs. A host maps
|
|
26
|
+
* its own error class onto this once.
|
|
27
|
+
*
|
|
28
|
+
* `"api"` is the catch-all for a response the provider rejected with a status
|
|
29
|
+
* the transport did not interpret further; it is the only kind whose routing
|
|
30
|
+
* behavior depends on {@link TransportFailure.statusCode}.
|
|
31
|
+
*/
|
|
32
|
+
export type TransportFailureKind = CompletionDefectKind | "server_error" | "rate_limit" | "no_credits" | "api" | "network";
|
|
33
|
+
export interface TransportFailure {
|
|
34
|
+
readonly kind: TransportFailureKind;
|
|
35
|
+
/** HTTP status when the failure carried one; null for transport-level faults. */
|
|
36
|
+
readonly statusCode: number | null;
|
|
37
|
+
/** Parsed `Retry-After`, which bounds-extends the breaker cooldown. */
|
|
38
|
+
readonly retryAfterMs: number | null;
|
|
39
|
+
}
|
|
40
|
+
export interface AttemptClassification {
|
|
41
|
+
/**
|
|
42
|
+
* Recognize the host's own transport-error class and describe it. Return
|
|
43
|
+
* `null` for anything that is not one — those propagate as a client error
|
|
44
|
+
* rather than burning the plan (see {@link classifyAttemptError}).
|
|
45
|
+
*/
|
|
46
|
+
readonly asTransportFailure: (error: unknown) => TransportFailure | null;
|
|
47
|
+
/**
|
|
48
|
+
* Provider-specific status → category, consulted before the neutral
|
|
49
|
+
* {@link categorizeHttpStatus}. Return `null` to fall through to it.
|
|
50
|
+
*
|
|
51
|
+
* This exists because status codes are not portable across providers: one
|
|
52
|
+
* gateway answers 403 for moderation-flagged *input* (request-shaped — a
|
|
53
|
+
* different provider may accept it, and the endpoint is healthy), where the
|
|
54
|
+
* neutral mapping reads 403 as a credential failure and opens the circuit
|
|
55
|
+
* immediately.
|
|
56
|
+
*/
|
|
57
|
+
readonly categorizeStatus?: (statusCode: number, providerId: ProviderId) => HttpFailureCategory | null;
|
|
58
|
+
/**
|
|
59
|
+
* Recognize a caller-cancellation. Defaults to {@link isAbortByName} —
|
|
60
|
+
* `error.name === "AbortError"`, which is what `AbortSignal` and `fetch`
|
|
61
|
+
* produce.
|
|
62
|
+
*
|
|
63
|
+
* **Override this if your SDK wraps aborts in its own class.** The one that
|
|
64
|
+
* bites: `openai`'s `APIUserAbortError` extends its `APIError` and never sets
|
|
65
|
+
* `name`, so `error.name` is the inherited `"Error"` — it matches neither the
|
|
66
|
+
* default nor an `instanceof` check you didn't write. Left unrecognized, a
|
|
67
|
+
* deliberate cancellation classifies as a propagating `client_error`: it
|
|
68
|
+
* still stops the plan, but it is attributed as a fault rather than a
|
|
69
|
+
* cancellation, which pollutes failure telemetry and any breaker or retry
|
|
70
|
+
* accounting keyed off it.
|
|
71
|
+
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* isAbort: (e) => e instanceof OpenAI.APIUserAbortError || isAbortByName(e),
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
readonly isAbort?: (error: unknown) => boolean;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The default abort test: the `name` an `AbortSignal`-driven `fetch` rejection
|
|
80
|
+
* carries. Exported so a host overriding {@link AttemptClassification.isAbort}
|
|
81
|
+
* can widen it rather than replace it.
|
|
82
|
+
*/
|
|
83
|
+
export declare function isAbortByName(error: unknown): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Map a thrown error onto the attempt-error taxonomy. The original error rides
|
|
86
|
+
* in `cause` and is re-thrown verbatim if the plan exhausts, so a host's error
|
|
87
|
+
* classes and messages survive routing untouched.
|
|
88
|
+
*
|
|
89
|
+
* Order is deliberate. An abort is checked first: a cancelled call must never
|
|
90
|
+
* be reclassified as a provider fault, whatever else is true of it. Anything
|
|
91
|
+
* the host does not recognize as a transport failure is classified
|
|
92
|
+
* `client_error` — which propagates rather than traverses — because an error
|
|
93
|
+
* that escaped the transport without becoming one of its own is a programming
|
|
94
|
+
* defect (a `TypeError`, a validation throw), and burning every provider and
|
|
95
|
+
* the fallback model retrying a bug wastes a whole plan to arrive at the same
|
|
96
|
+
* exception.
|
|
97
|
+
*/
|
|
98
|
+
export declare function classifyAttemptError(error: unknown, target: AttemptTarget, classification: AttemptClassification): InferenceAttemptError;
|
|
99
|
+
/**
|
|
100
|
+
* Parse a `Retry-After` response header into milliseconds, per RFC 9110: either
|
|
101
|
+
* delta-seconds or an HTTP-date. Returns `null` when absent or unparseable —
|
|
102
|
+
* the breaker then falls back to its own cooldown, so a header this cannot read
|
|
103
|
+
* degrades to the default rather than to no cooldown at all.
|
|
104
|
+
*
|
|
105
|
+
* **This value is attacker-influenceable and is not bounded here.** It comes
|
|
106
|
+
* from whatever answered the request — the provider, a gateway, a proxy — and
|
|
107
|
+
* RFC 9110 puts no ceiling on it, so a hostile or malfunctioning upstream can
|
|
108
|
+
* ask for a delay of years. Clamp before using it as a delay:
|
|
109
|
+
* {@link createCircuitBreaker} already does (`maxCooldownMs`), but a host that
|
|
110
|
+
* sleeps on this directly must impose its own bound, or one bad response header
|
|
111
|
+
* parks an endpoint indefinitely.
|
|
112
|
+
*
|
|
113
|
+
* `headers` is deliberately `unknown`: SDKs hand back a `Headers`, a plain
|
|
114
|
+
* object, or a `Map` depending on version and runtime. Anything with a `get`
|
|
115
|
+
* method is asked for the header (covering `Headers` and `Map` without naming
|
|
116
|
+
* either global, which keeps this portable to runtimes that ship neither);
|
|
117
|
+
* a plain object is read case-insensitively for the two spellings that occur
|
|
118
|
+
* in practice.
|
|
119
|
+
*
|
|
120
|
+
* @param now Injectable clock for the HTTP-date branch; defaults to `Date.now`.
|
|
121
|
+
*/
|
|
122
|
+
export declare function retryAfterMsFromHeaders(headers: unknown, now?: () => number): number | null;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification: a thrown transport error → the {@link InferenceAttemptError}
|
|
3
|
+
* taxonomy the executor routes on.
|
|
4
|
+
*
|
|
5
|
+
* `errors.ts` owns what the router *does* with a classified failure
|
|
6
|
+
* (`failureDisposition`) and `executor.ts` applies it. This module is the step
|
|
7
|
+
* before both — the one every host was writing itself. The PRD's split is
|
|
8
|
+
* "transports classify facts; the executor decides," and shipping only the
|
|
9
|
+
* decider left each consumer to hand-roll the classifier, where the mistakes
|
|
10
|
+
* are not obvious: misfile a moderation refusal as a credential failure and the
|
|
11
|
+
* circuit breaker opens the endpoint for everyone sharing that breaker key —
|
|
12
|
+
* which, for a host that does not set `credentialScope`, is every tenant on the
|
|
13
|
+
* process.
|
|
14
|
+
*
|
|
15
|
+
* The host keeps the two things that are genuinely its own — recognizing its
|
|
16
|
+
* error class ({@link AttemptClassification.asTransportFailure}) and any
|
|
17
|
+
* provider-specific status reading ({@link AttemptClassification.categorizeStatus}).
|
|
18
|
+
* Neither can live here: the first is a class this package must not import, and
|
|
19
|
+
* the second is provider-registry knowledge.
|
|
20
|
+
*/
|
|
21
|
+
import { categorizeHttpStatus } from "./errors.js";
|
|
22
|
+
/**
|
|
23
|
+
* The default abort test: the `name` an `AbortSignal`-driven `fetch` rejection
|
|
24
|
+
* carries. Exported so a host overriding {@link AttemptClassification.isAbort}
|
|
25
|
+
* can widen it rather than replace it.
|
|
26
|
+
*/
|
|
27
|
+
export function isAbortByName(error) {
|
|
28
|
+
return error instanceof Error && error.name === "AbortError";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Map a thrown error onto the attempt-error taxonomy. The original error rides
|
|
32
|
+
* in `cause` and is re-thrown verbatim if the plan exhausts, so a host's error
|
|
33
|
+
* classes and messages survive routing untouched.
|
|
34
|
+
*
|
|
35
|
+
* Order is deliberate. An abort is checked first: a cancelled call must never
|
|
36
|
+
* be reclassified as a provider fault, whatever else is true of it. Anything
|
|
37
|
+
* the host does not recognize as a transport failure is classified
|
|
38
|
+
* `client_error` — which propagates rather than traverses — because an error
|
|
39
|
+
* that escaped the transport without becoming one of its own is a programming
|
|
40
|
+
* defect (a `TypeError`, a validation throw), and burning every provider and
|
|
41
|
+
* the fallback model retrying a bug wastes a whole plan to arrive at the same
|
|
42
|
+
* exception.
|
|
43
|
+
*/
|
|
44
|
+
export function classifyAttemptError(error, target, classification) {
|
|
45
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
46
|
+
const isAbort = classification.isAbort ?? isAbortByName;
|
|
47
|
+
if (isAbort(error)) {
|
|
48
|
+
return { kind: "aborted", target, cause };
|
|
49
|
+
}
|
|
50
|
+
const failure = classification.asTransportFailure(error);
|
|
51
|
+
if (failure === null) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "http",
|
|
54
|
+
category: "client_error",
|
|
55
|
+
statusCode: 0,
|
|
56
|
+
retryAfterMs: null,
|
|
57
|
+
target,
|
|
58
|
+
cause,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
switch (failure.kind) {
|
|
62
|
+
case "empty_completion":
|
|
63
|
+
case "truncated_tool_call":
|
|
64
|
+
return { kind: "completion_defect", defect: failure.kind, target, cause };
|
|
65
|
+
case "network":
|
|
66
|
+
return { kind: "network", target, cause };
|
|
67
|
+
case "rate_limit":
|
|
68
|
+
return httpError("rate_limit", failure.statusCode ?? 429, failure, target, cause);
|
|
69
|
+
case "no_credits":
|
|
70
|
+
return httpError("credits", failure.statusCode ?? 402, failure, target, cause);
|
|
71
|
+
case "server_error":
|
|
72
|
+
return httpError("server_error", failure.statusCode ?? 500, failure, target, cause);
|
|
73
|
+
case "api": {
|
|
74
|
+
const statusCode = failure.statusCode;
|
|
75
|
+
// No status at all means the transport could not attribute the rejection
|
|
76
|
+
// to the endpoint; propagate rather than traverse.
|
|
77
|
+
const category = statusCode === null
|
|
78
|
+
? "client_error"
|
|
79
|
+
: (classification.categorizeStatus?.(statusCode, target.providerId) ??
|
|
80
|
+
categorizeHttpStatus(statusCode));
|
|
81
|
+
return httpError(category, statusCode ?? 0, failure, target, cause);
|
|
82
|
+
}
|
|
83
|
+
default: {
|
|
84
|
+
const _exhaustive = failure.kind;
|
|
85
|
+
throw new Error(`unknown transport failure kind: ${JSON.stringify(_exhaustive)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function httpError(category, statusCode, failure, target, cause) {
|
|
90
|
+
return {
|
|
91
|
+
kind: "http",
|
|
92
|
+
category,
|
|
93
|
+
statusCode,
|
|
94
|
+
retryAfterMs: failure.retryAfterMs,
|
|
95
|
+
target,
|
|
96
|
+
cause,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Parse a `Retry-After` response header into milliseconds, per RFC 9110: either
|
|
101
|
+
* delta-seconds or an HTTP-date. Returns `null` when absent or unparseable —
|
|
102
|
+
* the breaker then falls back to its own cooldown, so a header this cannot read
|
|
103
|
+
* degrades to the default rather than to no cooldown at all.
|
|
104
|
+
*
|
|
105
|
+
* **This value is attacker-influenceable and is not bounded here.** It comes
|
|
106
|
+
* from whatever answered the request — the provider, a gateway, a proxy — and
|
|
107
|
+
* RFC 9110 puts no ceiling on it, so a hostile or malfunctioning upstream can
|
|
108
|
+
* ask for a delay of years. Clamp before using it as a delay:
|
|
109
|
+
* {@link createCircuitBreaker} already does (`maxCooldownMs`), but a host that
|
|
110
|
+
* sleeps on this directly must impose its own bound, or one bad response header
|
|
111
|
+
* parks an endpoint indefinitely.
|
|
112
|
+
*
|
|
113
|
+
* `headers` is deliberately `unknown`: SDKs hand back a `Headers`, a plain
|
|
114
|
+
* object, or a `Map` depending on version and runtime. Anything with a `get`
|
|
115
|
+
* method is asked for the header (covering `Headers` and `Map` without naming
|
|
116
|
+
* either global, which keeps this portable to runtimes that ship neither);
|
|
117
|
+
* a plain object is read case-insensitively for the two spellings that occur
|
|
118
|
+
* in practice.
|
|
119
|
+
*
|
|
120
|
+
* @param now Injectable clock for the HTTP-date branch; defaults to `Date.now`.
|
|
121
|
+
*/
|
|
122
|
+
export function retryAfterMsFromHeaders(headers, now = Date.now) {
|
|
123
|
+
const raw = rawRetryAfter(headers);
|
|
124
|
+
if (raw === null || raw === "")
|
|
125
|
+
return null;
|
|
126
|
+
const seconds = Number(raw);
|
|
127
|
+
if (Number.isFinite(seconds)) {
|
|
128
|
+
// A negative delta is malformed, and the date branch below would not
|
|
129
|
+
// reliably reject a bare "-5" either. Reject here instead.
|
|
130
|
+
if (seconds < 0)
|
|
131
|
+
return null;
|
|
132
|
+
// `1e308` is finite but overflows to Infinity once scaled to ms, and a
|
|
133
|
+
// non-finite delay is the one value that makes a timer fire immediately
|
|
134
|
+
// rather than never — the same trap `requirePositiveMs` guards in the
|
|
135
|
+
// watchdog. Refuse it rather than hand a caller a poisoned number.
|
|
136
|
+
const ms = Math.round(seconds * 1000);
|
|
137
|
+
return Number.isFinite(ms) ? ms : null;
|
|
138
|
+
}
|
|
139
|
+
const dateMs = Date.parse(raw);
|
|
140
|
+
if (Number.isFinite(dateMs))
|
|
141
|
+
return Math.max(0, dateMs - now());
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
function rawRetryAfter(headers) {
|
|
145
|
+
if (typeof headers !== "object" || headers === null)
|
|
146
|
+
return null;
|
|
147
|
+
const getter = headers.get;
|
|
148
|
+
if (typeof getter === "function") {
|
|
149
|
+
// The getter belongs to the caller's object, so it is arbitrary code. This
|
|
150
|
+
// runs inside a host's `catch` while classifying a failure; letting a throw
|
|
151
|
+
// escape would replace the real provider error with this one and lose the
|
|
152
|
+
// classification entirely.
|
|
153
|
+
let value;
|
|
154
|
+
try {
|
|
155
|
+
value = getter.call(headers, "retry-after");
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (typeof value === "string")
|
|
161
|
+
return value;
|
|
162
|
+
// A `Map<string, number>` is as plausible as a `Map<string, string>`; the
|
|
163
|
+
// plain-object branch below accepts a number, so this one must too or the
|
|
164
|
+
// same header is read from one container and dropped from the other.
|
|
165
|
+
if (typeof value === "number")
|
|
166
|
+
return String(value);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const record = headers;
|
|
170
|
+
const value = record["retry-after"] ?? record["Retry-After"];
|
|
171
|
+
if (typeof value === "string")
|
|
172
|
+
return value;
|
|
173
|
+
if (typeof value === "number")
|
|
174
|
+
return String(value);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
package/routing/executor.js
CHANGED
|
@@ -42,7 +42,22 @@ export async function executeRoutePlan(options) {
|
|
|
42
42
|
endpointAttempt,
|
|
43
43
|
});
|
|
44
44
|
attemptCount += 1;
|
|
45
|
-
|
|
45
|
+
// `attempt` is host code and is expected to resolve to an outcome, not
|
|
46
|
+
// throw — but if it does throw (a bug in its own classification, a
|
|
47
|
+
// host-supplied port raising), the throw must not carry a half-open
|
|
48
|
+
// probe out of the loop with it. The probe slot would stay occupied for
|
|
49
|
+
// the process's lifetime and every later call for this endpoint would
|
|
50
|
+
// be refused admission, silently, with no failure recorded to ever
|
|
51
|
+
// reopen it.
|
|
52
|
+
let outcome;
|
|
53
|
+
try {
|
|
54
|
+
outcome = await options.attempt(candidate, cursor);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (halfOpenProbe)
|
|
58
|
+
breaker?.releaseProbe(breakerKey);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
46
61
|
if (outcome.kind === "success") {
|
|
47
62
|
breaker?.recordSuccess(breakerKey);
|
|
48
63
|
return {
|
package/routing/index.d.ts
CHANGED
|
@@ -7,5 +7,6 @@ export { buildRoutePlanWithConfigDegradation, type DegradedStage, type DegradedP
|
|
|
7
7
|
export { computeConfiguredRatesCostCents, type BillingBasisUsage, type BillingBasisResult, } from "./billing-basis.js";
|
|
8
8
|
export { fallbackKindOfCursor } from "./executor.js";
|
|
9
9
|
export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, type RouteAttemptCursor, type AttemptTarget, type HttpFailureCategory, type InferenceAttemptError, type FailureDisposition, type BreakerEffect, } from "./errors.js";
|
|
10
|
+
export { classifyAttemptError, retryAfterMsFromHeaders, isAbortByName, type AttemptClassification, type TransportFailure, type TransportFailureKind, } from "./attempt-errors.js";
|
|
10
11
|
export { createCircuitBreaker, type BreakerKey, type CircuitBreakerOptions, type EndpointAdmission, type RecordFailureOptions, type RouteCircuitBreaker, } from "./circuit-breaker.js";
|
|
11
12
|
export { executeRoutePlan, type AttemptOutcome, type AttemptFn, type FallbackKind, type ExecutePlanOptions, type RouteExecutionResult, } from "./executor.js";
|
package/routing/index.js
CHANGED
|
@@ -7,5 +7,6 @@ export { buildRoutePlanWithConfigDegradation, } from "./plan-degradation.js";
|
|
|
7
7
|
export { computeConfiguredRatesCostCents, } from "./billing-basis.js";
|
|
8
8
|
export { fallbackKindOfCursor } from "./executor.js";
|
|
9
9
|
export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, } from "./errors.js";
|
|
10
|
+
export { classifyAttemptError, retryAfterMsFromHeaders, isAbortByName, } from "./attempt-errors.js";
|
|
10
11
|
export { createCircuitBreaker, } from "./circuit-breaker.js";
|
|
11
12
|
export { executeRoutePlan, } from "./executor.js";
|