frutebox-node 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- frutebox/adminheader.py +59 -0
- frutebox/attest.py +43 -0
- frutebox/audiogen.py +364 -0
- frutebox/config.py +367 -0
- frutebox/deltasync.py +211 -0
- frutebox/economics.py +270 -0
- frutebox/engine_guard.py +718 -0
- frutebox/envelope.py +77 -0
- frutebox/envvars.py +167 -0
- frutebox/gpuprices.py +641 -0
- frutebox/gpuproviders.py +4489 -0
- frutebox/gui.py +270 -0
- frutebox/imagegen.py +152 -0
- frutebox/inference.py +624 -0
- frutebox/manifest.py +218 -0
- frutebox/mediaserving.py +603 -0
- frutebox/packaging.py +542 -0
- frutebox/pqctransport.py +660 -0
- frutebox/provider.py +1867 -0
- frutebox/providers.py +942 -0
- frutebox/registry/__init__.py +189 -0
- frutebox/registry/generate.py +311 -0
- frutebox/registry/generate_providers.py +120 -0
- frutebox/registry/models.yaml +1759 -0
- frutebox/registry/providers.yaml +518 -0
- frutebox/registry/registry.gen.json +2043 -0
- frutebox/registry_gen.py +3 -0
- frutebox/runpod_templates.py +143 -0
- frutebox/serving.py +691 -0
- frutebox/signedconfig.py +259 -0
- frutebox/signing.py +208 -0
- frutebox/thinkbudget.py +155 -0
- frutebox/videogen.py +118 -0
- frutebox_node-0.1.0.dist-info/METADATA +100 -0
- frutebox_node-0.1.0.dist-info/RECORD +36 -0
- frutebox_node-0.1.0.dist-info/WHEEL +4 -0
frutebox/signedconfig.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Control-plane-signed configuration, and what a gateway does when it goes stale (file 02-A5).
|
|
2
|
+
|
|
3
|
+
WHY SIGNED
|
|
4
|
+
Policy, the model registry and feature flags decide which models may run, which nodes are
|
|
5
|
+
eligible, and what a user is allowed to do. A gateway that accepts those over plain HTTP trusts
|
|
6
|
+
whoever can answer as the directory — DNS, a proxy, a compromised sibling. So the control plane
|
|
7
|
+
signs the blob and a gateway REFUSES anything unsigned or badly signed. There is no "warn and
|
|
8
|
+
apply": applying unverified policy is the failure, not the warning.
|
|
9
|
+
|
|
10
|
+
The signature is the repo's existing hybrid Ed25519 + ML-DSA-65 (`signing.py`), so the config
|
|
11
|
+
path is post-quantum on the same terms as everything else (global rule 9).
|
|
12
|
+
|
|
13
|
+
WHY DEGRADED AUTONOMY
|
|
14
|
+
The alternative to serving on a slightly stale policy is refusing to serve at all the moment the
|
|
15
|
+
directory is unreachable — turning a control-plane blip into a full outage while the nodes sit
|
|
16
|
+
idle and perfectly capable. So a gateway keeps serving on the last VERIFIED blob:
|
|
17
|
+
|
|
18
|
+
fresh (< FRUTEBOX_CONFIG_TTL_S, default 900 s) normal
|
|
19
|
+
autonomous (< FRUTEBOX_AUTONOMY_MAX_S, default 6 h) serving, refresh failing, noisy
|
|
20
|
+
fail-closed (beyond that) refuse NEW network jobs, 503
|
|
21
|
+
|
|
22
|
+
Fail-closed is the honest end state: after six hours of not being able to check, we no longer
|
|
23
|
+
know whether a node was revoked or a model was withdrawn, and continuing to dispatch on that is
|
|
24
|
+
worse than an honest error.
|
|
25
|
+
|
|
26
|
+
LOCAL INFERENCE IS NEVER AFFECTED. It does not pass through here, and cutting a user's own
|
|
27
|
+
on-device model because OUR control plane is unreachable would punish them for our problem.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import time
|
|
34
|
+
from typing import Any, Dict, Optional
|
|
35
|
+
|
|
36
|
+
from . import signing
|
|
37
|
+
from .envvars import env as envvar
|
|
38
|
+
|
|
39
|
+
#: How long a signed blob is considered current.
|
|
40
|
+
CONFIG_TTL_S = float(envvar("CONFIG_TTL_S", "900"))
|
|
41
|
+
|
|
42
|
+
#: How long we will keep serving on a blob we can no longer refresh.
|
|
43
|
+
AUTONOMY_MAX_S = float(envvar("AUTONOMY_MAX_S", "21600"))
|
|
44
|
+
|
|
45
|
+
#: The signing key lives in the PORTABLE secret store (README rule 10) — an env var delivered by
|
|
46
|
+
#: whatever the deployment uses, never Google Secret Manager or Azure Key Vault, so the platform
|
|
47
|
+
#: can move clouds. Referenced by NAME here; the value never appears in code or logs.
|
|
48
|
+
SIGNING_SEED_ENV = "FRUTEBOX_CONFIG_SIGNING_KEY"
|
|
49
|
+
|
|
50
|
+
#: Gateways verify with a public key baked into the image at build time, so a gateway cannot be
|
|
51
|
+
#: talked into trusting a new signer by anything it receives at runtime.
|
|
52
|
+
PUBLIC_KEY_ENV = "FRUTEBOX_CONFIG_PUBLIC_KEY"
|
|
53
|
+
|
|
54
|
+
#: Read through `envvar`, which tries FRUTEBOX_ then STEVAI_.
|
|
55
|
+
#:
|
|
56
|
+
#: ⚠️ THESE TWO WERE RAW `os.environ.get(PUBLIC_KEY_ENV)` AGAINST A HARD-CODED `STEVAI_` NAME, AND
|
|
57
|
+
#: THAT MADE THE RENAME A SILENT DISARM. `enforcing()` below returns True *exactly when* the public
|
|
58
|
+
#: key is set, and that is what arms signed-config enforcement. So renaming the variable on the
|
|
59
|
+
#: service to `FRUTEBOX_CONFIG_PUBLIC_KEY` — which F-86 instructs every lane to do today — would have
|
|
60
|
+
#: made `enforcing()` return False, and the gateway would have stopped requiring signed configuration
|
|
61
|
+
#: WITH NO ERROR ANYWHERE. A security control switched off by a correct-looking rename.
|
|
62
|
+
#:
|
|
63
|
+
#: The seed half fails the other way, safely but just as quietly: signing would refuse with
|
|
64
|
+
#: "not set" while the value sat in the environment under the other spelling.
|
|
65
|
+
def config_public_key() -> str:
|
|
66
|
+
"""The baked-in verification key, under either spelling. "" when unset."""
|
|
67
|
+
return (envvar("CONFIG_PUBLIC_KEY") or "").strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def config_signing_seed() -> str:
|
|
71
|
+
"""The signing seed, under either spelling. "" when unset. The VALUE never gets logged."""
|
|
72
|
+
return (envvar("CONFIG_SIGNING_KEY") or "").strip()
|
|
73
|
+
|
|
74
|
+
#: The last blob we verified. Mutated, never rebound.
|
|
75
|
+
CACHE: Dict[str, Any] = {"config": None, "verified_at": 0.0, "issued_at": 0.0, "source": ""}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ConfigRejected(Exception):
|
|
79
|
+
"""The blob did not verify. The caller must NOT apply it."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class NoVerifyKey(ConfigRejected):
|
|
83
|
+
"""We have no key to verify WITH — a different finding from "verified and failed".
|
|
84
|
+
|
|
85
|
+
A distinct type because the caller's decision hinges on it, and hinging it on a substring of the
|
|
86
|
+
message (`if "not set" in str(e)`) means one reworded sentence silently turns a refusal into a warning.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def sign_blob(kind: str, payload: dict, *, ttl_s: float) -> str:
|
|
91
|
+
"""Sign any small control-plane payload with the SAME hybrid key as the config. "" if we cannot.
|
|
92
|
+
|
|
93
|
+
⚠️ THIS MODULE OWNS THE SEED, and that is why the function lives here. File 22 needed a signed
|
|
94
|
+
node-package manifest, and the first version read `SIGNING_SEED_ENV` from `directory_public` — which
|
|
95
|
+
another lane's guard caught immediately (`test_provider_credential_env_bypass_guard`: an environment
|
|
96
|
+
variable read through a COMPUTED name is the obvious way to reintroduce a credential bypass, so it
|
|
97
|
+
must be reviewed). The guard was right about more than it could see: a second module reaching for
|
|
98
|
+
another module's secret env var is also how two spellings of one key name start to drift.
|
|
99
|
+
|
|
100
|
+
`kind` is carried INSIDE the signed claims, so a ticket minted for one purpose cannot be replayed as
|
|
101
|
+
another — a config blob must not pass as a package manifest, and `verify_blob` checks it.
|
|
102
|
+
|
|
103
|
+
Returns "" when no seed is configured, rather than raising: an unsigned manifest is a state the
|
|
104
|
+
caller must be able to report honestly. `sign_config` keeps raising, because a config that cannot be
|
|
105
|
+
signed must not be published at all.
|
|
106
|
+
"""
|
|
107
|
+
# NO `seed_hex` OVERRIDE, and it had one until another lane's guard caught it: nothing in production
|
|
108
|
+
# supplied it, so the branch behind it was proven by tests and unreachable in the product
|
|
109
|
+
# (`test_no_unsupplied_keyword_args`). `sign_config` keeps its override because the control plane
|
|
110
|
+
# genuinely passes one; copying the parameter here was habit, not a requirement.
|
|
111
|
+
seed = config_signing_seed()
|
|
112
|
+
if not seed:
|
|
113
|
+
return ""
|
|
114
|
+
try:
|
|
115
|
+
priv, _pub = signing.keypair_from_seed(seed)
|
|
116
|
+
return signing.mint_ticket(priv, {"kind": kind, **payload}, ttl_s=ttl_s)
|
|
117
|
+
except Exception:
|
|
118
|
+
# Signing must never take an endpoint down. The caller reports it as unsigned, which is true.
|
|
119
|
+
return ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def verify_blob(kind: str, blob: str, *, public_key: str = "") -> dict:
|
|
123
|
+
"""Verify a `sign_blob` payload and return its claims. Raises `ConfigRejected`.
|
|
124
|
+
|
|
125
|
+
No "unsigned is fine" branch, deliberately: a caller that wants to tolerate an unsigned deployment
|
|
126
|
+
decides that in the open, where it can be read, rather than inheriting it from a library default.
|
|
127
|
+
"""
|
|
128
|
+
pub_b64 = public_key or config_public_key()
|
|
129
|
+
if not pub_b64:
|
|
130
|
+
raise NoVerifyKey(f"{PUBLIC_KEY_ENV} is not set — refusing an unverifiable {kind} payload")
|
|
131
|
+
if not blob:
|
|
132
|
+
raise ConfigRejected(f"the {kind} payload carries no signature")
|
|
133
|
+
try:
|
|
134
|
+
claims = signing.verify_ticket(signing.load_public_key(pub_b64), blob)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
raise ConfigRejected(f"signature did not verify: {type(e).__name__}") from e
|
|
137
|
+
if claims.get("kind") != kind:
|
|
138
|
+
raise ConfigRejected(f"wrong ticket kind {claims.get('kind')!r}")
|
|
139
|
+
return claims
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def sign_config(config: dict, *, seed_hex: str = "") -> str:
|
|
143
|
+
"""Sign a config blob. Runs on the control plane, never on a gateway."""
|
|
144
|
+
seed = seed_hex or config_signing_seed()
|
|
145
|
+
if not seed:
|
|
146
|
+
raise ConfigRejected(f"{SIGNING_SEED_ENV} is not set — cannot sign configuration")
|
|
147
|
+
priv, _pub = signing.keypair_from_seed(seed)
|
|
148
|
+
claims = {"kind": "gateway-config", "issued_at": time.time(), "config": config}
|
|
149
|
+
# The ticket stays verifiable through the whole autonomy window; STALENESS is our policy to
|
|
150
|
+
# judge (below), not the token's. Folding both into one expiry would mean a blob that is
|
|
151
|
+
# merely stale becomes indistinguishable from one that is forged.
|
|
152
|
+
return signing.mint_ticket(priv, claims, ttl_s=AUTONOMY_MAX_S * 2)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def verify_config(blob: str, *, public_key: str = "") -> dict:
|
|
156
|
+
"""Verify and return the config. Raises `ConfigRejected` on anything suspicious."""
|
|
157
|
+
pub_b64 = public_key or config_public_key()
|
|
158
|
+
if not pub_b64:
|
|
159
|
+
# No baked-in key means we cannot tell the control plane from an impostor. Refusing is
|
|
160
|
+
# the only safe answer; applying "because it is probably fine" is the whole attack.
|
|
161
|
+
raise ConfigRejected(f"{PUBLIC_KEY_ENV} is not set — refusing unverifiable configuration")
|
|
162
|
+
if not blob:
|
|
163
|
+
raise ConfigRejected("empty configuration blob")
|
|
164
|
+
try:
|
|
165
|
+
pub = signing.load_public_key(pub_b64)
|
|
166
|
+
claims = signing.verify_ticket(pub, blob)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
raise ConfigRejected(f"signature did not verify: {type(e).__name__}") from e
|
|
169
|
+
|
|
170
|
+
if claims.get("kind") != "gateway-config":
|
|
171
|
+
raise ConfigRejected(f"wrong ticket kind {claims.get('kind')!r}")
|
|
172
|
+
config = claims.get("config")
|
|
173
|
+
if not isinstance(config, dict):
|
|
174
|
+
raise ConfigRejected("configuration payload is not an object")
|
|
175
|
+
return {"config": config, "issued_at": float(claims.get("issued_at") or 0.0)}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def apply_blob(blob: str, *, public_key: str = "", source: str = "") -> dict:
|
|
179
|
+
"""Verify a blob and make it the live config. Returns the config it applied."""
|
|
180
|
+
result = verify_config(blob, public_key=public_key)
|
|
181
|
+
CACHE.update({"config": result["config"], "verified_at": time.time(),
|
|
182
|
+
"issued_at": result["issued_at"], "source": source})
|
|
183
|
+
return result["config"]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def age_s(now: Optional[float] = None) -> float:
|
|
187
|
+
"""Seconds since the live config was issued. `inf` when we have never had one."""
|
|
188
|
+
issued = float(CACHE.get("issued_at") or 0.0)
|
|
189
|
+
if not issued:
|
|
190
|
+
return float("inf")
|
|
191
|
+
return max(0.0, (now if now is not None else time.time()) - issued)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def status(now: Optional[float] = None) -> str:
|
|
195
|
+
"""'fresh' | 'autonomous' | 'fail-closed'."""
|
|
196
|
+
if CACHE.get("config") is None:
|
|
197
|
+
return "fail-closed"
|
|
198
|
+
a = age_s(now)
|
|
199
|
+
if a <= CONFIG_TTL_S:
|
|
200
|
+
return "fresh"
|
|
201
|
+
if a <= AUTONOMY_MAX_S:
|
|
202
|
+
return "autonomous"
|
|
203
|
+
return "fail-closed"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def enforcing() -> bool:
|
|
207
|
+
"""Whether signed-config enforcement is live on this gateway.
|
|
208
|
+
|
|
209
|
+
It activates exactly when the operator bakes a public key into the image. Before that there
|
|
210
|
+
is no config distribution to refresh FROM, so enforcing would mean every gateway refuses
|
|
211
|
+
every network job the moment this code ships — turning a not-yet-deployed feature into a
|
|
212
|
+
total outage. Provisioning the key is the deliberate act that arms it.
|
|
213
|
+
"""
|
|
214
|
+
return bool(config_public_key())
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def accepting_network_jobs(now: Optional[float] = None) -> bool:
|
|
218
|
+
"""Whether this gateway may still dispatch NEW network work.
|
|
219
|
+
|
|
220
|
+
False does not mean the product is down: local/on-device inference never passes through
|
|
221
|
+
here and is deliberately untouched.
|
|
222
|
+
"""
|
|
223
|
+
if not enforcing():
|
|
224
|
+
return True
|
|
225
|
+
return status(now) != "fail-closed"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def refusal_reason(now: Optional[float] = None) -> str:
|
|
229
|
+
"""A message a user can act on, rather than a bare 503."""
|
|
230
|
+
if CACHE.get("config") is None:
|
|
231
|
+
return ("this gateway has never received a signed configuration and will not dispatch "
|
|
232
|
+
"network jobs without one")
|
|
233
|
+
hours = age_s(now) / 3600.0
|
|
234
|
+
return (f"this gateway has not been able to refresh its signed configuration for "
|
|
235
|
+
f"{hours:.1f}h (limit {AUTONOMY_MAX_S / 3600:.0f}h); it is refusing new network "
|
|
236
|
+
f"jobs rather than dispatching on policy it can no longer verify")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def current(now: Optional[float] = None) -> Optional[dict]:
|
|
240
|
+
"""The live config while it may still be used, else None."""
|
|
241
|
+
return CACHE.get("config") if accepting_network_jobs(now) else None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def snapshot(now: Optional[float] = None) -> dict:
|
|
245
|
+
"""For /health, so the degradation is visible before it becomes an outage."""
|
|
246
|
+
a = age_s(now)
|
|
247
|
+
return {
|
|
248
|
+
"enforcing": enforcing(),
|
|
249
|
+
"status": status(now),
|
|
250
|
+
"age_s": None if a == float("inf") else round(a, 1),
|
|
251
|
+
"ttl_s": CONFIG_TTL_S,
|
|
252
|
+
"autonomy_max_s": AUTONOMY_MAX_S,
|
|
253
|
+
"accepting_network_jobs": accepting_network_jobs(now),
|
|
254
|
+
"source": CACHE.get("source") or "",
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def reset() -> None:
|
|
259
|
+
CACHE.update({"config": None, "verified_at": 0.0, "issued_at": 0.0, "source": ""})
|
frutebox/signing.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Signed authorization tickets — HYBRID classical + post-quantum (Ed25519 +
|
|
2
|
+
ML-DSA-65 / FIPS 204).
|
|
3
|
+
|
|
4
|
+
Only the directory (the control plane) holds the operator's private keys (derived
|
|
5
|
+
deterministically from a 32-byte seed), so only it can *mint* a ticket that says
|
|
6
|
+
"this request, for this model, in this region, is authorized." Gateways are given
|
|
7
|
+
only the **public keys**; they can *verify* a ticket but can never forge one —
|
|
8
|
+
even a fully compromised gateway cannot self-authorize traffic.
|
|
9
|
+
|
|
10
|
+
HYBRID-BY-DEFAULT (§6): every ticket carries BOTH an Ed25519 signature and an
|
|
11
|
+
ML-DSA-65 signature over the same payload, and verification requires BOTH to pass.
|
|
12
|
+
We don't drop the classical algorithm — we pair it — so a ticket is safe if EITHER
|
|
13
|
+
the classical curve OR the lattice scheme holds: Shor's algorithm breaks Ed25519 on
|
|
14
|
+
a quantum computer, while an undiscovered flaw in a young PQC scheme would break
|
|
15
|
+
ML-DSA on a classical one. This mirrors the Node side (`web/src/lib/crypto/pqc.ts`
|
|
16
|
+
`hybridSign`/`hybridVerify` = Ed25519 + ML-DSA-65). Ed25519 comes from `cryptography`
|
|
17
|
+
(already a dependency); ML-DSA from pure-Python `dilithium_py` — no new native build.
|
|
18
|
+
|
|
19
|
+
The signing seed is a 32-byte secret the operator generates and sets as
|
|
20
|
+
STEVAI_SIGNING_SEED; it must never pass through anyone but the operator, and it
|
|
21
|
+
deterministically derives BOTH keys so tickets stay valid across restarts/clouds.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Tuple
|
|
31
|
+
|
|
32
|
+
from cryptography.exceptions import InvalidSignature
|
|
33
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
34
|
+
Ed25519PrivateKey,
|
|
35
|
+
Ed25519PublicKey,
|
|
36
|
+
)
|
|
37
|
+
from dilithium_py.ml_dsa import ML_DSA_65
|
|
38
|
+
|
|
39
|
+
_ALG = "ed25519+ml-dsa-65"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _b64u(data: bytes) -> str:
|
|
43
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _b64u_dec(s: str) -> bytes:
|
|
47
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TicketError(Exception):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TicketExpired(TicketError):
|
|
55
|
+
"""The signatures verified, but `exp` has passed.
|
|
56
|
+
|
|
57
|
+
Kept DISTINCT from every other TicketError because the two mean opposite things
|
|
58
|
+
about the holder. A signature failure is evidence: somebody produced bytes the
|
|
59
|
+
operator never signed. An expiry is not — both signatures checked out, so the
|
|
60
|
+
holder is provably the legitimate subject of a grant that simply ran out (or the
|
|
61
|
+
signing seed rotated, or a clock drifted). Callers that punish tampering must not
|
|
62
|
+
punish this: treating a lapsed credential as forgery is how an honest node gets
|
|
63
|
+
slashed off the network for doing nothing wrong. Subclasses TicketError so every
|
|
64
|
+
existing `except TicketError` handler keeps working unchanged.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class HybridPrivate:
|
|
70
|
+
ed_sk: Ed25519PrivateKey
|
|
71
|
+
dsa_sk: bytes
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class HybridPublic:
|
|
76
|
+
ed_pk: Ed25519PublicKey
|
|
77
|
+
dsa_pk: bytes
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def alg() -> str:
|
|
81
|
+
return _ALG
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _pub_b64(ed_pk: Ed25519PublicKey, dsa_pk: bytes) -> str:
|
|
85
|
+
"""Public form gateways receive: '<ed25519_pub>.<ml_dsa_pub>' (both b64url)."""
|
|
86
|
+
return f"{_b64u(ed_pk.public_bytes_raw())}.{_b64u(dsa_pk)}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def generate_keypair() -> Tuple[HybridPrivate, str]:
|
|
90
|
+
"""Return (private, public_b64). Public form is what gateways get."""
|
|
91
|
+
ed_sk = Ed25519PrivateKey.generate()
|
|
92
|
+
dsa_pk, dsa_sk = ML_DSA_65.keygen()
|
|
93
|
+
return HybridPrivate(ed_sk, dsa_sk), _pub_b64(ed_sk.public_key(), dsa_pk)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def keypair_from_seed(seed_hex: str) -> Tuple[HybridPrivate, str]:
|
|
97
|
+
"""Deterministically derive BOTH operator keys from a 32-byte hex seed, so the
|
|
98
|
+
signing keys are STABLE across restarts and portable across clouds (same seed →
|
|
99
|
+
same keys → tickets stay valid after a redeploy). The Ed25519 and ML-DSA keys
|
|
100
|
+
are derived from independent sub-seeds so neither key leaks the other."""
|
|
101
|
+
raw = bytes.fromhex(seed_hex.strip())
|
|
102
|
+
if len(raw) != 32:
|
|
103
|
+
raise ValueError("signing seed must be 32 bytes (64 hex chars)")
|
|
104
|
+
ed_seed = hashlib.sha256(b"stevai-ed25519\x00" + raw).digest()
|
|
105
|
+
dsa_seed = hashlib.sha256(b"stevai-ml-dsa\x00" + raw).digest()
|
|
106
|
+
ed_sk = Ed25519PrivateKey.from_private_bytes(ed_seed)
|
|
107
|
+
dsa_pk, dsa_sk = ML_DSA_65._keygen_internal(dsa_seed)
|
|
108
|
+
return HybridPrivate(ed_sk, dsa_sk), _pub_b64(ed_sk.public_key(), dsa_pk)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_public_key(pub_b64: str) -> HybridPublic:
|
|
112
|
+
"""Gateways load the operator's public keys to verify tickets."""
|
|
113
|
+
try:
|
|
114
|
+
ed_b64, dsa_b64 = pub_b64.split(".", 1)
|
|
115
|
+
except ValueError:
|
|
116
|
+
raise TicketError("malformed public key")
|
|
117
|
+
return HybridPublic(
|
|
118
|
+
Ed25519PublicKey.from_public_bytes(_b64u_dec(ed_b64)),
|
|
119
|
+
_b64u_dec(dsa_b64),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def sign_blob(priv: HybridPrivate, msg: bytes) -> str:
|
|
124
|
+
"""Sign ARBITRARY BYTES with the same hybrid pair a ticket uses. `<ed_sig>~<dsa_sig>`, b64url.
|
|
125
|
+
|
|
126
|
+
FILE 22-3 asks for ML-DSA signatures "asserted on file-02 signed config AND file-17
|
|
127
|
+
appliance/update signing". The ticket functions below could not serve the second: a ticket
|
|
128
|
+
embeds `iat`/`exp` and expires in 30 seconds, which is right for a dispatch authorisation and
|
|
129
|
+
wrong for a release — a signed release must stay verifiable for as long as it is installable,
|
|
130
|
+
and its freshness is governed by the update metadata's own `expires`.
|
|
131
|
+
|
|
132
|
+
So this is the ticket's signing core without the lifecycle: both signatures over the caller's
|
|
133
|
+
own bytes, and `verify_blob` requires BOTH. One scheme for both halves of 22-3, rather than a
|
|
134
|
+
second scheme for the second half — which is how the two would come to disagree about what
|
|
135
|
+
"signed" means.
|
|
136
|
+
"""
|
|
137
|
+
ed_sig = priv.ed_sk.sign(msg)
|
|
138
|
+
dsa_sig = ML_DSA_65.sign(priv.dsa_sk, msg)
|
|
139
|
+
return f"{_b64u(ed_sig)}~{_b64u(dsa_sig)}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def verify_blob(pub: HybridPublic, msg: bytes, sig: str) -> bool:
|
|
143
|
+
"""Verify BOTH signatures over `msg`. One valid signature is not enough.
|
|
144
|
+
|
|
145
|
+
Returns a bool rather than raising, because its caller is a policy decision that collects
|
|
146
|
+
several reasons (`applianceupdate.verify_update`) rather than a request that must fail fast.
|
|
147
|
+
A malformed signature is `False`, never an exception reaching the caller — an update verifier
|
|
148
|
+
that threw on a hostile input would be a denial of service on the update path.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
ed_sig_b64, dsa_sig_b64 = str(sig or "").split("~", 1)
|
|
152
|
+
except ValueError:
|
|
153
|
+
return False
|
|
154
|
+
try:
|
|
155
|
+
pub.ed_pk.verify(_b64u_dec(ed_sig_b64), msg)
|
|
156
|
+
except Exception:
|
|
157
|
+
return False
|
|
158
|
+
try:
|
|
159
|
+
return bool(ML_DSA_65.verify(pub.dsa_pk, msg, _b64u_dec(dsa_sig_b64)))
|
|
160
|
+
except Exception:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def mint_ticket(priv: HybridPrivate, claims: dict, ttl_s: float = 30.0) -> str:
|
|
165
|
+
"""Create a signed, short-lived ticket. Format:
|
|
166
|
+
<payload_b64>.<ed_sig_b64>~<dsa_sig_b64> — BOTH signatures over the payload."""
|
|
167
|
+
body = dict(claims)
|
|
168
|
+
now = time.time()
|
|
169
|
+
body["iat"] = round(now, 3)
|
|
170
|
+
body["exp"] = round(now + ttl_s, 3)
|
|
171
|
+
payload = _b64u(json.dumps(body, separators=(",", ":")).encode())
|
|
172
|
+
msg = payload.encode()
|
|
173
|
+
ed_sig = priv.ed_sk.sign(msg)
|
|
174
|
+
dsa_sig = ML_DSA_65.sign(priv.dsa_sk, msg)
|
|
175
|
+
return f"{payload}.{_b64u(ed_sig)}~{_b64u(dsa_sig)}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def verify_ticket(pub: HybridPublic, token: str) -> dict:
|
|
179
|
+
"""Verify BOTH signatures + expiry; return the claims, or raise TicketError.
|
|
180
|
+
A ticket is rejected unless the Ed25519 AND the ML-DSA signature both check
|
|
181
|
+
out — one valid signature is not enough (defence-in-depth)."""
|
|
182
|
+
try:
|
|
183
|
+
payload, sigblob = token.split(".", 1)
|
|
184
|
+
ed_sig_b64, dsa_sig_b64 = sigblob.split("~", 1)
|
|
185
|
+
except ValueError:
|
|
186
|
+
raise TicketError("malformed ticket")
|
|
187
|
+
msg = payload.encode()
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
pub.ed_pk.verify(_b64u_dec(ed_sig_b64), msg)
|
|
191
|
+
except InvalidSignature:
|
|
192
|
+
raise TicketError("classical (ed25519) signature check failed")
|
|
193
|
+
except Exception:
|
|
194
|
+
raise TicketError("classical (ed25519) signature malformed")
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
dsa_ok = ML_DSA_65.verify(pub.dsa_pk, msg, _b64u_dec(dsa_sig_b64))
|
|
198
|
+
except Exception:
|
|
199
|
+
raise TicketError("post-quantum (ml-dsa) signature check failed")
|
|
200
|
+
if not dsa_ok:
|
|
201
|
+
raise TicketError("bad post-quantum signature")
|
|
202
|
+
|
|
203
|
+
claims = json.loads(_b64u_dec(payload))
|
|
204
|
+
if claims.get("exp", 0) < time.time():
|
|
205
|
+
# Signatures already checked out above, so the bearer IS the subject named in
|
|
206
|
+
# the payload — this is a lifecycle event, not an attack (see TicketExpired).
|
|
207
|
+
raise TicketExpired("ticket expired")
|
|
208
|
+
return claims
|
frutebox/thinkbudget.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""How much reasoning this turn is worth — effort tiers, in ONE place, chosen by triage.
|
|
2
|
+
|
|
3
|
+
THE PROBLEM, MEASURED on qwen3:4b via Ollama 0.35 (2026-10-11, local engine, temperature 0):
|
|
4
|
+
|
|
5
|
+
prompt "hi" 751 chars of thinking, 196 output tokens, 4.7 s
|
|
6
|
+
...for a 37-character answer.
|
|
7
|
+
prompt "Prove that the square
|
|
8
|
+
root of 2 is irrational" 2431 chars of thinking, 1266 output tokens, 29.9 s
|
|
9
|
+
|
|
10
|
+
The second is the model earning its keep. The first is a page of chain-of-thought to say "Hi there!
|
|
11
|
+
How can I help you today?" — and `THINK_ALWAYS = True` asks for it on every turn, because the owner's
|
|
12
|
+
2026-10-03 ruling was that models must always think. That ruling stands; what was missing is that
|
|
13
|
+
EFFORT is a separate decision from WHETHER.
|
|
14
|
+
|
|
15
|
+
FOUR MECHANISMS WERE MEASURED BEFORE THIS MODULE WAS WRITTEN, AND THREE OF THEM DO NOT WORK. This
|
|
16
|
+
matters more than the tiers, because each one looks like it works:
|
|
17
|
+
|
|
18
|
+
1. `think: "low"` / `"high"` — Ollama 0.35 ACCEPTS the string and qwen3:4b IGNORES it. Byte-for-byte
|
|
19
|
+
identical output to `think: true`: 751 chars, 196 tokens. A knob that is accepted and ignored is
|
|
20
|
+
worse than none, so NO level is sent at all — see the note on `BUDGET`.
|
|
21
|
+
2. `think: false` — costs THE SAME 196 tokens and moves the monologue into the answer: 798 chars of
|
|
22
|
+
content beginning "Okay, the user said 'hi'...". This is the finding already recorded at
|
|
23
|
+
`gateway.py:4260`, re-measured. It relocates the spend; it does not remove it.
|
|
24
|
+
3. `options.num_predict` as a thinking cap — genuinely bounds generation (24 tokens -> 88 chars of
|
|
25
|
+
thinking) and `done_reason: "length"` with an EMPTY ANSWER. It truncates the turn, not the
|
|
26
|
+
thinking.
|
|
27
|
+
4. Two-phase (cap the think, then re-ask for the answer with the truncated thinking as context) —
|
|
28
|
+
310 tokens and 7.9 s against a 196-token, 4.5 s baseline. MORE expensive, and phase two
|
|
29
|
+
monologued inline anyway. Refuted.
|
|
30
|
+
|
|
31
|
+
SO THE BUDGET HAS TWO TEETH, AND NEITHER IS AN ENGINE PARAMETER ALONE:
|
|
32
|
+
|
|
33
|
+
* A CAP ON THE REASONING CHANNEL, enforced in the node's streaming loop. It bounds what the caller
|
|
34
|
+
receives, what the governance store keeps, and what the §6A post-gate has to screen — on every
|
|
35
|
+
engine, including one that ignores its own knob. It does NOT reduce engine tokens on such a
|
|
36
|
+
model, and this module does not pretend otherwise: measurement 1 is why.
|
|
37
|
+
* THE TIER AS A ROUTING SIGNAL. This is where the real saving is, and it is large: the same "hi" on
|
|
38
|
+
`llama3.2:1b` is 8 output tokens and 1.74 s against 196 tokens and 4.71 s — 24x the tokens and
|
|
39
|
+
2.7x the latency, for the same useful answer. `effort == NONE` is the selector's licence to take
|
|
40
|
+
a non-reasoning model in tier when the fleet has one. On the hard prompt the cheap model is NOT
|
|
41
|
+
a substitute, which is why this is a per-turn decision and not a fleet-wide one.
|
|
42
|
+
|
|
43
|
+
NOT A FIXED MAX, which the brief is explicit about. The tier comes from the signals the gateway
|
|
44
|
+
already computes before dispatch — `pipeline.triage` (keyword, microseconds) and `router.route()`,
|
|
45
|
+
which already returns `reasoning_needed` and a tier and already runs at `gateway.py:4474`.
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import re
|
|
50
|
+
from typing import Optional
|
|
51
|
+
|
|
52
|
+
#: The tiers, cheapest first. `max_reasoning_tokens == 0` means UNBOUNDED, not zero — a budget of
|
|
53
|
+
#: nothing would be `NONE`, and the two must not be spelled the same way.
|
|
54
|
+
NONE = "none"
|
|
55
|
+
LOW = "low"
|
|
56
|
+
MEDIUM = "medium"
|
|
57
|
+
HIGH = "high"
|
|
58
|
+
|
|
59
|
+
TIERS = (NONE, LOW, MEDIUM, HIGH)
|
|
60
|
+
|
|
61
|
+
#: tier -> (max reasoning tokens forwarded, one-line reason)
|
|
62
|
+
#:
|
|
63
|
+
#: The numbers are sized against the measurement in this module's docstring: a hard prompt spent
|
|
64
|
+
#: 2431 chars (~600 tokens) of thinking on qwen3:4b, so MEDIUM at 1024 does not bite a genuinely
|
|
65
|
+
#: hard turn and HIGH is unbounded for the ones that are harder still. LOW at 192 is above the
|
|
66
|
+
#: ~190 tokens the trivial prompt wanted and below the hard one, so it is a real cap rather than a
|
|
67
|
+
#: number that never fires.
|
|
68
|
+
BUDGET: dict[str, tuple[int, str]] = {
|
|
69
|
+
NONE: (0, "trivial: a greeting, an acknowledgement, or a one-token question"),
|
|
70
|
+
LOW: (192, "simple: answerable without multi-step work"),
|
|
71
|
+
MEDIUM: (1024, "ordinary: the default when nothing says otherwise"),
|
|
72
|
+
HIGH: (0, "hard: the triage or the router asked for reasoning"),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#: NO ENGINE LEVEL IS SENT, and that is a decision with a measurement behind it rather than an
|
|
76
|
+
#: omission. Ollama's `think` takes EITHER a bool or a level string, so asking for a level means
|
|
77
|
+
#: replacing the bool that is known to work. Measured 2026-10-11 on qwen3:4b / Ollama 0.35:
|
|
78
|
+
#: `think: "low"` and `think: "high"` are both accepted and produce output byte-identical to
|
|
79
|
+
#: `think: true` — 751 chars of thinking, 196 tokens, in every case. So sending a level would trade a
|
|
80
|
+
#: field that works for one that does nothing on the roster's own default model. If a model that
|
|
81
|
+
#: honours levels (gpt-oss) is ever served, this is the note that says what to add and what to
|
|
82
|
+
#: re-measure first. Rule 12: there is no `engine_level()` here, because nothing would call it.
|
|
83
|
+
|
|
84
|
+
#: NONE means "forward no reasoning at all"; HIGH means "do not bound it". Both are 0 in the table
|
|
85
|
+
#: above and they are opposites, so the distinction is a function and not a comparison on the number.
|
|
86
|
+
UNBOUNDED = {HIGH}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def max_reasoning_tokens(tier: str) -> Optional[int]:
|
|
90
|
+
"""The cap for this tier, or None for unbounded. `NONE` returns 0, which is a real cap."""
|
|
91
|
+
if tier in UNBOUNDED:
|
|
92
|
+
return None
|
|
93
|
+
return BUDGET.get(tier, BUDGET[MEDIUM])[0]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def reason(tier: str) -> str:
|
|
97
|
+
return BUDGET.get(tier, BUDGET[MEDIUM])[1]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
#: TRIVIAL TURNS, matched on the WHOLE message rather than on a keyword anywhere in it. "hi" is
|
|
101
|
+
#: trivial; "hi, can you prove the Collatz conjecture" is not, and a substring match would call both
|
|
102
|
+
#: of them greetings. Anchored, and the message must be SHORT as well as matching — a 400-word
|
|
103
|
+
#: message that happens to open with "thanks" is not an acknowledgement.
|
|
104
|
+
_TRIVIAL_RE = re.compile(
|
|
105
|
+
r"^\s*(?:hi|hey|hello|yo|sup|hiya|howdy|good\s+(?:morning|afternoon|evening)|"
|
|
106
|
+
r"thanks|thank\s+you|thx|ta|cheers|ok|okay|k|cool|nice|great|got\s+it|"
|
|
107
|
+
r"yes|no|yep|nope|sure|please|bye|goodbye|see\s+you|good\s?night)"
|
|
108
|
+
r"[\s!.,?:;)\-~]*$",
|
|
109
|
+
re.IGNORECASE)
|
|
110
|
+
|
|
111
|
+
#: Above this many characters a message is not trivial whatever it matches.
|
|
112
|
+
TRIVIAL_MAX_CHARS = 32
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_trivial(query: str) -> bool:
|
|
116
|
+
"""A greeting or an acknowledgement, and nothing else in the message."""
|
|
117
|
+
q = (query or "").strip()
|
|
118
|
+
return bool(q) and len(q) <= TRIVIAL_MAX_CHARS and bool(_TRIVIAL_RE.match(q))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def effort_for(query: str, *, difficulty: str = "", reasoning_needed: Optional[bool] = None,
|
|
122
|
+
routed_tier: str = "", quality: str = "quality") -> str:
|
|
123
|
+
"""The effort tier for this turn, from the signals the gateway already has before dispatch.
|
|
124
|
+
|
|
125
|
+
PRECEDENCE, and every step of it is a decision rather than a default:
|
|
126
|
+
|
|
127
|
+
1. TRIVIAL WINS OVER EVERYTHING. A greeting gets `NONE` even on the quality profile and even if
|
|
128
|
+
the router asked for reasoning, because there is nothing to reason about. This is the case
|
|
129
|
+
the brief is about.
|
|
130
|
+
2. The TRIAGE's "hard" and the ROUTER's `reasoning_needed` both mean HIGH. Either one is
|
|
131
|
+
enough: they are independent classifiers of the same question and a turn only has to look
|
|
132
|
+
hard to one of them to be worth the tokens. `routed_tier` in the top tiers (A/S) means the
|
|
133
|
+
same thing — a frontier-tier question is not a LOW-effort one.
|
|
134
|
+
3. The SPEED profile drops one tier. A caller who asked for speed asked for this.
|
|
135
|
+
4. Otherwise MEDIUM, which is the old behaviour bounded: 1024 tokens does not bite a turn that
|
|
136
|
+
was going to spend 600.
|
|
137
|
+
|
|
138
|
+
`quality` is read but is NOT the gate. `profile.quality` is "quality" for every request that says
|
|
139
|
+
nothing, so keying effort on it would mean MEDIUM-or-higher on every ordinary turn — the trap the
|
|
140
|
+
deleted thinking hint fell into and the one `CONFIG.cove_enabled` was added for.
|
|
141
|
+
"""
|
|
142
|
+
if is_trivial(query):
|
|
143
|
+
return NONE
|
|
144
|
+
if difficulty == "hard" or reasoning_needed is True or (routed_tier or "").upper() in ("A", "S"):
|
|
145
|
+
return HIGH
|
|
146
|
+
if quality == "speed":
|
|
147
|
+
return LOW
|
|
148
|
+
return MEDIUM
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def describe(tier: str) -> dict:
|
|
152
|
+
"""What this tier does, for a response's diagnostics. No secrets, no prompt text."""
|
|
153
|
+
cap = max_reasoning_tokens(tier)
|
|
154
|
+
return {"effort": tier, "max_reasoning_tokens": cap,
|
|
155
|
+
"unbounded": cap is None, "why": reason(tier)}
|