@jaypie/llm 1.3.13 → 1.3.15

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.
Files changed (34) hide show
  1. package/dist/cjs/Llm.d.ts +6 -0
  2. package/dist/cjs/index.cjs +813 -344
  3. package/dist/cjs/index.cjs.map +1 -1
  4. package/dist/cjs/index.d.cts +112 -1
  5. package/dist/cjs/index.d.ts +1 -1
  6. package/dist/cjs/observability/exchangeStore.d.ts +20 -0
  7. package/dist/cjs/operate/adapters/BedrockAdapter.d.ts +6 -0
  8. package/dist/cjs/operate/adapters/OpenAiAdapter.d.ts +5 -0
  9. package/dist/cjs/operate/adapters/OpenRouterAdapter.d.ts +11 -0
  10. package/dist/cjs/operate/exchange/buildExchangeEnvelope.d.ts +16 -0
  11. package/dist/cjs/operate/exchange/emitExchange.d.ts +20 -0
  12. package/dist/cjs/operate/exchange/index.d.ts +2 -0
  13. package/dist/cjs/operate/types.d.ts +10 -1
  14. package/dist/cjs/providers/anthropic/types.d.ts +14 -1
  15. package/dist/cjs/types/LlmProvider.interface.d.ts +106 -1
  16. package/dist/cjs/util/cacheControl.d.ts +21 -0
  17. package/dist/cjs/util/index.d.ts +1 -0
  18. package/dist/esm/Llm.d.ts +6 -0
  19. package/dist/esm/index.d.ts +112 -1
  20. package/dist/esm/index.js +813 -344
  21. package/dist/esm/index.js.map +1 -1
  22. package/dist/esm/observability/exchangeStore.d.ts +20 -0
  23. package/dist/esm/operate/adapters/BedrockAdapter.d.ts +6 -0
  24. package/dist/esm/operate/adapters/OpenAiAdapter.d.ts +5 -0
  25. package/dist/esm/operate/adapters/OpenRouterAdapter.d.ts +11 -0
  26. package/dist/esm/operate/exchange/buildExchangeEnvelope.d.ts +16 -0
  27. package/dist/esm/operate/exchange/emitExchange.d.ts +20 -0
  28. package/dist/esm/operate/exchange/index.d.ts +2 -0
  29. package/dist/esm/operate/types.d.ts +10 -1
  30. package/dist/esm/providers/anthropic/types.d.ts +14 -1
  31. package/dist/esm/types/LlmProvider.interface.d.ts +106 -1
  32. package/dist/esm/util/cacheControl.d.ts +21 -0
  33. package/dist/esm/util/index.d.ts +1 -0
  34. package/package.json +5 -1
@@ -478,234 +478,130 @@ 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
- constructor() {
489
- /**
490
- * Whether OperateLoop may take a corrective turn when a `format` request
491
- * completes with prose instead of structured output. Providers whose
492
- * structured output rides a tool emulation (where compliance is a model
493
- * decision) opt in; native grammar-constrained providers do not need it.
494
- */
495
- this.supportsStructuredOutputRetry = false;
496
- }
497
- /**
498
- * Default implementation checks if error is retryable via classifyError
499
- */
500
- isRetryableError(error) {
501
- const classified = this.classifyError(error);
502
- return classified.shouldRetry;
503
- }
504
- /**
505
- * Default implementation checks error category via classifyError
506
- */
507
- isRateLimitError(error) {
508
- const classified = this.classifyError(error);
509
- return classified.category === "rate_limit";
510
- }
511
- /**
512
- * Default implementation returns false - override for providers with native structured output
513
- */
514
- hasStructuredOutput(_response) {
515
- return false;
516
- }
517
- /**
518
- * Default implementation returns undefined - override for providers with native structured output
519
- */
520
- extractStructuredOutput(_response) {
483
+ function sumUsageByProviderModel(usage) {
484
+ if (!usage.length) {
521
485
  return undefined;
522
486
  }
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;
504
+ if (item.cacheRead !== undefined) {
505
+ totals[key].cacheRead = (totals[key].cacheRead ?? 0) + item.cacheRead;
506
+ }
507
+ if (item.cacheWrite !== undefined) {
508
+ totals[key].cacheWrite = (totals[key].cacheWrite ?? 0) + item.cacheWrite;
509
+ }
510
+ }
511
+ return totals;
523
512
  }
524
-
513
+ function extractResponseIds(responses) {
514
+ const ids = responses
515
+ .map((response) => response && typeof response === "object"
516
+ ? response.id
517
+ : undefined)
518
+ .filter((id) => typeof id === "string");
519
+ return ids.length ? ids : undefined;
520
+ }
521
+ //
522
+ //
523
+ // Main
524
+ //
525
525
  /**
526
- * Per-provider translation of the provider-neutral {@link LlmEffort} scale.
527
- *
528
- * The neutral enum (lowest low medium → high → highest) is a relative
529
- * five-point scale. Each table below maps it onto the target provider's native
530
- * effort control, keeping `medium`/`high` semantically aligned across providers
531
- * and using each provider's extra bottom (`minimal`) or top (`xhigh`/`max`)
532
- * rung where one exists. Providers with fewer levels collapse the ends and mark
533
- * the result `papered`. A single `effort` value is therefore safe to reuse
534
- * across providers and fallback chains.
526
+ * Assemble the serializable exchange envelope for one operate() settlement.
527
+ * The `resolution` block is stamped by the Llm facade, which is the layer
528
+ * that knows fallback outcome; the loop fills provider/model best-effort.
535
529
  */
536
- /** Consistent debug message for a papered-over effort level. */
537
- function paperedEffortMessage({ model, provider, requested, value, }) {
538
- return `[llm] effort '${requested}' has no distinct tier on ${provider} model '${model}'; using '${value}'`;
539
- }
540
- // OpenAI Responses API `reasoning.effort`.
541
- // Full ladder: minimal | low | medium | high | xhigh (plus `none`). Availability
542
- // is not uniform across the gpt-5 line:
543
- // - `xhigh` was introduced at gpt-5.2 and has been continuous since, so it is
544
- // safe for our gpt-5.4 default and everything newer.
545
- // - `minimal` shipped on gpt-5/5.1, was dropped at gpt-5.2, and returned on
546
- // the current line; because that history is non-monotonic we only trust it
547
- // from gpt-5.4 (our default floor) onward.
548
- // Outside those windows (older gpt-5, o-series) the extreme rung is clamped and
549
- // the mapping reports `papered: true`.
550
- const OPENAI_EFFORT = {
551
- [EFFORT.LOWEST]: "minimal",
552
- [EFFORT.LOW]: "low",
553
- [EFFORT.MEDIUM]: "medium",
554
- [EFFORT.HIGH]: "high",
555
- [EFFORT.HIGHEST]: "xhigh",
556
- };
557
- function openAiGptVersion(model) {
558
- const match = model.match(/^gpt-(\d+)(?:\.(\d+))?/);
559
- if (!match)
560
- return null;
561
- return { major: Number(match[1]), minor: match[2] ? Number(match[2]) : 0 };
562
- }
563
- function atLeast(version, major, minor) {
564
- if (!version)
565
- return false;
566
- return (version.major > major || (version.major === major && version.minor >= minor));
530
+ function buildExchangeEnvelope({ duration, initialHistoryLength, input, options, response, startedAt, state, }) {
531
+ const historyDelta = response.history.slice(initialHistoryLength);
532
+ return {
533
+ ids: extractResponseIds(response.responses),
534
+ request: {
535
+ cache: options.cache,
536
+ data: options.data,
537
+ effort: options.effort,
538
+ explain: options.explain,
539
+ format: state.formattedFormat,
540
+ input,
541
+ instructions: options.instructions,
542
+ model: options.model,
543
+ placeholders: options.placeholders,
544
+ providerOptions: options.providerOptions,
545
+ system: options.system,
546
+ temperature: options.temperature,
547
+ tools: state.formattedTools?.map(({ name }) => name),
548
+ turns: options.turns,
549
+ user: options.user,
550
+ },
551
+ resolution: {
552
+ model: response.model,
553
+ provider: response.provider,
554
+ retries: state.retries,
555
+ },
556
+ response: {
557
+ content: response.content,
558
+ error: response.error,
559
+ historyDelta,
560
+ reasoning: response.reasoning,
561
+ status: response.status,
562
+ stopReason: state.lastStopReason,
563
+ usage: response.usage,
564
+ usageTotals: sumUsageByProviderModel(response.usage),
565
+ },
566
+ timing: {
567
+ duration,
568
+ startedAt,
569
+ },
570
+ };
567
571
  }
568
- function toOpenAiEffort(effort, { model }) {
569
- const native = OPENAI_EFFORT[effort];
570
- const version = openAiGptVersion(model);
571
- // `minimal` only from gpt-5.4 (non-monotonic history; absent on o-series)
572
- if (native === "minimal" && !atLeast(version, 5, 4)) {
573
- return { papered: true, value: "low" };
572
+
573
+ const CACHE_TTL_DEFAULT = "5m";
574
+ /**
575
+ * Normalize the caller-facing `cache` option into a concrete decision.
576
+ * - `undefined` / `true` enabled at the default TTL
577
+ * - `false` / `0` → disabled
578
+ * - `"5m"` / `"1h"` → enabled at that TTL
579
+ */
580
+ function resolveCache(cache) {
581
+ if (cache === false || cache === 0) {
582
+ return { enabled: false, ttl: CACHE_TTL_DEFAULT };
574
583
  }
575
- // `xhigh` from gpt-5.2 onward (absent on older gpt-5 and o-series)
576
- if (native === "xhigh" && !atLeast(version, 5, 2)) {
577
- return { papered: true, value: "high" };
584
+ if (cache === "5m" || cache === "1h") {
585
+ return { enabled: true, ttl: cache };
578
586
  }
579
- return { papered: false, value: native };
580
- }
581
- // xAI Grok `reasoning_effort` — low | medium | high. No sub-low or top rung, so
582
- // `lowest` collapses onto `low` and `highest` onto `high`.
583
- const XAI_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: true, value: "high" },
589
- };
590
- function toXaiEffort(effort) {
591
- return XAI_EFFORT[effort];
592
- }
593
- // Anthropic `output_config.effort` — low | medium | high | xhigh | max. No
594
- // sub-low rung, so `lowest` collapses onto `low`; `highest` reaches `max`.
595
- const ANTHROPIC_EFFORT = {
596
- [EFFORT.LOWEST]: { papered: true, value: "low" },
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: false, value: "max" },
601
- };
602
- function toAnthropicEffort(effort) {
603
- return ANTHROPIC_EFFORT[effort];
604
- }
605
- // Gemini 3.x `thinkingConfig.thinkingLevel` — MINIMAL | LOW | MEDIUM | HIGH. No
606
- // top rung above HIGH, so `highest` collapses onto HIGH.
607
- const GEMINI_THINKING_LEVEL = {
608
- [EFFORT.LOWEST]: { papered: false, value: "MINIMAL" },
609
- [EFFORT.LOW]: { papered: false, value: "LOW" },
610
- [EFFORT.MEDIUM]: { papered: false, value: "MEDIUM" },
611
- [EFFORT.HIGH]: { papered: false, value: "HIGH" },
612
- [EFFORT.HIGHEST]: { papered: true, value: "HIGH" },
613
- };
614
- function toGeminiThinkingLevel(effort) {
615
- return GEMINI_THINKING_LEVEL[effort];
616
- }
617
- // Gemini 2.5 `thinkingConfig.thinkingBudget` — every tier is a distinct token
618
- // budget, so nothing is papered over. Floor (512) clears every 2.5 minimum;
619
- // ceiling (24,576) is valid across 2.5 Pro (max 32,768) and Flash (max 24,576).
620
- const GEMINI_THINKING_BUDGET = {
621
- [EFFORT.LOWEST]: 512,
622
- [EFFORT.LOW]: 4096,
623
- [EFFORT.MEDIUM]: 8192,
624
- [EFFORT.HIGH]: 16384,
625
- [EFFORT.HIGHEST]: 24576,
626
- };
627
- function toGeminiThinkingBudget(effort) {
628
- return { papered: false, value: GEMINI_THINKING_BUDGET[effort] };
629
- }
630
- // Fireworks `reasoning_effort` — low | medium | high. Accepted on every model
631
- // (the API no-ops where unsupported). No sub-low or top rung, so `lowest`
632
- // collapses onto `low` and `highest` onto `high`.
633
- const FIREWORKS_EFFORT = {
634
- [EFFORT.LOWEST]: { papered: true, value: "low" },
635
- [EFFORT.LOW]: { papered: false, value: "low" },
636
- [EFFORT.MEDIUM]: { papered: false, value: "medium" },
637
- [EFFORT.HIGH]: { papered: false, value: "high" },
638
- [EFFORT.HIGHEST]: { papered: true, value: "high" },
639
- };
640
- function toFireworksEffort(effort) {
641
- return FIREWORKS_EFFORT[effort];
587
+ // undefined or true
588
+ return { enabled: true, ttl: CACHE_TTL_DEFAULT };
642
589
  }
643
- // OpenRouter `reasoning.effort` — accepts the full minimal..xhigh ladder and
644
- // maps to the routed provider's nearest supported level itself, so nothing is
645
- // papered here.
646
- const OPENROUTER_EFFORT = {
647
- [EFFORT.LOWEST]: "minimal",
648
- [EFFORT.LOW]: "low",
649
- [EFFORT.MEDIUM]: "medium",
650
- [EFFORT.HIGH]: "high",
651
- [EFFORT.HIGHEST]: "xhigh",
652
- };
653
- function toOpenRouterEffort(effort) {
654
- return { papered: false, value: OPENROUTER_EFFORT[effort] };
590
+ /**
591
+ * Deterministic short key for providers with automatic, prefix-based caching
592
+ * (e.g. OpenAI `prompt_cache_key`). Derived from the stable prefix so the same
593
+ * system prompt + tools + model always routes to the same cache. Dependency-
594
+ * free FNV-1a; not cryptographic.
595
+ */
596
+ function promptCacheKey(seed) {
597
+ let hash = 0x811c9dc5;
598
+ for (let i = 0; i < seed.length; i += 1) {
599
+ hash ^= seed.charCodeAt(i);
600
+ hash = Math.imul(hash, 0x01000193);
601
+ }
602
+ return `jaypie-${(hash >>> 0).toString(16)}`;
655
603
  }
656
604
 
657
- // Enums
658
- exports.LlmMessageRole = void 0;
659
- (function (LlmMessageRole) {
660
- LlmMessageRole["Assistant"] = "assistant";
661
- LlmMessageRole["Developer"] = "developer";
662
- LlmMessageRole["System"] = "system";
663
- LlmMessageRole["User"] = "user";
664
- })(exports.LlmMessageRole || (exports.LlmMessageRole = {}));
665
- exports.LlmMessageType = void 0;
666
- (function (LlmMessageType) {
667
- LlmMessageType["FunctionCall"] = "function_call";
668
- LlmMessageType["FunctionCallOutput"] = "function_call_output";
669
- LlmMessageType["InputFile"] = "input_file";
670
- LlmMessageType["InputImage"] = "input_image";
671
- LlmMessageType["InputText"] = "input_text";
672
- LlmMessageType["ItemReference"] = "item_reference";
673
- LlmMessageType["Message"] = "message";
674
- LlmMessageType["OutputText"] = "output_text";
675
- LlmMessageType["Refusal"] = "refusal";
676
- })(exports.LlmMessageType || (exports.LlmMessageType = {}));
677
- var LlmResponseStatus;
678
- (function (LlmResponseStatus) {
679
- LlmResponseStatus["Completed"] = "completed";
680
- LlmResponseStatus["Incomplete"] = "incomplete";
681
- LlmResponseStatus["InProgress"] = "in_progress";
682
- })(LlmResponseStatus || (LlmResponseStatus = {}));
683
- // Progress
684
- exports.LlmProgressEventType = void 0;
685
- (function (LlmProgressEventType) {
686
- LlmProgressEventType["Done"] = "done";
687
- LlmProgressEventType["ModelRequest"] = "model_request";
688
- LlmProgressEventType["ModelResponse"] = "model_response";
689
- LlmProgressEventType["Retry"] = "retry";
690
- LlmProgressEventType["Start"] = "start";
691
- LlmProgressEventType["ToolCall"] = "tool_call";
692
- LlmProgressEventType["ToolError"] = "tool_error";
693
- LlmProgressEventType["ToolResult"] = "tool_result";
694
- })(exports.LlmProgressEventType || (exports.LlmProgressEventType = {}));
695
-
696
- //
697
- //
698
- // Types
699
- //
700
- exports.LlmStreamChunkType = void 0;
701
- (function (LlmStreamChunkType) {
702
- LlmStreamChunkType["Done"] = "done";
703
- LlmStreamChunkType["Error"] = "error";
704
- LlmStreamChunkType["Text"] = "text";
705
- LlmStreamChunkType["ToolCall"] = "tool_call";
706
- LlmStreamChunkType["ToolResult"] = "tool_result";
707
- })(exports.LlmStreamChunkType || (exports.LlmStreamChunkType = {}));
708
-
709
605
  /**
710
606
  * Type guard to check if an item is a dedicated reasoning item (OpenAI)
711
607
  */
@@ -924,6 +820,45 @@ function fillFormatArrays({ content, format, }) {
924
820
  return fillFromSchema(schema, structuredClone(content));
925
821
  }
926
822
 
823
+ // Enums
824
+ exports.LlmMessageRole = void 0;
825
+ (function (LlmMessageRole) {
826
+ LlmMessageRole["Assistant"] = "assistant";
827
+ LlmMessageRole["Developer"] = "developer";
828
+ LlmMessageRole["System"] = "system";
829
+ LlmMessageRole["User"] = "user";
830
+ })(exports.LlmMessageRole || (exports.LlmMessageRole = {}));
831
+ exports.LlmMessageType = void 0;
832
+ (function (LlmMessageType) {
833
+ LlmMessageType["FunctionCall"] = "function_call";
834
+ LlmMessageType["FunctionCallOutput"] = "function_call_output";
835
+ LlmMessageType["InputFile"] = "input_file";
836
+ LlmMessageType["InputImage"] = "input_image";
837
+ LlmMessageType["InputText"] = "input_text";
838
+ LlmMessageType["ItemReference"] = "item_reference";
839
+ LlmMessageType["Message"] = "message";
840
+ LlmMessageType["OutputText"] = "output_text";
841
+ LlmMessageType["Refusal"] = "refusal";
842
+ })(exports.LlmMessageType || (exports.LlmMessageType = {}));
843
+ var LlmResponseStatus;
844
+ (function (LlmResponseStatus) {
845
+ LlmResponseStatus["Completed"] = "completed";
846
+ LlmResponseStatus["Incomplete"] = "incomplete";
847
+ LlmResponseStatus["InProgress"] = "in_progress";
848
+ })(LlmResponseStatus || (LlmResponseStatus = {}));
849
+ // Progress
850
+ exports.LlmProgressEventType = void 0;
851
+ (function (LlmProgressEventType) {
852
+ LlmProgressEventType["Done"] = "done";
853
+ LlmProgressEventType["ModelRequest"] = "model_request";
854
+ LlmProgressEventType["ModelResponse"] = "model_response";
855
+ LlmProgressEventType["Retry"] = "retry";
856
+ LlmProgressEventType["Start"] = "start";
857
+ LlmProgressEventType["ToolCall"] = "tool_call";
858
+ LlmProgressEventType["ToolError"] = "tool_error";
859
+ LlmProgressEventType["ToolResult"] = "tool_result";
860
+ })(exports.LlmProgressEventType || (exports.LlmProgressEventType = {}));
861
+
927
862
  /**
928
863
  * Converts a string to a standardized LlmInputMessage
929
864
  * @param input - String to format
@@ -1518,64 +1453,367 @@ function tallyOperate({ toolCallNames = [], turns, usage = [], }) {
1518
1453
  usageByModel[key].output += item.output;
1519
1454
  usageByModel[key].reasoning += item.reasoning;
1520
1455
  usageByModel[key].total += item.total;
1456
+ if (item.cacheRead !== undefined) {
1457
+ usageByModel[key].cacheRead =
1458
+ (usageByModel[key].cacheRead ?? 0) + item.cacheRead;
1459
+ }
1460
+ if (item.cacheWrite !== undefined) {
1461
+ usageByModel[key].cacheWrite =
1462
+ (usageByModel[key].cacheWrite ?? 0) + item.cacheWrite;
1463
+ }
1521
1464
  }
1522
1465
  llm.usage = usageByModel;
1523
1466
  }
1524
1467
  log$1.log.tally({ llm });
1525
1468
  }
1526
-
1527
- /**
1528
- * Helper function to safely call a function that might throw
1529
- * @param fn Function to call safely
1530
- */
1531
- function callSafely(fn) {
1532
- try {
1533
- fn();
1534
- }
1535
- catch {
1536
- // Silently catch any errors from the function
1537
- }
1469
+
1470
+ /**
1471
+ * Helper function to safely call a function that might throw
1472
+ * @param fn Function to call safely
1473
+ */
1474
+ function callSafely(fn) {
1475
+ try {
1476
+ fn();
1477
+ }
1478
+ catch {
1479
+ // Silently catch any errors from the function
1480
+ }
1481
+ }
1482
+ /**
1483
+ * Attempts to parse a value as a number. Returns the original input if parsing fails or results in NaN.
1484
+ * @param input - The value to attempt to parse as a number
1485
+ * @param options - Optional configuration
1486
+ * @param options.defaultValue - Default value to return if parsing fails or results in NaN
1487
+ * @param options.warnFunction - Function to call with warning message if parsing fails or results in NaN
1488
+ * @returns The parsed number, defaultValue (if specified and parsing fails), or the original input
1489
+ */
1490
+ function tryParseNumber(input, options) {
1491
+ if (input === null || input === undefined) {
1492
+ return input;
1493
+ }
1494
+ try {
1495
+ const parsed = Number(input);
1496
+ if (Number.isNaN(parsed)) {
1497
+ if (options?.warnFunction) {
1498
+ const warningMessage = `Failed to parse "${String(input)}" as number`;
1499
+ callSafely(() => options.warnFunction(warningMessage));
1500
+ }
1501
+ return typeof options?.defaultValue === "number"
1502
+ ? options.defaultValue
1503
+ : input;
1504
+ }
1505
+ return parsed;
1506
+ }
1507
+ catch (error) {
1508
+ if (options?.warnFunction) {
1509
+ let errorMessage = "";
1510
+ if (error instanceof Error) {
1511
+ errorMessage = error.message;
1512
+ }
1513
+ const warningMessage = `Error parsing "${String(input)}" as number${errorMessage ? "; " + errorMessage : ""}`;
1514
+ callSafely(() => options.warnFunction(warningMessage));
1515
+ }
1516
+ return typeof options?.defaultValue === "number"
1517
+ ? options.defaultValue
1518
+ : input;
1519
+ }
1520
+ }
1521
+
1522
+ /**
1523
+ * Truthy-except-"false"/"0" gate on LLM_EXCHANGE_ENABLED, matching the
1524
+ * DD_LLMOBS_ENABLED convention in observability/llmobs.ts.
1525
+ */
1526
+ function isExchangeStoreEnabled() {
1527
+ const flag = process.env.LLM_EXCHANGE_ENABLED;
1528
+ if (!flag) {
1529
+ return false;
1530
+ }
1531
+ return flag.toLowerCase() !== "false" && flag !== "0";
1532
+ }
1533
+ /**
1534
+ * Whether the loop should assemble an exchange envelope at settlement.
1535
+ */
1536
+ function isExchangeRequested({ onExchange, }) {
1537
+ return Boolean(onExchange) || isExchangeStoreEnabled();
1538
+ }
1539
+ /**
1540
+ * Deliver an exchange envelope to a callback. Errors thrown by the callback
1541
+ * are logged and swallowed — exchange capture must never interrupt the call.
1542
+ */
1543
+ async function emitExchange({ envelope, onExchange, }) {
1544
+ if (!onExchange) {
1545
+ return;
1546
+ }
1547
+ try {
1548
+ await onExchange(envelope);
1549
+ }
1550
+ catch (error) {
1551
+ const log = getLogger$7();
1552
+ log.warn("[operate] onExchange callback threw");
1553
+ log.var({ error });
1554
+ }
1555
+ }
1556
+
1557
+ //
1558
+ //
1559
+ // Constants
1560
+ //
1561
+ const MODULE$1 = {
1562
+ // Computed at runtime so bundlers (esbuild) do not attempt to include
1563
+ // @jaypie/dynamodb, which is an optional peer dependency.
1564
+ JAYPIE_DYNAMODB: ["@jaypie", "dynamodb"].join("/"),
1565
+ };
1566
+ //
1567
+ //
1568
+ // Helpers
1569
+ //
1570
+ // Native dynamic import that neither rollup nor tsc rewrites to require(), so
1571
+ // a CJS-bundled build still loads @jaypie/dynamodb's ESM entry and shares the
1572
+ // host's initialized module instance. A require()-based resolution would load
1573
+ // the CJS build, whose module-level client state is separate from the ESM
1574
+ // instance an ESM host initializes (dual-package hazard, issue #429).
1575
+ const dynamicImport = new Function("s", "return import(s)");
1576
+ let resolved$1 = false;
1577
+ let cachedSdk$2 = null;
1578
+ /**
1579
+ * Lazily resolve @jaypie/dynamodb's storeExchange. Returns null (and never
1580
+ * throws) when the peer is absent. Cached after the first attempt.
1581
+ */
1582
+ async function resolveExchangeStore() {
1583
+ if (resolved$1) {
1584
+ return cachedSdk$2;
1585
+ }
1586
+ resolved$1 = true;
1587
+ try {
1588
+ const dynamodb = await dynamicImport(MODULE$1.JAYPIE_DYNAMODB);
1589
+ const sdk = (dynamodb?.default ?? dynamodb);
1590
+ if (sdk && typeof sdk.storeExchange === "function") {
1591
+ cachedSdk$2 = sdk;
1592
+ }
1593
+ }
1594
+ catch {
1595
+ cachedSdk$2 = null;
1596
+ }
1597
+ return cachedSdk$2;
1598
+ }
1599
+ //
1600
+ //
1601
+ // Main
1602
+ //
1603
+ /**
1604
+ * Persist an exchange envelope via @jaypie/dynamodb when
1605
+ * LLM_EXCHANGE_ENABLED is set. Silent no-op when the flag is unset or the
1606
+ * peer is absent; persister failures are logged and never thrown.
1607
+ */
1608
+ async function persistExchange(envelope) {
1609
+ if (!isExchangeStoreEnabled()) {
1610
+ return;
1611
+ }
1612
+ const sdk = (await resolveExchangeStore());
1613
+ if (!sdk) {
1614
+ return;
1615
+ }
1616
+ try {
1617
+ await sdk.storeExchange(envelope);
1618
+ }
1619
+ catch (error) {
1620
+ const log = getLogger$7();
1621
+ log.warn("[operate] Exchange persistence failed");
1622
+ log.var({ error });
1623
+ }
1624
+ }
1625
+
1626
+ //
1627
+ //
1628
+ // Abstract Base Adapter
1629
+ //
1630
+ /**
1631
+ * BaseProviderAdapter provides default implementations for common adapter methods.
1632
+ * Providers can extend this class to reduce boilerplate.
1633
+ */
1634
+ class BaseProviderAdapter {
1635
+ constructor() {
1636
+ /**
1637
+ * Whether OperateLoop may take a corrective turn when a `format` request
1638
+ * completes with prose instead of structured output. Providers whose
1639
+ * structured output rides a tool emulation (where compliance is a model
1640
+ * decision) opt in; native grammar-constrained providers do not need it.
1641
+ */
1642
+ this.supportsStructuredOutputRetry = false;
1643
+ }
1644
+ /**
1645
+ * Default implementation checks if error is retryable via classifyError
1646
+ */
1647
+ isRetryableError(error) {
1648
+ const classified = this.classifyError(error);
1649
+ return classified.shouldRetry;
1650
+ }
1651
+ /**
1652
+ * Default implementation checks error category via classifyError
1653
+ */
1654
+ isRateLimitError(error) {
1655
+ const classified = this.classifyError(error);
1656
+ return classified.category === "rate_limit";
1657
+ }
1658
+ /**
1659
+ * Default implementation returns false - override for providers with native structured output
1660
+ */
1661
+ hasStructuredOutput(_response) {
1662
+ return false;
1663
+ }
1664
+ /**
1665
+ * Default implementation returns undefined - override for providers with native structured output
1666
+ */
1667
+ extractStructuredOutput(_response) {
1668
+ return undefined;
1669
+ }
1670
+ }
1671
+
1672
+ /**
1673
+ * Per-provider translation of the provider-neutral {@link LlmEffort} scale.
1674
+ *
1675
+ * The neutral enum (lowest → low → medium → high → highest) is a relative
1676
+ * five-point scale. Each table below maps it onto the target provider's native
1677
+ * effort control, keeping `medium`/`high` semantically aligned across providers
1678
+ * and using each provider's extra bottom (`minimal`) or top (`xhigh`/`max`)
1679
+ * rung where one exists. Providers with fewer levels collapse the ends and mark
1680
+ * the result `papered`. A single `effort` value is therefore safe to reuse
1681
+ * across providers and fallback chains.
1682
+ */
1683
+ /** Consistent debug message for a papered-over effort level. */
1684
+ function paperedEffortMessage({ model, provider, requested, value, }) {
1685
+ return `[llm] effort '${requested}' has no distinct tier on ${provider} model '${model}'; using '${value}'`;
1686
+ }
1687
+ // OpenAI Responses API `reasoning.effort`.
1688
+ // Full ladder: minimal | low | medium | high | xhigh (plus `none`). Availability
1689
+ // is not uniform across the gpt-5 line:
1690
+ // - `xhigh` was introduced at gpt-5.2 and has been continuous since, so it is
1691
+ // safe for our gpt-5.4 default and everything newer.
1692
+ // - `minimal` shipped on gpt-5/5.1, was dropped at gpt-5.2, and returned on
1693
+ // the current line; because that history is non-monotonic we only trust it
1694
+ // from gpt-5.4 (our default floor) onward.
1695
+ // Outside those windows (older gpt-5, o-series) the extreme rung is clamped and
1696
+ // the mapping reports `papered: true`.
1697
+ const OPENAI_EFFORT = {
1698
+ [EFFORT.LOWEST]: "minimal",
1699
+ [EFFORT.LOW]: "low",
1700
+ [EFFORT.MEDIUM]: "medium",
1701
+ [EFFORT.HIGH]: "high",
1702
+ [EFFORT.HIGHEST]: "xhigh",
1703
+ };
1704
+ function openAiGptVersion(model) {
1705
+ const match = model.match(/^gpt-(\d+)(?:\.(\d+))?/);
1706
+ if (!match)
1707
+ return null;
1708
+ return { major: Number(match[1]), minor: match[2] ? Number(match[2]) : 0 };
1709
+ }
1710
+ function atLeast(version, major, minor) {
1711
+ if (!version)
1712
+ return false;
1713
+ return (version.major > major || (version.major === major && version.minor >= minor));
1538
1714
  }
1539
- /**
1540
- * Attempts to parse a value as a number. Returns the original input if parsing fails or results in NaN.
1541
- * @param input - The value to attempt to parse as a number
1542
- * @param options - Optional configuration
1543
- * @param options.defaultValue - Default value to return if parsing fails or results in NaN
1544
- * @param options.warnFunction - Function to call with warning message if parsing fails or results in NaN
1545
- * @returns The parsed number, defaultValue (if specified and parsing fails), or the original input
1546
- */
1547
- function tryParseNumber(input, options) {
1548
- if (input === null || input === undefined) {
1549
- return input;
1550
- }
1551
- try {
1552
- const parsed = Number(input);
1553
- if (Number.isNaN(parsed)) {
1554
- if (options?.warnFunction) {
1555
- const warningMessage = `Failed to parse "${String(input)}" as number`;
1556
- callSafely(() => options.warnFunction(warningMessage));
1557
- }
1558
- return typeof options?.defaultValue === "number"
1559
- ? options.defaultValue
1560
- : input;
1561
- }
1562
- return parsed;
1715
+ function toOpenAiEffort(effort, { model }) {
1716
+ const native = OPENAI_EFFORT[effort];
1717
+ const version = openAiGptVersion(model);
1718
+ // `minimal` only from gpt-5.4 (non-monotonic history; absent on o-series)
1719
+ if (native === "minimal" && !atLeast(version, 5, 4)) {
1720
+ return { papered: true, value: "low" };
1563
1721
  }
1564
- catch (error) {
1565
- if (options?.warnFunction) {
1566
- let errorMessage = "";
1567
- if (error instanceof Error) {
1568
- errorMessage = error.message;
1569
- }
1570
- const warningMessage = `Error parsing "${String(input)}" as number${errorMessage ? "; " + errorMessage : ""}`;
1571
- callSafely(() => options.warnFunction(warningMessage));
1572
- }
1573
- return typeof options?.defaultValue === "number"
1574
- ? options.defaultValue
1575
- : input;
1722
+ // `xhigh` from gpt-5.2 onward (absent on older gpt-5 and o-series)
1723
+ if (native === "xhigh" && !atLeast(version, 5, 2)) {
1724
+ return { papered: true, value: "high" };
1576
1725
  }
1726
+ return { papered: false, value: native };
1727
+ }
1728
+ // xAI Grok `reasoning_effort` — low | medium | high. No sub-low or top rung, so
1729
+ // `lowest` collapses onto `low` and `highest` onto `high`.
1730
+ const XAI_EFFORT = {
1731
+ [EFFORT.LOWEST]: { papered: true, value: "low" },
1732
+ [EFFORT.LOW]: { papered: false, value: "low" },
1733
+ [EFFORT.MEDIUM]: { papered: false, value: "medium" },
1734
+ [EFFORT.HIGH]: { papered: false, value: "high" },
1735
+ [EFFORT.HIGHEST]: { papered: true, value: "high" },
1736
+ };
1737
+ function toXaiEffort(effort) {
1738
+ return XAI_EFFORT[effort];
1739
+ }
1740
+ // Anthropic `output_config.effort` — low | medium | high | xhigh | max. No
1741
+ // sub-low rung, so `lowest` collapses onto `low`; `highest` reaches `max`.
1742
+ const ANTHROPIC_EFFORT = {
1743
+ [EFFORT.LOWEST]: { papered: true, value: "low" },
1744
+ [EFFORT.LOW]: { papered: false, value: "low" },
1745
+ [EFFORT.MEDIUM]: { papered: false, value: "medium" },
1746
+ [EFFORT.HIGH]: { papered: false, value: "high" },
1747
+ [EFFORT.HIGHEST]: { papered: false, value: "max" },
1748
+ };
1749
+ function toAnthropicEffort(effort) {
1750
+ return ANTHROPIC_EFFORT[effort];
1751
+ }
1752
+ // Gemini 3.x `thinkingConfig.thinkingLevel` — MINIMAL | LOW | MEDIUM | HIGH. No
1753
+ // top rung above HIGH, so `highest` collapses onto HIGH.
1754
+ const GEMINI_THINKING_LEVEL = {
1755
+ [EFFORT.LOWEST]: { papered: false, value: "MINIMAL" },
1756
+ [EFFORT.LOW]: { papered: false, value: "LOW" },
1757
+ [EFFORT.MEDIUM]: { papered: false, value: "MEDIUM" },
1758
+ [EFFORT.HIGH]: { papered: false, value: "HIGH" },
1759
+ [EFFORT.HIGHEST]: { papered: true, value: "HIGH" },
1760
+ };
1761
+ function toGeminiThinkingLevel(effort) {
1762
+ return GEMINI_THINKING_LEVEL[effort];
1763
+ }
1764
+ // Gemini 2.5 `thinkingConfig.thinkingBudget` — every tier is a distinct token
1765
+ // budget, so nothing is papered over. Floor (512) clears every 2.5 minimum;
1766
+ // ceiling (24,576) is valid across 2.5 Pro (max 32,768) and Flash (max 24,576).
1767
+ const GEMINI_THINKING_BUDGET = {
1768
+ [EFFORT.LOWEST]: 512,
1769
+ [EFFORT.LOW]: 4096,
1770
+ [EFFORT.MEDIUM]: 8192,
1771
+ [EFFORT.HIGH]: 16384,
1772
+ [EFFORT.HIGHEST]: 24576,
1773
+ };
1774
+ function toGeminiThinkingBudget(effort) {
1775
+ return { papered: false, value: GEMINI_THINKING_BUDGET[effort] };
1776
+ }
1777
+ // Fireworks `reasoning_effort` — low | medium | high. Accepted on every model
1778
+ // (the API no-ops where unsupported). No sub-low or top rung, so `lowest`
1779
+ // collapses onto `low` and `highest` onto `high`.
1780
+ const FIREWORKS_EFFORT = {
1781
+ [EFFORT.LOWEST]: { papered: true, value: "low" },
1782
+ [EFFORT.LOW]: { papered: false, value: "low" },
1783
+ [EFFORT.MEDIUM]: { papered: false, value: "medium" },
1784
+ [EFFORT.HIGH]: { papered: false, value: "high" },
1785
+ [EFFORT.HIGHEST]: { papered: true, value: "high" },
1786
+ };
1787
+ function toFireworksEffort(effort) {
1788
+ return FIREWORKS_EFFORT[effort];
1789
+ }
1790
+ // OpenRouter `reasoning.effort` — accepts the full minimal..xhigh ladder and
1791
+ // maps to the routed provider's nearest supported level itself, so nothing is
1792
+ // papered here.
1793
+ const OPENROUTER_EFFORT = {
1794
+ [EFFORT.LOWEST]: "minimal",
1795
+ [EFFORT.LOW]: "low",
1796
+ [EFFORT.MEDIUM]: "medium",
1797
+ [EFFORT.HIGH]: "high",
1798
+ [EFFORT.HIGHEST]: "xhigh",
1799
+ };
1800
+ function toOpenRouterEffort(effort) {
1801
+ return { papered: false, value: OPENROUTER_EFFORT[effort] };
1577
1802
  }
1578
1803
 
1804
+ //
1805
+ //
1806
+ // Types
1807
+ //
1808
+ exports.LlmStreamChunkType = void 0;
1809
+ (function (LlmStreamChunkType) {
1810
+ LlmStreamChunkType["Done"] = "done";
1811
+ LlmStreamChunkType["Error"] = "error";
1812
+ LlmStreamChunkType["Text"] = "text";
1813
+ LlmStreamChunkType["ToolCall"] = "tool_call";
1814
+ LlmStreamChunkType["ToolResult"] = "tool_result";
1815
+ })(exports.LlmStreamChunkType || (exports.LlmStreamChunkType = {}));
1816
+
1579
1817
  //
1580
1818
  //
1581
1819
  // Error Classification Types
@@ -2159,8 +2397,19 @@ class AnthropicAdapter extends BaseProviderAdapter {
2159
2397
  PROVIDER.ANTHROPIC.MAX_TOKENS.DEFAULT,
2160
2398
  stream: false,
2161
2399
  };
2400
+ const cache = resolveCache(request.cache);
2401
+ const cacheControl = cache.enabled
2402
+ ? {
2403
+ type: "ephemeral",
2404
+ ...(cache.ttl === "1h" ? { ttl: "1h" } : {}),
2405
+ }
2406
+ : undefined;
2162
2407
  if (request.system) {
2163
- anthropicRequest.system = request.system;
2408
+ // A cache breakpoint on the system block caches tools+system together
2409
+ // (render order is tools -> system -> messages).
2410
+ anthropicRequest.system = cacheControl
2411
+ ? [{ type: "text", text: request.system, cache_control: cacheControl }]
2412
+ : request.system;
2164
2413
  }
2165
2414
  const useFallbackStructuredOutput = Boolean(request.format) &&
2166
2415
  !this.supportsStructuredOutput(anthropicRequest.model);
@@ -2177,7 +2426,7 @@ class AnthropicAdapter extends BaseProviderAdapter {
2177
2426
  });
2178
2427
  }
2179
2428
  if (allTools.length > 0) {
2180
- anthropicRequest.tools = allTools.map((tool) => ({
2429
+ anthropicRequest.tools = allTools.map((tool, index) => ({
2181
2430
  name: tool.name,
2182
2431
  description: tool.description,
2183
2432
  input_schema: {
@@ -2185,6 +2434,11 @@ class AnthropicAdapter extends BaseProviderAdapter {
2185
2434
  type: "object",
2186
2435
  },
2187
2436
  type: "custom",
2437
+ // Breakpoint on the last tool caches the tool list (which renders
2438
+ // before system) when there is no system prompt to anchor it.
2439
+ ...(cacheControl && index === allTools.length - 1
2440
+ ? { cache_control: cacheControl }
2441
+ : {}),
2188
2442
  }));
2189
2443
  anthropicRequest.tool_choice = useFallbackStructuredOutput
2190
2444
  ? { type: "any" }
@@ -2487,6 +2741,12 @@ class AnthropicAdapter extends BaseProviderAdapter {
2487
2741
  output: usage.output_tokens,
2488
2742
  reasoning: usage.thinking_tokens || 0,
2489
2743
  total: usage.input_tokens + usage.output_tokens,
2744
+ ...(usage.cache_read_input_tokens !== undefined
2745
+ ? { cacheRead: usage.cache_read_input_tokens }
2746
+ : {}),
2747
+ ...(usage.cache_creation_input_tokens !== undefined
2748
+ ? { cacheWrite: usage.cache_creation_input_tokens }
2749
+ : {}),
2490
2750
  provider: this.name,
2491
2751
  model,
2492
2752
  };
@@ -2739,6 +2999,14 @@ function isTemperatureDeprecationError$3(error) {
2739
2999
  const msg = error?.message ?? "";
2740
3000
  return /temperature.*deprecated|deprecated.*temperature/i.test(msg);
2741
3001
  }
3002
+ function isCachePointUnsupportedError(error) {
3003
+ const name = error?.constructor?.name ?? "";
3004
+ const msg = error?.message ?? "";
3005
+ // A model that cannot cache rejects the cachePoint block with a
3006
+ // ValidationException naming caching / cachePoint.
3007
+ return (/ValidationException|invalid|not support/i.test(name + " " + msg) &&
3008
+ /cachePoint|cache_point|prompt caching|caching/i.test(msg));
3009
+ }
2742
3010
  function extractJson(text) {
2743
3011
  // Try direct parse first
2744
3012
  try {
@@ -2774,6 +3042,13 @@ class BedrockAdapter extends BaseProviderAdapter {
2774
3042
  this.defaultModel = PROVIDER.BEDROCK.DEFAULT;
2775
3043
  this._modelsFallbackToStructuredOutputTool = new Set();
2776
3044
  this._modelsWithoutTemperature = new Set();
3045
+ this._modelsWithoutCachePoint = new Set();
3046
+ }
3047
+ rememberModelRejectsCachePoint(model) {
3048
+ this._modelsWithoutCachePoint.add(model);
3049
+ }
3050
+ supportsCachePoint(model) {
3051
+ return !this._modelsWithoutCachePoint.has(model);
2777
3052
  }
2778
3053
  rememberModelRejectsOutputConfig(model) {
2779
3054
  this._modelsFallbackToStructuredOutputTool.add(model);
@@ -2910,11 +3185,51 @@ class BedrockAdapter extends BaseProviderAdapter {
2910
3185
  };
2911
3186
  }
2912
3187
  }
3188
+ // Prompt caching via Converse cachePoint blocks (5-min default TTL; the
3189
+ // `ttl` option does not apply). Gated per model — cachePoint 400s on
3190
+ // unsupported models, which executeRequest catches and denylists.
3191
+ if (resolveCache(request.cache).enabled && this.supportsCachePoint(model)) {
3192
+ const cachePoint = { cachePoint: { type: "default" } };
3193
+ if (bedrockRequest.system && bedrockRequest.system.length > 0) {
3194
+ bedrockRequest.system = [
3195
+ ...bedrockRequest.system,
3196
+ cachePoint,
3197
+ ];
3198
+ }
3199
+ if (bedrockRequest.toolConfig?.tools?.length) {
3200
+ bedrockRequest.toolConfig = {
3201
+ ...bedrockRequest.toolConfig,
3202
+ tools: [
3203
+ ...bedrockRequest.toolConfig.tools,
3204
+ cachePoint,
3205
+ ],
3206
+ };
3207
+ }
3208
+ }
2913
3209
  if (request.providerOptions) {
2914
3210
  Object.assign(bedrockRequest, request.providerOptions);
2915
3211
  }
2916
3212
  return bedrockRequest;
2917
3213
  }
3214
+ /** Remove any cachePoint blocks so the request can be retried unsupported. */
3215
+ stripCachePoints(request) {
3216
+ const stripped = { ...request };
3217
+ if (stripped.system) {
3218
+ stripped.system = stripped.system.filter((block) => !("cachePoint" in block));
3219
+ }
3220
+ if (stripped.toolConfig?.tools) {
3221
+ stripped.toolConfig = {
3222
+ ...stripped.toolConfig,
3223
+ tools: stripped.toolConfig.tools.filter((tool) => !("cachePoint" in tool)),
3224
+ };
3225
+ }
3226
+ return stripped;
3227
+ }
3228
+ requestHasCachePoints(request) {
3229
+ const inSystem = (request.system ?? []).some((block) => "cachePoint" in block);
3230
+ const inTools = (request.toolConfig?.tools ?? []).some((tool) => "cachePoint" in tool);
3231
+ return inSystem || inTools;
3232
+ }
2918
3233
  formatTools(toolkit) {
2919
3234
  return toolkit.tools.map((tool) => ({
2920
3235
  name: tool.name,
@@ -2969,6 +3284,15 @@ class BedrockAdapter extends BaseProviderAdapter {
2969
3284
  const fallbackRequest = this.toFallbackStructuredOutputRequest(bedrockRequest);
2970
3285
  return (await bedrockClient.send(new ConverseCommand(fallbackRequest), signal ? { abortSignal: signal } : undefined));
2971
3286
  }
3287
+ if (this.requestHasCachePoints(bedrockRequest) &&
3288
+ isCachePointUnsupportedError(error)) {
3289
+ this.rememberModelRejectsCachePoint(bedrockRequest.modelId || this.defaultModel);
3290
+ const retryRequest = this.stripCachePoints(bedrockRequest);
3291
+ const response = (await bedrockClient.send(new ConverseCommand(retryRequest), signal ? { abortSignal: signal } : undefined));
3292
+ if (wantsStructuredOutput)
3293
+ response.__jaypieStructuredOutput = true;
3294
+ return response;
3295
+ }
2972
3296
  throw error;
2973
3297
  }
2974
3298
  }
@@ -3112,6 +3436,12 @@ class BedrockAdapter extends BaseProviderAdapter {
3112
3436
  reasoning: 0,
3113
3437
  total: usage?.totalTokens ??
3114
3438
  (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0),
3439
+ ...(usage?.cacheReadInputTokens !== undefined
3440
+ ? { cacheRead: usage.cacheReadInputTokens }
3441
+ : {}),
3442
+ ...(usage?.cacheWriteInputTokens !== undefined
3443
+ ? { cacheWrite: usage.cacheWriteInputTokens }
3444
+ : {}),
3115
3445
  provider: this.name,
3116
3446
  model,
3117
3447
  };
@@ -5283,6 +5613,13 @@ class OpenAiAdapter extends BaseProviderAdapter {
5283
5613
  supportsReasoningEffort(model) {
5284
5614
  return isReasoningModel(model);
5285
5615
  }
5616
+ /**
5617
+ * Whether to emit `prompt_cache_key` (OpenAI Responses API). Overridable by
5618
+ * OpenAI-compatible subclasses whose backend rejects the field.
5619
+ */
5620
+ supportsPromptCacheKey() {
5621
+ return true;
5622
+ }
5286
5623
  /** Translate a normalized effort to this provider's `reasoning.effort` value. */
5287
5624
  mapReasoningEffort(effort, model) {
5288
5625
  return toOpenAiEffort(effort, { model });
@@ -5322,6 +5659,18 @@ class OpenAiAdapter extends BaseProviderAdapter {
5322
5659
  summary: "auto",
5323
5660
  };
5324
5661
  }
5662
+ // OpenAI prompt caching is automatic (prefix-based). Setting a stable
5663
+ // prompt_cache_key routes repeat traffic to the same cache across
5664
+ // instances. Keyed on the stable prefix only (system + instructions +
5665
+ // tools + model), never the volatile user turn.
5666
+ if (this.supportsPromptCacheKey() && resolveCache(request.cache).enabled) {
5667
+ openaiRequest.prompt_cache_key = promptCacheKey(JSON.stringify([
5668
+ model,
5669
+ request.system ?? "",
5670
+ request.instructions ?? "",
5671
+ request.tools ?? [],
5672
+ ]));
5673
+ }
5325
5674
  if (request.providerOptions) {
5326
5675
  Object.assign(openaiRequest, request.providerOptions);
5327
5676
  }
@@ -5580,11 +5929,13 @@ class OpenAiAdapter extends BaseProviderAdapter {
5580
5929
  model,
5581
5930
  };
5582
5931
  }
5932
+ const cachedTokens = openaiResponse.usage.input_tokens_details?.cached_tokens;
5583
5933
  return {
5584
5934
  input: openaiResponse.usage.input_tokens || 0,
5585
5935
  output: openaiResponse.usage.output_tokens || 0,
5586
5936
  reasoning: openaiResponse.usage.output_tokens_details?.reasoning_tokens || 0,
5587
5937
  total: openaiResponse.usage.total_tokens || 0,
5938
+ ...(cachedTokens !== undefined ? { cacheRead: cachedTokens } : {}),
5588
5939
  provider: this.name,
5589
5940
  model,
5590
5941
  };
@@ -5976,7 +6327,13 @@ class OpenRouterAdapter extends BaseProviderAdapter {
5976
6327
  //
5977
6328
  buildRequest(request) {
5978
6329
  // Convert messages to OpenRouter format (OpenAI-compatible)
5979
- const messages = this.convertMessagesToOpenRouter(request.messages, request.system);
6330
+ const cache = resolveCache(request.cache);
6331
+ const messages = this.convertMessagesToOpenRouter(request.messages, request.system, cache.enabled
6332
+ ? {
6333
+ type: "ephemeral",
6334
+ ...(cache.ttl === "1h" ? { ttl: "1h" } : {}),
6335
+ }
6336
+ : undefined);
5980
6337
  // Append instructions to last message if provided
5981
6338
  if (request.instructions && messages.length > 0) {
5982
6339
  const lastMsg = messages[messages.length - 1];
@@ -6340,11 +6697,15 @@ class OpenRouterAdapter extends BaseProviderAdapter {
6340
6697
  }
6341
6698
  // SDK returns camelCase, but support snake_case as fallback
6342
6699
  const usage = openRouterResponse.usage;
6700
+ const cachedTokens = usage.promptTokensDetails?.cachedTokens ??
6701
+ usage.promptTokensDetails?.cached_tokens ??
6702
+ usage.prompt_tokens_details?.cached_tokens;
6343
6703
  return {
6344
6704
  input: usage.promptTokens || usage.prompt_tokens || 0,
6345
6705
  output: usage.completionTokens || usage.completion_tokens || 0,
6346
6706
  reasoning: usage.completionTokensDetails?.reasoningTokens || 0,
6347
6707
  total: usage.totalTokens || usage.total_tokens || 0,
6708
+ ...(cachedTokens !== undefined ? { cacheRead: cachedTokens } : {}),
6348
6709
  provider: this.name,
6349
6710
  model,
6350
6711
  };
@@ -6545,13 +6906,17 @@ class OpenRouterAdapter extends BaseProviderAdapter {
6545
6906
  const choice = response.choices[0];
6546
6907
  return choice?.message?.content ?? undefined;
6547
6908
  }
6548
- convertMessagesToOpenRouter(messages, system) {
6909
+ convertMessagesToOpenRouter(messages, system, cacheControl) {
6549
6910
  const openRouterMessages = [];
6550
- // Add system message if provided
6911
+ // Add system message if provided. A cache_control breakpoint on the system
6912
+ // content is forwarded by OpenRouter to Anthropic/Gemini backends (and
6913
+ // ignored by others), caching the stable system prefix.
6551
6914
  if (system) {
6552
6915
  openRouterMessages.push({
6553
6916
  role: "system",
6554
- content: system,
6917
+ content: cacheControl
6918
+ ? [{ type: "text", text: system, cache_control: cacheControl }]
6919
+ : system,
6555
6920
  });
6556
6921
  }
6557
6922
  for (const msg of messages) {
@@ -8077,10 +8442,14 @@ class OperateLoop {
8077
8442
  log.trace("[operate] Starting operate loop");
8078
8443
  log.trace.var({ "operate.input": input });
8079
8444
  log.trace.var({ "operate.options": options });
8445
+ const startedAt = new Date().toISOString();
8446
+ const startMs = Date.now();
8080
8447
  // Initialize state
8081
8448
  const state = await this.initializeState(input, options);
8082
8449
  const context = this.createContext(options);
8083
8450
  const modelName = options.model ?? this.adapter.defaultModel;
8451
+ const exchangeRequested = isExchangeRequested(options);
8452
+ const initialHistoryLength = state.responseBuilder.getHistory().length;
8084
8453
  await emitProgress({
8085
8454
  event: {
8086
8455
  maxTurns: state.maxTurns,
@@ -8092,59 +8461,102 @@ class OperateLoop {
8092
8461
  });
8093
8462
  // Enclosing LLM Observability span (no-op when DD_LLMOBS_ENABLED is unset).
8094
8463
  // Child llm/tool spans nest under it via the SDK's active-span context.
8095
- return withLlmObsSpan({
8096
- kind: state.toolkit ? "agent" : "llm",
8097
- modelName,
8098
- modelProvider: this.adapter.name,
8099
- name: "jaypie.llm.operate",
8100
- }, async () => {
8101
- // Build initial request
8102
- let request = this.buildInitialRequest(state, options);
8103
- // Multi-turn loop
8104
- while (state.currentTurn < state.maxTurns) {
8105
- state.currentTurn++;
8106
- // Execute one turn with retry logic
8107
- const shouldContinue = await this.executeOneTurn(request, state, context, options);
8108
- if (!shouldContinue) {
8109
- break;
8464
+ try {
8465
+ return await withLlmObsSpan({
8466
+ kind: state.toolkit ? "agent" : "llm",
8467
+ modelName,
8468
+ modelProvider: this.adapter.name,
8469
+ name: "jaypie.llm.operate",
8470
+ }, async () => {
8471
+ // Build initial request
8472
+ let request = this.buildInitialRequest(state, options);
8473
+ // Multi-turn loop
8474
+ while (state.currentTurn < state.maxTurns) {
8475
+ state.currentTurn++;
8476
+ // Execute one turn with retry logic
8477
+ const shouldContinue = await this.executeOneTurn(request, state, context, options);
8478
+ if (!shouldContinue) {
8479
+ break;
8480
+ }
8481
+ // Rebuild request with updated history for next turn
8482
+ request = {
8483
+ effort: options.effort,
8484
+ format: state.formattedFormat,
8485
+ instructions: options.instructions,
8486
+ messages: state.currentInput,
8487
+ model: modelName,
8488
+ providerOptions: options.providerOptions,
8489
+ structuredOutputRetry: state.structuredOutputRetry,
8490
+ system: options.system,
8491
+ temperature: options.temperature,
8492
+ tools: state.formattedTools,
8493
+ user: options.user,
8494
+ };
8110
8495
  }
8111
- // Rebuild request with updated history for next turn
8112
- request = {
8113
- effort: options.effort,
8114
- format: state.formattedFormat,
8115
- instructions: options.instructions,
8116
- messages: state.currentInput,
8117
- model: modelName,
8118
- providerOptions: options.providerOptions,
8119
- structuredOutputRetry: state.structuredOutputRetry,
8120
- system: options.system,
8121
- temperature: options.temperature,
8122
- tools: state.formattedTools,
8123
- user: options.user,
8124
- };
8125
- }
8126
- const response = state.responseBuilder.build();
8127
- annotateLlmObs({
8128
- inputData: input,
8129
- metrics: usageToLlmObsMetrics(response.usage),
8130
- outputData: response.content,
8131
- });
8132
- tallyOperate({
8133
- toolCallNames: state.toolCallNames,
8134
- turns: state.currentTurn,
8135
- usage: response.usage,
8136
- });
8137
- await emitProgress({
8138
- event: {
8139
- content: response.content,
8140
- turn: state.currentTurn,
8141
- type: exports.LlmProgressEventType.Done,
8496
+ const response = state.responseBuilder.build();
8497
+ if (exchangeRequested) {
8498
+ response.exchange = buildExchangeEnvelope({
8499
+ duration: Date.now() - startMs,
8500
+ initialHistoryLength,
8501
+ input,
8502
+ options,
8503
+ response,
8504
+ startedAt,
8505
+ state,
8506
+ });
8507
+ }
8508
+ annotateLlmObs({
8509
+ inputData: input,
8510
+ metrics: usageToLlmObsMetrics(response.usage),
8511
+ outputData: response.content,
8512
+ });
8513
+ tallyOperate({
8514
+ toolCallNames: state.toolCallNames,
8515
+ turns: state.currentTurn,
8142
8516
  usage: response.usage,
8143
- },
8144
- onProgress: options.onProgress,
8517
+ });
8518
+ await emitProgress({
8519
+ event: {
8520
+ content: response.content,
8521
+ turn: state.currentTurn,
8522
+ type: exports.LlmProgressEventType.Done,
8523
+ usage: response.usage,
8524
+ },
8525
+ onProgress: options.onProgress,
8526
+ });
8527
+ return response;
8145
8528
  });
8146
- return response;
8147
- });
8529
+ }
8530
+ catch (error) {
8531
+ // A hard failure (retry budget exhausted, unrecoverable) still settles
8532
+ // the exchange: attach the envelope to the thrown error so the facade
8533
+ // can emit it before rethrowing to the caller.
8534
+ if (exchangeRequested) {
8535
+ const response = state.responseBuilder.build();
8536
+ if (!response.error) {
8537
+ const thrown = error;
8538
+ response.error = {
8539
+ detail: thrown.detail ?? thrown.message,
8540
+ status: thrown.status ?? 500,
8541
+ title: thrown.title ?? thrown.name ?? "Error",
8542
+ };
8543
+ }
8544
+ if (response.status === LlmResponseStatus.InProgress) {
8545
+ response.status = LlmResponseStatus.Incomplete;
8546
+ }
8547
+ error.exchange =
8548
+ buildExchangeEnvelope({
8549
+ duration: Date.now() - startMs,
8550
+ initialHistoryLength,
8551
+ input,
8552
+ options,
8553
+ response,
8554
+ startedAt,
8555
+ state,
8556
+ });
8557
+ }
8558
+ throw error;
8559
+ }
8148
8560
  }
8149
8561
  //
8150
8562
  // Private Methods
@@ -8203,6 +8615,7 @@ class OperateLoop {
8203
8615
  formattedTools,
8204
8616
  maxTurns,
8205
8617
  responseBuilder,
8618
+ retries: 0,
8206
8619
  toolCallNames: [],
8207
8620
  toolkit,
8208
8621
  };
@@ -8215,6 +8628,7 @@ class OperateLoop {
8215
8628
  }
8216
8629
  buildInitialRequest(state, options) {
8217
8630
  return {
8631
+ cache: options.cache,
8218
8632
  effort: options.effort,
8219
8633
  format: state.formattedFormat,
8220
8634
  instructions: options.instructions,
@@ -8264,26 +8678,26 @@ class OperateLoop {
8264
8678
  options,
8265
8679
  providerRequest,
8266
8680
  });
8267
- // Emit retry progress alongside the caller's onRetryableModelError hook
8681
+ // Count retries for the exchange envelope and emit retry progress
8682
+ // alongside the caller's onRetryableModelError hook
8268
8683
  const hooks = context.hooks;
8269
- const hooksWithProgress = options.onProgress
8270
- ? {
8271
- ...hooks,
8272
- onRetryableModelError: async (retryContext) => {
8273
- await emitProgress({
8274
- event: {
8275
- error: retryContext.error instanceof Error
8276
- ? retryContext.error.message
8277
- : String(retryContext.error),
8278
- turn: state.currentTurn,
8279
- type: exports.LlmProgressEventType.Retry,
8280
- },
8281
- onProgress: options.onProgress,
8282
- });
8283
- return hooks?.onRetryableModelError?.(retryContext);
8284
- },
8285
- }
8286
- : hooks;
8684
+ const hooksWithProgress = {
8685
+ ...hooks,
8686
+ onRetryableModelError: async (retryContext) => {
8687
+ state.retries++;
8688
+ await emitProgress({
8689
+ event: {
8690
+ error: retryContext.error instanceof Error
8691
+ ? retryContext.error.message
8692
+ : String(retryContext.error),
8693
+ turn: state.currentTurn,
8694
+ type: exports.LlmProgressEventType.Retry,
8695
+ },
8696
+ onProgress: options.onProgress,
8697
+ });
8698
+ return hooks?.onRetryableModelError?.(retryContext);
8699
+ },
8700
+ };
8287
8701
  // Execute with retry inside a child llm span (no-op when llmobs disabled).
8288
8702
  // RetryExecutor handles error hooks and throws appropriate errors.
8289
8703
  const { parsed, response, toolCalls } = await withLlmObsSpan({
@@ -8342,6 +8756,10 @@ class OperateLoop {
8342
8756
  if (parsed.usage) {
8343
8757
  state.responseBuilder.addUsage(parsed.usage);
8344
8758
  }
8759
+ // Track stop reason for the exchange envelope
8760
+ if (parsed.stopReason) {
8761
+ state.lastStopReason = parsed.stopReason;
8762
+ }
8345
8763
  // Add raw response
8346
8764
  state.responseBuilder.addResponse(parsed.raw);
8347
8765
  // Execute afterEachModelResponse hook
@@ -8844,6 +9262,7 @@ class StreamLoop {
8844
9262
  }
8845
9263
  buildInitialRequest(state, options) {
8846
9264
  return {
9265
+ cache: options.cache,
8847
9266
  effort: options.effort,
8848
9267
  format: state.formattedFormat,
8849
9268
  instructions: options.instructions,
@@ -10844,12 +11263,17 @@ class Llm {
10844
11263
  attempts++;
10845
11264
  try {
10846
11265
  const response = await this._llm.operate(input, optionsWithoutFallback);
10847
- return {
11266
+ const settled = {
10848
11267
  ...response,
10849
11268
  fallbackAttempts: attempts,
10850
11269
  fallbackUsed: false,
10851
11270
  provider: response.provider || this._provider,
10852
11271
  };
11272
+ await this.settleExchange({
11273
+ onExchange: resolvedOptions.onExchange,
11274
+ response: settled,
11275
+ });
11276
+ return settled;
10853
11277
  }
10854
11278
  catch (error) {
10855
11279
  lastError = error;
@@ -10858,18 +11282,28 @@ class Llm {
10858
11282
  fallbacksRemaining: fallbackChain.length,
10859
11283
  });
10860
11284
  }
10861
- // Try fallback providers
11285
+ // Try fallback providers. The fallback instance's underlying provider is
11286
+ // called directly so the nested facade does not settle the exchange —
11287
+ // exactly one settlement per operate() happens here.
10862
11288
  for (const fallbackConfig of fallbackChain) {
10863
11289
  attempts++;
10864
11290
  try {
10865
11291
  const fallbackInstance = this.createFallbackInstance(fallbackConfig);
10866
- const response = await fallbackInstance.operate(input, optionsWithoutFallback);
10867
- return {
11292
+ if (!fallbackInstance._llm.operate) {
11293
+ throw new errors.NotImplementedError(`Provider ${fallbackConfig.provider} does not support operate method`);
11294
+ }
11295
+ const response = await fallbackInstance._llm.operate(input, optionsWithoutFallback);
11296
+ const settled = {
10868
11297
  ...response,
10869
11298
  fallbackAttempts: attempts,
10870
11299
  fallbackUsed: true,
10871
11300
  provider: response.provider || fallbackConfig.provider,
10872
11301
  };
11302
+ await this.settleExchange({
11303
+ onExchange: resolvedOptions.onExchange,
11304
+ response: settled,
11305
+ });
11306
+ return settled;
10873
11307
  }
10874
11308
  catch (error) {
10875
11309
  lastError = error;
@@ -10879,9 +11313,44 @@ class Llm {
10879
11313
  });
10880
11314
  }
10881
11315
  }
10882
- // All providers failed, throw the last error
11316
+ // All providers failed: settle the exchange from the envelope the loop
11317
+ // attached to the last error, then throw
11318
+ const failureEnvelope = lastError
11319
+ ?.exchange;
11320
+ if (failureEnvelope) {
11321
+ failureEnvelope.resolution = {
11322
+ ...failureEnvelope.resolution,
11323
+ fallbackAttempts: attempts,
11324
+ fallbackUsed: attempts > 1,
11325
+ };
11326
+ await emitExchange({
11327
+ envelope: failureEnvelope,
11328
+ onExchange: resolvedOptions.onExchange,
11329
+ });
11330
+ await persistExchange(failureEnvelope);
11331
+ }
10883
11332
  throw lastError;
10884
11333
  }
11334
+ /**
11335
+ * Stamp fallback resolution onto the envelope the operate loop attached to
11336
+ * the response and deliver it to the caller's onExchange. Fires once per
11337
+ * operate() settlement; callback errors are logged and never thrown.
11338
+ */
11339
+ async settleExchange({ onExchange, response, }) {
11340
+ const envelope = response.exchange;
11341
+ if (!envelope) {
11342
+ return;
11343
+ }
11344
+ envelope.resolution = {
11345
+ ...envelope.resolution,
11346
+ fallbackAttempts: response.fallbackAttempts,
11347
+ fallbackUsed: response.fallbackUsed,
11348
+ model: response.model,
11349
+ provider: response.provider,
11350
+ };
11351
+ await emitExchange({ envelope, onExchange });
11352
+ await persistExchange(envelope);
11353
+ }
10885
11354
  async *stream(input, options = {}) {
10886
11355
  if (!this._llm.stream) {
10887
11356
  throw new errors.NotImplementedError(`Provider ${this._provider} does not support stream method`);