diff --git a/packages/ai-config/src/__tests__/defaults.test.ts b/packages/ai-config/src/__tests__/defaults.test.ts index 17a8e59..ef1a76e 100644 --- a/packages/ai-config/src/__tests__/defaults.test.ts +++ b/packages/ai-config/src/__tests__/defaults.test.ts @@ -43,12 +43,14 @@ describe("provider connection defaults", () => { expect(PROVIDER_CONNECTION_DEFAULTS.positai).toBe(POSIT_AI_DEFAULTS); expect(PROVIDER_CONNECTION_DEFAULTS.ollama).toBe(OLLAMA_DEFAULTS); expect(PROVIDER_CONNECTION_DEFAULTS.lmstudio).toBe(LMSTUDIO_DEFAULTS); - expect(PROVIDER_CONNECTION_DEFAULTS.bedrock).toBe(BEDROCK_DEFAULTS); expect(PROVIDER_CONNECTION_DEFAULTS["google-vertex"]).toBe(GOOGLE_VERTEX_DEFAULTS); }); it("providers without specific defaults are not in the map", () => { expect(PROVIDER_CONNECTION_DEFAULTS.anthropic).toBeUndefined(); expect(PROVIDER_CONNECTION_DEFAULTS.openai).toBeUndefined(); + // Bedrock's default region is applied at credential-synthesis time, + // not baked into the resolved connection — see BEDROCK_DEFAULTS. + expect(PROVIDER_CONNECTION_DEFAULTS.bedrock).toBeUndefined(); }); }); diff --git a/packages/ai-config/src/__tests__/resolve-catalog.test.ts b/packages/ai-config/src/__tests__/resolve-catalog.test.ts index 88b8234..b45b634 100644 --- a/packages/ai-config/src/__tests__/resolve-catalog.test.ts +++ b/packages/ai-config/src/__tests__/resolve-catalog.test.ts @@ -362,9 +362,9 @@ describe("resolveProviderCatalog — legacy-positron layer merge semantics", () expect(resolved?.account).toBe("user-acct"); }); - it("AWS region ordering: env > legacy authentication setting > us-east-1 default", () => { - // legacy setting beats the us-east-1 default (revival of the previously-dead - // authentication.aws.credentials.AWS_REGION). + it("AWS region ordering: env > legacy authentication setting > unset", () => { + // legacy setting surfaces in the resolved connection (revival of the + // previously-dead authentication.aws.credentials.AWS_REGION). const hostOnly = resolveProviderCatalog({ sources: [ source("legacy-positron", { providers: { bedrock: { aws: { region: "eu-west-1" } } } }), @@ -384,13 +384,15 @@ describe("resolveProviderCatalog — legacy-positron layer merge semantics", () }); expect(find(withEnv, "bedrock")?.connection.aws?.region).toBe("ap-south-1"); - // no legacy setting, no env → the us-east-1 default. + // no legacy setting, no env → no region in the resolved connection. + // The us-east-1 default is applied later, at credential-synthesis time, + // so it doesn't outrank the user's stored credential region. const defaultOnly = resolveProviderCatalog({ sources: [source("user", { providers: {} })], baseline: STANDALONE, envVars: {}, }); - expect(find(defaultOnly, "bedrock")?.connection.aws?.region).toBe("us-east-1"); + expect(find(defaultOnly, "bedrock")?.connection.aws?.region).toBeUndefined(); }); }); diff --git a/packages/ai-config/src/defaults.ts b/packages/ai-config/src/defaults.ts index 6a627a4..11479b7 100644 --- a/packages/ai-config/src/defaults.ts +++ b/packages/ai-config/src/defaults.ts @@ -37,7 +37,14 @@ export const LMSTUDIO_DEFAULTS = { endpoint: "http://localhost:1234/v1", } as const satisfies ResolvedConnection; -/** AWS Bedrock default region. */ +/** + * AWS Bedrock default region. + * + * Deliberately **not** in {@link PROVIDER_CONNECTION_DEFAULTS}: injecting it + * into the resolved connection would make the baked-in default outrank the + * user's stored credential region downstream. It is applied instead at + * credential-synthesis time (and by UI forms) as a last-resort fallback. + */ export const BEDROCK_DEFAULTS = { aws: { region: "us-east-1" }, } as const satisfies ResolvedConnection; @@ -57,6 +64,5 @@ export const PROVIDER_CONNECTION_DEFAULTS: Readonly< positai: POSIT_AI_DEFAULTS, ollama: OLLAMA_DEFAULTS, lmstudio: LMSTUDIO_DEFAULTS, - bedrock: BEDROCK_DEFAULTS, "google-vertex": GOOGLE_VERTEX_DEFAULTS, }; diff --git a/packages/ai-provider-bridge/src/providers/__tests__/bedrock-mantle-provider.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/bedrock-fetcher.test.ts similarity index 50% rename from packages/ai-provider-bridge/src/providers/__tests__/bedrock-mantle-provider.test.ts rename to packages/ai-provider-bridge/src/providers/__tests__/bedrock-fetcher.test.ts index b89fcf4..375df5f 100644 --- a/packages/ai-provider-bridge/src/providers/__tests__/bedrock-mantle-provider.test.ts +++ b/packages/ai-provider-bridge/src/providers/__tests__/bedrock-fetcher.test.ts @@ -4,17 +4,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { listMantleModels, listFoundationModels } = vi.hoisted(() => ({ +const { listMantleModels, listFoundationModels, listInferenceProfiles } = vi.hoisted(() => ({ listMantleModels: vi.fn(), listFoundationModels: vi.fn(), + listInferenceProfiles: vi.fn(), })); vi.mock("../bedrock-mantle-models", () => ({ listMantleModels })); vi.mock("@aws-sdk/client-bedrock", () => ({ BedrockClient: vi.fn(function () { - return { send: listFoundationModels }; + return { + send: (command: { __kind?: string }) => + command.__kind === "inference-profiles" + ? listInferenceProfiles(command) + : listFoundationModels(command), + }; + }), + ListFoundationModelsCommand: vi.fn(function (this: { __kind: string }) { + this.__kind = "foundation-models"; + }), + ListInferenceProfilesCommand: vi.fn(function (this: { __kind: string }) { + this.__kind = "inference-profiles"; }), - ListFoundationModelsCommand: vi.fn(function () {}), })); import type { Logger } from "../../types"; @@ -31,6 +42,28 @@ function logger(): Logger { }; } +const CLAUDE_MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0"; + +function inferenceProfile(profileId: string, modelId: string) { + return { + inferenceProfileName: profileId, + inferenceProfileArn: `arn:aws:bedrock:us-east-1::inference-profile/${profileId}`, + inferenceProfileId: profileId, + status: "ACTIVE", + models: [{ modelArn: `arn:aws:bedrock:us-east-1::foundation-model/${modelId}` }], + type: "SYSTEM_DEFINED", + }; +} + +function credentialsFor(region: string) { + return { + type: "aws-credentials" as const, + region, + accessKeyId: "key", + secretAccessKey: "secret", + }; +} + beforeEach(() => { vi.clearAllMocks(); listMantleModels.mockResolvedValue([ @@ -42,7 +75,7 @@ beforeEach(() => { listFoundationModels.mockResolvedValue({ modelSummaries: [ { - modelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + modelId: CLAUDE_MODEL_ID, modelName: "Claude 3.5 Sonnet", responseStreamingSupported: true, }, @@ -55,18 +88,17 @@ beforeEach(() => { }, ], }); + // Default: discovery succeeds and mirrors the legacy prefix-constructed ID. + listInferenceProfiles.mockResolvedValue({ + inferenceProfileSummaries: [inferenceProfile(`us.${CLAUDE_MODEL_ID}`, CLAUDE_MODEL_ID)], + }); }); describe("Bedrock provider Mantle aggregation", () => { it("maps supported Mantle families, filters duplicates, and caches sources independently", async () => { const registry = new ProviderRegistry(logger()); registerBedrockProvider(registry, logger()); - const credentials = { - type: "aws-credentials" as const, - region: "us-east-2", - accessKeyId: "key", - secretAccessKey: "secret", - }; + const credentials = credentialsFor("us-east-2"); const models = await registry.getModelsForProvider("bedrock", credentials); const gptOss = models.find((model) => model.id === "openai.gpt-oss-120b"); @@ -96,12 +128,7 @@ describe("Bedrock provider Mantle aggregation", () => { const onProviderStatusChange = vi.fn(async () => {}); registerBedrockProvider(registry, logger(), { onProviderStatusChange }); - const models = await registry.getModelsForProvider("bedrock", { - type: "aws-credentials", - region: "us-east-2", - accessKeyId: "key", - secretAccessKey: "secret", - }); + const models = await registry.getModelsForProvider("bedrock", credentialsFor("us-east-2")); expect(models.some((model) => model.vendor === "anthropic")).toBe(true); expect(onProviderStatusChange).toHaveBeenCalledWith(expect.objectContaining({ status: "ok" })); @@ -126,12 +153,7 @@ describe("Bedrock provider Mantle aggregation", () => { const registry = new ProviderRegistry(logger()); registerBedrockProvider(registry, logger()); - const fetchPromise = registry.getModelsForProvider("bedrock", { - type: "aws-credentials", - region: "us-east-2", - accessKeyId: "key", - secretAccessKey: "secret", - }); + const fetchPromise = registry.getModelsForProvider("bedrock", credentialsFor("us-east-2")); await vi.waitFor(() => { expect(listMantleModels).toHaveBeenCalledTimes(1); @@ -142,3 +164,68 @@ describe("Bedrock provider Mantle aggregation", () => { await expect(fetchPromise).resolves.toEqual([]); }); }); + +describe("Bedrock inference profile discovery", () => { + it("uses discovered profile IDs over prefix construction", async () => { + listInferenceProfiles.mockResolvedValueOnce({ + inferenceProfileSummaries: [inferenceProfile(`global.${CLAUDE_MODEL_ID}`, CLAUDE_MODEL_ID)], + }); + const registry = new ProviderRegistry(logger()); + registerBedrockProvider(registry, logger()); + + const models = await registry.getModelsForProvider("bedrock", credentialsFor("us-east-1")); + + expect(models.some((model) => model.id === `global.${CLAUDE_MODEL_ID}`)).toBe(true); + expect(models.some((model) => model.id === `us.${CLAUDE_MODEL_ID}`)).toBe(false); + }); + + it("falls back to prefix-constructed IDs when discovery is unavailable", async () => { + // Behavior-preservation contract: a denied/failed discovery in a handled + // family must produce exactly today's IDs. + listInferenceProfiles.mockRejectedValueOnce( + Object.assign(new Error("denied"), { + name: "AccessDeniedException", + $metadata: { httpStatusCode: 403 }, + }), + ); + const registry = new ProviderRegistry(logger()); + registerBedrockProvider(registry, logger()); + + const models = await registry.getModelsForProvider("bedrock", credentialsFor("us-east-1")); + + expect(models.some((model) => model.id === `us.${CLAUDE_MODEL_ID}`)).toBe(true); + }); + + it("returns no Converse models when discovery is unavailable in an unhandled family", async () => { + listInferenceProfiles.mockRejectedValueOnce( + Object.assign(new Error("denied"), { + name: "AccessDeniedException", + $metadata: { httpStatusCode: 403 }, + }), + ); + const registry = new ProviderRegistry(logger()); + const onProviderStatusChange = vi.fn(async () => {}); + registerBedrockProvider(registry, logger(), { onProviderStatusChange }); + + const models = await registry.getModelsForProvider("bedrock", credentialsFor("ca-central-1")); + + // No fabricated `us.` IDs — and a discovery 403 is a degradation, not + // an auth failure. + expect(models.some((model) => model.id.includes(CLAUDE_MODEL_ID))).toBe(false); + expect(onProviderStatusChange).not.toHaveBeenCalledWith( + expect.objectContaining({ status: "auth_error" }), + ); + }); + + it("skips FM models absent from a successful profile map", async () => { + // A successful listing is authoritative absence: guessing an ID for a + // missing profile would fail at invoke time. + listInferenceProfiles.mockResolvedValueOnce({ inferenceProfileSummaries: [] }); + const registry = new ProviderRegistry(logger()); + registerBedrockProvider(registry, logger()); + + const models = await registry.getModelsForProvider("bedrock", credentialsFor("us-east-1")); + + expect(models.some((model) => model.id.includes(CLAUDE_MODEL_ID))).toBe(false); + }); +}); diff --git a/packages/ai-provider-bridge/src/providers/__tests__/bedrock-inference-profiles.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/bedrock-inference-profiles.test.ts new file mode 100644 index 0000000..bf58959 --- /dev/null +++ b/packages/ai-provider-bridge/src/providers/__tests__/bedrock-inference-profiles.test.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import type { ListInferenceProfilesResponse } from "@aws-sdk/client-bedrock"; +import { describe, expect, it, vi } from "vitest"; + +import type { Logger } from "../../types"; +import { + type InferenceProfileLister, + listInferenceProfileIds, +} from "../bedrock-inference-profiles"; + +function logger(): Logger { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }; +} + +function profile(id: string, modelId: string, status = "ACTIVE") { + return { + inferenceProfileName: id, + inferenceProfileArn: `arn:aws:bedrock:us-east-1::inference-profile/${id}`, + inferenceProfileId: id, + status, + models: [{ modelArn: `arn:aws:bedrock:us-east-1::foundation-model/${modelId}` }], + type: "SYSTEM_DEFINED", + }; +} + +function lister( + responses: ListInferenceProfilesResponse[] | ((call: number) => ListInferenceProfilesResponse), +): InferenceProfileLister & { calls: unknown[] } { + const calls: unknown[] = []; + let call = 0; + return { + calls, + send: (command) => { + calls.push(command.input); + const response = typeof responses === "function" ? responses(call) : responses[call]; + call += 1; + if (!response) { + throw new Error(`unexpected call ${call}`); + } + return Promise.resolve(response); + }, + }; +} + +describe("listInferenceProfileIds", () => { + it("joins profile model ARNs to bare model IDs", async () => { + const client = lister([ + { + inferenceProfileSummaries: [ + profile( + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "anthropic.claude-sonnet-4-5-20250929-v1:0", + ), + ], + }, + ]); + + const map = await listInferenceProfileIds(client, "us", logger()); + + expect(map?.get("anthropic.claude-sonnet-4-5-20250929-v1:0")).toBe( + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + ); + }); + + it("selects profiles by total order, independent of response order", async () => { + const summaries = [ + profile("global.anthropic.claude-x", "anthropic.claude-x"), + profile("apac.anthropic.claude-x", "anthropic.claude-x"), + profile("jp.anthropic.claude-x", "anthropic.claude-x"), + ]; + const forward = await listInferenceProfileIds( + lister([{ inferenceProfileSummaries: summaries }]), + "apac", + logger(), + ); + const reversed = await listInferenceProfileIds( + lister([{ inferenceProfileSummaries: [...summaries].reverse() }]), + "apac", + logger(), + ); + + // Legacy-prefix match wins over other geo and global profiles. + expect(forward?.get("anthropic.claude-x")).toBe("apac.anthropic.claude-x"); + expect(reversed?.get("anthropic.claude-x")).toBe("apac.anthropic.claude-x"); + }); + + it("chooses among non-preferred geo profiles alphabetically, independent of response order", async () => { + // With no preferred-prefix match, the alphabetically first non-global + // profile must win in both orders — this is the branch whose + // determinism the sort protects (a plain first-wins find would flip + // the answer when the response order flips). + const summaries = [ + profile("jp.anthropic.claude-y", "anthropic.claude-y"), + profile("global.anthropic.claude-y", "anthropic.claude-y"), + profile("eu.anthropic.claude-y", "anthropic.claude-y"), + ]; + const forward = await listInferenceProfileIds( + lister([{ inferenceProfileSummaries: summaries }]), + "apac", // matches no candidate + logger(), + ); + const reversed = await listInferenceProfileIds( + lister([{ inferenceProfileSummaries: [...summaries].reverse() }]), + "apac", + logger(), + ); + + expect(forward?.get("anthropic.claude-y")).toBe("eu.anthropic.claude-y"); + expect(reversed?.get("anthropic.claude-y")).toBe("eu.anthropic.claude-y"); + }); + + it("prefers other geo profiles over global, and uses global when it is the only mapping", async () => { + const map = await listInferenceProfileIds( + lister([ + { + inferenceProfileSummaries: [ + profile("global.anthropic.claude-a", "anthropic.claude-a"), + profile("eu.anthropic.claude-a", "anthropic.claude-a"), + profile("global.anthropic.claude-b", "anthropic.claude-b"), + ], + }, + ]), + "us", // matches neither candidate + logger(), + ); + + expect(map?.get("anthropic.claude-a")).toBe("eu.anthropic.claude-a"); + expect(map?.get("anthropic.claude-b")).toBe("global.anthropic.claude-b"); + }); + + it("follows nextToken across pages", async () => { + const client = lister([ + { + inferenceProfileSummaries: [profile("us.anthropic.claude-a", "anthropic.claude-a")], + nextToken: "page-2", + }, + { + inferenceProfileSummaries: [profile("us.anthropic.claude-b", "anthropic.claude-b")], + }, + ]); + + const map = await listInferenceProfileIds(client, "us", logger()); + + expect(client.calls).toHaveLength(2); + // The second request must carry the page token — otherwise a regression + // that drops it would re-request page one forever and still pass here. + expect(client.calls[1]).toMatchObject({ nextToken: "page-2" }); + expect(map?.get("anthropic.claude-a")).toBe("us.anthropic.claude-a"); + expect(map?.get("anthropic.claude-b")).toBe("us.anthropic.claude-b"); + }); + + it("excludes non-ACTIVE profiles and openai.* models", async () => { + const map = await listInferenceProfileIds( + lister([ + { + inferenceProfileSummaries: [ + profile("us.anthropic.claude-a", "anthropic.claude-a", "CREATING"), + profile("us.openai.gpt-oss-120b", "openai.gpt-oss-120b"), + ], + }, + ]), + "us", + logger(), + ); + + expect(map?.size).toBe(0); + }); + + it("returns null and logs at debug on AccessDenied, naming the IAM action", async () => { + const log = logger(); + const error = Object.assign(new Error("denied"), { + name: "AccessDeniedException", + $metadata: { httpStatusCode: 403 }, + }); + const client: InferenceProfileLister = { send: () => Promise.reject(error) }; + + const map = await listInferenceProfileIds(client, "us", log); + + expect(map).toBeNull(); + expect(log.debug).toHaveBeenCalledWith( + expect.stringContaining("bedrock:ListInferenceProfiles"), + ); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it("returns null and logs at warn with the error on other failures", async () => { + const log = logger(); + const client: InferenceProfileLister = { + send: () => Promise.reject(new Error("ThrottlingException: slow down")), + }; + + const map = await listInferenceProfileIds(client, "us", log); + + expect(map).toBeNull(); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("ThrottlingException")); + expect(log.debug).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ai-provider-bridge/src/providers/__tests__/bedrock-provider.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/bedrock-provider.test.ts index 01df5e0..24f14b3 100644 --- a/packages/ai-provider-bridge/src/providers/__tests__/bedrock-provider.test.ts +++ b/packages/ai-provider-bridge/src/providers/__tests__/bedrock-provider.test.ts @@ -24,7 +24,12 @@ describe("getInferenceProfilePrefix", () => { expect(getInferenceProfilePrefix("ap-southeast-1")).toBe("apac"); }); - it("defaults unknown regions to us", () => { - expect(getInferenceProfilePrefix("ca-central-1")).toBe("us"); + it("returns null for region families with no known legacy prefix", () => { + // The old "default to us" behavior fabricated `us.` IDs that are not + // invokable from these regions; with ListInferenceProfiles discovery as + // the primary source, the fallback no longer pretends. + expect(getInferenceProfilePrefix("ca-central-1")).toBeNull(); + expect(getInferenceProfilePrefix("sa-east-1")).toBeNull(); + expect(getInferenceProfilePrefix("il-central-1")).toBeNull(); }); }); diff --git a/packages/ai-provider-bridge/src/providers/bedrock-inference-profiles.ts b/packages/ai-provider-bridge/src/providers/bedrock-inference-profiles.ts new file mode 100644 index 0000000..662f002 --- /dev/null +++ b/packages/ai-provider-bridge/src/providers/bedrock-inference-profiles.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { + ListInferenceProfilesCommand, + type ListInferenceProfilesResponse, +} from "@aws-sdk/client-bedrock"; + +import type { Logger } from "../types"; + +/** + * Narrow structural sender for the one command this module consumes. The real + * `BedrockClient` satisfies it; tests use strictly typed fakes instead of + * mocking the whole SDK. + */ +export interface InferenceProfileLister { + send(command: ListInferenceProfilesCommand): Promise; +} + +/** + * Extract the bare foundation-model ID from a model ARN + * (`…:foundation-model/anthropic.claude-…` → `anthropic.claude-…`). + */ +function bareModelId(modelArn: string): string | null { + const segment = modelArn.split("/").pop(); + return segment ? segment : null; +} + +function isAccessDenied(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + if (error.name === "AccessDeniedException" || error.name === "UnauthorizedOperation") { + return true; + } + if (!("$metadata" in error)) { + return false; + } + const metadata = error.$metadata; + if (typeof metadata !== "object" || metadata === null || !("httpStatusCode" in metadata)) { + return false; + } + return metadata.httpStatusCode === 403; +} + +/** + * Discover which cross-region inference profile IDs are invokable in the + * calling region, keyed by bare foundation-model ID. + * + * A successful `ListInferenceProfiles` listing is authoritative for the + * region (that is the API's contract), so the returned map is the whole + * truth: callers must not guess IDs for models absent from it. `null` is the + * only failure signal and means "discovery unavailable" — the caller falls + * back to prefix construction. + * + * Selection is a documented total order, independent of response order (the + * API defines pagination but no result ordering, and a model can have + * several geo candidates in one region, e.g. `jp.` alongside `apac.`): + * + * 1. the profile whose prefix matches `preferredProfilePrefix` (the region's + * legacy family prefix, computed by the caller), preserving today's IDs; + * 2. otherwise other non-`global.` profiles, alphabetically by profile ID; + * 3. `global.` only when nothing else maps. + * + * A denied listing (missing `bedrock:ListInferenceProfiles`) is an expected + * policy gap and logs at debug; every other failure (throttling, service + * errors, malformed responses, network) logs at warn with the actual error. + * This function never touches provider status — a discovery 403 is a + * degradation, not an auth failure. + */ +export async function listInferenceProfileIds( + listClient: InferenceProfileLister, + preferredProfilePrefix: string | null, + logger: Logger, +): Promise | null> { + try { + // bare modelId → candidate profile IDs + const candidates = new Map(); + let nextToken: string | undefined; + do { + const command = new ListInferenceProfilesCommand({ + typeEquals: "SYSTEM_DEFINED", + ...(nextToken ? { nextToken } : {}), + }); + const response: ListInferenceProfilesResponse = await listClient.send(command); + for (const profile of response.inferenceProfileSummaries ?? []) { + if (profile.status !== "ACTIVE" || !profile.inferenceProfileId) { + continue; + } + const profileId = profile.inferenceProfileId; + for (const model of profile.models ?? []) { + if (!model.modelArn) { + continue; + } + const modelId = bareModelId(model.modelArn); + // Mantle owns openai.* models; never map them to Converse profiles. + if (!modelId || modelId.startsWith("openai.")) { + continue; + } + const list = candidates.get(modelId); + if (list) { + list.push(profileId); + } else { + candidates.set(modelId, [profileId]); + } + } + } + nextToken = response.nextToken; + } while (nextToken); + + const result = new Map(); + for (const [modelId, profileIds] of candidates) { + result.set(modelId, chooseProfileId(profileIds, preferredProfilePrefix)); + } + return result; + } catch (error) { + if (isAccessDenied(error)) { + logger.debug( + "[Bedrock] Inference profile discovery denied; bedrock:ListInferenceProfiles may be missing from the IAM policy. Falling back to prefix-constructed model IDs.", + ); + } else { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`[Bedrock] Inference profile discovery failed: ${message}`); + } + return null; + } +} + +/** + * Pick one profile ID per the module's documented total order. Sorting makes + * the choice independent of API response order, so a cache refresh can never + * flip a model's ID. + */ +function chooseProfileId(profileIds: string[], preferredProfilePrefix: string | null): string { + const sorted = [...profileIds].sort(); + if (preferredProfilePrefix !== null) { + const preferred = sorted.find((id) => id.startsWith(`${preferredProfilePrefix}.`)); + if (preferred) { + return preferred; + } + } + const geo = sorted.find((id) => !id.startsWith("global.")); + return geo ?? sorted[0]; +} diff --git a/packages/ai-provider-bridge/src/providers/bedrock-provider.ts b/packages/ai-provider-bridge/src/providers/bedrock-provider.ts index 86c26c4..ec8b315 100644 --- a/packages/ai-provider-bridge/src/providers/bedrock-provider.ts +++ b/packages/ai-provider-bridge/src/providers/bedrock-provider.ts @@ -12,6 +12,7 @@ import { createAwsCredentialProvider } from "../aws-credentials"; import { BedrockClient } from "../model-clients/BedrockClient"; import type { AwsCredentials, Logger, ModelInfo, ProviderCredentials } from "../types"; import { NOTIFICATION_ACTIONS } from "../types"; +import { listInferenceProfileIds } from "./bedrock-inference-profiles"; import { listMantleModels } from "./bedrock-mantle-models"; import { isAwsSsoProfileConfigured } from "./bedrock-sso"; import { getOpenAIModelName } from "./openai-model-names"; @@ -68,8 +69,13 @@ const MODEL_CACHE_TTL = 60 * 60 * 1000; * GovCloud must be checked before the general `us-` case: `us-gov-west-1` * also starts with `us-`, but its profiles live under the `us-gov` partition * and the commercial `us.` profiles don't exist there. + * + * Returns `null` for region families with no known legacy prefix (`ca-`, + * `sa-`, `me-`, `af-`, `il-`, …). Prefix construction is only a fallback for + * when `ListInferenceProfiles` discovery is unavailable, and fabricating a + * `us.` ID for those regions produces IDs that fail at invoke time. */ -export function getInferenceProfilePrefix(region: string): string { +export function getInferenceProfilePrefix(region: string): string | null { if (region.startsWith("us-gov-")) { return "us-gov"; } @@ -82,8 +88,7 @@ export function getInferenceProfilePrefix(region: string): string { if (region.startsWith("ap-")) { return "apac"; } - // Default to US for unknown regions - return "us"; + return null; } /** @@ -293,19 +298,47 @@ export function registerBedrockProvider( byOutputModality: "TEXT", // Only text output models }); - const response = await listClient.send(command); + // Run the foundation-model list and inference-profile discovery in + // parallel — discovery is a second listing off the same client, not a + // serialized round trip. + const regionPrefix = getInferenceProfilePrefix(credentials.region); + const [response, profileMap] = await Promise.all([ + listClient.send(command), + listInferenceProfileIds(listClient, regionPrefix, logger), + ]); // Debug logging logger.debug( `[Bedrock] API returned ${response.modelSummaries?.length || 0} Anthropic models`, ); - // Parse response - construct inference profile IDs - const regionPrefix = getInferenceProfilePrefix(credentials.region); + if (profileMap === null && regionPrefix === null) { + logger.info( + `[Bedrock] Inference profile discovery is unavailable and region ${credentials.region} has no known legacy profile prefix; bedrock:ListInferenceProfiles is required to list models in this region.`, + ); + } + + // Parse response - resolve invokable inference profile IDs. + // A successful discovery is authoritative for the region: models + // absent from the map are skipped rather than prefix-guessed (a + // fabricated ID would fail at invoke time). Prefix construction is + // only the fallback for when discovery is unavailable. const freshModels: ModelInfo[] = response.modelSummaries ?.filter((model) => !model.modelId?.startsWith("openai.")) - .map((model) => { + .flatMap((model) => { + const inferenceProfileId = + profileMap !== null + ? profileMap.get(model.modelId!) + : regionPrefix !== null + ? `${regionPrefix}.${model.modelId}` + : undefined; + if (!inferenceProfileId) { + logger.debug( + `[Bedrock] Skipping ${model.modelId}: no inference profile available in ${credentials.region}`, + ); + return []; + } // Extract vendor from model ID (e.g., "anthropic.claude-..." → "anthropic") const vendor = model.modelId?.split(".")[0] || "aws"; @@ -316,33 +349,30 @@ export function registerBedrockProvider( ); const supportsImages = vendor === "anthropic" || vendor === "amazon"; - // Construct cross-region inference profile ID - // Claude 4.x and newer models require inference profiles, not direct model IDs - // Format: {region-prefix}.{model-id} (e.g., "us.anthropic.claude-sonnet-4-5-...") - const inferenceProfileId = `${regionPrefix}.${model.modelId}`; - // Infer capabilities for Anthropic models const capabilities = getAnthropicModelCapabilities(model.modelId!); - return { - id: inferenceProfileId, - name: model.modelName || model.modelId!, - providerId: BEDROCK_PROVIDER_ID, - vendor, - family: undefined, - maxInputTokens: undefined, - maxOutputTokens: undefined, - supportsTools, - supportsImages, - supportsToolResultImages: supportsImages, - supportedInputMediaTypes: supportsImages - ? ["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"] - : undefined, - maxContextLength: 200000, - // Spread Anthropic capabilities (token limits, family, thinking effort) - ...capabilities, - supportsWebSearch: false, - }; + return [ + { + id: inferenceProfileId, + name: model.modelName || model.modelId!, + providerId: BEDROCK_PROVIDER_ID, + vendor, + family: undefined, + maxInputTokens: undefined, + maxOutputTokens: undefined, + supportsTools, + supportsImages, + supportsToolResultImages: supportsImages, + supportedInputMediaTypes: supportsImages + ? ["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"] + : undefined, + maxContextLength: 200000, + // Spread Anthropic capabilities (token limits, family, thinking effort) + ...capabilities, + supportsWebSearch: false, + }, + ]; }) || []; // Update cache