@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.
- package/CHANGELOG.md +79 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -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 +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- 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_service.py +10 -183
- 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/fabric-auth.mjs +286 -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-session.mjs +840 -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 +1 -16
- 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 +13 -45
- 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 +8 -67
- 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 +2 -2
- 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 +202 -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 +2 -12
- 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 +400 -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 +402 -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 +78 -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_session.py +902 -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 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- 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 +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -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_interop.py +1 -6
- 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 +21 -43
- 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 +31 -38
- 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_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 +412 -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 +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
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}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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 =
|
|
98
|
-
r = int.from_bytes(hashlib.sha512(prefix + message).digest(), "little") %
|
|
99
|
-
big_r =
|
|
100
|
-
k = int.from_bytes(hashlib.sha512(big_r + public + message).digest(), "little") %
|
|
101
|
-
return big_r + ((r + k * a) %
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 =
|
|
521
|
+
raw = _crypto.b64url_decode(x)
|
|
544
522
|
if len(raw) != 32:
|
|
545
523
|
return {"problem": "unusable-key"}
|
|
546
|
-
if
|
|
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 =
|
|
584
|
-
if len(signature) != 64 or not
|
|
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 +
|
|
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
|
|
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]]:
|