Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to NadirClaw will be documented in this file.

## [Unreleased]

## [0.21.1] - 2026-06-25

### Added
- **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.

### Fixed
- **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).

## [0.21.0] - 2026-06-24

### Added
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,17 @@ nadirclaw auth anthropic login
nadirclaw auth setup-token
```

#### Subscription tokens and premium-model access

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:

```bash
export NADIRCLAW_CLAUDE_CODE_IDENTITY=1
nadirclaw serve
```

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.

### What happens

Claude Code sends every request to Anthropic's API. With NadirClaw in front, each prompt is classified in ~10ms:
Expand Down
2 changes: 1 addition & 1 deletion nadirclaw/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""NadirClaw — Open-source LLM router."""

__version__ = "0.21.0"
__version__ = "0.21.1"
75 changes: 70 additions & 5 deletions nadirclaw/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -991,16 +991,31 @@ async def _call_litellm(
if cred_provider == "anthropic" and "sk-ant-oat" in api_key:
import httpx
model_id = litellm_model.removeprefix("anthropic/")
anthropic_messages = [
{"role": m["role"], "content": m["content"]}
for m in call_kwargs.get("messages", [])
if m.get("content") is not None
]
# Anthropic /v1/messages requires system prompts as a top-level
# `system` field and only accepts user/assistant roles in the
# messages array — split system/developer turns out here.
system_blocks: list[str] = []
anthropic_messages = []
for m in call_kwargs.get("messages", []):
if m.get("content") is None:
continue
if m["role"] in ("system", "developer"):
content = m["content"]
if isinstance(content, str):
system_blocks.append(content)
continue
anthropic_messages.append({"role": m["role"], "content": m["content"]})
anthropic_body = {
"model": model_id,
"messages": anthropic_messages,
"max_tokens": call_kwargs.get("max_tokens", 1024),
}
if system_blocks:
anthropic_body["system"] = "\n\n".join(system_blocks)
# OAuth tokens gate Sonnet/Opus behind the Claude Code identity
# block (#74); prepend it when opted in.
if settings.CLAUDE_CODE_IDENTITY:
_inject_claude_code_identity(anthropic_body)
if call_kwargs.get("temperature") is not None:
anthropic_body["temperature"] = call_kwargs["temperature"]
req_extra = request.model_extra or {}
Expand Down Expand Up @@ -2296,6 +2311,49 @@ async def view_logs(
_ANTHROPIC_UPSTREAM = "https://api.anthropic.com/v1/messages"
_CLAUDE_OAUTH_BETA = "oauth-2025-04-20,claude-code-20250219"

# The exact first system block the official Claude Code client sends. Anthropic
# gates premium models (Sonnet/Opus) behind subscription OAuth tokens unless the
# request leads with this identity string; raw API callers omit it and get a
# bare rate_limit_error on those models (see issue #74). Opt-in via
# settings.CLAUDE_CODE_IDENTITY — injected only for OAuth (sk-ant-oat*) tokens.
_CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."


def _has_claude_code_identity(system: Any) -> bool:
"""True if ``system`` already leads with the Claude Code identity block."""
if isinstance(system, str):
return system.lstrip().startswith(_CLAUDE_CODE_IDENTITY)
if isinstance(system, list) and system:
first = system[0]
if isinstance(first, dict):
return str(first.get("text", "")).lstrip().startswith(_CLAUDE_CODE_IDENTITY)
if isinstance(first, str):
return first.lstrip().startswith(_CLAUDE_CODE_IDENTITY)
return False


def _inject_claude_code_identity(body: Dict[str, Any]) -> bool:
"""Prepend the Claude Code identity block to an Anthropic ``/v1/messages`` body.

Normalizes ``system`` to the block-array form Anthropic expects and inserts
the identity as the first block, preserving any caller-supplied system
prompt after it. No-op (returns False) if the identity is already first.
Returns True if the body was modified.
"""
system = body.get("system")
if _has_claude_code_identity(system):
return False
identity = {"type": "text", "text": _CLAUDE_CODE_IDENTITY}
if system is None:
body["system"] = [identity]
elif isinstance(system, str):
body["system"] = [identity, {"type": "text", "text": system}] if system else [identity]
elif isinstance(system, list):
body["system"] = [identity, *system]
else:
body["system"] = [identity]
return True


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

headers = _anthropic_auth_headers(raw, body)

# OAuth subscription tokens (Bearer) gate Sonnet/Opus behind the Claude Code
# identity system block (#74). Inject it for OAuth requests when opted in.
identity_injected = False
if settings.CLAUDE_CODE_IDENTITY and "Authorization" in headers:
identity_injected = _inject_claude_code_identity(body)

import httpx
from fastapi.responses import StreamingResponse

Expand All @@ -2539,6 +2603,7 @@ async def anthropic_messages(
"requested_model": requested_model,
"selected_model": upstream_model,
"streaming": stream,
"claude_code_identity": identity_injected,
**analysis_info,
}

Expand Down
13 changes: 13 additions & 0 deletions nadirclaw/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,19 @@ def LOG_SYSTEM_PROMPTS(self) -> bool:
"""When True, log (redacted+truncated) system prompts to the request log."""
return os.getenv("NADIRCLAW_LOG_SYSTEM_PROMPTS", "").lower() in ("1", "true", "yes")

@property
def CLAUDE_CODE_IDENTITY(self) -> bool:
"""When True, prepend the Claude Code identity system block to Anthropic
OAuth (subscription / ``sk-ant-oat*``) requests.

Anthropic gates premium models (Sonnet/Opus) behind subscription tokens
unless the request's first system block is the official Claude Code
identity string — the real client always sends it, raw API callers don't.
Opt-in because it changes the system prompt seen by the model. No effect
on API-key (``sk-ant-api*``) credentials. See issue #74.
"""
return os.getenv("NADIRCLAW_CLAUDE_CODE_IDENTITY", "").lower() in ("1", "true", "yes", "on")

@property
def HSTS(self) -> bool:
"""When True, emit Strict-Transport-Security header. Opt-in for HTTPS deployments."""
Expand Down
105 changes: 105 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,46 @@ def test_extract_text_from_anthropic_response(self):
assert _extract_text_from_anthropic_response(payload) == "hello world"


class TestClaudeCodeIdentityInjection:
"""The opt-in Claude Code identity system block injection (#74)."""

IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."

def test_inject_into_body_without_system(self):
from nadirclaw.server import _inject_claude_code_identity
body = {"model": "claude-opus-4-7", "messages": []}
assert _inject_claude_code_identity(body) is True
assert body["system"] == [{"type": "text", "text": self.IDENTITY}]

def test_inject_prepends_before_string_system(self):
from nadirclaw.server import _inject_claude_code_identity
body = {"system": "Be terse."}
assert _inject_claude_code_identity(body) is True
assert body["system"] == [
{"type": "text", "text": self.IDENTITY},
{"type": "text", "text": "Be terse."},
]

def test_inject_prepends_before_block_array_system(self):
from nadirclaw.server import _inject_claude_code_identity
body = {"system": [{"type": "text", "text": "Be terse."}]}
assert _inject_claude_code_identity(body) is True
assert body["system"][0] == {"type": "text", "text": self.IDENTITY}
assert body["system"][1] == {"type": "text", "text": "Be terse."}

def test_inject_is_noop_when_identity_already_first(self):
from nadirclaw.server import _inject_claude_code_identity
body = {"system": [{"type": "text", "text": self.IDENTITY + " extra"}]}
assert _inject_claude_code_identity(body) is False
assert len(body["system"]) == 1

def test_inject_is_noop_when_string_system_already_identity(self):
from nadirclaw.server import _inject_claude_code_identity
body = {"system": self.IDENTITY}
assert _inject_claude_code_identity(body) is False
assert body["system"] == self.IDENTITY


class TestMessagesEndpoint:
"""The /v1/messages Anthropic-compatible proxy endpoint."""

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

@staticmethod
def _capturing_client():
"""Return (FakeClient, captured) recording the forwarded JSON body."""
import httpx
captured = {}

class _FakeResponse:
status_code = 200
headers = {"content-type": "application/json"}
def json(self):
return {"id": "msg_1", "model": captured.get("model"),
"content": [{"type": "text", "text": "ok"}],
"usage": {"input_tokens": 3, "output_tokens": 1}}

class _FakeClient:
def __init__(self, *a, **kw): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, headers=None, json=None):
captured["model"] = json.get("model")
captured["system"] = json.get("system")
captured["auth"] = headers.get("Authorization") or headers.get("x-api-key")
return _FakeResponse()

return httpx, _FakeClient, captured

def test_identity_injected_for_oauth_when_enabled(self, client, monkeypatch):
monkeypatch.setenv("NADIRCLAW_CLAUDE_CODE_IDENTITY", "1")
httpx, _FakeClient, captured = self._capturing_client()
with patch("nadirclaw.credentials.get_credential", return_value="sk-ant-oat01-test"), \
patch.object(httpx, "AsyncClient", _FakeClient):
resp = client.post("/v1/messages", json={
"model": "claude-opus-4-7", "max_tokens": 10,
"messages": [{"role": "user", "content": "hi"}],
})
assert resp.status_code == 200
assert captured["auth"] == "Bearer sk-ant-oat01-test"
assert captured["system"][0]["text"].startswith("You are Claude Code")

def test_identity_not_injected_when_disabled(self, client, monkeypatch):
monkeypatch.delenv("NADIRCLAW_CLAUDE_CODE_IDENTITY", raising=False)
httpx, _FakeClient, captured = self._capturing_client()
with patch("nadirclaw.credentials.get_credential", return_value="sk-ant-oat01-test"), \
patch.object(httpx, "AsyncClient", _FakeClient):
resp = client.post("/v1/messages", json={
"model": "claude-opus-4-7", "max_tokens": 10,
"messages": [{"role": "user", "content": "hi"}],
})
assert resp.status_code == 200
assert captured["system"] is None

def test_identity_not_injected_for_api_key_token(self, client, monkeypatch):
"""Even with the flag on, an sk-ant-api key uses x-api-key — no injection."""
monkeypatch.setenv("NADIRCLAW_CLAUDE_CODE_IDENTITY", "1")
httpx, _FakeClient, captured = self._capturing_client()
with patch("nadirclaw.credentials.get_credential", return_value="sk-ant-api-test"), \
patch.object(httpx, "AsyncClient", _FakeClient):
resp = client.post("/v1/messages", json={
"model": "claude-opus-4-7", "max_tokens": 10,
"messages": [{"role": "user", "content": "hi"}],
})
assert resp.status_code == 200
assert captured["auth"] == "sk-ant-api-test" # x-api-key path
assert captured["system"] is None

def test_upstream_error_is_passed_through(self, client):
import httpx

Expand Down
Loading