Merge origin/master (v0.5.69) into gitea/new_feature
This commit is contained in:
@@ -58,6 +58,15 @@ export function extractThinking(body) {
|
||||
return { mode: "level", level: e };
|
||||
}
|
||||
|
||||
// OpenAI chat / Responses shape — check effort first (zai sends both thinking object and reasoning.effort)
|
||||
const effort = body.reasoning_effort ?? (typeof body.reasoning === "object" ? body.reasoning?.effort : null);
|
||||
if (typeof effort === "string" && effort) {
|
||||
const e = effort.toLowerCase();
|
||||
if (e === "none" || e === "off") return { mode: "none" };
|
||||
if (e === "auto") return { mode: "auto" };
|
||||
return { mode: "level", level: e };
|
||||
}
|
||||
|
||||
// Claude shape
|
||||
const t = body.thinking;
|
||||
if (t && typeof t === "object") {
|
||||
@@ -69,15 +78,6 @@ export function extractThinking(body) {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI chat / Responses shape
|
||||
const effort = body.reasoning_effort ?? (typeof body.reasoning === "object" ? body.reasoning?.effort : null);
|
||||
if (typeof effort === "string" && effort) {
|
||||
const e = effort.toLowerCase();
|
||||
if (e === "none" || e === "off") return { mode: "none" };
|
||||
if (e === "auto") return { mode: "auto" };
|
||||
return { mode: "level", level: e };
|
||||
}
|
||||
|
||||
// Gemini shape (top-level, generationConfig, or request envelope)
|
||||
const tc = body.thinkingConfig || body.generationConfig?.thinkingConfig || body.request?.generationConfig?.thinkingConfig;
|
||||
if (tc && typeof tc === "object") {
|
||||
@@ -105,12 +105,16 @@ export function extractThinking(body) {
|
||||
// at the call-site where intent is snapshotted before format translation.
|
||||
export const captureThinking = extractThinking;
|
||||
|
||||
// Resolve thinking format: provider override > capability > derive(targetFormat).
|
||||
const NATIVE_ONLY_FORMATS = new Set(["gemini-level", "gemini-budget", "claude-budget", "claude-adaptive", "kiro"]);
|
||||
|
||||
function resolveFormat(targetFormat, model, provider) {
|
||||
const providerFmt = provider ? PROVIDERS[provider]?.thinkingFormat : null;
|
||||
if (providerFmt) return providerFmt;
|
||||
const caps = getCapabilitiesForModel(provider, model);
|
||||
if (caps.thinkingFormat) return caps.thinkingFormat;
|
||||
const isOpenAIWire = targetFormat === "openai" || targetFormat === "openai-responses";
|
||||
if (caps.thinkingFormat && !(isOpenAIWire && NATIVE_ONLY_FORMATS.has(caps.thinkingFormat))) {
|
||||
return caps.thinkingFormat;
|
||||
}
|
||||
return FORMAT_TO_NATIVE[targetFormat] || "openai";
|
||||
}
|
||||
|
||||
@@ -237,14 +241,12 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) {
|
||||
}
|
||||
case "claude-adaptive": {
|
||||
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
|
||||
// output_config.effort alone does NOT turn thinking on: Anthropic requires
|
||||
// an explicit thinking:{type:"adaptive"} on Opus 4.6/4.7/4.8 and Sonnet 4.6
|
||||
// ("thinking is off unless you explicitly set it"), and Anthropic-compatible
|
||||
// shims (e.g. GitHub Copilot /v1/messages) default thinking off even for
|
||||
// Sonnet 5. Send both fields — the documented adaptive-thinking shape.
|
||||
body.thinking = { type: "adaptive" };
|
||||
// Models that can disable thinking need the explicit adaptive switch.
|
||||
// Permanently adaptive models such as Fable 5.1 accept effort directly.
|
||||
if (canDisable) body.thinking = { type: "adaptive" };
|
||||
else delete body.thinking;
|
||||
const level = toLevel(eff);
|
||||
body.output_config = { effort: level === "xhigh" ? "high" : level };
|
||||
body.output_config = { effort: level === "xhigh" || level === "auto" ? "high" : level };
|
||||
break;
|
||||
}
|
||||
case "claude-budget": {
|
||||
@@ -270,6 +272,18 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) {
|
||||
// Z.ai ignores thinking.disabled → must use enable_thinking:false to turn off.
|
||||
if (none && canDisable) { body.enable_thinking = false; delete body.thinking; break; }
|
||||
body.thinking = { type: "enabled" };
|
||||
// reasoning_effort is only read by z.ai from GLM-5.2 onward — older GLM ignores it
|
||||
// (see thinkingEffortSupported in capabilities.js). Skip on unsupported models so we
|
||||
// don't send a field the API doesn't recognize.
|
||||
if (caps.thinkingEffortSupported) {
|
||||
const zaiLvl = toLevel(eff);
|
||||
// GLM-5.3 only accepts exactly low|high|max (anything else errors); GLM-5.2 accepts
|
||||
// a wider set but z.ai maps low/medium->high and xhigh->max server-side anyway, so
|
||||
// this 3-value mapping matches both.
|
||||
body.reasoning_effort = (zaiLvl === "low" || zaiLvl === "minimal") ? "low"
|
||||
: (zaiLvl === "high" || zaiLvl === "medium") ? "high"
|
||||
: "max";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "qwen": {
|
||||
|
||||
@@ -151,3 +151,17 @@ export function fixMissingToolResponses(body) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// Default `type: "custom"` on Claude-format tools that arrive without one.
|
||||
// Anthropic's Claude tool schema requires `type` to be explicitly set; strict gateways
|
||||
// (e.g., MiniMax Anthropic-compatible endpoint, error 2013) reject legacy payloads that
|
||||
// omit it with HTTP 400. Tools that already carry a truthy `type` (e.g., `computer_use`,
|
||||
// `bash`, `web_search_20250305`) are passed through untouched.
|
||||
//
|
||||
// Spread order matters: `{ ...tool, type: "custom" }` (spread first, override last)
|
||||
// ensures that falsy `type` values (null, undefined, "") in the original tool don't
|
||||
// overwrite the default. `{ type: "custom", ...tool }` would let `type: null` survive.
|
||||
export function defaultClaudeToolType(tools) {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
return tools.map(tool => tool?.type ? tool : { ...tool, type: "custom" });
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,18 @@ import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js";
|
||||
const CACHE_CONTROL_5M = { type: "ephemeral" };
|
||||
const CACHE_CONTROL_1H = { type: "ephemeral", ttl: "1h" };
|
||||
|
||||
// Anthropic rejects a tool carrying BOTH defer_loading:true and cache_control
|
||||
// ("Tools defer_loading cannot use prompt caching", #3567). MCP clients put
|
||||
// deferred tools at the tail, which is exactly where the cache anchor lands.
|
||||
// Anchor on the last tool that CAN be cached instead of dropping caching.
|
||||
export function lastCacheableToolIndex(tools) {
|
||||
if (!Array.isArray(tools)) return -1;
|
||||
for (let i = tools.length - 1; i >= 0; i--) {
|
||||
if (tools[i]?.defer_loading !== true) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Check if message has valid non-empty content
|
||||
export function hasValidContent(msg) {
|
||||
if (typeof msg.content === "string" && msg.content.trim()) return true;
|
||||
@@ -108,11 +120,24 @@ function buildThinkingPlaceholder(provider) {
|
||||
return block;
|
||||
}
|
||||
|
||||
// Anthropic validates server_tool_use ids against this pattern and rejects the
|
||||
// whole request with a 400 when one does not match. A combo that falls back to a
|
||||
// provider with its own built-in tools (z.ai/glm emits OpenAI-style `call_` ids for
|
||||
// its analyze_image tool) leaves such blocks in the history, so every later Claude
|
||||
// turn carries a poisoned id.
|
||||
const CLAUDE_SERVER_TOOL_USE_ID = /^srvtoolu_[a-zA-Z0-9_]+$/;
|
||||
|
||||
function hasForeignServerToolUseId(block) {
|
||||
return block?.type === CLAUDE_BLOCK.SERVER_TOOL_USE
|
||||
&& !CLAUDE_SERVER_TOOL_USE_ID.test(String(block.id ?? ""));
|
||||
}
|
||||
|
||||
// Normalize a native Claude passthrough body to match Anthropic Messages API spec.
|
||||
// Newer Cowork/Claude Code clients emit beta-only shapes that OAuth endpoints reject:
|
||||
// 1. thinking.type "adaptive" → unsupported on Haiku
|
||||
// 2. output_config.effort → unsupported on Haiku
|
||||
// 3. role "system" messages (mid-conversation-system beta) → only top-level system is allowed
|
||||
// 4. server_tool_use blocks carrying a foreign (non-srvtoolu_) id → rejected outright
|
||||
export function normalizeClaudePassthrough(body, model = "") {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
|
||||
@@ -164,6 +189,7 @@ export function normalizeClaudePassthrough(body, model = "") {
|
||||
// 3. Drop thinking blocks whose signature is not Claude's (combo mixes models,
|
||||
// so foreign signatures leak into history and Anthropic rejects them).
|
||||
const thinkingEnabled = body.thinking?.type === "enabled";
|
||||
const droppedServerToolUseIds = new Set();
|
||||
if (Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content)) continue;
|
||||
@@ -178,6 +204,10 @@ export function normalizeClaudePassthrough(body, model = "") {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (hasForeignServerToolUseId(block)) {
|
||||
if (block.id != null) droppedServerToolUseIds.add(String(block.id));
|
||||
continue;
|
||||
}
|
||||
if (block.type === CLAUDE_BLOCK.TOOL_USE) hasToolUse = true;
|
||||
kept.push(block);
|
||||
}
|
||||
@@ -188,6 +218,35 @@ export function normalizeClaudePassthrough(body, model = "") {
|
||||
}
|
||||
}
|
||||
|
||||
// A dropped server_tool_use leaves its result behind; Anthropic rejects a
|
||||
// tool_result that references an id no block declares, so both halves must go.
|
||||
if (droppedServerToolUseIds.size > 0 && Array.isArray(body.messages)) {
|
||||
for (const msg of body.messages) {
|
||||
if (!Array.isArray(msg.content)) continue;
|
||||
const kept = msg.content.filter(block => !(
|
||||
(block?.type === CLAUDE_BLOCK.TOOL_RESULT || block?.type === CLAUDE_BLOCK.WEB_SEARCH_TOOL_RESULT)
|
||||
&& droppedServerToolUseIds.has(String(block.tool_use_id ?? ""))
|
||||
));
|
||||
if (kept.length !== msg.content.length) {
|
||||
msg.content = kept;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Drop empty text blocks and any message left with no content at all.
|
||||
// Anthropic rejects `messages.N.content` blocks with empty text (400
|
||||
// "text content blocks must be non-empty"); a message whose blocks were all
|
||||
// stripped above must be dropped, not padded with an empty placeholder.
|
||||
if (Array.isArray(body.messages)) {
|
||||
body.messages = body.messages.filter(msg => {
|
||||
if (typeof msg.content === "string") return msg.content.trim().length > 0;
|
||||
if (!Array.isArray(msg.content)) return true;
|
||||
msg.content = msg.content.filter(block =>
|
||||
!(block?.type === CLAUDE_BLOCK.TEXT && !String(block.text ?? "").trim()));
|
||||
return msg.content.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -223,7 +282,7 @@ export function anchorClaudeCache(body) {
|
||||
}
|
||||
|
||||
if (Array.isArray(body.tools)) {
|
||||
const last = body.tools.length - 1;
|
||||
const last = lastCacheableToolIndex(body.tools);
|
||||
body.tools.forEach((tool, i) => {
|
||||
if (i === last) tool.cache_control = { ...CACHE_CONTROL_1H };
|
||||
else delete tool.cache_control;
|
||||
@@ -417,9 +476,10 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
|
||||
});
|
||||
}
|
||||
|
||||
const lastCacheable = lastCacheableToolIndex(body.tools);
|
||||
body.tools = body.tools.map((tool, i) => {
|
||||
const { cache_control, ...rest } = tool;
|
||||
if (i === body.tools.length - 1) {
|
||||
if (i === lastCacheable) {
|
||||
return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } };
|
||||
}
|
||||
return rest;
|
||||
|
||||
@@ -14,6 +14,8 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
|
||||
"uniqueItems", "contains",
|
||||
// 2020-12 keywords with no Gemini equivalent
|
||||
"unevaluatedProperties", "unevaluatedItems", "contentSchema",
|
||||
// Tuple-array keywords; converted to items first, leftovers stripped
|
||||
"prefixItems", "additionalItems",
|
||||
// Claude rejects these in VALIDATED mode
|
||||
"default", "examples",
|
||||
// JSON Schema meta keywords
|
||||
@@ -308,6 +310,37 @@ function ensureObjectType(obj) {
|
||||
for (const v of Object.values(obj)) if (v && typeof v === "object") ensureObjectType(v);
|
||||
}
|
||||
|
||||
// Convert prefixItems (tuple validation) to items — Gemini cannot express tuples,
|
||||
// and a type:"array" schema without items is rejected with "missing field"
|
||||
function convertPrefixItems(obj) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (Array.isArray(obj.prefixItems) && obj.prefixItems.length > 0) {
|
||||
const variants = obj.prefixItems.filter(s => s && s.type !== "null");
|
||||
if (!obj.items && variants.length === 1) {
|
||||
obj.items = variants[0];
|
||||
} else if (!obj.items && variants.length > 1) {
|
||||
obj.items = { anyOf: variants };
|
||||
}
|
||||
delete obj.prefixItems;
|
||||
}
|
||||
|
||||
for (const value of Object.values(obj)) {
|
||||
if (value && typeof value === "object") {
|
||||
convertPrefixItems(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini requires items on every type:"array" schema — fill a permissive placeholder
|
||||
function ensureArrayItems(obj) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
if (obj.type === "array" && !obj.items) {
|
||||
obj.items = { type: "string" };
|
||||
}
|
||||
for (const v of Object.values(obj)) if (v && typeof v === "object") ensureArrayItems(v);
|
||||
}
|
||||
|
||||
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
|
||||
export function cleanJSONSchemaForAntigravity(schema) {
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
@@ -321,11 +354,13 @@ export function cleanJSONSchemaForAntigravity(schema) {
|
||||
|
||||
// Phase 2: Flatten complex structures
|
||||
mergeAllOf(cleaned);
|
||||
convertPrefixItems(cleaned);
|
||||
flattenAnyOfOneOf(cleaned);
|
||||
flattenTypeArrays(cleaned);
|
||||
|
||||
// Phase 2.5: Infer missing type=object when properties exist (Gemini requirement)
|
||||
ensureObjectType(cleaned);
|
||||
ensureArrayItems(cleaned);
|
||||
|
||||
// Phase 3: Remove all unsupported keywords at ALL levels (including inside arrays)
|
||||
removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS);
|
||||
|
||||
@@ -23,6 +23,59 @@ export function normalizeResponsesInput(input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strict Responses upstreams reject overlong call_ids with InputValidationError (#393).
|
||||
export const MAX_RESPONSES_CALL_ID_LEN = 64;
|
||||
|
||||
// Fallback ids share one Date.now() when a batch of items is sanitized in a tight
|
||||
// loop — a per-process sequence keeps same-millisecond ids unique so
|
||||
// function_call ↔ function_call_output correlation never collides.
|
||||
let responsesCallIdSeq = 0;
|
||||
|
||||
export function clampResponsesCallId(id) {
|
||||
if (typeof id !== "string" || !id) return `call_${Date.now()}_${(responsesCallIdSeq += 1)}`;
|
||||
return id.length > MAX_RESPONSES_CALL_ID_LEN ? id.substring(0, MAX_RESPONSES_CALL_ID_LEN) : id;
|
||||
}
|
||||
|
||||
// Single-stringify: objects → JSON once; valid JSON strings pass through untouched;
|
||||
// anything else (partial fragments, empty) falls back to "{}" instead of
|
||||
// double-encoding and tripping upstream InputValidationError.
|
||||
export function coerceResponsesArguments(value) {
|
||||
if (value === undefined || value === null || value === "") return "{}";
|
||||
if (typeof value !== "string") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return value;
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
// function_call_output.output must be a string — never null/object.
|
||||
export function coerceResponsesOutput(value) {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === undefined || value === null) return "";
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((c) => {
|
||||
try {
|
||||
return c?.text ?? JSON.stringify(c);
|
||||
} catch {
|
||||
return String(c);
|
||||
}
|
||||
}).join("");
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI Responses API format to standard chat completions format
|
||||
* Responses API uses: { input: [...], instructions: "..." }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FORMATS } from "./formats.js";
|
||||
import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js";
|
||||
import { prepareClaudeRequest } from "./formats/claude.js";
|
||||
import { cloakClaudeTools } from "../utils/claudeCloaking.js";
|
||||
import { cloakClaudeTools, decloakStreamChunk } from "../utils/claudeCloaking.js";
|
||||
import { filterToOpenAIFormat } from "./formats/openai.js";
|
||||
import { normalizeThinkingConfig } from "../services/provider.js";
|
||||
import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js";
|
||||
@@ -133,7 +133,7 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
|
||||
result = prepareClaudeRequest(result, provider, apiKey, connectionId, credentials?.rawHeaders, clientSessionId);
|
||||
}
|
||||
|
||||
// Claude cloaking: rename client tools with _cc suffix (anti-ban)
|
||||
// Claude cloaking: rename client tools with CLAUDE_TOOL_SUFFIX (anti-ban)
|
||||
// quirk: only providers flagged cloakToolsOnOAuth, and only with an OAuth token
|
||||
if (PROVIDERS[provider]?.quirks?.cloakToolsOnOAuth) {
|
||||
const apiKey = credentials?.accessToken || credentials?.apiKey || null;
|
||||
@@ -161,9 +161,12 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
|
||||
// Translate response chunk: target -> openai -> source
|
||||
export function translateResponse(targetFormat, sourceFormat, chunk, state) {
|
||||
ensureInitialized();
|
||||
// If same format, return as-is
|
||||
// If same format, return as-is — except the tool name may still be cloaked:
|
||||
// translateRequest() suffixes client tools for OAuth-cloaked Claude providers
|
||||
// even when no format conversion is needed, so streamed tool_use blocks must
|
||||
// be decloaked here or the client sees an unknown ("_ide"-suffixed) tool.
|
||||
if (sourceFormat === targetFormat) {
|
||||
return [chunk];
|
||||
return [decloakStreamChunk(chunk, state?.toolNameMap)];
|
||||
}
|
||||
|
||||
let results = [chunk];
|
||||
|
||||
@@ -327,7 +327,6 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
};
|
||||
|
||||
if (profileArn) payload.profileArn = profileArn;
|
||||
if (systemPrompt) payload.systemPrompt = systemPrompt;
|
||||
if (additionalModelRequestFields) {
|
||||
payload.additionalModelRequestFields = additionalModelRequestFields;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
*/
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { normalizeResponsesInput } from "../formats/responsesApi.js";
|
||||
import {
|
||||
normalizeResponsesInput,
|
||||
clampResponsesCallId,
|
||||
coerceResponsesArguments,
|
||||
coerceResponsesOutput,
|
||||
} from "../formats/responsesApi.js";
|
||||
import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM } from "../schema/index.js";
|
||||
|
||||
// Responses API enforces max 64 chars on call_id (#393)
|
||||
const MAX_CALL_ID_LEN = 64;
|
||||
const clampCallId = (id) => (typeof id === "string" && id.length > MAX_CALL_ID_LEN ? id.substring(0, MAX_CALL_ID_LEN) : id);
|
||||
const MAX_TOOL_NAME_LEN = 128;
|
||||
|
||||
/**
|
||||
* Convert OpenAI Responses API request to OpenAI Chat Completions format
|
||||
@@ -249,6 +252,23 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from a system/developer message for Responses instructions.
|
||||
* Array content (text parts) is joined; anything else falls back to "" rather
|
||||
* than leaking "[object Object]" upstream.
|
||||
*/
|
||||
function extractInstructionsText(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((c) => {
|
||||
if (typeof c?.text === "string") return c.text;
|
||||
if (typeof c?.content === "string") return c.content;
|
||||
return "";
|
||||
}).filter(Boolean).join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure object schema always has properties field (required by Codex Responses API)
|
||||
*/
|
||||
@@ -300,7 +320,16 @@ function buildReasoningInputItem(msg) {
|
||||
*/
|
||||
export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) {
|
||||
// Body already in Responses API format (e.g. Cursor CLI calling /chat/completions with input[])
|
||||
if (body.input) return { ...body, model, stream: true };
|
||||
if (body.input) {
|
||||
const out = { ...body, model, stream: true };
|
||||
if (out.max_output_tokens === undefined) {
|
||||
if (out.max_completion_tokens !== undefined) out.max_output_tokens = out.max_completion_tokens;
|
||||
else if (out.max_tokens !== undefined) out.max_output_tokens = out.max_tokens;
|
||||
}
|
||||
delete out.max_tokens;
|
||||
delete out.max_completion_tokens;
|
||||
return out;
|
||||
}
|
||||
|
||||
const result = {
|
||||
model,
|
||||
@@ -318,7 +347,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
|
||||
// Use the first instruction-bearing message as instructions.
|
||||
// OpenAI recommends role="developer" for GPT-5/Codex as the system-level prompt.
|
||||
if (!hasSystemMessage) {
|
||||
result.instructions = typeof msg.content === "string" ? msg.content : "";
|
||||
result.instructions = extractInstructionsText(msg.content);
|
||||
hasSystemMessage = true;
|
||||
}
|
||||
continue; // Skip instruction messages in input
|
||||
@@ -369,26 +398,24 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
|
||||
// Convert tool calls
|
||||
if (msg.role === ROLE.ASSISTANT && msg.tool_calls) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
// Skip nameless calls — strict Responses upstreams reject them (#444)
|
||||
const name = typeof tc.function?.name === "string" ? tc.function.name.trim() : "";
|
||||
if (!name) continue;
|
||||
result.input.push({
|
||||
type: RESPONSES_ITEM.FUNCTION_CALL,
|
||||
call_id: clampCallId(tc.id),
|
||||
name: tc.function?.name || "_unknown",
|
||||
arguments: tc.function?.arguments || "{}"
|
||||
call_id: clampResponsesCallId(tc.id),
|
||||
name: name.slice(0, MAX_TOOL_NAME_LEN),
|
||||
arguments: coerceResponsesArguments(tc.function?.arguments)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tool results - output must be a string for Responses API
|
||||
if (msg.role === ROLE.TOOL) {
|
||||
const output = typeof msg.content === "string"
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.map(c => c.text || JSON.stringify(c)).join("")
|
||||
: JSON.stringify(msg.content);
|
||||
result.input.push({
|
||||
type: RESPONSES_ITEM.FUNCTION_CALL_OUTPUT,
|
||||
call_id: clampCallId(msg.tool_call_id),
|
||||
output
|
||||
call_id: clampResponsesCallId(msg.tool_call_id),
|
||||
output: coerceResponsesOutput(msg.content)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -402,21 +429,30 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = body.tools.map(tool => {
|
||||
if (tool.type === OPENAI_BLOCK.FUNCTION) {
|
||||
// Strict upstreams reject nameless/overlong tool declarations
|
||||
const name = typeof tool.function?.name === "string" ? tool.function.name.trim() : "";
|
||||
if (!name) return null;
|
||||
return {
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
name: tool.function.name,
|
||||
name: name.slice(0, MAX_TOOL_NAME_LEN),
|
||||
description: String(tool.function.description || ""),
|
||||
parameters: normalizeToolParameters(tool.function.parameters),
|
||||
strict: tool.function.strict
|
||||
};
|
||||
}
|
||||
return tool;
|
||||
});
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
// Pass through other relevant fields
|
||||
if (body.temperature !== undefined) result.temperature = body.temperature;
|
||||
if (body.max_tokens !== undefined) result.max_tokens = body.max_tokens;
|
||||
if (body.max_output_tokens !== undefined) {
|
||||
result.max_output_tokens = body.max_output_tokens;
|
||||
} else if (body.max_completion_tokens !== undefined) {
|
||||
result.max_output_tokens = body.max_completion_tokens;
|
||||
} else if (body.max_tokens !== undefined) {
|
||||
result.max_output_tokens = body.max_tokens;
|
||||
}
|
||||
if (body.top_p !== undefined) result.top_p = body.top_p;
|
||||
if (body.reasoning !== undefined) result.reasoning = body.reasoning;
|
||||
if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { DEFAULT_THINKING_AG_SIGNATURE, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
||||
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
|
||||
import { getGeminiThoughtSignatureSync } from "../../services/thoughtSignatureStore.js";
|
||||
function generateUUID() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
@@ -46,7 +47,7 @@ function normalizeGeminiContents(contents) {
|
||||
}
|
||||
|
||||
// Core: Convert OpenAI request to Gemini format (base for all variants)
|
||||
function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG_SIGNATURE) {
|
||||
function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG_SIGNATURE, sessionId = null) {
|
||||
const result = {
|
||||
model: model,
|
||||
contents: [],
|
||||
@@ -133,18 +134,27 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG
|
||||
|
||||
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
||||
const toolCallIds = [];
|
||||
let firstFunctionCallSeen = false;
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.type !== OPENAI_BLOCK.FUNCTION) continue;
|
||||
|
||||
const args = tryParseJSON(tc.function?.arguments || "{}");
|
||||
parts.push({
|
||||
thoughtSignature: signature,
|
||||
const cachedSig = tc.id ? getGeminiThoughtSignatureSync(tc.id, sessionId) : null;
|
||||
// First call gets cached signature or fallback; sibling calls remain unsigned if no cached sig
|
||||
const callSig = cachedSig || (!firstFunctionCallSeen ? signature : undefined);
|
||||
firstFunctionCallSeen = true;
|
||||
|
||||
const part = {
|
||||
functionCall: {
|
||||
id: tc.id,
|
||||
name: sanitizeGeminiFunctionName(tc.function.name),
|
||||
args: args
|
||||
}
|
||||
});
|
||||
};
|
||||
if (callSig) {
|
||||
part.thoughtSignature = callSig;
|
||||
}
|
||||
parts.push(part);
|
||||
toolCallIds.push(tc.id);
|
||||
}
|
||||
|
||||
@@ -232,13 +242,13 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG
|
||||
}
|
||||
|
||||
// OpenAI -> Gemini (standard API)
|
||||
export function openaiToGeminiRequest(model, body, stream) {
|
||||
return openaiToGeminiBase(model, body, stream);
|
||||
export function openaiToGeminiRequest(model, body, stream, credentials = null) {
|
||||
return openaiToGeminiBase(model, body, stream, DEFAULT_THINKING_AG_SIGNATURE, credentials?._clientSessionId);
|
||||
}
|
||||
|
||||
// OpenAI -> Gemini CLI (Cloud Code Assist)
|
||||
export function openaiToGeminiCLIRequest(model, body, stream) {
|
||||
const gemini = openaiToGeminiBase(model, body, stream, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE);
|
||||
export function openaiToGeminiCLIRequest(model, body, stream, credentials = null) {
|
||||
const gemini = openaiToGeminiBase(model, body, stream, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE, credentials?._clientSessionId);
|
||||
// Thinking is normalized centrally by applyThinking (thinkingUnified.js) after translation.
|
||||
|
||||
// Clean schema for tools
|
||||
@@ -335,18 +345,26 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
|
||||
const parts = [];
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
let firstToolUseSeen = false;
|
||||
for (const block of msg.content) {
|
||||
if (block.type === CLAUDE_BLOCK.TEXT) {
|
||||
parts.push({ text: block.text });
|
||||
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
|
||||
parts.push({
|
||||
thoughtSignature: signature,
|
||||
const cachedSig = block.id ? getGeminiThoughtSignatureSync(block.id, credentials?._clientSessionId) : null;
|
||||
const callSig = cachedSig || (!firstToolUseSeen ? signature : undefined);
|
||||
firstToolUseSeen = true;
|
||||
|
||||
const part = {
|
||||
functionCall: {
|
||||
id: block.id,
|
||||
name: sanitizeGeminiFunctionName(block.name),
|
||||
args: block.input || {}
|
||||
}
|
||||
});
|
||||
};
|
||||
if (callSig) {
|
||||
part.thoughtSignature = callSig;
|
||||
}
|
||||
parts.push(part);
|
||||
} else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) {
|
||||
let content = block.content;
|
||||
if (Array.isArray(content)) {
|
||||
|
||||
@@ -420,7 +420,6 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
if (profileArn) {
|
||||
payload.profileArn = profileArn;
|
||||
}
|
||||
if (systemPrompt) payload.systemPrompt = systemPrompt;
|
||||
if (additionalModelRequestFields) {
|
||||
payload.additionalModelRequestFields = additionalModelRequestFields;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toOpenAIUsage } from "../concerns/usage.js";
|
||||
import { reasoningDelta } from "../concerns/reasoning.js";
|
||||
import { encodeDataUri } from "../concerns/image.js";
|
||||
import { toOpenAIFinish } from "../concerns/finishReason.js";
|
||||
import { storeGeminiThoughtSignature } from "../../services/thoughtSignatureStore.js";
|
||||
|
||||
// Build chunk meta for current gemini state
|
||||
function chunkMeta(state) {
|
||||
@@ -13,14 +14,18 @@ function chunkMeta(state) {
|
||||
}
|
||||
|
||||
// Build a tool_call chunk from a gemini functionCall part (shared by sig/non-sig branches)
|
||||
function emitFunctionCall(functionCall, state) {
|
||||
function emitFunctionCall(functionCall, state, signature = null) {
|
||||
const rawName = functionCall.name;
|
||||
// Restore original tool name from mapping (AG cloaking)
|
||||
const fcName = state.toolNameMap?.get(rawName) || rawName;
|
||||
const fcArgs = functionCall.args || {};
|
||||
const toolCallIndex = state.functionIndex++;
|
||||
const callId = functionCall.id || `${fcName}-${Date.now()}-${toolCallIndex}`;
|
||||
if (signature) {
|
||||
storeGeminiThoughtSignature(callId, signature, state.sessionId);
|
||||
}
|
||||
const toolCall = {
|
||||
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
|
||||
id: callId,
|
||||
index: toolCallIndex,
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
function: { name: fcName, arguments: JSON.stringify(fcArgs) },
|
||||
@@ -57,13 +62,21 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
if (content?.parts) {
|
||||
for (const part of content.parts) {
|
||||
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
|
||||
if (hasThoughtSig && typeof hasThoughtSig === "string") {
|
||||
state.pendingThoughtSignature = hasThoughtSig;
|
||||
}
|
||||
const isThought = part.thought === true;
|
||||
|
||||
|
||||
// Handle thought signature (thinking mode)
|
||||
if (hasThoughtSig) {
|
||||
const hasTextContent = part.text !== undefined && part.text !== "";
|
||||
const hasFunctionCall = !!part.functionCall;
|
||||
|
||||
|
||||
// Standalone thoughtSignature part (no text, no functionCall): keep pending for next functionCall
|
||||
if (!hasTextContent && !hasFunctionCall) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasTextContent) {
|
||||
results.push(buildChunk(
|
||||
chunkMeta(state),
|
||||
@@ -71,9 +84,10 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
if (hasFunctionCall) {
|
||||
results.push(emitFunctionCall(part.functionCall, state));
|
||||
results.push(emitFunctionCall(part.functionCall, state, hasThoughtSig));
|
||||
state.pendingThoughtSignature = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -92,7 +106,9 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
|
||||
// Function call
|
||||
if (part.functionCall) {
|
||||
results.push(emitFunctionCall(part.functionCall, state));
|
||||
const sig = state.pendingThoughtSignature || null;
|
||||
results.push(emitFunctionCall(part.functionCall, state, sig));
|
||||
state.pendingThoughtSignature = null;
|
||||
}
|
||||
|
||||
// Inline data (images)
|
||||
|
||||
@@ -446,6 +446,13 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
state.created = Math.floor(Date.now() / 1000);
|
||||
state.toolCallIndex = 0;
|
||||
state.currentToolCallId = null;
|
||||
// item_id → chat tool_calls index. Deltas carry item_id; keying on it (not
|
||||
// stream position) keeps parallel calls separate when upstream emits all
|
||||
// output_item.added events before any done/delta. Lazily created so callers
|
||||
// that build their own state object (stream.js) need no changes.
|
||||
state.respToolChatIndex ??= new Map();
|
||||
// Indices that already received argument deltas (guards done-with-args).
|
||||
state.respToolArgsEmitted ??= new Set();
|
||||
}
|
||||
|
||||
// Text content delta
|
||||
@@ -464,16 +471,29 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function call started (standard function_call or custom_tool_call)
|
||||
// Function call started (standard function_call or custom_tool_call).
|
||||
// Index is assigned here (not on done): attributing deltas by stream position
|
||||
// merges parallel calls into index 0 whenever upstream emits all addeds
|
||||
// before dones — the client then concatenates N JSON payloads into one
|
||||
// tool input and fails validation. The server item id is the correlator.
|
||||
if (eventType === "response.output_item.added" && (data.item?.type === RESPONSES_ITEM.FUNCTION_CALL || data.item?.type === "custom_tool_call")) {
|
||||
const item = data.item;
|
||||
state.currentToolCallId = item.call_id || fallbackToolCallId();
|
||||
state.respToolChatIndex ??= new Map();
|
||||
const key = item.id || data.item_id || state.currentToolCallId;
|
||||
let idx;
|
||||
if (key && state.respToolChatIndex.has(key)) {
|
||||
idx = state.respToolChatIndex.get(key); // duplicate added (retry) — reuse
|
||||
} else {
|
||||
idx = state.toolCallIndex++;
|
||||
if (key) state.respToolChatIndex.set(key, idx);
|
||||
}
|
||||
|
||||
return buildChunk(
|
||||
{ id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK },
|
||||
{
|
||||
tool_calls: [{
|
||||
index: state.toolCallIndex,
|
||||
index: idx,
|
||||
id: state.currentToolCallId,
|
||||
type: OPENAI_BLOCK.FUNCTION,
|
||||
function: { name: item.name || "", arguments: "" }
|
||||
@@ -482,20 +502,39 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
);
|
||||
}
|
||||
|
||||
// Function call arguments delta (standard or custom_tool_call variant)
|
||||
// Function call arguments delta (standard or custom_tool_call variant).
|
||||
// Routed by item_id so interleaved parallel fragments stay on their own call.
|
||||
if (eventType === "response.function_call_arguments.delta" || eventType === "response.custom_tool_call_input.delta") {
|
||||
const argsDelta = data.delta || "";
|
||||
if (!argsDelta) return null;
|
||||
|
||||
const known = data.item_id ? state.respToolChatIndex?.get(data.item_id) : undefined;
|
||||
const idx = known ?? Math.max(0, (state.toolCallIndex || 1) - 1);
|
||||
state.respToolArgsEmitted ??= new Set();
|
||||
state.respToolArgsEmitted.add(idx);
|
||||
return buildChunk(
|
||||
{ id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK },
|
||||
{ tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsDelta } }] }
|
||||
{ tool_calls: [{ index: idx, function: { arguments: argsDelta } }] }
|
||||
);
|
||||
}
|
||||
|
||||
// Function call done (standard or custom_tool_call variant)
|
||||
// Function call done (standard or custom_tool_call variant).
|
||||
// Index was assigned at added-time; nothing to advance. Some upstreams send
|
||||
// complete arguments only here (no deltas) — emit them once in that case.
|
||||
if (eventType === "response.output_item.done" && (data.item?.type === RESPONSES_ITEM.FUNCTION_CALL || data.item?.type === "custom_tool_call")) {
|
||||
state.toolCallIndex++;
|
||||
const key = data.item?.id || data.item_id;
|
||||
const idx = (key && state.respToolChatIndex?.get(key)) ?? Math.max(0, (state.toolCallIndex || 1) - 1);
|
||||
const fullArgs = data.item?.arguments;
|
||||
if (typeof fullArgs === "string" && fullArgs) {
|
||||
state.respToolArgsEmitted ??= new Set();
|
||||
if (!state.respToolArgsEmitted.has(idx)) {
|
||||
state.respToolArgsEmitted.add(idx);
|
||||
return buildChunk(
|
||||
{ id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK },
|
||||
{ tool_calls: [{ index: idx, function: { arguments: fullArgs } }] }
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export const CLAUDE_BLOCK = {
|
||||
TOOL_RESULT: "tool_result",
|
||||
THINKING: "thinking",
|
||||
REDACTED_THINKING: "redacted_thinking",
|
||||
SERVER_TOOL_USE: "server_tool_use",
|
||||
WEB_SEARCH_TOOL_RESULT: "web_search_tool_result",
|
||||
};
|
||||
|
||||
// OpenAI Responses API item types.
|
||||
|
||||
Reference in New Issue
Block a user