seedcode-cli 6.1.5__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.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,931 @@
1
+ """FreeModel backends: two independent providers sharing one FreeModel key.
2
+
3
+ FreeModel (https://freemodel.dev) runs TWO separate services, and Seed Code
4
+ exposes each as its own first-class provider — own base URL, own catalogue,
5
+ own connection status, own selected model:
6
+
7
+ * **FreeModel Claude** (``freemodel_claude``) — Claude-compatible (Anthropic
8
+ Messages) API at ``https://cc.freemodel.dev``.
9
+ * **FreeModel Codex** (``freemodel_codex``) — OpenAI-compatible (Responses)
10
+ API at ``https://api.freemodel.dev``.
11
+
12
+ Both authenticate with the user's FreeModel API key (``fe_oa_...`` from
13
+ https://freemodel.dev/dashboard); the key is stored per provider so either
14
+ can be replaced independently. Model catalogues are fetched live; the Claude
15
+ backend additionally keeps a maintained fallback list of the current Claude
16
+ family for when discovery is unavailable. Keys are validated ONLY by a real
17
+ authenticated request — never by format heuristics.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import time
24
+ from collections.abc import Callable, Iterator
25
+ from dataclasses import dataclass, field
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ import httpx
29
+
30
+ from ..streaming import iter_stream
31
+ from ...utils.logger import get_logger
32
+ from .base import (
33
+ ModelInfo,
34
+ Provider,
35
+ ProviderError,
36
+ StreamEvent,
37
+ TextDelta,
38
+ ToolCallEvent,
39
+ ToolSpec,
40
+ ValidationResult,
41
+ )
42
+
43
+ if TYPE_CHECKING:
44
+ from ..models import AppConfig, Message
45
+
46
+ _log = get_logger("freemodel")
47
+
48
+ # Codex backend: OpenAI-compatible API (Responses, with chat fallback).
49
+ CODEX_BASE = "https://api.freemodel.dev"
50
+ _CODEX_API = f"{CODEX_BASE}/v1"
51
+ _CODEX_MODELS_URL = f"{_CODEX_API}/models"
52
+
53
+ # Claude backend: Anthropic-Messages-compatible API.
54
+ CLAUDE_BASE = "https://cc.freemodel.dev"
55
+ _CLAUDE_MODELS_URL = f"{CLAUDE_BASE}/v1/models"
56
+ _CLAUDE_MESSAGES_URL = f"{CLAUDE_BASE}/v1/messages"
57
+ _CLAUDE_API_VERSION = "2023-06-01"
58
+
59
+ _TIMEOUT = 20.0
60
+ # Chat: fail fast on connect, but give busy free models time to answer.
61
+ _CHAT_TIMEOUT = httpx.Timeout(20.0, read=180.0)
62
+
63
+ # Sentinel model id for Auto mode (resolved per request, never sent as-is).
64
+ AUTO_MODEL = "auto"
65
+
66
+ # Maintained fallback for the Claude backend when catalogue discovery is
67
+ # unavailable: the current Claude family (aliases, newest first per tier).
68
+ CLAUDE_FALLBACK_MODELS: tuple[tuple[str, str], ...] = (
69
+ ("claude-opus-4-8", "Claude Opus 4.8"),
70
+ ("claude-opus-4-7", "Claude Opus 4.7"),
71
+ ("claude-opus-4-6", "Claude Opus 4.6"),
72
+ ("claude-opus-4-5", "Claude Opus 4.5"),
73
+ ("claude-sonnet-5", "Claude Sonnet 5"),
74
+ ("claude-sonnet-4-6", "Claude Sonnet 4.6"),
75
+ ("claude-sonnet-4-5", "Claude Sonnet 4.5"),
76
+ ("claude-haiku-4-5", "Claude Haiku 4.5"),
77
+ )
78
+
79
+ # Live-catalogue cache for Auto resolution, PER PROVIDER: id -> (at, entries).
80
+ _CATALOGUE_TTL_S = 300.0
81
+ _catalogue: dict[str, tuple[float, list[dict[str, Any]]]] = {}
82
+
83
+
84
+ def _codex_headers(api_key: str) -> dict[str, str]:
85
+ return {"Authorization": f"Bearer {api_key}"} if api_key else {}
86
+
87
+
88
+ def _claude_headers(api_key: str) -> dict[str, str]:
89
+ headers = {"anthropic-version": _CLAUDE_API_VERSION, "content-type": "application/json"}
90
+ if api_key:
91
+ headers["x-api-key"] = api_key
92
+ return headers
93
+
94
+
95
+ def _fetch_entries(label: str, url: str, headers: dict[str, str]) -> list[dict[str, Any]]:
96
+ """Fetch one backend's live catalogue. Raises ProviderError."""
97
+ try:
98
+ response = httpx.get(url, headers=headers, timeout=_TIMEOUT)
99
+ response.raise_for_status()
100
+ data = response.json().get("data", [])
101
+ except httpx.TimeoutException as exc:
102
+ raise ProviderError(
103
+ f"Timed out fetching the {label} catalogue.", transient=True
104
+ ) from exc
105
+ except (httpx.HTTPError, ValueError) as exc:
106
+ raise ProviderError(
107
+ f"Could not fetch the {label} catalogue. Check your connection.",
108
+ transient=True,
109
+ ) from exc
110
+ entries = [e for e in data if e.get("id")]
111
+ if not entries:
112
+ raise ProviderError(f"{label} reports no models right now.")
113
+ return entries
114
+
115
+
116
+ def _validate_by_request(label: str, request: "Callable[[], httpx.Response]") -> ValidationResult:
117
+ """Key validation via a real authenticated request — never heuristics.
118
+
119
+ ``request`` performs the lightweight authenticated call (a 1-token chat
120
+ probe: the ``/v1/models`` catalogues on both FreeModel gateways are
121
+ public, so only a chat request actually exercises the key). Failures
122
+ surface the actual server response so the user sees what the backend
123
+ said, not a guess.
124
+ """
125
+ try:
126
+ response = request()
127
+ except httpx.TimeoutException:
128
+ return ValidationResult(False, f"{label} validation timed out. Check your connection.")
129
+ except httpx.HTTPError as exc:
130
+ return ValidationResult(False, f"Could not reach {label}: {exc}")
131
+ if response.status_code < 400:
132
+ return ValidationResult(True, f"API key verified with {label}.")
133
+ detail = response.text.strip()[:200] or response.reason_phrase
134
+ return ValidationResult(
135
+ False, f"{label} rejected the request (HTTP {response.status_code}): {detail}"
136
+ )
137
+
138
+
139
+ class _FreeModelBase(Provider):
140
+ """Shared plumbing for the two FreeModel providers (never registered)."""
141
+
142
+ def _cached_entries(self, api_key: str) -> list[dict[str, Any]]:
143
+ """Catalogue with a short per-provider cache for Auto mode."""
144
+ now = time.monotonic()
145
+ cached = _catalogue.get(self.id)
146
+ if cached is not None and now - cached[0] < _CATALOGUE_TTL_S:
147
+ return cached[1]
148
+ entries = self._fetch(api_key)
149
+ _catalogue[self.id] = (now, entries)
150
+ return entries
151
+
152
+ def _resolve_auto(self, api_key: str) -> str:
153
+ """Pick the best model from this provider's live list."""
154
+ entries = self._cached_entries(api_key)
155
+ best = max(entries, key=lambda e: e.get("context_length") or 0)
156
+ _log.info("auto mode (%s) resolved to %s", self.id, best["id"])
157
+ return best["id"]
158
+
159
+ def _fetch(self, api_key: str) -> list[dict[str, Any]]: # pragma: no cover
160
+ raise NotImplementedError
161
+
162
+
163
+ @dataclass
164
+ class FreeModelClaudeProvider(_FreeModelBase):
165
+ """FreeModel's Claude-compatible backend at cc.freemodel.dev."""
166
+
167
+ def __post_init__(self) -> None:
168
+ self.id = "freemodel_claude"
169
+ self.label = "FreeModel Claude"
170
+ self.base_url = CLAUDE_BASE
171
+ self.backend_label = "Claude API"
172
+ self.requires_key = True
173
+ self.supports_auto = True
174
+ self.key_hint = "fe_oa_... (get a free API key: https://freemodel.dev/dashboard)"
175
+
176
+ def validate_key(self, api_key: str) -> ValidationResult:
177
+ key = api_key.strip()
178
+ if not key:
179
+ return ValidationResult(False, "API key is empty.")
180
+
181
+ def probe() -> httpx.Response:
182
+ # A 1-token Messages request is the lightest call that actually
183
+ # exercises the key (the catalogue endpoint is public).
184
+ return httpx.post(
185
+ _CLAUDE_MESSAGES_URL,
186
+ headers=_claude_headers(key),
187
+ json={
188
+ "model": self._probe_model(key),
189
+ "max_tokens": 1,
190
+ "messages": [{"role": "user", "content": "ping"}],
191
+ },
192
+ timeout=_TIMEOUT,
193
+ )
194
+
195
+ return _validate_by_request(self.label, probe)
196
+
197
+ def _probe_model(self, api_key: str) -> str:
198
+ """A currently-served model id for the validation probe."""
199
+ try:
200
+ return self._cached_entries(api_key)[0]["id"]
201
+ except ProviderError:
202
+ return CLAUDE_FALLBACK_MODELS[0][0]
203
+
204
+ def _fetch(self, api_key: str) -> list[dict[str, Any]]:
205
+ return _fetch_entries(self.label, _CLAUDE_MODELS_URL, _claude_headers(api_key))
206
+
207
+ def list_models(self, config: "AppConfig") -> list[ModelInfo]:
208
+ """Live Claude catalogue, falling back to the maintained family list.
209
+
210
+ Discovery failures (endpoint missing, timeout, bad payload) fall back
211
+ so Claude models are ALWAYS selectable; the fallback is marked so the
212
+ user knows the ids were not fetched live.
213
+ """
214
+ try:
215
+ entries = self._fetch(config.get_api_key(self.id))
216
+ except ProviderError as exc:
217
+ _log.warning("claude catalogue unavailable (%s); using fallback list", exc)
218
+ return [
219
+ ModelInfo(id=model_id, label=label, detail="claude · fallback list")
220
+ for model_id, label in CLAUDE_FALLBACK_MODELS
221
+ ]
222
+ models = []
223
+ for entry in entries:
224
+ ctx = entry.get("context_length")
225
+ models.append(
226
+ ModelInfo(
227
+ id=entry["id"],
228
+ label=entry.get("display_name") or entry.get("name") or entry["id"],
229
+ detail=f"claude · {ctx} ctx" if ctx else "claude",
230
+ is_free=True,
231
+ )
232
+ )
233
+ models.sort(key=lambda m: m.id)
234
+ return models
235
+
236
+ def stream_chat(self, config: "AppConfig", messages: list["Message"]) -> Iterator[str]:
237
+ api_key = config.get_api_key(self.id)
238
+ model = config.model
239
+ if model == AUTO_MODEL:
240
+ model = self._resolve_auto(api_key)
241
+ max_tokens = config.effective_max_tokens()
242
+ _log.debug("claude chat request: model=%s max_tokens=%d", model, max_tokens)
243
+
244
+ system = next((m.content for m in messages if m.role == "system"), None)
245
+ turns = [m.to_api() for m in messages if m.role != "system"]
246
+ payload: dict[str, object] = {
247
+ "model": model,
248
+ "max_tokens": max_tokens,
249
+ "messages": turns,
250
+ "stream": True,
251
+ }
252
+ if system:
253
+ payload["system"] = system
254
+ try:
255
+ with httpx.stream(
256
+ "POST",
257
+ _CLAUDE_MESSAGES_URL,
258
+ headers=_claude_headers(api_key),
259
+ json=payload,
260
+ timeout=_CHAT_TIMEOUT,
261
+ ) as response:
262
+ self._raise_for_stream_status(response, model)
263
+ # Anthropic-style SSE: data lines with typed JSON events.
264
+ for line in response.iter_lines():
265
+ if not line.startswith("data:"):
266
+ continue
267
+ data = line[5:].strip()
268
+ if not data or data == "[DONE]":
269
+ continue
270
+ try:
271
+ event = json.loads(data)
272
+ except ValueError:
273
+ continue # tolerate keep-alive noise
274
+ if event.get("type") == "content_block_delta":
275
+ delta = event.get("delta") or {}
276
+ if delta.get("type") == "text_delta" and delta.get("text"):
277
+ yield delta["text"]
278
+ elif event.get("type") == "error":
279
+ message = (event.get("error") or {}).get(
280
+ "message", "Unknown FreeModel Claude error."
281
+ )
282
+ raise ProviderError(f"FreeModel Claude error: {message}")
283
+ except httpx.TimeoutException as exc:
284
+ raise ProviderError(
285
+ "The FreeModel Claude request timed out. Please try again.", transient=True
286
+ ) from exc
287
+ except httpx.HTTPError as exc:
288
+ raise ProviderError(
289
+ "Network error reaching FreeModel Claude. Check your connection.",
290
+ transient=True,
291
+ ) from exc
292
+
293
+ def _raise_for_stream_status(self, response: httpx.Response, model: str) -> None:
294
+ status = response.status_code
295
+ if status < 400:
296
+ return
297
+ if status in (401, 403):
298
+ raise ProviderError(
299
+ "Authentication failed. Your FreeModel key may be invalid — run /apikey."
300
+ )
301
+ if status == 404:
302
+ raise ProviderError(
303
+ f"Model '{model}' was not found on FreeModel Claude. "
304
+ "Pick another with /model."
305
+ )
306
+ if status in (408, 429) or status >= 500:
307
+ raise ProviderError(
308
+ f"FreeModel Claude is unavailable right now (HTTP {status}). "
309
+ "Please try again.",
310
+ transient=True,
311
+ )
312
+ detail = response.read().decode("utf-8", "replace")[:300]
313
+ raise ProviderError(f"FreeModel Claude error (HTTP {status}): {detail}")
314
+
315
+ # --- native tool calling -------------------------------------------------
316
+ def supports_tools(self, config: "AppConfig") -> bool:
317
+ """The Claude family takes native tool definitions."""
318
+ return True
319
+
320
+ def stream_chat_with_tools(
321
+ self, config: "AppConfig", messages: list["Message"], tools: list[ToolSpec]
322
+ ) -> Iterator[StreamEvent]:
323
+ api_key = config.get_api_key(self.id)
324
+ model = config.model
325
+ if model == AUTO_MODEL:
326
+ model = self._resolve_auto(api_key)
327
+ max_tokens = config.effective_max_tokens()
328
+ _log.debug(
329
+ "claude tool chat request: model=%s max_tokens=%d tools=%d",
330
+ model, max_tokens, len(tools),
331
+ )
332
+
333
+ system = next((m.content for m in messages if m.role == "system"), None)
334
+ payload: dict[str, object] = {
335
+ "model": model,
336
+ "max_tokens": max_tokens,
337
+ "messages": _to_claude_turns(messages),
338
+ "tools": [
339
+ {
340
+ "name": t.name,
341
+ "description": t.description,
342
+ "input_schema": t.parameters,
343
+ }
344
+ for t in tools
345
+ ],
346
+ "stream": True,
347
+ }
348
+ if system:
349
+ payload["system"] = system
350
+ try:
351
+ with httpx.stream(
352
+ "POST",
353
+ _CLAUDE_MESSAGES_URL,
354
+ headers=_claude_headers(api_key),
355
+ json=payload,
356
+ timeout=_CHAT_TIMEOUT,
357
+ ) as response:
358
+ self._raise_for_stream_status(response, model)
359
+ yield from _iter_claude_events(response)
360
+ except httpx.TimeoutException as exc:
361
+ raise ProviderError(
362
+ "The FreeModel Claude request timed out. Please try again.", transient=True
363
+ ) from exc
364
+ except httpx.HTTPError as exc:
365
+ raise ProviderError(
366
+ "Network error reaching FreeModel Claude. Check your connection.",
367
+ transient=True,
368
+ ) from exc
369
+
370
+
371
+ @dataclass
372
+ class FreeModelCodexProvider(_FreeModelBase):
373
+ """FreeModel's OpenAI-compatible backend at api.freemodel.dev.
374
+
375
+ Chat uses the Responses API first and transparently falls back to Chat
376
+ Completions when the gateway does not expose ``/v1/responses`` (both are
377
+ OpenAI-compatible surfaces of the same backend).
378
+ """
379
+
380
+ # Private client cache (key -> client); never shared with other providers.
381
+ _client: Any = field(init=False, default=None, repr=False)
382
+ _client_key: str = field(init=False, default="", repr=False)
383
+ # Remembered per session after the first 404 so we don't re-probe.
384
+ _use_chat_completions: bool = field(init=False, default=False, repr=False)
385
+
386
+ def __post_init__(self) -> None:
387
+ self.id = "freemodel_codex"
388
+ self.label = "FreeModel Codex"
389
+ self.base_url = CODEX_BASE
390
+ self.backend_label = "Responses API"
391
+ self.requires_key = True
392
+ self.supports_auto = True
393
+ self.key_hint = "fe_oa_... (get a free API key: https://freemodel.dev/dashboard)"
394
+
395
+ def validate_key(self, api_key: str) -> ValidationResult:
396
+ key = api_key.strip()
397
+ if not key:
398
+ return ValidationResult(False, "API key is empty.")
399
+ try:
400
+ model = self._probe_model(key)
401
+ except ProviderError as exc:
402
+ return ValidationResult(False, str(exc))
403
+
404
+ def probe() -> httpx.Response:
405
+ # A 1-token completion is the lightest call that actually
406
+ # exercises the key (the catalogue endpoint is public).
407
+ return httpx.post(
408
+ f"{_CODEX_API}/chat/completions",
409
+ headers=_codex_headers(key),
410
+ json={
411
+ "model": model,
412
+ "max_tokens": 1,
413
+ "messages": [{"role": "user", "content": "ping"}],
414
+ },
415
+ timeout=_TIMEOUT,
416
+ )
417
+
418
+ return _validate_by_request(self.label, probe)
419
+
420
+ def _probe_model(self, api_key: str) -> str:
421
+ """A currently-served model id for the validation probe."""
422
+ entries = self._cached_entries(api_key) # raises ProviderError offline
423
+ return entries[0]["id"]
424
+
425
+ def _fetch(self, api_key: str) -> list[dict[str, Any]]:
426
+ return _fetch_entries(self.label, _CODEX_MODELS_URL, _codex_headers(api_key))
427
+
428
+ def list_models(self, config: "AppConfig") -> list[ModelInfo]:
429
+ entries = self._fetch(config.get_api_key(self.id))
430
+ models = []
431
+ for entry in entries:
432
+ ctx = entry.get("context_length")
433
+ models.append(
434
+ ModelInfo(
435
+ id=entry["id"],
436
+ label=entry.get("display_name") or entry.get("name") or entry["id"],
437
+ detail=f"codex · {ctx} ctx" if ctx else "codex",
438
+ is_free=True,
439
+ )
440
+ )
441
+ models.sort(key=lambda m: m.id)
442
+ return models
443
+
444
+ def _get_client(self, api_key: str):
445
+ # The OpenAI SDK is the heaviest import in the app; loading it here
446
+ # (first message) instead of at startup keeps launch fast.
447
+ from openai import OpenAI
448
+
449
+ if self._client is None or self._client_key != api_key:
450
+ self._client = OpenAI(
451
+ api_key=api_key,
452
+ base_url=_CODEX_API,
453
+ timeout=_CHAT_TIMEOUT,
454
+ # The engine owns retry policy; keep the SDK from stacking its own.
455
+ max_retries=0,
456
+ )
457
+ self._client_key = api_key
458
+ return self._client
459
+
460
+ def stream_chat(self, config: "AppConfig", messages: list["Message"]) -> Iterator[str]:
461
+ api_key = config.get_api_key(self.id)
462
+ model = config.model
463
+ if model == AUTO_MODEL:
464
+ model = self._resolve_auto(api_key)
465
+ max_tokens = config.effective_max_tokens()
466
+ _log.debug("codex chat request: model=%s max_tokens=%d", model, max_tokens)
467
+
468
+ from openai import (
469
+ APIConnectionError,
470
+ APIError,
471
+ APITimeoutError,
472
+ AuthenticationError,
473
+ NotFoundError,
474
+ RateLimitError,
475
+ )
476
+
477
+ client = self._get_client(api_key)
478
+ try:
479
+ if not self._use_chat_completions:
480
+ try:
481
+ yield from self._stream_responses(client, model, max_tokens, messages)
482
+ return
483
+ except NotFoundError:
484
+ # /v1/responses not exposed by the gateway — remember and
485
+ # fall back to the Chat Completions surface.
486
+ _log.info("responses endpoint unavailable; using chat completions")
487
+ self._use_chat_completions = True
488
+ yield from self._stream_chat_completions(client, model, max_tokens, messages)
489
+ except AuthenticationError as exc:
490
+ raise ProviderError(
491
+ "Authentication failed. Your FreeModel key may be invalid — run /apikey."
492
+ ) from exc
493
+ except RateLimitError as exc:
494
+ raise ProviderError(
495
+ "Rate limited by FreeModel Codex. Please wait and try again.",
496
+ transient=True,
497
+ ) from exc
498
+ except APITimeoutError as exc:
499
+ raise ProviderError(
500
+ "The FreeModel Codex request timed out. Please try again.", transient=True
501
+ ) from exc
502
+ except APIConnectionError as exc:
503
+ # DNS failures, SSL errors, and dropped connections all land here.
504
+ raise ProviderError(
505
+ "Network error reaching FreeModel Codex. Check your connection.",
506
+ transient=True,
507
+ ) from exc
508
+ except APIError as exc:
509
+ raise _friendly_api_error(exc, model) from exc
510
+
511
+ def _stream_responses(self, client, model, max_tokens, messages) -> Iterator[str]:
512
+ """Primary path: the Responses API (/v1/responses)."""
513
+ system = next((m.content for m in messages if m.role == "system"), None)
514
+ turns = [m.to_api() for m in messages if m.role != "system"]
515
+ kwargs: dict[str, Any] = {
516
+ "model": model,
517
+ "input": turns,
518
+ "max_output_tokens": max_tokens,
519
+ "stream": True,
520
+ }
521
+ if system:
522
+ kwargs["instructions"] = system
523
+ stream = client.responses.create(**kwargs)
524
+ for event in stream:
525
+ kind = getattr(event, "type", "")
526
+ if kind == "response.output_text.delta":
527
+ piece = getattr(event, "delta", "")
528
+ if piece:
529
+ yield piece
530
+ elif kind in ("response.failed", "error"):
531
+ detail = getattr(getattr(event, "response", None), "error", None)
532
+ message = getattr(detail, "message", None) or "Unknown FreeModel Codex error."
533
+ raise ProviderError(f"FreeModel Codex error: {message}")
534
+
535
+ def _stream_chat_completions(self, client, model, max_tokens, messages) -> Iterator[str]:
536
+ """Fallback path: OpenAI Chat Completions (/v1/chat/completions)."""
537
+ stream = client.chat.completions.create(
538
+ model=model,
539
+ messages=[m.to_api() for m in messages], # type: ignore[arg-type]
540
+ max_tokens=max_tokens,
541
+ stream=True,
542
+ )
543
+ yield from iter_stream(stream)
544
+
545
+ # --- native tool calling -------------------------------------------------
546
+ def supports_tools(self, config: "AppConfig") -> bool:
547
+ """Both OpenAI surfaces (Responses / Chat Completions) take tools."""
548
+ return True
549
+
550
+ def stream_chat_with_tools(
551
+ self, config: "AppConfig", messages: list["Message"], tools: list[ToolSpec]
552
+ ) -> Iterator[StreamEvent]:
553
+ api_key = config.get_api_key(self.id)
554
+ model = config.model
555
+ if model == AUTO_MODEL:
556
+ model = self._resolve_auto(api_key)
557
+ max_tokens = config.effective_max_tokens()
558
+ _log.debug(
559
+ "codex tool chat request: model=%s max_tokens=%d tools=%d",
560
+ model, max_tokens, len(tools),
561
+ )
562
+
563
+ from openai import (
564
+ APIConnectionError,
565
+ APIError,
566
+ APITimeoutError,
567
+ AuthenticationError,
568
+ NotFoundError,
569
+ RateLimitError,
570
+ )
571
+
572
+ client = self._get_client(api_key)
573
+ try:
574
+ if not self._use_chat_completions:
575
+ try:
576
+ yield from self._stream_responses_tools(
577
+ client, model, max_tokens, messages, tools
578
+ )
579
+ return
580
+ except NotFoundError:
581
+ _log.info("responses endpoint unavailable; using chat completions")
582
+ self._use_chat_completions = True
583
+ yield from self._stream_chat_completions_tools(
584
+ client, model, max_tokens, messages, tools
585
+ )
586
+ except AuthenticationError as exc:
587
+ raise ProviderError(
588
+ "Authentication failed. Your FreeModel key may be invalid — run /apikey."
589
+ ) from exc
590
+ except RateLimitError as exc:
591
+ raise ProviderError(
592
+ "Rate limited by FreeModel Codex. Please wait and try again.",
593
+ transient=True,
594
+ ) from exc
595
+ except APITimeoutError as exc:
596
+ raise ProviderError(
597
+ "The FreeModel Codex request timed out. Please try again.", transient=True
598
+ ) from exc
599
+ except APIConnectionError as exc:
600
+ raise ProviderError(
601
+ "Network error reaching FreeModel Codex. Check your connection.",
602
+ transient=True,
603
+ ) from exc
604
+ except APIError as exc:
605
+ raise _friendly_api_error(exc, model) from exc
606
+
607
+ def _stream_responses_tools(
608
+ self, client, model, max_tokens, messages, tools
609
+ ) -> Iterator[StreamEvent]:
610
+ """Responses API with tools: function_call output items stream in."""
611
+ system = next((m.content for m in messages if m.role == "system"), None)
612
+ kwargs: dict[str, Any] = {
613
+ "model": model,
614
+ "input": _to_responses_input(messages),
615
+ "max_output_tokens": max_tokens,
616
+ "tools": [
617
+ {
618
+ "type": "function",
619
+ "name": t.name,
620
+ "description": t.description,
621
+ "parameters": t.parameters,
622
+ }
623
+ for t in tools
624
+ ],
625
+ "stream": True,
626
+ }
627
+ if system:
628
+ kwargs["instructions"] = system
629
+ stream = client.responses.create(**kwargs)
630
+ # call item id -> {call_id, name, args}
631
+ pending: dict[str, dict[str, str]] = {}
632
+ for event in stream:
633
+ kind = getattr(event, "type", "")
634
+ if kind == "response.output_text.delta":
635
+ piece = getattr(event, "delta", "")
636
+ if piece:
637
+ yield TextDelta(piece)
638
+ elif kind == "response.output_item.added":
639
+ item = getattr(event, "item", None)
640
+ if getattr(item, "type", "") == "function_call":
641
+ pending[getattr(item, "id", "") or ""] = {
642
+ "call_id": getattr(item, "call_id", "") or "",
643
+ "name": getattr(item, "name", "") or "",
644
+ "args": getattr(item, "arguments", "") or "",
645
+ }
646
+ elif kind == "response.function_call_arguments.delta":
647
+ slot = pending.get(getattr(event, "item_id", "") or "")
648
+ if slot is not None:
649
+ slot["args"] += getattr(event, "delta", "") or ""
650
+ elif kind == "response.output_item.done":
651
+ item = getattr(event, "item", None)
652
+ if getattr(item, "type", "") == "function_call":
653
+ slot = pending.pop(getattr(item, "id", "") or "", None) or {
654
+ "call_id": getattr(item, "call_id", "") or "",
655
+ "name": getattr(item, "name", "") or "",
656
+ "args": "",
657
+ }
658
+ # The done item carries the full arguments; prefer them.
659
+ raw = (getattr(item, "arguments", "") or slot["args"]).strip() or "{}"
660
+ try:
661
+ arguments = json.loads(raw)
662
+ if not isinstance(arguments, dict):
663
+ raise ValueError("arguments must be a JSON object")
664
+ yield ToolCallEvent(
665
+ id=slot["call_id"], name=slot["name"], arguments=arguments
666
+ )
667
+ except ValueError as exc:
668
+ yield ToolCallEvent(
669
+ id=slot["call_id"], name=slot["name"], arguments={},
670
+ error=f"Tool call arguments were not valid JSON: {exc}",
671
+ )
672
+ elif kind in ("response.failed", "error"):
673
+ detail = getattr(getattr(event, "response", None), "error", None)
674
+ message = getattr(detail, "message", None) or "Unknown FreeModel Codex error."
675
+ raise ProviderError(f"FreeModel Codex error: {message}")
676
+
677
+ def _stream_chat_completions_tools(
678
+ self, client, model, max_tokens, messages, tools
679
+ ) -> Iterator[StreamEvent]:
680
+ """Chat Completions with tools (OpenAI-style delta.tool_calls)."""
681
+ stream = client.chat.completions.create(
682
+ model=model,
683
+ messages=[_to_openai_tools_message(m) for m in messages], # type: ignore[arg-type]
684
+ max_tokens=max_tokens,
685
+ tools=[
686
+ {
687
+ "type": "function",
688
+ "function": {
689
+ "name": t.name,
690
+ "description": t.description,
691
+ "parameters": t.parameters,
692
+ },
693
+ }
694
+ for t in tools
695
+ ],
696
+ stream=True,
697
+ )
698
+ pending: dict[int, dict[str, str]] = {} # index -> {id, name, args}
699
+ for chunk in stream:
700
+ choices = getattr(chunk, "choices", None) or []
701
+ if not choices:
702
+ continue
703
+ delta = choices[0].delta
704
+ if getattr(delta, "content", None):
705
+ yield TextDelta(delta.content)
706
+ for fragment in getattr(delta, "tool_calls", None) or []:
707
+ slot = pending.setdefault(
708
+ fragment.index, {"id": "", "name": "", "args": ""}
709
+ )
710
+ if getattr(fragment, "id", None):
711
+ slot["id"] = fragment.id
712
+ fn = getattr(fragment, "function", None)
713
+ if fn is not None:
714
+ if getattr(fn, "name", None):
715
+ slot["name"] = fn.name
716
+ if getattr(fn, "arguments", None):
717
+ slot["args"] += fn.arguments
718
+ for index in sorted(pending):
719
+ slot = pending[index]
720
+ raw = slot["args"].strip() or "{}"
721
+ try:
722
+ arguments = json.loads(raw)
723
+ if not isinstance(arguments, dict):
724
+ raise ValueError("arguments must be a JSON object")
725
+ yield ToolCallEvent(id=slot["id"], name=slot["name"], arguments=arguments)
726
+ except ValueError as exc:
727
+ yield ToolCallEvent(
728
+ id=slot["id"], name=slot["name"], arguments={},
729
+ error=f"Tool call arguments were not valid JSON: {exc}",
730
+ )
731
+
732
+
733
+ def _to_claude_turns(messages: list["Message"]) -> list[dict[str, Any]]:
734
+ """Serialize history to Anthropic Messages turns with tool blocks.
735
+
736
+ Wire rules this must satisfy (the API 400s otherwise):
737
+ * an assistant turn that called tools carries ``tool_use`` content blocks;
738
+ * ALL of that turn's results arrive in the SINGLE next user message as
739
+ ``tool_result`` blocks — consecutive role=="tool" messages are merged.
740
+ """
741
+ turns: list[dict[str, Any]] = []
742
+ pending_results: list[dict[str, Any]] = []
743
+
744
+ def flush_results() -> None:
745
+ if pending_results:
746
+ turns.append({"role": "user", "content": list(pending_results)})
747
+ pending_results.clear()
748
+
749
+ for message in messages:
750
+ if message.role == "system":
751
+ continue
752
+ if message.role == "tool":
753
+ pending_results.append(
754
+ {
755
+ "type": "tool_result",
756
+ "tool_use_id": message.tool_call_id,
757
+ "content": message.content,
758
+ }
759
+ )
760
+ continue
761
+ flush_results()
762
+ if message.role == "assistant" and message.tool_calls:
763
+ content: list[dict[str, Any]] = []
764
+ if message.content.strip():
765
+ content.append({"type": "text", "text": message.content})
766
+ content += [
767
+ {
768
+ "type": "tool_use",
769
+ "id": call.id,
770
+ "name": call.name,
771
+ "input": call.arguments,
772
+ }
773
+ for call in message.tool_calls
774
+ ]
775
+ turns.append({"role": "assistant", "content": content})
776
+ else:
777
+ turns.append({"role": message.role, "content": message.content})
778
+ flush_results()
779
+ return turns
780
+
781
+
782
+ def _iter_claude_events(response: httpx.Response) -> Iterator[StreamEvent]:
783
+ """Yield text deltas and COMPLETE tool calls from an Anthropic SSE stream.
784
+
785
+ ``content_block_start`` with a ``tool_use`` block opens a call (id and
786
+ name arrive there); ``input_json_delta`` fragments accumulate its
787
+ argument JSON; ``content_block_stop`` closes and emits it.
788
+ """
789
+ open_calls: dict[int, dict[str, str]] = {} # block index -> {id, name, json}
790
+ for line in response.iter_lines():
791
+ if not line.startswith("data:"):
792
+ continue
793
+ data = line[5:].strip()
794
+ if not data or data == "[DONE]":
795
+ continue
796
+ try:
797
+ event = json.loads(data)
798
+ except ValueError:
799
+ continue # tolerate keep-alive noise
800
+ kind = event.get("type")
801
+ if kind == "content_block_start":
802
+ block = event.get("content_block") or {}
803
+ if block.get("type") == "tool_use":
804
+ open_calls[event.get("index", 0)] = {
805
+ "id": block.get("id", ""),
806
+ "name": block.get("name", ""),
807
+ "json": "",
808
+ }
809
+ elif kind == "content_block_delta":
810
+ delta = event.get("delta") or {}
811
+ if delta.get("type") == "text_delta" and delta.get("text"):
812
+ yield TextDelta(delta["text"])
813
+ elif delta.get("type") == "input_json_delta":
814
+ slot = open_calls.get(event.get("index", 0))
815
+ if slot is not None:
816
+ slot["json"] += delta.get("partial_json", "")
817
+ elif kind == "content_block_stop":
818
+ slot = open_calls.pop(event.get("index", 0), None)
819
+ if slot is not None:
820
+ raw = slot["json"].strip() or "{}"
821
+ try:
822
+ arguments = json.loads(raw)
823
+ if not isinstance(arguments, dict):
824
+ raise ValueError("input must be a JSON object")
825
+ yield ToolCallEvent(
826
+ id=slot["id"], name=slot["name"], arguments=arguments
827
+ )
828
+ except ValueError as exc:
829
+ yield ToolCallEvent(
830
+ id=slot["id"], name=slot["name"], arguments={},
831
+ error=f"Tool call arguments were not valid JSON: {exc}",
832
+ )
833
+ elif kind == "error":
834
+ message = (event.get("error") or {}).get(
835
+ "message", "Unknown FreeModel Claude error."
836
+ )
837
+ raise ProviderError(f"FreeModel Claude error: {message}")
838
+
839
+
840
+ def _to_responses_input(messages: list["Message"]) -> list[dict[str, Any]]:
841
+ """Serialize history to Responses API input items with function calls.
842
+
843
+ Assistant tool calls become ``function_call`` items and results become
844
+ ``function_call_output`` items, paired by ``call_id``.
845
+ """
846
+ items: list[dict[str, Any]] = []
847
+ for message in messages:
848
+ if message.role == "system":
849
+ continue
850
+ if message.role == "tool":
851
+ items.append(
852
+ {
853
+ "type": "function_call_output",
854
+ "call_id": message.tool_call_id,
855
+ "output": message.content,
856
+ }
857
+ )
858
+ continue
859
+ if message.role == "assistant" and message.tool_calls:
860
+ if message.content.strip():
861
+ items.append({"role": "assistant", "content": message.content})
862
+ items += [
863
+ {
864
+ "type": "function_call",
865
+ "call_id": call.id,
866
+ "name": call.name,
867
+ "arguments": json.dumps(call.arguments, ensure_ascii=False),
868
+ }
869
+ for call in message.tool_calls
870
+ ]
871
+ continue
872
+ items.append({"role": message.role, "content": message.content})
873
+ return items
874
+
875
+
876
+ def _to_openai_tools_message(message: "Message") -> dict[str, Any]:
877
+ """OpenAI Chat Completions wire shape for a tool-calling conversation."""
878
+ if message.role == "tool":
879
+ return {
880
+ "role": "tool",
881
+ "tool_call_id": message.tool_call_id,
882
+ "content": message.content,
883
+ }
884
+ if message.role == "assistant" and message.tool_calls:
885
+ return {
886
+ "role": "assistant",
887
+ "content": message.content or None,
888
+ "tool_calls": [
889
+ {
890
+ "id": call.id,
891
+ "type": "function",
892
+ "function": {
893
+ "name": call.name,
894
+ "arguments": json.dumps(call.arguments, ensure_ascii=False),
895
+ },
896
+ }
897
+ for call in message.tool_calls
898
+ ],
899
+ }
900
+ return message.to_api()
901
+
902
+
903
+ def _friendly_api_error(exc: Any, model: str) -> ProviderError:
904
+ """Translate FreeModel Codex API errors into actionable user messages."""
905
+ status = getattr(exc, "status_code", None)
906
+ detail = getattr(exc, "message", str(exc)) or "Unknown API error."
907
+ if status == 402:
908
+ return ProviderError(
909
+ "FreeModel Codex rejected the request (HTTP 402). Pick another model "
910
+ "with /model, or lower max_tokens in /settings."
911
+ )
912
+ if status == 403:
913
+ return ProviderError(
914
+ "FreeModel Codex refused the request (HTTP 403). Your key may lack "
915
+ "access to this model — pick another with /model."
916
+ )
917
+ if status == 404:
918
+ return ProviderError(
919
+ f"Model '{model}' was not found on FreeModel Codex. Pick another with /model."
920
+ )
921
+ if status == 408:
922
+ return ProviderError(
923
+ "FreeModel Codex timed out handling the request. Please try again.",
924
+ transient=True,
925
+ )
926
+ if status is not None and status >= 500:
927
+ return ProviderError(
928
+ f"FreeModel Codex had a server error (HTTP {status}). Please try again.",
929
+ transient=True,
930
+ )
931
+ return ProviderError(f"FreeModel Codex error: {detail}")