@earendil-works/pi-ai 1.5.0 → 1.5.1

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 (122) hide show
  1. package/README.md +23 -4
  2. package/dist/api/anthropic-messages.d.ts.map +1 -1
  3. package/dist/api/anthropic-messages.js +224 -17
  4. package/dist/api/anthropic-messages.js.map +1 -1
  5. package/dist/api/azure-openai-responses.js +1 -1
  6. package/dist/api/azure-openai-responses.js.map +1 -1
  7. package/dist/api/bedrock-converse-stream.d.ts.map +1 -1
  8. package/dist/api/bedrock-converse-stream.js +4 -2
  9. package/dist/api/bedrock-converse-stream.js.map +1 -1
  10. package/dist/api/bedrock-converse-stream.lazy.d.ts +7 -0
  11. package/dist/api/bedrock-converse-stream.lazy.d.ts.map +1 -1
  12. package/dist/api/bedrock-converse-stream.lazy.js +18 -5
  13. package/dist/api/bedrock-converse-stream.lazy.js.map +1 -1
  14. package/dist/api/constrained-sampling.d.ts +0 -2
  15. package/dist/api/constrained-sampling.d.ts.map +1 -1
  16. package/dist/api/constrained-sampling.js +0 -13
  17. package/dist/api/constrained-sampling.js.map +1 -1
  18. package/dist/api/lazy.d.ts +1 -1
  19. package/dist/api/lazy.d.ts.map +1 -1
  20. package/dist/api/lazy.js +9 -8
  21. package/dist/api/lazy.js.map +1 -1
  22. package/dist/api/openai-codex-responses.d.ts.map +1 -1
  23. package/dist/api/openai-codex-responses.js +7 -3
  24. package/dist/api/openai-codex-responses.js.map +1 -1
  25. package/dist/api/openai-completions.d.ts +2 -1
  26. package/dist/api/openai-completions.d.ts.map +1 -1
  27. package/dist/api/openai-completions.js +13 -5
  28. package/dist/api/openai-completions.js.map +1 -1
  29. package/dist/api/openai-responses.d.ts.map +1 -1
  30. package/dist/api/openai-responses.js +4 -2
  31. package/dist/api/openai-responses.js.map +1 -1
  32. package/dist/api/simple-options.d.ts +1 -0
  33. package/dist/api/simple-options.d.ts.map +1 -1
  34. package/dist/api/simple-options.js +16 -2
  35. package/dist/api/simple-options.js.map +1 -1
  36. package/dist/api/strict-tool-limits.d.ts +13 -0
  37. package/dist/api/strict-tool-limits.d.ts.map +1 -0
  38. package/dist/api/strict-tool-limits.js +50 -0
  39. package/dist/api/strict-tool-limits.js.map +1 -0
  40. package/dist/auth/oauth/load.d.ts.map +1 -1
  41. package/dist/auth/oauth/load.js +5 -2
  42. package/dist/auth/oauth/load.js.map +1 -1
  43. package/dist/cache-warming.d.ts +53 -0
  44. package/dist/cache-warming.d.ts.map +1 -0
  45. package/dist/cache-warming.js +82 -0
  46. package/dist/cache-warming.js.map +1 -0
  47. package/dist/env-api-keys.d.ts.map +1 -1
  48. package/dist/env-api-keys.js +1 -0
  49. package/dist/env-api-keys.js.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/lazy-bedrock-provider.d.ts +10 -0
  55. package/dist/lazy-bedrock-provider.d.ts.map +1 -0
  56. package/dist/lazy-bedrock-provider.js +13 -0
  57. package/dist/lazy-bedrock-provider.js.map +1 -0
  58. package/dist/lazy-oauth.d.ts +10 -0
  59. package/dist/lazy-oauth.d.ts.map +1 -0
  60. package/dist/lazy-oauth.js +23 -0
  61. package/dist/lazy-oauth.js.map +1 -0
  62. package/dist/models.d.ts +7 -0
  63. package/dist/models.d.ts.map +1 -1
  64. package/dist/models.generated.d.ts +4 -0
  65. package/dist/models.generated.d.ts.map +1 -1
  66. package/dist/models.generated.js +4 -0
  67. package/dist/models.generated.js.map +1 -1
  68. package/dist/models.js +19 -11
  69. package/dist/models.js.map +1 -1
  70. package/dist/providers/all.d.ts.map +1 -1
  71. package/dist/providers/all.js +2 -0
  72. package/dist/providers/all.js.map +1 -1
  73. package/dist/providers/compat-schema.d.ts +16 -0
  74. package/dist/providers/compat-schema.d.ts.map +1 -1
  75. package/dist/providers/compat-schema.js +22 -1
  76. package/dist/providers/compat-schema.js.map +1 -1
  77. package/dist/providers/data/.manifest.json +1 -1
  78. package/dist/providers/data/amazon-bedrock.json +1 -1
  79. package/dist/providers/data/anthropic.json +1 -1
  80. package/dist/providers/data/azure.json +1 -1
  81. package/dist/providers/data/baseten.json +1 -1
  82. package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  83. package/dist/providers/data/fireworks.json +1 -1
  84. package/dist/providers/data/github-copilot.json +1 -1
  85. package/dist/providers/data/google.json +1 -1
  86. package/dist/providers/data/nvidia.json +1 -1
  87. package/dist/providers/data/opencode-go.json +1 -1
  88. package/dist/providers/data/opencode.json +1 -1
  89. package/dist/providers/data/openrouter.json +1 -1
  90. package/dist/providers/data/qwen-token-plan-individual-cn.json +1 -0
  91. package/dist/providers/data/qwen-token-plan-individual.json +1 -1
  92. package/dist/providers/data/radius.json +1 -1
  93. package/dist/providers/data/together.json +1 -1
  94. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  95. package/dist/providers/github-copilot.d.ts +3 -1
  96. package/dist/providers/github-copilot.d.ts.map +1 -1
  97. package/dist/providers/github-copilot.js +13 -2
  98. package/dist/providers/github-copilot.js.map +1 -1
  99. package/dist/providers/openai-codex.js +1 -1
  100. package/dist/providers/openai-codex.js.map +1 -1
  101. package/dist/providers/qwen-token-plan-individual-cn.d.ts +3 -0
  102. package/dist/providers/qwen-token-plan-individual-cn.d.ts.map +1 -0
  103. package/dist/providers/qwen-token-plan-individual-cn.js +15 -0
  104. package/dist/providers/qwen-token-plan-individual-cn.js.map +1 -0
  105. package/dist/providers/qwen-token-plan-individual-cn.models.d.ts +6 -0
  106. package/dist/providers/qwen-token-plan-individual-cn.models.d.ts.map +1 -0
  107. package/dist/providers/qwen-token-plan-individual-cn.models.js +8 -0
  108. package/dist/providers/qwen-token-plan-individual-cn.models.js.map +1 -0
  109. package/dist/types.d.ts +8 -1
  110. package/dist/types.d.ts.map +1 -1
  111. package/dist/types.js.map +1 -1
  112. package/dist/utils/error-body.d.ts +8 -0
  113. package/dist/utils/error-body.d.ts.map +1 -1
  114. package/dist/utils/error-body.js +28 -1
  115. package/dist/utils/error-body.js.map +1 -1
  116. package/dist/utils/retry.d.ts.map +1 -1
  117. package/dist/utils/retry.js +3 -0
  118. package/dist/utils/retry.js.map +1 -1
  119. package/dist/utils/version.d.ts +1 -1
  120. package/dist/utils/version.js +1 -1
  121. package/dist/utils/version.js.map +1 -1
  122. package/package.json +11 -3
package/README.md CHANGED
@@ -64,7 +64,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
64
64
  - **OpenAI** (including the Decisions classifier API)
65
65
  - **Ant Ling**
66
66
  - **Azure OpenAI (Responses)**
67
- - **OpenAI Codex (legacy)** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
67
+ - **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
68
68
  - **Radius** (API key or OAuth, with a dynamically refreshed gateway catalog)
69
69
  - **TypeSafe** (System One classifier API)
70
70
  - **DeepSeek**
@@ -524,6 +524,7 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex
524
524
  | Qwen Token Plan (existing catalog) | `QWEN_TOKEN_PLAN_API_KEY` |
525
525
  | Qwen Token Plan (Individual) | `QWEN_TOKEN_PLAN_API_KEY` |
526
526
  | Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` |
527
+ | Qwen Token Plan (Individual, China) | `QWEN_TOKEN_PLAN_CN_API_KEY` |
527
528
  | Xiaomi MiMo (API billing) | `XIAOMI_API_KEY` |
528
529
  | Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` |
529
530
  | Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` |
@@ -533,6 +534,8 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex
533
534
  `qwen-token-plan-individual` and `qwen-token-plan` share the international endpoint and
534
535
  `QWEN_TOKEN_PLAN_API_KEY`. The Individual provider exposes only the models documented for Individual
535
536
  subscriptions, while the existing provider retains its broader catalog for backward compatibility.
537
+ `qwen-token-plan-individual-cn` applies the same Individual catalog to the China endpoint and
538
+ `QWEN_TOKEN_PLAN_CN_API_KEY`.
536
539
  Stored credentials remain provider-scoped, so save the key under the provider ID you register.
537
540
 
538
541
  Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs, `AWS_BEARER_TOKEN_BEDROCK`, ECS task roles, web identity tokens); its provider-owned login flow supports bearer tokens, AWS profiles, and the existing credential chain. Vertex AI resolves either an explicit key or gcloud Application Default Credentials plus project/location, with a provider-owned login flow for API keys, ADC, and service-account files.
@@ -891,7 +894,7 @@ console.log(model.output); // ['image'] or ['image', 'text']
891
894
 
892
895
  ## Classification
893
896
 
894
- Classifier models consume structured JSON state, and optionally images, and answer one or more typed questions. They do not use chat or image-generation APIs. TypeSafe's Jev model, Cloudflare's Clef models, and OpenAI's GPT-6 Luna are available from these built-in providers:
897
+ Classifier models consume structured JSON state, and optionally images, and answer one or more typed questions. They do not use chat or image-generation APIs. TypeSafe's Jev model, Cloudflare's Clef models, OpenAI's GPT-6 Luna, and Microsoft-Decision-1 are available from these built-in providers:
895
898
 
896
899
  | Provider | Model IDs | Auth |
897
900
  | --- | --- | --- |
@@ -901,6 +904,7 @@ Classifier models consume structured JSON state, and optionally images, and answ
901
904
  | `vercel-ai-gateway` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY` |
902
905
  | `opencode` | `jev-1.13`, `jev-1.13-free` | `OPENCODE_API_KEY` |
903
906
  | `openai` | `gpt-6-luna` | `OPENAI_API_KEY` |
907
+ | `github-copilot` | `microsoft-decision-1` | `COPILOT_GITHUB_TOKEN` or GitHub Copilot OAuth |
904
908
 
905
909
  ```typescript
906
910
  import { builtinModels } from '@earendil-works/pi-ai/providers/all';
@@ -1437,6 +1441,8 @@ Built-in API implementations live under `./api/<api-id>`:
1437
1441
 
1438
1442
  Importing an implementation module loads its SDK. The `./api/<id>.lazy` wrappers (used by the provider factories) defer that load to the first request when the runtime or bundler supports dynamic import chunking. Legacy raw API subpaths from older releases (`./anthropic`, `./google`, `./mistral`, `./openai-completions`, ...) were removed; use `@earendil-works/pi-ai/api/<api-id>`.
1439
1443
 
1444
+ **Anthropic message threads:** with `sessionId` set and `cacheRetention` not `"none"`, Anthropic requests continue from the previous reply on the server (`message-threads-2026-08-12` beta) and send only the new messages. When anything earlier in the request changed, or the server can't continue, the full request is sent. Token counts and prompt caching are unchanged. On by default only for the `anthropic` provider on `api.anthropic.com`; set `compat.supportsMessageThreads` to change that, for example for gateways that forward the `thread` field. Set `disableContinuation: true` on requests that repeat an earlier one, such as cache warming. Call `cleanupSessionResources(sessionId)` when a session ends.
1445
+
1440
1446
  ### OpenAI Compatibility Settings
1441
1447
 
1442
1448
  The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, NVIDIA NIM, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field supports Responses-specific flags.
@@ -1456,6 +1462,7 @@ interface OpenAICompletionsCompat {
1456
1462
  maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens)
1457
1463
  requiresToolResultName?: boolean; // Whether tool results require the `name` field (default: false)
1458
1464
  requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false)
1465
+ requiresNonNullAssistantContent?: boolean; // Whether replayed assistant messages with no text must send "" instead of null content (default: false)
1459
1466
  requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false)
1460
1467
  requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek)
1461
1468
  thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'baseten' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'baseten' uses configurable chat_template_args plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai)
@@ -1764,6 +1771,18 @@ setBedrockProviderModule(bedrockProviderModule);
1764
1771
 
1765
1772
  That explicit override bundles the AWS SDK. Without it, Bedrock's opaque runtime import expects the package's Bedrock implementation file to be available at runtime.
1766
1773
 
1774
+ Node bundles that split at dynamic imports (esbuild `splitting: true`) can keep Bedrock and the OAuth login flows inside the bundle but lazy: register loaders with literal dynamic imports, which the bundler turns into chunks that load on first use:
1775
+
1776
+ ```typescript
1777
+ import { registerLazyBedrockProvider } from '@earendil-works/pi-ai/lazy-bedrock-provider';
1778
+ import { registerLazyOAuthFlows } from '@earendil-works/pi-ai/lazy-oauth';
1779
+
1780
+ registerLazyBedrockProvider();
1781
+ registerLazyOAuthFlows();
1782
+ ```
1783
+
1784
+ Both modules are Node-only; never import them into a browser bundle.
1785
+
1767
1786
  ### Provider-Scoped Environment Overrides
1768
1787
 
1769
1788
  Pass `env` in stream options to scope provider configuration to a request. Values in `env` are used before process environment variables for provider auth and configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`.
@@ -1789,7 +1808,7 @@ Several providers support OAuth authentication instead of static API keys:
1789
1808
 
1790
1809
  - **Anthropic** (Claude Pro/Max subscription)
1791
1810
  - **OpenAI** (Sign in with ChatGPT: uses the ChatGPT subscription with the OpenAI API)
1792
- - **OpenAI Codex (legacy)** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
1811
+ - **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
1793
1812
  - **GitHub Copilot** (Copilot subscription)
1794
1813
  - **OpenRouter** (OAuth PKCE that mints a user-controlled API key)
1795
1814
 
@@ -1869,7 +1888,7 @@ Built-in login and refresh flows are private provider implementations. Use provi
1869
1888
 
1870
1889
  Provider notes:
1871
1890
 
1872
- **OpenAI Codex (legacy)**: Superseded by Sign in with ChatGPT on the OpenAI provider. Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options unless `cacheRetention` is `"none"`. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId` and cache retention enabled, connections are reused per session and expire after 5 minutes of inactivity. Call `cleanupSessionResources(sessionId)` when finished so the pooled connection does not keep the process alive.
1891
+ **OpenAI Codex**: Superseded by Sign in with ChatGPT on the OpenAI provider. Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options unless `cacheRetention` is `"none"`. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId` and cache retention enabled, connections are reused per session and expire after 5 minutes of inactivity. Call `cleanupSessionResources(sessionId)` when finished so the pooled connection does not keep the process alive.
1873
1892
 
1874
1893
  **Azure OpenAI (Responses)**: Uses the Responses API only. Set `AZURE_OPENAI_API_KEY` and either `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME`. `AZURE_OPENAI_BASE_URL` supports both `https://<resource>.openai.azure.com` and `https://<resource>.cognitiveservices.azure.com`; root endpoints are normalized to `.../openai/v1` automatically. Use `AZURE_OPENAI_API_VERSION` (defaults to `v1`) to override the API version if needed. Deployment names are treated as model IDs by default, override with `azureDeploymentName` or `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` using comma-separated `model-id=deployment` pairs (for example `gpt-4o-mini=my-deployment,gpt-4o=prod`). Legacy deployment-based URLs are intentionally unsupported.
1875
1894
 
@@ -1 +1 @@
1
- {"version":3,"file":"anthropic-messages.d.ts","sourceRoot":"","sources":["../../src/api/anthropic-messages.ts"],"names":[],"mappings":"AAAA,OAAO,SAAiC,MAAM,mBAAmB,CAAC;AAoBlE,OAAO,KAAK,EASX,mBAAmB,EAEnB,cAAc,EACd,aAAa,EAIb,MAAM,aAAa,CAAC;AA+JrB,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,CAAC;AAE1E,MAAM,MAAM,wBAAwB,GAAG,YAAY,GAAG,SAAS,CAAC;AA2ChE,MAAM,WAAW,gBAAiB,SAAQ,aAAa;IACtD;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB;;;;;;;;;;;OAWG;IACH,eAAe,CAAC,EAAE,wBAAwB,CAAC;IAC3C;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACtE;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;CACnB;AAoRD,eAAO,MAAM,MAAM,EAAE,cAAc,CAAC,oBAAoB,EAAE,gBAAgB,CA8VzE,CAAC;AA2BF,eAAO,MAAM,YAAY,EAAE,cAAc,CAAC,oBAAoB,EAAE,mBAAmB,CAgDlF,CAAC"}
1
+ {"version":3,"file":"anthropic-messages.d.ts","sourceRoot":"","sources":["../../src/api/anthropic-messages.ts"],"names":[],"mappings":"AAAA,OAAO,SAAiC,MAAM,mBAAmB,CAAC;AAqBlE,OAAO,KAAK,EASX,mBAAmB,EAEnB,cAAc,EACd,aAAa,EAIb,MAAM,aAAa,CAAC;AAqKrB,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,CAAC;AAE1E,MAAM,MAAM,wBAAwB,GAAG,YAAY,GAAG,SAAS,CAAC;AA6QhE,MAAM,WAAW,gBAAiB,SAAQ,aAAa;IACtD;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB;;;;;;;;;;;OAWG;IACH,eAAe,CAAC,EAAE,wBAAwB,CAAC;IAC3C;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACtE;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;CACnB;AAoRD,eAAO,MAAM,MAAM,EAAE,cAAc,CAAC,oBAAoB,EAAE,gBAAgB,CAiXzE,CAAC;AA2BF,eAAO,MAAM,YAAY,EAAE,cAAc,CAAC,oBAAoB,EAAE,mBAAmB,CAgDlF,CAAC"}
@@ -1,8 +1,11 @@
1
1
  import Anthropic, {} from "@anthropic-ai/sdk";
2
2
  import { ANTHROPIC_FEDERATION_RULE_ID_ENV, ANTHROPIC_IDENTITY_TOKEN_FILE_ENV, ANTHROPIC_ORGANIZATION_ID_ENV, ANTHROPIC_SERVICE_ACCOUNT_ID_ENV, ANTHROPIC_WORKSPACE_ID_ENV, } from "../env-api-keys.js";
3
3
  import { calculateCost } from "../models.js";
4
+ import { registerSessionResourceCleanup } from "../session-resources.js";
4
5
  import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.js";
6
+ import { withCauseDetail } from "../utils/error-body.js";
5
7
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
8
+ import { shortHash } from "../utils/hash.js";
6
9
  import { headersToRecord } from "../utils/headers.js";
7
10
  import { getIdentityHeaders } from "../utils/identity.js";
8
11
  import { parseJsonWithRepair } from "../utils/json-parse.js";
@@ -13,9 +16,10 @@ import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
13
16
  import { ToolCallArgumentsStream } from "../utils/streaming-json.js";
14
17
  import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.js";
15
18
  import { getCurrentTools, getInitialSystemMessage, resolveTranscript, } from "../utils/transcript.js";
16
- import { getJsonSchemaToolParameters, isStrictToolLimitErrorMessage, prefersJsonSchemaStrictSampling, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.js";
19
+ import { getJsonSchemaToolParameters, prefersJsonSchemaStrictSampling, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.js";
17
20
  import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
18
21
  import { adjustMaxTokensForThinking, buildBaseOptions, clampMaxTokensToContext } from "./simple-options.js";
22
+ import { hasExceededStrictToolLimits, isStrictToolLimitErrorMessage, markStrictToolLimitsExceeded, } from "./strict-tool-limits.js";
19
23
  import { splitErrorToolResultImages, transformMessages } from "./transform-messages.js";
20
24
  /**
21
25
  * Resolve cache retention preference.
@@ -122,6 +126,7 @@ const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01";
122
126
  const MID_CONVERSATION_OUTPUT_CONFIG_BETA = "mid-conversation-output-config-2026-07-01";
123
127
  const THINKING_BINDING_CONTROLS_BETA = "thinking-binding-controls-2026-08-01";
124
128
  const INLINE_TOOLS_BETA = "inline-tools-2026-09-15";
129
+ const MESSAGE_THREADS_BETA = "message-threads-2026-08-12";
125
130
  /**
126
131
  * Stable deferred tool declared whenever native tool changes are in use. Anthropic adds
127
132
  * hidden prompt scaffolding for mid-conversation tool changes; declaring this placeholder
@@ -151,8 +156,178 @@ function getAnthropicCompat(model) {
151
156
  supportsStrictTools: model.compat?.supportsStrictTools ?? false,
152
157
  supportsMidConvoSystemMessages: model.compat?.supportsMidConvoSystemMessages ?? false,
153
158
  supportsMidConvoToolChanges: model.compat?.supportsMidConvoToolChanges ?? false,
159
+ // Gateways that drop unknown request fields would silently turn a `continue` into a request
160
+ // without the earlier conversation, so the default is limited to Anthropic's own endpoint.
161
+ // Widen or remove this restriction once proxies generally forward the `thread` field.
162
+ supportsMessageThreads: model.compat?.supportsMessageThreads ??
163
+ (model.provider === "anthropic" && model.baseUrl.includes("api.anthropic.com")),
154
164
  };
155
165
  }
166
+ /** Per session: where the next request can continue, or "off" after the endpoint rejected threads. */
167
+ const threadState = new Map();
168
+ const MAX_THREAD_STATE_ENTRIES = 10_000;
169
+ /** Rejections of a `continue` that a new thread fixes: expired, raced, or a fixed setting changed. */
170
+ const NEW_THREAD_ERRORS = new Set([
171
+ "thread_not_found",
172
+ "thread_already_continued",
173
+ "thread_continue_in_flight",
174
+ "thread_fingerprint_mismatch",
175
+ ]);
176
+ /**
177
+ * Sets a session's thread state, keeping at most `MAX_THREAD_STATE_ENTRIES` sessions.
178
+ *
179
+ * State is normally freed by `cleanupSessionResources(sessionId)` when a session ends, as pi's
180
+ * coding agent does. The cap bounds memory for library users that never call it, for example a
181
+ * server passing a new `sessionId` per conversation. Dropping the least recently updated session
182
+ * is cheap: its thread has most likely expired on the server already (after a few minutes idle),
183
+ * and otherwise its next request just starts a new thread.
184
+ */
185
+ function setThreadState(sessionId, value) {
186
+ // Re-insert so Map order stays oldest-first for the cap.
187
+ threadState.delete(sessionId);
188
+ threadState.set(sessionId, value);
189
+ if (threadState.size > MAX_THREAD_STATE_ENTRIES) {
190
+ const oldest = threadState.keys().next().value;
191
+ if (oldest !== undefined)
192
+ threadState.delete(oldest);
193
+ }
194
+ }
195
+ registerSessionResourceCleanup((sessionId) => {
196
+ if (sessionId)
197
+ threadState.delete(sessionId);
198
+ else
199
+ threadState.clear();
200
+ });
201
+ /**
202
+ * Hash of everything a `continue` builds on: the settings that must stay the same within a thread
203
+ * and the messages the server already holds.
204
+ *
205
+ * - Settings, as sent: the endpoint (a thread exists on one endpoint only), and the fixed request
206
+ * fields. A `continue` omits `system` and `tools` and the server keeps the thread's own, so only
207
+ * this check can notice a change to them, including their cache markers. The server rejects
208
+ * changes to the other fields with `thread_fingerprint_mismatch`; checking them here saves that
209
+ * rejected request. A fixed field missing here costs only that rejection, which also starts a
210
+ * new thread.
211
+ * - Messages, normalized so messages that mean the same to the API hash the same.
212
+ */
213
+ function hashThreadPrefix(model, params, messages) {
214
+ const { system, tools, thinking, output_config, tool_choice, betas, fallbacks } = params;
215
+ const settings = JSON.stringify([
216
+ model.provider,
217
+ model.baseUrl,
218
+ params.model,
219
+ system,
220
+ tools,
221
+ thinking,
222
+ output_config,
223
+ tool_choice,
224
+ betas,
225
+ fallbacks,
226
+ ]);
227
+ const conversation = JSON.stringify(messages, (key, value) => {
228
+ // Cache markers don't change what a message means, but they move: pi puts one on the newest
229
+ // message, so the message that had it in the last request has none in this one. Extensions
230
+ // may add markers anywhere too.
231
+ if (key === "cache_control")
232
+ return undefined;
233
+ // To attach that marker, the newest user message's string content is sent as one text block;
234
+ // the next request sends the same message as a string: "hi" vs [{ type: "text", text: "hi" }].
235
+ if (key === "content" && typeof value === "string")
236
+ return [{ type: "text", text: value }];
237
+ return value;
238
+ });
239
+ return shortHash(settings + conversation);
240
+ }
241
+ function createThreadRequest(sessionId, params) {
242
+ return { sessionId, body: { ...params, thread: { type: "create" } } };
243
+ }
244
+ function prepareThreadRequest(model, sessionId, params) {
245
+ const state = threadState.get(sessionId);
246
+ if (state === "off")
247
+ return undefined;
248
+ if (state !== undefined &&
249
+ params.messages.length > state.prefixCount &&
250
+ hashThreadPrefix(model, params, params.messages.slice(0, state.prefixCount)) === state.prefixHash) {
251
+ // Each reply can be continued once; deleting also keeps a concurrent request from trying.
252
+ threadState.delete(sessionId);
253
+ const { system: _system, tools: _tools, ...rest } = params;
254
+ return {
255
+ body: {
256
+ ...rest,
257
+ messages: params.messages.slice(state.prefixCount),
258
+ thread: { type: "continue", previous_message_id: state.replyId },
259
+ },
260
+ sessionId,
261
+ };
262
+ }
263
+ return createThreadRequest(sessionId, params);
264
+ }
265
+ /** The `error_code` Anthropic puts in `error.details` of thread rejections. */
266
+ function getThreadErrorCode(error) {
267
+ const body = error.error;
268
+ const code = body?.error?.details?.error_code;
269
+ return typeof code === "string" ? code : undefined;
270
+ }
271
+ /**
272
+ * Sends a thread request. A rejection about the thread (never a started stream) is resent once in
273
+ * full: as a new thread when the old one can't be continued, or without `thread` when the endpoint
274
+ * can't use threads, which also turns threads off for the session. Other errors are passed on.
275
+ * Returns the response and the thread request that produced it, if any.
276
+ */
277
+ async function sendThreadRequest(thread, params, send, output, signal) {
278
+ try {
279
+ return { response: await send(thread.body), thread };
280
+ }
281
+ catch (error) {
282
+ if (signal?.aborted || !(error instanceof Anthropic.APIError))
283
+ throw error;
284
+ const errorCode = getThreadErrorCode(error);
285
+ const startNewThread = errorCode !== undefined && NEW_THREAD_ERRORS.has(errorCode);
286
+ // The endpoint can't use threads: it can't thread this request, or it doesn't know the
287
+ // `thread` field and rejects it without a code ("thread: Extra inputs are not permitted"),
288
+ // e.g. with an `anthropic-beta` header that lacks the threads beta.
289
+ const threadsUnsupported = errorCode === "thread_unsupported_request" ||
290
+ (errorCode === undefined && error.status === 400 && error.message.includes("thread"));
291
+ // Any other error (prompt too long, unknown model, strict tool limits, ...) is not about the
292
+ // thread, and resending would only fail again.
293
+ if (!startNewThread && !threadsUnsupported)
294
+ throw error;
295
+ // Recorded before the resend, so a failed resend keeps it.
296
+ appendAssistantMessageDiagnostic(output, {
297
+ type: "anthropic_thread_fallback",
298
+ timestamp: Date.now(),
299
+ error: { message: error.message },
300
+ details: { reason: errorCode ?? String(error.status) },
301
+ });
302
+ if (startNewThread) {
303
+ const created = createThreadRequest(thread.sessionId, params);
304
+ return { response: await send(created.body), thread: created };
305
+ }
306
+ // With the threads beta, a request without `thread` is a normal request.
307
+ const response = await send(params);
308
+ setThreadState(thread.sessionId, "off");
309
+ return { response, thread: undefined };
310
+ }
311
+ }
312
+ /**
313
+ * Records where the session's next request can continue: after `output`, the clean reply to the
314
+ * thread request `thread`, whose full request was `params`.
315
+ */
316
+ function recordThreadReply(model, thread, params, output, isOAuth, allowEmptySignature) {
317
+ // `responseModel` is set only when a fallback model answered; the server does not store those
318
+ // replies, so they cannot be continued.
319
+ if (output.responseId === undefined || output.responseModel !== undefined)
320
+ return;
321
+ // Convert the reply like the next request will, keeping only the assistant message
322
+ // (transformMessages adds placeholder results for trailing tool calls).
323
+ const reply = convertMessages(transformMessages([output], model, normalizeToolCallId), isOAuth, undefined, allowEmptySignature).messages.filter((message) => message.role === "assistant");
324
+ const prefix = [...params.messages, ...reply];
325
+ setThreadState(thread.sessionId, {
326
+ prefixCount: prefix.length,
327
+ prefixHash: hashThreadPrefix(model, params, prefix),
328
+ replyId: output.responseId,
329
+ });
330
+ }
156
331
  function mergeHeaders(...headerSources) {
157
332
  const merged = {};
158
333
  for (const headers of headerSources) {
@@ -377,7 +552,8 @@ async function* iterateAnthropicEvents(response, signal) {
377
552
  }
378
553
  export const stream = (model, context, options) => {
379
554
  const stream = new AssistantMessageEventStream();
380
- const normalizedContext = resolveTranscript(context, getAnthropicCompat(model).supportsMidConvoSystemMessages);
555
+ const compat = getAnthropicCompat(model);
556
+ const normalizedContext = resolveTranscript(context, compat.supportsMidConvoSystemMessages);
381
557
  const currentTools = getCurrentTools(normalizedContext.messages);
382
558
  (async () => {
383
559
  const output = {
@@ -402,6 +578,12 @@ export const stream = (model, context, options) => {
402
578
  let isOAuth;
403
579
  let usageModel = model;
404
580
  let inputTransformations;
581
+ const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
582
+ const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
583
+ // Session whose requests may use threads; undefined when threads don't apply. Sessions where
584
+ // the endpoint rejected threads ("off") still send the threads beta, which the resend that
585
+ // turned them off was accepted with; `prepareThreadRequest()` just sends no `thread`.
586
+ const threadSessionId = compat.supportsMessageThreads ? cacheSessionId : undefined;
405
587
  if (options?.client) {
406
588
  client = options.client;
407
589
  isOAuth = false;
@@ -419,8 +601,6 @@ export const stream = (model, context, options) => {
419
601
  hasImages,
420
602
  });
421
603
  }
422
- const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
423
- const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
424
604
  const created = createClient(model, apiKey, options?.headers, options?.fetch, copilotDynamicHeaders, cacheSessionId, federation, options?.identity);
425
605
  client = created.client;
426
606
  isOAuth = created.isOAuthToken;
@@ -430,24 +610,38 @@ export const stream = (model, context, options) => {
430
610
  ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
431
611
  maxRetries: 0,
432
612
  };
433
- let disablePreferredStrictTools = false;
613
+ const send = (body) => retryProviderRequest(() => client.beta.messages.create(body, requestOptions).asResponse(), {
614
+ maxRetries: options?.maxRetries,
615
+ maxRetryDelayMs: options?.maxRetryDelayMs,
616
+ signal: options?.signal,
617
+ });
618
+ let disablePreferredStrictTools = hasExceededStrictToolLimits(options?.sessionId);
434
619
  let response;
620
+ let params;
621
+ // The thread request that produced the response; undefined when no `thread` was sent.
622
+ let thread;
435
623
  for (;;) {
436
624
  options?.signal?.throwIfAborted();
437
- const built = buildParams(model, normalizedContext, isOAuth, options, disablePreferredStrictTools);
625
+ const built = buildParams(model, normalizedContext, isOAuth, options, {
626
+ disablePreferredStrictTools,
627
+ messageThreads: threadSessionId !== undefined,
628
+ });
438
629
  if (built.providerThinkingLevel)
439
630
  output.providerThinkingLevel = built.providerThinkingLevel;
440
- let params = built.params;
631
+ params = built.params;
441
632
  const nextParams = await options?.onPayload?.(params, model);
442
633
  if (nextParams !== undefined) {
443
634
  params = { ...nextParams, stream: true };
444
635
  }
636
+ const prepared = threadSessionId !== undefined && options?.disableContinuation !== true
637
+ ? prepareThreadRequest(model, threadSessionId, params)
638
+ : undefined;
445
639
  try {
446
- response = await retryProviderRequest(() => client.beta.messages.create(params, requestOptions).asResponse(), {
447
- maxRetries: options?.maxRetries,
448
- maxRetryDelayMs: options?.maxRetryDelayMs,
449
- signal: options?.signal,
450
- });
640
+ const sent = prepared
641
+ ? await sendThreadRequest(prepared, params, send, output, options?.signal)
642
+ : { response: await send(params), thread: undefined };
643
+ response = sent.response;
644
+ thread = sent.thread;
451
645
  break;
452
646
  }
453
647
  catch (error) {
@@ -462,6 +656,7 @@ export const stream = (model, context, options) => {
462
656
  throw error;
463
657
  }
464
658
  disablePreferredStrictTools = true;
659
+ markStrictToolLimitsExceeded(options?.sessionId);
465
660
  appendAssistantMessageDiagnostic(output, {
466
661
  type: "anthropic_strict_tools_fallback",
467
662
  timestamp: Date.now(),
@@ -683,6 +878,8 @@ export const stream = (model, context, options) => {
683
878
  if (output.stopReason === "aborted" || output.stopReason === "error") {
684
879
  throw new Error(output.errorMessage || "An unknown error occurred");
685
880
  }
881
+ if (thread)
882
+ recordThreadReply(model, thread, params, output, isOAuth, compat.allowEmptySignature);
686
883
  if (inputTransformations && inputTransformations.length > 0) {
687
884
  appendAssistantMessageDiagnostic(output, {
688
885
  type: "anthropic_input_transformations",
@@ -701,7 +898,7 @@ export const stream = (model, context, options) => {
701
898
  }
702
899
  catch (error) {
703
900
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
704
- output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
901
+ output.errorMessage = error instanceof Error ? withCauseDetail(error) : JSON.stringify(error);
705
902
  stream.push({ type: "error", reason: output.stopReason, error: output });
706
903
  stream.end();
707
904
  }
@@ -836,7 +1033,7 @@ function createClient(model, apiKey, optionsHeaders, fetch, dynamicHeaders, sess
836
1033
  });
837
1034
  return { client, isOAuthToken: false };
838
1035
  }
839
- function getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, options) {
1036
+ function getBetaFeatures(model, context, options, { isOAuthToken, nativeToolChanges, messageThreads }) {
840
1037
  let configuredFeatures;
841
1038
  for (const headers of [model.headers, options?.headers]) {
842
1039
  for (const [name, value] of Object.entries(headers ?? {})) {
@@ -872,9 +1069,11 @@ function getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, option
872
1069
  }
873
1070
  if (nativeToolChanges)
874
1071
  features.push(INLINE_TOOLS_BETA);
1072
+ if (messageThreads)
1073
+ features.push(MESSAGE_THREADS_BETA);
875
1074
  return [...new Set(features)];
876
1075
  }
877
- function buildParams(model, context, isOAuthToken, options, disablePreferredStrictTools = false) {
1076
+ function buildParams(model, context, isOAuthToken, options, flags) {
878
1077
  const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);
879
1078
  const compat = getAnthropicCompat(model);
880
1079
  const initialSystemMessage = getInitialSystemMessage(context.messages);
@@ -887,7 +1086,7 @@ function buildParams(model, context, isOAuthToken, options, disablePreferredStri
887
1086
  // active tool to anchor the placeholder. Otherwise the current tool list is sent.
888
1087
  let hasPreferredStrictTools = false;
889
1088
  const convertToolDefinitions = (tools, toolCacheControl) => {
890
- const definitions = convertTools(tools, isOAuthToken, compat.supportsEagerToolInputStreaming, compat.supportsStrictTools, toolCacheControl, disablePreferredStrictTools);
1089
+ const definitions = convertTools(tools, isOAuthToken, compat.supportsEagerToolInputStreaming, compat.supportsStrictTools, toolCacheControl, flags.disablePreferredStrictTools);
891
1090
  if (definitions.some((definition, index) => definition.strict === true && prefersJsonSchemaStrictSampling(tools[index]))) {
892
1091
  hasPreferredStrictTools = true;
893
1092
  }
@@ -900,7 +1099,11 @@ function buildParams(model, context, isOAuthToken, options, disablePreferredStri
900
1099
  const effortMessages = model.compat?.supportsMidConvoEffort === true && !thinkingDisabled;
901
1100
  const converted = convertMessages(conversationMessages, isOAuthToken, cacheControl, compat.allowEmptySignature, effortMessages ? model.provider : undefined, nativeToolChanges ? convertToolDefinitions : undefined);
902
1101
  const activeEffort = options?.effort ?? "high";
903
- const betaFeatures = getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, options);
1102
+ const betaFeatures = getBetaFeatures(model, context, options, {
1103
+ isOAuthToken,
1104
+ nativeToolChanges,
1105
+ messageThreads: flags.messageThreads,
1106
+ });
904
1107
  const params = {
905
1108
  model: model.id,
906
1109
  messages: effortMessages ? insertThinkingLevelMessages(converted, activeEffort) : converted.messages,
@@ -1293,10 +1496,14 @@ function convertTools(tools, isOAuthToken, supportsEagerToolInputStreaming, supp
1293
1496
  : resolveJsonSchemaStrictSampling(tool, supportsStrictTools, isAnthropicStrictUnsupportedKeyword);
1294
1497
  const parameters = getJsonSchemaToolParameters(tool, strict);
1295
1498
  const schema = parameters;
1499
+ // Keep shared definitions so property $refs still resolve. Other root keywords stay out:
1500
+ // Anthropic rejects root anyOf/oneOf/allOf with a 400.
1296
1501
  const legacyInputSchema = {
1297
1502
  type: "object",
1298
1503
  properties: schema.properties ?? {},
1299
1504
  required: schema.required ?? [],
1505
+ ...(schema.$defs === undefined ? {} : { $defs: schema.$defs }),
1506
+ ...(schema.definitions === undefined ? {} : { definitions: schema.definitions }),
1300
1507
  };
1301
1508
  const inputSchema = strict === true
1302
1509
  ? {