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,603 @@
1
+ """FILE 23-B1 — the MEDIA serving resolver: which commercial-safe model serves a media request.
2
+
3
+ `serving.py` answers this for TEXT models. Nothing answered it for media, and the three
4
+ generator modules (`imagegen`, `videogen`, `audiogen`) each carried their own hardcoded
5
+ Hugging Face default instead. That gap was not cosmetic — it put a NON-COMMERCIAL model in
6
+ production as a live default:
7
+
8
+ * `audiogen._MUSIC_MODEL` defaulted to `facebook/musicgen-small`. MusicGen is CC-BY-NC 4.0.
9
+ Plan 23 §A names it as one of the five exclusions and §D4 says the file-09 licence CI is
10
+ the backstop that makes shipping one impossible. The backstop could not see it, because
11
+ `registry.landmines_referenced()` matches REGISTRY IDS (`musicgen`) and the serving
12
+ surface speaks WEIGHTS REFS (`facebook/musicgen-small`). Two vocabularies, one gate, and
13
+ the gate was reading the wrong one. `landmines_referenced(['facebook/musicgen-small'])`
14
+ returned `[]` on a clean tree.
15
+ * `audiogen._SPEECH_MODEL` defaulted to `suno/bark-small` and `videogen._MODEL` to
16
+ `THUDM/CogVideoX-2b` — neither model is DECLARED in `registry/models.yaml` at all, so
17
+ neither passed through any licence, territory or tier gate on its way to a GPU.
18
+ * `audiogen._CLONE_MODEL` defaulted to `resemble-ai/chatterbox`, which does not exist
19
+ (HTTP 401). The real repo is `ResembleAI/chatterbox`. The voice-cloning path could never
20
+ have fetched its model.
21
+
22
+ So the rule this module enforces, from §B1 ("Declared per model in `models.yaml` — file 09
23
+ `runtime`, `vram_gb`, `modality`"):
24
+
25
+ **A media job may only ever run weights that belong to a registry-DECLARED model which
26
+ `registry.serving_allowed()` passes.** An undeclared ref is refused for being undeclared,
27
+ which catches a landmine without needing to have predicted that particular landmine.
28
+
29
+ WHERE THE REPOS AND REVISIONS COME FROM — not guessed, MEASURED.
30
+ Plan 28 §B3, quoted in `scripts/resolve_weights.py`, is explicit: *"If a model's exact
31
+ repo/revision cannot be resolved and license-verified, set `status: verify` and raise it as
32
+ DECISION REQUIRED, never guess."* Every entry in `WEIGHTS` below was read from the live
33
+ Hugging Face API on **2026-09-28**, with its commit SHA pinned and its card licence compared
34
+ against the registry's declared one. A model whose repo could not be resolved that way has NO
35
+ entry, and `resolve_media_model` then refuses it by name rather than inventing a path — see
36
+ `UNRESOLVED` for the three that are in that position and why.
37
+
38
+ `models.yaml` carries no `weights:` blocks yet (that is plan 28 §B2/B3, another lane), so the
39
+ pins live here, next to the code that loads them, until that block exists to move them into.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ from dataclasses import dataclass
44
+
45
+ from .envvars import env as envvar
46
+ from .registry import serving_allowed, territory_allows
47
+ from .registry_gen import BY_ID, REGISTRY
48
+
49
+ #: The registry tiers that describe a media modality (`models.yaml` field notes: "sound-effects
50
+ #: models ride the music tier"). `voice` is the realtime duplex tier and belongs to file 07, not
51
+ #: to a generate-an-artifact request, so it is deliberately not here.
52
+ MEDIA_TIERS: tuple[str, ...] = ("image", "video", "music", "tts", "stt")
53
+
54
+ #: A request kind (the API's vocabulary) -> the registry tier that serves it. `/v1/audio/generations`
55
+ #: takes kind=music|song|sfx; all three are music-tier models.
56
+ #: An API `modality` -> the media KIND that serves it.
57
+ #:
58
+ #: Two vocabularies meet here and neither is wrong: the API speaks in modalities (what the
59
+ #: caller asked for) and this module speaks in kinds (what gets generated). `audio` is the one
60
+ #: that is genuinely ambiguous — a request for audio is a request for SPEECH unless it says
61
+ #: otherwise — and deciding it once here is better than each caller guessing.
62
+ KIND_FOR_MODALITY: dict[str, str] = {
63
+ "image": "image",
64
+ "video": "video",
65
+ "audio": "speech",
66
+ }
67
+
68
+ KIND_TIER: dict[str, str] = {
69
+ "image": "image",
70
+ "video": "video",
71
+ "music": "music",
72
+ "song": "music",
73
+ "sfx": "music",
74
+ "speech": "tts",
75
+ "clone": "tts",
76
+ "transcription": "stt",
77
+ }
78
+
79
+ #: Per-kind preference order, transcribed from plan 23 §A's table (primary first, then its named
80
+ #: backups). The resolver walks this list and takes the first model that passes every gate, so a
81
+ #: licence env that is not signed, or a geo-fence, demotes to the next PLAN-SANCTIONED model rather
82
+ #: than to whatever else happens to share the tier.
83
+ KIND_PREFERENCE: dict[str, tuple[str, ...]] = {
84
+ # §A: primary Qwen-Image; backups Chroma / FLUX.2 [klein] (neither is in the registry yet),
85
+ # so SDXL — declared, open-commercial, status default — is the standing second choice.
86
+ "image": ("qwen-image-20b", "sdxl-1.0"),
87
+ # §A: primary Wan 2.2; backups Mochi-1, CogVideoX-2B; LTX-2 only under its revenue cap.
88
+ "video": ("wan-2.2", "mochi-1", "ltx-2.3"),
89
+ # §A: short instrumental -> ACE-Step.
90
+ "music": ("ace-step-1.5",),
91
+ # §A: full song with vocals -> YuE 7B, and the backup column is literally "—".
92
+ # ACE-Step is NOT a fallback here, for the same reason it is not one for sfx: it is an
93
+ # INSTRUMENTAL model, and answering "a song with vocals" with a vocal-less track is the
94
+ # quality-for-availability trade §B3 forbids. Listing it looked harmless while nothing
95
+ # constrained VRAM; once `max_vram_gb` was honoured it meant every card below YuE's 16 GB
96
+ # silently got an instrumental instead of a song.
97
+ "song": ("yue-7b",),
98
+ # §A: SFX/ambience -> Stable Audio Open, with MOSS-SoundEffect as the Apache alternative for
99
+ # when the Stability revenue-threshold licence is not signed. ACE-Step is deliberately NOT in
100
+ # this chain: it is a MUSIC model, and answering "a door creaking" with a short instrumental is
101
+ # trading quality for availability, which §B3 forbids outright. An sfx request with no sfx
102
+ # model refuses and says so.
103
+ "sfx": ("stable-audio-open-1.5", "moss-soundeffect"),
104
+ # §A: local-first TTS primary Kokoro-82M; backups Piper (CPU, not a GPU-node model), Chatterbox
105
+ # (cloning), Orpheus.
106
+ "speech": ("kokoro-82m", "chatterbox", "orpheus"),
107
+ # Cloning is a capability, not a tier: only Chatterbox does zero-shot voice cloning on a
108
+ # commercial licence. XTTS-v2 clones better and is CC-BY-NC — excluded on purpose.
109
+ "clone": ("chatterbox",),
110
+ "transcription": ("whisper-large-v3-turbo",),
111
+ }
112
+
113
+ #: Tier B is the 8-16 GB consumer/contributor band, Tier A the 16 GB+ datacentre band. §B2: "The
114
+ #: router never offers a video/music job to a Tier-C/edge node — Tier B/A GPUs only (8-16 GB+)."
115
+ #: Tier C therefore never appears as a value here, and `PLACEMENT_TIERS` is what a caller checks a
116
+ #: node against.
117
+ PLACEMENT_TIERS: frozenset = frozenset({"A", "B"})
118
+ _TIER_B_MAX_VRAM_GB = 16.0
119
+ #: §B2 puts the media floor at "Tier B/A GPUs only (8-16 GB+)" — below this a node is edge/Tier C.
120
+ _TIER_B_MIN_VRAM_GB = 8.0
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class Weights:
125
+ """A pinned download source. `revision` is a commit SHA, never a branch — a moving
126
+ reference would let the weights under a shipped model change with no change to this repo."""
127
+ repo: str
128
+ revision: str
129
+ source: str # how this repo was established, so the next reader need not re-derive it
130
+ stage2_repo: str = "" # YuE is a two-stage model; stage 2 is required for a complete song
131
+ stage2_revision: str = ""
132
+
133
+
134
+ #: registry id -> pinned weights. Read from the Hugging Face API 2026-09-28; `source` records how
135
+ #: the repo was picked where the org held more than one candidate.
136
+ WEIGHTS: dict[str, Weights] = {
137
+ "qwen-image-20b": Weights(
138
+ "Qwen/Qwen-Image", "75e0b4be04f60ec59a75f475837eced720f823b6",
139
+ "Qwen/ holds Qwen-Image plus -Edit/-Layered/-PE-* variants; those are different TASKS "
140
+ "(editing, layering, prompt-expansion), and -2.1/-2512 are later generations than the "
141
+ "registry's `qwen-image-20b`. The base text-to-image 20B model is Qwen-Image. "
142
+ "Card licence apache-2.0 == registry declaration.",
143
+ ),
144
+ "flux2-klein-4b": Weights(
145
+ "black-forest-labs/FLUX.2-klein-4B", "e7b7dc27f91deacad38e78976d1f2b499d76a294",
146
+ "RESOLVED AND LICENCE-VERIFIED 2026-10-12, and it is the only EU-published image model "
147
+ "that can be. black-forest-labs/ holds 38 repos: the owner's instruction is \"FLUX.2 "
148
+ "[klein] 4B only — the 9B is non-commercial — exclude it explicitly\", and the live cards "
149
+ "say exactly that. klein-4B: `license: apache-2.0`, `gated: false`, and its LICENSE.md is "
150
+ "the verbatim Apache 2.0 text with no non-commercial clause (checked, not inferred from "
151
+ "the tag). klein-9B: `license: other`, `license_name: flux-non-commercial-license`, "
152
+ "`gated: auto` — excluded, and it is the registry's `flux2-klein-9b` at status `verify`. "
153
+ "The -fp8/-nvfp4/-base/-kv variants are quantisations and base (non-instruct) builds of "
154
+ "the same two; `flux1-schnell` stays UNRESOLVED because its repo is gated. "
155
+ "STILL `emerging` IN THE REGISTRY, DELIBERATELY: a pin and a licence are not a measured "
156
+ "load. `AutoPipelineForText2Image` picks `Flux2KleinPipeline` from the repo's "
157
+ "`model_index.json` and the node installs diffusers unpinned, so it SHOULD load — and "
158
+ "'should' is not what `status: default` claims. Promoting it needs one GPU load on a "
159
+ "frutebox-backend-* pod, which is owner-authorised spend.",
160
+ ),
161
+ "sdxl-1.0": Weights(
162
+ "stabilityai/stable-diffusion-xl-base-1.0", "462165984030d82259a11f4367a4eed129e94a7b",
163
+ "The ref `imagegen.py` has always shipped, now pinned. Card licence openrail++ == the "
164
+ "registry's `openrail++-m` / open_commercial.",
165
+ ),
166
+ "wan-2.2": Weights(
167
+ "Wan-AI/Wan2.2-T2V-A14B-Diffusers", "5be7df9619b54f4e2667b2755bc6a756675b5cd7",
168
+ "Wan-AI/ holds T2V / I2V / TI2V / S2V / Animate builds. `/v1/videos` is TEXT-to-video, so "
169
+ "T2V — the others need an input image or audio and would reject the request. The "
170
+ "-Diffusers build is the one §B1's named serving library can load. Card apache-2.0 == "
171
+ "registry declaration.",
172
+ ),
173
+ "mochi-1": Weights(
174
+ "genmo/mochi-1-preview", "14be5fcea23095ed330cb214647916a451e38b6e",
175
+ "genmo/ publishes exactly one model — unambiguous. Card apache-2.0 == registry.",
176
+ ),
177
+ "ace-step-1.5": Weights(
178
+ "ACE-Step/Ace-Step1.5", "19671f406d603126926c1b7e2adc169acbcade22",
179
+ "Resolved by scripts/resolve_weights.py against the §B3 org table (ace-step -> ACE-Step). "
180
+ "NOTE: the card says `mit` where the registry declares `apache-2.0`. Both are the "
181
+ "apache_mit licence CLASS, so the gate's verdict is unaffected, but the declared string "
182
+ "is wrong — reported for file 09.",
183
+ ),
184
+ "yue-7b": Weights(
185
+ "m-a-p/YuE-s1-7B-anneal-en-cot", "454c20e1748888800f8e4b3da45125f55482d967",
186
+ "m-a-p/ holds YuE s1 in 0.5B and 7B, the 7B in en/jp-kr/zh x cot/icl. The variant is not "
187
+ "this lane's choice: `web/src/lib/audio/music-models.ts` already committed the label "
188
+ "'YuE 7B (anneal-en-cot)'. Card apache-2.0 == registry.",
189
+ stage2_repo="m-a-p/YuE-s2-1B-general",
190
+ stage2_revision="9dfa90b7013f6b5e7eb5eb2991620dca33058a0e",
191
+ ),
192
+ "kokoro-82m": Weights(
193
+ "hexgrad/Kokoro-82M", "f3ff3571791e39611d31c381e3a41a3af07b4987",
194
+ "Resolved by scripts/resolve_weights.py against the §B3 org table (kokoro -> hexgrad). "
195
+ "Card apache-2.0 == registry.",
196
+ ),
197
+ "chatterbox": Weights(
198
+ "ResembleAI/chatterbox", "5bb1f6ee58e50c3b8d408bc82a6d3740c2db6e18",
199
+ "CORRECTS the ref `audiogen.py` shipped. `resemble-ai/chatterbox` returns HTTP 401 — the "
200
+ "org is `ResembleAI`. The cloning path could never have fetched its model. Card mit == "
201
+ "registry.",
202
+ ),
203
+ "whisper-large-v3-turbo": Weights(
204
+ "openai/whisper-large-v3-turbo", "41f01f3fe87f28c78e2fbf8b568835947dd65ed9",
205
+ "Resolved by scripts/resolve_weights.py (§B3 org table: whisper -> openai); the repo name "
206
+ "is the model id. Card mit == registry.",
207
+ ),
208
+ }
209
+
210
+ #: Declared media models with NO resolvable pinned source, and the reason. §B3's rule is to raise
211
+ #: these rather than guess, so `resolve_media_model` skips them and says why. Each is build work
212
+ #: for file 09/28 (a `weights:` block or a corrected id), not an owner action.
213
+ UNRESOLVED: dict[str, str] = {
214
+ "stable-audio-open-1.5": (
215
+ "stabilityai/ publishes stable-audio-open-1.0 and -open-small, and stable-audio-3-*. "
216
+ "There is NO stable-audio-open-1.5. The registry id names a version that does not exist, "
217
+ "so the sfx primary cannot be fetched. (1.0 is also gated=auto, license `other` — the "
218
+ "Stability Community revenue threshold plan 23 §A flags 'verify at build'.)"
219
+ ),
220
+ "flux1-schnell": (
221
+ "black-forest-labs/FLUX.1-schnell resolves but is GATED — it needs an accepted licence on "
222
+ "a Hugging Face account before the weights can be fetched. Registry status is `emerging` "
223
+ "so it is not offered anyway."
224
+ ),
225
+ "orpheus": "No publisher org in the §B3 table and no unambiguous repo; registry status `emerging`.",
226
+ "moss-soundeffect": "No publisher org in the §B3 table; registry status `emerging`, runtime [].",
227
+ "auraflow": "No publisher org in the §B3 table; registry status `emerging`.",
228
+ "lumina-image-2": "No publisher org in the §B3 table; registry status `emerging`.",
229
+ "hidream-i1": "No publisher org in the §B3 table; registry status `emerging`.",
230
+ "hunyuan-image": "No publisher org in the §B3 table; geo-fenced backup.",
231
+ "hunyuan-video-1.5": "No publisher org in the §B3 table; geo-fenced backup.",
232
+ "ltx-2.3": "No publisher org in the §B3 table; paid_commercial backup behind LIGHTRICKS env.",
233
+ "stable-video": "No publisher org in the §B3 table; paid_commercial backup behind STABILITY env.",
234
+ "sd-3.5-large": "No publisher org in the §B3 table; paid_commercial backup behind STABILITY env.",
235
+ "open-sora": "No publisher org in the §B3 table; registry status `emerging`.",
236
+ "xtts-v2": "NEVER resolved on purpose — CC-BY-NC (coqui-cpml). Declared so the gate can assert it.",
237
+ }
238
+
239
+ #: weights repo (lowercased) -> registry id, so the gate can speak the serving surface's own
240
+ #: vocabulary. A multi-stage model maps BOTH its repos to the one id it is: YuE's stage-2
241
+ #: upsampler is not a second model to declare, it is part of yue-7b's serving recipe the same way
242
+ #: a quant file is part of a GGUF model's, and the licence that governs stage 1 governs it.
243
+ _REF_TO_ID: dict[str, str] = {}
244
+ for _mid, _w in WEIGHTS.items():
245
+ _REF_TO_ID[_w.repo.lower()] = _mid
246
+ if _w.stage2_repo:
247
+ _REF_TO_ID[_w.stage2_repo.lower()] = _mid
248
+ del _mid, _w
249
+
250
+ #: Refs that a previous default put on the serving path and that must never come back. Each maps to
251
+ #: the reason, because "undeclared" understates a CC-BY-NC model.
252
+ RETIRED_REFS: dict[str, str] = {
253
+ "facebook/musicgen-small": "MusicGen is CC-BY-NC 4.0 — plan 23 §A excludes it by name. Use ace-step-1.5.",
254
+ "facebook/musicgen-medium": "MusicGen is CC-BY-NC 4.0 — plan 23 §A excludes it by name. Use ace-step-1.5.",
255
+ "facebook/musicgen-large": "MusicGen is CC-BY-NC 4.0 — plan 23 §A excludes it by name. Use ace-step-1.5.",
256
+ "coqui/xtts-v2": "XTTS-v2 is CPML, non-commercial — plan 23 §A excludes it by name. Use chatterbox.",
257
+ "suno/bark-small": "Bark is not declared in models.yaml, so it passes no licence, territory or tier gate. Use kokoro-82m.",
258
+ "suno/bark": "Bark is not declared in models.yaml, so it passes no licence, territory or tier gate. Use kokoro-82m.",
259
+ "thudm/cogvideox-2b": "Not declared in models.yaml. Plan 23 §A's video primary is wan-2.2.",
260
+ "thudm/cogvideox-5b": "CogVideoX-5B carries a conditional licence plan 23 §A flags; it is not declared. Use wan-2.2.",
261
+ "resemble-ai/chatterbox": "Wrong org — this repo does not exist (HTTP 401). The real one is ResembleAI/chatterbox.",
262
+ "black-forest-labs/flux.1-dev": "FLUX.1 [dev] is non-commercial — plan 23 §A excludes it by name.",
263
+ "black-forest-labs/flux.2-dev": "FLUX.2 [dev] is non-commercial — plan 23 §A excludes it by name.",
264
+ }
265
+
266
+
267
+ class MediaUnavailable(RuntimeError):
268
+ """No commercial-safe, declared, fetchable model serves this request here.
269
+
270
+ Raised with the reason PER CANDIDATE, because "image generation is unavailable" and "the
271
+ licence env for the only model that fits is not signed" need different fixes, and a node
272
+ operator reading a log cannot tell them apart from a single sentence.
273
+ """
274
+
275
+
276
+ @dataclass(frozen=True)
277
+ class MediaSpec:
278
+ """The resolved answer: which model, from where, at what size, on which placement tier."""
279
+ kind: str
280
+ model_id: str
281
+ tier: str # the registry tier (image|video|music|tts|stt)
282
+ repo: str
283
+ revision: str
284
+ vram_gb: float
285
+ runtime: tuple
286
+ placement_tier: str # "A" | "B" — never "C" (§B2)
287
+ local_ok: bool # may run free on a capable device (§B3 local-first)
288
+ stage2_repo: str = ""
289
+ stage2_revision: str = ""
290
+
291
+ @property
292
+ def ref(self) -> str:
293
+ """`repo@revision` — what a node fetches, pinned."""
294
+ return f"{self.repo}@{self.revision}"
295
+
296
+
297
+ @dataclass(frozen=True)
298
+ class Placement:
299
+ """§B3 — where this generation runs, and who pays for it.
300
+
301
+ `metered` and `reward` are SEPARATE fields and they always move together. That is the
302
+ invariant worth having a type for: you cannot charge the user for work no node did, and you
303
+ cannot pay a node for work it did not do. Two booleans that must agree are easier to get
304
+ wrong than one, which is exactly why the test asserts they never disagree rather than
305
+ trusting the two lines below to stay in step.
306
+ """
307
+ where: str # "device" | "node"
308
+ metered: bool # charged against the user's §8A budget
309
+ reward: bool # a node earned something for it
310
+ tier: str # the placement tier a node would need — "A" | "B", never "C"
311
+ reason: str = ""
312
+
313
+ @property
314
+ def free(self) -> bool:
315
+ return not self.metered
316
+
317
+
318
+ def media_placement(spec: "MediaSpec", device_vram_gb: float = 0.0) -> Placement:
319
+ """§B3 — "a capable device generates on-device for free; an incapable one routes to a
320
+ Tier-A/B node, meters on the user's budget, and settles a node reward."
321
+
322
+ CAPABLE IS TWO CONDITIONS, not one. The card has to be big enough AND the model has to be
323
+ one we may run on a contributor's machine at all: `local_ok` is false for models whose
324
+ licence or tier bars them from a device however large its GPU is, and treating VRAM alone
325
+ as capability would put those on the one machine we do not control.
326
+
327
+ A device that can do the work is not metered, and no node is paid, because no node did
328
+ anything. That is the whole of §B3's economics and it is one branch.
329
+ """
330
+ need = float(spec.vram_gb or 0.0)
331
+ have = float(device_vram_gb or 0.0)
332
+
333
+ if spec.local_ok and have and have >= need:
334
+ return Placement(
335
+ "device", metered=False, reward=False, tier=spec.placement_tier,
336
+ reason=(f"the device has {have:g} GB and {spec.model_id} needs {need:g} — "
337
+ f"it runs here, free, and no node is involved"))
338
+
339
+ why = ("this model may not run on a contributor device"
340
+ if not spec.local_ok
341
+ else f"the device has {have:g} GB and {spec.model_id} needs {need:g}")
342
+ return Placement(
343
+ "node", metered=True, reward=True, tier=spec.placement_tier,
344
+ reason=f"{why}; a tier-{spec.placement_tier} node runs it, metered on your budget")
345
+
346
+
347
+ def model_placement_tier(vram_gb: float) -> str:
348
+ """Which tier of node can host this MODEL: "B" for the 8-16 GB consumer band, "A" above it.
349
+
350
+ Never "C". This is a requirement, not a measurement — §B2 keeps media jobs off edge nodes
351
+ entirely, so no model weight yields one, not even the 1 GB TTS model.
352
+ """
353
+ return "B" if float(vram_gb) <= _TIER_B_MAX_VRAM_GB else "A"
354
+
355
+
356
+ def node_tier_for_vram(vram_gb: float) -> str:
357
+ """Which tier THIS NODE is, measured from the card in it.
358
+
359
+ The counterpart to `model_placement_tier`, and deliberately a separate function: one is
360
+ "what does this model need", the other "what is this machine". Collapsing them is how a
361
+ 4 GB laptop GPU would read as Tier B — the model side never returns "C", so the same
362
+ mapping cannot answer both questions.
363
+
364
+ §B2 puts the media floor at 8-16 GB+, so below 8 GB is Tier C / edge.
365
+ """
366
+ v = float(vram_gb)
367
+ if v > _TIER_B_MAX_VRAM_GB:
368
+ return "A"
369
+ if v >= _TIER_B_MIN_VRAM_GB:
370
+ return "B"
371
+ return "C"
372
+
373
+
374
+ def tier_accepts(node_tier: str) -> bool:
375
+ """May a node of this tier be offered a media job at all? (§B2 / the done-criterion
376
+ "refused on a Tier-C/edge node".) Unknown tiers fail closed."""
377
+ return (node_tier or "").strip().upper() in PLACEMENT_TIERS
378
+
379
+
380
+ #: The modalities §B2 keeps off edge nodes — and ONLY these two.
381
+ #:
382
+ #: §B2's sentence is narrow and deliberate: "video and music/SFX are async/batch, NOT in the
383
+ #: token loop … The router never offers a VIDEO/MUSIC job to a Tier-C/edge node." It does not
384
+ #: say image, and it does not say speech. Applying the floor to all four would contradict the
385
+ #: rest of the plan in the same breath: §A calls Kokoro-82M "Apache, local-first", lists Piper as
386
+ #: the CPU backup, and §B3 says "on a capable device … Kokoro, ACE-Step, small image models run
387
+ #: locally" and that this is the FREE path. A 1 GB TTS model barred from a 4 GB laptop by a rule
388
+ #: written for 24 GB video diffusion would delete the plan's own local-first story.
389
+ #:
390
+ #: Image and speech are instead constrained by fit, which the resolver already does properly:
391
+ #: `max_vram_gb` skips any model the card cannot hold, so a small device gets the small model
392
+ #: or an honest refusal — never a job it cannot run.
393
+ EDGE_BARRED_MODALITIES: tuple[str, ...] = ("video",)
394
+
395
+ #: The advertisement vocabulary a node uses (`GEN_MODALITIES`, `output_modalities`). "audio"
396
+ #: covers speech AND music there, and a node advertising it may still serve speech on a small
397
+ #: card, so the floor cannot be applied to the whole label — only `video` is barred outright.
398
+ MEDIA_MODALITIES: tuple[str, ...] = ("image", "audio", "video")
399
+
400
+
401
+ def media_modalities_for_node(advertised, vram_gb: float) -> list:
402
+ """Filter a node's advertised modalities to what §B2 permits on a card this size.
403
+
404
+ A node that advertises a modality it must then refuse is capacity that does not exist: the
405
+ dispatch is wasted and the router has no way to know. So the floor is applied where the
406
+ claim is MADE, not only where the job lands, and an operator's `GEN_MODALITIES` cannot opt
407
+ an edge machine back into VIDEO — the rule is about the hardware, not the configuration.
408
+
409
+ Only `video` is stripped below the floor. See `EDGE_BARRED_MODALITIES`: §B2 names video and
410
+ music, and "audio" in the advertisement vocabulary also covers speech, which §A and §B3
411
+ explicitly want running locally on small hardware. Stripping the whole `audio` label
412
+ would take Kokoro off exactly the laptops the plan puts it on. Image and speech are
413
+ constrained by FIT instead — `resolve_media_model(..., max_vram_gb=...)` already refuses a
414
+ model the card cannot hold — and a music request on a small card is refused by that same
415
+ fit check, since the smallest music model needs 8 GB.
416
+
417
+ Always returns at least ["text"]: every node serves text.
418
+ """
419
+ keep = list(advertised or [])
420
+ if not tier_accepts(node_tier_for_vram(vram_gb)):
421
+ # Driven by the constant, not by a literal beside it. A constant that documents the
422
+ # rule without driving it drifts from it, and editing it would change nothing.
423
+ keep = [m for m in keep if m not in EDGE_BARRED_MODALITIES]
424
+ if "text" not in keep:
425
+ keep.insert(0, "text")
426
+ return keep
427
+
428
+
429
+ def weights_allowed(ref: str, env: dict | None = None) -> tuple[bool, str]:
430
+ """The §D4 LICENCE gate, speaking WEIGHTS REFS instead of registry ids.
431
+
432
+ Returns (allowed, reason). A ref is allowed only when it belongs to a registry-declared
433
+ model that `serving_allowed()` passes — so an undeclared ref is refused for being
434
+ undeclared, and no landmine has to have been predicted individually for the gate to stop it.
435
+
436
+ SCOPE, because the name invites a wrong assumption: this is the licence gate and nothing
437
+ else. It does NOT apply the geo-fence — `territory_allows` needs a node region and a user
438
+ region, which a bare weights ref does not carry. `resolve_media_model` applies the fence
439
+ separately, and anything else adopting this as "the gate" must do the same or it will admit
440
+ a geo-fenced model inside its fence.
441
+ """
442
+ r = (ref or "").strip()
443
+ if not r:
444
+ return False, "empty weights ref"
445
+ bare = r.split("@", 1)[0].lower()
446
+ retired = RETIRED_REFS.get(bare)
447
+ if retired:
448
+ return False, retired
449
+ # Callers speak both vocabularies: the serving surface hands over a weights repo, the resolver
450
+ # hands over a registry id. Accept either, or the refusal reason libels a declared model as
451
+ # "not declared" — which sends the reader to fix the registry instead of the actual cause.
452
+ mid = _REF_TO_ID.get(bare) or (r if r in BY_ID else None)
453
+ if mid is None:
454
+ return False, (
455
+ f"{r} is not declared in registry/models.yaml, so it passes no licence or tier gate. "
456
+ f"Plan 23 §B1 requires every media model to be declared there."
457
+ )
458
+ if not serving_allowed(mid, env):
459
+ m = BY_ID[mid]
460
+ if m["license_class"] == "non_commercial" or m["status"] == "verify":
461
+ return False, f"{mid} is {m['license_class']}/{m['status']} — never served (§D4)."
462
+ if m["status"] == "emerging":
463
+ return False, f"{mid} is status `emerging` — declared, not yet servable."
464
+ if m["license_class"] == "paid_commercial":
465
+ return False, (
466
+ f"{mid} is paid_commercial: it serves only with "
467
+ f"FRUTEBOX_LICENSE_{m['vendor']}_AGREED=true."
468
+ )
469
+ return False, f"{mid} is refused by the licence gate."
470
+ return True, ""
471
+
472
+
473
+ def resolve_media_model(kind: str, *, node_region: str = "", user_region: str = "",
474
+ env: dict | None = None, override: str = "",
475
+ max_vram_gb: float = 0.0) -> MediaSpec:
476
+ """The one answer to "which model serves this media request here".
477
+
478
+ `override` is an operator's env choice (`FRUTEBOX_IMAGE_MODEL` and friends). It is
479
+ VALIDATED, not trusted: an override naming an undeclared or non-commercial model is
480
+ refused outright rather than honoured, which is the whole reason MusicGen could previously
481
+ reach a GPU. It may name either a registry id or a weights repo.
482
+
483
+ `max_vram_gb` is the VRAM actually available where this will run, and it is what makes
484
+ §B3's local-first path real rather than aspirational: on a 12 GB contributor card the 24 GB
485
+ image primary is skipped and the 10 GB one is taken, so the job runs on the device FOR FREE
486
+ instead of going to a metered Tier-A node. 0 means "don't constrain" (a datacentre caller
487
+ that will size the pod from the spec it gets back). Note this only reorders WHICH sanctioned
488
+ model runs — it never lowers the licence, territory or tier bar.
489
+
490
+ Raises `MediaUnavailable`, listing what was tried and why each was rejected. It never
491
+ substitutes a different modality's model or a different artist's voice — a request whose
492
+ model cannot serve gets an error, because the point of the request was which model.
493
+ """
494
+ k = (kind or "").strip().lower()
495
+ tier = KIND_TIER.get(k)
496
+ if tier is None:
497
+ raise MediaUnavailable(
498
+ f"unknown media kind {kind!r} — expected one of {', '.join(sorted(KIND_TIER))}")
499
+
500
+ tried: list[str] = []
501
+ if override:
502
+ # An override may only pick FROM THE KIND'S OWN CHAIN, never merely from its tier.
503
+ # `KIND_TIER` maps music, song and sfx all to the music tier, so a tier-level check
504
+ # would let `FRUTEBOX_MUSIC_MODEL=ace-step-1.5` — a reasonable thing for an operator to
505
+ # set — silently redirect every SFX request to a music model. That is the exact trade
506
+ # the no-fallthrough rule below exists to prevent, and an env var must not be a way
507
+ # round it. An override outside the chain is refused and told which ids are open to it.
508
+ picked = _id_for_override(override)
509
+ sanctioned = KIND_PREFERENCE.get(k, ())
510
+ if picked not in sanctioned:
511
+ # A retired ref gets its own reason first. "Not in the chain" is true of
512
+ # `facebook/musicgen-small` but badly understates it — an operator who reads only
513
+ # that will try it again for the kind it IS a model of. The licence is why it will
514
+ # never be re-enabled anywhere, and that is the sentence worth showing.
515
+ retired = RETIRED_REFS.get(str(override).split("@", 1)[0].strip().lower())
516
+ raise MediaUnavailable(
517
+ f"{override!r} cannot serve a {k!r} request: "
518
+ + (retired or
519
+ f"it is not in plan 23 §A's list for {k!r} ({', '.join(sanctioned) or 'none'}). "
520
+ f"An override may choose among those, not beyond them.")
521
+ )
522
+ candidates: tuple[str, ...] = (picked,)
523
+ else:
524
+ # §A's table IS the routing table, and it is exhaustive per kind. There is deliberately no
525
+ # fallthrough to "any other model in this tier": the music tier holds an instrumental
526
+ # model, a song model and a sound-effect model, so falling through would answer "a door
527
+ # creaking" with a short instrumental and call it served. §B3 — "quality is never traded
528
+ # for locality" — rules that out. A kind whose sanctioned models cannot serve REFUSES.
529
+ candidates = KIND_PREFERENCE.get(k, ())
530
+
531
+ for mid in candidates:
532
+ m = BY_ID.get(mid)
533
+ if m is None:
534
+ # Not a declared model. `weights_allowed` knows the retired refs by name, so an
535
+ # override of `facebook/musicgen-small` is told it is CC-BY-NC rather than merely
536
+ # "undeclared" — the operator needs to know WHY it will never be re-enabled.
537
+ tried.append(f"{mid}: {weights_allowed(mid, env)[1]}")
538
+ continue
539
+ if m["tier"] != tier:
540
+ tried.append(f"{mid}: is a {m['tier']}-tier model, not {tier}")
541
+ continue
542
+ if not serving_allowed(mid, env):
543
+ ok, why = weights_allowed(WEIGHTS[mid].repo if mid in WEIGHTS else mid, env)
544
+ tried.append(f"{mid}: {why or 'refused by the licence gate'}")
545
+ continue
546
+ if not territory_allows(mid, node_region, user_region):
547
+ tried.append(
548
+ f"{mid}: geo-fenced — territory {m.get('territory')} bars node "
549
+ f"{node_region or '?'} / user {user_region or '?'}")
550
+ continue
551
+ w = WEIGHTS.get(mid)
552
+ if w is None:
553
+ tried.append(f"{mid}: no pinned source — {UNRESOLVED.get(mid, 'no weights block (plan 28 §B2/B3)')}")
554
+ continue
555
+ vram = float(m["vram_gb"])
556
+ if max_vram_gb and vram > float(max_vram_gb):
557
+ tried.append(f"{mid}: needs {vram:g} GB VRAM, this device has {float(max_vram_gb):g} GB")
558
+ continue
559
+ return MediaSpec(
560
+ kind=k, model_id=mid, tier=tier, repo=w.repo, revision=w.revision,
561
+ vram_gb=vram, runtime=tuple(m.get("runtime") or ()),
562
+ placement_tier=model_placement_tier(vram),
563
+ local_ok=m.get("local_class") in ("L", "W"),
564
+ stage2_repo=w.stage2_repo, stage2_revision=w.stage2_revision,
565
+ )
566
+
567
+ detail = "\n ".join(tried) or "no declared model in this tier"
568
+ raise MediaUnavailable(
569
+ f"no commercial-safe model can serve a {k!r} request here:\n {detail}")
570
+
571
+
572
+ def _id_for_override(override: str) -> str:
573
+ """An operator override may name a registry id or a weights repo; normalise to the id so the
574
+ same gates apply either way. An unrecognised value is returned unchanged and then fails the
575
+ declared-model check by name, which is the honest outcome."""
576
+ o = (override or "").strip()
577
+ return _REF_TO_ID.get(o.split("@", 1)[0].lower(), o)
578
+
579
+
580
+ #: The operator's per-area model overrides. Each read is written out LITERALLY rather than
581
+ #: composed from `area`, because `tests/test_provider_credential_env_bypass_guard.py` fails any
582
+ #: env read through a computed name — it cannot see what a variable resolves to, so it cannot
583
+ #: rule out a credential, and it is default-deny by design. These are model names, not secrets,
584
+ #: but the right answer to that guard is to make the read readable, not to file an exemption.
585
+ _OVERRIDE_READS = {
586
+ "IMAGE_MODEL": lambda: envvar("IMAGE_MODEL", ""),
587
+ "VIDEO_MODEL": lambda: envvar("VIDEO_MODEL", ""),
588
+ "SPEECH_MODEL": lambda: envvar("SPEECH_MODEL", ""),
589
+ "VOICE_CLONE_MODEL": lambda: envvar("VOICE_CLONE_MODEL", ""),
590
+ "MUSIC_MODEL": lambda: envvar("MUSIC_MODEL", ""),
591
+ }
592
+
593
+
594
+ def model_override(area: str) -> str:
595
+ """The operator's env override for one area, e.g. `IMAGE_MODEL` -> $FRUTEBOX_IMAGE_MODEL.
596
+
597
+ Returns "" when unset, so `resolve_media_model` takes the plan's preference order. Read at
598
+ CALL time rather than at module import: a test and an operator can both change it without
599
+ reloading the process. An unknown area returns "" rather than raising — a missing override
600
+ is the same as no override.
601
+ """
602
+ read = _OVERRIDE_READS.get(area)
603
+ return ((read() if read else "") or "").strip()