Skip to content
Closed
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
56 changes: 55 additions & 1 deletion codex_shim/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class ProviderSpec:
default_context: int
allowed_providers: frozenset[str]
template_path: Path
requires_api_key: bool = True


PROVIDER_SPECS = {
Expand Down Expand Up @@ -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,
),
}


Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions codex_shim/server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import argparse
import datetime as dt
import json
import time
from pathlib import Path
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion codex_shim/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 27 additions & 1 deletion tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
74 changes: 74 additions & 0 deletions tests/test_settings_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Loading