import type { BedrockClient } from "@aws-sdk/client-bedrock"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { discoverBedrockModels, mergeImplicitBedrockProvider, resetBedrockDiscoveryCacheForTest, resolveBedrockConfigApiKey, resolveImplicitBedrockProvider, } from "./api.js"; const sendMock = vi.fn(); const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient; const baseActiveAnthropicSummary = { modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", modelName: "Claude 3.7 Sonnet", providerName: "anthropic", inputModalities: ["TEXT"], outputModalities: ["TEXT"], responseStreamingSupported: true, modelLifecycle: { status: "ACTIVE" }, }; function mockSingleActiveSummary(overrides: Partial = {}): void { sendMock .mockResolvedValueOnce({ modelSummaries: [{ ...baseActiveAnthropicSummary, ...overrides }], }) // ListInferenceProfiles response (empty — no inference profiles in basic tests). .mockResolvedValueOnce({ inferenceProfileSummaries: [] }); } describe("bedrock discovery", () => { beforeEach(() => { sendMock.mockClear(); resetBedrockDiscoveryCacheForTest(); }); it("filters to active streaming text models and maps modalities", async () => { sendMock .mockResolvedValueOnce({ modelSummaries: [ { modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", modelName: "Claude 3.7 Sonnet", providerName: "anthropic", inputModalities: ["TEXT", "IMAGE"], outputModalities: ["TEXT"], responseStreamingSupported: true, modelLifecycle: { status: "ACTIVE" }, }, { modelId: "anthropic.claude-3-haiku-20240307-v1:0", modelName: "Claude 3 Haiku", providerName: "anthropic", inputModalities: ["TEXT"], outputModalities: ["TEXT"], responseStreamingSupported: false, modelLifecycle: { status: "ACTIVE" }, }, { modelId: "meta.llama3-8b-instruct-v1:0", modelName: "Llama 3 8B", providerName: "meta", inputModalities: ["TEXT"], outputModalities: ["TEXT"], responseStreamingSupported: true, modelLifecycle: { status: "INACTIVE" }, }, { modelId: "amazon.titan-embed-text-v1", modelName: "Titan Embed", providerName: "amazon", inputModalities: ["TEXT"], outputModalities: ["EMBEDDING"], responseStreamingSupported: true, modelLifecycle: { status: "ACTIVE" }, }, ], }) .mockResolvedValueOnce({ inferenceProfileSummaries: [] }); const models = await discoverBedrockModels({ region: "us-east-1", clientFactory }); expect(models).toHaveLength(1); expect(models[0]).toMatchObject({ id: "anthropic.claude-3-7-sonnet-20250219-v1:0", name: "Claude 3.7 Sonnet", reasoning: false, input: ["text", "image"], contextWindow: 32000, maxTokens: 4096, }); }); it("applies provider filter", async () => { mockSingleActiveSummary(); const models = await discoverBedrockModels({ region: "us-east-1", config: { providerFilter: ["amazon"] }, clientFactory, }); expect(models).toHaveLength(0); }); it("uses configured defaults for context and max tokens", async () => { mockSingleActiveSummary(); const models = await discoverBedrockModels({ region: "us-east-1", config: { defaultContextWindow: 64000, defaultMaxTokens: 8192 }, clientFactory, }); expect(models[0]).toMatchObject({ contextWindow: 64000, maxTokens: 8192 }); }); it("caches results when refreshInterval is enabled", async () => { mockSingleActiveSummary(); await discoverBedrockModels({ region: "us-east-1", clientFactory }); await discoverBedrockModels({ region: "us-east-1", clientFactory }); // 2 calls on first discovery (ListFoundationModels + ListInferenceProfiles), 0 on cached second. expect(sendMock).toHaveBeenCalledTimes(2); }); it("skips cache when refreshInterval is 0", async () => { sendMock .mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] }) .mockResolvedValueOnce({ inferenceProfileSummaries: [] }) .mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] }) .mockResolvedValueOnce({ inferenceProfileSummaries: [] }); await discoverBedrockModels({ region: "us-east-1", config: { refreshInterval: 0 }, clientFactory, }); await discoverBedrockModels({ region: "us-east-1", config: { refreshInterval: 0 }, clientFactory, }); // 2 calls per discovery (ListFoundationModels + ListInferenceProfiles) × 2 runs. expect(sendMock).toHaveBeenCalledTimes(4); }); it("resolves the Bedrock config apiKey from AWS auth env vars", () => { expect( resolveBedrockConfigApiKey({ AWS_BEARER_TOKEN_BEDROCK: "bearer", // pragma: allowlist secret AWS_PROFILE: "default", }), ).toBe("AWS_BEARER_TOKEN_BEDROCK"); // When no AWS env vars are present (e.g. instance role), no marker should be injected. // The aws-sdk credential chain handles auth at request time. (#49891) expect(resolveBedrockConfigApiKey({} as NodeJS.ProcessEnv)).toBeUndefined(); // When AWS_PROFILE is explicitly set, it should return the marker. expect(resolveBedrockConfigApiKey({ AWS_PROFILE: "default" } as NodeJS.ProcessEnv)).toBe( "AWS_PROFILE", ); }); it("discovers inference profiles and inherits foundation model capabilities", async () => { sendMock .mockResolvedValueOnce({ modelSummaries: [ { modelId: "anthropic.claude-sonnet-4-6", modelName: "Claude Sonnet 4.6", providerName: "anthropic", inputModalities: ["TEXT", "IMAGE"], outputModalities: ["TEXT"], responseStreamingSupported: true, modelLifecycle: { status: "ACTIVE" }, }, ], }) .mockResolvedValueOnce({ inferenceProfileSummaries: [ { inferenceProfileId: "us.anthropic.claude-sonnet-4-6", inferenceProfileName: "US Anthropic Claude Sonnet 4.6", inferenceProfileArn: "arn:aws:bedrock:us-east-1::inference-profile/us.anthropic.claude-sonnet-4-6", status: "ACTIVE", type: "SYSTEM_DEFINED", models: [ { modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", }, { modelArn: "arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-sonnet-4-6", }, ], }, { inferenceProfileId: "eu.anthropic.claude-sonnet-4-6", inferenceProfileName: "EU Anthropic Claude Sonnet 4.6", inferenceProfileArn: "arn:aws:bedrock:eu-west-1::inference-profile/eu.anthropic.claude-sonnet-4-6", status: "ACTIVE", type: "SYSTEM_DEFINED", models: [ { modelArn: "arn:aws:bedrock:eu-west-1::foundation-model/anthropic.claude-sonnet-4-6", }, ], }, { inferenceProfileId: "global.anthropic.claude-sonnet-4-6", inferenceProfileName: "Global Anthropic Claude Sonnet 4.6", inferenceProfileArn: "arn:aws:bedrock:us-east-1::inference-profile/global.anthropic.claude-sonnet-4-6", status: "ACTIVE", type: "SYSTEM_DEFINED", models: [ { modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", }, ], }, // Inactive profile should be filtered out. { inferenceProfileId: "ap.anthropic.claude-sonnet-4-6", inferenceProfileName: "AP Claude Sonnet 4.6", status: "LEGACY", type: "SYSTEM_DEFINED", models: [], }, ], }); const models = await discoverBedrockModels({ region: "us-east-1", clientFactory }); // Foundation model + 3 active inference profiles = 4 models. expect(models).toHaveLength(4); // Global profiles should be sorted first (recommended for most users). expect(models[0]?.id).toBe("global.anthropic.claude-sonnet-4-6"); const foundationModel = models.find((m) => m.id === "anthropic.claude-sonnet-4-6"); const usProfile = models.find((m) => m.id === "us.anthropic.claude-sonnet-4-6"); const euProfile = models.find((m) => m.id === "eu.anthropic.claude-sonnet-4-6"); const globalProfile = models.find((m) => m.id === "global.anthropic.claude-sonnet-4-6"); // Foundation model has image input. expect(foundationModel).toMatchObject({ input: ["text", "image"] }); // Inference profiles inherit image input from the foundation model. expect(usProfile).toMatchObject({ name: "US Anthropic Claude Sonnet 4.6", input: ["text", "image"], contextWindow: 32000, maxTokens: 4096, }); expect(euProfile).toMatchObject({ input: ["text", "image"] }); expect(globalProfile).toMatchObject({ input: ["text", "image"] }); // Inactive profile should not be present. expect(models.find((m) => m.id === "ap.anthropic.claude-sonnet-4-6")).toBeUndefined(); }); it("gracefully handles ListInferenceProfiles permission errors", async () => { sendMock .mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary], }) // Simulate AccessDeniedException for ListInferenceProfiles. .mockRejectedValueOnce(new Error("AccessDeniedException")); const models = await discoverBedrockModels({ region: "us-east-1", clientFactory }); // Foundation model should still be discovered despite profile discovery failure. expect(models).toHaveLength(1); expect(models[0]?.id).toBe("anthropic.claude-3-7-sonnet-20250219-v1:0"); }); it("keeps matching inference profiles when provider filters are enabled", async () => { sendMock .mockResolvedValueOnce({ modelSummaries: [ { modelId: "anthropic.claude-sonnet-4-6", modelName: "Claude Sonnet 4.6", providerName: "anthropic", inputModalities: ["TEXT", "IMAGE"], outputModalities: ["TEXT"], responseStreamingSupported: true, modelLifecycle: { status: "ACTIVE" }, }, ], }) .mockResolvedValueOnce({ inferenceProfileSummaries: [ { inferenceProfileId: "global.anthropic.claude-sonnet-4-6", inferenceProfileName: "Global Anthropic Claude Sonnet 4.6", status: "ACTIVE", type: "SYSTEM_DEFINED", models: [ { modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", }, ], }, ], }); const models = await discoverBedrockModels({ region: "us-east-1", config: { providerFilter: ["anthropic"] }, clientFactory, }); expect(models.map((model) => model.id)).toEqual([ "global.anthropic.claude-sonnet-4-6", "anthropic.claude-sonnet-4-6", ]); }); it("prefers backing model ARNs for application profiles with region-like ids", async () => { sendMock .mockResolvedValueOnce({ modelSummaries: [ { modelId: "anthropic.claude-sonnet-4-6", modelName: "Claude Sonnet 4.6", providerName: "anthropic", inputModalities: ["TEXT", "IMAGE"], outputModalities: ["TEXT"], responseStreamingSupported: true, modelLifecycle: { status: "ACTIVE" }, }, ], }) .mockResolvedValueOnce({ inferenceProfileSummaries: [ { inferenceProfileId: "us.my-prod-profile", inferenceProfileName: "Prod Claude Profile", status: "ACTIVE", type: "APPLICATION", models: [ { modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", }, ], }, ], }); const models = await discoverBedrockModels({ region: "us-east-1", clientFactory }); const profile = models.find((model) => model.id === "us.my-prod-profile"); expect(profile).toMatchObject({ id: "us.my-prod-profile", input: ["text", "image"], contextWindow: 32000, maxTokens: 4096, }); }); it("merges implicit Bedrock models into explicit provider overrides", () => { expect( mergeImplicitBedrockProvider({ existing: { baseUrl: "https://override.example.com", headers: { "x-test-header": "1" }, models: [], }, implicit: { baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", api: "bedrock-converse-stream", auth: "aws-sdk", models: [ { id: "amazon.nova-micro-v1:0", name: "Nova", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1, maxTokens: 1, }, ], }, }).models?.map((model) => model.id), ).toEqual(["amazon.nova-micro-v1:0"]); }); it("prefers plugin-owned discovery config and still honors legacy fallback", async () => { mockSingleActiveSummary(); const pluginEnabled = await resolveImplicitBedrockProvider({ config: { models: { bedrockDiscovery: { enabled: false, region: "us-west-2", }, }, }, pluginConfig: { discovery: { enabled: true, region: "us-east-1", }, }, env: {} as NodeJS.ProcessEnv, clientFactory, }); expect(pluginEnabled?.baseUrl).toBe("https://bedrock-runtime.us-east-1.amazonaws.com"); // 2 calls per discovery (ListFoundationModels + ListInferenceProfiles). expect(sendMock).toHaveBeenCalledTimes(2); mockSingleActiveSummary(); const legacyEnabled = await resolveImplicitBedrockProvider({ config: { models: { bedrockDiscovery: { enabled: true, region: "us-west-2", }, }, }, env: {} as NodeJS.ProcessEnv, clientFactory, }); expect(legacyEnabled?.baseUrl).toBe("https://bedrock-runtime.us-west-2.amazonaws.com"); expect(sendMock).toHaveBeenCalledTimes(4); }); });