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.
Files changed (113) hide show
  1. forgecli/__init__.py +2 -0
  2. forgecli/ads.py +322 -0
  3. forgecli/agent.py +1093 -0
  4. forgecli/art.py +574 -0
  5. forgecli/attach.py +246 -0
  6. forgecli/brief.py +215 -0
  7. forgecli/browsercheck.py +99 -0
  8. forgecli/checkpoints.py +81 -0
  9. forgecli/city.py +833 -0
  10. forgecli/cli.py +1668 -0
  11. forgecli/config.py +194 -0
  12. forgecli/courier.py +177 -0
  13. forgecli/courier_data.py +164 -0
  14. forgecli/courier_hd.py +431 -0
  15. forgecli/courier_hd_data.bin +0 -0
  16. forgecli/courier_hd_data.py +19 -0
  17. forgecli/ghlogin.py +76 -0
  18. forgecli/github.py +54 -0
  19. forgecli/guards.py +403 -0
  20. forgecli/hooks.py +50 -0
  21. forgecli/httpapi.py +434 -0
  22. forgecli/images.py +110 -0
  23. forgecli/keys.py +152 -0
  24. forgecli/keyvault.py +320 -0
  25. forgecli/mcp.py +674 -0
  26. forgecli/memory.py +42 -0
  27. forgecli/openapi.py +331 -0
  28. forgecli/parallel.py +308 -0
  29. forgecli/prices.py +72 -0
  30. forgecli/procs.py +144 -0
  31. forgecli/providers.py +758 -0
  32. forgecli/quality.py +192 -0
  33. forgecli/repomap.py +78 -0
  34. forgecli/roster.py +227 -0
  35. forgecli/safety.py +93 -0
  36. forgecli/sandbox.py +83 -0
  37. forgecli/sessions.py +97 -0
  38. forgecli/skills.py +446 -0
  39. forgecli/tools.py +1184 -0
  40. forgecli/ui.py +860 -0
  41. forgecli/worktrees.py +79 -0
  42. forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
  43. forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
  44. forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
  45. forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
  46. forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
  47. forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
  48. forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
  49. forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
  50. forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
  51. forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
  52. forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
  53. forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
  54. forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
  55. forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
  56. forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
  57. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
  58. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
  59. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
  60. forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
  61. forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
  62. forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
  63. forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
  64. forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
  65. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
  66. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
  67. forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
  68. forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
  69. forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
  70. forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
  71. forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
  72. forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
  73. forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
  74. forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
  75. forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
  76. forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
  77. forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
  78. forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
  79. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
  80. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
  81. forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
  82. forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
  83. forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
  84. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
  85. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
  86. forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
  87. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
  88. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
  89. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
  90. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
  91. forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
  92. forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
  93. forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
  94. forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
  95. forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
  96. forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
  97. forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
  98. forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
  99. forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
  100. forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
  101. forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
  102. forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
  103. forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
  104. forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
  105. forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
  106. forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
  107. forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
  108. forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
  109. forgecommandline-0.8.0.dist-info/METADATA +357 -0
  110. forgecommandline-0.8.0.dist-info/RECORD +113 -0
  111. forgecommandline-0.8.0.dist-info/WHEEL +5 -0
  112. forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
  113. 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