@inflexa-ai/harness 0.7.2 → 0.8.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.
@@ -1,10 +1,23 @@
1
1
  import { type LanguageModel } from "ai";
2
2
  import type { ResolveBilling } from "../billing/resolver.js";
3
+ import type { Logger } from "../lib/logger.js";
3
4
  import type { ChatProvider, FetchLike, ProviderCapabilities } from "./types.js";
5
+ /**
6
+ * The harness-owned retry envelope. The AI SDK's built-in retry exposes only a
7
+ * count: its 2s initial delay and ×2 factor are fixed, it caps neither the
8
+ * per-attempt delay nor the total, and it ignores a server-named `Retry-After`.
9
+ * Wrapping each wire attempt in `retryWithExponentialBackoff` with these values
10
+ * gives a bounded, jittered envelope with a hard per-attempt delay ceiling.
11
+ */
12
+ export declare const RETRY_MAX_RETRIES = 10;
13
+ export declare const RETRY_INITIAL_DELAY_MS = 2000;
14
+ export declare const RETRY_BACKOFF_FACTOR = 2;
15
+ export declare const RETRY_MAX_DELAY_MS = 30000;
4
16
  export interface AiSdkProviderDeps {
5
17
  readonly model: LanguageModel;
6
18
  readonly resolveBilling: ResolveBilling;
7
19
  readonly capabilities?: Partial<ProviderCapabilities>;
20
+ readonly logger?: Logger;
8
21
  }
9
22
  /**
10
23
  * Connection + model configuration for one `ChatProvider`, discriminated by
@@ -35,7 +48,17 @@ export type AiSdkProviderConfig = {
35
48
  export interface ConfiguredAiSdkProviderDeps {
36
49
  readonly config: AiSdkProviderConfig;
37
50
  readonly resolveBilling: ResolveBilling;
51
+ readonly logger?: Logger;
38
52
  }
53
+ /**
54
+ * The delay before the next retry attempt. A server-named `Retry-After` inside
55
+ * the envelope is honored exactly — the server pointed at a precise time, so
56
+ * jitter would only fight it. Otherwise apply full jitter over the exponential
57
+ * delay capped at `RETRY_MAX_DELAY_MS`: full jitter spreads a crowd of callers
58
+ * that failed at the same instant across the window instead of re-colliding on
59
+ * the same retry tick. `random` is injectable so the delay is testable.
60
+ */
61
+ export declare function computeRetryDelayMs(error: unknown, exponentialBackoffDelay: number, random?: () => number): number;
39
62
  export declare function createAiSdkProvider(deps: AiSdkProviderDeps): ChatProvider;
40
63
  /**
41
64
  * Realize an `AiSdkProviderConfig` into a `ChatProvider` bound to that config's
@@ -1,10 +1,23 @@
1
1
  import { generateText, streamText } from "ai";
2
2
  import { createAnthropic } from "@ai-sdk/anthropic";
3
3
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4
+ import { APICallError } from "@ai-sdk/provider";
5
+ import { retryWithExponentialBackoff } from "@ai-sdk/provider-utils";
4
6
  import { ResultAsync, err, ok } from "neverthrow";
5
7
  import { scopeWorkloadId } from "../auth/types.js";
6
- import { toProviderError } from "./errors.js";
7
- const DEFAULT_MAX_RETRIES = 2;
8
+ import { createNoopLogger } from "../lib/console-logger.js";
9
+ import { classifyProviderError, toProviderError } from "./errors.js";
10
+ /**
11
+ * The harness-owned retry envelope. The AI SDK's built-in retry exposes only a
12
+ * count: its 2s initial delay and ×2 factor are fixed, it caps neither the
13
+ * per-attempt delay nor the total, and it ignores a server-named `Retry-After`.
14
+ * Wrapping each wire attempt in `retryWithExponentialBackoff` with these values
15
+ * gives a bounded, jittered envelope with a hard per-attempt delay ceiling.
16
+ */
17
+ export const RETRY_MAX_RETRIES = 10;
18
+ export const RETRY_INITIAL_DELAY_MS = 2_000;
19
+ export const RETRY_BACKOFF_FACTOR = 2;
20
+ export const RETRY_MAX_DELAY_MS = 30_000;
8
21
  function workloadOf(session) {
9
22
  return `${session.scope.kind}:${scopeWorkloadId(session.scope)}`;
10
23
  }
@@ -15,6 +28,153 @@ function isAbortError(value) {
15
28
  return true;
16
29
  return false;
17
30
  }
31
+ /**
32
+ * Locate an `APICallError` on the throwable itself or one hop down its `cause`.
33
+ * A wire failure the SDK raises is an `APICallError` directly; once rewrapped it
34
+ * sits on the wrapper's `cause`, which is as deep as a `Retry-After` header is
35
+ * ever carried.
36
+ */
37
+ function apiCallErrorOf(error) {
38
+ if (APICallError.isInstance(error))
39
+ return error;
40
+ const cause = error?.cause;
41
+ if (APICallError.isInstance(cause))
42
+ return cause;
43
+ return undefined;
44
+ }
45
+ /** Case-insensitive header lookup; `name` must already be lowercase. */
46
+ function headerValue(headers, name) {
47
+ if (headers === undefined)
48
+ return undefined;
49
+ const direct = headers[name];
50
+ if (direct !== undefined)
51
+ return direct;
52
+ for (const [key, value] of Object.entries(headers)) {
53
+ if (key.toLowerCase() === name)
54
+ return value;
55
+ }
56
+ return undefined;
57
+ }
58
+ /** Parse a header to a finite number, or `undefined` when it is not numeric. */
59
+ function finiteNumber(value) {
60
+ const trimmed = value.trim();
61
+ if (trimmed === "")
62
+ return undefined;
63
+ const n = Number(trimmed);
64
+ return Number.isFinite(n) ? n : undefined;
65
+ }
66
+ /**
67
+ * The delay a server named via `Retry-After`, in ms, or `undefined` when none
68
+ * applies. `retry-after-ms` is float ms; `retry-after` is float seconds or an
69
+ * HTTP date read relative to now. A value outside `[0, RETRY_MAX_DELAY_MS]` is
70
+ * treated as absent so the jittered exponential delay governs instead — this
71
+ * envelope does not honor a wait longer than its own ceiling verbatim.
72
+ */
73
+ function serverNamedDelayMs(error) {
74
+ const headers = apiCallErrorOf(error)?.responseHeaders;
75
+ if (headers === undefined)
76
+ return undefined;
77
+ let ms;
78
+ const retryAfterMs = headerValue(headers, "retry-after-ms");
79
+ if (retryAfterMs !== undefined)
80
+ ms = finiteNumber(retryAfterMs);
81
+ if (ms === undefined) {
82
+ const retryAfter = headerValue(headers, "retry-after");
83
+ if (retryAfter !== undefined) {
84
+ const seconds = finiteNumber(retryAfter);
85
+ if (seconds !== undefined) {
86
+ ms = seconds * 1_000;
87
+ }
88
+ else {
89
+ const at = Date.parse(retryAfter);
90
+ if (Number.isFinite(at))
91
+ ms = at - Date.now();
92
+ }
93
+ }
94
+ }
95
+ if (ms === undefined || ms < 0 || ms > RETRY_MAX_DELAY_MS)
96
+ return undefined;
97
+ return ms;
98
+ }
99
+ /**
100
+ * The delay before the next retry attempt. A server-named `Retry-After` inside
101
+ * the envelope is honored exactly — the server pointed at a precise time, so
102
+ * jitter would only fight it. Otherwise apply full jitter over the exponential
103
+ * delay capped at `RETRY_MAX_DELAY_MS`: full jitter spreads a crowd of callers
104
+ * that failed at the same instant across the window instead of re-colliding on
105
+ * the same retry tick. `random` is injectable so the delay is testable.
106
+ */
107
+ export function computeRetryDelayMs(error, exponentialBackoffDelay, random = Math.random) {
108
+ const named = serverNamedDelayMs(error);
109
+ if (named !== undefined)
110
+ return named;
111
+ return random() * Math.min(exponentialBackoffDelay, RETRY_MAX_DELAY_MS);
112
+ }
113
+ /**
114
+ * Marks a failure that came from the `ResolveBilling` seam rather than the model
115
+ * wire. The retry envelope's budget is sized for model-provider outages; the
116
+ * billing seam is a separate system whose failure must surface immediately, not
117
+ * after a multi-minute retry window — even when the underlying failure is
118
+ * connection-shaped and would otherwise read as retryable. The original throwable
119
+ * rides on `cause` so the outer catch can hand it to `toProviderError` unchanged
120
+ * and classify it byte-identically to a billing failure raised outside the
121
+ * envelope (right down to the surfaced message).
122
+ */
123
+ class BillingSeamFailure extends Error {
124
+ constructor(cause) {
125
+ super("billing resolution failed", { cause });
126
+ this.name = "BillingSeamFailure";
127
+ }
128
+ }
129
+ /**
130
+ * Resolve attribution headers for one attempt, tagging any non-abort failure as
131
+ * a `BillingSeamFailure` so the retry envelope fails fast on it. An abort is
132
+ * rethrown raw so it stays on the abort control-flow path.
133
+ */
134
+ async function resolveBillingHeaders(resolveBilling, session) {
135
+ try {
136
+ return await resolveBilling(session);
137
+ }
138
+ catch (e) {
139
+ if (isAbortError(e))
140
+ throw e;
141
+ throw new BillingSeamFailure(e);
142
+ }
143
+ }
144
+ /** Unwrap a billing-seam failure to the original throwable for classification. */
145
+ function unwrapForClassification(e) {
146
+ return e instanceof BillingSeamFailure ? e.cause : e;
147
+ }
148
+ /**
149
+ * Wrap a single wire attempt in the harness retry envelope. Built per call so
150
+ * the caller's `AbortSignal` reaches the primitive: it rethrows abort errors
151
+ * without retrying and cancels its own backoff sleep when the signal fires, so
152
+ * the retried closure adds no abort handling of its own. `shouldRetry` defers to
153
+ * the harness retryability taxonomy — but never retries a `BillingSeamFailure`,
154
+ * whose fail-fast is the whole point of the marker — and a non-retried first
155
+ * failure is rethrown untouched, preserving the exact throwable the outer catch
156
+ * classifies. `createRetryError` carries the last real failure on `cause` so
157
+ * that, once retries are exhausted, `toProviderError`'s status walk reaches the
158
+ * true HTTP status instead of stopping at a synthetic wrapper. The attempt
159
+ * counter is per-instance because each call builds its own retry.
160
+ */
161
+ function createRetry(signal, logger) {
162
+ let retryCount = 0;
163
+ return retryWithExponentialBackoff({
164
+ maxRetries: RETRY_MAX_RETRIES,
165
+ initialDelayInMs: RETRY_INITIAL_DELAY_MS,
166
+ backoffFactor: RETRY_BACKOFF_FACTOR,
167
+ abortSignal: signal,
168
+ shouldRetry: (e) => !(e instanceof BillingSeamFailure) && classifyProviderError(e).retryable,
169
+ getDelayInMs: ({ error, exponentialBackoffDelay }) => {
170
+ const delayMs = computeRetryDelayMs(error, exponentialBackoffDelay);
171
+ retryCount += 1;
172
+ logger.debug("retrying provider call", { attempt: retryCount, delayMs, ...logger.errorFields(error) });
173
+ return delayMs;
174
+ },
175
+ createRetryError: ({ message, errors }) => new Error(message, { cause: errors[errors.length - 1] }),
176
+ });
177
+ }
18
178
  /**
19
179
  * Map AI SDK token accounting onto the harness's neutral `ChatUsage`.
20
180
  *
@@ -85,51 +245,101 @@ function sanitizeMessages(messages) {
85
245
  }
86
246
  export function createAiSdkProvider(deps) {
87
247
  const capabilities = { toolCalling: deps.capabilities?.toolCalling ?? true };
248
+ const logger = (deps.logger ?? createNoopLogger()).named("providers.ai-sdk");
88
249
  function chat(req, session, signal) {
89
250
  const run = async () => {
251
+ const retry = createRetry(signal, logger);
90
252
  try {
91
- const headers = await deps.resolveBilling(session);
92
- const result = await generateText({
93
- model: deps.model,
94
- system: req.system,
95
- messages: sanitizeMessages(req.messages),
96
- tools: req.tools,
97
- toolChoice: req.toolChoice ?? "auto",
98
- stopWhen: [],
99
- maxRetries: DEFAULT_MAX_RETRIES,
100
- headers,
101
- abortSignal: signal,
102
- providerOptions: req.providerOptions,
253
+ const result = await retry(async () => {
254
+ // Attribution headers are time-limited; resolving them inside the
255
+ // retried closure keeps them fresh across a multi-minute window.
256
+ const headers = await resolveBillingHeaders(deps.resolveBilling, session);
257
+ return generateText({
258
+ model: deps.model,
259
+ system: req.system,
260
+ messages: sanitizeMessages(req.messages),
261
+ tools: req.tools,
262
+ toolChoice: req.toolChoice ?? "auto",
263
+ stopWhen: [],
264
+ // The harness envelope owns retries; leaving the SDK default in
265
+ // place would multiply attempts (10 outer × 3 inner).
266
+ maxRetries: 0,
267
+ headers,
268
+ abortSignal: signal,
269
+ providerOptions: req.providerOptions,
270
+ });
103
271
  });
104
272
  return ok(responseFromGenerate(result));
105
273
  }
106
274
  catch (e) {
107
275
  if (isAbortError(e) || signal?.aborted)
108
276
  throw e;
109
- return err(toProviderError(e, workloadOf(session)));
277
+ return err(toProviderError(unwrapForClassification(e), workloadOf(session)));
110
278
  }
111
279
  };
112
280
  return new ResultAsync(run());
113
281
  }
114
282
  async function* chatStream(req, session, signal) {
283
+ const retry = createRetry(signal, logger);
115
284
  try {
116
- const headers = await deps.resolveBilling(session);
117
- const result = streamText({
118
- model: deps.model,
119
- system: req.system,
120
- messages: sanitizeMessages(req.messages),
121
- tools: req.tools,
122
- toolChoice: req.toolChoice ?? "auto",
123
- stopWhen: [],
124
- maxRetries: DEFAULT_MAX_RETRIES,
125
- headers,
126
- abortSignal: signal,
127
- providerOptions: req.providerOptions,
285
+ // Retry covers only stream establishment: streamText defers wire errors
286
+ // to consumption, so a failure is not visible until the stream is read.
287
+ // The retried closure resolves to one of two outcomes — the stream is
288
+ // live with a first delta in hand, or it finished without yielding any
289
+ // text — and a delta is only ever yielded OUTSIDE the closure, so a
290
+ // retried attempt can never re-emit a delta the consumer already saw.
291
+ // Resolving billing inside the closure keeps attribution headers fresh
292
+ // per attempt.
293
+ const opened = await retry(async () => {
294
+ const headers = await resolveBillingHeaders(deps.resolveBilling, session);
295
+ const result = streamText({
296
+ model: deps.model,
297
+ system: req.system,
298
+ messages: sanitizeMessages(req.messages),
299
+ tools: req.tools,
300
+ toolChoice: req.toolChoice ?? "auto",
301
+ stopWhen: [],
302
+ maxRetries: 0,
303
+ headers,
304
+ abortSignal: signal,
305
+ providerOptions: req.providerOptions,
306
+ });
307
+ const iterator = result.textStream[Symbol.asyncIterator]();
308
+ const first = await iterator.next();
309
+ if (first.done) {
310
+ // A doStream promise rejection — the shape a real connection
311
+ // failure takes — does NOT surface at this first pull: streamText
312
+ // resolves it `{ done: true }` and defers the rejection to the
313
+ // deferred result promises. Awaiting them here brings that failure
314
+ // inside the retry envelope so a text-less establishment error is
315
+ // retried; a genuine text-less turn resolves them, and the terminal
316
+ // event is built from these already-resolved values without a
317
+ // second await. (These promises only settle once the stream is
318
+ // fully drained, so they can be awaited here only because the first
319
+ // pull already reported the stream done.)
320
+ return {
321
+ kind: "completed",
322
+ messages: await result.responseMessages,
323
+ finishReason: await result.finishReason,
324
+ rawFinishReason: await result.rawFinishReason,
325
+ usage: await result.usage,
326
+ };
327
+ }
328
+ return { kind: "streaming", result, iterator, firstDelta: first.value };
128
329
  });
129
- let text = "";
130
- for await (const delta of result.textStream) {
131
- text += delta;
132
- yield { type: "text-delta", text: delta };
330
+ if (opened.kind === "completed") {
331
+ const response = responseFromMessages(opened.messages, "", opened.finishReason, opened.rawFinishReason, opened.usage);
332
+ yield { type: "done", response };
333
+ return;
334
+ }
335
+ const { result, iterator, firstDelta } = opened;
336
+ let text = firstDelta;
337
+ yield { type: "text-delta", text: firstDelta };
338
+ // Drain the same iterator: past the first delta, errors propagate
339
+ // unchanged (no retry, no re-emit).
340
+ for (let next = await iterator.next(); !next.done; next = await iterator.next()) {
341
+ text += next.value;
342
+ yield { type: "text-delta", text: next.value };
133
343
  }
134
344
  const response = responseFromMessages(await result.responseMessages, text, await result.finishReason, await result.rawFinishReason, await result.usage);
135
345
  yield { type: "done", response };
@@ -137,7 +347,7 @@ export function createAiSdkProvider(deps) {
137
347
  catch (e) {
138
348
  if (isAbortError(e) || signal?.aborted)
139
349
  throw e;
140
- throw toProviderError(e, workloadOf(session));
350
+ throw toProviderError(unwrapForClassification(e), workloadOf(session));
141
351
  }
142
352
  }
143
353
  return { capabilities, chat, chatStream };
@@ -157,7 +367,12 @@ export function createConfiguredAiSdkProvider(deps) {
157
367
  apiKey: config.apiKey,
158
368
  fetch: config.fetch,
159
369
  });
160
- return createAiSdkProvider({ model: provider.chat(config.model), resolveBilling: deps.resolveBilling, capabilities: config.capabilities });
370
+ return createAiSdkProvider({
371
+ model: provider.chat(config.model),
372
+ resolveBilling: deps.resolveBilling,
373
+ capabilities: config.capabilities,
374
+ logger: deps.logger,
375
+ });
161
376
  }
162
377
  const provider = createOpenAICompatible({
163
378
  name: config.name,
@@ -165,5 +380,10 @@ export function createConfiguredAiSdkProvider(deps) {
165
380
  apiKey: config.apiKey,
166
381
  fetch: config.fetch,
167
382
  });
168
- return createAiSdkProvider({ model: provider.chatModel(config.model), resolveBilling: deps.resolveBilling, capabilities: config.capabilities });
383
+ return createAiSdkProvider({
384
+ model: provider.chatModel(config.model),
385
+ resolveBilling: deps.resolveBilling,
386
+ capabilities: config.capabilities,
387
+ logger: deps.logger,
388
+ });
169
389
  }
@@ -5,6 +5,7 @@
5
5
  * old factory name for embedders while returning the new `ChatProvider` seam.
6
6
  */
7
7
  import type { ResolveBilling } from "../billing/resolver.js";
8
+ import type { Logger } from "../lib/logger.js";
8
9
  import type { ChatProvider, FetchLike } from "./types.js";
9
10
  export interface AnthropicProviderDeps {
10
11
  readonly baseURL?: string;
@@ -12,6 +13,7 @@ export interface AnthropicProviderDeps {
12
13
  readonly model: string;
13
14
  readonly resolveBilling: ResolveBilling;
14
15
  readonly fetch?: FetchLike;
16
+ readonly logger?: Logger;
15
17
  }
16
18
  /**
17
19
  * Convenience over the `anthropic` arm of the public `AiSdkProviderConfig`
@@ -14,6 +14,7 @@ import { createConfiguredAiSdkProvider } from "./ai-sdk.js";
14
14
  export function createAnthropicProvider(deps) {
15
15
  return createConfiguredAiSdkProvider({
16
16
  resolveBilling: deps.resolveBilling,
17
+ logger: deps.logger,
17
18
  config: {
18
19
  kind: "anthropic",
19
20
  baseURL: deps.baseURL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -47,6 +47,7 @@
47
47
  "@ai-sdk/anthropic": "^4.0.15",
48
48
  "@ai-sdk/openai-compatible": "^3.0.10",
49
49
  "@ai-sdk/provider": "^4.0.3",
50
+ "@ai-sdk/provider-utils": "5.0.10",
50
51
  "@anthropic-ai/sdk": "^0.107.0",
51
52
  "@dbos-inc/dbos-sdk": "^4.23.6",
52
53
  "@kubernetes/client-node": "^1.4.0",