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
4 changes: 3 additions & 1 deletion packages/ai-config/src/__tests__/defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
12 changes: 7 additions & 5 deletions packages/ai-config/src/__tests__/resolve-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } } } }),
Expand All @@ -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();
});
});

Expand Down
10 changes: 8 additions & 2 deletions packages/ai-config/src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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([
Expand All @@ -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,
},
Expand All @@ -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");
Expand Down Expand Up @@ -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" }));
Expand All @@ -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);
Expand All @@ -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);
});
});
Loading