@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.
- package/dist/api/anthropic-messages.js +148 -72
- package/dist/api/anthropic-native-replay-blocks.d.ts +4 -0
- package/dist/api/anthropic-native-replay-blocks.js +28 -0
- package/dist/api/anthropic-tool-references.js +24 -18
- package/dist/api/anthropic-web-search-expiry.d.ts +10 -0
- package/dist/api/anthropic-web-search-expiry.js +104 -0
- package/dist/api/bedrock-converse-stream.js +5 -1
- package/dist/api/google-shared.js +101 -21
- package/dist/api/lazy.js +13 -0
- package/dist/api/mistral-conversations.js +18 -3
- package/dist/api/openai-codex-responses.js +2 -2
- package/dist/api/openai-completions.js +26 -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-shared.js +19 -0
- package/dist/api/openai-responses.js +48 -59
- package/dist/auth/oauth/anthropic.js +4 -3
- package/dist/auth/oauth/chatgpt-subscription.js +3 -2
- package/dist/auth/oauth/cursor.js +3 -2
- package/dist/auth/oauth/devin-token.js +2 -1
- package/dist/auth/oauth/github-copilot.js +3 -2
- package/dist/auth/oauth/kimi-coding.js +5 -4
- package/dist/auth/oauth/openai-chatgpt.js +2 -1
- package/dist/auth/oauth/openrouter.js +2 -1
- package/dist/auth/oauth/radius.js +5 -5
- package/dist/auth/oauth/xai.js +7 -4
- package/dist/auth/oauth-refresh.js +10 -0
- package/dist/auth/resolve.js +5 -0
- package/dist/model.d.ts +6 -2
- package/dist/openai-responses-compat.d.ts +7 -2
- package/dist/openai-responses-compat.js +15 -2
- package/dist/providers/all.d.ts +1 -1
- package/dist/providers/data/.manifest.json +1 -1
- package/dist/providers/data/amazon-bedrock.json +1 -1
- package/dist/providers/data/anthropic.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/github-copilot.json +1 -1
- package/dist/providers/data/google-vertex.json +1 -1
- package/dist/providers/data/huggingface.json +1 -1
- package/dist/providers/data/mistral.json +1 -1
- package/dist/providers/data/moonshotai-cn.json +1 -1
- package/dist/providers/data/moonshotai.json +1 -1
- package/dist/providers/data/opencode-go.json +1 -1
- package/dist/providers/data/opencode.json +1 -1
- package/dist/providers/data/opengateway.json +1 -1
- package/dist/providers/data/openrouter.json +1 -1
- package/dist/providers/data/radius.json +1 -1
- package/dist/providers/data/venice.json +1 -1
- package/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/dist/providers/kimi-coding.models.d.ts +1 -1
- package/dist/providers/kimi-coding.models.js +1 -1
- package/dist/providers/opengateway-refresh.js +2 -1
- package/dist/providers/xai.js +7 -1
- package/dist/types.d.ts +25 -2
- package/dist/utils/oauth-refresh-error.d.ts +16 -0
- package/dist/utils/oauth-refresh-error.js +67 -0
- package/dist/utils/prompt-cache-ttl.d.ts +2 -1
- package/dist/utils/prompt-cache-ttl.js +4 -1
- package/dist/utils/provider-failure-description.d.ts +3 -1
- package/dist/utils/provider-failure-description.js +8 -0
- package/dist/utils/retry.d.ts +6 -0
- package/dist/utils/retry.js +10 -0
- package/dist/utils/tool-choice-fallback.d.ts +11 -0
- package/dist/utils/tool-choice-fallback.js +69 -2
- 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
|
-
|
|
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 } : {}),
|
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
|
|
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
|
|
655
|
+
appendUserMessage(result, content);
|
|
641
656
|
continue;
|
|
642
657
|
}
|
|
643
658
|
if (hadImages && !supportsImages) {
|
|
644
|
-
result
|
|
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
|
|
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":
|
|
@@ -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
|
-
|
|
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;
|