open-langchain 0.6.1__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.
- open_langchain-0.6.1/.gitignore +9 -0
- open_langchain-0.6.1/LICENSE +21 -0
- open_langchain-0.6.1/PKG-INFO +164 -0
- open_langchain-0.6.1/README.md +143 -0
- open_langchain-0.6.1/btca.config.jsonc +27 -0
- open_langchain-0.6.1/open_langchain/__init__.py +21 -0
- open_langchain-0.6.1/open_langchain/auth.py +174 -0
- open_langchain-0.6.1/open_langchain/chat_models.py +164 -0
- open_langchain-0.6.1/open_langchain/claude_code_auth.py +227 -0
- open_langchain-0.6.1/open_langchain/claude_code_chat_models.py +235 -0
- open_langchain-0.6.1/open_langchain/claude_code_client.py +216 -0
- open_langchain-0.6.1/open_langchain/claude_code_conversions.py +393 -0
- open_langchain-0.6.1/open_langchain/claude_code_models.py +103 -0
- open_langchain-0.6.1/open_langchain/claude_code_signing.py +58 -0
- open_langchain-0.6.1/open_langchain/cli.py +59 -0
- open_langchain-0.6.1/open_langchain/client.py +202 -0
- open_langchain-0.6.1/open_langchain/codex_conversions.py +197 -0
- open_langchain-0.6.1/open_langchain/constants.py +39 -0
- open_langchain-0.6.1/open_langchain/factory.py +23 -0
- open_langchain-0.6.1/open_langchain/models.py +112 -0
- open_langchain-0.6.1/open_langchain/oauth.py +292 -0
- open_langchain-0.6.1/open_langchain/oauth_pages.py +19 -0
- open_langchain-0.6.1/open_langchain/opencode.py +36 -0
- open_langchain-0.6.1/open_langchain/py.typed +0 -0
- open_langchain-0.6.1/pyproject.toml +38 -0
- open_langchain-0.6.1/tests/__init__.py +1 -0
- open_langchain-0.6.1/tests/conftest.py +86 -0
- open_langchain-0.6.1/tests/test_auth.py +103 -0
- open_langchain-0.6.1/tests/test_chat_models.py +125 -0
- open_langchain-0.6.1/tests/test_claude_code_auth.py +187 -0
- open_langchain-0.6.1/tests/test_claude_code_chat_models.py +159 -0
- open_langchain-0.6.1/tests/test_claude_code_client.py +223 -0
- open_langchain-0.6.1/tests/test_claude_code_conversions.py +223 -0
- open_langchain-0.6.1/tests/test_claude_code_models.py +55 -0
- open_langchain-0.6.1/tests/test_claude_code_signing.py +109 -0
- open_langchain-0.6.1/tests/test_client.py +184 -0
- open_langchain-0.6.1/tests/test_conversions.py +181 -0
- open_langchain-0.6.1/tests/test_oauth.py +121 -0
- open_langchain-0.6.1/uv.lock +3364 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cgaravitoq
|
|
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,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: open-langchain
|
|
3
|
+
Version: 0.6.1
|
|
4
|
+
Summary: Native LangChain chat models for OpenAI Codex subscription OAuth and OpenCode Zen/Go — no Node sidecar.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cgaravitoq/open-langchain
|
|
6
|
+
Project-URL: Repository, https://github.com/cgaravitoq/open-langchain
|
|
7
|
+
Author: cgaravitoq
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: chat-model,codex,langchain,langgraph,llm,openai,opencode
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: langchain-core<2,>=0.3.15
|
|
14
|
+
Requires-Dist: langchain-openai>=0.2
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: langchain-tests>=0.3.5; extra == 'test'
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
18
|
+
Requires-Dist: pytest-socket>=0.7; extra == 'test'
|
|
19
|
+
Requires-Dist: pytest>=7.4; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# open-langchain
|
|
23
|
+
|
|
24
|
+
Native LangChain chat models for **OpenAI Codex** (ChatGPT Plus/Pro
|
|
25
|
+
subscription OAuth), **Claude** (Claude Code subscription OAuth), and
|
|
26
|
+
**OpenCode Zen/Go**. No Pi runtime, no Node sidecar.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Python >= 3.9
|
|
31
|
+
- For Codex: an `openai-codex` credential in `~/.pi/agent/auth.json`, or sign in
|
|
32
|
+
with `codex-login`
|
|
33
|
+
- For Claude Code: the Claude Code CLI logged in once (`~/.claude/.credentials.json`)
|
|
34
|
+
- For paid OpenCode models: `OPENCODE_API_KEY` or an explicit `api_key`
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install open-langchain
|
|
40
|
+
# or: uv add open-langchain
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
`create_chat` routes the supported native providers:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from open_langchain import create_chat
|
|
49
|
+
|
|
50
|
+
codex = create_chat("openai-codex", "gpt-5.3-codex-spark")
|
|
51
|
+
claude = create_chat("claude-code", "claude-sonnet-4-6")
|
|
52
|
+
free = create_chat("opencode", "deepseek-v4-flash-free")
|
|
53
|
+
go = create_chat("opencode-go", "glm-5", api_key="...")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or construct Codex directly:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from open_langchain import ChatCodex
|
|
60
|
+
|
|
61
|
+
model = ChatCodex(
|
|
62
|
+
model="gpt-5.3-codex-spark",
|
|
63
|
+
reasoning="minimal",
|
|
64
|
+
system="You are a helpful assistant.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
print(model.invoke("Hello!").content)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Codex Auth
|
|
71
|
+
|
|
72
|
+
`ChatCodex` reads the same `~/.pi/agent/auth.json` credential shape under
|
|
73
|
+
`openai-codex`, refreshes the OAuth token in place, and talks directly to
|
|
74
|
+
`https://chatgpt.com/backend-api/codex/responses`.
|
|
75
|
+
|
|
76
|
+
If no credential exists:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
codex-login
|
|
80
|
+
codex-login --device
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Tool Calling
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from langchain_core.tools import tool
|
|
87
|
+
from open_langchain import ChatCodex
|
|
88
|
+
|
|
89
|
+
@tool
|
|
90
|
+
def get_weather(city: str) -> str:
|
|
91
|
+
"""Get the current weather for a city."""
|
|
92
|
+
return f"It is sunny in {city}, 24C."
|
|
93
|
+
|
|
94
|
+
model = ChatCodex(model="gpt-5.3-codex").bind_tools([get_weather])
|
|
95
|
+
msg = model.invoke("What's the weather in Paris?")
|
|
96
|
+
print(msg.tool_calls)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`tool_choice` is passed through to the Codex Responses API:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
forced = ChatCodex(model="gpt-5.3-codex").bind_tools(
|
|
103
|
+
[get_weather],
|
|
104
|
+
tool_choice={"type": "function", "name": "get_weather"},
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
For agent loops, keep `tool_choice="auto"` unless every model turn should call the
|
|
109
|
+
same tool.
|
|
110
|
+
|
|
111
|
+
## Streaming
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
for chunk in model.stream("Write a haiku."):
|
|
115
|
+
print(chunk.content, end="")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## OpenCode
|
|
119
|
+
|
|
120
|
+
`ChatOpencode` uses OpenCode's OpenAI-compatible endpoints through
|
|
121
|
+
`langchain-openai`.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from open_langchain import ChatOpencode
|
|
125
|
+
|
|
126
|
+
free = ChatOpencode("deepseek-v4-flash-free")
|
|
127
|
+
paid = ChatOpencode("glm-5")
|
|
128
|
+
go = ChatOpencode("glm-5", tier="go")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Free models include `deepseek-v4-flash-free`, `big-pickle`, `mimo-v2.5-free`, and
|
|
132
|
+
`nemotron-3-super-free`.
|
|
133
|
+
|
|
134
|
+
## Claude Code (Anthropic subscription)
|
|
135
|
+
|
|
136
|
+
`ChatClaudeCode` talks to the Anthropic Messages API authenticated with the
|
|
137
|
+
Claude Code OAuth session already on the machine (`~/.claude/.credentials.json`),
|
|
138
|
+
billing requests against your Claude Code subscription — no API key. It reads and
|
|
139
|
+
refreshes the token in place (with a `claude` CLI fallback).
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from open_langchain import ChatClaudeCode, create_chat
|
|
143
|
+
|
|
144
|
+
chat = create_chat("claude-code", "claude-sonnet-4-6")
|
|
145
|
+
print(chat.invoke("Hello!").content)
|
|
146
|
+
|
|
147
|
+
# Or construct directly, with options:
|
|
148
|
+
opus = ChatClaudeCode(model="claude-opus-4-8", reasoning="medium")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Models: `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`,
|
|
152
|
+
`claude-haiku-4-5`. Reasoning uses adaptive thinking on Opus 4.8/4.7 and a token
|
|
153
|
+
budget on Sonnet 4.6 (Haiku has no reasoning). The 1M-context beta is **opt-in**
|
|
154
|
+
via `long_context=True`; the subscription rejects long-context requests without
|
|
155
|
+
extra credits otherwise. Tool calling and streaming work as with `ChatCodex`.
|
|
156
|
+
|
|
157
|
+
> Using a subscription OAuth session from a third-party app may violate
|
|
158
|
+
> Anthropic's terms and risk your account. See the
|
|
159
|
+
> [`pi-claude-code-auth`](https://github.com/cgaravitoq/pi-claude-code-auth)
|
|
160
|
+
> README before relying on this.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# open-langchain
|
|
2
|
+
|
|
3
|
+
Native LangChain chat models for **OpenAI Codex** (ChatGPT Plus/Pro
|
|
4
|
+
subscription OAuth), **Claude** (Claude Code subscription OAuth), and
|
|
5
|
+
**OpenCode Zen/Go**. No Pi runtime, no Node sidecar.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python >= 3.9
|
|
10
|
+
- For Codex: an `openai-codex` credential in `~/.pi/agent/auth.json`, or sign in
|
|
11
|
+
with `codex-login`
|
|
12
|
+
- For Claude Code: the Claude Code CLI logged in once (`~/.claude/.credentials.json`)
|
|
13
|
+
- For paid OpenCode models: `OPENCODE_API_KEY` or an explicit `api_key`
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pip install open-langchain
|
|
19
|
+
# or: uv add open-langchain
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
`create_chat` routes the supported native providers:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from open_langchain import create_chat
|
|
28
|
+
|
|
29
|
+
codex = create_chat("openai-codex", "gpt-5.3-codex-spark")
|
|
30
|
+
claude = create_chat("claude-code", "claude-sonnet-4-6")
|
|
31
|
+
free = create_chat("opencode", "deepseek-v4-flash-free")
|
|
32
|
+
go = create_chat("opencode-go", "glm-5", api_key="...")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or construct Codex directly:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from open_langchain import ChatCodex
|
|
39
|
+
|
|
40
|
+
model = ChatCodex(
|
|
41
|
+
model="gpt-5.3-codex-spark",
|
|
42
|
+
reasoning="minimal",
|
|
43
|
+
system="You are a helpful assistant.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
print(model.invoke("Hello!").content)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Codex Auth
|
|
50
|
+
|
|
51
|
+
`ChatCodex` reads the same `~/.pi/agent/auth.json` credential shape under
|
|
52
|
+
`openai-codex`, refreshes the OAuth token in place, and talks directly to
|
|
53
|
+
`https://chatgpt.com/backend-api/codex/responses`.
|
|
54
|
+
|
|
55
|
+
If no credential exists:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
codex-login
|
|
59
|
+
codex-login --device
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Tool Calling
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from langchain_core.tools import tool
|
|
66
|
+
from open_langchain import ChatCodex
|
|
67
|
+
|
|
68
|
+
@tool
|
|
69
|
+
def get_weather(city: str) -> str:
|
|
70
|
+
"""Get the current weather for a city."""
|
|
71
|
+
return f"It is sunny in {city}, 24C."
|
|
72
|
+
|
|
73
|
+
model = ChatCodex(model="gpt-5.3-codex").bind_tools([get_weather])
|
|
74
|
+
msg = model.invoke("What's the weather in Paris?")
|
|
75
|
+
print(msg.tool_calls)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`tool_choice` is passed through to the Codex Responses API:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
forced = ChatCodex(model="gpt-5.3-codex").bind_tools(
|
|
82
|
+
[get_weather],
|
|
83
|
+
tool_choice={"type": "function", "name": "get_weather"},
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For agent loops, keep `tool_choice="auto"` unless every model turn should call the
|
|
88
|
+
same tool.
|
|
89
|
+
|
|
90
|
+
## Streaming
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
for chunk in model.stream("Write a haiku."):
|
|
94
|
+
print(chunk.content, end="")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## OpenCode
|
|
98
|
+
|
|
99
|
+
`ChatOpencode` uses OpenCode's OpenAI-compatible endpoints through
|
|
100
|
+
`langchain-openai`.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from open_langchain import ChatOpencode
|
|
104
|
+
|
|
105
|
+
free = ChatOpencode("deepseek-v4-flash-free")
|
|
106
|
+
paid = ChatOpencode("glm-5")
|
|
107
|
+
go = ChatOpencode("glm-5", tier="go")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Free models include `deepseek-v4-flash-free`, `big-pickle`, `mimo-v2.5-free`, and
|
|
111
|
+
`nemotron-3-super-free`.
|
|
112
|
+
|
|
113
|
+
## Claude Code (Anthropic subscription)
|
|
114
|
+
|
|
115
|
+
`ChatClaudeCode` talks to the Anthropic Messages API authenticated with the
|
|
116
|
+
Claude Code OAuth session already on the machine (`~/.claude/.credentials.json`),
|
|
117
|
+
billing requests against your Claude Code subscription — no API key. It reads and
|
|
118
|
+
refreshes the token in place (with a `claude` CLI fallback).
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from open_langchain import ChatClaudeCode, create_chat
|
|
122
|
+
|
|
123
|
+
chat = create_chat("claude-code", "claude-sonnet-4-6")
|
|
124
|
+
print(chat.invoke("Hello!").content)
|
|
125
|
+
|
|
126
|
+
# Or construct directly, with options:
|
|
127
|
+
opus = ChatClaudeCode(model="claude-opus-4-8", reasoning="medium")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Models: `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`,
|
|
131
|
+
`claude-haiku-4-5`. Reasoning uses adaptive thinking on Opus 4.8/4.7 and a token
|
|
132
|
+
budget on Sonnet 4.6 (Haiku has no reasoning). The 1M-context beta is **opt-in**
|
|
133
|
+
via `long_context=True`; the subscription rejects long-context requests without
|
|
134
|
+
extra credits otherwise. Tool calling and streaming work as with `ChatCodex`.
|
|
135
|
+
|
|
136
|
+
> Using a subscription OAuth session from a third-party app may violate
|
|
137
|
+
> Anthropic's terms and risk your account. See the
|
|
138
|
+
> [`pi-claude-code-auth`](https://github.com/cgaravitoq/pi-claude-code-auth)
|
|
139
|
+
> README before relying on this.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://btca.dev/btca.schema.json",
|
|
3
|
+
"dataDirectory": ".btca",
|
|
4
|
+
"resources": [
|
|
5
|
+
{
|
|
6
|
+
"name": "open-langchain",
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/cgaravitoq/open-langchain",
|
|
9
|
+
"branch": "main",
|
|
10
|
+
"specialNotes": "This repo (to be renamed open-langchain): native LangChain chat models in Python, no Pi sidecar. ChatCodex (auth.py, client.py, codex_conversions.py, models.py, chat_models.py), ChatClaudeCode (claude_code_*.py), ChatOpencode (opencode.py). Reverse-engineered OAuth + Anthropic billing signing live in claude_code_signing.py and auth.py."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "open-langchain-ts",
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/cgaravitoq/open-langchain-ts",
|
|
16
|
+
"branch": "main",
|
|
17
|
+
"specialNotes": "TypeScript twin (to be renamed open-langchain-ts). The Codex path here was ported FROM this Python repo; keep both in sync when the Codex responses API or OpenAI/Anthropic OAuth flow changes."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "opencode-claude-auth",
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/griffinmartin/opencode-claude-auth",
|
|
23
|
+
"branch": "main",
|
|
24
|
+
"specialNotes": "Upstream origin of the Claude Code OAuth + payload-transform logic mirrored in claude_code_*.py. Re-read when Claude Code changes its billing header, betas, or refresh flow."
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .auth import CodexAuth, CodexAuthError
|
|
2
|
+
from .chat_models import ChatCodex
|
|
3
|
+
from .claude_code_auth import ClaudeCodeAuth, ClaudeCodeAuthError
|
|
4
|
+
from .claude_code_chat_models import ChatClaudeCode
|
|
5
|
+
from .claude_code_models import CLAUDE_CODE_MODELS
|
|
6
|
+
from .factory import create_chat
|
|
7
|
+
from .models import OPENAI_CODEX_MODELS
|
|
8
|
+
from .opencode import ChatOpencode
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ChatCodex",
|
|
12
|
+
"ChatClaudeCode",
|
|
13
|
+
"ChatOpencode",
|
|
14
|
+
"CodexAuth",
|
|
15
|
+
"CodexAuthError",
|
|
16
|
+
"ClaudeCodeAuth",
|
|
17
|
+
"ClaudeCodeAuthError",
|
|
18
|
+
"CLAUDE_CODE_MODELS",
|
|
19
|
+
"OPENAI_CODEX_MODELS",
|
|
20
|
+
"create_chat",
|
|
21
|
+
]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .constants import (
|
|
13
|
+
CLIENT_ID,
|
|
14
|
+
JWT_CLAIM_PATH,
|
|
15
|
+
PROVIDER_ID,
|
|
16
|
+
REFRESH_TIMEOUT,
|
|
17
|
+
TOKEN_URL,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import fcntl
|
|
22
|
+
except ImportError: # pragma: no cover - non-POSIX
|
|
23
|
+
fcntl = None # type: ignore[assignment]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CodexAuthError(Exception):
|
|
27
|
+
"""Raised when no usable openai-codex credential is available."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _decode_jwt_payload(token: str) -> Optional[dict]:
|
|
31
|
+
parts = token.split(".")
|
|
32
|
+
if len(parts) != 3:
|
|
33
|
+
return None
|
|
34
|
+
payload = parts[1]
|
|
35
|
+
payload += "=" * (-len(payload) % 4)
|
|
36
|
+
try:
|
|
37
|
+
raw = base64.urlsafe_b64decode(payload.encode())
|
|
38
|
+
return json.loads(raw)
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_account_id(access_token: str) -> Optional[str]:
|
|
44
|
+
payload = _decode_jwt_payload(access_token)
|
|
45
|
+
if not payload:
|
|
46
|
+
return None
|
|
47
|
+
auth = payload.get(JWT_CLAIM_PATH)
|
|
48
|
+
if not isinstance(auth, dict):
|
|
49
|
+
return None
|
|
50
|
+
account_id = auth.get("chatgpt_account_id")
|
|
51
|
+
if isinstance(account_id, str) and account_id:
|
|
52
|
+
return account_id
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_auth_path(explicit: Optional[str] = None) -> Path:
|
|
57
|
+
if explicit:
|
|
58
|
+
return Path(explicit).expanduser()
|
|
59
|
+
agent_dir = os.environ.get("PI_CODING_AGENT_DIR")
|
|
60
|
+
if agent_dir:
|
|
61
|
+
return Path(agent_dir).expanduser() / "auth.json"
|
|
62
|
+
config_dir = os.environ.get("PI_CONFIG_DIR_NAME", ".pi")
|
|
63
|
+
return Path.home() / config_dir / "agent" / "auth.json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CodexAuth:
|
|
67
|
+
def __init__(self, auth_path: Optional[str] = None) -> None:
|
|
68
|
+
self.path = resolve_auth_path(auth_path)
|
|
69
|
+
|
|
70
|
+
def load(self) -> dict:
|
|
71
|
+
if not self.path.exists():
|
|
72
|
+
raise CodexAuthError(
|
|
73
|
+
f"No auth.json found at {self.path}. Run `codex-login` to sign in."
|
|
74
|
+
)
|
|
75
|
+
return json.loads(self.path.read_text())
|
|
76
|
+
|
|
77
|
+
def get_credential(self) -> dict:
|
|
78
|
+
credentials = self.load()
|
|
79
|
+
entry = credentials.get(PROVIDER_ID)
|
|
80
|
+
if not entry:
|
|
81
|
+
raise CodexAuthError(
|
|
82
|
+
f"No `{PROVIDER_ID}` credential in {self.path}. "
|
|
83
|
+
"Run `codex-login` to sign in with your ChatGPT subscription."
|
|
84
|
+
)
|
|
85
|
+
return entry
|
|
86
|
+
|
|
87
|
+
def get_access_token(self) -> dict:
|
|
88
|
+
credential = self.get_credential()
|
|
89
|
+
now_ms = int(time.time() * 1000)
|
|
90
|
+
if now_ms >= int(credential.get("expires", 0)):
|
|
91
|
+
credential = self.refresh()
|
|
92
|
+
return credential
|
|
93
|
+
|
|
94
|
+
def account_id(self, credential: Optional[dict] = None) -> str:
|
|
95
|
+
credential = credential or self.get_credential()
|
|
96
|
+
stored = credential.get("accountId")
|
|
97
|
+
if isinstance(stored, str) and stored:
|
|
98
|
+
return stored
|
|
99
|
+
account_id = extract_account_id(credential.get("access", ""))
|
|
100
|
+
if not account_id:
|
|
101
|
+
raise CodexAuthError("Failed to extract accountId from token")
|
|
102
|
+
return account_id
|
|
103
|
+
|
|
104
|
+
def refresh(self) -> dict:
|
|
105
|
+
credentials = self.load()
|
|
106
|
+
entry = credentials.get(PROVIDER_ID)
|
|
107
|
+
if not entry:
|
|
108
|
+
raise CodexAuthError(
|
|
109
|
+
f"No `{PROVIDER_ID}` credential in {self.path}. Run `codex-login`."
|
|
110
|
+
)
|
|
111
|
+
response = httpx.post(
|
|
112
|
+
TOKEN_URL,
|
|
113
|
+
data={
|
|
114
|
+
"grant_type": "refresh_token",
|
|
115
|
+
"refresh_token": entry["refresh"],
|
|
116
|
+
"client_id": CLIENT_ID,
|
|
117
|
+
},
|
|
118
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
119
|
+
timeout=REFRESH_TIMEOUT,
|
|
120
|
+
)
|
|
121
|
+
if response.status_code >= 400:
|
|
122
|
+
raise CodexAuthError(
|
|
123
|
+
f"OpenAI Codex token refresh failed ({response.status_code}): "
|
|
124
|
+
f"{response.text}"
|
|
125
|
+
)
|
|
126
|
+
data = response.json()
|
|
127
|
+
access = data.get("access_token")
|
|
128
|
+
refresh_token = data.get("refresh_token")
|
|
129
|
+
expires_in = data.get("expires_in")
|
|
130
|
+
if not access or not refresh_token or not isinstance(expires_in, (int, float)):
|
|
131
|
+
raise CodexAuthError("Token refresh response missing fields")
|
|
132
|
+
credential = {
|
|
133
|
+
"type": "oauth",
|
|
134
|
+
"access": access,
|
|
135
|
+
"refresh": refresh_token,
|
|
136
|
+
"expires": int(time.time() * 1000) + int(expires_in) * 1000,
|
|
137
|
+
"accountId": extract_account_id(access),
|
|
138
|
+
}
|
|
139
|
+
credentials[PROVIDER_ID] = credential
|
|
140
|
+
self._write(credentials)
|
|
141
|
+
return credential
|
|
142
|
+
|
|
143
|
+
def write_credential(self, credential: dict) -> None:
|
|
144
|
+
try:
|
|
145
|
+
credentials = self.load()
|
|
146
|
+
except CodexAuthError:
|
|
147
|
+
credentials = {}
|
|
148
|
+
credentials[PROVIDER_ID] = {"type": "oauth", **credential}
|
|
149
|
+
self._write(credentials)
|
|
150
|
+
|
|
151
|
+
def _write(self, credentials: dict) -> None:
|
|
152
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
os.chmod(self.path.parent, 0o700)
|
|
154
|
+
lock_path = self.path.with_name(self.path.name + ".lock")
|
|
155
|
+
lock_file = open(lock_path, "w")
|
|
156
|
+
try:
|
|
157
|
+
if fcntl is not None:
|
|
158
|
+
try:
|
|
159
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
self.path.write_text(json.dumps(credentials, indent=2))
|
|
163
|
+
os.chmod(self.path, 0o600)
|
|
164
|
+
finally:
|
|
165
|
+
if fcntl is not None:
|
|
166
|
+
try:
|
|
167
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
lock_file.close()
|
|
171
|
+
try:
|
|
172
|
+
lock_path.unlink()
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|