diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js index 3ff6acbbfa..cbc104bcc4 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -38,6 +38,46 @@ export function resolveDefaultProfileArn(authMethod) { return social ? KIRO_DEFAULT_PROFILE_ARNS.social : KIRO_DEFAULT_PROFILE_ARNS["builder-id"]; } +// ─── Regional CodeWhisperer endpoints ─────────────────────────────────────── +// Kiro historically assumed us-east-1 everywhere. IAM Identity Center (IdC) +// accounts can be homed in another region (e.g. eu-central-1); their token is +// rejected with 403 "bearer token invalid" by the us-east-1 endpoints, and +// their CodeWhisperer profile is only visible from the regional Amazon Q host. +// These helpers thread `providerSpecificData.region` through, defaulting to +// us-east-1 so existing Builder ID / social accounts are unaffected. + +export const KIRO_DEFAULT_REGION = "us-east-1"; + +/** Normalize a Kiro region, falling back to us-east-1. */ +export function resolveKiroRegion(region) { + const r = typeof region === "string" ? region.trim() : ""; + return r || KIRO_DEFAULT_REGION; +} + +/** + * Data-plane (generateAssistantResponse) URL for a region. + * Returns null for us-east-1 so callers keep the historical registry baseUrls + * (runtime.kiro.dev → codewhisperer → q). Other regions use the regional + * Amazon Q endpoint, the only host that resolves + accepts their token. + */ +export function resolveKiroDataPlaneUrl(region) { + const r = resolveKiroRegion(region); + if (r === KIRO_DEFAULT_REGION) return null; + return `https://q.${r}.amazonaws.com/generateAssistantResponse`; +} + +/** + * Control-plane host (ListAvailableProfiles / GetUsageLimits) for a region. + * us-east-1 keeps the historical codewhisperer host; other regions use the + * regional Amazon Q host. + */ +export function resolveKiroControlPlaneHost(region) { + const r = resolveKiroRegion(region); + return r === KIRO_DEFAULT_REGION + ? "https://codewhisperer.us-east-1.amazonaws.com" + : `https://q.${r}.amazonaws.com`; +} + export const KIRO_THINKING_BUDGET_DEFAULT = 16000; export const KIRO_AGENTIC_SYSTEM_PROMPT = ` diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 3034f725f9..bd7a9ce894 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -2,6 +2,7 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; +import { resolveKiroDataPlaneUrl } from "../config/kiroConstants.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; /** @@ -51,6 +52,13 @@ export class KiroExecutor extends BaseExecutor { * (kiro.dev first) since its token is what that gateway accepts. */ getOrderedBaseUrls(credentials) { + // IAM Identity Center accounts can be homed outside us-east-1. Their token is + // rejected by the hardcoded us-east-1 registry baseUrls (403 "bearer token + // invalid"), so route to the account's regional Amazon Q endpoint. us-east-1 + // (Builder ID / social / unset) keeps the registry baseUrls + 429 rotation. + const regional = resolveKiroDataPlaneUrl(credentials?.providerSpecificData?.region); + if (regional) return [regional]; + const baseUrls = this.getBaseUrls(); const isApiKey = credentials?.providerSpecificData?.authMethod === "api_key"; if (!isApiKey) return baseUrls; diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index b8352156bb..8929cc1a33 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -296,7 +296,7 @@ async function resolveKiroProfileArnPatch(providerSpecificData, accessToken, ref let profileArn = refreshedArn?.trim?.() || null; if (!profileArn) { const { fetchKiroProfileArn } = await import("../../../src/lib/oauth/providers.js"); - profileArn = await fetchKiroProfileArn(accessToken); + profileArn = await fetchKiroProfileArn(accessToken, providerSpecificData?.region); } return profileArn ? { providerSpecificData: { profileArn } } : {}; } diff --git a/open-sse/services/usage/kiro.js b/open-sse/services/usage/kiro.js index fb22156567..e2d2399ff9 100644 --- a/open-sse/services/usage/kiro.js +++ b/open-sse/services/usage/kiro.js @@ -3,7 +3,7 @@ */ import { proxyAwareFetch } from "../../utils/proxyFetch.js"; -import { resolveDefaultProfileArn } from "../../config/kiroConstants.js"; +import { resolveDefaultProfileArn, resolveKiroRegion, resolveKiroControlPlaneHost, KIRO_DEFAULT_REGION } from "../../config/kiroConstants.js"; import { U, parseResetTime } from "./shared.js"; /** @@ -70,12 +70,20 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio resourceType: "AGENTIC_REQUEST", }); + // Region-aware usage hosts. us-east-1 keeps the historical codewhisperer/q + // hosts from the registry; other regions (IdC accounts) use the regional + // Amazon Q host so the GetUsageLimits call isn't rejected with 403. + const isDefaultRegion = resolveKiroRegion(providerSpecificData?.region) === KIRO_DEFAULT_REGION; + const cwHost = isDefaultRegion ? U("kiro").cwHost : resolveKiroControlPlaneHost(providerSpecificData?.region); + const qHost = isDefaultRegion ? U("kiro").qHost : resolveKiroControlPlaneHost(providerSpecificData?.region); + const limitsPath = U("kiro").limitsPath; + // For compatibility, try multiple known Kiro usage endpoints const attempts = [ { name: "codewhisperer-get", run: async () => proxyAwareFetch( - `${U("kiro").cwHost}${U("kiro").limitsPath}?${getUsageParams.toString()}`, + `${cwHost}${limitsPath}?${getUsageParams.toString()}`, { method: "GET", headers: { @@ -91,7 +99,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio }, { name: "codewhisperer-post", - run: async () => proxyAwareFetch(U("kiro").cwHost, { + run: async () => proxyAwareFetch(cwHost, { method: "POST", headers: { "Authorization": `Bearer ${accessToken}`, @@ -115,7 +123,7 @@ export async function getKiroUsage(accessToken, providerSpecificData, proxyOptio ...(profileArn ? { profileArn } : {}), resourceType: "AGENTIC_REQUEST", }); - return proxyAwareFetch(`${U("kiro").qHost}${U("kiro").limitsPath}?${params}`, { + return proxyAwareFetch(`${qHost}${limitsPath}?${params}`, { method: "GET", headers: { "Authorization": `Bearer ${accessToken}`, diff --git a/src/lib/oauth/providerHelpers.js b/src/lib/oauth/providerHelpers.js index 0cb46933b5..1f2cef4e6c 100644 --- a/src/lib/oauth/providerHelpers.js +++ b/src/lib/oauth/providerHelpers.js @@ -50,10 +50,17 @@ function extractEmailFromAccessToken(accessToken) { return payload.email || payload.preferred_username || payload.sub || undefined; } -export async function fetchKiroProfileArn(accessToken) { +export async function fetchKiroProfileArn(accessToken, region) { if (!accessToken) return null; + // IdC accounts homed outside us-east-1 only expose their CodeWhisperer profile + // via the regional Amazon Q host; us-east-1 returns an empty profile list for + // them. Default to the historical us-east-1 codewhisperer host. + const normalizedRegion = typeof region === "string" && region.trim() ? region.trim() : "us-east-1"; + const host = normalizedRegion === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com" + : `https://q.${normalizedRegion}.amazonaws.com`; try { - const response = await fetch("https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles", { + const response = await fetch(`${host}/ListAvailableProfiles`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index c66d25bbdc..e567ce3cdf 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -1362,7 +1362,7 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra const tokens = provider.mapTokens(result.data, extra); // Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403 if (providerName === "kiro" && !tokens.providerSpecificData?.profileArn) { - const profileArn = await fetchKiroProfileArn(tokens.accessToken); + const profileArn = await fetchKiroProfileArn(tokens.accessToken, tokens.providerSpecificData?.region); if (profileArn) tokens.providerSpecificData.profileArn = profileArn; } return { success: true, tokens }; diff --git a/tests/unit/kiro-region.test.js b/tests/unit/kiro-region.test.js new file mode 100644 index 0000000000..084a632c97 --- /dev/null +++ b/tests/unit/kiro-region.test.js @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + resolveKiroRegion, + resolveKiroDataPlaneUrl, + resolveKiroControlPlaneHost, + KIRO_DEFAULT_REGION, +} from "../../open-sse/config/kiroConstants.js"; +import { KiroExecutor } from "../../open-sse/executors/kiro.js"; +import { fetchKiroProfileArn } from "../../src/lib/oauth/providerHelpers.js"; + +/** + * Regression tests for multi-region Kiro / CodeWhisperer support. + * + * Background: Kiro assumed us-east-1 for the data plane, profile resolution and + * usage. IAM Identity Center accounts homed elsewhere (e.g. eu-central-1) are + * rejected with 403 "bearer token invalid" by the us-east-1 hosts, and their + * CodeWhisperer profile is only visible from the regional Amazon Q endpoint. + * The region (stored in providerSpecificData.region by the IdC device flow) now + * drives those endpoints, defaulting to us-east-1 for existing accounts. + */ +describe("kiro region helpers", () => { + it("defaults to us-east-1 and trims input", () => { + expect(resolveKiroRegion()).toBe(KIRO_DEFAULT_REGION); + expect(resolveKiroRegion("")).toBe("us-east-1"); + expect(resolveKiroRegion(" eu-central-1 ")).toBe("eu-central-1"); + }); + + it("returns null data-plane URL for us-east-1 (keep registry baseUrls)", () => { + expect(resolveKiroDataPlaneUrl()).toBeNull(); + expect(resolveKiroDataPlaneUrl("us-east-1")).toBeNull(); + }); + + it("returns the regional Amazon Q data-plane URL for other regions", () => { + expect(resolveKiroDataPlaneUrl("eu-central-1")).toBe( + "https://q.eu-central-1.amazonaws.com/generateAssistantResponse" + ); + }); + + it("resolves the control-plane host per region", () => { + expect(resolveKiroControlPlaneHost("us-east-1")).toBe( + "https://codewhisperer.us-east-1.amazonaws.com" + ); + expect(resolveKiroControlPlaneHost("eu-central-1")).toBe( + "https://q.eu-central-1.amazonaws.com" + ); + }); +}); + +describe("KiroExecutor.buildUrl region routing", () => { + it("keeps the us-east-1 registry baseUrl when no/default region", () => { + const ex = new KiroExecutor(); + const url = ex.buildUrl("claude-sonnet-4.5", true, 0, { providerSpecificData: {} }); + expect(url).toBe("https://runtime.us-east-1.kiro.dev/generateAssistantResponse"); + }); + + it("routes to the regional Amazon Q endpoint for a non-default region", () => { + const ex = new KiroExecutor(); + const url = ex.buildUrl("claude-opus-4.8", true, 0, { + providerSpecificData: { region: "eu-central-1" }, + }); + expect(url).toBe("https://q.eu-central-1.amazonaws.com/generateAssistantResponse"); + }); +}); + +describe("fetchKiroProfileArn region host", () => { + afterEach(() => vi.restoreAllMocks()); + + it("queries us-east-1 codewhisperer by default", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/X" }] }), + }); + const arn = await fetchKiroProfileArn("token"); + expect(arn).toBe("arn:aws:codewhisperer:us-east-1:1:profile/X"); + expect(fetchMock.mock.calls[0][0]).toBe( + "https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles" + ); + }); + + it("queries the regional Amazon Q host for a non-default region", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ profiles: [{ arn: "arn:aws:codewhisperer:eu-central-1:2:profile/Y" }] }), + }); + const arn = await fetchKiroProfileArn("token", "eu-central-1"); + expect(arn).toBe("arn:aws:codewhisperer:eu-central-1:2:profile/Y"); + expect(fetchMock.mock.calls[0][0]).toBe( + "https://q.eu-central-1.amazonaws.com/ListAvailableProfiles" + ); + }); +});