@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.
Files changed (83) hide show
  1. package/dist/agents/report-session-agent.d.ts +72 -0
  2. package/dist/agents/report-session-agent.js +87 -0
  3. package/dist/app/report-session-runtime.d.ts +72 -0
  4. package/dist/app/report-session-runtime.js +183 -0
  5. package/dist/app/spawn-report-session.d.ts +32 -5
  6. package/dist/app/spawn-report-session.js +19 -0
  7. package/dist/config/environment-stores.d.ts +12 -7
  8. package/dist/config/resource-limits.d.ts +6 -10
  9. package/dist/config/resource-limits.js +6 -44
  10. package/dist/contracts/report-reference.d.ts +4 -0
  11. package/dist/contracts/report-reference.js +3 -0
  12. package/dist/index.d.ts +2 -1
  13. package/dist/lib/chrome.d.ts +14 -0
  14. package/dist/lib/chrome.js +7 -0
  15. package/dist/lib/page-capture.d.ts +47 -0
  16. package/dist/lib/page-capture.js +83 -0
  17. package/dist/loop/run-agent.js +104 -15
  18. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  19. package/dist/memory/ai-sdk-message-storage.js +9 -4
  20. package/dist/memory/count-tokens.js +34 -5
  21. package/dist/memory/thread-store.d.ts +4 -4
  22. package/dist/memory/thread-store.js +18 -9
  23. package/dist/prompts/report-session.d.ts +1 -0
  24. package/dist/prompts/report-session.js +110 -0
  25. package/dist/providers/ai-sdk.d.ts +158 -3
  26. package/dist/providers/ai-sdk.js +234 -23
  27. package/dist/providers/errors.d.ts +14 -0
  28. package/dist/providers/errors.js +103 -18
  29. package/dist/providers/types.d.ts +33 -0
  30. package/dist/providers/types.js +10 -1
  31. package/dist/report-model/assert-rules.d.ts +44 -0
  32. package/dist/report-model/assert-rules.js +97 -0
  33. package/dist/report-model/draft-hash.d.ts +14 -0
  34. package/dist/report-model/draft-hash.js +36 -0
  35. package/dist/report-model/fixture-resolver.js +2 -71
  36. package/dist/report-model/{mint-snapshot.d.ts → pin-snapshot.d.ts} +7 -7
  37. package/dist/report-model/{mint-snapshot.js → pin-snapshot.js} +7 -7
  38. package/dist/report-model/production-resolver.d.ts +124 -0
  39. package/dist/report-model/production-resolver.js +801 -0
  40. package/dist/report-model/reference-resolver.d.ts +7 -1
  41. package/dist/report-model/validate.d.ts +34 -2
  42. package/dist/report-model/validate.js +37 -17
  43. package/dist/report-render/page.d.ts +23 -0
  44. package/dist/report-render/page.js +36 -3
  45. package/dist/report-render/value-bridge.d.ts +62 -0
  46. package/dist/report-render/value-bridge.js +82 -0
  47. package/dist/runtime/assemble.d.ts +39 -0
  48. package/dist/runtime/assemble.js +67 -6
  49. package/dist/sandbox/mount-plan.d.ts +7 -0
  50. package/dist/sandbox/mount-plan.js +7 -2
  51. package/dist/state/init.js +36 -0
  52. package/dist/state/purge-analysis.js +1 -0
  53. package/dist/state/report-session-state.d.ts +139 -0
  54. package/dist/state/report-session-state.js +177 -0
  55. package/dist/state/report-versions.d.ts +3 -6
  56. package/dist/state/report-versions.js +11 -38
  57. package/dist/state/snapshot-parse.d.ts +34 -0
  58. package/dist/state/snapshot-parse.js +82 -0
  59. package/dist/tasks/extract-values-script.d.ts +64 -0
  60. package/dist/tasks/extract-values-script.js +180 -0
  61. package/dist/tasks/extract-values.d.ts +124 -0
  62. package/dist/tasks/extract-values.js +193 -0
  63. package/dist/tools/ad-hoc-router.d.ts +7 -2
  64. package/dist/tools/ad-hoc-router.js +2 -1
  65. package/dist/tools/define-tool.d.ts +29 -0
  66. package/dist/tools/define-tool.js +26 -0
  67. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  68. package/dist/tools/report/preview-snapshot.js +11 -50
  69. package/dist/tools/report-authoring/authoring-tools.d.ts +181 -24
  70. package/dist/tools/report-authoring/authoring-tools.js +152 -44
  71. package/dist/tools/report-session/examine-page.d.ts +90 -0
  72. package/dist/tools/report-session/examine-page.js +136 -0
  73. package/dist/tools/report-session/index.d.ts +10 -0
  74. package/dist/tools/report-session/index.js +10 -0
  75. package/dist/tools/report-session/preview-report.d.ts +105 -0
  76. package/dist/tools/report-session/preview-report.js +269 -0
  77. package/dist/tools/report-session/record-version.d.ts +102 -0
  78. package/dist/tools/report-session/record-version.js +139 -0
  79. package/dist/tools/research/generate-plan.js +4 -2
  80. package/dist/tools/sandbox/list-available-packages.js +2 -1
  81. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  82. package/dist/tools/sandbox/list-available-refs.js +13 -6
  83. package/package.json +10 -8
@@ -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: RETRY_MAX_RETRIES,
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 = { toolCalling: deps.capabilities?.toolCalling ?? true };
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
- maxOutputTokens,
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
- maxOutputTokens,
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.textStream[Symbol.asyncIterator]();
401
- const first = await iterator.next();
402
- if (first.done) {
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 first
412
- // pull already reported the stream done.)
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.value };
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 next = await iterator.next(); !next.done; next = await iterator.next()) {
442
- text += next.value;
443
- yield { type: "text-delta", text: next.value };
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
- return { capabilities, chat, chatStream };
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: config.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: config.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
@@ -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
- let cursor = err;
160
- for (let i = 0; i < MAX_CAUSE_HOPS && cursor; i++) {
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
- cursor = e.cause;
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
- let cursor = err;
192
- for (let i = 0; i < MAX_CAUSE_HOPS && cursor; i++) {
193
- const e = cursor;
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
- cursor = e.cause;
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
@@ -6,4 +6,13 @@
6
6
  * attribution is resolved lazily at the provider wire boundary.
7
7
  */
8
8
  const _assertChatUsageFields = true;
9
- export {};
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
+ }