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