Claude Code Integration¶
Doc Version: 1.1.0 Last Updated: 2026-09-14 Applies To: v3.1+ Git Commit: - Author: Lincoln
Overview¶
Since v3.1, JAiRouter exposes an Anthropic Messages protocol endpoint (POST /v1/messages). Claude Code only needs ANTHROPIC_BASE_URL pointed at JAiRouter to connect with zero code changes: the request goes through API Key authentication → quota → rate limiting / circuit breaking → load balancing to any downstream instance (OpenAI / DeepSeek / vLLM / Ollama / Claude …), and the response is translated back into Anthropic shape.
| Capability | Endpoint | Description |
|---|---|---|
| Messages (non-streaming) | POST /v1/messages | Downstream native JSON → Anthropic Message |
| Messages (streaming) | POST /v1/messages (stream: true) | Downstream OpenAI-style SSE → Anthropic event sequence |
| Tool use | POST /v1/messages | Anthropic tools / tool_use / tool_result ↔ downstream OpenAI-compatible function calling (since v3.1 PR-5, so Claude Code's tools actually work) |
| Token counting | POST /v1/messages/count_tokens | Local estimate {"input_tokens": N}, never calls downstream |
| Model list | GET /v1/models | OpenAI-shaped catalog of available models |
In one sentence:
/api/v1/**(console surface) returnsRouterResponseenvelopes;/v1/**(native surface) returns downstream-native / protocol-native shapes so that clients such as Claude Code and the OpenAI SDK can use JAiRouter directly.
Prerequisites¶
- JAiRouter is running (default
http://localhost:8080). - An API Key has been created in the console (it authenticates both
/api/**and/v1/**). - The target model is registered as an instance or pool (service type
chat), e.g.deepseek-chatorqwen2:7b. - Downstream credentials are configured (recommended: in the instance
headers, see "Authentication Layers").
Quick Start¶
# 1) Gateway URL: ANTHROPIC_BASE_URL=http://<host>:8080 (no /v1 suffix; replace <host> with the gateway host/IP)
export ANTHROPIC_BASE_URL=http://localhost:8080
# 2) Gateway credential (API Key created in the JAiRouter console)
export ANTHROPIC_API_KEY=<JAiRouter API Key>
# 3) A model name registered in JAiRouter (must match the instance/pool name exactly; no aliases)
export ANTHROPIC_MODEL=deepseek-chat
# 4) Start Claude Code
claude
PowerShell:
$env:ANTHROPIC_BASE_URL = "http://localhost:8080"
$env:ANTHROPIC_API_KEY = "<JAiRouter API Key>"
$env:ANTHROPIC_MODEL = "deepseek-chat"
claude
Claude Code sends ANTHROPIC_API_KEY in the x-api-key request header. HTTP header names are case-insensitive, so x-api-key and X-API-Key are equivalent (both verified against the gateway).
Important: inside JAiRouter,
x-api-keyis a gateway credential. It is consumed by the security layer and never forwarded downstream. Downstream AI service keys must be configured separately — see the next section.
Authentication Layers (read this first)¶
A single /v1/messages request passes through two authentication layers with completely different purposes:
| Header | Belongs to | Forwarded downstream | Notes |
|---|---|---|---|
x-api-key (X-API-Key is equivalent) | Gateway credential | ❌ No | API Key created in the JAiRouter console; used for gateway auth + service permission (ROLE_CHAT) |
Jairouter_Token | Gateway credential (JWT) | ❌ No | JWT issued by the console/login endpoint; takes precedence when both are present |
Authorization | Downstream credential | ✅ Yes | e.g. Bearer sk-xxx (OpenAI/DeepSeek), Bearer <token>; the gateway never consumes it |
Instance headers | Downstream credential | ✅ Injected by gateway | Custom request headers from instance config, used to pass Authorization / x-api-key etc. downstream |
Three rules to remember:
x-api-key= gateway credential, not forwarded downstream. If you put a downstream key inx-api-key, the downstream receives a request with no credentials → 401 / unauthenticated failure.- Configure the downstream key in instance
headers(recommended), or send it per request viaAuthorization: Bearer <downstream key>. When both are present, instanceheaderswin (instance configuration overrides the incomingAuthorization). - Credential precedence:
Jairouter_Token(JWT) >X-API-Key(API Key).Authorizationis unrelated to gateway authentication; it only affects the downstream.
Native-surface authentication note:
/v1/**accepts bothX-API-Key(API Key) andJairouter_Token(JWT). When both are present, JWT takes precedence. JWT must be enabled (jairouter.security.jwt.enabled) and the token must not be expired or revoked. For Claude Code, usingANTHROPIC_API_KEY(i.e.x-api-key) is the simplest approach; if you prefer JWT, make sure the above conditions are met. The console surface/api/**is not affected.
Three Ways to Configure Downstream Credentials¶
| Option | Where the downstream key lives | Use case | Pros | Notes |
|---|---|---|---|---|
| A (recommended) | Instance headers | Production / shared gateway | Keys stay centralized; clients hold nothing; instance config wins | Switch downstream keys by editing config only |
| B | Request Authorization: Bearer <downstream key> | Trials / per-request keys | No gateway config change | Claude Code can only attach it via ANTHROPIC_CUSTOM_HEADERS etc.; only one Authorization slot exists |
| C | Instance headers with a custom header name (e.g. api-key) | Downstream is not Authorization-style | Works with any downstream protocol | Verify which header the downstream expects |
Option A: downstream key in instance headers (recommended)¶
# DeepSeek example: the downstream key lives in the instance headers (injected by the gateway)
curl -X POST "http://localhost:8080/api/config/instance/add/chat" \
-H "Content-Type: application/json" \
-H "X-API-Key: <JAiRouter API Key>" \
-d '{
"name": "deepseek-chat",
"baseUrl": "https://api.deepseek.com",
"path": "/v1/chat/completions",
"adapter": "normal",
"headers": {
"Authorization": "Bearer <DeepSeek API Key>"
}
}'
Claude Code then only needs ANTHROPIC_API_KEY=<JAiRouter API Key>; the client neither stores nor sends the downstream key.
Option B: send the downstream key per request (pass-through Authorization)¶
Leave the instance config untouched and let the client carry the downstream key:
curl -N -X POST "http://localhost:8080/v1/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: <JAiRouter API Key>" \
-H "Authorization: Bearer <DeepSeek API Key>" \
-d '{"model":"deepseek-chat","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}'
If the instance already defines Authorization, the instance configuration wins (the request header is overridden).
Option C: custom downstream header¶
curl -X POST "http://localhost:8080/api/config/instance/add/chat" \
-H "Content-Type: application/json" \
-H "X-API-Key: <JAiRouter API Key>" \
-d '{
"name": "claude-sonnet",
"baseUrl": "https://api.anthropic.com",
"path": "/v1/messages",
"adapter": "claude",
"headers": {
"x-api-key": "<Anthropic API Key>"
}
}'
Note: this
x-api-keylives in the instanceheadersand targets the downstream Anthropic API. It is unrelated to thex-api-keythe client sends to the gateway. With theclaudeadapter, theanthropic-versionheader is injected automatically.
Endpoint Examples¶
All examples use x-api-key: <JAiRouter API Key> as the gateway credential.
Non-streaming messages¶
curl -X POST "http://localhost:8080/v1/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: <JAiRouter API Key>" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "deepseek-chat",
"max_tokens": 128,
"system": "You are a concise assistant",
"messages": [{"role": "user", "content": "Introduce yourself in one sentence"}]
}'
Response (Anthropic Message shape):
{
"id": "chatcmpl-xxxxxxxx",
"type": "message",
"role": "assistant",
"model": "deepseek-chat",
"content": [{"type": "text", "text": "I am an AI assistant ..."}],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {"input_tokens": 10, "output_tokens": 15}
}
usage.input_tokens / output_tokens come from the downstream usage; they are 0 when the downstream does not provide it.
Streaming messages¶
curl -N -X POST "http://localhost:8080/v1/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: <JAiRouter API Key>" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "deepseek-chat",
"max_tokens": 128,
"stream": true,
"messages": [{"role": "user", "content": "Count to three"}]
}'
Content-Type: text/event-stream;charset=UTF-8. The event sequence (each event = an event:<name> line + a data:<json> line + a blank line; the SSE spec allows one space after the colon, the gateway emits none, and clients accept both):
| Order | Event | Payload highlights |
|---|---|---|
| 1 | message_start | message.id (gateway-generated msg_*), model (requested model), content: [], stop_reason: null, usage.input_tokens (estimated), usage.output_tokens: 0 |
| 2 | content_block_start | index: 0, content_block: {type:"text", text:""} |
| 3…N | content_block_delta | index: 0, delta: {type:"text_delta", text:"…"} |
| N+1 | content_block_stop | index: 0 |
| N+2 | message_delta | delta: {stop_reason:"end_turn"\|"max_tokens", stop_sequence:null}, usage.output_tokens (downstream usage preferred, otherwise estimated from accumulated text) |
| N+3 | message_stop | {type:"message_stop"} |
Real output sample (produced by the actual serializer, 7 events; the model returned "1" then "、2、3"):
event:message_start
data:{"type":"message_start","message":{"id":"msg_4976714626ba4577a486e0772f604a87","type":"message","role":"assistant","model":"deepseek-chat","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":6,"output_tokens":0}}}
event:content_block_start
data:{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event:content_block_delta
data:{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"1"}}
event:content_block_delta
data:{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"、2、3"}}
event:content_block_stop
data:{"type":"content_block_stop","index":0}
event:message_delta
data:{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}
event:message_stop
data:{"type":"message_stop"}
When the downstream fails mid-stream (HTTP 200 already established, SSE already started) the gateway closes with event:error so the client does not just see a truncated stream:
error.type is mapped from the downstream status: 401/403 → authentication_error, 429 → rate_limit_error, 400/404/422 → invalid_request_error, otherwise api_error.
Tool use¶
Prerequisite: the downstream (OpenAI / DeepSeek / vLLM / any other OpenAI-compatible service) must support function calling. If it does not, nothing breaks — the gateway still forwards tools, the downstream ignores them and returns plain text, and this endpoint degrades to text chat (content holds only text blocks, stop_reason is end_turn).
Request-side mapping (Anthropic → downstream OpenAI-compatible wire):
| Anthropic | Downstream OpenAI-compatible |
|---|---|
tools[{name,description,input_schema}] | tools:[{type:"function",function:{name,description,parameters:input_schema}}]; input_schema is used verbatim as parameters, defaulting to {"type":"object","properties":{}} when absent |
tool_choice:"auto" / {"type":"auto"} | "auto" |
tool_choice:"any" / {"type":"any"} | "required" |
tool_choice:{"type":"tool","name":X} | {"type":"function","function":{"name":X}} |
tool_choice:"none" | omits both tools and tool_choice (tools disabled per protocol) |
assistant block {type:"tool_use",id,name,input} | tool_calls:[{id,type:"function",function:{name,arguments:JSON.stringify(input)}}] on that assistant message |
user block {type:"tool_result",tool_use_id,content} | a separate {role:"tool",tool_call_id:tool_use_id,content:<text>} message (content arrays are joined into text) |
Ordering rules:
- within one assistant message:
textblocks becomecontent, multipletool_useblocks becometool_callsin block order (OpenAI structurally putscontentfirst andtool_callsafter, so no finer interleaving can be expressed); - within one user message:
tool_resultblocks becomerole:"tool"messages emitted before the text message, so they directly follow the assistant message carryingtool_calls(a hard requirement of OpenAI / DeepSeek).
Non-streaming example¶
curl -X POST "http://localhost:8080/v1/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: <JAiRouter API Key>" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "deepseek-chat",
"max_tokens": 512,
"messages": [{"role": "user", "content": "What is the weather in Beijing today?"}],
"tools": [{
"name": "get_weather",
"description": "Look up the weather of a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
}],
"tool_choice": {"type": "auto"}
}'
Response (content holds the text block plus each tool_use block in downstream order; stop_reason is tool_use):
{
"id": "chatcmpl-tool-1",
"type": "message",
"role": "assistant",
"model": "deepseek-chat",
"content": [
{"type": "text", "text": "Let me look that up."},
{"type": "tool_use", "id": "call_0", "name": "get_weather", "input": {"city": "Beijing"}}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {"input_tokens": 88, "output_tokens": 24}
}
After running the tool, the client feeds the result back as a tool_result block inside a user message (Claude Code does this automatically):
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_0", "content": "Sunny, 25C"}]}
Streaming event sequence¶
Tool calls stream as tool_use content blocks whose input arrives as input_json_delta fragments: the text block is always index: 0, tool block indices increment in order of appearance (1, 2, …), and each block's start / delta / stop triple never interleaves with another block.
| Order | Event | Payload highlights |
|---|---|---|
| 1 | message_start | Same as a plain text stream (usage.input_tokens is the request-side estimate) |
| 2 | content_block_start | index: 0, content_block: {type:"text", text:""} |
| 3…k | content_block_delta | index: 0, delta: {type:"text_delta", text:"…"} |
| k+1 | content_block_stop | index: 0 (the text block is closed before switching to a tool block) |
| k+2 | content_block_start | index: n, content_block: {type:"tool_use", id:"call_*", name:"<tool>"} |
| k+3…m | content_block_delta | index: n, delta: {type:"input_json_delta", partial_json:"<argument fragment>"} (concatenate in arrival order to get the full input) |
| m+1 | content_block_stop | index: n |
| … | (repeat k+2…m+1) | one block per tool, indices increment |
| last-1 | message_delta | delta: {stop_reason:"tool_use", stop_sequence:null}, usage.output_tokens |
| last | message_stop | {type:"message_stop"} |
Real output sample (produced by the actual serializer, 10 events; the model first returned the text "Let me look that up." then streamed get_weather's input {"city":"Beijing"} with arguments arriving in two fragments):
event:message_start
data:{"type":"message_start","message":{"id":"msg_ec73908348944d82b34dfd2ef5516f71","type":"message","role":"assistant","model":"deepseek-chat","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":88,"output_tokens":0}}}
event:content_block_start
data:{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event:content_block_delta
data:{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me look that up."}}
event:content_block_stop
data:{"type":"content_block_stop","index":0}
event:content_block_start
data:{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_0","name":"get_weather"}}
event:content_block_delta
data:{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}
event:content_block_delta
data:{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"Beijing\"}"}}
event:content_block_stop
data:{"type":"content_block_stop","index":1}
event:message_delta
data:{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":24}}
event:message_stop
data:{"type":"message_stop"}
stop_reason rules: downstream finish_reason: "tool_calls" → tool_use; a stream that produced tool blocks but ends with stop / no reason is also upgraded to tool_use (Claude Code decides whether to run tools from it); length always stays max_tokens (truncation is more informative). usage.output_tokens semantics are unchanged — downstream usage wins when present, otherwise it is estimated from the accumulated text plus tool arguments fragments.
Tolerance and degradation¶
| Case | Behavior |
|---|---|
Downstream function.arguments is invalid JSON | tool_use.input degrades to {"raw_arguments": "<original text>"} with a warn log (so input is always an object and clients never fail on shape) |
Downstream arguments empty/missing | tool_use.input is {} (a tool without arguments) |
Downstream tool_calls[].id missing | The gateway generates toolu_*; a request-side tool_use.id missing yields call_* |
Request tool_use.id / tool_result.tool_use_id missing | Downstream tool_call_id is an empty string, with a warn log |
| Downstream does not support function calling | tools are ignored and plain text comes back (content has only text, stop_reason: end_turn, no tool blocks) |
| Interleaved tool fragments (one tool split into alternating segments) | A new content block is opened for that tool with a debug log (the event sequence stays legal; clients correlate by id) |
Claude Code verification¶
# 1) Point at the gateway (the model must be a registered instance/pool whose downstream supports
# function calling, e.g. deepseek-chat)
export ANTHROPIC_BASE_URL=http://localhost:8080
export ANTHROPIC_API_KEY=<JAiRouter API Key>
export ANTHROPIC_MODEL=deepseek-chat
# 2) Trigger one tool call non-interactively (Claude Code attaches its Read/Bash tool definitions)
claude -p "Use the Bash tool to run pwd and tell me the output verbatim" --allowedTools "Bash"
# 3) Check that it really ran: the answer must contain the command output (e.g. /home/...), not
# "I cannot execute commands"
Two places to confirm the path end to end:
- gateway logs: the
/v1/messagesrequest carriestools, and the downstream request body carriestools/tool_calls(with tracing enabled, the downstream call span carriesadapter.api_standard=openai); - the event stream (
curl -N, or Claude Code--verbose): acontent_block_startwith"type":"tool_use"and amessage_deltawith"stop_reason":"tool_use".
Token counting¶
curl -X POST "http://localhost:8080/v1/messages/count_tokens" \
-H "Content-Type: application/json" \
-H "x-api-key: <JAiRouter API Key>" \
-d '{
"model": "deepseek-chat",
"system": "You are a concise assistant",
"messages": [{"role": "user", "content": "Introduce yourself in one sentence"}]
}'
- The body is the same as
/v1/messages, butmax_tokens/stream/toolsare ignored: only the text blocks insystemandmessagesare counted. - The value is estimated locally by the gateway (ideographic characters ≈ 2 chars/token, other non-whitespace ≈ 4 chars/token). No downstream call, no quota consumption, and it is the same estimator used for the streaming
message_start.usage.input_tokens. - The estimate is for budgeting/trimming; it is not the official Anthropic tokenizer output.
Model catalog¶
Returns the OpenAI shape {"object":"list","data":[{"id":"deepseek-chat","object":"model",…}]}; each id can be used as the model field.
Current Limitations¶
| Limitation | Description | Workaround |
|---|---|---|
| Tool use depends on downstream function calling | tools / tool_choice / tool_use / tool_result are forwarded downstream in OpenAI-compatible wire shape (v3.1 PR-5); if the downstream does not support function calling, tools are ignored and plain text comes back, which looks like "tool use does not work" | Pick a downstream with function calling (OpenAI / DeepSeek / vLLM …); verify with claude -p "…" --allowedTools "Bash" or curl against /v1/messages |
model must be a registered model name | No aliases, no automatic routing prefixes; an unregistered name fails instance selection (404/503) | List ids via GET /v1/models, or map pool names such as auto-model in the console |
| Error body shapes differ between surfaces | Gateway errors (401 auth failure, 429 quota/rate limit, 5xx) use the RouterResponse shape {"success":false,"message":"…","errorCode":"…","timestamp":"…"} on both /v1 and /api; only the Anthropic endpoint's parameter-validation errors use the Anthropic shape {"type":"error","error":{…}}; non-2xx downstream responses are passed through verbatim | Detect the shape by the presence of the type field |
x-api-key is never forwarded | See "Authentication Layers": downstream keys must go to instance headers or Authorization | Option A / B |
| Native surface accepts both API Key and JWT | /v1/** accepts X-API-Key (API Key) and Jairouter_Token (JWT); when both are present JWT takes precedence; JWT must be enabled and not expired/revoked | Either x-api-key (simplest) or Jairouter_Token works |
Streaming message_start.id / model come from the gateway | id is a gateway-generated msg_* (the event is emitted before the first downstream chunk, so the downstream id is unknown yet); model is the requested model | Use the non-streaming endpoint when the real downstream id matters (it is passed through) |
Streaming emits text blocks plus tool_use blocks | The text block is always index: 0 and tool blocks increment in order of appearance; no thinking / redacted_thinking blocks are emitted (a request thinking field is accepted but not consumed) | Connect to Anthropic directly when thinking is required |
tool_choice:"none" omits tools | Per protocol that value disables tools, so the gateway forwards neither tools nor tool_choice (instead of sending "none") | Use it whenever you want "no tools at all" |
| Response-cache hit (non-streaming) | With jairouter.response-cache.enabled=true, a cache hit on the native surface (/v1/**) returns native JSON (fixed in v3.1 — no console envelope); the cache key now includes tools / tool_choice / tool-conversation messages (PR-5), so different tool rounds never collide | Streaming requests skip the cache by default (skip-streaming: true); with false, a hit is replayed chunk-by-chunk into the event sequence |
count_tokens is an estimate | It excludes the tools JSON Schema, tool_use / tool_result block bodies, image blocks, and downstream tokenizer differences | Treat it as an upper-bound reference |
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
401 Unauthorized | x-api-key is not a valid JAiRouter API Key, or a downstream key was put in x-api-key | Create/copy the console API Key; move the downstream key to instance headers or Authorization |
| Request succeeds but downstream returns 401/403 | Downstream key missing or expired (x-api-key is not forwarded) | Check the instance headers; verify with /api/config/instance/type/chat |
404 / 503 (model unavailable) | model not registered, instance unhealthy, or service circuit-broken | Cross-check the model name with GET /v1/models; check instance health and circuit breaker |
| Claude Code reports an "empty response" / no output | x-api-key is invalid or missing, and Jairouter_Token is not enabled / expired / revoked | Set a valid ANTHROPIC_API_KEY, or confirm JWT is enabled and the token is valid |
| Tool use does not work | The downstream does not support function calling (tools ignored → plain text with stop_reason: end_turn), or the model chose not to call a tool | Switch to a downstream with function calling; force a call with tool_choice:"any" (mapped to downstream required) to verify the path |
tool_use blocks appear but Claude Code does not run the tool | stop_reason is not tool_use (odd downstream finish reason), or tool_use.id does not match tool_result.tool_use_id | Inspect message_delta.delta.stop_reason in the event stream; make sure the client echoes the same tool_use_id |
Tool input shows {"raw_arguments": "…"} | Downstream function.arguments is not valid JSON (downstream bug or truncation) | Check the gateway warn log to identify the downstream; tolerate the raw field client-side or retry |
400 invalid_request_error: model 为必填字段 | The request body has no model | Add model or set ANTHROPIC_MODEL |
Streaming response has no event: lines | Upstream/intermediate proxy buffered the SSE stream | Use curl -N (disable buffering); turn off response buffering on reverse proxies |
Claude Code prints [claude-code:unrecognized_model] (e.g. "deepseek-chat" isn't described by this version's model catalog) | That Claude Code build's built-in model catalog does not know a custom model name, so auto-compact assumes the default 200k window | It does not affect responses (verified: exit 0 with normal text output); declare the real window, e.g. CLAUDE_CODE_MAX_CONTEXT_TOKENS=65536; only if needed (e.g. names using the [1m] suffix) consider CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1 |
Related Documentation¶
- Quick Start — start the gateway, configure your first instance
- API Key Management — create and rotate gateway credentials
- Instance Configuration Examples — downstream templates (including the Claude adapter)
- Universal API — console surface
/api/v1/** - Error Codes — gateway error codes