@inflexa-ai/harness 0.37.0 → 0.38.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.
Files changed (91) hide show
  1. package/README.md +1 -2
  2. package/dist/auth/local-run-authorizer.js +5 -4
  3. package/dist/auth/types.d.ts +28 -9
  4. package/dist/auth/types.js +5 -3
  5. package/dist/billing/noop-run-charge.js +3 -2
  6. package/dist/billing/noop-usage-recorder.js +2 -1
  7. package/dist/billing/run-charge.d.ts +18 -5
  8. package/dist/billing/run-charge.js +5 -1
  9. package/dist/billing/usage-recorder.d.ts +11 -9
  10. package/dist/billing/usage-recorder.js +2 -3
  11. package/dist/contracts/chat-events.d.ts +4 -1
  12. package/dist/contracts/chat-parts.d.ts +1 -1
  13. package/dist/contracts/schemas/chat-parts.d.ts +4 -4
  14. package/dist/execution/artifact-registration.d.ts +6 -1
  15. package/dist/execution/artifact-registration.js +12 -4
  16. package/dist/execution/artifact-registry.d.ts +10 -3
  17. package/dist/execution/noop-artifact-registry.js +3 -4
  18. package/dist/execution/post-step-pipeline.d.ts +14 -3
  19. package/dist/execution/post-step-pipeline.js +21 -8
  20. package/dist/execution/run-authorizer.d.ts +5 -3
  21. package/dist/execution/run-canceler.js +4 -20
  22. package/dist/index.d.ts +13 -7
  23. package/dist/index.js +12 -6
  24. package/dist/lib/hooks.d.ts +49 -0
  25. package/dist/lib/hooks.js +39 -0
  26. package/dist/lib/result.d.ts +22 -11
  27. package/dist/lib/result.js +24 -14
  28. package/dist/loop/run-agent.d.ts +2 -2
  29. package/dist/loop/run-agent.js +5 -4
  30. package/dist/provenance/seam.d.ts +1 -1
  31. package/dist/providers/ai-sdk.d.ts +43 -5
  32. package/dist/providers/ai-sdk.js +55 -52
  33. package/dist/providers/anthropic.d.ts +2 -3
  34. package/dist/providers/anthropic.js +2 -2
  35. package/dist/providers/embedding.d.ts +13 -7
  36. package/dist/providers/embedding.js +26 -6
  37. package/dist/providers/errors.d.ts +67 -37
  38. package/dist/providers/errors.js +57 -58
  39. package/dist/providers/request-headers.d.ts +18 -0
  40. package/dist/providers/request-headers.js +22 -0
  41. package/dist/report-model/production-resolver.d.ts +5 -2
  42. package/dist/report-model/production-resolver.js +12 -2
  43. package/dist/runtime/assemble.d.ts +3 -2
  44. package/dist/runtime/dbos.d.ts +4 -3
  45. package/dist/runtime/dbos.js +18 -3
  46. package/dist/runtime/result-serialization.d.ts +29 -0
  47. package/dist/runtime/result-serialization.js +45 -0
  48. package/dist/sandbox/client.d.ts +11 -3
  49. package/dist/sandbox/create-sandbox.d.ts +5 -2
  50. package/dist/sandbox/create-sandbox.js +51 -25
  51. package/dist/sandbox/docker-client.d.ts +4 -3
  52. package/dist/sandbox/docker-client.js +23 -31
  53. package/dist/sandbox/k8s-client.d.ts +4 -13
  54. package/dist/sandbox/k8s-client.js +28 -61
  55. package/dist/sandbox/labels.d.ts +34 -0
  56. package/dist/sandbox/labels.js +47 -0
  57. package/dist/sandbox/mount-plan.d.ts +3 -1
  58. package/dist/sandbox/mount-plan.js +9 -0
  59. package/dist/sandbox/sandbox-error.d.ts +36 -13
  60. package/dist/sandbox/sandbox-error.js +32 -17
  61. package/dist/sandbox/types.d.ts +13 -9
  62. package/dist/schemas/validate-plan.js +13 -0
  63. package/dist/schemas/workflow-state.d.ts +4 -4
  64. package/dist/state/analyses.d.ts +4 -4
  65. package/dist/state/analyses.js +4 -4
  66. package/dist/state/schema.d.ts +5 -5
  67. package/dist/tasks/data-profile.d.ts +5 -8
  68. package/dist/tasks/data-profile.js +88 -43
  69. package/dist/tasks/derive-table-exec.d.ts +10 -6
  70. package/dist/tasks/derive-table-exec.js +17 -12
  71. package/dist/tasks/extract-values.d.ts +16 -10
  72. package/dist/tasks/extract-values.js +34 -18
  73. package/dist/tools/execute-analysis.js +22 -15
  74. package/dist/tools/report-session/derive-table.d.ts +9 -1
  75. package/dist/tools/report-session/derive-table.js +25 -22
  76. package/dist/tools/research/inspect-run.d.ts +1 -1
  77. package/dist/workflows/execute-analysis.d.ts +25 -16
  78. package/dist/workflows/execute-analysis.js +223 -216
  79. package/dist/workflows/metrics.d.ts +4 -3
  80. package/dist/workflows/metrics.js +4 -3
  81. package/dist/workflows/sandbox-step.d.ts +20 -29
  82. package/dist/workflows/sandbox-step.js +71 -127
  83. package/dist/workflows/suspension.d.ts +32 -0
  84. package/dist/workflows/suspension.js +38 -0
  85. package/package.json +1 -1
  86. package/dist/billing/noop-resolver.d.ts +0 -6
  87. package/dist/billing/noop-resolver.js +0 -7
  88. package/dist/billing/resolver.d.ts +0 -64
  89. package/dist/billing/resolver.js +0 -24
  90. package/dist/loop/budget-exceeded.d.ts +0 -38
  91. package/dist/loop/budget-exceeded.js +0 -88
@@ -10,7 +10,8 @@ import { ResultAsync, err, ok } from "neverthrow";
10
10
  import { scopeWorkloadId } from "../auth/types.js";
11
11
  import { createNoopLogger } from "../lib/console-logger.js";
12
12
  import { passThroughTracer } from "../lib/otel-spans.js";
13
- import { classifyProviderError, RequestTimeoutError, toProviderError } from "./errors.js";
13
+ import { DEFAULT_SUSPEND_ON, classifyProviderError, RequestTimeoutError, toProviderError } from "./errors.js";
14
+ import { headersRefusalError, requestHeadersFor } from "./request-headers.js";
14
15
  /**
15
16
  * The harness-owned retry envelope. The AI SDK's built-in retry exposes only a
16
17
  * count: its 2s initial delay and ×2 factor are fixed, it caps neither the
@@ -152,49 +153,38 @@ export function computeRetryDelayMs(error, exponentialBackoffDelay, random = Mat
152
153
  return random() * Math.min(exponentialBackoffDelay, RETRY_MAX_DELAY_MS);
153
154
  }
154
155
  /**
155
- * Marks a failure that came from the `ResolveBilling` seam rather than the model
156
- * wire. The retry envelope's budget is sized for model-provider outages; the
157
- * billing seam is a separate system whose failure must surface immediately, not
158
- * after a multi-minute retry window — even when the underlying failure is
159
- * connection-shaped and would otherwise read as retryable. The original throwable
160
- * rides on `cause` so the outer catch can hand it to `toProviderError` unchanged
161
- * and classify it byte-identically to a billing failure raised outside the
162
- * envelope (right down to the surfaced message).
156
+ * A refusal of the request headers hook inside the retry envelope. The envelope
157
+ * speaks exceptions, thus the refusal crosses it as this throw.
163
158
  */
164
- class BillingSeamFailure extends Error {
165
- constructor(cause) {
166
- super("billing resolution failed", { cause });
167
- this.name = "BillingSeamFailure";
159
+ class HeadersRefused extends Error {
160
+ refusal;
161
+ constructor(refusal) {
162
+ super(`the request headers hook refused the model call: ${refusal.reason}`);
163
+ this.refusal = refusal;
164
+ this.name = "HeadersRefused";
168
165
  }
169
166
  }
170
- /**
171
- * Resolve attribution headers for one attempt, tagging any non-abort failure as
172
- * a `BillingSeamFailure` so the retry envelope fails fast on it. An abort is
173
- * rethrown raw so it stays on the abort control-flow path.
174
- */
175
- async function resolveBillingHeaders(resolveBilling, session) {
176
- try {
177
- return await resolveBilling(session);
178
- }
179
- catch (e) {
180
- if (isAbortError(e))
181
- throw e;
182
- throw new BillingSeamFailure(e);
183
- }
167
+ /** A refusal of the hook throws `HeadersRefused`. */
168
+ export async function headersForAttempt(hook, session, call) {
169
+ call.unsettled = true;
170
+ const resolved = await requestHeadersFor(hook, session);
171
+ call.unsettled = false;
172
+ if (resolved.isErr())
173
+ throw new HeadersRefused(resolved.error);
174
+ return { ...resolved.value };
184
175
  }
185
- /** Unwrap a billing-seam failure to the original throwable for classification. */
186
- function unwrapForClassification(e) {
187
- return e instanceof BillingSeamFailure ? e.cause : e;
176
+ export function failureOf(e, workload, suspendOn) {
177
+ return e instanceof HeadersRefused ? headersRefusalError(e.refusal, workload) : toProviderError(e, workload, suspendOn);
188
178
  }
189
179
  /**
190
180
  * Wrap a single wire attempt in the harness retry envelope. Built per call so
191
181
  * the caller's `AbortSignal` reaches the primitive: it rethrows abort errors
192
182
  * without retrying and cancels its own backoff sleep when the signal fires, so
193
183
  * the retried closure adds no abort handling of its own. `shouldRetry` defers to
194
- * the harness retryability taxonomy — but never retries a `BillingSeamFailure`,
195
- * whose fail-fast is the whole point of the marker — and a non-retried first
196
- * failure is rethrown untouched, preserving the exact throwable the outer catch
197
- * classifies. `createRetryError` carries the last real failure on `cause` so
184
+ * the harness retryability taxonomy under the `suspendOn` map of the provider —
185
+ * thus a mapped status is never retried — and it never retries a refusal or a
186
+ * rejection of the request headers hook. A non-retried first failure is rethrown untouched,
187
+ * preserving the exact throwable the outer catch classifies. `createRetryError` carries the last real failure on `cause` so
198
188
  * that, once retries are exhausted, `toProviderError`'s status walk reaches the
199
189
  * true HTTP status instead of stopping at a synthetic wrapper. The attempt
200
190
  * counter is per-instance because each call builds its own retry.
@@ -204,21 +194,27 @@ function unwrapForClassification(e) {
204
194
  * timeout. Without this guard the envelope would loop an expiry that the caller
205
195
  * itself declared.
206
196
  */
207
- function createRetry(signal, logger, maxRetries) {
197
+ export function createRetry(signal, logger, maxRetries, suspendOn, hookCall) {
208
198
  let retryCount = 0;
209
199
  return retryWithExponentialBackoff({
210
200
  maxRetries,
211
201
  initialDelayInMs: RETRY_INITIAL_DELAY_MS,
212
202
  backoffFactor: RETRY_BACKOFF_FACTOR,
213
203
  abortSignal: signal,
214
- shouldRetry: (e) => !(e instanceof BillingSeamFailure) && signal?.aborted !== true && classifyProviderError(e).retryable,
204
+ shouldRetry: (e) => !hookCall.unsettled && !(e instanceof HeadersRefused) && signal?.aborted !== true && classifyProviderError(e, suspendOn).retryable,
215
205
  getDelayInMs: ({ error, exponentialBackoffDelay }) => {
216
206
  const delayMs = computeRetryDelayMs(error, exponentialBackoffDelay);
217
207
  retryCount += 1;
218
208
  logger.debug("retrying provider call", { attempt: retryCount, delayMs, ...logger.errorFields(error) });
219
209
  return delayMs;
220
210
  },
221
- createRetryError: ({ message, errors }) => new Error(message, { cause: errors[errors.length - 1] }),
211
+ createRetryError: ({ message, errors }) => {
212
+ const last = errors[errors.length - 1];
213
+ // A refusal of the hook on a later attempt ends the call as that refusal, with its
214
+ // suspend flag, and never as a wire failure whose message text the taxonomy reads.
215
+ // A rejection of the hook leaves the envelope unchanged.
216
+ return hookCall.unsettled || last instanceof HeadersRefused ? last : new Error(message, { cause: last });
217
+ },
222
218
  });
223
219
  }
224
220
  /**
@@ -436,6 +432,7 @@ export function createAiSdkProvider(deps) {
436
432
  // stream, thus the two cannot disagree about the ceiling of a request.
437
433
  const outputCeiling = maxOutputTokens === "provider-maximum" ? {} : { maxOutputTokens };
438
434
  const maxRetries = deps.maxRetries ?? RETRY_MAX_RETRIES;
435
+ const suspendOn = deps.suspendOn ?? DEFAULT_SUSPEND_ON;
439
436
  const requestTimeoutMs = deps.requestTimeoutMs;
440
437
  const requestedModelId = requestedModelIdOf(deps.model);
441
438
  // Each model call emits OpenTelemetry GenAI spans through the AI SDK, with no
@@ -466,13 +463,14 @@ export function createAiSdkProvider(deps) {
466
463
  }
467
464
  function chat(req, session, signal) {
468
465
  const run = async () => {
469
- const retry = createRetry(signal, logger, maxRetries);
466
+ const hookCall = { unsettled: false };
467
+ const retry = createRetry(signal, logger, maxRetries, suspendOn, hookCall);
470
468
  const capture = captureServedModelId(deps.model);
471
469
  try {
472
470
  const collected = await retry(async () => {
473
- // Attribution headers are time-limited; resolving them inside the
474
- // retried closure keeps them fresh across a multi-minute window.
475
- const headers = await resolveBillingHeaders(deps.resolveBilling, session);
471
+ // The hook runs before each attempt: a host header can be
472
+ // time-limited, and the retry window can last minutes.
473
+ const headers = await headersForAttempt(deps.resolveRequestHeaders, session, hookCall);
476
474
  // The call streams on the wire and collapses below.
477
475
  //
478
476
  // A non-streaming turn sends no header until the model completes,
@@ -543,9 +541,9 @@ export function createAiSdkProvider(deps) {
543
541
  }));
544
542
  }
545
543
  catch (e) {
546
- if (isAbortError(e) || signal?.aborted)
544
+ if (isAbortError(e) || signal?.aborted || hookCall.unsettled)
547
545
  throw e;
548
- const failure = toProviderError(unwrapForClassification(e), workloadOf(session));
546
+ const failure = failureOf(e, workloadOf(session), suspendOn);
549
547
  logFailure(session, failure, e);
550
548
  return err(failure);
551
549
  }
@@ -553,7 +551,8 @@ export function createAiSdkProvider(deps) {
553
551
  return new ResultAsync(run());
554
552
  }
555
553
  async function* chatStream(req, session, signal) {
556
- const retry = createRetry(signal, logger, maxRetries);
554
+ const hookCall = { unsettled: false };
555
+ const retry = createRetry(signal, logger, maxRetries, suspendOn, hookCall);
557
556
  const capture = captureServedModelId(deps.model);
558
557
  try {
559
558
  // Retry covers only stream establishment: streamText defers wire errors
@@ -562,10 +561,8 @@ export function createAiSdkProvider(deps) {
562
561
  // live with a first delta in hand, or it finished without yielding any
563
562
  // text — and a delta is only ever yielded OUTSIDE the closure, so a
564
563
  // retried attempt can never re-emit a delta the consumer already saw.
565
- // Resolving billing inside the closure keeps attribution headers fresh
566
- // per attempt.
567
564
  const opened = await retry(async () => {
568
- const headers = await resolveBillingHeaders(deps.resolveBilling, session);
565
+ const headers = await headersForAttempt(deps.resolveRequestHeaders, session, hookCall);
569
566
  const result = streamText({
570
567
  model: capture.model,
571
568
  system: req.system,
@@ -662,9 +659,9 @@ export function createAiSdkProvider(deps) {
662
659
  yield { type: "done", response };
663
660
  }
664
661
  catch (e) {
665
- if (isAbortError(e) || signal?.aborted)
662
+ if (isAbortError(e) || signal?.aborted || hookCall.unsettled)
666
663
  throw e;
667
- const failure = toProviderError(unwrapForClassification(e), workloadOf(session));
664
+ const failure = failureOf(e, workloadOf(session), suspendOn);
668
665
  logFailure(session, failure, e);
669
666
  throw failure;
670
667
  }
@@ -749,6 +746,12 @@ function withStoreDirective(model, store) {
749
746
  },
750
747
  });
751
748
  }
749
+ export function hostPolicyOf(deps) {
750
+ return {
751
+ ...(deps.resolveRequestHeaders !== undefined ? { resolveRequestHeaders: deps.resolveRequestHeaders } : {}),
752
+ ...(deps.suspendOn !== undefined ? { suspendOn: deps.suspendOn } : {}),
753
+ };
754
+ }
752
755
  /**
753
756
  * Realize an `AiSdkProviderConfig` into a `ChatProvider` bound to that config's
754
757
  * connection and model. The model is closed into the returned provider here (it
@@ -782,7 +785,7 @@ export function createConfiguredAiSdkProvider(deps) {
782
785
  const pictureDefault = config.baseURL === undefined ? { imageToolResults: true } : {};
783
786
  return createAiSdkProvider({
784
787
  model: provider.chat(config.model),
785
- resolveBilling: deps.resolveBilling,
788
+ ...hostPolicyOf(deps),
786
789
  capabilities: { ...pictureDefault, ...config.capabilities },
787
790
  logger: deps.logger,
788
791
  ...(config.maxOutputTokens !== undefined ? { maxOutputTokens: config.maxOutputTokens } : {}),
@@ -809,7 +812,7 @@ export function createConfiguredAiSdkProvider(deps) {
809
812
  // factory callable of the package resolves to the same path today,
810
813
  // but nothing in the package holds it there.
811
814
  model: withStoreDirective(provider.responses(config.model), config.store ?? false),
812
- resolveBilling: deps.resolveBilling,
815
+ ...hostPolicyOf(deps),
813
816
  capabilities: { ...pictureDefault, ...config.capabilities },
814
817
  logger: deps.logger,
815
818
  // The package puts this number on the wire as it is, and it clamps
@@ -832,7 +835,7 @@ export function createConfiguredAiSdkProvider(deps) {
832
835
  // The compatible package reads the neutral `reasoning` of the call, thus
833
836
  // the seam needs no middleware to carry the depth into its namespace.
834
837
  model: provider.chatModel(config.model),
835
- resolveBilling: deps.resolveBilling,
838
+ ...hostPolicyOf(deps),
836
839
  capabilities: config.capabilities,
837
840
  logger: deps.logger,
838
841
  ...(config.maxOutputTokens !== undefined ? { maxOutputTokens: config.maxOutputTokens } : {}),
@@ -4,14 +4,13 @@
4
4
  * The production provider path is AI SDK-backed; this wrapper preserves the
5
5
  * old factory name for embedders while returning the new `ChatProvider` seam.
6
6
  */
7
- import type { ResolveBilling } from "../billing/resolver.js";
8
7
  import type { Logger } from "../lib/logger.js";
8
+ import { type ProviderHostPolicy } from "./ai-sdk.js";
9
9
  import type { ChatProvider, FetchLike } from "./types.js";
10
- export interface AnthropicProviderDeps {
10
+ export interface AnthropicProviderDeps extends ProviderHostPolicy {
11
11
  readonly baseURL?: string;
12
12
  readonly token: string;
13
13
  readonly model: string;
14
- readonly resolveBilling: ResolveBilling;
15
14
  readonly fetch?: FetchLike;
16
15
  readonly logger?: Logger;
17
16
  /** Output-token ceiling per request. Defaults to `DEFAULT_MAX_OUTPUT_TOKENS`. */
@@ -4,7 +4,7 @@
4
4
  * The production provider path is AI SDK-backed; this wrapper preserves the
5
5
  * old factory name for embedders while returning the new `ChatProvider` seam.
6
6
  */
7
- import { createConfiguredAiSdkProvider } from "./ai-sdk.js";
7
+ import { createConfiguredAiSdkProvider, hostPolicyOf } from "./ai-sdk.js";
8
8
  /**
9
9
  * Convenience over the `anthropic` arm of the public `AiSdkProviderConfig`
10
10
  * union: it takes the Anthropic connection fields directly (`token` as the api
@@ -13,7 +13,7 @@ import { createConfiguredAiSdkProvider } from "./ai-sdk.js";
13
13
  */
14
14
  export function createAnthropicProvider(deps) {
15
15
  return createConfiguredAiSdkProvider({
16
- resolveBilling: deps.resolveBilling,
16
+ ...hostPolicyOf(deps),
17
17
  logger: deps.logger,
18
18
  config: {
19
19
  kind: "anthropic",
@@ -2,15 +2,18 @@
2
2
  * Embedding provider.
3
3
  *
4
4
  * The ONLY file in the harness that imports `openai`. The SDK client is
5
- * pointed at the billing-gateway base URL; per-call billing headers are assembled
6
- * from the `Session`. Embeds with OpenAI `text-embedding-3-small`.
5
+ * pointed at the gateway base URL of the host; the optional request headers
6
+ * hook of the host gives the headers of each attempt. Embeds with OpenAI
7
+ * `text-embedding-3-small`.
7
8
  */
8
- import type { ResolveBilling } from "../billing/resolver.js";
9
+ import type { Logger } from "../lib/logger.js";
10
+ import { type SuspendOn } from "./errors.js";
11
+ import type { ResolveRequestHeaders } from "./request-headers.js";
9
12
  import type { EmbeddingProvider, FetchLike } from "./types.js";
10
13
  export interface EmbeddingProviderDeps {
11
- /** Billing-gateway base URL — all embedding traffic is routed through it. */
14
+ /** Gateway base URL — all embedding traffic is routed through it. */
12
15
  readonly baseURL: string;
13
- /** API token presented to the billing gateway. */
16
+ /** API token presented to the gateway. */
14
17
  readonly token: string;
15
18
  /** Embedding model id. Defaults to `text-embedding-3-small`. */
16
19
  readonly model?: string;
@@ -21,8 +24,11 @@ export interface EmbeddingProviderDeps {
21
24
  * the matching width or the per-analysis index is created at the wrong size.
22
25
  */
23
26
  readonly dimensions?: number;
24
- /** Resolves the billing attribution map at the call site. */
25
- readonly resolveBilling: ResolveBilling;
27
+ readonly resolveRequestHeaders?: ResolveRequestHeaders;
28
+ /** Absent, the provider uses `DEFAULT_SUSPEND_ON`. */
29
+ readonly suspendOn?: SuspendOn;
30
+ /** Diagnostics sink for the retry envelope. Defaults to a no-op. */
31
+ readonly logger?: Logger;
26
32
  /**
27
33
  * `fetch` override. Production omits it (the SDK's default is used);
28
34
  * tests inject a fake to feed a recorded response.
@@ -2,38 +2,58 @@
2
2
  * Embedding provider.
3
3
  *
4
4
  * The ONLY file in the harness that imports `openai`. The SDK client is
5
- * pointed at the billing-gateway base URL; per-call billing headers are assembled
6
- * from the `Session`. Embeds with OpenAI `text-embedding-3-small`.
5
+ * pointed at the gateway base URL of the host; the optional request headers
6
+ * hook of the host gives the headers of each attempt. Embeds with OpenAI
7
+ * `text-embedding-3-small`.
7
8
  */
8
9
  import OpenAI from "openai";
9
10
  import { ResultAsync, err, ok, okAsync } from "neverthrow";
10
11
  import { scopeWorkloadId } from "../auth/types.js";
11
- import { toProviderError } from "./errors.js";
12
+ import { createNoopLogger } from "../lib/console-logger.js";
13
+ import { createRetry, failureOf, headersForAttempt } from "./ai-sdk.js";
14
+ import { DEFAULT_SUSPEND_ON } from "./errors.js";
12
15
  const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
13
16
  /** Vector width of the default model — `text-embedding-3-small` emits 1536-dim vectors. */
14
17
  const DEFAULT_EMBEDDING_DIMENSIONS = 1536;
18
+ /**
19
+ * The retries of one embed call: the default count of the OpenAI client. The
20
+ * harness envelope owns them, and the client makes one attempt, thus the
21
+ * request headers hook runs before each attempt and a mapped status is never
22
+ * retried.
23
+ */
24
+ const EMBEDDING_MAX_RETRIES = 2;
15
25
  export function createEmbeddingProvider(deps) {
16
26
  const client = new OpenAI({
17
27
  baseURL: deps.baseURL,
18
28
  apiKey: deps.token,
29
+ maxRetries: 0,
19
30
  ...(deps.fetch ? { fetch: deps.fetch } : {}),
20
31
  });
21
32
  const model = deps.model ?? DEFAULT_EMBEDDING_MODEL;
22
33
  const dimensions = deps.dimensions ?? DEFAULT_EMBEDDING_DIMENSIONS;
34
+ const suspendOn = deps.suspendOn ?? DEFAULT_SUSPEND_ON;
35
+ const logger = deps.logger ?? createNoopLogger();
23
36
  function embed(texts, session) {
24
37
  if (texts.length === 0)
25
38
  return okAsync([]);
26
39
  const workload = `${session.scope.kind}:${scopeWorkloadId(session.scope)}`;
27
40
  const run = async () => {
41
+ const hookCall = { unsettled: false };
42
+ const retry = createRetry(undefined, logger, EMBEDDING_MAX_RETRIES, suspendOn, hookCall);
28
43
  try {
29
- const headers = await deps.resolveBilling(session);
30
- const response = await client.embeddings.create({ model, input: [...texts], encoding_format: "float" }, { headers });
44
+ const response = await retry(async () => {
45
+ const headers = await headersForAttempt(deps.resolveRequestHeaders, session, hookCall);
46
+ return await client.embeddings.create({ model, input: [...texts], encoding_format: "float" }, { headers });
47
+ });
31
48
  // The API does not guarantee response order; re-key by `index`.
32
49
  const rows = [...response.data].sort((a, b) => a.index - b.index).map((d) => d.embedding);
33
50
  return ok(rows);
34
51
  }
35
52
  catch (e) {
36
- return err(toProviderError(e, workload));
53
+ // A rejection of the hook is a defect of the host, and it passes through with no change.
54
+ if (hookCall.unsettled)
55
+ throw e;
56
+ return err(failureOf(e, workload, suspendOn));
37
57
  }
38
58
  };
39
59
  return new ResultAsync(run());
@@ -2,18 +2,20 @@
2
2
  * Provider error taxonomy.
3
3
  *
4
4
  * Callers (the agent loop, the chat route, DBOS steps) branch on error
5
- * origin: a billing-gateway governance rejection is permanent and user-facing; a
5
+ * origin: a status that the host maps to a suspension stops the work; a
6
6
  * transient upstream hiccup is retryable. This module owns the
7
7
  * classification so callers do not re-derive it.
8
8
  *
9
- * The billing-gateway convention is status-code driven (no dedicated error header
10
- * is required to disambiguate): the gateway emits `402` exclusively for
11
- * `budget_exceeded` and `403` for a blocked tenant. Provider-originated
12
- * failures (429, 5xx, connection) surface with their own status or as a
13
- * network error with no status at all. A `401` is `auth`: the credential the
14
- * host put behind the call is expired, revoked, or absent, and only a human
15
- * re-authenticating fixes it — which is a different remedy from every other
16
- * non-retryable 4xx, so it is a different kind.
9
+ * The classification keys on the HTTP status alone. The meaning of a status
10
+ * belongs to the gateway of the host, thus each provider takes a map from a
11
+ * status to a suspend reason (`suspendOn`). A status in the map is a `suspend`
12
+ * error, before each other rule, and it is never retried. The harness carries
13
+ * the reason and never reads it. Provider-originated failures (429, 5xx,
14
+ * connection) surface with their own status or as a network error with no
15
+ * status at all. A `401` is `auth`: the credential the host put behind the call
16
+ * is expired, revoked, or absent, and only a human re-authenticating fixes it —
17
+ * which is a different remedy from every other non-retryable 4xx, so it is a
18
+ * different kind.
17
19
  */
18
20
  /**
19
21
  * The sentinel that the request-timeout guard uses as an abort reason. The guard
@@ -29,15 +31,25 @@ export declare class RequestTimeoutError extends Error {
29
31
  readonly requestTimeoutMs: number;
30
32
  constructor(requestTimeoutMs: number);
31
33
  }
32
- export type ProviderErrorKind = "auth" | "budget" | "tenant-blocked" | "provider";
34
+ export type ProviderErrorKind = "auth" | "suspend" | "provider";
35
+ /**
36
+ * A map from an HTTP status to the suspend reason of the host. A map from the
37
+ * host replaces {@link DEFAULT_SUSPEND_ON}, and the two maps do not merge.
38
+ */
39
+ export type SuspendOn = Readonly<Record<number, string>>;
40
+ /** The map of a provider that the host gives no map: `402` names a payment that the gateway requires. */
41
+ export declare const DEFAULT_SUSPEND_ON: SuspendOn;
33
42
  /**
34
43
  * The provider error value channel — a `DomainError`-conforming
35
44
  * discriminated union mirroring `ProviderErrorKind`. `chat` / `embed` return
36
45
  * `err(ProviderError)` instead of throwing a typed Error; `toProviderError`
37
- * is the sole constructor. `cause` carries the original SDK throwable so the
38
- * cause-walking classifiers (`isBudgetExceeded`, `classifyProviderError`)
39
- * still reach the `status` / `code` signals after `toThrowable` rethrows at a
40
- * step boundary.
46
+ * is the sole constructor of a classified failure. `cause` carries the
47
+ * original SDK throwable so the cause-walking classifier
48
+ * (`classifyProviderError`) still reaches the `status` / `code` signals after
49
+ * `toThrowable` rethrows at a step boundary.
50
+ *
51
+ * A `suspend` error carries the `reason` of the host. It carries no `status`
52
+ * when the request headers hook refused the call with the suspend flag.
41
53
  */
42
54
  export type ProviderError = {
43
55
  readonly type: "auth";
@@ -45,13 +57,10 @@ export type ProviderError = {
45
57
  readonly message: string;
46
58
  readonly cause?: unknown;
47
59
  } | {
48
- readonly type: "budget";
49
- readonly retryable: false;
50
- readonly message: string;
51
- readonly cause?: unknown;
52
- } | {
53
- readonly type: "tenant-blocked";
60
+ readonly type: "suspend";
54
61
  readonly retryable: false;
62
+ readonly reason: string;
63
+ readonly status?: number;
55
64
  readonly message: string;
56
65
  readonly cause?: unknown;
57
66
  } | {
@@ -67,12 +76,25 @@ export type ProviderError = {
67
76
  * always-present fields. Used by `toProviderError` to stay idempotent.
68
77
  */
69
78
  export declare function isProviderError(value: unknown): value is ProviderError;
79
+ export type SuspendError = Extract<ProviderError, {
80
+ readonly type: "suspend";
81
+ }>;
82
+ /**
83
+ * Find a `suspend` error on a failure or on its `cause` chain. A step boundary
84
+ * rethrows a `ProviderError` inside a `ResultError`, with the value on
85
+ * `.cause`, thus the walk reaches it.
86
+ *
87
+ * A failed step that DBOS replays gives the error that DBOS recorded. That
88
+ * record keeps only the enumerable fields of a `ResultError`: `.value` stays,
89
+ * and the non-enumerable `.cause` does not. Thus the walk also reads `.value`.
90
+ */
91
+ export declare function findSuspendError(err: unknown): SuspendError | undefined;
70
92
  /**
71
93
  * Turn a caught SDK throwable into a `ProviderError` value. Routes through
72
- * `classifyProviderError` so the `provider` variant's `retryable` keeps the
73
- * transient (429 / 5xx / connection) classification. `cause` is the original
74
- * throwable verbatim — for the budget variant it MUST be the SDK error
75
- * carrying status 402, which is what `isBudgetExceeded` walks.
94
+ * `classifyProviderError` with the `suspendOn` map of the provider, so the
95
+ * `provider` variant's `retryable` keeps the transient (429 / 5xx / connection)
96
+ * classification and a mapped status becomes a `suspend` error. `cause` is the
97
+ * original throwable verbatim.
76
98
  *
77
99
  * Idempotent: `chatStream` throws a `ProviderError` value, which
78
100
  * `streaming-chat`'s `catch` re-wraps by calling this again. Returning an
@@ -80,17 +102,25 @@ export declare function isProviderError(value: unknown): value is ProviderError;
80
102
  * `String()`-ing the object (a `ProviderError` is not an `Error`) into a
81
103
  * `"[object Object]"` message that would bury the real inner one.
82
104
  */
83
- export declare function toProviderError(e: unknown, workload: string): ProviderError;
84
- export interface ProviderErrorClassification {
85
- readonly kind: ProviderErrorKind;
86
- /**
87
- * Whether re-issuing the same call could plausibly succeed. The Anthropic
88
- * SDK already retries transient failures internally; a `retryable: true`
89
- * classification on an error that still reached here tells the caller the
90
- * failure is transient in nature, not that a retry is mandatory.
91
- */
105
+ export declare function toProviderError(e: unknown, workload: string, suspendOn?: SuspendOn): ProviderError;
106
+ /**
107
+ * `retryable` says whether re-issuing the same call could plausibly succeed.
108
+ * The Anthropic SDK already retries transient failures internally; a
109
+ * `retryable: true` classification on an error that still reached here tells
110
+ * the caller the failure is transient in nature, not that a retry is mandatory.
111
+ */
112
+ export type ProviderErrorClassification = {
113
+ readonly kind: "suspend";
114
+ readonly retryable: false;
115
+ readonly reason: string;
116
+ readonly status: number;
117
+ } | {
118
+ readonly kind: "auth";
119
+ readonly retryable: false;
120
+ } | {
121
+ readonly kind: "provider";
92
122
  readonly retryable: boolean;
93
- }
123
+ };
94
124
  /**
95
125
  * Walk the `cause` chain for the first numeric HTTP status. The single status
96
126
  * walker: `classifyProviderError` keys on what this returns, so a caller asking
@@ -101,10 +131,10 @@ export declare function extractStatus(err: unknown): number | undefined;
101
131
  /**
102
132
  * Classify a provider failure by origin.
103
133
  *
134
+ * - A status in `suspendOn` → `suspend` with the reason of the map, not
135
+ * retryable. This rule comes before each other status rule.
104
136
  * - Provider `401` → `auth`, not retryable.
105
- * - Billing-gateway `402` → `budget`, not retryable.
106
- * - Billing-gateway `403` → `tenant-blocked`, not retryable.
107
137
  * - Provider `429` / `5xx` / connection errors → `provider`, retryable.
108
138
  * - Other `4xx` and parse / unknown errors → `provider`, not retryable.
109
139
  */
110
- export declare function classifyProviderError(e: unknown): ProviderErrorClassification;
140
+ export declare function classifyProviderError(e: unknown, suspendOn?: SuspendOn): ProviderErrorClassification;