@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. 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))
@@ -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}