@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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 (86) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +9 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -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}
@@ -65,12 +65,12 @@ def package_digest(pkg: Dict[str, Any]) -> str:
65
65
  """SHA-256 over the UTF-8 RFC 8785 form of the package without ``digest`` and ``signatures`` (DEC-0050).
66
66
  Raises TypeError for a value with no RFC 8785 form."""
67
67
  covered = {k: v for k, v in pkg.items() if k not in ("digest", "signatures")}
68
- return sha256_of(fd.jcs(covered))
68
+ return sha256_of(_jsv.jcs(covered))
69
69
 
70
70
 
71
71
  def package_signing_input(digest: str, role: str) -> bytes:
72
72
  """The bytes a package signature covers (DEC-0051); the role is inside, so no signature stands for another role."""
73
- return fd.jcs({"type": SIGNATURE_STATEMENT, "role": role, "digest": digest}).encode("utf-8")
73
+ return _jsv.jcs({"type": SIGNATURE_STATEMENT, "role": role, "digest": digest}).encode("utf-8")
74
74
 
75
75
 
76
76
  # --- Ed25519 signing (RFC 8032 §5.1.6) ------------------------------------------------------------------
@@ -88,21 +88,20 @@ def ed25519_public_key(seed: bytes) -> bytes:
88
88
  if len(seed) != 32:
89
89
  raise PackageError("An Ed25519 private key is 32 bytes.")
90
90
  a, _ = _secret_scalar(seed)
91
- return fd._encode_point(fd._mul(a, fd._G))
91
+ return _crypto._ed_encode_point(_crypto._ed_mul(a, _crypto._ED_G))
92
92
 
93
93
 
94
94
  def ed25519_sign(seed: bytes, message: bytes) -> bytes:
95
95
  """A deterministic Ed25519 signature; the same seed and message give the bytes Node's crypto.sign gives."""
96
96
  a, prefix = _secret_scalar(seed)
97
- public = fd._encode_point(fd._mul(a, fd._G))
98
- r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % fd._L
99
- big_r = fd._encode_point(fd._mul(r, fd._G))
100
- k = int.from_bytes(hashlib.sha512(big_r + public + message).digest(), "little") % fd._L
101
- return big_r + ((r + k * a) % fd._L).to_bytes(32, "little")
97
+ public = _crypto._ed_encode_point(_crypto._ed_mul(a, _crypto._ED_G))
98
+ r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") % _crypto._ED_L
99
+ big_r = _crypto._ed_encode_point(_crypto._ed_mul(r, _crypto._ED_G))
100
+ k = int.from_bytes(hashlib.sha512(big_r + public + message).digest(), "little") % _crypto._ED_L
101
+ return big_r + ((r + k * a) % _crypto._ED_L).to_bytes(32, "little")
102
102
 
103
103
 
104
- def _b64url(data: bytes) -> str:
105
- return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
104
+ from fabric_crypto import b64url as _b64url # noqa: E402 - the core's (FAA-65)
106
105
 
107
106
 
108
107
  def public_jwk(seed: bytes, kid: str) -> Dict[str, str]:
@@ -132,7 +131,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
132
131
  raise PackageError("The key file must be an Ed25519 private JWK (kty OKP, crv Ed25519, with d).")
133
132
  if jwk.get("alg", "EdDSA") != "EdDSA":
134
133
  raise PackageError("The key names alg %r; a package signature is EdDSA (DEC-0051)." % jwk.get("alg"))
135
- seed = fd._b64decode(jwk["d"]) if _B64URL.match(jwk["d"]) else b""
134
+ seed = _crypto.b64url_decode(jwk["d"]) if _B64URL.match(jwk["d"]) else b""
136
135
  if len(seed) != 32:
137
136
  raise PackageError("The key's d is not a 32-byte Ed25519 private key.")
138
137
  if isinstance(jwk.get("x"), str) and jwk["x"] != _b64url(ed25519_public_key(seed)):
@@ -168,16 +167,7 @@ def _schema() -> Dict[str, Any]:
168
167
  return _SCHEMA_CACHE["s"]
169
168
 
170
169
 
171
- def _json_equal(a: Any, b: Any) -> bool:
172
- if isinstance(a, bool) or isinstance(b, bool):
173
- return isinstance(a, bool) and isinstance(b, bool) and a == b
174
- if isinstance(a, (int, float)) and isinstance(b, (int, float)):
175
- return a == b
176
- if isinstance(a, list) and isinstance(b, list):
177
- return len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
178
- if isinstance(a, dict) and isinstance(b, dict):
179
- return a.keys() == b.keys() and all(_json_equal(a[k], b[k]) for k in a)
180
- return type(a) is type(b) and a == b
170
+ from fabric_jsvalue import json_equal as _json_equal # noqa: E402 - the core's (FAA-65)
181
171
 
182
172
 
183
173
  def _type_of(value: Any) -> str:
@@ -373,7 +363,7 @@ def _whole(value: Dict[str, Any]) -> List[Dict[str, str]]:
373
363
  return out
374
364
 
375
365
 
376
- _UNDEFINED = object()
366
+ from fabric_jsvalue import MISSING as _UNDEFINED # noqa: E402 - the core's absent value (FAA-65)
377
367
 
378
368
 
379
369
  def _same_member(obj: Dict[str, Any], name: str, other: Any) -> bool:
@@ -386,21 +376,9 @@ def _same_member(obj: Dict[str, Any], name: str, other: Any) -> bool:
386
376
  return _json_equal(mine, other)
387
377
 
388
378
 
389
- def _js_string(value: Any) -> str:
390
- """JavaScript's ``String(value)`` for a JSON value, as the contract's messages print it."""
391
- if value is _UNDEFINED:
392
- return "undefined"
393
- if value is None:
394
- return "null"
395
- if isinstance(value, bool):
396
- return "true" if value else "false"
397
- if isinstance(value, (int, float)):
398
- return fd.jcs(value)
399
- if isinstance(value, list):
400
- return ",".join("" if item is None else _js_string(item) for item in value)
401
- if isinstance(value, dict):
402
- return "[object Object]"
403
- return value
379
+ from fabric_jsvalue import js_string as _js_string # noqa: E402 - the core's (FAA-65)
380
+ import fabric_jsvalue as _jsv # noqa: E402 - the core (FAA-65)
381
+ import fabric_crypto as _crypto # noqa: E402 - the core (FAA-65)
404
382
 
405
383
 
406
384
  def _no_secret(value: Dict[str, Any]) -> List[Dict[str, str]]:
@@ -540,10 +518,10 @@ def _key_for(keys: Any, kid: str, alg: str) -> Dict[str, Any]:
540
518
  if (jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or not isinstance(x, str) or not _B64URL.match(x)
541
519
  or "d" in jwk or ("use" in jwk and jwk["use"] != "sig")):
542
520
  return {"problem": "unusable-key"}
543
- raw = fd._b64decode(x)
521
+ raw = _crypto.b64url_decode(x)
544
522
  if len(raw) != 32:
545
523
  return {"problem": "unusable-key"}
546
- if fd.is_small_order_key(raw): # DEC-0045: refused before any verification
524
+ if _crypto.is_small_order_key(raw): # DEC-0045: refused before any verification
547
525
  return {"problem": "small-order-key"}
548
526
  return {"key": raw}
549
527
 
@@ -580,8 +558,8 @@ def verify_package(pkg: Any, trust: Dict[str, Any], countersigned: bool = False)
580
558
  continue
581
559
  if "problem" in found:
582
560
  return {"ok": False, "problem": found["problem"], "signature": index}
583
- signature = fd._b64decode(entry["sig"])
584
- if len(signature) != 64 or not fd.ed25519_verify(found["key"], package_signing_input(digest, entry["role"]), signature):
561
+ signature = _crypto.b64url_decode(entry["sig"])
562
+ if len(signature) != 64 or not _crypto.ed25519_verify(found["key"], package_signing_input(digest, entry["role"]), signature):
585
563
  return {"ok": False, "problem": "bad-signature", "signature": index}
586
564
  signers.append({"role": entry["role"], "kid": entry["kid"]})
587
565
  if countersigned is True and not any(s["role"] == "organization" for s in signers):
@@ -670,12 +648,12 @@ def dumps(value: Any, indent: str = "") -> str:
670
648
  if isinstance(value, dict):
671
649
  if not value:
672
650
  return "{}"
673
- return "{\n" + ",\n".join(inner + fd._string(k) + ": " + dumps(v, inner) for k, v in value.items()) + "\n" + indent + "}"
651
+ return "{\n" + ",\n".join(inner + _jsv.json_string_literal(k) + ": " + dumps(v, inner) for k, v in value.items()) + "\n" + indent + "}"
674
652
  if isinstance(value, list):
675
653
  if not value:
676
654
  return "[]"
677
655
  return "[\n" + ",\n".join(inner + dumps(v, inner) for v in value) + "\n" + indent + "]"
678
- return fd.jcs(value)
656
+ return _jsv.jcs(value)
679
657
 
680
658
 
681
659
  def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, Any], List[str]]: