polymath-agent 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polymath/__init__.py +2 -0
- polymath/adapters/__init__.py +7 -0
- polymath/adapters/base.py +175 -0
- polymath/adapters/claude.py +280 -0
- polymath/adapters/gemini.py +186 -0
- polymath/adapters/ollama.py +117 -0
- polymath/adapters/openai_adapter.py +168 -0
- polymath/bootstrap.py +159 -0
- polymath/command_registry.py +41 -0
- polymath/command_service.py +572 -0
- polymath/compressor.py +90 -0
- polymath/config.py +293 -0
- polymath/context_manager.py +76 -0
- polymath/context_store.py +336 -0
- polymath/detector.py +442 -0
- polymath/domain.py +78 -0
- polymath/execution_service.py +325 -0
- polymath/main.py +1293 -0
- polymath/memory/__init__.py +15 -0
- polymath/memory/chunker.py +6 -0
- polymath/memory/embedder.py +179 -0
- polymath/memory/migrate.py +2 -0
- polymath/memory/retriever.py +2 -0
- polymath/memory/store.py +9 -0
- polymath/memory/sync.py +2 -0
- polymath/memory/writer.py +9 -0
- polymath/model_policy.py +172 -0
- polymath/orchestrator/__init__.py +68 -0
- polymath/orchestrator/attempt_ledger.py +34 -0
- polymath/orchestrator/ensemble.py +229 -0
- polymath/orchestrator/fanout.py +322 -0
- polymath/orchestrator/output_policy.py +61 -0
- polymath/orchestrator/race.py +311 -0
- polymath/orchestrator/run_controller.py +91 -0
- polymath/orchestrator/speculative_review.py +120 -0
- polymath/orchestrator/state_responder.py +184 -0
- polymath/orchestrator/worker_pool.py +37 -0
- polymath/permissions.py +82 -0
- polymath/pipeline.py +700 -0
- polymath/project_config.py +229 -0
- polymath/project_runtime.py +109 -0
- polymath/router.py +127 -0
- polymath/setup_wizard.py +106 -0
- polymath/slash_commands.py +566 -0
- polymath/subagents.py +486 -0
- polymath/tools.py +333 -0
- polymath/ui_state.py +84 -0
- polymath/workspace.py +66 -0
- polymath_agent-0.4.0.dist-info/METADATA +693 -0
- polymath_agent-0.4.0.dist-info/RECORD +54 -0
- polymath_agent-0.4.0.dist-info/WHEEL +5 -0
- polymath_agent-0.4.0.dist-info/entry_points.txt +2 -0
- polymath_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
- polymath_agent-0.4.0.dist-info/top_level.txt +1 -0
polymath/detector.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-detect all available models on the device.
|
|
3
|
+
Checks: CLI tools, Ollama API, LM Studio API, env vars, polymath config.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from polymath.config import MODEL_REGISTRY, ModelInfo, get_api_key, load_config, atomic_write_text
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_registry_cache: list[ModelInfo] | None = None
|
|
21
|
+
_cache_ts: float = 0
|
|
22
|
+
CACHE_TTL = 300 # seconds
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def invalidate_cache() -> None:
|
|
26
|
+
"""Force next detect() call to re-probe all providers."""
|
|
27
|
+
global _registry_cache, _cache_ts
|
|
28
|
+
_registry_cache = None
|
|
29
|
+
_cache_ts = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def detect(force: bool = False) -> list[ModelInfo]:
|
|
33
|
+
"""Return MODEL_REGISTRY with `available` flags set. Results cached for 5 minutes."""
|
|
34
|
+
global _registry_cache, _cache_ts
|
|
35
|
+
if not force and _registry_cache and (time.time() - _cache_ts) < CACHE_TTL:
|
|
36
|
+
return _registry_cache
|
|
37
|
+
_registry_cache = _run_detect()
|
|
38
|
+
_cache_ts = time.time()
|
|
39
|
+
return _registry_cache
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run_detect() -> list[ModelInfo]:
|
|
43
|
+
"""Internal: actually probe all providers and return registry."""
|
|
44
|
+
cfg = load_config()
|
|
45
|
+
registry = [_clone(m) for m in MODEL_REGISTRY]
|
|
46
|
+
|
|
47
|
+
_detect_claude(registry, cfg)
|
|
48
|
+
_detect_gemini(registry, cfg)
|
|
49
|
+
_detect_openai(registry, cfg)
|
|
50
|
+
_detect_ollama(registry, cfg)
|
|
51
|
+
_detect_lmstudio(registry, cfg)
|
|
52
|
+
|
|
53
|
+
return registry
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def available_models(registry: list[ModelInfo] | None = None) -> list[ModelInfo]:
|
|
57
|
+
r = registry or detect()
|
|
58
|
+
return [m for m in r if m.available]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── Provider detectors ────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def _detect_claude(registry: list[ModelInfo], cfg: dict) -> None:
|
|
64
|
+
key = get_api_key("claude", cfg)
|
|
65
|
+
cli = shutil.which("claude")
|
|
66
|
+
if not key and not cli:
|
|
67
|
+
return
|
|
68
|
+
# If CLI exists but no explicit key, attempt to read from claude's own config
|
|
69
|
+
if not key and cli:
|
|
70
|
+
key = _read_claude_cli_key()
|
|
71
|
+
if key:
|
|
72
|
+
cfg.setdefault("api_keys", {})["claude"] = key
|
|
73
|
+
for m in registry:
|
|
74
|
+
if m.provider == "claude":
|
|
75
|
+
m.available = True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _detect_gemini(registry: list[ModelInfo], cfg: dict) -> None:
|
|
79
|
+
key = get_api_key("gemini", cfg)
|
|
80
|
+
cli = shutil.which("gemini")
|
|
81
|
+
if not key and not cli:
|
|
82
|
+
return
|
|
83
|
+
if not key and cli:
|
|
84
|
+
key = _read_gemini_cli_key()
|
|
85
|
+
if key:
|
|
86
|
+
cfg.setdefault("api_keys", {})["gemini"] = key
|
|
87
|
+
# Mark whether this is an OAuth token (from gemini CLI) vs API key
|
|
88
|
+
# OAuth tokens start with "ya29." — flag it so the adapter knows
|
|
89
|
+
cfg.setdefault("gemini_auth_type", "oauth" if key.startswith("ya29.") else "api_key")
|
|
90
|
+
for m in registry:
|
|
91
|
+
if m.provider == "gemini":
|
|
92
|
+
m.available = True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _detect_openai(registry: list[ModelInfo], cfg: dict) -> None:
|
|
96
|
+
key = get_api_key("openai", cfg)
|
|
97
|
+
cli = shutil.which("codex")
|
|
98
|
+
if not key and not cli:
|
|
99
|
+
return
|
|
100
|
+
if not key and cli:
|
|
101
|
+
key = _read_codex_cli_key()
|
|
102
|
+
if key:
|
|
103
|
+
cfg.setdefault("api_keys", {})["openai"] = key
|
|
104
|
+
for m in registry:
|
|
105
|
+
if m.provider == "openai":
|
|
106
|
+
m.available = True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _detect_ollama(registry: list[ModelInfo], cfg: dict) -> None:
|
|
110
|
+
base = cfg.get("ollama_url", "http://localhost:11434")
|
|
111
|
+
try:
|
|
112
|
+
resp = httpx.get(f"{base}/api/tags", timeout=3)
|
|
113
|
+
if resp.status_code != 200:
|
|
114
|
+
return
|
|
115
|
+
remote_models = {m["name"] for m in resp.json().get("models", [])}
|
|
116
|
+
except Exception:
|
|
117
|
+
# Ollama not running — still mark as available if CLI exists
|
|
118
|
+
if not shutil.which("ollama"):
|
|
119
|
+
return
|
|
120
|
+
remote_models = set()
|
|
121
|
+
|
|
122
|
+
# Mark known registry entries
|
|
123
|
+
for m in registry:
|
|
124
|
+
if m.provider == "ollama" and m.id in remote_models:
|
|
125
|
+
m.available = True
|
|
126
|
+
|
|
127
|
+
# Add any extra Ollama models not in registry
|
|
128
|
+
known = {m.id for m in registry if m.provider == "ollama"}
|
|
129
|
+
for name in remote_models - known:
|
|
130
|
+
registry.append(ModelInfo(
|
|
131
|
+
id=name, provider="ollama", display_name=name,
|
|
132
|
+
cost_tier=__import__("polymath.config", fromlist=["CostTier"]).CostTier.FREE,
|
|
133
|
+
context_window=32_000, speed=3, quality=2,
|
|
134
|
+
capabilities=["general", "code"],
|
|
135
|
+
input_cost_per_1k=0.0, output_cost_per_1k=0.0,
|
|
136
|
+
available=True,
|
|
137
|
+
))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _detect_lmstudio(registry: list[ModelInfo], cfg: dict) -> None:
|
|
141
|
+
base = cfg.get("lmstudio_url", "http://localhost:1234")
|
|
142
|
+
try:
|
|
143
|
+
resp = httpx.get(f"{base}/v1/models", timeout=3)
|
|
144
|
+
if resp.status_code != 200:
|
|
145
|
+
return
|
|
146
|
+
for item in resp.json().get("data", []):
|
|
147
|
+
model_id = item["id"]
|
|
148
|
+
known = {m.id for m in registry}
|
|
149
|
+
if model_id not in known:
|
|
150
|
+
from polymath.config import CostTier
|
|
151
|
+
registry.append(ModelInfo(
|
|
152
|
+
id=model_id, provider="lmstudio", display_name=model_id,
|
|
153
|
+
cost_tier=CostTier.FREE,
|
|
154
|
+
context_window=32_000, speed=3, quality=3,
|
|
155
|
+
capabilities=["general", "code"],
|
|
156
|
+
input_cost_per_1k=0.0, output_cost_per_1k=0.0,
|
|
157
|
+
available=True,
|
|
158
|
+
))
|
|
159
|
+
else:
|
|
160
|
+
for m in registry:
|
|
161
|
+
if m.id == model_id:
|
|
162
|
+
m.available = True
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── Key readers ───────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
def _read_claude_cli_key() -> str | None:
|
|
170
|
+
"""
|
|
171
|
+
Claude Code stores its key in the macOS Keychain, not a plain file.
|
|
172
|
+
Read it via the `security` CLI, which is the only reliable cross-version method.
|
|
173
|
+
Falls back to scanning known JSON locations for older/alternate installs.
|
|
174
|
+
"""
|
|
175
|
+
import json, subprocess
|
|
176
|
+
from pathlib import Path
|
|
177
|
+
|
|
178
|
+
# 1. macOS Keychain — Claude Code stores credentials here
|
|
179
|
+
# "Claude Code-credentials" holds a JSON blob; extract apiKey if present
|
|
180
|
+
for service in ("Claude API Key", "Claude Code-credentials", "Claude Safe Storage"):
|
|
181
|
+
try:
|
|
182
|
+
result = subprocess.run(
|
|
183
|
+
["security", "find-generic-password", "-s", service, "-w"],
|
|
184
|
+
capture_output=True, text=True, timeout=5,
|
|
185
|
+
)
|
|
186
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
187
|
+
raw = result.stdout.strip()
|
|
188
|
+
# Try to parse as JSON (Claude Code-credentials format)
|
|
189
|
+
try:
|
|
190
|
+
import json as _json
|
|
191
|
+
data = _json.loads(raw)
|
|
192
|
+
# "claudeAiOauth" holds a nested dict with accessToken
|
|
193
|
+
oauth = data.get("claudeAiOauth")
|
|
194
|
+
if isinstance(oauth, dict) and oauth.get("accessToken"):
|
|
195
|
+
return oauth["accessToken"]
|
|
196
|
+
if isinstance(oauth, str) and "accessToken" in oauth:
|
|
197
|
+
# Sometimes stored as a JSON string inside JSON
|
|
198
|
+
inner = _json.loads(oauth)
|
|
199
|
+
if inner.get("accessToken"):
|
|
200
|
+
return inner["accessToken"]
|
|
201
|
+
for field in ("apiKey", "api_key", "ANTHROPIC_API_KEY", "claudeApiKey"):
|
|
202
|
+
if data.get(field):
|
|
203
|
+
return data[field]
|
|
204
|
+
except Exception:
|
|
205
|
+
# Raw string — check it looks like an Anthropic key/token
|
|
206
|
+
if raw.startswith(("sk-ant-", "claude-")):
|
|
207
|
+
return raw
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
# 2. JSON file fallbacks — Linux / Windows / older Claude CLI versions
|
|
212
|
+
candidates = [
|
|
213
|
+
Path.home() / ".claude" / "config.json",
|
|
214
|
+
Path.home() / ".config" / "claude" / "config.json",
|
|
215
|
+
]
|
|
216
|
+
# Windows: %APPDATA%\Claude\config.json
|
|
217
|
+
import os as _os
|
|
218
|
+
_appdata = _os.environ.get("APPDATA")
|
|
219
|
+
if _appdata:
|
|
220
|
+
candidates.append(Path(_appdata) / "Claude" / "config.json")
|
|
221
|
+
# Also check ANTHROPIC_API_KEY env var here as a final fallback
|
|
222
|
+
_env_key = _os.environ.get("ANTHROPIC_API_KEY")
|
|
223
|
+
if _env_key:
|
|
224
|
+
return _env_key
|
|
225
|
+
for p in candidates:
|
|
226
|
+
if p.exists():
|
|
227
|
+
try:
|
|
228
|
+
data = json.loads(p.read_text())
|
|
229
|
+
for field in ("apiKey", "api_key", "ANTHROPIC_API_KEY"):
|
|
230
|
+
if data.get(field):
|
|
231
|
+
return data[field]
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _read_gemini_cli_key() -> str | None:
|
|
238
|
+
"""
|
|
239
|
+
Gemini CLI uses OAuth, not a raw API key.
|
|
240
|
+
Reads ~/.gemini/oauth_creds.json. If the token is expired (expiry_date < now),
|
|
241
|
+
attempts a silent refresh via the refresh_token before returning.
|
|
242
|
+
Also checks standard JSON config locations for API-key-based setups.
|
|
243
|
+
"""
|
|
244
|
+
import json
|
|
245
|
+
import time as _time
|
|
246
|
+
from pathlib import Path
|
|
247
|
+
|
|
248
|
+
# 1. OAuth access token (google-gemini CLI)
|
|
249
|
+
oauth_path = Path.home() / ".gemini" / "oauth_creds.json"
|
|
250
|
+
if oauth_path.exists():
|
|
251
|
+
try:
|
|
252
|
+
data = json.loads(oauth_path.read_text())
|
|
253
|
+
token = data.get("access_token")
|
|
254
|
+
expiry_ms = data.get("expiry_date", 0)
|
|
255
|
+
refresh_token = data.get("refresh_token")
|
|
256
|
+
|
|
257
|
+
# expiry_date is in milliseconds
|
|
258
|
+
is_expired = expiry_ms and (_time.time() * 1000) > expiry_ms
|
|
259
|
+
|
|
260
|
+
if is_expired and refresh_token:
|
|
261
|
+
token = _refresh_gemini_token(oauth_path, data, refresh_token)
|
|
262
|
+
elif token:
|
|
263
|
+
return token
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
# 2. JSON config API key (older / API-key based setups)
|
|
268
|
+
candidates = [
|
|
269
|
+
Path.home() / ".gemini" / "config.json",
|
|
270
|
+
Path.home() / ".gemini" / "settings.json",
|
|
271
|
+
Path.home() / ".config" / "gemini" / "config.json",
|
|
272
|
+
Path.home() / ".config" / "google" / "gemini.json",
|
|
273
|
+
]
|
|
274
|
+
for p in candidates:
|
|
275
|
+
if p.exists():
|
|
276
|
+
try:
|
|
277
|
+
data = json.loads(p.read_text())
|
|
278
|
+
for field_name in ("apiKey", "api_key", "GOOGLE_API_KEY", "key"):
|
|
279
|
+
if data.get(field_name):
|
|
280
|
+
return data[field_name]
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _refresh_gemini_token(oauth_path, data: dict, refresh_token: str) -> str | None:
|
|
287
|
+
"""
|
|
288
|
+
Attempt to refresh an expired Gemini OAuth access token using the refresh_token.
|
|
289
|
+
Updates oauth_creds.json in-place. Returns new access_token or None on failure.
|
|
290
|
+
"""
|
|
291
|
+
import json
|
|
292
|
+
import time as _time
|
|
293
|
+
try:
|
|
294
|
+
# Google OAuth2 token endpoint — client_id/secret are public Gemini CLI values
|
|
295
|
+
resp = httpx.post(
|
|
296
|
+
"https://oauth2.googleapis.com/token",
|
|
297
|
+
data={
|
|
298
|
+
"grant_type": "refresh_token",
|
|
299
|
+
"refresh_token": refresh_token,
|
|
300
|
+
# Gemini CLI's public OAuth client credentials
|
|
301
|
+
"client_id": "681254290090-ob3mcu4n8j2rdjvo8c32pnfenrh3hjqm.apps.googleusercontent.com",
|
|
302
|
+
"client_secret": "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",
|
|
303
|
+
},
|
|
304
|
+
timeout=10,
|
|
305
|
+
)
|
|
306
|
+
if resp.status_code == 200:
|
|
307
|
+
new_data = resp.json()
|
|
308
|
+
new_token = new_data.get("access_token")
|
|
309
|
+
if new_token:
|
|
310
|
+
# Merge new fields back and persist
|
|
311
|
+
data.update(new_data)
|
|
312
|
+
import time as _time2
|
|
313
|
+
if "expires_in" in new_data and "expiry_date" not in new_data:
|
|
314
|
+
data["expiry_date"] = int((_time2.time() + new_data["expires_in"]) * 1000)
|
|
315
|
+
# Another tool's credential file. A truncated write here
|
|
316
|
+
# logs the user out of the Gemini CLI, not just polymath.
|
|
317
|
+
atomic_write_text(oauth_path, json.dumps(data, indent=2))
|
|
318
|
+
return new_token
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ── Gemini account management ─────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
def list_gemini_accounts() -> dict:
|
|
327
|
+
"""
|
|
328
|
+
Return {"active": email_str, "old": [email, ...]} from google_accounts.json.
|
|
329
|
+
Returns {"active": None, "old": []} if not found.
|
|
330
|
+
"""
|
|
331
|
+
import json
|
|
332
|
+
from pathlib import Path
|
|
333
|
+
accounts_path = Path.home() / ".gemini" / "google_accounts.json"
|
|
334
|
+
if not accounts_path.exists():
|
|
335
|
+
return {"active": None, "old": []}
|
|
336
|
+
try:
|
|
337
|
+
data = json.loads(accounts_path.read_text())
|
|
338
|
+
active = data.get("active")
|
|
339
|
+
old = data.get("old", [])
|
|
340
|
+
if isinstance(old, str):
|
|
341
|
+
old = [old] if old else []
|
|
342
|
+
return {"active": active, "old": list(old)}
|
|
343
|
+
except Exception:
|
|
344
|
+
return {"active": None, "old": []}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def switch_gemini_account(email: str) -> bool:
|
|
348
|
+
"""
|
|
349
|
+
Switch active Gemini account by updating google_accounts.json and clearing
|
|
350
|
+
oauth_creds.json (forcing re-login on next use). Returns True on success.
|
|
351
|
+
Note: actually switching accounts requires `gemini auth login` for the new account.
|
|
352
|
+
This just updates the active pointer and wipes cached tokens.
|
|
353
|
+
"""
|
|
354
|
+
import json
|
|
355
|
+
from pathlib import Path
|
|
356
|
+
accounts_path = Path.home() / ".gemini" / "google_accounts.json"
|
|
357
|
+
if not accounts_path.exists():
|
|
358
|
+
return False
|
|
359
|
+
try:
|
|
360
|
+
data = json.loads(accounts_path.read_text())
|
|
361
|
+
current_active = data.get("active")
|
|
362
|
+
old = data.get("old", [])
|
|
363
|
+
if isinstance(old, str):
|
|
364
|
+
old = [old] if old else []
|
|
365
|
+
old = list(old)
|
|
366
|
+
|
|
367
|
+
if email == current_active:
|
|
368
|
+
return True # already active
|
|
369
|
+
|
|
370
|
+
all_known = ([current_active] if current_active else []) + old
|
|
371
|
+
if email not in all_known:
|
|
372
|
+
return False # unknown account
|
|
373
|
+
|
|
374
|
+
# Rotate: current active → old, requested → active
|
|
375
|
+
new_old = [a for a in all_known if a != email]
|
|
376
|
+
data["active"] = email
|
|
377
|
+
data["old"] = new_old
|
|
378
|
+
atomic_write_text(accounts_path, json.dumps(data, indent=2))
|
|
379
|
+
|
|
380
|
+
# Wipe token cache so next detect() re-reads/re-logs
|
|
381
|
+
oauth_path = Path.home() / ".gemini" / "oauth_creds.json"
|
|
382
|
+
if oauth_path.exists():
|
|
383
|
+
oauth_path.unlink()
|
|
384
|
+
invalidate_cache()
|
|
385
|
+
return True
|
|
386
|
+
except Exception:
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _read_codex_cli_key() -> str | None:
|
|
391
|
+
"""
|
|
392
|
+
Codex CLI stores the OpenAI key in ~/.codex/auth.json under OPENAI_API_KEY.
|
|
393
|
+
"""
|
|
394
|
+
import json
|
|
395
|
+
from pathlib import Path
|
|
396
|
+
|
|
397
|
+
# 1. Codex auth.json (primary location)
|
|
398
|
+
auth = Path.home() / ".codex" / "auth.json"
|
|
399
|
+
if auth.exists():
|
|
400
|
+
try:
|
|
401
|
+
data = json.loads(auth.read_text())
|
|
402
|
+
# Direct API key (api_key auth mode)
|
|
403
|
+
key = data.get("OPENAI_API_KEY")
|
|
404
|
+
if key:
|
|
405
|
+
return key
|
|
406
|
+
# ChatGPT OAuth mode — access_token works as bearer for OpenAI API
|
|
407
|
+
if data.get("auth_mode") == "chatgpt":
|
|
408
|
+
token = (data.get("tokens") or {}).get("access_token")
|
|
409
|
+
if token:
|
|
410
|
+
return f"chatgpt_oauth:{token}" # flagged for adapter
|
|
411
|
+
except Exception:
|
|
412
|
+
pass
|
|
413
|
+
|
|
414
|
+
# 2. Fallback JSON locations
|
|
415
|
+
candidates = [
|
|
416
|
+
Path.home() / ".codex" / "config.json",
|
|
417
|
+
Path.home() / ".config" / "codex" / "config.json",
|
|
418
|
+
Path.home() / ".openai" / "config.json",
|
|
419
|
+
]
|
|
420
|
+
for p in candidates:
|
|
421
|
+
if p.exists():
|
|
422
|
+
try:
|
|
423
|
+
data = json.loads(p.read_text())
|
|
424
|
+
for field in ("apiKey", "api_key", "OPENAI_API_KEY", "key"):
|
|
425
|
+
if data.get(field):
|
|
426
|
+
return data[field]
|
|
427
|
+
except Exception:
|
|
428
|
+
pass
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
def _clone(m: ModelInfo) -> ModelInfo:
|
|
435
|
+
return ModelInfo(
|
|
436
|
+
id=m.id, provider=m.provider, display_name=m.display_name,
|
|
437
|
+
cost_tier=m.cost_tier, context_window=m.context_window,
|
|
438
|
+
speed=m.speed, quality=m.quality, capabilities=list(m.capabilities),
|
|
439
|
+
input_cost_per_1k=m.input_cost_per_1k,
|
|
440
|
+
output_cost_per_1k=m.output_cost_per_1k,
|
|
441
|
+
available=False,
|
|
442
|
+
)
|
polymath/domain.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from polymath.config import ModelInfo, TaskType
|
|
8
|
+
from polymath.project_config import LocalProject
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Project:
|
|
13
|
+
name: str = ""
|
|
14
|
+
source: str = "local" # local | repo | none
|
|
15
|
+
shared_root: Path | None = None
|
|
16
|
+
local_project: LocalProject | None = None
|
|
17
|
+
repo_root: Path | None = None
|
|
18
|
+
repo_branch: str = ""
|
|
19
|
+
repo_dirty: bool = False
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def shared(self) -> bool:
|
|
23
|
+
return self.source == "repo" and self.shared_root is not None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SessionState:
|
|
28
|
+
session_id: str
|
|
29
|
+
title: str = ""
|
|
30
|
+
active_project: str = ""
|
|
31
|
+
queued_inputs: list[str] = field(default_factory=list)
|
|
32
|
+
last_output: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ModelSelection:
|
|
37
|
+
primary: ModelInfo | None = None
|
|
38
|
+
reviewer: ModelInfo | None = None
|
|
39
|
+
summarizer: ModelInfo | None = None
|
|
40
|
+
estimated_prompt_tokens: int = 0
|
|
41
|
+
fallback_reason: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PermissionRequest:
|
|
46
|
+
"""One request to run something that needs the user's consent.
|
|
47
|
+
|
|
48
|
+
`tool_name` and `arguments` are the tool call itself, and they are what
|
|
49
|
+
the permission layer decides on. `action` is a rendering of the same
|
|
50
|
+
call for the user to read — never parse it back, that is the bug this
|
|
51
|
+
dataclass exists to prevent.
|
|
52
|
+
"""
|
|
53
|
+
action: str
|
|
54
|
+
tool_name: str = ""
|
|
55
|
+
shell_command: str = ""
|
|
56
|
+
arguments: dict = field(default_factory=dict)
|
|
57
|
+
#: Non-empty when this particular call reaches beyond the tool's ordinary
|
|
58
|
+
#: scope, e.g. a read outside the project. An allowlist entry for the tool
|
|
59
|
+
#: does not cover it — the user is asked every time.
|
|
60
|
+
escalation_reason: str = ""
|
|
61
|
+
requires_prompt: bool = True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class CommandInvocation:
|
|
66
|
+
raw: str
|
|
67
|
+
name: str
|
|
68
|
+
args: str = ""
|
|
69
|
+
kind: str = "user" # user | builtin | custom
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class RunContext:
|
|
74
|
+
session: SessionState
|
|
75
|
+
project: Project
|
|
76
|
+
task_type: TaskType
|
|
77
|
+
model_selection: ModelSelection
|
|
78
|
+
permission_handler: Callable[[PermissionRequest], bool]
|