fix(translator): keep tool-result images, restore Kiro tool names, preserve thinking display
Forward images inside tool_result to OpenAI and Kiro upstreams via following user messages, restore original client tool names on Kiro responses via _toolNameMap, and preserve thinking display settings across translations.
This commit is contained in:
@@ -127,7 +127,7 @@ export class BaseExecutor {
|
||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||
const url = this.buildUrl(model, stream, urlIndex, credentials);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
const headers = this.buildHeaders(credentials, stream, url, model);
|
||||
const headers = this.buildHeaders(credentials, stream, url, model, transformedBody);
|
||||
|
||||
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
return BEARER;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true, url, model) {
|
||||
buildHeaders(credentials, stream = true, url, model, body = null) {
|
||||
const rt = credentials?.runtimeTransport;
|
||||
const headers = { "Content-Type": "application/json", ...(rt ? rt.headers : this.config.headers) };
|
||||
const desc = rt?.auth || AUTH_DESCRIPTORS[this.provider] || this.resolveAuthDescriptor();
|
||||
@@ -166,7 +166,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
const isClaudeModel = typeof model === "string" && /^claude-/.test(model);
|
||||
if (model && (this.provider === "claude"
|
||||
|| (this.provider?.startsWith?.("anthropic-compatible-") && isClaudeModel))) {
|
||||
headers["Anthropic-Beta"] = selectAnthropicBeta(model);
|
||||
headers["Anthropic-Beta"] = selectAnthropicBeta(model, body);
|
||||
}
|
||||
|
||||
// Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams
|
||||
|
||||
@@ -62,8 +62,17 @@ const ANTHROPIC_BETA_BASE = [
|
||||
const ANTHROPIC_BETA_HEAVY_AGENT = ["advanced-tool-use-2025-11-20", "effort-2025-11-24"];
|
||||
|
||||
// Heavy-agent beta flags are gated to opus/sonnet — cheaper models don't need them.
|
||||
export function selectAnthropicBeta(model = "") {
|
||||
const flags = [...ANTHROPIC_BETA_BASE];
|
||||
// `redact-thinking` asks Anthropic to return signature-only thinking blocks, which
|
||||
// is right for clients that never render thinking but blanks the summaries a
|
||||
// client explicitly requested with `thinking.display: "summarized"`.
|
||||
const ANTHROPIC_BETA_REDACT_THINKING = "redact-thinking-2026-02-12";
|
||||
|
||||
export function wantsThinkingSummaries(body) {
|
||||
return body?.thinking?.display === "summarized";
|
||||
}
|
||||
|
||||
export function selectAnthropicBeta(model = "", body = null) {
|
||||
const flags = ANTHROPIC_BETA_BASE.filter((flag) => flag !== ANTHROPIC_BETA_REDACT_THINKING || !wantsThinkingSummaries(body));
|
||||
if (/^claude-(opus|sonnet)/.test(model)) flags.push(...ANTHROPIC_BETA_HEAVY_AGENT);
|
||||
return flags.join(",");
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ function stripAll(body) {
|
||||
}
|
||||
|
||||
// Apply unified thinking config to body in the resolved provider-native format.
|
||||
function applyFormat(fmt, body, cfg, caps, supportedLevels) {
|
||||
function applyFormat(fmt, body, cfg, caps, supportedLevels, display) {
|
||||
const none = cfg.mode === "none";
|
||||
const canDisable = caps.thinkingCanDisable !== false;
|
||||
// Model cannot disable thinking → clamp "none" to minimal effort instead.
|
||||
@@ -249,7 +249,7 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) {
|
||||
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
|
||||
// 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" };
|
||||
if (canDisable) body.thinking = { type: "adaptive", ...(display ? { display } : {}) };
|
||||
else delete body.thinking;
|
||||
const level = toLevel(eff);
|
||||
body.output_config = { effort: level === "xhigh" || level === "auto" ? "high" : level };
|
||||
@@ -258,7 +258,7 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) {
|
||||
case "claude-budget": {
|
||||
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
|
||||
const budget = toBudget(eff, caps.thinkingRange);
|
||||
body.thinking = budget === -1 ? { type: "enabled" } : { type: "enabled", budget_tokens: budget || 8192 };
|
||||
body.thinking = budget === -1 ? { type: "enabled", ...(display ? { display } : {}) } : { type: "enabled", budget_tokens: budget || 8192, ...(display ? { display } : {}) };
|
||||
break;
|
||||
}
|
||||
case "gemini-level": {
|
||||
@@ -378,7 +378,10 @@ export function applyThinking(targetFormat, model, body, provider = null, intent
|
||||
|
||||
const fmt = resolveFormat(targetFormat, cleanModel, provider);
|
||||
const supportedLevels = getThinkingLevels(provider, cleanModel);
|
||||
// Anthropic's `display` (summarized | omitted) decides whether thinking text
|
||||
// comes back at all; keep what the client asked for instead of resetting it.
|
||||
const display = typeof body.thinking?.display === "string" ? body.thinking.display : undefined;
|
||||
stripAll(body);
|
||||
applyFormat(fmt, body, cfg, caps, supportedLevels);
|
||||
applyFormat(fmt, body, cfg, caps, supportedLevels, display);
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -415,6 +415,28 @@ export function anchorClaudeCache(body) {
|
||||
// - Add thinking block for Anthropic endpoint (provider === "claude")
|
||||
// - Fix tool_use/tool_result ordering
|
||||
// - Apply cloaking (billing header + fake user ID) for OAuth tokens
|
||||
export function hoistToolResultImages(body) {
|
||||
if (!Array.isArray(body?.messages)) return body;
|
||||
let touched = false;
|
||||
const messages = body.messages.map((msg) => {
|
||||
if (msg?.role !== ROLE.USER || !Array.isArray(msg.content)) return msg;
|
||||
const hoisted = [];
|
||||
const content = msg.content.map((block) => {
|
||||
if (block?.type !== CLAUDE_BLOCK.TOOL_RESULT || !Array.isArray(block.content)) return block;
|
||||
const images = block.content.filter((c) => c?.type === CLAUDE_BLOCK.IMAGE);
|
||||
if (!images.length) return block;
|
||||
const rest = block.content.filter((c) => c?.type !== CLAUDE_BLOCK.IMAGE);
|
||||
hoisted.push({ type: CLAUDE_BLOCK.TEXT, text: `[Image from tool result ${block.tool_use_id}]` }, ...images);
|
||||
return { ...block, content: rest.length ? rest : [{ type: CLAUDE_BLOCK.TEXT, text: "(image attached below)" }] };
|
||||
});
|
||||
if (!hoisted.length) return msg;
|
||||
touched = true;
|
||||
// tool_result blocks must lead a user message; the hoisted image follows them.
|
||||
return { ...msg, content: [...content, ...hoisted] };
|
||||
});
|
||||
return touched ? { ...body, messages } : body;
|
||||
}
|
||||
|
||||
export function prepareClaudeRequest(body, provider = null, apiKey = null, connectionId = null, rawHeaders = null, sessionId = null) {
|
||||
// quirk: MiniMax's Claude-compatible endpoint rejects Anthropic's output_config (400 invalid params)
|
||||
if (PROVIDERS[provider]?.quirks?.dropOutputConfig) {
|
||||
@@ -608,6 +630,14 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic itself reads images inside tool_result; other Anthropic-compatible
|
||||
// endpoints (OpenCode Go, Kimi, DeepSeek, GLM, MiniMax) accept image blocks
|
||||
// only as user content and silently drop them inside a tool result. Move a
|
||||
// tool's screenshot out of the result and into the same user turn.
|
||||
if (provider !== "claude" && !provider?.startsWith("anthropic-compatible")) {
|
||||
body = hoistToolResultImages(body);
|
||||
}
|
||||
|
||||
// Apply cloaking for OAuth tokens (billing header + fake user ID)
|
||||
// session_id in user_id must match X-Claude-Code-Session-Id for fingerprint consistency
|
||||
if ((provider === "claude" || provider?.startsWith("anthropic-compatible")) && apiKey) {
|
||||
|
||||
@@ -97,11 +97,21 @@ function convertClaudeMessagesToKiro(messages, model) {
|
||||
if (typeof block.content === "string") {
|
||||
resultContent = block.content;
|
||||
} else if (Array.isArray(block.content)) {
|
||||
// Images a tool returned (screenshots) ride along as user images;
|
||||
// Kiro tool results are text-only.
|
||||
let hasImage = false;
|
||||
for (const c of block.content) {
|
||||
if (c?.type === CLAUDE_BLOCK.IMAGE && c.source?.type === "base64") {
|
||||
hasImage = true;
|
||||
const imageType = c.source.media_type || DEFAULT_IMAGE_MIME;
|
||||
pendingImages.push({ format: imageType.split("/")[1] || imageType, source: { bytes: c.source.data } });
|
||||
}
|
||||
}
|
||||
resultContent =
|
||||
block.content
|
||||
.filter((c) => c.type === CLAUDE_BLOCK.TEXT)
|
||||
.map((c) => c.text)
|
||||
.join("\n") || JSON.stringify(block.content);
|
||||
.join("\n") || (hasImage ? "(image attached)" : JSON.stringify(block.content));
|
||||
} else if (block.content) {
|
||||
resultContent = JSON.stringify(block.content);
|
||||
}
|
||||
@@ -341,6 +351,13 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
enumerable: false,
|
||||
});
|
||||
|
||||
// Kiro tool specs get sanitized names (`mcp__a__b` → `mcp_a_b`); keep the
|
||||
// reverse map so tool calls stream back under the client's own names.
|
||||
const restoredToolNames = new Map();
|
||||
for (const [original, sanitized] of nameMap) {
|
||||
if (original !== sanitized) restoredToolNames.set(sanitized, original);
|
||||
}
|
||||
if (restoredToolNames.size) payload._toolNameMap = restoredToolNames;
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -196,25 +196,41 @@ function convertClaudeMessage(msg) {
|
||||
});
|
||||
break;
|
||||
|
||||
case CLAUDE_BLOCK.TOOL_RESULT:
|
||||
case CLAUDE_BLOCK.TOOL_RESULT: {
|
||||
let resultContent = "";
|
||||
const resultImages = [];
|
||||
if (typeof block.content === "string") {
|
||||
resultContent = block.content;
|
||||
} else if (Array.isArray(block.content)) {
|
||||
resultContent = block.content
|
||||
.filter(c => c.type === CLAUDE_BLOCK.TEXT)
|
||||
.map(c => c.text)
|
||||
.join("\n") || JSON.stringify(block.content);
|
||||
for (const c of block.content) {
|
||||
if (c?.type === CLAUDE_BLOCK.IMAGE && c.source?.type === "base64") {
|
||||
resultImages.push({
|
||||
type: OPENAI_BLOCK.IMAGE_URL,
|
||||
image_url: { url: encodeDataUri(c.source.media_type, c.source.data) }
|
||||
});
|
||||
}
|
||||
}
|
||||
const textOnly = block.content.filter(c => c?.type === CLAUDE_BLOCK.TEXT);
|
||||
resultContent = textOnly.map(c => c.text).join("\n")
|
||||
|| (resultImages.length ? "" : JSON.stringify(block.content));
|
||||
} else if (block.content) {
|
||||
resultContent = JSON.stringify(block.content);
|
||||
}
|
||||
|
||||
|
||||
toolResults.push({
|
||||
role: ROLE.TOOL,
|
||||
tool_call_id: block.tool_use_id,
|
||||
content: resultContent
|
||||
});
|
||||
// The OpenAI tool role is text-only, so a screenshot or any other image a
|
||||
// tool returned would otherwise vanish. Hand it to the model in the user
|
||||
// turn that follows the tool messages, tagged with the call it came from.
|
||||
if (resultImages.length) {
|
||||
parts.push({ type: OPENAI_BLOCK.TEXT, text: `[Image from tool result ${block.tool_use_id}]` });
|
||||
parts.push(...resultImages);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,16 +42,19 @@ function toNativeImageBlock(part) {
|
||||
type: OPENAI_BLOCK.IMAGE,
|
||||
image: encodeDataUri(parsed.mimeType, parsed.base64),
|
||||
mimeType: parsed.mimeType,
|
||||
mediaType: parsed.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === OPENAI_BLOCK.IMAGE || part.type === CLAUDE_BLOCK.IMAGE) {
|
||||
if (typeof part.image === "string" && part.image.startsWith("data:")) {
|
||||
const parsed = parseDataUri(part.image);
|
||||
const mime = part.mimeType || parsed?.mimeType || "image/png";
|
||||
return {
|
||||
type: OPENAI_BLOCK.IMAGE,
|
||||
image: part.image,
|
||||
mimeType: part.mimeType || parsed?.mimeType || "image/png",
|
||||
mimeType: mime,
|
||||
mediaType: mime,
|
||||
};
|
||||
}
|
||||
const source = part.source;
|
||||
@@ -61,6 +64,7 @@ function toNativeImageBlock(part) {
|
||||
type: OPENAI_BLOCK.IMAGE,
|
||||
image: encodeDataUri(mime, source.data),
|
||||
mimeType: mime,
|
||||
mediaType: mime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,6 +434,13 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
enumerable: false
|
||||
});
|
||||
|
||||
// Kiro tool specs get sanitized names (`mcp__a__b` → `mcp_a_b`); keep the
|
||||
// reverse map so tool calls stream back under the client's own names.
|
||||
const restoredToolNames = new Map();
|
||||
for (const [original, sanitized] of nameMap) {
|
||||
if (original !== sanitized) restoredToolNames.set(sanitized, original);
|
||||
}
|
||||
if (restoredToolNames.size) payload._toolNameMap = restoredToolNames;
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ function convertFinishReason(reason) {
|
||||
* Convert one OpenAI-format chunk (from KiroExecutor) into Claude SSE events.
|
||||
* Returns an array of Claude events, or null when the chunk yields nothing.
|
||||
*/
|
||||
// Kiro only accepts sanitized tool names; the request translator leaves the
|
||||
// reverse map on the stream state so calls come back under the client's names.
|
||||
function restoreToolName(state, name) {
|
||||
const raw = name || "";
|
||||
const map = state?.toolNameMap;
|
||||
return map && typeof map.get === "function" && map.has(raw) ? map.get(raw) : raw;
|
||||
}
|
||||
|
||||
export function kiroToClaudeResponse(chunk, state) {
|
||||
// KiroExecutor emits chat.completion.chunk objects; tolerate string chunks
|
||||
// by attempting a parse (defensive — the direct path is always objects).
|
||||
@@ -161,7 +169,7 @@ export function kiroToClaudeResponse(chunk, state) {
|
||||
const toolBlockIndex = state.nextBlockIndex++;
|
||||
state.toolCalls.set(idx, {
|
||||
id: tc.id,
|
||||
name: tc.function?.name || "",
|
||||
name: restoreToolName(state, tc.function?.name),
|
||||
blockIndex: toolBlockIndex,
|
||||
});
|
||||
results.push({
|
||||
@@ -170,7 +178,7 @@ export function kiroToClaudeResponse(chunk, state) {
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: tc.id,
|
||||
name: tc.function?.name || "",
|
||||
name: restoreToolName(state, tc.function?.name),
|
||||
input: {},
|
||||
},
|
||||
});
|
||||
@@ -246,7 +254,7 @@ export function kiroToClaudeNonStreaming(data) {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: tc.id || `toolu_${Date.now()}`,
|
||||
name: tc.function?.name || "",
|
||||
name: restoreToolName(state, tc.function?.name),
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,13 +20,38 @@ function chunkMeta(state) {
|
||||
* Parse Kiro SSE event and convert to OpenAI format
|
||||
* Kiro events: assistantResponseEvent, codeEvent, supplementaryWebLinksEvent, etc.
|
||||
*/
|
||||
// Kiro only accepts sanitized tool names; the request translator leaves the
|
||||
// reverse map on the stream state so calls come back under the client's names.
|
||||
function restoreToolName(state, name) {
|
||||
const raw = name || "";
|
||||
const map = state?.toolNameMap;
|
||||
return map && typeof map.get === "function" && map.has(raw) ? map.get(raw) : raw;
|
||||
}
|
||||
|
||||
export function kiroToOpenAIResponse(chunk, state) {
|
||||
|
||||
if (!chunk) return null;
|
||||
|
||||
// If chunk is already in OpenAI format (from executor transform), return as-is
|
||||
// If chunk is already in OpenAI format (from executor transform), return it
|
||||
// with the client's tool names restored.
|
||||
if (chunk.object === "chat.completion.chunk" && chunk.choices) {
|
||||
return chunk;
|
||||
if (!state?.toolNameMap?.size) return chunk;
|
||||
return {
|
||||
...chunk,
|
||||
choices: chunk.choices.map((choice) => {
|
||||
const calls = choice?.delta?.tool_calls;
|
||||
if (!Array.isArray(calls)) return choice;
|
||||
return {
|
||||
...choice,
|
||||
delta: {
|
||||
...choice.delta,
|
||||
tool_calls: calls.map((tc) => tc?.function?.name
|
||||
? { ...tc, function: { ...tc.function, name: restoreToolName(state, tc.function.name) } }
|
||||
: tc),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle string chunk (raw SSE data)
|
||||
@@ -109,7 +134,7 @@ export function kiroToOpenAIResponse(chunk, state) {
|
||||
state.hadToolUse = true;
|
||||
const toolUse = data.toolUseEvent || data;
|
||||
const toolCallId = toolUse.toolUseId || fallbackToolCallId();
|
||||
const toolName = toolUse.name || "";
|
||||
const toolName = restoreToolName(state, toolUse.name);
|
||||
const toolInput = toolUse.input || {};
|
||||
|
||||
const openaiChunk = buildChunk(chunkMeta(state), {
|
||||
|
||||
Reference in New Issue
Block a user