@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.
- package/dist/api/google-shared.js +101 -21
- package/dist/api/openai-codex-responses.js +2 -2
- package/dist/api/openai-responses-allowed-tools.d.ts +33 -0
- package/dist/api/openai-responses-allowed-tools.js +108 -0
- package/dist/api/openai-responses.js +43 -58
- package/dist/openai-responses-compat.d.ts +7 -2
- package/dist/openai-responses-compat.js +15 -2
- package/dist/providers/data/.manifest.json +1 -1
- package/dist/providers/data/baseten.json +1 -1
- package/dist/providers/data/chatgpt-subscription.json +1 -1
- package/dist/providers/data/cloudflare-ai-gateway.json +1 -1
- package/dist/providers/data/cloudflare-workers-ai.json +1 -1
- package/dist/providers/data/google-vertex.json +1 -1
- package/dist/providers/data/huggingface.json +1 -1
- package/dist/providers/data/opengateway.json +1 -1
- package/dist/providers/data/openrouter.json +1 -1
- package/dist/providers/data/venice.json +1 -1
- package/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/dist/types.d.ts +3 -2
- package/dist/utils/tool-choice-fallback.d.ts +5 -0
- package/dist/utils/tool-choice-fallback.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
name: block.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
272
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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: (
|
|
274
|
-
? awaitProviderTransport(() => client.responses.create(
|
|
275
|
-
: client.responses.create(
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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-
|
|
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"}}
|