Skip to content

TAS-32: Anthropic Bedrock Bridge#386

Merged
buger merged 7 commits into
mainfrom
TAS-32/anthropic-bedrock-bridge
Jul 22, 2026
Merged

TAS-32: Anthropic Bedrock Bridge#386
buger merged 7 commits into
mainfrom
TAS-32/anthropic-bedrock-bridge

Conversation

@nerdydread

Copy link
Copy Markdown
Contributor

Summary

Adds a native Anthropic Messages API → Amazon Bedrock bridge so Claude Code (and any Anthropic-SDK client) can drive Claude models hosted in Bedrock through AI Studio, with full governance — auth, budget, filters, and analytics — instead of bypassing it via CLAUDE_CODE_USE_BEDROCK.

Jira: TAS-32

Before this change, AI Studio only exposed Bedrock through an OpenAI-compatible endpoint (/ai/{routeId}/v1/chat/completions). Claude Code speaks only the native Anthropic Messages API (POST /v1/messages), so pointing it at a Bedrock LLM didn't work. This adds the missing translation layer.

ANTHROPIC_BASE_URL=https://<ai-studio-host>/anthropic/<llm-slug>
ANTHROPIC_AUTH_TOKEN=<ai-studio-app-key>   # or ANTHROPIC_API_KEY
claude

How it works

POST /anthropic/{routeId}/v1/messages → credential validator (App key) → budget + filters → translate Anthropic Messages ⇄ Bedrock Converse → SigV4-signed call to Bedrock → response translated back → analytics recorded. AWS credentials never leave AI Studio.

It follows the existing translation-layer pattern (sibling of the /ai/ router), not the /llm/ pass-through: a dedicated anthropicRouter outside /llm/ middleware → credValidator → inline model validation → direct Bedrock SDK calls.

What's covered

  • Text, tool-calling (multi-turn tool_use / tool_result), and system prompts — mapped both ways between Anthropic content blocks and Converse content blocks.
  • Streaming — Bedrock Converse events translated into the Anthropic SSE sequence (message_startcontent_block_start/delta/stopmessage_deltamessage_stop), including input_json_delta for streamed tool arguments.
  • Prompt cachingcache_control markers → Converse cachePoint blocks (message / system / tools); cache-token usage mapped back on responses + streaming (cache_creation_input_tokens / cache_read_input_tokens) and into analytics/metrics.
  • Auth — App key via x-api-key or Authorization: Bearer.
  • Model resolution — uses the LLM's default_model (a real Bedrock ID; Claude Code's own model name isn't one), validated against allowed_models.
  • Governance — budget, response filters, and analytics (proxy log + chat record with token/cache-token counts and cost) applied consistently with the /ai/ Bedrock path.

Changes

Area File
Anthropic wire-format structs proxy/anthropic_messages_models.go (new)
Anthropic ⇄ Converse converters vendors/bedrock/anthropic_converse.go (new)
HTTP/SSE orchestration + entry handler proxy/anthropic_bedrock_translator.go (new)
Routing (anthropicRouter, combinedHandler dispatch) proxy/proxy.go
Auth (anthropic path case + x-api-key fallback) proxy/credential_validator.go
Cache-token usage capture across all Bedrock paths proxy/bedrock_translator.go, proxy/bedrock_streaming.go

Also included (bug found during testing):

  • Credential-wipe fix (services/llm_service.go): editing/saving an LLM was persisting the redacted [redacted] placeholder over real metadata credentials (broke Bedrock AWS keys). UpdateLLM now preserves the stored value for any metadata field submitted as [redacted], mirroring the existing api_key handling.
  • UI: an "Anthropic-Compatible Endpoint" card on an app's LLM Access Details, shown only for Bedrock LLMs (ui/admin-frontend/src/portal/components/AppDetailView.js).
image

No changes to the LLM/App configuration schema.

Testing

Automated (go test ./proxy/... ./services/... — green):

  • Converters: text / tool_use / tool_result / system, cache-point insertion, tool_choice, stop-reason mapping, response mapping incl. cache-token usage.
  • Streaming: synthetic Converse events → asserted Anthropic SSE sequence, tool-arg reconstruction from input_json_delta, cache-token usage on message_delta.
  • Auth: x-api-key and Bearer both authenticate on /anthropic/{routeId}/v1/messages; invalid/missing → 401 (end-to-end through createHandler).
  • Handler/governance branches: route-not-found (404), non-Bedrock vendor (400), invalid body (400), empty messages (400), model resolution (400/403).
  • Credential-preserve regression test.

Manual e2e: ran a real Claude Code session against /anthropic/<slug> (streaming, multi-turn tool use — create/edit files, run tests). Verified streamed output, tool execution, and 16/16 requests logged + metered in AI Studio.

Not covered / follow-ups

  • The live AWS Converse/ConverseStream round-trip is exercised only by the manual e2e — NewBedrockClient returns a concrete *bedrockruntime.Client with no mockable interface (same limit as the existing /ai/ Bedrock path).
  • Live cache-hit behavior (repeat-prompt cache read) is verified manually; the cache-token mapping is unit-tested.
  • Out of scope: image content blocks, /v1/messages/count_tokens (neither required for Claude Code).

Reviewer notes — local test

make dev-full
# create a Bedrock LLM (endpoint region + AWS creds + a current default_model,
# e.g. an inference-profile ID) and an App with that LLM
ANTHROPIC_BASE_URL=http://localhost:9090/anthropic/<slug> \
ANTHROPIC_AUTH_TOKEN=<app-secret> \
claude

nerdydread and others added 5 commits July 20, 2026 14:34
Add POST /anthropic/{routeId}/v1/messages so clients speaking the native
Anthropic Messages API (e.g. Claude Code via ANTHROPIC_BASE_URL) can drive
Claude models hosted in Amazon Bedrock, with AI Studio applying auth, budget,
filters, and analytics.

- Translate Anthropic Messages <-> Bedrock Converse (text, tool_use,
  tool_result, system, tool_choice) plus prompt caching pass-through
  (cache_control -> Converse cache points).
- Stream Converse events as the Anthropic SSE sequence (message_start,
  content_block_start/delta/stop with input_json_delta for tool calls,
  message_delta, message_stop).
- Converters live in vendors/bedrock; HTTP/SSE orchestration in proxy,
  reusing NewBedrockClient, budget/filter/analytics recorders.
- Auth: reuse Bearer app-secret path; add x-api-key fallback for /anthropic.
- Model resolves to the LLM default_model (Bedrock ID) validated against
  allowed_models; the client's model name is echoed back.

Deferred: image content blocks, /v1/messages/count_tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cted]"

Secret metadata fields (e.g. a Bedrock LLM's aws_access_key_id /
aws_secret_access_key / aws_session_token) are redacted to "[redacted]" in GET
responses, so the edit form loads placeholders. On save, UpdateLLM overwrote
metadata verbatim, persisting "[redacted]" as the real credential and breaking
the provider (AWS: UnrecognizedClientException).

UpdateLLM now merges metadata via mergeMetadataPreservingRedacted: a field sent
as "[redacted]" keeps its existing stored value (mirroring the api_key and
datasource-credential handling), a changed field overwrites, and an omitted
field is dropped (so e.g. a session token can still be cleared).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an "Anthropic-Compatible Endpoint" card to an app's LLM Access Details,
rendered only for bedrock-vendor LLMs. It displays {proxyUrl}/anthropic/{slug}
(the ANTHROPIC_BASE_URL for the Anthropic Messages -> Bedrock bridge) with a
copy button and usage note, matching the existing OpenAI-Compatible card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the two coverage gaps that were previously only manually verified:

- Auth wiring (anthropic_bedrock_auth_test.go): end-to-end through createHandler
  and the credential validator on /anthropic/{routeId}/v1/messages — valid
  x-api-key and Authorization: Bearer both authenticate (reach model resolution),
  while an invalid key and missing credentials are rejected with 401. The LLM has
  no default_model so authenticated requests stop at model resolution before any
  AWS call, isolating auth without a live Bedrock backend.

- Handler error branches (anthropic_bedrock_handler_test.go): entry-handler early
  rejections (route not found, non-Bedrock vendor, invalid body, empty messages)
  and resolveAnthropicModelID governance (missing default_model, model not in
  allowed_models, allowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ridge

Complete the prompt-caching acceptance criterion: previously only cache_control
-> cachePoint request insertion was implemented. Now cache-token usage flows
back through responses and analytics.

- Anthropic response usage gains cache_creation_input_tokens /
  cache_read_input_tokens, mapped from Bedrock Converse
  CacheWriteInputTokens / CacheReadInputTokens (non-streaming + streaming
  message_delta).
- recordBedrockChatRecord records CacheWritePromptTokens / CacheReadPromptTokens
  and costs them via ModelPrice.CacheWritePT / CacheReadPT; this also feeds the
  existing cache_read / cache_write metrics. Cache tokens are captured from the
  Converse usage across all Bedrock paths (/ai non-streaming + streaming,
  /llm/stream native, and the /anthropic bridge).

Tests: response and streaming unit tests assert the mapped cache-token usage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Jul 20, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@nerdydread
nerdydread requested a review from buger July 20, 2026 23:52
@probelabs

probelabs Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

This PR introduces a translation bridge to allow clients using the native Anthropic Messages API to connect to Amazon Bedrock models through AI Studio. This brings Bedrock usage by Anthropic-native tools under the full governance of AI Studio, including authentication, budget management, content filtering, and analytics.

Previously, AI Studio only exposed Bedrock via an OpenAI-compatible endpoint, which was incompatible with clients expecting the Anthropic API format. This change adds a new endpoint, POST /anthropic/{routeId}/v1/messages, that translates requests and responses between the two formats.

The implementation includes support for text, multi-turn tool-use, system prompts, and streaming (SSE). It also correctly maps prompt-caching directives and token counts (including cache-related tokens) into AI Studio's analytics.

Additionally, the PR contains two important related changes:

  1. A bug fix in the LLM service to prevent [redacted] placeholder values from overwriting actual credentials when an LLM configuration is saved.
  2. A new UI card on the LLM Access Details page to display the Anthropic-compatible endpoint for relevant Bedrock LLMs.

Files Changed Analysis

The changes are centered in the proxy directory, with significant new logic and supporting tests:

  • New Files: The core of the feature is in new files: proxy/anthropic_bedrock_translator.go (HTTP/SSE handler), vendors/bedrock/anthropic_converse.go (translation logic), and proxy/anthropic_messages_models.go (API wire format). These are accompanied by extensive new tests.
  • Modified Proxy Files: Existing proxy files are updated to integrate the new functionality: proxy/proxy.go adds the new /anthropic/ route, and proxy/credential_validator.go is updated to handle authentication for this route, including the x-api-key header common in Anthropic clients.
  • Analytics Enhancement: proxy/bedrock_translator.go and proxy/bedrock_streaming.go are modified to track and record Bedrock's cache-token usage, a change that benefits all Bedrock integrations.
  • Bug Fix: The credential persistence issue is addressed in services/llm_service.go with a new helper function and a corresponding regression test in services/llm_metadata_merge_test.go.
  • UI Addition: ui/admin-frontend/src/portal/components/AppDetailView.js is updated to add a new section displaying the Anthropic-compatible endpoint.

Architecture & Impact Assessment

  • What this PR accomplishes: It bridges the gap between the Anthropic Messages API and Amazon Bedrock's Converse API, allowing Anthropic-native clients to use Bedrock models through AI Studio with full governance.

  • Key technical changes introduced:

    1. A new API endpoint POST /anthropic/{routeId}/v1/messages.
    2. A translation layer that maps requests, responses, streaming events, tool calls, and caching directives between the two API formats.
    3. Updated authentication middleware to support the new route and the x-api-key header.
    4. A fix for a bug where saving an LLM with redacted credentials would overwrite the stored secrets.
  • Affected system components:

    • Proxy: Routing, authentication, and new translation logic.
    • Services: LLM configuration management.
    • Analytics: Token counting for all Bedrock paths is enhanced to include cache tokens.
    • UI: The application detail view now shows the new endpoint.
  • Request Flow:

    sequenceDiagram
        participant Client as Anthropic SDK Client
        participant AIST as AI Studio Proxy
        participant Bedrock as Amazon Bedrock
    
        Client->>+AIST: POST /anthropic/{routeId}/v1/messages
        Note over AIST: 1. Auth (App Key via x-api-key/Bearer)
        Note over AIST: 2. Budget & Filter Checks
        AIST->>AIST: 3. Translate Anthropic Messages ⇄ Bedrock Converse
        AIST->>+Bedrock: 4. InvokeModel / InvokeModelWithResponseStream
        Bedrock-->>-AIST: 5. Bedrock Response / Event Stream
        AIST->>AIST: 6. Translate Bedrock Converse ⇄ Anthropic Messages
        Note over AIST: 7. Record Analytics (incl. cache tokens)
        AIST-->>-Client: 8. Anthropic Response / SSE Stream
    
    Loading

Scope Discovery & Context Expansion

While the primary feature is the new translation bridge, the changes have a broader impact:

  • Cross-Cutting Auth Changes: The modification to proxy/credential_validator.go affects a critical, shared component. The logic now accommodates the /anthropic path and the x-api-key header.
  • Global Analytics Improvement: The addition of cache-token tracking in proxy/bedrock_translator.go and proxy/bedrock_streaming.go enhances analytics for all Bedrock models proxied through AI Studio, improving cost allocation accuracy.
  • Platform-wide Bug Fix: The fix in services/llm_service.go corrects a significant bug affecting the configuration of any LLM that stores credentials in its metadata (e.g., all Bedrock LLMs), preventing accidental credential loss during routine updates.
Metadata
  • Review Effort: 4 / 5
  • Primary Label: feature

Powered by Visor from Probelabs

Last updated: 2026-07-21T16:22:49.350Z | Triggered by: pr_updated | Commit: cc86a4d

💡 TIP: You can chat with Visor using /visor ask <your question>

@probelabs

probelabs Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Security Issues (2)

Severity Location Issue
🟡 Warning proxy/anthropic_bedrock_translator.go:134
The raw error from the Bedrock SDK is returned directly to the client. This can expose internal system details, such as AWS infrastructure information or stack traces, which could aid an attacker in reconnaissance.
💡 SuggestionReturn a generic error message to the client and log the detailed internal error on the server side for debugging purposes. This applies to both the non-streaming and streaming handlers.
🔧 Suggested Fix
respondWithAnthropicError(w, http.StatusBadGateway, "api_error", "An error occurred while communicating with the upstream service.")
🟡 Warning proxy/anthropic_bedrock_translator.go:228
The raw error from the Bedrock SDK is returned directly to the client in the SSE stream. This can expose internal system details, such as AWS infrastructure information or stack traces, which could aid an attacker in reconnaissance.
💡 SuggestionReturn a generic error message to the client and log the detailed internal error on the server side for debugging purposes. This applies to both the non-streaming and streaming handlers.
🔧 Suggested Fix
writeAnthropicSSEError(w, flusher, "api_error", "An error occurred while communicating with the upstream service.")

Security Issues (2)

Severity Location Issue
🟡 Warning proxy/anthropic_bedrock_translator.go:134
The raw error from the Bedrock SDK is returned directly to the client. This can expose internal system details, such as AWS infrastructure information or stack traces, which could aid an attacker in reconnaissance.
💡 SuggestionReturn a generic error message to the client and log the detailed internal error on the server side for debugging purposes. This applies to both the non-streaming and streaming handlers.
🔧 Suggested Fix
respondWithAnthropicError(w, http.StatusBadGateway, "api_error", "An error occurred while communicating with the upstream service.")
🟡 Warning proxy/anthropic_bedrock_translator.go:228
The raw error from the Bedrock SDK is returned directly to the client in the SSE stream. This can expose internal system details, such as AWS infrastructure information or stack traces, which could aid an attacker in reconnaissance.
💡 SuggestionReturn a generic error message to the client and log the detailed internal error on the server side for debugging purposes. This applies to both the non-streaming and streaming handlers.
🔧 Suggested Fix
writeAnthropicSSEError(w, flusher, "api_error", "An error occurred while communicating with the upstream service.")
\n\n \n\n

Performance Issues (3)

Severity Location Issue
🟡 Warning proxy/anthropic_bedrock_translator.go:58
The entire request body is buffered into memory using `helpers.CopyRequestBody` before processing, for both streaming and non-streaming requests. This can lead to excessive memory consumption if the request payload is large, which can be a performance bottleneck under high load.
💡 SuggestionFor streaming requests, consider using an `io.TeeReader` to stream the request body to the JSON decoder while simultaneously writing it to a buffer for later use in analytics. This avoids holding the entire request body in memory throughout the request's lifecycle.
🟡 Warning proxy/anthropic_bedrock_translator.go:532
String concatenation using `+=` is performed inside the streaming event loop (`s.textBuffer += d.Value`) to accumulate the full response text. This is inefficient due to repeated memory allocations and copies for the string on each chunk.
💡 SuggestionChange the type of `anthropicStreamState.textBuffer` from `string` to `strings.Builder` and use the `WriteString` method to append text chunks. This is significantly more memory and CPU efficient for building strings from multiple pieces.
🟡 Warning proxy/anthropic_messages_models.go:125
The `parseAnthropicBlocks` function handles a polymorphic JSON field by attempting to unmarshal it as a string, and if that fails, re-attempting to unmarshal it as an array of blocks. Relying on unmarshal failure for control flow is inefficient, as the parser may do significant work before failing.
💡 SuggestionInspect the first non-whitespace character of the `json.RawMessage`. If it is `[` then unmarshal as an array; if it is `"` then unmarshal as a string. This avoids the performance cost of the failing unmarshal attempt on the hot path.

Quality Issues (2)

Severity Location Issue
🟠 Error services/llm_service.go:40
The `mergeMetadataPreservingRedacted` function does not handle non-string values for redacted fields. If an existing metadata field is not a string (e.g., a number or boolean) and the incoming update contains `[redacted]` for that key, the `if str, ok := v.(string)` check will fail, and the function will incorrectly overwrite the existing value with the string `"[redacted]"`.
💡 SuggestionModify the logic to handle the case where the incoming value is `"[redacted]"` but the existing value is not a string. The function should preserve the original non-string value instead of overwriting it. The check for `REDACTED_VALUE` should be performed first, and if it matches, the existing value should be preserved regardless of its type.
🟡 Warning proxy/proxy.go:398
The request dispatching logic in the combined handler relies on string prefix matching (`strings.HasPrefix`), which can be brittle. For example, a future route like `/ai-plugins/` would be incorrectly routed to the `/ai/` handler. Using a more robust routing mechanism would be safer.
💡 SuggestionRefactor the `combinedHandler` to mount the sub-routers (`authenticatedAIHandler`, `authenticatedAnthropicHandler`, `authenticatedHandler`) on distinct path prefixes within a parent router. This leverages the router's own path matching capabilities, which is more robust and idiomatic than manual string comparisons.

Powered by Visor from Probelabs

Last updated: 2026-07-21T16:22:38.923Z | Triggered by: pr_updated | Commit: cc86a4d

💡 TIP: You can chat with Visor using /visor ask <your question>

…plane

The bridge's routing, auth, and translation all live inside the shared gateway
handler (proxy.createHandler, reached via aigateway.Gateway.Handler()). The
studio embedded gateway already served /anthropic; the microgateway's gin router
only forwarded /llm, /tools, /datasource, and /ai, so /anthropic 404'd on the
edge/data plane.

Forward /anthropic/*path to the gateway handler, same as /ai/. Adds a routing
test mirroring SetupRouter's gateway-mount block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nerdydread

Copy link
Copy Markdown
Contributor Author

Re: 🟠 Performance — "A new Amazon Bedrock client is created for every incoming request" (proxy/anthropic_bedrock_translator.go:122)

This one is a false positive. Despite the name, bedrockVendor.NewBedrockClient(conf) does not construct an AWS SDK client per request — it returns a cached *bedrockruntime.Client.

See vendors/bedrock/bedrock.go L38-L107:

  • A package-level clientCache sync.Map keyed by LLM ID.
  • NewBedrockClient computes a credential+region fingerprint and returns the cached client when it matches, only creating (and storing) a new one when the LLM is unseen or its credentials/region changed:
// NewBedrockClient returns a cached bedrockruntime.Client for the given LLM config,
// creating a new one only if the LLM ID is unseen or credentials have changed.
func NewBedrockClient(llm *models.LLM) (*bedrockruntime.Client, error) {
    ...
    // Check cache
    if cached, ok := clientCache.Load(llm.ID); ok {
        entry := cached.(*clientCacheEntry)
        if entry.fingerprint == fp {
            return entry.client, nil
        }
        // Credentials changed — fall through to create new client
    }
    ...
}

So the client caching the suggestion recommends is already implemented (and shared with the existing /ai/ Bedrock handlers). No per-request client construction occurs, so there's no action to take here.

The other warnings (upstream error verbosity, the combinedHandler prefix dispatch, and request-body buffering) all mirror the existing /ai/ Bedrock path; happy to discuss those separately.

The Anthropic bridge handlers call bedrockVendor.NewBedrockClient(conf), whose
name reads like a per-request constructor but actually returns a cached client
(sync.Map keyed by LLM ID + credential fingerprint; see vendors/bedrock/bedrock.go).
Add an inline note at both call sites so the caching is obvious to readers (and
to static reviewers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nerdydread

Copy link
Copy Markdown
Contributor Author

Re: Visor: quality findings on cc86a4d1 — both are false positives

First, context: services/llm_service.go was not modified in cc86a4d1 (that commit only added doc-comments to proxy/anthropic_bedrock_translator.go), yet the quality check flipped pass → fail and now flags it. Flagging unchanged code points to run-to-run nondeterminism. On the merits, both findings are incorrect:

🟠 Error — mergeMetadataPreservingRedacted "overwrites non-string values" (services/llm_service.go:40)

The type assertion is on the incoming value, not the existing one — see services/llm_service.go L45-L55:

for k, v := range incoming {
    if str, ok := v.(string); ok && str == REDACTED_VALUE {
        if orig, has := existing[k]; has {
            merged[k] = orig   // preserves existing by interface value — any type
        }
        continue
    }
    merged[k] = v
}

REDACTED_VALUE ("[redacted]") is only ever emitted as a string by redactMetadataSecrets, so when the incoming value is the placeholder the assertion succeeds and we copy existing[k] (orig) by interface value — preserving it regardless of type (number, bool, map, …). The existing value is never type-asserted, so a non-string existing value is preserved, not overwritten. The finding appears to conflate the incoming value with the existing one.

🟡 Warning — HasPrefix routing brittleness, "/ai-plugins/ would match /ai/" (proxy/proxy.go:398)

The dispatch prefixes include a trailing slash (/ai/, /anthropic/), so /ai-plugins/… does not have the prefix /ai/ and would not be misrouted — the cited example doesn't hold. This combinedHandler dispatch also predates this PR; /anthropic/ was added following the existing /ai/ pattern.

Happy to revisit if I've misread either, but as written neither reflects a defect.

@buger buger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Look good!

@buger
buger merged commit 2160483 into main Jul 22, 2026
13 of 15 checks passed
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