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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- 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}")
|