mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(kiro): harden OpenAI-to-Kiro translator for API compliance
- Normalize tool schemas: strip additionalProperties and empty required arrays - Merge consecutive assistant messages and adjacent user turns after role normalization - Prepend synthetic user message when conversation starts with assistant - Convert orphaned toolResults to inline text when assistant with toolUses is missing - Enforce strictly alternating user/assistant roles in history - Use deterministic uuidv5 conversationId based on first message for session caching - Ensure origin field is present on all userInputMessage entries
This commit is contained in:
@@ -27,19 +27,55 @@ function parseToolInput(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKiroToolSchema(schema: unknown) {
|
||||
/**
|
||||
* Recursively sanitize JSON Schema for Kiro API.
|
||||
* Kiro returns 400 "Improperly formed request" if:
|
||||
* - `required` is an empty array []
|
||||
* - `additionalProperties` is present anywhere
|
||||
*/
|
||||
function normalizeKiroToolSchema(schema: unknown): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return { type: "object", properties: {}, required: [] };
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
|
||||
return {
|
||||
type: "object",
|
||||
properties: {},
|
||||
...(schema as Record<string, unknown>),
|
||||
required: Array.isArray((schema as { required?: unknown }).required)
|
||||
? (schema as { required: unknown[] }).required
|
||||
: [],
|
||||
};
|
||||
const result: Record<string, unknown> = {};
|
||||
const src = schema as Record<string, unknown>;
|
||||
|
||||
for (const [key, value] of Object.entries(src)) {
|
||||
// Skip empty required arrays — Kiro rejects them
|
||||
if (key === "required" && Array.isArray(value) && value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
// Skip additionalProperties — Kiro doesn't support it
|
||||
if (key === "additionalProperties") {
|
||||
continue;
|
||||
}
|
||||
// Recursively process nested objects
|
||||
if (
|
||||
key === "properties" &&
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
const sanitizedProps: Record<string, unknown> = {};
|
||||
for (const [propName, propValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
sanitizedProps[propName] = normalizeKiroToolSchema(propValue);
|
||||
}
|
||||
result[key] = sanitizedProps;
|
||||
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
result[key] = normalizeKiroToolSchema(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
result[key] = value.map((item) =>
|
||||
typeof item === "object" && item !== null && !Array.isArray(item)
|
||||
? normalizeKiroToolSchema(item)
|
||||
: item
|
||||
);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,11 +93,12 @@ function convertMessages(messages, tools, model) {
|
||||
|
||||
const flushPending = () => {
|
||||
if (currentRole === "user") {
|
||||
const content = pendingUserContent.join("\n\n").trim() || "continue";
|
||||
const content = pendingUserContent.join("\n\n").trim() || "(empty)";
|
||||
const userMsg: {
|
||||
userInputMessage: {
|
||||
content: string;
|
||||
modelId: string;
|
||||
origin: string;
|
||||
userInputMessageContext?: {
|
||||
toolResults?: Array<Record<string, unknown>>;
|
||||
tools?: Array<Record<string, unknown>>;
|
||||
@@ -71,6 +108,7 @@ function convertMessages(messages, tools, model) {
|
||||
userInputMessage: {
|
||||
content: content,
|
||||
modelId: "",
|
||||
origin: "AI_EDITOR",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -112,7 +150,7 @@ function convertMessages(messages, tools, model) {
|
||||
pendingUserContent = [];
|
||||
pendingToolResults = [];
|
||||
} else if (currentRole === "assistant") {
|
||||
const content = pendingAssistantContent.join("\n\n").trim() || "...";
|
||||
const content = pendingAssistantContent.join("\n\n").trim() || "(empty)";
|
||||
const assistantMsg = {
|
||||
assistantResponseMessage: {
|
||||
content: content,
|
||||
@@ -286,6 +324,11 @@ function convertMessages(messages, tools, model) {
|
||||
if (item.userInputMessage && !item.userInputMessage.modelId) {
|
||||
item.userInputMessage.modelId = model;
|
||||
}
|
||||
|
||||
// Kiro API requires `origin` on every userInputMessage
|
||||
if (item.userInputMessage && !item.userInputMessage.origin) {
|
||||
item.userInputMessage.origin = "AI_EDITOR";
|
||||
}
|
||||
});
|
||||
|
||||
// Kiro expects history to alternate between user and assistant turns. After
|
||||
@@ -326,12 +369,119 @@ function convertMessages(messages, tools, model) {
|
||||
|
||||
previous.userInputMessage.userInputMessageContext = mergedContext;
|
||||
}
|
||||
} else if (item.assistantResponseMessage && previous?.assistantResponseMessage) {
|
||||
// Kiro API also rejects consecutive assistant messages. Merge them.
|
||||
const previousContent = previous.assistantResponseMessage.content || "";
|
||||
const currentContent = item.assistantResponseMessage.content || "";
|
||||
previous.assistantResponseMessage.content = previousContent
|
||||
? `${previousContent}\n\n${currentContent}`
|
||||
: currentContent;
|
||||
|
||||
if (item.assistantResponseMessage.toolUses) {
|
||||
const existingToolUses = previous.assistantResponseMessage.toolUses || [];
|
||||
previous.assistantResponseMessage.toolUses = [
|
||||
...existingToolUses,
|
||||
...item.assistantResponseMessage.toolUses,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
mergedHistory.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return { history: mergedHistory, currentMessage };
|
||||
// Ensure first message is user. Kiro API requires conversations to start
|
||||
// with a user message (fixes "Improperly formed request" for assistant-first).
|
||||
if (mergedHistory.length > 0 && mergedHistory[0].assistantResponseMessage) {
|
||||
mergedHistory.unshift({
|
||||
userInputMessage: {
|
||||
content: "(empty)",
|
||||
modelId: model,
|
||||
origin: "AI_EDITOR",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure assistant exists before toolResults. Kiro API validates that every
|
||||
// toolResults array has a preceding assistantResponseMessage with toolUses.
|
||||
// When the assistant message is missing (truncated conversation), we strip
|
||||
// the orphaned toolResults and convert them to text to preserve context.
|
||||
for (let i = 0; i < mergedHistory.length; i++) {
|
||||
const item = mergedHistory[i];
|
||||
if (!item.userInputMessage?.userInputMessageContext?.toolResults) continue;
|
||||
|
||||
const prev = mergedHistory[i - 1];
|
||||
const hasPrecedingAssistant =
|
||||
prev?.assistantResponseMessage?.toolUses && prev.assistantResponseMessage.toolUses.length > 0;
|
||||
|
||||
if (!hasPrecedingAssistant) {
|
||||
const toolResults = item.userInputMessage.userInputMessageContext.toolResults as Array<{
|
||||
toolUseId?: string;
|
||||
content?: Array<{ text?: string }>;
|
||||
}>;
|
||||
const toolResultTexts = toolResults
|
||||
.map((tr) => {
|
||||
const id = tr.toolUseId || "";
|
||||
const text = tr.content?.map((c) => c.text || "").join("\n") || "";
|
||||
return id ? `[Tool Result (${id})]\n${text}` : `[Tool Result]\n${text}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const originalContent = item.userInputMessage.content || "";
|
||||
item.userInputMessage.content = originalContent
|
||||
? `${originalContent}\n\n${toolResultTexts}`
|
||||
: toolResultTexts;
|
||||
delete item.userInputMessage.userInputMessageContext.toolResults;
|
||||
|
||||
if (Object.keys(item.userInputMessage.userInputMessageContext).length === 0) {
|
||||
delete item.userInputMessage.userInputMessageContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check currentMessage for orphaned toolResults (not in history)
|
||||
if (currentMessage?.userInputMessage?.userInputMessageContext?.toolResults) {
|
||||
const lastHistory = mergedHistory[mergedHistory.length - 1];
|
||||
const hasPrecedingAssistant =
|
||||
lastHistory?.assistantResponseMessage?.toolUses &&
|
||||
lastHistory.assistantResponseMessage.toolUses.length > 0;
|
||||
|
||||
if (!hasPrecedingAssistant) {
|
||||
const toolResults = currentMessage.userInputMessage.userInputMessageContext
|
||||
.toolResults as Array<{ toolUseId?: string; content?: Array<{ text?: string }> }>;
|
||||
const toolResultTexts = toolResults
|
||||
.map((tr) => {
|
||||
const id = tr.toolUseId || "";
|
||||
const text = tr.content?.map((c) => c.text || "").join("\n") || "";
|
||||
return id ? `[Tool Result (${id})]\n${text}` : `[Tool Result]\n${text}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
const originalContent = currentMessage.userInputMessage.content || "";
|
||||
currentMessage.userInputMessage.content = originalContent
|
||||
? `${originalContent}\n\n${toolResultTexts}`
|
||||
: toolResultTexts;
|
||||
delete currentMessage.userInputMessage.userInputMessageContext.toolResults;
|
||||
|
||||
if (Object.keys(currentMessage.userInputMessage.userInputMessageContext).length === 0) {
|
||||
delete currentMessage.userInputMessage.userInputMessageContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure alternating roles by inserting synthetic assistant messages
|
||||
// between consecutive user turns that couldn't be merged.
|
||||
const alternatingHistory: typeof mergedHistory = [];
|
||||
for (const item of mergedHistory) {
|
||||
const last = alternatingHistory[alternatingHistory.length - 1];
|
||||
if (item.userInputMessage && last?.userInputMessage) {
|
||||
alternatingHistory.push({
|
||||
assistantResponseMessage: { content: "(empty)" },
|
||||
});
|
||||
}
|
||||
alternatingHistory.push(item);
|
||||
}
|
||||
|
||||
return { history: alternatingHistory, currentMessage };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -80,7 +80,11 @@ test("OpenAI -> Kiro preserves prior history, tool uses and accumulated tool res
|
||||
|
||||
assert.equal(result.conversationState.history.length, 2);
|
||||
assert.deepEqual(result.conversationState.history[0], {
|
||||
userInputMessage: { content: "Rules\n\nHello", modelId: "claude-sonnet-4" },
|
||||
userInputMessage: {
|
||||
content: "Rules\n\nHello",
|
||||
modelId: "claude-sonnet-4",
|
||||
origin: "AI_EDITOR",
|
||||
},
|
||||
});
|
||||
assert.deepEqual(result.conversationState.history[1], {
|
||||
assistantResponseMessage: {
|
||||
@@ -111,7 +115,6 @@ test("OpenAI -> Kiro preserves prior history, tool uses and accumulated tool res
|
||||
assert.deepEqual(context.tools[0].toolSpecification.inputSchema.json, {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +220,9 @@ test("OpenAI -> Kiro uses Continue currentMessage when the request ends with ass
|
||||
/^\[Context: Current time is .*Z\]\n\nContinue$/
|
||||
);
|
||||
assert.deepEqual(result.conversationState.history, [
|
||||
{ userInputMessage: { content: "First user", modelId: "claude-sonnet-4" } },
|
||||
{
|
||||
userInputMessage: { content: "First user", modelId: "claude-sonnet-4", origin: "AI_EDITOR" },
|
||||
},
|
||||
{ assistantResponseMessage: { content: "Assistant answer" } },
|
||||
]);
|
||||
});
|
||||
@@ -289,3 +294,143 @@ test("OpenAI -> Kiro merges adjacent user history turns after role normalization
|
||||
assert.equal(firstUser.content, "System rules\n\nFirst question");
|
||||
assert.equal(history[1].assistantResponseMessage?.content, "Answer 1");
|
||||
});
|
||||
|
||||
test("OpenAI -> Kiro strips additionalProperties and empty required from tool schemas", () => {
|
||||
const result = buildKiroPayload(
|
||||
"claude-sonnet-4",
|
||||
{
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
description: "Test",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", additionalProperties: false },
|
||||
nested: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
const schema = result.conversationState.currentMessage.userInputMessage.userInputMessageContext
|
||||
?.tools?.[0]?.toolSpecification?.inputSchema?.json as any;
|
||||
|
||||
assert.ok(schema, "schema should exist");
|
||||
assert.equal(
|
||||
schema.additionalProperties,
|
||||
undefined,
|
||||
"top-level additionalProperties should be stripped"
|
||||
);
|
||||
assert.equal(schema.required, undefined, "empty required should be omitted");
|
||||
assert.equal(
|
||||
schema.properties.path.additionalProperties,
|
||||
undefined,
|
||||
"nested additionalProperties should be stripped"
|
||||
);
|
||||
assert.equal(
|
||||
schema.properties.nested.additionalProperties,
|
||||
undefined,
|
||||
"deep nested additionalProperties should be stripped"
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAI -> Kiro merges consecutive assistant messages", () => {
|
||||
const result = buildKiroPayload(
|
||||
"claude-sonnet-4",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Part 1" },
|
||||
{ role: "assistant", content: "Part 2" },
|
||||
{ role: "user", content: "Continue" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
const history = result.conversationState.history as any[];
|
||||
assert.equal(history.length, 2, "consecutive assistants should be merged into one");
|
||||
assert.equal(history[0].userInputMessage.content, "Hello");
|
||||
assert.equal(history[1].assistantResponseMessage.content, "Part 1\n\nPart 2");
|
||||
});
|
||||
|
||||
test("OpenAI -> Kiro prepends synthetic user when conversation starts with assistant", () => {
|
||||
const result = buildKiroPayload(
|
||||
"claude-sonnet-4",
|
||||
{
|
||||
messages: [
|
||||
{ role: "assistant", content: "Greeting" },
|
||||
{ role: "user", content: "Hello" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
const history = result.conversationState.history as any[];
|
||||
assert.equal(history.length, 2);
|
||||
assert.equal(history[0].userInputMessage.content, "(empty)");
|
||||
assert.equal(history[0].userInputMessage.origin, "AI_EDITOR");
|
||||
assert.equal(history[1].assistantResponseMessage.content, "Greeting");
|
||||
});
|
||||
|
||||
test("OpenAI -> Kiro converts orphaned tool results to text", () => {
|
||||
const result = buildKiroPayload(
|
||||
"claude-sonnet-4",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "First" },
|
||||
{ role: "assistant", content: "Answer" },
|
||||
{ role: "tool", tool_call_id: "orphan_1", content: "result data" },
|
||||
{ role: "user", content: "Follow-up" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
const currentMsg = result.conversationState.currentMessage.userInputMessage;
|
||||
assert.match(currentMsg.content, /Follow-up\n\n\[Tool Result \(orphan_1\)\]\nresult data$/);
|
||||
assert.equal(
|
||||
currentMsg.userInputMessageContext,
|
||||
undefined,
|
||||
"orphaned toolResults should be removed from context"
|
||||
);
|
||||
});
|
||||
|
||||
test("OpenAI -> Kiro includes origin on all history user messages", () => {
|
||||
const result = buildKiroPayload(
|
||||
"claude-sonnet-4",
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "A" },
|
||||
{ role: "assistant", content: "B" },
|
||||
{ role: "user", content: "C" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
null
|
||||
);
|
||||
|
||||
const history = result.conversationState.history as any[];
|
||||
assert.equal(history[0].userInputMessage.origin, "AI_EDITOR");
|
||||
assert.equal(history[1].assistantResponseMessage.content, "B");
|
||||
// Note: last user message becomes currentMessage, not history
|
||||
assert.equal(history.length, 2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user