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.
- pydantic_claude_code-0.1.0/.gitignore +10 -0
- pydantic_claude_code-0.1.0/LICENSE +21 -0
- pydantic_claude_code-0.1.0/PKG-INFO +141 -0
- pydantic_claude_code-0.1.0/README.md +118 -0
- pydantic_claude_code-0.1.0/examples/basic.py +30 -0
- pydantic_claude_code-0.1.0/pyproject.toml +46 -0
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/__init__.py +35 -0
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/__main__.py +19 -0
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/auth.py +83 -0
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/config.py +44 -0
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/credentials.py +72 -0
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/flow.py +238 -0
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/model.py +40 -0
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/provider.py +106 -0
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/py.typed +0 -0
- pydantic_claude_code-0.1.0/src/pydantic_ai_claude_code/storage.py +50 -0
- pydantic_claude_code-0.1.0/tests/test_callback_server.py +30 -0
- pydantic_claude_code-0.1.0/tests/test_credentials.py +53 -0
- pydantic_claude_code-0.1.0/tests/test_flow.py +86 -0
- pydantic_claude_code-0.1.0/tests/test_integration.py +79 -0
- pydantic_claude_code-0.1.0/tests/test_model.py +32 -0
- pydantic_claude_code-0.1.0/tests/test_provider.py +57 -0
- pydantic_claude_code-0.1.0/tests/test_storage.py +50 -0
|
@@ -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)
|