@juno-ai/bind 4.0.0 → 6.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 +182 -25
- package/completion/defects.d.ts +138 -0
- package/completion/defects.js +130 -0
- package/completion/index.d.ts +3 -0
- package/completion/index.js +3 -0
- package/completion/tool-calls.d.ts +90 -0
- package/completion/tool-calls.js +67 -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/run/children.d.ts +55 -3
- package/run/children.js +43 -10
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,126 @@ 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 read the arguments of a tool call the model asked for
|
|
432
|
+
|
|
433
|
+
`JSON.parse(toolCall.function.arguments)` is the obvious implementation and it
|
|
434
|
+
is wrong for the commonest tool there is. Providers send `""` for a
|
|
435
|
+
**zero-argument** call as readily as `"{}"`, so the obvious version kills a
|
|
436
|
+
perfectly good call as a JSON syntax error and burns a recovery turn on a turn
|
|
437
|
+
that was never broken.
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import { parseToolCallArguments } from "@juno-ai/bind/completion";
|
|
441
|
+
|
|
442
|
+
const read = parseToolCallArguments(toolCall);
|
|
443
|
+
switch (read.kind) {
|
|
444
|
+
case "parsed":
|
|
445
|
+
return dispatch(toolCall.function.name, read.arguments);
|
|
446
|
+
case "unsupported_type":
|
|
447
|
+
return toolMessage(toolCall.id, `Unsupported tool call type: ${read.type}`);
|
|
448
|
+
case "unparseable":
|
|
449
|
+
// Put `detail` in front of the MODEL, not only in a log — its next turn is
|
|
450
|
+
// the only thing that can correct the arguments.
|
|
451
|
+
return toolMessage(toolCall.id, `Invalid tool arguments: ${read.detail}`);
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Every outcome is a value, not a throw, because every outcome has to end with a
|
|
456
|
+
`tool` message carrying this call's id — a transcript where an assistant asked
|
|
457
|
+
for a tool and nothing answered it is rejected by the provider on the *next*
|
|
458
|
+
request, so "give up on this call" was never an option.
|
|
459
|
+
|
|
460
|
+
Valid JSON that is not an object — `null`, `[]`, `42` — is refused rather than
|
|
461
|
+
dispatched. Tool arguments are a named parameter bag by definition, and handing
|
|
462
|
+
a tool an array where it expects fields turns a clear failure here into a
|
|
463
|
+
confusing one inside the tool, after any side effect it performs before its own
|
|
464
|
+
validation.
|
|
465
|
+
|
|
466
|
+
### How to map your transport errors onto the routing taxonomy
|
|
467
|
+
|
|
468
|
+
`failureDisposition` decides what the router does with a classified failure, but
|
|
469
|
+
something has to produce the classification. Give `classifyAttemptError` two
|
|
470
|
+
ports — one that recognizes your error class, one that overrides the neutral
|
|
471
|
+
HTTP mapping where your provider disagrees with it — and it does the rest.
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
import { classifyAttemptError, type AttemptClassification } from "@juno-ai/bind/routing";
|
|
475
|
+
|
|
476
|
+
const CLASSIFICATION: AttemptClassification = {
|
|
477
|
+
asTransportFailure: (error) =>
|
|
478
|
+
error instanceof MyLLMError
|
|
479
|
+
? { kind: error.kind, statusCode: error.statusCode, retryAfterMs: error.retryAfterMs }
|
|
480
|
+
: null,
|
|
481
|
+
// This gateway answers 403 for moderation-flagged INPUT. The neutral mapping
|
|
482
|
+
// reads 403 as a credential failure and opens the circuit immediately —
|
|
483
|
+
// degrading a healthy shared endpoint for every tenant over one prompt.
|
|
484
|
+
categorizeStatus: (status, providerId) =>
|
|
485
|
+
status === 403 && providerId === MY_GATEWAY ? "provider_bad_request" : null,
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// `target` is the `AttemptTarget` you assemble in your `attempt` callback from
|
|
489
|
+
// the candidate and cursor it was handed — see the tutorial's step 4.
|
|
490
|
+
const attemptError = classifyAttemptError(error, target, CLASSIFICATION);
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
An `AbortError` is classified first, whatever else is true of it. Anything
|
|
494
|
+
`asTransportFailure` does not recognize becomes a propagating `client_error`: an
|
|
495
|
+
error that escaped your transport without becoming one of its own is a bug, and
|
|
496
|
+
retrying it against every provider and your fallback model arrives at the same
|
|
497
|
+
exception having spent a whole plan. `retryAfterMsFromHeaders` lives alongside
|
|
498
|
+
it and feeds the `retryAfterMs` the breaker uses to extend a cooldown — clamp
|
|
499
|
+
it before using it as a delay anywhere else, since it is a value the upstream
|
|
500
|
+
chose and RFC 9110 puts no ceiling on it.
|
|
501
|
+
|
|
382
502
|
### How to stop a run's progress writes from stampeding
|
|
383
503
|
|
|
384
504
|
```ts
|
|
@@ -522,7 +642,8 @@ keeps a consumer who only wants routing from pulling in the rest.
|
|
|
522
642
|
|
|
523
643
|
| Import | Owns | Reach for it when |
|
|
524
644
|
|---|---|---|
|
|
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 |
|
|
645
|
+
| `@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 |
|
|
646
|
+
| `@juno-ai/bind/completion` | Streaming idle watchdog (time-to-first-token, inter-chunk, absolute cap), completion-defect detection, the tool-call argument read (`parseToolCallArguments`), and the structured-output retry predicates | You read a streamed completion |
|
|
526
647
|
| `@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
648
|
| `@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
649
|
| `@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 |
|
|
@@ -564,6 +685,22 @@ its runs are on the queue is not bounded, it is billed. A rule whose measurement
|
|
|
564
685
|
is `NaN` or `Infinity` refuses rather than admits — every comparison is false
|
|
565
686
|
against `NaN`, so the naive reading of a broken count is an unbounded chain.
|
|
566
687
|
|
|
688
|
+
**Run — "could not measure" is an omission, never a sentinel.** The refusal
|
|
689
|
+
above is for a count you *did* supply and that came back broken. A bound you
|
|
690
|
+
could not measure at all — the query threw, the counter was unreachable — is
|
|
691
|
+
expressed by leaving that rule out of the array, which admits. The two are
|
|
692
|
+
opposite answers to opposite questions, and passing `NaN` for "unknown" turns a
|
|
693
|
+
transient database blip into every agent in your system refusing to run. Which
|
|
694
|
+
of the two a given failure is is yours to decide; the harness only judges what
|
|
695
|
+
it was handed.
|
|
696
|
+
|
|
697
|
+
**Completion — the zero-argument rule has two halves and they must agree.**
|
|
698
|
+
`detectCompletionDefect` decides a tool call with *empty* arguments is
|
|
699
|
+
legitimate and passes it through; `parseToolCallArguments` is what then reads
|
|
700
|
+
it as the zero-argument call it is. Both read one `toolCallArgumentsAbsent`, so
|
|
701
|
+
adopting only the detection half means independently reinventing the matching
|
|
702
|
+
parse — and getting it wrong the way everyone does, with a bare `JSON.parse`.
|
|
703
|
+
|
|
567
704
|
**Run — chain lineage is "null means me".** A root run's `rootRunId` and
|
|
568
705
|
`parentRunId` are both `null`, because a chain's origin has no id to point at
|
|
569
706
|
until its own row exists. Read any chain's root as `chain.rootRunId ?? runId`;
|
|
@@ -785,7 +922,15 @@ transport reports **facts**, the executor decides order.
|
|
|
785
922
|
// else if (status === 401) throw;
|
|
786
923
|
// else tryNextModel();
|
|
787
924
|
|
|
788
|
-
// After: the transport only classifies
|
|
925
|
+
// After: the transport only classifies — and `classifyAttemptError` does that
|
|
926
|
+
// for you, so the only thing you write is how to recognize your own error class.
|
|
927
|
+
const CLASSIFICATION: AttemptClassification = {
|
|
928
|
+
asTransportFailure: (error) =>
|
|
929
|
+
error instanceof MyLLMError
|
|
930
|
+
? { kind: error.kind, statusCode: error.statusCode, retryAfterMs: error.retryAfterMs }
|
|
931
|
+
: null,
|
|
932
|
+
};
|
|
933
|
+
|
|
789
934
|
const attempt: AttemptFn<Completion> = async (candidate, cursor) => {
|
|
790
935
|
const startedAt = performance.now();
|
|
791
936
|
try {
|
|
@@ -798,27 +943,23 @@ const attempt: AttemptFn<Completion> = async (candidate, cursor) => {
|
|
|
798
943
|
providerInvocationModel: candidate.providerInvocationModel,
|
|
799
944
|
durationMs: Math.round(performance.now() - startedAt),
|
|
800
945
|
};
|
|
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
|
-
};
|
|
946
|
+
return { kind: "failure", error: classifyAttemptError(cause, target, CLASSIFICATION) };
|
|
814
947
|
}
|
|
815
948
|
};
|
|
816
949
|
```
|
|
817
950
|
|
|
951
|
+
Do not hand-roll that `catch` from the neutral pieces — mapping statuses
|
|
952
|
+
yourself is where the consequential mistakes live. The neutral mapping reads
|
|
953
|
+
**403 as a credential failure**, which opens the breaker immediately; if your
|
|
954
|
+
gateway also answers 403 for a moderation-flagged *prompt*, that one tenant's
|
|
955
|
+
request takes a healthy endpoint out of rotation for everyone sharing the
|
|
956
|
+
breaker key. Supply a `categorizeStatus` port instead — see
|
|
957
|
+
[How to map your transport errors onto the routing taxonomy](#how-to-map-your-transport-errors-onto-the-routing-taxonomy).
|
|
958
|
+
|
|
818
959
|
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.
|
|
960
|
+
rejection (a 400, a correctly-classified moderation refusal) traverses to
|
|
961
|
+
another provider **without** opening the breaker, and an empty or truncated
|
|
962
|
+
completion is retried on the same endpoint before any fallback.
|
|
822
963
|
|
|
823
964
|
### Adapting a non-SDK client to `TurnFn`
|
|
824
965
|
|
|
@@ -1034,13 +1175,15 @@ import { admitChildRun, descendChain, type ChainRule } from "@juno-ai/bind/run";
|
|
|
1034
1175
|
const rules: ChainRule[] = [
|
|
1035
1176
|
{ kind: "depth", parentDepth: chain.depth, maxDepth: 5 },
|
|
1036
1177
|
{ kind: "chain_budget", runsInChain: await countRunsInChain(chain), maxRuns: 50 },
|
|
1178
|
+
{ kind: "pair_seen", alreadyPaired: await hasPairedInChain(chain, targetId) },
|
|
1037
1179
|
{ kind: "pair_cooldown", msSinceLastSpawn: await msSinceLastSpawn(runId), cooldownMs: 30_000 },
|
|
1180
|
+
{ kind: "tenant_rate", runsInWindow: await countRecentRuns(tenantId), maxRuns: 100, windowMs: 30_000 },
|
|
1038
1181
|
{ kind: "tenant_ceiling", activeRuns: await countActiveRuns(tenantId), maxActiveRuns: 200 },
|
|
1039
1182
|
];
|
|
1040
1183
|
|
|
1041
1184
|
const admission = admitChildRun(rules);
|
|
1042
1185
|
if (!admission.admitted) {
|
|
1043
|
-
log.warn("child run refused", { rule: admission.rule });
|
|
1186
|
+
log.warn("child run refused", { rule: admission.rule, retryable: admission.retryable });
|
|
1044
1187
|
return { success: false, kind: "validation", error: admission.reason };
|
|
1045
1188
|
}
|
|
1046
1189
|
```
|
|
@@ -1048,11 +1191,25 @@ if (!admission.admitted) {
|
|
|
1048
1191
|
Rules are evaluated in order and the first refusal wins, so you choose which
|
|
1049
1192
|
reason the model sees. Pick the set against your own cost model: depth caps
|
|
1050
1193
|
runaway recursion, a chain budget caps a chain that stays shallow but keeps
|
|
1051
|
-
fanning out,
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1194
|
+
fanning out, `pair_seen` allows a given pair to work together once per chain
|
|
1195
|
+
and a `pair_cooldown` merely spaces them out, a tenant rate limit bounds a burst
|
|
1196
|
+
over a rolling window, and a tenant ceiling bounds what is running *right now* —
|
|
1197
|
+
the last two covering what no chain rule can, someone starting a thousand
|
|
1198
|
+
independent chains.
|
|
1199
|
+
|
|
1200
|
+
A refusal carries `rule` (which bound fired) and `retryable`, which separates a
|
|
1201
|
+
bound that clears on its own — a cooldown, a rate window rolling — from one that
|
|
1202
|
+
never will, so a caller can choose between waiting and giving up. Only a
|
|
1203
|
+
refusal carries them; `retryable === undefined` means there was nothing to
|
|
1204
|
+
retry, not "not retryable".
|
|
1205
|
+
|
|
1206
|
+
Two failure modes get opposite treatment, and the difference is the part to get
|
|
1207
|
+
right. A **broken measurement** (`NaN`, `Infinity`, a negative count) refuses:
|
|
1208
|
+
every comparison is false against `NaN`, so the naive reading would turn a
|
|
1209
|
+
broken count into an unbounded chain. A bound you **could not measure at all**
|
|
1210
|
+
— the count query threw — is expressed by *omitting the rule*, which admits.
|
|
1211
|
+
Reaching for `NaN` to mean "unknown" collapses the two and makes a transient
|
|
1212
|
+
database blip refuse every child run you have.
|
|
1056
1213
|
|
|
1057
1214
|
`descendChain` handles the lineage arithmetic, including the root-id fallback
|
|
1058
1215
|
that is easy to get backwards — a first-generation child adopts its parent's
|
|
@@ -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,130 @@
|
|
|
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
|
+
import { toolCallArgumentsAbsent } from "./tool-calls.js";
|
|
22
|
+
/**
|
|
23
|
+
* Additional attempts when a structured-output call returns content that will
|
|
24
|
+
* not parse as JSON: the initial call plus this many retries. Providers
|
|
25
|
+
* occasionally truncate or malform JSON even under a strict schema, and a fresh
|
|
26
|
+
* attempt almost always comes back valid.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES = 3;
|
|
29
|
+
/**
|
|
30
|
+
* Same-endpoint retry budget for a transiently-corrupt completion. A fresh
|
|
31
|
+
* attempt on the SAME endpoint usually recovers an intact response; a
|
|
32
|
+
* persistently-broken endpoint exhausts these, and the route executor then
|
|
33
|
+
* traverses to the next provider or model.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_COMPLETION_DEFECT_MAX_RETRIES = 2;
|
|
36
|
+
/** True when `s` parses as JSON. */
|
|
37
|
+
export function jsonParses(s) {
|
|
38
|
+
try {
|
|
39
|
+
JSON.parse(s);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Find the transient defect in an assembled completion, or `null` when it is
|
|
48
|
+
* usable.
|
|
49
|
+
*
|
|
50
|
+
* **Empty completion.** No content, no tool calls, no refusal. A refusal alone
|
|
51
|
+
* is a legitimate output and is not a defect.
|
|
52
|
+
*
|
|
53
|
+
* **Truncated tool call.** Non-empty arguments that do not parse as JSON are
|
|
54
|
+
* always a truncation — the provider cut the stream mid-arguments. *Empty*
|
|
55
|
+
* arguments are the normal zero-arg shape and are not a defect **unless** the
|
|
56
|
+
* stream was `cutByTokenLimit`: a length-truncated empty-args call is an
|
|
57
|
+
* incomplete message, not a deliberate no-arg call, and exempting it would let
|
|
58
|
+
* a dispatcher execute a tool off a half-streamed turn.
|
|
59
|
+
*
|
|
60
|
+
* `cutByTokenLimit` is the *fact*, not the wire spelling of it — OpenAI says
|
|
61
|
+
* `finish_reason: "length"`, Anthropic says `max_tokens`, Gemini says
|
|
62
|
+
* `MAX_TOKENS`. Taking the fact keeps the one behavioural rule in this module
|
|
63
|
+
* from silently reading `false` for every host that isn't on an
|
|
64
|
+
* OpenAI-compatible gateway, which would apply the zero-arg exemption to
|
|
65
|
+
* exactly the truncated calls it exists to exclude.
|
|
66
|
+
*
|
|
67
|
+
* A tool call whose `arguments` are not a readable string is skipped rather
|
|
68
|
+
* than inspected: {@link CompletionOutputs.tool_calls} accepts a host's full
|
|
69
|
+
* SDK union, and a non-function member (OpenAI's `type: "custom"`) has no
|
|
70
|
+
* arguments to truncate.
|
|
71
|
+
*/
|
|
72
|
+
export function detectCompletionDefect(completion, cutByTokenLimit) {
|
|
73
|
+
const toolCalls = completion.tool_calls;
|
|
74
|
+
const hasToolCalls = toolCalls !== undefined && toolCalls.length > 0;
|
|
75
|
+
if (!completion.content && !hasToolCalls && !completion.refusal) {
|
|
76
|
+
return { kind: "empty_completion" };
|
|
77
|
+
}
|
|
78
|
+
if (hasToolCalls) {
|
|
79
|
+
const truncated = toolCalls.find((toolCall) => {
|
|
80
|
+
const args = toolCall?.function?.arguments;
|
|
81
|
+
if (typeof args !== "string")
|
|
82
|
+
return false;
|
|
83
|
+
// The same predicate the dispatcher reads, so the two halves of the
|
|
84
|
+
// zero-argument rule cannot drift — see `tool-calls.ts`.
|
|
85
|
+
if (toolCallArgumentsAbsent(args))
|
|
86
|
+
return cutByTokenLimit;
|
|
87
|
+
return !jsonParses(args.trim());
|
|
88
|
+
});
|
|
89
|
+
if (truncated !== undefined) {
|
|
90
|
+
return { kind: "truncated_tool_call", toolCall: truncated };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* True when `response_format` asks the model for JSON. Free-form output
|
|
97
|
+
* (`text`, or omitted) is never retried on a parse failure — there is nothing
|
|
98
|
+
* to parse, so every completion would look like a defect.
|
|
99
|
+
*/
|
|
100
|
+
export function expectsJsonOutput(responseFormat) {
|
|
101
|
+
return (responseFormat?.type === "json_schema" || responseFormat?.type === "json_object");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Whether a structured-output completion holds parseable JSON.
|
|
105
|
+
*
|
|
106
|
+
* Tool calls and refusals are valid non-JSON outcomes and pass. A wholly empty
|
|
107
|
+
* completion is a {@link detectCompletionDefect} concern, but a *whitespace-only*
|
|
108
|
+
* one is truthy there and slips through — so this deliberately does NOT
|
|
109
|
+
* short-circuit on empty content: an empty or whitespace string is not valid
|
|
110
|
+
* JSON, fails the parse, and earns a retry.
|
|
111
|
+
*
|
|
112
|
+
* The ``` / ```json fences some models wrap JSON in are stripped first,
|
|
113
|
+
* mirroring what structured consumers do before parsing. A bare `true` / `false`
|
|
114
|
+
* is itself valid JSON, so a model that answers a boolean schema with the bare
|
|
115
|
+
* literal still passes; only genuinely malformed or truncated JSON fails.
|
|
116
|
+
*/
|
|
117
|
+
export function structuredOutputParses(completion) {
|
|
118
|
+
const toolCalls = completion.tool_calls;
|
|
119
|
+
if (toolCalls !== undefined && toolCalls !== null && toolCalls.length > 0) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (completion.refusal)
|
|
123
|
+
return true;
|
|
124
|
+
const content = (completion.content ?? "").trim();
|
|
125
|
+
const unfenced = content
|
|
126
|
+
.replace(/^```(?:json)?\s*/i, "")
|
|
127
|
+
.replace(/\s*```$/, "")
|
|
128
|
+
.trim();
|
|
129
|
+
return jsonParses(unfenced);
|
|
130
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
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";
|
|
3
|
+
export { parseToolCallArguments, toolCallArgumentsAbsent, type DispatchableToolCall, type ToolCallArguments, } from "./tool-calls.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
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";
|
|
3
|
+
export { parseToolCallArguments, toolCallArgumentsAbsent, } from "./tool-calls.js";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a tool call the model asked for.
|
|
3
|
+
*
|
|
4
|
+
* This is the dispatch half of a rule whose other half lives in
|
|
5
|
+
* `defects.ts`, and the two only work if they agree. `detectCompletionDefect`
|
|
6
|
+
* decides that a tool call with *empty* arguments is legitimate — the normal
|
|
7
|
+
* zero-argument shape, not a truncation — and passes it through to be run.
|
|
8
|
+
* Something then has to run it, and that something has to reach the same
|
|
9
|
+
* conclusion: empty arguments mean no arguments, not malformed JSON.
|
|
10
|
+
*
|
|
11
|
+
* Splitting those halves across a package boundary is how the original bug
|
|
12
|
+
* happened. Providers send `""` for a zero-arg call as readily as `"{}"`, the
|
|
13
|
+
* dispatcher fed `""` to `JSON.parse`, and a perfectly good call died as
|
|
14
|
+
* "Invalid JSON in tool arguments" — a whole recovery turn spent on a turn
|
|
15
|
+
* that was never broken. A host adopting only the detection half inherits the
|
|
16
|
+
* permissive decision and has to independently invent the matching parse.
|
|
17
|
+
*
|
|
18
|
+
* So both halves read {@link toolCallArgumentsAbsent}. One definition, and a
|
|
19
|
+
* change to what "absent" means cannot update one side and miss the other.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The shape this module reads. Structural rather than tied to an SDK's class,
|
|
23
|
+
* and `type` is optional because a host assembling calls by hand may not carry
|
|
24
|
+
* one — its absence is treated as the ordinary function call it almost always
|
|
25
|
+
* is, while a *present* non-function type is refused.
|
|
26
|
+
*/
|
|
27
|
+
export interface DispatchableToolCall {
|
|
28
|
+
readonly type?: string | undefined;
|
|
29
|
+
readonly function?: {
|
|
30
|
+
readonly name?: string | undefined;
|
|
31
|
+
readonly arguments?: string | undefined;
|
|
32
|
+
} | undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether a tool call carried no arguments at all.
|
|
36
|
+
*
|
|
37
|
+
* Whitespace counts as absent: a provider that pads its zero-arg payload is
|
|
38
|
+
* still saying "no arguments", and treating `" "` as content sends it to a
|
|
39
|
+
* JSON parse that can only fail.
|
|
40
|
+
*/
|
|
41
|
+
export declare function toolCallArgumentsAbsent(rawArguments: string): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* What a tool call's arguments turned out to be.
|
|
44
|
+
*
|
|
45
|
+
* A tagged union rather than a throw, because every outcome here has to end
|
|
46
|
+
* with the host emitting a `tool` message for this call's id. A transcript
|
|
47
|
+
* where an assistant asked for a tool and no `tool` message answers it is
|
|
48
|
+
* rejected by the provider on the *next* request, so "give up on this call"
|
|
49
|
+
* is never an option — only "dispatch it" or "answer it with an error".
|
|
50
|
+
*/
|
|
51
|
+
export type ToolCallArguments =
|
|
52
|
+
/** Dispatch with these. `{}` for a legitimate zero-argument call. */
|
|
53
|
+
{
|
|
54
|
+
kind: "parsed";
|
|
55
|
+
arguments: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Not a function call, so there is nothing to dispatch. Present in the
|
|
59
|
+
* OpenAI union (`type: "custom"`) and in whatever a future provider adds.
|
|
60
|
+
*/
|
|
61
|
+
| {
|
|
62
|
+
kind: "unsupported_type";
|
|
63
|
+
type: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Arguments that cannot be used: either not JSON at all, or JSON that is not
|
|
67
|
+
* an object.
|
|
68
|
+
*
|
|
69
|
+
* The two have different histories, which matters for how the host words the
|
|
70
|
+
* error. A *syntax* failure by this point is a real error rather than a
|
|
71
|
+
* truncation — a cut-off stream is caught upstream by
|
|
72
|
+
* `detectCompletionDefect` and retried, so anything still unparseable here
|
|
73
|
+
* survived that. A payload that parsed but is not an object was never
|
|
74
|
+
* eligible for that retry (it parses fine), so the model's next turn is its
|
|
75
|
+
* only correction — which is why {@link detail} names what was wrong, and
|
|
76
|
+
* why a host should put it in front of the model rather than only in a log.
|
|
77
|
+
*/
|
|
78
|
+
| {
|
|
79
|
+
kind: "unparseable";
|
|
80
|
+
detail: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Read a tool call's arguments, or say why it cannot be dispatched.
|
|
84
|
+
*
|
|
85
|
+
* A parsed value that is not a JSON object — `"null"`, `"[]"`, `"42"`, all
|
|
86
|
+
* valid JSON — is refused rather than passed on. Tool arguments are a named
|
|
87
|
+
* parameter bag by definition, and handing a tool an array where it expects
|
|
88
|
+
* fields turns a clear failure here into a confusing one inside the tool.
|
|
89
|
+
*/
|
|
90
|
+
export declare function parseToolCallArguments(toolCall: DispatchableToolCall): ToolCallArguments;
|