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/config.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Central configuration for frutebox.
|
|
2
|
+
|
|
3
|
+
Values can be overridden with environment variables (prefixed ``FRUTEBOX_``, with the
|
|
4
|
+
legacy ``STEVAI_`` name still honoured — see ``frutebox.envvars``), e.g.
|
|
5
|
+
``FRUTEBOX_EPOCH_SECONDS=30``. Keeping this in one place means the control
|
|
6
|
+
plane, provider nodes and the economics engine all agree on the same rules.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Dict, List
|
|
13
|
+
|
|
14
|
+
from .envvars import env as _env
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _env_float(name: str, default: float) -> float:
|
|
18
|
+
try:
|
|
19
|
+
return float(_env(name)) # type: ignore[arg-type]
|
|
20
|
+
except (KeyError, ValueError, TypeError):
|
|
21
|
+
return default
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _env_int(name: str, default: int) -> int:
|
|
25
|
+
try:
|
|
26
|
+
return int(_env(name)) # type: ignore[arg-type]
|
|
27
|
+
except (KeyError, ValueError, TypeError):
|
|
28
|
+
return default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _env_str(name: str, default: str) -> str:
|
|
32
|
+
return _env(name, default) or default
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
36
|
+
val = _env(name)
|
|
37
|
+
if val is None:
|
|
38
|
+
return default
|
|
39
|
+
return val.strip().lower() in ("1", "true", "yes", "on")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _env_list(name: str, default: List[str]) -> List[str]:
|
|
43
|
+
val = _env(name)
|
|
44
|
+
if not val:
|
|
45
|
+
return list(default)
|
|
46
|
+
return [x.strip() for x in val.split(",") if x.strip()]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# The jurisdictions a user is allowed to pin their inference to. In production
|
|
50
|
+
# these map to legally-attested data-centre locations; here they are logical
|
|
51
|
+
# labels used by the router as a *hard* constraint.
|
|
52
|
+
DEFAULT_REGIONS: List[str] = [
|
|
53
|
+
"eu-west", # e.g. Ireland / Belgium
|
|
54
|
+
"eu-central", # e.g. Germany / Netherlands
|
|
55
|
+
"eu-north", # e.g. Sweden / Finland
|
|
56
|
+
"us-east",
|
|
57
|
+
"us-west",
|
|
58
|
+
"uk",
|
|
59
|
+
"ap-southeast", # e.g. Singapore
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Config:
|
|
65
|
+
# --- networking -------------------------------------------------------
|
|
66
|
+
control_host: str = _env_str("CONTROL_HOST", "127.0.0.1")
|
|
67
|
+
control_port: int = _env_int("CONTROL_PORT", 8080)
|
|
68
|
+
|
|
69
|
+
# A provider node considered dead if no heartbeat within this window.
|
|
70
|
+
stale_seconds: float = _env_float("STALE_SECONDS", 12.0)
|
|
71
|
+
heartbeat_seconds: float = _env_float("HEARTBEAT_SECONDS", 4.0)
|
|
72
|
+
# How long the control plane waits for a provider to return a job. Frontier REASONING models
|
|
73
|
+
# (e.g. Kimi K2.7-Code) think for minutes before the answer, so a 60s cap would time the node
|
|
74
|
+
# out mid-reasoning → exclude it → re-plan onto nobody → a misleading "no capacity" 503. On the
|
|
75
|
+
# streaming path this is a per-chunk gap (reasoning tokens keep it alive); on the non-stream path
|
|
76
|
+
# it bounds the whole turn. Sized for a long reasoning turn; a genuinely hung node still fails
|
|
77
|
+
# fast on streaming. Override per-deploy with JOB_TIMEOUT_S.
|
|
78
|
+
job_timeout_s: float = _env_float("JOB_TIMEOUT_S", 1800.0)
|
|
79
|
+
|
|
80
|
+
# --- routing weights (lower score = better provider) ------------------
|
|
81
|
+
w_latency: float = _env_float("W_LATENCY", 0.5)
|
|
82
|
+
w_load: float = _env_float("W_LOAD", 0.35)
|
|
83
|
+
w_price: float = _env_float("W_PRICE", 0.15)
|
|
84
|
+
|
|
85
|
+
# --- economics --------------------------------------------------------
|
|
86
|
+
# One "epoch" is deliberately short here so you can watch the reward
|
|
87
|
+
# decay live. In production an epoch might be a day or a week.
|
|
88
|
+
epoch_seconds: float = _env_float("EPOCH_SECONDS", 60.0)
|
|
89
|
+
# The bonus emission halves every N epochs (the "Bitcoin halving").
|
|
90
|
+
halving_epochs: int = _env_int("HALVING_EPOCHS", 4)
|
|
91
|
+
|
|
92
|
+
# Default price a provider charges to cover energy + basic maintenance,
|
|
93
|
+
# expressed per 1000 tokens, in the platform's accounting unit (credits).
|
|
94
|
+
default_cost_per_1k: float = _env_float("DEFAULT_COST_PER_1K", 0.06)
|
|
95
|
+
|
|
96
|
+
# Provider profit on top of their costs.
|
|
97
|
+
margin_rate: float = _env_float("MARGIN_RATE", 0.15)
|
|
98
|
+
# Our (the platform operator's) cut.
|
|
99
|
+
platform_rate: float = _env_float("PLATFORM_RATE", 0.05)
|
|
100
|
+
# The decaying bootstrap bonus, as a multiple of base cost, at epoch 0.
|
|
101
|
+
# Funded from a treasury / emission pool, NOT paid by users.
|
|
102
|
+
initial_bonus_rate: float = _env_float("INITIAL_BONUS_RATE", 1.0)
|
|
103
|
+
|
|
104
|
+
# --- File 01-D: settlement is priced on what serving actually costs -------------------
|
|
105
|
+
# A node operator pays for BANDWIDTH and ELECTRICITY, not for tokens. The reward on top is
|
|
106
|
+
# the §8A headroom — what we saved versus the cheapest alternative — so it is capped and
|
|
107
|
+
# floored rather than being a free-running multiple of a made-up base.
|
|
108
|
+
reward_cap_eur: float = _env_float("REWARD_CAP_EUR", 0.50)
|
|
109
|
+
reward_floor_eur: float = _env_float("REWARD_FLOOR_EUR", 0.0)
|
|
110
|
+
#: Fallback wattage per GPU tier when a node has not been benchmarked yet (file 01-D3).
|
|
111
|
+
#: Deliberately a declaration, not a guess dressed as a measurement — `power_measured`
|
|
112
|
+
#: on the settlement result says which one was used.
|
|
113
|
+
tier_watts_c: float = _env_float("TIER_WATTS_C", 60.0)
|
|
114
|
+
tier_watts_b: float = _env_float("TIER_WATTS_B", 200.0)
|
|
115
|
+
tier_watts_a: float = _env_float("TIER_WATTS_A", 350.0)
|
|
116
|
+
tier_watts_s: float = _env_float("TIER_WATTS_S", 700.0)
|
|
117
|
+
|
|
118
|
+
# How many epochs to render on the reward-curve chart.
|
|
119
|
+
curve_epochs: int = _env_int("CURVE_EPOCHS", 8)
|
|
120
|
+
|
|
121
|
+
default_region: str = _env_str("DEFAULT_REGION", "eu-west")
|
|
122
|
+
regions: List[str] = field(default_factory=lambda: list(DEFAULT_REGIONS))
|
|
123
|
+
|
|
124
|
+
# --- zero-trust redaction --------------------------------------------
|
|
125
|
+
# Strip secrets before a prompt reaches any provider. Ideally the client
|
|
126
|
+
# scrubs first (secrets never leave the user); this is the operator-side
|
|
127
|
+
# safety net for callers who don't. Placeholders go to the node; the
|
|
128
|
+
# gateway holds the vault and restores real values in the answer.
|
|
129
|
+
redact_at_gateway: bool = _env_bool("REDACT_AT_GATEWAY", True)
|
|
130
|
+
|
|
131
|
+
# --- reactive detection & accountability (Phase C) --------------------
|
|
132
|
+
# Fraction of jobs that get a planted honeytoken / a redundant re-run. Kept
|
|
133
|
+
# at 0 by default so latency and existing behaviour are untouched; demos and
|
|
134
|
+
# the operator dial these up. Staking backs the slashing penalties.
|
|
135
|
+
canary_rate: float = _env_float("CANARY_RATE", 0.0)
|
|
136
|
+
verify_rate: float = _env_float("VERIFY_RATE", 0.0)
|
|
137
|
+
default_stake: float = _env_float("DEFAULT_STAKE", 100.0)
|
|
138
|
+
# Self-consistency (Phase D quality): on HARD, quality-profile queries the
|
|
139
|
+
# gateway re-samples the brain this many times total and the pipeline scores
|
|
140
|
+
# agreement — disagreement lowers confidence and can trigger abstention. 1 =
|
|
141
|
+
# off (no extra samples) by default, since each sample is another full
|
|
142
|
+
# inference (latency); the operator dials it up when quality > speed matters.
|
|
143
|
+
self_consistency_samples: int = _env_int("SELF_CONSISTENCY_SAMPLES", 1)
|
|
144
|
+
|
|
145
|
+
#: File 03-F3 — how many CONVERSATIONAL turns survive context hygiene. The plan names this
|
|
146
|
+
#: knob and its default. Instruction roles (system/developer) are held out of the budget
|
|
147
|
+
#: entirely, so this counts the conversation, not the prompt.
|
|
148
|
+
ctx_turns: int = _env_int("CTX_TURNS", 20)
|
|
149
|
+
# Smart triage (§5.1): consult a small model to classify difficulty for quality queries the
|
|
150
|
+
# keyword pass thinks are EASY (catching hard ones it misses). One tiny extra call on the
|
|
151
|
+
# quality path only; off by default so latency/behaviour are unchanged until enabled.
|
|
152
|
+
smart_triage: bool = _env_bool("SMART_TRIAGE", False)
|
|
153
|
+
# Chain-of-Verification (§5.5): plan verification questions about the draft, answer each in a
|
|
154
|
+
# FRESH context, revise. It was the only quality pass with no operator switch, and it is not a
|
|
155
|
+
# cheap one — measured 2026-10-07 on a stock deployment (every other flag at its default) with a
|
|
156
|
+
# query the keyword pass calls hard: SEVEN node calls, one answer plus six verification calls,
|
|
157
|
+
# four of them serial. `self_consistency_samples` is off by default and `smart_triage` is off by
|
|
158
|
+
# default precisely because each costs an extra inference; CoVe cost six and could not be turned
|
|
159
|
+
# off at all. DEFAULT TRUE, so no deployment's behaviour changes: this closes the gap between
|
|
160
|
+
# what the quality stage's docstring promised an operator ("off or cheap by default") and what
|
|
161
|
+
# they could actually control. Turn it off where latency or power matters more than the
|
|
162
|
+
# hallucination guard — the draft is then returned unrevised, which is what every failure path
|
|
163
|
+
# in `cove_verify` already does.
|
|
164
|
+
cove_enabled: bool = _env_bool("COVE_ENABLED", True)
|
|
165
|
+
# Enforce software integrity attestation on node connect when the operator
|
|
166
|
+
# has published allowed agent digests. Off if no digests are configured.
|
|
167
|
+
require_attestation: bool = _env_bool("REQUIRE_ATTESTATION", True)
|
|
168
|
+
# Additional agent digests the operator publishes alongside this build's own.
|
|
169
|
+
# A directory only ever knows the digest of the code IT was built from, so any
|
|
170
|
+
# node-agent change locks out every still-running older node the moment the
|
|
171
|
+
# directory deploys first. Listing the outgoing digest here keeps both versions
|
|
172
|
+
# admissible for the length of a rollout; drop it once the fleet has caught up.
|
|
173
|
+
extra_agent_digests: List[str] = field(
|
|
174
|
+
default_factory=lambda: _env_list("EXTRA_AGENT_DIGESTS", []))
|
|
175
|
+
|
|
176
|
+
# --- governance (the accountable-operator model) ----------------------
|
|
177
|
+
# frutebox is deliberately NOT a leaderless community like Bitcoin. One
|
|
178
|
+
# company operates the control plane, is legally liable, and can act
|
|
179
|
+
# instantly. These switches are how that control is exercised.
|
|
180
|
+
#
|
|
181
|
+
# When admission is required, a newly-connected provider is parked as
|
|
182
|
+
# "pending" and CANNOT receive traffic until the operator approves it.
|
|
183
|
+
require_admission: bool = _env_bool("REQUIRE_ADMISSION", True)
|
|
184
|
+
# Bearer token protecting every /admin endpoint. Override in production.
|
|
185
|
+
admin_token: str = _env_str("ADMIN_TOKEN", "dev-admin-token")
|
|
186
|
+
# Provider ids pre-approved at startup (skip the pending queue).
|
|
187
|
+
approved_ids: List[str] = field(
|
|
188
|
+
default_factory=lambda: _env_list("APPROVED_IDS", []))
|
|
189
|
+
|
|
190
|
+
# --- signed node credentials (admission without an in-memory allow-list) ---
|
|
191
|
+
# A node credential is a directory-signed ticket ("this node id, owned by this
|
|
192
|
+
# user, may join") that the gateway verifies on connect. Unlike APPROVED_IDS —
|
|
193
|
+
# which lives in the directory's memory and evaporates on every restart — the
|
|
194
|
+
# grant travels WITH the node, so admission survives a control-plane restart.
|
|
195
|
+
# Revocation is still the operator's kill switch (SUSPENDED_IDS wins over any
|
|
196
|
+
# valid credential) plus a short-enough TTL, so keep the TTL modest.
|
|
197
|
+
node_credential_ttl_s: float = _env_float("NODE_CREDENTIAL_TTL_S", 30 * 86400.0)
|
|
198
|
+
# Hard ceiling on a caller-requested TTL: a credential cannot be un-signed, so
|
|
199
|
+
# the longest life we will ever mint is bounded here (90 days).
|
|
200
|
+
node_credential_max_ttl_s: float = _env_float("NODE_CREDENTIAL_MAX_TTL_S", 90 * 86400.0)
|
|
201
|
+
|
|
202
|
+
# Node ids the operator vouches for (KYC'd, staked) — eligible to run
|
|
203
|
+
# *sensitive* jobs. Everything else defaults to "untrusted" (general work
|
|
204
|
+
# only). Owned/neutral labels are assigned per-node via the admin API.
|
|
205
|
+
trusted_ids: List[str] = field(
|
|
206
|
+
default_factory=lambda: _env_list("TRUSTED_IDS", []))
|
|
207
|
+
|
|
208
|
+
# --- node-side job governance (defence in depth on a volunteer's machine) ---
|
|
209
|
+
# A node runs on somebody's real laptop/server and takes work from any signed-up
|
|
210
|
+
# user, so the job frame is attacker-controlled. These are the limits the NODE
|
|
211
|
+
# enforces on itself — deliberately not negotiable by the job, and enforced even
|
|
212
|
+
# if the gateway is compromised. Enforcement lives in frutebox/engine_guard.py
|
|
213
|
+
# (payload shape + engine surface) and frutebox/provider.py (concurrency + clock).
|
|
214
|
+
#
|
|
215
|
+
# Simultaneous jobs one node will run. Was advertised in the register frame but
|
|
216
|
+
# never enforced: every inbound `job` frame became an asyncio task, so one user
|
|
217
|
+
# could start unlimited concurrent generations on a volunteer's machine.
|
|
218
|
+
node_max_concurrent_jobs: int = _env_int("NODE_MAX_CONCURRENT_JOBS", 4)
|
|
219
|
+
# Hard wall-clock ceiling on ONE job, cancel included. Matches the gateway's own
|
|
220
|
+
# job_timeout_s: past that the gateway has already given up and re-planned, so a
|
|
221
|
+
# node computing on is pure waste (and the amplification vector — one request
|
|
222
|
+
# burning compute on N machines).
|
|
223
|
+
node_job_timeout_s: float = _env_float("NODE_JOB_TIMEOUT_S", 1800.0)
|
|
224
|
+
# Prompt bounds. The WebSocket frame limit is 8 MB; a prompt has no business
|
|
225
|
+
# being anywhere near that.
|
|
226
|
+
node_max_prompt_bytes: int = _env_int("NODE_MAX_PROMPT_BYTES", 1_000_000)
|
|
227
|
+
node_max_messages: int = _env_int("NODE_MAX_MESSAGES", 200)
|
|
228
|
+
# Generation budget. Ollama received NO options at all before, meaning "generate
|
|
229
|
+
# until the context fills"; 4096 matches Ollama's own default context so nothing
|
|
230
|
+
# gets shorter — it is now explicit, bounded and tunable instead of implicit.
|
|
231
|
+
node_max_context_tokens: int = _env_int("NODE_MAX_CONTEXT_TOKENS", 4096)
|
|
232
|
+
node_max_output_tokens: int = _env_int("NODE_MAX_OUTPUT_TOKENS", 4096)
|
|
233
|
+
# llama.cpp serves the reasoning models (Kimi K2.x), which legitimately think for
|
|
234
|
+
# many tokens before answering — same value the engine already used, kept separate
|
|
235
|
+
# so bounding a laptop never quietly downgrades the frontier node.
|
|
236
|
+
node_max_output_tokens_llamacpp: int = _env_int("NODE_MAX_OUTPUT_TOKENS_LLAMACPP", 20000)
|
|
237
|
+
# Backstop that does not depend on the engine honouring a token budget.
|
|
238
|
+
node_max_output_bytes: int = _env_int("NODE_MAX_OUTPUT_BYTES", 2_000_000)
|
|
239
|
+
# Tool DEFINITIONS forwarded to the engine (bounded + scrubbed first). Untrusted
|
|
240
|
+
# JSON Schema is rendered through the engine's chat template — llama.cpp runs with
|
|
241
|
+
# --jinja — so an owner can switch the surface off entirely.
|
|
242
|
+
node_max_tools: int = _env_int("NODE_MAX_TOOLS", 32)
|
|
243
|
+
node_max_tool_bytes: int = _env_int("NODE_MAX_TOOL_BYTES", 128_000)
|
|
244
|
+
node_allow_tool_defs: bool = _env_bool("NODE_ALLOW_TOOL_DEFS", True)
|
|
245
|
+
# Tool EXECUTION on the node's host. OFF by default and, on top of that, only ever
|
|
246
|
+
# possible on an operator-trusted node (see engine_guard.tool_execution_allowed).
|
|
247
|
+
# Today nothing on the node executes tools at all; this is the seam being sealed
|
|
248
|
+
# before a node-side runner could ever ship.
|
|
249
|
+
node_allow_tool_exec: bool = _env_bool("NODE_ALLOW_TOOL_EXEC", False)
|
|
250
|
+
# The engine must sit on loopback: a node is not a proxy that forwards user
|
|
251
|
+
# prompts to somebody else's host.
|
|
252
|
+
node_engine_allow_remote: bool = _env_bool("NODE_ENGINE_ALLOW_REMOTE", False)
|
|
253
|
+
# Attacker-supplied audio handed to a torch/C++ decoder — bound it hard.
|
|
254
|
+
node_max_voice_sample_bytes: int = _env_int("NODE_MAX_VOICE_SAMPLE_BYTES", 5_000_000)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
CONFIG = Config()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# File 11-A — latency budgets.
|
|
262
|
+
#
|
|
263
|
+
# The plan is explicit that these live HERE ("Budgets live in frutebox/config.py as
|
|
264
|
+
# LATENCY_BUDGETS"), alongside every other operational number, rather than in a module of their
|
|
265
|
+
# own. `frutebox/latency.py` holds the percentile and verdict MACHINERY that reads them; the numbers
|
|
266
|
+
# are config, and config lives in the config file.
|
|
267
|
+
#
|
|
268
|
+
# Overridable per key as FRUTEBOX_LAT_<PATH>_<STAT>_MS (STEVAI_LAT_* still read).
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def _lat(path: str, stat: str, default_ms: float) -> float:
|
|
272
|
+
# RULE 11 (2026-09-24): `FRUTEBOX_LAT_*` first, `STEVAI_LAT_*` second, through the same
|
|
273
|
+
# dual-read every other variable uses. Both spellings are accepted deliberately — a rename
|
|
274
|
+
# that breaks a live override the moment it ships is a worse bug than the old spelling, and
|
|
275
|
+
# nothing is removed until the senders have moved.
|
|
276
|
+
from .envvars import env as _env
|
|
277
|
+
suffix = f"LAT_{path.upper().replace('-', '_')}_{stat.upper()}_MS"
|
|
278
|
+
raw = (_env(suffix) or "").strip()
|
|
279
|
+
try:
|
|
280
|
+
return float(raw) if raw else default_ms
|
|
281
|
+
except ValueError:
|
|
282
|
+
return default_ms
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def latency_budgets() -> Dict[str, Dict[str, float]]:
|
|
286
|
+
"""File 11 table A. `{path: {ttft_p50, ttft_p95, tps}}`, in milliseconds (tps in tokens/s)."""
|
|
287
|
+
return {
|
|
288
|
+
"local_chat": {"ttft_p50": _lat("local_chat", "ttft_p50", 300),
|
|
289
|
+
"ttft_p95": _lat("local_chat", "ttft_p95", 600),
|
|
290
|
+
"tps": _lat("local_chat", "tps", 30)},
|
|
291
|
+
"network_chat": {"ttft_p50": _lat("network_chat", "ttft_p50", 800),
|
|
292
|
+
"ttft_p95": _lat("network_chat", "ttft_p95", 1500),
|
|
293
|
+
"tps": _lat("network_chat", "tps", 30)},
|
|
294
|
+
"network_frontier": {"ttft_p50": _lat("network_frontier", "ttft_p50", 1200),
|
|
295
|
+
"ttft_p95": _lat("network_frontier", "ttft_p95", 2500),
|
|
296
|
+
"tps": _lat("network_frontier", "tps", 20)},
|
|
297
|
+
"voice_local": {"ttft_p50": _lat("voice_local", "ttft_p50", 300),
|
|
298
|
+
"ttft_p95": _lat("voice_local", "ttft_p95", 500),
|
|
299
|
+
"tps": _lat("voice_local", "tps", 0)},
|
|
300
|
+
# FILE 13's done-criterion, as a NUMBER rather than prose: "answers a wake-word question
|
|
301
|
+
# <=3 s with a citation". It was written only in the plan, so nothing could fail when the
|
|
302
|
+
# companion took four seconds. p95 is the 3 s the plan names; p50 is half of it, because a
|
|
303
|
+
# budget whose only number is the ceiling is one nobody notices drifting toward it.
|
|
304
|
+
# `tps` is 0: a meeting answer is <=3 spoken sentences (§C3), so throughput is not what
|
|
305
|
+
# makes it feel fast — time to the first word is.
|
|
306
|
+
"meeting_answer": {"ttft_p50": _lat("meeting_answer", "ttft_p50", 1500),
|
|
307
|
+
"ttft_p95": _lat("meeting_answer", "ttft_p95", 3000),
|
|
308
|
+
"tps": _lat("meeting_answer", "tps", 0)},
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def latency_stage_budgets() -> Dict[str, float]:
|
|
313
|
+
"""Stages measured on their own rather than as a whole path (file 11-A)."""
|
|
314
|
+
return {
|
|
315
|
+
"gateway_overhead": _lat("gateway_overhead", "p95", 50),
|
|
316
|
+
"screening_verdict": _lat("screening_verdict", "p95", 80),
|
|
317
|
+
"steer_resume": _lat("steer_resume", "p95", 700),
|
|
318
|
+
# FILE 13 line 53, verbatim: "answers a wake-word question <=3 s with a citation". The
|
|
319
|
+
# number is the plan's, not a guess — a spoken answer that arrives after the conversation
|
|
320
|
+
# has moved on is worse than no answer.
|
|
321
|
+
"meeting_answer": _lat("meeting_answer", "p95", 3000),
|
|
322
|
+
# The router+rewriter pair, which 11-A budgets "together under 15 ms". It lived as a
|
|
323
|
+
# literal in `scripts/perf_harness.py` and as prose in `latency._NOTES`, so unlike the
|
|
324
|
+
# other three it could NOT be relaxed by an operator setting an override, and a drift
|
|
325
|
+
# between the two copies failed nothing. One source, same mechanism.
|
|
326
|
+
"router_rewriter": _lat("router_rewriter", "p95", 15),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
LATENCY_BUDGETS: Dict[str, Dict[str, float]] = latency_budgets()
|
|
331
|
+
LATENCY_STAGE_BUDGETS: Dict[str, float] = latency_stage_budgets()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def json_flag(value, default: bool = False) -> bool:
|
|
335
|
+
"""Parse a CLIENT-SUPPLIED boolean. NEVER `bool(x)`.
|
|
336
|
+
|
|
337
|
+
`bool("false")` is True, and so is `bool("no")` and `bool("0")`. A client whose checkbox
|
|
338
|
+
serialises as a string — a form post, a JS `String(x)`, an SDK that stringifies — flips the
|
|
339
|
+
caller's "off" into "on", silently, at the door.
|
|
340
|
+
|
|
341
|
+
THIS HAS ALREADY COST SOMETHING. It was caught at plan 13's integration as a CONSENT BYPASS:
|
|
342
|
+
a client sending `"consent_confirmed": "false"` joined a meeting without consent and had
|
|
343
|
+
`save_minutes` turned on, against two §D promises ("join without confirming consent is
|
|
344
|
+
impossible"; "No covert retention"). `decide_join` implemented them correctly; the coercion
|
|
345
|
+
destroyed them before the decision was reached.
|
|
346
|
+
|
|
347
|
+
It lives HERE, in the one module both the gateway and the directory already import, because
|
|
348
|
+
the fix is the RULE and not the door it was found at — the same reason credential logging was
|
|
349
|
+
fixed as a rule in September. Two copies of a parser like this is two chances for one of them
|
|
350
|
+
to go back to `bool()`.
|
|
351
|
+
|
|
352
|
+
Real booleans and real JSON literals only. Anything else is the DEFAULT, because a value this
|
|
353
|
+
function cannot read is a value the caller did not clearly say.
|
|
354
|
+
"""
|
|
355
|
+
if isinstance(value, bool):
|
|
356
|
+
return value
|
|
357
|
+
if value is None:
|
|
358
|
+
return default
|
|
359
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
360
|
+
return value != 0
|
|
361
|
+
if isinstance(value, str):
|
|
362
|
+
v = value.strip().lower()
|
|
363
|
+
if v in ("true", "yes", "on", "1"):
|
|
364
|
+
return True
|
|
365
|
+
if v in ("false", "no", "off", "0", ""):
|
|
366
|
+
return False
|
|
367
|
+
return default
|
frutebox/deltasync.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""FILE 03-F2 — delta-sync: the client resends only what is new.
|
|
2
|
+
|
|
3
|
+
THE SAVING, AND WHERE IT COMES FROM
|
|
4
|
+
A 20-turn conversation re-sends 19 turns of context on turn 20. Those tokens are paid for on
|
|
5
|
+
every single turn, and almost all of them are bytes the server already has. Delta-sync sends the
|
|
6
|
+
NEW turns plus a hash of the prefix; the server reconstructs the rest.
|
|
7
|
+
|
|
8
|
+
WHERE THE PREFIX COMES FROM — AND WHERE IT DOES NOT (owner ruling, 2026-09-03)
|
|
9
|
+
From the **existing consented conversation store**, under its own consent and retention rules.
|
|
10
|
+
NOT from the operational session cache: `sessionstate.py` deliberately refuses to hold
|
|
11
|
+
prompt/messages/completion/content, because an operational cache with a 24h TTL is not where
|
|
12
|
+
someone's conversation belongs, and that refusal is enforced in code rather than promised. There
|
|
13
|
+
is also no Redis here (Neon only, rule 10). The plan's original "reconstruct from Redis session
|
|
14
|
+
state" wording was the wrong half of that conflict and has been corrected.
|
|
15
|
+
|
|
16
|
+
FAIL SAFE, NOT FAIL CHEAP
|
|
17
|
+
If the hash does not match — the client edited a turn, a message was deleted, the client is out
|
|
18
|
+
of date — reconstruction REFUSES and the caller asks for the full context. Answering with a
|
|
19
|
+
prefix that is not the one the user is looking at would be a wrong answer produced confidently,
|
|
20
|
+
which is worse than resending some tokens.
|
|
21
|
+
|
|
22
|
+
THE OTHER HALF OF THE SAVING
|
|
23
|
+
Prefix reuse at the engine (`cache_prompt=true`) means the reconstructed prefix is not
|
|
24
|
+
re-evaluated either — the KV cache is reused across turns. That is where the compute saving
|
|
25
|
+
comes from; delta-sync is where the transfer saving comes from.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import os
|
|
31
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
32
|
+
from .envvars import env as envvar, env_bool, env_required
|
|
33
|
+
from . import adminheader
|
|
34
|
+
|
|
35
|
+
#: Roughly four characters per token. Only ever used to REPORT a saving, never to bill: the
|
|
36
|
+
#: real token count comes from the engine. A rough number is fine for "did this help".
|
|
37
|
+
CHARS_PER_TOKEN = 4
|
|
38
|
+
|
|
39
|
+
#: The web app that owns the conversation store. Same variable the session-state client uses.
|
|
40
|
+
#:
|
|
41
|
+
#: RESOLVED LAZILY, AND THAT IS NOT A STYLE CHOICE. At module scope this raised `MissingConfig` on
|
|
42
|
+
#: import, and `frutebox/inference.py` imports this module for `engine_cache_options()` — so
|
|
43
|
+
#: `python -m frutebox.provider` stopped starting at all for anyone without `FRUTEBOX_WEB_URL`.
|
|
44
|
+
#: That is the DOWNLOADABLE NODE: `run.sh`, `docker-entrypoint.sh`'s provider role, the Dockerfile
|
|
45
|
+
#: and every runbook set no such variable, and a GPU box has no business knowing the web app's URL.
|
|
46
|
+
#: The gateway already required it via `sessionstate`, so a gateway deploy and CI both stayed green
|
|
47
|
+
#: while the node was dead — it would have surfaced when an operator restarted a node.
|
|
48
|
+
#:
|
|
49
|
+
#: Only `fetch_prefix` needs it, and `fetch_prefix` is still unwired, so nothing pays this cost
|
|
50
|
+
#: until the transfer half of delta-sync is built.
|
|
51
|
+
def web_url() -> str:
|
|
52
|
+
return env_required("WEB_URL").rstrip("/")
|
|
53
|
+
TIMEOUT_S = float(envvar("CONTEXT_PREFIX_TIMEOUT_S", "3.0"))
|
|
54
|
+
|
|
55
|
+
#: Keys that must never travel into the operational session cache. Mirrors
|
|
56
|
+
#: `sessionstate._FORBIDDEN` so a drift in either is caught by a test rather than in production.
|
|
57
|
+
FORBIDDEN_IN_SESSION_STATE = frozenset(
|
|
58
|
+
{"prompt", "messages", "completion", "content", "vault", "secrets"}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _norm(msg: Dict[str, Any]) -> str:
|
|
63
|
+
"""One message, reduced to what actually identifies it: who said it and what they said."""
|
|
64
|
+
return f"{str(msg.get('role', '')).strip().lower()}\x1f{str(msg.get('content', ''))}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def context_hash(messages: Sequence[Dict[str, Any]]) -> str:
|
|
68
|
+
"""A stable fingerprint of a conversation prefix.
|
|
69
|
+
|
|
70
|
+
Order-sensitive on purpose: the same turns in a different order are a different conversation,
|
|
71
|
+
and a hash that ignored that would let a reordered history pass as the original.
|
|
72
|
+
"""
|
|
73
|
+
h = hashlib.sha256()
|
|
74
|
+
for m in messages:
|
|
75
|
+
h.update(_norm(m).encode("utf8"))
|
|
76
|
+
h.update(b"\x1e")
|
|
77
|
+
return h.hexdigest()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def split_prefix(messages: Sequence[Dict[str, Any]], keep_last: int) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
81
|
+
"""(prefix, new_turns) — what the server already has vs what the client must send."""
|
|
82
|
+
n = max(0, int(keep_last))
|
|
83
|
+
if n == 0 or n >= len(messages):
|
|
84
|
+
return [], list(messages)
|
|
85
|
+
return list(messages[:-n]), list(messages[-n:])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_request(messages: Sequence[Dict[str, Any]], *, session_id: str, keep_last: int = 1) -> Dict[str, Any]:
|
|
89
|
+
"""What a client sends INSTEAD of the whole conversation."""
|
|
90
|
+
prefix, new_turns = split_prefix(messages, keep_last)
|
|
91
|
+
return {
|
|
92
|
+
"session_id": session_id,
|
|
93
|
+
"context_hash": context_hash(prefix),
|
|
94
|
+
"prefix_len": len(prefix),
|
|
95
|
+
"new_turns": new_turns,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ContextMismatch(Exception):
|
|
100
|
+
"""The stored prefix is not the one the client hashed. Ask for the full context."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def reconstruct(
|
|
104
|
+
request: Dict[str, Any],
|
|
105
|
+
*,
|
|
106
|
+
load_prefix: Callable[[str], Optional[List[Dict[str, Any]]]],
|
|
107
|
+
) -> List[Dict[str, Any]]:
|
|
108
|
+
"""Rebuild the full message list from the stored prefix plus the client's new turns.
|
|
109
|
+
|
|
110
|
+
`load_prefix` is injected — this module imports no store, so the same logic is exercised in
|
|
111
|
+
tests against real data instead of a stand-in for the thing under test.
|
|
112
|
+
|
|
113
|
+
Raises `ContextMismatch` when the stored prefix is absent or does not hash to what the client
|
|
114
|
+
claimed. Callers treat that as "resend everything", never as "close enough".
|
|
115
|
+
"""
|
|
116
|
+
new_turns = list(request.get("new_turns") or [])
|
|
117
|
+
claimed = str(request.get("context_hash") or "")
|
|
118
|
+
session_id = str(request.get("session_id") or "")
|
|
119
|
+
|
|
120
|
+
if not claimed:
|
|
121
|
+
raise ContextMismatch("no context hash supplied")
|
|
122
|
+
|
|
123
|
+
# An empty prefix is a legitimate first turn: nothing to load, and the hash of nothing is a
|
|
124
|
+
# known value. Checking it rather than special-casing keeps one code path.
|
|
125
|
+
if claimed == context_hash([]):
|
|
126
|
+
if int(request.get("prefix_len") or 0) != 0:
|
|
127
|
+
raise ContextMismatch("prefix length disagrees with the empty-prefix hash")
|
|
128
|
+
return new_turns
|
|
129
|
+
|
|
130
|
+
prefix = load_prefix(session_id)
|
|
131
|
+
if prefix is None:
|
|
132
|
+
raise ContextMismatch("no stored prefix for this session")
|
|
133
|
+
if context_hash(prefix) != claimed:
|
|
134
|
+
raise ContextMismatch("stored prefix does not match the client's context hash")
|
|
135
|
+
if int(request.get("prefix_len") or -1) != len(prefix):
|
|
136
|
+
raise ContextMismatch("prefix length disagrees with the stored prefix")
|
|
137
|
+
|
|
138
|
+
return list(prefix) + new_turns
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def approx_tokens(messages: Sequence[Dict[str, Any]]) -> int:
|
|
142
|
+
return sum(len(_norm(m)) for m in messages) // CHARS_PER_TOKEN
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def input_token_saving(full: Sequence[Dict[str, Any]], request: Dict[str, Any]) -> float:
|
|
146
|
+
"""The fraction of resent input tokens delta-sync removes, 0.0–1.0.
|
|
147
|
+
|
|
148
|
+
Measured against what the client WOULD have sent — the whole conversation — because that is
|
|
149
|
+
the bill the user was actually paying.
|
|
150
|
+
"""
|
|
151
|
+
before = approx_tokens(full)
|
|
152
|
+
if before <= 0:
|
|
153
|
+
return 0.0
|
|
154
|
+
after = approx_tokens(request.get("new_turns") or [])
|
|
155
|
+
return max(0.0, (before - after) / before)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
#: 03-F2: prefix reuse at the engine. The reconstructed prefix must not be re-evaluated either —
|
|
159
|
+
#: without this the transfer gets cheaper and the compute does not.
|
|
160
|
+
#: Accepts the SAME falsey words as `envvars.env_bool` ({"0","false","no","off"}). It used to
|
|
161
|
+
#: compare against "false" alone, so an operator whose engine rejected the field and who
|
|
162
|
+
#: reached for the instinctive `FRUTEBOX_KV_PREFIX_REUSE=0` would not have turned it off.
|
|
163
|
+
#: That was harmless while nothing called this; wiring it made the switch load-bearing.
|
|
164
|
+
KV_PREFIX_REUSE = env_bool("KV_PREFIX_REUSE", True)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def engine_cache_options() -> Dict[str, bool]:
|
|
168
|
+
"""`cache_prompt` for the local engine, so the reused prefix is also a reused KV cache."""
|
|
169
|
+
return {"cache_prompt": True} if KV_PREFIX_REUSE else {}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def fetch_prefix(conversation_id: str, *, context_hash_hex: str, prefix_len: int) -> Optional[List[Dict[str, Any]]]:
|
|
173
|
+
"""Load the verified prefix from the consented conversation store (03-F2).
|
|
174
|
+
|
|
175
|
+
Mirrors `sessionstate.py`'s service-to-service shape — same admin header, same short timeout,
|
|
176
|
+
same "degrade to nothing rather than fail the turn" contract. A None here is not an error the
|
|
177
|
+
user should ever see: the caller resends the full context and the turn proceeds.
|
|
178
|
+
|
|
179
|
+
The hash goes UP with the request, so the store only returns a prefix to a caller that
|
|
180
|
+
already knows what it is asking for.
|
|
181
|
+
"""
|
|
182
|
+
import httpx
|
|
183
|
+
from .config import CONFIG
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
r = httpx.post(
|
|
187
|
+
f"{web_url()}/api/internal/context-prefix",
|
|
188
|
+
headers=adminheader.send(CONFIG.admin_token),
|
|
189
|
+
json={"conversationId": conversation_id,
|
|
190
|
+
"contextHash": context_hash_hex,
|
|
191
|
+
"prefixLen": int(prefix_len)},
|
|
192
|
+
timeout=TIMEOUT_S,
|
|
193
|
+
)
|
|
194
|
+
except Exception:
|
|
195
|
+
return None # network trouble: resend the full context, do not fail
|
|
196
|
+
if r.status_code != 200:
|
|
197
|
+
return None # 409 = the client's view is stale; same answer
|
|
198
|
+
try:
|
|
199
|
+
msgs = r.json().get("messages")
|
|
200
|
+
except Exception:
|
|
201
|
+
return None
|
|
202
|
+
return list(msgs) if isinstance(msgs, list) else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def safe_for_session_state(state: Dict[str, Any]) -> bool:
|
|
206
|
+
"""Would this dict be legal to hand to `sessionstate.save`?
|
|
207
|
+
|
|
208
|
+
Delta-sync exists BECAUSE conversation content must not go into that cache, so the module
|
|
209
|
+
that reconstructs content also carries the check that it never puts any there.
|
|
210
|
+
"""
|
|
211
|
+
return not any(k.lower() in FORBIDDEN_IN_SESSION_STATE for k in (state or {}))
|