frutebox-node 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- frutebox/adminheader.py +59 -0
- frutebox/attest.py +43 -0
- frutebox/audiogen.py +364 -0
- frutebox/config.py +367 -0
- frutebox/deltasync.py +211 -0
- frutebox/economics.py +270 -0
- frutebox/engine_guard.py +718 -0
- frutebox/envelope.py +77 -0
- frutebox/envvars.py +167 -0
- frutebox/gpuprices.py +641 -0
- frutebox/gpuproviders.py +4489 -0
- frutebox/gui.py +270 -0
- frutebox/imagegen.py +152 -0
- frutebox/inference.py +624 -0
- frutebox/manifest.py +218 -0
- frutebox/mediaserving.py +603 -0
- frutebox/packaging.py +542 -0
- frutebox/pqctransport.py +660 -0
- frutebox/provider.py +1867 -0
- frutebox/providers.py +942 -0
- frutebox/registry/__init__.py +189 -0
- frutebox/registry/generate.py +311 -0
- frutebox/registry/generate_providers.py +120 -0
- frutebox/registry/models.yaml +1759 -0
- frutebox/registry/providers.yaml +518 -0
- frutebox/registry/registry.gen.json +2043 -0
- frutebox/registry_gen.py +3 -0
- frutebox/runpod_templates.py +143 -0
- frutebox/serving.py +691 -0
- frutebox/signedconfig.py +259 -0
- frutebox/signing.py +208 -0
- frutebox/thinkbudget.py +155 -0
- frutebox/videogen.py +118 -0
- frutebox_node-0.1.0.dist-info/METADATA +100 -0
- frutebox_node-0.1.0.dist-info/RECORD +36 -0
- frutebox_node-0.1.0.dist-info/WHEEL +4 -0
frutebox/mediaserving.py
ADDED
|
@@ -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()
|