From e3625ede0135eb9006f65fb820e30d0c23a043f4 Mon Sep 17 00:00:00 2001 From: luckeyfaraday Date: Tue, 26 May 2026 00:54:45 +0200 Subject: [PATCH] Add anthropic-oauth provider using Claude Code credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users run Claude models through Codex Desktop without a separate Anthropic API key by reusing the OAuth token Claude Code already holds in ~/.claude/.credentials.json. - New `anthropic-oauth` provider in PROVIDER_SPECS (port 8768) - `codex-shim setup anthropic-oauth` writes keyless settings with Opus 4.7 + Sonnet 4.6 pre-configured; works non-interactively - `codex-shim anthropic-oauth .` runs Codex through that provider - Server reads the OAuth access token at request time, checks expiry, and injects it as Bearer — no token ever stored in settings - `requires_api_key=False` on ProviderSpec skips the key prompt/check for the whole provider lifecycle (setup, status, doctor) - `ShimModel.is_anthropic` now covers `anthropic-oauth` so routing and translation work without changes Co-Authored-By: Claude Sonnet 4.6 --- codex_shim/cli.py | 56 ++++++++++++++++++++++++- codex_shim/server.py | 35 ++++++++++++++++ codex_shim/settings.py | 2 +- tests/test_server.py | 28 ++++++++++++- tests/test_settings_catalog.py | 74 ++++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 3 deletions(-) diff --git a/codex_shim/cli.py b/codex_shim/cli.py index 9a4d6fb0..86d6b251 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -58,6 +58,7 @@ class ProviderSpec: default_context: int allowed_providers: frozenset[str] template_path: Path + requires_api_key: bool = True PROVIDER_SPECS = { @@ -89,6 +90,21 @@ class ProviderSpec: allowed_providers=frozenset({"minimax", "generic-chat-completion-api", "openai"}), template_path=PROJECT_ROOT / "examples" / "minimax-models.example.json", ), + "anthropic-oauth": ProviderSpec( + name="anthropic-oauth", + title="Claude Code OAuth", + settings_path=DEFAULT_SETTINGS.parent / "anthropic-oauth-models.json", + port=8768, + placeholder_key="", + default_model="claude-opus-4-7", + default_display_name="Claude Opus 4.7", + default_provider="anthropic-oauth", + default_base_url="https://api.anthropic.com/v1", + default_context=1000000, + allowed_providers=frozenset({"anthropic-oauth"}), + template_path=PROJECT_ROOT / "examples" / "anthropic-oauth-models.example.json", + requires_api_key=False, + ), } @@ -261,12 +277,17 @@ def _provider_settings_status(spec: ProviderSpec) -> str: return "unsupported_provider" if not model.base_url: return "missing_base_url" - if not model.api_key or model.api_key == spec.placeholder_key: + if spec.requires_api_key and (not model.api_key or model.api_key == spec.placeholder_key): return "missing_key" return "ok" def _prompt_provider_setup(spec: ProviderSpec, status: str) -> None: + if not spec.requires_api_key: + _write_anthropic_oauth_settings(spec) + print(f"Wrote {spec.settings_path}", file=sys.stderr) + return + if not sys.stdin.isatty(): print( f"{spec.title} settings are not configured ({status}):\n" @@ -370,6 +391,39 @@ def _write_provider_settings( os.umask(old_umask) +def _write_anthropic_oauth_settings(spec: ProviderSpec) -> None: + spec.settings_path.parent.mkdir(parents=True, exist_ok=True) + try: + spec.settings_path.parent.chmod(0o700) + except OSError: + pass + old_umask = os.umask(0o077) + try: + payload = { + "models": [ + { + "model": "claude-opus-4-7", + "provider": "anthropic-oauth", + "base_url": "https://api.anthropic.com/v1", + "display_name": "Claude Opus 4.7 (OAuth)", + "max_context_limit": 1000000, + "max_output_tokens": 32000, + }, + { + "model": "claude-sonnet-4-6", + "provider": "anthropic-oauth", + "base_url": "https://api.anthropic.com/v1", + "display_name": "Claude Sonnet 4.6 (OAuth)", + "max_context_limit": 200000, + "max_output_tokens": 32000, + }, + ] + } + spec.settings_path.write_text(json.dumps(payload, indent=2) + "\n") + finally: + os.umask(old_umask) + + def _first_model_slug(settings_path: Path) -> str | None: models = ModelSettings(settings_path).load() return models[0].slug if models else None diff --git a/codex_shim/server.py b/codex_shim/server.py index bb906ab2..4c511805 100644 --- a/codex_shim/server.py +++ b/codex_shim/server.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import datetime as dt import json import time from pathlib import Path @@ -978,12 +979,46 @@ def _anthropic_headers(route: ShimModel) -> dict[str, str]: "anthropic-version": "2023-06-01", **route.extra_headers, } + if route.provider == "anthropic-oauth": + headers["Authorization"] = f"Bearer {_claude_code_oauth_access_token()}" + return headers if route.api_key: headers.setdefault("x-api-key", route.api_key) headers.setdefault("Authorization", f"Bearer {route.api_key}") return headers +def _claude_code_oauth_access_token() -> str: + auth_path = Path.home() / ".claude" / ".credentials.json" + try: + auth = json.loads(auth_path.read_text()) + except (FileNotFoundError, json.JSONDecodeError, OSError) as exc: + raise web.HTTPUnauthorized( + text="Claude Code credentials not found; run `claude` and log in first." + ) from exc + oauth = auth.get("claudeAiOauth") or {} + token = str(oauth.get("accessToken") or "") + if not token: + raise web.HTTPUnauthorized( + text="Claude Code credentials have no access token; run `claude` and log in again." + ) + expires_at = _parse_int(oauth.get("expiresAt")) + if expires_at is not None: + now_ms = int(dt.datetime.now(dt.timezone.utc).timestamp() * 1000) + if expires_at <= now_ms + 60_000: + raise web.HTTPUnauthorized( + text="Claude Code OAuth token is expired or near expiry; run `claude` to refresh." + ) + return token + + +def _parse_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + def _sse_response() -> web.StreamResponse: response = web.StreamResponse( status=200, diff --git a/codex_shim/settings.py b/codex_shim/settings.py index a1e42cf4..ff8608b1 100644 --- a/codex_shim/settings.py +++ b/codex_shim/settings.py @@ -59,7 +59,7 @@ class ShimModel: @property def is_anthropic(self) -> bool: - return self.provider == "anthropic" + return self.provider in {"anthropic", "anthropic-oauth"} @property def is_openai_chat(self) -> bool: diff --git a/tests/test_server.py b/tests/test_server.py index 01b4c5a7..2452abef 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,7 +6,8 @@ from aiohttp import web from aiohttp.test_utils import TestClient, TestServer -from codex_shim.server import ShimServer, _rewrite_response_model, _sanitize_chatgpt_passthrough_body +from codex_shim.server import ShimServer, _rewrite_response_model, _sanitize_chatgpt_passthrough_body, _anthropic_headers, _claude_code_oauth_access_token +from codex_shim.settings import ShimModel from codex_shim.translate import SHIM_ENCRYPTED_CONTENT_PREFIX @@ -339,3 +340,28 @@ async def messages(request): await shim_client.close() await upstream_client.close() + + +def test_anthropic_oauth_headers_inject_bearer_not_x_api_key(monkeypatch): + route = ShimModel( + slug="claude-opus-4-7", + model="claude-opus-4-7", + display_name="Claude Opus 4.7 (OAuth)", + provider="anthropic-oauth", + base_url="https://api.anthropic.com/v1", + ) + + monkeypatch.setattr("codex_shim.server._claude_code_oauth_access_token", lambda: "test-oauth-token") + + headers = _anthropic_headers(route) + + assert headers["Authorization"] == "Bearer test-oauth-token" + assert "x-api-key" not in headers + + +def test_claude_code_oauth_token_raises_on_missing_credentials(tmp_path, monkeypatch): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + import pytest + with pytest.raises(Exception): + _claude_code_oauth_access_token() diff --git a/tests/test_settings_catalog.py b/tests/test_settings_catalog.py index 79bf3706..ba73cbe3 100644 --- a/tests/test_settings_catalog.py +++ b/tests/test_settings_catalog.py @@ -104,6 +104,29 @@ def test_minimax_provider_uses_openai_chat_translation(tmp_path): assert model.is_openai_chat is True +def test_anthropic_oauth_provider_routes_as_anthropic(tmp_path): + settings = tmp_path / "anthropic-oauth-models.json" + settings.write_text( + json.dumps( + { + "models": [ + { + "model": "claude-opus-4-7", + "display_name": "Claude Opus 4.7 (OAuth)", + "provider": "anthropic-oauth", + "base_url": "https://api.anthropic.com/v1", + } + ] + } + ) + ) + + [model] = ModelSettings(settings).load() + + assert model.is_anthropic is True + assert model.is_openai_chat is False + + def test_catalog_preserves_context_and_visibility(): model = ModelSettingsFixture.one() entry = catalog_entry(model) @@ -320,3 +343,54 @@ def one(): ) ) return ModelSettings(path).load()[0] + + +def test_anthropic_oauth_setup_writes_keyless_settings(tmp_path, monkeypatch): + settings = tmp_path / "anthropic-oauth-models.json" + spec = cli.ProviderSpec( + name="anthropic-oauth", + title="Claude Code OAuth", + settings_path=settings, + port=8768, + placeholder_key="", + default_model="claude-opus-4-7", + default_display_name="Claude Opus 4.7", + default_provider="anthropic-oauth", + default_base_url="https://api.anthropic.com/v1", + default_context=1000000, + allowed_providers=frozenset({"anthropic-oauth"}), + template_path=tmp_path / "example.json", + requires_api_key=False, + ) + monkeypatch.setitem(cli.PROVIDER_SPECS, "anthropic-oauth", spec) + + assert cli.setup_provider("anthropic-oauth") == 0 + + models = json.loads(settings.read_text())["models"] + assert [m["model"] for m in models] == ["claude-opus-4-7", "claude-sonnet-4-6"] + assert all(m["provider"] == "anthropic-oauth" for m in models) + assert all("api_key" not in m for m in models) + + +def test_anthropic_oauth_setup_works_non_interactively(tmp_path, monkeypatch): + settings = tmp_path / "anthropic-oauth-models.json" + spec = cli.ProviderSpec( + name="anthropic-oauth", + title="Claude Code OAuth", + settings_path=settings, + port=8768, + placeholder_key="", + default_model="claude-opus-4-7", + default_display_name="Claude Opus 4.7", + default_provider="anthropic-oauth", + default_base_url="https://api.anthropic.com/v1", + default_context=1000000, + allowed_providers=frozenset({"anthropic-oauth"}), + template_path=tmp_path / "example.json", + requires_api_key=False, + ) + monkeypatch.setitem(cli.PROVIDER_SPECS, "anthropic-oauth", spec) + monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False) + + assert cli.setup_provider("anthropic-oauth") == 0 + assert settings.exists()