@inflexa-ai/harness 0.18.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/report-session-agent.d.ts +72 -0
- package/dist/agents/report-session-agent.js +87 -0
- package/dist/app/report-session-runtime.d.ts +72 -0
- package/dist/app/report-session-runtime.js +183 -0
- package/dist/app/spawn-report-session.d.ts +32 -5
- package/dist/app/spawn-report-session.js +19 -0
- package/dist/config/environment-stores.d.ts +12 -7
- package/dist/config/resource-limits.d.ts +6 -10
- package/dist/config/resource-limits.js +6 -44
- package/dist/contracts/report-reference.d.ts +4 -0
- package/dist/contracts/report-reference.js +3 -0
- package/dist/index.d.ts +2 -1
- package/dist/lib/chrome.d.ts +14 -0
- package/dist/lib/chrome.js +7 -0
- package/dist/lib/page-capture.d.ts +47 -0
- package/dist/lib/page-capture.js +83 -0
- package/dist/loop/run-agent.js +104 -15
- package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
- package/dist/memory/ai-sdk-message-storage.js +9 -4
- package/dist/memory/count-tokens.js +34 -5
- package/dist/memory/thread-store.d.ts +4 -4
- package/dist/memory/thread-store.js +18 -9
- package/dist/prompts/report-session.d.ts +1 -0
- package/dist/prompts/report-session.js +110 -0
- package/dist/providers/ai-sdk.d.ts +158 -3
- package/dist/providers/ai-sdk.js +234 -23
- package/dist/providers/errors.d.ts +14 -0
- package/dist/providers/errors.js +103 -18
- package/dist/providers/types.d.ts +33 -0
- package/dist/providers/types.js +10 -1
- package/dist/report-model/assert-rules.d.ts +44 -0
- package/dist/report-model/assert-rules.js +97 -0
- package/dist/report-model/draft-hash.d.ts +14 -0
- package/dist/report-model/draft-hash.js +36 -0
- package/dist/report-model/fixture-resolver.js +2 -71
- package/dist/report-model/{mint-snapshot.d.ts → pin-snapshot.d.ts} +7 -7
- package/dist/report-model/{mint-snapshot.js → pin-snapshot.js} +7 -7
- package/dist/report-model/production-resolver.d.ts +124 -0
- package/dist/report-model/production-resolver.js +801 -0
- package/dist/report-model/reference-resolver.d.ts +7 -1
- package/dist/report-model/validate.d.ts +34 -2
- package/dist/report-model/validate.js +37 -17
- package/dist/report-render/page.d.ts +23 -0
- package/dist/report-render/page.js +36 -3
- package/dist/report-render/value-bridge.d.ts +62 -0
- package/dist/report-render/value-bridge.js +82 -0
- package/dist/runtime/assemble.d.ts +39 -0
- package/dist/runtime/assemble.js +67 -6
- package/dist/sandbox/mount-plan.d.ts +7 -0
- package/dist/sandbox/mount-plan.js +7 -2
- package/dist/state/init.js +36 -0
- package/dist/state/purge-analysis.js +1 -0
- package/dist/state/report-session-state.d.ts +139 -0
- package/dist/state/report-session-state.js +177 -0
- package/dist/state/report-versions.d.ts +3 -6
- package/dist/state/report-versions.js +11 -38
- package/dist/state/snapshot-parse.d.ts +34 -0
- package/dist/state/snapshot-parse.js +82 -0
- package/dist/tasks/extract-values-script.d.ts +64 -0
- package/dist/tasks/extract-values-script.js +180 -0
- package/dist/tasks/extract-values.d.ts +124 -0
- package/dist/tasks/extract-values.js +193 -0
- package/dist/tools/ad-hoc-router.d.ts +7 -2
- package/dist/tools/ad-hoc-router.js +2 -1
- package/dist/tools/define-tool.d.ts +29 -0
- package/dist/tools/define-tool.js +26 -0
- package/dist/tools/report/preview-snapshot.d.ts +1 -1
- package/dist/tools/report/preview-snapshot.js +11 -50
- package/dist/tools/report-authoring/authoring-tools.d.ts +181 -24
- package/dist/tools/report-authoring/authoring-tools.js +152 -44
- package/dist/tools/report-session/examine-page.d.ts +90 -0
- package/dist/tools/report-session/examine-page.js +136 -0
- package/dist/tools/report-session/index.d.ts +10 -0
- package/dist/tools/report-session/index.js +10 -0
- package/dist/tools/report-session/preview-report.d.ts +105 -0
- package/dist/tools/report-session/preview-report.js +269 -0
- package/dist/tools/report-session/record-version.d.ts +102 -0
- package/dist/tools/report-session/record-version.js +139 -0
- package/dist/tools/research/generate-plan.js +4 -2
- package/dist/tools/sandbox/list-available-packages.js +2 -1
- package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
- package/dist/tools/sandbox/list-available-refs.js +13 -6
- package/package.json +10 -8
package/dist/providers/ai-sdk.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { generateText, streamText, wrapLanguageModel } from "ai";
|
|
1
|
+
import { generateText, streamText, wrapLanguageModel, } from "ai";
|
|
2
2
|
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
3
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
3
4
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
4
5
|
import { APICallError } from "@ai-sdk/provider";
|
|
5
6
|
import { retryWithExponentialBackoff } from "@ai-sdk/provider-utils";
|
|
6
7
|
import { ResultAsync, err, ok } from "neverthrow";
|
|
7
8
|
import { scopeWorkloadId } from "../auth/types.js";
|
|
8
9
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
9
|
-
import { classifyProviderError, toProviderError } from "./errors.js";
|
|
10
|
+
import { classifyProviderError, RequestTimeoutError, toProviderError } from "./errors.js";
|
|
10
11
|
/**
|
|
11
12
|
* The harness-owned retry envelope. The AI SDK's built-in retry exposes only a
|
|
12
13
|
* count: its 2s initial delay and ×2 factor are fixed, it caps neither the
|
|
@@ -28,6 +29,11 @@ export const RETRY_MAX_DELAY_MS = 30_000;
|
|
|
28
29
|
*
|
|
29
30
|
* Lower it per-config for servers that validate `prompt + max_tokens <= context`
|
|
30
31
|
* (vLLM and similar) against a small-context model.
|
|
32
|
+
*
|
|
33
|
+
* The openai arm takes no value from this default. `@ai-sdk/openai` clamps
|
|
34
|
+
* nothing per model, and it puts the number on the wire as it is. Thus a model
|
|
35
|
+
* whose own cap is smaller answers 400 on each call. That arm sends no ceiling,
|
|
36
|
+
* and the server then enforces the true cap of the model.
|
|
31
37
|
*/
|
|
32
38
|
export const DEFAULT_MAX_OUTPUT_TOKENS = 64_000;
|
|
33
39
|
/**
|
|
@@ -189,15 +195,20 @@ function unwrapForClassification(e) {
|
|
|
189
195
|
* that, once retries are exhausted, `toProviderError`'s status walk reaches the
|
|
190
196
|
* true HTTP status instead of stopping at a synthetic wrapper. The attempt
|
|
191
197
|
* counter is per-instance because each call builds its own retry.
|
|
198
|
+
*
|
|
199
|
+
* An aborted caller signal also stops the retry. A wall-clock guard of a caller
|
|
200
|
+
* aborts with a `TimeoutError` reason, which the taxonomy marks as a retryable
|
|
201
|
+
* timeout. Without this guard the envelope would loop an expiry that the caller
|
|
202
|
+
* itself declared.
|
|
192
203
|
*/
|
|
193
|
-
function createRetry(signal, logger) {
|
|
204
|
+
function createRetry(signal, logger, maxRetries) {
|
|
194
205
|
let retryCount = 0;
|
|
195
206
|
return retryWithExponentialBackoff({
|
|
196
|
-
maxRetries
|
|
207
|
+
maxRetries,
|
|
197
208
|
initialDelayInMs: RETRY_INITIAL_DELAY_MS,
|
|
198
209
|
backoffFactor: RETRY_BACKOFF_FACTOR,
|
|
199
210
|
abortSignal: signal,
|
|
200
|
-
shouldRetry: (e) => !(e instanceof BillingSeamFailure) && classifyProviderError(e).retryable,
|
|
211
|
+
shouldRetry: (e) => !(e instanceof BillingSeamFailure) && signal?.aborted !== true && classifyProviderError(e).retryable,
|
|
201
212
|
getDelayInMs: ({ error, exponentialBackoffDelay }) => {
|
|
202
213
|
const delayMs = computeRetryDelayMs(error, exponentialBackoffDelay);
|
|
203
214
|
retryCount += 1;
|
|
@@ -329,15 +340,63 @@ function sanitizeMessages(messages) {
|
|
|
329
340
|
}
|
|
330
341
|
return out;
|
|
331
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* Advance the SDK stream to the next text delta.
|
|
345
|
+
*
|
|
346
|
+
* The full stream carries one part for each event of a turn, and only a text
|
|
347
|
+
* part reaches the consumer of the harness. Thus the pull skips each other part.
|
|
348
|
+
* The `abort` part stops the pull, because the SDK closes the stream directly
|
|
349
|
+
* after it. Thus a timeout can never end the stream in silence with the text
|
|
350
|
+
* that arrived before it.
|
|
351
|
+
*/
|
|
352
|
+
async function pullNextDelta(iterator) {
|
|
353
|
+
for (let next = await iterator.next(); next.done !== true; next = await iterator.next()) {
|
|
354
|
+
const part = next.value;
|
|
355
|
+
if (part.type === "text-delta")
|
|
356
|
+
return { kind: "delta", text: part.text };
|
|
357
|
+
if (part.type === "abort")
|
|
358
|
+
return { kind: "aborted", reason: part.reason };
|
|
359
|
+
}
|
|
360
|
+
return { kind: "end" };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The failure that an `abort` part of the SDK stream stands for.
|
|
364
|
+
*
|
|
365
|
+
* The part carries a message only, thus the caller signal is the one thing that
|
|
366
|
+
* separates a cancellation from a timeout. When the caller signal is aborted,
|
|
367
|
+
* the reason of that signal rides out, and the cancellation path applies
|
|
368
|
+
* unchanged. Otherwise the SDK chunk bound stopped the stream, and the sentinel
|
|
369
|
+
* names the configured value for the classifier.
|
|
370
|
+
*/
|
|
371
|
+
function abortedStreamFailure(signal, reason, requestTimeoutMs) {
|
|
372
|
+
if (signal?.aborted === true)
|
|
373
|
+
return signal.reason;
|
|
374
|
+
if (requestTimeoutMs !== undefined)
|
|
375
|
+
return new RequestTimeoutError(requestTimeoutMs);
|
|
376
|
+
return new Error(reason ?? "The provider aborted the stream.");
|
|
377
|
+
}
|
|
332
378
|
export function createAiSdkProvider(deps) {
|
|
333
|
-
const capabilities = {
|
|
379
|
+
const capabilities = {
|
|
380
|
+
toolCalling: deps.capabilities?.toolCalling ?? true,
|
|
381
|
+
// Propagate a picture flag only when the caller states it. An absent
|
|
382
|
+
// flag stays absent, thus the loop degrades to the next placement for a
|
|
383
|
+
// wire that carries no picture there.
|
|
384
|
+
...(deps.capabilities?.imageToolResults !== undefined ? { imageToolResults: deps.capabilities.imageToolResults } : {}),
|
|
385
|
+
...(deps.capabilities?.imageUserMessages !== undefined ? { imageUserMessages: deps.capabilities.imageUserMessages } : {}),
|
|
386
|
+
};
|
|
334
387
|
const logger = (deps.logger ?? createNoopLogger()).named("providers.ai-sdk");
|
|
335
388
|
const maxOutputTokens = deps.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
389
|
+
// The sentinel hands the SDK no ceiling, thus the request carries none and the
|
|
390
|
+
// server enforces the cap of the model. One binding feeds the call and the
|
|
391
|
+
// stream, thus the two cannot disagree about the ceiling of a request.
|
|
392
|
+
const outputCeiling = maxOutputTokens === "provider-maximum" ? {} : { maxOutputTokens };
|
|
393
|
+
const maxRetries = deps.maxRetries ?? RETRY_MAX_RETRIES;
|
|
394
|
+
const requestTimeoutMs = deps.requestTimeoutMs;
|
|
336
395
|
const requestedModelId = requestedModelIdOf(deps.model);
|
|
337
396
|
routeSdkWarningsTo(logger);
|
|
338
397
|
function chat(req, session, signal) {
|
|
339
398
|
const run = async () => {
|
|
340
|
-
const retry = createRetry(signal, logger);
|
|
399
|
+
const retry = createRetry(signal, logger, maxRetries);
|
|
341
400
|
const capture = captureServedModelId(deps.model);
|
|
342
401
|
try {
|
|
343
402
|
const result = await retry(async () => {
|
|
@@ -350,13 +409,17 @@ export function createAiSdkProvider(deps) {
|
|
|
350
409
|
messages: sanitizeMessages(req.messages),
|
|
351
410
|
tools: req.tools,
|
|
352
411
|
toolChoice: req.toolChoice ?? "auto",
|
|
353
|
-
|
|
412
|
+
...outputCeiling,
|
|
354
413
|
stopWhen: [],
|
|
355
414
|
// The harness envelope owns retries; leaving the SDK default in
|
|
356
415
|
// place would multiply attempts (10 outer × 3 inner).
|
|
357
416
|
maxRetries: 0,
|
|
358
417
|
headers,
|
|
359
418
|
abortSignal: signal,
|
|
419
|
+
// No SDK timeout rides here. `chunkMs` bounds a stream only,
|
|
420
|
+
// thus it is inert on this call. The fetch guard is the full
|
|
421
|
+
// bound, because the headers of a non-streaming response arrive
|
|
422
|
+
// when the model completes.
|
|
360
423
|
providerOptions: req.providerOptions,
|
|
361
424
|
});
|
|
362
425
|
});
|
|
@@ -371,7 +434,7 @@ export function createAiSdkProvider(deps) {
|
|
|
371
434
|
return new ResultAsync(run());
|
|
372
435
|
}
|
|
373
436
|
async function* chatStream(req, session, signal) {
|
|
374
|
-
const retry = createRetry(signal, logger);
|
|
437
|
+
const retry = createRetry(signal, logger, maxRetries);
|
|
375
438
|
const capture = captureServedModelId(deps.model);
|
|
376
439
|
try {
|
|
377
440
|
// Retry covers only stream establishment: streamText defers wire errors
|
|
@@ -390,16 +453,29 @@ export function createAiSdkProvider(deps) {
|
|
|
390
453
|
messages: sanitizeMessages(req.messages),
|
|
391
454
|
tools: req.tools,
|
|
392
455
|
toolChoice: req.toolChoice ?? "auto",
|
|
393
|
-
|
|
456
|
+
...outputCeiling,
|
|
394
457
|
stopWhen: [],
|
|
395
458
|
maxRetries: 0,
|
|
396
459
|
headers,
|
|
397
460
|
abortSignal: signal,
|
|
461
|
+
// The fetch guard bounds the wait until the headers. Past the
|
|
462
|
+
// headers the SDK owns two bounds: `firstChunkMs` covers the wait
|
|
463
|
+
// for the first content chunk, and `chunkMs` covers each later
|
|
464
|
+
// gap. The two take the same value, thus each silent interval of
|
|
465
|
+
// the stream has the one configured bound. A chunk that carries
|
|
466
|
+
// no content, for example a keep-alive comment, produces no part,
|
|
467
|
+
// thus it feeds neither bound.
|
|
468
|
+
...(requestTimeoutMs !== undefined ? { timeout: { firstChunkMs: requestTimeoutMs, chunkMs: requestTimeoutMs } } : {}),
|
|
398
469
|
providerOptions: req.providerOptions,
|
|
399
470
|
});
|
|
400
|
-
const iterator = result.
|
|
401
|
-
const first = await iterator
|
|
402
|
-
|
|
471
|
+
const iterator = result.fullStream[Symbol.asyncIterator]();
|
|
472
|
+
const first = await pullNextDelta(iterator);
|
|
473
|
+
// The chunk bound can stop the stream before any content arrives. The
|
|
474
|
+
// throw carries the sentinel, thus the envelope gives the attempt a
|
|
475
|
+
// fresh window under the retry policy of a connection error.
|
|
476
|
+
if (first.kind === "aborted")
|
|
477
|
+
throw abortedStreamFailure(signal, first.reason, requestTimeoutMs);
|
|
478
|
+
if (first.kind === "end") {
|
|
403
479
|
// A doStream promise rejection — the shape a real connection
|
|
404
480
|
// failure takes — does NOT surface at this first pull: streamText
|
|
405
481
|
// resolves it `{ done: true }` and defers the rejection to the
|
|
@@ -408,8 +484,8 @@ export function createAiSdkProvider(deps) {
|
|
|
408
484
|
// retried; a genuine text-less turn resolves them, and the terminal
|
|
409
485
|
// event is built from these already-resolved values without a
|
|
410
486
|
// second await. (These promises only settle once the stream is
|
|
411
|
-
// fully drained, so they can be awaited here only because the
|
|
412
|
-
//
|
|
487
|
+
// fully drained, so they can be awaited here only because the pull
|
|
488
|
+
// already reported the stream done.)
|
|
413
489
|
return {
|
|
414
490
|
kind: "completed",
|
|
415
491
|
messages: await result.responseMessages,
|
|
@@ -418,7 +494,7 @@ export function createAiSdkProvider(deps) {
|
|
|
418
494
|
usage: await result.usage,
|
|
419
495
|
};
|
|
420
496
|
}
|
|
421
|
-
return { kind: "streaming", result, iterator, firstDelta: first.
|
|
497
|
+
return { kind: "streaming", result, iterator, firstDelta: first.text };
|
|
422
498
|
});
|
|
423
499
|
if (opened.kind === "completed") {
|
|
424
500
|
const response = responseFromMessages({
|
|
@@ -438,9 +514,14 @@ export function createAiSdkProvider(deps) {
|
|
|
438
514
|
yield { type: "text-delta", text: firstDelta };
|
|
439
515
|
// Drain the same iterator: past the first delta, errors propagate
|
|
440
516
|
// unchanged (no retry, no re-emit).
|
|
441
|
-
for (let
|
|
442
|
-
|
|
443
|
-
|
|
517
|
+
for (let pull = await pullNextDelta(iterator); pull.kind !== "end"; pull = await pullNextDelta(iterator)) {
|
|
518
|
+
// A mid-stream abort ends the turn with an error, never with the
|
|
519
|
+
// partial text that arrived before it. The catch below builds the
|
|
520
|
+
// terminal `ProviderError` from the throwable.
|
|
521
|
+
if (pull.kind === "aborted")
|
|
522
|
+
throw abortedStreamFailure(signal, pull.reason, requestTimeoutMs);
|
|
523
|
+
text += pull.text;
|
|
524
|
+
yield { type: "text-delta", text: pull.text };
|
|
444
525
|
}
|
|
445
526
|
const response = responseFromMessages({
|
|
446
527
|
messages: await result.responseMessages,
|
|
@@ -461,7 +542,85 @@ export function createAiSdkProvider(deps) {
|
|
|
461
542
|
throw toProviderError(unwrapForClassification(e), workloadOf(session));
|
|
462
543
|
}
|
|
463
544
|
}
|
|
464
|
-
|
|
545
|
+
// The provider advertises the guard limit on its own instance, thus a
|
|
546
|
+
// consumer that scales a deadline reads it from the provider in its deps.
|
|
547
|
+
return { capabilities, chat, chatStream, ...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}) };
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Wrap a fetch so that the wait until the response starts does not exceed
|
|
551
|
+
* `requestTimeoutMs`. The wrapper arms one timer for each fetch call. The timer
|
|
552
|
+
* runs from the send until the fetch promise settles, and the headers of the
|
|
553
|
+
* response settle it. On expiry the wrapper aborts a dedicated controller with a
|
|
554
|
+
* `RequestTimeoutError` reason, and it composes that controller with the caller
|
|
555
|
+
* signal through `AbortSignal.any`. Thus the caller signal keeps its normal
|
|
556
|
+
* effect, and a caller abort stays a cancellation.
|
|
557
|
+
*
|
|
558
|
+
* The guard bounds the response start only. The SDK bounds each later gap of a
|
|
559
|
+
* streamed body with its own `chunkMs` setting, thus no timer of the wrapper
|
|
560
|
+
* survives the headers. Thus a stream with steady content does not trip the
|
|
561
|
+
* guard, whatever its total length. One fetch call is one attempt, because the
|
|
562
|
+
* SDK retries are off, thus the window is per attempt.
|
|
563
|
+
*
|
|
564
|
+
* When the field is absent, `createConfiguredAiSdkProvider` installs no wrapper,
|
|
565
|
+
* and the behavior stays byte-identical.
|
|
566
|
+
*/
|
|
567
|
+
export function wrapFetchWithRequestTimeout(fetchImpl, requestTimeoutMs) {
|
|
568
|
+
return async (input, init) => {
|
|
569
|
+
const controller = new AbortController();
|
|
570
|
+
const callerSignal = init?.signal ?? undefined;
|
|
571
|
+
const timer = setTimeout(() => controller.abort(new RequestTimeoutError(requestTimeoutMs)), requestTimeoutMs);
|
|
572
|
+
// The harness runs on Node, where a timer has `unref`. Unref the guard
|
|
573
|
+
// timer, thus an armed guard does not keep a short-lived process alive.
|
|
574
|
+
// The DOM type of `setTimeout` declares no `unref`, thus guard the call.
|
|
575
|
+
timer.unref?.();
|
|
576
|
+
const clear = () => {
|
|
577
|
+
clearTimeout(timer);
|
|
578
|
+
// The harness runs on Node, where a caller signal can outlive one fetch
|
|
579
|
+
// attempt. Remove the abort listener when the attempt settles, thus a
|
|
580
|
+
// long-lived signal does not retain this closure.
|
|
581
|
+
callerSignal?.removeEventListener("abort", clear);
|
|
582
|
+
};
|
|
583
|
+
// Compose the guard controller with the caller signal, thus either one
|
|
584
|
+
// can abort the wire, and the abort reason names its own source. A caller
|
|
585
|
+
// abort also clears the guard timer at once. Thus an armed timer does not
|
|
586
|
+
// reach a pointless expiry after the caller cancels.
|
|
587
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, controller.signal]) : controller.signal;
|
|
588
|
+
callerSignal?.addEventListener("abort", clear, { once: true });
|
|
589
|
+
// `timeout: false` is an extension of the fetch init of Bun. Bun cuts a
|
|
590
|
+
// request that stays idle for 300 seconds, and this key lifts that cut. Node
|
|
591
|
+
// ignores an unknown member of the init, thus the key is inert there. A
|
|
592
|
+
// probe on Bun 1.3.14 proved both results. The `RequestInit` type declares
|
|
593
|
+
// no such member, thus the cast.
|
|
594
|
+
const forwarded = { ...init, signal, timeout: false };
|
|
595
|
+
try {
|
|
596
|
+
return await fetchImpl(input, forwarded);
|
|
597
|
+
}
|
|
598
|
+
finally {
|
|
599
|
+
clear();
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Bind one explicit `store` directive onto each call of a model.
|
|
605
|
+
*
|
|
606
|
+
* The middleware merges the value into the `openai` namespace of
|
|
607
|
+
* `providerOptions`. It keeps each other namespace, and it keeps each other key
|
|
608
|
+
* of the `openai` namespace, thus a cache directive of a different vendor rides
|
|
609
|
+
* through untouched. The arm value wins over a request-level `store`, because
|
|
610
|
+
* the retention mode belongs to the connection and not to one turn. A thread
|
|
611
|
+
* that mixes the two modes replays a reference onto an item that the server
|
|
612
|
+
* never stored.
|
|
613
|
+
*/
|
|
614
|
+
function withStoreDirective(model, store) {
|
|
615
|
+
return wrapLanguageModel({
|
|
616
|
+
model,
|
|
617
|
+
middleware: {
|
|
618
|
+
transformParams: async ({ params }) => ({
|
|
619
|
+
...params,
|
|
620
|
+
providerOptions: { ...params.providerOptions, openai: { ...params.providerOptions?.openai, store } },
|
|
621
|
+
}),
|
|
622
|
+
},
|
|
623
|
+
});
|
|
465
624
|
}
|
|
466
625
|
/**
|
|
467
626
|
* Realize an `AiSdkProviderConfig` into a `ChatProvider` bound to that config's
|
|
@@ -469,28 +628,78 @@ export function createAiSdkProvider(deps) {
|
|
|
469
628
|
* is never passed per `ChatRequest`), so N models require N provider
|
|
470
629
|
* instances over one shared connection configuration — one instance serves the
|
|
471
630
|
* single model it was built with.
|
|
631
|
+
*
|
|
632
|
+
* When the config sets `requestTimeoutMs`, the provider wraps the effective fetch
|
|
633
|
+
* (`config.fetch ?? globalThis.fetch`) with the response-start guard, it hands
|
|
634
|
+
* the value to the stream call as the SDK chunk bound, and it advertises the
|
|
635
|
+
* value on the returned instance. When the field is absent, it installs no
|
|
636
|
+
* wrapper, it hands the SDK no bound, and it advertises no value.
|
|
472
637
|
*/
|
|
473
638
|
export function createConfiguredAiSdkProvider(deps) {
|
|
474
639
|
const config = deps.config;
|
|
640
|
+
const requestTimeoutMs = config.requestTimeoutMs;
|
|
641
|
+
const effectiveFetch = requestTimeoutMs === undefined ? config.fetch : wrapFetchWithRequestTimeout(config.fetch ?? globalThis.fetch, requestTimeoutMs);
|
|
475
642
|
if (config.kind === "anthropic") {
|
|
476
643
|
const provider = createAnthropic({
|
|
477
644
|
baseURL: config.baseURL,
|
|
478
645
|
apiKey: config.apiKey,
|
|
479
|
-
fetch:
|
|
646
|
+
fetch: effectiveFetch,
|
|
480
647
|
});
|
|
648
|
+
// The Anthropic Messages wire renders an image block inside a
|
|
649
|
+
// tool_result, thus the loop can send a picture. A custom baseURL can
|
|
650
|
+
// front a different backend that stringifies the block. Thus the
|
|
651
|
+
// harness asserts the capability only for the default endpoint. An
|
|
652
|
+
// absent flag means "cannot carry", thus a config with a custom baseURL
|
|
653
|
+
// must declare the capability. A config value overrides this default in
|
|
654
|
+
// both directions.
|
|
655
|
+
const pictureDefault = config.baseURL === undefined ? { imageToolResults: true } : {};
|
|
481
656
|
return createAiSdkProvider({
|
|
482
657
|
model: provider.chat(config.model),
|
|
483
658
|
resolveBilling: deps.resolveBilling,
|
|
484
|
-
capabilities: config.capabilities,
|
|
659
|
+
capabilities: { ...pictureDefault, ...config.capabilities },
|
|
485
660
|
logger: deps.logger,
|
|
486
661
|
...(config.maxOutputTokens !== undefined ? { maxOutputTokens: config.maxOutputTokens } : {}),
|
|
662
|
+
...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),
|
|
663
|
+
...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
if (config.kind === "openai") {
|
|
667
|
+
const provider = createOpenAI({
|
|
668
|
+
baseURL: config.baseURL,
|
|
669
|
+
apiKey: config.apiKey,
|
|
670
|
+
fetch: effectiveFetch,
|
|
671
|
+
});
|
|
672
|
+
// The Responses wire renders an image block inside a function-call
|
|
673
|
+
// output, thus the loop can send a picture. A custom baseURL can front
|
|
674
|
+
// a different backend that stringifies the block. Thus the harness
|
|
675
|
+
// asserts the capability only for the default endpoint. An absent flag
|
|
676
|
+
// means "cannot carry", thus a config with a custom baseURL must
|
|
677
|
+
// declare the capability. A config value overrides this default in both
|
|
678
|
+
// directions.
|
|
679
|
+
const pictureDefault = config.baseURL === undefined ? { imageToolResults: true } : {};
|
|
680
|
+
return createAiSdkProvider({
|
|
681
|
+
// The explicit Responses binding pins the wire here. The bare
|
|
682
|
+
// factory callable of the package resolves to the same path today,
|
|
683
|
+
// but nothing in the package holds it there.
|
|
684
|
+
model: withStoreDirective(provider.responses(config.model), config.store ?? false),
|
|
685
|
+
resolveBilling: deps.resolveBilling,
|
|
686
|
+
capabilities: { ...pictureDefault, ...config.capabilities },
|
|
687
|
+
logger: deps.logger,
|
|
688
|
+
// The package puts this number on the wire as it is, and it clamps
|
|
689
|
+
// nothing per model. Thus the shared default of 64000 breaks each call
|
|
690
|
+
// to a model with a smaller cap, with a 400 that no retry can pass. An
|
|
691
|
+
// unset config sends no ceiling, and the server holds the reply at the
|
|
692
|
+
// cap of the model.
|
|
693
|
+
maxOutputTokens: config.maxOutputTokens ?? "provider-maximum",
|
|
694
|
+
...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),
|
|
695
|
+
...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
|
|
487
696
|
});
|
|
488
697
|
}
|
|
489
698
|
const provider = createOpenAICompatible({
|
|
490
699
|
name: config.name,
|
|
491
700
|
baseURL: config.baseURL,
|
|
492
701
|
apiKey: config.apiKey,
|
|
493
|
-
fetch:
|
|
702
|
+
fetch: effectiveFetch,
|
|
494
703
|
});
|
|
495
704
|
return createAiSdkProvider({
|
|
496
705
|
model: provider.chatModel(config.model),
|
|
@@ -498,5 +707,7 @@ export function createConfiguredAiSdkProvider(deps) {
|
|
|
498
707
|
capabilities: config.capabilities,
|
|
499
708
|
logger: deps.logger,
|
|
500
709
|
...(config.maxOutputTokens !== undefined ? { maxOutputTokens: config.maxOutputTokens } : {}),
|
|
710
|
+
...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),
|
|
711
|
+
...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
|
|
501
712
|
});
|
|
502
713
|
}
|
|
@@ -15,6 +15,20 @@
|
|
|
15
15
|
* re-authenticating fixes it — which is a different remedy from every other
|
|
16
16
|
* non-retryable 4xx, so it is a different kind.
|
|
17
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* The sentinel that the request-timeout guard uses as an abort reason. The guard
|
|
20
|
+
* arms a timer for each fetch attempt. On expiry the guard aborts the attempt
|
|
21
|
+
* with this value. The value rides the abort chain, thus the classifier finds it
|
|
22
|
+
* and marks the attempt as a retryable provider timeout.
|
|
23
|
+
*
|
|
24
|
+
* The name is not `AbortError`, thus the abort-detection paths do not swallow a
|
|
25
|
+
* guard abort as a caller cancellation. The message names the configured value.
|
|
26
|
+
*/
|
|
27
|
+
export declare class RequestTimeoutError extends Error {
|
|
28
|
+
/** The configured bound for one silent interval, in milliseconds. */
|
|
29
|
+
readonly requestTimeoutMs: number;
|
|
30
|
+
constructor(requestTimeoutMs: number);
|
|
31
|
+
}
|
|
18
32
|
export type ProviderErrorKind = "auth" | "budget" | "tenant-blocked" | "provider";
|
|
19
33
|
/**
|
|
20
34
|
* The provider error value channel — a `DomainError`-conforming
|
package/dist/providers/errors.js
CHANGED
|
@@ -16,6 +16,24 @@
|
|
|
16
16
|
* non-retryable 4xx, so it is a different kind.
|
|
17
17
|
*/
|
|
18
18
|
import { redactSecrets } from "../input-sanitization.js";
|
|
19
|
+
/**
|
|
20
|
+
* The sentinel that the request-timeout guard uses as an abort reason. The guard
|
|
21
|
+
* arms a timer for each fetch attempt. On expiry the guard aborts the attempt
|
|
22
|
+
* with this value. The value rides the abort chain, thus the classifier finds it
|
|
23
|
+
* and marks the attempt as a retryable provider timeout.
|
|
24
|
+
*
|
|
25
|
+
* The name is not `AbortError`, thus the abort-detection paths do not swallow a
|
|
26
|
+
* guard abort as a caller cancellation. The message names the configured value.
|
|
27
|
+
*/
|
|
28
|
+
export class RequestTimeoutError extends Error {
|
|
29
|
+
/** The configured bound for one silent interval, in milliseconds. */
|
|
30
|
+
requestTimeoutMs;
|
|
31
|
+
constructor(requestTimeoutMs) {
|
|
32
|
+
super(`The provider sent no data within the request timeout of ${requestTimeoutMs} ms.`);
|
|
33
|
+
this.name = "RequestTimeoutError";
|
|
34
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
19
37
|
/**
|
|
20
38
|
* Structural guard for an already-constructed `ProviderError` value. A
|
|
21
39
|
* `ProviderError` is a plain object, not an `Error`, so it cannot be
|
|
@@ -46,6 +64,20 @@ export function isProviderError(value) {
|
|
|
46
64
|
export function toProviderError(e, workload) {
|
|
47
65
|
if (isProviderError(e))
|
|
48
66
|
return e;
|
|
67
|
+
// A guard abort names the configured value in its message. Compose a message
|
|
68
|
+
// that also names the workload, thus a reader sees both. This runs before the
|
|
69
|
+
// status walk, because a guard abort carries no HTTP status. A timeout of the
|
|
70
|
+
// SDK carries no sentinel, thus it falls to the composed arm below, which
|
|
71
|
+
// forwards the message of the SDK.
|
|
72
|
+
const timeout = findRequestTimeout(e);
|
|
73
|
+
if (timeout !== undefined) {
|
|
74
|
+
return {
|
|
75
|
+
type: "provider",
|
|
76
|
+
retryable: true,
|
|
77
|
+
message: `The provider sent no data for ${workload} within the request timeout of ${timeout.requestTimeoutMs} ms.`,
|
|
78
|
+
cause: e,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
49
81
|
const { kind, retryable } = classifyProviderError(e);
|
|
50
82
|
const detail = e instanceof Error ? e.message : String(e);
|
|
51
83
|
if (kind === "auth") {
|
|
@@ -149,6 +181,22 @@ const CONNECTION_ERROR_CODES = new Set([
|
|
|
149
181
|
"UND_ERR_SOCKET",
|
|
150
182
|
]);
|
|
151
183
|
const CONNECTION_ERROR_PATTERN = /fetch failed|socket hang up|network error|connection (?:refused|reset|error|closed)|econnrefused|enotfound|etimedout|terminated/i;
|
|
184
|
+
/**
|
|
185
|
+
* Walk the `cause` chain up to {@link MAX_CAUSE_HOPS} links, and return the first
|
|
186
|
+
* result that `probe` gives. `probe` gives a value for a link that matches, or
|
|
187
|
+
* `undefined` for a link that does not. The walk stops at the first match, at
|
|
188
|
+
* the hop cap, or at the end of the chain.
|
|
189
|
+
*/
|
|
190
|
+
function findInCauseChain(err, probe) {
|
|
191
|
+
let cursor = err;
|
|
192
|
+
for (let i = 0; i < MAX_CAUSE_HOPS && cursor; i++) {
|
|
193
|
+
const found = probe(cursor);
|
|
194
|
+
if (found !== undefined)
|
|
195
|
+
return found;
|
|
196
|
+
cursor = cursor.cause;
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
152
200
|
/**
|
|
153
201
|
* Walk the `cause` chain for the first numeric HTTP status. The single status
|
|
154
202
|
* walker: `classifyProviderError` keys on what this returns, so a caller asking
|
|
@@ -156,16 +204,14 @@ const CONNECTION_ERROR_PATTERN = /fetch failed|socket hang up|network error|conn
|
|
|
156
204
|
* than re-implement the traversal.
|
|
157
205
|
*/
|
|
158
206
|
export function extractStatus(err) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const e = cursor;
|
|
207
|
+
return findInCauseChain(err, (link) => {
|
|
208
|
+
const e = link;
|
|
162
209
|
if (typeof e.status === "number")
|
|
163
210
|
return e.status;
|
|
164
211
|
if (typeof e.statusCode === "number")
|
|
165
212
|
return e.statusCode;
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
return undefined;
|
|
213
|
+
return undefined;
|
|
214
|
+
});
|
|
169
215
|
}
|
|
170
216
|
/**
|
|
171
217
|
* Walk the `cause` chain for the first captured provider response body.
|
|
@@ -186,23 +232,54 @@ function extractResponseBody(err) {
|
|
|
186
232
|
}
|
|
187
233
|
return undefined;
|
|
188
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Walk the `cause` chain for the request-timeout sentinel. The guard aborts a
|
|
237
|
+
* fetch with a `RequestTimeoutError`. The SDK carries the abort reason down the
|
|
238
|
+
* `cause` chain, thus the sentinel can sit one or more hops down.
|
|
239
|
+
*/
|
|
240
|
+
function findRequestTimeout(err) {
|
|
241
|
+
return findInCauseChain(err, (link) => {
|
|
242
|
+
if (link instanceof RequestTimeoutError)
|
|
243
|
+
return link;
|
|
244
|
+
// A duplicate class identity across a realm boundary fails `instanceof`,
|
|
245
|
+
// thus match the name and the field as a fallback.
|
|
246
|
+
const e = link;
|
|
247
|
+
if (e.name === "RequestTimeoutError" && typeof e.requestTimeoutMs === "number")
|
|
248
|
+
return link;
|
|
249
|
+
return undefined;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The name of the abort reason that the `timeout` setting of the SDK raises. The
|
|
254
|
+
* SDK bounds each gap between two content chunks of a stream. On expiry it
|
|
255
|
+
* aborts the call with a bare `DOMException` that has this name, no cause, and
|
|
256
|
+
* no HTTP status. The message of that `DOMException` names the configured value.
|
|
257
|
+
*/
|
|
258
|
+
const SDK_TIMEOUT_ERROR_NAME = "TimeoutError";
|
|
259
|
+
/**
|
|
260
|
+
* Walk the `cause` chain for the timeout that the SDK raises. The `DOMException`
|
|
261
|
+
* carries no field of its own, thus the name is the one signal. The SDK carries
|
|
262
|
+
* an abort reason down the `cause` chain, thus the reason can sit one or more
|
|
263
|
+
* hops down.
|
|
264
|
+
*/
|
|
265
|
+
function findSdkTimeout(err) {
|
|
266
|
+
return findInCauseChain(err, (link) => {
|
|
267
|
+
const e = link;
|
|
268
|
+
return e.name === SDK_TIMEOUT_ERROR_NAME ? link : undefined;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
189
271
|
/** Does any link of the `cause` chain look like a transport-level failure? */
|
|
190
272
|
function looksLikeConnectionError(err) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (typeof e.code === "string" && CONNECTION_ERROR_CODES.has(e.code)) {
|
|
273
|
+
return (findInCauseChain(err, (link) => {
|
|
274
|
+
const e = link;
|
|
275
|
+
if (typeof e.code === "string" && CONNECTION_ERROR_CODES.has(e.code))
|
|
195
276
|
return true;
|
|
196
|
-
|
|
197
|
-
if (typeof e.name === "string" && /^APIConnection/.test(e.name)) {
|
|
277
|
+
if (typeof e.name === "string" && /^APIConnection/.test(e.name))
|
|
198
278
|
return true;
|
|
199
|
-
|
|
200
|
-
if (typeof e.message === "string" && CONNECTION_ERROR_PATTERN.test(e.message)) {
|
|
279
|
+
if (typeof e.message === "string" && CONNECTION_ERROR_PATTERN.test(e.message))
|
|
201
280
|
return true;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
return false;
|
|
281
|
+
return undefined;
|
|
282
|
+
}) ?? false);
|
|
206
283
|
}
|
|
207
284
|
/**
|
|
208
285
|
* Classify a provider failure by origin.
|
|
@@ -214,6 +291,14 @@ function looksLikeConnectionError(err) {
|
|
|
214
291
|
* - Other `4xx` and parse / unknown errors → `provider`, not retryable.
|
|
215
292
|
*/
|
|
216
293
|
export function classifyProviderError(e) {
|
|
294
|
+
// The request-timeout guard aborts an attempt with a typed sentinel, and the
|
|
295
|
+
// chunk bound of the SDK aborts one with a `TimeoutError`. Each classifies as
|
|
296
|
+
// a retryable provider timeout, thus the envelope retries it under the same
|
|
297
|
+
// policy as a connection error. This runs before the status and the
|
|
298
|
+
// connection paths, because a timeout carries no HTTP status, and it must not
|
|
299
|
+
// fall to a non-retryable path.
|
|
300
|
+
if (findRequestTimeout(e) !== undefined || findSdkTimeout(e) !== undefined)
|
|
301
|
+
return { kind: "provider", retryable: true };
|
|
217
302
|
const status = extractStatus(e);
|
|
218
303
|
// `auth` is its own kind rather than a plain non-retryable 4xx because the
|
|
219
304
|
// remedy is categorically different: the request was well-formed and the
|
|
@@ -13,6 +13,23 @@ import type { ProviderError } from "./errors.js";
|
|
|
13
13
|
export type { FinishReason, LanguageModel, ModelMessage, ProviderOptions, ToolSet };
|
|
14
14
|
export interface ProviderCapabilities {
|
|
15
15
|
readonly toolCalling: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Whether the wire can carry a picture inside a tool result. The loop moves a
|
|
18
|
+
* tool's picture into an image content block on the tool-result message. A
|
|
19
|
+
* wire that renders that block sets this flag. A wire that stringifies the
|
|
20
|
+
* block into text leaves it absent, and the loop then drops the picture and
|
|
21
|
+
* keeps the text, because base64 text floods the context and the model cannot
|
|
22
|
+
* see it. Absent means "cannot carry", never "unknown".
|
|
23
|
+
*/
|
|
24
|
+
readonly imageToolResults?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the wire can carry a picture inside a user message. The loop uses
|
|
27
|
+
* this placement only when the tool result cannot carry the picture. The tool
|
|
28
|
+
* result then keeps its JSON text, and one user message after the tool message
|
|
29
|
+
* of the round carries the bytes. Absent means "cannot carry", never
|
|
30
|
+
* "unknown".
|
|
31
|
+
*/
|
|
32
|
+
readonly imageUserMessages?: boolean;
|
|
16
33
|
}
|
|
17
34
|
export interface ChatRequest {
|
|
18
35
|
readonly system: string;
|
|
@@ -109,7 +126,23 @@ export interface AgentChat {
|
|
|
109
126
|
}
|
|
110
127
|
export interface ChatProvider extends AgentChat {
|
|
111
128
|
chatStream(req: ChatRequest, session: AgentSession, signal?: AbortSignal): AsyncIterable<ChatStreamEvent>;
|
|
129
|
+
/**
|
|
130
|
+
* The request-timeout limit that this provider enforces, in milliseconds, or
|
|
131
|
+
* absent when it enforces none. The value bounds each silent interval of a
|
|
132
|
+
* request attempt: the wait until the response starts, and each gap between
|
|
133
|
+
* two content chunks of a stream. A consumer that scales a deadline reads this
|
|
134
|
+
* value from the provider instance, not from a harness constant.
|
|
135
|
+
*/
|
|
136
|
+
readonly requestTimeoutMs?: number;
|
|
112
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* The effective deadline in milliseconds for a provider-bound operation.
|
|
140
|
+
*
|
|
141
|
+
* An explicit value wins. Absent an explicit value, the deadline is the maximum
|
|
142
|
+
* of `floorMs` and the request-timeout limit that the provider advertises. Thus
|
|
143
|
+
* a configured request timeout can raise the floor. It cannot lower the floor.
|
|
144
|
+
*/
|
|
145
|
+
export declare function effectiveDeadlineMs(provider: Pick<ChatProvider, "requestTimeoutMs">, floorMs: number, explicitMs?: number): number;
|
|
113
146
|
export interface EmbeddingProvider {
|
|
114
147
|
/**
|
|
115
148
|
* Width of every vector `embed` returns. The write-side index paths create
|
package/dist/providers/types.js
CHANGED
|
@@ -6,4 +6,13 @@
|
|
|
6
6
|
* attribution is resolved lazily at the provider wire boundary.
|
|
7
7
|
*/
|
|
8
8
|
const _assertChatUsageFields = true;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The effective deadline in milliseconds for a provider-bound operation.
|
|
11
|
+
*
|
|
12
|
+
* An explicit value wins. Absent an explicit value, the deadline is the maximum
|
|
13
|
+
* of `floorMs` and the request-timeout limit that the provider advertises. Thus
|
|
14
|
+
* a configured request timeout can raise the floor. It cannot lower the floor.
|
|
15
|
+
*/
|
|
16
|
+
export function effectiveDeadlineMs(provider, floorMs, explicitMs) {
|
|
17
|
+
return explicitMs ?? Math.max(floorMs, provider.requestTimeoutMs ?? 0);
|
|
18
|
+
}
|