@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.0
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.
- package/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Logs and stopping (LC-01, LC-12): a rotating log, a capped stdout log, the stop signal and the drain.
|
|
2
|
+
Part of the service kit (FAA-65, layer L3)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import traceback
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Dict, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
import sys as _sys # noqa: E402
|
|
17
|
+
from pathlib import Path as _Path # noqa: E402
|
|
18
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
19
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
20
|
+
from fabric_base import (
|
|
21
|
+
DRAIN_SECONDS,
|
|
22
|
+
ServiceError,
|
|
23
|
+
_O_BINARY,
|
|
24
|
+
now_iso,
|
|
25
|
+
)
|
|
26
|
+
from fabric_paths import (
|
|
27
|
+
ensure_private_dir,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DRAIN_GRACE_SECONDS = 2.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
EXIT_HARD_STOP = 70
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# LC-12: logs rotate by size, 5 x 5 MB by default.
|
|
38
|
+
LOG_MAX_BYTES = 5 * 1024 * 1024
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
LOG_BACKUPS = 5
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RotatingLog:
|
|
45
|
+
"""One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a
|
|
46
|
+
0700 directory. ``service.jsonl`` -> ``.1`` ... ``.<backups>``; the oldest falls off.
|
|
47
|
+
|
|
48
|
+
Never pass a token, a cookie, a login code or a request body that may carry one."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, path: Path, max_bytes: int = LOG_MAX_BYTES, backups: int = LOG_BACKUPS):
|
|
51
|
+
if max_bytes < 256 or backups < 1:
|
|
52
|
+
raise ServiceError("A rotating log needs max_bytes >= 256 and at least one backup.")
|
|
53
|
+
self.path = Path(path)
|
|
54
|
+
self.max_bytes = max_bytes
|
|
55
|
+
self.backups = backups
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
|
|
58
|
+
def _line(self, record: Dict[str, Any]) -> bytes:
|
|
59
|
+
data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
|
|
60
|
+
if len(data) <= self.max_bytes:
|
|
61
|
+
return data
|
|
62
|
+
record = dict(record, truncated=True)
|
|
63
|
+
message = str(record.get("message", ""))
|
|
64
|
+
while message:
|
|
65
|
+
message = message[: len(message) // 2]
|
|
66
|
+
record["message"] = message
|
|
67
|
+
data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
|
|
68
|
+
if len(data) <= self.max_bytes:
|
|
69
|
+
return data
|
|
70
|
+
return (json.dumps({"at": record.get("at"), "level": record.get("level"), "message": "",
|
|
71
|
+
"truncated": True}) + "\n").encode()
|
|
72
|
+
|
|
73
|
+
def write(self, level: str, message: str, **fields: Any) -> None:
|
|
74
|
+
record: Dict[str, Any] = {"at": now_iso(), "level": str(level), "message": str(message)}
|
|
75
|
+
record.update(fields)
|
|
76
|
+
data = self._line(record)
|
|
77
|
+
with self._lock:
|
|
78
|
+
ensure_private_dir(self.path.parent)
|
|
79
|
+
try:
|
|
80
|
+
size = self.path.stat().st_size
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
size = 0
|
|
83
|
+
if size and size + len(data) > self.max_bytes:
|
|
84
|
+
self._rotate()
|
|
85
|
+
fd = os.open(str(self.path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | _O_BINARY, 0o600)
|
|
86
|
+
try:
|
|
87
|
+
os.write(fd, data)
|
|
88
|
+
finally:
|
|
89
|
+
os.close(fd)
|
|
90
|
+
|
|
91
|
+
def _rotate(self) -> None:
|
|
92
|
+
for index in range(self.backups - 1, 0, -1):
|
|
93
|
+
older = Path("%s.%d" % (self.path, index))
|
|
94
|
+
if older.exists():
|
|
95
|
+
os.replace(older, "%s.%d" % (self.path, index + 1))
|
|
96
|
+
os.replace(self.path, "%s.1" % self.path)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cap_stdout_log(path: Path, max_bytes: int = LOG_MAX_BYTES) -> bool:
|
|
100
|
+
"""Call once at start: a launchd stdout file over ``max_bytes`` is copied to ``<path>.1`` and
|
|
101
|
+
truncated in place. launchd holds the file open for append, so a rename would leave it
|
|
102
|
+
writing to the old name; truncation keeps its descriptor valid. Returns True when capped."""
|
|
103
|
+
path = Path(path)
|
|
104
|
+
try:
|
|
105
|
+
size = path.stat().st_size
|
|
106
|
+
except FileNotFoundError:
|
|
107
|
+
return False
|
|
108
|
+
if size <= max_bytes:
|
|
109
|
+
return False
|
|
110
|
+
backup = Path("%s.1" % path)
|
|
111
|
+
shutil.copyfile(path, backup)
|
|
112
|
+
os.chmod(backup, 0o600)
|
|
113
|
+
with open(path, "r+b") as handle:
|
|
114
|
+
handle.truncate(0)
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Stopping(ServiceError):
|
|
119
|
+
"""The service is stopping and takes no new work."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Drain:
|
|
123
|
+
"""SIGTERM/SIGINT: stop taking new work, drain in-flight work until ``deadline``, then hand
|
|
124
|
+
over with ``on_stop(drained)``. A hand-over that hangs is cut by a hard exit
|
|
125
|
+
(``EXIT_HARD_STOP``) ``grace`` seconds later, so quit reaches exit inside the lifecycle bound.
|
|
126
|
+
|
|
127
|
+
drain = fs.Drain()
|
|
128
|
+
drain.install(lambda drained: server.shutdown(),
|
|
129
|
+
on_stopping=lambda: log.append("service.stopping", "info", "Stopping."))
|
|
130
|
+
with drain.work(): # raises fs.Stopping once a stop has begun
|
|
131
|
+
...
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, deadline: float = DRAIN_SECONDS, grace: float = DRAIN_GRACE_SECONDS):
|
|
135
|
+
self.deadline = deadline
|
|
136
|
+
self.grace = grace
|
|
137
|
+
self._cond = threading.Condition()
|
|
138
|
+
self._inflight = 0
|
|
139
|
+
self._stopping = False
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def stopping(self) -> bool:
|
|
143
|
+
return self._stopping
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def inflight(self) -> int:
|
|
147
|
+
return self._inflight
|
|
148
|
+
|
|
149
|
+
@contextlib.contextmanager
|
|
150
|
+
def work(self):
|
|
151
|
+
with self._cond:
|
|
152
|
+
if self._stopping:
|
|
153
|
+
raise Stopping("The service is stopping and takes no new work.")
|
|
154
|
+
self._inflight += 1
|
|
155
|
+
try:
|
|
156
|
+
yield
|
|
157
|
+
finally:
|
|
158
|
+
with self._cond:
|
|
159
|
+
self._inflight -= 1
|
|
160
|
+
self._cond.notify_all()
|
|
161
|
+
|
|
162
|
+
def request_stop(self) -> bool:
|
|
163
|
+
"""Refuse new work from now on; False when a stop had already begun."""
|
|
164
|
+
with self._cond:
|
|
165
|
+
if self._stopping:
|
|
166
|
+
return False
|
|
167
|
+
self._stopping = True
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def wait(self, timeout: Optional[float] = None) -> bool:
|
|
171
|
+
"""Wait for in-flight work to finish; True when it did inside ``timeout``."""
|
|
172
|
+
end = None if timeout is None else time.monotonic() + timeout
|
|
173
|
+
with self._cond:
|
|
174
|
+
while self._inflight > 0:
|
|
175
|
+
left = None if end is None else end - time.monotonic()
|
|
176
|
+
if left is not None and left <= 0:
|
|
177
|
+
return False
|
|
178
|
+
self._cond.wait(left)
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
def install(self, on_stop: Callable[[bool], None], *, on_stopping: Optional[Callable[[], None]] = None,
|
|
182
|
+
signals: Sequence[int] = (signal.SIGTERM, signal.SIGINT)) -> "Drain":
|
|
183
|
+
"""Install the handlers (main thread only). A second signal changes nothing: the hard-exit
|
|
184
|
+
timer already holds the deadline."""
|
|
185
|
+
def handler(_signum: int, _frame: Any) -> None:
|
|
186
|
+
if self.request_stop():
|
|
187
|
+
threading.Thread(target=self._stop, args=(on_stopping, on_stop), daemon=True).start()
|
|
188
|
+
|
|
189
|
+
for sig in signals:
|
|
190
|
+
signal.signal(sig, handler)
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def _stop(self, on_stopping: Optional[Callable[[], None]], on_stop: Callable[[bool], None]) -> None:
|
|
194
|
+
hard = threading.Timer(self.deadline + self.grace, os._exit, args=(EXIT_HARD_STOP,))
|
|
195
|
+
hard.daemon = True
|
|
196
|
+
hard.start()
|
|
197
|
+
if on_stopping is not None:
|
|
198
|
+
try:
|
|
199
|
+
on_stopping()
|
|
200
|
+
except Exception: # a failing stop notice must not stop the drain
|
|
201
|
+
traceback.print_exc()
|
|
202
|
+
on_stop(self.wait(self.deadline))
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""OpenRouter chat completions for a service: its own key, a spend ceiling checked before every attempt, a usage
|
|
2
|
+
receipt for every answered call, and a fallback model of another vendor.
|
|
3
|
+
|
|
4
|
+
client = fabric_openrouter.Client(ceiling_usd=2.0, ledger=JsonlUsageLedger(state / "usage.jsonl"),
|
|
5
|
+
prices=fabric_openrouter.prices_from_catalog(catalog))
|
|
6
|
+
answer = client.chat([{"role": "user", "content": "..."}], model="deepseek/deepseek-v4.1-flash",
|
|
7
|
+
fallback="qwen/qwen3.8-flash", max_tokens=800)
|
|
8
|
+
answer["content"], answer["receipt"]["costUsd"]
|
|
9
|
+
|
|
10
|
+
Rules (FAA-65 REQ-07; the model guide is creating-fabric-agents references/models-and-providers.md):
|
|
11
|
+
|
|
12
|
+
- **The key** is read from the environment variable `key_env` names (`OPENROUTER_API_KEY` by default) when a call is
|
|
13
|
+
made; it is sent only in the `Authorization` header to `base_url`, never logged and never in an error.
|
|
14
|
+
- **The ceiling.** Before every attempt, retries and the fallback included, the client adds what the window has
|
|
15
|
+
spent (the ledger's receipts in the UTC day or month, plus calls in flight) to this attempt's worst case: the
|
|
16
|
+
prompt counted as one token per two UTF-8 bytes, `max_tokens` of output, at the model's price. Over the ceiling, the
|
|
17
|
+
attempt is not sent (`OpenRouterError` kind `ceiling`). A model without a price is not sent (`unpriced`): its spend
|
|
18
|
+
could not be bounded. A receipt without a cost counts as `unmeasured_call_usd`, by default this attempt's own worst
|
|
19
|
+
case.
|
|
20
|
+
- **Receipts.** Every answer with status 200 records one receipt (`provider: openrouter`, `route: key`): the served
|
|
21
|
+
model, the provider's token counts, `costUsd` from `usage.cost`, or from the price list when only tokens came
|
|
22
|
+
(`costBasis: price-list`), or `null` with `tokensMeasured: false` when the answer carried no usage. A refused
|
|
23
|
+
request (4xx, 5xx) records nothing: it was not billed.
|
|
24
|
+
- **What is an answer.** `finish_reason` `stop` or `tool_calls` with content or tool calls. `error` is retried on
|
|
25
|
+
the same model; `length` (the budget ran out, reasoning included), `content_filter` and an empty answer go to the
|
|
26
|
+
fallback. Never returned as success.
|
|
27
|
+
- **Retries.** 408, 429, 5xx and connection errors are retried on the same model (`retries` times, `Retry-After`
|
|
28
|
+
honoured) within one deadline for the whole call; then the fallback is tried the same way. 401, 402 and 403 stop at
|
|
29
|
+
once: a key, credit or permission problem is the same on every model.
|
|
30
|
+
- **The fallback** is required and of another vendor (the part before `/` differs).
|
|
31
|
+
- **Attribution** is off unless an attribution setting is given (FAC-SEM-098, `fabric_usage.attribution_headers`).
|
|
32
|
+
|
|
33
|
+
The transport is fabric_http's, injected in tests. Standard library only. Part of the service kit (FAA-65, layer L4).
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import math
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import random as _random
|
|
41
|
+
import threading
|
|
42
|
+
import time
|
|
43
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional
|
|
44
|
+
|
|
45
|
+
import sys as _sys # noqa: E402
|
|
46
|
+
from pathlib import Path as _Path # noqa: E402
|
|
47
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
48
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
49
|
+
import fabric_http
|
|
50
|
+
import fabric_time
|
|
51
|
+
import fabric_usage
|
|
52
|
+
|
|
53
|
+
BASE_URL = "https://openrouter.ai/api/v1"
|
|
54
|
+
ANSWERED = ("stop", "tool_calls")
|
|
55
|
+
_STOP_AT_ONCE = {401: "auth", 402: "payment", 403: "auth"}
|
|
56
|
+
_RESERVED_PARAMS = ("model", "messages", "max_tokens", "max_completion_tokens", "stream", "usage")
|
|
57
|
+
#: Params that change what a call is billed for (other models, plugins, web search): the ceiling could not bound them.
|
|
58
|
+
_UNBOUNDED_PARAMS = ("models", "route", "plugins", "web_search_options", "n")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OpenRouterError(Exception):
|
|
62
|
+
"""No answer the caller can use. `kind`: config, unpriced, ceiling, auth, payment, failed. `attempts` lists every
|
|
63
|
+
attempt as `{model, status, outcome}`; nothing in it holds the key or the prompt."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, *, kind: str, attempts: Optional[List[Dict[str, Any]]] = None):
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.kind = kind
|
|
68
|
+
self.attempts = list(attempts or [])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def vendor(model: str) -> str:
|
|
72
|
+
return str(model).split("/", 1)[0] if "/" in str(model) else ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?", re.ASCII)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _price(value: Any) -> Optional[float]:
|
|
79
|
+
"""A catalog price (a plain decimal string such as "0.000001", or a number; USD per token) as a float; None for
|
|
80
|
+
anything else (blank, hex, a list, negative, not finite), exactly as the Node twin reads it."""
|
|
81
|
+
if isinstance(value, bool):
|
|
82
|
+
return None
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
if not _DECIMAL.fullmatch(value):
|
|
85
|
+
return None
|
|
86
|
+
value = float(value)
|
|
87
|
+
if not isinstance(value, (int, float)):
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
number = float(value)
|
|
91
|
+
except OverflowError: # an integer too large for a double is no price
|
|
92
|
+
return None
|
|
93
|
+
return number if math.isfinite(number) and number >= 0 else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def prices_from_catalog(catalog: Any) -> Dict[str, Dict[str, float]]:
|
|
97
|
+
"""`{model: {prompt, completion, request}}` in USD per token (per request) from `GET /api/v1/models`
|
|
98
|
+
(`{"data": [{"id", "pricing": {"prompt", "completion", "request"}}]}`). A model whose prompt or completion price
|
|
99
|
+
is missing or unreadable is left out, so the client refuses it rather than guessing."""
|
|
100
|
+
out: Dict[str, Dict[str, float]] = {}
|
|
101
|
+
rows = catalog.get("data") if isinstance(catalog, dict) else None
|
|
102
|
+
for row in rows if isinstance(rows, list) else []:
|
|
103
|
+
if not isinstance(row, dict) or not isinstance(row.get("id"), str):
|
|
104
|
+
continue
|
|
105
|
+
pricing = row.get("pricing") if isinstance(row.get("pricing"), dict) else {}
|
|
106
|
+
prompt, completion = _price(pricing.get("prompt")), _price(pricing.get("completion"))
|
|
107
|
+
if prompt is None or completion is None:
|
|
108
|
+
continue
|
|
109
|
+
out[row["id"]] = {"prompt": prompt, "completion": completion, "request": _price(pricing.get("request")) or 0.0}
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def prompt_tokens_bound(value: Any) -> int:
|
|
114
|
+
"""An upper bound on the input tokens of what is sent: one token per two UTF-8 bytes of it as compact JSON (as
|
|
115
|
+
`JSON.stringify` writes it). The client bounds the messages and every param together (tools, response_format):
|
|
116
|
+
all of it is billed as input. Real tokenizers average three to four bytes a token on prose; the bound is loose."""
|
|
117
|
+
return math.ceil(len(fabric_http.json_bytes(value)) / 2)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _count(value: Any) -> Optional[int]:
|
|
121
|
+
"""A token count: an integer from 0 to 2**53 - 1 (what a JavaScript number holds exactly), else None."""
|
|
122
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 2 ** 53 - 1 else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _text_only(messages: Any) -> bool:
|
|
126
|
+
"""Whether every message's content is text: a string, null, or parts that are all `{"type": "text"}`. An image,
|
|
127
|
+
audio or file part is billed by the provider in ways a byte count cannot bound, so the client does not send it."""
|
|
128
|
+
for message in messages:
|
|
129
|
+
if not isinstance(message, Mapping):
|
|
130
|
+
return False
|
|
131
|
+
content = message.get("content")
|
|
132
|
+
if isinstance(content, list):
|
|
133
|
+
if not all(isinstance(part, Mapping) and part.get("type") == "text" for part in content):
|
|
134
|
+
return False
|
|
135
|
+
elif content is not None and not isinstance(content, str):
|
|
136
|
+
return False
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_attribution(project_id: Optional[str], task_run_id: Optional[str]) -> None:
|
|
141
|
+
"""Refuse, before a call is sent, a project or task run its receipt would refuse after the call was billed."""
|
|
142
|
+
try:
|
|
143
|
+
fabric_usage.make_usage_receipt("openrouter", "check", input_tokens=0, output_tokens=0,
|
|
144
|
+
project_id=project_id, task_run_id=task_run_id)
|
|
145
|
+
except fabric_usage.ServiceError as err:
|
|
146
|
+
raise OpenRouterError(str(err), kind="config")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Client:
|
|
150
|
+
def __init__(self, *, ceiling_usd: float, ledger: Any, prices: Mapping[str, Mapping[str, float]],
|
|
151
|
+
key_env: str = "OPENROUTER_API_KEY", window: str = "day", base_url: str = BASE_URL,
|
|
152
|
+
attribution: Optional[Mapping[str, Any]] = None, project_id: Optional[str] = None,
|
|
153
|
+
unmeasured_call_usd: Optional[float] = None, deadline_s: float = 120.0, retries: int = 1,
|
|
154
|
+
environ: Optional[Mapping[str, str]] = None, transport: Optional[fabric_http.Transport] = None,
|
|
155
|
+
sleep: Callable[[float], None] = time.sleep, clock: Callable[[], float] = time.monotonic,
|
|
156
|
+
now: Callable[[], float] = time.time, rand: Callable[[], float] = _random.random):
|
|
157
|
+
if isinstance(ceiling_usd, bool) or not isinstance(ceiling_usd, (int, float)) or not ceiling_usd >= 0 \
|
|
158
|
+
or not math.isfinite(ceiling_usd):
|
|
159
|
+
raise OpenRouterError("ceiling_usd is a finite number of dollars, 0 or more", kind="config")
|
|
160
|
+
if window not in ("day", "month"):
|
|
161
|
+
raise OpenRouterError("window is day or month (UTC)", kind="config")
|
|
162
|
+
if not (hasattr(ledger, "record") and hasattr(ledger, "receipts")):
|
|
163
|
+
raise OpenRouterError("ledger has record(receipt) and receipts(), as fabric_usage.JsonlUsageLedger",
|
|
164
|
+
kind="config")
|
|
165
|
+
if not (isinstance(retries, int) and 0 <= retries <= 5) or isinstance(retries, bool):
|
|
166
|
+
raise OpenRouterError("retries is an integer from 0 to 5", kind="config")
|
|
167
|
+
fabric_http.check_url(base_url)
|
|
168
|
+
_check_attribution(project_id, None)
|
|
169
|
+
self.ceiling_usd = float(ceiling_usd)
|
|
170
|
+
self.ledger = ledger
|
|
171
|
+
self.prices = {str(k): dict(v) for k, v in prices.items()}
|
|
172
|
+
self.key_env = key_env
|
|
173
|
+
self.window = window
|
|
174
|
+
self.base_url = base_url.rstrip("/")
|
|
175
|
+
self.attribution = attribution
|
|
176
|
+
self.project_id = project_id
|
|
177
|
+
self.unmeasured_call_usd = unmeasured_call_usd
|
|
178
|
+
self.deadline_s = deadline_s
|
|
179
|
+
self.retries = retries
|
|
180
|
+
self._environ = os.environ if environ is None else environ
|
|
181
|
+
self._transport = transport
|
|
182
|
+
self._sleep, self._clock, self._now, self._rand = sleep, clock, now, rand
|
|
183
|
+
self._lock = threading.Lock()
|
|
184
|
+
self._in_flight = 0.0
|
|
185
|
+
self._unmeasured_worst: Dict[Any, float] = {} # (at, model) of an unmeasured receipt -> that attempt's worst case
|
|
186
|
+
|
|
187
|
+
# -- money ---------------------------------------------------------------------------------------------------
|
|
188
|
+
def _priced(self, model: str) -> Optional[Dict[str, float]]:
|
|
189
|
+
price = self.prices.get(model)
|
|
190
|
+
if not isinstance(price, Mapping):
|
|
191
|
+
return None
|
|
192
|
+
prompt, completion = _price(price.get("prompt")), _price(price.get("completion"))
|
|
193
|
+
if prompt is None or completion is None:
|
|
194
|
+
return None
|
|
195
|
+
return {"prompt": prompt, "completion": completion, "request": _price(price.get("request")) or 0.0}
|
|
196
|
+
|
|
197
|
+
def worst_case_usd(self, model: str, messages: Any, max_tokens: int,
|
|
198
|
+
params: Optional[Mapping[str, Any]] = None) -> Optional[float]:
|
|
199
|
+
"""What one attempt may cost at most (the messages and params as input, `max_tokens` of output), or None when
|
|
200
|
+
the model has no price."""
|
|
201
|
+
price = self._priced(model)
|
|
202
|
+
if price is None:
|
|
203
|
+
return None
|
|
204
|
+
sent = dict(params or {}, messages=messages)
|
|
205
|
+
return prompt_tokens_bound(sent) * price["prompt"] + max_tokens * price["completion"] + price["request"]
|
|
206
|
+
|
|
207
|
+
def _in_window(self, at: Any, today: str) -> bool:
|
|
208
|
+
ms = fabric_time.parse_rfc3339_ms(at)
|
|
209
|
+
if math.isnan(ms):
|
|
210
|
+
return False
|
|
211
|
+
day = fabric_time.utc_day(ms)
|
|
212
|
+
return day == today if self.window == "day" else day[:7] == today[:7]
|
|
213
|
+
|
|
214
|
+
def spent_usd(self, *, unmeasured_usd: float = 0.0) -> float:
|
|
215
|
+
"""What the ledger records for the current window; a receipt without a cost counts as `unmeasured_usd`."""
|
|
216
|
+
today = fabric_time.utc_day(fabric_time.now_ms(self._now))
|
|
217
|
+
total = 0.0
|
|
218
|
+
for receipt in self.ledger.receipts():
|
|
219
|
+
if not isinstance(receipt, dict) or not self._in_window(receipt.get("at"), today):
|
|
220
|
+
continue
|
|
221
|
+
cost = receipt.get("costUsd")
|
|
222
|
+
measured = isinstance(cost, (int, float)) and _price(cost) is not None
|
|
223
|
+
if measured:
|
|
224
|
+
total += float(cost)
|
|
225
|
+
else: # this process knows what such an attempt could cost; one from before it counts as unmeasured_usd
|
|
226
|
+
total += self._unmeasured_worst.get((receipt.get("at"), receipt.get("model")), unmeasured_usd)
|
|
227
|
+
return total
|
|
228
|
+
|
|
229
|
+
def _reserve(self, model: str, messages: Any, max_tokens: int, params: Mapping[str, Any]) -> float:
|
|
230
|
+
worst = self.worst_case_usd(model, messages, max_tokens, params)
|
|
231
|
+
if worst is None:
|
|
232
|
+
raise OpenRouterError("%s has no price: its spend cannot be bounded, so it is not called" % model,
|
|
233
|
+
kind="unpriced")
|
|
234
|
+
unmeasured = worst if self.unmeasured_call_usd is None else float(self.unmeasured_call_usd)
|
|
235
|
+
with self._lock:
|
|
236
|
+
spent = self.spent_usd(unmeasured_usd=unmeasured) + self._in_flight
|
|
237
|
+
if spent + worst > self.ceiling_usd:
|
|
238
|
+
raise OpenRouterError("the %s ceiling of $%.4f would be passed: spent $%.4f, this attempt up to $%.4f"
|
|
239
|
+
% (self.window, self.ceiling_usd, spent, worst), kind="ceiling")
|
|
240
|
+
self._in_flight += worst
|
|
241
|
+
return worst
|
|
242
|
+
|
|
243
|
+
def _release(self, amount: float) -> None:
|
|
244
|
+
with self._lock:
|
|
245
|
+
self._in_flight = max(0.0, self._in_flight - amount)
|
|
246
|
+
|
|
247
|
+
# -- receipts ------------------------------------------------------------------------------------------------
|
|
248
|
+
def _receipt(self, asked: str, body: Mapping[str, Any], task_run_id: Optional[str], worst: float) -> Dict[str, Any]:
|
|
249
|
+
served = body.get("model") if isinstance(body.get("model"), str) and body.get("model").strip() else asked
|
|
250
|
+
usage = body.get("usage") if isinstance(body.get("usage"), dict) else None
|
|
251
|
+
at = fabric_time.format_rfc3339(fabric_time.now_ms(self._now))
|
|
252
|
+
common = dict(at=at, route="key", project_id=self.project_id, task_run_id=task_run_id)
|
|
253
|
+
prompt = _count(usage.get("prompt_tokens")) if usage else None
|
|
254
|
+
completion = _count(usage.get("completion_tokens")) if usage else None
|
|
255
|
+
if prompt is None or completion is None:
|
|
256
|
+
receipt = fabric_usage.make_usage_receipt("openrouter", served[:128], input_tokens=0, output_tokens=0,
|
|
257
|
+
tokens_measured=False, **common)
|
|
258
|
+
self._remember_unmeasured(receipt, worst)
|
|
259
|
+
return receipt
|
|
260
|
+
details = usage.get("prompt_tokens_details") if isinstance(usage.get("prompt_tokens_details"), dict) else {}
|
|
261
|
+
cached = _count(details.get("cached_tokens")) or 0
|
|
262
|
+
written = _count(details.get("cache_write_tokens")) or 0
|
|
263
|
+
# prompt_tokens counts cache reads and writes; a receipt keeps them apart, as Anthropic's usage block does
|
|
264
|
+
cost = _price(usage.get("cost")) if isinstance(usage.get("cost"), (int, float)) else None
|
|
265
|
+
basis = "provider"
|
|
266
|
+
if cost is None:
|
|
267
|
+
price = self._priced(served) or self._priced(asked)
|
|
268
|
+
if price:
|
|
269
|
+
cost = prompt * price["prompt"] + completion * price["completion"]
|
|
270
|
+
basis = "price-list"
|
|
271
|
+
return fabric_usage.make_usage_receipt(
|
|
272
|
+
"openrouter", served[:128], input_tokens=max(0, prompt - cached - written), output_tokens=completion,
|
|
273
|
+
cache_read_tokens=cached, cache_write_tokens=written, cost_usd=cost,
|
|
274
|
+
cost_basis=basis if cost is not None else None, **common)
|
|
275
|
+
|
|
276
|
+
def _unmeasured(self, asked: str, task_run_id: Optional[str], worst: float) -> Dict[str, Any]:
|
|
277
|
+
"""A receipt for an attempt that may have been billed with no usage to read: no tokens, no cost. While this
|
|
278
|
+
client runs, the ceiling counts it at that attempt's own worst case."""
|
|
279
|
+
receipt = fabric_usage.make_usage_receipt(
|
|
280
|
+
"openrouter", asked[:128], input_tokens=0, output_tokens=0, tokens_measured=False,
|
|
281
|
+
at=fabric_time.format_rfc3339(fabric_time.now_ms(self._now)), route="key", project_id=self.project_id,
|
|
282
|
+
task_run_id=task_run_id)
|
|
283
|
+
self._remember_unmeasured(receipt, worst)
|
|
284
|
+
return receipt
|
|
285
|
+
|
|
286
|
+
def _remember_unmeasured(self, receipt: Mapping[str, Any], worst: float) -> None:
|
|
287
|
+
with self._lock:
|
|
288
|
+
key = (receipt["at"], receipt["model"])
|
|
289
|
+
self._unmeasured_worst[key] = self._unmeasured_worst.get(key, 0.0) + worst
|
|
290
|
+
|
|
291
|
+
# -- the call ------------------------------------------------------------------------------------------------
|
|
292
|
+
def _key(self) -> str:
|
|
293
|
+
key = self._environ.get(self.key_env, "")
|
|
294
|
+
if not key or any(c in key for c in "\r\n\0 "):
|
|
295
|
+
raise OpenRouterError("no usable key in the environment variable %s" % self.key_env, kind="config")
|
|
296
|
+
return key
|
|
297
|
+
|
|
298
|
+
def _headers(self, key: str) -> Dict[str, str]:
|
|
299
|
+
headers = {"Authorization": "Bearer " + key, "Content-Type": "application/json"}
|
|
300
|
+
if self.attribution is not None:
|
|
301
|
+
headers.update(fabric_usage.attribution_headers(
|
|
302
|
+
self.attribution, {"url": self.base_url, "originated": True, "headers": {}})["headers"])
|
|
303
|
+
return headers
|
|
304
|
+
|
|
305
|
+
def chat(self, messages: List[Mapping[str, Any]], *, model: str, fallback: str, max_tokens: int,
|
|
306
|
+
task_run_id: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
307
|
+
"""One chat completion: `{content, message, model, finish_reason, receipt, attempts}`; `OpenRouterError`
|
|
308
|
+
when no attempt gave an answer. `params` go into the request body as they are (temperature, reasoning,
|
|
309
|
+
response_format, tools...)."""
|
|
310
|
+
if not isinstance(messages, list) or not messages:
|
|
311
|
+
raise OpenRouterError("messages is a non-empty list", kind="config")
|
|
312
|
+
if not _text_only(messages):
|
|
313
|
+
raise OpenRouterError("only text content is sent: an image, audio or file part has a cost the ceiling "
|
|
314
|
+
"cannot bound", kind="config")
|
|
315
|
+
if not vendor(model) or not vendor(fallback) or vendor(model) == vendor(fallback):
|
|
316
|
+
raise OpenRouterError("the fallback is a model of another vendor than %s" % model, kind="config")
|
|
317
|
+
if not (isinstance(max_tokens, int) and not isinstance(max_tokens, bool) and 1 <= max_tokens <= 200_000):
|
|
318
|
+
raise OpenRouterError("max_tokens is an integer from 1 to 200000", kind="config")
|
|
319
|
+
_check_attribution(self.project_id, task_run_id) # before anything is sent: a billed call keeps its receipt
|
|
320
|
+
clash = [k for k in params if k in _RESERVED_PARAMS]
|
|
321
|
+
if clash:
|
|
322
|
+
raise OpenRouterError("the client sets %s itself" % ", ".join(sorted(clash)), kind="config")
|
|
323
|
+
unbounded = [k for k in params if k in _UNBOUNDED_PARAMS]
|
|
324
|
+
if unbounded:
|
|
325
|
+
raise OpenRouterError("%s change what a call is billed for; the ceiling cannot bound them"
|
|
326
|
+
% ", ".join(sorted(unbounded)), kind="config")
|
|
327
|
+
try:
|
|
328
|
+
prompt_tokens_bound(dict(params, messages=messages))
|
|
329
|
+
except (ValueError, TypeError):
|
|
330
|
+
raise OpenRouterError("the request holds a value JSON cannot write (NaN, Infinity or a non-JSON type)",
|
|
331
|
+
kind="config")
|
|
332
|
+
headers = self._headers(self._key())
|
|
333
|
+
url = self.base_url + "/chat/completions"
|
|
334
|
+
end = self._clock() + self.deadline_s
|
|
335
|
+
attempts: List[Dict[str, Any]] = []
|
|
336
|
+
for current in (model, fallback):
|
|
337
|
+
body = {"model": current, "messages": messages, "max_tokens": max_tokens, "usage": {"include": True},
|
|
338
|
+
**params}
|
|
339
|
+
for tried in range(self.retries + 1):
|
|
340
|
+
left = end - self._clock()
|
|
341
|
+
if left <= 0:
|
|
342
|
+
raise OpenRouterError("the deadline passed after %d attempts" % len(attempts), kind="failed",
|
|
343
|
+
attempts=attempts)
|
|
344
|
+
try:
|
|
345
|
+
held = self._reserve(current, messages, max_tokens, params)
|
|
346
|
+
except OpenRouterError as refused:
|
|
347
|
+
refused.attempts = list(attempts)
|
|
348
|
+
raise
|
|
349
|
+
wait: Optional[float] = None
|
|
350
|
+
try:
|
|
351
|
+
outcome, status, wait, answer = self._attempt(url, headers, body, left, current, task_run_id, held)
|
|
352
|
+
finally:
|
|
353
|
+
self._release(held)
|
|
354
|
+
attempts.append({"model": current, "status": status, "outcome": outcome})
|
|
355
|
+
if answer is not None:
|
|
356
|
+
answer["attempts"] = attempts
|
|
357
|
+
return answer
|
|
358
|
+
if outcome in _STOP_AT_ONCE.values():
|
|
359
|
+
raise OpenRouterError("OpenRouter refused the key or the request (%s)" % status, kind=outcome,
|
|
360
|
+
attempts=attempts)
|
|
361
|
+
if outcome not in ("retry", "error") or tried == self.retries:
|
|
362
|
+
break # next model
|
|
363
|
+
if wait is None:
|
|
364
|
+
wait = fabric_http.backoff_s(tried, base_s=0.5, max_s=8.0, rand=self._rand())
|
|
365
|
+
if self._clock() + wait >= end:
|
|
366
|
+
break
|
|
367
|
+
self._sleep(wait)
|
|
368
|
+
raise OpenRouterError("no answer from %s or %s" % (model, fallback), kind="failed", attempts=attempts)
|
|
369
|
+
|
|
370
|
+
def _attempt(self, url: str, headers: Dict[str, str], body: Dict[str, Any], left: float, asked: str,
|
|
371
|
+
task_run_id: Optional[str], worst: float):
|
|
372
|
+
"""One request: (outcome, status, wait_s, answer or None). Outcomes: answered, retry, error, length,
|
|
373
|
+
filtered, empty, refused, bad-answer, auth, payment."""
|
|
374
|
+
try:
|
|
375
|
+
response = fabric_http.request("POST", url, headers=headers, body=body, deadline_s=left, retries=0,
|
|
376
|
+
transport=self._transport, clock=self._clock)
|
|
377
|
+
except fabric_http.HttpError as err:
|
|
378
|
+
if err.kind in ("timeout", "connection") or (err.kind == "too-large" and err.status == 200):
|
|
379
|
+
# the provider may have billed a call whose answer never arrived whole (a cut connection looks the
|
|
380
|
+
# same as a refused one from here): counted, never as $0; over-counting is the safe side
|
|
381
|
+
self.ledger.record(self._unmeasured(asked, task_run_id, worst))
|
|
382
|
+
return ("retry" if err.kind in ("connection", "timeout") else "refused"), err.status, None, None
|
|
383
|
+
status = response.status
|
|
384
|
+
if status in _STOP_AT_ONCE:
|
|
385
|
+
return _STOP_AT_ONCE[status], status, None, None
|
|
386
|
+
if status != 200:
|
|
387
|
+
if status in fabric_http.RETRY_STATUSES:
|
|
388
|
+
return "retry", status, fabric_http.retry_after_s(response.headers.get("retry-after")), None
|
|
389
|
+
return "refused", status, None, None
|
|
390
|
+
try:
|
|
391
|
+
parsed = response.json()
|
|
392
|
+
except ValueError:
|
|
393
|
+
parsed = None
|
|
394
|
+
if not isinstance(parsed, dict):
|
|
395
|
+
self.ledger.record(self._unmeasured(asked, task_run_id, worst))
|
|
396
|
+
return "bad-answer", status, None, None
|
|
397
|
+
try:
|
|
398
|
+
receipt = self._receipt(asked, parsed, task_run_id, worst)
|
|
399
|
+
except Exception: # noqa: BLE001 - a billed answer the client cannot read still counts, never as $0
|
|
400
|
+
self.ledger.record(self._unmeasured(asked, task_run_id, worst))
|
|
401
|
+
return "bad-answer", status, None, None
|
|
402
|
+
self.ledger.record(receipt)
|
|
403
|
+
choices = parsed.get("choices")
|
|
404
|
+
choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
|
405
|
+
message = choice.get("message") if isinstance(choice.get("message"), dict) else {}
|
|
406
|
+
finish = choice.get("finish_reason")
|
|
407
|
+
content = message.get("content") if isinstance(message.get("content"), str) else ""
|
|
408
|
+
tools = message.get("tool_calls") if isinstance(message.get("tool_calls"), list) else []
|
|
409
|
+
if finish == "error" or isinstance(parsed.get("error"), dict):
|
|
410
|
+
return "error", status, None, None
|
|
411
|
+
if finish == "length":
|
|
412
|
+
return "length", status, None, None
|
|
413
|
+
if finish == "content_filter":
|
|
414
|
+
return "filtered", status, None, None
|
|
415
|
+
if finish not in ANSWERED or not (content.strip() or tools):
|
|
416
|
+
return "empty", status, None, None
|
|
417
|
+
return "answered", status, None, {"content": content, "message": message, "model": receipt["model"],
|
|
418
|
+
"finish_reason": finish, "receipt": receipt}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def totals(receipts: Iterable[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
422
|
+
"""Calls, priced spend and unpriced calls over receipts (what a dashboard line shows); `spentUsd` is None when
|
|
423
|
+
every call is unpriced."""
|
|
424
|
+
calls = priced = 0
|
|
425
|
+
spent = 0.0
|
|
426
|
+
for r in receipts:
|
|
427
|
+
calls += 1
|
|
428
|
+
if isinstance(r.get("costUsd"), (int, float)) and _price(r.get("costUsd")) is not None:
|
|
429
|
+
priced += 1
|
|
430
|
+
spent += float(r["costUsd"])
|
|
431
|
+
return {"calls": calls, "spentUsd": spent if priced else None, "unpricedCalls": calls - priced}
|