Skip to content

BAI-299 Implement native AWS Bedrock Converse transport - #107

Open
imranq2 wants to merge 1023 commits into
imranq2:mainfrom
icanbwell:IQ-BAI-299
Open

BAI-299 Implement native AWS Bedrock Converse transport#107
imranq2 wants to merge 1023 commits into
imranq2:mainfrom
icanbwell:IQ-BAI-299

Conversation

@imranq2

@imranq2 imranq2 commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

Implements native AWS Bedrock Converse API transport as a fallback mechanism when the standard bedrock-runtime API is unavailable. This allows the gateway to route requests directly to Bedrock's Converse API endpoint, bypassing the Mantle layer.

Changes

New Features

  • Native Bedrock Transport: Added bedrock_native_dispatcher.py with full support for both streaming and non-streaming Converse API calls
  • Transport Selection Toggle: MODEL_ROUTING_BEDROCK_TRANSPORT environment variable enables native transport (defaults to off; switches to on in bd49c78)
  • Usage Tracking: Enhanced usage_tracker.py to properly track cache and input/output token usage for all Converse API routes

Code Structure

  • New Modules:
    • bedrock_native_dispatcher.py - Core native transport dispatcher (408 lines)
    • bedrock_converse_client.py - Async wrapper for Bedrock Converse API (77 lines)
    • converse_request_translator.py - OpenAI-to-Converse request conversion (249 lines)
    • converse_stream_adapter.py - SSE stream adapter for streaming responses (383 lines)
  • Refactored: bedrock_client.py - Extracted shared client caching logic

Bug Fixes

  • Fixes tool_choice="none" silently enabling tool use (6306437)
  • Corrects APIError misclassification in error handling (0542fe1)
  • Captures full response headers in model-router-errors (e2f1cf5, 29ebbab)
  • Fixed race condition in bedrock-runtime client cache (7683427)
  • Mid-stream error visibility for native transport (b4a71c4)

Tests

  • Added 21 test files with 2,300+ test cases covering:
    • Converse request translation
    • Streaming adapter and SSE conversion
    • Usage tracking accuracy
    • Concurrency and thread safety
    • Error handling and credential scenarios
  • Enhanced test_coding_model_router.py with native transport scenarios

Testing Plan

  • Verify native transport fallback when Mantle unavailable
  • Confirm usage tracking accuracy for cache tokens
  • Test streaming with large responses
  • Validate error scenarios (credential errors, throttling)

Related

  • Implementation Plan: docs/superpowers/plans/2026-07-13-native-bedrock-transport.md
  • Design Spec: docs/superpowers/specs/2026-07-13-native-bedrock-transport-design.md
  • Branch: IQ-BAI-299

imranq2 and others added 30 commits April 15, 2026 15:06
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…s for improved consistency and maintainability
…ls-framework, language-model-common, and langgraph-prebuilt
…update uv.lock for dependency version upgrades
…del-common to versions 1.0.54 and 2.0.25 respectively
imranq2 added 30 commits July 12, 2026 20:48
…ough usage, and upstream errors

Adds start_time/end_time/duration_ms to usage records; upserts a
per-session rollup (model-router-sessions) bucketed by tier with
independent MODEL_ROUTING_USAGE_SESSION_TRACKING_ENABLED toggle; extends
usage tracking to the api_type:anthropic passthrough route (previously
uncovered) by buffering non-streaming responses and sniffing token usage
out of streaming SSE bytes without altering them; and adds an ErrorTracker
recording upstream failures to model-router-errors.
Fix usage-tracking IDOR (spoofable auth headers) and switch JSON logging to structlog
…r-errors

openai.APIError (raised by the SDK for SSE data events with no HTTP status
code, e.g. Bedrock Mantle sending {"error": {...}} mid-stream) was falling
through to the generic exception handler, which only recorded str(exc) - a
generic "server had an error" message with no diagnostic value. Add a
dedicated except branch that captures the SDK's .body/.code/.type/.param
in model-router-errors, and surface the same detail to the client instead
of just the generic passthrough message.
- Retry transient mid-stream Bedrock errors (ModelStreamErrorException,
  ThrottlingException, etc.) instead of skipping the retry loop entirely
  just because they carry no HTTP status code.
- Record errors on the Anthropic-passthrough dispatch path, which
  previously re-raised connection/timeout failures with zero trace in
  model-router-errors.
- Retry transport-level failures (httpx.TransportError) in
  _send_with_bedrock_retry with the same backoff+jitter as HTTP throttling,
  instead of failing outright on a momentary network blip.
- Distinguish AWS credential failures (no profile configured, STS/SSO
  ClientError) from TokenRetrievalError instead of lumping them into a
  generic, unhelpful error_type with no actionable message.
- Record mid-stream SDK failures (after streaming has already started)
  to model-router-errors via a new on_stream_error hook, instead of only
  ever showing them inline to the client.
…on, extract credential-error helper

APIConnectionError/APITimeoutError/APIResponseValidationError are subclasses
of openai.APIError but carry no code/type/param/body, so they were being
misrecorded as bedrock_stream_error with a null-filled body instead of their
real exception type. Fixed by adding an exact-type guard and extracting the
shared classify/record/re-raise logic into _handle_unexpected_upstream_error,
since a bare raise from the APIError except clause does not fall through to
the sibling except Exception clause.

Also moves _bedrock_credential_error_detail into aws_auth.py, alongside the
BedrockCredentialsUnavailableError it already depends on, addressing the
router.py file-size concern raised on the PR without a full-file refactor.
Documents the plan to let BEDROCK_TRANSPORT=native route haiku/sonnet
traffic through Bedrock's Converse API instead of Bedrock Mantle, as a
manual operator toggle during Mantle incidents.
Route the new toggle through LanguageModelGatewayEnvironmentVariables
(model_routing_bedrock_transport) and thread it into CodingModelRouter
via constructor injection from api.py, matching how every other
model_routing_* setting already flows in — instead of a bare
module-level os.environ.get() inside the model_routing package.
…errors

ErrorTracker.record_error and CodingModelRouter._record_error now accept
response_headers, populated from exc.response.headers for HTTP-status
Bedrock Mantle errors and from the initial stream handshake response for
mid-stream SSE-embedded errors (which carry no response of their own).
Bedrock Mantle's error bodies are often too generic to act on alone (e.g.
a bare "internal_server_error" with no further detail); the AWS request
ID and other response headers give something to correlate against
AWS-side logs.
…tream error wrapping

Rework the >=400 error-response handling in the Anthropic-passthrough
branch: parse the upstream error body once, after fallback-route
annotation, instead of twice with two different criteria. Previously
clean_error was computed from the pre-annotation body, so whenever the
annotated body failed the second JSON check the response silently fell
back to the stale, un-annotated message — dropping the fallback-route
note in exactly the case it exists to surface. Also tighten the
passthrough check to require a usable error shape (not just an "error"
key), so malformed bodies like {"error": null} get normalized instead
of forwarded verbatim, and stop leaking Python dict-repr syntax into
client-facing error text.

Adds test coverage for all three fixed scenarios, which previously had
none.
Task-by-task plan for the approved design spec: the
model_routing_bedrock_transport toggle, bedrock_converse_client.py
(request/response conversion, boto3 client cache, streaming adapter),
and router.py dispatch wiring with error handling. Notes one deviation
from the spec found while grounding the plan in the actual code: the
Converse request is built from the already-OpenAI-shaped,
already-budget-enforced body, not the raw Anthropic request, since
context-budget enforcement runs upstream of the dispatch point for every
api_type=="openai" route.
Adds model_routing_bedrock_transport (default "mantle") and threads it
from api.py through CodingModelRouter's constructor as
self._bedrock_transport. No dispatch behavior changes yet — the flag
does nothing until later tasks add the native-Bedrock code path that
reads it.
_get_bedrock_runtime_client caches one client per (AWS_PROFILE, region)
pair rather than building one per request. _is_transient_bedrock_error_code
reuses bedrock_client.py's existing Bedrock exception-name taxonomy for
retry decisions on the native path.
_get_bedrock_runtime_client's check-then-set on _CLIENT_CACHE was
unguarded — concurrent calls (this function runs via asyncio.to_thread
from an async request path) racing on the same new (profile, region)
key could each construct their own client, one silently overwriting
the other. Double-checked locking with a threading.Lock ensures only
one client is ever built per key.
The prior version of test_concurrent_calls_for_same_new_key_construct_only_one_client
synchronized thread entry via threading.Barrier but mocked client
construction as near-instant, so the GIL rarely switched between the
two threads' check-then-set sequences — the test passed even against
the pre-fix, unguarded code (verified: reverting the lock and
re-running showed the old test wasn't a reliable regression guard).
Adding a brief sleep inside the mocked construction call widens the
critical section enough that the test now reliably fails without the
lock and passes with it.
_openai_to_converse_request builds converse()/converse_stream() kwargs
from the already-OpenAI-shaped, already-budget-enforced request body
router.py produces today — not from the original Anthropic request, since
context-budget enforcement runs upstream of the native-transport dispatch
point and only understands the OpenAI shape. Covers text, tool_use/
tool_result round-trips, system prompts, and tool_choice mapping; image
content blocks are dropped with a warning rather than supported.
… requests

_openai_to_converse_request had no branch for tool_choice=="none" (a
value message_translator.py's _anthropic_to_openai_request legitimately
produces), so toolConfig.toolChoice was left unset while tools was
still sent — an absent toolChoice behaves like "auto" in Bedrock's
Converse API, silently inverting the caller's explicit "don't use
tools" instruction. Converse has no direct "disable tools" toolChoice
value, so the fix omits toolConfig entirely when tool_choice is "none" —
if the model isn't told about any tools, it can't call one.
_converse_response_to_anthropic mirrors _openai_to_anthropic_response's
contract for the native transport. stopReason values without a direct
Anthropic equivalent (guardrail_intervened, content_filtered) default to
end_turn rather than raising on an unrecognized value.
_iter_converse_stream_events pulls boto3's synchronous EventStream onto
the event loop via asyncio.to_thread, one event per thread hop.
_stream_bedrock_converse_to_anthropic translates Converse stream events
into the same Anthropic SSE sequence _stream_oai_sdk_to_anthropic already
emits for the Mantle path, with the same sinks/on_stream_error contract.
_converse_stream_with_usage_tracking records usage via the generic
UsageTracker.record_usage (not the OpenAI-specific
record_usage_from_openai_response, since Converse's usage keys differ
from OpenAI's) after the stream completes, matching the skip-when-no-
tokens behavior of its OAI counterpart.
Adds the auth=="aws" && bedrock_transport=="native" branch ahead of
the existing openai-SDK Mantle path, and
_dispatch_bedrock_native_nonstreaming to handle it: converse() via
asyncio.to_thread, throttle-retry on transient ClientError codes,
credential-error and non-transient-error recording via the existing
_record_error/_error_response helpers with error_type="bedrock_native_error".
Streaming requests still fall through to a not-yet-implemented method,
completed in the next task.
_dispatch_bedrock_native_streaming mirrors the non-streaming dispatch's
retry/error-recording logic, then hands off to
_converse_stream_with_usage_tracking (or the bare translator when usage
tracking is disabled). Mid-stream failures — after the SSE response has
already started — are recorded the same way router.py's own
_record_mid_stream_error does for the Mantle path, so they reach
model-router-errors instead of only being shown inline to the client.
Sets MODEL_ROUTING_BEDROCK_TRANSPORT=native so haiku/sonnet traffic now
routes through Bedrock's Converse API instead of Bedrock Mantle by
default in this compose file. Flip back to "mantle" (or delete the
line) once no longer needed.
…ng credential-error test

Final whole-branch review found the native Converse streaming path could
never show inline error text to the client on an early mid-stream failure
(the Mantle path does, via a lazy message_start gate this path lacked,
sending message_start eagerly instead) — the exact failure mode this
fallback exists to serve produced a silently empty assistant message.
Added the same inline "[bedrock-native-proxy error]" text injection
Mantle already has, tracking whether any content block has ever opened
(not just currently-open ones). Also added a streaming-dispatch
credential/ClientError test (previously only the non-streaming path had
one), corrected a docker-compose comment that inaccurately implied native
Bedrock access might be provisioned separately from Mantle access (both
share the same AWS credentials, so the "native" default stays), and
updated the native-transport design spec's non-streaming data-flow section
to reference the actual shipped _openai_to_converse_request function
instead of the superseded _anthropic_to_converse_request name.
…verse transport

MCP-prefixed tool names routinely exceed toolSpec.name's 64-character
limit, failing the whole request with a ValidationException. Deterministically
shorten names sent to Bedrock and restore the original name in tool_use
blocks returned to the client.
Addresses aikido-pr-checks' review comments on PR #200: router.py and
the new bedrock_converse_client.py had grown into files mixing several
distinct responsibilities. Extracted _dispatch_bedrock_native_nonstreaming/
_streaming out of CodingModelRouter into a new BedrockNativeDispatcher
class (bedrock_native_dispatcher.py), constructor-injected with the
client provider, a usage-tracker getter, and error/latency-recording
callbacks — mirrors the BaseChatCompletionsProvider adapter pattern
already used for other backends. Split bedrock_converse_client.py into
converse_request_translator.py (OpenAI<->Converse format conversion) and
converse_stream_adapter.py (event-stream adaptation + usage-tracking
wrapper); the remaining file owns only client lifecycle and native
error-code classification. Replaced the module-level _CLIENT_CACHE
dict/lock with an injectable BedrockRuntimeClientProvider instance,
removing the hidden global state.

Also fixes two usage-tracking gaps found while reviewing the native
transport's usage handling:

- Native Bedrock Converse responses (streaming and non-streaming) were
  dropping Bedrock's cacheReadInputTokens/cacheWriteInputTokens instead
  of mapping them onto Anthropic's cache_read_input_tokens/
  cache_creation_input_tokens. Clients that derive context-window usage
  from these fields (e.g. Claude Code, see
  anthropics/claude-code#13385) can't distinguish a missing field from
  "no caching happened," so this silently broke context accounting for
  any client routed through the native transport.
- UsageTracker.record_usage now accepts raw_usage and stores the
  upstream response's usage object verbatim across all three response
  paths (Anthropic passthrough, OpenAI/Mantle, native Bedrock Converse),
  so fields not yet normalized into a top-level column aren't silently
  dropped from model-router-usage.
…ve Bedrock

Extends the anthropics/claude-code#13385 fix to every route, not just the
native Bedrock Converse transport fixed previously:

- OpenAI/Bedrock Mantle (non-streaming): _openai_to_anthropic_response now
  maps usage.prompt_tokens_details.cached_tokens (bedrock-mantle is a
  conformant OpenAI Chat Completions endpoint, per
  docs.aws.amazon.com/bedrock/latest/userguide/inference-chat-completions-mantle.html)
  onto cache_read_input_tokens. cache_creation_input_tokens has no OpenAI
  equivalent (that API doesn't charge separately for cache writes) but is
  still always included as 0 rather than omitted, for the same reason as
  the native-transport fix: a missing field looks identical to a broken
  one to a client computing context usage from it.
- OpenAI/Bedrock Mantle (streaming): _stream_oai_sdk_to_anthropic now
  captures cached_tokens from each chunk's usage and surfaces it in the
  final message_delta event, plus stores the raw CompletionUsage dump for
  Mongo logging. Also fixes a pre-existing bug found while touching this
  code: message_delta's usage.output_tokens read a stale local variable
  that was never updated from usage_sink, so it always reported 0 instead
  of the real completion token count.
- Anthropic passthrough: the client-facing SSE/JSON bytes are already
  relayed verbatim from Anthropic's own API, which already includes these
  fields correctly — no client-facing change needed. Only
  _parse_anthropic_sse_usage's raw_usage capture (for Mongo logging) was
  extended to carry message_start's cache fields forward instead of
  discarding everything but input_tokens/output_tokens.

raw_usage (the full upstream usage object, verbatim) is now recorded to
model-router-usage from every streaming path, matching what non-streaming
already had.
…anslators

Both the native Converse and Mantle/OpenAI streaming translators could
produce a client-facing SSE stream with zero content blocks ever opened,
or with a content_block_delta referencing a block that was never
started — both are invalid Anthropic protocol and break Claude Code's
SSE parser ("Content block not found"), forcing a fallback to
non-streaming that starves the client's context-usage tracking (the
"0% until auto-compact" symptom).

- converse_stream_adapter.py: Bedrock's Converse API skips
  contentBlockStart entirely for qwen.qwen3-coder-next over the native
  transport and goes straight to contentBlockDelta — lazily open the
  block on first delta instead of assuming a prior start. Also
  guarantee at least one (empty) content block when a completion ends
  with none opened and no error (e.g. the model exhausts max_tokens
  entirely within hidden reasoning).
- stream_converter.py (Mantle/OpenAI path): same "guarantee at least
  one content block" fix, plus fix the inline error-visibility guard to
  check `not open_blocks` instead of `not sent_message_start` — the
  latter is already True as soon as the first chunk arrives, so a
  mid-stream error before any visible text was silently dropped instead
  of surfacing as error text.

Added regression tests for both translators.
Records the 2026-07-14 investigation into the "0% until auto-compact"
context-usage bug: symptom, root cause in both streaming translators,
why the router's own logging didn't reveal which transport was
actually live, the fix, and which other response paths were audited
and confirmed already safe.
…ransport ran

MODEL_ROUTING_BEDROCK_TRANSPORT now defaults to "native" instead of
"mantle" — "mantle" becomes the manual opt-out instead of the default.
Mantle's own reliability (bare, undiagnosable internal_server_error
500s with no further diagnostic detail, as seen in a services-env
incident) makes native the better default now that both transports
have been proven out in production. Only the literal value "mantle"
(case-insensitive) opts back in; anything else, including a typo,
resolves to "native" rather than silently landing on the less
reliable transport. CodingModelRouter's own constructor default is
left at "mantle" since api.py always passes bedrock_transport
explicitly from this env var — flipping the constructor default too
would have silently changed which path ~20 existing tests exercise
for no behavioral benefit.

Every model-router-usage/model-router-errors record now also carries
a bedrock_transport field ("native"/"mantle") whenever
backend == "aws_bedrock". auth/api_type alone can't disambiguate this
today — native Converse dispatch is also reached via auth="aws",
api_type="openai" routes — which is exactly what made diagnosing the
services-env incident harder than it needed to be. record_error fills
this in automatically from CodingModelRouter._bedrock_transport (no
call-site changes needed); record_usage's callers pass it explicitly,
hardcoded per translator since each usage-tracking wrapper is
transport-specific by construction (_oai_stream_with_usage_tracking is
always Mantle, _converse_stream_with_usage_tracking and
BedrockNativeDispatcher are always native).

Removed the now-redundant MODEL_ROUTING_BEDROCK_TRANSPORT: native
override from docker-compose.yml (native is the default everywhere
now) and updated docs/coding_model_router.md and the original
native-transport design spec to reflect the new default and field.
…Bedrock dispatch paths

- record_usage/session rollup now capture retry_count/total_retries across
  all six dispatch paths (Mantle + native Converse, streaming +
  non-streaming, plus the Anthropic-format Bedrock/passthrough path).
- Native Bedrock Converse streaming now reports sse_event_count, matching
  Mantle's existing behavior - usage records for native-transport streams
  were silently missing this sanity field.
- Dispatch pacing (_pace_bedrock_dispatch) was only wired into the unused
  Anthropic-format Bedrock route; now also applied to the Mantle and native
  Converse paths that actually serve the haiku/sonnet tiers in production,
  so concurrent requests no longer burst against Bedrock unthrottled.
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.

3 participants