@code-yeongyu/senpi-ai 2026.10.10 → 2026.10.12

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 (68) hide show
  1. package/dist/api/anthropic-messages.js +148 -72
  2. package/dist/api/anthropic-native-replay-blocks.d.ts +4 -0
  3. package/dist/api/anthropic-native-replay-blocks.js +28 -0
  4. package/dist/api/anthropic-tool-references.js +24 -18
  5. package/dist/api/anthropic-web-search-expiry.d.ts +10 -0
  6. package/dist/api/anthropic-web-search-expiry.js +104 -0
  7. package/dist/api/bedrock-converse-stream.js +5 -1
  8. package/dist/api/google-shared.js +101 -21
  9. package/dist/api/lazy.js +13 -0
  10. package/dist/api/mistral-conversations.js +18 -3
  11. package/dist/api/openai-codex-responses.js +2 -2
  12. package/dist/api/openai-completions.js +26 -2
  13. package/dist/api/openai-responses-allowed-tools.d.ts +33 -0
  14. package/dist/api/openai-responses-allowed-tools.js +108 -0
  15. package/dist/api/openai-responses-shared.js +19 -0
  16. package/dist/api/openai-responses.js +48 -59
  17. package/dist/auth/oauth/anthropic.js +4 -3
  18. package/dist/auth/oauth/chatgpt-subscription.js +3 -2
  19. package/dist/auth/oauth/cursor.js +3 -2
  20. package/dist/auth/oauth/devin-token.js +2 -1
  21. package/dist/auth/oauth/github-copilot.js +3 -2
  22. package/dist/auth/oauth/kimi-coding.js +5 -4
  23. package/dist/auth/oauth/openai-chatgpt.js +2 -1
  24. package/dist/auth/oauth/openrouter.js +2 -1
  25. package/dist/auth/oauth/radius.js +5 -5
  26. package/dist/auth/oauth/xai.js +7 -4
  27. package/dist/auth/oauth-refresh.js +10 -0
  28. package/dist/auth/resolve.js +5 -0
  29. package/dist/model.d.ts +6 -2
  30. package/dist/openai-responses-compat.d.ts +7 -2
  31. package/dist/openai-responses-compat.js +15 -2
  32. package/dist/providers/all.d.ts +1 -1
  33. package/dist/providers/data/.manifest.json +1 -1
  34. package/dist/providers/data/amazon-bedrock.json +1 -1
  35. package/dist/providers/data/anthropic.json +1 -1
  36. package/dist/providers/data/baseten.json +1 -1
  37. package/dist/providers/data/chatgpt-subscription.json +1 -1
  38. package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  39. package/dist/providers/data/cloudflare-workers-ai.json +1 -1
  40. package/dist/providers/data/github-copilot.json +1 -1
  41. package/dist/providers/data/google-vertex.json +1 -1
  42. package/dist/providers/data/huggingface.json +1 -1
  43. package/dist/providers/data/mistral.json +1 -1
  44. package/dist/providers/data/moonshotai-cn.json +1 -1
  45. package/dist/providers/data/moonshotai.json +1 -1
  46. package/dist/providers/data/opencode-go.json +1 -1
  47. package/dist/providers/data/opencode.json +1 -1
  48. package/dist/providers/data/opengateway.json +1 -1
  49. package/dist/providers/data/openrouter.json +1 -1
  50. package/dist/providers/data/radius.json +1 -1
  51. package/dist/providers/data/venice.json +1 -1
  52. package/dist/providers/data/vercel-ai-gateway.json +1 -1
  53. package/dist/providers/kimi-coding.models.d.ts +1 -1
  54. package/dist/providers/kimi-coding.models.js +1 -1
  55. package/dist/providers/opengateway-refresh.js +2 -1
  56. package/dist/providers/xai.js +7 -1
  57. package/dist/types.d.ts +25 -2
  58. package/dist/utils/oauth-refresh-error.d.ts +16 -0
  59. package/dist/utils/oauth-refresh-error.js +67 -0
  60. package/dist/utils/prompt-cache-ttl.d.ts +2 -1
  61. package/dist/utils/prompt-cache-ttl.js +4 -1
  62. package/dist/utils/provider-failure-description.d.ts +3 -1
  63. package/dist/utils/provider-failure-description.js +8 -0
  64. package/dist/utils/retry.d.ts +6 -0
  65. package/dist/utils/retry.js +10 -0
  66. package/dist/utils/tool-choice-fallback.d.ts +11 -0
  67. package/dist/utils/tool-choice-fallback.js +69 -2
  68. package/package.json +3 -3
@@ -590,6 +590,7 @@ function supportsAdaptiveThinking(modelId, modelName) {
590
590
  s.includes("opus-5") ||
591
591
  s.includes("sonnet-4-6") ||
592
592
  s.includes("sonnet-5") ||
593
+ s.includes("haiku-5-5") ||
593
594
  s.includes("fable-5") ||
594
595
  s.includes("mythos-5"));
595
596
  }
@@ -599,6 +600,7 @@ function supportsNativeXhighEffort(model) {
599
600
  s.includes("opus-4-8") ||
600
601
  s.includes("opus-5") ||
601
602
  s.includes("sonnet-5") ||
603
+ s.includes("haiku-5-5") ||
602
604
  s.includes("fable-5") ||
603
605
  s.includes("mythos-5"));
604
606
  }
@@ -616,7 +618,9 @@ function rejectsDisabledThinking(model) {
616
618
  s.includes("opus-5-5") ||
617
619
  s.includes("opus-5.5") ||
618
620
  s.includes("sonnet-5-5") ||
619
- s.includes("sonnet-5.5"));
621
+ s.includes("sonnet-5.5") ||
622
+ s.includes("haiku-5-5") ||
623
+ s.includes("haiku-5.5"));
620
624
  }
621
625
  function mapThinkingLevelToEffort(model, level) {
622
626
  if (level === "xhigh" && supportsNativeXhighEffort(model))
@@ -127,6 +127,17 @@ export function requiresToolCallId(modelId) {
127
127
  modelId.startsWith("gpt-oss-") ||
128
128
  (geminiMajorVersion !== undefined && geminiMajorVersion >= 3));
129
129
  }
130
+ /**
131
+ * Gemini 3+ strictly validates replayed tool calls: the first functionCall part of every step
132
+ * in the current turn must carry the thoughtSignature the model returned, or the API answers
133
+ * 400 "Function call is missing a thought_signature in functionCall parts" (Vertex and AI
134
+ * Studio both; enforced even at MINIMAL thinking). See:
135
+ * https://docs.cloud.google.com/vertex-ai/generative-ai/docs/thought-signatures
136
+ */
137
+ function requiresThoughtSignatureReplay(modelId) {
138
+ const geminiMajorVersion = getGeminiMajorVersion(modelId);
139
+ return geminiMajorVersion !== undefined && geminiMajorVersion >= 3;
140
+ }
130
141
  function getGeminiMajorVersion(modelId) {
131
142
  const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/);
132
143
  if (!match)
@@ -155,6 +166,25 @@ function appendContent(contents, content) {
155
166
  }
156
167
  contents.push(content);
157
168
  }
169
+ // The current turn starts after the last user message that carries content. An image-only
170
+ // user message (a pasted screenshot with no caption) starts a new turn exactly like a text
171
+ // prompt does: assistant steps after it are current-turn steps. Empty user messages carry
172
+ // no content and never act as the boundary.
173
+ function findLastUserContentMessageIndex(messages) {
174
+ for (let i = messages.length - 1; i >= 0; i--) {
175
+ const msg = messages[i];
176
+ if (msg.role !== "user")
177
+ continue;
178
+ if (typeof msg.content === "string") {
179
+ if (msg.content.trim().length > 0)
180
+ return i;
181
+ }
182
+ else if (msg.content.some((item) => (item.type === "text" ? item.text.trim().length > 0 : true))) {
183
+ return i;
184
+ }
185
+ }
186
+ return -1;
187
+ }
158
188
  /**
159
189
  * Convert internal messages to Gemini Content[] format.
160
190
  */
@@ -162,6 +192,9 @@ export function convertMessages(model, context, options = {}) {
162
192
  // Gemini has no mid-conversation system messages; the leading prompt is sent as systemInstruction.
163
193
  const conversation = withoutInitialSystemMessage(collapseSystemMessages(context).messages);
164
194
  const contents = [];
195
+ // Tool-call blocks replayed as text under a strict-signature model; their paired tool
196
+ // results must follow the same shape so no unpaired functionResponse part is emitted.
197
+ const textToolCallIds = new Set();
165
198
  const normalizeId = (id) => {
166
199
  if (!requiresToolCallId(model.id))
167
200
  return id;
@@ -170,7 +203,9 @@ export function convertMessages(model, context, options = {}) {
170
203
  const transformedMessages = transformMessages(conversation, model, normalizeId, {
171
204
  preserveThinking: options.preserveThinking,
172
205
  });
173
- for (const msg of transformedMessages) {
206
+ const lastUserContentIndex = findLastUserContentMessageIndex(transformedMessages);
207
+ for (let msgIndex = 0; msgIndex < transformedMessages.length; msgIndex++) {
208
+ const msg = transformedMessages[msgIndex];
174
209
  if (msg.role === "user") {
175
210
  if (typeof msg.content === "string") {
176
211
  appendContent(contents, {
@@ -204,6 +239,17 @@ export function convertMessages(model, context, options = {}) {
204
239
  const parts = [];
205
240
  // Check if message is from same provider and model - only then keep thinking blocks
206
241
  const isSameProviderAndModel = msg.provider === model.provider && msg.model === model.id;
242
+ // Strict validation (requiring a thoughtSignature on the first functionCall part) applies
243
+ // to the current turn — assistant steps after the last user message carrying content.
244
+ // Earlier turns preserve structured function calls. In the current turn, if the first
245
+ // toolCall block lacks a valid signature, the whole step is replayed as text, because
246
+ // the documented skip_thought_signature_validator sentinel is rejected by Vertex (pi-mono #4032)
247
+ // and an unsigned functionCall part is a hard 400 on Gemini 3+.
248
+ const isCurrentTurn = msgIndex > lastUserContentIndex;
249
+ const firstToolCall = msg.content.find((b) => b.type === "toolCall");
250
+ const hasValidFirstSignature = firstToolCall !== undefined &&
251
+ resolveThoughtSignature(isSameProviderAndModel, firstToolCall.thoughtSignature) !== undefined;
252
+ const replayUnsignedToolCallsAsText = requiresThoughtSignatureReplay(model.id) && isCurrentTurn && !hasValidFirstSignature;
207
253
  for (const block of msg.content) {
208
254
  if (block.type === "text") {
209
255
  const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.textSignature);
@@ -243,16 +289,24 @@ export function convertMessages(model, context, options = {}) {
243
289
  }
244
290
  }
245
291
  else if (block.type === "toolCall") {
246
- const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
247
- const part = {
248
- functionCall: {
249
- name: block.name,
250
- args: block.arguments ?? {},
251
- ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
252
- },
253
- ...(thoughtSignature && { thoughtSignature }),
254
- };
255
- parts.push(part);
292
+ if (replayUnsignedToolCallsAsText) {
293
+ textToolCallIds.add(block.id);
294
+ parts.push({
295
+ text: sanitizeSurrogates(`[Tool Call: ${block.name} (id: ${block.id})]\nArguments: ${JSON.stringify(block.arguments ?? {}, null, 2)}`),
296
+ });
297
+ }
298
+ else {
299
+ const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
300
+ const part = {
301
+ functionCall: {
302
+ name: block.name,
303
+ args: block.arguments ?? {},
304
+ ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
305
+ },
306
+ ...(thoughtSignature && { thoughtSignature }),
307
+ };
308
+ parts.push(part);
309
+ }
256
310
  }
257
311
  else if (block.type === "providerNative") {
258
312
  }
@@ -268,9 +322,41 @@ export function convertMessages(model, context, options = {}) {
268
322
  // Extract text and image content
269
323
  const textContent = msg.content.filter((c) => c.type === "text");
270
324
  const textResult = textContent.map((c) => c.text).join("\n");
271
- const imageContent = model.input.includes("image")
272
- ? msg.content.filter((c) => c.type === "image")
273
- : [];
325
+ const allImageBlocks = msg.content.filter((c) => c.type === "image");
326
+ const modelAcceptsImages = model.input.includes("image");
327
+ const imageContent = modelAcceptsImages ? allImageBlocks : [];
328
+ const imageParts = imageContent.map((imageBlock) => ({
329
+ inlineData: {
330
+ mimeType: imageBlock.mimeType,
331
+ data: imageBlock.data,
332
+ },
333
+ }));
334
+ const sanitizedToolName = sanitizeSurrogates(msg.toolName);
335
+ // A tool call replayed as text pairs with its result as text: an unpaired
336
+ // functionResponse part would break the model turn's call/response pairing.
337
+ if (textToolCallIds.has(msg.toolCallId)) {
338
+ // Unsupported tool images never arrive here as image blocks: transformMessages already
339
+ // replaced them with placeholder text for non-vision models, so an image-only result on
340
+ // such a model reaches this branch as text. A vision model's image-only result keeps an
341
+ // empty body and pairs with the inlineData parts appended below.
342
+ let bodyText = "";
343
+ if (textResult.length > 0) {
344
+ bodyText = `\n${sanitizeSurrogates(textResult)}`;
345
+ }
346
+ else if (allImageBlocks.length === 0) {
347
+ bodyText = "\n(no output)";
348
+ }
349
+ const errorMarker = msg.isError ? " (error)" : "";
350
+ const callIdPart = ` (id: ${msg.toolCallId})`;
351
+ appendContent(contents, {
352
+ role: "user",
353
+ parts: [
354
+ { text: `[Tool Result: ${sanitizedToolName}${callIdPart}]${errorMarker}${bodyText}` },
355
+ ...imageParts,
356
+ ],
357
+ });
358
+ continue;
359
+ }
274
360
  const hasText = textResult.length > 0;
275
361
  const hasImages = imageContent.length > 0;
276
362
  // Gemini 3+ models support multimodal function responses with images nested inside
@@ -279,16 +365,10 @@ export function convertMessages(model, context, options = {}) {
279
365
  const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id);
280
366
  // Use "output" key for success, "error" key for errors as per SDK documentation
281
367
  const responseValue = hasText ? sanitizeSurrogates(textResult) : hasImages ? "(see attached image)" : "";
282
- const imageParts = imageContent.map((imageBlock) => ({
283
- inlineData: {
284
- mimeType: imageBlock.mimeType,
285
- data: imageBlock.data,
286
- },
287
- }));
288
368
  const includeId = requiresToolCallId(model.id);
289
369
  const functionResponsePart = {
290
370
  functionResponse: {
291
- name: msg.toolName,
371
+ name: sanitizedToolName,
292
372
  response: msg.isError ? { error: responseValue } : { output: responseValue },
293
373
  ...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }),
294
374
  ...(includeId ? { id: msg.toolCallId } : {}),
package/dist/api/lazy.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
2
+ import { isOAuthRefreshUnavailableError } from "../utils/oauth-refresh-error.js";
2
3
  import { describeReplacedInstall } from "../utils/provider-failure-description.js";
4
+ import { OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC } from "../utils/retry.js";
3
5
  function createSetupErrorMessage(model, error) {
4
6
  return {
5
7
  role: "assistant",
@@ -17,6 +19,17 @@ function createSetupErrorMessage(model, error) {
17
19
  },
18
20
  stopReason: "error",
19
21
  errorMessage: describeReplacedInstall(error) ?? (error instanceof Error ? error.message : String(error)),
22
+ ...(isOAuthRefreshUnavailableError(error)
23
+ ? {
24
+ diagnostics: [
25
+ {
26
+ type: OAUTH_REFRESH_UNAVAILABLE_DIAGNOSTIC,
27
+ timestamp: Date.now(),
28
+ details: { provider: model.provider },
29
+ },
30
+ ],
31
+ }
32
+ : {}),
20
33
  timestamp: Date.now(),
21
34
  };
22
35
  }
@@ -612,6 +612,21 @@ function stripSymbolKeys(value) {
612
612
  }
613
613
  return value;
614
614
  }
615
+ /**
616
+ * Consecutive user turns become one user message, as the OpenAI Chat converter does for
617
+ * non-OpenAI endpoints: a word message rebuilt after tool results (senpi#2920) is often followed
618
+ * by a steer or the next prompt, and Mistral's role-order validation is not documented to accept
619
+ * two user messages in a row.
620
+ */
621
+ function appendUserMessage(result, content) {
622
+ const previous = result[result.length - 1];
623
+ if (previous?.role !== "user") {
624
+ result.push({ role: "user", content });
625
+ return;
626
+ }
627
+ const chunks = (value) => typeof value === "string" ? [{ type: "text", text: value }] : (value ?? []);
628
+ previous.content = [...chunks(previous.content), ...chunks(content)];
629
+ }
615
630
  function toChatMessages(messages, supportsImages) {
616
631
  const result = [];
617
632
  for (const [index, msg] of messages.entries()) {
@@ -625,7 +640,7 @@ function toChatMessages(messages, supportsImages) {
625
640
  }
626
641
  if (msg.role === "user") {
627
642
  if (typeof msg.content === "string") {
628
- result.push({ role: "user", content: sanitizeSurrogates(msg.content) });
643
+ appendUserMessage(result, sanitizeSurrogates(msg.content));
629
644
  continue;
630
645
  }
631
646
  const hadImages = msg.content.some((item) => item.type === "image");
@@ -637,11 +652,11 @@ function toChatMessages(messages, supportsImages) {
637
652
  return { type: "image_url", imageUrl: `data:${item.mimeType};base64,${item.data}` };
638
653
  });
639
654
  if (content.length > 0) {
640
- result.push({ role: "user", content });
655
+ appendUserMessage(result, content);
641
656
  continue;
642
657
  }
643
658
  if (hadImages && !supportsImages) {
644
- result.push({ role: "user", content: "(image omitted: model does not support images)" });
659
+ appendUserMessage(result, "(image omitted: model does not support images)");
645
660
  }
646
661
  continue;
647
662
  }
@@ -450,9 +450,9 @@ function buildRequestBody(model, context, options, cacheSessionId, grammarToolIn
450
450
  function getServiceTierCostMultiplier(model, serviceTier) {
451
451
  switch (serviceTier) {
452
452
  case "ultrafast":
453
- // OpenAI publishes an Ultrafast price for GPT-6 Astra only: 6x Standard on every
453
+ // OpenAI prices GPT-6 Astra and GPT-6.1 Sol Ultrafast at 6x Standard on every
454
454
  // token class and context tier. Any other model keeps its base rate.
455
- return model.id === "gpt-6-astra" ? 6 : 1;
455
+ return ["gpt-6-astra", "gpt-6.1-sol"].includes(model.upstreamModelId ?? model.id) ? 6 : 1;
456
456
  case "flex":
457
457
  return 0.5;
458
458
  case "priority":
@@ -14,7 +14,7 @@ import { getProviderEnvValue } from "../utils/provider-env.js";
14
14
  import { retryProviderStreamRequest } from "../utils/provider-retry.js";
15
15
  import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
16
16
  import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.js";
17
- import { sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.js";
17
+ import { primeStreamUntilContent, sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.js";
18
18
  import { normalizeToolParametersForMoonshot, normalizeToolParametersForOpenAICompat, } from "../utils/tool-schema-compat.js";
19
19
  import { getCurrentTools, getDeclaredTools, resolveTranscript, resolveTranscriptTools, } from "../utils/transcript.js";
20
20
  import { appendGrammarToolInputJsonDelta, createGrammarToolInputProperties, getGrammarToolInput, getJsonSchemaToolParameters, resolveGrammarConstrainedSampling, resolveJsonSchemaStrictSampling, } from "./constrained-sampling.js";
@@ -169,6 +169,23 @@ function getOpenRouterRawMetadata(error) {
169
169
  function isForcedOpenAICompletionsToolChoice(toolChoice) {
170
170
  return toolChoice !== undefined && toolChoice !== "auto" && toolChoice !== "none";
171
171
  }
172
+ /** A chunk that carries model output or a finish: text, reasoning, a tool call, or a finish_reason. */
173
+ function chatChunkHasContent(chunk) {
174
+ if (!isRecord(chunk) || !Array.isArray(chunk.choices))
175
+ return false;
176
+ return chunk.choices.some((choice) => {
177
+ if (!isRecord(choice))
178
+ return false;
179
+ if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0)
180
+ return true;
181
+ const delta = choice.delta;
182
+ if (!isRecord(delta))
183
+ return false;
184
+ if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0)
185
+ return true;
186
+ return ["content", "reasoning_content", "reasoning", "reasoning_text"].some((field) => typeof delta[field] === "string" && delta[field].length > 0);
187
+ });
188
+ }
172
189
  function isReasoningDetailObject(detail) {
173
190
  return typeof detail === "object" && detail !== null && !Array.isArray(detail);
174
191
  }
@@ -362,7 +379,14 @@ export const stream = (model, context, options) => {
362
379
  params,
363
380
  acceptsForcedToolChoice: compat.supportsForcedToolChoice !== false,
364
381
  isForced: isForcedOpenAICompletionsToolChoice,
365
- send: createStream,
382
+ // A keepalive gateway answers 200 and refuses a forced choice in-band (senpi#2801): a forced
383
+ // request is read up to its first content so that refusal can still be retried.
384
+ send: async (sentParams) => {
385
+ const created = await createStream(sentParams);
386
+ if (!isForcedOpenAICompletionsToolChoice(sentParams.tool_choice))
387
+ return created;
388
+ return { ...created, data: await primeStreamUntilContent(created.data, chatChunkHasContent) };
389
+ },
366
390
  });
367
391
  params = sent.params;
368
392
  return sent.result;
@@ -0,0 +1,33 @@
1
+ import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
2
+ import type { Api, Model, OpenAIResponsesCompat, TranscriptContext } from "../types.ts";
3
+ export type AllowedToolsTarget = Pick<Model<Api>, "api" | "provider" | "baseUrl" | "id">;
4
+ export declare function hasRefusedAllowedToolsChoice(target: AllowedToolsTarget): boolean;
5
+ export declare function rememberAllowedToolsChoiceRefusal(target: AllowedToolsTarget): void;
6
+ export declare function clearAllowedToolsChoiceRefusals(): void;
7
+ /**
8
+ * A refusal of `allowed_tools` as a `tool_choice` type, sent to a request that carried one. Over HTTP it is
9
+ * a 400; a WebSocket error event before any content carries no status, so the caller vouches for it with
10
+ * `beforeContent`. Observed on a Responses-compatible gateway: "Invalid value: 'allowed_tools'. Supported
11
+ * values are: ..." with param `tool_choice.type`.
12
+ */
13
+ export declare function isAllowedToolsChoiceRefusal(error: unknown, params: {
14
+ readonly tool_choice?: unknown;
15
+ }, options?: {
16
+ readonly beforeContent?: boolean;
17
+ }): boolean;
18
+ /**
19
+ * The pre-senpi#2095 request shape for an `allowed_tools` request: top-level function and custom tools
20
+ * outside the allowed list leave `tools`, hosted tools stay, and `tool_choice` goes. Tools that transcript
21
+ * items load in place are not filtered; a call to an inactive one is still refused by the session's active
22
+ * tool set. Any other request is unchanged.
23
+ */
24
+ export declare function restrictToAllowedTools<TParams extends ResponseCreateParamsStreaming>(params: TParams): TParams;
25
+ /**
26
+ * senpi#2095: `tools` carries every tool declared this session, so removing a tool never rewrites the
27
+ * cached prefix; the callable subset rides `tool_choice: allowed_tools` instead. Hosted tools a payload
28
+ * hook added stay callable, and deferred tools (declared by transcript items rather than `tools`) are
29
+ * referenced by name. A declared tool a payload hook removed from `tools` is not referenced (senpi#2234).
30
+ * An empty subset forbids tool calls. An explicit `tool_choice` always wins.
31
+ */
32
+ export declare function applyAllowedToolsChoice<TParams extends ResponseCreateParamsStreaming>(params: TParams, context: TranscriptContext, activeToolNames: readonly string[] | undefined, compat: Required<OpenAIResponsesCompat>): TParams;
33
+ //# sourceMappingURL=openai-responses-allowed-tools.d.ts.map
@@ -0,0 +1,108 @@
1
+ import { isRequestRefusal } from "../utils/tool-choice-fallback.js";
2
+ import { getCurrentTools } from "../utils/transcript.js";
3
+ import { convertResponsesTools, resolveResponsesDeferredToolsMode, resolveResponsesToolPlacement, } from "./openai-responses-shared.js";
4
+ /**
5
+ * Models whose endpoint refused `tool_choice: allowed_tools` and then accepted the same request
6
+ * restricted to the active tools, for the life of the process (senpi#3080).
7
+ */
8
+ const allowedToolsRefusals = new Set();
9
+ function refusalKey(target) {
10
+ return JSON.stringify([target.api, target.provider, target.baseUrl, target.id]);
11
+ }
12
+ export function hasRefusedAllowedToolsChoice(target) {
13
+ return allowedToolsRefusals.has(refusalKey(target));
14
+ }
15
+ export function rememberAllowedToolsChoiceRefusal(target) {
16
+ allowedToolsRefusals.add(refusalKey(target));
17
+ }
18
+ export function clearAllowedToolsChoiceRefusals() {
19
+ allowedToolsRefusals.clear();
20
+ }
21
+ function allowedReference(tool) {
22
+ if (tool.type === "function" || tool.type === "custom")
23
+ return { type: tool.type, name: tool.name };
24
+ if (tool.type === "mcp")
25
+ return { type: "mcp", server_label: tool.server_label };
26
+ return { type: tool.type };
27
+ }
28
+ function allowedToolsChoice(params) {
29
+ const choice = params.tool_choice;
30
+ if (typeof choice !== "object" || choice === null)
31
+ return undefined;
32
+ const { type, tools } = choice;
33
+ return type === "allowed_tools" && Array.isArray(tools) ? tools : undefined;
34
+ }
35
+ function errorMessage(error) {
36
+ return error instanceof Error ? error.message : String(error);
37
+ }
38
+ /**
39
+ * A refusal of `allowed_tools` as a `tool_choice` type, sent to a request that carried one. Over HTTP it is
40
+ * a 400; a WebSocket error event before any content carries no status, so the caller vouches for it with
41
+ * `beforeContent`. Observed on a Responses-compatible gateway: "Invalid value: 'allowed_tools'. Supported
42
+ * values are: ..." with param `tool_choice.type`.
43
+ */
44
+ export function isAllowedToolsChoiceRefusal(error, params, options = {}) {
45
+ if (allowedToolsChoice(params) === undefined)
46
+ return false;
47
+ if (!options.beforeContent && !isRequestRefusal(error))
48
+ return false;
49
+ const message = errorMessage(error);
50
+ return /\ballowed_tools\b/.test(message) && /invalid|unsupported|not\s+supported/i.test(message);
51
+ }
52
+ /**
53
+ * The pre-senpi#2095 request shape for an `allowed_tools` request: top-level function and custom tools
54
+ * outside the allowed list leave `tools`, hosted tools stay, and `tool_choice` goes. Tools that transcript
55
+ * items load in place are not filtered; a call to an inactive one is still refused by the session's active
56
+ * tool set. Any other request is unchanged.
57
+ */
58
+ export function restrictToAllowedTools(params) {
59
+ const allowed = allowedToolsChoice(params);
60
+ if (allowed === undefined)
61
+ return params;
62
+ const allowedNames = new Set(allowed.map((tool) => tool.name).filter((name) => typeof name === "string"));
63
+ const tools = (params.tools ?? []).filter((tool) => (tool.type !== "function" && tool.type !== "custom") || allowedNames.has(tool.name));
64
+ const restricted = { ...params, tools };
65
+ delete restricted.tool_choice;
66
+ return restricted;
67
+ }
68
+ /**
69
+ * senpi#2095: `tools` carries every tool declared this session, so removing a tool never rewrites the
70
+ * cached prefix; the callable subset rides `tool_choice: allowed_tools` instead. Hosted tools a payload
71
+ * hook added stay callable, and deferred tools (declared by transcript items rather than `tools`) are
72
+ * referenced by name. A declared tool a payload hook removed from `tools` is not referenced (senpi#2234).
73
+ * An empty subset forbids tool calls. An explicit `tool_choice` always wins.
74
+ */
75
+ export function applyAllowedToolsChoice(params, context, activeToolNames, compat) {
76
+ if (!compat.supportsAllowedTools || activeToolNames === undefined || params.tool_choice !== undefined) {
77
+ return params;
78
+ }
79
+ const active = new Set(activeToolNames);
80
+ const declaredTools = getCurrentTools(context.messages);
81
+ if (declaredTools.every((tool) => active.has(tool.name)))
82
+ return params;
83
+ const allowed = [];
84
+ const namedInTools = new Set();
85
+ for (const tool of params.tools ?? []) {
86
+ if (tool.type === "function" || tool.type === "custom") {
87
+ namedInTools.add(tool.name);
88
+ if (!active.has(tool.name))
89
+ continue;
90
+ }
91
+ allowed.push(allowedReference(tool));
92
+ }
93
+ // Tools missing from the request-level `tools` placement are the ones transcript items load in place.
94
+ const requestToolNames = new Set(resolveResponsesToolPlacement(context.messages, resolveResponsesDeferredToolsMode(compat) !== undefined).requestTools.map((tool) => tool.name));
95
+ for (const tool of declaredTools) {
96
+ if (namedInTools.has(tool.name) || !active.has(tool.name) || requestToolNames.has(tool.name))
97
+ continue;
98
+ const [converted] = convertResponsesTools([tool], {
99
+ supportsStrictMode: compat.supportsStrictMode,
100
+ supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
101
+ });
102
+ if (converted)
103
+ allowed.push(allowedReference(converted));
104
+ }
105
+ const toolChoice = allowed.length > 0 ? { type: "allowed_tools", mode: "auto", tools: allowed } : "none";
106
+ return { ...params, tool_choice: toolChoice };
107
+ }
108
+ //# sourceMappingURL=openai-responses-allowed-tools.js.map
@@ -322,6 +322,25 @@ export function convertResponsesMessages(model, context, allowedToolCallProvider
322
322
  // Signed foreign blocks with no text are intentionally dropped.
323
323
  }
324
324
  else if (block.type === "providerNative") {
325
+ // Responses accepts its completed web_search_call output items
326
+ // in the next input. Keep them only on the originating route/model;
327
+ // encrypted/native state must never cross provider boundaries.
328
+ // https://developers.openai.com/api/docs/guides/conversation-state
329
+ // Verified against the ChatGPT subscription endpoint for senpi#3122.
330
+ const raw = block.raw;
331
+ if (isSameModel &&
332
+ block.subtype === "web_search_call" &&
333
+ raw !== null &&
334
+ typeof raw === "object" &&
335
+ "type" in raw &&
336
+ raw.type === "web_search_call" &&
337
+ "id" in raw &&
338
+ typeof raw.id === "string" &&
339
+ "action" in raw &&
340
+ "status" in raw &&
341
+ (raw.status === "completed" || raw.status === "failed")) {
342
+ output.push(raw);
343
+ }
325
344
  }
326
345
  else if (block.type === "text") {
327
346
  const textBlock = block;