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.
@@ -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
@@ -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)}