Skip to content
Open
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
103 changes: 90 additions & 13 deletions cdk/src/constructs/bedrock-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* SOFTWARE.
*/

import { CrossRegionInferenceProfileRegion } from '@aws-cdk/aws-bedrock-alpha';
import { Node } from 'constructs';

/**
Expand Down Expand Up @@ -46,31 +47,106 @@ export const DEFAULT_BEDROCK_MODEL_IDS: readonly string[] = [
// turn 0 with AccessDenied. Bare ID by contract: Bedrock refuses the bare ID
// for on-demand invocation ("ValidationException: … isn't supported. Retry
// your request with the ID or ARN of an inference profile"), and both grant
// sites derive the `us.`-prefixed inference-profile ARN — the invocable one —
// from this entry. Opus 4.8 above stays granted: blueprints may pin it
// per-repo, so removing it would fail those repos at turn 0.
// sites derive the geo-prefixed inference-profile ARN — the invocable one —
// from this entry plus `bedrockGeoRegion` (default `us`). Opus 4.8 above
// stays granted: blueprints may pin it per-repo, so removing it would fail
// those repos at turn 0.
'anthropic.claude-opus-5',
'anthropic.claude-haiku-4-5-20251001-v1:0',
];

/** CDK context key whose value (a string array) overrides the model set. */
export const BEDROCK_MODELS_CONTEXT_KEY = 'bedrockModels';

/** CDK context key selecting the cross-Region inference-profile geography. */
export const BEDROCK_GEO_REGION_CONTEXT_KEY = 'bedrockGeoRegion';

/**
* Default inference-profile geography: the US cross-Region profiles
* (`us.anthropic.…`). Documented here rather than in `cdk.json` so a deploy that
* passes no context at all still resolves — `cdk.json` context is not present
* when the app is synthesized from a test or another CDK app.
*/
export const DEFAULT_BEDROCK_GEO_REGION = CrossRegionInferenceProfileRegion.US;

/**
* The geographies `@aws-cdk/aws-bedrock-alpha` actually models — the single
* source of truth for both the {@link resolveBedrockGeoRegion} allow-list and
* the region-prefix rejection in {@link resolveBedrockModelIds}. Derived from
* the enum, so a future CDK release that adds a geography widens both at once
* instead of leaving one of them silently behind.
*/
export const BEDROCK_GEO_REGIONS: readonly CrossRegionInferenceProfileRegion[] =
Object.values(CrossRegionInferenceProfileRegion);

/**
* `global|us-gov|apac|eu|us|jp|au` — an alternation over
* {@link BEDROCK_GEO_REGIONS}, sorted longest-first so the pattern reads
* unambiguously with `us-gov` ahead of `us`. Readability only: because
* {@link GEO_PREFIX_RE} anchors a literal `.` after the alternation, a `us`-first
* order would still reject `us-gov.…` correctly (the engine backtracks when the
* `.` fails against `-`). Sorting just means nobody has to reason about that.
*/
const GEO_ALTERNATION = [...BEDROCK_GEO_REGIONS]
.sort((a, b) => b.length - a.length)
.join('|');

/** Matches a leading `<geo>.` inference-profile prefix on a model ID. */
const GEO_PREFIX_RE = new RegExp(`^(?:${GEO_ALTERNATION})\\.`);

/**
* Resolves the cross-Region inference-profile geography: CDK context
* `bedrockGeoRegion` when provided, else {@link DEFAULT_BEDROCK_GEO_REGION}
* (`us`). Set via `cdk.json` `context` or `-c bedrockGeoRegion=global`, then
* redeploy, to move the deployment's inference profiles to another geography —
* no construct edits needed.
*
* Both grant sites derive their inference-profile ARNs from this one value (the
* AgentCore runtime in `stacks/agent.ts` via
* `CrossRegionInferenceProfile.fromConfig`, the ECS task role in
* `constructs/ecs-agent-cluster.ts` via the `<geo>.<modelId>` ARN resource
* name), and the agent's auxiliary-model env var (`ANTHROPIC_DEFAULT_HAIKU_MODEL`)
* takes the same prefix — so the main and auxiliary models can never route
* through different geographies.
*
* Throws at synth on an unrecognized value: an invented geography would produce
* a syntactically valid but non-existent inference-profile ARN, and the grant
* would silently authorize nothing (the agent then fails at turn 0 with
* AccessDenied). A typo must fail the synth, not the deployment.
*/
export function resolveBedrockGeoRegion(node: Node): CrossRegionInferenceProfileRegion {
const override = node.tryGetContext(BEDROCK_GEO_REGION_CONTEXT_KEY);
if (override === undefined || override === null) {
return DEFAULT_BEDROCK_GEO_REGION;
}
if (typeof override !== 'string' || !BEDROCK_GEO_REGIONS.includes(override as CrossRegionInferenceProfileRegion)) {
throw new Error(
`Context '${BEDROCK_GEO_REGION_CONTEXT_KEY}' must be one of `
+ `${BEDROCK_GEO_REGIONS.map((g) => `'${g}'`).join(', ')}; got ${JSON.stringify(override)}.`,
);
}
return override as CrossRegionInferenceProfileRegion;
}

/**
* Resolves the invocable foundation-model IDs: CDK context `bedrockModels`
* (an array of **bare foundation-model IDs**) when provided, else
* {@link DEFAULT_BEDROCK_MODEL_IDS}. Set via `cdk.json` `context` or
* `-c bedrockModels='["anthropic.claude-opus-4-8", …]'`, then redeploy, to add
* a model the runtime may invoke — no construct edits needed.
*
* **Use the bare foundation-model ID (`anthropic.claude-…`), NOT the
* `us.`-prefixed inference-profile ID.** Both grant sites derive the US
* inference-profile ARN by prefixing `us.`, so passing `us.anthropic.…` here
* would produce an invalid `us.us.anthropic.…` ARN. The resolver rejects a
* `us.`/`eu.`/`apac.`-prefixed entry to catch that early.
* **Use the bare foundation-model ID (`anthropic.claude-…`), NOT a
* geo-prefixed inference-profile ID.** Both grant sites derive the
* inference-profile ARN by prefixing the geography from
* {@link resolveBedrockGeoRegion} (`bedrockGeoRegion`, default `us`), so passing
* `us.anthropic.…` here would produce an invalid `us.us.anthropic.…` ARN. The
* resolver rejects an entry carrying ANY modelled geo prefix
* ({@link BEDROCK_GEO_REGIONS}) to catch that early — including `global.`,
* which previously slipped through and silently yielded
* `us.global.anthropic.…`.
*
* Throws on a malformed override (non-array, non-string / empty entries, or a
* region-prefixed ID) so a typo fails synth loudly instead of silently
* geo-prefixed ID) so a typo fails synth loudly instead of silently
* granting nothing or an invalid ARN.
*/
export function resolveBedrockModelIds(node: Node): readonly string[] {
Expand All @@ -90,11 +166,12 @@ export function resolveBedrockModelIds(node: Node): readonly string[] {
`Context '${BEDROCK_MODELS_CONTEXT_KEY}' entries must be non-empty strings; got ${JSON.stringify(id)}.`,
);
}
if (/^(us|eu|apac)\./.test(id)) {
if (GEO_PREFIX_RE.test(id)) {
throw new Error(
`Context '${BEDROCK_MODELS_CONTEXT_KEY}' expects bare foundation-model IDs, not region-prefixed `
+ `inference-profile IDs — got '${id}'. Use '${id.replace(/^(us|eu|apac)\./, '')}'; `
+ 'the US inference-profile ARN is derived automatically.',
`Context '${BEDROCK_MODELS_CONTEXT_KEY}' expects bare foundation-model IDs, not geo-prefixed `
+ `inference-profile IDs — got '${id}'. Use '${id.replace(GEO_PREFIX_RE, '')}'; `
+ `the inference-profile ARN is derived automatically from the '${BEDROCK_GEO_REGION_CONTEXT_KEY}' `
+ 'context key (default \'us\').',
);
}
}
Expand Down
15 changes: 10 additions & 5 deletions cdk/src/constructs/ecs-agent-cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { NagSuppressions } from 'cdk-nag';
import { Construct, type Node } from 'constructs';
import { AgentMemory } from './agent-memory';
import { AgentSessionRole } from './agent-session-role';
import { resolveBedrockModelIds } from './bedrock-models';
import { resolveBedrockGeoRegion, resolveBedrockModelIds } from './bedrock-models';
import { buildAppId } from './solution-ua-aspect';
import { ToolGateway } from './tool-gateway';

Expand Down Expand Up @@ -585,10 +585,12 @@ export class EcsAgentCluster extends Construct {

// Bedrock model invocation — scoped to explicit foundation-model and
// cross-region inference-profile ARNs (parity with the AgentCore runtime
// grants in agent.ts), NOT a Resource: '*' wildcard. The model set is the
// shared, context-overridable list (constructs/bedrock-models.ts) so the
// ECS and AgentCore backends can't drift.
// grants in agent.ts), NOT a Resource: '*' wildcard. The model set and the
// inference-profile geography are both the shared, context-overridable
// values (constructs/bedrock-models.ts: `bedrockModels`, `bedrockGeoRegion`)
// so the ECS and AgentCore backends can't drift.
const stack = Stack.of(this);
const bedrockGeoRegion = resolveBedrockGeoRegion(this.node);
const bedrockResources: string[] = [];
for (const modelId of resolveBedrockModelIds(this.node)) {
bedrockResources.push(
Expand All @@ -603,7 +605,10 @@ export class EcsAgentCluster extends Construct {
stack.formatArn({
service: 'bedrock',
resource: 'inference-profile',
resourceName: `us.${modelId}`,
// Same `<geo>.<modelId>` shape CrossRegionInferenceProfile.fromConfig
// builds for the AgentCore grant — regional + account-qualified for
// every geography, `global.` included.
resourceName: `${bedrockGeoRegion}.${modelId}`,
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
}),
);
Expand Down
31 changes: 21 additions & 10 deletions cdk/src/stacks/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { AgentVpc } from '../constructs/agent-vpc';
import { ApiKeyTable } from '../constructs/api-key-table';
import { ApprovalMetricsPublisherConsumer } from '../constructs/approval-metrics-publisher-consumer';
import { AttachmentsBucket } from '../constructs/attachments-bucket';
import { resolveBedrockModelIds } from '../constructs/bedrock-models';
import { resolveBedrockGeoRegion, resolveBedrockModelIds } from '../constructs/bedrock-models';
import { Blueprint } from '../constructs/blueprint';
import { CedarWasmLayer } from '../constructs/cedar-wasm-layer';
import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler';
Expand Down Expand Up @@ -446,16 +446,26 @@ export class AgentStack extends Stack {
this.node.tryGetContext('sdkUaAppId') as string | undefined,
);

// Cross-Region inference-profile geography (`bedrockGeoRegion`, default
// `us`). Resolved once and used for BOTH the auxiliary-model env var below
// and the Bedrock grants further down, so a deployment can never grant one
// geography's profiles while telling the agent to call another's.
const bedrockGeoRegion = resolveBedrockGeoRegion(this.node);

const runtimeEnvironmentVariables = {
GITHUB_TOKEN_SECRET_ARN: githubTokenSecret.secretArn,
AWS_REGION: process.env.AWS_REGION ?? 'us-east-1',
CLAUDE_CODE_USE_BEDROCK: '1',
ANTHROPIC_LOG: 'debug',
// Cross-region inference-profile id (``us.`` prefix), NOT the bare
// foundation-model id: Claude 4.x can't be invoked on-demand by bare id
// (400 "on-demand throughput isn't supported"). Must match a granted
// profile (see bedrock-models.ts). runner.py re-sets this at spawn time.
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
// Cross-region inference-profile id (geo prefix, `us.` by default), NOT
// the bare foundation-model id: Claude 4.x can't be invoked on-demand by
// bare id (400 "on-demand throughput isn't supported"). The prefix is
// derived from `bedrockGeoRegion` rather than hardcoded so this auxiliary
// model routes through the same geography as the granted profiles (see
// bedrock-models.ts) — a second hardcode here would silently split the
// two on any non-`us` deploy. runner.py re-sets this at spawn time.
ANTHROPIC_DEFAULT_HAIKU_MODEL:
`${bedrockGeoRegion}.anthropic.claude-haiku-4-5-20251001-v1:0`,
TASK_TABLE_NAME: taskTable.table.tableName,
TASK_EVENTS_TABLE_NAME: taskEventsTable.table.tableName,
NUDGES_TABLE_NAME: taskNudgesTable.table.tableName,
Expand Down Expand Up @@ -607,9 +617,10 @@ export class AgentStack extends Stack {
// EcsAgentCluster prop below (substrate parity).
toolGateway?.grantInvoke(runtime);

// Grant the runtime invoke on each configured foundation model + its US
// cross-Region inference profile. The model set is a single source of truth
// (constructs/bedrock-models.ts), shared with the ECS task role and
// Grant the runtime invoke on each configured foundation model + its
// cross-Region inference profile in the configured geography
// (`bedrockGeoRegion`, default `us`). The model set is a single source of
// truth (constructs/bedrock-models.ts), shared with the ECS task role and
// overridable via the `bedrockModels` CDK context. Each invokable is also
// collected so the same set is granted to the SessionRole below (for cost
// attribution) — the two grants derive from one list and can't drift.
Expand All @@ -622,7 +633,7 @@ export class AgentStack extends Stack {
supportsCrossRegion: true,
});
const crossRegionProfile = bedrock.CrossRegionInferenceProfile.fromConfig({
geoRegion: bedrock.CrossRegionInferenceProfileRegion.US,
geoRegion: bedrockGeoRegion,
model: foundationModel,
});
foundationModel.grantInvoke(runtime);
Expand Down
99 changes: 93 additions & 6 deletions cdk/test/constructs/bedrock-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@

import * as fs from 'fs';
import * as path from 'path';
import { CrossRegionInferenceProfileRegion } from '@aws-cdk/aws-bedrock-alpha';
import { App, Stack } from 'aws-cdk-lib';
import {
BEDROCK_GEO_REGION_CONTEXT_KEY,
BEDROCK_GEO_REGIONS,
BEDROCK_MODELS_CONTEXT_KEY,
DEFAULT_BEDROCK_GEO_REGION,
DEFAULT_BEDROCK_MODEL_IDS,
resolveBedrockGeoRegion,
resolveBedrockModelIds,
} from '../../src/constructs/bedrock-models';

Expand Down Expand Up @@ -61,16 +66,84 @@ describe('resolveBedrockModelIds', () => {
).toThrow(/non-empty strings/);
});

it('throws on a region-prefixed (us./eu./apac.) inference-profile ID', () => {
it('throws on a geo-prefixed inference-profile ID', () => {
// Guards the us.us.… double-prefix footgun: both grant sites derive the
// inference-profile ARN by prefixing `us.`, so the context wants the bare id.
// inference-profile ARN by prefixing the geo, so the context wants the bare id.
expect(() =>
resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['us.anthropic.claude-opus-4-8'] })),
).toThrow(/bare foundation-model IDs/);
expect(() =>
resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['eu.anthropic.claude-sonnet-4-6'] })),
).toThrow(/bare foundation-model IDs/);
});

// The guard used to test only /^(us|eu|apac)\./, so a `global.`-, `us-gov.`-,
// `jp.`- or `au.`-prefixed entry sailed through and produced a syntactically
// valid but non-existent `us.global.anthropic.…` inference-profile ARN — the
// grant then authorized nothing and the agent failed at turn 0 with
// AccessDenied, with nothing at synth to say why. Every geography the CDK enum
// models must be rejected, so the hole cannot reopen when a geography is added.
it.each([...BEDROCK_GEO_REGIONS])('throws on a %s-prefixed entry (no silent double-prefix)', (geo) => {
expect(() =>
resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: [`${geo}.anthropic.claude-opus-5`] })),
).toThrow(/bare foundation-model IDs/);
});

it('names the bare id and the geo context key in the rejection message', () => {
// The error is the only place the bare-ids-only contract is stated at the
// moment an operator gets it wrong, so it must carry the fix, not just the
// complaint: strip the geo the operator actually typed (`us-gov`, not `us`,
// for a `us-gov.` entry) and point at where geo really belongs.
expect(() =>
resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['us-gov.anthropic.claude-opus-5'] })),
).toThrow(/Use 'anthropic\.claude-opus-5'/);
expect(() =>
resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['global.anthropic.claude-opus-5'] })),
).toThrow(new RegExp(BEDROCK_GEO_REGION_CONTEXT_KEY));
});

it('still accepts a bare id whose name merely starts with a geo word', () => {
// The rejection keys on the `<geo>.` separator, not a bare prefix match, so a
// hypothetical `august.…`/`european.…` model id is not collateral damage.
expect(resolveBedrockModelIds(nodeWithContext({ [BEDROCK_MODELS_CONTEXT_KEY]: ['august-labs.model-1'] })))
.toEqual(['august-labs.model-1']);
});
});

describe('resolveBedrockGeoRegion', () => {
it('defaults to the US geography so an existing deploy is unchanged', () => {
expect(resolveBedrockGeoRegion(nodeWithContext())).toBe(CrossRegionInferenceProfileRegion.US);
expect(DEFAULT_BEDROCK_GEO_REGION).toBe(CrossRegionInferenceProfileRegion.US);
});

it.each([...BEDROCK_GEO_REGIONS])('accepts the %s geography the CDK enum models', (geo) => {
expect(resolveBedrockGeoRegion(nodeWithContext({ [BEDROCK_GEO_REGION_CONTEXT_KEY]: geo }))).toBe(geo);
});

it('covers exactly the geographies @aws-cdk/aws-bedrock-alpha models', () => {
// Derived from the enum rather than hand-listed: a CDK release that adds a
// geography must widen the allow-list automatically, and one that REMOVES a
// geography must not leave us granting an ARN the SDK no longer builds.
expect([...BEDROCK_GEO_REGIONS].sort())
.toEqual(['apac', 'au', 'eu', 'global', 'jp', 'us', 'us-gov']);
});

it('throws at synth on an unknown geography rather than granting an invalid ARN', () => {
// A typo'd geo yields a well-formed but non-existent inference-profile ARN.
// The grant would be accepted by IAM and authorize nothing, so the failure
// would surface as a turn-0 AccessDenied on a deployed stack instead of here.
expect(() => resolveBedrockGeoRegion(nodeWithContext({ [BEDROCK_GEO_REGION_CONTEXT_KEY]: 'usa' })))
.toThrow(/must be one of/);
expect(() => resolveBedrockGeoRegion(nodeWithContext({ [BEDROCK_GEO_REGION_CONTEXT_KEY]: 'US' })))
.toThrow(/must be one of/);
expect(() => resolveBedrockGeoRegion(nodeWithContext({ [BEDROCK_GEO_REGION_CONTEXT_KEY]: 'us-east-1' })))
.toThrow(/must be one of/);
});

it('throws on a non-string value', () => {
expect(() => resolveBedrockGeoRegion(nodeWithContext({ [BEDROCK_GEO_REGION_CONTEXT_KEY]: ['us'] })))
.toThrow(/must be one of/);
});
});

/**
Expand All @@ -90,10 +163,24 @@ describe('DEFAULT_BEDROCK_MODEL_IDS covers the agent runtime default', () => {
expect(match).not.toBeNull();
const agentDefault = match![1];

// The agent names the US inference profile (`us.anthropic.…`); the grant list
// holds bare foundation-model IDs and both grant sites add the `us.` prefix.
expect(agentDefault).toMatch(/^us\./);
const bare = agentDefault.replace(/^us\./, '');
// The agent names a cross-Region inference profile (`<geo>.anthropic.…`); the
// grant list holds bare foundation-model IDs and both grant sites add the geo
// prefix from `bedrockGeoRegion`. Accept any geography the CDK enum models —
// deliberately NOT `.*`: the assertion's teeth are that a BARE id here is a
// bug, because Bedrock refuses a bare Claude 4.x/5 id for on-demand
// invocation ("ValidationException: … on-demand throughput isn't supported").
// Widening to `.*` would let that un-invokable default land unnoticed.
const geoPrefix = agentDefault.match(new RegExp(`^(${[...BEDROCK_GEO_REGIONS].join('|')})\\.`));
expect(geoPrefix).not.toBeNull();
const bare = agentDefault.slice(geoPrefix![0].length);
expect(DEFAULT_BEDROCK_MODEL_IDS).toContain(bare);
});

it('rejects a bare foundation-model id as the agent default', () => {
// Mutation-proof for the assertion above: if someone "simplifies" the geo
// regex to `.*`, or drops it, this test is what still fails. Exercises the
// same matcher against the shape the guard exists to catch.
const bareDefault = 'anthropic.claude-opus-4-8';
expect(bareDefault.match(new RegExp(`^(${[...BEDROCK_GEO_REGIONS].join('|')})\\.`))).toBeNull();
});
});
Loading
Loading