@code-yeongyu/senpi-ai 2026.10.1-3 → 2026.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1626,10 +1626,10 @@ Browser compatibility notes:
1626
1626
 
1627
1627
  ## Bundling and Tree Shaking
1628
1628
 
1629
- For small bundles, import only the providers you need:
1629
+ For small bundles and low-overhead unbundled scripts, import the model runtime and only the providers you need:
1630
1630
 
1631
1631
  ```typescript
1632
- import { createModels } from '@earendil-works/pi-ai';
1632
+ import { createModels } from '@earendil-works/pi-ai/models';
1633
1633
  import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
1634
1634
 
1635
1635
  const models = createModels();
@@ -1638,7 +1638,8 @@ models.setProvider(openaiProvider());
1638
1638
 
1639
1639
  Rules:
1640
1640
 
1641
- - `@earendil-works/pi-ai` is the core entrypoint and does not import built-in catalogs, provider factories, or SDK implementations.
1641
+ - `@earendil-works/pi-ai/models` exports the model runtime (`createModels`, `createProvider`, model helpers, and their types) without TypeBox, built-in catalogs, or SDK implementations. Other types can still use `import type` from the root.
1642
+ - `@earendil-works/pi-ai` is the core entrypoint and does not import built-in catalogs, real provider factories, or SDK implementations, but it eagerly imports TypeBox and schema validation. Unbundled Node scripts do not tree-shake its unused exports; prefer `./models`, `./providers/faux`, and specific `./utils/*` subpaths when those are all you need.
1642
1643
  - `@earendil-works/pi-ai/providers/<provider>` imports that provider's catalog and lazy API wrapper only.
1643
1644
  - `@earendil-works/pi-ai/providers/all` imports every built-in provider factory and all catalogs. Use it only when you want the full built-in set.
1644
1645
  - With code splitting, provider SDKs stay in lazy chunks and load on first request.
@@ -1,4 +1,5 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
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";
2
3
  import { calculateCost } from "../models.js";
3
4
  import { readProviderDiagnostic } from "../provider-diagnostic.js";
4
5
  import { registerSessionResourceCleanup } from "../session-resources.js";
@@ -27,9 +28,9 @@ import { sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.
27
28
  import { resolveRootObjectSchema } from "../utils/tool-schema-compat.js";
28
29
  import { getCurrentTools, getDeclaredTools, getInitialSystemMessage, hasToolRedefinitions, normalizeContext, resolveTranscript, } from "../utils/transcript.js";
29
30
  import { sanitizeAnthropicToolPairs } from "./anthropic-tool-pairs.js";
30
- import { demoteUnavailableToolReferences } from "./anthropic-tool-references.js";
31
+ import { demoteToolReferenceReplay, demoteUnavailableToolReferences } from "./anthropic-tool-references.js";
31
32
  import { resolveCloudflareBaseUrl } from "./cloudflare.js";
32
- import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.js";
33
+ import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.js";
33
34
  import { withGitHubCopilotFailureNote } from "./github-copilot-errors.js";
34
35
  import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
35
36
  import { ANTHROPIC_RESERVED_BODY_KEYS, adjustMaxTokensForThinking, buildBaseOptions, clampMaxTokensToContext, } from "./simple-options.js";
@@ -231,18 +232,23 @@ function supportsMidConvoToolChanges(model) {
231
232
  // A provider can reject its own empty signatures. Learn that capability for one
232
233
  // conversation without changing the shared model definition used by other sessions.
233
234
  const unsignedThinkingTextReplayFallbacks = new Set();
235
+ // Endpoints that rejected a replayed tool_reference for one conversation (senpi #2568).
236
+ const toolReferenceReplayFallbacks = new Set();
234
237
  registerSessionResourceCleanup((sessionId) => {
235
238
  // One small entry per (session, base URL, model) that ever hit the fallback;
236
239
  // drop the session's entries at teardown so long-lived hosts do not collect
237
240
  // them for every session that ever ran.
238
241
  if (sessionId === undefined) {
239
242
  unsignedThinkingTextReplayFallbacks.clear();
243
+ toolReferenceReplayFallbacks.clear();
240
244
  return;
241
245
  }
242
246
  const prefix = `${sessionId}\u0000`;
243
- for (const key of unsignedThinkingTextReplayFallbacks) {
244
- if (key.startsWith(prefix))
245
- unsignedThinkingTextReplayFallbacks.delete(key);
247
+ for (const fallbacks of [unsignedThinkingTextReplayFallbacks, toolReferenceReplayFallbacks]) {
248
+ for (const key of fallbacks) {
249
+ if (key.startsWith(prefix))
250
+ fallbacks.delete(key);
251
+ }
246
252
  }
247
253
  });
248
254
  function unsignedThinkingFallbackKey(model, sessionId) {
@@ -256,6 +262,12 @@ function isInvalidUnsignedThinkingSignatureError(error) {
256
262
  error instanceof Error &&
257
263
  /Invalid signature in thinking block/i.test(error.message));
258
264
  }
265
+ function isToolReferenceNotFoundError(error) {
266
+ return (error instanceof Error &&
267
+ "status" in error &&
268
+ error.status === 400 &&
269
+ /Tool reference '[^']*' not found in available tools/i.test(error.message));
270
+ }
259
271
  function mergeHeaders(...headerSources) {
260
272
  const merged = {};
261
273
  for (const headers of headerSources) {
@@ -279,16 +291,58 @@ function hasHeader(headers, name) {
279
291
  }
280
292
  return false;
281
293
  }
282
- function assertRequestAuth(provider, apiKey, headers) {
283
- if (apiKey)
284
- return;
285
- if (hasHeader(headers, "authorization") ||
294
+ function hasRequestAuth(apiKey, headers) {
295
+ return (!!apiKey ||
296
+ hasHeader(headers, "authorization") ||
286
297
  hasHeader(headers, "x-api-key") ||
287
- hasHeader(headers, "cf-aig-authorization")) {
288
- return;
298
+ hasHeader(headers, "cf-aig-authorization"));
299
+ }
300
+ function assertRequestAuth(provider, apiKey, headers) {
301
+ if (!hasRequestAuth(apiKey, headers))
302
+ throw new Error(`No API key for provider: ${provider}`);
303
+ }
304
+ /**
305
+ * Anthropic SDK client that never runs the SDK's own credential chain
306
+ * (ANTHROPIC_PROFILE config files, federation env vars). Without this, every
307
+ * client built with `apiKey: null, authToken: null` for header-owned auth would
308
+ * also resolve and exchange SDK credentials behind pi's auth resolver.
309
+ */
310
+ class PiAnthropic extends Anthropic {
311
+ _shouldResolveDefaultCredentials() {
312
+ return false;
289
313
  }
290
- throw new Error(`No API key for provider: ${provider}`);
291
314
  }
315
+ /**
316
+ * Workload identity federation config from the ANTHROPIC_* variables the
317
+ * Anthropic SDK documents; the SDK performs the token exchange and refresh.
318
+ * Only for the anthropic provider, since the exchange is an Anthropic API
319
+ * endpoint, and only when no key or auth header was resolved.
320
+ */
321
+ function getAnthropicFederation(model, apiKey, headers, env) {
322
+ if (model.provider !== "anthropic" || hasRequestAuth(apiKey, headers))
323
+ return undefined;
324
+ const federationRuleId = getProviderEnvValue(ANTHROPIC_FEDERATION_RULE_ID_ENV, env);
325
+ const organizationId = getProviderEnvValue(ANTHROPIC_ORGANIZATION_ID_ENV, env);
326
+ const identityTokenFile = getProviderEnvValue(ANTHROPIC_IDENTITY_TOKEN_FILE_ENV, env);
327
+ if (!federationRuleId || !organizationId || !identityTokenFile)
328
+ return undefined;
329
+ return {
330
+ organization_id: organizationId,
331
+ workspace_id: getProviderEnvValue(ANTHROPIC_WORKSPACE_ID_ENV, env),
332
+ authentication: {
333
+ type: "oidc_federation",
334
+ federation_rule_id: federationRuleId,
335
+ service_account_id: getProviderEnvValue(ANTHROPIC_SERVICE_ACCOUNT_ID_ENV, env),
336
+ identity_token: { source: "file", path: identityTokenFile },
337
+ },
338
+ };
339
+ }
340
+ /**
341
+ * The SDK caches the federated access token per client, but pi creates a client
342
+ * per request. Keep one client for the current federation config and fetch, and
343
+ * clone it per request with `withOptions()`, which shares the token cache.
344
+ */
345
+ let federationClient;
292
346
  function isRecord(value) {
293
347
  return typeof value === "object" && value !== null;
294
348
  }
@@ -904,7 +958,9 @@ export const stream = (model, context, options) => {
904
958
  else {
905
959
  const apiKey = options?.apiKey;
906
960
  const optionsHeaders = providerHeadersToRecord(options?.headers);
907
- assertRequestAuth(model.provider, apiKey, optionsHeaders);
961
+ const federation = getAnthropicFederation(model, apiKey, optionsHeaders, options?.env);
962
+ if (!federation)
963
+ assertRequestAuth(model.provider, apiKey, optionsHeaders);
908
964
  let copilotDynamicHeaders;
909
965
  if (model.provider === "github-copilot") {
910
966
  const hasImages = hasCopilotVisionInput(normalizedContext.messages);
@@ -916,7 +972,7 @@ export const stream = (model, context, options) => {
916
972
  const cacheRetention = resolveCacheRetention(options?.cacheRetention ?? model.cacheRetention, options?.env, "short");
917
973
  const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
918
974
  openClient = () => {
919
- const created = createClient(model, apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, normalizedContext), options?.refusalFallbacks !== undefined, optionsHeaders, options?.fetch, copilotDynamicHeaders, cacheSessionId, options?.env);
975
+ const created = createClient(model, apiKey, options?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, normalizedContext), options?.refusalFallbacks !== undefined, optionsHeaders, options?.fetch, copilotDynamicHeaders, cacheSessionId, options?.env, federation);
920
976
  client = created.client;
921
977
  isOAuth = created.isOAuthToken;
922
978
  advertisedClaudeCodeVersion = created.claudeCodeVersion;
@@ -949,6 +1005,8 @@ export const stream = (model, context, options) => {
949
1005
  let unsignedThinkingReplay = fallbackKey && unsignedThinkingTextReplayFallbacks.has(fallbackKey)
950
1006
  ? "text"
951
1007
  : getAnthropicCompat(model).unsignedThinkingReplay;
1008
+ let demoteReferenceReplay = fallbackKey !== undefined && toolReferenceReplayFallbacks.has(fallbackKey);
1009
+ let requestReplaysReferences = false;
952
1010
  const createRequest = async () => {
953
1011
  let params = buildParams(model, normalizedContext, isOAuth, options, unsignedThinkingReplay);
954
1012
  const nextParams = await options?.onPayload?.(params, model);
@@ -957,7 +1015,12 @@ export const stream = (model, context, options) => {
957
1015
  }
958
1016
  params = sanitizeAdaptiveThinkingPayload(model, params, options);
959
1017
  params = sanitizeUnsupportedNativeTools(model, params);
960
- params = sanitizeAnthropicToolPairs(demoteUnavailableToolReferences(params));
1018
+ params = demoteUnavailableToolReferences(params);
1019
+ const withoutReferenceReplay = demoteToolReferenceReplay(params);
1020
+ requestReplaysReferences = withoutReferenceReplay !== params;
1021
+ if (demoteReferenceReplay)
1022
+ params = withoutReferenceReplay;
1023
+ params = sanitizeAnthropicToolPairs(params);
961
1024
  const payloadRequestMetadata = extractPayloadRequestMetadata(params);
962
1025
  params = payloadRequestMetadata.params;
963
1026
  const limitedTools = limitGitHubCopilotTools(model.provider, params.tools, params.tool_choice);
@@ -994,6 +1057,12 @@ export const stream = (model, context, options) => {
994
1057
  unsignedThinkingTextReplayFallbacks.add(fallbackKey);
995
1058
  return createRequest();
996
1059
  }
1060
+ if (!demoteReferenceReplay && requestReplaysReferences && isToolReferenceNotFoundError(error)) {
1061
+ demoteReferenceReplay = true;
1062
+ if (fallbackKey)
1063
+ toolReferenceReplayFallbacks.add(fallbackKey);
1064
+ return createRequest();
1065
+ }
997
1066
  if (await retryWithNewerClaudeCode(error)) {
998
1067
  try {
999
1068
  return await createRequest();
@@ -1437,7 +1506,10 @@ function mapThinkingLevelToEffort(model, level) {
1437
1506
  }
1438
1507
  export const streamSimple = (model, context, options) => {
1439
1508
  const apiKey = options?.apiKey;
1440
- assertRequestAuth(model.provider, apiKey, providerHeadersToRecord(options?.headers));
1509
+ const optionsHeaders = providerHeadersToRecord(options?.headers);
1510
+ if (!getAnthropicFederation(model, apiKey, optionsHeaders, options?.env)) {
1511
+ assertRequestAuth(model.provider, apiKey, optionsHeaders);
1512
+ }
1441
1513
  const base = {
1442
1514
  ...buildBaseOptions(model, context, options, options?.apiKey),
1443
1515
  toolChoice: options?.toolChoice,
@@ -1472,7 +1544,7 @@ export const streamSimple = (model, context, options) => {
1472
1544
  function isOAuthToken(apiKey) {
1473
1545
  return apiKey.includes("sk-ant-oat");
1474
1546
  }
1475
- function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStreamingBeta, useServerSideFallbackBeta, optionsHeaders, fetch, dynamicHeaders, sessionId, env) {
1547
+ function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStreamingBeta, useServerSideFallbackBeta, optionsHeaders, fetch, dynamicHeaders, sessionId, env, federation) {
1476
1548
  // Adaptive thinking models have interleaved thinking built in, so skip the beta header.
1477
1549
  const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model);
1478
1550
  const betaFeatures = [];
@@ -1486,7 +1558,7 @@ function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStre
1486
1558
  betaFeatures.push(SERVER_SIDE_FALLBACK_BETA);
1487
1559
  }
1488
1560
  if (model.provider === "cloudflare-ai-gateway") {
1489
- const client = new Anthropic({
1561
+ const client = new PiAnthropic({
1490
1562
  apiKey: null,
1491
1563
  authToken: null,
1492
1564
  baseURL: resolveCloudflareBaseUrl(model, env),
@@ -1505,7 +1577,7 @@ function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStre
1505
1577
  }
1506
1578
  // Copilot: Bearer auth, selective betas.
1507
1579
  if (model.provider === "github-copilot") {
1508
- const client = new Anthropic({
1580
+ const client = new PiAnthropic({
1509
1581
  apiKey: null,
1510
1582
  authToken: apiKey,
1511
1583
  baseURL: model.baseUrl,
@@ -1522,7 +1594,7 @@ function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStre
1522
1594
  // OAuth: Bearer auth, Claude Code identity headers
1523
1595
  if (apiKey && isOAuthToken(apiKey)) {
1524
1596
  const advertisedClaudeCodeVersion = getClaudeCodeVersion(claudeCodeVersion, env);
1525
- const client = new Anthropic({
1597
+ const client = new PiAnthropic({
1526
1598
  apiKey: null,
1527
1599
  authToken: apiKey,
1528
1600
  baseURL: model.baseUrl,
@@ -1538,7 +1610,7 @@ function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStre
1538
1610
  });
1539
1611
  return { client, isOAuthToken: true, claudeCodeVersion: advertisedClaudeCodeVersion };
1540
1612
  }
1541
- // API key auth
1613
+ // API key, header-owned auth, or workload identity federation.
1542
1614
  const affinityCompat = getAnthropicCompat(model);
1543
1615
  const sessionAffinityHeaders = {};
1544
1616
  if (sessionId && affinityCompat.sendSessionAffinityHeaders) {
@@ -1547,17 +1619,33 @@ function createClient(model, apiKey, interleavedThinking, useFineGrainedToolStre
1547
1619
  const header = affinityCompat.sessionAffinityFormat === "openrouter" ? "x-session-id" : "x-session-affinity";
1548
1620
  sessionAffinityHeaders[header] = sessionId;
1549
1621
  }
1550
- const client = new Anthropic({
1622
+ const defaultHeaders = sanitizeAdaptiveThinkingHeaders(model, mergeClientHeaders(model, {
1623
+ accept: "application/json",
1624
+ "anthropic-dangerous-direct-browser-access": "true",
1625
+ ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),
1626
+ }, sessionAffinityHeaders, model.headers, optionsHeaders));
1627
+ if (federation) {
1628
+ const key = JSON.stringify([model.baseUrl, federation]);
1629
+ if (federationClient?.key !== key || federationClient.fetch !== fetch) {
1630
+ const client = new PiAnthropic({
1631
+ apiKey: null,
1632
+ authToken: null,
1633
+ config: federation,
1634
+ baseURL: model.baseUrl,
1635
+ dangerouslyAllowBrowser: true,
1636
+ fetch,
1637
+ });
1638
+ federationClient = { key, fetch, client };
1639
+ }
1640
+ return { client: federationClient.client.withOptions({ defaultHeaders }), isOAuthToken: false };
1641
+ }
1642
+ const client = new PiAnthropic({
1551
1643
  apiKey: apiKey ?? null,
1552
1644
  authToken: null,
1553
1645
  baseURL: model.baseUrl,
1554
1646
  dangerouslyAllowBrowser: true,
1555
1647
  fetch,
1556
- defaultHeaders: sanitizeAdaptiveThinkingHeaders(model, mergeClientHeaders(model, {
1557
- accept: "application/json",
1558
- "anthropic-dangerous-direct-browser-access": "true",
1559
- ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),
1560
- }, sessionAffinityHeaders, model.headers, optionsHeaders)),
1648
+ defaultHeaders,
1561
1649
  });
1562
1650
  return { client, isOAuthToken: false };
1563
1651
  }
@@ -2142,11 +2230,47 @@ function insertThinkingLevelMessages(converted, activeEffort) {
2142
2230
  function shouldUseFineGrainedToolStreamingBeta(model, context) {
2143
2231
  return getCurrentTools(context.messages).length > 0 && !getAnthropicCompat(model).supportsEagerToolInputStreaming;
2144
2232
  }
2233
+ // Keywords Anthropic strict tool use rejects with a 400 for the whole request.
2234
+ // https://platform.claude.com/docs/en/build-with-claude/structured-outputs#json-schema-limitations
2235
+ const ANTHROPIC_STRICT_UNSUPPORTED_KEYWORDS = new Set([
2236
+ "minimum",
2237
+ "maximum",
2238
+ "exclusiveMinimum",
2239
+ "exclusiveMaximum",
2240
+ "multipleOf",
2241
+ "maxItems",
2242
+ "uniqueItems",
2243
+ "minContains",
2244
+ "maxContains",
2245
+ "minProperties",
2246
+ "maxProperties",
2247
+ ]);
2248
+ const ANTHROPIC_STRICT_STRING_FORMATS = new Set([
2249
+ "date-time",
2250
+ "time",
2251
+ "date",
2252
+ "duration",
2253
+ "email",
2254
+ "hostname",
2255
+ "uri",
2256
+ "ipv4",
2257
+ "ipv6",
2258
+ "uuid",
2259
+ ]);
2260
+ const isAnthropicStrictUnsupportedKeyword = (key, value) => {
2261
+ if (ANTHROPIC_STRICT_UNSUPPORTED_KEYWORDS.has(key))
2262
+ return true;
2263
+ if (key === "minItems")
2264
+ return value !== 0 && value !== 1;
2265
+ if (key === "format")
2266
+ return typeof value !== "string" || !ANTHROPIC_STRICT_STRING_FORMATS.has(value);
2267
+ return false;
2268
+ };
2145
2269
  function convertTools(tools, isOAuthToken, supportsEagerToolInputStreaming, supportsStrictTools, cacheControl, deferLoading = false) {
2146
2270
  if (!tools)
2147
2271
  return [];
2148
2272
  return tools.map((tool, index) => {
2149
- const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictTools);
2273
+ const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictTools, isAnthropicStrictUnsupportedKeyword);
2150
2274
  const parameters = getJsonSchemaToolParameters(tool, strict);
2151
2275
  // A root union carries no top-level properties, so reading them directly
2152
2276
  // would advertise the tool to the model as taking no arguments at all.
@@ -1,3 +1,17 @@
1
1
  import type { MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/beta/messages/messages.js";
2
2
  export declare function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): MessageCreateParamsStreaming;
3
+ /**
4
+ * Some Anthropic-compatible endpoints run a native tool search inside one request
5
+ * but reject every `tool_reference` replayed from history once the request
6
+ * carries more than a handful of tools: "Tool reference '<name>' not found in
7
+ * available tools" even though `tools` defines the name (senpi #2568). The
8
+ * request is then retried with the replay demoted to text: a native search pair
9
+ * (`server_tool_use` plus its `tool_search_tool_result`, which may sit in a later
10
+ * assistant message) becomes a note naming the tools it found, and
11
+ * `tool_reference` items inside a client `tool_result` become one text item.
12
+ * A deferred tool is loaded only by a replayed reference, so every tool the demoted
13
+ * references named is sent without `defer_loading`: it stays callable without another
14
+ * search. Returns `params` unchanged when the history replays no reference.
15
+ */
16
+ export declare function demoteToolReferenceReplay(params: MessageCreateParamsStreaming): MessageCreateParamsStreaming;
3
17
  //# sourceMappingURL=anthropic-tool-references.d.ts.map
@@ -183,6 +183,113 @@ export function demoteUnavailableToolReferences(params) {
183
183
  return params;
184
184
  return { ...params, messages: rewrittenMessages };
185
185
  }
186
+ /**
187
+ * Some Anthropic-compatible endpoints run a native tool search inside one request
188
+ * but reject every `tool_reference` replayed from history once the request
189
+ * carries more than a handful of tools: "Tool reference '<name>' not found in
190
+ * available tools" even though `tools` defines the name (senpi #2568). The
191
+ * request is then retried with the replay demoted to text: a native search pair
192
+ * (`server_tool_use` plus its `tool_search_tool_result`, which may sit in a later
193
+ * assistant message) becomes a note naming the tools it found, and
194
+ * `tool_reference` items inside a client `tool_result` become one text item.
195
+ * A deferred tool is loaded only by a replayed reference, so every tool the demoted
196
+ * references named is sent without `defer_loading`: it stays callable without another
197
+ * search. Returns `params` unchanged when the history replays no reference.
198
+ */
199
+ export function demoteToolReferenceReplay(params) {
200
+ const messages = params.messages;
201
+ if (!Array.isArray(messages) || messages.length === 0)
202
+ return params;
203
+ const searchUseIds = new Set();
204
+ for (const message of messages) {
205
+ if (message.role !== "assistant" || !Array.isArray(message.content))
206
+ continue;
207
+ for (const block of message.content) {
208
+ if (isRecord(block) && block.type === "tool_search_tool_result" && typeof block.tool_use_id === "string") {
209
+ searchUseIds.add(block.tool_use_id);
210
+ }
211
+ }
212
+ }
213
+ let changed = false;
214
+ const demotedNames = new Set();
215
+ const rewrittenMessages = [];
216
+ for (const message of messages) {
217
+ if (!Array.isArray(message.content)) {
218
+ rewrittenMessages.push(message);
219
+ continue;
220
+ }
221
+ let messageChanged = false;
222
+ const content = [];
223
+ for (const block of message.content) {
224
+ if (message.role === "assistant" && isRecord(block)) {
225
+ if (block.type === "server_tool_use" && typeof block.id === "string" && searchUseIds.has(block.id)) {
226
+ messageChanged = true;
227
+ continue;
228
+ }
229
+ if (block.type === "tool_search_tool_result") {
230
+ messageChanged = true;
231
+ const names = isNativeToolSearchResultBlock(block)
232
+ ? toolReferenceNames(block.content.tool_references)
233
+ : [];
234
+ for (const name of names)
235
+ demotedNames.add(name);
236
+ content.push({
237
+ type: "text",
238
+ text: names.length > 0 ? `Tool search found: ${names.join(", ")}` : "Tool search found no tools.",
239
+ });
240
+ continue;
241
+ }
242
+ }
243
+ if (isRecord(block) && block.type === "tool_result" && Array.isArray(block.content)) {
244
+ const names = toolReferenceNames(block.content);
245
+ if (names.length > 0) {
246
+ messageChanged = true;
247
+ for (const name of names)
248
+ demotedNames.add(name);
249
+ const kept = block.content.filter((item) => !(isRecord(item) && item.type === "tool_reference"));
250
+ content.push({
251
+ ...block,
252
+ content: [...kept, { type: "text", text: `Tools loaded: ${names.join(", ")}` }],
253
+ });
254
+ continue;
255
+ }
256
+ }
257
+ content.push(block);
258
+ }
259
+ if (!messageChanged) {
260
+ rewrittenMessages.push(message);
261
+ continue;
262
+ }
263
+ changed = true;
264
+ if (content.length > 0)
265
+ rewrittenMessages.push({ ...message, content });
266
+ }
267
+ if (!changed)
268
+ return params;
269
+ return { ...params, messages: rewrittenMessages, ...residentTools(params.tools, demotedNames) };
270
+ }
271
+ /** Drops `defer_loading` from the tools a demoted reference named; the rest keep their deferral. */
272
+ function residentTools(tools, names) {
273
+ if (!Array.isArray(tools) || names.size === 0)
274
+ return {};
275
+ let changed = false;
276
+ const rewritten = tools.map((tool) => {
277
+ if (!isRecord(tool) || tool.defer_loading !== true || typeof tool.name !== "string" || !names.has(tool.name))
278
+ return tool;
279
+ changed = true;
280
+ const { defer_loading: _deferLoading, ...resident } = tool;
281
+ return resident;
282
+ });
283
+ return changed ? { tools: rewritten } : {};
284
+ }
285
+ function toolReferenceNames(items) {
286
+ const names = new Set();
287
+ for (const item of items) {
288
+ if (isRecord(item) && item.type === "tool_reference" && typeof item.tool_name === "string")
289
+ names.add(item.tool_name);
290
+ }
291
+ return [...names];
292
+ }
186
293
  /**
187
294
  * Folds every `tool_reference` item in `items` onto the request's own tool
188
295
  * name and drops the ones that still do not resolve. Returns undefined when
@@ -1,6 +1,8 @@
1
1
  import type { Tool } from "../types.ts";
2
+ /** Returns true when a provider's strict mode rejects this schema keyword with this value. */
3
+ export type UnsupportedStrictSchemaKeywordCheck = (key: string, value: unknown) => boolean;
2
4
  /** Convert a tool schema to the strict subset expected by provider constrained sampling. */
3
- export declare function makeStrictJsonSchema(schema: Tool["parameters"]): Record<string, unknown>;
5
+ export declare function makeStrictJsonSchema(schema: Tool["parameters"], isUnsupportedKeyword?: UnsupportedStrictSchemaKeywordCheck): Record<string, unknown>;
4
6
  export declare function getJsonSchemaToolParameters(tool: Tool, strict: boolean | undefined): Tool["parameters"];
5
7
  export interface GrammarConstrainedSampling {
6
8
  format: "lark" | "regex";
@@ -14,7 +16,11 @@ export interface GrammarToolInputJsonBuffer {
14
16
  }
15
17
  export declare function getGrammarToolInput(toolName: string, arguments_: Record<string, unknown>, inputProperty: string): string;
16
18
  export declare function appendGrammarToolInputJsonDelta(buffer: GrammarToolInputJsonBuffer, inputProperty: string, nextInput: string, close: boolean): string | undefined;
17
- export declare function resolveJsonSchemaStrictSampling(tool: Tool, supportsStrictMode: boolean): boolean | undefined;
19
+ /**
20
+ * Decide whether a JSON-schema tool is sent in strict mode. `isUnsupportedKeyword` lets a provider
21
+ * reject extra keywords its strict mode does not accept, so "prefer" tools fall back to non-strict.
22
+ */
23
+ export declare function resolveJsonSchemaStrictSampling(tool: Tool, supportsStrictMode: boolean, isUnsupportedKeyword?: UnsupportedStrictSchemaKeywordCheck): boolean | undefined;
18
24
  export declare function resolveGrammarConstrainedSampling(tool: Tool, supportsOpenAIGrammarTools: boolean): GrammarConstrainedSampling | undefined;
19
25
  export declare function createGrammarToolInputProperties(tools: Tool[] | undefined, supportsOpenAIGrammarTools: boolean): ReadonlyMap<string, string>;
20
26
  //# sourceMappingURL=constrained-sampling.d.ts.map
@@ -39,7 +39,7 @@ function schemaAllowsNull(schema) {
39
39
  return true;
40
40
  return Array.isArray(schema.anyOf) && schema.anyOf.some((variant) => schemaAllowsNull(variant));
41
41
  }
42
- function makeJsonSchemaNodeStrict(schema) {
42
+ function makeJsonSchemaNodeStrict(schema, isUnsupportedKeyword) {
43
43
  if (!isJsonSchemaObject(schema)) {
44
44
  throw new UnsupportedStrictJsonSchemaError("boolean schemas are unsupported");
45
45
  }
@@ -48,6 +48,13 @@ function makeJsonSchemaNodeStrict(schema) {
48
48
  throw new UnsupportedStrictJsonSchemaError(`${key} schemas are unsupported`);
49
49
  }
50
50
  }
51
+ if (isUnsupportedKeyword) {
52
+ for (const [key, value] of Object.entries(schema)) {
53
+ if (isUnsupportedKeyword(key, value)) {
54
+ throw new UnsupportedStrictJsonSchemaError(`${key}: ${JSON.stringify(value)} is unsupported`);
55
+ }
56
+ }
57
+ }
51
58
  if (schema.anyOf !== undefined) {
52
59
  if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) {
53
60
  throw new UnsupportedStrictJsonSchemaError("anyOf must contain at least one schema");
@@ -56,14 +63,14 @@ function makeJsonSchemaNodeStrict(schema) {
56
63
  if (isStructuredSchema(variant)) {
57
64
  throw new UnsupportedStrictJsonSchemaError("object and array unions are unsupported");
58
65
  }
59
- makeJsonSchemaNodeStrict(variant);
66
+ makeJsonSchemaNodeStrict(variant, isUnsupportedKeyword);
60
67
  }
61
68
  }
62
69
  if (schema.items !== undefined) {
63
70
  if (Array.isArray(schema.items)) {
64
71
  throw new UnsupportedStrictJsonSchemaError("tuple schemas are unsupported");
65
72
  }
66
- makeJsonSchemaNodeStrict(schema.items);
73
+ makeJsonSchemaNodeStrict(schema.items, isUnsupportedKeyword);
67
74
  }
68
75
  const isObjectSchema = schema.type === "object";
69
76
  if (schema.properties !== undefined && !isObjectSchema) {
@@ -88,7 +95,7 @@ function makeJsonSchemaNodeStrict(schema) {
88
95
  throw new UnsupportedStrictJsonSchemaError("required contains an unknown property");
89
96
  }
90
97
  for (const [key, property] of Object.entries(properties)) {
91
- makeJsonSchemaNodeStrict(property);
98
+ makeJsonSchemaNodeStrict(property, isUnsupportedKeyword);
92
99
  if (!required.has(key) && !schemaAllowsNull(property)) {
93
100
  properties[key] = { anyOf: [property, { type: "null" }] };
94
101
  }
@@ -97,12 +104,12 @@ function makeJsonSchemaNodeStrict(schema) {
97
104
  schema.additionalProperties = false;
98
105
  }
99
106
  /** Convert a tool schema to the strict subset expected by provider constrained sampling. */
100
- export function makeStrictJsonSchema(schema) {
107
+ export function makeStrictJsonSchema(schema, isUnsupportedKeyword) {
101
108
  const cloned = structuredClone(schema);
102
109
  if (!isJsonSchemaObject(cloned)) {
103
110
  throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
104
111
  }
105
- makeJsonSchemaNodeStrict(cloned);
112
+ makeJsonSchemaNodeStrict(cloned, isUnsupportedKeyword);
106
113
  if (cloned.type !== "object") {
107
114
  throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
108
115
  }
@@ -160,13 +167,17 @@ function inferGrammarInputProperty(tool) {
160
167
  }
161
168
  return inputProperty;
162
169
  }
163
- export function resolveJsonSchemaStrictSampling(tool, supportsStrictMode) {
170
+ /**
171
+ * Decide whether a JSON-schema tool is sent in strict mode. `isUnsupportedKeyword` lets a provider
172
+ * reject extra keywords its strict mode does not accept, so "prefer" tools fall back to non-strict.
173
+ */
174
+ export function resolveJsonSchemaStrictSampling(tool, supportsStrictMode, isUnsupportedKeyword) {
164
175
  const config = tool.constrainedSampling;
165
176
  if (config === false || config?.type !== "json_schema")
166
177
  return undefined;
167
178
  if (supportsStrictMode) {
168
179
  try {
169
- makeStrictJsonSchema(tool.parameters);
180
+ makeStrictJsonSchema(tool.parameters, isUnsupportedKeyword);
170
181
  return true;
171
182
  }
172
183
  catch (error) {
@@ -337,12 +337,14 @@ export function convertResponsesMessages(model, context, allowedToolCallProvider
337
337
  // An active grammar declaration wins over sentinel recovery below: its
338
338
  // named input property is richer than the persisted freeform fallback.
339
339
  // For different-model messages, set id to undefined to avoid pairing validation.
340
- // OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items.
340
+ // OpenAI tracks which item IDs were paired with rs_xxx reasoning items.
341
341
  // By omitting the id, we avoid triggering that validation (like cross-provider does).
342
- // Function-call item ids must begin with fc_ while freeform calls can replay
343
- // without the local <call_id>|custom sentinel.
344
- if ((isDifferentModel && itemId?.startsWith("fc_")) ||
345
- (!isFreeform && customInputProperty === undefined && !itemId?.startsWith("fc_"))) {
342
+ // Also drop ids that do not match the replayed item type: function_call ids must be fc_*
343
+ // and custom_tool_call ids must be ctc_*. Foreign tool call ids are normalized to fc_*, and
344
+ // a call can switch between the two types when grammar tool support differs. Freeform
345
+ // calls replay without an item id and without the local <call_id>|custom sentinel.
346
+ const itemIdPrefix = customInputProperty === undefined ? "fc_" : "ctc_";
347
+ if (isDifferentModel || !itemId?.startsWith(itemIdPrefix)) {
346
348
  itemId = undefined;
347
349
  }
348
350
  const canReplayNamespace = isSameModel || toolPlacement.deferred.has(toolCall.name);
@@ -14,6 +14,8 @@ export interface ApiProviderInternal {
14
14
  export type RegisteredApiProvider = {
15
15
  provider: ApiProviderInternal;
16
16
  sourceId?: string;
17
+ /** Kept by `clearApiProviders()`; removed only through `unregisterApiProviders(sourceId)`. */
18
+ survivesClear?: boolean;
17
19
  };
18
20
  /** Browser-neutral shape installed by the node-only provider-scope subpath. */
19
21
  export interface ProviderScopeAccess {
@@ -23,7 +25,9 @@ export interface ProviderScopeAccess {
23
25
  /** Installs the optional node-only scope lookup without importing node APIs here. */
24
26
  export declare function installProviderScopeAccessor(accessor: () => ProviderScopeAccess | undefined): void;
25
27
  export declare function setProviderScopeStrictMode(enabled: boolean): void;
26
- export declare function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(provider: ApiProvider<TApi, TOptions>, sourceId?: string): void;
28
+ export declare function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(provider: ApiProvider<TApi, TOptions>, sourceId?: string, options?: {
29
+ survivesClear?: boolean;
30
+ }): void;
27
31
  /** Registers a builtin once, retaining its immutable identity for active scopes. */
28
32
  export declare function registerBuiltinApiProvider<TApi extends Api, TOptions extends StreamOptions>(provider: ApiProvider<TApi, TOptions>): void;
29
33
  export declare function getBuiltinApiProvider(api: Api): ApiProviderInternal | undefined;