forgecommandline 0.8.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.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/providers.py
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
"""OpenAI-compatible provider calls (result dict or None, never raises),
|
|
2
|
+
response screening, and the failover router."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from . import images
|
|
15
|
+
from . import quality as _quality
|
|
16
|
+
from .config import ANON_KEY, HOME_DIR, ProviderConfig, Settings
|
|
17
|
+
from .keys import CooldownStore, KeyPool, seconds_until_utc_midnight, tail
|
|
18
|
+
|
|
19
|
+
STATS_FILE = HOME_DIR / "roster_stats.json" # same file roster.py writes
|
|
20
|
+
LIMITS_FILE = HOME_DIR / "model_limits.json" # learned: largest request each model accepts / rejects
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def request_size(messages, tools) -> int:
|
|
24
|
+
"""Characters sent in one request: a stable stand-in for tokens (~4 chars each)."""
|
|
25
|
+
return len(json.dumps(messages, ensure_ascii=False)) + len(json.dumps(tools or [], ensure_ascii=False))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Thought(str):
|
|
29
|
+
"""A piece of the model's reasoning, passed to on_text for display only."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Action(str):
|
|
33
|
+
"""The name of a tool the model has started calling, passed to on_text for display only."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Limits:
|
|
37
|
+
"""What each model has shown it can and can't take, kept across runs.
|
|
38
|
+
|
|
39
|
+
'fail' is the smallest request the model rejected as too large; 'ok' the largest it handled.
|
|
40
|
+
A request at or above 'fail' isn't sent to that model again: it would only be rejected."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, path=None):
|
|
43
|
+
self.path = path = path or LIMITS_FILE
|
|
44
|
+
try:
|
|
45
|
+
self.data: dict[str, dict] = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
except (OSError, ValueError):
|
|
47
|
+
self.data = {}
|
|
48
|
+
|
|
49
|
+
def _save(self) -> None:
|
|
50
|
+
try:
|
|
51
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
self.path.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def fits(self, provider: str, model: str, size: int) -> bool:
|
|
57
|
+
cap = self.data.get(f"{provider}:{model}", {}).get("fail")
|
|
58
|
+
return cap is None or size < cap * 0.95
|
|
59
|
+
|
|
60
|
+
def cap(self, provider: str, model: str) -> int | None:
|
|
61
|
+
return self.data.get(f"{provider}:{model}", {}).get("fail")
|
|
62
|
+
|
|
63
|
+
def too_big(self, provider: str, model: str, size: int) -> None:
|
|
64
|
+
d = self.data.setdefault(f"{provider}:{model}", {})
|
|
65
|
+
# Never set the cap below a size the model has already handled.
|
|
66
|
+
d["fail"] = max(min(d.get("fail", size), size), d.get("ok", 0) + 1)
|
|
67
|
+
self._save()
|
|
68
|
+
|
|
69
|
+
def handled(self, provider: str, model: str, size: int) -> None:
|
|
70
|
+
d = self.data.setdefault(f"{provider}:{model}", {})
|
|
71
|
+
if size > d.get("ok", 0):
|
|
72
|
+
d["ok"] = size
|
|
73
|
+
if d.get("fail") and d["fail"] <= size:
|
|
74
|
+
d["fail"] = size + 1
|
|
75
|
+
self._save()
|
|
76
|
+
|
|
77
|
+
# --- screening -------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
PSEUDO_TOOL_CALL = re.compile(r"<tool_call>\s*(\{.*?\})\s*(?:</tool_call>|$)", re.S)
|
|
80
|
+
TOOL_JUNK = [
|
|
81
|
+
re.compile(r"<tool_call>.*?(?:</tool_call>|$)", re.S),
|
|
82
|
+
re.compile(r"\[TOOL_CALLS?\].*?(?:\[/TOOL_CALLS?\]|$)", re.S | re.I),
|
|
83
|
+
re.compile(r"<\|python_tag\|>.*$", re.S),
|
|
84
|
+
re.compile(r"<\|tool_calls?_begin\|>.*?(?:<\|tool_calls?_end\|>|$)", re.S),
|
|
85
|
+
re.compile(r"<function=.*?(?:</function>|$)", re.S),
|
|
86
|
+
]
|
|
87
|
+
VERDICT = re.compile(r"^\s*(?:user\s+|assistant\s+)?safety\s*:\s*(?:safe|unsafe)\b[^\n]*\n*", re.I)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def auth_headers(key: str) -> dict:
|
|
91
|
+
"""Keyless gateways get no Authorization header at all."""
|
|
92
|
+
h = {"User-Agent": UA}
|
|
93
|
+
if key and key != ANON_KEY:
|
|
94
|
+
h["Authorization"] = f"Bearer {key}"
|
|
95
|
+
return h
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def new_call_id() -> str:
|
|
99
|
+
return uuid.uuid4().hex[:9]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_ID_OK = re.compile(r"[A-Za-z0-9]{9}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def portable(messages: list[dict]) -> list[dict]:
|
|
106
|
+
"""History any provider accepts: only standard fields, and tool-call ids in the
|
|
107
|
+
strictest format (9 alphanumerics), mapped consistently. Old sessions and
|
|
108
|
+
replies from other providers then work everywhere."""
|
|
109
|
+
def fix(i: str) -> str:
|
|
110
|
+
return i if _ID_OK.fullmatch(i or "") else hashlib.md5((i or "").encode()).hexdigest()[:9]
|
|
111
|
+
|
|
112
|
+
out = []
|
|
113
|
+
for m in messages:
|
|
114
|
+
n = {"role": m["role"], "content": m.get("content") or ""}
|
|
115
|
+
if m.get("tool_calls"):
|
|
116
|
+
n["tool_calls"] = [{"id": fix(tc["id"]), "type": "function", "function": tc["function"]}
|
|
117
|
+
for tc in m["tool_calls"]]
|
|
118
|
+
if m["role"] == "tool":
|
|
119
|
+
n["tool_call_id"] = fix(m.get("tool_call_id", ""))
|
|
120
|
+
out.append(n)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _norm_tool_calls(raw) -> list[dict] | None:
|
|
125
|
+
"""Validate structured tool calls. None = malformed (treat as failure)."""
|
|
126
|
+
out = []
|
|
127
|
+
for tc in raw or []:
|
|
128
|
+
fn = (tc or {}).get("function") or {}
|
|
129
|
+
name = fn.get("name")
|
|
130
|
+
args = fn.get("arguments", "{}")
|
|
131
|
+
if not name:
|
|
132
|
+
return None
|
|
133
|
+
if isinstance(args, dict):
|
|
134
|
+
args = json.dumps(args)
|
|
135
|
+
try:
|
|
136
|
+
json.loads(args or "{}")
|
|
137
|
+
except ValueError:
|
|
138
|
+
return None
|
|
139
|
+
# Fresh 9-char alphanumeric ids: the strictest format any provider accepts (Mistral),
|
|
140
|
+
# so a conversation started on one provider can be continued on any other.
|
|
141
|
+
out.append({"id": new_call_id(),
|
|
142
|
+
"type": "function", "function": {"name": name, "arguments": args or "{}"}})
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def screen(content: str | None, tool_calls: list[dict]) -> tuple[str, list[dict]] | None:
|
|
147
|
+
text = content or ""
|
|
148
|
+
# Some models write tool calls as text. Rescue them into real calls.
|
|
149
|
+
if not tool_calls:
|
|
150
|
+
for m in PSEUDO_TOOL_CALL.finditer(text):
|
|
151
|
+
try:
|
|
152
|
+
obj = json.loads(m.group(1))
|
|
153
|
+
name = obj.get("name")
|
|
154
|
+
args = obj.get("arguments", obj.get("parameters", {}))
|
|
155
|
+
if name:
|
|
156
|
+
tool_calls.append({"id": new_call_id(), "type": "function",
|
|
157
|
+
"function": {"name": name, "arguments": json.dumps(args)}})
|
|
158
|
+
except ValueError:
|
|
159
|
+
pass
|
|
160
|
+
for rx in TOOL_JUNK:
|
|
161
|
+
text = rx.sub("", text)
|
|
162
|
+
text = VERDICT.sub("", text)
|
|
163
|
+
if not text.strip() and not tool_calls:
|
|
164
|
+
return None
|
|
165
|
+
return text, tool_calls
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- provider --------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
# Cloudflare (Groq) rejects the default Python-urllib agent with 403 / error 1010.
|
|
171
|
+
UA = "forge-cli/0.5"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Provider:
|
|
175
|
+
def __init__(self, cfg: ProviderConfig, store: CooldownStore, settings: Settings, log):
|
|
176
|
+
self.name = cfg.name
|
|
177
|
+
self.base_url = cfg.base_url
|
|
178
|
+
self.model = cfg.model
|
|
179
|
+
self.models = list(cfg.models) # extra models usable with the same keys
|
|
180
|
+
self.pool = KeyPool(cfg.name, cfg.keys, store)
|
|
181
|
+
self.last_error = ""
|
|
182
|
+
self.limits: dict[str, tuple[float, dict]] = {} # key tail -> (when, rate-limit headers)
|
|
183
|
+
self.settings = settings
|
|
184
|
+
self.log = log
|
|
185
|
+
self.last_fail = 0.0
|
|
186
|
+
|
|
187
|
+
def list_models(self) -> list[str]:
|
|
188
|
+
"""Model ids from the provider's /models endpoint. Raises RuntimeError on failure."""
|
|
189
|
+
key = self.pool.next_key() or self.pool.keys[0]
|
|
190
|
+
req = urllib.request.Request(f"{self.base_url}/models",
|
|
191
|
+
headers=auth_headers(key))
|
|
192
|
+
try:
|
|
193
|
+
with urllib.request.urlopen(req, timeout=self.settings.request_timeout) as resp:
|
|
194
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
195
|
+
except urllib.error.HTTPError as e:
|
|
196
|
+
raise RuntimeError(f"{self.name}: HTTP {e.code}") from None
|
|
197
|
+
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as e:
|
|
198
|
+
raise RuntimeError(f"{self.name}: {e}") from None
|
|
199
|
+
items = data.get("data", data if isinstance(data, list) else [])
|
|
200
|
+
ids = [(m.get("id") if isinstance(m, dict) else str(m)) for m in items]
|
|
201
|
+
return sorted({i.removeprefix("models/") for i in ids if i})
|
|
202
|
+
|
|
203
|
+
def _fail(self, key: str, why: str):
|
|
204
|
+
self.last_fail = time.time()
|
|
205
|
+
self.last_error = why
|
|
206
|
+
self.pool.store.count(self.pool.kid(key), ok=False)
|
|
207
|
+
self.log(f"{self.name} {tail(key)}: {why}")
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def _note_limits(self, key: str, headers) -> None:
|
|
211
|
+
if not headers:
|
|
212
|
+
return
|
|
213
|
+
vals = {k.lower(): v for k, v in headers.items()
|
|
214
|
+
if k.lower().startswith(("x-ratelimit", "ratelimit", "x-ratelimit"))}
|
|
215
|
+
if vals:
|
|
216
|
+
self.limits[tail(key)] = (time.time(), vals)
|
|
217
|
+
|
|
218
|
+
def remaining(self, key_tail: str | None = None) -> dict:
|
|
219
|
+
"""Latest rate-limit numbers the provider reported: {'req': (left, limit), 'tok': (left, limit)}."""
|
|
220
|
+
items = [self.limits[key_tail]] if key_tail in self.limits else list(self.limits.values())
|
|
221
|
+
if not items:
|
|
222
|
+
return {}
|
|
223
|
+
_, h = max(items, key=lambda x: x[0])
|
|
224
|
+
out = {}
|
|
225
|
+
for kind, names in (("req", "requests"), ("tok", "tokens")):
|
|
226
|
+
left = h.get(f"x-ratelimit-remaining-{names}") or h.get(f"ratelimit-remaining-{names}")
|
|
227
|
+
lim = h.get(f"x-ratelimit-limit-{names}") or h.get(f"ratelimit-limit-{names}")
|
|
228
|
+
if left is not None:
|
|
229
|
+
out[kind] = (left, lim)
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
def credits(self) -> str:
|
|
233
|
+
"""Account balance, where the provider has an API for it (OpenRouter does)."""
|
|
234
|
+
if self.name != "openrouter":
|
|
235
|
+
return ""
|
|
236
|
+
out = []
|
|
237
|
+
for key in self.pool.keys:
|
|
238
|
+
req = urllib.request.Request(f"{self.base_url}/key",
|
|
239
|
+
headers=auth_headers(key))
|
|
240
|
+
try:
|
|
241
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
242
|
+
d = json.loads(r.read().decode("utf-8")).get("data") or {}
|
|
243
|
+
except Exception as e:
|
|
244
|
+
out.append(f"{tail(key)}: couldn't check ({type(e).__name__})")
|
|
245
|
+
continue
|
|
246
|
+
used = float(d.get("usage") or 0)
|
|
247
|
+
left = d.get("limit_remaining")
|
|
248
|
+
bits = [("free tier" if d.get("is_free_tier") else "paid"), f"${used:.2f} used"]
|
|
249
|
+
if left is not None:
|
|
250
|
+
bits.append(f"${float(left):.2f} left")
|
|
251
|
+
out.append(f"{tail(key)}: " + ", ".join(bits))
|
|
252
|
+
return " · ".join(out)
|
|
253
|
+
|
|
254
|
+
def all_models(self) -> list[str]:
|
|
255
|
+
return [self.model] + [m for m in self.models if m != self.model]
|
|
256
|
+
|
|
257
|
+
def _stream(self, r, on_text) -> dict:
|
|
258
|
+
"""Read a server-sent-events reply, passing text to on_text as it arrives.
|
|
259
|
+
Returns a non-streaming-shaped body so the normal checks below apply."""
|
|
260
|
+
content, reasoning, calls, finish, usage = [], 0, {}, None, {}
|
|
261
|
+
for raw_line in r:
|
|
262
|
+
line = raw_line.decode("utf-8", "replace").strip()
|
|
263
|
+
if not line.startswith("data:"):
|
|
264
|
+
continue
|
|
265
|
+
payload = line[5:].strip()
|
|
266
|
+
if payload == "[DONE]":
|
|
267
|
+
break
|
|
268
|
+
try:
|
|
269
|
+
chunk = json.loads(payload)
|
|
270
|
+
except ValueError:
|
|
271
|
+
continue
|
|
272
|
+
if chunk.get("error"):
|
|
273
|
+
return {"error": chunk["error"]}
|
|
274
|
+
if chunk.get("usage"):
|
|
275
|
+
usage = chunk["usage"]
|
|
276
|
+
for ch in chunk.get("choices") or []:
|
|
277
|
+
d = ch.get("delta") or {}
|
|
278
|
+
if d.get("content"):
|
|
279
|
+
content.append(d["content"])
|
|
280
|
+
on_text(d["content"])
|
|
281
|
+
think = d.get("reasoning_content") or d.get("reasoning")
|
|
282
|
+
if isinstance(think, str) and think:
|
|
283
|
+
reasoning += len(think)
|
|
284
|
+
on_text(Thought(think)) # shown live to the user; never kept in the conversation
|
|
285
|
+
for tc in d.get("tool_calls") or []:
|
|
286
|
+
idx = tc.get("index", len(calls))
|
|
287
|
+
if idx not in calls and (tc.get("function") or {}).get("name"):
|
|
288
|
+
on_text(Action(tc["function"]["name"])) # "about to edit a file" etc.
|
|
289
|
+
slot = calls.setdefault(idx,
|
|
290
|
+
{"id": "", "type": "function", "function": {"name": "", "arguments": ""}})
|
|
291
|
+
if tc.get("id"):
|
|
292
|
+
slot["id"] = tc["id"]
|
|
293
|
+
fn = tc.get("function") or {}
|
|
294
|
+
slot["function"]["name"] += fn.get("name") or ""
|
|
295
|
+
slot["function"]["arguments"] += fn.get("arguments") or ""
|
|
296
|
+
if ch.get("finish_reason"):
|
|
297
|
+
finish = ch["finish_reason"]
|
|
298
|
+
msg = {"content": "".join(content)}
|
|
299
|
+
if not usage.get("completion_tokens"):
|
|
300
|
+
# Some providers skip usage when streaming; estimate (~4 characters per token).
|
|
301
|
+
usage = dict(usage, completion_tokens=max(1, (len(msg["content"]) + reasoning
|
|
302
|
+
+ sum(len(c["function"]["arguments"]) for c in calls.values())) // 4))
|
|
303
|
+
if calls:
|
|
304
|
+
msg["tool_calls"] = [calls[i] for i in sorted(calls)]
|
|
305
|
+
return {"choices": [{"message": msg, "finish_reason": finish}], "usage": usage}
|
|
306
|
+
|
|
307
|
+
def call(self, key: str, messages: list[dict], tools: list[dict] | None,
|
|
308
|
+
model: str | None = None, timeout: float | None = None, on_text=None) -> dict | None:
|
|
309
|
+
"""on_text(str) streams the reply as it is written; without it the reply comes in one piece."""
|
|
310
|
+
model = model or self.model
|
|
311
|
+
body = {"model": model, "messages": portable(images.parts(messages, model)),
|
|
312
|
+
"max_tokens": self.settings.max_tokens, "temperature": self.settings.temperature}
|
|
313
|
+
if tools:
|
|
314
|
+
body["tools"] = tools
|
|
315
|
+
body["tool_choice"] = "auto"
|
|
316
|
+
stream = on_text is not None and self.settings.stream
|
|
317
|
+
if stream:
|
|
318
|
+
body["stream"] = True
|
|
319
|
+
headers = {"Content-Type": "application/json", **auth_headers(key)}
|
|
320
|
+
if self.name == "openrouter":
|
|
321
|
+
headers["X-Title"] = "forge-cli"
|
|
322
|
+
req = urllib.request.Request(f"{self.base_url}/chat/completions",
|
|
323
|
+
data=json.dumps(body).encode(), headers=headers, method="POST")
|
|
324
|
+
status, raw, resp_headers, data = 0, b"", {}, None
|
|
325
|
+
try:
|
|
326
|
+
# The timeout is per read: it fires if the server goes silent that long, so a streaming
|
|
327
|
+
# reply can run as long as it keeps sending.
|
|
328
|
+
with urllib.request.urlopen(req, timeout=timeout or self.settings.request_timeout) as r:
|
|
329
|
+
status, resp_headers = r.status, r.headers
|
|
330
|
+
if stream and "text/event-stream" in (r.headers.get("Content-Type") or ""):
|
|
331
|
+
data = self._stream(r, on_text)
|
|
332
|
+
else:
|
|
333
|
+
raw = r.read()
|
|
334
|
+
except urllib.error.HTTPError as e:
|
|
335
|
+
status, raw, resp_headers = e.code, e.read(), e.headers
|
|
336
|
+
except Exception as e: # network, DNS, timeout, TLS...
|
|
337
|
+
why = "timed out" if "timed out" in str(e).lower() else f"network error: {e}"
|
|
338
|
+
return self._fail(key, f"{why} ({model})")
|
|
339
|
+
self._note_limits(key, resp_headers)
|
|
340
|
+
|
|
341
|
+
text = raw.decode("utf-8", "replace") if data is None else "{}"
|
|
342
|
+
low = text.lower()
|
|
343
|
+
if status == 429:
|
|
344
|
+
retry_after = (resp_headers or {}).get("Retry-After")
|
|
345
|
+
if any(s in low for s in ("per-day", "per day", "daily", "tokens per day", "requests per day", "rpd")):
|
|
346
|
+
secs = seconds_until_utc_midnight()
|
|
347
|
+
why = "daily limit"
|
|
348
|
+
elif retry_after and retry_after.replace(".", "", 1).isdigit():
|
|
349
|
+
secs, why = float(retry_after), "rate limit (Retry-After)"
|
|
350
|
+
else:
|
|
351
|
+
secs, why = 60, "per-minute limit"
|
|
352
|
+
# Free tiers count limits per model, so only this model rests on this key.
|
|
353
|
+
self.pool.bench(key, secs, model)
|
|
354
|
+
return self._fail(key, f"429 {why}, benched {int(secs)}s")
|
|
355
|
+
if status in (401, 403):
|
|
356
|
+
self.pool.bench(key, 3600)
|
|
357
|
+
return self._fail(key, f"{status} bad or revoked key, benched 1h")
|
|
358
|
+
if status == 402:
|
|
359
|
+
self.pool.bench(key, seconds_until_utc_midnight())
|
|
360
|
+
return self._fail(key, "402 out of credits, benched until UTC midnight")
|
|
361
|
+
if status == 413 or (status == 400 and ("too large" in low or "context length" in low
|
|
362
|
+
or "maximum context" in low or "too many tokens" in low)):
|
|
363
|
+
return self._fail(key, f"HTTP {status} ({model}): conversation too large for this model/plan")
|
|
364
|
+
if status != 200:
|
|
365
|
+
return self._fail(key, f"HTTP {status} ({model}): {text[:300]}")
|
|
366
|
+
|
|
367
|
+
if data is None:
|
|
368
|
+
try:
|
|
369
|
+
data = json.loads(text)
|
|
370
|
+
except ValueError:
|
|
371
|
+
return self._fail(key, "invalid JSON body")
|
|
372
|
+
if data.get("error"):
|
|
373
|
+
return self._fail(key, f"error in 200 body: {str(data['error'])[:300]}")
|
|
374
|
+
choice = (data.get("choices") or [{}])[0]
|
|
375
|
+
msg = choice.get("message") or {}
|
|
376
|
+
tool_calls = _norm_tool_calls(msg.get("tool_calls"))
|
|
377
|
+
if tool_calls is None:
|
|
378
|
+
return self._fail(key, "malformed tool call")
|
|
379
|
+
screened = screen(msg.get("content"), tool_calls)
|
|
380
|
+
if not screened:
|
|
381
|
+
return self._fail(key, "empty / junk / moderation-only reply")
|
|
382
|
+
content, tool_calls = screened
|
|
383
|
+
self.pool.store.count(self.pool.kid(key), ok=True)
|
|
384
|
+
u = data.get("usage") or {}
|
|
385
|
+
usage = {"in": int(u.get("prompt_tokens") or 0), "out": int(u.get("completion_tokens") or 0)}
|
|
386
|
+
return {"content": content, "tool_calls": tool_calls,
|
|
387
|
+
"truncated": choice.get("finish_reason") == "length" and not tool_calls,
|
|
388
|
+
"provider": self.name, "key": tail(key), "model": model, "usage": usage}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# --- router ----------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
CONTINUE_PROMPT = ("Your previous reply was cut off by the length limit. Continue EXACTLY where it "
|
|
394
|
+
"stopped. No preamble, no repetition of earlier text, do not restart, and do not "
|
|
395
|
+
"open a new code fence if you were inside one.")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def quality(model: str, provider: str = "") -> int:
|
|
399
|
+
"""Capability 1-10, worked out automatically (see forgecli/quality.py)."""
|
|
400
|
+
return _quality.score(model, provider)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
STICKY_WAIT = 25 # seconds the task's own model is waited for (short rate limit) before switching
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
HANDOFF_NOTE = ("\n\n[Forge: you are taking over this task from another model ({who}), which became "
|
|
407
|
+
"unavailable. Read the conversation above: the user's request, the work already done and "
|
|
408
|
+
"its results. Continue the same plan, structure and style; don't redo finished work or "
|
|
409
|
+
"start over.{todo}]")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class Router:
|
|
413
|
+
def __init__(self, settings: Settings, log, store: CooldownStore | None = None):
|
|
414
|
+
self.settings = settings
|
|
415
|
+
self.log = log
|
|
416
|
+
self.store = store or CooldownStore()
|
|
417
|
+
self.providers = [Provider(p, self.store, settings, log) for p in settings.providers]
|
|
418
|
+
self.pinned: str | None = None # provider name, or None for automatic failover
|
|
419
|
+
self.model_down: dict[tuple[str, str], float] = {} # (provider, model) -> skip until
|
|
420
|
+
self._speeds: dict[str, float] | None = None
|
|
421
|
+
self.by_speed = settings.fastest_first
|
|
422
|
+
self.by_quality = getattr(settings, "routing", "quality") != "speed"
|
|
423
|
+
self.sticky: tuple[str, str] | None = None # the model doing the current task
|
|
424
|
+
self.todo = None # callable -> open checklist items, for handoffs
|
|
425
|
+
self.limits = Limits()
|
|
426
|
+
self.junk: dict[tuple[str, str], int] = {} # consecutive empty/junk replies per model
|
|
427
|
+
self.usage = None
|
|
428
|
+
self.too_big = False # last complete(): every model skipped or refused it for size
|
|
429
|
+
self.size_cap = 0 # …and the biggest request any of them is known to take
|
|
430
|
+
self._skipped_size: list[int] = []
|
|
431
|
+
|
|
432
|
+
def get(self, name: str) -> Provider | None:
|
|
433
|
+
return next((p for p in self.providers if p.name == name), None)
|
|
434
|
+
|
|
435
|
+
def add_key(self, name: str, key: str, base_url: str = "", model: str = "") -> bool:
|
|
436
|
+
"""Use a key from now on (NEXT 7: /keys add, pasted keys). A new provider is placed before
|
|
437
|
+
the keyless safety-net ones. False when the provider has no known endpoint."""
|
|
438
|
+
p = self.get(name)
|
|
439
|
+
if p:
|
|
440
|
+
with p.pool._lock:
|
|
441
|
+
if p.pool.keys == [ANON_KEY]:
|
|
442
|
+
p.pool.keys[:] = [key]
|
|
443
|
+
elif key not in p.pool.keys:
|
|
444
|
+
p.pool.keys.append(key)
|
|
445
|
+
return True
|
|
446
|
+
from .config import KNOWN_PROVIDERS
|
|
447
|
+
base, default = KNOWN_PROVIDERS.get(name, ("", ""))
|
|
448
|
+
base, model = base_url or base, model or default
|
|
449
|
+
if not (base and model):
|
|
450
|
+
return False
|
|
451
|
+
cfg = ProviderConfig(name, base.rstrip("/"), model, [key])
|
|
452
|
+
self.settings.providers.append(cfg)
|
|
453
|
+
new = Provider(cfg, self.store, self.settings, self.log)
|
|
454
|
+
at = next((i for i, q in enumerate(self.providers) if q.pool.keys == [ANON_KEY]), len(self.providers))
|
|
455
|
+
self.providers.insert(at, new)
|
|
456
|
+
return True
|
|
457
|
+
|
|
458
|
+
def remove_key(self, name: str, key: str) -> None:
|
|
459
|
+
p = self.get(name)
|
|
460
|
+
if p:
|
|
461
|
+
with p.pool._lock:
|
|
462
|
+
if key in p.pool.keys and len(p.pool.keys) > 1:
|
|
463
|
+
p.pool.keys.remove(key)
|
|
464
|
+
elif key in p.pool.keys:
|
|
465
|
+
self.providers.remove(p)
|
|
466
|
+
|
|
467
|
+
def speed(self, provider: str, model: str) -> float:
|
|
468
|
+
"""Measured seconds per reply (from /roster bench and real use); unknown = 5s."""
|
|
469
|
+
if self._speeds is None:
|
|
470
|
+
try:
|
|
471
|
+
stats = json.loads(STATS_FILE.read_text(encoding="utf-8"))
|
|
472
|
+
self._speeds = {k: float(v["latency"]) for k, v in stats.items()
|
|
473
|
+
if v.get("up") and v.get("latency")}
|
|
474
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
475
|
+
self._speeds = {}
|
|
476
|
+
return self._speeds.get(f"{provider}:{model}", 5.0)
|
|
477
|
+
|
|
478
|
+
def observe(self, provider: str, model: str, secs: float) -> None:
|
|
479
|
+
"""Blend a real reply time into the speed estimate used for ordering."""
|
|
480
|
+
self.speed(provider, model)
|
|
481
|
+
k = f"{provider}:{model}"
|
|
482
|
+
old = self._speeds.get(k)
|
|
483
|
+
self._speeds[k] = secs if old is None else 0.7 * old + 0.3 * secs
|
|
484
|
+
|
|
485
|
+
def _ordered(self, offset: int = 0) -> list[Provider]:
|
|
486
|
+
if self.pinned and self.get(self.pinned):
|
|
487
|
+
return [self.get(self.pinned)]
|
|
488
|
+
# Healthy providers first, fastest first; one that failed in the last 5 minutes goes
|
|
489
|
+
# to the back. Ties keep the FORGE_PROVIDERS order.
|
|
490
|
+
recent = time.time() - 300
|
|
491
|
+
best = (lambda p: -max(quality(m, p.name) for m in self._models_of(p))) if self.by_quality \
|
|
492
|
+
else (lambda p: 0)
|
|
493
|
+
order = sorted(self.providers, key=lambda p: (self._health_rank(p.name, p.model),
|
|
494
|
+
p.last_fail > recent, best(p),
|
|
495
|
+
self.speed(p.name, p.model) if self.by_speed else 0))
|
|
496
|
+
if offset and order:
|
|
497
|
+
# Parallel agents start at different providers; the rest stay as fallbacks.
|
|
498
|
+
k = offset % len(order)
|
|
499
|
+
order = order[k:] + order[:k]
|
|
500
|
+
return order
|
|
501
|
+
|
|
502
|
+
def lead(self) -> Provider | None:
|
|
503
|
+
"""The provider that will be tried first."""
|
|
504
|
+
order = self._ordered()
|
|
505
|
+
return order[0] if order else None
|
|
506
|
+
|
|
507
|
+
def any_available(self) -> bool:
|
|
508
|
+
return any(p.pool.any_available() for p in self._ordered())
|
|
509
|
+
|
|
510
|
+
def _try(self, p: Provider, messages, tools, model: str | None = None, ev=None,
|
|
511
|
+
on_text=None) -> dict | None:
|
|
512
|
+
ev = ev or (lambda *a: None)
|
|
513
|
+
m = model or p.model
|
|
514
|
+
if self.is_down(p.name, m):
|
|
515
|
+
return None
|
|
516
|
+
size = request_size(messages, tools)
|
|
517
|
+
if not self.limits.fits(p.name, m, size):
|
|
518
|
+
# It already told us it can't take a conversation this big; don't ask again.
|
|
519
|
+
self.log(f"skipping {p.name}:{m} — {size:,} chars is over what it accepts")
|
|
520
|
+
self._skipped_size.append(self.limits.cap(p.name, m) or 0)
|
|
521
|
+
return None
|
|
522
|
+
for _ in range(min(self.settings.keys_per_provider, len(p.pool.keys))):
|
|
523
|
+
key = p.pool.next_key(m)
|
|
524
|
+
if key is None:
|
|
525
|
+
ev("benched", p.name, m, "all keys resting after limits")
|
|
526
|
+
return None # every key of this provider is benched
|
|
527
|
+
ev("try", p.name, m, p.pool.kid(key).split(":", 1)[1])
|
|
528
|
+
t0 = time.time()
|
|
529
|
+
r = p.call(key, messages, tools, model, on_text=on_text)
|
|
530
|
+
if r:
|
|
531
|
+
self.observe(p.name, m, time.time() - t0)
|
|
532
|
+
self.limits.handled(p.name, m, size)
|
|
533
|
+
self.junk.pop((p.name, m), None)
|
|
534
|
+
if self.usage is not None:
|
|
535
|
+
self.usage.record("timeout", f"{p.name}:{m}", True)
|
|
536
|
+
return r
|
|
537
|
+
ev("fail", p.name, m, p.last_error)
|
|
538
|
+
if "too large" in p.last_error:
|
|
539
|
+
self.limits.too_big(p.name, m, size) # remembered, so it's skipped next time
|
|
540
|
+
self._skipped_size.append(self.limits.cap(p.name, m) or 0)
|
|
541
|
+
return None
|
|
542
|
+
if p.last_error.startswith("timed out"):
|
|
543
|
+
# A model that went silent is usually overloaded; skip it for 3 min (its sibling
|
|
544
|
+
# models on the same provider are tried next).
|
|
545
|
+
self.model_down[(p.name, m)] = time.time() + 180
|
|
546
|
+
if self.usage is not None:
|
|
547
|
+
self.usage.record("timeout", f"{p.name}:{m}", False)
|
|
548
|
+
return None
|
|
549
|
+
if p.last_error.startswith("empty / junk"):
|
|
550
|
+
_quality.record(p.name, m, False)
|
|
551
|
+
n = self.junk[(p.name, m)] = self.junk.get((p.name, m), 0) + 1
|
|
552
|
+
if n >= 2: # it keeps failing at this job; let the others take it for a while
|
|
553
|
+
self.model_down[(p.name, m)] = time.time() + 300
|
|
554
|
+
self.junk.pop((p.name, m), None)
|
|
555
|
+
return None
|
|
556
|
+
if any(f"HTTP {c}" in p.last_error for c in (404, 410)):
|
|
557
|
+
# the model is gone from this provider; stop asking it for an hour
|
|
558
|
+
self.model_down[(p.name, m)] = time.time() + 3600
|
|
559
|
+
return None
|
|
560
|
+
if any(f"HTTP {c}" in p.last_error for c in (400, 413)):
|
|
561
|
+
# Rejects how Forge builds the request (e.g. "System message must be at the
|
|
562
|
+
# beginning"). It would fail the same way on every step, costing a round trip
|
|
563
|
+
# each time, so rest it for 10 minutes.
|
|
564
|
+
self.model_down[(p.name, m)] = time.time() + 600
|
|
565
|
+
return None # the model itself is the problem; other keys won't help
|
|
566
|
+
return None
|
|
567
|
+
|
|
568
|
+
def _all_too_small(self, messages, tools) -> bool:
|
|
569
|
+
"""True when every model still in play is known to reject a request this big."""
|
|
570
|
+
size = request_size(messages, tools)
|
|
571
|
+
return all(not self.limits.fits(p.name, p.model, size) or self.is_down(p.name, p.model)
|
|
572
|
+
for p in self._ordered())
|
|
573
|
+
|
|
574
|
+
def is_down(self, provider: str, model: str) -> bool:
|
|
575
|
+
return self.model_down.get((provider, model), 0) > time.time()
|
|
576
|
+
|
|
577
|
+
def chain_once(self, messages, tools, offset: int = 0,
|
|
578
|
+
prefer: tuple[str, str] | None = None, ev=None, on_text=None) -> dict | None:
|
|
579
|
+
# A preferred worker (team mode) goes first; the normal chain is the fallback.
|
|
580
|
+
if prefer and not self.is_down(*prefer):
|
|
581
|
+
p = self.get(prefer[0])
|
|
582
|
+
if p:
|
|
583
|
+
r = self._try(p, messages, tools, prefer[1], ev, on_text)
|
|
584
|
+
if r:
|
|
585
|
+
return r
|
|
586
|
+
self.model_down[prefer] = time.time() + 300 # skip it for 5 min
|
|
587
|
+
self.log(f"{prefer[0]}:{prefer[1]} failed, falling back to the normal chain")
|
|
588
|
+
pairs = self._pairs(offset)
|
|
589
|
+
if images.has_images(messages):
|
|
590
|
+
# The user attached an image: models that can see it go first.
|
|
591
|
+
vis = [(p, m) for p, m in pairs if images.sees_images(m)]
|
|
592
|
+
if not vis and not self.pinned:
|
|
593
|
+
vis = [(p, m) for p in self._ordered(offset) for m in p.all_models()
|
|
594
|
+
if images.sees_images(m)][:3]
|
|
595
|
+
pairs = vis + [pm for pm in pairs if pm not in vis]
|
|
596
|
+
for p, m in pairs:
|
|
597
|
+
r = self._try(p, messages, tools, m, ev, on_text)
|
|
598
|
+
if r:
|
|
599
|
+
return r
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
def _pairs(self, offset: int = 0) -> list[tuple[Provider, str]]:
|
|
603
|
+
"""Every (provider, model) to try, in order. Quality routing ranks all models across all
|
|
604
|
+
providers: healthy first, then the most capable, then the fastest."""
|
|
605
|
+
pairs = [(p, m) for p in self._ordered(offset) for m in self._models_of(p)]
|
|
606
|
+
if self.by_quality and not self.pinned and not offset:
|
|
607
|
+
recent = time.time() - 300
|
|
608
|
+
pairs.sort(key=lambda pm: (self._health_rank(pm[0].name, pm[1]), pm[0].last_fail > recent,
|
|
609
|
+
(pm[0].name, pm[1]) in self.model_down
|
|
610
|
+
and self.model_down[(pm[0].name, pm[1])] > time.time(),
|
|
611
|
+
-quality(pm[1], pm[0].name),
|
|
612
|
+
self.speed(pm[0].name, pm[1]) if self.by_speed else 0))
|
|
613
|
+
return pairs
|
|
614
|
+
|
|
615
|
+
def new_turn(self) -> None:
|
|
616
|
+
"""Start of a user turn: keep the task's model unless a more capable one is usable again."""
|
|
617
|
+
if not self.sticky or not self.by_quality:
|
|
618
|
+
return
|
|
619
|
+
now = time.time()
|
|
620
|
+
for p, m in self._pairs():
|
|
621
|
+
if p.pool.wait_for(m) <= 0 and self.model_down.get((p.name, m), 0) <= now:
|
|
622
|
+
if quality(m, p.name) > quality(self.sticky[1], self.sticky[0]):
|
|
623
|
+
self.sticky = None
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
def _stick(self, messages, tools, ev, on_text) -> dict | None:
|
|
627
|
+
"""Try the model already doing this task (waiting briefly through a short rate limit)."""
|
|
628
|
+
s = self.sticky
|
|
629
|
+
p = self.get(s[0]) if s and not self.pinned else None
|
|
630
|
+
if not p or self.model_down.get(s, 0) > time.time():
|
|
631
|
+
return None
|
|
632
|
+
wait = p.pool.wait_for(s[1])
|
|
633
|
+
if wait > STICKY_WAIT:
|
|
634
|
+
return None
|
|
635
|
+
if wait > 0:
|
|
636
|
+
if ev:
|
|
637
|
+
ev("wait", p.name, s[1], f"{wait:.0f}")
|
|
638
|
+
time.sleep(wait + 0.2)
|
|
639
|
+
return self._try(p, messages, tools, s[1], ev, on_text)
|
|
640
|
+
|
|
641
|
+
def _handoff(self, messages: list[dict]) -> list[dict]:
|
|
642
|
+
"""A copy of the conversation telling the next model it is taking over mid-task."""
|
|
643
|
+
todo = ""
|
|
644
|
+
try:
|
|
645
|
+
items = self.todo() if self.todo else []
|
|
646
|
+
except Exception:
|
|
647
|
+
items = []
|
|
648
|
+
if items:
|
|
649
|
+
todo = " Still open on the checklist: " + "; ".join(items[:6]) + "."
|
|
650
|
+
note = HANDOFF_NOTE.format(who=f"{self.sticky[0]} · {self.sticky[1]}", todo=todo)
|
|
651
|
+
out = list(messages)
|
|
652
|
+
if out and out[0].get("role") == "system" and isinstance(out[0].get("content"), str):
|
|
653
|
+
out[0] = dict(out[0], content=out[0]["content"] + note)
|
|
654
|
+
return out
|
|
655
|
+
|
|
656
|
+
def _models_of(self, p: Provider) -> list[str]:
|
|
657
|
+
"""The provider's main model, then (when pinned via automatic failover) up to 3 of its
|
|
658
|
+
other models: a limit or outage on one model rarely hits the others."""
|
|
659
|
+
alts = [m for m in p.models if m != p.model][:3]
|
|
660
|
+
return sorted([p.model] + alts, key=lambda model: self._health_rank(p.name, model))
|
|
661
|
+
|
|
662
|
+
def _health_rank(self, provider: str, model: str) -> int:
|
|
663
|
+
"""Models with repeated timeouts or recent claim-check failures wait behind healthy ones."""
|
|
664
|
+
if self.usage is None:
|
|
665
|
+
return 0
|
|
666
|
+
timed_out = self.usage.get("timeout", f"{provider}:{model}").get("consecutive_failures", 0) >= 3
|
|
667
|
+
guard_failures = self.usage.recent_failures("guard", f"{provider}:{model}", 20) >= 3
|
|
668
|
+
return int(timed_out) + int(guard_failures)
|
|
669
|
+
|
|
670
|
+
def soonest(self) -> tuple[float, str]:
|
|
671
|
+
"""(seconds until something can be tried again, "provider·model"); (inf, "") if nothing will."""
|
|
672
|
+
best, who = float("inf"), ""
|
|
673
|
+
now = time.time()
|
|
674
|
+
for p in self._ordered():
|
|
675
|
+
for m in self._models_of(p):
|
|
676
|
+
w = max(p.pool.wait_for(m), self.model_down.get((p.name, m), 0) - now, 0.0)
|
|
677
|
+
if w < best:
|
|
678
|
+
best, who = w, f"{p.name}·{m.split('/')[-1]}"
|
|
679
|
+
return best, who
|
|
680
|
+
|
|
681
|
+
def complete(self, messages, tools, offset: int = 0,
|
|
682
|
+
prefer: tuple[str, str] | None = None, ev=None, on_text=None) -> dict | None:
|
|
683
|
+
"""ev(kind, provider, model, detail) reports progress: try / fail / benched / wait.
|
|
684
|
+
on_text(str) receives the reply as it streams; after a 'fail' event the text so far is void."""
|
|
685
|
+
rounds = self.settings.retry_rounds
|
|
686
|
+
self.too_big, self.size_cap = False, 0
|
|
687
|
+
deadline = time.time() + max(0, getattr(self.settings, "max_wait", 150))
|
|
688
|
+
attempt = 0
|
|
689
|
+
sticky = not offset and not prefer and not self.pinned # one model per task (main agent)
|
|
690
|
+
while True:
|
|
691
|
+
self._skipped_size: list[int] = []
|
|
692
|
+
r = self._stick(messages, tools, ev, on_text) if sticky else None
|
|
693
|
+
if not r:
|
|
694
|
+
msgs = self._handoff(messages) if sticky and self.sticky else messages
|
|
695
|
+
r = self.chain_once(msgs, tools, offset, prefer, ev, on_text)
|
|
696
|
+
if r:
|
|
697
|
+
if sticky:
|
|
698
|
+
if self.sticky and self.sticky != (r["provider"], r["model"]):
|
|
699
|
+
self.log(f"task moved from {self.sticky[0]}:{self.sticky[1]} "
|
|
700
|
+
f"to {r['provider']}:{r['model']}")
|
|
701
|
+
self.sticky = (r["provider"], r["model"])
|
|
702
|
+
return self._continue(r, messages, tools, offset, prefer, ev, on_text)
|
|
703
|
+
if self._skipped_size and self._all_too_small(messages, tools):
|
|
704
|
+
# Waiting won't help: no model takes a request this size. The caller shrinks it.
|
|
705
|
+
self.too_big, self.size_cap = True, max(self._skipped_size)
|
|
706
|
+
return None
|
|
707
|
+
left = deadline - time.time()
|
|
708
|
+
wait, who = self.soonest()
|
|
709
|
+
if wait > 0.5:
|
|
710
|
+
# Everything is resting after limits: wait for the first one to come back
|
|
711
|
+
# (instead of failing while /status says "ready" a minute later).
|
|
712
|
+
if wait > left:
|
|
713
|
+
self.log(f"nothing frees up within FORGE_MAX_WAIT ({wait:.0f}s > {left:.0f}s left)")
|
|
714
|
+
break
|
|
715
|
+
delay = wait + 0.5
|
|
716
|
+
else:
|
|
717
|
+
# Something is usable but just failed (5xx, network, junk): short jittered backoff.
|
|
718
|
+
if attempt >= rounds or left <= 0:
|
|
719
|
+
break
|
|
720
|
+
delay = min(20.0, 1.5 * 2 ** attempt) * random.uniform(0.5, 1.5)
|
|
721
|
+
delay, who = min(delay, max(left, 0.5)), ""
|
|
722
|
+
attempt += 1
|
|
723
|
+
self.log(f"all sources failed, retrying in {delay:.1f}s" + (f" ({who})" if who else ""))
|
|
724
|
+
if ev:
|
|
725
|
+
ev("wait", "", who, f"{delay:.0f}")
|
|
726
|
+
time.sleep(delay)
|
|
727
|
+
return None
|
|
728
|
+
|
|
729
|
+
def _continue(self, first: dict, messages, tools, offset: int = 0,
|
|
730
|
+
prefer: tuple[str, str] | None = None, ev=None, on_text=None) -> dict:
|
|
731
|
+
parts, current = [first["content"]], first
|
|
732
|
+
for _ in range(self.settings.max_continuations):
|
|
733
|
+
if not current["truncated"]:
|
|
734
|
+
break
|
|
735
|
+
self.log("reply cut off by length limit, continuing")
|
|
736
|
+
follow = messages + [{"role": "assistant", "content": "".join(parts)},
|
|
737
|
+
{"role": "user", "content": CONTINUE_PROMPT}]
|
|
738
|
+
# Finish with the same model that started the reply when possible.
|
|
739
|
+
nxt = self.chain_once(follow, tools, offset, (first["provider"], first["model"]), ev, on_text)
|
|
740
|
+
if not nxt:
|
|
741
|
+
break
|
|
742
|
+
parts.append(nxt["content"])
|
|
743
|
+
for k in ("in", "out"):
|
|
744
|
+
first["usage"][k] += nxt.get("usage", {}).get(k, 0)
|
|
745
|
+
current = nxt
|
|
746
|
+
if nxt["tool_calls"]:
|
|
747
|
+
first["tool_calls"] = nxt["tool_calls"]
|
|
748
|
+
break
|
|
749
|
+
first["content"] = "".join(parts)
|
|
750
|
+
first["truncated"] = current["truncated"]
|
|
751
|
+
return first
|
|
752
|
+
|
|
753
|
+
def status_lines(self) -> list[str]:
|
|
754
|
+
out = []
|
|
755
|
+
for p in self.providers:
|
|
756
|
+
out.append(f"{p.name} model={p.model}" + (f" (+{', '.join(p.models)})" if p.models else ""))
|
|
757
|
+
out.extend(p.pool.status_lines())
|
|
758
|
+
return out
|