pydantic-claude-code 0.1.0__tar.gz

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,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ htmlcov/
@@ -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.
@@ -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,118 @@
1
+ # pydantic-claude-code
2
+
3
+ Use your Claude Code subscription from a plain pydantic-ai `Agent`, with full
4
+ pydantic-ai tool support. No API key, no separate billing: if Claude Code works
5
+ from your terminal, this wheel works too.
6
+
7
+ The repo is `mpfaffenberger/pydantic-ai-claude-code` and the import is
8
+ `pydantic_ai_claude_code`; the PyPI project is `pydantic-claude-code`
9
+ (`pip install pydantic-claude-code`).
10
+
11
+ ## Why
12
+
13
+ pydantic-ai gained a Codex OAuth path where `openai-codex:gpt-6-astra` just
14
+ works against a ChatGPT subscription. This wheel brings the same experience to
15
+ Claude: authenticate once, then run pydantic-ai agents against your Claude
16
+ subscription, using `claude-sonnet-4-5`, `claude-opus-5`, or whatever model you
17
+ subscribe to.
18
+
19
+ Two deliberate design choices distinguish this from a fork of pydantic-ai:
20
+
21
+ 1. **pydantic-ai owns the loop.** We don't hand the whole agent loop to the
22
+ Claude Code CLI. pydantic-ai's own `Agent` machinery drives the conversation,
23
+ executes your tools, and validates structured output. The wheel is a model
24
+ + provider, not a second agent fighting for control.
25
+ 2. **Object-only resolution.** Instead of a `claude-code:` model-name string
26
+ (which would require patching pydantic-ai's internals), pass the model
27
+ object you build from the provider.
28
+
29
+ ## Quick start
30
+
31
+ ```python
32
+ import asyncio
33
+
34
+ from pydantic_ai import Agent
35
+
36
+ from pydantic_ai_claude_code import ClaudeCodeProvider, login
37
+
38
+ async def main() -> None:
39
+ # One-time: opens your browser, mints tokens, stores them
40
+ # (only needs to run again when tokens are revoked).
41
+ await login()
42
+
43
+ provider = ClaudeCodeProvider() # loads the stored tokens
44
+ agent = Agent(provider.model('claude-sonnet-4-5'))
45
+
46
+ result = await agent.run('Say hi in three words.')
47
+ print(result.data)
48
+
49
+ asyncio.run(main())
50
+ ```
51
+
52
+ ### With tools
53
+
54
+ ```python
55
+ from pydantic_ai import Agent
56
+
57
+ provider = ClaudeCodeProvider()
58
+ agent = Agent(provider.model('claude-sonnet-4-5'))
59
+
60
+ @agent.tool_plain
61
+ def add(a: int, b: int) -> int:
62
+ """Add two numbers."""
63
+ return a + b
64
+ ```
65
+
66
+ Tools defined on the agent are passed to the API as standard Anthropic tool
67
+ definitions, and structured output works the same way as with the built-in
68
+ `anthropic` provider. That's the whole point of the wheel.
69
+
70
+ ## How auth works
71
+
72
+ The flow uses the same shared OAuth client the Claude Code CLI uses:
73
+
74
+ - Authorization URL: `https://claude.ai/oauth/authorize`
75
+ - Token URL: `https://platform.claude.com/v1/oauth/token`
76
+ - Scopes: `org:create_api_key user:profile user:inference`
77
+
78
+ Tokens are stored in (overridable via `CLAUDE_CODE_AUTH_FILE`):
79
+
80
+ ```
81
+ ~/.local/share/pydantic-ai-claude-code/auth.json
82
+ ```
83
+
84
+ The file is written with `0o600` permissions and only ever contains what the
85
+ issuer gave us. We never read the CLI's own credential files.
86
+
87
+ Refreshes happen automatically in the background: the auth shim refreshes
88
+ before expiry and retries once on a 401, exactly like the codex provider does.
89
+
90
+ Requests identify as Claude Code: `"You are Claude Code, Anthropic's official
91
+ CLI for Claude."` is prepended to the system context (position 0), the same
92
+ persona the CLI sends. The subscription backend expects it and rate-gates
93
+ premium models without it.
94
+
95
+ ## Security and scope
96
+
97
+ This is a plain Anthropic Messages API client authenticated by your Claude
98
+ subscription tokens. It does not run the Claude Code CLI in a subprocess, so it
99
+ does not inherit Claude Code's sandboxing, permission prompts, or hooks. Treat
100
+ it like any code-executing agent: only give it tools you trust.
101
+
102
+ Projects using this are responsible for following Anthropic's rules for using
103
+ Claude Code credentials in their own products.
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ uv sync --extra dev # or: source .venv/bin/activate && pip install -e ".[dev]"
109
+ ruff check src tests
110
+ pytest
111
+ ```
112
+
113
+ ## Prior art
114
+
115
+ The OAuth mechanics (shared client id, PKCE, token storage and refresh) are
116
+ lifted from the `claude_code_oauth` plugin in
117
+ [`code_puppy_core_plugins`](https://github.com/mpfaffenberger/code_puppy_core_plugins),
118
+ cleaned up and reshaped around the provider pattern in pydantic-ai.
@@ -0,0 +1,30 @@
1
+ """Run the OAuth web flow, then run a real agent on `claude-fable-5-1`.
2
+
3
+ Requires a Claude (chat) subscription and a browser. Run:
4
+
5
+ python examples/basic.py
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ from pydantic_ai import Agent
13
+
14
+ from pydantic_ai_claude_code import ClaudeCodeProvider, ClaudeCodeTokenStore, login
15
+
16
+
17
+ async def main() -> None:
18
+ # Only run the browser flow when no usable credentials are stored yet.
19
+ if ClaudeCodeTokenStore().load() is None:
20
+ await login()
21
+
22
+ provider = ClaudeCodeProvider()
23
+ agent = Agent(provider.model("claude-fable-5-1"))
24
+
25
+ result = await agent.run("Say hi in exactly three words.")
26
+ print("Agent said:", result.output)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ asyncio.run(main())
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pydantic-claude-code"
7
+ version = "0.1.0"
8
+ description = "Use your Claude Code subscription from a Pydantic AI Agent, with full tool support"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11,<3.15"
11
+ dependencies = [
12
+ "pydantic-ai-slim[anthropic]>=2.31.0,<3",
13
+ ]
14
+ license = { text = "MIT" }
15
+ authors = [{ name = "Michael Pfaffenberger" }]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ ]
24
+
25
+ [project.urls]
26
+ Repository = "https://github.com/mpfaffenberger/pydantic-ai-claude-code"
27
+ Homepage = "https://pypi.org/project/pydantic-claude-code/"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/pydantic_ai_claude_code"]
31
+
32
+ [tool.ruff]
33
+ line-length = 120
34
+ target-version = "py311"
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ asyncio_mode = "auto"
39
+ addopts = "-q"
40
+
41
+ [project.optional-dependencies]
42
+ dev = [
43
+ "pytest>=8.3.4",
44
+ "pytest-asyncio>=0.23.1",
45
+ "ruff>=0.15,<0.16",
46
+ ]
@@ -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)