Skip to content

Commit ffce0ba

Browse files
doramirdorNadirclaude
authored
v0.21.1: Opt-in Claude Code identity injection for OAuth tokens (#74) (#75)
Anthropic gates premium models (Sonnet/Opus) behind subscription/OAuth tokens (sk-ant-oat*) unless the request leads with the official Claude Code identity system block. The real client always sends it; raw API/SDK callers omit it and get a bare rate_limit_error on those models while Haiku works. Add NADIRCLAW_CLAUDE_CODE_IDENTITY (default off). When enabled, /v1/messages and the OAuth completion path prepend "You are Claude Code, Anthropic's official CLI for Claude." as the first system block — only for Bearer/OAuth tokens, only when not already present, preserving any caller-supplied system prompt after it. Recorded as claude_code_identity on the request log. Also fix the OAuth completion path forwarding role:"system" messages inside the messages array (Anthropic requires system as a top-level field); system/developer turns are now collected into the top-level system field. Co-authored-by: Nadir <info@getnadir.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 202d92c commit ffce0ba

6 files changed

Lines changed: 208 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to NadirClaw will be documented in this file.
44

55
## [Unreleased]
66

7+
## [0.21.1] - 2026-06-25
8+
9+
### Added
10+
- **Opt-in Claude Code identity injection for OAuth tokens** (`NADIRCLAW_CLAUDE_CODE_IDENTITY=1`) — Anthropic gates premium models (Sonnet/Opus) behind subscription/OAuth tokens (`sk-ant-oat*`) unless the request leads with the official Claude Code identity system block. The real Claude Code client always sends it; raw API/SDK callers omit it and get a bare `rate_limit_error` on those models while Haiku works (#74). When enabled, `/v1/messages` and the OAuth completion path prepend `"You are Claude Code, Anthropic's official CLI for Claude."` as the first `system` block — only for Bearer/OAuth tokens (no effect on `sk-ant-api*` keys), only when not already present, preserving any caller-supplied system prompt after it. The decision is recorded as `claude_code_identity` on the request log. Default off, since it changes the system prompt the model sees.
11+
12+
### Fixed
13+
- **OAuth completion path sent `system` turns as chat messages** — the direct Anthropic OAuth call in `/v1/chat/completions` forwarded `role: "system"` messages inside the `messages` array, which Anthropic's `/v1/messages` API rejects (system must be a top-level field). System/developer turns are now collected into the top-level `system` field before forwarding (#74).
14+
715
## [0.21.0] - 2026-06-24
816

917
### Added

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,17 @@ nadirclaw auth anthropic login
740740
nadirclaw auth setup-token
741741
```
742742

743+
#### Subscription tokens and premium-model access
744+
745+
If you authenticate with a Claude **subscription** token (`sk-ant-oat*`, from `nadirclaw auth anthropic login` or `claude setup-token`) and find that **Haiku works but Sonnet/Opus return an immediate `rate_limit_error`**, Anthropic is likely gating premium models behind the official Claude Code identity. The real client always leads its requests with a fixed identity system block; raw API/SDK callers omit it. Opt in to have NadirClaw prepend it:
746+
747+
```bash
748+
export NADIRCLAW_CLAUDE_CODE_IDENTITY=1
749+
nadirclaw serve
750+
```
751+
752+
When enabled, NadirClaw prepends `"You are Claude Code, Anthropic's official CLI for Claude."` as the first `system` block on OAuth (`sk-ant-oat*`) requests — only when not already present, preserving any system prompt you sent after it. It has **no effect on API-key (`sk-ant-api*`) credentials** and is **off by default**, since it changes the system prompt the model sees.
753+
743754
### What happens
744755

745756
Claude Code sends every request to Anthropic's API. With NadirClaw in front, each prompt is classified in ~10ms:

nadirclaw/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""NadirClaw — Open-source LLM router."""
22

3-
__version__ = "0.21.0"
3+
__version__ = "0.21.1"

nadirclaw/server.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -991,16 +991,31 @@ async def _call_litellm(
991991
if cred_provider == "anthropic" and "sk-ant-oat" in api_key:
992992
import httpx
993993
model_id = litellm_model.removeprefix("anthropic/")
994-
anthropic_messages = [
995-
{"role": m["role"], "content": m["content"]}
996-
for m in call_kwargs.get("messages", [])
997-
if m.get("content") is not None
998-
]
994+
# Anthropic /v1/messages requires system prompts as a top-level
995+
# `system` field and only accepts user/assistant roles in the
996+
# messages array — split system/developer turns out here.
997+
system_blocks: list[str] = []
998+
anthropic_messages = []
999+
for m in call_kwargs.get("messages", []):
1000+
if m.get("content") is None:
1001+
continue
1002+
if m["role"] in ("system", "developer"):
1003+
content = m["content"]
1004+
if isinstance(content, str):
1005+
system_blocks.append(content)
1006+
continue
1007+
anthropic_messages.append({"role": m["role"], "content": m["content"]})
9991008
anthropic_body = {
10001009
"model": model_id,
10011010
"messages": anthropic_messages,
10021011
"max_tokens": call_kwargs.get("max_tokens", 1024),
10031012
}
1013+
if system_blocks:
1014+
anthropic_body["system"] = "\n\n".join(system_blocks)
1015+
# OAuth tokens gate Sonnet/Opus behind the Claude Code identity
1016+
# block (#74); prepend it when opted in.
1017+
if settings.CLAUDE_CODE_IDENTITY:
1018+
_inject_claude_code_identity(anthropic_body)
10041019
if call_kwargs.get("temperature") is not None:
10051020
anthropic_body["temperature"] = call_kwargs["temperature"]
10061021
req_extra = request.model_extra or {}
@@ -2296,6 +2311,49 @@ async def view_logs(
22962311
_ANTHROPIC_UPSTREAM = "https://api.anthropic.com/v1/messages"
22972312
_CLAUDE_OAUTH_BETA = "oauth-2025-04-20,claude-code-20250219"
22982313

2314+
# The exact first system block the official Claude Code client sends. Anthropic
2315+
# gates premium models (Sonnet/Opus) behind subscription OAuth tokens unless the
2316+
# request leads with this identity string; raw API callers omit it and get a
2317+
# bare rate_limit_error on those models (see issue #74). Opt-in via
2318+
# settings.CLAUDE_CODE_IDENTITY — injected only for OAuth (sk-ant-oat*) tokens.
2319+
_CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."
2320+
2321+
2322+
def _has_claude_code_identity(system: Any) -> bool:
2323+
"""True if ``system`` already leads with the Claude Code identity block."""
2324+
if isinstance(system, str):
2325+
return system.lstrip().startswith(_CLAUDE_CODE_IDENTITY)
2326+
if isinstance(system, list) and system:
2327+
first = system[0]
2328+
if isinstance(first, dict):
2329+
return str(first.get("text", "")).lstrip().startswith(_CLAUDE_CODE_IDENTITY)
2330+
if isinstance(first, str):
2331+
return first.lstrip().startswith(_CLAUDE_CODE_IDENTITY)
2332+
return False
2333+
2334+
2335+
def _inject_claude_code_identity(body: Dict[str, Any]) -> bool:
2336+
"""Prepend the Claude Code identity block to an Anthropic ``/v1/messages`` body.
2337+
2338+
Normalizes ``system`` to the block-array form Anthropic expects and inserts
2339+
the identity as the first block, preserving any caller-supplied system
2340+
prompt after it. No-op (returns False) if the identity is already first.
2341+
Returns True if the body was modified.
2342+
"""
2343+
system = body.get("system")
2344+
if _has_claude_code_identity(system):
2345+
return False
2346+
identity = {"type": "text", "text": _CLAUDE_CODE_IDENTITY}
2347+
if system is None:
2348+
body["system"] = [identity]
2349+
elif isinstance(system, str):
2350+
body["system"] = [identity, {"type": "text", "text": system}] if system else [identity]
2351+
elif isinstance(system, list):
2352+
body["system"] = [identity, *system]
2353+
else:
2354+
body["system"] = [identity]
2355+
return True
2356+
22992357

23002358
def _anthropic_messages_to_chat(messages: List[Dict[str, Any]]) -> List[ChatMessage]:
23012359
"""Convert Anthropic message blocks to our internal ChatMessage shape.
@@ -2531,6 +2589,12 @@ async def anthropic_messages(
25312589

25322590
headers = _anthropic_auth_headers(raw, body)
25332591

2592+
# OAuth subscription tokens (Bearer) gate Sonnet/Opus behind the Claude Code
2593+
# identity system block (#74). Inject it for OAuth requests when opted in.
2594+
identity_injected = False
2595+
if settings.CLAUDE_CODE_IDENTITY and "Authorization" in headers:
2596+
identity_injected = _inject_claude_code_identity(body)
2597+
25342598
import httpx
25352599
from fastapi.responses import StreamingResponse
25362600

@@ -2539,6 +2603,7 @@ async def anthropic_messages(
25392603
"requested_model": requested_model,
25402604
"selected_model": upstream_model,
25412605
"streaming": stream,
2606+
"claude_code_identity": identity_injected,
25422607
**analysis_info,
25432608
}
25442609

nadirclaw/settings.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,19 @@ def LOG_SYSTEM_PROMPTS(self) -> bool:
353353
"""When True, log (redacted+truncated) system prompts to the request log."""
354354
return os.getenv("NADIRCLAW_LOG_SYSTEM_PROMPTS", "").lower() in ("1", "true", "yes")
355355

356+
@property
357+
def CLAUDE_CODE_IDENTITY(self) -> bool:
358+
"""When True, prepend the Claude Code identity system block to Anthropic
359+
OAuth (subscription / ``sk-ant-oat*``) requests.
360+
361+
Anthropic gates premium models (Sonnet/Opus) behind subscription tokens
362+
unless the request's first system block is the official Claude Code
363+
identity string — the real client always sends it, raw API callers don't.
364+
Opt-in because it changes the system prompt seen by the model. No effect
365+
on API-key (``sk-ant-api*``) credentials. See issue #74.
366+
"""
367+
return os.getenv("NADIRCLAW_CLAUDE_CODE_IDENTITY", "").lower() in ("1", "true", "yes", "on")
368+
356369
@property
357370
def HSTS(self) -> bool:
358371
"""When True, emit Strict-Transport-Security header. Opt-in for HTTPS deployments."""

tests/test_server.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,46 @@ def test_extract_text_from_anthropic_response(self):
127127
assert _extract_text_from_anthropic_response(payload) == "hello world"
128128

129129

130+
class TestClaudeCodeIdentityInjection:
131+
"""The opt-in Claude Code identity system block injection (#74)."""
132+
133+
IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."
134+
135+
def test_inject_into_body_without_system(self):
136+
from nadirclaw.server import _inject_claude_code_identity
137+
body = {"model": "claude-opus-4-7", "messages": []}
138+
assert _inject_claude_code_identity(body) is True
139+
assert body["system"] == [{"type": "text", "text": self.IDENTITY}]
140+
141+
def test_inject_prepends_before_string_system(self):
142+
from nadirclaw.server import _inject_claude_code_identity
143+
body = {"system": "Be terse."}
144+
assert _inject_claude_code_identity(body) is True
145+
assert body["system"] == [
146+
{"type": "text", "text": self.IDENTITY},
147+
{"type": "text", "text": "Be terse."},
148+
]
149+
150+
def test_inject_prepends_before_block_array_system(self):
151+
from nadirclaw.server import _inject_claude_code_identity
152+
body = {"system": [{"type": "text", "text": "Be terse."}]}
153+
assert _inject_claude_code_identity(body) is True
154+
assert body["system"][0] == {"type": "text", "text": self.IDENTITY}
155+
assert body["system"][1] == {"type": "text", "text": "Be terse."}
156+
157+
def test_inject_is_noop_when_identity_already_first(self):
158+
from nadirclaw.server import _inject_claude_code_identity
159+
body = {"system": [{"type": "text", "text": self.IDENTITY + " extra"}]}
160+
assert _inject_claude_code_identity(body) is False
161+
assert len(body["system"]) == 1
162+
163+
def test_inject_is_noop_when_string_system_already_identity(self):
164+
from nadirclaw.server import _inject_claude_code_identity
165+
body = {"system": self.IDENTITY}
166+
assert _inject_claude_code_identity(body) is False
167+
assert body["system"] == self.IDENTITY
168+
169+
130170
class TestMessagesEndpoint:
131171
"""The /v1/messages Anthropic-compatible proxy endpoint."""
132172

@@ -191,6 +231,71 @@ async def post(self, url, headers=None, json=None):
191231
# OAuth token → Bearer header
192232
assert captured["auth"] == "Bearer sk-ant-oat01-test"
193233

234+
@staticmethod
235+
def _capturing_client():
236+
"""Return (FakeClient, captured) recording the forwarded JSON body."""
237+
import httpx
238+
captured = {}
239+
240+
class _FakeResponse:
241+
status_code = 200
242+
headers = {"content-type": "application/json"}
243+
def json(self):
244+
return {"id": "msg_1", "model": captured.get("model"),
245+
"content": [{"type": "text", "text": "ok"}],
246+
"usage": {"input_tokens": 3, "output_tokens": 1}}
247+
248+
class _FakeClient:
249+
def __init__(self, *a, **kw): pass
250+
async def __aenter__(self): return self
251+
async def __aexit__(self, *a): return False
252+
async def post(self, url, headers=None, json=None):
253+
captured["model"] = json.get("model")
254+
captured["system"] = json.get("system")
255+
captured["auth"] = headers.get("Authorization") or headers.get("x-api-key")
256+
return _FakeResponse()
257+
258+
return httpx, _FakeClient, captured
259+
260+
def test_identity_injected_for_oauth_when_enabled(self, client, monkeypatch):
261+
monkeypatch.setenv("NADIRCLAW_CLAUDE_CODE_IDENTITY", "1")
262+
httpx, _FakeClient, captured = self._capturing_client()
263+
with patch("nadirclaw.credentials.get_credential", return_value="sk-ant-oat01-test"), \
264+
patch.object(httpx, "AsyncClient", _FakeClient):
265+
resp = client.post("/v1/messages", json={
266+
"model": "claude-opus-4-7", "max_tokens": 10,
267+
"messages": [{"role": "user", "content": "hi"}],
268+
})
269+
assert resp.status_code == 200
270+
assert captured["auth"] == "Bearer sk-ant-oat01-test"
271+
assert captured["system"][0]["text"].startswith("You are Claude Code")
272+
273+
def test_identity_not_injected_when_disabled(self, client, monkeypatch):
274+
monkeypatch.delenv("NADIRCLAW_CLAUDE_CODE_IDENTITY", raising=False)
275+
httpx, _FakeClient, captured = self._capturing_client()
276+
with patch("nadirclaw.credentials.get_credential", return_value="sk-ant-oat01-test"), \
277+
patch.object(httpx, "AsyncClient", _FakeClient):
278+
resp = client.post("/v1/messages", json={
279+
"model": "claude-opus-4-7", "max_tokens": 10,
280+
"messages": [{"role": "user", "content": "hi"}],
281+
})
282+
assert resp.status_code == 200
283+
assert captured["system"] is None
284+
285+
def test_identity_not_injected_for_api_key_token(self, client, monkeypatch):
286+
"""Even with the flag on, an sk-ant-api key uses x-api-key — no injection."""
287+
monkeypatch.setenv("NADIRCLAW_CLAUDE_CODE_IDENTITY", "1")
288+
httpx, _FakeClient, captured = self._capturing_client()
289+
with patch("nadirclaw.credentials.get_credential", return_value="sk-ant-api-test"), \
290+
patch.object(httpx, "AsyncClient", _FakeClient):
291+
resp = client.post("/v1/messages", json={
292+
"model": "claude-opus-4-7", "max_tokens": 10,
293+
"messages": [{"role": "user", "content": "hi"}],
294+
})
295+
assert resp.status_code == 200
296+
assert captured["auth"] == "sk-ant-api-test" # x-api-key path
297+
assert captured["system"] is None
298+
194299
def test_upstream_error_is_passed_through(self, client):
195300
import httpx
196301

0 commit comments

Comments
 (0)