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.
Files changed (39) hide show
  1. open_langchain-0.6.1/.gitignore +9 -0
  2. open_langchain-0.6.1/LICENSE +21 -0
  3. open_langchain-0.6.1/PKG-INFO +164 -0
  4. open_langchain-0.6.1/README.md +143 -0
  5. open_langchain-0.6.1/btca.config.jsonc +27 -0
  6. open_langchain-0.6.1/open_langchain/__init__.py +21 -0
  7. open_langchain-0.6.1/open_langchain/auth.py +174 -0
  8. open_langchain-0.6.1/open_langchain/chat_models.py +164 -0
  9. open_langchain-0.6.1/open_langchain/claude_code_auth.py +227 -0
  10. open_langchain-0.6.1/open_langchain/claude_code_chat_models.py +235 -0
  11. open_langchain-0.6.1/open_langchain/claude_code_client.py +216 -0
  12. open_langchain-0.6.1/open_langchain/claude_code_conversions.py +393 -0
  13. open_langchain-0.6.1/open_langchain/claude_code_models.py +103 -0
  14. open_langchain-0.6.1/open_langchain/claude_code_signing.py +58 -0
  15. open_langchain-0.6.1/open_langchain/cli.py +59 -0
  16. open_langchain-0.6.1/open_langchain/client.py +202 -0
  17. open_langchain-0.6.1/open_langchain/codex_conversions.py +197 -0
  18. open_langchain-0.6.1/open_langchain/constants.py +39 -0
  19. open_langchain-0.6.1/open_langchain/factory.py +23 -0
  20. open_langchain-0.6.1/open_langchain/models.py +112 -0
  21. open_langchain-0.6.1/open_langchain/oauth.py +292 -0
  22. open_langchain-0.6.1/open_langchain/oauth_pages.py +19 -0
  23. open_langchain-0.6.1/open_langchain/opencode.py +36 -0
  24. open_langchain-0.6.1/open_langchain/py.typed +0 -0
  25. open_langchain-0.6.1/pyproject.toml +38 -0
  26. open_langchain-0.6.1/tests/__init__.py +1 -0
  27. open_langchain-0.6.1/tests/conftest.py +86 -0
  28. open_langchain-0.6.1/tests/test_auth.py +103 -0
  29. open_langchain-0.6.1/tests/test_chat_models.py +125 -0
  30. open_langchain-0.6.1/tests/test_claude_code_auth.py +187 -0
  31. open_langchain-0.6.1/tests/test_claude_code_chat_models.py +159 -0
  32. open_langchain-0.6.1/tests/test_claude_code_client.py +223 -0
  33. open_langchain-0.6.1/tests/test_claude_code_conversions.py +223 -0
  34. open_langchain-0.6.1/tests/test_claude_code_models.py +55 -0
  35. open_langchain-0.6.1/tests/test_claude_code_signing.py +109 -0
  36. open_langchain-0.6.1/tests/test_client.py +184 -0
  37. open_langchain-0.6.1/tests/test_conversions.py +181 -0
  38. open_langchain-0.6.1/tests/test_oauth.py +121 -0
  39. open_langchain-0.6.1/uv.lock +3364 -0
@@ -0,0 +1,9 @@
1
+ node_modules
2
+ dist
3
+ build
4
+ *.egg-info
5
+ __pycache__
6
+ .pytest_cache
7
+ .venv
8
+ *.log
9
+ .DS_Store
@@ -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