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:
Manan Santoki
2026-09-17 18:11:20 +07:00
parent 0c6ab4f99b
commit f4f06f290c
12 changed files with 328 additions and 23 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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(",");
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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,
};
}
}

View File

@@ -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;
}

View File

@@ -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,
});
}

View File

@@ -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), {