@code-yeongyu/senpi-ai 2026.10.10-9 → 2026.10.11

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.
@@ -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 } : {}),
@@ -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":
@@ -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
@@ -1,5 +1,6 @@
1
1
  import OpenAI from "openai";
2
2
  import { calculateCost, clampThinkingLevel, inferOpenAIThinkingLevelMap, supportsMax, supportsXhigh, } from "../models.js";
3
+ import { supportsAllowedToolChoice } from "../openai-responses-compat.js";
3
4
  import { readProviderDiagnostic } from "../provider-diagnostic.js";
4
5
  import { formatProviderError, normalizeProviderError } from "../utils/error-body.js";
5
6
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
@@ -10,13 +11,14 @@ import { awaitProviderTransport, openAICompatibleProviderDiagnosticFromError, }
10
11
  import { getProviderEnvValue } from "../utils/provider-env.js";
11
12
  import { retryProviderRequest } from "../utils/provider-retry.js";
12
13
  import { sendWithForcedToolChoiceFallback } from "../utils/tool-choice-fallback.js";
13
- import { getCurrentTools, getDeclaredTools, normalizeContext, resolveTranscript } from "../utils/transcript.js";
14
+ import { getDeclaredTools, normalizeContext, resolveTranscript } from "../utils/transcript.js";
14
15
  import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js";
15
16
  import { createGrammarToolInputProperties } from "./constrained-sampling.js";
16
17
  import { withGitHubCopilotFailureNote } from "./github-copilot-errors.js";
17
18
  import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
18
19
  import { resolveOpenAIClientAuth } from "./openai-client-auth.js";
19
20
  import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
21
+ import { applyAllowedToolsChoice, hasRefusedAllowedToolsChoice, isAllowedToolsChoiceRefusal, rememberAllowedToolsChoiceRefusal, restrictToAllowedTools, } from "./openai-responses-allowed-tools.js";
20
22
  import { findPromptCacheComparisonResponseId, withPromptCacheComparison, } from "./openai-responses-prompt-cache.js";
21
23
  import { convertResponsesMessages, convertResponsesTools, processResponsesStream, resolveResponsesDeferredToolsMode, resolveResponsesToolPlacement, } from "./openai-responses-shared.js";
22
24
  import { buildBaseOptions, clampMaxForOpenAI, OPENAI_RESPONSES_RESERVED_BODY_KEYS } from "./simple-options.js";
@@ -70,7 +72,7 @@ function getCompat(model, env) {
70
72
  supportsConfigurationUpdate: model.compat?.supportsConfigurationUpdate ?? false,
71
73
  supportsMaxOutputTokens: model.compat?.supportsMaxOutputTokens ?? true,
72
74
  supportsForcedToolChoice: model.compat?.supportsForcedToolChoice ?? true,
73
- supportsAllowedTools: model.compat?.supportsAllowedTools ?? false,
75
+ supportsAllowedTools: supportsAllowedToolChoice(model),
74
76
  };
75
77
  }
76
78
  function isOpenAIResponsesNativeEndpoint(model, env) {
@@ -141,53 +143,6 @@ function sanitizeUnsupportedNativeTools(params, compat) {
141
143
  }
142
144
  return sanitized ? sanitized : params;
143
145
  }
144
- function allowedReference(tool) {
145
- if (tool.type === "function" || tool.type === "custom")
146
- return { type: tool.type, name: tool.name };
147
- if (tool.type === "mcp")
148
- return { type: "mcp", server_label: tool.server_label };
149
- return { type: tool.type };
150
- }
151
- /**
152
- * senpi#2095: `tools` carries every tool declared this session, so removing a tool never rewrites the
153
- * cached prefix; the callable subset rides `tool_choice: allowed_tools` instead. Hosted tools a payload
154
- * hook added stay callable, and deferred tools (declared by transcript items rather than `tools`) are
155
- * referenced by name. A declared tool a payload hook removed from `tools` is not referenced (senpi#2234).
156
- * An empty subset forbids tool calls. An explicit `tool_choice` always wins.
157
- */
158
- function applyAllowedToolsChoice(params, context, activeToolNames, compat) {
159
- if (!compat.supportsAllowedTools || activeToolNames === undefined || params.tool_choice !== undefined) {
160
- return params;
161
- }
162
- const active = new Set(activeToolNames);
163
- const declaredTools = getCurrentTools(context.messages);
164
- if (declaredTools.every((tool) => active.has(tool.name)))
165
- return params;
166
- const allowed = [];
167
- const namedInTools = new Set();
168
- for (const tool of params.tools ?? []) {
169
- if (tool.type === "function" || tool.type === "custom") {
170
- namedInTools.add(tool.name);
171
- if (!active.has(tool.name))
172
- continue;
173
- }
174
- allowed.push(allowedReference(tool));
175
- }
176
- // Tools missing from the request-level `tools` placement are the ones transcript items load in place.
177
- const requestToolNames = new Set(resolveResponsesToolPlacement(context.messages, resolveResponsesDeferredToolsMode(compat) !== undefined).requestTools.map((tool) => tool.name));
178
- for (const tool of declaredTools) {
179
- if (namedInTools.has(tool.name) || !active.has(tool.name) || requestToolNames.has(tool.name))
180
- continue;
181
- const [converted] = convertResponsesTools([tool], {
182
- supportsStrictMode: compat.supportsStrictMode,
183
- supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
184
- });
185
- if (converted)
186
- allowed.push(allowedReference(converted));
187
- }
188
- const toolChoice = allowed.length > 0 ? { type: "allowed_tools", mode: "auto", tools: allowed } : "none";
189
- return { ...params, tool_choice: toolChoice };
190
- }
191
146
  function formatOpenAIResponsesError(error, provider) {
192
147
  const errorMessage = formatProviderError(normalizeProviderError(error), `${provider === "openai" ? "OpenAI" : provider} API error`);
193
148
  // Sign in with ChatGPT shares the subscription's usage limit with other apps.
@@ -234,12 +189,15 @@ export const stream = (model, context, options) => {
234
189
  }
235
190
  params = sanitizeUnsupportedNativeTools(params, compat);
236
191
  params = applyAllowedToolsChoice(params, normalizedContext, context.activeToolNames, compat);
192
+ if (hasRefusedAllowedToolsChoice(model))
193
+ params = restrictToAllowedTools(params);
237
194
  const limitedTools = limitGitHubCopilotTools(model.provider, params.tools, params.tool_choice);
238
195
  if (limitedTools.omittedCount > 0) {
239
196
  params = { ...params, tools: limitedTools.tools };
240
197
  recordGitHubCopilotToolLimit(output, limitedTools.omittedCount);
241
198
  }
242
199
  const transport = options?.transport ?? "sse";
200
+ let refusedAllowedToolsOverWebSocket = false;
243
201
  if (transport !== "sse" && compat.supportsWebSocket) {
244
202
  let websocketStarted = false;
245
203
  try {
@@ -254,9 +212,16 @@ export const stream = (model, context, options) => {
254
212
  return;
255
213
  }
256
214
  catch (error) {
257
- if (transport === "websocket" || websocketStarted) {
215
+ const refusedAllowedTools = output.content.length === 0 && isAllowedToolsChoiceRefusal(error, params, { beforeContent: true });
216
+ if (!refusedAllowedTools && (transport === "websocket" || websocketStarted)) {
258
217
  throw error;
259
218
  }
219
+ if (refusedAllowedTools) {
220
+ params = restrictToAllowedTools(params);
221
+ refusedAllowedToolsOverWebSocket = true;
222
+ output.stopReason = "pending";
223
+ delete output.errorMessage;
224
+ }
260
225
  }
261
226
  }
262
227
  const requestOptions = {
@@ -264,26 +229,46 @@ export const stream = (model, context, options) => {
264
229
  ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
265
230
  maxRetries: 0,
266
231
  };
267
- const createRequest = async () => {
232
+ const sendOnce = async (body) => {
268
233
  const sent = await sendWithForcedToolChoiceFallback({
269
234
  target: model,
270
- params,
235
+ params: body,
271
236
  acceptsForcedToolChoice: compat.supportsForcedToolChoice,
272
237
  isForced: isForcedOpenAIResponsesToolChoice,
273
- send: (body) => model.provider === "github-copilot"
274
- ? awaitProviderTransport(() => client.responses.create(body, requestOptions).withResponse(), openAICompatibleProviderDiagnosticFromError)
275
- : client.responses.create(body, requestOptions).withResponse(),
238
+ send: (request) => model.provider === "github-copilot"
239
+ ? awaitProviderTransport(() => client.responses.create(request, requestOptions).withResponse(), openAICompatibleProviderDiagnosticFromError)
240
+ : client.responses.create(request, requestOptions).withResponse(),
276
241
  });
277
242
  params = sent.params;
278
243
  return sent.result;
279
244
  };
245
+ // senpi#3080: an endpoint that refuses `allowed_tools` gets the request restricted to the active
246
+ // tools; the model is remembered once that restricted request is accepted.
247
+ const createRequest = async () => {
248
+ try {
249
+ const result = await sendOnce(params);
250
+ if (refusedAllowedToolsOverWebSocket)
251
+ rememberAllowedToolsChoiceRefusal(model);
252
+ return result;
253
+ }
254
+ catch (error) {
255
+ if (!isAllowedToolsChoiceRefusal(error, params))
256
+ throw error;
257
+ params = restrictToAllowedTools(params);
258
+ const result = await sendOnce(params);
259
+ rememberAllowedToolsChoiceRefusal(model);
260
+ return result;
261
+ }
262
+ };
280
263
  const { data: openaiStream, response } = await retryProviderRequest(() => createRequest(), {
281
264
  maxRetries: options?.maxRetries,
282
265
  maxRetryDelayMs: options?.maxRetryDelayMs,
283
266
  signal: options?.signal,
284
267
  });
285
268
  await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
286
- stream.push({ type: "start", partial: output });
269
+ // A WebSocket attempt that fell through after its refusal already pushed `start`.
270
+ if (!refusedAllowedToolsOverWebSocket)
271
+ stream.push({ type: "start", partial: output });
287
272
  await processResponsesStream(openaiStream, output, stream, model, {
288
273
  onProviderStreamEvent: options?.onProviderStreamEvent,
289
274
  serviceTier: options?.serviceTier,
@@ -861,9 +846,9 @@ function buildWebSocketHeaders(model, context, apiKey, optionsHeaders, sessionId
861
846
  function getServiceTierCostMultiplier(model, serviceTier) {
862
847
  switch (serviceTier) {
863
848
  case "ultrafast":
864
- // OpenAI publishes an Ultrafast price for GPT-6 Astra only: 6x Standard on every
849
+ // OpenAI prices GPT-6 Astra and GPT-6.1 Sol Ultrafast at 6x Standard on every
865
850
  // token class and context tier. Any other model keeps its base rate.
866
- return model.id === "gpt-6-astra" ? 6 : 1;
851
+ return ["gpt-6-astra", "gpt-6.1-sol"].includes(model.upstreamModelId ?? model.id) ? 6 : 1;
867
852
  case "flex":
868
853
  return 0.5;
869
854
  case "priority":
@@ -42,10 +42,15 @@ export interface OpenAIResponsesCompat {
42
42
  * Whether the model accepts `tool_choice: { type: "allowed_tools" }`. When set, callers keep every
43
43
  * declared tool in `tools` and restrict the callable subset through `Context.activeToolNames`, so a
44
44
  * shrinking tool set does not rewrite the cached prompt prefix. Honored by the `openai-responses`
45
- * adapter. Default: false.
45
+ * adapter, and only on the native OpenAI API hosts (`api.openai.com` and its regional `*.api.openai.com`
46
+ * hosts): Responses-compatible gateways reject the choice (senpi#3080), and since a provider `baseUrl`
47
+ * override keeps the catalog compat, the flag cannot opt a gateway in. Default: false.
46
48
  */
47
49
  supportsAllowedTools?: boolean;
48
50
  }
49
- /** Whether a model's compat declares `allowed_tools` support (see {@link OpenAIResponsesCompat.supportsAllowedTools}). */
51
+ /**
52
+ * Whether requests to `model` restrict tools through `allowed_tools`: its compat declares support and it is
53
+ * served by the native OpenAI endpoint (see {@link OpenAIResponsesCompat.supportsAllowedTools}).
54
+ */
50
55
  export declare function supportsAllowedToolChoice(model: Model<Api>): boolean;
51
56
  //# sourceMappingURL=openai-responses-compat.d.ts.map
@@ -1,5 +1,18 @@
1
- /** Whether a model's compat declares `allowed_tools` support (see {@link OpenAIResponsesCompat.supportsAllowedTools}). */
1
+ function isNativeOpenAIEndpoint(baseUrl) {
2
+ try {
3
+ const { hostname } = new URL(baseUrl || "https://api.openai.com/v1");
4
+ return hostname === "api.openai.com" || hostname.endsWith(".api.openai.com");
5
+ }
6
+ catch {
7
+ return false;
8
+ }
9
+ }
10
+ /**
11
+ * Whether requests to `model` restrict tools through `allowed_tools`: its compat declares support and it is
12
+ * served by the native OpenAI endpoint (see {@link OpenAIResponsesCompat.supportsAllowedTools}).
13
+ */
2
14
  export function supportsAllowedToolChoice(model) {
3
- return model.compat?.supportsAllowedTools === true;
15
+ return (model.compat?.supportsAllowedTools === true &&
16
+ isNativeOpenAIEndpoint(model.baseUrl));
4
17
  }
5
18
  //# sourceMappingURL=openai-responses-compat.js.map
@@ -1 +1 @@
1
- {"schemaVersion":6,"generatedAt":"2026-10-08T17:53:41.797Z","structureHash":"c8caaf99abdf138011f2a2f12a89c7d833e523410ec5453cb90fd46a49088f5d","files":{"alibaba-token-plan.json":"16b3c6c78d72c73ca34a1e79ce71c0cf36ba409d02e83a17396ce0949a1d5363","amazon-bedrock.json":"4a95e6bf974e5ca967d5c050df580262e13573e42b3186edb448fa11a302f09f","ant-ling.json":"628feab3ef6c7d80ce96f75999804e1d9f05fec3851e5302e30fff3b69db90f6","anthropic.json":"111384e9f5845e0247bd34edf2400ef7720fcbdac140b944cf8878192d8bd6ab","azure-openai-responses.json":"a3e52bd78f0220aa32bce519e13955b8e6e567860416343844cbe19902f4a682","bai.json":"7e6861aaf27d8af34e1771c6092602194fcec0a0046e6adfab9c3ee54d32b99f","baseten.json":"b04d1423d6958bec857ffccc36726f091ec1a8f3a09cf0836b64cc94b0ee697d","cerebras.json":"3eccb58c5de3d0dc7ba49dc6e477ba29f00cb3150ce0a35cc90d59e6b5ab368e","chatgpt-subscription.json":"08f365952647594f5b07337666255f8510ef2e09bfb704530843b63121ed07f4","cloudflare-ai-gateway.json":"632b9e96edfabe0f0da4a8fcc09ba951f6bbc8878a2262aebe56d10b2b4fd699","cloudflare-workers-ai.json":"4bf0b488460c2e6cb8a6615bb2f87ca2a31cd9597eb249d5b58cba251330a99a","deepseek.json":"10a296fb3e898f7715c80890c8af0af4f5fd58f22dbcd6612fdc5d762157ccdc","fireworks.json":"c3dc21dc2e95428fc9a38e3840c05bbf90f94e4c25dee0eedf0b385b49ff65a4","github-copilot.json":"2c5a84983477a7c4f6c001088a1e043ee959ce58c99fea6eb843e0286228664b","google-vertex.json":"40090c680538bc867f2624d986a449f361be4dfa6ba96d7a27402050f036e009","google.json":"c0f5a633e5e5a5044432db3e537f4bc674d4de18df09325c6349547fcc13cabe","groq.json":"910952ad9dad07856983459d63fce0148dba1203edd7b10d801a77c5824f3405","huggingface.json":"1b8604bd31a5e05fe43a40ec1ec6dfc1a51a28b0b020151ef057fab43f57c923","meta.json":"0c7e9a370a7005c5a89dc94f0f93bc67b8935c3f802de324f11af6ff56fff429","minimax-cn.json":"f962c20ee6a6a342820f778e9dc363a18a524bf11998f6e2c57f2989915d62e5","minimax.json":"17a432f80d0ea6dd26eade80a4314250d3a855678601728a5f70dd8fa0d21dd2","mistral.json":"27aa66c71a0504b53dc87ec194286fa7e70b99747fc8df9b709b23b3548afe50","moonshotai-cn.json":"18954b55dc5edecc174d8a07a2c5993416dd9d3ce52e411438035c16ee982e8e","moonshotai.json":"62d08fd5beead025a56d5f2e4227d8516b9c5034275b1b36d761a8625918f2b9","nvidia.json":"a75e963dc81779e3eec01c051b12277c15ec37c5b94ab51ece9e9ccb3b16407c","openai.json":"9104538118053eed57f90852332b6ad274a4e4792389bdbc9cfca1ae400c705a","opencode-go.json":"48ff011f3e171f977cfed2928272667e9361f13a508e5994f00f2241f6a23419","opencode.json":"882b039f64ef266da7b02d30aee9255c191e16f0600ef5494022bc1ef746f749","opengateway.json":"fdb64c9a0906febf3a5164d43be20b901f73fd11f5c8cb5b86823c41a6e073e2","openrouter.json":"6d07953963656513f2e75a99b1c6dd93bfb6094cbf5f4c7498c56a97e911178d","qwen-token-plan-cn.json":"bd36a6d9cc2048deafacc85f7f0f2e51868c8b7aa09502e3e4e60e3e8b76f690","qwen-token-plan-individual.json":"35ffb0ba1a1ce3f68fc73262dd5e07fbcb5bf5911ad1e6e17bc0621e2894676a","qwen-token-plan.json":"ca27e49e62965221badead8e867b2007035c6b6a15a97c47219bd23d2c83c2b1","radius.json":"8214122385e8e4c4bc2e1d2a7d032f9e4c654da3bfd3ac8c5d4396e1b522e53a","together.json":"bbd7fad86cacfefe3d3d61f6638f22e9b6f5d575722fd1112a16aafe0485c46d","typesafe.json":"a4429a2f696d5cc5273969af61a7310bda07d6bdcd27bf9ac9c76c534cc43716","venice.json":"ab4e80fb489d2baa1afa053f88eb58d1fd32ba8992fa13d2d70ddd94446f7243","vercel-ai-gateway.json":"e0336a908a63cf808bffd27e8355cf13ce4e31fcd9699d6ecd19d84439028cac","xai.json":"d7771a1450b21c1f6aee887ca6fea61b6e7b3fc13b4dee5cf72a71134fe623bc","xiaomi-token-plan-ams.json":"4dd8f838e21d7aa7caf3122d0b0b1e5a02a33bc012b96e6a75bd119334152d7a","xiaomi-token-plan-cn.json":"46414ff49973b95a0c14bd33e8992cc1938a50ce2c7cea9e2e5cc02de8f1df31","xiaomi-token-plan-sgp.json":"f5b3f66bf7bb7238592d4a9667cad983a008d7539a66e9896b02422622800935","xiaomi.json":"8b62c158d55d5a165f35cd72c1fc82ab89ff8e73f08960105fc41b822f236f69","zai-coding-cn.json":"2a7f59d15f588a97d218997883f2506dfc52d3ee320697d39f14213a050fc912","zai.json":"e9cc408e1756de8abc70148d63d89d11a63c65b7ef06ad0661cd1328e4209ba3"}}
1
+ {"schemaVersion":6,"generatedAt":"2026-10-11T10:01:11.497Z","structureHash":"0989a1c88b40439cecf9d8a82fe466628aa29d2c7cc597ed79540d082d7890d6","files":{"alibaba-token-plan.json":"16b3c6c78d72c73ca34a1e79ce71c0cf36ba409d02e83a17396ce0949a1d5363","amazon-bedrock.json":"4a95e6bf974e5ca967d5c050df580262e13573e42b3186edb448fa11a302f09f","ant-ling.json":"628feab3ef6c7d80ce96f75999804e1d9f05fec3851e5302e30fff3b69db90f6","anthropic.json":"111384e9f5845e0247bd34edf2400ef7720fcbdac140b944cf8878192d8bd6ab","azure-openai-responses.json":"a3e52bd78f0220aa32bce519e13955b8e6e567860416343844cbe19902f4a682","bai.json":"7e6861aaf27d8af34e1771c6092602194fcec0a0046e6adfab9c3ee54d32b99f","baseten.json":"1fcdab8daf2881bd099eb08f9416f7df5bf39975937d2a63b841542bd032f2e6","cerebras.json":"3eccb58c5de3d0dc7ba49dc6e477ba29f00cb3150ce0a35cc90d59e6b5ab368e","chatgpt-subscription.json":"2fbeb0141b29c340542798bce53422b93bef026a13420986e87fca89353e0aa2","cloudflare-ai-gateway.json":"5c8782fdbf2477f620b238da4344b0bcc0948b8c177461e63e7e5ba256f769c7","cloudflare-workers-ai.json":"f351e80493dea7b7a2c31dceb086ee804a88d67c5f8a3c823ea907991fe5d659","deepseek.json":"10a296fb3e898f7715c80890c8af0af4f5fd58f22dbcd6612fdc5d762157ccdc","fireworks.json":"c3dc21dc2e95428fc9a38e3840c05bbf90f94e4c25dee0eedf0b385b49ff65a4","github-copilot.json":"2c5a84983477a7c4f6c001088a1e043ee959ce58c99fea6eb843e0286228664b","google-vertex.json":"96ecc19bf20a706f99ae658b021a0ee5438146119e1e99de5fd824a01fcfd81b","google.json":"c0f5a633e5e5a5044432db3e537f4bc674d4de18df09325c6349547fcc13cabe","groq.json":"910952ad9dad07856983459d63fce0148dba1203edd7b10d801a77c5824f3405","huggingface.json":"6399e958790a9d90c21fb55d1cbef8569427a1ceb28de86dadeb3b4454ecfade","meta.json":"0c7e9a370a7005c5a89dc94f0f93bc67b8935c3f802de324f11af6ff56fff429","minimax-cn.json":"f962c20ee6a6a342820f778e9dc363a18a524bf11998f6e2c57f2989915d62e5","minimax.json":"17a432f80d0ea6dd26eade80a4314250d3a855678601728a5f70dd8fa0d21dd2","mistral.json":"27aa66c71a0504b53dc87ec194286fa7e70b99747fc8df9b709b23b3548afe50","moonshotai-cn.json":"18954b55dc5edecc174d8a07a2c5993416dd9d3ce52e411438035c16ee982e8e","moonshotai.json":"62d08fd5beead025a56d5f2e4227d8516b9c5034275b1b36d761a8625918f2b9","nvidia.json":"a75e963dc81779e3eec01c051b12277c15ec37c5b94ab51ece9e9ccb3b16407c","openai.json":"9104538118053eed57f90852332b6ad274a4e4792389bdbc9cfca1ae400c705a","opencode-go.json":"48ff011f3e171f977cfed2928272667e9361f13a508e5994f00f2241f6a23419","opencode.json":"882b039f64ef266da7b02d30aee9255c191e16f0600ef5494022bc1ef746f749","opengateway.json":"dd4259885ea6045d723b3abaae06657dfcce07b9ce3c2b0300220ac661432859","openrouter.json":"9484c1583eb1d419e89ffe258cd4efb7bb793666ae09c94db545f400fe0d628e","qwen-token-plan-cn.json":"bd36a6d9cc2048deafacc85f7f0f2e51868c8b7aa09502e3e4e60e3e8b76f690","qwen-token-plan-individual.json":"35ffb0ba1a1ce3f68fc73262dd5e07fbcb5bf5911ad1e6e17bc0621e2894676a","qwen-token-plan.json":"ca27e49e62965221badead8e867b2007035c6b6a15a97c47219bd23d2c83c2b1","radius.json":"8214122385e8e4c4bc2e1d2a7d032f9e4c654da3bfd3ac8c5d4396e1b522e53a","together.json":"bbd7fad86cacfefe3d3d61f6638f22e9b6f5d575722fd1112a16aafe0485c46d","typesafe.json":"a4429a2f696d5cc5273969af61a7310bda07d6bdcd27bf9ac9c76c534cc43716","venice.json":"3604c88c46dd3e4151b833927c6f87bcc165d0999a4339b32db2ad74a9ff173f","vercel-ai-gateway.json":"9859481f690a9a9a8131af3abfadf5378b2d1ef51990041da4465de3f1e4ab78","xai.json":"d7771a1450b21c1f6aee887ca6fea61b6e7b3fc13b4dee5cf72a71134fe623bc","xiaomi-token-plan-ams.json":"4dd8f838e21d7aa7caf3122d0b0b1e5a02a33bc012b96e6a75bd119334152d7a","xiaomi-token-plan-cn.json":"46414ff49973b95a0c14bd33e8992cc1938a50ce2c7cea9e2e5cc02de8f1df31","xiaomi-token-plan-sgp.json":"f5b3f66bf7bb7238592d4a9667cad983a008d7539a66e9896b02422622800935","xiaomi.json":"8b62c158d55d5a165f35cd72c1fc82ab89ff8e73f08960105fc41b822f236f69","zai-coding-cn.json":"2a7f59d15f588a97d218997883f2506dfc52d3ee320697d39f14213a050fc912","zai.json":"e9cc408e1756de8abc70148d63d89d11a63c65b7ef06ad0661cd1328e4209ba3"}}