Merge origin/master (v0.5.81) into gitea/new_feature

Resolve conflicts:
- streamingHandler.js: merge buildStreamErrorBytes onAbortTerminal + local shouldPersistRequestDetail & streamStatusForContent
- capabilities.js: preserve server-injected user-asserted caps and models.dev catalog lookup; wire CommandCode /alpha/generate caps inside resolve()
- commandcode.js (services/usage): adopt upstream whoami + billing credits/subscriptions with 5h/weekly rate windows and plan caps
- openai-to-commandcode.js: merge toNativeImageBlock (data-URI & http(s) support) and assistant reasoning_content preservation
- commandcode-to-openai.js: adopt upstream mid-stream error throw for clean retry and abortion
- tests: sync commandcode test suite and exclude .next from vitest config
This commit is contained in:
2026-09-22 10:08:58 +07:00
95 changed files with 4059 additions and 1016 deletions

View File

@@ -5,6 +5,20 @@ import {
} from "../../config/kiroConstants.js";
const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
/**
* Kiro rejects user turns with empty `content`, so a turn that only carries
* tool results needs placeholder text. It must not read like a user
* instruction: with "continue", models answer the word itself ("Nothing in
* progress to continue") and drop the task they were in the middle of.
*/
export const KIRO_TOOL_RESULTS_PLACEHOLDER = "Tool results provided.";
export const KIRO_EMPTY_USER_PLACEHOLDER = "continue";
/** Placeholder content for a user turn with no text of its own. */
export function kiroEmptyUserContent(hasToolResults) {
return hasToolResults ? KIRO_TOOL_RESULTS_PLACEHOLDER : KIRO_EMPTY_USER_PLACEHOLDER;
}
const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g;
function clone(value) {
@@ -34,7 +48,6 @@ function uniqueName(rawName, index, usedNames) {
const cleaned = String(rawName || "")
.trim()
.replace(TOOL_NAME_PATTERN, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
const base = trimCodePoints(cleaned || `tool_${index + 1}`, KIRO_TOOL_NAME_MAX_LENGTH);
let candidate = base;
@@ -174,7 +187,8 @@ function normalizeTurns(history, currentMessage, modelId) {
for (const turn of turns) {
if (turn.userInputMessage) {
turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue";
turn.userInputMessage.content = text(turn.userInputMessage.content).trim()
|| kiroEmptyUserContent(turn.userInputMessage.userInputMessageContext?.toolResults?.length > 0);
turn.userInputMessage.modelId ||= modelId;
if (turn.userInputMessage.userInputMessageContext?.tools) {
delete turn.userInputMessage.userInputMessageContext.tools;

View File

@@ -8,6 +8,7 @@ import { fetchImageAsBase64, parseDataUri } from "./image.js";
const TARGETS_NEED_BASE64 = new Set([
FORMATS.GEMINI, FORMATS.GEMINI_CLI, FORMATS.VERTEX,
FORMATS.ANTIGRAVITY, FORMATS.OLLAMA, FORMATS.KIRO,
FORMATS.COMMANDCODE,
]);
function isRemoteUrl(url) {

View File

@@ -19,6 +19,7 @@ const FORMAT_TO_NATIVE = {
vertex: "gemini-budget",
antigravity: "gemini-budget",
kiro: "kiro",
commandcode: "commandcode",
};
// Strip a trailing thinking suffix "model(value)" → "model" (no-op when absent).
@@ -108,6 +109,7 @@ export const captureThinking = extractThinking;
const NATIVE_ONLY_FORMATS = new Set(["gemini-level", "gemini-budget", "claude-budget", "claude-adaptive", "kiro"]);
function resolveFormat(targetFormat, model, provider) {
if (targetFormat === "commandcode") return "commandcode";
const providerFmt = provider ? PROVIDERS[provider]?.thinkingFormat : null;
if (providerFmt) return providerFmt;
const caps = getCapabilitiesForModel(provider, model);
@@ -223,10 +225,14 @@ function stripAll(body) {
delete body.output_config;
if (body.generationConfig) delete body.generationConfig.thinkingConfig;
if (body.request?.generationConfig) delete body.request.generationConfig.thinkingConfig;
if (body.params && typeof body.params === "object") {
delete body.params.reasoning_effort;
delete body.params.thinking;
}
}
// 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.
@@ -243,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 };
@@ -252,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": {
@@ -336,6 +342,17 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) {
case "kiro":
// Kiro thinking handled via system-tag injection in openai-to-kiro.js; no body field here.
break;
case "commandcode": {
// Native CLI sends reasoning_effort inside params of the /alpha/generate envelope.
if (!body.params || typeof body.params !== "object") body.params = {};
if (none && canDisable) {
delete body.params.reasoning_effort;
break;
}
const level = toLevel(eff);
if (level) body.params.reasoning_effort = level;
break;
}
default:
break;
}
@@ -361,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

@@ -34,6 +34,7 @@ import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
import {
canonicalizeKiroConversation,
normalizeKiroToolSpecs,
kiroEmptyUserContent,
} from "../concerns/kiroConversation.js";
/**
@@ -53,7 +54,8 @@ function convertClaudeMessagesToKiro(messages, model) {
const flushPending = () => {
if (currentRole === ROLE.USER) {
const content = pendingUserContent.join("\n\n").trim() || "continue";
const content = pendingUserContent.join("\n\n").trim()
|| kiroEmptyUserContent(pendingToolResults.length > 0);
const userMsg = { userInputMessage: { content, modelId: model } };
if (pendingImages.length > 0) {
@@ -97,11 +99,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 +353,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

@@ -5,6 +5,7 @@
* - params.system: STRING at top level (Anthropic-style; system messages NOT allowed in messages[])
* - params.messages[*].role ∈ {"user","assistant","tool"}
* - params.messages[*].content: Array of content blocks (NEVER a string)
* - image_url / image source → {type:"image", image:"data:...;base64,...", mimeType}
* - tool_use blocks (assistant): {type:"tool-call", toolCallId, toolName, input}
* - tool_result blocks (role=user): {type:"tool-result", toolCallId, toolName, output}
* - tools[*]: Anthropic plain {name, description, input_schema}
@@ -12,10 +13,9 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { randomUUID } from "crypto";
import { ROLE, OPENAI_BLOCK } from "../schema/index.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
import { parseDataUri } from "../concerns/image.js";
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js";
import { parseDataUri, encodeDataUri } from "../concerns/image.js";
function flattenText(content) {
if (content == null) return "";
@@ -32,6 +32,58 @@ function flattenText(content) {
return String(content);
}
function toNativeImageBlock(part) {
if (!part || typeof part !== "object") return null;
if (part.type === OPENAI_BLOCK.IMAGE_URL) {
const url = typeof part.image_url === "string" ? part.image_url : part.image_url?.url;
if (!url) return null;
const parsed = parseDataUri(url);
if (parsed) {
return {
type: OPENAI_BLOCK.IMAGE,
image: encodeDataUri(parsed.mimeType, parsed.base64),
mimeType: parsed.mimeType,
};
}
if (typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://"))) {
return {
type: OPENAI_BLOCK.IMAGE,
image: url,
};
}
return null;
}
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);
return {
type: OPENAI_BLOCK.IMAGE,
image: part.image,
mimeType: part.mimeType || parsed?.mimeType || "image/png",
};
}
if (typeof part.image === "string" && (part.image.startsWith("http://") || part.image.startsWith("https://"))) {
return {
type: OPENAI_BLOCK.IMAGE,
image: part.image,
};
}
const source = part.source;
if (source?.type === "base64" && typeof source.data === "string") {
const mime = source.media_type || "image/png";
return {
type: OPENAI_BLOCK.IMAGE,
image: encodeDataUri(mime, source.data),
mimeType: mime,
};
}
}
return null;
}
function toContentBlocks(content) {
if (content == null) return [{ type: OPENAI_BLOCK.TEXT, text: "" }];
if (typeof content === "string")
@@ -44,30 +96,12 @@ function toContentBlocks(content) {
} else if (part && typeof part === "object") {
if (part.type === OPENAI_BLOCK.TEXT && typeof part.text === "string") {
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
} else if (
part.type === OPENAI_BLOCK.IMAGE_URL ||
part.type === OPENAI_BLOCK.IMAGE
) {
// CommandCode `/alpha/generate` accepts {type:"image", image:"<data URI | url>"} —
// same shape the official command-code CLI sends (verified from CLI source).
const src = part.source;
let raw = part.image_url?.url || src?.data || src?.url || "";
let parsed = parseDataUri(raw);
if (!parsed && src?.type === "base64" && src?.data) {
// Claude-style base64 source without a data-URI prefix → wrap it.
raw = `data:${src.media_type || DEFAULT_IMAGE_MIME};base64,${src.data}`;
parsed = parseDataUri(raw);
} else {
const image = toNativeImageBlock(part);
if (image) blocks.push(image);
else if (typeof part.text === "string") {
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
}
if (parsed) {
blocks.push({
type: "image",
image: `data:${parsed.mimeType};base64,${parsed.base64}`,
});
} else if (raw) {
blocks.push({ type: "image", image: raw });
}
} else if (typeof part.text === "string") {
blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text });
}
}
}
@@ -119,6 +153,10 @@ function convertMessages(messages = []) {
if (role === ROLE.ASSISTANT) {
const blocks = [];
const rc = m.reasoning_content || m.thought || m.reasoning;
if (rc || (Array.isArray(m.tool_calls) && m.tool_calls.length > 0)) {
blocks.push({ type: "reasoning", text: rc || " " });
}
const text = flattenText(m.content);
if (text) blocks.push({ type: OPENAI_BLOCK.TEXT, text });
if (Array.isArray(m.tool_calls)) {

View File

@@ -129,7 +129,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG
if (tc.type !== OPENAI_BLOCK.FUNCTION) continue;
const args = tryParseJSON(tc.function?.arguments || "{}");
const cachedSig = tc.id ? getGeminiThoughtSignatureSync(tc.id, sessionId) : null;
const cachedSig = tc.id ? getGeminiThoughtSignatureSync(tc.id, sessionId, model) : null;
// First call gets cached signature or fallback; sibling calls remain unsigned if no cached sig
const callSig = cachedSig || (!firstFunctionCallSeen ? signature : undefined);
firstFunctionCallSeen = true;
@@ -341,7 +341,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
if (block.type === CLAUDE_BLOCK.TEXT) {
parts.push({ text: block.text });
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
const cachedSig = block.id ? getGeminiThoughtSignatureSync(block.id, credentials?._clientSessionId) : null;
const cachedSig = block.id ? getGeminiThoughtSignatureSync(block.id, credentials?._clientSessionId, model) : null;
const callSig = cachedSig || (!firstToolUseSeen ? signature : undefined);
firstToolUseSeen = true;

View File

@@ -23,6 +23,7 @@ import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
import {
canonicalizeKiroConversation,
normalizeKiroToolSpecs,
kiroEmptyUserContent,
} from "../concerns/kiroConversation.js";
/**
@@ -51,7 +52,8 @@ function convertMessages(messages, model) {
const flushPending = () => {
if (currentRole === "user") {
const content = pendingUserContent.join("\n\n").trim() || "continue";
const content = pendingUserContent.join("\n\n").trim()
|| kiroEmptyUserContent(pendingToolResults.length > 0);
const userMsg = {
userInputMessage: {
content: content,
@@ -434,6 +436,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

@@ -183,27 +183,12 @@ export function commandCodeToOpenAIResponse(chunk, state) {
break;
}
case "error": {
// Terminal upstream failure (AI SDK v5 error event) — NOT content. Emit an
// OpenAI-shaped error chunk (chunk.error) so downstream — parseSSEToOpenAIResponse
// for non-streaming, OpenAI SDK clients for streaming — treats the request as
// failed instead of surfacing fake success content like "[CommandCode error: ...]".
state.finishReason = OPENAI_FINISH.STOP;
const errVal = event.error ?? event.message ?? "unknown";
const errStr =
typeof errVal === "string"
? errVal
: typeof errVal?.message === "string"
? errVal.message
: JSON.stringify(errVal);
const errType =
typeof errVal === "string"
? "upstream_error"
: errVal?.type || "upstream_error";
const errChunk = makeChunk(state, {});
errChunk.error = { message: errStr, type: errType };
out.push(errChunk);
out.push(makeChunk(state, {}, OPENAI_FINISH.STOP));
break;
typeof errVal === "string" ? errVal : JSON.stringify(errVal);
// Mid-stream error: throw rather than emitting as fake content with finish_reason: "stop"
// This ensures the downstream stream handler marks the stream as errored/aborted.
throw new Error(`[CommandCode error: ${errStr}]`);
}
// Silently ignore: start, start-step, reasoning-start, reasoning-end, text-start, text-end,
// provider-metadata, message-metadata, etc. They carry no client-visible content.

View File

@@ -22,7 +22,7 @@ function emitFunctionCall(functionCall, state, signature = null) {
const toolCallIndex = state.functionIndex++;
const callId = functionCall.id || `${fcName}-${Date.now()}-${toolCallIndex}`;
if (signature) {
storeGeminiThoughtSignature(callId, signature, state.sessionId);
storeGeminiThoughtSignature(callId, signature, state.sessionId, state.model);
}
const toolCall = {
id: callId,
@@ -52,7 +52,7 @@ export function geminiToOpenAIResponse(chunk, state) {
// Initialize state
if (!state.messageId) {
state.messageId = response.responseId || `msg_${Date.now()}`;
state.model = response.modelVersion || "gemini";
state.model = response.modelVersion || state.model || "gemini";
state.functionIndex = 0;
state.geminiToolCallCount = 0;
results.push(buildChunk(chunkMeta(state), { role: ROLE.ASSISTANT }, null));

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(stateOrData, name) {
const raw = name || "";
const map = stateOrData?.toolNameMap || stateOrData?._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(data, 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), {