diff --git a/CHANGELOG.md b/CHANGELOG.md index 94a6ea1..8f8db7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index a22607a..4687ac1 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/nadirclaw/__init__.py b/nadirclaw/__init__.py index 8318154..038ef6b 100644 --- a/nadirclaw/__init__.py +++ b/nadirclaw/__init__.py @@ -1,3 +1,3 @@ """NadirClaw — Open-source LLM router.""" -__version__ = "0.21.0" +__version__ = "0.21.1" diff --git a/nadirclaw/server.py b/nadirclaw/server.py index e1797a3..3373f86 100644 --- a/nadirclaw/server.py +++ b/nadirclaw/server.py @@ -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 {} @@ -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. @@ -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 @@ -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, } diff --git a/nadirclaw/settings.py b/nadirclaw/settings.py index cccc89b..604b608 100644 --- a/nadirclaw/settings.py +++ b/nadirclaw/settings.py @@ -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.""" diff --git a/tests/test_server.py b/tests/test_server.py index 1aa72ad..3c46c7a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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.""" @@ -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