Files
coder/aibridge
Yevhenii Shcherbina ab69fa2f0d fix(aibridge/provider): disable keep-alive on the STS assume-role client (#26971)
A Bedrock provider that assumes an IAM role kept failing with
`AssumeRole` `AccessDenied` for several minutes after its target role's
trust policy was changed, and only recovered on a gateway restart or a
long wait. The request itself was correct: the AWS CLI, using the same
identity and the same `ExternalId`/role/region, accepted the identical
request immediately against the same endpoint.

The difference is the connection. The Go SDK reuses a keep-alive
connection for the STS client, so every `AssumeRole` rides one
connection pinned to a single STS endpoint. After a trust-policy change,
that connection kept returning `AccessDenied` for minutes while a fresh
connection (the AWS CLI) accepted the identical request at once; it
recovered only when the connection recycled or the process restarted.
The exact STS-internal reason is unconfirmed (likely per-endpoint
propagation of the change) — what is verified is that a fresh connection
per call recovers promptly.

Disable keep-alive on the STS client so each `AssumeRole` opens a fresh
connection and a trust-policy update takes effect quickly. `AssumeRole`
runs at most once per credential-cache lifetime, so keep-alive bought
nothing here. The change is scoped to the STS client only; Bedrock model
requests are signed by a separate client and keep their connection
pooling.

## What the data proves

| | CLI | Gateway |

|--------------------|-------------------------------------------|------------------------------------------|
| Identity / key | `bedrock-base-user-useless` / `AKIA…44NL` | same |
| STS endpoint | `sts.us-east-2.amazonaws.com` | same |
| Request params | `ExternalId=QL53…`, role, session, 900 | same |
| Recovery after fix | 7 seconds (21:27:54) | ~4.5 minutes (21:32:17) |
| Re-hitting AWS? | new call each time | yes — 77 fresh `AssumeRole`s,
all denied |

Same identity, params, and endpoint, concurrent — yet the gateway was
denied for ~4.5 minutes while the CLI recovered in 7 seconds, and the
gateway made a fresh `AssumeRole` on every request (so it was not
caching a failure). The only difference was connection reuse.

After disabling keep-alive, the same break/fix experiment brought
gateway recovery down from ~4.5 minutes to ~7 seconds, in lockstep with
the AWS CLI.
2026-07-02 18:58:17 +00:00
..

aibridge

aibridge provides an HTTP handler that intercepts AI client requests bound for upstream AI providers (Anthropic, OpenAI, Copilot). It records token usage, prompts, and tool invocations per user. Optionally supports centralized MCP tool injection with allowlist/denylist filtering.

The handler is mounted by a host process. Today that host is coderd, which mounts the handler at /api/v2/ai-gateway/<provider>/*. Running aibridge as a separate process is planned for the future.

Architecture

┌─────────────────┐     ┌───────────────────────────────────────────┐
│    AI Client    │     │                    aibridge               │
│  (Claude Code,  │────▶│  ┌─────────────────┐    ┌─────────────┐   │
│   Cursor, etc.) │     │  │  RequestBridge  │───▶│  Providers  │   │
└─────────────────┘     │  │  (http.Handler) │    │  (Anthropic │   │
                        │  └─────────────────┘    │   OpenAI)   │   │
                        │                         └──────┬──────┘   │
                        │                                │          │
                        │                                ▼          │    ┌─────────────┐
                        │  ┌─────────────────┐    ┌─────────────┐   │    │  Upstream   │
                        │  │    Recorder     │◀───│ Interceptor │─── ───▶│    API      │
                        │  │ (tokens, tools, │    │ (streaming/ │   │    │ (Anthropic  │
                        │  │  prompts)       │    │  blocking)  │   │    │   OpenAI)   │
                        │  └────────┬────────┘    └──────┬──────┘   │    └─────────────┘
                        │           │                    │          │
                        │           ▼             ┌──────▼──────┐   │
                        │  ┌ ─ ─ ─ ─ ─ ─ ─ ┐      │  MCP Proxy  │   │
                        │  │    Database   │      │   (tools)   │   │
                        │  └ ─ ─ ─ ─ ─ ─ ─ ┘      └─────────────┘   │
                        └───────────────────────────────────────────┘

Components

  • RequestBridge: The main http.Handler that routes requests to providers
  • Provider: Defines bridged routes (intercepted) and passthrough routes (proxied)
  • Interceptor: Handles request/response processing and streaming
  • Recorder: Interface for capturing usage data (tokens, prompts, tools)
  • MCP Proxy (optional): Connects to MCP servers to list tool, inject them into requests, and invoke them in an inner agentic loop

Request Flow

  1. Client sends request to /anthropic/v1/messages or /openai/v1/chat/completions
  2. Actor extraction: Request must have an actor in context (via AsActor()). The host is responsible for authenticating the caller before invoking the handler.
  3. Upstream call: Request forwarded to the AI provider
  4. Response relay: Response streamed/sent to client
  5. Recording: Token usage, prompts, and tool invocations recorded

With MCP enabled: Tools from configured MCP servers are centrally defined and injected into requests (prefixed bmcp_). Allowlist/denylist regex patterns control which tools are available. When the model selects an injected tool, the gateway invokes it in an inner agentic loop, and continues the conversation loop until complete.

Passthrough routes (/v1/models, /v1/messages/count_tokens) are reverse-proxied directly.

Observability

Prometheus Metrics

Create metrics with NewMetrics(prometheus.Registerer):

Metric Type Description
interceptions_total Counter Intercepted request count
interceptions_inflight Gauge Currently processing requests
interceptions_duration_seconds Histogram Request duration
passthrough_total Counter Non-intercepted requests forwarded to the upstream
prompts_total Counter User prompt count
tokens_total Counter Token usage (input, output, cache read/write, provider extras)
injected_tool_invocations_total Counter Injected MCP tool invocations performed by the handler
non_injected_tool_selections_total Counter Client-defined tool selections returned by the model
circuit_breaker_state Gauge Circuit breaker state per provider/endpoint (0=closed, 0.5=half, 1=open)
circuit_breaker_trips_total Counter Times the circuit breaker transitioned to open
circuit_breaker_rejects_total Counter Requests rejected due to an open circuit breaker

Recorder Interface

Implement Recorder to persist usage data to your database:

  • aibridge_interceptions - request metadata (provider, model, initiator, timestamps)
  • aibridge_token_usages - input/output and cache read/write token counts per response
  • aibridge_user_prompts - user prompts
  • aibridge_tool_usages - tool invocations (injected and client-defined)
  • aibridge_model_thoughts - model reasoning content (thinking, reasoning summaries, commentary)
type Recorder interface {
    RecordInterception(ctx context.Context, req *InterceptionRecord) error
    RecordInterceptionEnded(ctx context.Context, req *InterceptionRecordEnded) error
    RecordTokenUsage(ctx context.Context, req *TokenUsageRecord) error
    RecordPromptUsage(ctx context.Context, req *PromptUsageRecord) error
    RecordToolUsage(ctx context.Context, req *ToolUsageRecord) error
    RecordModelThought(ctx context.Context, req *ModelThoughtRecord) error
}

Supported Routes

Each provider instance is mounted under /api/v2/ai-gateway/<name>, where <name> is the provider's configured name. For example, with an Anthropic provider named my-anthropic, its /messages endpoint would be reachable at /api/v2/ai-gateway/my-anthropic/v1/messages.

If a name is not set, the route path defaults to the provider's type: anthropic, openai, or copilot. The table below uses the default names.

(/*) denotes a route that handles both the exact path and any subpaths. A trailing /* denotes subpaths only.

Provider Route Type
Anthropic /anthropic/v1/messages Bridged (intercepted)
Anthropic /anthropic/v1/messages/count_tokens Passthrough
Anthropic /anthropic/v1/models(/*) Passthrough
Anthropic /anthropic/api/event_logging/* Passthrough
OpenAI /openai/v1/chat/completions Bridged (intercepted)
OpenAI /openai/v1/responses Bridged (intercepted)
OpenAI /openai/v1/responses/* Passthrough
OpenAI /openai/v1/conversations(/*) Passthrough
OpenAI /openai/v1/models(/*) Passthrough
Copilot /copilot/chat/completions Bridged (intercepted)
Copilot /copilot/responses Bridged (intercepted)
Copilot /copilot/models(/*) Passthrough
Copilot /copilot/agents/* Passthrough
Copilot /copilot/mcp/* Passthrough
Copilot /copilot/.well-known/* Passthrough