README
¶
halo-proxy
Stack-agnostic Halos traffic plane. One static Go binary that sits on the request path and, for every model call:
- verifies the caller (OIDC JWT via the IdP's discovery + JWKS, or identity headers from a trusted proxy) - client-supplied identity is never trusted;
- assigns the ring / experiment variant (
gateway.PrepareVerified); - rewrites the model (JSON
modelor Bedrock/model/{id}/path); - mirrors eligible first-turn requests to halo-shadow;
- stamps
x-halo-ring|release|experiment|variant(all clientx-halo-*are dropped first); - forwards to the policy's upstream, or to a single
--next-hop, streaming the response with no buffering (FlushInterval: -1; SSE and AWS eventstream stay incremental).kind: bedrockupstreams on AWS are SigV4-signed by halo-proxy (see below).
Works with any gateway (Kong, LiteLLM, Envoy, nginx, AWS API Gateway) or none,
any OIDC IdP, and any provider that speaks anthropic-messages,
openai-responses or Bedrock invoke/converse paths.
Deploy modes
(a) edge clients -> halo-proxy --next-hop=<existing gateway> -> provider
(b) behind clients -> <existing gateway> -> halo-proxy -> provider
(c) standalone clients -> halo-proxy -> providers
| (a) edge | (b) behind a gateway | (c) standalone | |
|---|---|---|---|
| Identity | JWT (default). The client's token is what halo-proxy sees. | trusted_header with trustedProxyCIDRs = the gateway's addresses, or JWT if the gateway forwards Authorization. |
JWT |
| Upstream | nextHop: http://kong:8000 |
policy upstreams (or defaultUpstream) |
policy upstreams + upstreamHeaders for provider keys |
forwardAuth |
true if the gateway behind must authenticate the same token |
usually false |
false (inject provider credentials via upstreamHeaders) |
| Client IP | sees the real client (sets X-Forwarded-For) |
sees only the gateway; trust its XFF yourself | real client |
Pick (a) when the existing gateway must stay authoritative for auth/rate limits and you
can change client base URLs to halo-proxy. Pick (b) when clients must keep hitting the
gateway. In (b) the gateway must overwrite the identity header and halo-proxy must
be unreachable except from it (trustedProxyCIDRs enforces this at the socket level).
Model rewriting happens in every mode, including --next-hop: the next hop
receives the upstream model id, not the alias.
Identity
identity.mode |
Behaviour |
|---|---|
jwt (default when identity.issuer is set here or in the org policy) |
Bearer JWT from Authorization: Bearer or x-api-key. Issuer discovery + JWKS cached and refetched on unknown kid (key rotation). Checks iss, aud, exp, nbf. Algorithms limited to RS256 / ES256 / EdDSA; none and HS* are rejected. User = userClaim (default email), groups = groupsClaim (default groups; array or comma-separated string). Missing groups is allowed. |
trusted_header |
identityHeader / groupsHeader are honoured only if the TCP peer is inside trustedProxyCIDRs. Without CIDRs it fails closed (503) rather than trusting headers. |
none |
No verification; everyone is anonymous (default routing, no experiments). |
Issuer/audience/claims default to Org.Identity in the policy snapshot. Failed
verification returns 401 unless identity.allowAnonymous: true (useful when
the token is an opaque API key meant for the next hop: the caller then gets
default routing, ring unknown). With no issuer and no CIDRs configured and no explicit
mode, halo-proxy fails closed with 503 rather than trusting headers.
Run
halo gateway compile ./policy-repo > policy.json # policy.Compile output; hot-reloaded (~1s)
halo-proxy --policy policy.json --listen :8088 # (c), issuer from policy
halo-proxy --policy policy.json --next-hop http://kong:8000 --forward-auth # (a)
halo-proxy --config halo-proxy.yaml
Precedence: defaults < YAML (--config/HALO_PROXY_CONFIG) < env HALO_PROXY_<FLAG> (e.g.
HALO_PROXY_HALO_SHADOW_TOKEN) < flags. Unknown YAML keys are errors.
listen: ":8088"
adminListen: "127.0.0.1:9090" # /healthz + /metrics only; "" disables
policy: /etc/halos/policy.json
nextHop: "" # (a): forward everything here
defaultUpstream: "" # for paths the policy has no route for (e.g. /v1/models)
forwardAuth: false # keep client Authorization / x-api-key on the forwarded request
upstreamHeaders: # per policy upstream name; ${ENV} expanded
anthropic: {x-api-key: "${ANTHROPIC_API_KEY}", anthropic-version: "2023-06-01"}
identity:
mode: "" # jwt | trusted_header | none
issuer: "" # default: policy identity.issuer
audience: "" # default: policy identity.audience / clientID
userClaim: "" # default email
groupsClaim: "" # default groups
identityHeader: "" # trusted_header
groupsHeader: ""
trustedProxyCIDRs: []
allowAnonymous: false
shadow: {url: "", token: "", maxBytes: 1048576}
maxBodyBytes: 33554432 # 32 MiB; larger => 413
readHeaderTimeout: 10s
readTimeout: 5m # request only; responses are never time-limited
idleTimeout: 2m
upstreamHeaderTimeout: 10m # wait for response headers; then the stream is unbounded
shutdownTimeout: 30s # graceful drain of in-flight streams on SIGTERM/SIGINT
Behaviour worth knowing
- Oversize bodies get 413, never a silent skip of the rewrite. Model-call bodies are read fully (needed to inspect/rewrite); other bodies stream through bounded by the same limit.
- Model allowlist fails closed. Only exact model-call paths are accepted
(
/v1/messages,/v1/messages/count_tokens,/v1/responses,/model/{id}/invoke[-with-response-stream]|converse[-stream]); any other path under those prefixes (after unescaping/cleaning) is 404,/v1/messages/batches*is 403, and a model that is not a policy alias (including an unparseable body) is 403 with an Anthropic- / OpenAI- / Bedrock-shaped error so the CLI prints the reason. Nothing rejected is forwarded. count_tokensis routed and rewritten like/v1/messagesbut never mirrored.- The configured identity/groups headers and every
x-halo-*are removed from the forwarded request once the subject is derived; intrusted_headermode a request carrying either header twice is a 400. - Client
Authorization/x-api-keyare stripped unlessforwardAuth: true; they are never included in shadow jobs (onlyanthropic-version|beta,openai-beta). - Admin endpoints (on
adminListenonly, unauthenticated):GET /healthz(503 until a policy snapshot loads),GET /metrics(Prometheus text:halo_proxy_requests_total{ring,variant,status},halo_proxy_upstream_ttfb_secondsandhalo_proxy_request_duration_secondshistograms, auth-failure and shadow counters). The publiclistenaddress serves proxied model calls and/v1/modelsonly; every other path is a 404. In Kubernetes setadminListen: ":9090"so the kubelet can probe it, and restrict it with a NetworkPolicy. - Access log: one JSON
slogline per request (method, path without query, status, bytes, duration, ttfb, ring, variant, upstream). No bodies, headers, or user ids. - Mirroring is sampled by the policy's shadow experiments (first-turn only) and never
blocks the request; a full queue drops jobs (
halo_proxy_shadow_dropped_total). A shadow job carries only experiment, variant, protocol, method, path, allowlisted headers and body; halo-shadow resolves both upstreams from its own copy of the policy (run it with-policy). - Direct Bedrock (SigV4). See Bedrock without an orchestrator.
- HTTP/2 to TLS upstreams is negotiated automatically; compression is passed through untouched.
- Behind a load balancer, terminate TLS in front of halo-proxy; it serves plain HTTP.
Bedrock without an orchestrator
Point Claude Code at halo-proxy in Bedrock mode; halo-proxy verifies the developer (JWT), rewrites the alias to the real model / inference-profile ARN, then signs with its own AWS identity:
export CLAUDE_CODE_USE_BEDROCK=1
export ANTHROPIC_BEDROCK_BASE_URL=https://halo-proxy.internal.example.com
export CLAUDE_CODE_SKIP_BEDROCK_AUTH=1 # the CLI sends no SigV4; halo-proxy signs
export ANTHROPIC_MODEL=sonnet # a policy alias, not a Bedrock id
# Claude Code still needs its halo JWT: apiKeyHelper / ANTHROPIC_AUTH_TOKEN from `gateway.auth.helperCommand`.
# halo-proxy config: only for hosts that are not AWS endpoints
signHosts: [bedrock.corp.example]
# policy Gateway document
upstreams:
bedrock: {url: "https://bedrock-runtime.us-east-1.amazonaws.com", kind: bedrock} # region from host
bedrock-vpce: {url: "https://bedrock.corp.example", kind: bedrock, region: eu-west-1} # custom host => set region
models:
sonnet: {upstream: bedrock, model: "arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-sonnet-4-5-20250929-v1:0"}
- When it signs:
kind: bedrock, a region is known (regionset, or abedrock-runtime[-fips].<region>host label), and the host is an AWS endpoint: it ends in.amazonaws.com,.amazonaws.com.cnor.api.aws, or it is listed insignHosts(exact hostnames in this config, e.g. a private VPC endpoint DNS name). A region on any other host is502 bad_upstream(logged with the host) so credentials never reach a third party. Akind: bedrockupstream on a non-AWS host with no derivable region (an orchestrator or SigV4 sidecar) is forwarded unsigned, exactly as before.nextHop/defaultUpstreamare never signed. - What is signed: SigV4, service
bedrock, computed after the model rewrite over the final method, escaped path (/model/arn%3A.../invoke), host,Content-Type,x-amz*headers and the SHA-256 of the final body. Other headers are left unsigned so intermediaries can't break the signature. ClientAuthorization,x-api-keyand everyx-amz*/x-amzn*header are always dropped before halo-proxy sets its own (even withforwardAuth: true). - Paths:
invoke,invoke-with-response-stream,converse,converse-stream. Theapplication/vnd.amazon.eventstreamresponse is passed through unbuffered. - Credentials: the AWS SDK default chain, resolved on first use: env (
AWS_ACCESS_KEY_ID...), shared config / SSO (AWS_PROFILE), web identity (EKS IRSA / Pod Identity), ECS task role, EC2 instance profile (IMDS). Short-lived credentials are cached and refreshed before expiry. Grant the rolebedrock:InvokeModel+bedrock:InvokeModelWithResponseStreamon the models / inference profiles in the policy. Credentials are never logged. - No credentials =>
502 upstream_auth_error("halo-proxy has no AWS credentials to sign the Bedrock request"); nothing is forwarded unsigned. A malformedregion=>502 bad_upstream. - Verified with fixed-key tests (AWS SigV4 test-suite vector + a fake Bedrock that re-computes the signature). Not verified against real AWS in CI.
Gates
Per ring, posture and versionGate (off | warn | enforce, default warn) apply to model calls. The version gate
needs nothing extra: the CLI version comes from the User-Agent and is compared with the ring's pins. The posture gate
asks halo-server for the verified caller's device posture:
posture:
url: https://halo.internal.example.com/api/v1/gateway/posture # empty disables the posture gate
tokenFile: /run/secrets/halo-gateway-token # halo-server --gateway-token-file
cacheTTL: 1m # per-user verdict cache
grace: 15m # last verdict kept while halo-server is unreachable
Enforce answers 403 in the caller's wire format with the reason and halod status. Warn passes and records gates=
on the request log line. An unreachable halo-server past grace makes the posture unknown: the request passes.
/metrics counts halo_proxy_gate_total{gate,ring,outcome}.
Budgets
The policy's gateway.budgets cap spend per user, group, ring or org (ADR-0017). halo-proxy reads token usage from
each model response as it streams, prices it from gateway.pricing, reports it to halo-server every
flushInterval, and checks the caller's counters before each model call:
budgets:
url: https://halo.internal.example.com/api/v1/gateway/budget # empty: budgets are neither metered nor enforced
tokenFile: /run/secrets/halo-gateway-token # halo-server --gateway-token-file
cacheTTL: 30s # per-caller verdict cache
grace: 15m # last verdict kept while halo-server is unreachable
flushInterval: 5s # usage report interval
failMode: open # open | closed: unknown verdict under an enforce budget
serverStateInputTokens: 200000 # input charged when a cut Responses call names server-side state
maxEstimatedOutputTokens: 128000 # output estimate cap when the request sets no max tokens
An exhausted enforce budget answers 429 with Retry-After at the window reset, in the caller's wire format
(Anthropic rate_limit_error, OpenAI insufficient_quota, Gemini RESOURCE_EXHAUSTED, Bedrock
ThrottlingException). warn budgets and soft thresholds (alertAt) pass with an X-Halo-Budget response header.
Flags: --budget-url, --budget-token-file, --budget-fail-mode. /metrics exports halo_proxy_budget_*. See
the cost budgets guide.
Kill switch
halo-proxy can poll halo-server's signed kill list and treat killed experiments as not running:
control routing, no shadow mirroring, and no x-halo-experiment / x-halo-variant stamp. It takes
effect at the next poll (default 10s), with no policy PR or snapshot roll-out.
killSwitch:
url: https://halo.internal.example.com/api/v1/gateway/killswitch # empty disables
tokenFile: /run/secrets/halo-gateway-token # halo-server --gateway-token-file
pubkeyFile: /etc/halos/killswitch.pub # public half of halo-server --killswitch-key-file
interval: 10s
allowInsecureInCluster: false # default; see below
Flags (env HALO_PROXY_<FLAG>): --killswitch-url, --killswitch-token-file, --killswitch-pubkey-file,
--killswitch-interval. url, tokenFile and pubkeyFile must be set together; a partial config is a
startup error.
- Verified, not trusted. The list is an ed25519 signature (domain
halo-killswitch-v1\n) over a JSON payload{version, experiments, issuedAt}. The key is the dedicated kill-switch key, never the release key. A list is rejected ifissuedAtis more than 10 minutes old or more than 1 minute in the future, or is not strictly newer than the list already held (so a replayed older envelope cannot undo a kill). - Failure policy. A failed fetch or verification keeps the last accepted list (a kill never lapses because
halo-server is down). Before the first successful fetch nothing is killed and the policy snapshot's
statusfields decide. Kills do not expire; they are lifted only by an explicit unkill on halo-server. Failures are logged on the transition (failing, recovered), not every interval. - The token is sent as
Authorization: Bearer; redirects are not followed. Because of that,urlmust behttps://unless it targets loopback orallowInsecureInCluster: true(a trusted in-cluster Service URL with no TLS listener); otherwise startup fails. - Client-axis experiments change profiles on machines, which the gateway cannot reach: a kill stops their gateway-side routing and mirroring only.
Server side and operations: cmd/halo-server/README.md; design: ADR-0009.
Telemetry (gateway evidence)
telemetry:
otlpEndpoint: http://otel-collector:4319 # the collector's gateway receiver; empty disables
tokenFile: /run/secrets/halo-otlp-token # same value as the collector's HALO_OTLP_GATEWAY_TOKEN
protocol: http/protobuf # or http/json
interval: 10s # minimum 1s
genai:
metrics: true # gen_ai.client.* semconv metrics (default on); false also skips the response-body scan
traces: false # opt-in: one `chat {model}` span per call to <otlpEndpoint>/v1/traces
Flags (env HALO_PROXY_<FLAG>): --telemetry-otlp-endpoint, --telemetry-token-file, --telemetry-protocol,
--telemetry-interval, --telemetry-unit-salt-file, --telemetry-genai-metrics, --telemetry-genai-traces. halo-proxy exports halo.gateway.* per-request metrics;
the halo.unit attribute is HMAC-SHA256(salt, verified subject), never a client id. Use the same salt file on
every replica so one user stays one unit (no salt means a random per-process one; there is no inline flag, flags
leak via ps). The collector accepts these only on its authenticated gateway receiver (:4319, bearer token);
without tokenFile the collector answers 401. Only this gateway-sourced evidence may trip the controller's kill
switch. The same export carries OpenTelemetry GenAI semantic-convention metrics and, opt-in, spans (semconv v1.40.0; names,
attributes and the no-content rule: evidence plane).
Generate the collector with halo telemetry collector-config (evidence plane).
Provider-agnostic routing (halo-proxy only)
A model alias maps to an ordered or weighted list of provider targets, so a model upgrade or provider move is a
policy change (gateway.yaml):
upstreams:
bedrock-use1: {kind: bedrock, url: https://bedrock-runtime.us-east-1.amazonaws.com, region: us-east-1}
anthropic-direct: {kind: anthropic, url: https://api.anthropic.com}
vertex-east5: {kind: vertex, project: acme-ai, region: us-east5} # url derived: us-east5-aiplatform.googleapis.com
azure: {kind: azure-openai, url: https://acme.openai.azure.com, credential: {env: AZURE_OPENAI_KEY}}
models:
opus: # primary + failover
targets:
- {upstream: bedrock-use1, model: "arn:aws:bedrock:...", timeoutSeconds: 30}
- {upstream: anthropic-direct, model: claude-opus-4-1-20250805, priority: 1}
default: # 90/10, sticky per user+session
targets:
- {upstream: anthropic-direct, model: claude-sonnet-4-5-20250929, weight: 90}
- {upstream: anthropic-direct, model: claude-opus-4-1-20250805, weight: 10}
- Order. Targets group into priority tiers (lowest first). In a tier the weighted pick (
internal/assign, keyed on verified identity + session) goes first and the rest follow in policy order. Anonymous callers with no session use policy order.halo gateway routes [--user U] [--session S] [--output json]prints the effective table. - Failover. Connect errors, header timeouts, 5xx and 429 move to the next target, up to
route.maxAttempts(default 3). Only before the first response byte: once a response is returned its stream is never retried (a retried LLM call is a duplicated bill, so keeptimeoutSecondstight). If every target fails the last failure is returned. - Circuit breaker per
upstream/model:route.breakerFailuresconsecutive failures (default 5) open it forroute.breakerCooldown(default 30s), then one half-open probe. All circuits open answers 503no_available_target. Single-target routes have no breaker. - Wire translation. anthropic-messages clients can target
anthropic,bedrock(request translated toinvoke; the Bedrock event stream is converted back to SSE) andvertex(rawPredict/streamRawPredict, model in the path). bedrock-invoke clients only reachbedrock. openai-responses clients reachopenaiandazure-openai(/openai/v1/responses, or/openai/responses?api-version=whenapiVersionis set). A target that cannot serve the caller's protocol is skipped.count_tokenshas no Bedrock/Vertex equivalent here and is skipped for them. - Credentials never come from policy or the client.
vertexuses Google ADC reduced to what the stdlib covers:GOOGLE_APPLICATION_CREDENTIALSservice-account key, else the metadata server (GKE workload identity, GCE, Cloud Run); external-account andgclouduser credentials are not supported.openai/azure-openaireadcredential.env/credential.fileper request. ClientAuthorization,x-api-keyandapi-keyare dropped, even withforwardAuth. - Gemini wire.
/v1beta/models/{model}:{generateContent,streamGenerateContent,countTokens}(also/v1/) is a first-class client wire: same allowlist, routes, experiments and toggles; onlygemini(andorchestrator) upstreams serve it. gemini-cli'sx-goog-api-keyis accepted as the Halos credential on this wire only, verified like a bearer token and stripped (with?key=) before forwarding. Ageminiupstream withcredentialgets the gateway's own key asx-goog-api-key. - Host allowlist. Credentialed kinds must be https (except
allowInsecureUpstreams: true, for in-cluster or test endpoints) and on the provider's own domain (*.googleapis.com,api.openai.com,*.openai.azure.com, ...) or an exactupstreamHostsentry. Others are skipped, never sent a credential. - Telemetry. OTLP
halo.gateway.requests/latency_msgainhalo.gateway.provider,halo.gateway.target,halo.gateway.failover;/metricsaddshalo_proxy_upstream_attempts_total{provider,outcome}. - halo-kong cannot run this: it routes to each alias's first target (priority-first). Set
engine: kongon the Gateway andhalo validatewarns for every multi-target route.
Not included
mTLS/SPIFFE identity, per-user rate limiting, and retries after the response has started.
Documentation
¶
Overview ¶
Command halo-proxy is Halos's stack-agnostic traffic plane: a reverse proxy that verifies the caller (OIDC JWT or trusted-proxy headers), assigns the rollout ring / experiment variant, rewrites the model, mirrors eligible first-turn requests to halo-shadow, and forwards to the decided upstream or a single --next-hop. See README.md for deployment modes.