Skip to content

feat(migration-to-aws): map OpenAI sources to the same GPT model on Bedrock - #210

Draft
leon1418 wants to merge 6 commits into
awslabs:mainfrom
leon1418:fix/openai-on-bedrock-model-mapping
Draft

feat(migration-to-aws): map OpenAI sources to the same GPT model on Bedrock#210
leon1418 wants to merge 6 commits into
awslabs:mainfrom
leon1418:fix/openai-on-bedrock-model-mapping

Conversation

@leon1418

@leon1418 leon1418 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Why

OpenAI's proprietary models — GPT-5.6 Sol/Terra/Luna, GPT-5.5, GPT-5.4 — are GA on Amazon Bedrock at OpenAI's first-party rates, with usage counting toward existing AWS commitments.

The OpenAI mapping logic still assumed they were unavailable on AWS. It cross-mapped every GPT source to Claude/Nova and then argued the migration on a price delta. Both halves of that are now wrong: the source model is usually itself a Bedrock target, and a same-model move is cost-neutral by construction, so the case rests on AWS commitments, IAM/VPC/CloudTrail governance, data residency, prompt caching, and eliminated behaviour-delta risk.

Scope: migrate/plugins/migration-to-aws only. See "Deliberately out of scope" for why the advisor/ copy is untouched.

What changed

New fact source. shared/openai-on-bedrock.md holds the model IDs, the /openai/v1 mantle path, the region matrix, TPM quota semantics, prompt-caching parameters, and SDK/IAM prerequisites. Everything else links to it instead of restating.

Tiered selection policy in ai-openai-to-bedrock.md:

  • Tier 0 — source model is on Bedrock → keep it (strong_migrate, reported as low-risk rather than cheaper)
  • Region gate — these are in-region only with no cross-region fallback, so a fixed region can force a model change; that is reported as a region constraint, not a model judgment
  • Tier 1 — no Bedrock equivalent → present a same-vendor upgrade and a cross-family option side by side, without pre-picking
  • Tier 2ai_priority = cost adds a cheaper alternative alongside the recommendation rather than replacing it

Propagated through discover, clarify, design, estimate, and generate. Same-model availability now outranks the Q16 priority, so a default balanced no longer silently moves a GPT-5.6 workload to Sonnet.

Three defects fixed

These made GPT-5.x targets unusable, not merely mis-recommended:

Where Symptom
resolve-bedrock-model-id Resolved every ID via list-inference-profiles. Mantle-only models have no inference profile, so a valid openai.gpt-5.6-terra always returned blocked / model_unresolvable.
preflight_bedrock.py Probed via bedrock-runtime Converse. These models have no bedrock-runtime surface, so preflight always failed and blocked the rewrite pipeline.
ai-migration-guardrails.md Claimed a shared 10,000 RPM Mantle cap and advised switching to bedrock-runtime for throughput. Quotas are per-model input/output TPM with no RPM dimension, and there is no bedrock-runtime path to switch to.

Pricing caveat worth reviewing

The AWS Price List API carries no GPT-5.x rows — querying AmazonBedrock returns only gpt-oss and GPT OSS Safeguard. The awspricing MCP therefore cannot price these models, and an empty result must not be read as "model unavailable". bedrock_pricing.py short-circuits with an actionable note and refuses prefix matching across the Sol/Terra/Luna tiers, whose rates differ by an order of magnitude.

GPT-5.6 Terra and Sol rates are deliberately left unverified rather than derived from the published percentage deltas. Only Luna ($0.20/$1.20, verified) is filled in. Someone with access should read Terra and Sol off the pricing page's OpenAI tab — this is the one open data item.

Notable corrections

  • Four pre-existing blended-savings figures were wrong and are corrected: GPT-5.2 vs Sonnet 4.6 read 50% (actually 17%), o3-pro 87% → 89%, o1 83% → 85%, GPT-5 Pro 90% → 91%.
  • GPT-5.6 context window: the launch blog says 272K, all three model cards say 1M. Model cards treated as authoritative.
  • Prompt caching is listed as supported on GPT-5.6 only — not 5.5/5.4.
  • Gate (a.5) in llm-to-bedrock/SKILL.md now prefix-matches migration_path, so the more specific mantle_openai_responses cannot fall through to the Converse path.

Verification

fmt:check, lint:md, lint:frontmatter, lint:types, test all pass. 169 pytest pass in llm-to-bedrock/scripts, including 10 new tests covering mantle detection, mantle error classification, the RPM-warning suppression, and the tier prefix-match refusal. Pricing staleness OK.

Two things I could not verify and would like a reviewer to close:

  • No live probe. The mantle Responses path has unit coverage only; no real account call. uv run --project . python preflight_bedrock.py --region us-east-1 --models openai.gpt-5.6-luna
  • Terra/Sol rates, per above.

mise run build fails only at fixtures:check (17 problems), all inside three untracked agent-advisor-*-demo fixture directories that predate this branch and are untouched here.

Conflict note

#201 (fix/bedrock-pricing-opus-48-rate) edits the same two files — bedrock_pricing.py and test_bedrock_pricing.py. It changes the opus-4-8 rows in STATIC_FALLBACK; this branch appends openai.* rows after nova-pro, three lines away, and both append tests. Whichever merges second should re-check that hunk.

Deliberately out of scope

The advisor/ vendored copy is not touched, pending #206. An earlier revision of this branch also ported these changes into advisor/plugins/aws-startup-advisor/skills/migration-to-aws; that commit has been dropped and the branch force-pushed (no reviews had been posted).

Reason: #206 (feat(advisor): port migration-to-aws skills into aws-startup-advisor) deletes skills/migration-to-aws and renames it to skills/gcp-to-aws, while splitting out llm-to-bedrock, agent-advisor, heroku-to-aws, tf-best-practices, and shared. All eight files the port had edited are renamed in #206, so carrying them here would have produced rename/modify conflicts on every one, for content that #206 supersedes anyway. The advisor side will be refreshed in one pass once #206 lands.

agent-advisor is also untouched — its OpenAI work lives in unmerged #195, so this branch takes no dependency on it and will be followed up after that merges. The Claude-tier figures in the cross-family tables are likewise stale relative to #195's catalog (Sonnet 5 / Opus 4.8 vs the trunk's 4.6/4.7); that is left for a knowledge-refresh pipeline rather than widened into this diff.

…edrock

OpenAI's proprietary models (GPT-5.6 Sol/Terra/Luna, GPT-5.5, GPT-5.4) are GA
on Bedrock at OpenAI's first-party rates. The OpenAI mapping logic still assumed
they were unavailable on AWS, so it cross-mapped every GPT source to Claude/Nova
and then argued the move on a price delta. That premise is gone: the source model
is usually itself a Bedrock target, cost is neutral on a same-model move, and the
case rests on AWS commitments, governance, residency, and eliminated eval risk.

Add shared/openai-on-bedrock.md as the single fact source (model IDs, the
/openai/v1 mantle path, region matrix, TPM quotas, prompt caching) and rewrite
ai-openai-to-bedrock.md around a tiered policy: Tier 0 keeps the model when it is
on Bedrock, a region gate handles the in-region-only constraint, and Tier 1
presents a same-vendor upgrade and a cross-family option side by side for sources
with no Bedrock equivalent. Propagate through discover, clarify, design, estimate,
and generate.

Fix three defects that made GPT-5.x targets unusable rather than merely
mis-recommended:

- resolve-bedrock-model-id resolved every id through list-inference-profiles.
  Mantle-only models have no inference profile, so a valid openai.gpt-5.6-terra
  always returned blocked/model_unresolvable. Route them to a catalog check and
  drop the cross-region-prefix remedy, which cannot apply in-region-only.
- preflight_bedrock.py probed via bedrock-runtime Converse. These models have no
  bedrock-runtime surface, so preflight failed and blocked the rewrite pipeline.
  Add a Responses-API probe, mantle error classification pointing at
  bedrock-mantle:* IAM rather than bedrock:InvokeModel, and suppress the RPM
  pacing warning for a surface that has no RPM quota.
- ai-migration-guardrails claimed a shared 10,000 RPM Mantle cap and advised
  switching to bedrock-runtime for throughput. Both are wrong here: quotas are
  per-model input/output TPM with no RPM dimension, and no bedrock-runtime path
  exists to switch to. Replace with the real scaling levers.

Also: the AWS Price List API carries no GPT-5.x rows, so the awspricing MCP
cannot price these models and an empty result must not read as "unavailable".
bedrock_pricing.py short-circuits with an actionable note and refuses prefix
matching across the Sol/Terra/Luna tiers, whose rates differ by an order of
magnitude. GPT-5.6 Terra and Sol rates are left unverified rather than derived
from percentage deltas.

Widen same_model_family in the llm2bedrock agents from "Anthropic 1P only" to
"the model is unchanged", while keeping the API-surface deltas (Chat Completions
to Responses, reasoning-item round-tripping) that a same-model OpenAI move still
carries. Gate (a.5) now prefix-matches migration_path so the more specific
mantle_openai_responses value cannot fall through to the Converse path.

Corrects four pre-existing blended-savings figures found while recomputing the
tables: GPT-5.2 vs Sonnet 4.6 was 50% (actually 17%), o3-pro 87% (89%), o1 83%
(85%), GPT-5 Pro 90% (91%).
@leon1418
leon1418 force-pushed the fix/openai-on-bedrock-model-mapping branch from b1a4680 to 04901c8 Compare August 10, 2026 18:28

@leon1418 leon1418 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] Found four blocking correctness gaps in the same-model OpenAI path: evaluation still uses Converse, emitted behavior deltas violate the analysis schema, the standard preflight silently skips Mantle probing, and the generated IAM policy cannot authorize Mantle calls. I read all 22 changed human-written files plus the relevant schema, callers, tests, and IAM helper; verified the API, quota, model, and IAM claims against AWS documentation; and reproduced the schema rejection and mantle_unprobed success path. GitHub checks are green, but these functional paths are not covered by the current validation.

# 8. Same-model-family short-circuit

If `same_model_family: true` (Anthropic 1P → Bedrock Claude):
If `same_model_family: true` — either Anthropic 1P → Bedrock Claude, or OpenAI → the same GPT model on Bedrock (`openai.gpt-5*`):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] This OpenAI same-model branch cannot be reached successfully for proprietary GPT targets. Section 6 runs first and unconditionally calls boto3.converse; these openai.gpt-5* models are Mantle-only, so that probe fails and stops before section 8. The per-prompt verification also points to the Converse-only section 10 script. Route Mantle targets through the Responses API for both connectivity and prompt verification, as the new preflight path does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 893f88d.

Reproduced the ordering: §6 starts at line 125 and §8 at line 178, and §6's probe calls boto3.converse unconditionally — so a mantle-only target aborted the agent before §8 was ever evaluated. You were also right that §10 had the same problem independently.

What changed:

  • Added §6a Mantle connectivity check using BedrockOpenAI + client.responses.create, with its own failure taxonomy: 404 → model_unavailable_in_region (these are in-region only, so the resolve-bedrock-model-id inference-profile ranking does not apply and there is no cross-region fallback to offer), 401/403 → model_access vs authz where the authz detail names bedrock-mantle:* rather than bedrock:InvokeModel, ModuleNotFoundErrormantle_deps_missing.
  • Added an API-path selection table at the top of §6 keyed on <TARGET_MODEL_ID>, so the routing decision is made once and applies to §6, §9.5 and §10.
  • §8 now states explicitly that per-prompt verification uses the path §6 selected — the §6a Responses client for mantle targets — and must not call §10's script.
  • Marked §10 Converse-only, and tightened §8's skip list to name §9.5 and §10 explicitly. §9.5 is also Converse-based and its exclusion was previously only implied by "skip to §14", which is exactly the ambiguity that produced this bug.
  • Also corrected two stale lines in §3: <TARGET_MODEL_ID> no longer says "substitute in every boto3.converse call", and same_model_family no longer reads "only for Anthropic 1P".


## reasoning-items-must-round-trip

- `resolution_kind`: `mechanical`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] analysis.json rejects this value: resolution_kind is limited to ux_choice|impl_path. The analyzer is required to emit these same-model deltas and then validate its output, so detecting this reasoning delta (or either later mechanical delta) makes T2-3 unable to produce a valid result. I reproduced the schema failure locally. Use impl_path for no-choice rewrites, or extend the schema and every downstream consumer consistently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 893f88d.

scripts/schemas/analysis.json:71 restricts resolution_kind to ["ux_choice", "impl_path"], so all three mechanical blocks would have failed the analyzer's own output validation.

Fixed by using impl_pathno schema change. Checking how impl_path is already used made clear the new value was redundant rather than missing: behavior-delta-detection.md:97 defines it as "do NOT ask the user, pick the default specified in the reference", and :119 records it as impl_path_default. That is precisely the semantics I was reaching for, so inventing mechanical added a second name for an existing concept on top of breaking the schema.

Changed at lines 35, 48 and 56; grep for mechanical in that file now returns 0.

from aws_bedrock_token_generator import provide_token
from openai import BedrockOpenAI
except ImportError as e:
return {"ok": True, "reason": "mantle_unprobed",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] This success path is the standard clean-install path, not an edge case. scripts/pyproject.toml declares neither openai nor aws-bedrock-token-generator, while Phase B2 invokes this script with uv run --project $SCRIPTS; the imports therefore fail and return ok: true. Phase B4 only surfaces embedding_unprobed, so endpoint/model/IAM access is never checked and the migration proceeds as if preflight passed. Declare the runtime dependencies and fail closed, or treat mantle_unprobed as an explicit stop in B4.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and your framing is the correct one — fixed in 893f88d by doing both remedies.

I had flagged this as a deliberate judgment call, reasoning that it mirrored the embedding_unprobed precedent ("a caller-machine gap is not a Bedrock verdict"). That analogy does not hold, and your point is what breaks it: embedding_unprobed fires only for an unrecognized embedding vendor, which is rare, whereas these imports were guaranteed to fail because scripts/pyproject.toml declared neither package. Treating a certain path as an edge case turned a fail-fast preflight into an unconditional green light, with endpoint, model and IAM access never checked.

  • Declared openai>=2.45.0,<3 and aws-bedrock-token-generator>=1,<2 in scripts/pyproject.toml, so probing actually runs. Verified uv resolves and installs them.
  • probe_mantle_model now fails closed: ok: False, reason: mantle_deps_missing, with a detail that states access was NOT verified and points at re-syncing the pinned env.
  • Added a mantle_deps_missing row to the B4 branch table in llm-to-bedrock/SKILL.md as an explicit stop, since B4 previously surfaced only embedding_unprobed.
  • Two tests: one monkeypatches the imports away and asserts the fail-closed verdict, one asserts the pinned env declares both packages so the fail-closed branch cannot become the normal path again.

- `resolution_kind`: `mechanical`
- Base URL must be `https://bedrock-mantle.{region}.api.aws/openai/v1` — the `openai/v1` segment is required and differs from the `v1` path other mantle models use. A hardcoded `/v1` returns 404.
- The API key must be a Bedrock API key or an auto-refreshing token provider, **not** an existing OpenAI key. A long-lived `OPENAI_API_KEY` read from the environment will fail authentication.
- IAM must grant `bedrock-mantle:*` actions; `bedrock:InvokeModel` does not authorize these models.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] The final report still generates .saws-migrate/iam-policy.json through scripts/iam_policy.py, which emits only bedrock:InvokeModel* against foundation-model/inference-profile ARNs. That policy does not authorize the Mantle bearer-token call documented here: AWS requires bedrock-mantle:CallWithBearerToken (with Resource: "*"). Applying the generated artifact therefore leaves the migrated app unauthorized. Add a Mantle-specific policy branch and coverage for openai.gpt-5* targets.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 893f88d. I verified the permission model against AWS docs before implementing rather than taking the claim on faith — the AmazonBedrockMantleInferenceAccess managed policy document confirms CallWithBearerToken is scoped to Resource: "*".

iam_policy.py now partitions targets and emits up to three statements:

  • BedrockInvokeModelScoped — unchanged, for bedrock-runtime targets
  • BedrockMantleInferencebedrock-mantle:CreateInference / Get* / List* on arn:aws:bedrock-mantle:{region}:{account}:project/*
  • BedrockMantleCallWithBearerToken — on *, which AWS does not permit narrowing

Two details worth flagging beyond the report:

  1. An all-mantle run omits BedrockInvokeModelScoped entirely. Keeping it would have emitted "Resource": [], which is an invalid policy. Mantle models have no foundation-model or inference-profile ARN to scope to, so the absence is correct rather than a generation failure — noted in the report-generator prompt so it is not read as a bug.
  2. The two mantle statements are not redundant. The AWS reference policies split them: the first covers SigV4, the second covers Bedrock API-key (bearer token) auth. Since the rewriter's generated client authenticates with a bearer token, both are required. Recorded in a code comment.

Mantle authorizes at project granularity, so the policy cannot be scoped to specific model IDs; the report now says so explicitly and points at an SCP for restricting the model set.

Six tests cover this: the mantle action set, the bearer-token wildcard scope, region/account scoping, mixed runtime+mantle runs, gpt-oss staying on the runtime path, and the empty-Resource guard.

One self-inflicted issue found while fixing: my first cut omitted the statement whenever resources was empty, which broke the pre-existing test_empty_models (it asserts the legacy Statement[0]["Resource"] == [] shape for an empty model list). Narrowed the condition to resources or not mantle_ids so only the all-mantle case is omitted and existing callers see no behaviour change.

…enAI path

Addresses the AI review on awslabs#210. All four findings reproduced and confirmed
before fixing; the common thread is that the policy and documentation layers were
updated consistently while three executable contracts and one ordering dependency
were not.

Evaluator could never reach the same-model branch. §6's connectivity check runs
before §8's short-circuit and calls boto3.converse unconditionally, so a
mantle-only openai.gpt-5* target aborted the agent before §8 was ever evaluated.
§10's golden evaluation is Converse-only for the same reason. Add §6a, a Mantle
Responses connectivity check selected from the target model id, route §8's
per-prompt verification through that same client, and mark §9.5 and §10 as
Converse-only so a mantle target cannot fall into them.

Emitted behavior deltas violated the analysis schema. scripts/schemas/analysis.json
restricts resolution_kind to ux_choice|impl_path, and the same-model delta blocks
declared `mechanical` — so the analyzer, which must emit these deltas and then
validate its own output, could not produce a valid T2-3 result. The invented value
was also redundant: impl_path already means "no user choice, apply the reference
default, record as impl_path_default". Three occurrences changed to impl_path; the
schema is untouched.

Preflight passed without probing anything. probe_mantle_model treated a missing
OpenAI SDK as ok=true with a caveat, copying the embedding_unprobed precedent. That
precedent does not transfer: an unrecognized embedding family is rare, whereas the
imports were guaranteed to fail because scripts/pyproject.toml declared neither
openai nor aws-bedrock-token-generator. Every GPT-5.x run therefore reported a
passing preflight with endpoint, model, and IAM access never checked. Declare both
dependencies and fail closed with reason mantle_deps_missing, surfaced as a stop in
the skill's B4 branch table.

Generated IAM policy could not authorize the migrated app. iam_policy.py emitted
only bedrock:InvokeModel* against foundation-model and inference-profile ARNs.
Mantle models have neither, and require bedrock-mantle actions. Add a mantle branch
emitting CreateInference/Get*/List* scoped to the account's mantle projects plus
CallWithBearerToken on "*", which AWS does not permit narrowing (verified against
the AmazonBedrockMantleInferenceAccess managed policy). An all-mantle run omits the
InvokeModel statement entirely rather than emitting an invalid empty Resource list,
while a genuinely empty model list keeps its previous shape so existing callers are
unaffected.

Adds 8 tests: fail-closed on missing deps, a guard that the pinned env declares
them, and six covering the IAM branch — mantle action set, bearer-token wildcard
scope, region/account scoping, mixed runtime+mantle runs, and gpt-oss staying on
the runtime path.

@leon1418 leon1418 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] Re-reviewed 893f88d against 04901c8. The Responses routing, fail-closed preflight dependencies, and Mantle IAM policy are materially improved; 177 script tests pass, and public AWS documentation confirms the API, image-input, and IAM facts. Three blockers remain: one mechanical resolution-kind instruction still violates the analysis schema, the new evaluator emits three blocked reasons rejected by eval.json, and same-model vision paths now skip every image call. Details inline.

- Base URL must be `https://bedrock-mantle.{region}.api.aws/openai/v1` — the `openai/v1` segment is required and differs from the `v1` path other mantle models use. A hardcoded `/v1` returns 404.
- The API key must be a Bedrock API key or an auto-refreshing token provider, **not** an existing OpenAI key. A long-lived `OPENAI_API_KEY` read from the environment will fail authentication.
- IAM must grant `bedrock-mantle:*` actions; `bedrock:InvokeModel` does not authorize these models.
- Not user-visible — always `user_visible: false`, `resolution_kind: mechanical`. Apply without prompting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] This leaves the original schema-breaking value in the same normative block. The header now says impl_path, but this sentence still instructs the analyzer to emit resolution_kind: mechanical. analysis.json accepts only ux_choice|impl_path; a validator mutation check returned impl_path valid and mechanical invalid. Because T2-3 reads this reference to construct behavior_deltas, the contradiction can still make analyzer output fail validation. Change this sentence to impl_path (and ideally add a reference/schema consistency test).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 0588a3a.

You are right that the previous fix left the contradiction inside the same normative block. My edit was a sed matching the bullet form - \resolution_kind`: `mechanical`, so the prose mention on this line never matched — the header said impl_pathwhile the instruction still saidmechanical`.

Changed to impl_path, and took the "ideally add a consistency test" suggestion, because a targeted edit missing one form of the same statement is exactly the failure that should not depend on review catching it:

scripts/test_delta_reference_schema.py parses every resolution_kind mention across all files in the delta-reference directory — bullet and prose forms, value inside or outside backticks — and asserts each is in analysis.json's enum. It reads the enum from the schema rather than hardcoding it, so extending the schema does not require touching the test. There is also a guard-the-guard case asserting the regex still matches both forms, so the test cannot silently stop checking.

Verified by reintroducing mechanical on this exact line: the test fails with openai-to-bedrock.md:52 -> 'mechanical', then passes again once reverted.

region. These models are **in-region only**, so there is no cross-region
inference profile to fall back to and `resolve-bedrock-model-id`'s
inference-profile ranking does not apply. Return
`{ blocked: { reason: 'model_unavailable_in_region', detail: '<the exact error> — <TARGET_MODEL_ID> in <REGION>. These models are in-region only; a supported region or a different model is required.' } }`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] These new control states do not satisfy the evaluator contract. scripts/schemas/eval.json and the C3 blocked-flow handler accept only model_access, source_key_auth, and model_unresolvable. I passed the documented reasons through validate_result.py --schema eval: model_access returned 0, while model_unavailable_in_region, authz, and mantle_deps_missing each returned 1. A 404, IAM denial, or missing-dependency run therefore cannot produce a valid phase file and will enter the stateless retry path without a representable outcome. Extend the schema and orchestration handling for these reasons, or map them to supported typed states with equivalent recovery semantics.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 0588a3a. I reproduced it with validate_result.py --schema eval: model_access validated, while model_unavailable_in_region, authz, and mantle_deps_missing were each rejected.

I took your first option for two of them and the second for one, deciding per reason rather than uniformly:

  • model_unavailable_in_region → mapped to model_unresolvable. No new state. Its documented recovery — user picks or pastes an id, recorded in resolved_model_overrides — is precisely the remedy for a mantle 404, so the semantics already match. The detail now explains the in-region-only constraint, and the prompt explicitly says not to offer a us.-prefixed candidate since those do not exist for these models.
  • authz and mantle_deps_missing → added to the enum, with their own recovery rows in the C3 blocked-flow table. Neither has an equivalent existing state, and folding them into model_access would have been worse than the bug: it sends someone to the console Model access page for what is actually an IAM policy gap or a broken pinned environment. The authz row spells out that the action set differs by path (bedrock:InvokeModel* vs the bedrock-mantle:* actions), and mantle_deps_missing notes that access was never verified so an earlier pass must not be treated as still valid.

§14's own reason list is updated to match, and all four now validate.

Added a second test in test_delta_reference_schema.py that extracts every reason: '...' the evaluator documents and asserts it is representable in eval.json — checking the blocked and partial enums together, since throttled legitimately belongs to partial. That closes the class rather than just this instance.

- Compute `pass_rate = success_count / total_cases` (connectivity-only ratio) and write `failures = total_cases - success_count`.
- In §14, set `live_source_baseline: false` (no live comparison ran) and add `notes` prefix `same_model_family: true — connectivity-only verification, no rubric scoring`. T2-6 reads that prefix to render the report banner with "connectivity verified" instead of "judge scored X/Y prompts". Set `source_baseline_quality: 'unknown'` (no live baseline ran).
- Skip to §14 (no §9 baseline, no §11 scoring, no §12–§13 adaptation).
- Skip to §14 (no §9 baseline, **no §9.5 vision smoke test**, no §10 Converse eval, no §11 scoring, no §12–§13 adaptation). §9.5 and §10 both call `boto3.converse`, so running either against a mantle target would fail for reasons unrelated to the migration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] This suppresses the mandatory Layer 2 check for all same-model runs. same_model_family also includes Anthropic→Claude, where §9.5’s Converse vision call is valid, so this regresses the existing path. For proprietary GPT-5.x, the public AWS model card marks Image input as supported through Responses, but §8’s input=<prompt> loop never sends the golden case’s image_path. A vision migration can therefore report pass_rate: 1.0 and become ready-to-merge without making any image request. Keep §9.5 for Converse targets and add a Responses image probe/per-case image input path for Mantle before short-circuiting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 0588a3a. This was the worst of the three and the diagnosis is exactly right on both halves.

It was also self-inflicted in a specific way worth naming: I added that blanket skip in the previous round to prevent a Converse call against a mantle target, and in doing so reintroduced the same silently-green failure mode this branch exists to remove — a vision migration reporting pass_rate: 1.0 having never sent an image. It also regressed the pre-existing Anthropic → Claude path, where §9.5's Converse vision call was valid.

  • §9.5 no longer skipped. It runs whenever special_patterns.vision == true, including same-model runs, and selects its API path the same way §6 does. The section now states explicitly that §8 short-circuits scoring, not image verification.
  • §9.5a added — a Responses image probe for mantle targets, sending the image as a base64 data URL in an input_image content block, with the same three outcomes as §9.5. On VISION_FAIL it must not fall back to text-only, since a text-only pass rate would misrepresent an unverified vision capability. I flagged in the prompt that the content-block shape is the documented OpenAI Responses form and should be reported verbatim if rejected for a non-access reason, rather than guessed at — consistent with how this branch treats Chat Completions support as unverified.
  • §8's per-case loop now requires image input. Added a per-path table for the text-only and image_path cases, and a rule that a case which cannot be sent as an image is recorded as a failure and surfaced in notes — never silently downgraded.
  • §6's routing table now covers all three call sites (connectivity, vision smoke, golden eval) and states that path selection is independent of same_model_family: the short-circuit decides whether to score, the table decides how to call.

…on verified

Second review round on awslabs#210. All three findings were regressions introduced by the
previous fix commit, and all three are now covered by tests rather than only by
prose.

A prose sentence still instructed the analyzer to emit `resolution_kind:
mechanical`. The earlier fix rewrote the bullet declarations but missed a mention
in the same normative block, so the schema violation survived in the one place a
targeted edit would not match. Changed to `impl_path`, and added
test_delta_reference_schema.py, which parses every resolution_kind mention in the
delta references — both the bullet and prose forms — and asserts each value is in
analysis.json's enum. Verified by reintroducing `mechanical` and watching the test
fail on the exact line.

The new §6a control states could not be written to a valid phase file.
eval.json accepted only model_access / source_key_auth / model_unresolvable, so
the documented model_unavailable_in_region, authz, and mantle_deps_missing each
failed validation, leaving a 404, an IAM denial, or a broken environment with no
representable outcome. Resolved by kind rather than uniformly: the 404 now maps to
model_unresolvable, whose existing recovery path (user supplies an id, recorded in
resolved_model_overrides) is exactly right, while authz and mantle_deps_missing are
added to the enum with their own C3 recovery rows — mapping either onto
model_access would have sent users to the console for an IAM or dependency problem.
A second test asserts every control-state reason the evaluator documents is
representable in the schema.

Vision verification was suppressed for every same-model run. Skipping §9.5
wholesale also disabled it for Anthropic → Claude, where the Converse vision call
was valid, and §8's text-only loop never sent a case's image_path — so a vision
migration could report pass_rate 1.0 and reach ready-to-merge without issuing a
single image request. That is the same silently-green failure this branch set out
to remove. §9.5 now runs whenever vision is detected and selects its API path like
§6; §9.5a adds a Responses image probe for mantle targets; §8 requires image input
for any case carrying an image_path and forbids downgrading such a case to a
text-only call. The §6 routing table now states path selection once, across
connectivity, vision, and golden eval, and notes that it is independent of
same_model_family.

@leon1418 leon1418 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] Re-reviewed final head 0588a3a against prior head 893f88d9 and all three author replies. The resolution_kind contradiction is fixed, and model_unresolvable/authz/mantle_deps_missing are now representable end-to-end. The same-model vision fix is incomplete: the smoke probe uses the correct Responses message wrapper, but the per-golden-case table tells the evaluator to pass raw content blocks, which are not a valid ResponseInputParam; .jpg is also not normalized to jpeg/image/jpeg. This can make every GPT-5.x vision golden case fail after a successful smoke test.

Validation: targeted regressions 45 passed; full llm-to-bedrock scripts suite 180 passed; uv lock --check passed; eval schema accepted the three intended reasons and rejected model_unavailable_in_region; in-memory mechanical mutation was rejected; pinned OpenAI SDK 2.53.0 type inspection confirms input is a list of message/items and content blocks belong under a message's content. No live/billable Mantle calls were made.

Merge is not recommended until the inline vision request-shape blocker is fixed and regression-tested.

| Path | Text-only case | Case with `image_path` |
| ------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Converse (Claude) | `converse(modelId=…, messages=[{'role':'user','content':[{'text': prompt}]}])` | add `{'image': {'format': <ext>, 'source': {'bytes': <file bytes>}}}` to the same `content` list, before the text block |
| Responses (mantle) | `client.responses.create(model=…, input=prompt, max_output_tokens=…, store=False)` | pass a content list: `[{'type':'input_text','text': prompt}, {'type':'input_image','image_url': 'data:image/<ext>;base64,<b64>'}]` |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] This per-case image shape is not a valid Responses input. In the pinned OpenAI SDK 2.53.0, ResponseInputParam is a list of message/items; input_text and input_image blocks belong inside a message's content, as §9.5a correctly shows. Following this row literally sends raw content blocks, so every GPT-5.x vision golden case can fail even after the smoke probe succeeds. Use the full input=[{"role":"user","content":[...]}] wrapper here. Also normalize the preserved filename format (.jpg → Converse jpeg and Responses image/jpeg) instead of passing <ext> verbatim. Add a static request-shape regression for both the Claude and Mantle per-case paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in e677823. Both halves were real, and the reason neither showed up in my own verification is exactly what you identified: §9.5 and §9.5a hardcode a known-good jpeg, so the smoke tests pass while every golden case would fail.

The wrapper. §8 documented a bare block list as input while §9.5a correctly wrapped the same blocks in {"role": "user", "content": [...]}. I wrote the two in different rounds and never reconciled them — the inconsistency was visible in my own diff and I did not catch it.

The format. format: <ext> and data:image/<ext> produce jpg and image/jpg for a .jpg case. .jpg is the common case, which makes this worse than a corner: the smoke image itself is a .jpg and only passes because the format is hardcoded.

Rather than restate the correct shapes in prose a third time, both now come from code:

scripts/image_input.pyconverse_message / responses_message return complete message items (the Responses one returns the role/content wrapper, so omitting it is no longer possible); converse_format maps extensions onto Bedrock's accepted set with .jpg -> jpeg; mime_type and data_url derive the Responses form from that same mapping so the two cannot drift; an unsupported extension raises rather than passing through to an opaque API rejection. §8 now calls the helper and the inline dicts are gone.

Regression tests, per your request:

  • 16 unit tests over the helper: extension normalization (including uppercase), image/jpg never appearing in a data URL, png/gif/webp, unsupported types raising, the user-message wrapper, image-before-text ordering for Converse, text-only cases, and the missing-bytes misuse.
  • 2 static guards over the prompt itself, since prompt text is not reachable by unit tests: one fails if a Responses image block is documented without a role/content wrapper, one fails on image/<ext> or format: <ext> templating. Both verified by injecting the regression and confirming the specific failure line, then reverting — the same way I validated the resolution_kind guard last round.

Suite is now 198 passing; uv lock --check passes.

One adjacent pre-existing gap I did not change, and want your read on. §10's Converse golden eval has the same blind spot independently of this branch: it builds messages.append({"role": "user", "content": [{"text": prompt["user_prompt"]}]}) and never reads image_path, so a cross-family vision migration (e.g. GPT-4o → Claude) also evaluates vision cases as text-only. converse_message is already built and tested for it, so the fix is small — but it changes the primary evaluation path for every cross-family run, so I did not fold it into a review-response commit. Happy to do it here or split it out, whichever you prefer.

Third review round on awslabs#210. The remaining blocker was that the per-case evaluator
loop could not actually work for GPT-5.x vision cases, in two ways that both hide
behind a passing smoke test — §9.5 and §9.5a hardcode a known-good jpeg, so neither
mistake surfaces until golden cases run.

The per-case Responses request was malformed. §8 documented a bare
`[{"type": "input_text", ...}, {"type": "input_image", ...}]` list as `input`, while
§9.5a correctly wrapped the same blocks in `{"role": "user", "content": [...]}`. The
two shapes were written in different rounds and never reconciled.

The file extension was templated straight into the wire format. `format: <ext>`
and `data:image/<ext>` yield `jpg` and `image/jpeg`'s invalid sibling `image/jpg`
for any `.jpg` case — and `.jpg` is the common case, since the smoke images and most
captured vision fixtures use it.

Rather than restate the correct shapes in prose a third time, both now come from
`scripts/image_input.py`: `converse_message` / `responses_message` return complete
message items, `converse_format` maps extensions onto Bedrock's accepted formats
(`.jpg` -> `jpeg`), `mime_type` and `data_url` derive the Responses form from that
single mapping, and an unsupported extension raises instead of being passed through
to an opaque API rejection. §8 now calls the helper and the inline dict shapes are
gone.

Coverage: 16 unit tests over the helper (extension normalization including case,
the user-message wrapper, image-before-text ordering for Converse, text-only cases,
unsupported types, and missing-bytes misuse), plus two static guards over the
evaluator prompt — one rejecting a Responses image block documented without a
role/content wrapper, one rejecting `image/<ext>` or `format: <ext>` templating.
Both guards were verified by injecting each regression and confirming the specific
failure, then reverting.

Note on an adjacent pre-existing gap, deliberately not changed here: §10's Converse
golden eval also sends text only (`content: [{"text": ...}]`) and never reads
`image_path`, so cross-family vision runs have the same blind spot independent of
this branch. `converse_message` is ready for it, but switching the primary
cross-family evaluation path is its own decision and its own change.

@leon1418 leon1418 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] Re-reviewed current head e677823 against prior head 0588a3a and the author reply. The original wrapper and .jpg normalization defects are materially improved: request construction is centralized, .jpg maps to Converse jpeg / Responses image/jpeg, and 16 helper tests plus static guards cover those two regressions. Two mandatory execution-contract gaps remain. The new §8 caller reads case["prompt"], but the canonical golden record contains user_prompt, so both same-model Anthropic and GPT-5.x paths raise KeyError before any API call. The Responses image helper also omits detail, which pinned OpenAI 2.53.0 marks required on ResponseInputImageParam; §9.5a has the same omission. Details inline.

Validation (no live/billable inference): 76 targeted tests passed; the full scripts suite passed 198/198; pinned OpenAI 2.53.0 types confirm message role/content wrapping and required image detail; pinned botocore 1.43.24 confirms image formats are png|jpeg|gif|webp; a canonical golden record reproduces KeyError: prompt. All 8 GitHub checks are green. Related overlap: #201 merged into current main and a synthetic merge preserves both its Opus 4.8 rate correction and this PR’s Mantle pricing; #195 has no file overlap; #206 carries a separate, older evaluator port and will need a follow-up sync after this path is corrected.

Merge is not recommended until both inline blockers are fixed and regression-tested.

raw = open(case["image_path"], "rb").read() if case.get("image_path") else None

# Converse target:
msgs = [converse_message(case["prompt"], case.get("image_path"), raw)]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] The canonical golden-dataset record has user_prompt, not prompt (see the log ingestor schema, dedupe key, source-baseline helper, and §10 below). Following this new example raises KeyError: prompt before either Converse or Responses is called, so both same-model Anthropic and GPT-5.x verification paths fail for every case. Use case["user_prompt"] in both helper calls and add a regression that executes this documented §8 caller shape against a canonical golden record; the current helper-only tests cannot catch a caller/schema mismatch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 2445213. I reproduced the KeyError: 'prompt' against the canonical record.

The field name was invented rather than read off the record. What makes it worse is that the correct usage was already in the same file — §10 line 535 has prompt["user_prompt"] — so this was reachable by looking six hundred lines down, not by consulting external docs.

Fixed to case["user_prompt"] on both the Converse and Responses branches. Correcting it also surfaced that the loop silently dropped system_prompt, which the canonical record carries and §10 handles: it now passes it as Converse system, and on the Responses branch emits it as a developer-role item ahead of the user item.

Regression test — rather than asserting the one key, the guard parses the canonical prompts.jsonl record out of the llm2bedrock-log-ingestor prompt and asserts that every golden-case field the evaluator reads (case[...], prompt[...], entry[...], and .get(...) forms) exists in it. A field-name drift on either side now fails in CI rather than at eval time. Verified by injecting case["prompt"] and confirming the guard names the line.

if image_path is not None:
if raw is None:
raise ValueError("raw image bytes are required when image_path is given")
content.append({"type": "input_image", "image_url": data_url(image_path, raw)})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] This image block still does not satisfy the pinned OpenAI 2.53.0 Responses contract: ResponseInputImageParam declares both type and detail as required, while the helper emits only type and image_url. BedrockOpenAI subclasses the same OpenAI client, and §9.5a also omits detail, so GPT-5.x vision remains outside the pinned request contract even though the wrapper and MIME type are now correct. Add an explicit detail value (for example "auto") in the shared helper, use the helper or the same complete shape in the smoke path, and assert all required image fields in the regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 2445213. Verified directly against the pinned SDK: ResponseInputImageParam.detail is Required[Literal["low","high","auto","original"]] in openai 2.53.0.

responses_message() now always emits detail (default auto, the SDK's documented default; overridable and validated against the literal), and the §9.5a smoke snippet sets it inline.

On the guards — I made them read the contract from the SDK rather than restate it, so an SDK bump that adds a required field fails here instead of at runtime:

  • Required and known keys are derived from the installed ResponseInputImageParam, and the emitted block is checked for both missing required fields and fields the SDK does not define.
  • The accepted detail values are compared against the SDK's own Literal args.

One implementation note worth recording, since it would silently defeat a naive version of this test: __required_keys__ is unusable for this class. It is declared total=False with per-field Required[...] markers, which CPython 3.11 does not fold in — __required_keys__ is frozenset() and __optional_keys__ contains every key, including detail. My first attempt asserted against __required_keys__ and passed vacuously. The guard now reads requiredness from the annotation origin (get_type_hints(..., include_extras=True) + get_origin(...) is Required), and asserts detail is in the derived required set so the test fails loudly if that derivation ever stops working.

A fourth static guard rejects any inline input_image block in prompt text that omits detail.

Suite is 209 passing. Still no live/billable Mantle call — the wire format is verified against the SDK's type definitions, not against the service.

… image detail

Fourth review round on awslabs#210. Both findings are contract mismatches in the §8
per-case loop I added last round, and both fail before or at the API boundary
rather than degrading quietly.

Wrong golden-case key. The example read `case["prompt"]`, but the canonical
record written by llm2bedrock-log-ingestor §9 uses `user_prompt` (alongside
`system_prompt` and `image_path`) — the field name was invented rather than taken
from the record, and §10 six hundred lines below already used `prompt["user_prompt"]`
correctly. Both the Converse and Responses branches would raise KeyError before any
call. Fixed, and while correcting it the loop also picked up `system_prompt`, which
it previously dropped: §10 passes it as Converse `system`, and the Responses branch
now emits it as a `developer`-role item ahead of the user item.

Missing required `detail`. The pinned OpenAI SDK 2.53.0 declares
`ResponseInputImageParam.detail` as `Required`, and neither `responses_message` nor
the §9.5a smoke snippet set it. `responses_message` now emits `detail` (default
`auto`, overridable, validated against the SDK's literal), and the smoke snippet
sets it inline.

Coverage — the guards derive their expectations from the SDK and the canonical
record rather than restating them, so a future SDK bump or record change fails here
instead of at runtime:

- Required/known keys of the image param are read from the pinned SDK. Note
  `__required_keys__` is unusable for this class: it is declared `total=False` with
  per-field `Required[...]`, which CPython 3.11 reports as all-optional, so the
  guard reads requiredness from the annotation origin instead.
- The accepted `detail` values are compared against the SDK's own `Literal`.
- Golden-case field names read anywhere in the evaluator are checked against the
  record parsed out of the log-ingestor prompt.
- Inline `input_image` blocks in prompt text must set `detail`.

The pre-existing bare-content-list guard also needed widening: it accepted only a
`user` wrapper and flagged the new legitimate `developer` item, so it now accepts
any message-item role. All guards re-verified by injecting each regression,
confirming the specific failure, and reverting. Suite is 209 passing.

@leon1418 leon1418 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 AI review 🤖] Re-reviewed current head 2445213 against prior head e677823 and the author replies. Both prior blockers are fixed: §8 now reads the canonical user_prompt field for both Anthropic Converse and GPT-5.x Responses paths (and preserves system_prompt), while responses_message() and §9.5a now include the OpenAI 2.53.0-required image detail field. The previously fixed Responses message wrapper, .jpg → Converse jpeg / Responses image/jpeg normalization, resolution_kind consistency, and evaluator control reasons remain intact. I found no new mandatory or non-blocking findings in the four-file delta.

Validation made no live or billable Mantle calls: 32 targeted tests passed; the complete scripts suite passed 209/209; uv lock --check --offline and git diff --check passed; direct botocore/OpenAI contract checks validated both API payloads and reproduced the old KeyError / missing-detail failures as negative controls. All 8 GitHub checks pass. Related PRs: #195 is merged with no file overlap; #201 overlaps pricing files but a synthetic merge is clean and preserves both its Opus 4.8 correction and this PR’s Mantle pricing; #206 is a separate older port and still needs a follow-up sync of this evaluator path. The code changes are ready from this review perspective; merge after updating the branch from main, removing draft status, and completing normal human review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant