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:
@@ -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 = {};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
Reference in New Issue
Block a user