From 0057f201714d01a2743ccdd5e952ccb8dabb1f38 Mon Sep 17 00:00:00 2001 From: Eric Law <39393654+acn-ericlaw@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:33:44 -0700 Subject: [PATCH 1/2] fix(llm-helper): map the Bedrock client's own credential error - "Could not resolve AWS credentials from session" - to the 503 by name The live drive's second negative control (no credential at all) reached the SDK's Mantle client, whose RuntimeError reads "Could not resolve AWS credentials from session"; the helper matched the legacy client's wording ("could not resolve credentials from session") and let the error through as a bare 500. The match is now case-insensitive on the words both wordings carry, botocore's NoCredentialsError still included; the test pins the live message. The health check already reported the missing credential by name. --- examples/llm-helper/llm_helper.py | 19 ++++++++++++++----- tests/test_llm_helper.py | 7 ++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/examples/llm-helper/llm_helper.py b/examples/llm-helper/llm_helper.py index d7dee99..e40d1e2 100644 --- a/examples/llm-helper/llm_helper.py +++ b/examples/llm-helper/llm_helper.py @@ -89,8 +89,10 @@ MISSING_BEDROCK_SDK = "The Bedrock backend needs boto3 - pip install 'mercury-composable[bedrock]'" # the SDK raises a bare TypeError, before sending anything, when no credential resolves CREDENTIAL_TYPE_ERROR = "Could not resolve authentication method" -# the SDK's Bedrock client raises a bare RuntimeError, while signing, when the AWS chain is empty -AWS_CREDENTIAL_RUNTIME_ERROR = "could not resolve credentials from session" +# the SDK's Bedrock client raises a bare RuntimeError, while signing, when the AWS chain is empty: +# "Could not resolve AWS credentials from session" (its legacy client says "credentials from +# session" without "AWS"); matched without case on the words both carry +AWS_CREDENTIAL_RUNTIME_ERROR = ("could not resolve", "credentials from session") def _prop(key: str, default: str | None = None) -> str | None: @@ -515,6 +517,15 @@ def _status_failure(exc: anthropic.APIStatusError) -> AppException: return AppException(status, f"LLM provider error - {_detail(exc)}") +def _aws_credential_error(exc: BaseException) -> bool: + """The AWS credential chain resolved nothing: the SDK's bare RuntimeError while signing, or + botocore's NoCredentialsError.""" + if type(exc).__name__ == "NoCredentialsError": + return True + text = str(exc).lower() + return isinstance(exc, RuntimeError) and all(w in text for w in AWS_CREDENTIAL_RUNTIME_ERROR) + + def _failure(exc: BaseException, timeout_ms: int) -> AppException | None: """Map a provider failure to AppException(status, message); None for anything else.""" if isinstance(exc, AppException): @@ -527,9 +538,7 @@ def _failure(exc: BaseException, timeout_ms: int) -> AppException | None: return AppException(503, f"LLM provider unreachable - {exc}") if isinstance(exc, TypeError) and CREDENTIAL_TYPE_ERROR in str(exc): return AppException(503, MISSING_CREDENTIAL) - if (isinstance(exc, RuntimeError) and AWS_CREDENTIAL_RUNTIME_ERROR in str(exc)) or type( - exc - ).__name__ == "NoCredentialsError": + if _aws_credential_error(exc): return AppException(503, MISSING_AWS_CREDENTIAL) return None diff --git a/tests/test_llm_helper.py b/tests/test_llm_helper.py index 2559ec4..f4c729a 100644 --- a/tests/test_llm_helper.py +++ b/tests/test_llm_helper.py @@ -637,10 +637,15 @@ def test_a_missing_aws_credential_at_call_time_is_a_503_by_name() -> None: the chain is empty (botocore its NoCredentialsError); both map to the portable 503.""" from botocore.exceptions import NoCredentialsError - failure = helper._failure(RuntimeError("could not resolve credentials from session"), 25) + # the message the SDK's Bedrock client raises, seen live in the drive of 2026-10-14 + failure = helper._failure(RuntimeError("Could not resolve AWS credentials from session"), 25) assert failure is not None assert failure.status == 503 assert "AWS credential missing" in failure.message + # the legacy client's wording, without "AWS" + failure = helper._failure(RuntimeError("could not resolve credentials from session"), 25) + assert failure is not None + assert failure.status == 503 failure = helper._failure(NoCredentialsError(), 25) assert failure is not None assert failure.status == 503 From b735b9e01aa0b9d401181d794cc9ed0e68b8b342 Mon Sep 17 00:00:00 2001 From: Eric Law <39393654+acn-ericlaw@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:21:34 -0700 Subject: [PATCH 2/2] feat(llm-helper): the classic Bedrock runtime as a second surface (llm.aws.api: mantle | runtime), and the live drive's record An account may be enabled for one Bedrock surface and not the other: the drive of 2026-10-14 found an enterprise account refusing every Claude model on the Anthropic-shaped endpoint while its classic runtime served Haiku 4.5. llm.aws.api selects mantle (the default, the SDK's AsyncAnthropicBedrockMantle with the anthropic. prefix for a bare id) or runtime (AsyncAnthropicBedrock, every model id sent as written - an inference profile id as the account lists it); llm.health names the surface; no region on either surface is the 503 by name; a bad value is a 500 by name. Two shared vectors (68) pin the runtime surface's id rule in both packs; the README, the configuration and the CHANGELOG document the two surfaces and the sign that an account lacks the default one. This pack's report gains the live drive: negative controls first, then chat, a structured verdict and a progressive stream on Haiku 4.5, and the Java engine's shipped triage graph and streaming relay in front of the helper. --- CHANGELOG.md | 10 +- docs/test-reports/llm-helper-certification.md | 32 +++++ examples/llm-helper/README.md | 32 ++++- examples/llm-helper/llm_helper.py | 58 ++++++--- examples/llm-helper/resources/application.yml | 3 + tests/test_llm_helper.py | 55 ++++++++- tests/vectors/llm-helper-vectors.json | 113 +++++++++++++++++- 7 files changed, 274 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e329d3..233dd1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,14 @@ prefix and an id already spelled for Bedrock as written; server-side refusal fallbacks are never sent there (Bedrock offers none); a call whose credential chain is empty is a `503 AWS credential missing` by name. `llm.health` reports `region` beside `backend` and `model`, and its check resolves the credential chain the way a call would, without spending a token. - The routes and the contract are unchanged: the shared vector file gains three Bedrock cases (66), byte-identical with the - Node pack, and the Bedrock client, its region and credential checks are pinned by unit tests. **Upgrade note:** none - the + Bedrock serves Claude on two surfaces and an account may be enabled for one only: `llm.aws.api` selects `mantle` (the + default, the Anthropic-shaped endpoint with the prefixed ids) or `runtime` (the classic one, every model id sent as written + - an inference profile id as the account lists it), and `llm.health` names the surface. Certified live on 2026-10-14 against + an enterprise account, negative controls first (a wrong secret a 401 by name, no credential a 503 by name), then a chat + reply, a structured verdict and a progressive stream on Haiku 4.5 through the runtime surface, from both packs and through + the Java engine's shipped triage graph and streaming relay (the engines' `docs/test-reports/llm-helper-certification.md`, Scenario 8, and this pack's own report). + The routes and the contract are unchanged: the shared vector file gains five Bedrock cases (68), byte-identical with the + Node pack, and the Bedrock clients, their region and credential checks are pinned by unit tests. **Upgrade note:** none - the Claude API backend is the default and behaves as before; `llm.backend` now also accepts `bedrock`. ## Version 4.12.21, 10/7/2026 diff --git a/docs/test-reports/llm-helper-certification.md b/docs/test-reports/llm-helper-certification.md index e0c4fde..0823dc8 100644 --- a/docs/test-reports/llm-helper-certification.md +++ b/docs/test-reports/llm-helper-certification.md @@ -47,6 +47,38 @@ nothing is gathered or sent once. The long gaps are the API's own pacing, the sa no credential gave 503 through Java on all three layers and through Rust on the graph, and an invalid key gave Anthropic's 401 with its request id when this pack's client called the helper directly. The same message came from both helpers, through every layer. All traces that touched this helper rebuild as one connected tree. +## Verified live on AWS Bedrock (2026-10-14) + +The helper's second backend (`llm.backend: 'bedrock'`), driven against an enterprise AWS account in `us-east-1` with temporary IAM +credentials - a key pair and a session token - in the helper's environment and no vendor key anywhere. Negative controls first. + +| Step | Result | +|---|---| +| A corrupted secret | `401 LLM provider error - 401 authentication_error: The request signature we calculated does not match ...` | +| No credential at all | health `503 AWS credential missing`; the call a bare 500 until this pack mapped the SDK's wording (below) | +| `llm.chat`, Haiku 4.5 | `200`, `ready`, 14 input / 4 output tokens | +| `llm.chat` with a schema | `{"verdict": "bug", "confidence": 0.95}`, 218 / 17 | +| `llm.stream` | 4 batches, the first after 815 ms, `eof` with usage 20 / 23 | +| Java engine, the shipped `support-triage` graph | `bug` → `bug-filed`, 240 / 29 | +| Java engine, the streaming relay | 4 `data` frames, then `done` with usage, the trace id and the correlation id | + +Every call ran on `us.anthropic.claude-haiku-4-5-20251001-v1:0` through the classic runtime surface (`llm.aws.api: 'runtime'`): the +account was enabled for that surface and not for the Anthropic-shaped one the SDK prefers, and for Haiku 4.5 and not for the newer +model generation. The full account of the surfaces and the gates is Finding 7 of the +[engine report](https://accenture.github.io/mercury-composable/test-reports/llm-helper-certification/). + +## What the Python SDK needed on Bedrock + +- **The Bedrock clients live at `anthropic.lib.bedrock`** (`AsyncAnthropicBedrockMantle`, `AsyncAnthropicBedrock`); the top level + re-exports them through a star import that the type checker reads as private, so the helper imports the package path. +- **An empty credential chain is a bare `RuntimeError`** raised while signing - `Could not resolve AWS credentials from session` + from the Mantle client, `could not resolve credentials from session` from the classic one - so the helper matches the words both + wordings carry, without case, and maps it to `503 AWS credential missing`. The health check resolves a boto3 session's + credentials the way a call would, so a probe tells a missing credential from a present one without spending a token. +- **No region is a construction error**, an `AnthropicError` from the Mantle client and a `ValueError` from the classic one; both + are the helper's `503 AWS region missing`, raised before any call. +- **The reply carries no request id:** Bedrock sends no `request-id` header the helper reads. + ## What the Python SDK needed - **No credential is not an API error.** `anthropic` 1.x raises a bare `TypeError` ("Could not resolve authentication method…") diff --git a/examples/llm-helper/README.md b/examples/llm-helper/README.md index 7bf2f35..655284b 100644 --- a/examples/llm-helper/README.md +++ b/examples/llm-helper/README.md @@ -32,6 +32,15 @@ export AWS_REGION=us-east-1 # or llm.aws.region; the AWS credenti mercury-serve examples/llm-helper/llm_helper.py -Dllm.backend=bedrock ``` +If the account is enabled for Bedrock's classic runtime and not for its Anthropic-shaped endpoint (the default +surface answers `403 ... not available for this account` on a model the console's playground runs), switch the +surface and name the model as an inference profile id, exactly as the account lists it: + +```bash +mercury-serve examples/llm-helper/llm_helper.py -Dllm.backend=bedrock -Dllm.aws.api=runtime \ + -Dllm.model=us.anthropic.claude-haiku-4-5-20251001-v1:0 +``` + The app listens on port 8086 (`rest.server.port` in `resources/application.yml`). Map its routes from the engine's `event-over-http.yaml`, as the Java and Rust MiniGraph Playgrounds do: @@ -54,7 +63,7 @@ token batches as they are produced. | `prompt` or `messages` | A single user turn, or conversation turns `[{role: user\|assistant, content}]`. `messages` wins when both are given. Content is plain text. | | `system` | Optional system prompt (text). | | `schema` | `llm.chat` only. A JSON schema; the reply is then structured output, returned parsed as `data`. `additionalProperties` defaults to `false`. A schema on `llm.stream` is a 400. | -| `params.model` | The model. Default `claude-opus-5-5` (`llm.model`). On Bedrock a bare id is sent with the `anthropic.` prefix; an id already spelled for Bedrock is sent as written. | +| `params.model` | The model. Default `claude-opus-5-5` (`llm.model`). On Bedrock's default surface a bare id is sent with the `anthropic.` prefix and an id already spelled for Bedrock as written; on the classic runtime surface every id is sent as written, an inference profile id as the account lists it. | | `params.max_tokens` | Output cap. Default 16000 (`llm.max.tokens`). | | `params.timeout_ms` | `llm.chat`: a deadline for the whole call, SDK retries included. `llm.stream`: the idle allowance between events. Default 60000 (`llm.timeout.ms`). | | `params.effort` | `low`, `medium`, `high`, `xhigh` or `max`. Sent only when set; otherwise the model's own default applies. A model that takes no effort parameter answers 400. | @@ -121,6 +130,7 @@ the 422 above, not an empty stream. Raise `params.max_tokens` or lower `params.e |---|---|---| | `llm.backend` | `anthropic` | The route to the models: `anthropic` (the Claude API) or `bedrock` (AWS Bedrock); see Backends. | | `llm.aws.region` | unset | The AWS region a Bedrock call is signed for. Unset, the SDK reads `AWS_REGION` / `AWS_DEFAULT_REGION`. | +| `llm.aws.api` | `mantle` | The Bedrock surface: `mantle`, the Anthropic-shaped endpoint, or `runtime`, the classic one; see Backends. | | `llm.model` | `claude-opus-5-5` | The default model. | | `llm.max.tokens` | `16000` | The default output cap. | | `llm.timeout.ms` | `60000` | The default deadline (chat) or idle allowance (stream). | @@ -166,7 +176,7 @@ contract and the vector file are the same on every backend. | `llm.backend` | Client | Credential | Model id | Server-side fallbacks | |---|---|---|---|---| | `anthropic` (default) | the Claude API, `AsyncAnthropic` | `ANTHROPIC_API_KEY`, or whatever the SDK resolves | as written | on the models that support them | -| `bedrock` | AWS Bedrock, the SDK's `AsyncAnthropicBedrockMantle` | the AWS credential chain through IAM (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, a profile, a role), or a Bedrock API key in `AWS_BEARER_TOKEN_BEDROCK` | a bare id gets the `anthropic.` prefix; an id already spelled for Bedrock is sent as written | none - the request never carries them | +| `bedrock` | AWS Bedrock, the SDK's `AsyncAnthropicBedrockMantle` (`llm.aws.api: mantle`) or `AsyncAnthropicBedrock` (`runtime`) | the AWS credential chain through IAM (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, a profile, a role), or a Bedrock API key in `AWS_BEARER_TOKEN_BEDROCK` | `mantle`: a bare id gets the `anthropic.` prefix, an id already spelled for Bedrock is sent as written; `runtime`: every id as written | none - the request never carries them | **AWS Bedrock.** Install the extra (`pip install 'mercury-composable[llm,bedrock]'` - it adds boto3, which signs each call), set `llm.backend: 'bedrock'` and a region (`llm.aws.region`, or `AWS_REGION` @@ -180,6 +190,16 @@ Effort, structured output and streaming work as on the Claude API. The SDK's cli fallback middleware is not wired in; a refusal on Bedrock is the 422 or the `stop_details` of the contract, like a refusal on the Claude API with `llm.fallbacks: off`. +**Two surfaces.** Bedrock serves Claude on two endpoints, and an account may be enabled for one and not the +other. `llm.aws.api: 'mantle'` (the default) is the Anthropic-shaped endpoint the SDK prefers, which takes the +vendor-prefixed ids above. `llm.aws.api: 'runtime'` is the classic Bedrock runtime, which takes an inference profile +id exactly as the account lists it (`us.anthropic.claude-haiku-4-5-20251001-v1:0` - they carry dates and versions no +rule can derive, so the helper sends every id as written there). The sign that an account lacks the default surface +is a `403 ... is not available for this account` on a model the console's playground runs: switch the surface and +name the model as a profile id. The same message on both surfaces, with the model agreement in place, is an account +enablement for that model generation - a conversation with the AWS account team, not a setting. `llm.health` reports +`api` beside `backend`, `model` and `region`. Bedrock sends no `request_id` the helper reads, so a reply carries none. + ## What it does not do No tools or function calling, no images or documents, no `system` turns inside `messages`, no @@ -189,10 +209,10 @@ deliberate: the helper is the bounded AI node of a graph, not an agent runtime. ## Tests `tests/test_llm_helper.py` runs the shared `tests/vectors/llm-helper-vectors.json` against a fake of -the SDK (66 cases: request validation, the exact SDK call, replies, the error contract, streaming, -the shape of a Bedrock call), plus the tests a vector cannot express (token batches are never held -back, no prompt text in a log, the deadline cancels the call, trace annotations, health, the backend -seam, the Bedrock client with its region and credential checks). No token is spent and no credential +the SDK (68 cases: request validation, the exact SDK call, replies, the error contract, streaming, +the shape of a Bedrock call on either surface), plus the tests a vector cannot express (token batches are +never held back, no prompt text in a log, the deadline cancels the call, trace annotations, health, the +backend seam, the Bedrock clients with their region and credential checks). No token is spent and no credential is needed. ```bash diff --git a/examples/llm-helper/llm_helper.py b/examples/llm-helper/llm_helper.py index e40d1e2..4495d12 100644 --- a/examples/llm-helper/llm_helper.py +++ b/examples/llm-helper/llm_helper.py @@ -11,8 +11,8 @@ The credential comes from the environment, never from a config file: ANTHROPIC_API_KEY for the Claude API, the AWS credential chain (or AWS_BEARER_TOKEN_BEDROCK) for AWS Bedrock. Settings live in resources/application.yml (or -Dkey=value): llm.backend (anthropic or -bedrock), llm.aws.region, llm.model, llm.max.tokens, llm.timeout.ms, llm.max.retries, -llm.fallbacks, llm.effort. The contract, the keys and the backends are documented in +bedrock), llm.aws.api (mantle or runtime), llm.aws.region, llm.model, llm.max.tokens, +llm.timeout.ms, llm.max.retries, llm.fallbacks, llm.effort. The contract, the keys and the backends are documented in README.md next to this file. The Node.js twin (mercury-nodejs examples/llm-helper) speaks the same contract, and both are pinned by one shared vector file. @@ -63,13 +63,19 @@ DEFAULT_PROVIDER = "anthropic" DEFAULT_BACKEND = "anthropic" BEDROCK_BACKEND = "bedrock" +# the two Bedrock surfaces: the Anthropic-shaped endpoint (the default) and the classic runtime +AWS_API_MANTLE = "mantle" +AWS_API_RUNTIME = "runtime" +AWS_APIS = (AWS_API_MANTLE, AWS_API_RUNTIME) DEFAULT_MODEL = "claude-opus-5-5" DEFAULT_MAX_TOKENS = 16000 DEFAULT_TIMEOUT_MS = 60000 DEFAULT_MAX_RETRIES = 2 PROVIDERS = (DEFAULT_PROVIDER,) BACKENDS = (DEFAULT_BACKEND, BEDROCK_BACKEND) -# Bedrock spells a model id with the vendor prefix; a bare id gets it, a prefixed one is kept +# the Anthropic-shaped surface spells a model id with the vendor prefix; a bare id gets it, a +# prefixed one is kept. The classic runtime takes an inference profile id exactly as the account +# lists it (they carry dates and versions no rule can derive), so there the id is sent as written BEDROCK_MODEL_PREFIX = "anthropic." EFFORTS = ("low", "medium", "high", "xhigh", "max") ROLES = ("user", "assistant") @@ -267,8 +273,9 @@ def _same_model(model: str) -> str: def _bedrock_model(model: str) -> str: - """A bare model id gets the prefix Bedrock expects (``anthropic.claude-opus-5-5``); an id - already spelled for Bedrock - prefixed, or an inference profile - is sent as written.""" + """The Anthropic-shaped surface: a bare model id gets the prefix it expects + (``anthropic.claude-opus-5-5``); an id already spelled for Bedrock is sent as written. The + classic runtime surface sends every id as written (``_same_model``).""" return BEDROCK_MODEL_PREFIX + model if model.startswith("claude-") else model @@ -311,21 +318,24 @@ class Backend: supports_fallbacks: bool provider_model: Callable[[str], str] = _same_model credentials: Callable[[Any], str | None] = _anthropic_credentials + api: str | None = None # the Bedrock surface in use, reported by llm.health def credential_problem(self) -> str | None: """What is missing for a call to be sent, or None. No token is spent.""" return self.credentials(self.client) -def backend_for(name: str, client: Any) -> Backend: - """The Backend of a route over a client: get_backend() builds the client, tests inject one.""" +def backend_for(name: str, client: Any, aws_api: str = AWS_API_MANTLE) -> Backend: + """The Backend of a route over a client: get_backend() builds the client, tests inject one. + For Bedrock, aws_api names the surface, which decides how a model id is spelled.""" if name == BEDROCK_BACKEND: return Backend( BEDROCK_BACKEND, client, supports_fallbacks=False, # server-side refusal fallbacks are not offered on Bedrock - provider_model=_bedrock_model, + provider_model=_bedrock_model if aws_api == AWS_API_MANTLE else _same_model, credentials=_bedrock_credentials, + api=aws_api, ) return Backend(DEFAULT_BACKEND, client, supports_fallbacks=True) @@ -333,18 +343,29 @@ def backend_for(name: str, client: Any) -> Backend: _backend: Backend | None = None # built on first use - module-level so tests can inject -def _bedrock_client() -> Any: - """AWS Bedrock through the SDK's Mantle client: the AWS credential chain (or a Bedrock API - key) signs each call, and the region comes from llm.aws.region or the AWS environment.""" +def _aws_api() -> str: + """The Bedrock surface a call goes to: the Anthropic-shaped endpoint (mantle, the default) + or the classic runtime - an account may be enabled for one and not the other.""" + api = str(_prop("llm.aws.api", AWS_API_MANTLE) or AWS_API_MANTLE).lower() + if api not in AWS_APIS: + raise AppException(500, f"llm.aws.api must be one of: {', '.join(AWS_APIS)}") + return api + + +def _bedrock_client(aws_api: str) -> Any: + """AWS Bedrock through the SDK's client for the chosen surface: the AWS credential chain (or + a Bedrock API key) signs each call, and the region comes from llm.aws.region or the AWS + environment.""" if importlib.util.find_spec("boto3") is None: raise AppException(503, MISSING_BEDROCK_SDK) - # the SDK reaches its Bedrock client lazily at the top level; the package path is the typed one - from anthropic.lib.bedrock import AsyncAnthropicBedrockMantle + # the SDK reaches its Bedrock clients lazily at the top level; the package path is the typed one + from anthropic.lib.bedrock import AsyncAnthropicBedrock, AsyncAnthropicBedrockMantle + factory = AsyncAnthropicBedrockMantle if aws_api == AWS_API_MANTLE else AsyncAnthropicBedrock region = _prop("llm.aws.region") try: - return AsyncAnthropicBedrockMantle(aws_region=str(region) if region else None) - except anthropic.AnthropicError as exc: # no region, and no base URL to stand in for one + return factory(aws_region=str(region) if region else None) + except (anthropic.AnthropicError, ValueError) as exc: # no region, and no base URL for one raise AppException(503, MISSING_AWS_REGION) from exc @@ -358,8 +379,9 @@ def get_backend() -> Backend: 501, f"unknown LLM backend '{name}' - this helper serves: {', '.join(BACKENDS)}" ) # credentials resolve per call, so the app starts (and reports its health) without one - client = _bedrock_client() if name == BEDROCK_BACKEND else anthropic.AsyncAnthropic() - backend = backend_for(name, client) + aws_api = _aws_api() if name == BEDROCK_BACKEND else AWS_API_MANTLE + client = _bedrock_client(aws_api) if name == BEDROCK_BACKEND else anthropic.AsyncAnthropic() + backend = backend_for(name, client, aws_api) _backend = backend return backend @@ -702,6 +724,8 @@ async def llm_health(headers: dict[str, str], _body: Body) -> Any: region = getattr(backend.client, "aws_region", None) if region: info["region"] = region # which region a Bedrock call is signed for + if backend.api: + info["api"] = backend.api # which Bedrock surface answers return info problem = backend.credential_problem() if problem: diff --git a/examples/llm-helper/resources/application.yml b/examples/llm-helper/resources/application.yml index f4ffbd4..16fc8c7 100644 --- a/examples/llm-helper/resources/application.yml +++ b/examples/llm-helper/resources/application.yml @@ -29,6 +29,9 @@ show.application.properties: 'application.name, rest.server.port, llm.backend, l llm.backend: 'anthropic' # the region a Bedrock call is signed for; unset, the SDK reads AWS_REGION / AWS_DEFAULT_REGION # llm.aws.region: 'us-east-1' +# the Bedrock surface: mantle (the Anthropic-shaped endpoint, default) | runtime (the classic one, +# which takes an inference profile id as the account lists it - set llm.model to that id) +# llm.aws.api: 'mantle' llm.model: 'claude-opus-5-5' llm.max.tokens: 16000 # llm.chat: the whole call, SDK retries included. llm.stream: the idle allowance between events. diff --git a/tests/test_llm_helper.py b/tests/test_llm_helper.py index f4c729a..eb954b4 100644 --- a/tests/test_llm_helper.py +++ b/tests/test_llm_helper.py @@ -24,7 +24,7 @@ import anthropic import httpx2 import pytest -from anthropic.lib.bedrock import AsyncAnthropicBedrockMantle +from anthropic.lib.bedrock import AsyncAnthropicBedrock, AsyncAnthropicBedrockMantle from mercury_composable import AppException @@ -32,7 +32,7 @@ _HELPER = _TESTS.parent / "examples" / "llm-helper" / "llm_helper.py" _VECTORS = _TESTS / "vectors" / "llm-helper-vectors.json" # the Node.js twin pins the same digest: change the file in both packs or in neither -_VECTORS_SHA256 = "1b758e73605b2b088594faa8e573483778d7b85f29073d4779c3bba20ac024c1" +_VECTORS_SHA256 = "7bfffbf36c52488c246f250f36afc4e8012f119fd56c93e9de1cbe93bf5988b6" _spec = importlib.util.spec_from_file_location("llm_helper_under_test", _HELPER) assert _spec is not None @@ -200,7 +200,11 @@ def _install_backend( settings = dict(config or {}) monkeypatch.setattr(helper, "_prop", lambda key, default=None: settings.get(key, default)) # the fake client under the shape of the backend the case names (the Claude API by default) - backend = helper.backend_for(settings.get("llm.backend", helper.DEFAULT_BACKEND), client) + backend = helper.backend_for( + settings.get("llm.backend", helper.DEFAULT_BACKEND), + client, + settings.get("llm.aws.api", helper.AWS_API_MANTLE), + ) monkeypatch.setattr(helper, "_backend", backend) @@ -575,9 +579,54 @@ def test_the_bedrock_backend_is_the_sdk_mantle_client(monkeypatch: pytest.Monkey assert backend.supports_fallbacks is False assert backend.provider_model("claude-opus-5-5") == "anthropic.claude-opus-5-5" assert backend.provider_model("us.anthropic.claude-opus-5-5") == "us.anthropic.claude-opus-5-5" + assert backend.api == "mantle" assert helper.get_backend() is backend +def test_the_bedrock_backend_takes_the_classic_runtime_surface( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """llm.aws.api: runtime - the SDK's classic client, every model id sent as written (an + inference profile id as the account lists it), no server-side fallbacks; found necessary in + the live drive of 2026-10-14, where the account was enabled for the runtime and not for the + Anthropic-shaped surface.""" + _bedrock_settings(monkeypatch, **{"llm.aws.region": "us-east-1", "llm.aws.api": "runtime"}) + backend = helper.get_backend() + assert isinstance(backend.client, AsyncAnthropicBedrock) + assert backend.client.aws_region == "us-east-1" + assert backend.api == "runtime" + assert backend.supports_fallbacks is False + profile = "us.anthropic.claude-haiku-4-5-20251001-v1:0" + assert backend.provider_model(profile) == profile + assert backend.provider_model("claude-opus-5-5") == "claude-opus-5-5" + + +async def test_bedrock_health_names_the_surface(monkeypatch: pytest.MonkeyPatch) -> None: + _bedrock_settings(monkeypatch, **{"llm.aws.region": "us-east-1", "llm.aws.api": "runtime"}) + info = await helper.llm_health({"type": "info"}, None) + assert info["backend"] == "bedrock" + assert info["api"] == "runtime" + assert info["region"] == "us-east-1" + + +def test_the_runtime_surface_without_a_region_is_a_503_that_names_the_setting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _bedrock_settings(monkeypatch, **{"llm.aws.api": "runtime"}) + with pytest.raises(AppException) as raised: + helper.get_backend() + assert raised.value.status == 503 + assert "AWS region missing" in raised.value.message + + +def test_a_bad_aws_api_value_is_a_500_by_name(monkeypatch: pytest.MonkeyPatch) -> None: + _bedrock_settings(monkeypatch, **{"llm.aws.region": "us-east-1", "llm.aws.api": "console"}) + with pytest.raises(AppException) as raised: + helper.get_backend() + assert raised.value.status == 500 + assert "llm.aws.api must be one of: mantle, runtime" in raised.value.message + + def test_the_bedrock_backend_reads_the_region_from_the_aws_environment( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/vectors/llm-helper-vectors.json b/tests/vectors/llm-helper-vectors.json index 49bfba6..47fc4ad 100644 --- a/tests/vectors/llm-helper-vectors.json +++ b/tests/vectors/llm-helper-vectors.json @@ -1,7 +1,7 @@ { "contract": "llm-helper", "version": 1, - "description": "One contract, two function hosts. mercury-python and mercury-nodejs ship this file byte-identical and run every case against their own fake of the Anthropic SDK, so the two helpers cannot drift apart. A case's config may name llm.backend, and the harness then installs its fake under that backend's shape (bedrock: the anthropic. model prefix for a bare id, no server-side fallbacks, the messages namespace). A case names the route, the config keys in force, the request body and what the fake provider does; the expectation is the exact SDK call (namespace, parameters, options), then either the reply map or the error (status and message fragments) for llm.chat, or the head, the segments in order, the terminal metadata (a subset) or the in-band error for llm.stream.", + "description": "One contract, two function hosts. mercury-python and mercury-nodejs ship this file byte-identical and run every case against their own fake of the Anthropic SDK, so the two helpers cannot drift apart. A case's config may name llm.backend, and the harness then installs its fake under that backend's shape (bedrock: no server-side fallbacks, the messages namespace, and the model id by surface - llm.aws.api mantle, the default, prefixes a bare id with anthropic.; runtime sends every id as written). A case names the route, the config keys in force, the request body and what the fake provider does; the expectation is the exact SDK call (namespace, parameters, options), then either the reply map or the error (status and message fragments) for llm.chat, or the head, the segments in order, the terminal metadata (a subset) or the in-band error for llm.stream.", "provider_kinds": { "message": "succeeds: deltas are the token batches; text is the final message text (default: the deltas joined); usage, model, stop_reason, request_id (the request-id response header), stop_details", "status": "an HTTP error from the API: status, type, message, request_id; after = the number of deltas delivered before it is raised (0: at open), deltas = those batches", @@ -2355,6 +2355,117 @@ "trace_id": null } } + }, + { + "name": "chat/bedrock-runtime-sends-an-inference-profile-as-written", + "route": "llm.chat", + "config": { + "llm.backend": "bedrock", + "llm.aws.api": "runtime" + }, + "body": { + "prompt": "x", + "params": { + "model": "us.anthropic.claude-haiku-4-5-20251001-v1:0" + } + }, + "provider": { + "kind": "message", + "model": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "stop_reason": "end_turn", + "deltas": [ + "ok" + ], + "usage": { + "input_tokens": 5, + "output_tokens": 1 + }, + "request_id": "req_ok_001" + }, + "expect": { + "sdk": { + "namespace": "messages", + "params": { + "model": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "max_tokens": 16000, + "messages": [ + { + "role": "user", + "content": "x" + } + ] + }, + "options": { + "timeout_ms": 60000, + "max_retries": 2 + } + }, + "result": { + "text": "ok", + "model": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 5, + "output_tokens": 1 + }, + "request_id": "req_ok_001" + } + }, + "note": "the classic runtime surface takes an inference profile id exactly as the account lists it" + }, + { + "name": "chat/bedrock-runtime-keeps-a-bare-model-as-written", + "route": "llm.chat", + "config": { + "llm.backend": "bedrock", + "llm.aws.api": "runtime" + }, + "body": { + "prompt": "x" + }, + "provider": { + "kind": "message", + "model": "claude-opus-5-5", + "stop_reason": "end_turn", + "deltas": [ + "ok" + ], + "usage": { + "input_tokens": 5, + "output_tokens": 1 + }, + "request_id": "req_ok_001" + }, + "expect": { + "sdk": { + "namespace": "messages", + "params": { + "model": "claude-opus-5-5", + "max_tokens": 16000, + "messages": [ + { + "role": "user", + "content": "x" + } + ] + }, + "options": { + "timeout_ms": 60000, + "max_retries": 2 + } + }, + "result": { + "text": "ok", + "model": "claude-opus-5-5", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 5, + "output_tokens": 1 + }, + "request_id": "req_ok_001" + } + }, + "note": "no prefix rule on the runtime surface - the ids there carry dates and versions no rule can derive" } ] }