diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 8d022c2d7..1f093b391 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -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 { @@ -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 { @@ -385,6 +406,7 @@ export function buildKiroPayload( parsed: OcxParsedRequest, profileArn: string | undefined, forcedCompletionMode?: KiroCompletionMode, + wireClient: KiroWireClient = "ide", ): { payload: Record; nameMap: Map; @@ -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 } } : {}), }, @@ -539,6 +561,10 @@ export function buildKiroPayload( const payload: Record = { conversationState: { chatTriggerType: "MANUAL", + ...(wireClient === "cli" ? { + agentContinuationId: crypto.randomUUID(), + agentTaskType: "vibe", + } : {}), conversationId, currentMessage: { userInputMessage: currentUim }, ...(history.length > 0 ? { history } : {}), @@ -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 = { + const headers: Record = 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", @@ -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); @@ -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 { diff --git a/tests/kiro-adapter.test.ts b/tests/kiro-adapter.test.ts index 62e578279..b2ad63731 100644 --- a/tests/kiro-adapter.test.ts +++ b/tests/kiro-adapter.test.ts @@ -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 }; + }; + }; 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 () => { @@ -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); }); @@ -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");