pydantic-claude-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ """Use your Claude Code subscription from a Pydantic AI Agent.
2
+
3
+ Quick start:
4
+
5
+ from pydantic_ai import Agent
6
+ from pydantic_ai_claude_code import ClaudeCodeProvider
7
+
8
+ provider = ClaudeCodeProvider()
9
+ agent = Agent(provider.model('claude-sonnet-4-5'))
10
+ """
11
+
12
+ from .credentials import ClaudeCodeCredentials
13
+ from .flow import (
14
+ ClaudeCodeOAuthFlow,
15
+ exchange_code,
16
+ login,
17
+ parse_pasteback,
18
+ refresh_credentials,
19
+ )
20
+ from .model import ClaudeCodeModel
21
+ from .provider import ClaudeCodeProvider
22
+ from .storage import ClaudeCodeTokenStore, default_auth_path
23
+
24
+ __all__ = [
25
+ "ClaudeCodeCredentials",
26
+ "ClaudeCodeModel",
27
+ "ClaudeCodeOAuthFlow",
28
+ "ClaudeCodeProvider",
29
+ "ClaudeCodeTokenStore",
30
+ "default_auth_path",
31
+ "exchange_code",
32
+ "login",
33
+ "parse_pasteback",
34
+ "refresh_credentials",
35
+ ]
@@ -0,0 +1,19 @@
1
+ """Run `python -m pydantic_ai_claude_code login` to authenticate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+
8
+ from . import login
9
+
10
+
11
+ def main() -> None:
12
+ if "login" not in sys.argv[1:]:
13
+ print("Usage: python -m pydantic_ai_claude_code login", file=sys.stderr)
14
+ sys.exit(2)
15
+ asyncio.run(login())
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
@@ -0,0 +1,83 @@
1
+ """httpx2 auth shim that authenticates Messages API calls with Claude Code tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import AsyncGenerator, Awaitable, Callable
8
+
9
+ import httpx2
10
+
11
+ from pydantic_ai.exceptions import UserError
12
+
13
+ from . import config
14
+ from .credentials import ClaudeCodeCredentials
15
+ from .flow import refresh_credentials
16
+
17
+ CredentialsRefreshCallback = Callable[[ClaudeCodeCredentials], Awaitable[None]]
18
+
19
+
20
+ class ClaudeCodeCredentialsPersistenceError(UserError):
21
+ """Raised after refreshed in-memory credentials could not be persisted."""
22
+
23
+
24
+ def _expires_soon(credentials: ClaudeCodeCredentials) -> bool:
25
+ if credentials.expires_at is None:
26
+ return False
27
+ return time.time() + 30 >= credentials.expires_at.timestamp()
28
+
29
+
30
+ class _ClaudeCodeAuth(httpx2.Auth):
31
+ requires_response_body = True
32
+
33
+ def __init__(self, credentials: ClaudeCodeCredentials, callback: CredentialsRefreshCallback | None = None) -> None:
34
+ self.credentials = credentials
35
+ self.callback = callback
36
+ self.revision = 0
37
+ self.lock = asyncio.Lock()
38
+ self.refresh_client = httpx2.AsyncClient()
39
+
40
+ async def _refresh(self, used_revision: int) -> None:
41
+ async with self.lock:
42
+ if self.revision != used_revision:
43
+ return
44
+ updated = await refresh_credentials(self.credentials, http_client=self.refresh_client)
45
+ self.credentials = updated
46
+ self.revision += 1
47
+ if self.callback is not None:
48
+ try:
49
+ await self.callback(updated)
50
+ except Exception as exc: # noqa: BLE001 - surface the persistence failure to the caller
51
+ raise ClaudeCodeCredentialsPersistenceError(
52
+ "Claude Code credentials refreshed in memory, but the persistence callback failed."
53
+ ) from exc
54
+
55
+ def _apply(self, request: httpx2.Request) -> int:
56
+ # Subscription tokens are `Authorization: Bearer` credentials, not API keys.
57
+ # The SDK injects `x-api-key` from the placeholder `api_key`, so it must be
58
+ # removed or the server validates it first and rejects it as an API key.
59
+ if "x-api-key" in request.headers:
60
+ del request.headers["x-api-key"]
61
+ request.headers["Authorization"] = f"Bearer {self.credentials.token}"
62
+ request.headers["x-app"] = config.X_APP
63
+ request.headers["user-agent"] = config.USER_AGENT
64
+ # Anthropic's SDK manages its own betas; ours must be merged, not replaced.
65
+ existing_beta = request.headers.get("anthropic-beta")
66
+ if config.ANTHROPIC_BETA not in (existing_beta or ""):
67
+ request.headers["anthropic-beta"] = ", ".join(filter(None, [existing_beta, config.ANTHROPIC_BETA]))
68
+
69
+ return self.revision
70
+
71
+ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
72
+ revision = self._apply(request)
73
+ if _expires_soon(self.credentials):
74
+ await self._refresh(revision)
75
+ revision = self._apply(request)
76
+ response = yield request
77
+ if response.status_code != 401:
78
+ return
79
+ await response.aread()
80
+ await response.aclose()
81
+ await self._refresh(revision)
82
+ self._apply(request)
83
+ yield request
@@ -0,0 +1,44 @@
1
+ """OAuth endpoint and client configuration for Claude Code authentication.
2
+
3
+ These constants were verified against the Claude Code CLI 2.1.263 binary
4
+ (embedded config) and its published client metadata:
5
+
6
+ - `https://claude.ai/oauth/claude-code-client-metadata` (dynamic client registration)
7
+ - Authorization server: `https://claude.com/cai/oauth/authorize`
8
+ - Token endpoint: `https://platform.claude.com/v1/oauth/token`
9
+
10
+ The shared `9d1c250a...` client id is the public client the official CLI uses,
11
+ so our flow presents the same credentials to Anthropic's authorization server.
12
+ See `flow.py` for the authorization-code flow, and `provider.py` for the API client.
13
+ """
14
+
15
+ # OAuth endpoints and the shared public client id used by the Claude Code CLI.
16
+ AUTH_URL = "https://claude.ai/oauth/authorize"
17
+ TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
18
+ CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
19
+ # Same scope set the plugin in `code_puppy_core_plugins` uses. The binary's
20
+ # `user:ccr_inference` scope is not recognized by the authorization server.
21
+ SCOPES = "org:create_api_key user:profile user:inference"
22
+
23
+ # The subscription tokens minted by this flow are valid against api.anthropic.com.
24
+ API_BASE_URL = "https://api.anthropic.com"
25
+
26
+ # Must open the system context when authenticating with Claude Code tokens. This
27
+ # is the exact persona string the official CLI sends; the subscription backend
28
+ # expects it at position 0.
29
+ CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."
30
+
31
+ # Redirect handling. We host a short-lived callback server on localhost. The
32
+ # authorization server accepts `http://localhost:<any port>/callback` but
33
+ # rejects `127.0.0.1` variants, so the host must stay `localhost`.
34
+ REDIRECT_HOST = "http://localhost"
35
+ REDIRECT_PATH = "callback"
36
+ CALLBACK_PORT_RANGE = (8765, 8795)
37
+ CALLBACK_TIMEOUT = 180
38
+ PASTEBACK_SCHEMES = ("claude://",)
39
+
40
+ # Request headers that must accompany Claude Code subscription tokens. `anthropic-beta` is
41
+ # appended (never replaced) by the auth layer so feature betas set by pydantic-ai survive.
42
+ ANTHROPIC_BETA = "oauth-2025-04-20"
43
+ USER_AGENT = "claude-cli/2.1.263 (external, cli)"
44
+ X_APP = "cli"
@@ -0,0 +1,72 @@
1
+ """Credentials for Claude Code subscription authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import UTC, datetime
7
+
8
+ from pydantic import BaseModel, SecretStr
9
+
10
+ from pydantic_ai.exceptions import UserError
11
+
12
+
13
+ class ClaudeCodeCredentials(BaseModel):
14
+ """Credentials minted by the Claude Code OAuth flow.
15
+
16
+ `access_token` is the bearer token sent to the Messages API. `refresh_token`
17
+ is used by the auth layer to mint a new pair before expiry.
18
+ """
19
+
20
+ access_token: SecretStr
21
+ refresh_token: SecretStr
22
+ expires_at: datetime | None = None
23
+
24
+ @property
25
+ def token(self) -> str:
26
+ """The plaintext access token."""
27
+ return self.access_token.get_secret_value()
28
+
29
+ def to_wire_dict(self) -> dict[str, object]:
30
+ """A JSON-serializable, plain-string form of the credentials for storage.
31
+
32
+ Pydantic 2.13 masks `SecretStr` in some serialization modes, so the stored
33
+ form is spelled out here instead of trusting `model_dump`.
34
+ """
35
+ return {
36
+ "access_token": self.token,
37
+ "refresh_token": self.refresh_token.get_secret_value(),
38
+ "expires_at": self.expires_at.isoformat() if self.expires_at is not None else None,
39
+ }
40
+
41
+ @classmethod
42
+ def from_token_response(cls, data: object, previous: ClaudeCodeCredentials | None = None) -> ClaudeCodeCredentials:
43
+ """Build credentials from a token-endpoint response.
44
+
45
+ Args:
46
+ data: The parsed JSON body of a token response.
47
+ previous: Prior credentials whose refresh token is reused when the
48
+ issuer omits a new one, as Anthropic's refresh responses do.
49
+ """
50
+ root = data if isinstance(data, dict) else None
51
+ access_token = root.get("access_token") if root is not None else None
52
+ refresh_token = root.get("refresh_token") if root is not None else None
53
+ if not isinstance(access_token, str):
54
+ raise UserError("Claude Code token response did not contain a string `access_token`.")
55
+ if not isinstance(refresh_token, str):
56
+ if previous is None:
57
+ raise UserError("Claude Code token response did not contain a string `refresh_token`.")
58
+ refresh_token = previous.refresh_token.get_secret_value()
59
+ expires_at = None
60
+ if expires_in := root.get("expires_in"):
61
+ if isinstance(expires_in, (int, float)) and not isinstance(expires_in, bool):
62
+ expires_at = datetime.fromtimestamp(time.time() + float(expires_in), tz=UTC)
63
+ return cls(
64
+ access_token=SecretStr(access_token),
65
+ refresh_token=SecretStr(refresh_token),
66
+ expires_at=expires_at,
67
+ )
68
+
69
+ @classmethod
70
+ def from_token_file(cls, data: object) -> ClaudeCodeCredentials:
71
+ """Parse a stored token file (the saved output of a previous exchange)."""
72
+ return cls.model_validate(data)
@@ -0,0 +1,238 @@
1
+ """Authorization-code PKCE flow for Claude Code credentials.
2
+
3
+ The browser flow hosts a short-lived localhost callback server, the same shape as
4
+ the Codex provider's `_REDIRECT_URI`. Both the `claude://` pasteback scheme and a
5
+ plain localhost redirect are supported.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import secrets
13
+ import threading
14
+ import webbrowser
15
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
+ from typing import Any
17
+ from urllib.parse import parse_qs, urlencode
18
+
19
+ import httpx2
20
+
21
+ from pydantic_ai.exceptions import UserError
22
+
23
+ from . import config
24
+ from .credentials import ClaudeCodeCredentials
25
+
26
+
27
+ class ClaudeCodeOAuthFlow:
28
+ """A side-effect-free authorization-code PKCE flow context."""
29
+
30
+ def __init__(self, redirect_uri: str | None = None) -> None:
31
+ self.redirect_uri = redirect_uri
32
+ self.state = secrets.token_urlsafe(32)
33
+ self.code_verifier = secrets.token_urlsafe(64)
34
+ digest = hashlib.sha256(self.code_verifier.encode()).digest()
35
+ self.code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
36
+
37
+ def authorization_url(self) -> str:
38
+ query = urlencode(
39
+ {
40
+ "response_type": "code",
41
+ "client_id": config.CLIENT_ID,
42
+ "redirect_uri": self.redirect_uri or "",
43
+ "scope": config.SCOPES,
44
+ "state": self.state,
45
+ "code": "true",
46
+ "code_challenge": self.code_challenge,
47
+ "code_challenge_method": "S256",
48
+ }
49
+ )
50
+ return f"{config.AUTH_URL}?{query}"
51
+
52
+ async def exchange_code(self, code: str, *, http_client: httpx2.AsyncClient | None = None) -> ClaudeCodeCredentials:
53
+ """Exchange an authorization code for credentials."""
54
+ return await exchange_code(
55
+ code, self.code_verifier, self.redirect_uri, state=self.state, http_client=http_client
56
+ )
57
+
58
+
59
+ async def exchange_code(
60
+ code: str,
61
+ code_verifier: str,
62
+ redirect_uri: str | None,
63
+ *,
64
+ state: str | None = None,
65
+ http_client: httpx2.AsyncClient | None = None,
66
+ ) -> ClaudeCodeCredentials:
67
+ """Exchange an authorization code for Claude Code credentials."""
68
+ async with _client_context(http_client) as client:
69
+ response = await client.post(
70
+ config.TOKEN_URL,
71
+ json={
72
+ "grant_type": "authorization_code",
73
+ "client_id": config.CLIENT_ID,
74
+ "code": code,
75
+ "redirect_uri": redirect_uri,
76
+ "code_verifier": code_verifier,
77
+ "state": state,
78
+ },
79
+ headers=_token_headers(),
80
+ )
81
+ _raise_for_token_error(response)
82
+ return ClaudeCodeCredentials.from_token_response(response.json())
83
+
84
+
85
+ async def refresh_credentials(
86
+ credentials: ClaudeCodeCredentials, *, http_client: httpx2.AsyncClient | None = None
87
+ ) -> ClaudeCodeCredentials:
88
+ """Refresh Claude Code credentials without persisting them."""
89
+ async with _client_context(http_client) as client:
90
+ response = await client.post(
91
+ config.TOKEN_URL,
92
+ json={
93
+ "grant_type": "refresh_token",
94
+ "client_id": config.CLIENT_ID,
95
+ "refresh_token": credentials.refresh_token.get_secret_value(),
96
+ },
97
+ headers=_token_headers(),
98
+ )
99
+ _raise_for_token_error(response)
100
+ return ClaudeCodeCredentials.from_token_response(response.json(), previous=credentials)
101
+
102
+
103
+ def _raise_for_token_error(response: httpx2.Response) -> None:
104
+ """Raise a `UserError` that includes the token server's error body.
105
+
106
+ The token endpoints return sparse HTTP codes, so the reason lives in the
107
+ JSON body; without it a 400 tells us nothing.
108
+ """
109
+ if response.status_code < 400:
110
+ return
111
+ body = response.text[:500]
112
+ raise UserError(f"Claude Code token endpoint returned {response.status_code}: {body}")
113
+
114
+
115
+ def _token_headers() -> dict[str, str]:
116
+ return {
117
+ "Content-Type": "application/json",
118
+ "Accept": "application/json",
119
+ "anthropic-beta": config.ANTHROPIC_BETA,
120
+ "User-Agent": config.USER_AGENT,
121
+ }
122
+
123
+
124
+ def parse_pasteback(raw: str) -> tuple[str, str] | None:
125
+ """Parse a `claude://oauth/callback?...` paste back into `(code, state)`.
126
+
127
+ Returns `None` when the input isn't a pasteback URL.
128
+ """
129
+ if not raw.startswith(config.PASTEBACK_SCHEMES):
130
+ return None
131
+ try:
132
+ query = raw.partition("?")[2]
133
+ params = parse_qs(query)
134
+ except ValueError:
135
+ return None
136
+ code = params.get("code", [""])[0]
137
+ state = params.get("state", [""])[0]
138
+ if not code:
139
+ return None
140
+ return code, state
141
+
142
+
143
+ class _CallbackServer(ThreadingHTTPServer):
144
+ """Localhost server that captures the OAuth redirect query string."""
145
+
146
+ def __init__(self) -> None:
147
+ self.query: str | None = None
148
+ self.received = threading.Event()
149
+ super().__init__(("127.0.0.1", 0), _CallbackHandler)
150
+ self.daemon_threads = True
151
+
152
+
153
+ class _CallbackHandler(BaseHTTPRequestHandler):
154
+ server: _CallbackServer
155
+
156
+ def do_GET(self) -> None: # noqa: N802
157
+ if not self.server.received.is_set():
158
+ self.server.query = self.path
159
+ self.server.received.set()
160
+ self.send_response(200)
161
+ self.send_header("Content-Type", "text/html; charset=utf-8")
162
+ self.end_headers()
163
+ self.wfile.write(
164
+ b"<html><body><h1>pydantic-ai-claude-code</h1><p>Auth successful. Return to your terminal.</p></body></html>"
165
+ )
166
+
167
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002 - stdlib signature
168
+ pass
169
+
170
+
171
+ def start_login_callback_server() -> _CallbackServer:
172
+ """Start a callback server plus a daemon thread serving it."""
173
+ server = _CallbackServer()
174
+ threading.Thread(target=server.serve_forever, daemon=True).start()
175
+ return server
176
+
177
+
178
+ async def login(*, store: Any = None) -> ClaudeCodeCredentials:
179
+ """Run the full OAuth flow and persist the minted credentials.
180
+
181
+ Args:
182
+ store: The token store to write to. Defaults to the standard store.
183
+
184
+ Returns:
185
+ The credentials that were persisted.
186
+ """
187
+ from .storage import ClaudeCodeTokenStore
188
+
189
+ if store is None:
190
+ store = ClaudeCodeTokenStore()
191
+ server = start_login_callback_server()
192
+ try:
193
+ redirect_uri = f"{config.REDIRECT_HOST}:{server.server_address[1]}/{config.REDIRECT_PATH}"
194
+ flow = ClaudeCodeOAuthFlow(redirect_uri=redirect_uri)
195
+ url = flow.authorization_url()
196
+ print(f"Open this URL in your browser if it did not open automatically:\n{url}")
197
+ # Launch the browser off the event path: on some macOS setups `webbrowser.open`
198
+ # hangs until the UI settles, which would starve the callback wait below.
199
+ threading.Thread(target=_open_browser, args=(url,), daemon=True).start()
200
+ if not server.received.wait(config.CALLBACK_TIMEOUT):
201
+ raise UserError("Claude Code OAuth callback timed out.")
202
+ code, state = _parse_callback_query(server.query)
203
+ if state and state != flow.state:
204
+ raise UserError("Claude Code OAuth state mismatch; the redirect may be a replay.")
205
+ if not code:
206
+ raise UserError(f"Claude Code OAuth redirect did not include a `code` parameter: {server.query!r}")
207
+ credentials = await flow.exchange_code(code)
208
+ finally:
209
+ server.shutdown()
210
+ server.server_close()
211
+ store.save(credentials)
212
+ return credentials
213
+
214
+
215
+ def _open_browser(url: str) -> None:
216
+ try:
217
+ webbrowser.open(url)
218
+ except Exception: # noqa: BLE001 - best-effort: the URL is printed for manual use
219
+ pass
220
+
221
+
222
+ def _parse_callback_query(raw: str | None) -> tuple[str, str]:
223
+ """Extract `(code, state)` from a captured redirect path like `/callback?code=...`."""
224
+ params = parse_qs((raw or "").split("?", 1)[-1])
225
+ return params.get("code", [""])[0], params.get("state", [""])[0]
226
+
227
+
228
+ class _client_context:
229
+ def __init__(self, client: httpx2.AsyncClient | None) -> None:
230
+ self.client = client or httpx2.AsyncClient()
231
+ self.owned = client is None
232
+
233
+ async def __aenter__(self) -> httpx2.AsyncClient:
234
+ return self.client
235
+
236
+ async def __aexit__(self, *_: object) -> None:
237
+ if self.owned:
238
+ await self.client.aclose()
@@ -0,0 +1,40 @@
1
+ """The Claude Code model: an `AnthropicModel` with the Claude Code persona prepended."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic_ai.messages import ModelMessage, ModelRequest, SystemPromptPart
6
+ from pydantic_ai.models.anthropic import AnthropicModel
7
+
8
+ from . import config
9
+
10
+
11
+ class ClaudeCodeModel(AnthropicModel):
12
+ """AnthropicModel whose system prompt opens with the Claude Code persona.
13
+
14
+ The subscription backend expects the Claude Code persona at position 0 of
15
+ the system context. Prepending a `SystemPromptPart` here is idempotent:
16
+ once it's in history it stays, and repeated `prepare_messages` calls don't
17
+ duplicate it.
18
+ """
19
+
20
+ def prepare_messages(
21
+ self,
22
+ messages: list[ModelMessage],
23
+ model_request_parameters=None,
24
+ ) -> list[ModelMessage]:
25
+ return super().prepare_messages(_prepend_persona(messages), model_request_parameters)
26
+
27
+
28
+ def _prepend_persona(messages: list[ModelMessage]) -> list[ModelMessage]:
29
+ """Return `messages` with the persona added first, unless it's already there."""
30
+ for index, message in enumerate(messages):
31
+ if not isinstance(message, ModelRequest):
32
+ continue
33
+ if any(
34
+ isinstance(part, SystemPromptPart) and part.content.strip().startswith(config.CLAUDE_CODE_SYSTEM_PROMPT)
35
+ for part in message.parts
36
+ ):
37
+ return messages
38
+ updated = ModelRequest([SystemPromptPart(config.CLAUDE_CODE_SYSTEM_PROMPT), *message.parts])
39
+ return [*messages[:index], updated, *messages[index + 1 :]]
40
+ return messages
@@ -0,0 +1,106 @@
1
+ """Pydantic AI provider backed by a Claude Code subscription."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx2
8
+
9
+ from anthropic import AsyncAnthropic
10
+
11
+ from pydantic_ai.exceptions import UserError
12
+ from pydantic_ai.providers.anthropic import AnthropicProvider
13
+
14
+ from . import config
15
+ from .model import ClaudeCodeModel
16
+ from .auth import _ClaudeCodeAuth
17
+ from .credentials import ClaudeCodeCredentials
18
+ from .storage import ClaudeCodeTokenStore
19
+
20
+
21
+ class ClaudeCodeProvider(AnthropicProvider):
22
+ """Anthropic provider that authenticates with Claude Code subscription tokens.
23
+
24
+ Mirrors the `OpenAICodexProvider` pattern from pydantic-ai: the provider owns
25
+ an httpx2 auth shim that injects the bearer token, refreshes it before expiry,
26
+ and retries once on 401.
27
+ """
28
+
29
+ @property
30
+ def name(self) -> str:
31
+ return "claude-code"
32
+
33
+ def __init__(
34
+ self,
35
+ credentials: ClaudeCodeCredentials | None = None,
36
+ *,
37
+ store: ClaudeCodeTokenStore | None = None,
38
+ on_credentials_refresh: Any = None,
39
+ http_client: httpx2.AsyncClient | None = None,
40
+ base_url: str | None = None,
41
+ ) -> None:
42
+ """Create a Claude Code provider.
43
+
44
+ Args:
45
+ credentials: Credentials to use. When omitted, they are loaded from
46
+ the store (which defaults to the standard token file).
47
+ store: Token store used to persist refreshed credentials. When
48
+ credentials are loaded from the store, refreshes are persisted
49
+ back to it automatically.
50
+ on_credentials_refresh: Optional callback in place of automatic
51
+ persistence. Takes precedence over `store` when both are given.
52
+ http_client: An existing httpx2 client to use; its `auth` is replaced.
53
+ base_url: Override for the Anthropic API base URL.
54
+ """
55
+ if credentials is None:
56
+ if store is None:
57
+ store = ClaudeCodeTokenStore()
58
+ creds = store.load()
59
+ if creds is None:
60
+ raise UserError(
61
+ "No Claude Code credentials found. Run `asyncio.run(pydantic_ai_claude_code.login())` "
62
+ "to authenticate, or pass credentials to the provider."
63
+ )
64
+ credentials = creds
65
+ if on_credentials_refresh is None and store is not None:
66
+
67
+ async def persist(updated: ClaudeCodeCredentials) -> None:
68
+ store.save(updated)
69
+
70
+ on_credentials_refresh = persist
71
+ auth = _ClaudeCodeAuth(credentials, on_credentials_refresh)
72
+ if http_client is None:
73
+ http_client = httpx2.AsyncClient(auth=auth)
74
+ else:
75
+ http_client.auth = auth # type: ignore[assignment]
76
+ self._claude_code_auth = auth
77
+ self._client = AsyncAnthropic(
78
+ api_key="unused", # the auth shim supplies the real credentials
79
+ base_url=base_url or config.API_BASE_URL,
80
+ http_client=http_client,
81
+ )
82
+
83
+ @property
84
+ def credentials(self) -> ClaudeCodeCredentials:
85
+ """The current credentials; refreshed in place by the auth shim."""
86
+ return self._claude_code_auth.credentials
87
+
88
+ def model(self, model_name: str) -> ClaudeCodeModel:
89
+ """Build a `ClaudeCodeModel` bound to this provider.
90
+
91
+ This is the object-only entry point agreed for the wheel: pass its result
92
+ to `Agent(model=...)` without modifying pydantic-ai. The persona is
93
+ prepended to the system context automatically.
94
+ """
95
+ return ClaudeCodeModel(model_name, provider=self)
96
+
97
+ @classmethod
98
+ def from_token_store(
99
+ cls,
100
+ *,
101
+ store: ClaudeCodeTokenStore | None = None,
102
+ http_client: httpx2.AsyncClient | None = None,
103
+ base_url: str | None = None,
104
+ ) -> "ClaudeCodeProvider":
105
+ """Build a provider from the stored token file."""
106
+ return cls(store=store, http_client=http_client, base_url=base_url)
File without changes
@@ -0,0 +1,50 @@
1
+ """Persistent token storage for Claude Code credentials.
2
+
3
+ The file lives under the user's data directory, is created with `0o600`
4
+ permissions, and its path can be overridden with the `CLAUDE_CODE_AUTH_FILE`
5
+ environment variable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from pydantic_ai.exceptions import UserError
15
+
16
+ from .credentials import ClaudeCodeCredentials
17
+
18
+
19
+ def default_auth_path() -> Path:
20
+ """The token file path, honoring the `CLAUDE_CODE_AUTH_FILE` override."""
21
+ env_path = os.environ.get("CLAUDE_CODE_AUTH_FILE")
22
+ if env_path:
23
+ return Path(env_path).expanduser()
24
+ data_dir = Path(os.environ.get("XDG_DATA_HOME", "") or (Path.home() / ".local" / "share"))
25
+ return data_dir / "pydantic-ai-claude-code" / "auth.json"
26
+
27
+
28
+ class ClaudeCodeTokenStore:
29
+ """Read and write `ClaudeCodeCredentials` to a single JSON file."""
30
+
31
+ def __init__(self, path: str | Path | None = None) -> None:
32
+ self.path = Path(path) if path is not None else default_auth_path()
33
+
34
+ def load(self) -> ClaudeCodeCredentials | None:
35
+ """Return stored credentials, or `None` when nothing is stored yet."""
36
+ try:
37
+ data = json.loads(self.path.read_text())
38
+ except FileNotFoundError:
39
+ return None
40
+ except (OSError, json.JSONDecodeError) as exc:
41
+ raise UserError(f"Unable to read Claude Code credentials from {str(self.path)!r}.") from exc
42
+ return ClaudeCodeCredentials.from_token_file(data)
43
+
44
+ def save(self, credentials: ClaudeCodeCredentials) -> None:
45
+ """Persist credentials atomically with restrictive permissions."""
46
+ self.path.parent.mkdir(parents=True, exist_ok=True)
47
+ tmp = self.path.with_suffix(".tmp")
48
+ tmp.write_text(json.dumps(credentials.to_wire_dict(), indent=2))
49
+ tmp.chmod(0o600)
50
+ os.replace(tmp, self.path)
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.5
2
+ Name: pydantic-claude-code
3
+ Version: 0.1.0
4
+ Summary: Use your Claude Code subscription from a Pydantic AI Agent, with full tool support
5
+ Project-URL: Repository, https://github.com/mpfaffenberger/pydantic-ai-claude-code
6
+ Project-URL: Homepage, https://pypi.org/project/pydantic-claude-code/
7
+ Author: Michael Pfaffenberger
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: <3.15,>=3.11
17
+ Requires-Dist: pydantic-ai-slim[anthropic]<3,>=2.31.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest-asyncio>=0.23.1; extra == 'dev'
20
+ Requires-Dist: pytest>=8.3.4; extra == 'dev'
21
+ Requires-Dist: ruff<0.16,>=0.15; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pydantic-claude-code
25
+
26
+ Use your Claude Code subscription from a plain pydantic-ai `Agent`, with full
27
+ pydantic-ai tool support. No API key, no separate billing: if Claude Code works
28
+ from your terminal, this wheel works too.
29
+
30
+ The repo is `mpfaffenberger/pydantic-ai-claude-code` and the import is
31
+ `pydantic_ai_claude_code`; the PyPI project is `pydantic-claude-code`
32
+ (`pip install pydantic-claude-code`).
33
+
34
+ ## Why
35
+
36
+ pydantic-ai gained a Codex OAuth path where `openai-codex:gpt-6-astra` just
37
+ works against a ChatGPT subscription. This wheel brings the same experience to
38
+ Claude: authenticate once, then run pydantic-ai agents against your Claude
39
+ subscription, using `claude-sonnet-4-5`, `claude-opus-5`, or whatever model you
40
+ subscribe to.
41
+
42
+ Two deliberate design choices distinguish this from a fork of pydantic-ai:
43
+
44
+ 1. **pydantic-ai owns the loop.** We don't hand the whole agent loop to the
45
+ Claude Code CLI. pydantic-ai's own `Agent` machinery drives the conversation,
46
+ executes your tools, and validates structured output. The wheel is a model
47
+ + provider, not a second agent fighting for control.
48
+ 2. **Object-only resolution.** Instead of a `claude-code:` model-name string
49
+ (which would require patching pydantic-ai's internals), pass the model
50
+ object you build from the provider.
51
+
52
+ ## Quick start
53
+
54
+ ```python
55
+ import asyncio
56
+
57
+ from pydantic_ai import Agent
58
+
59
+ from pydantic_ai_claude_code import ClaudeCodeProvider, login
60
+
61
+ async def main() -> None:
62
+ # One-time: opens your browser, mints tokens, stores them
63
+ # (only needs to run again when tokens are revoked).
64
+ await login()
65
+
66
+ provider = ClaudeCodeProvider() # loads the stored tokens
67
+ agent = Agent(provider.model('claude-sonnet-4-5'))
68
+
69
+ result = await agent.run('Say hi in three words.')
70
+ print(result.data)
71
+
72
+ asyncio.run(main())
73
+ ```
74
+
75
+ ### With tools
76
+
77
+ ```python
78
+ from pydantic_ai import Agent
79
+
80
+ provider = ClaudeCodeProvider()
81
+ agent = Agent(provider.model('claude-sonnet-4-5'))
82
+
83
+ @agent.tool_plain
84
+ def add(a: int, b: int) -> int:
85
+ """Add two numbers."""
86
+ return a + b
87
+ ```
88
+
89
+ Tools defined on the agent are passed to the API as standard Anthropic tool
90
+ definitions, and structured output works the same way as with the built-in
91
+ `anthropic` provider. That's the whole point of the wheel.
92
+
93
+ ## How auth works
94
+
95
+ The flow uses the same shared OAuth client the Claude Code CLI uses:
96
+
97
+ - Authorization URL: `https://claude.ai/oauth/authorize`
98
+ - Token URL: `https://platform.claude.com/v1/oauth/token`
99
+ - Scopes: `org:create_api_key user:profile user:inference`
100
+
101
+ Tokens are stored in (overridable via `CLAUDE_CODE_AUTH_FILE`):
102
+
103
+ ```
104
+ ~/.local/share/pydantic-ai-claude-code/auth.json
105
+ ```
106
+
107
+ The file is written with `0o600` permissions and only ever contains what the
108
+ issuer gave us. We never read the CLI's own credential files.
109
+
110
+ Refreshes happen automatically in the background: the auth shim refreshes
111
+ before expiry and retries once on a 401, exactly like the codex provider does.
112
+
113
+ Requests identify as Claude Code: `"You are Claude Code, Anthropic's official
114
+ CLI for Claude."` is prepended to the system context (position 0), the same
115
+ persona the CLI sends. The subscription backend expects it and rate-gates
116
+ premium models without it.
117
+
118
+ ## Security and scope
119
+
120
+ This is a plain Anthropic Messages API client authenticated by your Claude
121
+ subscription tokens. It does not run the Claude Code CLI in a subprocess, so it
122
+ does not inherit Claude Code's sandboxing, permission prompts, or hooks. Treat
123
+ it like any code-executing agent: only give it tools you trust.
124
+
125
+ Projects using this are responsible for following Anthropic's rules for using
126
+ Claude Code credentials in their own products.
127
+
128
+ ## Development
129
+
130
+ ```bash
131
+ uv sync --extra dev # or: source .venv/bin/activate && pip install -e ".[dev]"
132
+ ruff check src tests
133
+ pytest
134
+ ```
135
+
136
+ ## Prior art
137
+
138
+ The OAuth mechanics (shared client id, PKCE, token storage and refresh) are
139
+ lifted from the `claude_code_oauth` plugin in
140
+ [`code_puppy_core_plugins`](https://github.com/mpfaffenberger/code_puppy_core_plugins),
141
+ cleaned up and reshaped around the provider pattern in pydantic-ai.
@@ -0,0 +1,14 @@
1
+ pydantic_ai_claude_code/__init__.py,sha256=oEoYiQ_uyGv4GTdwEgyO2oNAoeA12e7sakHJXIpwAA4,832
2
+ pydantic_ai_claude_code/__main__.py,sha256=bFQey7CDpTvkqXpE_wWgf_kQlv977NFLo0t8SzTdw_I,377
3
+ pydantic_ai_claude_code/auth.py,sha256=iv4LpXTU5MIJDCiDOYKce6BidqkEFANqzvnIH_QI4Gs,3373
4
+ pydantic_ai_claude_code/config.py,sha256=MX2M9zc4Alv4c1KbvdjShsCpqwDNepc9wlrUhKEmpWY,2171
5
+ pydantic_ai_claude_code/credentials.py,sha256=3-PVMx-H7SyXMzJNBITdMgMZjZ4k18KJiaft_7fa8Z0,2939
6
+ pydantic_ai_claude_code/flow.py,sha256=XlLeGtUcdvNJP6bKXWZNE_nh18VOZahuLcly2-fq2BI,8424
7
+ pydantic_ai_claude_code/model.py,sha256=jikHE7bY5YMVVfMqUTUPknQpRVFGOtckGaSWLVPe_Ik,1580
8
+ pydantic_ai_claude_code/provider.py,sha256=kRtyUF_zevW90DIUcL9An1GLp-4KzlIwOmdvfMb8gRY,4012
9
+ pydantic_ai_claude_code/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ pydantic_ai_claude_code/storage.py,sha256=IB271p5VZtjXlch11p3i5LpXwJ5q1kO39hmTOeFxDuQ,1891
11
+ pydantic_claude_code-0.1.0.dist-info/METADATA,sha256=IGx_no9XK0gvSd-w-jzUeaKde4xY0GnlilgTY0JaES8,5049
12
+ pydantic_claude_code-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
13
+ pydantic_claude_code-0.1.0.dist-info/licenses/LICENSE,sha256=ANk1gpICbuBKisJscuk8cBJ6SQ6nA3yEi4soa2qSNT8,1078
14
+ pydantic_claude_code-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Pfaffenberger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.