@jaypie/llm 1.3.12 → 1.3.14

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.
@@ -478,224 +478,90 @@ function resolveModelChain(model) {
478
478
 
479
479
  //
480
480
  //
481
- // Abstract Base Adapter
481
+ // Helpers
482
482
  //
483
- /**
484
- * BaseProviderAdapter provides default implementations for common adapter methods.
485
- * Providers can extend this class to reduce boilerplate.
486
- */
487
- class BaseProviderAdapter {
488
- /**
489
- * Default implementation checks if error is retryable via classifyError
490
- */
491
- isRetryableError(error) {
492
- const classified = this.classifyError(error);
493
- return classified.shouldRetry;
494
- }
495
- /**
496
- * Default implementation checks error category via classifyError
497
- */
498
- isRateLimitError(error) {
499
- const classified = this.classifyError(error);
500
- return classified.category === "rate_limit";
501
- }
502
- /**
503
- * Default implementation returns false - override for providers with native structured output
504
- */
505
- hasStructuredOutput(_response) {
506
- return false;
507
- }
508
- /**
509
- * Default implementation returns undefined - override for providers with native structured output
510
- */
511
- extractStructuredOutput(_response) {
483
+ function sumUsageByProviderModel(usage) {
484
+ if (!usage.length) {
512
485
  return undefined;
513
486
  }
514
- }
515
-
516
- /**
517
- * Per-provider translation of the provider-neutral {@link LlmEffort} scale.
518
- *
519
- * The neutral enum (lowest → low → medium → high → highest) is a relative
520
- * five-point scale. Each table below maps it onto the target provider's native
521
- * effort control, keeping `medium`/`high` semantically aligned across providers
522
- * and using each provider's extra bottom (`minimal`) or top (`xhigh`/`max`)
523
- * rung where one exists. Providers with fewer levels collapse the ends and mark
524
- * the result `papered`. A single `effort` value is therefore safe to reuse
525
- * across providers and fallback chains.
526
- */
527
- /** Consistent debug message for a papered-over effort level. */
528
- function paperedEffortMessage({ model, provider, requested, value, }) {
529
- return `[llm] effort '${requested}' has no distinct tier on ${provider} model '${model}'; using '${value}'`;
530
- }
531
- // OpenAI Responses API `reasoning.effort`.
532
- // Full ladder: minimal | low | medium | high | xhigh (plus `none`). Availability
533
- // is not uniform across the gpt-5 line:
534
- // - `xhigh` was introduced at gpt-5.2 and has been continuous since, so it is
535
- // safe for our gpt-5.4 default and everything newer.
536
- // - `minimal` shipped on gpt-5/5.1, was dropped at gpt-5.2, and returned on
537
- // the current line; because that history is non-monotonic we only trust it
538
- // from gpt-5.4 (our default floor) onward.
539
- // Outside those windows (older gpt-5, o-series) the extreme rung is clamped and
540
- // the mapping reports `papered: true`.
541
- const OPENAI_EFFORT = {
542
- [EFFORT.LOWEST]: "minimal",
543
- [EFFORT.LOW]: "low",
544
- [EFFORT.MEDIUM]: "medium",
545
- [EFFORT.HIGH]: "high",
546
- [EFFORT.HIGHEST]: "xhigh",
547
- };
548
- function openAiGptVersion(model) {
549
- const match = model.match(/^gpt-(\d+)(?:\.(\d+))?/);
550
- if (!match)
551
- return null;
552
- return { major: Number(match[1]), minor: match[2] ? Number(match[2]) : 0 };
553
- }
554
- function atLeast(version, major, minor) {
555
- if (!version)
556
- return false;
557
- return (version.major > major || (version.major === major && version.minor >= minor));
558
- }
559
- function toOpenAiEffort(effort, { model }) {
560
- const native = OPENAI_EFFORT[effort];
561
- const version = openAiGptVersion(model);
562
- // `minimal` only from gpt-5.4 (non-monotonic history; absent on o-series)
563
- if (native === "minimal" && !atLeast(version, 5, 4)) {
564
- return { papered: true, value: "low" };
565
- }
566
- // `xhigh` from gpt-5.2 onward (absent on older gpt-5 and o-series)
567
- if (native === "xhigh" && !atLeast(version, 5, 2)) {
568
- return { papered: true, value: "high" };
487
+ const totals = {};
488
+ for (const item of usage) {
489
+ const key = `${item.provider ?? "unknown"}:${item.model ?? "unknown"}`;
490
+ if (!totals[key]) {
491
+ totals[key] = {
492
+ input: 0,
493
+ model: item.model,
494
+ output: 0,
495
+ provider: item.provider,
496
+ reasoning: 0,
497
+ total: 0,
498
+ };
499
+ }
500
+ totals[key].input += item.input;
501
+ totals[key].output += item.output;
502
+ totals[key].reasoning += item.reasoning;
503
+ totals[key].total += item.total;
569
504
  }
570
- return { papered: false, value: native };
571
- }
572
- // xAI Grok `reasoning_effort` — low | medium | high. No sub-low or top rung, so
573
- // `lowest` collapses onto `low` and `highest` onto `high`.
574
- const XAI_EFFORT = {
575
- [EFFORT.LOWEST]: { papered: true, value: "low" },
576
- [EFFORT.LOW]: { papered: false, value: "low" },
577
- [EFFORT.MEDIUM]: { papered: false, value: "medium" },
578
- [EFFORT.HIGH]: { papered: false, value: "high" },
579
- [EFFORT.HIGHEST]: { papered: true, value: "high" },
580
- };
581
- function toXaiEffort(effort) {
582
- return XAI_EFFORT[effort];
583
- }
584
- // Anthropic `output_config.effort` — low | medium | high | xhigh | max. No
585
- // sub-low rung, so `lowest` collapses onto `low`; `highest` reaches `max`.
586
- const ANTHROPIC_EFFORT = {
587
- [EFFORT.LOWEST]: { papered: true, value: "low" },
588
- [EFFORT.LOW]: { papered: false, value: "low" },
589
- [EFFORT.MEDIUM]: { papered: false, value: "medium" },
590
- [EFFORT.HIGH]: { papered: false, value: "high" },
591
- [EFFORT.HIGHEST]: { papered: false, value: "max" },
592
- };
593
- function toAnthropicEffort(effort) {
594
- return ANTHROPIC_EFFORT[effort];
595
- }
596
- // Gemini 3.x `thinkingConfig.thinkingLevel` — MINIMAL | LOW | MEDIUM | HIGH. No
597
- // top rung above HIGH, so `highest` collapses onto HIGH.
598
- const GEMINI_THINKING_LEVEL = {
599
- [EFFORT.LOWEST]: { papered: false, value: "MINIMAL" },
600
- [EFFORT.LOW]: { papered: false, value: "LOW" },
601
- [EFFORT.MEDIUM]: { papered: false, value: "MEDIUM" },
602
- [EFFORT.HIGH]: { papered: false, value: "HIGH" },
603
- [EFFORT.HIGHEST]: { papered: true, value: "HIGH" },
604
- };
605
- function toGeminiThinkingLevel(effort) {
606
- return GEMINI_THINKING_LEVEL[effort];
607
- }
608
- // Gemini 2.5 `thinkingConfig.thinkingBudget` — every tier is a distinct token
609
- // budget, so nothing is papered over. Floor (512) clears every 2.5 minimum;
610
- // ceiling (24,576) is valid across 2.5 Pro (max 32,768) and Flash (max 24,576).
611
- const GEMINI_THINKING_BUDGET = {
612
- [EFFORT.LOWEST]: 512,
613
- [EFFORT.LOW]: 4096,
614
- [EFFORT.MEDIUM]: 8192,
615
- [EFFORT.HIGH]: 16384,
616
- [EFFORT.HIGHEST]: 24576,
617
- };
618
- function toGeminiThinkingBudget(effort) {
619
- return { papered: false, value: GEMINI_THINKING_BUDGET[effort] };
620
- }
621
- // Fireworks `reasoning_effort` — low | medium | high. Accepted on every model
622
- // (the API no-ops where unsupported). No sub-low or top rung, so `lowest`
623
- // collapses onto `low` and `highest` onto `high`.
624
- const FIREWORKS_EFFORT = {
625
- [EFFORT.LOWEST]: { papered: true, value: "low" },
626
- [EFFORT.LOW]: { papered: false, value: "low" },
627
- [EFFORT.MEDIUM]: { papered: false, value: "medium" },
628
- [EFFORT.HIGH]: { papered: false, value: "high" },
629
- [EFFORT.HIGHEST]: { papered: true, value: "high" },
630
- };
631
- function toFireworksEffort(effort) {
632
- return FIREWORKS_EFFORT[effort];
505
+ return totals;
633
506
  }
634
- // OpenRouter `reasoning.effort` — accepts the full minimal..xhigh ladder and
635
- // maps to the routed provider's nearest supported level itself, so nothing is
636
- // papered here.
637
- const OPENROUTER_EFFORT = {
638
- [EFFORT.LOWEST]: "minimal",
639
- [EFFORT.LOW]: "low",
640
- [EFFORT.MEDIUM]: "medium",
641
- [EFFORT.HIGH]: "high",
642
- [EFFORT.HIGHEST]: "xhigh",
643
- };
644
- function toOpenRouterEffort(effort) {
645
- return { papered: false, value: OPENROUTER_EFFORT[effort] };
507
+ function extractResponseIds(responses) {
508
+ const ids = responses
509
+ .map((response) => response && typeof response === "object"
510
+ ? response.id
511
+ : undefined)
512
+ .filter((id) => typeof id === "string");
513
+ return ids.length ? ids : undefined;
646
514
  }
647
-
648
- // Enums
649
- exports.LlmMessageRole = void 0;
650
- (function (LlmMessageRole) {
651
- LlmMessageRole["Assistant"] = "assistant";
652
- LlmMessageRole["Developer"] = "developer";
653
- LlmMessageRole["System"] = "system";
654
- LlmMessageRole["User"] = "user";
655
- })(exports.LlmMessageRole || (exports.LlmMessageRole = {}));
656
- exports.LlmMessageType = void 0;
657
- (function (LlmMessageType) {
658
- LlmMessageType["FunctionCall"] = "function_call";
659
- LlmMessageType["FunctionCallOutput"] = "function_call_output";
660
- LlmMessageType["InputFile"] = "input_file";
661
- LlmMessageType["InputImage"] = "input_image";
662
- LlmMessageType["InputText"] = "input_text";
663
- LlmMessageType["ItemReference"] = "item_reference";
664
- LlmMessageType["Message"] = "message";
665
- LlmMessageType["OutputText"] = "output_text";
666
- LlmMessageType["Refusal"] = "refusal";
667
- })(exports.LlmMessageType || (exports.LlmMessageType = {}));
668
- var LlmResponseStatus;
669
- (function (LlmResponseStatus) {
670
- LlmResponseStatus["Completed"] = "completed";
671
- LlmResponseStatus["Incomplete"] = "incomplete";
672
- LlmResponseStatus["InProgress"] = "in_progress";
673
- })(LlmResponseStatus || (LlmResponseStatus = {}));
674
- // Progress
675
- exports.LlmProgressEventType = void 0;
676
- (function (LlmProgressEventType) {
677
- LlmProgressEventType["Done"] = "done";
678
- LlmProgressEventType["ModelRequest"] = "model_request";
679
- LlmProgressEventType["ModelResponse"] = "model_response";
680
- LlmProgressEventType["Retry"] = "retry";
681
- LlmProgressEventType["Start"] = "start";
682
- LlmProgressEventType["ToolCall"] = "tool_call";
683
- LlmProgressEventType["ToolError"] = "tool_error";
684
- LlmProgressEventType["ToolResult"] = "tool_result";
685
- })(exports.LlmProgressEventType || (exports.LlmProgressEventType = {}));
686
-
687
515
  //
688
516
  //
689
- // Types
517
+ // Main
690
518
  //
691
- exports.LlmStreamChunkType = void 0;
692
- (function (LlmStreamChunkType) {
693
- LlmStreamChunkType["Done"] = "done";
694
- LlmStreamChunkType["Error"] = "error";
695
- LlmStreamChunkType["Text"] = "text";
696
- LlmStreamChunkType["ToolCall"] = "tool_call";
697
- LlmStreamChunkType["ToolResult"] = "tool_result";
698
- })(exports.LlmStreamChunkType || (exports.LlmStreamChunkType = {}));
519
+ /**
520
+ * Assemble the serializable exchange envelope for one operate() settlement.
521
+ * The `resolution` block is stamped by the Llm facade, which is the layer
522
+ * that knows fallback outcome; the loop fills provider/model best-effort.
523
+ */
524
+ function buildExchangeEnvelope({ duration, initialHistoryLength, input, options, response, startedAt, state, }) {
525
+ const historyDelta = response.history.slice(initialHistoryLength);
526
+ return {
527
+ ids: extractResponseIds(response.responses),
528
+ request: {
529
+ data: options.data,
530
+ effort: options.effort,
531
+ explain: options.explain,
532
+ format: state.formattedFormat,
533
+ input,
534
+ instructions: options.instructions,
535
+ model: options.model,
536
+ placeholders: options.placeholders,
537
+ providerOptions: options.providerOptions,
538
+ system: options.system,
539
+ temperature: options.temperature,
540
+ tools: state.formattedTools?.map(({ name }) => name),
541
+ turns: options.turns,
542
+ user: options.user,
543
+ },
544
+ resolution: {
545
+ model: response.model,
546
+ provider: response.provider,
547
+ retries: state.retries,
548
+ },
549
+ response: {
550
+ content: response.content,
551
+ error: response.error,
552
+ historyDelta,
553
+ reasoning: response.reasoning,
554
+ status: response.status,
555
+ stopReason: state.lastStopReason,
556
+ usage: response.usage,
557
+ usageTotals: sumUsageByProviderModel(response.usage),
558
+ },
559
+ timing: {
560
+ duration,
561
+ startedAt,
562
+ },
563
+ };
564
+ }
699
565
 
700
566
  /**
701
567
  * Type guard to check if an item is a dedicated reasoning item (OpenAI)
@@ -915,6 +781,45 @@ function fillFormatArrays({ content, format, }) {
915
781
  return fillFromSchema(schema, structuredClone(content));
916
782
  }
917
783
 
784
+ // Enums
785
+ exports.LlmMessageRole = void 0;
786
+ (function (LlmMessageRole) {
787
+ LlmMessageRole["Assistant"] = "assistant";
788
+ LlmMessageRole["Developer"] = "developer";
789
+ LlmMessageRole["System"] = "system";
790
+ LlmMessageRole["User"] = "user";
791
+ })(exports.LlmMessageRole || (exports.LlmMessageRole = {}));
792
+ exports.LlmMessageType = void 0;
793
+ (function (LlmMessageType) {
794
+ LlmMessageType["FunctionCall"] = "function_call";
795
+ LlmMessageType["FunctionCallOutput"] = "function_call_output";
796
+ LlmMessageType["InputFile"] = "input_file";
797
+ LlmMessageType["InputImage"] = "input_image";
798
+ LlmMessageType["InputText"] = "input_text";
799
+ LlmMessageType["ItemReference"] = "item_reference";
800
+ LlmMessageType["Message"] = "message";
801
+ LlmMessageType["OutputText"] = "output_text";
802
+ LlmMessageType["Refusal"] = "refusal";
803
+ })(exports.LlmMessageType || (exports.LlmMessageType = {}));
804
+ var LlmResponseStatus;
805
+ (function (LlmResponseStatus) {
806
+ LlmResponseStatus["Completed"] = "completed";
807
+ LlmResponseStatus["Incomplete"] = "incomplete";
808
+ LlmResponseStatus["InProgress"] = "in_progress";
809
+ })(LlmResponseStatus || (LlmResponseStatus = {}));
810
+ // Progress
811
+ exports.LlmProgressEventType = void 0;
812
+ (function (LlmProgressEventType) {
813
+ LlmProgressEventType["Done"] = "done";
814
+ LlmProgressEventType["ModelRequest"] = "model_request";
815
+ LlmProgressEventType["ModelResponse"] = "model_response";
816
+ LlmProgressEventType["Retry"] = "retry";
817
+ LlmProgressEventType["Start"] = "start";
818
+ LlmProgressEventType["ToolCall"] = "tool_call";
819
+ LlmProgressEventType["ToolError"] = "tool_error";
820
+ LlmProgressEventType["ToolResult"] = "tool_result";
821
+ })(exports.LlmProgressEventType || (exports.LlmProgressEventType = {}));
822
+
918
823
  /**
919
824
  * Converts a string to a standardized LlmInputMessage
920
825
  * @param input - String to format
@@ -1514,58 +1419,354 @@ function tallyOperate({ toolCallNames = [], turns, usage = [], }) {
1514
1419
  }
1515
1420
  log$1.log.tally({ llm });
1516
1421
  }
1517
-
1518
- /**
1519
- * Helper function to safely call a function that might throw
1520
- * @param fn Function to call safely
1521
- */
1522
- function callSafely(fn) {
1523
- try {
1524
- fn();
1525
- }
1526
- catch {
1527
- // Silently catch any errors from the function
1528
- }
1422
+
1423
+ /**
1424
+ * Helper function to safely call a function that might throw
1425
+ * @param fn Function to call safely
1426
+ */
1427
+ function callSafely(fn) {
1428
+ try {
1429
+ fn();
1430
+ }
1431
+ catch {
1432
+ // Silently catch any errors from the function
1433
+ }
1434
+ }
1435
+ /**
1436
+ * Attempts to parse a value as a number. Returns the original input if parsing fails or results in NaN.
1437
+ * @param input - The value to attempt to parse as a number
1438
+ * @param options - Optional configuration
1439
+ * @param options.defaultValue - Default value to return if parsing fails or results in NaN
1440
+ * @param options.warnFunction - Function to call with warning message if parsing fails or results in NaN
1441
+ * @returns The parsed number, defaultValue (if specified and parsing fails), or the original input
1442
+ */
1443
+ function tryParseNumber(input, options) {
1444
+ if (input === null || input === undefined) {
1445
+ return input;
1446
+ }
1447
+ try {
1448
+ const parsed = Number(input);
1449
+ if (Number.isNaN(parsed)) {
1450
+ if (options?.warnFunction) {
1451
+ const warningMessage = `Failed to parse "${String(input)}" as number`;
1452
+ callSafely(() => options.warnFunction(warningMessage));
1453
+ }
1454
+ return typeof options?.defaultValue === "number"
1455
+ ? options.defaultValue
1456
+ : input;
1457
+ }
1458
+ return parsed;
1459
+ }
1460
+ catch (error) {
1461
+ if (options?.warnFunction) {
1462
+ let errorMessage = "";
1463
+ if (error instanceof Error) {
1464
+ errorMessage = error.message;
1465
+ }
1466
+ const warningMessage = `Error parsing "${String(input)}" as number${errorMessage ? "; " + errorMessage : ""}`;
1467
+ callSafely(() => options.warnFunction(warningMessage));
1468
+ }
1469
+ return typeof options?.defaultValue === "number"
1470
+ ? options.defaultValue
1471
+ : input;
1472
+ }
1473
+ }
1474
+
1475
+ /**
1476
+ * Truthy-except-"false"/"0" gate on LLM_EXCHANGE_ENABLED, matching the
1477
+ * DD_LLMOBS_ENABLED convention in observability/llmobs.ts.
1478
+ */
1479
+ function isExchangeStoreEnabled() {
1480
+ const flag = process.env.LLM_EXCHANGE_ENABLED;
1481
+ if (!flag) {
1482
+ return false;
1483
+ }
1484
+ return flag.toLowerCase() !== "false" && flag !== "0";
1485
+ }
1486
+ /**
1487
+ * Whether the loop should assemble an exchange envelope at settlement.
1488
+ */
1489
+ function isExchangeRequested({ onExchange, }) {
1490
+ return Boolean(onExchange) || isExchangeStoreEnabled();
1491
+ }
1492
+ /**
1493
+ * Deliver an exchange envelope to a callback. Errors thrown by the callback
1494
+ * are logged and swallowed — exchange capture must never interrupt the call.
1495
+ */
1496
+ async function emitExchange({ envelope, onExchange, }) {
1497
+ if (!onExchange) {
1498
+ return;
1499
+ }
1500
+ try {
1501
+ await onExchange(envelope);
1502
+ }
1503
+ catch (error) {
1504
+ const log = getLogger$7();
1505
+ log.warn("[operate] onExchange callback threw");
1506
+ log.var({ error });
1507
+ }
1508
+ }
1509
+
1510
+ //
1511
+ //
1512
+ // Constants
1513
+ //
1514
+ const MODULE$1 = {
1515
+ // Computed at runtime so bundlers (esbuild) do not attempt to include
1516
+ // @jaypie/dynamodb, which is an optional peer dependency.
1517
+ JAYPIE_DYNAMODB: ["@jaypie", "dynamodb"].join("/"),
1518
+ };
1519
+ //
1520
+ //
1521
+ // Helpers
1522
+ //
1523
+ // CJS/ESM compatible require - handles bundling to CJS where import.meta.url
1524
+ // becomes undefined (mirrors observability/llmobs.ts).
1525
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1526
+ // @ts-ignore - __filename exists in CJS context when ESM is bundled to CJS
1527
+ const requireModule$1 = typeof __filename !== "undefined"
1528
+ ? module$1.createRequire(url.pathToFileURL(__filename).href)
1529
+ : module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
1530
+ let resolved$1 = false;
1531
+ let cachedSdk$2 = null;
1532
+ /**
1533
+ * Lazily resolve @jaypie/dynamodb's storeExchange. Returns null (and never
1534
+ * throws) when the peer is absent. Cached after the first attempt.
1535
+ */
1536
+ function resolveExchangeStore() {
1537
+ if (resolved$1) {
1538
+ return cachedSdk$2;
1539
+ }
1540
+ resolved$1 = true;
1541
+ try {
1542
+ const dynamodb = requireModule$1(MODULE$1.JAYPIE_DYNAMODB);
1543
+ const sdk = dynamodb?.default ?? dynamodb;
1544
+ if (sdk && typeof sdk.storeExchange === "function") {
1545
+ cachedSdk$2 = sdk;
1546
+ }
1547
+ }
1548
+ catch {
1549
+ cachedSdk$2 = null;
1550
+ }
1551
+ return cachedSdk$2;
1552
+ }
1553
+ //
1554
+ //
1555
+ // Main
1556
+ //
1557
+ /**
1558
+ * Persist an exchange envelope via @jaypie/dynamodb when
1559
+ * LLM_EXCHANGE_ENABLED is set. Silent no-op when the flag is unset or the
1560
+ * peer is absent; persister failures are logged and never thrown.
1561
+ */
1562
+ async function persistExchange(envelope) {
1563
+ if (!isExchangeStoreEnabled()) {
1564
+ return;
1565
+ }
1566
+ const sdk = resolveExchangeStore();
1567
+ if (!sdk) {
1568
+ return;
1569
+ }
1570
+ try {
1571
+ await sdk.storeExchange(envelope);
1572
+ }
1573
+ catch (error) {
1574
+ const log = getLogger$7();
1575
+ log.warn("[operate] Exchange persistence failed");
1576
+ log.var({ error });
1577
+ }
1578
+ }
1579
+
1580
+ //
1581
+ //
1582
+ // Abstract Base Adapter
1583
+ //
1584
+ /**
1585
+ * BaseProviderAdapter provides default implementations for common adapter methods.
1586
+ * Providers can extend this class to reduce boilerplate.
1587
+ */
1588
+ class BaseProviderAdapter {
1589
+ constructor() {
1590
+ /**
1591
+ * Whether OperateLoop may take a corrective turn when a `format` request
1592
+ * completes with prose instead of structured output. Providers whose
1593
+ * structured output rides a tool emulation (where compliance is a model
1594
+ * decision) opt in; native grammar-constrained providers do not need it.
1595
+ */
1596
+ this.supportsStructuredOutputRetry = false;
1597
+ }
1598
+ /**
1599
+ * Default implementation checks if error is retryable via classifyError
1600
+ */
1601
+ isRetryableError(error) {
1602
+ const classified = this.classifyError(error);
1603
+ return classified.shouldRetry;
1604
+ }
1605
+ /**
1606
+ * Default implementation checks error category via classifyError
1607
+ */
1608
+ isRateLimitError(error) {
1609
+ const classified = this.classifyError(error);
1610
+ return classified.category === "rate_limit";
1611
+ }
1612
+ /**
1613
+ * Default implementation returns false - override for providers with native structured output
1614
+ */
1615
+ hasStructuredOutput(_response) {
1616
+ return false;
1617
+ }
1618
+ /**
1619
+ * Default implementation returns undefined - override for providers with native structured output
1620
+ */
1621
+ extractStructuredOutput(_response) {
1622
+ return undefined;
1623
+ }
1624
+ }
1625
+
1626
+ /**
1627
+ * Per-provider translation of the provider-neutral {@link LlmEffort} scale.
1628
+ *
1629
+ * The neutral enum (lowest → low → medium → high → highest) is a relative
1630
+ * five-point scale. Each table below maps it onto the target provider's native
1631
+ * effort control, keeping `medium`/`high` semantically aligned across providers
1632
+ * and using each provider's extra bottom (`minimal`) or top (`xhigh`/`max`)
1633
+ * rung where one exists. Providers with fewer levels collapse the ends and mark
1634
+ * the result `papered`. A single `effort` value is therefore safe to reuse
1635
+ * across providers and fallback chains.
1636
+ */
1637
+ /** Consistent debug message for a papered-over effort level. */
1638
+ function paperedEffortMessage({ model, provider, requested, value, }) {
1639
+ return `[llm] effort '${requested}' has no distinct tier on ${provider} model '${model}'; using '${value}'`;
1640
+ }
1641
+ // OpenAI Responses API `reasoning.effort`.
1642
+ // Full ladder: minimal | low | medium | high | xhigh (plus `none`). Availability
1643
+ // is not uniform across the gpt-5 line:
1644
+ // - `xhigh` was introduced at gpt-5.2 and has been continuous since, so it is
1645
+ // safe for our gpt-5.4 default and everything newer.
1646
+ // - `minimal` shipped on gpt-5/5.1, was dropped at gpt-5.2, and returned on
1647
+ // the current line; because that history is non-monotonic we only trust it
1648
+ // from gpt-5.4 (our default floor) onward.
1649
+ // Outside those windows (older gpt-5, o-series) the extreme rung is clamped and
1650
+ // the mapping reports `papered: true`.
1651
+ const OPENAI_EFFORT = {
1652
+ [EFFORT.LOWEST]: "minimal",
1653
+ [EFFORT.LOW]: "low",
1654
+ [EFFORT.MEDIUM]: "medium",
1655
+ [EFFORT.HIGH]: "high",
1656
+ [EFFORT.HIGHEST]: "xhigh",
1657
+ };
1658
+ function openAiGptVersion(model) {
1659
+ const match = model.match(/^gpt-(\d+)(?:\.(\d+))?/);
1660
+ if (!match)
1661
+ return null;
1662
+ return { major: Number(match[1]), minor: match[2] ? Number(match[2]) : 0 };
1663
+ }
1664
+ function atLeast(version, major, minor) {
1665
+ if (!version)
1666
+ return false;
1667
+ return (version.major > major || (version.major === major && version.minor >= minor));
1529
1668
  }
1530
- /**
1531
- * Attempts to parse a value as a number. Returns the original input if parsing fails or results in NaN.
1532
- * @param input - The value to attempt to parse as a number
1533
- * @param options - Optional configuration
1534
- * @param options.defaultValue - Default value to return if parsing fails or results in NaN
1535
- * @param options.warnFunction - Function to call with warning message if parsing fails or results in NaN
1536
- * @returns The parsed number, defaultValue (if specified and parsing fails), or the original input
1537
- */
1538
- function tryParseNumber(input, options) {
1539
- if (input === null || input === undefined) {
1540
- return input;
1541
- }
1542
- try {
1543
- const parsed = Number(input);
1544
- if (Number.isNaN(parsed)) {
1545
- if (options?.warnFunction) {
1546
- const warningMessage = `Failed to parse "${String(input)}" as number`;
1547
- callSafely(() => options.warnFunction(warningMessage));
1548
- }
1549
- return typeof options?.defaultValue === "number"
1550
- ? options.defaultValue
1551
- : input;
1552
- }
1553
- return parsed;
1669
+ function toOpenAiEffort(effort, { model }) {
1670
+ const native = OPENAI_EFFORT[effort];
1671
+ const version = openAiGptVersion(model);
1672
+ // `minimal` only from gpt-5.4 (non-monotonic history; absent on o-series)
1673
+ if (native === "minimal" && !atLeast(version, 5, 4)) {
1674
+ return { papered: true, value: "low" };
1554
1675
  }
1555
- catch (error) {
1556
- if (options?.warnFunction) {
1557
- let errorMessage = "";
1558
- if (error instanceof Error) {
1559
- errorMessage = error.message;
1560
- }
1561
- const warningMessage = `Error parsing "${String(input)}" as number${errorMessage ? "; " + errorMessage : ""}`;
1562
- callSafely(() => options.warnFunction(warningMessage));
1563
- }
1564
- return typeof options?.defaultValue === "number"
1565
- ? options.defaultValue
1566
- : input;
1676
+ // `xhigh` from gpt-5.2 onward (absent on older gpt-5 and o-series)
1677
+ if (native === "xhigh" && !atLeast(version, 5, 2)) {
1678
+ return { papered: true, value: "high" };
1567
1679
  }
1680
+ return { papered: false, value: native };
1681
+ }
1682
+ // xAI Grok `reasoning_effort` — low | medium | high. No sub-low or top rung, so
1683
+ // `lowest` collapses onto `low` and `highest` onto `high`.
1684
+ const XAI_EFFORT = {
1685
+ [EFFORT.LOWEST]: { papered: true, value: "low" },
1686
+ [EFFORT.LOW]: { papered: false, value: "low" },
1687
+ [EFFORT.MEDIUM]: { papered: false, value: "medium" },
1688
+ [EFFORT.HIGH]: { papered: false, value: "high" },
1689
+ [EFFORT.HIGHEST]: { papered: true, value: "high" },
1690
+ };
1691
+ function toXaiEffort(effort) {
1692
+ return XAI_EFFORT[effort];
1693
+ }
1694
+ // Anthropic `output_config.effort` — low | medium | high | xhigh | max. No
1695
+ // sub-low rung, so `lowest` collapses onto `low`; `highest` reaches `max`.
1696
+ const ANTHROPIC_EFFORT = {
1697
+ [EFFORT.LOWEST]: { papered: true, value: "low" },
1698
+ [EFFORT.LOW]: { papered: false, value: "low" },
1699
+ [EFFORT.MEDIUM]: { papered: false, value: "medium" },
1700
+ [EFFORT.HIGH]: { papered: false, value: "high" },
1701
+ [EFFORT.HIGHEST]: { papered: false, value: "max" },
1702
+ };
1703
+ function toAnthropicEffort(effort) {
1704
+ return ANTHROPIC_EFFORT[effort];
1705
+ }
1706
+ // Gemini 3.x `thinkingConfig.thinkingLevel` — MINIMAL | LOW | MEDIUM | HIGH. No
1707
+ // top rung above HIGH, so `highest` collapses onto HIGH.
1708
+ const GEMINI_THINKING_LEVEL = {
1709
+ [EFFORT.LOWEST]: { papered: false, value: "MINIMAL" },
1710
+ [EFFORT.LOW]: { papered: false, value: "LOW" },
1711
+ [EFFORT.MEDIUM]: { papered: false, value: "MEDIUM" },
1712
+ [EFFORT.HIGH]: { papered: false, value: "HIGH" },
1713
+ [EFFORT.HIGHEST]: { papered: true, value: "HIGH" },
1714
+ };
1715
+ function toGeminiThinkingLevel(effort) {
1716
+ return GEMINI_THINKING_LEVEL[effort];
1717
+ }
1718
+ // Gemini 2.5 `thinkingConfig.thinkingBudget` — every tier is a distinct token
1719
+ // budget, so nothing is papered over. Floor (512) clears every 2.5 minimum;
1720
+ // ceiling (24,576) is valid across 2.5 Pro (max 32,768) and Flash (max 24,576).
1721
+ const GEMINI_THINKING_BUDGET = {
1722
+ [EFFORT.LOWEST]: 512,
1723
+ [EFFORT.LOW]: 4096,
1724
+ [EFFORT.MEDIUM]: 8192,
1725
+ [EFFORT.HIGH]: 16384,
1726
+ [EFFORT.HIGHEST]: 24576,
1727
+ };
1728
+ function toGeminiThinkingBudget(effort) {
1729
+ return { papered: false, value: GEMINI_THINKING_BUDGET[effort] };
1568
1730
  }
1731
+ // Fireworks `reasoning_effort` — low | medium | high. Accepted on every model
1732
+ // (the API no-ops where unsupported). No sub-low or top rung, so `lowest`
1733
+ // collapses onto `low` and `highest` onto `high`.
1734
+ const FIREWORKS_EFFORT = {
1735
+ [EFFORT.LOWEST]: { papered: true, value: "low" },
1736
+ [EFFORT.LOW]: { papered: false, value: "low" },
1737
+ [EFFORT.MEDIUM]: { papered: false, value: "medium" },
1738
+ [EFFORT.HIGH]: { papered: false, value: "high" },
1739
+ [EFFORT.HIGHEST]: { papered: true, value: "high" },
1740
+ };
1741
+ function toFireworksEffort(effort) {
1742
+ return FIREWORKS_EFFORT[effort];
1743
+ }
1744
+ // OpenRouter `reasoning.effort` — accepts the full minimal..xhigh ladder and
1745
+ // maps to the routed provider's nearest supported level itself, so nothing is
1746
+ // papered here.
1747
+ const OPENROUTER_EFFORT = {
1748
+ [EFFORT.LOWEST]: "minimal",
1749
+ [EFFORT.LOW]: "low",
1750
+ [EFFORT.MEDIUM]: "medium",
1751
+ [EFFORT.HIGH]: "high",
1752
+ [EFFORT.HIGHEST]: "xhigh",
1753
+ };
1754
+ function toOpenRouterEffort(effort) {
1755
+ return { papered: false, value: OPENROUTER_EFFORT[effort] };
1756
+ }
1757
+
1758
+ //
1759
+ //
1760
+ // Types
1761
+ //
1762
+ exports.LlmStreamChunkType = void 0;
1763
+ (function (LlmStreamChunkType) {
1764
+ LlmStreamChunkType["Done"] = "done";
1765
+ LlmStreamChunkType["Error"] = "error";
1766
+ LlmStreamChunkType["Text"] = "text";
1767
+ LlmStreamChunkType["ToolCall"] = "tool_call";
1768
+ LlmStreamChunkType["ToolResult"] = "tool_result";
1769
+ })(exports.LlmStreamChunkType || (exports.LlmStreamChunkType = {}));
1569
1770
 
1570
1771
  //
1571
1772
  //
@@ -3412,6 +3613,10 @@ class FireworksAdapter extends BaseProviderAdapter {
3412
3613
  super(...arguments);
3413
3614
  this.name = PROVIDER.FIREWORKS.NAME;
3414
3615
  this.defaultModel = PROVIDER.FIREWORKS.DEFAULT;
3616
+ // Structured output with tools rides the structured_output tool emulation
3617
+ // (Fireworks rejects response_format + tools), and emulation compliance is
3618
+ // a model decision — opt in to OperateLoop's corrective retry turn.
3619
+ this.supportsStructuredOutputRetry = true;
3415
3620
  // Session-level cache of models observed to reject native
3416
3621
  // `response_format: json_schema`. When a model is in this set, buildRequest
3417
3622
  // engages the legacy fake-tool path instead of native structured output.
@@ -3466,9 +3671,10 @@ class FireworksAdapter extends BaseProviderAdapter {
3466
3671
  const useFallbackStructuredOutput = Boolean(request.format) &&
3467
3672
  (hasCallerTools ||
3468
3673
  !this.supportsStructuredOutput(fireworksRequest.model));
3469
- const allTools = request.tools
3470
- ? [...request.tools]
3471
- : [];
3674
+ // On a corrective retry turn (the model answered a format request with
3675
+ // prose), offer only the structured_output tool so the demanded call is
3676
+ // the sole option.
3677
+ const allTools = request.tools && !request.structuredOutputRetry ? [...request.tools] : [];
3472
3678
  if (useFallbackStructuredOutput && request.format) {
3473
3679
  log$1.log.warn(hasCallerTools
3474
3680
  ? `[FireworksAdapter] Fireworks does not support response_format combined with tools; using structured_output tool emulation for model ${fireworksRequest.model}.`
@@ -8009,6 +8215,31 @@ function createErrorClassifier(adapter) {
8009
8215
  },
8010
8216
  };
8011
8217
  }
8218
+ /**
8219
+ * Attempt to read a prose response as the structured payload itself: parse the
8220
+ * text (stripping a Markdown code fence if present) and return the object, or
8221
+ * undefined when the text is not a JSON object.
8222
+ */
8223
+ function tryParseJsonObject(text) {
8224
+ let candidate = text.trim();
8225
+ const fence = candidate.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
8226
+ if (fence) {
8227
+ candidate = fence[1].trim();
8228
+ }
8229
+ if (!candidate.startsWith("{")) {
8230
+ return undefined;
8231
+ }
8232
+ try {
8233
+ const parsed = JSON.parse(candidate);
8234
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
8235
+ return parsed;
8236
+ }
8237
+ }
8238
+ catch {
8239
+ // Not JSON; caller falls through to the corrective-turn path.
8240
+ }
8241
+ return undefined;
8242
+ }
8012
8243
  //
8013
8244
  //
8014
8245
  // Main
@@ -8038,10 +8269,14 @@ class OperateLoop {
8038
8269
  log.trace("[operate] Starting operate loop");
8039
8270
  log.trace.var({ "operate.input": input });
8040
8271
  log.trace.var({ "operate.options": options });
8272
+ const startedAt = new Date().toISOString();
8273
+ const startMs = Date.now();
8041
8274
  // Initialize state
8042
8275
  const state = await this.initializeState(input, options);
8043
8276
  const context = this.createContext(options);
8044
8277
  const modelName = options.model ?? this.adapter.defaultModel;
8278
+ const exchangeRequested = isExchangeRequested(options);
8279
+ const initialHistoryLength = state.responseBuilder.getHistory().length;
8045
8280
  await emitProgress({
8046
8281
  event: {
8047
8282
  maxTurns: state.maxTurns,
@@ -8053,58 +8288,102 @@ class OperateLoop {
8053
8288
  });
8054
8289
  // Enclosing LLM Observability span (no-op when DD_LLMOBS_ENABLED is unset).
8055
8290
  // Child llm/tool spans nest under it via the SDK's active-span context.
8056
- return withLlmObsSpan({
8057
- kind: state.toolkit ? "agent" : "llm",
8058
- modelName,
8059
- modelProvider: this.adapter.name,
8060
- name: "jaypie.llm.operate",
8061
- }, async () => {
8062
- // Build initial request
8063
- let request = this.buildInitialRequest(state, options);
8064
- // Multi-turn loop
8065
- while (state.currentTurn < state.maxTurns) {
8066
- state.currentTurn++;
8067
- // Execute one turn with retry logic
8068
- const shouldContinue = await this.executeOneTurn(request, state, context, options);
8069
- if (!shouldContinue) {
8070
- break;
8291
+ try {
8292
+ return await withLlmObsSpan({
8293
+ kind: state.toolkit ? "agent" : "llm",
8294
+ modelName,
8295
+ modelProvider: this.adapter.name,
8296
+ name: "jaypie.llm.operate",
8297
+ }, async () => {
8298
+ // Build initial request
8299
+ let request = this.buildInitialRequest(state, options);
8300
+ // Multi-turn loop
8301
+ while (state.currentTurn < state.maxTurns) {
8302
+ state.currentTurn++;
8303
+ // Execute one turn with retry logic
8304
+ const shouldContinue = await this.executeOneTurn(request, state, context, options);
8305
+ if (!shouldContinue) {
8306
+ break;
8307
+ }
8308
+ // Rebuild request with updated history for next turn
8309
+ request = {
8310
+ effort: options.effort,
8311
+ format: state.formattedFormat,
8312
+ instructions: options.instructions,
8313
+ messages: state.currentInput,
8314
+ model: modelName,
8315
+ providerOptions: options.providerOptions,
8316
+ structuredOutputRetry: state.structuredOutputRetry,
8317
+ system: options.system,
8318
+ temperature: options.temperature,
8319
+ tools: state.formattedTools,
8320
+ user: options.user,
8321
+ };
8071
8322
  }
8072
- // Rebuild request with updated history for next turn
8073
- request = {
8074
- effort: options.effort,
8075
- format: state.formattedFormat,
8076
- instructions: options.instructions,
8077
- messages: state.currentInput,
8078
- model: modelName,
8079
- providerOptions: options.providerOptions,
8080
- system: options.system,
8081
- temperature: options.temperature,
8082
- tools: state.formattedTools,
8083
- user: options.user,
8084
- };
8085
- }
8086
- const response = state.responseBuilder.build();
8087
- annotateLlmObs({
8088
- inputData: input,
8089
- metrics: usageToLlmObsMetrics(response.usage),
8090
- outputData: response.content,
8091
- });
8092
- tallyOperate({
8093
- toolCallNames: state.toolCallNames,
8094
- turns: state.currentTurn,
8095
- usage: response.usage,
8096
- });
8097
- await emitProgress({
8098
- event: {
8099
- content: response.content,
8100
- turn: state.currentTurn,
8101
- type: exports.LlmProgressEventType.Done,
8323
+ const response = state.responseBuilder.build();
8324
+ if (exchangeRequested) {
8325
+ response.exchange = buildExchangeEnvelope({
8326
+ duration: Date.now() - startMs,
8327
+ initialHistoryLength,
8328
+ input,
8329
+ options,
8330
+ response,
8331
+ startedAt,
8332
+ state,
8333
+ });
8334
+ }
8335
+ annotateLlmObs({
8336
+ inputData: input,
8337
+ metrics: usageToLlmObsMetrics(response.usage),
8338
+ outputData: response.content,
8339
+ });
8340
+ tallyOperate({
8341
+ toolCallNames: state.toolCallNames,
8342
+ turns: state.currentTurn,
8102
8343
  usage: response.usage,
8103
- },
8104
- onProgress: options.onProgress,
8344
+ });
8345
+ await emitProgress({
8346
+ event: {
8347
+ content: response.content,
8348
+ turn: state.currentTurn,
8349
+ type: exports.LlmProgressEventType.Done,
8350
+ usage: response.usage,
8351
+ },
8352
+ onProgress: options.onProgress,
8353
+ });
8354
+ return response;
8105
8355
  });
8106
- return response;
8107
- });
8356
+ }
8357
+ catch (error) {
8358
+ // A hard failure (retry budget exhausted, unrecoverable) still settles
8359
+ // the exchange: attach the envelope to the thrown error so the facade
8360
+ // can emit it before rethrowing to the caller.
8361
+ if (exchangeRequested) {
8362
+ const response = state.responseBuilder.build();
8363
+ if (!response.error) {
8364
+ const thrown = error;
8365
+ response.error = {
8366
+ detail: thrown.detail ?? thrown.message,
8367
+ status: thrown.status ?? 500,
8368
+ title: thrown.title ?? thrown.name ?? "Error",
8369
+ };
8370
+ }
8371
+ if (response.status === LlmResponseStatus.InProgress) {
8372
+ response.status = LlmResponseStatus.Incomplete;
8373
+ }
8374
+ error.exchange =
8375
+ buildExchangeEnvelope({
8376
+ duration: Date.now() - startMs,
8377
+ initialHistoryLength,
8378
+ input,
8379
+ options,
8380
+ response,
8381
+ startedAt,
8382
+ state,
8383
+ });
8384
+ }
8385
+ throw error;
8386
+ }
8108
8387
  }
8109
8388
  //
8110
8389
  // Private Methods
@@ -8163,6 +8442,7 @@ class OperateLoop {
8163
8442
  formattedTools,
8164
8443
  maxTurns,
8165
8444
  responseBuilder,
8445
+ retries: 0,
8166
8446
  toolCallNames: [],
8167
8447
  toolkit,
8168
8448
  };
@@ -8224,26 +8504,26 @@ class OperateLoop {
8224
8504
  options,
8225
8505
  providerRequest,
8226
8506
  });
8227
- // Emit retry progress alongside the caller's onRetryableModelError hook
8507
+ // Count retries for the exchange envelope and emit retry progress
8508
+ // alongside the caller's onRetryableModelError hook
8228
8509
  const hooks = context.hooks;
8229
- const hooksWithProgress = options.onProgress
8230
- ? {
8231
- ...hooks,
8232
- onRetryableModelError: async (retryContext) => {
8233
- await emitProgress({
8234
- event: {
8235
- error: retryContext.error instanceof Error
8236
- ? retryContext.error.message
8237
- : String(retryContext.error),
8238
- turn: state.currentTurn,
8239
- type: exports.LlmProgressEventType.Retry,
8240
- },
8241
- onProgress: options.onProgress,
8242
- });
8243
- return hooks?.onRetryableModelError?.(retryContext);
8244
- },
8245
- }
8246
- : hooks;
8510
+ const hooksWithProgress = {
8511
+ ...hooks,
8512
+ onRetryableModelError: async (retryContext) => {
8513
+ state.retries++;
8514
+ await emitProgress({
8515
+ event: {
8516
+ error: retryContext.error instanceof Error
8517
+ ? retryContext.error.message
8518
+ : String(retryContext.error),
8519
+ turn: state.currentTurn,
8520
+ type: exports.LlmProgressEventType.Retry,
8521
+ },
8522
+ onProgress: options.onProgress,
8523
+ });
8524
+ return hooks?.onRetryableModelError?.(retryContext);
8525
+ },
8526
+ };
8247
8527
  // Execute with retry inside a child llm span (no-op when llmobs disabled).
8248
8528
  // RetryExecutor handles error hooks and throws appropriate errors.
8249
8529
  const { parsed, response, toolCalls } = await withLlmObsSpan({
@@ -8302,6 +8582,10 @@ class OperateLoop {
8302
8582
  if (parsed.usage) {
8303
8583
  state.responseBuilder.addUsage(parsed.usage);
8304
8584
  }
8585
+ // Track stop reason for the exchange envelope
8586
+ if (parsed.stopReason) {
8587
+ state.lastStopReason = parsed.stopReason;
8588
+ }
8305
8589
  // Add raw response
8306
8590
  state.responseBuilder.addResponse(parsed.raw);
8307
8591
  // Execute afterEachModelResponse hook
@@ -8480,6 +8764,42 @@ class OperateLoop {
8480
8764
  return true; // Continue to next turn
8481
8765
  }
8482
8766
  }
8767
+ // Format contract enforcement: the loop is about to complete but the
8768
+ // model answered with prose instead of structured output.
8769
+ if (state.formattedFormat && typeof parsed.content === "string") {
8770
+ // First salvage attempt: the text may be the JSON itself (with or
8771
+ // without a code fence).
8772
+ const salvaged = tryParseJsonObject(parsed.content);
8773
+ if (salvaged) {
8774
+ state.responseBuilder.setContent(this.applyFormatArrayDefaults(salvaged, options));
8775
+ state.responseBuilder.complete();
8776
+ for (const item of this.adapter.responseToHistoryItems(parsed.raw)) {
8777
+ state.responseBuilder.appendToHistory(item);
8778
+ }
8779
+ return false; // Stop loop
8780
+ }
8781
+ // Corrective turn: for adapters whose structured output rides a tool
8782
+ // emulation, take another turn offering only the structured_output tool
8783
+ // and demand it be called. Bounded by maxTurns.
8784
+ if (this.adapter.supportsStructuredOutputRetry &&
8785
+ state.currentTurn < state.maxTurns) {
8786
+ log.warn(`[operate] Model returned text despite format on turn ${state.currentTurn}; retrying with structured_output tool only`);
8787
+ for (const item of this.adapter.responseToHistoryItems(parsed.raw)) {
8788
+ state.currentInput.push(item);
8789
+ state.responseBuilder.appendToHistory(item);
8790
+ }
8791
+ const corrective = {
8792
+ content: "You must provide your final answer by calling the structured_output tool " +
8793
+ "with arguments matching the required schema. Do not respond with text.",
8794
+ role: exports.LlmMessageRole.User,
8795
+ type: exports.LlmMessageType.Message,
8796
+ };
8797
+ state.currentInput.push(corrective);
8798
+ state.responseBuilder.appendToHistory(corrective);
8799
+ state.structuredOutputRetry = true;
8800
+ return true; // Continue to corrective turn
8801
+ }
8802
+ }
8483
8803
  // No tool calls or no toolkit - we're done
8484
8804
  state.responseBuilder.setContent(this.applyFormatArrayDefaults(parsed.content, options));
8485
8805
  state.responseBuilder.complete();
@@ -10768,12 +11088,17 @@ class Llm {
10768
11088
  attempts++;
10769
11089
  try {
10770
11090
  const response = await this._llm.operate(input, optionsWithoutFallback);
10771
- return {
11091
+ const settled = {
10772
11092
  ...response,
10773
11093
  fallbackAttempts: attempts,
10774
11094
  fallbackUsed: false,
10775
11095
  provider: response.provider || this._provider,
10776
11096
  };
11097
+ await this.settleExchange({
11098
+ onExchange: resolvedOptions.onExchange,
11099
+ response: settled,
11100
+ });
11101
+ return settled;
10777
11102
  }
10778
11103
  catch (error) {
10779
11104
  lastError = error;
@@ -10782,18 +11107,28 @@ class Llm {
10782
11107
  fallbacksRemaining: fallbackChain.length,
10783
11108
  });
10784
11109
  }
10785
- // Try fallback providers
11110
+ // Try fallback providers. The fallback instance's underlying provider is
11111
+ // called directly so the nested facade does not settle the exchange —
11112
+ // exactly one settlement per operate() happens here.
10786
11113
  for (const fallbackConfig of fallbackChain) {
10787
11114
  attempts++;
10788
11115
  try {
10789
11116
  const fallbackInstance = this.createFallbackInstance(fallbackConfig);
10790
- const response = await fallbackInstance.operate(input, optionsWithoutFallback);
10791
- return {
11117
+ if (!fallbackInstance._llm.operate) {
11118
+ throw new errors.NotImplementedError(`Provider ${fallbackConfig.provider} does not support operate method`);
11119
+ }
11120
+ const response = await fallbackInstance._llm.operate(input, optionsWithoutFallback);
11121
+ const settled = {
10792
11122
  ...response,
10793
11123
  fallbackAttempts: attempts,
10794
11124
  fallbackUsed: true,
10795
11125
  provider: response.provider || fallbackConfig.provider,
10796
11126
  };
11127
+ await this.settleExchange({
11128
+ onExchange: resolvedOptions.onExchange,
11129
+ response: settled,
11130
+ });
11131
+ return settled;
10797
11132
  }
10798
11133
  catch (error) {
10799
11134
  lastError = error;
@@ -10803,9 +11138,44 @@ class Llm {
10803
11138
  });
10804
11139
  }
10805
11140
  }
10806
- // All providers failed, throw the last error
11141
+ // All providers failed: settle the exchange from the envelope the loop
11142
+ // attached to the last error, then throw
11143
+ const failureEnvelope = lastError
11144
+ ?.exchange;
11145
+ if (failureEnvelope) {
11146
+ failureEnvelope.resolution = {
11147
+ ...failureEnvelope.resolution,
11148
+ fallbackAttempts: attempts,
11149
+ fallbackUsed: attempts > 1,
11150
+ };
11151
+ await emitExchange({
11152
+ envelope: failureEnvelope,
11153
+ onExchange: resolvedOptions.onExchange,
11154
+ });
11155
+ await persistExchange(failureEnvelope);
11156
+ }
10807
11157
  throw lastError;
10808
11158
  }
11159
+ /**
11160
+ * Stamp fallback resolution onto the envelope the operate loop attached to
11161
+ * the response and deliver it to the caller's onExchange. Fires once per
11162
+ * operate() settlement; callback errors are logged and never thrown.
11163
+ */
11164
+ async settleExchange({ onExchange, response, }) {
11165
+ const envelope = response.exchange;
11166
+ if (!envelope) {
11167
+ return;
11168
+ }
11169
+ envelope.resolution = {
11170
+ ...envelope.resolution,
11171
+ fallbackAttempts: response.fallbackAttempts,
11172
+ fallbackUsed: response.fallbackUsed,
11173
+ model: response.model,
11174
+ provider: response.provider,
11175
+ };
11176
+ await emitExchange({ envelope, onExchange });
11177
+ await persistExchange(envelope);
11178
+ }
10809
11179
  async *stream(input, options = {}) {
10810
11180
  if (!this._llm.stream) {
10811
11181
  throw new errors.NotImplementedError(`Provider ${this._provider} does not support stream method`);