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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""FILE 09-B — registry enforcement: licence gates, geo-fencing, representation invariant.
|
|
2
|
+
|
|
3
|
+
The registry (registry/models.yaml → registry_gen.py) declares; this module ENFORCES:
|
|
4
|
+
|
|
5
|
+
serving_allowed() — licence gate: non-commercial/verify never serve; paid-commercial
|
|
6
|
+
serves only under its signed FRUTEBOX_LICENSE_<VENDOR>_AGREED env
|
|
7
|
+
(the STEVAI_ spelling is still read, second).
|
|
8
|
+
territory_allows() — geo-fence, BOTH directions (executing node's region AND the
|
|
9
|
+
requesting user's region), fail-closed on unknowns.
|
|
10
|
+
check_representation() — the CI invariant: per tier, every big-three bloc the roster
|
|
11
|
+
provides must keep ≥1 enabled model, and at least 2-of-3 overall
|
|
12
|
+
(scoped to what the roster provides — a tier the roster only
|
|
13
|
+
fills from one bloc is a tracked gap, not a permanent red CI).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
from frutebox.envvars import NEW_PREFIX, OLD_PREFIX
|
|
20
|
+
from frutebox.registry_gen import BY_ID, REGISTRY
|
|
21
|
+
|
|
22
|
+
#: 09-B1 — the known landmine list, asserted absent from every serving surface.
|
|
23
|
+
LANDMINES = frozenset({
|
|
24
|
+
"codestral-22b", "xtts-v2", "flux1-dev", "flux2-dev", "flux-pro", "musicgen",
|
|
25
|
+
"lg-exaone", "cohere-command-r-plus", "nvidia-sana", "suno", "udio", "ox-alpha",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
_BIG3 = ("eu", "us", "cn")
|
|
29
|
+
|
|
30
|
+
#: 09-B3 — the roster's own per-tier accounting of which big-three blocs provide a quality
|
|
31
|
+
#: model (frutebox-plan1-model-roster-balanced.md v1.3, balance-check lines). Hard-coded so
|
|
32
|
+
#: DELETING a bloc's models from models.yaml cannot silently lower the floor — the
|
|
33
|
+
#: criterion is that removing the only US frontier model FAILS, not that the requirement
|
|
34
|
+
#: vanishes with it. Known gaps (video-EU, music, tts, stt, transparency-CN) are the
|
|
35
|
+
#: roster's flagged gaps, tracked there, not CI failures here.
|
|
36
|
+
TIER_BASELINE: dict[str, frozenset] = {
|
|
37
|
+
"frontier": frozenset({"eu", "us", "cn"}),
|
|
38
|
+
"mid": frozenset({"eu", "us", "cn"}),
|
|
39
|
+
"small": frozenset({"eu", "us", "cn"}),
|
|
40
|
+
"coding": frozenset({"eu", "us", "cn"}),
|
|
41
|
+
"reasoning": frozenset({"eu", "us", "cn"}),
|
|
42
|
+
"multimodal": frozenset({"eu", "us", "cn"}),
|
|
43
|
+
"transparency": frozenset({"eu", "us"}),
|
|
44
|
+
"image": frozenset({"eu", "us", "cn"}),
|
|
45
|
+
"video": frozenset({"us", "cn"}),
|
|
46
|
+
"music": frozenset({"cn"}),
|
|
47
|
+
"tts": frozenset({"us"}),
|
|
48
|
+
"stt": frozenset({"us"}),
|
|
49
|
+
"voice": frozenset({"eu", "us", "cn"}),
|
|
50
|
+
}
|
|
51
|
+
_ENABLED_STATUSES = {"default", "backup", "emerging"}
|
|
52
|
+
_ENABLED_CLASSES = {"apache_mit", "open_commercial", "geo_fenced"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def serving_allowed(model_id: str, env: dict | None = None) -> bool:
|
|
56
|
+
"""May this model appear in a serving manifest / be offered at routing?
|
|
57
|
+
|
|
58
|
+
Unknown ids are ALLOWED here — the registry gates roster models; serving aliases
|
|
59
|
+
(ollama tags, the stevai-default wildcard) are governed by their catalog entry.
|
|
60
|
+
"""
|
|
61
|
+
m = BY_ID.get(model_id)
|
|
62
|
+
if m is None:
|
|
63
|
+
return True
|
|
64
|
+
if m["license_class"] == "non_commercial" or m["status"] == "verify":
|
|
65
|
+
return False
|
|
66
|
+
if m["status"] == "emerging":
|
|
67
|
+
return False # runtimes can't serve it yet — declared, never offered
|
|
68
|
+
if m["license_class"] == "paid_commercial":
|
|
69
|
+
e = env if env is not None else os.environ
|
|
70
|
+
# DUAL-READ, FRUTEBOX_ first — the same precedence as `envvars.env()`, including that an
|
|
71
|
+
# EMPTY new-name value counts as set and wins rather than falling through to the old one.
|
|
72
|
+
#
|
|
73
|
+
# This read ONLY the STEVAI_ name while the TypeScript half of this same gate
|
|
74
|
+
# (`video-models.ts`, `music-models.ts`) goes through `envVar()`, which is dual-read. So a
|
|
75
|
+
# deployment that set FRUTEBOX_LICENSE_STABILITY_AGREED — the name F-86 says to move to —
|
|
76
|
+
# got a TypeScript side that offered the model and a Python side that still refused it.
|
|
77
|
+
# Fail-closed, so not a licensing hole, but the two halves of one decision disagreeing
|
|
78
|
+
# about the name of the variable that decides it.
|
|
79
|
+
suffix = f"LICENSE_{m['vendor']}_AGREED"
|
|
80
|
+
new_name, old_name = NEW_PREFIX + suffix, OLD_PREFIX + suffix
|
|
81
|
+
raw = e[new_name] if new_name in e else e.get(old_name, "")
|
|
82
|
+
return str(raw).lower() == "true"
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def region_bloc(region: str) -> str | None:
|
|
87
|
+
"""Map a gateway region / country claim to a territory bloc tag. None = unknown."""
|
|
88
|
+
r = (region or "").strip().lower()
|
|
89
|
+
if not r:
|
|
90
|
+
return None
|
|
91
|
+
if r in ("eu", "us", "cn", "ca", "uk", "il", "uae", "jp", "kr", "za"):
|
|
92
|
+
return r
|
|
93
|
+
if r.startswith("eu-"):
|
|
94
|
+
return "eu"
|
|
95
|
+
if r.startswith("us-"):
|
|
96
|
+
return "us"
|
|
97
|
+
if r in ("gb",):
|
|
98
|
+
return "uk"
|
|
99
|
+
if r in ("ae",):
|
|
100
|
+
return "uae"
|
|
101
|
+
if len(r) == 2: # bare ISO country code → EU membership decides the bloc
|
|
102
|
+
eu = {"at", "be", "bg", "hr", "cy", "cz", "dk", "ee", "fi", "fr", "de", "gr", "hu",
|
|
103
|
+
"ie", "it", "lv", "lt", "lu", "mt", "nl", "pl", "pt", "ro", "sk", "si", "es", "se"}
|
|
104
|
+
return "eu" if r in eu else r
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def territory_allows(model_id: str, node_region: str, user_region: str) -> bool:
|
|
109
|
+
"""09-B2 — the geo-fence, applied to BOTH the node and the user, fail-closed.
|
|
110
|
+
|
|
111
|
+
deny: blocked when either side is inside a denied bloc — or UNKNOWN (an
|
|
112
|
+
unattributable region cannot be proven outside the fence).
|
|
113
|
+
allow_only: served only when BOTH sides are provably inside — unknown is outside.
|
|
114
|
+
Worldwide ({}): always allowed, regions irrelevant.
|
|
115
|
+
"""
|
|
116
|
+
m = BY_ID.get(model_id)
|
|
117
|
+
if m is None:
|
|
118
|
+
return True
|
|
119
|
+
t = m.get("territory") or {}
|
|
120
|
+
if not t:
|
|
121
|
+
return True
|
|
122
|
+
sides = (region_bloc(node_region), region_bloc(user_region))
|
|
123
|
+
if "deny" in t:
|
|
124
|
+
denied = set(t["deny"])
|
|
125
|
+
return all(s is not None and s not in denied for s in sides)
|
|
126
|
+
if "allow_only" in t:
|
|
127
|
+
allowed = set(t["allow_only"])
|
|
128
|
+
return all(s is not None and s in allowed for s in sides)
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _enabled(m: dict) -> bool:
|
|
133
|
+
return m["status"] in _ENABLED_STATUSES and m["license_class"] in _ENABLED_CLASSES
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def representation_report(registry: list[dict] | None = None) -> dict[str, dict]:
|
|
137
|
+
"""09-B3 — per tier: which big-three blocs the roster provides, which are enabled,
|
|
138
|
+
and every other-region bloc counted (the maximize-representation metric)."""
|
|
139
|
+
reg = registry if registry is not None else REGISTRY
|
|
140
|
+
tiers: dict[str, dict] = {}
|
|
141
|
+
for m in reg:
|
|
142
|
+
if m["tier"] == "utility":
|
|
143
|
+
continue # parity-exempt, never licence-exempt
|
|
144
|
+
t = tiers.setdefault(m["tier"], {"provided": set(), "enabled": set(), "other": {}})
|
|
145
|
+
# "Provided" counts only entries that COULD serve: a bloc represented solely by a
|
|
146
|
+
# named exclusion (a landmine like xtts-v2, or an unresolved verify) is not
|
|
147
|
+
# provided by the roster — it is documented as unusable. Paid/geo-fenced count as
|
|
148
|
+
# provided (they serve under their gates).
|
|
149
|
+
could_serve = m["status"] != "verify" and m["license_class"] != "non_commercial"
|
|
150
|
+
if m["bloc"] in _BIG3:
|
|
151
|
+
if could_serve:
|
|
152
|
+
t["provided"].add(m["bloc"])
|
|
153
|
+
if _enabled(m):
|
|
154
|
+
t["enabled"].add(m["bloc"])
|
|
155
|
+
elif _enabled(m):
|
|
156
|
+
t["other"][m["bloc"]] = t["other"].get(m["bloc"], 0) + 1
|
|
157
|
+
return tiers
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def check_representation(registry: list[dict] | None = None) -> list[str]:
|
|
161
|
+
"""The CI gate: failures as human sentences; empty list = invariant holds.
|
|
162
|
+
|
|
163
|
+
Judged against TIER_BASELINE (what the roster says the field provides), so neither
|
|
164
|
+
disabling nor DELETING a bloc's models can pass silently."""
|
|
165
|
+
failures: list[str] = []
|
|
166
|
+
report = representation_report(registry)
|
|
167
|
+
for tier, baseline in sorted(TIER_BASELINE.items()):
|
|
168
|
+
enabled = report.get(tier, {}).get("enabled", set())
|
|
169
|
+
for bloc in sorted(baseline - enabled):
|
|
170
|
+
failures.append(
|
|
171
|
+
f"tier '{tier}': the roster provides a {bloc.upper()} model but none is enabled")
|
|
172
|
+
if len(baseline) >= 2 and len(enabled) < 2:
|
|
173
|
+
failures.append(
|
|
174
|
+
f"tier '{tier}': below the 2-of-3 floor ({len(enabled)} of "
|
|
175
|
+
f"{len(baseline)} baseline big-three blocs enabled)")
|
|
176
|
+
return failures
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def landmines_referenced(serving_ids: list[str]) -> list[str]:
|
|
180
|
+
"""09-B1 — landmine or non-commercial ids referenced by a serving surface."""
|
|
181
|
+
bad: list[str] = []
|
|
182
|
+
for mid in serving_ids:
|
|
183
|
+
if mid in LANDMINES:
|
|
184
|
+
bad.append(mid)
|
|
185
|
+
continue
|
|
186
|
+
m = BY_ID.get(mid)
|
|
187
|
+
if m and (m["license_class"] == "non_commercial" or m["status"] == "verify"):
|
|
188
|
+
bad.append(mid)
|
|
189
|
+
return bad
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""FILE 09-A2 — registry codegen: models.yaml → TS + Python + JSON artifacts.
|
|
3
|
+
|
|
4
|
+
One source of truth, three consumers, zero drift: the web catalog, the Python serving
|
|
5
|
+
stack, and a canonical JSON the round-trip tests compare both against. Deterministic —
|
|
6
|
+
same input, byte-identical output — so `git diff` after a regen IS the drift check.
|
|
7
|
+
|
|
8
|
+
Run from the repo root: python -m frutebox.registry.generate
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import pathlib
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
18
|
+
YAML_PATH = ROOT / "frutebox" / "registry" / "models.yaml"
|
|
19
|
+
JSON_PATH = ROOT / "frutebox" / "registry" / "registry.gen.json"
|
|
20
|
+
PY_PATH = ROOT / "frutebox" / "registry_gen.py"
|
|
21
|
+
TS_PATH = ROOT / "web" / "src" / "lib" / "model-registry.gen.ts"
|
|
22
|
+
|
|
23
|
+
REQUIRED = ("id", "bloc", "tier", "license", "license_class", "territory",
|
|
24
|
+
"local_class", "status", "runtime", "vram_gb")
|
|
25
|
+
|
|
26
|
+
#: FILE 11-B2 — `speed`, OPTIONAL and validated when present.
|
|
27
|
+
#:
|
|
28
|
+
#: Plan 11 names this as "Registry field (file 09)", and it was the one field the plan asks for that
|
|
29
|
+
#: the registry did not have: `speed: {spec_decode, draft_model, K}`. Speculative decoding is
|
|
30
|
+
#: quality-lossless and roughly 2-3x on decode, so which method a model can use is a FACT ABOUT THE
|
|
31
|
+
#: MODEL — it belongs beside its runtime and its VRAM, not in a serving module's private table, or
|
|
32
|
+
#: the serving stack and the registry would disagree about the same model.
|
|
33
|
+
#:
|
|
34
|
+
#: Optional rather than required because most of the roster has no head and no suitable draft model,
|
|
35
|
+
#: and a required field would be filled with `none` 90 times to satisfy a validator. Absent means
|
|
36
|
+
#: `none`, which `speed_of()` returns.
|
|
37
|
+
SPEC_DECODE = {
|
|
38
|
+
# EAGLE-3 with P-EAGLE parallel drafting: a pre-trained head exists for this model.
|
|
39
|
+
"p-eagle",
|
|
40
|
+
# EAGLE-3 without parallel drafting (an older vLLM/SGLang, or parallel drafting unavailable).
|
|
41
|
+
"eagle3",
|
|
42
|
+
# No head: a small model from the same family drafts for the big one.
|
|
43
|
+
"draft",
|
|
44
|
+
# llama.cpp's multi-token prediction, for GGUF targets.
|
|
45
|
+
"mtp",
|
|
46
|
+
"none",
|
|
47
|
+
}
|
|
48
|
+
#: 11-B2's measured optimum for P-EAGLE. Carried per model so a model that wants a different K can
|
|
49
|
+
#: say so, rather than the number being a constant three call sites deep.
|
|
50
|
+
DEFAULT_SPEC_K = 7
|
|
51
|
+
#: `in` and `ru` added 2026-10-10. `in` is India, for Maya Research's `maya1` — the roster's claim is
|
|
52
|
+
#: global representation and the set of blocs it can express is part of that claim. `ru` exists only so
|
|
53
|
+
#: `kandinsky-5.0` can be CATALOGUED while it waits on an owner governance decision: a row that cannot
|
|
54
|
+
#: name its own origin cannot record why it is being held.
|
|
55
|
+
BLOCS = {"eu", "us", "cn", "ca", "uk", "il", "uae", "jp", "kr", "za", "in", "ru", "community"}
|
|
56
|
+
TIERS = {"frontier", "mid", "small", "coding", "reasoning", "multimodal", "transparency",
|
|
57
|
+
"utility", "voice", "image", "video", "music", "tts", "stt"}
|
|
58
|
+
LICENSE_CLASSES = {"apache_mit", "open_commercial", "geo_fenced", "paid_commercial", "non_commercial"}
|
|
59
|
+
STATUSES = {"default", "backup", "emerging", "verify"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load() -> list[dict]:
|
|
63
|
+
with open(YAML_PATH, encoding="utf-8") as f:
|
|
64
|
+
entries = yaml.safe_load(f)
|
|
65
|
+
if not isinstance(entries, list) or not entries:
|
|
66
|
+
raise SystemExit("models.yaml: expected a non-empty list")
|
|
67
|
+
seen: set[str] = set()
|
|
68
|
+
for e in entries:
|
|
69
|
+
for k in REQUIRED:
|
|
70
|
+
if k not in e:
|
|
71
|
+
raise SystemExit(f"{e.get('id', '?')}: missing field {k}")
|
|
72
|
+
if e["id"] in seen:
|
|
73
|
+
raise SystemExit(f"duplicate id {e['id']}")
|
|
74
|
+
seen.add(e["id"])
|
|
75
|
+
if e["bloc"] not in BLOCS:
|
|
76
|
+
raise SystemExit(f"{e['id']}: unknown bloc {e['bloc']}")
|
|
77
|
+
if e["tier"] not in TIERS:
|
|
78
|
+
raise SystemExit(f"{e['id']}: unknown tier {e['tier']}")
|
|
79
|
+
if e["license_class"] not in LICENSE_CLASSES:
|
|
80
|
+
raise SystemExit(f"{e['id']}: unknown license_class {e['license_class']}")
|
|
81
|
+
if e["status"] not in STATUSES:
|
|
82
|
+
raise SystemExit(f"{e['id']}: unknown status {e['status']}")
|
|
83
|
+
if e["local_class"] not in ("L", "W", "S", None):
|
|
84
|
+
raise SystemExit(f"{e['id']}: unknown local_class {e['local_class']}")
|
|
85
|
+
_check_origin_risk(e)
|
|
86
|
+
if e["license_class"] == "paid_commercial" and not e.get("vendor"):
|
|
87
|
+
raise SystemExit(f"{e['id']}: paid_commercial requires a vendor (env-gate key)")
|
|
88
|
+
t = e["territory"] or {}
|
|
89
|
+
if not isinstance(t, dict) or any(k not in ("deny", "allow_only") for k in t):
|
|
90
|
+
raise SystemExit(f"{e['id']}: territory must be {{}}, {{deny:[...]}} or {{allow_only:[...]}}")
|
|
91
|
+
_check_speed(e)
|
|
92
|
+
_check_source(e)
|
|
93
|
+
return entries
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
#: What a `source` block may contain. A pinned, RESOLVABLE place to get the weights — nothing about
|
|
97
|
+
#: how to serve them, which is `serving.py`'s business and varies by engine.
|
|
98
|
+
SOURCE_KEYS = {"hf_repo", "revision", "ollama_tag", "ollama_digest", "gated",
|
|
99
|
+
"gguf_repo", "gguf_revision", "gguf_quant"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_source(e: dict) -> None:
|
|
103
|
+
"""28 §B — an enabled model must have a PINNED source, or say honestly that it has none.
|
|
104
|
+
|
|
105
|
+
WHAT THIS CLOSES, measured 2026-10-01: of 29 enabled models, **25 could not be served at all** —
|
|
106
|
+
`serving.serving_spec()` returned a recipe with an empty command, because the only sources that
|
|
107
|
+
existed were two hand-maintained dicts in `serving.py` covering four models. Every other enabled
|
|
108
|
+
model was in the catalog, offered by routing, and unservable. All three enabled EMBEDDING models
|
|
109
|
+
were among them, which is why `/v1/embeddings` failed fleet-wide with `HTTPStatusError`.
|
|
110
|
+
|
|
111
|
+
And the four that did work were pinned to a REPO and not to a REVISION, so what they served was
|
|
112
|
+
whatever `main` happened to be that day.
|
|
113
|
+
|
|
114
|
+
THE RULES, each of which is a failure that actually happened or was one edit away:
|
|
115
|
+
|
|
116
|
+
* an enabled model (`default`/`backup`) carries `source` or `source_unavailable` — never neither,
|
|
117
|
+
because "neither" is how 25 models sat in the catalog looking fine;
|
|
118
|
+
* a revision is a full 40-character sha. A tag or a branch is not a pin: `revision: main` reads
|
|
119
|
+
like one and moves under you;
|
|
120
|
+
* an `ollama_tag` comes with an `ollama_digest`, for the same reason — `qwen3:8b` is republished;
|
|
121
|
+
* `source` and `source_unavailable` are mutually exclusive. A model cannot both have weights and
|
|
122
|
+
not have them, and a stale `source_unavailable` beside a working source is worse than either.
|
|
123
|
+
"""
|
|
124
|
+
import re as _re
|
|
125
|
+
|
|
126
|
+
src, gone = e.get("source"), e.get("source_unavailable")
|
|
127
|
+
if src is not None and gone is not None:
|
|
128
|
+
raise SystemExit(f"{e['id']}: has BOTH source and source_unavailable — pick one")
|
|
129
|
+
if e["status"] in ("default", "backup") and src is None and gone is None:
|
|
130
|
+
raise SystemExit(
|
|
131
|
+
f"{e['id']}: status {e['status']!r} but no source and no source_unavailable. An enabled "
|
|
132
|
+
f"model with no pinned source is in the catalog, offered by routing, and unservable — "
|
|
133
|
+
f"that was true of 25 of 29 enabled models on 2026-10-01. Pin a source, or say in "
|
|
134
|
+
f"`source_unavailable` why there is none.")
|
|
135
|
+
if src is None:
|
|
136
|
+
return
|
|
137
|
+
if not isinstance(src, dict) or not src:
|
|
138
|
+
raise SystemExit(f"{e['id']}: source must be a non-empty mapping")
|
|
139
|
+
unknown = set(src) - SOURCE_KEYS
|
|
140
|
+
if unknown:
|
|
141
|
+
raise SystemExit(f"{e['id']}: unknown source key(s) {sorted(unknown)}; "
|
|
142
|
+
f"known: {', '.join(sorted(SOURCE_KEYS))}")
|
|
143
|
+
if not any(src.get(k) for k in ("hf_repo", "ollama_tag", "gguf_repo")):
|
|
144
|
+
raise SystemExit(f"{e['id']}: source names no place to get the weights "
|
|
145
|
+
f"(needs hf_repo, ollama_tag or gguf_repo)")
|
|
146
|
+
for repo_key, rev_key in (("hf_repo", "revision"), ("gguf_repo", "gguf_revision")):
|
|
147
|
+
if src.get(repo_key) and not src.get(rev_key):
|
|
148
|
+
raise SystemExit(f"{e['id']}: {repo_key} without {rev_key} — an unpinned repo serves "
|
|
149
|
+
f"whatever `main` is today")
|
|
150
|
+
rev = src.get(rev_key)
|
|
151
|
+
if rev and not _re.fullmatch(r"[0-9a-f]{40}", str(rev)):
|
|
152
|
+
raise SystemExit(f"{e['id']}: {rev_key} {rev!r} is not a full 40-character sha. A tag or "
|
|
153
|
+
f"a branch name is not a pin.")
|
|
154
|
+
if src.get("ollama_tag") and not str(src.get("ollama_digest") or "").startswith("sha256:"):
|
|
155
|
+
raise SystemExit(f"{e['id']}: ollama_tag without a sha256 ollama_digest — a tag is "
|
|
156
|
+
f"republished, so the tag alone does not name what was tested")
|
|
157
|
+
if "gated" in src and not isinstance(src["gated"], bool):
|
|
158
|
+
raise SystemExit(f"{e['id']}: source.gated must be true/false")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _check_speed(e: dict) -> None:
|
|
162
|
+
"""Validate 11-B2's `speed` block. Absent is fine; wrong is not.
|
|
163
|
+
|
|
164
|
+
The two rules that matter are the ones a typo would otherwise turn into silent slowness: a
|
|
165
|
+
method this stack cannot spell does nothing at all (the flag is ignored by the engine, and the
|
|
166
|
+
pod serves at 1x while the registry claims 2-3x), and `spec_decode: draft` without a
|
|
167
|
+
`draft_model` is a configuration that cannot start.
|
|
168
|
+
"""
|
|
169
|
+
sp = e.get("speed")
|
|
170
|
+
if sp is None:
|
|
171
|
+
return
|
|
172
|
+
if not isinstance(sp, dict) or any(k not in ("spec_decode", "draft_model", "K") for k in sp):
|
|
173
|
+
raise SystemExit(f"{e['id']}: speed must be {{spec_decode, draft_model?, K?}}")
|
|
174
|
+
method = sp.get("spec_decode", "none")
|
|
175
|
+
if method not in SPEC_DECODE:
|
|
176
|
+
raise SystemExit(f"{e['id']}: unknown spec_decode {method!r}; "
|
|
177
|
+
f"known: {', '.join(sorted(SPEC_DECODE))}")
|
|
178
|
+
if method == "draft" and not str(sp.get("draft_model") or "").strip():
|
|
179
|
+
raise SystemExit(f"{e['id']}: spec_decode 'draft' needs a draft_model — a draft method with "
|
|
180
|
+
f"nothing to draft with cannot start")
|
|
181
|
+
if sp.get("draft_model") and method in ("none", "mtp"):
|
|
182
|
+
# `mtp` predicts from the target's own extra heads and `none` predicts nothing, so neither
|
|
183
|
+
# has anything to point a draft model at. An ignored field reads as a promise.
|
|
184
|
+
raise SystemExit(f"{e['id']}: draft_model is meaningless with spec_decode {method!r}")
|
|
185
|
+
#: THE HEAD PATH FOR EAGLE3 IS NOT IN THIS FILE, ON PURPOSE. SGLang wants
|
|
186
|
+
#: `--speculative-draft-model-path <head repo>`, and the head is a separate artefact published
|
|
187
|
+
#: per target model. Guessing a repo name here would produce a pod that fails to start (or worse,
|
|
188
|
+
#: one that starts with the wrong head), so `serving.py` reads it from configuration and leaves
|
|
189
|
+
#: speculative decoding OFF, loudly, until it is set. `speed.draft_model` may still name it when
|
|
190
|
+
#: it is known.
|
|
191
|
+
k = sp.get("K", DEFAULT_SPEC_K)
|
|
192
|
+
if not isinstance(k, int) or not 1 <= k <= 16:
|
|
193
|
+
raise SystemExit(f"{e['id']}: speed.K must be an int in 1..16 (11-B2 measures 7 as optimal)")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
#: The owner's at-your-own-risk flag (2026-10-13). Three facts, and the SENTENCE is composed here
|
|
197
|
+
#: rather than written per model: the catalog listing, the artifact and the governance record must
|
|
198
|
+
#: all say the same thing, and three surfaces each building their own string is how the music roster
|
|
199
|
+
#: and the licence-gate variable name came apart.
|
|
200
|
+
ORIGIN_RISK_KEYS = {"origin", "name", "reason"}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _check_origin_risk(e: dict) -> None:
|
|
204
|
+
r = e.get("origin_risk")
|
|
205
|
+
if r is None:
|
|
206
|
+
return
|
|
207
|
+
if not isinstance(r, dict):
|
|
208
|
+
raise SystemExit(f"{e['id']}: origin_risk must be a mapping")
|
|
209
|
+
missing = ORIGIN_RISK_KEYS - set(r)
|
|
210
|
+
if missing:
|
|
211
|
+
raise SystemExit(f"{e['id']}: origin_risk is missing {sorted(missing)}")
|
|
212
|
+
extra = set(r) - ORIGIN_RISK_KEYS
|
|
213
|
+
if extra:
|
|
214
|
+
# A field nobody composes is a field nobody renders. Refused rather than carried.
|
|
215
|
+
raise SystemExit(f"{e['id']}: origin_risk has unknown key(s) {sorted(extra)}")
|
|
216
|
+
if r["origin"] not in BLOCS:
|
|
217
|
+
raise SystemExit(f"{e['id']}: origin_risk.origin {r['origin']!r} is not a known bloc")
|
|
218
|
+
for k in ("name", "reason"):
|
|
219
|
+
if not str(r[k]).strip():
|
|
220
|
+
raise SystemExit(f"{e['id']}: origin_risk.{k} is empty")
|
|
221
|
+
# The risk is ABOUT the model's own origin; a flag naming a different bloc from `bloc` would
|
|
222
|
+
# make the two fields disagree about where the model comes from.
|
|
223
|
+
if r["origin"] != e["bloc"]:
|
|
224
|
+
raise SystemExit(
|
|
225
|
+
f"{e['id']}: origin_risk.origin {r['origin']!r} != bloc {e['bloc']!r}")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def origin_disclosure(risk: dict) -> str:
|
|
229
|
+
"""THE one sentence, in the owner's words (2026-10-13).
|
|
230
|
+
|
|
231
|
+
Composed from the row's facts so a second flagged model gets the same shape automatically, and
|
|
232
|
+
so no surface can paraphrase it. The em dash is the owner's.
|
|
233
|
+
"""
|
|
234
|
+
return f"Generated with {risk['name']} ({risk['reason']}) — use at your own risk"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def emit(entries: list[dict]) -> None:
|
|
238
|
+
canon = [
|
|
239
|
+
{
|
|
240
|
+
"id": e["id"], "bloc": e["bloc"], "tier": e["tier"], "license": e["license"],
|
|
241
|
+
"license_class": e["license_class"], "territory": e["territory"] or {},
|
|
242
|
+
"local_class": e["local_class"], "status": e["status"],
|
|
243
|
+
"runtime": list(e["runtime"] or []), "vram_gb": e["vram_gb"],
|
|
244
|
+
**({"vendor": e["vendor"]} if e.get("vendor") else {}),
|
|
245
|
+
# Emitted only when present, so the 96 models without a head do not each carry a
|
|
246
|
+
# `speed: {spec_decode: none}` that says nothing. `speed_of()` supplies the default.
|
|
247
|
+
**({"speed": {k: e["speed"][k] for k in sorted(e["speed"])}} if e.get("speed") else {}),
|
|
248
|
+
# 28 §B — the PINNED source travels with the model, so a consumer resolving weights reads
|
|
249
|
+
# the same fact the catalog was validated against instead of its own table. `serving.py`
|
|
250
|
+
# kept its own, and 25 of 29 enabled models were missing from it.
|
|
251
|
+
**({"source": {k: e["source"][k] for k in sorted(e["source"])}} if e.get("source") else {}),
|
|
252
|
+
**({"source_unavailable": " ".join(str(e["source_unavailable"]).split())}
|
|
253
|
+
if e.get("source_unavailable") else {}),
|
|
254
|
+
**({"source_notes": " ".join(str(e["source_notes"]).split())}
|
|
255
|
+
if e.get("source_notes") else {}),
|
|
256
|
+
# The flag, WITH the composed sentence. Consumers render `disclosure` verbatim rather
|
|
257
|
+
# than rebuilding it — the `edit_notice` arrangement: the thing that knows supplies the
|
|
258
|
+
# words, and the surface that shows them does not get to reword them.
|
|
259
|
+
**({"origin_risk": {**{k: e["origin_risk"][k] for k in sorted(e["origin_risk"])},
|
|
260
|
+
"disclosure": origin_disclosure(e["origin_risk"])}}
|
|
261
|
+
if e.get("origin_risk") else {}),
|
|
262
|
+
}
|
|
263
|
+
for e in entries
|
|
264
|
+
]
|
|
265
|
+
blob = json.dumps(canon, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
266
|
+
JSON_PATH.write_text(blob, encoding="utf-8")
|
|
267
|
+
|
|
268
|
+
PY_PATH.write_text(
|
|
269
|
+
'"""GENERATED by frutebox/registry/generate.py from registry/models.yaml — DO NOT EDIT."""\n'
|
|
270
|
+
"REGISTRY: list[dict] = " + repr(canon) + "\n"
|
|
271
|
+
"BY_ID: dict[str, dict] = {m['id']: m for m in REGISTRY}\n",
|
|
272
|
+
encoding="utf-8",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
TS_PATH.write_text(
|
|
276
|
+
"// GENERATED by frutebox/registry/generate.py from frutebox/registry/models.yaml — DO NOT EDIT.\n"
|
|
277
|
+
"export type RegistryEntry = {\n"
|
|
278
|
+
" id: string; bloc: string; tier: string; license: string; license_class: string;\n"
|
|
279
|
+
" territory: { deny?: string[]; allow_only?: string[] };\n"
|
|
280
|
+
" local_class: \"L\" | \"W\" | \"S\" | null; status: string; runtime: string[];\n"
|
|
281
|
+
" vram_gb: number; vendor?: string;\n"
|
|
282
|
+
" speed?: { spec_decode: string; draft_model?: string; K?: number };\n"
|
|
283
|
+
# 28 §B — the pinned source. All optional: a model may have one, may say why it has none, and
|
|
284
|
+
# `source_notes` records a problem that is neither (a wrong runtime declaration). Emitting the
|
|
285
|
+
# DATA without extending this type is what failed the web typecheck — the generator writes both
|
|
286
|
+
# halves, so they can only drift if one is forgotten.
|
|
287
|
+
" source?: {\n"
|
|
288
|
+
" hf_repo?: string; revision?: string;\n"
|
|
289
|
+
" ollama_tag?: string; ollama_digest?: string;\n"
|
|
290
|
+
" gguf_repo?: string; gguf_revision?: string; gguf_quant?: string;\n"
|
|
291
|
+
" gated?: boolean;\n"
|
|
292
|
+
" };\n"
|
|
293
|
+
" source_unavailable?: string;\n"
|
|
294
|
+
" source_notes?: string;\n"
|
|
295
|
+
# The owner's at-your-own-risk flag. `disclosure` is composed by the generator so every
|
|
296
|
+
# surface renders one string; emitting the DATA without extending this type is what failed
|
|
297
|
+
# the web typecheck when `source` was added.
|
|
298
|
+
" origin_risk?: {\n"
|
|
299
|
+
" origin: string; name: string; reason: string; disclosure: string;\n"
|
|
300
|
+
" };\n"
|
|
301
|
+
"};\n"
|
|
302
|
+
"export const MODEL_REGISTRY: RegistryEntry[] = " + blob.rstrip("\n") + ";\n"
|
|
303
|
+
"export const REGISTRY_BY_ID: Record<string, RegistryEntry> =\n"
|
|
304
|
+
" Object.fromEntries(MODEL_REGISTRY.map((m) => [m.id, m]));\n",
|
|
305
|
+
encoding="utf-8",
|
|
306
|
+
)
|
|
307
|
+
print(f"registry: {len(canon)} models → {JSON_PATH.name}, {PY_PATH.name}, {TS_PATH.name}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
emit(load())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""28 §A9 — provider registry codegen: providers.yaml → a TS artifact the web app can read.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS AT ALL
|
|
5
|
+
`Provider` rows in Neon are what `ProviderCredential.providerId` points at. Until 2026-09-22
|
|
6
|
+
**nothing in the codebase had ever written one** — no upsert, no migration, no seed. The rows that
|
|
7
|
+
existed were created by hand, so `providers.yaml` and the table could drift indefinitely, and the
|
|
8
|
+
only symptom was a 500 with an empty body (`P2003`, foreign key violated) when someone tried to
|
|
9
|
+
store a credential for a provider the table had never heard of. That is how `lambda` failed.
|
|
10
|
+
|
|
11
|
+
WHY CODEGEN RATHER THAN READING THE YAML AT RUNTIME
|
|
12
|
+
The web image is built with `web/` as its Docker context, so `frutebox/registry/providers.yaml` is
|
|
13
|
+
not in the container and cannot be read there. The seeder runs inside the web service because that
|
|
14
|
+
is the only service with `DATABASE_URL`. So the YAML has to travel as generated code — the same
|
|
15
|
+
route `models.yaml` already takes (`generate.py` → `model-registry.gen.ts`).
|
|
16
|
+
|
|
17
|
+
DELIBERATELY NOT EMITTED
|
|
18
|
+
* `env` — the credential NAMES. The table has no column for them; they stay in the YAML, which
|
|
19
|
+
is what `frutebox/providers.py` reads.
|
|
20
|
+
* `derived` — A TRAP, and the reason this is spelled out. In the YAML `derived` is a list of
|
|
21
|
+
NAMES TO DISCOVER from a credential (§A5: "the owner provides the credential; the code
|
|
22
|
+
discovers the rest"). In the database, `Provider.derivedJson` holds the DISCOVERED VALUES.
|
|
23
|
+
They are not the same thing in either direction, and syncing the first into the second would
|
|
24
|
+
overwrite real discovered ids with a list of labels.
|
|
25
|
+
|
|
26
|
+
Run from the repo root: python -m frutebox.registry.generate_providers
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import pathlib
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
36
|
+
YAML_PATH = ROOT / "frutebox" / "registry" / "providers.yaml"
|
|
37
|
+
TS_PATH = ROOT / "web" / "src" / "lib" / "provider-registry.gen.ts"
|
|
38
|
+
|
|
39
|
+
CLASSES = {"gpu", "compute", "tee", "weights"}
|
|
40
|
+
#: `being_replaced` added 2026-10-16 (owner decision: Verda + Nebius replace RunPod). It describes
|
|
41
|
+
#: a provider's ROLE, which is what this field is for — the same reason vast keeps `status: backup`
|
|
42
|
+
#: while being `enabled: true`. It is deliberately NOT `disabled`: RunPod is still serving the EU
|
|
43
|
+
#: media pods and is still the only funded fallback, so a status that read as "off" would be a
|
|
44
|
+
#: lie on the way to becoming a self-fulfilling one.
|
|
45
|
+
#:
|
|
46
|
+
#: SAFE TO ADD because nothing branches on this value: the column is `String` (no Prisma enum, no
|
|
47
|
+
#: migration), the generated TS types it as `string`, and `provider-seed.ts` — the only consumer of
|
|
48
|
+
#: PROVIDER_REGISTRY — copies it through. Checked 2026-10-16, with the search proven on a
|
|
49
|
+
#: known-present pattern first.
|
|
50
|
+
#:
|
|
51
|
+
#: ⚠️ FLAGGED FOR THE WEB LANE, not changed here: the doc comment on `Provider.status` in
|
|
52
|
+
#: `web/prisma/schema.prisma:2972` still reads "active | backup | emerging | disabled" and is now
|
|
53
|
+
#: one value short. It is a comment, not a constraint, and that file is not this lane's.
|
|
54
|
+
STATUSES = {"active", "backup", "emerging", "disabled", "being_replaced"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load() -> list[dict]:
|
|
58
|
+
doc = yaml.safe_load(YAML_PATH.read_text(encoding="utf-8")) or {}
|
|
59
|
+
rows = doc.get("providers") or []
|
|
60
|
+
if not isinstance(rows, list) or not rows:
|
|
61
|
+
raise SystemExit("providers.yaml: expected a non-empty `providers:` list")
|
|
62
|
+
|
|
63
|
+
held = set(doc.get("on_hold") or [])
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
out = []
|
|
66
|
+
for r in rows:
|
|
67
|
+
pid = str(r.get("id") or "")
|
|
68
|
+
if not pid:
|
|
69
|
+
raise SystemExit("a provider row has no id")
|
|
70
|
+
if pid in seen:
|
|
71
|
+
raise SystemExit(f"duplicate provider id {pid}")
|
|
72
|
+
seen.add(pid)
|
|
73
|
+
# An on-hold provider must not be a row here either: `test_on_hold_providers_are_not_
|
|
74
|
+
# rows_at_all` requires their ABSENCE so they cannot be enabled by flipping a flag, and
|
|
75
|
+
# generating one would smuggle it into the database by the back door.
|
|
76
|
+
if pid in held:
|
|
77
|
+
raise SystemExit(f"{pid} is in on_hold AND has a provider row — one of the two is wrong")
|
|
78
|
+
cls = str(r.get("class", "gpu"))
|
|
79
|
+
if cls not in CLASSES:
|
|
80
|
+
raise SystemExit(f"{pid}: unknown class {cls}")
|
|
81
|
+
status = str(r.get("status", "backup"))
|
|
82
|
+
if status not in STATUSES:
|
|
83
|
+
raise SystemExit(f"{pid}: unknown status {status}")
|
|
84
|
+
out.append({
|
|
85
|
+
"id": pid,
|
|
86
|
+
"cls": cls,
|
|
87
|
+
"status": status,
|
|
88
|
+
"enabled": bool(r.get("enabled", False)),
|
|
89
|
+
"phase": int(r.get("phase", 2)),
|
|
90
|
+
"hqCountry": str(r.get("hq_country", "") or ""),
|
|
91
|
+
"regions": [str(x) for x in (r.get("regions") or [])],
|
|
92
|
+
"apiBase": str(r.get("api_base", "") or ""),
|
|
93
|
+
"capabilities": r.get("capabilities") or {},
|
|
94
|
+
"notes": str(r.get("notes", "") or "").strip(),
|
|
95
|
+
})
|
|
96
|
+
return sorted(out, key=lambda x: x["id"])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def emit(rows: list[dict]) -> None:
|
|
100
|
+
blob = json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False)
|
|
101
|
+
TS_PATH.write_text(
|
|
102
|
+
"// GENERATED by frutebox/registry/generate_providers.py from\n"
|
|
103
|
+
"// frutebox/registry/providers.yaml — DO NOT EDIT.\n"
|
|
104
|
+
"//\n"
|
|
105
|
+
"// The shape the seeder writes into the `Provider` table. `env` and `derived` are\n"
|
|
106
|
+
"// deliberately absent: the table has no column for credential names, and the YAML's\n"
|
|
107
|
+
"// `derived` is a list of names to DISCOVER while the column holds DISCOVERED VALUES.\n"
|
|
108
|
+
"export type ProviderSeedRow = {\n"
|
|
109
|
+
" id: string; cls: string; status: string; enabled: boolean; phase: number;\n"
|
|
110
|
+
" hqCountry: string; regions: string[]; apiBase: string;\n"
|
|
111
|
+
" capabilities: Record<string, unknown>; notes: string;\n"
|
|
112
|
+
"};\n"
|
|
113
|
+
"export const PROVIDER_REGISTRY: ProviderSeedRow[] = " + blob + ";\n",
|
|
114
|
+
encoding="utf-8",
|
|
115
|
+
)
|
|
116
|
+
print(f"providers: {len(rows)} → {TS_PATH.relative_to(ROOT)}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
emit(load())
|