Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 98 additions & 9 deletions open-sse/translator/request/openai-to-kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function convertMessages(messages, tools, model) {
let pendingToolResults = [];
let pendingImages: Array<{ format: string; source: { bytes: string } }> = [];
let currentRole = null;
let toolsAttached = false;

const flushPending = () => {
if (currentRole === "user") {
Expand Down Expand Up @@ -88,8 +89,12 @@ function convertMessages(messages, tools, model) {
userMsg.userInputMessage.images = pendingImages;
}

// Add tools to first user message
if (tools && tools.length > 0 && history.length === 0) {
// Add tools to the first emitted user turn. We track a flag instead of
// relying on `history.length === 0` because the first few messages may
// be assistant turns (e.g. when role=undefined collapses to a prior
// assistant turn), in which case the first user flush would already see
// a non-empty history and lose the tools schema.
if (tools && tools.length > 0 && !toolsAttached) {
if (!userMsg.userInputMessage.userInputMessageContext) {
userMsg.userInputMessage.userInputMessageContext = {};
}
Expand Down Expand Up @@ -126,6 +131,7 @@ function convertMessages(messages, tools, model) {
if (toolDocs.length > 0) {
userMsg._toolDocs = toolDocs.join("\n\n---\n\n");
}
toolsAttached = true;
}

history.push(userMsg);
Expand Down Expand Up @@ -298,16 +304,51 @@ function convertMessages(messages, tools, model) {
};
}

const firstHistoryItem = history[0];
// Promote the tools schema to currentMessage. Tools may have been attached
// to any user turn in history (e.g. when the first message was assistant or
// had an undefined role, the first user flush lands further down). Scan the
// whole history so we never lose the schema.
if (!currentMessage?.userInputMessage?.userInputMessageContext?.tools) {
const carrier = history.find((item) => item?.userInputMessage?.userInputMessageContext?.tools);
if (carrier?.userInputMessage?.userInputMessageContext?.tools) {
if (!currentMessage.userInputMessage.userInputMessageContext) {
currentMessage.userInputMessage.userInputMessageContext = {};
}
currentMessage.userInputMessage.userInputMessageContext.tools =
carrier.userInputMessage.userInputMessageContext.tools;
}
}
Comment on lines +311 to 320

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current logic for promoting the tools schema to currentMessage relies on finding a 'carrier' message in history. However, if the message history consists only of assistant turns (e.g., when the request ends with an assistant message and a 'Continue' turn is synthesized), toolsAttached will remain false and no turn in history will contain the tools. This results in currentMessage lacking the tools schema, which can trigger the 400 Improperly formed request error from Kiro if the history contains tool calls. Consider ensuring that tools are attached to currentMessage if they haven't been attached to any history turn.


// Fallback: if the schema was never attached to any user turn (e.g. the
// input contained no user messages and currentMessage is a synthesized
// "Continue" turn), attach the provided tools directly to currentMessage so
// Kiro still sees the schema it needs to validate assistant.toolUses in
// history.
if (
firstHistoryItem?.userInputMessage?.userInputMessageContext?.tools &&
!toolsAttached &&
tools &&
tools.length > 0 &&
!currentMessage?.userInputMessage?.userInputMessageContext?.tools
) {
if (!currentMessage.userInputMessage.userInputMessageContext) {
currentMessage.userInputMessage.userInputMessageContext = {};
}
currentMessage.userInputMessage.userInputMessageContext.tools =
firstHistoryItem.userInputMessage.userInputMessageContext.tools;
currentMessage.userInputMessage.userInputMessageContext.tools = tools.map((t) => {
const name = t.function?.name || t.name;
const description = t.function?.description || t.description || `Tool: ${name}`;
return {
toolSpecification: {
name,
description,
inputSchema: {
json: normalizeKiroToolSchema(
t.function?.parameters || t.parameters || t.input_schema || {}
),
},
},
};
});
toolsAttached = true;
}

// Clean up history for Kiro API compatibility
Expand Down Expand Up @@ -371,7 +412,7 @@ function convertMessages(messages, tools, model) {
}
}

return { history: mergedHistory, currentMessage };
return { history: mergedHistory, currentMessage, toolsAttached };
}

/**
Expand All @@ -385,12 +426,60 @@ export function buildKiroPayload(model, body, stream, credentials) {
"$1.$2"
);
const messages = body.messages || [];
const tools = body.tools || [];
let tools = body.tools || [];
const maxTokens = body.max_tokens ?? body.max_completion_tokens ?? 32000;
const temperature = body.temperature;
const topP = body.top_p;

const { history, currentMessage } = convertMessages(messages, tools, normalizedModel);
// Kiro rejects history that references toolUses/toolResults without a tools
// schema in userInputMessageContext. When callers omit body.tools but the
// message history still contains assistant.tool_calls / role=tool turns,
// synthesize a minimal tool schema from the tool names present in history
// so Kiro accepts the request instead of returning `Improperly formed
// request`. This preserves tool-call history and is a no-op when body.tools
// is already populated.
if (tools.length === 0) {
const seen = new Set<string>();
const synthesized: Array<Record<string, unknown>> = [];
const pushName = (name: unknown) => {
if (typeof name === "string" && name && !seen.has(name)) {
seen.add(name);
synthesized.push({
type: "function",
function: {
name,
description: `Tool: ${name}`,
parameters: { type: "object", properties: {}, required: [] },
},
});
}
};
for (const msg of messages) {
if (msg?.role !== "assistant") continue;
if (Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
pushName(tc?.function?.name || tc?.name);
}
}
// Anthropic-style assistant blocks: content:[{type:"tool_use", name, ...}]
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block?.type === "tool_use") {
pushName(block.name);
}
}
}
}
Comment on lines +457 to +472

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The synthesis logic currently only scans msg.tool_calls. However, the translator also supports tool uses within the content array (Anthropic-style blocks). To ensure all referenced tools are synthesized, the scan should also check msg.content for blocks with type: "tool_use".

    for (const msg of messages) {
      if (msg?.role !== "assistant") continue;
      const toolCalls = Array.isArray(msg.tool_calls)
        ? msg.tool_calls
        : Array.isArray(msg.content)
          ? msg.content.filter((c) => c.type === "tool_use")
          : [];

      for (const tc of toolCalls) {
        const name = tc?.function?.name || tc?.name;
        if (typeof name === "string" && name && !seen.has(name)) {
          seen.add(name);
          synthesized.push({
            type: "function",
            function: {
              name,
              description: `Tool: ${name}`,
              parameters: { type: "object", properties: {}, required: [] },
            },
          });
        }
      }
    }

if (synthesized.length > 0) {
tools = synthesized;
}
}

const { history, currentMessage, toolsAttached } = convertMessages(
messages,
tools,
normalizedModel
);

const profileArn = credentials?.providerSpecificData?.profileArn || "";

Expand Down
150 changes: 150 additions & 0 deletions tests/unit/translator-openai-to-kiro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,153 @@ 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 synthesizes tools schema when body.tools is omitted but history has tool_calls", () => {
const result = buildKiroPayload(
"claude-opus-4.7",
{
messages: [
{ role: "user", content: "Start" },
{
role: "assistant",
tool_calls: [
{
id: "tooluse_1",
type: "function",
function: { name: "edit", arguments: '{"path":"x"}' },
},
],
},
{ role: "tool", tool_call_id: "tooluse_1", content: "ok" },
{
role: "assistant",
tool_calls: [
{
id: "tooluse_2",
type: "function",
function: { name: "bash", arguments: '{"cmd":"ls"}' },
},
],
},
{ role: "tool", tool_call_id: "tooluse_2", content: "listing" },
{ role: "user", content: "Continue" },
],
},
false,
null
);

const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as {
tools?: Array<{ toolSpecification: { name: string } }>;
};
const tools = ctx?.tools;
assert.ok(tools, "synthesized tools schema should be attached to currentMessage");
const names = tools.map((t) => t.toolSpecification.name).sort();
assert.deepEqual(names, ["bash", "edit"]);
});

test("OpenAI -> Kiro does not override body.tools when caller already provides a schema", () => {
const result = buildKiroPayload(
"claude-opus-4.7",
{
messages: [
{ role: "user", content: "Start" },
{
role: "assistant",
tool_calls: [
{
id: "tooluse_1",
type: "function",
function: { name: "read_file", arguments: "{}" },
},
],
},
{ role: "tool", tool_call_id: "tooluse_1", content: "ok" },
{ role: "user", content: "Continue" },
],
tools: [
{
type: "function",
function: {
name: "read_file",
description: "Real description",
parameters: { type: "object", properties: { path: { type: "string" } } },
},
},
],
},
false,
null
);

const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as {
tools?: Array<{ toolSpecification: { name: string; description: string } }>;
};
const tools = ctx.tools;
assert.ok(tools);
assert.equal(tools.length, 1);
assert.equal(tools[0].toolSpecification.description, "Real description");
});

test("OpenAI -> Kiro synthesizes tools from Anthropic-style tool_use content blocks", () => {
const result = buildKiroPayload(
"claude-opus-4.7",
{
messages: [
{ role: "user", content: "Start" },
{
role: "assistant",
content: [
{ type: "text", text: "Calling tools" },
{ type: "tool_use", id: "tu_1", name: "search", input: { q: "x" } },
{ type: "tool_use", id: "tu_2", name: "open_file", input: { path: "a" } },
],
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "tu_1", content: [{ type: "text", text: "hit" }] },
{ type: "tool_result", tool_use_id: "tu_2", content: [{ type: "text", text: "ok" }] },
],
},
{ role: "user", content: "continue" },
],
},
false,
null
);

const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as {
tools?: Array<{ toolSpecification: { name: string } }>;
};
const tools = ctx?.tools;
assert.ok(tools, "tools should be synthesized from tool_use content blocks");
const names = tools.map((t) => t.toolSpecification.name).sort();
assert.deepEqual(names, ["open_file", "search"]);
});

test("OpenAI -> Kiro attaches tools to currentMessage when history has no user turn to carry them", () => {
const result = buildKiroPayload(
"claude-opus-4.7",
{
messages: [
{
role: "assistant",
tool_calls: [
{ id: "tc_1", type: "function", function: { name: "edit", arguments: "{}" } },
],
},
],
},
false,
null
);

const cm = result.conversationState.currentMessage.userInputMessage;
const ctx = cm.userInputMessageContext as {
tools?: Array<{ toolSpecification: { name: string } }>;
};
assert.ok(ctx?.tools, "tools should be attached to currentMessage fallback");
assert.equal(ctx.tools!.length, 1);
assert.equal(ctx.tools![0].toolSpecification.name, "edit");
});