mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix(kiro): canonicalize tool history and route API keys correctly
Route API-key inference through Amazon Q first, enforce adjacent one-to-one tool use/result pairs after session replay, and treat payload-invalid HTTP 400 as terminal.
This commit is contained in:
@@ -20,6 +20,12 @@ import { effortToBudget } from "../translator/concerns/thinking.js";
|
||||
|
||||
export const KIRO_AGENTIC_SUFFIX = "-agentic";
|
||||
export const KIRO_THINKING_SUFFIX = "-thinking";
|
||||
export const KIRO_TOOL_NAME_MAX_LENGTH = 64;
|
||||
export const KIRO_TOOL_DESCRIPTION_MAX_LENGTH = 10237;
|
||||
export const KIRO_TOOL_ID_MAX_LENGTH = 64;
|
||||
export const KIRO_CODEWHISPERER_TARGET =
|
||||
"AmazonCodeWhispererStreamingService.GenerateAssistantResponse";
|
||||
export const KIRO_ENDPOINT_FALLBACK_STATUSES = new Set([401, 403, 404]);
|
||||
|
||||
// Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method.
|
||||
// Used when an account cannot resolve its own profileArn. Builder ID and social
|
||||
|
||||
@@ -126,7 +126,7 @@ export class BaseExecutor {
|
||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||
const url = this.buildUrl(model, stream, urlIndex, credentials);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
const headers = this.buildHeaders(credentials, stream, url);
|
||||
|
||||
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
|
||||
|
||||
|
||||
+36
-11
@@ -1,6 +1,10 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { resolveKiroModel } from "../config/kiroConstants.js";
|
||||
import {
|
||||
KIRO_CODEWHISPERER_TARGET,
|
||||
KIRO_ENDPOINT_FALLBACK_STATUSES,
|
||||
resolveKiroModel,
|
||||
} from "../config/kiroConstants.js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { refreshKiroToken } from "../services/tokenRefresh.js";
|
||||
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
|
||||
@@ -216,12 +220,17 @@ export class KiroExecutor extends BaseExecutor {
|
||||
super("kiro", PROVIDERS.kiro);
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
buildHeaders(credentials, stream = true, url = "") {
|
||||
const headers = {
|
||||
...this.config.headers,
|
||||
"Amz-Sdk-Request": "attempt=1; max=3",
|
||||
"Amz-Sdk-Invocation-Id": uuidv4()
|
||||
};
|
||||
if (url.includes("://codewhisperer.")) {
|
||||
headers["X-Amz-Target"] = KIRO_CODEWHISPERER_TARGET;
|
||||
} else {
|
||||
delete headers["X-Amz-Target"];
|
||||
}
|
||||
|
||||
// API-key auth: the key is stored as accessToken and sent as a bearer token
|
||||
// exactly like an OAuth access token, but with an extra `tokentype: API_KEY`
|
||||
@@ -236,8 +245,8 @@ export class KiroExecutor extends BaseExecutor {
|
||||
const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null);
|
||||
if (isApiKey && apiKey) {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
headers["tokentype"] = "API_KEY";
|
||||
} else if (credentials.accessToken) {
|
||||
headers["TokenType"] = "API_KEY";
|
||||
} else if (credentials?.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
if (isExternalIdp) {
|
||||
headers["TokenType"] = "EXTERNAL_IDP";
|
||||
@@ -250,14 +259,14 @@ export class KiroExecutor extends BaseExecutor {
|
||||
/**
|
||||
* Auth-aware endpoint ordering.
|
||||
*
|
||||
* API-key Kiro connections store a raw CodeWhisperer credential (validated
|
||||
* against codewhisperer.us-east-1.amazonaws.com via ListAvailableProfiles).
|
||||
* API-key Kiro connections use the Amazon Q surface. The legacy
|
||||
* codewhisperer.* GenerateAssistantResponse endpoint can authenticate the key
|
||||
* but rejects the same valid payload with REQUEST_BODY_INVALID. Since a 400
|
||||
* is terminal in BaseExecutor, putting CodeWhisperer first prevents the working
|
||||
* q.* endpoint from ever being tried. Keep q.* first only for api_key accounts.
|
||||
*
|
||||
* The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens
|
||||
* and rejects an `tokentype: API_KEY` token with 401/403 — which
|
||||
* BaseExecutor.execute() returns immediately (only 429 / network errors fall
|
||||
* through to the next host). So for api-key auth we must try the *.amazonaws.com
|
||||
* CodeWhisperer hosts FIRST, mirroring the Kiro-Go reference fork which never
|
||||
* routes api-key traffic through kiro.dev. External IdP enterprise tokens also
|
||||
* and rejects TokenType=API_KEY. External IdP enterprise tokens instead
|
||||
* use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header.
|
||||
* Other OAuth methods keep the default order (kiro.dev first) since their
|
||||
* tokens are what that gateway accepts.
|
||||
@@ -282,6 +291,14 @@ export class KiroExecutor extends BaseExecutor {
|
||||
|
||||
const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize);
|
||||
const others = baseUrls.filter((u) => !u.includes("amazonaws.com"));
|
||||
if (authMethod === "api_key") {
|
||||
const q = amazon.filter((u) => u.includes("://q."));
|
||||
const remaining = amazon.filter((u) => !u.includes("://q."));
|
||||
return q.length > 0
|
||||
? [...q, ...remaining, ...others]
|
||||
: [...amazon, ...others];
|
||||
}
|
||||
|
||||
return amazon.length > 0 ? [...amazon, ...others] : baseUrls;
|
||||
}
|
||||
|
||||
@@ -290,6 +307,14 @@ export class KiroExecutor extends BaseExecutor {
|
||||
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
|
||||
}
|
||||
|
||||
// Retry only endpoint/auth-surface failures. Payload-invalid HTTP 400 must be
|
||||
// terminal: sending the same malformed body to every surface cannot repair it.
|
||||
shouldRetry(status, urlIndex) {
|
||||
const hasFallback = urlIndex + 1 < this.getFallbackCount();
|
||||
return super.shouldRetry(status, urlIndex)
|
||||
|| (hasFallback && KIRO_ENDPOINT_FALLBACK_STATUSES.has(status));
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ export default {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.amazon.eventstream",
|
||||
"X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse",
|
||||
"User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
|
||||
"X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
// 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.
|
||||
|
||||
@@ -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
|
||||
@@ -38,82 +31,17 @@ import {
|
||||
} 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;
|
||||
|
||||
@@ -122,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) {
|
||||
@@ -157,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 = [];
|
||||
@@ -209,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 }],
|
||||
});
|
||||
}
|
||||
@@ -256,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
|
||||
@@ -307,66 +198,9 @@ 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;
|
||||
@@ -383,9 +217,8 @@ function extractClaudeSystemText(system) {
|
||||
* 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;
|
||||
@@ -397,21 +230,8 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
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.
|
||||
@@ -460,7 +280,14 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
history,
|
||||
currentMessage,
|
||||
});
|
||||
const replayCurrent = replay.currentMessage?.userInputMessage || {};
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: replay.history,
|
||||
currentMessage: replay.currentMessage,
|
||||
modelId: upstreamModel,
|
||||
toolSpecs,
|
||||
nameMap,
|
||||
});
|
||||
const replayCurrent = canonical.currentMessage.userInputMessage;
|
||||
const userInputMessage = {
|
||||
content: replayCurrent.content || "",
|
||||
modelId: upstreamModel,
|
||||
@@ -482,7 +309,7 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
currentMessage: {
|
||||
userInputMessage,
|
||||
},
|
||||
history: replay.history,
|
||||
history: canonical.history,
|
||||
},
|
||||
agentMode: "vibe",
|
||||
};
|
||||
|
||||
@@ -20,148 +20,10 @@ import {
|
||||
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.
|
||||
@@ -177,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") {
|
||||
@@ -219,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 = [];
|
||||
@@ -327,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 }]
|
||||
});
|
||||
});
|
||||
@@ -339,7 +157,7 @@ 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) {
|
||||
@@ -413,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;
|
||||
@@ -473,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 };
|
||||
}
|
||||
|
||||
@@ -532,7 +317,8 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
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
|
||||
@@ -586,7 +372,14 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
history,
|
||||
currentMessage,
|
||||
});
|
||||
const replayCurrent = replay.currentMessage?.userInputMessage || {};
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: replay.history,
|
||||
currentMessage: replay.currentMessage,
|
||||
modelId: upstreamModel,
|
||||
toolSpecs,
|
||||
nameMap,
|
||||
});
|
||||
const replayCurrent = canonical.currentMessage.userInputMessage;
|
||||
|
||||
const payload = {
|
||||
conversationState: {
|
||||
@@ -607,7 +400,7 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
})
|
||||
}
|
||||
},
|
||||
history: replay.history
|
||||
history: canonical.history
|
||||
},
|
||||
agentMode: "vibe",
|
||||
};
|
||||
|
||||
@@ -42,6 +42,14 @@ function findFirstUserIndex(history) {
|
||||
return history.findIndex((item) => item?.userInputMessage);
|
||||
}
|
||||
|
||||
function hasToolResults(message) {
|
||||
return !!message?.userInputMessage?.userInputMessageContext?.toolResults?.length;
|
||||
}
|
||||
|
||||
function canReplaceSessionStart(history, firstUserIndex) {
|
||||
return firstUserIndex === 0 && !hasToolResults(history[firstUserIndex]);
|
||||
}
|
||||
|
||||
function rememberSessionStart(key, entry) {
|
||||
if (sessionStartStore.size >= MAX_SESSION_STARTS) {
|
||||
sessionStartStore.delete(sessionStartStore.keys().next().value);
|
||||
@@ -73,10 +81,13 @@ export function applyKiroSessionReplay({
|
||||
existing.lastUsed = Date.now();
|
||||
const firstUserIndex = findFirstUserIndex(baseHistory);
|
||||
const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId);
|
||||
if (firstUserIndex >= 0) {
|
||||
if (canReplaceSessionStart(baseHistory, firstUserIndex)) {
|
||||
baseHistory[firstUserIndex] = sessionStart;
|
||||
} else {
|
||||
baseHistory.unshift(sessionStart);
|
||||
if (baseHistory.length === 1) {
|
||||
baseHistory.push({ assistantResponseMessage: { content: "..." } });
|
||||
}
|
||||
}
|
||||
return {
|
||||
history: ensureHistoryModelIds(baseHistory, modelId),
|
||||
@@ -88,10 +99,18 @@ export function applyKiroSessionReplay({
|
||||
const firstUserIndex = findFirstUserIndex(baseHistory);
|
||||
let sessionStart;
|
||||
let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId);
|
||||
if (firstUserIndex >= 0) {
|
||||
if (canReplaceSessionStart(baseHistory, firstUserIndex)) {
|
||||
sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId);
|
||||
baseHistory[firstUserIndex] = clone(sessionStart);
|
||||
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
|
||||
} else if (firstUserIndex >= 0) {
|
||||
sessionStart = prefixUserMessage(
|
||||
{ userInputMessage: { content: "", modelId } },
|
||||
contentPrefix,
|
||||
modelId
|
||||
);
|
||||
baseHistory.unshift(clone(sessionStart));
|
||||
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
|
||||
} else {
|
||||
sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId);
|
||||
nextCurrent = clone(sessionStart);
|
||||
|
||||
@@ -5,8 +5,8 @@ import { createProviderConnection } from "@/models";
|
||||
/**
|
||||
* POST /api/oauth/kiro/api-key
|
||||
* Import a Kiro API key (headless auth). The key is a long-lived bearer
|
||||
* credential — there is no refresh token. It is validated by listing
|
||||
* CodeWhisperer profiles, then stored with authMethod="api_key".
|
||||
* credential — there is no refresh token. It is validated against the Amazon
|
||||
* Q model catalog, then stored with authMethod="api_key".
|
||||
*/
|
||||
export async function POST(request) {
|
||||
try {
|
||||
@@ -21,7 +21,7 @@ export async function POST(request) {
|
||||
|
||||
const kiroService = new KiroService();
|
||||
|
||||
// Validate the key and resolve its profileArn via ListAvailableProfiles
|
||||
// Validate the key against the same Amazon Q surface used for inference.
|
||||
const credential = await kiroService.validateApiKey(
|
||||
apiKey,
|
||||
region || "us-east-1"
|
||||
@@ -40,7 +40,7 @@ export async function POST(request) {
|
||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
email: email || null,
|
||||
providerSpecificData: {
|
||||
profileArn: credential.profileArn,
|
||||
...(credential.profileArn ? { profileArn: credential.profileArn } : {}),
|
||||
region: credential.region,
|
||||
authMethod: "api_key",
|
||||
provider: "API Key",
|
||||
|
||||
@@ -260,11 +260,9 @@ export class KiroService {
|
||||
}
|
||||
|
||||
/**
|
||||
* List available CodeWhisperer profiles for a token (or API key) and return
|
||||
* the best-matching profileArn. AWS SSO OIDC logins return no profileArn, so
|
||||
* it must be fetched separately — the same call works for API-key auth.
|
||||
* Accepts both `arn` and `profileArn` response field names (the API-key
|
||||
* JSON-1.0 surface returns `arn`).
|
||||
* List available CodeWhisperer profiles for OAuth/IDC tokens and return the
|
||||
* best-matching profileArn. API keys use the Amazon Q model catalog instead;
|
||||
* ListAvailableProfiles does not support TokenType=API_KEY.
|
||||
*/
|
||||
async listAvailableProfiles(accessToken, region = "us-east-1") {
|
||||
assertValidAwsRegion(region);
|
||||
@@ -294,10 +292,41 @@ export class KiroService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API-key credential by listing profiles with it. API keys are
|
||||
* long-lived bearer tokens (no refresh), so the only way to validate one is
|
||||
* to make an authenticated CodeWhisperer call. Returns a credential object
|
||||
* ready to persist as a "kiro" connection with authMethod="api_key".
|
||||
* Validate an API key against the Amazon Q model catalog. A bearer-only call
|
||||
* to ListAvailableProfiles can return HTTP 200 with an empty list for an
|
||||
* arbitrary key, so it is not proof that the key can run inference.
|
||||
*/
|
||||
async listAvailableApiKeyModels(apiKey, region = "us-east-1") {
|
||||
assertValidAwsRegion(region);
|
||||
const params = new URLSearchParams({ origin: "AI_EDITOR" });
|
||||
const endpoint = `https://q.${region}.amazonaws.com/ListAvailableModels?${params}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"TokenType": "API_KEY",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
|
||||
"X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to list API-key models: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = Array.isArray(data?.models) ? data.models : [];
|
||||
if (models.length === 0) {
|
||||
throw new Error("API key returned no available models");
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API-key credential through the same Amazon Q surface used for
|
||||
* inference. API keys are account-bound but do not require a profileArn.
|
||||
*/
|
||||
async validateApiKey(apiKey, region = "us-east-1") {
|
||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||
@@ -305,9 +334,8 @@ export class KiroService {
|
||||
}
|
||||
const trimmed = apiKey.trim();
|
||||
|
||||
let profileArn = null;
|
||||
try {
|
||||
profileArn = await this.listAvailableProfiles(trimmed, region);
|
||||
await this.listAvailableApiKeyModels(trimmed, region);
|
||||
} catch (error) {
|
||||
throw new Error(`API key validation failed: ${error.message}`);
|
||||
}
|
||||
@@ -315,7 +343,7 @@ export class KiroService {
|
||||
return {
|
||||
accessToken: trimmed,
|
||||
refreshToken: null,
|
||||
profileArn,
|
||||
profileArn: null,
|
||||
region,
|
||||
authMethod: "api_key",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { KiroExecutor } from "../../open-sse/executors/kiro.js";
|
||||
|
||||
const RUNTIME = "https://runtime.us-east-1.kiro.dev/generateAssistantResponse";
|
||||
const CODEWHISPERER = "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse";
|
||||
const Q = "https://q.us-east-1.amazonaws.com/generateAssistantResponse";
|
||||
|
||||
function credentials(authMethod, region = "us-east-1") {
|
||||
return { providerSpecificData: { authMethod, region } };
|
||||
}
|
||||
|
||||
describe("Kiro auth-aware endpoint routing", () => {
|
||||
const executor = new KiroExecutor();
|
||||
|
||||
it("routes API-key inference through Amazon Q before other surfaces", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("api_key"))).toEqual([
|
||||
Q,
|
||||
CODEWHISPERER,
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps Builder ID OAuth on the Kiro runtime surface", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("builder-id"))).toEqual([
|
||||
RUNTIME,
|
||||
CODEWHISPERER,
|
||||
Q,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps external IdP on CodeWhisperer before Amazon Q", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("external_idp"))).toEqual([
|
||||
CODEWHISPERER,
|
||||
Q,
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("regionalizes AWS endpoints for IDC without changing Kiro runtime", () => {
|
||||
expect(executor.getOrderedBaseUrls(credentials("idc", "eu-west-1"))).toEqual([
|
||||
"https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse",
|
||||
"https://q.eu-west-1.amazonaws.com/generateAssistantResponse",
|
||||
RUNTIME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries only endpoint/auth-surface failures, not payload-invalid 400s", () => {
|
||||
expect(executor.shouldRetry(400, 0)).toBe(false);
|
||||
expect(executor.shouldRetry(401, 1)).toBe(true);
|
||||
expect(executor.shouldRetry(403, 2)).toBe(false);
|
||||
expect(executor.shouldRetry(422, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("builds endpoint-specific headers", () => {
|
||||
const auth = { accessToken: "test-key", providerSpecificData: { authMethod: "api_key" } };
|
||||
const qHeaders = executor.buildHeaders(auth, true, Q);
|
||||
const codeWhispererHeaders = executor.buildHeaders(auth, true, CODEWHISPERER);
|
||||
const runtimeHeaders = executor.buildHeaders(auth, true, RUNTIME);
|
||||
|
||||
expect(qHeaders.TokenType).toBe("API_KEY");
|
||||
expect(qHeaders["X-Amz-Target"]).toBeUndefined();
|
||||
expect(codeWhispererHeaders["X-Amz-Target"]).toBe(
|
||||
"AmazonCodeWhispererStreamingService.GenerateAssistantResponse"
|
||||
);
|
||||
expect(runtimeHeaders["X-Amz-Target"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canonicalizeKiroConversation,
|
||||
normalizeKiroToolSpecs,
|
||||
validateKiroConversation,
|
||||
} from "../../open-sse/translator/concerns/kiroConversation.js";
|
||||
import { clearKiroSessionReplayStore } from "../../open-sse/utils/kiroSessionReplay.js";
|
||||
import { clearSessionStore } from "../../open-sse/utils/sessionManager.js";
|
||||
import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js";
|
||||
import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js";
|
||||
|
||||
const modelId = "claude-opus-5";
|
||||
|
||||
function tool(name, schema = { type: "object", properties: {} }) {
|
||||
return { name, description: `Tool ${name}`, input_schema: schema };
|
||||
}
|
||||
|
||||
function specState(names = ["first", "second"]) {
|
||||
const source = names.map((name) => tool(name));
|
||||
return normalizeKiroToolSpecs(source);
|
||||
}
|
||||
|
||||
function user(content, toolResults = []) {
|
||||
return {
|
||||
userInputMessage: {
|
||||
content,
|
||||
modelId,
|
||||
...(toolResults.length > 0 && { userInputMessageContext: { toolResults } }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistant(content, toolUses = []) {
|
||||
return {
|
||||
assistantResponseMessage: {
|
||||
content,
|
||||
...(toolUses.length > 0 && { toolUses }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function result(toolUseId, value, status = "success") {
|
||||
return { toolUseId, status, content: [{ text: value }] };
|
||||
}
|
||||
|
||||
describe("Kiro conversation canonicalizer", () => {
|
||||
beforeEach(() => {
|
||||
clearKiroSessionReplayStore();
|
||||
clearSessionStore();
|
||||
});
|
||||
|
||||
it("keeps complete parallel tool pairs structured", () => {
|
||||
const { specs, nameMap } = specState();
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "t1", name: "first", input: { n: 1 } },
|
||||
{ toolUseId: "t2", name: "second", input: { n: 2 } },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const calls = canonical.history[1].assistantResponseMessage.toolUses;
|
||||
const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults;
|
||||
expect(calls.map((call) => call.toolUseId)).toEqual(["t1", "t2"]);
|
||||
expect(results.map((item) => item.toolUseId)).toEqual(["t1", "t2"]);
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the answered parallel call and flattens only the missing one", () => {
|
||||
const { specs, nameMap } = specState();
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "t1", name: "first", input: {} },
|
||||
{ toolUseId: "t2", name: "second", input: {} },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const assistantMessage = canonical.history[1].assistantResponseMessage;
|
||||
expect(assistantMessage.toolUses).toHaveLength(1);
|
||||
expect(assistantMessage.toolUses[0].toolUseId).toBe("t1");
|
||||
expect(assistantMessage.content).toContain("[Tool call: second(");
|
||||
expect(canonical.repairs.missingResults).toBe(1);
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens non-adjacent and orphaned tool results", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
user("result missing here"),
|
||||
assistant("later"),
|
||||
],
|
||||
currentMessage: user("late result", [result("t1", "too late")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(canonical)).not.toContain('"toolUseId":"t1"');
|
||||
expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:");
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("too late");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("remaps duplicate tool IDs together with their adjacent results", () => {
|
||||
const { specs, nameMap } = specState();
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "duplicate", name: "first", input: {} },
|
||||
{ toolUseId: "duplicate", name: "second", input: {} },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [
|
||||
result("duplicate", "one"),
|
||||
result("duplicate", "two"),
|
||||
]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const calls = canonical.history[1].assistantResponseMessage.toolUses;
|
||||
const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults;
|
||||
expect(new Set(calls.map((call) => call.toolUseId)).size).toBe(2);
|
||||
expect(results.map((item) => item.toolUseId)).toEqual(calls.map((call) => call.toolUseId));
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("deduplicates extra results without losing their text", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one"), result("t1", "duplicate")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
const current = canonical.currentMessage.userInputMessage;
|
||||
expect(current.userInputMessageContext.toolResults).toHaveLength(1);
|
||||
expect(current.content).toContain("duplicate");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens a trailing unanswered assistant tool call and creates a current user turn", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [user("start")],
|
||||
currentMessage: assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(canonical.currentMessage.userInputMessage.content).toBe("continue");
|
||||
expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined();
|
||||
expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens malformed input and tool uses missing from the current specs", () => {
|
||||
const { specs, nameMap } = specState(["first"]);
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [
|
||||
{ toolUseId: "t1", name: "first", input: "{bad json" },
|
||||
{ toolUseId: "t2", name: "removed_tool", input: {} },
|
||||
]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined();
|
||||
expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toBeUndefined();
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("one");
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("two");
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("repairs a 30-call parallel turn with one missing result", () => {
|
||||
const names = Array.from({ length: 30 }, (_, index) => `tool_${index}`);
|
||||
const { specs, nameMap } = specState(names);
|
||||
const calls = names.map((name, index) => ({
|
||||
toolUseId: `t${index}`,
|
||||
name,
|
||||
input: { index },
|
||||
}));
|
||||
const results = names.slice(0, -1).map((_, index) => result(`t${index}`, `r${index}`));
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [user("start"), assistant("run", calls)],
|
||||
currentMessage: user("continue", results),
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(canonical.history[1].assistantResponseMessage.toolUses).toHaveLength(29);
|
||||
expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toHaveLength(29);
|
||||
expect(canonical.repairs.missingResults).toBe(1);
|
||||
expect(canonical.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("flattens structured history when the client sent no tool specs", () => {
|
||||
const canonical = canonicalizeKiroConversation({
|
||||
history: [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
],
|
||||
currentMessage: user("continue", [result("t1", "one")]),
|
||||
modelId,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(canonical)).not.toContain("toolUses");
|
||||
expect(JSON.stringify(canonical)).not.toContain("toolResults");
|
||||
expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:");
|
||||
expect(canonical.currentMessage.userInputMessage.content).toContain("[Tool result:");
|
||||
});
|
||||
|
||||
it("normalizes names and recursively removes unsupported schema fields", () => {
|
||||
const longDescription = "x".repeat(11000);
|
||||
const { specs, nameMap } = normalizeKiroToolSpecs([{
|
||||
name: "bad tool/name",
|
||||
description: longDescription,
|
||||
input_schema: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nested: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
}]);
|
||||
|
||||
const specification = specs[0].toolSpecification;
|
||||
expect(nameMap.get("bad tool/name")).toBe("bad_tool_name");
|
||||
expect(specification.name.length).toBeLessThanOrEqual(64);
|
||||
expect(specification.description.length).toBe(10237);
|
||||
expect(JSON.stringify(specification.inputSchema.json)).not.toContain("additionalProperties");
|
||||
expect(JSON.stringify(specification.inputSchema.json)).not.toContain('"required":[]');
|
||||
});
|
||||
|
||||
it("does not mutate the source conversation or tool definitions", () => {
|
||||
const sourceTools = [tool("first")];
|
||||
const sourceHistory = [
|
||||
user("start"),
|
||||
assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]),
|
||||
];
|
||||
const sourceCurrent = user("continue", [result("t1", "one")]);
|
||||
const before = JSON.stringify({ sourceTools, sourceHistory, sourceCurrent });
|
||||
const { specs, nameMap } = normalizeKiroToolSpecs(sourceTools);
|
||||
|
||||
canonicalizeKiroConversation({
|
||||
history: sourceHistory,
|
||||
currentMessage: sourceCurrent,
|
||||
modelId,
|
||||
toolSpecs: specs,
|
||||
nameMap,
|
||||
});
|
||||
|
||||
expect(JSON.stringify({ sourceTools, sourceHistory, sourceCurrent })).toBe(before);
|
||||
});
|
||||
|
||||
it("preserves Claude tool_result errors", () => {
|
||||
const output = claudeToKiroRequest(modelId, {
|
||||
tools: [tool("first")],
|
||||
messages: [
|
||||
{ role: "user", content: "start" },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", is_error: true, content: "failed" }] },
|
||||
],
|
||||
}, true, {});
|
||||
|
||||
const item = output.conversationState.currentMessage.userInputMessage
|
||||
.userInputMessageContext.toolResults[0];
|
||||
expect(item.status).toBe("error");
|
||||
});
|
||||
|
||||
it("repairs partial parallel results in both direct translators", () => {
|
||||
const claude = claudeToKiroRequest(modelId, {
|
||||
tools: [tool("first"), tool("second")],
|
||||
messages: [
|
||||
{ role: "user", content: "start" },
|
||||
{ role: "assistant", content: [
|
||||
{ type: "tool_use", id: "t1", name: "first", input: {} },
|
||||
{ type: "tool_use", id: "t2", name: "second", input: {} },
|
||||
] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "one" }] },
|
||||
],
|
||||
}, true, {});
|
||||
const openai = openaiToKiroRequest(modelId, {
|
||||
tools: [
|
||||
{ type: "function", function: { name: "first", parameters: { type: "object", properties: {} } } },
|
||||
{ type: "function", function: { name: "second", parameters: { type: "object", properties: {} } } },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: "start" },
|
||||
{ role: "assistant", content: "", tool_calls: [
|
||||
{ id: "t1", type: "function", function: { name: "first", arguments: "{}" } },
|
||||
{ id: "t2", type: "function", function: { name: "second", arguments: "{}" } },
|
||||
] },
|
||||
{ role: "tool", tool_call_id: "t1", content: "one" },
|
||||
],
|
||||
}, true, {});
|
||||
|
||||
for (const payload of [claude, openai]) {
|
||||
const state = payload.conversationState;
|
||||
const validation = validateKiroConversation(
|
||||
state.history,
|
||||
state.currentMessage,
|
||||
state.currentMessage.userInputMessage.userInputMessageContext.tools
|
||||
);
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(state.history[1].assistantResponseMessage.toolUses).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let session replay replace a tool-result turn", () => {
|
||||
const credentials = {
|
||||
rawHeaders: { "x-session-id": "kiro-replay-tool-result-regression" },
|
||||
connectionId: "kiro-account",
|
||||
};
|
||||
claudeToKiroRequest(modelId, {
|
||||
messages: [{ role: "user", content: "frozen session start" }],
|
||||
}, true, credentials);
|
||||
|
||||
const output = claudeToKiroRequest(modelId, {
|
||||
tools: [tool("first")],
|
||||
messages: [
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "kept" }] },
|
||||
],
|
||||
}, true, credentials);
|
||||
const state = output.conversationState;
|
||||
const allText = JSON.stringify(state);
|
||||
|
||||
expect(allText).toContain("frozen session start");
|
||||
expect(allText).toContain("kept");
|
||||
expect(validateKiroConversation(
|
||||
state.history,
|
||||
state.currentMessage,
|
||||
state.currentMessage.userInputMessage.userInputMessageContext.tools
|
||||
).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,8 @@ import { KiroService } from "../../src/lib/oauth/services/kiro.js";
|
||||
/**
|
||||
* Regression tests for Kiro API-key auth.
|
||||
*
|
||||
* KiroService.validateApiKey resolves a profileArn with the key (via
|
||||
* CodeWhisperer ListAvailableProfiles) and returns a credential shaped for
|
||||
* persistence with authMethod="api_key". The response profile field name
|
||||
* varies (`arn` vs `profileArn`) — both are accepted by listAvailableProfiles.
|
||||
* KiroService.validateApiKey validates against the Amazon Q model catalog and
|
||||
* returns an account-bound credential without inventing a profileArn.
|
||||
*
|
||||
* Note: OAuth (Builder ID / IDC) profileArn resolution is handled upstream by
|
||||
* fetchKiroProfileArn in providers.js and is covered there — not here.
|
||||
@@ -16,11 +14,10 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("validates an API key and resolves a credential with profileArn", async () => {
|
||||
const expectedArn = "arn:aws:codewhisperer:us-east-1:444:profile/KEY";
|
||||
it("validates an API key against Amazon Q without inventing profileArn", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ profiles: [{ arn: expectedArn }] }),
|
||||
json: async () => ({ models: [{ modelId: "claude-opus-5" }] }),
|
||||
});
|
||||
|
||||
const svc = new KiroService();
|
||||
@@ -29,17 +26,18 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => {
|
||||
expect(cred).toEqual({
|
||||
accessToken: "my-secret-key",
|
||||
refreshToken: null,
|
||||
profileArn: expectedArn,
|
||||
profileArn: null,
|
||||
region: "us-east-1",
|
||||
authMethod: "api_key",
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://codewhisperer.us-east-1.amazonaws.com");
|
||||
expect(init.headers.Authorization).toBe("Bearer my-secret-key");
|
||||
expect(init.headers["x-amz-target"]).toBe(
|
||||
"AmazonCodeWhispererService.ListAvailableProfiles"
|
||||
expect(url).toBe(
|
||||
"https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR"
|
||||
);
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.headers.Authorization).toBe("Bearer my-secret-key");
|
||||
expect(init.headers.TokenType).toBe("API_KEY");
|
||||
});
|
||||
|
||||
it("rejects an empty API key without a network call", async () => {
|
||||
@@ -60,4 +58,15 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => {
|
||||
/API key validation failed/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a 200 response with an empty model catalog", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ models: [] }),
|
||||
});
|
||||
const svc = new KiroService();
|
||||
await expect(svc.validateApiKey("empty-key")).rejects.toThrow(
|
||||
/returned no available models/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user