@jaypie/llm 1.3.13 → 1.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -478,233 +478,90 @@ function resolveModelChain(model) {
478
478
 
479
479
  //
480
480
  //
481
- // Abstract Base Adapter
481
+ // Helpers
482
482
  //
483
- /**
484
- * BaseProviderAdapter provides default implementations for common adapter methods.
485
- * Providers can extend this class to reduce boilerplate.
486
- */
487
- class BaseProviderAdapter {
488
- 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
  }
523
- }
524
-
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.
535
- */
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));
567
- }
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" };
574
- }
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" };
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;
578
504
  }
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];
505
+ return totals;
604
506
  }
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];
507
+ function extractResponseIds(responses) {
508
+ const ids = responses
509
+ .map((response) => response && typeof response === "object"
510
+ ? response.id
511
+ : undefined)
512
+ .filter((id) => typeof id === "string");
513
+ return ids.length ? ids : undefined;
616
514
  }
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];
642
- }
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] };
655
- }
656
-
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
515
  //
697
516
  //
698
- // Types
517
+ // Main
699
518
  //
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 = {}));
519
+ /**
520
+ * Assemble the serializable exchange envelope for one operate() settlement.
521
+ * The `resolution` block is stamped by the Llm facade, which is the layer
522
+ * that knows fallback outcome; the loop fills provider/model best-effort.
523
+ */
524
+ function buildExchangeEnvelope({ duration, initialHistoryLength, input, options, response, startedAt, state, }) {
525
+ const historyDelta = response.history.slice(initialHistoryLength);
526
+ return {
527
+ ids: extractResponseIds(response.responses),
528
+ request: {
529
+ data: options.data,
530
+ effort: options.effort,
531
+ explain: options.explain,
532
+ format: state.formattedFormat,
533
+ input,
534
+ instructions: options.instructions,
535
+ model: options.model,
536
+ placeholders: options.placeholders,
537
+ providerOptions: options.providerOptions,
538
+ system: options.system,
539
+ temperature: options.temperature,
540
+ tools: state.formattedTools?.map(({ name }) => name),
541
+ turns: options.turns,
542
+ user: options.user,
543
+ },
544
+ resolution: {
545
+ model: response.model,
546
+ provider: response.provider,
547
+ retries: state.retries,
548
+ },
549
+ response: {
550
+ content: response.content,
551
+ error: response.error,
552
+ historyDelta,
553
+ reasoning: response.reasoning,
554
+ status: response.status,
555
+ stopReason: state.lastStopReason,
556
+ usage: response.usage,
557
+ usageTotals: sumUsageByProviderModel(response.usage),
558
+ },
559
+ timing: {
560
+ duration,
561
+ startedAt,
562
+ },
563
+ };
564
+ }
708
565
 
709
566
  /**
710
567
  * Type guard to check if an item is a dedicated reasoning item (OpenAI)
@@ -924,6 +781,45 @@ function fillFormatArrays({ content, format, }) {
924
781
  return fillFromSchema(schema, structuredClone(content));
925
782
  }
926
783
 
784
+ // Enums
785
+ exports.LlmMessageRole = void 0;
786
+ (function (LlmMessageRole) {
787
+ LlmMessageRole["Assistant"] = "assistant";
788
+ LlmMessageRole["Developer"] = "developer";
789
+ LlmMessageRole["System"] = "system";
790
+ LlmMessageRole["User"] = "user";
791
+ })(exports.LlmMessageRole || (exports.LlmMessageRole = {}));
792
+ exports.LlmMessageType = void 0;
793
+ (function (LlmMessageType) {
794
+ LlmMessageType["FunctionCall"] = "function_call";
795
+ LlmMessageType["FunctionCallOutput"] = "function_call_output";
796
+ LlmMessageType["InputFile"] = "input_file";
797
+ LlmMessageType["InputImage"] = "input_image";
798
+ LlmMessageType["InputText"] = "input_text";
799
+ LlmMessageType["ItemReference"] = "item_reference";
800
+ LlmMessageType["Message"] = "message";
801
+ LlmMessageType["OutputText"] = "output_text";
802
+ LlmMessageType["Refusal"] = "refusal";
803
+ })(exports.LlmMessageType || (exports.LlmMessageType = {}));
804
+ var LlmResponseStatus;
805
+ (function (LlmResponseStatus) {
806
+ LlmResponseStatus["Completed"] = "completed";
807
+ LlmResponseStatus["Incomplete"] = "incomplete";
808
+ LlmResponseStatus["InProgress"] = "in_progress";
809
+ })(LlmResponseStatus || (LlmResponseStatus = {}));
810
+ // Progress
811
+ exports.LlmProgressEventType = void 0;
812
+ (function (LlmProgressEventType) {
813
+ LlmProgressEventType["Done"] = "done";
814
+ LlmProgressEventType["ModelRequest"] = "model_request";
815
+ LlmProgressEventType["ModelResponse"] = "model_response";
816
+ LlmProgressEventType["Retry"] = "retry";
817
+ LlmProgressEventType["Start"] = "start";
818
+ LlmProgressEventType["ToolCall"] = "tool_call";
819
+ LlmProgressEventType["ToolError"] = "tool_error";
820
+ LlmProgressEventType["ToolResult"] = "tool_result";
821
+ })(exports.LlmProgressEventType || (exports.LlmProgressEventType = {}));
822
+
927
823
  /**
928
824
  * Converts a string to a standardized LlmInputMessage
929
825
  * @param input - String to format
@@ -1521,61 +1417,357 @@ function tallyOperate({ toolCallNames = [], turns, usage = [], }) {
1521
1417
  }
1522
1418
  llm.usage = usageByModel;
1523
1419
  }
1524
- log$1.log.tally({ llm });
1420
+ log$1.log.tally({ llm });
1421
+ }
1422
+
1423
+ /**
1424
+ * Helper function to safely call a function that might throw
1425
+ * @param fn Function to call safely
1426
+ */
1427
+ function callSafely(fn) {
1428
+ try {
1429
+ fn();
1430
+ }
1431
+ catch {
1432
+ // Silently catch any errors from the function
1433
+ }
1434
+ }
1435
+ /**
1436
+ * Attempts to parse a value as a number. Returns the original input if parsing fails or results in NaN.
1437
+ * @param input - The value to attempt to parse as a number
1438
+ * @param options - Optional configuration
1439
+ * @param options.defaultValue - Default value to return if parsing fails or results in NaN
1440
+ * @param options.warnFunction - Function to call with warning message if parsing fails or results in NaN
1441
+ * @returns The parsed number, defaultValue (if specified and parsing fails), or the original input
1442
+ */
1443
+ function tryParseNumber(input, options) {
1444
+ if (input === null || input === undefined) {
1445
+ return input;
1446
+ }
1447
+ try {
1448
+ const parsed = Number(input);
1449
+ if (Number.isNaN(parsed)) {
1450
+ if (options?.warnFunction) {
1451
+ const warningMessage = `Failed to parse "${String(input)}" as number`;
1452
+ callSafely(() => options.warnFunction(warningMessage));
1453
+ }
1454
+ return typeof options?.defaultValue === "number"
1455
+ ? options.defaultValue
1456
+ : input;
1457
+ }
1458
+ return parsed;
1459
+ }
1460
+ catch (error) {
1461
+ if (options?.warnFunction) {
1462
+ let errorMessage = "";
1463
+ if (error instanceof Error) {
1464
+ errorMessage = error.message;
1465
+ }
1466
+ const warningMessage = `Error parsing "${String(input)}" as number${errorMessage ? "; " + errorMessage : ""}`;
1467
+ callSafely(() => options.warnFunction(warningMessage));
1468
+ }
1469
+ return typeof options?.defaultValue === "number"
1470
+ ? options.defaultValue
1471
+ : input;
1472
+ }
1473
+ }
1474
+
1475
+ /**
1476
+ * Truthy-except-"false"/"0" gate on LLM_EXCHANGE_ENABLED, matching the
1477
+ * DD_LLMOBS_ENABLED convention in observability/llmobs.ts.
1478
+ */
1479
+ function isExchangeStoreEnabled() {
1480
+ const flag = process.env.LLM_EXCHANGE_ENABLED;
1481
+ if (!flag) {
1482
+ return false;
1483
+ }
1484
+ return flag.toLowerCase() !== "false" && flag !== "0";
1485
+ }
1486
+ /**
1487
+ * Whether the loop should assemble an exchange envelope at settlement.
1488
+ */
1489
+ function isExchangeRequested({ onExchange, }) {
1490
+ return Boolean(onExchange) || isExchangeStoreEnabled();
1491
+ }
1492
+ /**
1493
+ * Deliver an exchange envelope to a callback. Errors thrown by the callback
1494
+ * are logged and swallowed — exchange capture must never interrupt the call.
1495
+ */
1496
+ async function emitExchange({ envelope, onExchange, }) {
1497
+ if (!onExchange) {
1498
+ return;
1499
+ }
1500
+ try {
1501
+ await onExchange(envelope);
1502
+ }
1503
+ catch (error) {
1504
+ const log = getLogger$7();
1505
+ log.warn("[operate] onExchange callback threw");
1506
+ log.var({ error });
1507
+ }
1508
+ }
1509
+
1510
+ //
1511
+ //
1512
+ // Constants
1513
+ //
1514
+ const MODULE$1 = {
1515
+ // Computed at runtime so bundlers (esbuild) do not attempt to include
1516
+ // @jaypie/dynamodb, which is an optional peer dependency.
1517
+ JAYPIE_DYNAMODB: ["@jaypie", "dynamodb"].join("/"),
1518
+ };
1519
+ //
1520
+ //
1521
+ // Helpers
1522
+ //
1523
+ // CJS/ESM compatible require - handles bundling to CJS where import.meta.url
1524
+ // becomes undefined (mirrors observability/llmobs.ts).
1525
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1526
+ // @ts-ignore - __filename exists in CJS context when ESM is bundled to CJS
1527
+ const requireModule$1 = typeof __filename !== "undefined"
1528
+ ? module$1.createRequire(url.pathToFileURL(__filename).href)
1529
+ : module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
1530
+ let resolved$1 = false;
1531
+ let cachedSdk$2 = null;
1532
+ /**
1533
+ * Lazily resolve @jaypie/dynamodb's storeExchange. Returns null (and never
1534
+ * throws) when the peer is absent. Cached after the first attempt.
1535
+ */
1536
+ function resolveExchangeStore() {
1537
+ if (resolved$1) {
1538
+ return cachedSdk$2;
1539
+ }
1540
+ resolved$1 = true;
1541
+ try {
1542
+ const dynamodb = requireModule$1(MODULE$1.JAYPIE_DYNAMODB);
1543
+ const sdk = dynamodb?.default ?? dynamodb;
1544
+ if (sdk && typeof sdk.storeExchange === "function") {
1545
+ cachedSdk$2 = sdk;
1546
+ }
1547
+ }
1548
+ catch {
1549
+ cachedSdk$2 = null;
1550
+ }
1551
+ return cachedSdk$2;
1552
+ }
1553
+ //
1554
+ //
1555
+ // Main
1556
+ //
1557
+ /**
1558
+ * Persist an exchange envelope via @jaypie/dynamodb when
1559
+ * LLM_EXCHANGE_ENABLED is set. Silent no-op when the flag is unset or the
1560
+ * peer is absent; persister failures are logged and never thrown.
1561
+ */
1562
+ async function persistExchange(envelope) {
1563
+ if (!isExchangeStoreEnabled()) {
1564
+ return;
1565
+ }
1566
+ const sdk = resolveExchangeStore();
1567
+ if (!sdk) {
1568
+ return;
1569
+ }
1570
+ try {
1571
+ await sdk.storeExchange(envelope);
1572
+ }
1573
+ catch (error) {
1574
+ const log = getLogger$7();
1575
+ log.warn("[operate] Exchange persistence failed");
1576
+ log.var({ error });
1577
+ }
1578
+ }
1579
+
1580
+ //
1581
+ //
1582
+ // Abstract Base Adapter
1583
+ //
1584
+ /**
1585
+ * BaseProviderAdapter provides default implementations for common adapter methods.
1586
+ * Providers can extend this class to reduce boilerplate.
1587
+ */
1588
+ class BaseProviderAdapter {
1589
+ constructor() {
1590
+ /**
1591
+ * Whether OperateLoop may take a corrective turn when a `format` request
1592
+ * completes with prose instead of structured output. Providers whose
1593
+ * structured output rides a tool emulation (where compliance is a model
1594
+ * decision) opt in; native grammar-constrained providers do not need it.
1595
+ */
1596
+ this.supportsStructuredOutputRetry = false;
1597
+ }
1598
+ /**
1599
+ * Default implementation checks if error is retryable via classifyError
1600
+ */
1601
+ isRetryableError(error) {
1602
+ const classified = this.classifyError(error);
1603
+ return classified.shouldRetry;
1604
+ }
1605
+ /**
1606
+ * Default implementation checks error category via classifyError
1607
+ */
1608
+ isRateLimitError(error) {
1609
+ const classified = this.classifyError(error);
1610
+ return classified.category === "rate_limit";
1611
+ }
1612
+ /**
1613
+ * Default implementation returns false - override for providers with native structured output
1614
+ */
1615
+ hasStructuredOutput(_response) {
1616
+ return false;
1617
+ }
1618
+ /**
1619
+ * Default implementation returns undefined - override for providers with native structured output
1620
+ */
1621
+ extractStructuredOutput(_response) {
1622
+ return undefined;
1623
+ }
1525
1624
  }
1526
1625
 
1527
1626
  /**
1528
- * Helper function to safely call a function that might throw
1529
- * @param fn Function to call safely
1627
+ * Per-provider translation of the provider-neutral {@link LlmEffort} scale.
1628
+ *
1629
+ * The neutral enum (lowest → low → medium → high → highest) is a relative
1630
+ * five-point scale. Each table below maps it onto the target provider's native
1631
+ * effort control, keeping `medium`/`high` semantically aligned across providers
1632
+ * and using each provider's extra bottom (`minimal`) or top (`xhigh`/`max`)
1633
+ * rung where one exists. Providers with fewer levels collapse the ends and mark
1634
+ * the result `papered`. A single `effort` value is therefore safe to reuse
1635
+ * across providers and fallback chains.
1530
1636
  */
1531
- function callSafely(fn) {
1532
- try {
1533
- fn();
1534
- }
1535
- catch {
1536
- // Silently catch any errors from the function
1537
- }
1637
+ /** Consistent debug message for a papered-over effort level. */
1638
+ function paperedEffortMessage({ model, provider, requested, value, }) {
1639
+ return `[llm] effort '${requested}' has no distinct tier on ${provider} model '${model}'; using '${value}'`;
1538
1640
  }
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;
1641
+ // OpenAI Responses API `reasoning.effort`.
1642
+ // Full ladder: minimal | low | medium | high | xhigh (plus `none`). Availability
1643
+ // is not uniform across the gpt-5 line:
1644
+ // - `xhigh` was introduced at gpt-5.2 and has been continuous since, so it is
1645
+ // safe for our gpt-5.4 default and everything newer.
1646
+ // - `minimal` shipped on gpt-5/5.1, was dropped at gpt-5.2, and returned on
1647
+ // the current line; because that history is non-monotonic we only trust it
1648
+ // from gpt-5.4 (our default floor) onward.
1649
+ // Outside those windows (older gpt-5, o-series) the extreme rung is clamped and
1650
+ // the mapping reports `papered: true`.
1651
+ const OPENAI_EFFORT = {
1652
+ [EFFORT.LOWEST]: "minimal",
1653
+ [EFFORT.LOW]: "low",
1654
+ [EFFORT.MEDIUM]: "medium",
1655
+ [EFFORT.HIGH]: "high",
1656
+ [EFFORT.HIGHEST]: "xhigh",
1657
+ };
1658
+ function openAiGptVersion(model) {
1659
+ const match = model.match(/^gpt-(\d+)(?:\.(\d+))?/);
1660
+ if (!match)
1661
+ return null;
1662
+ return { major: Number(match[1]), minor: match[2] ? Number(match[2]) : 0 };
1663
+ }
1664
+ function atLeast(version, major, minor) {
1665
+ if (!version)
1666
+ return false;
1667
+ return (version.major > major || (version.major === major && version.minor >= minor));
1668
+ }
1669
+ function toOpenAiEffort(effort, { model }) {
1670
+ const native = OPENAI_EFFORT[effort];
1671
+ const version = openAiGptVersion(model);
1672
+ // `minimal` only from gpt-5.4 (non-monotonic history; absent on o-series)
1673
+ if (native === "minimal" && !atLeast(version, 5, 4)) {
1674
+ return { papered: true, value: "low" };
1563
1675
  }
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;
1676
+ // `xhigh` from gpt-5.2 onward (absent on older gpt-5 and o-series)
1677
+ if (native === "xhigh" && !atLeast(version, 5, 2)) {
1678
+ return { papered: true, value: "high" };
1576
1679
  }
1680
+ return { papered: false, value: native };
1681
+ }
1682
+ // xAI Grok `reasoning_effort` — low | medium | high. No sub-low or top rung, so
1683
+ // `lowest` collapses onto `low` and `highest` onto `high`.
1684
+ const XAI_EFFORT = {
1685
+ [EFFORT.LOWEST]: { papered: true, value: "low" },
1686
+ [EFFORT.LOW]: { papered: false, value: "low" },
1687
+ [EFFORT.MEDIUM]: { papered: false, value: "medium" },
1688
+ [EFFORT.HIGH]: { papered: false, value: "high" },
1689
+ [EFFORT.HIGHEST]: { papered: true, value: "high" },
1690
+ };
1691
+ function toXaiEffort(effort) {
1692
+ return XAI_EFFORT[effort];
1693
+ }
1694
+ // Anthropic `output_config.effort` — low | medium | high | xhigh | max. No
1695
+ // sub-low rung, so `lowest` collapses onto `low`; `highest` reaches `max`.
1696
+ const ANTHROPIC_EFFORT = {
1697
+ [EFFORT.LOWEST]: { papered: true, value: "low" },
1698
+ [EFFORT.LOW]: { papered: false, value: "low" },
1699
+ [EFFORT.MEDIUM]: { papered: false, value: "medium" },
1700
+ [EFFORT.HIGH]: { papered: false, value: "high" },
1701
+ [EFFORT.HIGHEST]: { papered: false, value: "max" },
1702
+ };
1703
+ function toAnthropicEffort(effort) {
1704
+ return ANTHROPIC_EFFORT[effort];
1705
+ }
1706
+ // Gemini 3.x `thinkingConfig.thinkingLevel` — MINIMAL | LOW | MEDIUM | HIGH. No
1707
+ // top rung above HIGH, so `highest` collapses onto HIGH.
1708
+ const GEMINI_THINKING_LEVEL = {
1709
+ [EFFORT.LOWEST]: { papered: false, value: "MINIMAL" },
1710
+ [EFFORT.LOW]: { papered: false, value: "LOW" },
1711
+ [EFFORT.MEDIUM]: { papered: false, value: "MEDIUM" },
1712
+ [EFFORT.HIGH]: { papered: false, value: "HIGH" },
1713
+ [EFFORT.HIGHEST]: { papered: true, value: "HIGH" },
1714
+ };
1715
+ function toGeminiThinkingLevel(effort) {
1716
+ return GEMINI_THINKING_LEVEL[effort];
1717
+ }
1718
+ // Gemini 2.5 `thinkingConfig.thinkingBudget` — every tier is a distinct token
1719
+ // budget, so nothing is papered over. Floor (512) clears every 2.5 minimum;
1720
+ // ceiling (24,576) is valid across 2.5 Pro (max 32,768) and Flash (max 24,576).
1721
+ const GEMINI_THINKING_BUDGET = {
1722
+ [EFFORT.LOWEST]: 512,
1723
+ [EFFORT.LOW]: 4096,
1724
+ [EFFORT.MEDIUM]: 8192,
1725
+ [EFFORT.HIGH]: 16384,
1726
+ [EFFORT.HIGHEST]: 24576,
1727
+ };
1728
+ function toGeminiThinkingBudget(effort) {
1729
+ return { papered: false, value: GEMINI_THINKING_BUDGET[effort] };
1730
+ }
1731
+ // Fireworks `reasoning_effort` — low | medium | high. Accepted on every model
1732
+ // (the API no-ops where unsupported). No sub-low or top rung, so `lowest`
1733
+ // collapses onto `low` and `highest` onto `high`.
1734
+ const FIREWORKS_EFFORT = {
1735
+ [EFFORT.LOWEST]: { papered: true, value: "low" },
1736
+ [EFFORT.LOW]: { papered: false, value: "low" },
1737
+ [EFFORT.MEDIUM]: { papered: false, value: "medium" },
1738
+ [EFFORT.HIGH]: { papered: false, value: "high" },
1739
+ [EFFORT.HIGHEST]: { papered: true, value: "high" },
1740
+ };
1741
+ function toFireworksEffort(effort) {
1742
+ return FIREWORKS_EFFORT[effort];
1743
+ }
1744
+ // OpenRouter `reasoning.effort` — accepts the full minimal..xhigh ladder and
1745
+ // maps to the routed provider's nearest supported level itself, so nothing is
1746
+ // papered here.
1747
+ const OPENROUTER_EFFORT = {
1748
+ [EFFORT.LOWEST]: "minimal",
1749
+ [EFFORT.LOW]: "low",
1750
+ [EFFORT.MEDIUM]: "medium",
1751
+ [EFFORT.HIGH]: "high",
1752
+ [EFFORT.HIGHEST]: "xhigh",
1753
+ };
1754
+ function toOpenRouterEffort(effort) {
1755
+ return { papered: false, value: OPENROUTER_EFFORT[effort] };
1577
1756
  }
1578
1757
 
1758
+ //
1759
+ //
1760
+ // Types
1761
+ //
1762
+ exports.LlmStreamChunkType = void 0;
1763
+ (function (LlmStreamChunkType) {
1764
+ LlmStreamChunkType["Done"] = "done";
1765
+ LlmStreamChunkType["Error"] = "error";
1766
+ LlmStreamChunkType["Text"] = "text";
1767
+ LlmStreamChunkType["ToolCall"] = "tool_call";
1768
+ LlmStreamChunkType["ToolResult"] = "tool_result";
1769
+ })(exports.LlmStreamChunkType || (exports.LlmStreamChunkType = {}));
1770
+
1579
1771
  //
1580
1772
  //
1581
1773
  // Error Classification Types
@@ -8077,10 +8269,14 @@ class OperateLoop {
8077
8269
  log.trace("[operate] Starting operate loop");
8078
8270
  log.trace.var({ "operate.input": input });
8079
8271
  log.trace.var({ "operate.options": options });
8272
+ const startedAt = new Date().toISOString();
8273
+ const startMs = Date.now();
8080
8274
  // Initialize state
8081
8275
  const state = await this.initializeState(input, options);
8082
8276
  const context = this.createContext(options);
8083
8277
  const modelName = options.model ?? this.adapter.defaultModel;
8278
+ const exchangeRequested = isExchangeRequested(options);
8279
+ const initialHistoryLength = state.responseBuilder.getHistory().length;
8084
8280
  await emitProgress({
8085
8281
  event: {
8086
8282
  maxTurns: state.maxTurns,
@@ -8092,59 +8288,102 @@ class OperateLoop {
8092
8288
  });
8093
8289
  // Enclosing LLM Observability span (no-op when DD_LLMOBS_ENABLED is unset).
8094
8290
  // 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;
8291
+ try {
8292
+ return await withLlmObsSpan({
8293
+ kind: state.toolkit ? "agent" : "llm",
8294
+ modelName,
8295
+ modelProvider: this.adapter.name,
8296
+ name: "jaypie.llm.operate",
8297
+ }, async () => {
8298
+ // Build initial request
8299
+ let request = this.buildInitialRequest(state, options);
8300
+ // Multi-turn loop
8301
+ while (state.currentTurn < state.maxTurns) {
8302
+ state.currentTurn++;
8303
+ // Execute one turn with retry logic
8304
+ const shouldContinue = await this.executeOneTurn(request, state, context, options);
8305
+ if (!shouldContinue) {
8306
+ break;
8307
+ }
8308
+ // Rebuild request with updated history for next turn
8309
+ request = {
8310
+ effort: options.effort,
8311
+ format: state.formattedFormat,
8312
+ instructions: options.instructions,
8313
+ messages: state.currentInput,
8314
+ model: modelName,
8315
+ providerOptions: options.providerOptions,
8316
+ structuredOutputRetry: state.structuredOutputRetry,
8317
+ system: options.system,
8318
+ temperature: options.temperature,
8319
+ tools: state.formattedTools,
8320
+ user: options.user,
8321
+ };
8110
8322
  }
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,
8323
+ const response = state.responseBuilder.build();
8324
+ if (exchangeRequested) {
8325
+ response.exchange = buildExchangeEnvelope({
8326
+ duration: Date.now() - startMs,
8327
+ initialHistoryLength,
8328
+ input,
8329
+ options,
8330
+ response,
8331
+ startedAt,
8332
+ state,
8333
+ });
8334
+ }
8335
+ annotateLlmObs({
8336
+ inputData: input,
8337
+ metrics: usageToLlmObsMetrics(response.usage),
8338
+ outputData: response.content,
8339
+ });
8340
+ tallyOperate({
8341
+ toolCallNames: state.toolCallNames,
8342
+ turns: state.currentTurn,
8142
8343
  usage: response.usage,
8143
- },
8144
- onProgress: options.onProgress,
8344
+ });
8345
+ await emitProgress({
8346
+ event: {
8347
+ content: response.content,
8348
+ turn: state.currentTurn,
8349
+ type: exports.LlmProgressEventType.Done,
8350
+ usage: response.usage,
8351
+ },
8352
+ onProgress: options.onProgress,
8353
+ });
8354
+ return response;
8145
8355
  });
8146
- return response;
8147
- });
8356
+ }
8357
+ catch (error) {
8358
+ // A hard failure (retry budget exhausted, unrecoverable) still settles
8359
+ // the exchange: attach the envelope to the thrown error so the facade
8360
+ // can emit it before rethrowing to the caller.
8361
+ if (exchangeRequested) {
8362
+ const response = state.responseBuilder.build();
8363
+ if (!response.error) {
8364
+ const thrown = error;
8365
+ response.error = {
8366
+ detail: thrown.detail ?? thrown.message,
8367
+ status: thrown.status ?? 500,
8368
+ title: thrown.title ?? thrown.name ?? "Error",
8369
+ };
8370
+ }
8371
+ if (response.status === LlmResponseStatus.InProgress) {
8372
+ response.status = LlmResponseStatus.Incomplete;
8373
+ }
8374
+ error.exchange =
8375
+ buildExchangeEnvelope({
8376
+ duration: Date.now() - startMs,
8377
+ initialHistoryLength,
8378
+ input,
8379
+ options,
8380
+ response,
8381
+ startedAt,
8382
+ state,
8383
+ });
8384
+ }
8385
+ throw error;
8386
+ }
8148
8387
  }
8149
8388
  //
8150
8389
  // Private Methods
@@ -8203,6 +8442,7 @@ class OperateLoop {
8203
8442
  formattedTools,
8204
8443
  maxTurns,
8205
8444
  responseBuilder,
8445
+ retries: 0,
8206
8446
  toolCallNames: [],
8207
8447
  toolkit,
8208
8448
  };
@@ -8264,26 +8504,26 @@ class OperateLoop {
8264
8504
  options,
8265
8505
  providerRequest,
8266
8506
  });
8267
- // Emit retry progress alongside the caller's onRetryableModelError hook
8507
+ // Count retries for the exchange envelope and emit retry progress
8508
+ // alongside the caller's onRetryableModelError hook
8268
8509
  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;
8510
+ const hooksWithProgress = {
8511
+ ...hooks,
8512
+ onRetryableModelError: async (retryContext) => {
8513
+ state.retries++;
8514
+ await emitProgress({
8515
+ event: {
8516
+ error: retryContext.error instanceof Error
8517
+ ? retryContext.error.message
8518
+ : String(retryContext.error),
8519
+ turn: state.currentTurn,
8520
+ type: exports.LlmProgressEventType.Retry,
8521
+ },
8522
+ onProgress: options.onProgress,
8523
+ });
8524
+ return hooks?.onRetryableModelError?.(retryContext);
8525
+ },
8526
+ };
8287
8527
  // Execute with retry inside a child llm span (no-op when llmobs disabled).
8288
8528
  // RetryExecutor handles error hooks and throws appropriate errors.
8289
8529
  const { parsed, response, toolCalls } = await withLlmObsSpan({
@@ -8342,6 +8582,10 @@ class OperateLoop {
8342
8582
  if (parsed.usage) {
8343
8583
  state.responseBuilder.addUsage(parsed.usage);
8344
8584
  }
8585
+ // Track stop reason for the exchange envelope
8586
+ if (parsed.stopReason) {
8587
+ state.lastStopReason = parsed.stopReason;
8588
+ }
8345
8589
  // Add raw response
8346
8590
  state.responseBuilder.addResponse(parsed.raw);
8347
8591
  // Execute afterEachModelResponse hook
@@ -10844,12 +11088,17 @@ class Llm {
10844
11088
  attempts++;
10845
11089
  try {
10846
11090
  const response = await this._llm.operate(input, optionsWithoutFallback);
10847
- return {
11091
+ const settled = {
10848
11092
  ...response,
10849
11093
  fallbackAttempts: attempts,
10850
11094
  fallbackUsed: false,
10851
11095
  provider: response.provider || this._provider,
10852
11096
  };
11097
+ await this.settleExchange({
11098
+ onExchange: resolvedOptions.onExchange,
11099
+ response: settled,
11100
+ });
11101
+ return settled;
10853
11102
  }
10854
11103
  catch (error) {
10855
11104
  lastError = error;
@@ -10858,18 +11107,28 @@ class Llm {
10858
11107
  fallbacksRemaining: fallbackChain.length,
10859
11108
  });
10860
11109
  }
10861
- // Try fallback providers
11110
+ // Try fallback providers. The fallback instance's underlying provider is
11111
+ // called directly so the nested facade does not settle the exchange —
11112
+ // exactly one settlement per operate() happens here.
10862
11113
  for (const fallbackConfig of fallbackChain) {
10863
11114
  attempts++;
10864
11115
  try {
10865
11116
  const fallbackInstance = this.createFallbackInstance(fallbackConfig);
10866
- const response = await fallbackInstance.operate(input, optionsWithoutFallback);
10867
- return {
11117
+ if (!fallbackInstance._llm.operate) {
11118
+ throw new errors.NotImplementedError(`Provider ${fallbackConfig.provider} does not support operate method`);
11119
+ }
11120
+ const response = await fallbackInstance._llm.operate(input, optionsWithoutFallback);
11121
+ const settled = {
10868
11122
  ...response,
10869
11123
  fallbackAttempts: attempts,
10870
11124
  fallbackUsed: true,
10871
11125
  provider: response.provider || fallbackConfig.provider,
10872
11126
  };
11127
+ await this.settleExchange({
11128
+ onExchange: resolvedOptions.onExchange,
11129
+ response: settled,
11130
+ });
11131
+ return settled;
10873
11132
  }
10874
11133
  catch (error) {
10875
11134
  lastError = error;
@@ -10879,9 +11138,44 @@ class Llm {
10879
11138
  });
10880
11139
  }
10881
11140
  }
10882
- // All providers failed, throw the last error
11141
+ // All providers failed: settle the exchange from the envelope the loop
11142
+ // attached to the last error, then throw
11143
+ const failureEnvelope = lastError
11144
+ ?.exchange;
11145
+ if (failureEnvelope) {
11146
+ failureEnvelope.resolution = {
11147
+ ...failureEnvelope.resolution,
11148
+ fallbackAttempts: attempts,
11149
+ fallbackUsed: attempts > 1,
11150
+ };
11151
+ await emitExchange({
11152
+ envelope: failureEnvelope,
11153
+ onExchange: resolvedOptions.onExchange,
11154
+ });
11155
+ await persistExchange(failureEnvelope);
11156
+ }
10883
11157
  throw lastError;
10884
11158
  }
11159
+ /**
11160
+ * Stamp fallback resolution onto the envelope the operate loop attached to
11161
+ * the response and deliver it to the caller's onExchange. Fires once per
11162
+ * operate() settlement; callback errors are logged and never thrown.
11163
+ */
11164
+ async settleExchange({ onExchange, response, }) {
11165
+ const envelope = response.exchange;
11166
+ if (!envelope) {
11167
+ return;
11168
+ }
11169
+ envelope.resolution = {
11170
+ ...envelope.resolution,
11171
+ fallbackAttempts: response.fallbackAttempts,
11172
+ fallbackUsed: response.fallbackUsed,
11173
+ model: response.model,
11174
+ provider: response.provider,
11175
+ };
11176
+ await emitExchange({ envelope, onExchange });
11177
+ await persistExchange(envelope);
11178
+ }
10885
11179
  async *stream(input, options = {}) {
10886
11180
  if (!this._llm.stream) {
10887
11181
  throw new errors.NotImplementedError(`Provider ${this._provider} does not support stream method`);