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,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())