@inflexa-ai/harness 0.30.1 → 0.30.2

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,4 +1,4 @@
1
- import { generateText, streamText, wrapLanguageModel, } from "ai";
1
+ import { streamText, wrapLanguageModel, } from "ai";
2
2
  import { createAnthropic } from "@ai-sdk/anthropic";
3
3
  import { createOpenAI } from "@ai-sdk/openai";
4
4
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
@@ -255,10 +255,10 @@ function requestedModelIdOf(model) {
255
255
  }
256
256
  /**
257
257
  * Capture the served model id at the raw model boundary, because that is the
258
- * only place it is still distinguishable from the requested one: `generateText`
259
- * and `streamText` both coalesce an unreported response model id to the bound
260
- * model's own id, so reading `result.response.modelId` would silently republish
261
- * the requested id as an endpoint's claim. A middleware sees the provider's
258
+ * only place it is still distinguishable from the requested one: `streamText`
259
+ * coalesces an unreported response model id to the bound model's own id, so
260
+ * reading `result.response.modelId` would silently republish the requested id
261
+ * as an endpoint's claim. A middleware sees the provider's
262
262
  * result before that fallback applies, so "reported nothing" stays absent.
263
263
  *
264
264
  * Built per call, never per provider: one provider instance serves concurrent
@@ -303,16 +303,6 @@ function responseFromMessages(input) {
303
303
  }
304
304
  return { message, finishReason, rawFinishReason, usage, requestedModelId, servedModelId };
305
305
  }
306
- function responseFromGenerate(result, identity) {
307
- return responseFromMessages({
308
- messages: result.responseMessages,
309
- fallbackText: result.text,
310
- finishReason: result.finishReason,
311
- rawFinishReason: result.rawFinishReason,
312
- usage: result.usage,
313
- ...identity,
314
- });
315
- }
316
306
  /**
317
307
  * Drop empty text parts — and messages left with no content at all — before
318
308
  * the wire call. The Anthropic Messages API rejects any request containing an
@@ -356,9 +346,57 @@ async function pullNextDelta(iterator) {
356
346
  return { kind: "delta", text: part.text };
357
347
  if (part.type === "abort")
358
348
  return { kind: "aborted", reason: part.reason };
349
+ // The SDK reports a wire failure as a part, not as a rejection, and it
350
+ // then closes the stream. Skipping it would turn a failed call into an
351
+ // empty assistant turn that the loop reads as a real answer.
352
+ if (part.type === "error")
353
+ return { kind: "failed", error: part.error };
359
354
  }
360
355
  return { kind: "end" };
361
356
  }
357
+ /**
358
+ * The HTTP status that each Anthropic stream error type stands for.
359
+ *
360
+ * A provider names a mid-stream failure in the payload, not in the status line:
361
+ * a stream that carries `overloaded_error` reports the condition that a 529
362
+ * names on the non-streaming wire. The classifier reads the status alone, thus
363
+ * the part has to reach it as an error that names one, or the same condition
364
+ * would classify one way on `chat` and another way on `chatStream`.
365
+ */
366
+ const STREAM_ERROR_STATUS = {
367
+ invalid_request_error: 400,
368
+ authentication_error: 401,
369
+ permission_error: 403,
370
+ not_found_error: 404,
371
+ request_too_large: 413,
372
+ rate_limit_error: 429,
373
+ timeout_error: 408,
374
+ api_error: 500,
375
+ overloaded_error: 529,
376
+ };
377
+ /**
378
+ * The failure that an `error` part of the SDK stream stands for.
379
+ *
380
+ * The part carries whatever the provider put on the wire, and that is often a
381
+ * bare object rather than an `Error`. Left as it is, it reaches the message as
382
+ * `[object Object]` and reaches the classifier with no status at all.
383
+ */
384
+ function streamPartFailure(error) {
385
+ if (error instanceof Error)
386
+ return error;
387
+ if (typeof error !== "object" || error === null)
388
+ return new Error(String(error));
389
+ const reported = error;
390
+ const type = typeof reported.type === "string" ? reported.type : undefined;
391
+ const message = typeof reported.message === "string" ? reported.message : undefined;
392
+ const failure = new Error(message ?? type ?? "The provider reported a stream error.", { cause: error });
393
+ const status = type === undefined ? undefined : STREAM_ERROR_STATUS[type];
394
+ // `extractStatus` walks the chain for this field, thus the named condition
395
+ // classifies the same way it would from a status line.
396
+ if (status !== undefined)
397
+ Object.assign(failure, { status });
398
+ return failure;
399
+ }
362
400
  /**
363
401
  * The failure that an `abort` part of the SDK stream stands for.
364
402
  *
@@ -394,16 +432,46 @@ export function createAiSdkProvider(deps) {
394
432
  const requestTimeoutMs = deps.requestTimeoutMs;
395
433
  const requestedModelId = requestedModelIdOf(deps.model);
396
434
  routeSdkWarningsTo(logger);
435
+ /**
436
+ * Record a call that the envelope gave up on.
437
+ *
438
+ * The envelope reports one attempt at `debug`, thus a deployment that runs
439
+ * at `info` keeps no trace of a call that exhausted its retries: the reply
440
+ * carries the failure to the user and the operator sees nothing. This is the
441
+ * one record that names the outcome.
442
+ */
443
+ function logFailure(session, failure, thrown) {
444
+ logger.error("provider call failed", {
445
+ workload: workloadOf(session),
446
+ kind: failure.type,
447
+ retryable: failure.retryable,
448
+ ...logger.errorFields(thrown),
449
+ });
450
+ }
397
451
  function chat(req, session, signal) {
398
452
  const run = async () => {
399
453
  const retry = createRetry(signal, logger, maxRetries);
400
454
  const capture = captureServedModelId(deps.model);
401
455
  try {
402
- const result = await retry(async () => {
456
+ const collected = await retry(async () => {
403
457
  // Attribution headers are time-limited; resolving them inside the
404
458
  // retried closure keeps them fresh across a multi-minute window.
405
459
  const headers = await resolveBillingHeaders(deps.resolveBilling, session);
406
- return generateText({
460
+ // The call streams on the wire and collapses below.
461
+ //
462
+ // A non-streaming turn sends no header until the model completes,
463
+ // thus the `headersTimeout` of the host HTTP client bounds the whole
464
+ // generation. No option of the SDK lifts that bound, because it sits
465
+ // under the fetch. Node defaults the field to 300s, and a turn near
466
+ // 18k output tokens reaches it, while `DEFAULT_MAX_OUTPUT_TOKENS`
467
+ // asks for up to 64k. A cut turn then costs twice: the envelope
468
+ // starts the same generation again from zero.
469
+ //
470
+ // Anthropic states the rule for its own SDKs: a large `max_tokens`
471
+ // makes streaming necessary, to prevent an HTTP timeout. Thus a
472
+ // ceiling this size streams whether or not a consumer wants the
473
+ // deltas, and `chatStream` stays the seam that forwards them.
474
+ const result = streamText({
407
475
  model: capture.model,
408
476
  system: req.system,
409
477
  messages: sanitizeMessages(req.messages),
@@ -416,20 +484,53 @@ export function createAiSdkProvider(deps) {
416
484
  maxRetries: 0,
417
485
  headers,
418
486
  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.
487
+ // `firstChunkMs` bounds the wait for the first content chunk,
488
+ // and `chunkMs` bounds each later gap. Thus each silent interval
489
+ // carries the one configured bound, and a turn that writes
490
+ // steadily runs as long as it needs.
491
+ ...(requestTimeoutMs !== undefined ? { timeout: { firstChunkMs: requestTimeoutMs, chunkMs: requestTimeoutMs } } : {}),
423
492
  providerOptions: req.providerOptions,
424
493
  reasoning: req.reasoning,
425
494
  });
495
+ // The drain sits inside the retried closure, thus a failure at any
496
+ // point of the stream re-runs the whole attempt and the envelope
497
+ // keeps its whole-call scope. No delta reaches a consumer here —
498
+ // `chatStream` is the seam that forwards them.
499
+ const iterator = result.fullStream[Symbol.asyncIterator]();
500
+ let text = "";
501
+ for (let pull = await pullNextDelta(iterator); pull.kind !== "end"; pull = await pullNextDelta(iterator)) {
502
+ // A caller abort rides out as the reason of its own signal, thus
503
+ // it stays a cancellation and reaches the caller as a throw.
504
+ if (pull.kind === "aborted")
505
+ throw abortedStreamFailure(signal, pull.reason, requestTimeoutMs);
506
+ if (pull.kind === "failed")
507
+ throw streamPartFailure(pull.error);
508
+ text += pull.text;
509
+ }
510
+ // These promises settle only once the stream is fully drained, thus
511
+ // the loop above must reach its end before this point.
512
+ return {
513
+ messages: await result.responseMessages,
514
+ fallbackText: text,
515
+ finishReason: await result.finishReason,
516
+ rawFinishReason: await result.rawFinishReason,
517
+ usage: await result.usage,
518
+ };
426
519
  });
427
- return ok(responseFromGenerate(result, { requestedModelId, servedModelId: capture.servedModelId() }));
520
+ return ok(responseFromMessages({
521
+ ...collected,
522
+ requestedModelId,
523
+ // Read after the drain: the metadata chunk that carries the
524
+ // served id reaches the capture only as the stream is consumed.
525
+ servedModelId: capture.servedModelId(),
526
+ }));
428
527
  }
429
528
  catch (e) {
430
529
  if (isAbortError(e) || signal?.aborted)
431
530
  throw e;
432
- return err(toProviderError(unwrapForClassification(e), workloadOf(session)));
531
+ const failure = toProviderError(unwrapForClassification(e), workloadOf(session));
532
+ logFailure(session, failure, e);
533
+ return err(failure);
433
534
  }
434
535
  };
435
536
  return new ResultAsync(run());
@@ -477,6 +578,8 @@ export function createAiSdkProvider(deps) {
477
578
  // fresh window under the retry policy of a connection error.
478
579
  if (first.kind === "aborted")
479
580
  throw abortedStreamFailure(signal, first.reason, requestTimeoutMs);
581
+ if (first.kind === "failed")
582
+ throw streamPartFailure(first.error);
480
583
  if (first.kind === "end") {
481
584
  // A doStream promise rejection — the shape a real connection
482
585
  // failure takes — does NOT surface at this first pull: streamText
@@ -522,6 +625,8 @@ export function createAiSdkProvider(deps) {
522
625
  // terminal `ProviderError` from the throwable.
523
626
  if (pull.kind === "aborted")
524
627
  throw abortedStreamFailure(signal, pull.reason, requestTimeoutMs);
628
+ if (pull.kind === "failed")
629
+ throw streamPartFailure(pull.error);
525
630
  text += pull.text;
526
631
  yield { type: "text-delta", text: pull.text };
527
632
  }
@@ -541,7 +646,9 @@ export function createAiSdkProvider(deps) {
541
646
  catch (e) {
542
647
  if (isAbortError(e) || signal?.aborted)
543
648
  throw e;
544
- throw toProviderError(unwrapForClassification(e), workloadOf(session));
649
+ const failure = toProviderError(unwrapForClassification(e), workloadOf(session));
650
+ logFailure(session, failure, e);
651
+ throw failure;
545
652
  }
546
653
  }
547
654
  // The provider advertises the guard limit on its own instance, thus a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",