Merge remote-tracking branch 'origin/master' into gitea/feature/end

Resolved conflicts taking origin/master (v0.5.55) as canonical, with local
features re-applied:
- runtime log level (LOG_LEVEL env + dashboard Settings → Logging, applied
  immediately and persisted across restarts)
- free/noAuth provider enable/disable toggle via providerStrategies.enabled
- parallel model testing (Test All Models / Test Selected Keys)
This commit is contained in:
2026-08-17 00:38:31 +07:00
parent e7470e955e
commit 7e45ead2ac
557 changed files with 53396 additions and 6935 deletions

View File

@@ -0,0 +1,435 @@
import {
KIRO_TOOL_DESCRIPTION_MAX_LENGTH,
KIRO_TOOL_ID_MAX_LENGTH,
KIRO_TOOL_NAME_MAX_LENGTH,
} from "../../config/kiroConstants.js";
const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g;
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function text(value) {
if (typeof value === "string") return value;
if (value == null) return "";
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function appendText(target, extra) {
if (!extra) return;
target.content = target.content ? `${target.content}\n\n${extra}` : extra;
}
function trimCodePoints(value, limit) {
return [...String(value || "")].slice(0, limit).join("");
}
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;
let suffix = 2;
while (usedNames.has(candidate)) {
const tail = `_${suffix++}`;
candidate = `${base.slice(0, KIRO_TOOL_NAME_MAX_LENGTH - tail.length)}${tail}`;
}
usedNames.add(candidate);
return candidate;
}
function cleanSchemaValue(value) {
if (Array.isArray(value)) return value.map(cleanSchemaValue);
if (!value || typeof value !== "object") return value;
const cleaned = {};
for (const [key, child] of Object.entries(value)) {
if (key === "additionalProperties") continue;
if (key === "required" && Array.isArray(child) && child.length === 0) continue;
cleaned[key] = cleanSchemaValue(child);
}
return cleaned;
}
function normalizeRootSchema(schema) {
const cleaned = cleanSchemaValue(schema && typeof schema === "object" ? clone(schema) : {});
cleaned.type = "object";
if (!cleaned.properties || typeof cleaned.properties !== "object" || Array.isArray(cleaned.properties)) {
cleaned.properties = {};
}
if (Array.isArray(cleaned.required)) {
cleaned.required = [...new Set(cleaned.required.filter(
(name) => typeof name === "string" && Object.hasOwn(cleaned.properties, name)
))];
if (cleaned.required.length === 0) delete cleaned.required;
}
return cleaned;
}
/** Normalize OpenAI- or Claude-shaped tool definitions into Kiro tool specs. */
export function normalizeKiroToolSpecs(tools) {
const specs = [];
const nameMap = new Map();
const usedNames = new Set();
for (const [index, tool] of (Array.isArray(tools) ? tools : []).entries()) {
if (!tool || typeof tool !== "object") continue;
const rawName = tool.function?.name ?? tool.name;
if (typeof rawName !== "string" || !rawName.trim()) continue;
// A repeated definition with the same source name describes the same tool.
if (nameMap.has(rawName)) continue;
const name = uniqueName(rawName, index, usedNames);
nameMap.set(rawName, name);
const rawDescription = tool.function?.description ?? tool.description ?? `Tool: ${rawName}`;
const description = trimCodePoints(
String(rawDescription || `Tool: ${rawName}`),
KIRO_TOOL_DESCRIPTION_MAX_LENGTH
);
const schema = tool.function?.parameters ?? tool.parameters ?? tool.input_schema ?? {};
specs.push({
toolSpecification: {
name,
description,
inputSchema: { json: normalizeRootSchema(schema) },
},
});
}
return { specs, nameMap };
}
function toolCallText(toolUse) {
return `[Tool call: ${toolUse?.name || "unknown"}(${text(toolUse?.input || {})})]`;
}
function toolResultText(toolResult) {
const content = Array.isArray(toolResult?.content)
? toolResult.content.map((part) => text(part?.text ?? part)).filter(Boolean).join("\n")
: text(toolResult?.content);
return `[Tool result${toolResult?.status === "error" ? " (error)" : ""}: ${content}]`;
}
function mergeUser(target, source) {
appendText(target, source.content);
if (Array.isArray(source.images) && source.images.length > 0) {
target.images = [...(target.images || []), ...source.images];
}
const results = source.userInputMessageContext?.toolResults;
if (Array.isArray(results) && results.length > 0) {
target.userInputMessageContext ||= {};
target.userInputMessageContext.toolResults = [
...(target.userInputMessageContext.toolResults || []),
...results,
];
}
}
function mergeAssistant(target, source) {
appendText(target, source.content);
if (Array.isArray(source.toolUses) && source.toolUses.length > 0) {
target.toolUses = [...(target.toolUses || []), ...source.toolUses];
}
}
function normalizeTurns(history, currentMessage, modelId) {
const rawTurns = [...(Array.isArray(history) ? history : [])];
if (currentMessage) rawTurns.push(currentMessage);
const turns = [];
for (const raw of rawTurns) {
const isUser = !!raw?.userInputMessage;
const isAssistant = !!raw?.assistantResponseMessage;
if (isUser === isAssistant) continue;
const turn = isUser
? { userInputMessage: clone(raw.userInputMessage) }
: { assistantResponseMessage: clone(raw.assistantResponseMessage) };
const previous = turns[turns.length - 1];
if (turn.userInputMessage && previous?.userInputMessage) {
mergeUser(previous.userInputMessage, turn.userInputMessage);
} else if (turn.assistantResponseMessage && previous?.assistantResponseMessage) {
mergeAssistant(previous.assistantResponseMessage, turn.assistantResponseMessage);
} else {
turns.push(turn);
}
}
if (turns[0]?.assistantResponseMessage) {
turns.unshift({ userInputMessage: { content: "continue", modelId } });
}
if (turns.length === 0 || turns[turns.length - 1]?.assistantResponseMessage) {
turns.push({ userInputMessage: { content: "continue", modelId } });
}
for (const turn of turns) {
if (turn.userInputMessage) {
turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue";
turn.userInputMessage.modelId ||= modelId;
if (turn.userInputMessage.userInputMessageContext?.tools) {
delete turn.userInputMessage.userInputMessageContext.tools;
}
} else {
turn.assistantResponseMessage.content =
text(turn.assistantResponseMessage.content).trim() || "...";
}
}
return turns;
}
function rawId(value) {
return typeof value === "string" ? value : "";
}
function reserveToolId(value, turnIndex, callIndex, name, usedIds) {
const sanitized = rawId(value).replace(/[^a-zA-Z0-9_-]/g, "");
const generated = `call_msg${turnIndex}_tc${callIndex}_${name || "tool"}`;
const base = trimCodePoints(
TOOL_ID_PATTERN.test(sanitized) && sanitized ? sanitized : generated,
KIRO_TOOL_ID_MAX_LENGTH
);
let candidate = base;
let suffix = 2;
while (usedIds.has(candidate)) {
const tail = `_${suffix++}`;
candidate = `${base.slice(0, KIRO_TOOL_ID_MAX_LENGTH - tail.length)}${tail}`;
}
usedIds.add(candidate);
return candidate;
}
function normalizeToolInput(input) {
if (input && typeof input === "object" && !Array.isArray(input)) return clone(input);
if (typeof input === "string") {
try {
const parsed = JSON.parse(input);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch {
return null;
}
}
return input == null ? {} : null;
}
function normalizeToolResult(result) {
const content = Array.isArray(result?.content)
? result.content.map((part) => ({ text: text(part?.text ?? part) }))
: [{ text: text(result?.content) }];
return {
toolUseId: rawId(result?.toolUseId),
status: result?.status === "error" ? "error" : "success",
content: content.length > 0 ? content : [{ text: "" }],
};
}
function flattenResults(userMessage, results) {
for (const result of results) appendText(userMessage, toolResultText(result));
}
function cleanUserContext(userMessage) {
const context = userMessage.userInputMessageContext;
if (!context) return;
if (!context.toolResults?.length) delete context.toolResults;
if (!context.tools?.length) delete context.tools;
if (Object.keys(context).length === 0) delete userMessage.userInputMessageContext;
}
function reconcileToolPair(assistant, user, turnIndex, nameMap, specNames, usedIds, repairs) {
const calls = Array.isArray(assistant.toolUses) ? assistant.toolUses : [];
const results = Array.isArray(user.userInputMessageContext?.toolResults)
? user.userInputMessageContext.toolResults.map(normalizeToolResult)
: [];
if (calls.length === 0) {
if (results.length > 0) {
flattenResults(user, results);
repairs.orphanResults += results.length;
}
if (user.userInputMessageContext) delete user.userInputMessageContext.toolResults;
cleanUserContext(user);
return;
}
const callQueues = new Map();
const callRecords = calls.map((call, callIndex) => {
const key = rawId(call?.toolUseId);
const mappedName = nameMap.get(call?.name) || call?.name;
const input = normalizeToolInput(call?.input);
const record = { call, callIndex, key, mappedName, input, result: null };
const queue = callQueues.get(key) || [];
queue.push(record);
callQueues.set(key, queue);
return record;
});
const orphanResults = [];
for (const result of results) {
const queue = callQueues.get(rawId(result.toolUseId));
const record = queue?.find((candidate) => !candidate.result);
if (record) record.result = result;
else orphanResults.push(result);
}
const keptCalls = [];
const keptResults = [];
for (const record of callRecords) {
const hasSpec = typeof record.mappedName === "string" && specNames.has(record.mappedName);
const valid = !!record.result && hasSpec && record.input !== null;
if (!valid) {
appendText(assistant, toolCallText({ name: record.mappedName, input: record.call?.input }));
repairs.missingResults += record.result ? 0 : 1;
repairs.invalidToolUses += hasSpec && record.input !== null ? 0 : 1;
if (record.result) {
flattenResults(user, [record.result]);
repairs.orphanResults++;
}
continue;
}
const toolUseId = reserveToolId(
record.key,
turnIndex,
record.callIndex,
record.mappedName,
usedIds
);
keptCalls.push({
toolUseId,
name: record.mappedName,
input: record.input,
});
keptResults.push({ ...record.result, toolUseId });
}
if (orphanResults.length > 0) {
flattenResults(user, orphanResults);
repairs.orphanResults += orphanResults.length;
}
if (keptCalls.length > 0) assistant.toolUses = keptCalls;
else delete assistant.toolUses;
user.userInputMessageContext ||= {};
if (keptResults.length > 0) user.userInputMessageContext.toolResults = keptResults;
else delete user.userInputMessageContext.toolResults;
cleanUserContext(user);
}
/** Validate the final Kiro wire conversation without mutating it. */
export function validateKiroConversation(history, currentMessage, toolSpecs = []) {
const errors = [];
const turns = [...(history || []), currentMessage].filter(Boolean);
const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean));
const usedIds = new Set();
for (let index = 0; index < turns.length; index++) {
const expectedUser = index % 2 === 0;
const isUser = !!turns[index]?.userInputMessage;
if (isUser !== expectedUser) errors.push(`role:${index}`);
if (!isUser) {
const calls = turns[index].assistantResponseMessage?.toolUses || [];
const results = turns[index + 1]?.userInputMessage?.userInputMessageContext?.toolResults || [];
const callIds = calls.map((call) => call.toolUseId);
const resultIds = results.map((result) => result.toolUseId);
if (calls.length !== results.length || callIds.some((id) => !resultIds.includes(id))) {
errors.push(`pair:${index}`);
}
for (const call of calls) {
if (!call.toolUseId || usedIds.has(call.toolUseId)) errors.push(`id:${index}`);
usedIds.add(call.toolUseId);
if (!specNames.has(call.name)) errors.push(`spec:${index}`);
}
} else if (index === 0) {
const results = turns[index].userInputMessage?.userInputMessageContext?.toolResults;
if (results?.length) errors.push("orphan:0");
}
}
if (!currentMessage?.userInputMessage?.content) errors.push("current");
return { valid: errors.length === 0, errors };
}
function flattenAllStructuredTools(turns, repairs) {
for (const turn of turns) {
if (turn.assistantResponseMessage?.toolUses?.length) {
for (const call of turn.assistantResponseMessage.toolUses) {
appendText(turn.assistantResponseMessage, toolCallText(call));
}
repairs.invalidToolUses += turn.assistantResponseMessage.toolUses.length;
delete turn.assistantResponseMessage.toolUses;
}
const user = turn.userInputMessage;
const results = user?.userInputMessageContext?.toolResults;
if (results?.length) {
flattenResults(user, results);
repairs.orphanResults += results.length;
delete user.userInputMessageContext.toolResults;
cleanUserContext(user);
}
}
}
/**
* Produce a strict Kiro conversation: alternating turns, current user message,
* adjacent one-to-one tool use/result pairs, and tool specs only on currentMessage.
*/
export function canonicalizeKiroConversation({
history,
currentMessage,
modelId,
toolSpecs = [],
nameMap = new Map(),
} = {}) {
const turns = normalizeTurns(history, currentMessage, modelId);
const repairs = { missingResults: 0, orphanResults: 0, invalidToolUses: 0 };
const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean));
const usedIds = new Set();
for (let index = 0; index < turns.length; index += 2) {
const user = turns[index].userInputMessage;
if (index === 0) {
const leadingResults = user.userInputMessageContext?.toolResults || [];
if (leadingResults.length > 0) {
flattenResults(user, leadingResults);
repairs.orphanResults += leadingResults.length;
delete user.userInputMessageContext.toolResults;
cleanUserContext(user);
}
}
const assistant = turns[index + 1]?.assistantResponseMessage;
const nextUser = turns[index + 2]?.userInputMessage;
if (assistant && nextUser) {
reconcileToolPair(assistant, nextUser, index + 1, nameMap, specNames, usedIds, repairs);
}
}
const finalCurrent = turns[turns.length - 1];
finalCurrent.userInputMessage.userInputMessageContext ||= {};
if (toolSpecs.length > 0) {
finalCurrent.userInputMessage.userInputMessageContext.tools = clone(toolSpecs);
}
cleanUserContext(finalCurrent.userInputMessage);
let finalHistory = turns.slice(0, -1);
let validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs);
if (!validation.valid) {
flattenAllStructuredTools(turns, repairs);
finalHistory = turns.slice(0, -1);
validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs);
}
return {
history: finalHistory,
currentMessage: finalCurrent,
repairs,
valid: validation.valid,
errors: validation.errors,
};
}

View File

@@ -62,6 +62,19 @@ function stripOpenAI(body, caps) {
if (!Array.isArray(body.messages)) return;
const last = body.messages.length - 1;
body.messages.forEach((msg, i) => {
if (caps.vision === false) {
if (Array.isArray(msg.images)) delete msg.images;
if (Array.isArray(msg.experimental_attachments)) {
msg.experimental_attachments = msg.experimental_attachments.filter(
(a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/")))
);
}
if (Array.isArray(msg.attachments)) {
msg.attachments = msg.attachments.filter(
(a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/")))
);
}
}
if (!Array.isArray(msg.content)) return;
const removed = new Set();
msg.content = filterBlocks(msg.content, capForOpenAIBlock, caps, removed, i === last);

View File

@@ -1,17 +1,26 @@
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
// Strip request params a given provider/model rejects upstream (e.g. HTTP 400).
// Config-driven: add a rule instead of scattering `delete body.x` across executors.
// Each rule: optional provider, regex match on model, list of params to drop.
// A param is removed only when it is present (!== undefined).
const STRIP_RULES = [
// claude-opus-4 series: temperature is deprecated (Anthropic 400). #1748
{ match: /claude-opus-4/i, drop: ["temperature"] },
// All Claude models: temperature deprecated/rejected upstream (Anthropic 400). #1748
{ match: /claude/i, drop: ["temperature"] },
// GitHub Copilot gpt-5.4: temperature unsupported.
{ provider: "github", match: /gpt-5\.4/i, drop: ["temperature"] },
// GitHub Copilot Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. #713
{ provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] },
// Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926)
{ provider: "cloudflare-ai", flattenContent: true },
{ provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true },
// VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's
// advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144),
// so clampToModelMaxOutput alone leaves it uncapped and the request 400s with
// "integer above maximum value, expected <= 32768". Pin an explicit endpoint cap;
// min() with the model ceiling still applies if a variant's own limit is lower.
{ provider: "volcengine-ark", match: /kimi/i, maxOutputCap: 32768, clampToModelMaxOutput: true },
];
// Test a rule's match (regex or predicate) against the model id.
@@ -20,6 +29,12 @@ function matches(rule, model) {
return typeof rule.match === "function" ? rule.match(model) : rule.match.test(model);
}
function clampNumber(body, key, ceiling) {
if (typeof body[key] === "number" && Number.isFinite(body[key]) && body[key] > ceiling) {
body[key] = ceiling;
}
}
// Remove unsupported params from body in place; returns body.
export function stripUnsupportedParams(provider, model, body) {
if (!model || !body || typeof body !== "object") return body;
@@ -39,6 +54,22 @@ export function stripUnsupportedParams(provider, model, body) {
}
}
}
if (rule.clampToModelMaxOutput || Number.isFinite(rule.maxOutputCap)) {
const modelCeiling = getCapabilitiesForModel(provider, model).maxOutput;
const candidates = [];
if (rule.clampToModelMaxOutput && Number.isFinite(modelCeiling) && modelCeiling > 0) {
candidates.push(modelCeiling);
}
if (Number.isFinite(rule.maxOutputCap) && rule.maxOutputCap > 0) {
candidates.push(rule.maxOutputCap);
}
if (candidates.length > 0) {
const ceiling = Math.min(...candidates);
clampNumber(body, "max_tokens", ceiling);
clampNumber(body, "max_completion_tokens", ceiling);
clampNumber(body, "max_output_tokens", ceiling);
}
}
}
return body;
}

View File

@@ -3,6 +3,7 @@
// never hardcoded per-model here. See .docs/thinking/plan.md MATRIX VI-A.
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
import { getThinkingLevels } from "../../providers/thinkingLevels.js";
import { PROVIDERS } from "../../providers/index.js";
import { LEVEL_TO_BUDGET, budgetToLevel, effortToBudget, effortToThinkingLevel } from "./thinking.js";
@@ -20,6 +21,13 @@ const FORMAT_TO_NATIVE = {
kiro: "kiro",
};
// Strip a trailing thinking suffix "model(value)" → "model" (no-op when absent).
export function stripThinkingSuffix(model) {
if (typeof model !== "string") return model;
const m = model.match(/^(.*)\([^()]+\)\s*$/);
return m ? m[1].trim() : model;
}
// Parse model-name suffix "model(value)" → { cleanModel, override }.
// value: level name (high) | number (8192) | auto | none. null override when absent.
export function parseSuffix(model) {
@@ -30,6 +38,7 @@ export function parseSuffix(model) {
const raw = m[2].trim().toLowerCase();
if (raw === "none" || raw === "off") return { cleanModel, override: { mode: "none" } };
if (raw === "auto") return { cleanModel, override: { mode: "auto" } };
if (raw === "ultra") return { cleanModel, override: { mode: "level", level: raw } };
if (/^\d+$/.test(raw)) return { cleanModel, override: { mode: "budget", budget: Number(raw) } };
if (LEVEL_TO_BUDGET[raw] !== undefined) return { cleanModel, override: { mode: "level", level: raw } };
return { cleanModel, override: null };
@@ -127,23 +136,78 @@ function toLevel(cfg) {
return null;
}
function normalizeOpenAILevel(level, supportedLevels) {
if (level !== "max" && level !== "ultra") return level;
if (supportedLevels?.includes(level)) return level;
if (level === "ultra" && supportedLevels?.includes("max")) return "max";
return "xhigh";
}
function toGeminiThinkingLevel(cfg) {
const raw = cfg.mode === "auto" ? "high" : (toLevel(cfg) || "high");
return effortToThinkingLevel(raw);
}
function toKimiReasoningEffort(cfg) {
const level = toLevel(cfg);
if (level === "auto") return "high";
if (level === "minimal") return "low";
if (level === "xhigh") return "max";
if (["low", "medium", "high", "max"].includes(level)) return level;
return null;
}
const GEMINI_LEVEL_OUTPUT_FLOOR = {
minimal: 4096,
low: 8192,
medium: 16384,
high: 65535,
};
function geminiBudgetOutputFloor(budget) {
if (budget === -1) return 32768;
if (!Number.isFinite(budget)) return 32768;
if (budget <= 1024) return 8192;
if (budget <= 8192) return 16384;
if (budget <= 24576) return 32768;
return 65535;
}
function geminiLevelOutputFloor(level) {
return GEMINI_LEVEL_OUTPUT_FLOOR[level] || GEMINI_LEVEL_OUTPUT_FLOOR.high;
}
// Gemini nests thinkingConfig under generationConfig. gemini-cli / antigravity wrap
// the whole request in a { request: { generationConfig } } envelope — target the
// envelope's generationConfig when present, else the top-level one.
function getGeminiGenerationConfig(body) {
if (body.request && typeof body.request === "object") {
if (!body.request.generationConfig || typeof body.request.generationConfig !== "object") {
body.request.generationConfig = {};
}
return body.request.generationConfig;
}
if (!body.generationConfig || typeof body.generationConfig !== "object") {
body.generationConfig = {};
}
return body.generationConfig;
}
function setGeminiThinking(body, tc) {
const gc = body.request?.generationConfig
? body.request.generationConfig
: (body.generationConfig && typeof body.generationConfig === "object"
? body.generationConfig
: (body.generationConfig = {}));
const gc = getGeminiGenerationConfig(body);
gc.thinkingConfig = tc;
}
function ensureGeminiOutputFloor(body, floor, caps) {
const cap = Number.isFinite(caps?.maxOutput) ? caps.maxOutput : floor;
const target = Math.min(floor, cap);
const gc = getGeminiGenerationConfig(body);
const current = Number(gc.maxOutputTokens);
if (!Number.isFinite(current) || current < target) {
gc.maxOutputTokens = target;
}
}
// Strip every known thinking field from a body (used before re-applying / when unsupported).
function stripAll(body) {
delete body.thinking;
@@ -158,7 +222,7 @@ function stripAll(body) {
}
// Apply unified thinking config to body in the resolved provider-native format.
function applyFormat(fmt, body, cfg, caps) {
function applyFormat(fmt, body, cfg, caps, supportedLevels) {
const none = cfg.mode === "none";
const canDisable = caps.thinkingCanDisable !== false;
// Model cannot disable thinking → clamp "none" to minimal effort instead.
@@ -168,11 +232,17 @@ function applyFormat(fmt, body, cfg, caps) {
case "openai": {
if (none && canDisable) { body.reasoning_effort = "none"; break; }
const level = toLevel(eff);
if (level) body.reasoning_effort = level;
if (level) body.reasoning_effort = normalizeOpenAILevel(level, supportedLevels);
break;
}
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" };
const level = toLevel(eff);
body.output_config = { effort: level === "xhigh" ? "high" : level };
break;
@@ -186,12 +256,14 @@ function applyFormat(fmt, body, cfg, caps) {
case "gemini-level": {
const level = none ? "minimal" : toGeminiThinkingLevel(eff);
setGeminiThinking(body, { thinkingLevel: level, includeThoughts: level !== "minimal" });
ensureGeminiOutputFloor(body, geminiLevelOutputFloor(level), caps);
break;
}
case "gemini-budget": {
if (none && canDisable) { setGeminiThinking(body, { thinkingBudget: 0, includeThoughts: false }); break; }
const budget = toBudget(eff, caps.thinkingRange);
setGeminiThinking(body, { thinkingBudget: budget ?? -1, includeThoughts: true });
ensureGeminiOutputFloor(body, geminiBudgetOutputFloor(budget ?? -1), caps);
break;
}
case "zai": {
@@ -217,8 +289,8 @@ function applyFormat(fmt, body, cfg, caps) {
}
case "kimi": {
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
const level = toLevel(eff);
if (level) body.reasoning_effort = level === "max" ? "high" : level;
const effort = toKimiReasoningEffort(eff);
if (effort) body.reasoning_effort = effort;
break;
}
case "minimax": {
@@ -238,6 +310,15 @@ function applyFormat(fmt, body, cfg, caps) {
if (level) body.reasoning_effort = level === "xhigh" || level === "max" ? "high" : level;
break;
}
case "tokenrouter": {
// TokenRouter's reasoning_effort enum is low/medium/high/xhigh/max — it rejects
// "none"/"auto" with a 400 and supports "max" natively (no clamp like openai).
// "none" → omit the field so the upstream default applies; pass levels through.
if (none || eff.mode === "auto") break;
const level = toLevel(eff);
if (level) body.reasoning_effort = level;
break;
}
case "kiro":
// Kiro thinking handled via system-tag injection in openai-to-kiro.js; no body field here.
break;
@@ -265,7 +346,8 @@ export function applyThinking(targetFormat, model, body, provider = null, intent
if (!cfg) return body;
const fmt = resolveFormat(targetFormat, cleanModel, provider);
const supportedLevels = getThinkingLevels(provider, cleanModel);
stripAll(body);
applyFormat(fmt, body, cfg, caps);
applyFormat(fmt, body, cfg, caps, supportedLevels);
return body;
}

View File

@@ -9,6 +9,9 @@ import { PROVIDERS } from "../../providers/index.js";
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js";
const CACHE_CONTROL_5M = { type: "ephemeral" };
const CACHE_CONTROL_1H = { type: "ephemeral", ttl: "1h" };
// Check if message has valid non-empty content
export function hasValidContent(msg) {
if (typeof msg.content === "string" && msg.content.trim()) return true;
@@ -16,7 +19,9 @@ export function hasValidContent(msg) {
return msg.content.some(block =>
(block.type === CLAUDE_BLOCK.TEXT && block.text?.trim()) ||
block.type === CLAUDE_BLOCK.TOOL_USE ||
block.type === CLAUDE_BLOCK.TOOL_RESULT
block.type === CLAUDE_BLOCK.TOOL_RESULT ||
block.type === CLAUDE_BLOCK.IMAGE ||
block.type === CLAUDE_BLOCK.DOCUMENT
);
}
return false;
@@ -122,31 +127,128 @@ export function normalizeClaudePassthrough(body, model = "") {
if (Object.keys(body.output_config).length === 0) delete body.output_config;
}
// 2. Hoist mid-conversation system messages into the top-level system field
// 2. Fold mid-conversation system messages into the neighbouring turn.
// Hoisting them into body.system would insert volatile content (token counters,
// reminders) ahead of the whole conversation and invalidate the prefix cache on
// every request. Folding in place keeps the cached prefix stable.
if (Array.isArray(body.messages)) {
const systemBlocks = [];
const messages = [];
for (const msg of body.messages) {
if (msg.role === ROLE.SYSTEM) {
const text = typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
: "";
if (text.trim()) systemBlocks.push({ type: CLAUDE_BLOCK.TEXT, text });
if (msg.role !== ROLE.SYSTEM) {
messages.push(msg);
continue;
}
messages.push(msg);
const text = typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n")
: "";
if (!text.trim()) continue;
// Copy-on-write: the caller's body is reused across account-fallback
// attempts, so folding must never mutate the original message.
const block = { type: CLAUDE_BLOCK.TEXT, text };
const prev = messages[messages.length - 1];
if (prev?.role === ROLE.USER) {
const content = typeof prev.content === "string"
? [{ type: CLAUDE_BLOCK.TEXT, text: prev.content }]
: Array.isArray(prev.content) ? [...prev.content] : [];
messages[messages.length - 1] = { ...prev, content: [...content, block] };
continue;
}
messages.push({ role: ROLE.USER, content: [block] });
}
body.messages = messages;
}
// 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";
if (Array.isArray(body.messages)) {
for (const msg of body.messages) {
if (msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content)) continue;
let hasToolUse = false;
let hasKeptThinking = false;
const kept = [];
for (const block of msg.content) {
if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) {
if (isValidClaudeSignature(block.signature)) {
hasKeptThinking = true;
kept.push(block);
}
continue;
}
if (block.type === CLAUDE_BLOCK.TOOL_USE) hasToolUse = true;
kept.push(block);
}
msg.content = kept;
if (thinkingEnabled && !hasKeptThinking && hasToolUse) {
msg.content.unshift(buildThinkingPlaceholder("claude"));
}
}
}
return body;
}
// Put a 5m breakpoint on the last cache-eligible block of a message.
// thinking/redacted_thinking blocks do not accept cache_control.
function markLastCacheableBlock(msg) {
if (!Array.isArray(msg?.content)) return false;
for (let i = msg.content.length - 1; i >= 0; i--) {
const block = msg.content[i];
if (typeof block !== "object" || block === null) continue;
if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) continue;
block.cache_control = { ...CACHE_CONTROL_5M };
return true;
}
return false;
}
// Re-anchor cache breakpoints on a Claude passthrough body (same policy as
// prepareClaudeRequest): last tool + last system block at 1h, last assistant at 5m.
// The client's own markers point at pre-normalization offsets, so they are dropped.
// Must run LAST, after every step that can reshape system/tools/messages
// (normalize, tool dedupe, token savers) — otherwise the anchor drifts off the tail.
export function anchorClaudeCache(body) {
if (!body || typeof body !== "object") return body;
if (Array.isArray(body.system)) {
const last = body.system.length - 1;
body.system.forEach((block, i) => {
if (typeof block !== "object" || block === null) return;
if (i === last) block.cache_control = { ...CACHE_CONTROL_1H };
else delete block.cache_control;
});
}
if (Array.isArray(body.tools)) {
const last = body.tools.length - 1;
body.tools.forEach((tool, i) => {
if (i === last) tool.cache_control = { ...CACHE_CONTROL_1H };
else delete tool.cache_control;
});
}
if (Array.isArray(body.messages)) {
let anchored = null;
for (let i = body.messages.length - 1; i >= 0; i--) {
const msg = body.messages[i];
if (!Array.isArray(msg.content)) continue;
for (const block of msg.content) delete block.cache_control;
// Prefer the last assistant turn: it ends a completed exchange, so the
// prefix up to it stays byte-stable across the following requests.
if (anchored || msg.role !== ROLE.ASSISTANT) continue;
anchored = markLastCacheableBlock(msg);
}
if (systemBlocks.length > 0) {
const existing = Array.isArray(body.system)
? body.system
: typeof body.system === "string" && body.system.trim()
? [{ type: "text", text: body.system }]
: [];
body.system = [...existing, ...systemBlocks];
body.messages = messages;
// First turn of a conversation has no assistant yet — anchor the final
// message instead, so the opening prompt is cached rather than paid twice.
if (!anchored) {
for (let i = body.messages.length - 1; i >= 0 && !anchored; i--) {
anchored = markLastCacheableBlock(body.messages[i]);
}
}
}
@@ -192,10 +294,27 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
delete body.output_config;
}
// Clamp max_tokens to the model output ceiling (never above DEFAULT_MAX_TOKENS)
// Clamp max_tokens to the model's real output ceiling. Models whose caps
// declare a higher maxOutput (e.g. Opus 4.8 / Sonnet 4.6 = 128000) are allowed
// up to it, so max-effort thinking gets full budget; others fall back to the
// conservative 64000 default.
if (body.max_tokens) {
const ceiling = Math.min(getCapabilitiesForModel(provider, body.model).maxOutput, DEFAULT_MAX_TOKENS);
const ceiling = getCapabilitiesForModel(provider, body.model).maxOutput || DEFAULT_MAX_TOKENS;
if (body.max_tokens > ceiling) body.max_tokens = ceiling;
// Reconcile against thinking budget. applyThinking (thinkingUnified.js) runs
// AFTER adjustMaxTokens capped max_tokens, and the claude-budget format maps
// max effort → budget_tokens 128000 — larger than the clamped max_tokens.
// Anthropic requires max_tokens strictly greater than budget_tokens (else 400).
// Prefer raising max_tokens to preserve the requested thinking depth; if the
// budget alone meets/exceeds the ceiling, cap output and shrink the budget so
// some tokens remain for the answer.
if (body.thinking?.type === "enabled" && body.thinking.budget_tokens && body.thinking.budget_tokens >= body.max_tokens) {
body.max_tokens = Math.min(body.thinking.budget_tokens + 1024, ceiling);
if (body.thinking.budget_tokens >= body.max_tokens) {
body.thinking.budget_tokens = Math.max(1024, body.max_tokens - 1024);
}
}
}
// 1. System: remove all cache_control, add only to last block with ttl 1h

View File

@@ -7,7 +7,13 @@ import { OPENAI_BLOCK } from "../schema/index.js";
export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
// Basic constraints (not supported by Gemini API)
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
"minItems", "maxItems", "format",
"minItems", "maxItems", "format", "multipleOf",
// Array keywords the Gemini schema proto has no field for. Agent tool
// schemas set these routinely, and one occurrence rejects the whole request
// with "Unknown name ...: Cannot find field".
"uniqueItems", "contains",
// 2020-12 keywords with no Gemini equivalent
"unevaluatedProperties", "unevaluatedItems", "contentSchema",
// Claude rejects these in VALIDATED mode
"default", "examples",
// JSON Schema meta keywords
@@ -353,6 +359,19 @@ export function cleanJSONSchemaForAntigravity(schema) {
function addPlaceholders(obj) {
if (!obj || typeof obj !== "object") return;
// Empty schema {} (no type, no properties) after $ref removal — treat as object with placeholder
if (Object.keys(obj).length === 0) {
obj.type = "object";
obj.properties = {
reason: {
type: "string",
description: "Brief explanation of why you are calling this tool"
}
};
obj.required = ["reason"];
return;
}
if (obj.type === "object") {
if (!obj.properties || Object.keys(obj.properties).length === 0) {
obj.properties = {

View File

@@ -3,9 +3,13 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConf
/**
* Adjust max_tokens based on request context
* @param {object} body - Request body
* @param {number} [ceiling=DEFAULT_MAX_TOKENS] - Upper bound for max_tokens.
* Callers with model context (e.g. openai-to-claude) pass the model's real
* maxOutput so high-output models (Opus 4.8 = 128000) aren't pre-clamped to
* the conservative 64000 default before the model-aware step sees them.
* @returns {number} Adjusted max_tokens
*/
export function adjustMaxTokens(body) {
export function adjustMaxTokens(body, ceiling = DEFAULT_MAX_TOKENS) {
let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS;
// Auto-increase for tool calling to prevent truncated arguments (min never above max)
@@ -16,14 +20,14 @@ export function adjustMaxTokens(body) {
}
// Ensure max_tokens > thinking.budget_tokens (Claude API requirement)
// Claude API requires strictly greater, so add buffer instead of using DEFAULT_MAX_TOKENS
// which could equal budget_tokens when budget_tokens >= 64000
// Claude API requires strictly greater, so add buffer instead of using the
// ceiling which could equal budget_tokens when budget_tokens >= ceiling
if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) {
maxTokens = body.thinking.budget_tokens + 1024;
}
// Never exceed the global ceiling
if (maxTokens > DEFAULT_MAX_TOKENS) maxTokens = DEFAULT_MAX_TOKENS;
// Never exceed the ceiling
if (maxTokens > ceiling) maxTokens = ceiling;
return maxTokens;
}

View File

@@ -62,8 +62,13 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
// Always ensure tool_calls have id (some providers require it)
ensureToolCallIds(result);
// Fix missing tool responses (insert empty tool_result if needed)
fixMissingToolResponses(result);
// Kiro performs stricter source-aware reconciliation after session replay.
// The generic helper inserts OpenAI `role: tool` messages, which a direct
// Claude→Kiro translator cannot consume and which cannot repair partial
// parallel tool results.
if (targetFormat !== FORMATS.KIRO) {
fixMissingToolResponses(result);
}
// Capture thinking intent from the original (pre-translation) body, before any
// format conversion strips/renames the fields. Applied after translation.
@@ -103,8 +108,16 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
}
}
// Normalize thinking to the target provider-native format (config-driven, capability-aware)
applyThinking(targetFormat, model, result, provider, thinkingIntent);
// Normalize thinking to the target provider-native format (config-driven, capability-aware).
// Kiro's GenerateAssistantResponse request does not accept the generic top-level
// `thinking` field; its translators map thinking intent to KAS-compatible
// systemPrompt/additionalModelRequestFields instead.
const kiroThinkingMappedByTranslator =
targetFormat === FORMATS.KIRO &&
(sourceFormat === FORMATS.OPENAI || sourceFormat === FORMATS.CLAUDE);
if (!kiroThinkingMappedByTranslator) {
applyThinking(targetFormat, model, result, provider, thinkingIntent);
}
// Always normalize to clean OpenAI format when target is OpenAI
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
@@ -245,8 +258,10 @@ export function initState(sourceFormat) {
funcArgsBuf: {},
funcNames: {},
funcCallIds: {},
funcItemAdded: {},
funcArgsDone: {},
funcItemDone: {},
customToolNames: new Set(),
completedSent: false
};
}

View File

@@ -6,17 +6,10 @@
* direct `claude:kiro` route in ../index.js uses; it is NOT reached through the
* claude→openai→kiro pivot.
*
* It reproduces the two 400-guards that live in openai-to-kiro.js so that a
* Claude client which omits the `tools` array on a follow-up turn (typical
* after client-side compaction) does not trip Kiro's schema validator and get
* "Improperly formed request" (HTTP 400):
*
* 1. flattenClaudeToolInteractions — when the client sent NO tools, collapse
* every tool_use / tool_result block to plain text so no structured tool
* reference survives to trigger the "tools required" rule.
* 2. reconcileOrphanedToolResults — when tools ARE present, fold any
* tool_result whose tool_use_id has no matching tool_use back into the
* user text instead of leaving a dangling structured reference.
* After session replay it delegates to the shared Kiro conversation
* canonicalizer. That layer enforces adjacent one-to-one tool use/results,
* repairs partial parallel calls, and flattens compacted structured references
* that can no longer be represented safely.
*
* It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and
* the `<thinking_mode>enabled</thinking_mode>` reasoning trigger, matching
@@ -24,92 +17,31 @@
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { v4 as uuidv4 } from "uuid";
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
import {
resolveKiroModel,
resolveKiroModelIntent,
applyKiroThinkingOverride,
resolveKiroThinkingBudget,
buildThinkingSystemPrefix,
KIRO_AGENTIC_SYSTEM_PROMPT,
resolveDefaultProfileArn,
buildKiroAdditionalModelRequestFieldsForModel,
usesKiroNativeGptEffort,
} from "../../config/kiroConstants.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
/** Stringify a tool_use input as a readable line. */
function toolUseToText(name, input) {
let argStr;
try {
argStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
} catch {
argStr = "{}";
}
return `[Tool call: ${name || "unknown"}(${argStr})]`;
}
/** Render a Claude tool_result block's content as a readable line. */
function toolResultBlockToText(content) {
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
text = content
.map((c) => (typeof c === "string" ? c : c?.text || ""))
.filter(Boolean)
.join("\n");
} else if (content) {
try {
text = JSON.stringify(content);
} catch {
text = "";
}
}
return `[Tool result: ${text}]`;
}
/**
* When the client sent no tools, rewrite every tool_use (assistant) and
* tool_result (user) content block into plain text. Keeps text + images.
* Returns a new messages array; never mutates the input.
*/
function flattenClaudeToolInteractions(messages) {
const out = [];
for (const msg of messages) {
if (!msg) continue;
if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) {
const parts = [];
for (const block of msg.content) {
if (block.type === CLAUDE_BLOCK.TEXT && block.text) {
parts.push(block.text);
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
parts.push(toolUseToText(block.name, block.input));
}
}
out.push({ ...msg, content: parts.join("\n") });
continue;
}
if (msg.role === ROLE.USER && Array.isArray(msg.content)) {
const newContent = msg.content.map((block) =>
block.type === CLAUDE_BLOCK.TOOL_RESULT
? { type: CLAUDE_BLOCK.TEXT, text: toolResultBlockToText(block.content) }
: block
);
out.push({ ...msg, content: newContent });
continue;
}
out.push(msg);
}
return out;
}
import {
canonicalizeKiroConversation,
normalizeKiroToolSpecs,
} from "../concerns/kiroConversation.js";
/**
* Convert Claude messages to Kiro history + currentMessage.
* Kiro requires alternating user/assistant turns; consecutive same-role
* messages are merged.
*/
function convertClaudeMessagesToKiro(messages, tools, model) {
function convertClaudeMessagesToKiro(messages, model) {
const history = [];
let currentMessage = null;
@@ -118,27 +50,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
let pendingToolResults = [];
let pendingImages = [];
let currentRole = null;
let toolsInjected = false;
const clientProvidedTools = Array.isArray(tools) && tools.length > 0;
const buildToolSpecs = () =>
tools.map((t) => {
const name = t.name;
const description = t.description || `Tool: ${name}`;
const schema = t.input_schema || {};
const normalizedSchema =
Object.keys(schema).length === 0
? { type: "object", properties: {}, required: [] }
: { ...schema, required: schema.required ?? [] };
return {
toolSpecification: {
name,
description,
inputSchema: { json: normalizedSchema },
},
};
});
const flushPending = () => {
if (currentRole === ROLE.USER) {
@@ -153,15 +64,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
toolResults: pendingToolResults,
};
}
// Attach tools to the first user turn only.
if (clientProvidedTools && !toolsInjected) {
if (!userMsg.userInputMessage.userInputMessageContext) {
userMsg.userInputMessage.userInputMessageContext = {};
}
userMsg.userInputMessage.userInputMessageContext.tools = buildToolSpecs();
toolsInjected = true;
}
history.push(userMsg);
currentMessage = userMsg;
pendingUserContent = [];
@@ -205,7 +107,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
}
pendingToolResults.push({
toolUseId: block.tool_use_id,
status: "success",
status: block.is_error ? "error" : "success",
content: [{ text: resultContent }],
});
}
@@ -252,14 +154,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
}
}
// Grab tools from the first history user turn before cleanup strips them.
const firstHistoryTools =
history[0]?.userInputMessage?.userInputMessageContext?.tools;
history.forEach((item) => {
if (item.userInputMessage?.userInputMessageContext?.tools) {
delete item.userInputMessage.userInputMessageContext.tools;
}
if (
item.userInputMessage?.userInputMessageContext &&
Object.keys(item.userInputMessage.userInputMessageContext).length === 0
@@ -303,95 +198,40 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
currentMessage = { userInputMessage: { content: "", modelId: model } };
}
// Inject tools into currentMessage after cleanup if not already present.
if (
firstHistoryTools?.length > 0 &&
!currentMessage.userInputMessage.userInputMessageContext?.tools
) {
if (!currentMessage.userInputMessage.userInputMessageContext) {
currentMessage.userInputMessage.userInputMessageContext = {};
}
currentMessage.userInputMessage.userInputMessageContext.tools =
firstHistoryTools;
}
return { history: mergedHistory, currentMessage };
}
/**
* Fold orphaned toolResults (those whose toolUseId has no matching toolUse in
* any assistant turn) back into the user text, removing the dangling
* structured reference that makes Kiro 400.
*/
function reconcileOrphanedToolResults(history, currentMessage) {
const validIds = new Set();
for (const h of history) {
const arm = h.assistantResponseMessage;
if (!arm) continue;
for (const tu of arm.toolUses || []) {
if (tu.toolUseId) validIds.add(tu.toolUseId);
}
}
const carriers = currentMessage ? [...history, currentMessage] : history;
for (const item of carriers) {
const uim = item.userInputMessage;
const ctx = uim?.userInputMessageContext;
if (!ctx?.toolResults?.length) continue;
const kept = [];
const salvaged = [];
for (const tr of ctx.toolResults) {
if (validIds.has(tr.toolUseId)) {
kept.push(tr);
} else {
const text = Array.isArray(tr.content)
? tr.content.map((c) => c?.text || "").join("\n")
: "";
salvaged.push(`[Tool result: ${text}]`);
}
}
if (salvaged.length === 0) continue;
const extra = salvaged.join("\n");
uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra;
ctx.toolResults = kept;
if (kept.length === 0 && !ctx.tools?.length) {
delete uim.userInputMessageContext;
}
function extractClaudeSystemText(system) {
if (!system) return "";
if (typeof system === "string") return system;
if (Array.isArray(system)) {
return system.map((s) => {
if (typeof s === "string") return s;
return s?.text || "";
}).filter(Boolean).join("\n");
}
return "";
}
/**
* Build a Kiro payload directly from a Claude Messages API request body.
*/
export function claudeToKiroRequest(model, body, stream, credentials) {
let messages = Array.isArray(body.messages) ? body.messages : [];
const messages = Array.isArray(body.messages) ? body.messages : [];
const tools = Array.isArray(body.tools) ? body.tools : [];
const clientProvidedTools = tools.length > 0;
const maxTokens = body.max_tokens || 32000;
const temperature = body.temperature;
const topP = body.top_p;
const { upstream: upstreamModel, agentic } = resolveKiroModel(model);
const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model);
const modelIntent = resolveKiroModelIntent(model);
const { upstream: upstreamModel, agentic } = modelIntent;
const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride);
const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model);
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel);
const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel);
// Guard 1: no client tools → flatten all tool interactions to text.
if (!clientProvidedTools) {
messages = flattenClaudeToolInteractions(messages);
}
const { history, currentMessage } = convertClaudeMessagesToKiro(
messages,
tools,
upstreamModel
);
// Guard 2: tools present → reconcile dangling tool_results.
if (clientProvidedTools) {
reconcileOrphanedToolResults(history, currentMessage);
}
const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools);
const { history, currentMessage } = convertClaudeMessagesToKiro(messages, upstreamModel);
// api_key / idc / external_idp must never use the shared default ARN (belongs
// to another account → 403 "bearer token invalid"); OAuth/social fall back to it.
@@ -402,50 +242,95 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
? (credentials?.providerSpecificData?.profileArn || "")
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
let finalContent = currentMessage?.userInputMessage?.content || "";
// System prompt → prepend to the user content.
if (body.system) {
let systemText = "";
if (typeof body.system === "string") {
systemText = body.system;
} else if (Array.isArray(body.system)) {
systemText = body.system.map((s) => s.text || "").join("\n");
}
if (systemText) finalContent = `${systemText}\n\n${finalContent}`;
}
// Prefix order: thinking_mode tag, timestamp marker, then agentic prompt.
// Kiro CLI/KAS sends system prompt as top-level `systemPrompt`. Keep a
// content fallback too because the CodeWhisperer surface does not always
// enforce top-level systemPrompt for direct calls.
const timestamp = new Date().toISOString();
const prefixParts = [];
if (thinkingBudget !== null) prefixParts.push(buildThinkingSystemPrefix(thinkingBudget));
prefixParts.push(`[Context: Current time is ${timestamp}]`);
if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
const systemPromptParts = [];
if (thinkingBudget !== null && !usesNativeGptEffort) {
systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
}
if (agentic) systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
const systemInstruction = extractClaudeSystemText(body.system);
if (systemInstruction) systemPromptParts.push(systemInstruction);
const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n");
const currentTimeContext = `[Context: Current time is ${timestamp}]`;
const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n");
const sessionIdentity = resolveSessionIdentity({
headers: credentials?.rawHeaders,
body,
connectionId: credentials?.connectionId,
scope: "kiro",
});
const conversationId = sessionIdentity.sessionId;
const continuationId = resolveContinuationId({
sessionId: conversationId,
connectionId: credentials?.connectionId,
scope: "kiro",
ephemeral: sessionIdentity.ephemeral,
});
const replay = applyKiroSessionReplay({
conversationId,
connectionId: credentials?.connectionId,
modelId: upstreamModel,
systemPrompt,
contentPrefix,
currentContentPrefix: currentTimeContext,
history,
currentMessage,
});
const canonical = canonicalizeKiroConversation({
history: replay.history,
currentMessage: replay.currentMessage,
modelId: upstreamModel,
toolSpecs,
nameMap,
});
// canonicalizeKiroConversation() already ran its second-chance repair (flatten
// every structured tool turn to text, then re-validate). A body that is STILL
// invalid here cannot be made shippable, and Kiro answers it with
// 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}.
// Fail locally instead: chatCore turns a falsy return into a 400 without
// spending an upstream call or a per-account cooldown. The taxonomy
// (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending
// turn so the shape can be diagnosed from the log alone.
if (!canonical.valid) {
console.error(`[Kiro] refusing invalid conversation (claude → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`);
return null;
}
const replayCurrent = canonical.currentMessage.userInputMessage;
const userInputMessage = {
content: replayCurrent.content || "",
modelId: upstreamModel,
origin: "AI_EDITOR",
...(replayCurrent.userInputMessageContext && {
userInputMessageContext: replayCurrent.userInputMessageContext,
}),
...(replayCurrent.images && {
images: replayCurrent.images,
}),
};
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: uuidv4(),
conversationId,
agentContinuationId: continuationId,
agentTaskType: "vibe",
currentMessage: {
userInputMessage: {
content: finalContent,
modelId: upstreamModel,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext:
currentMessage.userInputMessage.userInputMessageContext,
}),
...(currentMessage?.userInputMessage?.images && {
images: currentMessage.userInputMessage.images,
}),
},
userInputMessage,
},
history,
history: canonical.history,
},
agentMode: "vibe",
};
if (profileArn) payload.profileArn = profileArn;
if (systemPrompt) payload.systemPrompt = systemPrompt;
if (additionalModelRequestFields) {
payload.additionalModelRequestFields = additionalModelRequestFields;
}
if (maxTokens || temperature !== undefined || topP !== undefined) {
payload.inferenceConfig = {};

View File

@@ -129,14 +129,15 @@ function fixMissingToolResponsesOpenAI(messages) {
}
}
// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400)
// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400).
// Uses <instructions> tags that Claude models treat as authoritative directives.
function systemReminderText(content) {
const parts = Array.isArray(content)
? content.filter(c => c?.type === CLAUDE_BLOCK.TEXT).map(c => c.text || "")
: [typeof content === "string" ? content : ""];
const text = parts.filter(Boolean).join("\n");
if (!text.trim()) return "";
return `<system-reminder>\n${text}\n</system-reminder>`;
return `<instructions>\n${text}\n</instructions>`;
}
// Convert single Claude message - returns single message or array of messages

View File

@@ -31,11 +31,14 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
let currentAssistantMsg = null;
let pendingToolResults = [];
let pendingReasoning = "";
let pendingReasoningEncrypted = "";
const additionalTools = [];
const customToolNames = new Set();
const inputItems = normalizeResponsesInput(body.input);
if (!inputItems) return body;
// Extract reasoning text from summary[].text or encrypted_content fallback
// Extract reasoning text from summary[].text (encrypted_content is continuity-only)
const extractReasoningText = (item) => {
if (Array.isArray(item.summary)) {
const txt = item.summary.map(s => s?.text || "").filter(Boolean).join("\n");
@@ -48,6 +51,13 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
return "";
};
const attachPendingReasoning = (msg) => {
if (pendingReasoning) msg.reasoning_content = pendingReasoning;
if (pendingReasoningEncrypted) msg.encrypted_content = pendingReasoningEncrypted;
pendingReasoning = "";
pendingReasoningEncrypted = "";
};
for (const item of inputItems) {
// Determine item type - Droid CLI sends role-based items without 'type' field
// Fallback: if no type but has role property, treat as message
@@ -80,14 +90,15 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
})
: item.content;
const msg = { role: item.role, content };
// Attach buffered reasoning to assistant turn (required by xiaomi-mimo thinking mode)
if (item.role === ROLE.ASSISTANT && pendingReasoning) {
msg.reasoning_content = pendingReasoning;
// Attach buffered reasoning to assistant turn (required by xiaomi-mimo + store=false continuity)
if (item.role === ROLE.ASSISTANT) attachPendingReasoning(msg);
else {
pendingReasoning = "";
pendingReasoningEncrypted = "";
}
pendingReasoning = "";
result.messages.push(msg);
}
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) {
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL || itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL) {
// Start or append to assistant message with tool_calls
if (!currentAssistantMsg) {
currentAssistantMsg = {
@@ -95,23 +106,24 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
content: null,
tool_calls: []
};
if (pendingReasoning) {
currentAssistantMsg.reasoning_content = pendingReasoning;
pendingReasoning = "";
}
attachPendingReasoning(currentAssistantMsg);
}
// Skip items with empty/missing name — Codex/OpenAI reject nameless tool calls (#444)
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue;
if (itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL) customToolNames.add(item.name);
const toolInput = itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL
? { input: typeof item.input === "string" ? item.input : JSON.stringify(item.input ?? "") }
: item.arguments;
currentAssistantMsg.tool_calls.push({
id: item.call_id,
type: OPENAI_BLOCK.FUNCTION,
function: {
name: item.name,
arguments: item.arguments
arguments: typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput ?? {})
}
});
}
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) {
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT || itemType === RESPONSES_ITEM.CUSTOM_TOOL_CALL_OUTPUT) {
// Flush assistant message first if exists
if (currentAssistantMsg) {
result.messages.push(currentAssistantMsg);
@@ -131,10 +143,19 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
content: typeof item.output === "string" ? item.output : JSON.stringify(item.output)
});
}
else if (itemType === RESPONSES_ITEM.ADDITIONAL_TOOLS) {
if (Array.isArray(item.tools)) additionalTools.push(...item.tools);
}
else if (itemType === RESPONSES_ITEM.REASONING) {
// Buffer reasoning text; attached to next assistant message/function_call
// Buffer reasoning text; attached to next assistant message/function_call.
// Also stash encrypted_content so a later openai→responses hop can restore
// the store=false continuity blob (Grok CLI / Codex multi-turn).
const txt = extractReasoningText(item);
if (txt) pendingReasoning = pendingReasoning ? `${pendingReasoning}\n${txt}` : txt;
if (typeof item.encrypted_content === "string" && item.encrypted_content) {
// Prefer attaching to the next assistant message we create
pendingReasoningEncrypted = item.encrypted_content;
}
continue;
}
}
@@ -154,15 +175,45 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
// explicit `name` field and cannot be represented as Chat Completions function declarations.
// Filter them out to avoid sending nameless functionDeclarations to downstream providers
// such as Gemini, which strictly validates function names.
if (body.tools && Array.isArray(body.tools)) {
result.tools = body.tools
const responseTools = [
...(Array.isArray(body.tools) ? body.tools : []),
...additionalTools,
];
if (responseTools.length > 0) {
result.tools = responseTools
.map(tool => {
// Already in Chat Completions format: { type: "function", function: { name, ... } }
if (tool.function) return tool;
// Responses API function tool: { type: "function", name, description, parameters }
// Only convert when a non-empty name is present; skip hosted tools without one.
// Responses API function/custom tool: { type, name, description, parameters|format }.
// Chat Completions has no freeform custom-tool declaration, so expose custom
// tools as functions with one raw `input` string while retaining their names
// in translator-only metadata for the response conversion.
const name = tool.name;
if (!name || typeof name !== "string" || name.trim() === "") return null;
if (tool.type === "custom") {
customToolNames.add(name);
const formatHint = [tool.format?.syntax, tool.format?.definition].filter(Boolean).join("\n");
return {
type: OPENAI_BLOCK.FUNCTION,
function: {
name,
description: [String(tool.description || ""), formatHint].filter(Boolean).join("\n\n"),
parameters: {
type: "object",
properties: {
input: {
type: "string",
description: "Raw freeform input for this custom tool"
}
},
required: ["input"],
additionalProperties: false
}
}
};
}
// Responses API function tool: { type: "function", name, description, parameters }
// Only convert when a non-empty name is present; skip hosted tools without one.
return {
type: OPENAI_BLOCK.FUNCTION,
function: {
@@ -175,6 +226,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
})
.filter(Boolean);
}
if (customToolNames.size > 0) result._customToolNames = [...customToolNames];
// Cleanup Responses API specific fields
// Map Responses-only max_output_tokens to Chat max_tokens (avoid leaking unknown field upstream)
@@ -188,7 +240,11 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
delete result.include;
delete result.prompt_cache_key;
delete result.store;
if (typeof result.reasoning?.effort === "string") {
result.reasoning_effort = result.reasoning.effort;
}
delete result.reasoning;
delete result.client_metadata;
return result;
}
@@ -202,6 +258,43 @@ function normalizeToolParameters(params) {
return params;
}
/**
* Build a Responses `reasoning` input item from Chat Completions assistant fields.
* Preserves encrypted blobs needed by store=false multi-turn (Grok CLI / Codex).
* Returns null when the message has nothing useful to re-send.
*/
function buildReasoningInputItem(msg) {
if (!msg || typeof msg !== "object") return null;
const encrypted =
(typeof msg.encrypted_content === "string" && msg.encrypted_content) ||
(typeof msg.reasoning_encrypted_content === "string" && msg.reasoning_encrypted_content) ||
(typeof msg.reasoning?.encrypted_content === "string" && msg.reasoning.encrypted_content) ||
"";
let summaryText = "";
if (typeof msg.reasoning_content === "string" && msg.reasoning_content.trim()) {
summaryText = msg.reasoning_content;
} else if (typeof msg.reasoning === "string" && msg.reasoning.trim()) {
summaryText = msg.reasoning;
} else if (Array.isArray(msg.reasoning_details)) {
summaryText = msg.reasoning_details
.map((d) => (typeof d?.text === "string" ? d.text : typeof d?.content === "string" ? d.content : ""))
.filter(Boolean)
.join("\n");
}
if (!encrypted && !summaryText) return null;
const item = { type: RESPONSES_ITEM.REASONING };
if (summaryText) {
item.summary = [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: summaryText }];
}
// encrypted_content is the continuity token for store=false backends
if (encrypted) item.encrypted_content = encrypted;
return item;
}
/**
* Convert OpenAI Chat Completions to OpenAI Responses API format
*/
@@ -221,17 +314,26 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
const messages = body.messages || [];
for (const msg of messages) {
if (msg.role === ROLE.SYSTEM) {
// Use first system message as instructions
if (msg.role === ROLE.SYSTEM || msg.role === ROLE.DEVELOPER) {
// 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 : "";
hasSystemMessage = true;
}
continue; // Skip system messages in input
continue; // Skip instruction messages in input
}
// Convert user/assistant messages to input items
if (msg.role === ROLE.USER || msg.role === ROLE.ASSISTANT) {
// Multi-turn continuity for store=false Responses backends (Codex / Grok CLI):
// re-emit a reasoning item before the assistant message when the chat-format
// history carried reasoning text and/or encrypted_content from a prior turn.
if (msg.role === ROLE.ASSISTANT) {
const reasoningItem = buildReasoningInputItem(msg);
if (reasoningItem) result.input.push(reasoningItem);
}
const contentType = msg.role === ROLE.USER ? RESPONSES_ITEM.INPUT_TEXT : RESPONSES_ITEM.OUTPUT_TEXT;
const content = typeof msg.content === "string"
? [{ type: contentType, text: msg.content }]
@@ -318,6 +420,8 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
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" };
if (body.service_tier !== undefined) result.service_tier = body.service_tier;
if (body.prompt_cache_key !== undefined) result.prompt_cache_key = body.prompt_cache_key;
return result;
}

View File

@@ -6,6 +6,7 @@ import { safeParseJSON } from "../concerns/json.js";
import { parseDataUri } from "../concerns/image.js";
import { extractTextContent } from "../formats/gemini.js";
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
// Empty prefix matches real Claude Code behavior (no tool name prefix).
// Previously "proxy_" was used but this is a detectable fingerprint difference.
@@ -15,9 +16,13 @@ const CLAUDE_OAUTH_TOOL_PREFIX = "";
export function openaiToClaudeRequest(model, body, stream) {
// Tool name mapping for Claude OAuth (capitalizedName → originalName)
const toolNameMap = new Map();
// Cap max_tokens at the model's real output ceiling (e.g. Opus 4.8 = 128000),
// not the conservative 64000 default — otherwise a high-output model is
// pre-clamped here before prepareClaudeRequest's model-aware step runs.
const modelCeiling = getCapabilitiesForModel(null, model).maxOutput || undefined;
const result = {
model: model,
max_tokens: adjustMaxTokens(body),
max_tokens: adjustMaxTokens(body, modelCeiling),
stream: stream
};
@@ -148,7 +153,15 @@ Respond ONLY with the JSON object, no other text.`);
continue;
}
const toolData = toolType === OPENAI_BLOCK.FUNCTION && tool.function ? tool.function : tool;
// Function-shaped tools arrive in two flavors from real clients:
// (a) openai-spec: { type: "function", function: { name, ... } }
// (b) legacy/loose: { function: { name, ... } } (no parent `type`)
// Both must yield toolData.name = "echo". Treat the bare-function shape
// as a function tool too — Anthropic-compatible gateways (notably
// MiniMax M3 at api.minimaxi.com) reject payloads where this branch
// falls through with `toolData.name === undefined`, returning their
// upstream code (2013) "invalid tool type". See #2435.
const toolData = tool.function ?? tool;
const originalName = toolData.name;
// Claude OAuth requires prefixed tool names to avoid conflicts

View File

@@ -1,7 +1,6 @@
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 { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js";
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
function generateUUID() {
return crypto.randomUUID();
@@ -282,31 +281,17 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
// Antigravity specific fields
if (isAntigravity) {
envelope.requestType = "agent";
// Inject required default system prompt for Antigravity
// Inject required default system prompt for Antigravity (double injection)
const systemParts = [
{ text: ANTIGRAVITY_DEFAULT_SYSTEM },
{ text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` }
];
if (envelope.request.systemInstruction?.parts) {
envelope.request.systemInstruction.parts.unshift(...systemParts);
} else {
envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts };
}
// Add toolConfig for Antigravity
if (geminiCLI.tools?.length > 0) {
envelope.request.toolConfig = {
functionCallingConfig: { mode: "VALIDATED" }
};
}
} else {
// Keep safetySettings for Gemini CLI
envelope.request.safetySettings = geminiCLI.safetySettings;
}
if (geminiCLI.tools?.length > 0) {
envelope.request.toolConfig = {
functionCallingConfig: { mode: "VALIDATED" }
};
}
return envelope;
}
@@ -414,12 +399,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
}
}
// Add system instruction (Antigravity default - double injection + user system prompt)
const systemParts = [
{ text: ANTIGRAVITY_DEFAULT_SYSTEM },
{ text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` }
];
const systemParts = [];
// Merge user system prompt from claudeRequest
if (claudeRequest.system) {
if (Array.isArray(claudeRequest.system)) {
@@ -431,10 +411,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
}
}
// Merge existing systemInstruction parts (from contents conversion)
if (envelope.request.systemInstruction?.parts) {
envelope.request.systemInstruction.parts.unshift(...systemParts);
} else {
if (systemParts.length > 0) {
envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts };
}
@@ -463,4 +440,3 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu
register(FORMATS.OPENAI, FORMATS.GEMINI, openaiToGeminiRequest, null);
register(FORMATS.OPENAI, FORMATS.GEMINI_CLI, (model, body, stream, credentials) => wrapInCloudCodeEnvelope(model, openaiToGeminiCLIRequest(model, body, stream), credentials), null);
register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, openaiToAntigravityRequest, null);

View File

@@ -5,159 +5,25 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { v4 as uuidv4 } from "uuid";
import { resolveSessionId } from "../../utils/sessionManager.js";
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
import {
resolveKiroModel,
resolveKiroModelIntent,
applyKiroThinkingOverride,
resolveKiroThinkingBudget,
buildThinkingSystemPrefix,
KIRO_AGENTIC_SYSTEM_PROMPT,
resolveDefaultProfileArn
resolveDefaultProfileArn,
buildKiroAdditionalModelRequestFieldsForModel,
usesKiroNativeGptEffort
} from "../../config/kiroConstants.js";
import { parseDataUri } from "../concerns/image.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
/** Render a single tool call as a readable text line. */
function toolCallToText(name, input) {
let argStr;
try {
argStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
} catch {
argStr = "{}";
}
return `[Tool call: ${name || "unknown"}(${argStr})]`;
}
/** Render a tool result (string or content-block array) as a text line. */
function toolResultToText(content) {
const text = Array.isArray(content)
? content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n")
: (typeof content === "string" ? content : "");
return `[Tool result: ${text}]`;
}
/**
* Flatten all tool calls/results in a conversation into plain text.
*
* Kiro's schema validator requires a non-empty
* currentMessage.userInputMessageContext.tools array whenever the history
* references any tool use; otherwise it returns "Improperly formed request"
* (HTTP 400). A client can hit this by omitting the `tools` array on a
* follow-up request — typically after client-side compaction (e.g. OpenCode).
*
* Rather than fabricate stub tool specs — which would advertise tool-calling
* capability the client never requested and may not handle, risking a phantom
* tool call on an otherwise plain turn — we collapse the tool interaction into
* text. The request stays honest, and since no structured tool content
* remains, the validator's "tools required" rule never fires.
*
* Only invoked when the client did NOT send tools; when tools are present the
* structured form is preserved.
*/
function flattenToolInteractions(messages) {
const out = [];
for (const msg of messages) {
// OpenAI tool-result message → user text line
if (msg.role === ROLE.TOOL) {
out.push({ role: ROLE.USER, content: toolResultToText(msg.content) });
continue;
}
if (msg.role === ROLE.ASSISTANT) {
const parts = [];
if (Array.isArray(msg.content)) {
for (const c of msg.content) {
if (c.type === CLAUDE_BLOCK.TOOL_USE) {
parts.push(toolCallToText(c.name, c.input));
} else if (c.type === OPENAI_BLOCK.TEXT || c.text) {
parts.push(c.text || "");
}
}
} else if (typeof msg.content === "string") {
parts.push(msg.content);
}
for (const tc of msg.tool_calls || []) {
parts.push(toolCallToText(tc.function?.name, tc.function?.arguments));
}
out.push({ role: ROLE.ASSISTANT, content: parts.filter(Boolean).join("\n") });
continue;
}
// User messages: replace tool_result blocks with text, keep text + images.
if (msg.role === ROLE.USER && Array.isArray(msg.content)) {
const newContent = msg.content.map(c =>
c.type === CLAUDE_BLOCK.TOOL_RESULT
? { type: OPENAI_BLOCK.TEXT, text: toolResultToText(c.content) }
: c
);
out.push({ ...msg, content: newContent });
continue;
}
out.push(msg);
}
return out;
}
/**
* Reconcile orphaned toolResults — those whose toolUseId has no matching
* toolUse in any assistant message. This happens when client-side compaction
* truncates the conversation and removes the assistant message containing the
* tool_use, but keeps the user message with the corresponding tool_result.
*
* A dangling structured reference makes Kiro return 400, so it must be removed.
* But the client deliberately kept the result content through compaction, so
* rather than discard it we fold it back into the user message as text — the
* same shape flattenToolInteractions() produces. The 400 trigger (the
* structured reference) is gone; the content survives.
*
* `messages` is every carrier that can hold toolResults — both history items
* and the popped-out currentMessage (orphans can land on either).
*/
function reconcileOrphanedToolResults(history, currentMessage) {
// Phase 1: collect all valid toolUseIds from assistant messages in history.
// (currentMessage is always a user turn, so it carries no toolUses.)
const validIds = new Set();
for (const h of history) {
const arm = h.assistantResponseMessage;
if (!arm) continue;
for (const tu of arm.toolUses || []) {
if (tu.toolUseId) validIds.add(tu.toolUseId);
}
}
// Phase 2: across history + currentMessage, keep results with a matching
// toolUse and salvage the rest as text.
const carriers = currentMessage ? [...history, currentMessage] : history;
for (const item of carriers) {
const uim = item.userInputMessage;
const ctx = uim?.userInputMessageContext;
if (!ctx?.toolResults?.length) continue;
const kept = [];
const salvaged = [];
for (const tr of ctx.toolResults) {
if (validIds.has(tr.toolUseId)) {
kept.push(tr);
} else {
salvaged.push(toolResultToText(tr.content));
}
}
if (salvaged.length === 0) continue; // no orphans — leave untouched
// Fold orphaned result content into the user text so it is not lost
const extra = salvaged.join("\n");
uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra;
ctx.toolResults = kept;
if (kept.length === 0 && !ctx.tools?.length) {
delete uim.userInputMessageContext;
}
}
}
import {
canonicalizeKiroConversation,
normalizeKiroToolSpecs,
} from "../concerns/kiroConversation.js";
/**
* Safely parse JSON string, returning fallback on failure.
@@ -173,26 +39,15 @@ function safeJSONParse(str, fallback) {
*
* Returns { history, currentMessage }.
*/
function convertMessages(messages, tools, model) {
function convertMessages(messages, model) {
let history = [];
let currentMessage = null;
const clientProvidedTools = tools && tools.length > 0;
// When the client did not send tools, flatten any tool calls/results in the
// history into plain text (see flattenToolInteractions). This keeps the
// request honest and sidesteps Kiro's "tools required" 400, since no
// structured tool content survives to trigger it.
if (!clientProvidedTools) {
messages = flattenToolInteractions(messages);
}
let pendingUserContent = [];
let pendingAssistantContent = [];
let pendingToolResults = [];
let pendingImages = [];
let currentRole = null;
let toolsInjectedToFirstUserMsg = false;
const flushPending = () => {
if (currentRole === "user") {
@@ -215,39 +70,6 @@ function convertMessages(messages, tools, model) {
};
}
// Add tools to the user message that has no preceding assistant messages,
// OR the first user message (whichever comes first after any opening
// assistant messages). We track whether any user message has already
// received tools via a flag on the history array.
if (clientProvidedTools && !toolsInjectedToFirstUserMsg) {
if (!userMsg.userInputMessage.userInputMessageContext) {
userMsg.userInputMessage.userInputMessageContext = {};
}
userMsg.userInputMessage.userInputMessageContext.tools = tools.map(t => {
const name = t.function?.name || t.name;
let description = t.function?.description || t.description || "";
if (!description.trim()) {
description = `Tool: ${name}`;
}
const schema = t.function?.parameters || t.parameters || t.input_schema || {};
// Normalize schema: Kiro requires required[] and proper type/properties
const normalizedSchema = Object.keys(schema).length === 0
? { type: "object", properties: {}, required: [] }
: { ...schema, required: schema.required ?? [] };
return {
toolSpecification: {
name,
description,
inputSchema: { json: normalizedSchema }
}
};
});
toolsInjectedToFirstUserMsg = true;
}
history.push(userMsg);
currentMessage = userMsg;
pendingUserContent = [];
@@ -270,6 +92,7 @@ function convertMessages(messages, tools, model) {
let role = msg.role;
// Normalize: system/tool -> user
const wasSystem = role === ROLE.SYSTEM;
if (role === ROLE.SYSTEM || role === ROLE.TOOL) {
role = ROLE.USER;
}
@@ -322,7 +145,7 @@ function convertMessages(messages, tools, model) {
pendingToolResults.push({
toolUseId: block.tool_use_id,
status: "success",
status: block.is_error ? "error" : "success",
content: [{ text: text }]
});
});
@@ -334,11 +157,14 @@ function convertMessages(messages, tools, model) {
const toolContent = typeof msg.content === "string" ? msg.content : "";
pendingToolResults.push({
toolUseId: msg.tool_call_id,
status: "success",
status: msg.is_error || msg.status === "error" ? "error" : "success",
content: [{ text: toolContent }]
});
} else if (content) {
pendingUserContent.push(content);
// <instructions> tags: Claude models treat these as authoritative directives.
pendingUserContent.push(
wasSystem ? `<instructions>\n${content}\n</instructions>` : content
);
}
} else if (role === ROLE.ASSISTANT) {
// Extract text content and tool uses
@@ -405,14 +231,8 @@ function convertMessages(messages, tools, model) {
}
}
// Grab tools from first history item BEFORE cleanup removes them
const firstHistoryTools = history[0]?.userInputMessage?.userInputMessageContext?.tools;
// Clean up history for Kiro API compatibility
history.forEach(item => {
if (item.userInputMessage?.userInputMessageContext?.tools) {
delete item.userInputMessage.userInputMessageContext.tools;
}
if (item.userInputMessage?.userInputMessageContext &&
Object.keys(item.userInputMessage.userInputMessageContext).length === 0) {
delete item.userInputMessage.userInputMessageContext;
@@ -465,33 +285,6 @@ function convertMessages(messages, tools, model) {
};
}
// Reconcile orphaned toolResults across history AND currentMessage — when
// client-side compaction removes assistant messages containing tool_use but
// keeps the tool_result, the dangling reference triggers a Kiro 400. Fold the
// content back into the user text instead of discarding it. Run after
// currentMessage is finalized (an orphan can be merged into it) and before
// tool injection (which may re-add userInputMessageContext).
//
// Only needed on the tools-present path: when the client sent no tools,
// flattenToolInteractions already collapsed every toolResult to text, so
// there is nothing structured left to orphan.
if (clientProvidedTools) {
reconcileOrphanedToolResults(mergedHistory, currentMessage);
}
// Inject tools into currentMessage AFTER cleanup. Tools only exist here when
// the client explicitly sent them (otherwise flattenToolInteractions already
// collapsed all tool content to text upstream, so there is nothing to carry).
const resolvedTools = firstHistoryTools;
if (resolvedTools?.length > 0 &&
!currentMessage.userInputMessage.userInputMessageContext?.tools) {
if (!currentMessage.userInputMessage.userInputMessageContext) {
currentMessage.userInputMessage.userInputMessageContext = {};
}
currentMessage.userInputMessage.userInputMessageContext.tools = resolvedTools;
}
return { history: mergedHistory, currentMessage };
}
@@ -505,12 +298,10 @@ function convertMessages(messages, tools, model) {
* Kiro's 2-3 minute server timeout. The suffix is stripped before being
* sent upstream.
*
* 2. Thinking / reasoning. Kiro does not accept `thinking.type` or
* `reasoning_effort` natively. The only way to enable reasoning is to
* inject `<thinking_mode>enabled</thinking_mode>` into the user content
* sent upstream. Detection covers Anthropic-Beta header, Claude API
* 2. Thinking / reasoning. Detection covers Anthropic-Beta header, Claude API
* `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tags, and model
* name hints.
* name hints. Supported models receive Kiro's schema-specific effort fields;
* legacy prompt tags remain only for models that need them.
*/
export function openaiToKiroRequest(model, body, stream, credentials) {
const messages = body.messages || [];
@@ -519,10 +310,15 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
const temperature = body.temperature;
const topP = body.top_p;
const { upstream: upstreamModel, agentic } = resolveKiroModel(model);
const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model);
const modelIntent = resolveKiroModelIntent(model);
const { upstream: upstreamModel, agentic } = modelIntent;
const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride);
const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model);
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel);
const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel);
const { history, currentMessage } = convertMessages(messages, tools, upstreamModel);
const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools);
const { history, currentMessage } = convertMessages(messages, upstreamModel);
// API-key (headless) auth uses a raw CodeWhisperer credential whose profile is
// account-specific. Injecting the shared builder-id/social *default* placeholder
@@ -542,47 +338,92 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
? (credentials?.providerSpecificData?.profileArn || "")
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
let finalContent = currentMessage?.userInputMessage?.content || "";
const timestamp = new Date().toISOString();
// Build the system-prompt prefix that goes ABOVE the user message body.
// Order: thinking_mode tag first (so Kiro sees it before any user text),
// then context/timestamp marker, then optional agentic chunked-write prompt.
const prefixParts = [];
if (thinkingBudget !== null) {
prefixParts.push(buildThinkingSystemPrefix(thinkingBudget));
// Kiro CLI/KAS sends these as top-level systemPrompt. Keep a content fallback
// too because the CodeWhisperer surface does not always enforce top-level
// systemPrompt for direct calls.
const systemPromptParts = [];
if (thinkingBudget !== null && !usesNativeGptEffort) {
systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
}
prefixParts.push(`[Context: Current time is ${timestamp}]`);
if (agentic) {
prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
}
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n");
const currentTimeContext = `[Context: Current time is ${timestamp}]`;
const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n");
const sessionIdentity = resolveSessionIdentity({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" });
const conversationId = sessionIdentity.sessionId;
const continuationId = resolveContinuationId({
sessionId: conversationId,
connectionId: credentials?.connectionId,
scope: "kiro",
ephemeral: sessionIdentity.ephemeral,
});
const replay = applyKiroSessionReplay({
conversationId,
connectionId: credentials?.connectionId,
modelId: upstreamModel,
systemPrompt,
contentPrefix,
currentContentPrefix: currentTimeContext,
history,
currentMessage,
});
const canonical = canonicalizeKiroConversation({
history: replay.history,
currentMessage: replay.currentMessage,
modelId: upstreamModel,
toolSpecs,
nameMap,
});
// canonicalizeKiroConversation() already ran its second-chance repair (flatten
// every structured tool turn to text, then re-validate). A body that is STILL
// invalid here cannot be made shippable, and Kiro answers it with
// 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}.
// Fail locally instead: chatCore turns a falsy return into a 400 without
// spending an upstream call or a per-account cooldown. The taxonomy
// (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending
// turn so the shape can be diagnosed from the log alone.
if (!canonical.valid) {
console.error(`[Kiro] refusing invalid conversation (openai → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`);
return null;
}
const replayCurrent = canonical.currentMessage.userInputMessage;
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" }),
conversationId,
agentContinuationId: continuationId,
agentTaskType: "vibe",
currentMessage: {
userInputMessage: {
content: finalContent,
content: replayCurrent.content || "",
modelId: upstreamModel,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.images?.length > 0 && {
images: currentMessage.userInputMessage.images
...(replayCurrent.images?.length > 0 && {
images: replayCurrent.images
}),
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext
...(replayCurrent.userInputMessageContext && {
userInputMessageContext: replayCurrent.userInputMessageContext
})
}
},
history: history
}
history: canonical.history
},
agentMode: "vibe",
};
if (profileArn) {
payload.profileArn = profileArn;
}
if (systemPrompt) payload.systemPrompt = systemPrompt;
if (additionalModelRequestFields) {
payload.additionalModelRequestFields = additionalModelRequestFields;
}
if (maxTokens || temperature !== undefined || topP !== undefined) {
payload.inferenceConfig = {};

View File

@@ -75,6 +75,15 @@ export function kiroToClaudeResponse(chunk, state) {
? data.usage.completion_tokens
: 0;
state.usage = { input_tokens: promptTokens, output_tokens: outputTokens };
// Claude clients read cache_read/cache_creation to price a turn and to size
// their prompt cache. Both spellings are accepted because the Kiro executor
// emits the Chat shape and passthrough responses use the nested details form.
const cacheRead = data.usage.cache_read_input_tokens
?? data.usage.prompt_tokens_details?.cached_tokens;
const cacheCreation = data.usage.cache_creation_input_tokens
?? data.usage.prompt_tokens_details?.cache_creation_tokens;
if (typeof cacheRead === "number") state.usage.cache_read_input_tokens = cacheRead;
if (typeof cacheCreation === "number") state.usage.cache_creation_input_tokens = cacheCreation;
}
// First chunk → emit message_start.
@@ -254,6 +263,13 @@ export function kiroToClaudeNonStreaming(data) {
usage: {
input_tokens: usage.prompt_tokens || 0,
output_tokens: usage.completion_tokens || 0,
// Same cache preservation as the streaming path above.
...(typeof (usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens) === "number"
? { cache_read_input_tokens: usage.cache_read_input_tokens ?? usage.prompt_tokens_details.cached_tokens }
: {}),
...(typeof (usage.cache_creation_input_tokens ?? usage.prompt_tokens_details?.cache_creation_tokens) === "number"
? { cache_creation_input_tokens: usage.cache_creation_input_tokens ?? usage.prompt_tokens_details.cache_creation_tokens }
: {}),
},
};
}

View File

@@ -99,8 +99,8 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
}
}
// Handle tool_calls
if (delta.tool_calls) {
// Handle tool_calls (empty array is truthy; require a real call)
if (delta.tool_calls && delta.tool_calls.length) {
closeMessage(state, emit, idx);
for (const tc of delta.tool_calls) {
emitToolCall(state, emit, tc);
@@ -258,24 +258,43 @@ function closeMessage(state, emit, idx) {
}
}
function isCustomTool(state, name) {
return !!name && state.customToolNames?.has(name);
}
function extractCustomToolInput(argumentsText) {
if (typeof argumentsText !== "string") return "";
try {
const parsed = JSON.parse(argumentsText);
if (parsed && typeof parsed === "object" && typeof parsed.input === "string") return parsed.input;
} catch { /* incomplete or raw freeform input */ }
return argumentsText;
}
function emitToolCall(state, emit, tc) {
const tcIdx = tc.index ?? 0;
const newCallId = tc.id;
const funcName = tc.function?.name;
if (funcName) state.funcNames[tcIdx] = funcName;
if (newCallId) state.funcCallIds[tcIdx] = newCallId;
// Some compatible providers split the call id and function name across
// chunks. Wait for both before deciding whether this is a custom tool;
// otherwise an `exec` call can be irreversibly announced as function_call.
const callId = state.funcCallIds[tcIdx];
if (!state.funcItemAdded[tcIdx] && callId && state.funcNames[tcIdx]) {
state.funcItemAdded[tcIdx] = true;
const custom = isCustomTool(state, state.funcNames[tcIdx]);
if (!state.funcCallIds[tcIdx] && newCallId) {
state.funcCallIds[tcIdx] = newCallId;
emit("response.output_item.added", {
type: "response.output_item.added",
output_index: tcIdx,
item: {
id: `fc_${newCallId}`,
type: RESPONSES_ITEM.FUNCTION_CALL,
arguments: "",
call_id: newCallId,
id: `${custom ? "ctc" : "fc"}_${callId}`,
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
...(custom ? { input: "" } : { arguments: "" }),
call_id: callId,
name: state.funcNames[tcIdx] || ""
}
});
@@ -285,7 +304,7 @@ function emitToolCall(state, emit, tc) {
if (tc.function?.arguments) {
const refCallId = state.funcCallIds[tcIdx] || newCallId;
if (refCallId) {
if (state.funcItemAdded[tcIdx] && refCallId && !isCustomTool(state, state.funcNames[tcIdx])) {
emit("response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${refCallId}`,
@@ -293,6 +312,9 @@ function emitToolCall(state, emit, tc) {
delta: tc.function.arguments
});
}
// Custom input is emitted once at close, after the Chat JSON wrapper can be
// parsed and unwrapped. Streaming the raw JSON fragments would expose
// {"input":"..."} instead of the freeform program Codex expects.
state.funcArgsBuf[tcIdx] += tc.function.arguments;
}
}
@@ -301,21 +323,38 @@ function closeToolCall(state, emit, idx) {
const callId = state.funcCallIds[idx];
if (callId && !state.funcItemDone[idx]) {
const args = state.funcArgsBuf[idx] || "{}";
emit("response.function_call_arguments.done", {
type: "response.function_call_arguments.done",
item_id: `fc_${callId}`,
output_index: parseInt(idx),
arguments: args
});
const custom = isCustomTool(state, state.funcNames[idx]);
if (custom) {
const input = extractCustomToolInput(args);
emit("response.custom_tool_call_input.delta", {
type: "response.custom_tool_call_input.delta",
item_id: `ctc_${callId}`,
output_index: parseInt(idx),
delta: input
});
emit("response.custom_tool_call_input.done", {
type: "response.custom_tool_call_input.done",
item_id: `ctc_${callId}`,
output_index: parseInt(idx),
input
});
} else {
emit("response.function_call_arguments.done", {
type: "response.function_call_arguments.done",
item_id: `fc_${callId}`,
output_index: parseInt(idx),
arguments: args
});
}
emit("response.output_item.done", {
type: "response.output_item.done",
output_index: parseInt(idx),
item: {
id: `fc_${callId}`,
type: RESPONSES_ITEM.FUNCTION_CALL,
arguments: args,
id: `${custom ? "ctc" : "fc"}_${callId}`,
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
...(custom ? { input: extractCustomToolInput(args) } : { arguments: args }),
call_id: callId,
name: state.funcNames[idx] || ""
}

View File

@@ -27,6 +27,9 @@ export const RESPONSES_ITEM = {
MESSAGE: "message",
FUNCTION_CALL: "function_call",
FUNCTION_CALL_OUTPUT: "function_call_output",
CUSTOM_TOOL_CALL: "custom_tool_call",
CUSTOM_TOOL_CALL_OUTPUT: "custom_tool_call_output",
ADDITIONAL_TOOLS: "additional_tools",
REASONING: "reasoning",
OUTPUT_TEXT: "output_text",
INPUT_TEXT: "input_text",