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
53 changes: 48 additions & 5 deletions src/adapters/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ const AMZ_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantRespons
const SDK_VERSION = "1.0.27";
const NODE_VERSION = "22.21.1";
const KIRO_IDE_VERSION = "1.0.0";
type KiroWireClient = "ide" | "cli";

function kiroCliPlatform(): "linux" | "macos" | "windows" {
return process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux";
}

function kiroCliUserAgent(includeAppVersion: boolean): string {
return [
"aws-sdk-rust/1.3.15",
"ua/2.1",
"api/codewhispererstreaming/0.1.17975",
`os/${kiroCliPlatform()}`,
"lang/rust/1.92.0",
...(includeAppVersion ? ["md/appVersion-2.14.2"] : []),
"m/F",
"app/AmazonQ-For-CLI",
].join(" ");
}

// Payload construction (conversationState)
interface KiroToolUse {
Expand All @@ -68,7 +86,10 @@ interface KiroUserInputMessage {
content: string;
modelId?: string;
origin?: string;
userInputMessageContext?: { tools?: unknown[]; toolResults?: KiroToolResult[] };
userInputMessageContext?: {
tools?: unknown[];
toolResults?: KiroToolResult[];
};
images?: KiroImage[];
}
interface KiroHistoryEntry {
Expand Down Expand Up @@ -385,6 +406,7 @@ export function buildKiroPayload(
parsed: OcxParsedRequest,
profileArn: string | undefined,
forcedCompletionMode?: KiroCompletionMode,
wireClient: KiroWireClient = "ide",
): {
payload: Record<string, unknown>;
nameMap: Map<string, string>;
Expand Down Expand Up @@ -509,7 +531,7 @@ export function buildKiroPayload(
userInputMessage: {
content: turn.content,
modelId,
origin: "AI_EDITOR",
origin: wireClient === "cli" ? "KIRO_CLI" : "AI_EDITOR",
...(turn.images.length > 0 ? { images: turn.images } : {}),
...(turn.toolResults.length > 0 ? { userInputMessageContext: { toolResults: turn.toolResults } } : {}),
},
Expand Down Expand Up @@ -539,6 +561,10 @@ export function buildKiroPayload(
const payload: Record<string, unknown> = {
conversationState: {
chatTriggerType: "MANUAL",
...(wireClient === "cli" ? {
agentContinuationId: crypto.randomUUID(),
agentTaskType: "vibe",
} : {}),
conversationId,
currentMessage: { userInputMessage: currentUim },
...(history.length > 0 ? { history } : {}),
Expand Down Expand Up @@ -1446,9 +1472,25 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
throw new Error("kiro token missing — run ocx login kiro");
}
const region = resolveKiroApiRegion(parsed._kiroAuthContext);
const profileArn = resolveKiroProfileArn(parsed._kiroAuthContext);
const resolvedProfileArn = resolveKiroProfileArn(parsed._kiroAuthContext);
const isApiKey = provider.apiKey.trim().startsWith("ksk_");
const profileArn = isApiKey ? undefined : resolvedProfileArn;
// Builder ID and Kiro API keys have no profile ARN and are accepted only on Kiro's CLI
// request path. Enterprise profiles retain the existing IDE-shaped request.
const wireClient: KiroWireClient = isApiKey || !profileArn ? "cli" : "ide";
const fp = fingerprint().slice(0, 64);
const headers: Record<string, string> = {
const headers: Record<string, string> = wireClient === "cli" ? {
authorization: `Bearer ${provider.apiKey}`,
"content-type": "application/x-amz-json-1.0",
accept: "*/*",
"x-amz-target": AMZ_TARGET,
"user-agent": kiroCliUserAgent(true),
"x-amz-user-agent": kiroCliUserAgent(false),
"x-amzn-codewhisperer-optout": "true",
"amz-sdk-request": "attempt=1; max=3",
"amz-sdk-invocation-id": invocationId(),
...(isApiKey ? { tokentype: "API_KEY" } : {}),
} : {
authorization: `Bearer ${provider.apiKey}`,
"content-type": "application/x-amz-json-1.0",
accept: "application/vnd.amazon.eventstream",
Expand All @@ -1460,7 +1502,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
"amz-sdk-invocation-id": invocationId(),
};
if (profileArn) headers["x-amzn-kiro-profile-arn"] = profileArn;
const built = buildKiroPayload(parsed, profileArn, forcedCompletionMode);
const built = buildKiroPayload(parsed, profileArn, forcedCompletionMode, wireClient);
await normalizeKiroImages(built.payload);
const contextInputEstimate = estimateKiroPayloadInputTokens(built.payload, parsed.modelId);
const body = JSON.stringify(built.payload);
Expand All @@ -1472,6 +1514,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
messageCount: kiroPayloadMessages(parsed).length,
toolCount: parsed.context.tools?.length ?? 0,
hasProfileArn: Boolean(profileArn),
wireClient,
hasPreviousResponseId: Boolean(parsed.previousResponseId),
});
return {
Expand Down
51 changes: 46 additions & 5 deletions tests/kiro-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,53 @@ describe("kiro adapter — buildRequest", () => {
}
});

test("headers carry Bearer token + CW targets", async () => {
const { url, method, headers } = await createKiroAdapter(provider).buildRequest(parsedWith([{ role: "user", content: "hi" }]));
test("Builder ID requests without a profile ARN use the Kiro CLI wire contract", async () => {
const { url, method, headers, body } = await createKiroAdapter(provider).buildRequest(parsedWith([{ role: "user", content: "hi" }]));
const payload = JSON.parse(body) as {
profileArn?: string;
conversationState: {
agentContinuationId?: string;
agentTaskType?: string;
currentMessage: { userInputMessage: Record<string, unknown> };
};
};
expect(url).toBe("https://runtime.us-east-1.kiro.dev/");
expect(method).toBe("POST");
expect(headers.authorization).toBe("Bearer tok-123");
expect(headers["x-amz-target"]).toBe("AmazonCodeWhispererStreamingService.GenerateAssistantResponse");
expect(headers.accept).toBe("application/vnd.amazon.eventstream");
expect(headers["x-amzn-kiro-agent-mode"]).toBe("vibe");
expect(headers.accept).toBe("*/*");
expect(headers["user-agent"]).toContain("app/AmazonQ-For-CLI");
expect(headers["x-amzn-kiro-agent-mode"]).toBeUndefined();
expect(headers["x-amzn-kiro-profile-arn"]).toBeUndefined();
expect(headers["x-amzn-codewhisperer-optout"]).toBe("true");
expect(headers.tokentype).toBeUndefined();
expect(payload.profileArn).toBeUndefined();
expect(payload.conversationState.agentTaskType).toBe("vibe");
expect(payload.conversationState.agentContinuationId).toMatch(/^[0-9a-f-]{36}$/);
expect(payload.conversationState.currentMessage.userInputMessage).toMatchObject({
content: "hi",
origin: "KIRO_CLI",
});
expect(payload.conversationState.currentMessage.userInputMessage).not.toHaveProperty("userInputMessageContext.envState");
});

test("Kiro API keys force the CLI token type and ignore unrelated profile metadata", async () => {
const apiKeyProvider = { ...provider, authMode: "key", apiKey: "ksk_example" } as unknown as OcxProviderConfig;
const parsed = parsedWith([{ role: "user", content: "hi" }]);
parsed._kiroAuthContext = {
profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/unrelated",
};
const request = await createKiroAdapter(apiKeyProvider).buildRequest(parsed);
const body = JSON.parse(request.body) as {
profileArn?: string;
conversationState: { currentMessage: { userInputMessage: { origin?: string } } };
};

expect(request.headers.authorization).toBe("Bearer ksk_example");
expect(request.headers.tokentype).toBe("API_KEY");
expect(request.headers["x-amzn-kiro-profile-arn"]).toBeUndefined();
expect(body.profileArn).toBeUndefined();
expect(body.conversationState.currentMessage.userInputMessage.origin).toBe("KIRO_CLI");
});

test("runtime URL uses KIRO_API_REGION separately from auth region", async () => {
Expand All @@ -104,6 +143,8 @@ describe("kiro adapter — buildRequest", () => {

expect(request.url).toBe("https://runtime.eu-central-1.kiro.dev/");
expect(request.headers["x-amzn-kiro-profile-arn"]).toBe(parsed._kiroAuthContext.profileArn);
expect(request.headers.accept).toBe("application/vnd.amazon.eventstream");
expect(request.headers["x-amzn-kiro-agent-mode"]).toBe("vibe");
expect(body.profileArn).toBe(parsed._kiroAuthContext.profileArn);
});

Expand Down Expand Up @@ -668,7 +709,7 @@ describe("kiro adapter — buildRequest", () => {
];
const cs = JSON.parse((await createKiroAdapter(provider).buildRequest(parsedWith(messages))).body).conversationState;
expect(cs.history).toEqual([
{ userInputMessage: { content: "first\n\nsecond", modelId: "claude-sonnet-4.5", origin: "AI_EDITOR" } },
{ userInputMessage: { content: "first\n\nsecond", modelId: "claude-sonnet-4.5", origin: "KIRO_CLI" } },
{ assistantResponseMessage: { content: "one\n\ntwo" } },
]);
expect(cs.currentMessage.userInputMessage.content).toBe("third");
Expand Down
Loading