@inflexa-ai/harness 0.37.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -2
- package/dist/auth/local-run-authorizer.js +5 -4
- package/dist/auth/types.d.ts +28 -9
- package/dist/auth/types.js +5 -3
- package/dist/billing/cached-resolver.d.ts +11 -7
- package/dist/billing/cached-resolver.js +17 -11
- package/dist/billing/noop-run-charge.js +3 -2
- package/dist/billing/noop-usage-recorder.js +2 -1
- package/dist/billing/run-charge.d.ts +18 -5
- package/dist/billing/run-charge.js +5 -1
- package/dist/billing/usage-recorder.d.ts +11 -9
- package/dist/billing/usage-recorder.js +2 -3
- package/dist/contracts/chat-events.d.ts +4 -1
- package/dist/contracts/chat-parts.d.ts +1 -1
- package/dist/contracts/schemas/chat-parts.d.ts +4 -4
- package/dist/execution/artifact-registration.d.ts +6 -1
- package/dist/execution/artifact-registration.js +12 -4
- package/dist/execution/artifact-registry.d.ts +10 -3
- package/dist/execution/noop-artifact-registry.js +3 -4
- package/dist/execution/post-step-pipeline.d.ts +14 -3
- package/dist/execution/post-step-pipeline.js +21 -8
- package/dist/execution/run-authorizer.d.ts +5 -3
- package/dist/execution/run-canceler.js +4 -20
- package/dist/index.d.ts +14 -8
- package/dist/index.js +12 -6
- package/dist/lib/hooks.d.ts +49 -0
- package/dist/lib/hooks.js +39 -0
- package/dist/lib/result.d.ts +22 -11
- package/dist/lib/result.js +24 -14
- package/dist/loop/run-agent.d.ts +2 -2
- package/dist/loop/run-agent.js +5 -4
- package/dist/provenance/seam.d.ts +1 -1
- package/dist/providers/ai-sdk.d.ts +43 -5
- package/dist/providers/ai-sdk.js +55 -52
- package/dist/providers/anthropic.d.ts +2 -3
- package/dist/providers/anthropic.js +2 -2
- package/dist/providers/embedding.d.ts +13 -7
- package/dist/providers/embedding.js +26 -6
- package/dist/providers/errors.d.ts +67 -37
- package/dist/providers/errors.js +57 -58
- package/dist/providers/request-headers.d.ts +18 -0
- package/dist/providers/request-headers.js +22 -0
- package/dist/report-model/production-resolver.d.ts +5 -2
- package/dist/report-model/production-resolver.js +12 -2
- package/dist/runtime/assemble.d.ts +3 -2
- package/dist/runtime/dbos.d.ts +4 -3
- package/dist/runtime/dbos.js +18 -3
- package/dist/runtime/result-serialization.d.ts +29 -0
- package/dist/runtime/result-serialization.js +45 -0
- package/dist/sandbox/client.d.ts +11 -3
- package/dist/sandbox/create-sandbox.d.ts +5 -2
- package/dist/sandbox/create-sandbox.js +51 -25
- package/dist/sandbox/docker-client.d.ts +4 -3
- package/dist/sandbox/docker-client.js +23 -31
- package/dist/sandbox/k8s-client.d.ts +4 -13
- package/dist/sandbox/k8s-client.js +28 -61
- package/dist/sandbox/labels.d.ts +34 -0
- package/dist/sandbox/labels.js +47 -0
- package/dist/sandbox/mount-plan.d.ts +3 -1
- package/dist/sandbox/mount-plan.js +9 -0
- package/dist/sandbox/sandbox-error.d.ts +36 -13
- package/dist/sandbox/sandbox-error.js +32 -17
- package/dist/sandbox/types.d.ts +18 -10
- package/dist/schemas/validate-plan.js +13 -0
- package/dist/schemas/workflow-state.d.ts +4 -4
- package/dist/state/analyses.d.ts +4 -4
- package/dist/state/analyses.js +4 -4
- package/dist/state/schema.d.ts +5 -5
- package/dist/tasks/data-profile.d.ts +5 -8
- package/dist/tasks/data-profile.js +88 -43
- package/dist/tasks/derive-table-exec.d.ts +10 -6
- package/dist/tasks/derive-table-exec.js +17 -12
- package/dist/tasks/extract-values.d.ts +16 -10
- package/dist/tasks/extract-values.js +34 -18
- package/dist/tools/execute-analysis.js +29 -24
- package/dist/tools/report-session/derive-table.d.ts +9 -1
- package/dist/tools/report-session/derive-table.js +25 -22
- package/dist/tools/report-session/examine-page.d.ts +6 -3
- package/dist/tools/report-session/examine-page.js +16 -8
- package/dist/tools/report-session/index.d.ts +1 -1
- package/dist/tools/report-session/preview-report.js +11 -9
- package/dist/tools/report-session/session-page-publisher.d.ts +17 -21
- package/dist/tools/report-session/session-page-publisher.js +5 -7
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/tools/sandbox/link-packages.d.ts +8 -0
- package/dist/tools/sandbox/link-packages.js +19 -9
- package/dist/workflows/execute-analysis.d.ts +25 -16
- package/dist/workflows/execute-analysis.js +223 -216
- package/dist/workflows/metrics.d.ts +4 -3
- package/dist/workflows/metrics.js +4 -3
- package/dist/workflows/sandbox-step.d.ts +20 -29
- package/dist/workflows/sandbox-step.js +71 -127
- package/dist/workflows/suspension.d.ts +32 -0
- package/dist/workflows/suspension.js +38 -0
- package/package.json +1 -1
- package/dist/billing/noop-resolver.d.ts +0 -6
- package/dist/billing/noop-resolver.js +0 -7
- package/dist/billing/resolver.d.ts +0 -64
- package/dist/billing/resolver.js +0 -24
- package/dist/loop/budget-exceeded.d.ts +0 -38
- package/dist/loop/budget-exceeded.js +0 -88
package/dist/providers/ai-sdk.js
CHANGED
|
@@ -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
|
-
*
|
|
156
|
-
*
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
|
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 }) =>
|
|
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
|
|
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
|
-
//
|
|
474
|
-
//
|
|
475
|
-
const headers = await
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
14
|
+
/** Gateway base URL — all embedding traffic is routed through it. */
|
|
12
15
|
readonly baseURL: string;
|
|
13
|
-
/** API token presented to the
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
6
|
-
*
|
|
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 {
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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" | "
|
|
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
|
|
38
|
-
* cause-walking
|
|
39
|
-
* still
|
|
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: "
|
|
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`
|
|
73
|
-
* transient (429 / 5xx / connection)
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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;
|