A lightweight reverse proxy that translates the standard Anthropic Messages API (/v1/messages) into SAP AI Core deployment calls, with automatic OAuth2 token management.
This allows tools like Claude Code, the Anthropic Python/TypeScript SDK, and any client speaking the Anthropic Messages API to work seamlessly with Claude models deployed on SAP AI Core.
Client (Claude Code / SDK)
│ POST /v1/messages
▼
[Proxy] ── OAuth2 ──▶ SAP XSUAA
│
│ POST /v2/inference/deployments/{id}/invoke[-with-response-stream]
▼
SAP AI Core (Claude model)
- Streaming support — uses SAP AI Core's
/invoke-with-response-streamendpoint and converts Bedrock SSE format to standard Anthropic SSE format (event:+data:lines) - Non-streaming support — uses
/invokeendpoint, returns full JSON response - Auto OAuth2 token management — background thread refreshes tokens before expiry with exponential backoff on failure
- Request adaptation — automatically handles field differences between Anthropic API and SAP AI Core (removes
model,stream,context_management; addsanthropic_version) - Built-in tool filtering — strips Anthropic server-side tools (
web_search,text_editor, etc.) that SAP AI Core doesn't support - 401 auto-retry — transparently refreshes token and retries on authentication failure
- Least-connections load balancing — distributes requests across multiple SAP AI Core deployments, routing each request to the deployment with the fewest active connections; ideal for concurrent subagent workloads. Configure via comma-separated
SAP_DEPLOYMENT_ID - Model-aware routing (optional) — when separate
SAP_DEPLOYMENT_ID_OPUS/_SONNET/_HAIKUenv vars (or a per-model dict in the config file) are configured, the proxy inspects the client'smodelstring foropus/sonnet/haikuand routes to the matching pool, falling back to the full pool for unknown models. With only the legacySAP_DEPLOYMENT_IDset, behavior is unchanged: the request'smodelfield is ignored. - API key authentication — optional client API key validation via env var, config file, or database; disabled when no keys configured (backward compatible). DB-managed keys are stored only as SHA-256 hashes, never plaintext.
- Config file support — settings can be provided via
/etc/aicore-proxy/config.json(volume-mounted), with env vars taking priority;api_keysfield is hot-reloaded every 60s - Usage statistics — optional per-key request and token usage tracking with SQLite (enable via
ENABLE_STATS=true); the log stores only the key's hash and display prefix, not the raw key - Admin API — manage API keys and query usage stats via REST endpoints (requires
ENABLE_STATS=trueand a configuredADMIN_TOKEN) - Health check & stats endpoints —
GET /healthfor Docker healthcheck,GET /statsfor deployment active connections
- A running Claude model deployment on SAP AI Core
- SAP AI Core service key credentials (client ID, client secret, auth URL, API URL)
- Docker
The credentials come from a service key of your SAP AI Core instance on BTP:
- Go to BTP Cockpit → your subaccount → Instances and Subscriptions
- Find your AI Core service instance → click Create Service Key (or view an existing one)
- The service key JSON contains the values you need:
| Service Key Field | Environment Variable |
|---|---|
clientid |
SAP_CLIENT_ID |
clientsecret |
SAP_CLIENT_SECRET |
url |
SAP_AUTH_URL |
serviceurls.AI_API_URL |
SAP_AI_API_URL |
The SAP_DEPLOYMENT_ID identifies which model deployment to route requests to. You can list all deployments via the SAP AI Core API:
# Get an OAuth token
TOKEN=$(curl -s -X POST "$SAP_AUTH_URL/oauth/token" \
-u "$SAP_CLIENT_ID:$SAP_CLIENT_SECRET" \
-d "grant_type=client_credentials" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# List all deployments
curl -s "$SAP_AI_API_URL/v2/lm/deployments" \
-H "Authorization: Bearer $TOKEN" \
-H "AI-Resource-Group: $SAP_RESOURCE_GROUP" | python3 -m json.toolLook for the deployment with the desired model (e.g. anthropic--claude-4.6-opus) and "status": "RUNNING", then copy its id field.
cp docker-compose.example.yml docker-compose.ymlEdit docker-compose.yml and fill in your SAP AI Core credentials:
| Variable | Description |
|---|---|
SAP_CLIENT_ID |
OAuth2 client ID from service key |
SAP_CLIENT_SECRET |
OAuth2 client secret from service key |
SAP_AUTH_URL |
XSUAA token endpoint base URL |
SAP_AI_API_URL |
SAP AI Core API base URL |
SAP_DEPLOYMENT_ID |
Deployment ID(s), comma-separated for load balancing |
SAP_DEPLOYMENT_ID_OPUS / _SONNET / _HAIKU |
Optional. Per-model deployment pools — when any is set, the proxy routes based on the client's model field (opus/sonnet/haiku keyword) instead of round-robining across SAP_DEPLOYMENT_ID. Unknown models fall back to SAP_DEPLOYMENT_ID. |
SAP_RESOURCE_GROUP |
Resource group (default: default) |
VERBOSE |
Enable detailed request/response logging (default: false) |
API_KEYS |
Optional: comma-separated API keys for client authentication. Values are hashed in memory and never persisted in plaintext. |
ENABLE_STATS |
Optional: enable per-key usage tracking with SQLite (default: false) |
ADMIN_TOKEN |
Required to use /admin/* endpoints. If unset, the admin API is disabled and returns 403. |
All settings can also be provided via a config file (see below).
Mount a directory to /etc/aicore-proxy and create config.json:
mkdir -p ./aicore-proxy
cat > ./aicore-proxy/config.json << 'EOF'
{
"sap_client_id": "sb-xxx",
"sap_client_secret": "xxx",
"sap_auth_url": "https://...",
"sap_ai_api_url": "https://...",
"sap_deployment_id": "id1,id2",
"//": "Or, for model-aware routing, replace the line above with a dict:",
"//example": "\"sap_deployment_id\": {\"opus\": [\"opus-id-1\"], \"sonnet\": [\"sonnet-id-1\"], \"haiku\": [\"haiku-id-1\"]}",
"api_keys": ["sk-key1", "sk-key2"],
"enable_stats": true
}
EOF- Env vars take priority over config file values
- The
api_keysfield is hot-reloaded every 60s — change keys without restarting - All fields are optional — only override what you need
docker compose up -dThe proxy listens on port 6655.
# Set the API base URL to point to your proxy
export ANTHROPIC_BASE_URL=http://localhost:6655
export ANTHROPIC_API_KEY=dummy # any non-empty value works
claudeimport anthropic
client = anthropic.Anthropic(
base_url="http://localhost:6655",
api_key="dummy", # any non-empty value, auth is handled by the proxy
)
# Non-streaming
message = client.messages.create(
model="claude-sonnet-4-20250514", # model field is ignored, deployment ID determines the model
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)
# Streaming
with client.messages.stream(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)When API_KEYS is set (env var or config file), clients must provide a valid key:
export ANTHROPIC_BASE_URL=http://localhost:6655
export ANTHROPIC_API_KEY=sk-key1 # must match a configured key
claudeKeys can be provided via x-api-key header or Authorization: Bearer <key> header.
If no keys are configured, auth is disabled (backward compatible).
Enable with ENABLE_STATS=true to track per-key request counts and token usage in SQLite. The DB stores only sha256(key) and a short key_prefix for display — plaintext keys are never persisted.
Every /admin/* route requires ADMIN_TOKEN and rejects the request otherwise. Send it either as an X-Admin-Token header or as Authorization: Bearer <ADMIN_TOKEN>.
export ADMIN=$ADMIN_TOKEN
# Create a key — the plaintext is returned EXACTLY ONCE, store it now.
curl -X POST http://localhost:6655/admin/keys \
-H "X-Admin-Token: $ADMIN" -H "Content-Type: application/json" \
-d '{"name": "dev-team"}'
# {"key":"sk-...","key_hash":"...","key_prefix":"sk-abcdefgh…","name":"dev-team",
# "warning":"Store this key now — it will not be shown again."}
# List keys — only masked prefixes are returned, never the raw key.
curl -H "X-Admin-Token: $ADMIN" http://localhost:6655/admin/keys
# Revoke a key — pass its key_hash (preferred) or the raw key (hashed locally).
curl -X DELETE -H "X-Admin-Token: $ADMIN" \
http://localhost:6655/admin/keys/<key_hash-or-raw-key>
# Usage summary — filter by hash or (raw key, hashed server-side); by time range; group by day.
curl -H "X-Admin-Token: $ADMIN" http://localhost:6655/admin/usage
curl -H "X-Admin-Token: $ADMIN" "http://localhost:6655/admin/usage?key_hash=<hash>&days=7"
curl -H "X-Admin-Token: $ADMIN" "http://localhost:6655/admin/usage?key=sk-key1&days=7"
curl -H "X-Admin-Token: $ADMIN" "http://localhost:6655/admin/usage?group_by=day"
# Public (no admin token) — deployment stats and health.
curl http://localhost:6655/stats
curl http://localhost:6655/healthOlder proxy versions stored API keys and per-request logs in plaintext. On first boot, the proxy detects the old schema, rehashes every stored key into key_hash + key_prefix, drops the plaintext columns, and VACUUMs the DB so the old pages are reclaimed. The plaintext of DB-managed keys is unrecoverable after this migration — clients continue to work (they still send the same plaintext key; the proxy hashes it on each request), but the admin API can only display prefixes going forward. If you lost track of a key, revoke it via DELETE /admin/keys/<key_hash> and issue a new one.
docker build -t aicore-proxy .- No web search — Anthropic's built-in server-side tools (
web_search,text_editor) are not supported by SAP AI Core / Bedrock. The proxy silently filters them out. - No failover — if a deployment returns an error, the proxy does not automatically retry on another deployment.
MIT