Skip to content

Commit 78fe96f

Browse files
wirjovincentkoc
andauthored
feat(bedrock): add inference profile discovery and region injection (#61299)
* feat(bedrock): add inference profile discovery and region injection Inference profiles (cross-region and application) work with ConverseStream but require the SDK client region to match the profile region. Without this, users get "The provided model identifier is invalid" errors when using cross-region profiles like us.anthropic.claude-sonnet-4-6. Changes: 1. Inference profile discovery (discovery.ts): - Call ListInferenceProfiles alongside ListFoundationModels (parallel) - Inference profiles INHERIT capabilities from their underlying foundation model (modalities, reasoning, context window, cost) - resolveBaseModelId() maps profile → foundation model: "us.anthropic.claude-sonnet-4-6" → "anthropic.claude-sonnet-4-6" Application ARNs → extract model ID from models[].modelArn - Graceful degradation if IAM lacks bedrock:ListInferenceProfiles - Provider filter applies to profiles via underlying model ARNs 2. Region injection (register.sync.runtime.ts): - Extract region from provider baseUrl or bedrockDiscovery.region - Pass through to pi-ai options.region in wrapStreamFn - Ensures SDK client connects to correct regional endpoint 3. Inference profile model detection (anthropic-family-cache-semantics.ts): - isAnthropicBedrockModel() now recognizes application inference profile ARNs (arn:aws:bedrock:...:application-inference-profile/*) 4. Tests (discovery.test.ts): - New: inference profile inheritance test (4 models: 1 foundation + 3 profiles, verifies capability inheritance, inactive filtering) - New: graceful AccessDeniedException handling test - Updated: all existing tests for dual-API discovery pattern Fixes #55642 * fix(bedrock): preserve inference profile model lookup --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
1 parent 5f6ba74 commit 78fe96f

5 files changed

Lines changed: 522 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- Providers/request overrides: add shared model and media request transport overrides across OpenAI-, Anthropic-, Google-, and compatible provider paths, including headers, auth, proxy, and TLS controls. (#60200)
3131
- Providers/StepFun: add the bundled StepFun provider plugin with standard and Step Plan endpoints, China/global onboarding choices, `step-3.5-flash` on both catalogs, and `step-3.5-flash-2603` currently exposed on Step Plan. (#60032) Thanks @hengm3467.
3232
- Tools/web_search: add a bundled MiniMax Search provider backed by the Coding Plan search API, with region reuse from `MINIMAX_API_HOST` and plugin-owned credential config. (#54648) Thanks @fengmk2.
33+
- Providers/Amazon Bedrock: discover regional and global inference profiles, inherit their backing model capabilities, and inject the Bedrock request region automatically so cross-region Claude profiles work without manual provider overrides. (#61299) Thanks @wirjo.
3334

3435
### Fixes
3536

extensions/amazon-bedrock/discovery.test.ts

Lines changed: 257 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@ const baseActiveAnthropicSummary = {
2222
};
2323

2424
function mockSingleActiveSummary(overrides: Partial<typeof baseActiveAnthropicSummary> = {}): void {
25-
sendMock.mockResolvedValueOnce({
26-
modelSummaries: [{ ...baseActiveAnthropicSummary, ...overrides }],
27-
});
25+
sendMock
26+
.mockResolvedValueOnce({
27+
modelSummaries: [{ ...baseActiveAnthropicSummary, ...overrides }],
28+
})
29+
// ListInferenceProfiles response (empty — no inference profiles in basic tests).
30+
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
2831
}
2932

3033
describe("bedrock discovery", () => {
@@ -34,46 +37,48 @@ describe("bedrock discovery", () => {
3437
});
3538

3639
it("filters to active streaming text models and maps modalities", async () => {
37-
sendMock.mockResolvedValueOnce({
38-
modelSummaries: [
39-
{
40-
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
41-
modelName: "Claude 3.7 Sonnet",
42-
providerName: "anthropic",
43-
inputModalities: ["TEXT", "IMAGE"],
44-
outputModalities: ["TEXT"],
45-
responseStreamingSupported: true,
46-
modelLifecycle: { status: "ACTIVE" },
47-
},
48-
{
49-
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
50-
modelName: "Claude 3 Haiku",
51-
providerName: "anthropic",
52-
inputModalities: ["TEXT"],
53-
outputModalities: ["TEXT"],
54-
responseStreamingSupported: false,
55-
modelLifecycle: { status: "ACTIVE" },
56-
},
57-
{
58-
modelId: "meta.llama3-8b-instruct-v1:0",
59-
modelName: "Llama 3 8B",
60-
providerName: "meta",
61-
inputModalities: ["TEXT"],
62-
outputModalities: ["TEXT"],
63-
responseStreamingSupported: true,
64-
modelLifecycle: { status: "INACTIVE" },
65-
},
66-
{
67-
modelId: "amazon.titan-embed-text-v1",
68-
modelName: "Titan Embed",
69-
providerName: "amazon",
70-
inputModalities: ["TEXT"],
71-
outputModalities: ["EMBEDDING"],
72-
responseStreamingSupported: true,
73-
modelLifecycle: { status: "ACTIVE" },
74-
},
75-
],
76-
});
40+
sendMock
41+
.mockResolvedValueOnce({
42+
modelSummaries: [
43+
{
44+
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
45+
modelName: "Claude 3.7 Sonnet",
46+
providerName: "anthropic",
47+
inputModalities: ["TEXT", "IMAGE"],
48+
outputModalities: ["TEXT"],
49+
responseStreamingSupported: true,
50+
modelLifecycle: { status: "ACTIVE" },
51+
},
52+
{
53+
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
54+
modelName: "Claude 3 Haiku",
55+
providerName: "anthropic",
56+
inputModalities: ["TEXT"],
57+
outputModalities: ["TEXT"],
58+
responseStreamingSupported: false,
59+
modelLifecycle: { status: "ACTIVE" },
60+
},
61+
{
62+
modelId: "meta.llama3-8b-instruct-v1:0",
63+
modelName: "Llama 3 8B",
64+
providerName: "meta",
65+
inputModalities: ["TEXT"],
66+
outputModalities: ["TEXT"],
67+
responseStreamingSupported: true,
68+
modelLifecycle: { status: "INACTIVE" },
69+
},
70+
{
71+
modelId: "amazon.titan-embed-text-v1",
72+
modelName: "Titan Embed",
73+
providerName: "amazon",
74+
inputModalities: ["TEXT"],
75+
outputModalities: ["EMBEDDING"],
76+
responseStreamingSupported: true,
77+
modelLifecycle: { status: "ACTIVE" },
78+
},
79+
],
80+
})
81+
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
7782

7883
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
7984
expect(models).toHaveLength(1);
@@ -114,13 +119,16 @@ describe("bedrock discovery", () => {
114119

115120
await discoverBedrockModels({ region: "us-east-1", clientFactory });
116121
await discoverBedrockModels({ region: "us-east-1", clientFactory });
117-
expect(sendMock).toHaveBeenCalledTimes(1);
122+
// 2 calls on first discovery (ListFoundationModels + ListInferenceProfiles), 0 on cached second.
123+
expect(sendMock).toHaveBeenCalledTimes(2);
118124
});
119125

120126
it("skips cache when refreshInterval is 0", async () => {
121127
sendMock
122128
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
123-
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] });
129+
.mockResolvedValueOnce({ inferenceProfileSummaries: [] })
130+
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
131+
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
124132

125133
await discoverBedrockModels({
126134
region: "us-east-1",
@@ -132,7 +140,8 @@ describe("bedrock discovery", () => {
132140
config: { refreshInterval: 0 },
133141
clientFactory,
134142
});
135-
expect(sendMock).toHaveBeenCalledTimes(2);
143+
// 2 calls per discovery (ListFoundationModels + ListInferenceProfiles) × 2 runs.
144+
expect(sendMock).toHaveBeenCalledTimes(4);
136145
});
137146

138147
it("resolves the Bedrock config apiKey from AWS auth env vars", () => {
@@ -153,6 +162,205 @@ describe("bedrock discovery", () => {
153162
);
154163
});
155164

165+
it("discovers inference profiles and inherits foundation model capabilities", async () => {
166+
sendMock
167+
.mockResolvedValueOnce({
168+
modelSummaries: [
169+
{
170+
modelId: "anthropic.claude-sonnet-4-6",
171+
modelName: "Claude Sonnet 4.6",
172+
providerName: "anthropic",
173+
inputModalities: ["TEXT", "IMAGE"],
174+
outputModalities: ["TEXT"],
175+
responseStreamingSupported: true,
176+
modelLifecycle: { status: "ACTIVE" },
177+
},
178+
],
179+
})
180+
.mockResolvedValueOnce({
181+
inferenceProfileSummaries: [
182+
{
183+
inferenceProfileId: "us.anthropic.claude-sonnet-4-6",
184+
inferenceProfileName: "US Anthropic Claude Sonnet 4.6",
185+
inferenceProfileArn:
186+
"arn:aws:bedrock:us-east-1::inference-profile/us.anthropic.claude-sonnet-4-6",
187+
status: "ACTIVE",
188+
type: "SYSTEM_DEFINED",
189+
models: [
190+
{
191+
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
192+
},
193+
{
194+
modelArn: "arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-sonnet-4-6",
195+
},
196+
],
197+
},
198+
{
199+
inferenceProfileId: "eu.anthropic.claude-sonnet-4-6",
200+
inferenceProfileName: "EU Anthropic Claude Sonnet 4.6",
201+
inferenceProfileArn:
202+
"arn:aws:bedrock:eu-west-1::inference-profile/eu.anthropic.claude-sonnet-4-6",
203+
status: "ACTIVE",
204+
type: "SYSTEM_DEFINED",
205+
models: [
206+
{
207+
modelArn: "arn:aws:bedrock:eu-west-1::foundation-model/anthropic.claude-sonnet-4-6",
208+
},
209+
],
210+
},
211+
{
212+
inferenceProfileId: "global.anthropic.claude-sonnet-4-6",
213+
inferenceProfileName: "Global Anthropic Claude Sonnet 4.6",
214+
inferenceProfileArn:
215+
"arn:aws:bedrock:us-east-1::inference-profile/global.anthropic.claude-sonnet-4-6",
216+
status: "ACTIVE",
217+
type: "SYSTEM_DEFINED",
218+
models: [
219+
{
220+
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
221+
},
222+
],
223+
},
224+
// Inactive profile should be filtered out.
225+
{
226+
inferenceProfileId: "ap.anthropic.claude-sonnet-4-6",
227+
inferenceProfileName: "AP Claude Sonnet 4.6",
228+
status: "LEGACY",
229+
type: "SYSTEM_DEFINED",
230+
models: [],
231+
},
232+
],
233+
});
234+
235+
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
236+
237+
// Foundation model + 3 active inference profiles = 4 models.
238+
expect(models).toHaveLength(4);
239+
240+
// Global profiles should be sorted first (recommended for most users).
241+
expect(models[0]?.id).toBe("global.anthropic.claude-sonnet-4-6");
242+
243+
const foundationModel = models.find((m) => m.id === "anthropic.claude-sonnet-4-6");
244+
const usProfile = models.find((m) => m.id === "us.anthropic.claude-sonnet-4-6");
245+
const euProfile = models.find((m) => m.id === "eu.anthropic.claude-sonnet-4-6");
246+
const globalProfile = models.find((m) => m.id === "global.anthropic.claude-sonnet-4-6");
247+
248+
// Foundation model has image input.
249+
expect(foundationModel).toMatchObject({ input: ["text", "image"] });
250+
251+
// Inference profiles inherit image input from the foundation model.
252+
expect(usProfile).toMatchObject({
253+
name: "US Anthropic Claude Sonnet 4.6",
254+
input: ["text", "image"],
255+
contextWindow: 32000,
256+
maxTokens: 4096,
257+
});
258+
expect(euProfile).toMatchObject({ input: ["text", "image"] });
259+
expect(globalProfile).toMatchObject({ input: ["text", "image"] });
260+
261+
// Inactive profile should not be present.
262+
expect(models.find((m) => m.id === "ap.anthropic.claude-sonnet-4-6")).toBeUndefined();
263+
});
264+
265+
it("gracefully handles ListInferenceProfiles permission errors", async () => {
266+
sendMock
267+
.mockResolvedValueOnce({
268+
modelSummaries: [baseActiveAnthropicSummary],
269+
})
270+
// Simulate AccessDeniedException for ListInferenceProfiles.
271+
.mockRejectedValueOnce(new Error("AccessDeniedException"));
272+
273+
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
274+
// Foundation model should still be discovered despite profile discovery failure.
275+
expect(models).toHaveLength(1);
276+
expect(models[0]?.id).toBe("anthropic.claude-3-7-sonnet-20250219-v1:0");
277+
});
278+
279+
it("keeps matching inference profiles when provider filters are enabled", async () => {
280+
sendMock
281+
.mockResolvedValueOnce({
282+
modelSummaries: [
283+
{
284+
modelId: "anthropic.claude-sonnet-4-6",
285+
modelName: "Claude Sonnet 4.6",
286+
providerName: "anthropic",
287+
inputModalities: ["TEXT", "IMAGE"],
288+
outputModalities: ["TEXT"],
289+
responseStreamingSupported: true,
290+
modelLifecycle: { status: "ACTIVE" },
291+
},
292+
],
293+
})
294+
.mockResolvedValueOnce({
295+
inferenceProfileSummaries: [
296+
{
297+
inferenceProfileId: "global.anthropic.claude-sonnet-4-6",
298+
inferenceProfileName: "Global Anthropic Claude Sonnet 4.6",
299+
status: "ACTIVE",
300+
type: "SYSTEM_DEFINED",
301+
models: [
302+
{
303+
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
304+
},
305+
],
306+
},
307+
],
308+
});
309+
310+
const models = await discoverBedrockModels({
311+
region: "us-east-1",
312+
config: { providerFilter: ["anthropic"] },
313+
clientFactory,
314+
});
315+
316+
expect(models.map((model) => model.id)).toEqual([
317+
"global.anthropic.claude-sonnet-4-6",
318+
"anthropic.claude-sonnet-4-6",
319+
]);
320+
});
321+
322+
it("prefers backing model ARNs for application profiles with region-like ids", async () => {
323+
sendMock
324+
.mockResolvedValueOnce({
325+
modelSummaries: [
326+
{
327+
modelId: "anthropic.claude-sonnet-4-6",
328+
modelName: "Claude Sonnet 4.6",
329+
providerName: "anthropic",
330+
inputModalities: ["TEXT", "IMAGE"],
331+
outputModalities: ["TEXT"],
332+
responseStreamingSupported: true,
333+
modelLifecycle: { status: "ACTIVE" },
334+
},
335+
],
336+
})
337+
.mockResolvedValueOnce({
338+
inferenceProfileSummaries: [
339+
{
340+
inferenceProfileId: "us.my-prod-profile",
341+
inferenceProfileName: "Prod Claude Profile",
342+
status: "ACTIVE",
343+
type: "APPLICATION",
344+
models: [
345+
{
346+
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
347+
},
348+
],
349+
},
350+
],
351+
});
352+
353+
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
354+
const profile = models.find((model) => model.id === "us.my-prod-profile");
355+
356+
expect(profile).toMatchObject({
357+
id: "us.my-prod-profile",
358+
input: ["text", "image"],
359+
contextWindow: 32000,
360+
maxTokens: 4096,
361+
});
362+
});
363+
156364
it("merges implicit Bedrock models into explicit provider overrides", () => {
157365
expect(
158366
mergeImplicitBedrockProvider({
@@ -204,7 +412,8 @@ describe("bedrock discovery", () => {
204412
});
205413

206414
expect(pluginEnabled?.baseUrl).toBe("https://bedrock-runtime.us-east-1.amazonaws.com");
207-
expect(sendMock).toHaveBeenCalledTimes(1);
415+
// 2 calls per discovery (ListFoundationModels + ListInferenceProfiles).
416+
expect(sendMock).toHaveBeenCalledTimes(2);
208417

209418
mockSingleActiveSummary();
210419

@@ -222,6 +431,6 @@ describe("bedrock discovery", () => {
222431
});
223432

224433
expect(legacyEnabled?.baseUrl).toBe("https://bedrock-runtime.us-west-2.amazonaws.com");
225-
expect(sendMock).toHaveBeenCalledTimes(2);
434+
expect(sendMock).toHaveBeenCalledTimes(4);
226435
});
227436
});

0 commit comments

Comments
 (0)