Skip to content

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.

CapabilityEndpointDescription
Messages (non-streaming)POST /v1/messagesDownstream native JSON → Anthropic Message
Messages (streaming)POST /v1/messages (stream: true)Downstream OpenAI-style SSE → Anthropic event sequence
Tool usePOST /v1/messagesAnthropic tools / tool_use / tool_result ↔ downstream OpenAI-compatible function calling (since v3.1 PR-5, so Claude Code's tools actually work)
Token countingPOST /v1/messages/count_tokensLocal estimate {"input_tokens": N}, never calls downstream
Model listGET /v1/modelsOpenAI-shaped catalog of available models

In one sentence: /api/v1/** (console surface) returns RouterResponse envelopes; /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

  1. JAiRouter is running (default http://localhost:8080).
  2. An API Key has been created in the console (it authenticates both /api/** and /v1/**).
  3. The target model is registered as an instance or pool (service type chat), e.g. deepseek-chat or qwen2:7b.
  4. 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-key is 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:

HeaderBelongs toForwarded downstreamNotes
x-api-key (X-API-Key is equivalent)Gateway credential❌ NoAPI Key created in the JAiRouter console; used for gateway auth + service permission (ROLE_CHAT)
Jairouter_TokenGateway credential (JWT)❌ NoJWT issued by the console/login endpoint; takes precedence when both are present
AuthorizationDownstream credential✅ Yese.g. Bearer sk-xxx (OpenAI/DeepSeek), Bearer <token>; the gateway never consumes it
Instance headersDownstream credential✅ Injected by gatewayCustom request headers from instance config, used to pass Authorization / x-api-key etc. downstream

Three rules to remember:

  1. x-api-key = gateway credential, not forwarded downstream. If you put a downstream key in x-api-key, the downstream receives a request with no credentials → 401 / unauthenticated failure.
  2. Configure the downstream key in instance headers (recommended), or send it per request via Authorization: Bearer <downstream key>. When both are present, instance headers win (instance configuration overrides the incoming Authorization).
  3. Credential precedence: Jairouter_Token (JWT) > X-API-Key (API Key). Authorization is unrelated to gateway authentication; it only affects the downstream.

Native-surface authentication note: /v1/** accepts both X-API-Key (API Key) and Jairouter_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, using ANTHROPIC_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

OptionWhere the downstream key livesUse caseProsNotes
A (recommended)Instance headersProduction / shared gatewayKeys stay centralized; clients hold nothing; instance config winsSwitch downstream keys by editing config only
BRequest Authorization: Bearer <downstream key>Trials / per-request keysNo gateway config changeClaude Code can only attach it via ANTHROPIC_CUSTOM_HEADERS etc.; only one Authorization slot exists
CInstance headers with a custom header name (e.g. api-key)Downstream is not Authorization-styleWorks with any downstream protocolVerify which header the downstream expects
# 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-key lives in the instance headers and targets the downstream Anthropic API. It is unrelated to the x-api-key the client sends to the gateway. With the claude adapter, the anthropic-version header 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):

OrderEventPayload highlights
1message_startmessage.id (gateway-generated msg_*), model (requested model), content: [], stop_reason: null, usage.input_tokens (estimated), usage.output_tokens: 0
2content_block_startindex: 0, content_block: {type:"text", text:""}
3…Ncontent_block_deltaindex: 0, delta: {type:"text_delta", text:"…"}
N+1content_block_stopindex: 0
N+2message_deltadelta: {stop_reason:"end_turn"\|"max_tokens", stop_sequence:null}, usage.output_tokens (downstream usage preferred, otherwise estimated from accumulated text)
N+3message_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:

event:error
data:{"type":"error","error":{"type":"api_error","message":"…"}}

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):

AnthropicDownstream 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: text blocks become content, multiple tool_use blocks become tool_calls in block order (OpenAI structurally puts content first and tool_calls after, so no finer interleaving can be expressed);
  • within one user message: tool_result blocks become role:"tool" messages emitted before the text message, so they directly follow the assistant message carrying tool_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.

OrderEventPayload highlights
1message_startSame as a plain text stream (usage.input_tokens is the request-side estimate)
2content_block_startindex: 0, content_block: {type:"text", text:""}
3…kcontent_block_deltaindex: 0, delta: {type:"text_delta", text:"…"}
k+1content_block_stopindex: 0 (the text block is closed before switching to a tool block)
k+2content_block_startindex: n, content_block: {type:"tool_use", id:"call_*", name:"<tool>"}
k+3…mcontent_block_deltaindex: n, delta: {type:"input_json_delta", partial_json:"<argument fragment>"} (concatenate in arrival order to get the full input)
m+1content_block_stopindex: n
…(repeat k+2…m+1)one block per tool, indices increment
last-1message_deltadelta: {stop_reason:"tool_use", stop_sequence:null}, usage.output_tokens
lastmessage_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

CaseBehavior
Downstream function.arguments is invalid JSONtool_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/missingtool_use.input is {} (a tool without arguments)
Downstream tool_calls[].id missingThe gateway generates toolu_*; a request-side tool_use.id missing yields call_*
Request tool_use.id / tool_result.tool_use_id missingDownstream tool_call_id is an empty string, with a warn log
Downstream does not support function callingtools 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/messages request carries tools, and the downstream request body carries tools / tool_calls (with tracing enabled, the downstream call span carries adapter.api_standard=openai);
  • the event stream (curl -N, or Claude Code --verbose): a content_block_start with "type":"tool_use" and a message_delta with "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"}]
  }'
{"input_tokens": 10}
  • The body is the same as /v1/messages, but max_tokens / stream / tools are ignored: only the text blocks in system and messages are 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

curl -H "x-api-key: <JAiRouter API Key>" "http://localhost:8080/v1/models"

Returns the OpenAI shape {"object":"list","data":[{"id":"deepseek-chat","object":"model",…}]}; each id can be used as the model field.

Current Limitations

LimitationDescriptionWorkaround
Tool use depends on downstream function callingtools / 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 nameNo 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 surfacesGateway 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 verbatimDetect the shape by the presence of the type field
x-api-key is never forwardedSee "Authentication Layers": downstream keys must go to instance headers or AuthorizationOption 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/revokedEither x-api-key (simplest) or Jairouter_Token works
Streaming message_start.id / model come from the gatewayid 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 modelUse the non-streaming endpoint when the real downstream id matters (it is passed through)
Streaming emits text blocks plus tool_use blocksThe 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 toolsPer 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 collideStreaming 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 estimateIt excludes the tools JSON Schema, tool_use / tool_result block bodies, image blocks, and downstream tokenizer differencesTreat it as an upper-bound reference

Troubleshooting

SymptomCauseFix
401 Unauthorizedx-api-key is not a valid JAiRouter API Key, or a downstream key was put in x-api-keyCreate/copy the console API Key; move the downstream key to instance headers or Authorization
Request succeeds but downstream returns 401/403Downstream 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-brokenCross-check the model name with GET /v1/models; check instance health and circuit breaker
Claude Code reports an "empty response" / no outputx-api-key is invalid or missing, and Jairouter_Token is not enabled / expired / revokedSet a valid ANTHROPIC_API_KEY, or confirm JWT is enabled and the token is valid
Tool use does not workThe downstream does not support function calling (tools ignored → plain text with stop_reason: end_turn), or the model chose not to call a toolSwitch 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 toolstop_reason is not tool_use (odd downstream finish reason), or tool_use.id does not match tool_result.tool_use_idInspect 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 modelAdd model or set ANTHROPIC_MODEL
Streaming response has no event: linesUpstream/intermediate proxy buffered the SSE streamUse 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 windowIt 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