- Oct 29, 2026
-
-
Stefy Lanza (nextime / spora ) authored
Letting a sized-to-fit cache hit try the GPU (1f42d08c ) is right for a model that fits and expensive for one that does not. Measured on LTX-2.5: 04:55:07 RESIDENT all-GPU load (sized to fit) 04:56:53 [resident] components left off-GPU: transformer(CUDA OOM), vocoder(CUDA OOM), prompt_enhancer(CUDA OOM) 05:11:10 ✓ loaded — strategy: model CPU offload 05:11:12 23.28 GB total, of which 21.32 GB offloaded to host RAM Sixteen minutes to reach exactly the placement that would have been chosen immediately, and it would have paid that on every load. The sizing was not wrong so much as answering a different question: it knows what the weights weigh (22.1 GB against 25.0 GB free), while resident needs every component -- transformer, vocoder, VAE, text encoder, prompt_enhancer -- plus the activation peak, all at once. The real footprint turned out to be 23.28 GB. Only an attempt can answer that, so the answer is recorded as a system-owned learned field beside measured_n_gpu_layers, which exists for the same reason: stop repeating an expensive discovery every load. Both outcomes are recorded, not just the failure. Writing only failures would re-prove a model that fits on every load; writing only successes would never stop the expensive retry. And it is not a one-way door -- a later success clears an earlier failure, because a bigger card, a smaller quantisation or fewer co-resident models all change the answer. Unknown still means try. A model never attempted, or a card that has since changed, gets its chance at the GPU rather than being written off by default. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
-
- Oct 28, 2026
-
-
Stefy Lanza (nextime / spora ) authored
Production, 2026-10-28: Krea-2-Turbo answered one request and then 500ed. RuntimeError: Expected all tensors to be on the same device, but got index is on cuda:0, different from other tensors on cpu The offload moved the encoder to the CPU and the pipeline is CACHED, so the next request met an encoder that was no longer where encode_prompt indexes. _free's docstring even said the encoder "waits in host RAM ... the next request needs it again" -- and nothing ever brought it back. What turned that into a 500 rather than a skipped optimisation: the side effect outlives this function. The bail-out path returns the original kwargs and lets the pipeline generate "the ordinary way", but the ordinary way calls the SAME encoder, so it hit the same mismatch and the request failed anyway. A fallback that leaves the thing it fell back to broken is not a fallback. So the offload is a per-request cycle -- bring the encoder on, encode, move it off -- and every bail-out restores the encoder first, because each one hands the request to a path that needs it on the GPU. The regression test drives a pipeline that really refuses to encode off-device and asserts the SECOND request still works; without the restore it reproduces the production message exactly. A second test pins the weaker promise that any bail-out leaves the encoder where the ordinary path can use it. Found by the operator mid-render, not by the suite: every check so far ran one request per model against a freshly started engine, which is the one case that cannot fail. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
-
Stefy Lanza (nextime / spora ) authored
The sequential text-encoder offload reported itself "not enabled" on a card that plainly had `sequential_text_encoder = True`. Four defects, stacked so each one hid the next; the first is much wider than the feature that exposed it. _resolve_config_key could not resolve ANY non-text model by alias. Typed entries are keyed "<kind>:<name>", so the exact-id and alias-map lookups both miss, and the basename fallback misses too: "video:LTX-2.3-PinkCherry" has no slash, so basename() hands back the whole prefixed string. Every per-model image / video / audio / tts / embedding setting was therefore replaced by the global default, silently, for any model addressed by its alias -- which is the only name that distinguishes two configs sharing one checkpoint path. The resolver now tries the typed keys, ordered by the types the model is actually registered as. images.py (6 sites) and tts.py looked their config up with a raw config.get(model_key) and had the same bug; they resolve now, with a test that fails if a raw one reappears. The flag itself lived only in _raw_cfg, where a consumer reading config['sequential_text_encoder'] never saw it. Promoted to a top-level runtime kwarg, as gpu_split beside it already documents. With the gate finally firing, the render failed: "Must provide `prompt_attention_mask` when specifying `prompt_embeds`". encode_prompt's return order had been guessed. Read off the installed sources, LTX-2 returns (embeds, mask, neg_embeds, neg_mask) and Krea 2 returns (embeds, mask) -- the mask comes SECOND, and the old code read it third, filing negative embeddings as the attention mask. Naming is now done by classifying the values (a mask is an integer tensor, embeddings are floating) instead of by position. That rewrite then dropped Krea 2's negative prompt, which would have been worse than the failure it replaced: Krea 2's __call__ encodes "" itself when handed no negative, against an encoder this function has just moved to the CPU. The negative is always encoded up front for those families now. Hardened for families nobody has tried: an SDXL-shaped pipeline returns four float tensors including the pooled pair, and naming only two does not degrade quietly -- the pipeline rejects incomplete embeddings and the request fails. If everything encode_prompt returned cannot be named, the optimisation is abandoned for that request and says so. Measured on LTX-2.3-PinkCherry (480x480, 25 frames, 8 steps, RTX 3090): without 369.4 s peak 23718 MiB 91% util with 345.3 s peak 24098 MiB 92% util 7.3 GiB freed Read as headroom, not speed: the card is 24 GiB and the denoiser fills what it is given, so peak sits at the ceiling either way and the 24 s is one sample. The 7.3 GiB is what stops these renders being pushed to CPU offload, where the same job took tens of minutes. Also here: a clip-audio scene no longer needs an explicit duration (B1 said the duration comes from the clip, and validation refused exactly the request the feature exists for), and _free now reports per component whether it moved, was already off the GPU, or REFUSED the move -- a bnb-quantized encoder can refuse, and all three used to arrive as "nothing was freed". test_sequential_text_encoder's fakes returned bare strings in the wrong tuple order, which is why the bug shipped: the test encoded the mistaken belief rather than the pipeline's contract. They carry dtypes and the real orders now. test_compose's engine-selection test patches shutil.which as well, because the production image ships wav2lip and sadtalker shims on PATH and the test was asserting the absence of a binary the real deployment has. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
-
Stefy Lanza (nextime / spora ) authored
A text encoder runs ONCE per request, before the first denoising step, and is dead weight for every step after -- but it stays resident and keeps its VRAM. Measured on the cached snapshots, that is not a rounding error: Z-Image's encoder is 7.5 GiB of a 19.2 GiB pipeline, and LTX-2.3's 4-bit Gemma-3-12B is ~6.5 GiB in front of a 15 GiB transformer. PinkCherry declaring exactly 24.0 GB on a 24 GiB card is that sum, and it is why every LTX render was pushed to CPU offload. Encoding first and freeing the encoder hands that VRAM to the denoiser, so a model that "did not fit" fits. This is the sequential text-encoder offload the requirements asked for; it existed only as a side effect before, because diffusers' enable_model_cpu_offload happens to move each component out after use -- so the OFFLOADED path had it and the resident path did not. The embedding argument names differ per family and there is no common base class to ask, so they are read from the pipeline's own signature: Krea 2 takes prompt_embeds + prompt_embeds_mask and needs a second call for the negative, LTX-2 takes prompt_attention_mask and encodes both at once. A family that takes no embeddings at all is left alone rather than having its encoder freed with no way to pass the prompt back. Opt-in per model and settable from the model page, because freeing the encoder costs a host-RAM copy and only pays where the card is tight. The encoder is moved to CPU rather than deleted: the pipeline is cached and the next request needs it again. Any failure returns the request untouched -- a VRAM optimisation must never be the reason a generation fails, and a test holds that. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
-
Stefy Lanza (nextime / spora ) authored
Two bugs of the same shape: a signal that already existed and was not consulted. Same-path siblings. Two cards can share one weights path and differ only in settings -- Wan2.2-I2V-A14B-Lightning and the plain Wan2.2-I2V-A14B both point at Wan-AI/Wan2.2-I2V-A14B-Diffusers, declaring 22 GB and 151 GB. The manager has disambiguated exactly this since the lisa/lisa-32k case, through `requested_id`, and NEITHER placement caller passed it. So the Lightning card was sized by its 151 GB sibling, read as ~127 GB over free VRAM -- far outside the borderline band -- and went straight to CPU offload without trying the card, where it computed at 0% GPU for 26 minutes holding 37 GB of host RAM. Measured: 97.4 GB without the id, 14.1 GB with it, which is the difference between exile and residency on a 24 GiB card. Quantizing the experts was necessary but not sufficient; the ESTIMATE was still the wrong card's. The leak watcher's load signal. _load_in_progress() consulted only the manager's ready event, which diffusers image/video loads never touch -- they load in codai/api/{images,video}.py. A 12-minute multi-shard load therefore looked idle, its monotonically rising RSS looked exactly like a leak, and the mitigation ladder ran against a load doing nothing wrong ("RSS 19.8/43.0 GB LEAK SUSPECTED", every poll). It now also consults the loading reservations, which cover those paths by construction. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
-
Stefy Lanza (nextime / spora ) authored
13. A request may name itself (`request_id` in the body, or an X-Request-Id header) and poll /v1/{images,video,audio}/progress?request_id=... for its OWN steps. One global slot per media type meant two callers could not tell their progress apart, so a client stopped reading them altogether and showed no step progress at all while the server was drawing. The name is used as the TASK id, so this is a VIEW over the existing task registry -- which already tracks step/total/status per task under a lock and prunes finished ones -- rather than a second place progress is written. Claimed once, because a request registers a short "loading" task as well and both adopting the id would serve the client whichever happened to be last. Unknown id = 404, so a client stops polling on a definite answer instead of reading somebody else's progress. 13b. The encode reports frames written (`encoding 1200/2400 frames`) across the band it owns, so the longest step of a render stops looking like a stall; and the job's own log, kept all along but unreachable, is served as a tail in the status document plus GET /v1/video/compose/{id}/log?since=N for the whole thing incrementally. The record holds the live list, not a snapshot. A4. 72s per 768x1344 still -> 21.4s, measured on an idle 3090. The card carried `offload_strategy: "model"`, which is the model page's DEFAULT, so enable_model_cpu_offload() ran on a model that fits a 23.56 GB card whole and paid PCIe for it on every request. `auto` loads on GPU and falls back to offload only on OOM. The doc's other hypothesis is ruled out rather than assumed: the live progress endpoint reports total=10, so the card's step count IS applied, and an explicit steps=10 takes the same 21.2s. Also: `models.civitai_cache_dir`, configured like hf_cache_dir and gguf_cache_dir because it is the same kind of thing. Unset, these multi-gigabyte checkpoints land beside the other caches -- on this host a 140 GB volume, which a 13.5 GB checkpoint took to 99% until the next model could not write even its config fetch ("No space left on device"). That is what was actually stopping Z-Image-Turbo-IntoRealism from loading at all. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
Every one encoded something that had since changed, or depended on a package this checkout does not have. Each was read against the code before being touched, and two turned out to be real defects: - ggml_rpc.available() returned the bare exception, so a model naming RPC servers refused with "No module named 'llama_cpp'" -- true, but naming neither the capability nor the fix. Every refusal now says both. - building a BitsAndBytesConfig without bitsandbytes installed raised PackageNotFoundError from importlib.metadata. The IMPORT is guarded; only construction consults metadata, so a Vulkan-only or CPU install got a traceback naming neither the model nor the setting. Now reported as loudly as an unrecognised mode, because the consequence is the same: full precision. The 2-bit path already did this for optimum-quanto. The rest were stale expectations: - the broker protocol inverted (the client speaks first: one read, no separate ack), gained keepalive pings and an immediate `pending` frame, and renamed its stream envelopes to chunk/done. Added coverage for the status gate that suppresses `pending`, which nothing tested. - /admin/api/status was removed in def78c18 and the front owns status now, so its on-the-way-out coercion had nothing left to coerce. Retargeted to the invariant that survives: entries are well formed when MADE. - main()'s default boot became the front/engine split, so the app instance reaches uvicorn in the ENGINE path now. And four were brittle rather than wrong, each failing for something it was not testing: a 4000-character source window that stopped covering the raise when a comment was added above it (now the function's own source); a hand-written Config stand-in that broke on every new section the product read (now falls back to the real defaults, per section); a generic `selectedId` used as a proxy for "the pipeline UI is gone" that unrelated code then adopted; and a pool test that needed pypdfium2 to exercise what a BUSY pool does. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
NvidiaBackend.__init__ leaves self.device None. The loader picked the precision default as `'fp16' if self.device == 'cuda' else 'fp32'` at line 897 and only detected the device at line 1029, so the FIRST load in a fresh engine process defaulted to fp32 on a CUDA card -- while a second load in the same process inherited 'cuda' from the first and quietly got fp16. The same model loaded at two precisions depending on load order. fp32 costs twice the VRAM of the dtype asked for, which on a 24 GB card is the whole headroom argument for quantizing in the first place: Qwen3.5-9B is carded load_in_4bit with an estimate of 7.795 GB and measured 14.403 GB, a 1.85x gap that is the fp32 tell. FlashAttention also refuses fp32 outright, so transformers warned twice at load, cast the inputs back to fp32 at inference, and the model answered every request with "FlashAttention only support fp16 and bf16 data type" -- and before that error was surfaced at all, with an empty completion. It had been unusable since 2026-09-30. Also resolve flash_attn by PRESENCE rather than truthiness: `config.get(k) or global` cannot express "off", so a card saying `flash_attention: false` fell through to the global default. Not the cause here (the global is false too), but the same class of bug one layer up. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
place_at padded a mix up to the scene length with apad and let atrim end the stream. That handoff is a race. A bare apad loses it by generating silence for ever: ffmpeg never reaches EOF and spins at 100% CPU with the output half-written -- 4 hangs in 10 runs, and two orphans left on a dev host by test_compose_dialogue had burned 14h and 10h of CPU unnoticed. apad=whole_dur stops the hang and loses the same race the other way, emitting a track one clip short (3s for 4s) in 3 of 14 runs with no error anywhere. Neither -threads 1 nor -filter_threads 1 helps. A silent base input of exactly the target length, mixed under the placements with amix=duration=first, has no handoff to lose: 0 bad in 20 runs, one output size. The other apad sites feed no mix, but take whole_dur too since the length is known there as well. Also pass config/subfolder to the single-file component loader, the way the GGUF branch already does and for the same reason: left to itself from_single_file infers a base repo from the checkpoint's own metadata, so Krea-2-RealDream reached for krea/Krea-2-Raw and died on a repo nobody named. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
The lip-sync engines were outside the ledger entirely. A render is gigabytes on the card for minutes, in a process the manager did not start, so the next model to load read the card as emptier than it is, took what it found, and one of the two died. They are models.json rows now -- video_models, backend "lipsync", capabilities lip_sync -- configurable on the models page like anything else, and a run goes through acquire_stt_backend, the same door LongCat and the STT workers use. The footprint is sampled during the run (these are third-party scripts that report nothing about themselves) and kept as a high-water mark, so the next reservation is measured. The registration is dropped when the process exits: one-shot work that stayed in the ledger would claim VRAM nobody holds. The bigger half is a defect that was never about lip sync. acquire_stt_backend POPS the instance pool, and _is_key_busy looks only at that pool -- so every model registered through it reads as permanently idle. LongCat mid-generation, a CrisperWhisper transcription, a lip-sync render: eviction was not choosing to take the card from busy work, it could not see the work was there. LongCat's own source notes this and works around it inside its releaser; nothing else did. So multi_model_manager grows external_busy(key), and _is_key_busy consults it. The existing eviction logic then does the right thing with no further change: the first pass skips a busy model, the second waits for the active one. An in-flight render is something to queue behind, not a candidate -- cleanup() waits for the process and never terminates it, because a half-finished render is worth nothing and the request that triggered the eviction is not more important than the one already running. Also: the Civitai redirect fix moved the seam two tests patched, so they stopped intercepting and started reaching the real site -- which is why they failed on HTTP 404 rather than on the 401/403 pages they assert about. Every request now leaves through one function, which is the thing to patch. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
Two things that were only reachable from a python -c. The lip-sync engines had no management surface at all: no status, no install, no removal. They are not models.json entries on purpose -- each runs in its own interpreter as a subprocess, so a row in a model section would put them in the VRAM ledger as something the manager can load, which it cannot -- but that is a reason to give them their own surface, not none. So: a tab on the models page, three admin endpoints behind require_admin like every other model action, and an install that runs in the background while the page polls. The log is kept because when an install fails, WHERE it failed is the whole answer: a wheel that will not build reads nothing like a 401. "Rebuild venv" keeps the weights, since a dependency change does not alter six gigabytes. `component_models` could only name a repo. A community fine-tune is very often ONE file -- a retrained transformer with no repo around it, the base supplying the VAE, the text encoder and the scheduler -- and from_pretrained cannot open one, so such a component could only be named if somebody had published a converted directory tree for it. It now accepts a single-file or civitai: reference. And the Civitai resolver picked the LARGEST file of a version. Real Dream's Krea 2 V3 publishes the same weights three times: fp8 13.46 GB, int8 13.18 GB, nvfp4 8.12 GB. Largest-wins lands on fp8 by a 0.28 GB margin nobody chose, and could as easily have landed on nvfp4, which needs Blackwell and cannot load on an Ampere card at all. A reference may now name the file, and without one the choice is made among the builds this GPU can run, by precision rather than size. Two smaller ones found by using it. urllib replays headers across a redirect, so Civitai's signed R2 URL received a bearer token as well and answered 400 -- which looks exactly like a bad token, and meant adding a token BROKE downloads that had worked without one. curl strips it; now so does this. And the cache has its own root (CODERAI_CIVITAI_CACHE) instead of living under the HF cache, which is how a second GGUF directory grew inside one. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
Three CrisperWhisper models were configured and they stacked: each is a resident Whisper-large in its own process, so the card held two of them plus the server's text and embedding models until one OOMed inside the encoder's softmax. Nothing here was the model's fault. The reservation taken before a load was a hard-coded 3 GB -- roughly the fp16 weights, and about a third of what a verbatim word-timestamp pass peaks at, since that holds the encoder's attention for a whole 30s window. The worker now reports its own peak and the figure is recorded as a high-water mark, so the next reservation is measured rather than guessed. The eviction ladder had no way to reach a worker's VRAM: it lives in another process, and acquire_stt_backend pops the model pool, so the manager's busy signal cannot see a transcription either. There is an external releaser now -- idle workers go at once, a busy one is waited out to the drain budget and only then taken -- and asking for one model stops its idle siblings. A cold start was invisible while it lasted. Free VRAM comes from the driver, so a worker downloading its shards looks like room; live traffic loaded into it and the transcription OOMed on a card nobody over-committed on purpose. The load window holds a reservation now. whisper-server already evicts and knows when it is idle, but it sized itself at file size x 1.1, which counts the weights and not whisper.cpp's compute buffers. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
- Oct 27, 2026
-
-
Stefy Lanza (nextime / spora ) authored
The map takes 4bit / 8bit / 2bit or a .gguf path. Anything else was skipped without a word -- and one unusable entry empties the mapping, which takes the global load_in_4bit with it, so the component loads from the base repo at full precision while the operator believes it comes from the file they named. LTX-2.5 named a bf16 Gemma-4 12B for its text_encoder. The quantization config came back None, the 12B loaded unquantized, and every generation ended with the card full: 23.53 GiB of 23.56 GiB in use, unable to allocate 30 MiB, on a model whose own measured footprint is 14.45 GB. Four strategies and about an hour of 20-minute attempts, all of them really the same missing line. Now it says so, and names component_models -- the key that does what such a value was meant to do. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
The LTX-2.5 key reconciliation shipped, the retry failed identically, and the repair had never run. It needs the component's config.json to know which names the class expects, and _component_dir looked only in the local snapshot -- which, for a component whose weights come from a GGUF, is precisely the subfolder nobody downloads. 20 GB of weights the file replaces are not worth fetching; a few hundred bytes of config are. Cache first, then one hf_hub_download of that component's config.json. Same lesson as a cache-only existence check on a rented pod: an answer of "not here" from a cache is not an answer about the repo. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
The repair that fixed LTX-2.3's prompt-adaln names could only strip a suffix. LTX-2.5's GGUF needs a whole segment renamed: it calls the attention norms `q_norm` / `k_norm` where LTX2VideoTransformer3DModel expects `norm_q` / `norm_k`. 724 parameters, one to one, the same tensors -- and unreconciled they stay on the meta device, so a 37-minute load ends with Cannot copy out of meta tensor; no data! about weights the file was carrying all along. Measured by diffing the GGUF's 4349 tensors against the class's 4091 parameters: nothing was absent, 724 were simply spelled differently. Both directions, because nothing says which spelling the next exporter picks, and both repairs now run in one pass -- a checkpoint can need each for a different family of keys. The existing guard is unchanged: a key moves only when the class does not want its current name, does want the renamed one, and nothing already holds the target. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
diffusers reports a missing single-file checkpoint as Invalid `pretrained_model_name_or_path` provided. Please set it to a valid URL. which sends you to check the repo id. The actual cause, measured on LTX-2.5, was an entry pointing into ~/.cache/huggingface on the HOST -- a path the container does not mount -- so the file existed everywhere except in the process trying to open it. Every other part of that model was ready. The path is probed before the load and named in the failure, with the reason it is usually absent. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
_apply_sampler turns a cross-family sampler into a 400 that explains itself: flow-matching and DDPM schedulers parameterise the trajectory differently, so one cannot stand in for the other. The acceleration config applied its `scheduler` with no such check, and a card is exactly where that mistake gets made and never noticed. Krea-2-Turbo's entry asked for EulerDiscreteScheduler on a flow-matching model. The swap succeeded silently and the generation then died with EulerDiscreteScheduler.set_timesteps() got an unexpected keyword argument 'mu' which names neither the card nor the model, arrives from inside diffusers, and reads like a library bug. Now it is refused and logged, naming both schedulers, and the model keeps its own -- acceleration is an optimisation and has always been allowed to decline. One set of family names, imported by both paths, so they cannot drift. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnLhydX9QqUcfBbqLu
-
Stefy Lanza (nextime / spora ) authored
The silent one. self.models is keyed by model_key, and model_key was the weights path -- so two entries naming the same path shared ONE resident pipeline. LTX-2.3-PinkCherry requested while LTX-2.3-10Eros was resident never loaded its own transformer: no load, no eviction, straight to generation, and HTTP 200 with 10Eros weights. Every other instance of this family failed loudly; this one answered successfully with the wrong model. It also meant both wrote measured_vram_gb to the same key, so a Q4_K_M and a Q5_K_M of different sizes were overwriting each other sizing. A key is split only when the caller named something, two or more DISTINCT aliases point at that same path, and the named one is among them. A lone aliased model, or a request that names the path, keeps the key it has always had. That narrowness is the point, not caution for its own sake: collapsing every name form onto one key is deliberate a few lines above -- without it two concurrent first-requests under different spellings each load an instance and exceed max_model_instances=1. This is not a licence to key by whatever the caller typed. Verified live: the estimator now reports key=video:LTX-2.3-10Eros where both models previously shared key=video:diffusers/LTX-2.3-Diffusers. Suite: 15 failed, 5268 passed -- the same 15 as the baseline. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnmWyKjWZerwsb27jZ
-
Stefy Lanza (nextime / spora ) authored
A request for LTX-2.3-10Eros loaded LTX-2.3-PinkCherry: a different 15.9 GB transformer, silently, and from an entry that was DISABLED. self.config is keyed <kind>:<model_name> where model_name is the weights path, so entries sharing a path clobber one another and only the last survives. That is not a corner case -- it is how one base checkpoint is served under several configurations: lisa / lisa-32k differ only in context length, 10Eros / PinkCherry only in which GGUF is their transformer. PinkCherry comes second in models.json, so it won, parked or not. The VRAM estimator already disambiguated this way, by the id the caller asked for, after lisa-32k was once sized at lisa 51.97 GB. The config used to LOAD the model did not. Now both do: * every type setter stores a config under its ALIAS as well as its path, so a sibling is reachable by the only name that distinguishes it. The path key keeps its old meaning (last writer wins) for callers that know only a path. * _config_for_model_key takes the requested id and prefers the entry whose alias matches. Applied to all eight setters rather than video alone -- the collision is the same for audio, image and embedding, and fixing one type is how this survived being found twice already. Also: build_gguf_pipeline_components passed `model_name` as the diffusers repo supplying the component configs. Once siblings became reachable by alias that can BE the alias -- "LTX-2.3-10Eros", not a repo -- and from_single_file rejects it. The repo is whatever the entry names in `path`, every time. Verified live: LTX-2.3-10Eros now loads its own transformer and the pipeline assembles, 8/8 components. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnmWyKjWZerwsb27jZ
-
Stefy Lanza (nextime / spora ) authored
The LTX-2.3 GGUFs failed with "Cannot copy out of meta tensor; no data!", and the obvious reading -- parameters the checkpoint lacks -- was wrong. diffusers 0.38 converts an LTX-2 checkpoint prompt-adaln weights to prompt_adaln_single.* and audio_prompt_adaln_single.*, while LTX2VideoTransformer3DModel expects prompt_adaln.* and audio_prompt_adaln.*. Twelve names, one-to-one, the same tensors. They were present all along, under a name nobody reconciled, so they never materialised and stayed on meta. Measured identically on LTX-2.3-10Eros v1.5 and PinkCherry v1.8: 4444 GGUF tensors, 4186 expected parameters, and after the rename 0 missing and 0 unused on both. The tempting fix was to initialise those twelve. That would have invented weights to paper over a rename -- precisely what the surrounding refusal exists to stop -- so it is NOT what this does. A key is moved only when the class does not want its current name AND does want the renamed one AND nothing already occupies the target, which makes it a rename of a tensor the checkpoint has and never the creation of one it lacks. A test pins that: a genuinely absent parameter must stay absent and the load must still fail. The converter is a third-party global, so it is wrapped for the duration of one from_single_file call and restored in a finally. Version-robust: 9 pass against the image diffusers 0.38, including the real checkpoint; against 0.37 the class has no prompt_adaln modules at all, so the repair is correctly a no-op and that test skips rather than asserting a fact about someone else is version. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnmWyKjWZerwsb27jZ
-
Stefy Lanza (nextime / spora ) authored
"Cannot copy out of meta tensor; no data!" means some parameters were never materialised, and it names none of them -- which sends the reader hunting for a corrupt file. Measured on LTX-2.3-10Eros and -PinkCherry: diffusers already ships convert_ltx2_transformer_to_diffusers and it works almost completely, mapping their 4444 GGUF tensors onto exactly the 4186 the class expects. Precisely 12 remain unprovided, all in one conditioning branch, identically in both fine-tunes. So the checkpoints are fine and the converter is fine; those fine-tunes were exported without that branch, and the 12 stay on the meta device. The refusal added earlier now carries that analysis: how many tensors mapped, how many remain, and which submodules they belong to. Best effort throughout -- any failure to analyse returns nothing and the original error stands. It deliberately does NOT offer to fill them in. Supplying parameters a checkpoint does not contain is a substitution, and the surrounding refusal exists precisely because a silent substitution (here: the 38 GB bf16 transformer, on a 24 GB card) is worse than a failure. A test pins that: the hint must not grow a flag that papers over it. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnmWyKjWZerwsb27jZ
-
Stefy Lanza (nextime / spora ) authored
/v1/models re-reads models.json on every call, but the loader's alias map is built ONCE at startup. So an entry added while the server runs is advertised immediately and cannot be loaded at all: the alias resolves to nothing, the raw alias reaches diffusers as a repo id, and the caller gets Repository Not Found for url https://huggingface.co/api/models/<alias > -- a 401 against HuggingFace for a model sitting on the local disk. Measured today registering Wan2.2-TI2V-5B: weights cached, `path` correct, and the request failed that way; the SAME model requested by its path loaded immediately, which is what pinned the cause to the alias map rather than the entry or the weights. The pre-existing Z-Image alias, updated rather than added, worked throughout -- that contrast is the whole diagnosis. This is the fifth instance of one pattern in this codebase: a name in models.json not matching the name the runtime registered. v0.5.4 read a backend from the wrong map, v0.5.5 looked for a binary where it does not live, v0.5.7 published an id that resolved to nothing, the LTX pair shared one path between two entries, and now the listing advertises what the loader cannot resolve. A cross-cutting identity-resolution audit is still the right fix; this is the narrow one. An alias the map has not seen is now looked up in the config itself and cached. A parked entry is deliberately NOT resolved -- parking is how an entry waits on a blocker, and resolving it would hand clients a model that answers 500. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnmWyKjWZerwsb27jZ
-
Stefy Lanza (nextime / spora ) authored
9ae240b0 committed its tests and its documentation and none of its implementation: four files instead of twelve. The `git add` that should have staged the rest named a pathspec for requirements-acestep.txt, which did not exist, so git refused the whole invocation and I did not check its output before committing. The message described work that was not in the tree, and the suite at that commit could not have passed. This is that work: * video.py — LTX-2.x gets its own pipeline classes. This diffusers has LTX2Pipeline, LTX2ImageToVideoPipeline, a video AND an audio VAE, but only the LTX 0.9 family was mapped, so every LTX-2 entry in models.json sat parked: the name matched and the class did not. Split on the version in the name, since both families are just called "LTX" and a fine-tune keeps its base version (10Eros is LTX2.3, PinkCherry is LTX23). * hf_loading.py — `civitai:<modelVersionId>` references, and a GGUF component that fails to load now fails the load instead of being skipped. A skipped component is simply absent from the dict, so the base pipeline loaded its own full-precision copy: the operator asked for a 14 GB quantized transformer and got a 38 GB bf16 one, while the VRAM estimate still said 14 GB and the planner therefore freed about a third of what the load needed. * presenter.py — LatentSync and MuseTalk as first-class engines, ahead of wav2lip for a clip (wav2lip works at 96x96 and upscales, which is the soft mouth on a 1080p reel). Both are video-driven, so sadtalker still leads for a still portrait. Recognised from a shim on PATH or a managed install whose weights are already present — never "could be downloaded", because a compose job is the wrong place to fetch 5 GB. * cache/__init__.py + app.py — the loop stall. scan_cache_dir is not a listing: it stats every blob of every revision of every repo, measured at 27.3 SECONDS on this machine's 900 GB cache, and it was called from a per-model VRAM estimate, a GGUF path lookup and a POLLED endpoint, all on the event loop. 6631 stalls over 0.5s, worst 13.0s, serving nothing. Both halves were needed: memoised for 30s (short enough that a model downloaded while the server runs still becomes visible) and the poll moved to asyncio.to_thread, since memoising alone still puts the first 27-second call on the loop. * manager.py — /v1/models reads `modes` from a video entry's long-standing `video_subtypes` as well as the newer spellings. * requirements-acestep.txt — the file whose absence caused this. It is marked NOT YET INSTALLED, because it is: the install was refused as external code, nothing in it has been run, and the audio_gen loader refuses an ACE-Step model explicitly rather than mis-loading it as a MusicGen. Suite: 15 failed, 5194 passed — the same 15 as the baseline. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T3rZnmWyKjWZerwsb27jZ
-
Stefy Lanza (nextime / spora ) authored
normalise a language before transcribing, stop inflating weights that are already quantized, and never answer 200 with nothing (v0.5.9) Five bugs and five features from a client report. The bugs share a shape: the server held a belief about a model that nothing checked against the model. A1. A TTS voice is chosen for an accent, so the language a compose render carries is "en-us" / "pt-br". Passed to transformers' Whisper -- which accepts only the English NAME -- it raised "Unsupported language: en-us" and 500'd every captioned scene of every render. Reproduced verbatim against the running server before the fix. New codai/api/lang_codes.py normalises once, per backend: the NAME for the transformers pipeline, the ISO code for whisper.cpp, Canary and faster-whisper, and None (auto-detect) for a tag that maps to nothing -- refusing to transcribe because the caller named a dialect is never the better outcome. Language VALIDATION now compares by meaning, so a model declaring "english" no longer refuses "en-gb", and compose's model picker stops disqualifying the only model that can time words. A3. FLUX.2 (dev/klein/pro) is a unified generate-and-edit model whose name carries neither "edit" nor "kontext", so it fell through to the generation branch and advertised plain img2img -- telling clients to show a `strength` slider for a pipeline that has no such argument. Confirmed live: ['image_generation','image_to_image','inpainting']. diffusers 0.37 agrees it is an instruction edit (Flux2Pipeline takes `image`, not `strength`), which is what the runtime already decided on; only the advertised list disagreed. A4. The estimator normalises a disk size to the load precision. A GGUF was special-cased to 1.0 because its bytes ARE its footprint; an HF checkpoint published pre-quantized is the same case by another route and was not. Z-Image-Turbo-unsloth-bnb-4bit: 16.3 GB of 4-bit weights, 1.70x factor, estimated 30.03 GB against a 24 GB card. Nothing can free 30 of 24, so every still ran partly on the CPU -- ~72 s for one image from a few-step turbo model. A5. Qwen3.5-9B answered content "" with HTTP 200 for four weeks. All three rungs of the generation ladder raised ("Pointer argument (at 0) cannot be accessed from Triton (cpu tensor?)") and the last one assigned generated_text = "" and returned normally. Now raises. The Triton error is a quantized model whose weights were CPU-offloaded: such a load succeeds and then fails every request, so it is refused at load time and a quantized model tries full-GPU whatever the estimate says. That also breaks a loop -- measured_vram_gb = GPU + offloaded + reserve means one offloaded load writes a number bigger than the card, and the next load reads it and offloads again. B1. A scene can carry its clip's own sound: visual.keep_audio, scene.audio_mix (voice|clip|both) and clip_volume. "clip" makes the scene as long as the clip and skips the TTS call; "both" ducks the clip under the voice. Folded into the narration track, so the music bed ducks under it like a voice. B2. Two characters talking: scene.speakers, each line in its own voice, each character lip-synced in their own column (or the frame cutting to whoever speaks). One sync run per CHARACTER, not per line -- each gets a track holding only their own lines, so the mouth moves on their words and stays closed while the other talks. Captions never mix two speakers and carry the attribution into the .srt/.vtt. B3. characters: ["alice","bob"] on images and video. The gap was never the field: IP-Adapter conditions on the MEAN of its references, so a second character's pictures produced one person who looked like neither. Reference sets are kept apart and handed to a model that takes several (FLUX.2, Qwen-Image-Edit); a model that cannot do it says so instead of blending. B4. POST /v1/moderation -- nsfw, faces, age, face_match over images or sampled video frames. It fails CLOSED: a missing model is a 503 naming it, not a result with the check quietly absent, because a caller cannot tell a missing check from a passed one. minor_detected is nullable (explicit content with no face cannot be cleared on age) and keys on the LOW end of an age interval, not a point estimate from a model with a multi-year error. C5. Per-model defaults (steps, guidance, sampler) that fill in only what the caller omitted, published in /v1/models alongside modes and licence -- so a client stops keeping a table keyed on the model NAME and an 8-step model stops being run for 30. A sampler switcher that refuses to cross the flow-matching boundary (a FLUX-family pipeline handed a DDIM produces noise, not a worse picture). Stable Audio gets its own pipeline: it was mapped to AudioLDM2, so a configured entry could only fail at load. Two of my own regressions, caught by the suite's guard tests and fixed here: /v1/moderation was unmapped in the remote gateway, and the moderation availability probe LOADED each model -- which turned the /v1/models listing into a 350 MB download on the event loop. Suite: 15 failed, 5146 passed -- the same 15 as the baseline. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
- Oct 26, 2026
-
-
Stefy Lanza (nextime / spora ) authored
A whisper-server model could not be used by the id /v1/models advertises for it. Asking for it reported the weights as a missing HuggingFace repository: 500 Transcription error: whisper-large-v3-turbo-q8_0.bin is not a local folder and is not a valid model identifier listed on 'https://huggingface.co/models ' which reads like a broken download and is nothing of the kind. One whisper-server is described by TWO models.json rows: a RUNNER, carrying model_path + server_path + port, registered under its id (whisper0..whisper3 here); and a MODEL row naming the weights, which registration skips on purpose as "the model config, not a runner". But /v1/models takes its id from the model row -- so the published name was the one name that matched nothing: not a runner key, not an alias. The lookup missed, and the request fell through to the generic audio path, which can only interpret a string it does not recognise as a hub repo id. Now every name that identifies the same runner resolves to it: its own id, an alias (round-robin as before), and the weights by full path, basename, or basename without extension. Where several runners share one file, naming the file prefers an idle one, so it balances the way an alias does instead of always landing on whichever registered first. This is the third defect in one week with the same shape -- a name in models.json not matching the name the runtime registered. v0.5.4 read a backend from the runtime map instead of the file that declares it; v0.5.5 looked for a binary in a directory that does not exist; this one publishes an id it cannot resolve. The pattern is worth an audit of its own. Also pins a rule on the VRAM estimator that it already followed in most places and now cannot lose: an estimate of zero means NOTHING WAS MEASURED, never "measured as needing nothing". A stored zero was already read as absent; v0.5.6 stopped a configured zero from claiming authority over a real measurement; the two tests added here also cover a zero arriving with force_vram_update, which is the one flag that makes a measurement beat the operator's own number. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
fit the caption to the frame, install the font that draws it, and stop reading an unset field as a free GPU (v0.5.6) Four defects behind one report: vertical renders came back with caption lines running off both edges, in the wrong typeface, and speech-to-text answered 500/503 while a text model held the card. THE FONT WAS NEVER IN THE IMAGE. Dockerfile.oci and Dockerfile.update install fonts-montserrat; Dockerfile.oci-venv -- the one build_oci_image.sh actually builds -- installed no fonts at all, so no image built the normal way has ever had the family the compose API documents and defaults to. libass does not fail on a missing family, it substitutes: DejaVu is considerably wider, which is how a missing package became a layout bug with nothing but a log line to say so. All three Dockerfiles now install the fonts AND fontconfig (Dockerfile.oci had the fonts but not fc-match, so it could not even tell whether a family existed). And because an image rebuild is a poor way to add a brand font, fonts can now be supplied per installation: files dropped in <config>/fonts -- or wherever CODERAI_FONT_DIR points -- are registered at startup by writing a fontconfig snippet into the user config. The snippet matters more than it looks: captions are burned by libass inside an ffmpeg CHILD process, which does its own fontconfig lookup, so a font this process can find is not necessarily a font the renderer can use. The config volume outlives both a restart and a new image, which is the point. CAPTION SIZE CAME FROM THE FRAME HEIGHT. On a vertical video the height is the LONG side: at 1080x1920 the font was sized 0.052 * 1920 ~ 100px to be drawn across a 1080px frame. It is now sized from the short side, which leaves 16:9 byte-identical (there the short side IS the height) and gives 9:16 the same text-to-width ratio as 16:9. Line breaking upstream works on a word budget and cannot know how wide the words are, and WrapStyle is 2 -- no automatic wrapping -- so a line that still overflows was simply drawn past the edge. Each event's widest line is now measured against the real font file, and one that does not fit gets its own smaller size through an inline \fs override: the long line shrinks, not the whole video. Measured with the actual face rather than estimated per character, because the families involved differ in width by exactly the margin that decides the question. Clients scaling font_scale down by hand for 9:16 and 4:5 can stop. AN UNSET FIELD READ AS A FREE GPU. used_vram_gb=0 was honoured as authoritative -- yet zero is this estimator's own "could not determine" sentinel, and it is what the admin UI stores for a field the operator never filled in. A 27B Q4_K_P with used_vram_gb=0 and measured_vram_gb=18.0 therefore estimated 0.00 GB against 8852 MiB of free VRAM. Nothing was evicted, every layer was aimed at the GPU, the load failed and retried down a ladder -- all, 52, 39, 26 layers, re-reading the weights at each rung -- for 11m50s, and settled with 39 of 66 layers on the CPU. The request then generated for another 19 minutes, 31m04s in all, holding GPU ownership the whole time: every other model queued behind it, which is what made speech-to-text answer 500/503. The swap gate was working correctly; it swapped the moment the owner finished. A real measurement is better information than an unfilled field, so it now wins. An operator who means "no weights on the GPU" has n_gpu_layers=0, which is handled separately and still reserves nothing. Worth recording for the next time this looks like a quantization problem: it was not. Those weights are 4-bit on disk and the KV cache was already q4_0/q4_0 with flash attention -- about 2.2 GB for n_ctx=32000. Both were at their most aggressive setting while the model sat on the CPU. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
Both of these were found by running the previous commit rather than trusting it, which is the only reason they are not still in. The audio backend was read from the manager's per-model config map. That map holds RUNTIME kwargs, and a whisper-server model has no row in it at all -- it lives in `whisper_servers` -- so the lookup answered "no backend" for exactly the models whose backend decides the question, and the pick that was supposed to avoid them chose `whisper2`. The entry is now read from models.json, where `backend` is actually written, matched on id, alias, path or basename because an audio model is referred to by any of them and the same alias appears twice in a real catalogue. Verified against this install's sixteen audio models: every one resolves, and the pick is now the model measured to return fourteen word timings rather than the one measured to return none. The new warning is what caught it: the render reported "'whisper2' returned no word timings" instead of quietly estimating. That is the whole point of it. And `enabled: false` was hidden behind `all_engines`, which is the wrong seam. The node-wide list the system worker serves -- the one the front hands clients -- MUST pass all_engines=True, because that worker has no engine of its own and would otherwise list nothing. So the filter was off on the only path that mattered, and /v1/models offered LTX-2.5, Krea-2-Turbo and Krea-2-Edit: three models configured ahead of a diffusers version that does not exist yet, each of which answers 500. `include_disabled` is now a separate question, asked only by the admin config view, because that is where a parked model gets re-enabled. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
Two faults seen on a long render, plus the image-side work from the same reports. Caption timings never worked. The render asks for word timestamps on the narration clip it just wrote, so it passes a NAME where the HTTP endpoint passes an upload -- and the transcription core read `.filename` off it unconditionally, raising `'str' object has no attribute 'filename'` on every scene of every render. The caller caught it and fell back to estimating from character counts, so captions drifted against the voice and nothing ever failed. `_upload_name` now takes a path, an upload or nothing; only the extension is read from it, so a name was always enough. Fixing that alone would have changed nothing visible. The model the renderer picked by default was chosen by NAME -- "whisper" first, then canary -- and both are the two backends that cannot time words: the whisper.cpp runner returns text with no word array, and the NeMo worker ignores the flag. The pick now asks which backends report them (the HF pipelines, vosk, faster-whisper), and a model that answers with none of them is named in the job's warnings instead of silently downgrading the captions. Polling a render cost a render. `GET /v1/video/compose/{id}` sits under the `/v1/video/` prefix, which allows 10 requests a minute per IP, so a client polling a four-minute render from the address it submits from spent the whole budget on the polls and got 429 on the work. Read-only job status -- compose and talking-head -- is now exempt from the limit, by prefix and for GET only, so the sibling POST .../cancel stays limited. It is NOT exempt from authentication: the document names the job's output, unlike the progress endpoints it sits beside. Also here, from the image-edit reports: a uniformly black or white result is refused with the dtype named rather than answered 200, `--no-ram` no longer coerces a configured bf16 to fp16 (same two bytes, a quarter of the range, NaN latents, black picture), single-file and GGUF checkpoints load through the configured loader instead of falling back to a different one, per-model LoRAs are an identity rather than a hint, and a repository may run its own pipeline code only from an allow-list that starts empty. And: the measurements stay, the customer names go -- shipped images are public. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
- Oct 25, 2026
-
-
Stefy Lanza (nextime / spora ) authored
A model id is a configuration, not a set of weights. Image models have worked that way for a long time -- one checkpoint, several published ids, one copy in VRAM -- and five things that should have followed from it did not. PER-MODEL LoRA STACKS. A diffusion pipeline could carry an acceleration LoRA and per-request LoRAs, with nothing in between: no way to say "this id is that base plus this style LoRA at 0.8". `loras` on a model entry says it, in the shape text models already use. `lora_path` is honoured too -- it had been in the config and the admin page for years while nothing on the diffusers image path read it. And `set_adapters` replaces the ACTIVE SET, so the image path's request-LoRA call was switching the pipeline's own adapters off: a 4-step distilled preset running un-distilled, and an id published as a fine-tune serving its base checkpoint. 200 OK, a plausible picture, nothing in the logs. The video path had always re-included them. A per-model LoRA that will not load now FAILS THE LOAD. The acceleration path logs and continues because the cost there is speed; here it is identity. SINGLE-FILE CHECKPOINTS. `from_single_file` was wired up only for GGUF components, so the one .safetensors Civitai ships for every community fine-tune could not be opened at all. A model path may now name a file, or `repo:file.safetensors` -- community repos hold every version side by side, so the repo id alone does not say which to serve. A SAMPLER IS A CLASS PLUS ITS OPTIONS. "DPM++ 2M SDE Karras" is DPMSolverMultistepScheduler and two settings; naming the class alone gave plain DPM++ 2M, a different sampler, silently. `scheduler_config` carries them. RASTERISATION, PER REQUEST. A scan is normally handed to the model as the scanner wrote it -- 0.1 ms and 147 KB against a render plus ~1.8 MB of PNG -- but only between the target DPI and `page_image_max_dpi_factor` times it, and only install-wide. `native_dpi` removes the ceiling for one request; the floor stays, because a page passed through BELOW the target is a coarser page, which is what cost surya-ocr-2 its Italian accents once already. An uploaded image now carries its own bytes too: it was decoded and re-encoded to arrive at the picture the caller already had. SEVERAL IDS FOR ONE OCR ENGINE. `ocr.engine_configs` gives OCR what image models had: named configurations of one engine, each its own id, its own catalogue row, its own pool. An override naming a setting that does not exist is refused rather than quietly serving the base settings under another name. Which forced the last one into the open. `/v1/ocr/engines` advertised `surya: {regions: true, tables: true}` while the surya worker built every page with `"regions": [], "tables": []` hardcoded. surya ships a layout predictor and a table recogniser; they were never run. A client that chose surya BECAUSE the catalogue said it returned regions got empty arrays and no error. Both halves are now true by construction: the row is read from `surya_layout` / `surya_table_rec`, and those load the predictors. Regions come back with surya's own reading order (LayoutBox.position, explicit as `order`, -1 where an engine states none) and tables as cells with row/column ids. Loading is configured; spending is per request. `layout=true` chooses whether to pay for the pass on an engine that HOLDS the predictor -- it cannot make one acquire a model mid-page, which is how a footprint grows past what the VRAM accounting reserved. Asked for where none is loaded, it is refused with the configuration to use instead; answering 200 with empty regions would be the same lie wearing a request field. Pods differ from their siblings too: a pool whose entry names an `ocr_engine_config` boots pods with that configuration's settings, and a named config overriding `<engine>_model_id` reaches its own pods rather than sharing its sibling's. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
Requested for a client that edits photos by instruction -- "make the dress red", "add a necklace" -- which plain img2img cannot do at any strength, because `strength` is the fraction of the source it destroys before re-imagining it. Serving Qwen-Image-Edit / FLUX.1-Kontext class models turned up two latent bugs on the way. The instruction path. /v1/images/edits now shapes its call to what the pipeline accepts, from the signature, rather than branching on the model: QwenImageEditPlus / QwenImageEdit / FluxKontext image ✓ strength ✗ SDXL-Img2Img / QwenImageImg2Img / FluxImg2Img image ✓ strength ✓ Taking an `image` with no `strength` IS the distinction, and it generalises, so no list of class names has to be maintained. `strength` is dropped where it does not exist -- a caller cannot know which kind of model it is addressing, and these pipelines carry no **kwargs to swallow it, so passing it through would be a hard TypeError. `guidance_scale` becomes `true_cfg_scale`, the knob they actually read. Steps default to 4 at cfg 1.0 for them instead of inheriting img2img's 30 at 7.5, which would waste a minute an image and overcook the result. Up to three images may be given (`image` plus `images`) for a composed edit, conditioned at one shape; more is a 400. `instruction_edit` is reported in /v1/models so a client can hide the strength slider and treat the prompt as an instruction. Verified to classify the four Qwen/Kontext/FireRed ids and to leave Pony, Z-Image and Illustrious as img2img. component_models is new: a map from a component to ANOTHER repo, loaded and handed to the pipeline as a prebuilt object. Several Qwen-Image-Edit derivatives ship a transformer and nothing else -- the base supplies the VAE, the text encoder and the scheduler -- and there was previously no way to express that, so they could not be served at all. Its entry in component_quantization still applies, so a transformer too large for the card is quantized as it loads. Two latent bugs, both of which anyone using GGUF image components would have hit: * `gguf` was never a declared dependency. build_gguf_pipeline_components has existed through several releases and could not have worked: diffusers needs gguf>=0.10.0 for GGUFQuantizationConfig and only says so mid-load, as "Unable to load weights from checkpoint file". Now in requirements.txt, which build.sh installs. * from_single_file was called with no `config`, so it INFERRED the architecture -- and its fallback is Stable Diffusion 1.5. A Qwen-Image, Flux or Wan transformer therefore failed with "stable-diffusion-v1-5 does not appear to have a file named config.json". It now passes the base pipeline's own component subfolder, which was already at hand. Measured on an RTX 3090 after the fixes: the Q4_K_M transformer binds as QwenImageTransformer2DModel (13.23B params, 85s) and the assembled QwenImageEditPlusPipeline is 22.68 GiB resident with a 4-bit text encoder -- which fits 24 GB but leaves 1.32 GiB and OOMs on 390 MiB of activations. That is a deployment setting, not a code one, and it is why the figure is measured here rather than asserted. Full suite 4558 passed with the same 15 pre-existing failures as 0.4.91 (diff empty). Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
Reported from a client against nesaorg/PonyRealism-Uncensored as three symptoms -- "the edit result is garbage", "size is ignored", and a 500. Two defects, neither of which announced itself. The edit was never an edit. _load_img2img_pipeline picked its class by looking for 'xl', 'sdxl', 'stable-diffusion' or 'sd' in the MODEL NAME, and "nesaorg/ponyrealism-uncensored" contains none of them, so it fell through to the generic DiffusionPipeline -- which faithfully honours the checkpoint's model_index.json, and that one declares StableDiffusionXLPipeline: the text-to-image class. Every diffusers __call__ ends in **kwargs (it carries the deprecated callback pair), so `image` and `strength` were accepted, dropped, and the pipeline rendered a fresh picture from the prompt alone at its default 1024x1024. One defect, all three symptoms: garbage (a prompt saying "add X, keep everything else" rendered from scratch), size ignored (1024x1024 is the text-to-image default, not the source's shape), and 200 OK throughout. stable-diffusion-xl-base-1.0 worked only because its NAME contains "xl". Both loaders now ask AutoPipelineForImage2Image / AutoPipelineForInpainting, which read the declared class and substitute the one for this task. A guard backs it up, on cached pipelines as well as freshly loaded ones -- an install already holding a text-to-image pipeline would otherwise keep using it until it restarted. A pipeline with no `image` input is converted with from_pipe (the same components, no second copy in VRAM) or refused with 501. A 200 carrying a picture of something else is the one outcome worth ruling out. The 500 was a cache key used as a repo id. These endpoints keep task-specific pipelines in MultiModelManager.models under "img2img:<repo>", and the manager's fuzzy already-loaded pass matched one by its last path segment, then assigned the whole key to resolved_name -- so from_pretrained was asked for a repo called "img2img:nesaorg/PonyRealism-Uncensored" and answered "Repo id must use alphanumeric chars" about a model that loads perfectly well. That pass now skips task namespaces, which the endpoints look up by exact key anyway. Writing the test for that found two more namespaces with the identical latent bug -- depth: and segment: -- so _TASK_NAMESPACES covers four, and an invariant test asserts every task prefix the endpoints invent is declared. A future endpoint cannot reintroduce this quietly. Not fixed, because it is not a bug: /v1/images/edits is plain img2img, so "add X and keep everything else exactly the same" cannot work however correct the pipeline is. That needs a mask through /v1/images/inpaint, or an instruction-edit model exposed with a capability clients can prefer. 16 tests; full suite 4537 passed with the same 15 pre-existing failures as 0.4.91 (diff empty). Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
clip_skip was mapped onto diffusers' argument of the same name, and that argument counts something else. Its SDXL path reads hidden_states[-2] # clip_skip is None hidden_states[-(clip_skip + 2)] # otherwise so diffusers' clip_skip is the number of layers skipped BEYOND the penultimate one SDXL already uses. Every model card, A1111, ComfyUI and InvokeAI mean something simpler by the name: 1 is the final layer, 2 the penultimate, n the n-th from the end. So a request asking for 2 -- which is what the booru-tag SDXL families are trained at, and what their model cards all say -- read hidden_states[-4] instead: two layers too deep, the equivalent of clip-skip 4, which on a Pony-family model is the difference between a photograph and mush. It was inconsistent as well as wrong: the same 2 meant [-4] on SDXL and [-3] on SD 1.x, two depths from one number. The mapping now happens here, once, in the sense users mean, and clip_skip 2 reaches the same layer whichever family the model belongs to. "Mirrors diffusers exactly" was the mistake in 0.4.89: mirroring is the wrong goal for a field a caller fills in. SDXL's pooled conditioning vector is also handled properly now. The UNet cannot run without it and diffusers refuses prompt_embeds that arrive without one, so handing it half the set is a ValueError raised in check_inputs -- an instant 500, before any denoising. Where a pipeline does not offer the vector, it is rebuilt the way CLIP produces it (pooled at the end-of-text token, then projected through text_projection) rather than giving up the weighting and the window count this module exists for; only when even that is impossible does the encoder stand down and let the pipeline encode the prompt itself. The invalid combination is no longer constructible: either all four keys or None. A 500 from /v1/images/edits, /inpaint or /outfit now logs its traceback. The cause used to reach the caller alone, so the log showed the request and then the status, and a failure there could not be diagnosed afterwards at all. Test note: test_a_400_is_not_relabelled_as_a_server_fault scanned a fixed 250-character window for the HTTPException pass-through above each catch-all; the new log line pushed that to 316-382 and broke a passing check on correct code. It now asserts structure instead of proximity -- the pass-through must be the nearest handler inside the same function, with the logging between it and the raise -- which also catches it drifting into a different function. 58 tests in the prompt file; full suite 4521 passed with the same 15 pre-existing failures as 0.4.91 (diff empty). Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
Three things the image endpoints accepted and then quietly did something else with. size. A size that did not parse fell through to the default, so "1024" or "1k x 1k" generated a square nobody asked for and the response looked like a success. A side that was not a multiple of 8 went to the pipeline as-is, where the VAE cannot represent it. Nothing capped it either, so 4096x4096 was accepted and came back as a CUDA OOM -- which reads like a busy GPU rather than a size the caller chose. Now: a 400 quoting what was sent, a 400 for a zero dimension (snapping it up would answer an uninitialised client variable with an 8x8 image), snapping to the grid with a log line, and a 400 naming image.max_side / image.max_pixels when it is over the ceiling. "auto" is accepted as in the OpenAI API, and the dead `width != width` NaN guard on a value that was already an int is gone. The archive records the size actually generated alongside the one requested. /v1/images/inpaint no longer defaults to 1024x1024, which squashed every non-square photo and pulled the mask out of alignment with it -- worse than the same default on /v1/images/edits, where it was fixed in 0.4.86. Omitted now keeps the source's shape. Textual inversions. easynegative, bad-hands-5 and the rest are vectors living in CLIP's embedding table under a word of their own, and until the file is loaded that word is an ordinary unknown token -- so a large part of the ecosystem's negative prompting asked for nothing at all. They load from the request (embeddings: [...]), from the model's config (textual_inversion), or by filename from image.embeddings_dir, which is how A1111 works and what a prompt copied off a model card assumes. Two details decide whether this works at all: an SDXL-format embedding holds a clip_l and a clip_g tensor that must go into the matching tokenizer/encoder pair, and a multi-vector embedding occupies several tokens (tok, tok_1, tok_2...) that the prompt has to be expanded into -- without it an eight-vector embedding contributes one vector and appears inert. A name the caller gave that will not load is a 400; a bad file in the directory is a line in the log, because one file should not break every request. /v1/images/outfit goes through the same encoder now, so the weight grammar and clip_skip reach it too. Its per-frame video path encodes the prompt once for the clip instead of once per frame. And a 400 is no longer reported as a failed backend. On the first request for a model that is not loaded yet, the generate call sits inside the try that falls back from diffusers to sd.cpp, so a rejected request was caught as a load failure and answered with a report about which backends failed. The three edit endpoints did the same through their catch-alls. All four re-raise HTTPException untouched. Found by the tests while writing them: the embeddings directory was searched by the renamed trigger word rather than the filename, so "easynegative:myneg" never resolved, and "768X512" did not parse. 64 new tests; full suite 4475 passed with the same 15 pre-existing failures as 0.4.89 (diff empty). Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
A CLIP text encoder reads 77 tokens, two of them markers. Handing a longer prompt to a diffusers pipeline does not fail: the tokenizer truncates it and the model renders what fits. The booru-tag fine-tune families (Pony, Illustrious, NoobAI) open with a run of quality tags, so the part that got dropped was the subject -- and the request answered 200 with an image, which is why it was easy to miss. Image prompts are now encoded here rather than by the pipeline, which is what makes the rest possible: * as many 75-token windows as the prompt needs, concatenated -- no limit * the A1111 weight grammar: (tag:1.3), [tag], bare (tag) = 1.1, nesting multiplies, \( escapes. Per-token scaling with the window mean restored, so emphasis does not drag the brightness along with it * BREAK, which closes the current window instead of blending tags meant for different subjects * clip_skip, mapped to diffusers' own convention -- SDXL counts from the penultimate layer, SD 1.x from the final output and needs the layer norm applied by hand. Getting that backwards shifts every image Three places were dropping the negative prompt outright. The sd.cpp path hardcoded negative_prompt='', so for every GGUF image model whatever the caller asked to avoid stopped at the API -- half the idiom, since the quality tags have a negative half (score_4, score_5, score_6). /v1/images/edits and /v1/images/inpaint had no field for one at all, so an edit could only ever be steered positively. All three now pass it, and take clip_skip and prompt_weighting with it. clip_skip and prompt_weighting are also per-model and server-wide settings, because a client speaking the plain OpenAI image API has no field to ask for clip_skip and a model trained at 2 needs it on every request. Request beats model beats global. Weighting defaults on, so a literal '(' in prose now weighs 1.1 -- the ecosystem's convention, with \( and prompt_weighting:false as the way out. What that cannot be allowed to change is prompts that already worked: against a real CLIPTextModel a short unweighted prompt encodes bit-identically to what diffusers produced, because CLIP pads with its own end-of-text token, so [bos]+toks+[eos]+pad... and [bos]+toks+pad...+[eos] are the same ids. The test records that reasoning, so a tokenizer where it stops holding is caught. Flux, SD3 and Z-Image carry a T5 with its own limit and conventions and keep their pipeline's native text path. 49 tests; full suite 4410 passed with the same 15 pre-existing failures as 0.4.88 (diff empty). Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
- Oct 23, 2026
-
-
Stefy Lanza (nextime / spora ) authored
Measured against the production pod: a reasoning model asked for 64 tokens spent all 64 inside its thought, returned NO content, and the response said finish_reason "stop". max_tokens=64 finish=stop content=0 chars '' reasoning=262 max_tokens=1500 finish=stop content=13 chars 'XMU-5635-FB' reasoning=448 Two layers were each sure the other knew better. The proxy hardcoded finish_reason = "stop" and never read choices[0].finish_reason from the remote; OpenAIFormatter then hardcoded it again, so even a caller that worked the reason out had it overwritten on the way to the client. vLLM had been saying "length" all along. It matters because "length" is the one reason a client can act on. An answer cut off at the limit is half a sentence, and told it stopped normally the client has nothing to retry on and no sign anything is missing. On a reasoning model the thought is generated FIRST and spends the same budget, so a tight limit returns an EMPTY answer that claims to have finished of its own accord -- which is how this surfaced, as a retrieval test scoring zero for reasons that had nothing to do with what it was measuring. The remote's word is now carried through the per-call sink beside reasoning, tool_calls and logprobs (get_last_finish_reason, buffered and streamed), the formatter accepts the caller's reason and keeps "stop" only as the default, and a tool call still wins -- that describes the answer's shape, and a truncation after the call was formed must not relabel it. Fixed at all five response exits, not just the one it was found at: streamed chat (both terminal-chunk paths), buffered chat, and streamed and buffered /v1/completions. A local backend is never told a reason, so the budget decides: generation that used every token it was allowed was cut off, not finished. Drawn only when a limit was actually set and reached -- never from a token count alone, or every long completion would be mislabelled. Tests verified against the reverted files: assert None == 'length' for the backends, TypeError: unexpected keyword 'finish_reason' for the formatter. Co-Authored-By:Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
- Oct 22, 2026
-
-
Stefy Lanza (nextime / spora ) authored
v0.4.67 asked each pod for /admin/api/gpu-stats. Pods answer 404: a pod runs the SLIM app — /healthz, /v1/models, /internal/engine-state — and no admin API at all. Built on an assumption; the live check caught it. Probe the pod before choosing its endpoint. /internal/engine-state already carries a `vram` block: used, free and total per card with the GPU's name, built context-free and cached 4s there. It is what the front health-polls, so every pod has it and it costs the pod nothing extra. Verified against a live A40: 43.8/48 GB with qwen38 resident. The pod reports FREE, not used — that is what a context-free query returns — so used is the difference. Inverting that would show an idle pod as full. Utilisation and temperature added AT THE SOURCE: nvidia-smi now asks for utilization.gpu and temperature.gpu in the same call as the memory query rather than paying for a second one (a saturated card answers slowly enough that one call matters), with the pynvml equivalent, and both ride into engine-state's vram.devices. Absent or "[N/A]" is left out, never zeroed: a zero draws a bar that reads as "idle" when the truth is "not known". So VRAM works on every pod already running; utilisation needs the next pod image, and until then that column shows a dash — which is the degradation the page was built for. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
`enable_thinking: false` came back still thinking, verified on a pod running the v0.4.65 image with --reasoning-parser qwen3 (the other four checks passed: reasoning_content populated, content clean, and streamed thinking starting 0.4s in against the answer at 2.8s). generate_chat negotiates optional kwargs by calling the backend and retrying on TypeError. Every variant that carried `passthrough` ALSO carried `enable_thinking`, which a proxy backend does not accept -- so those variants raised TypeError, and the variant that survived had dropped the passthrough with it. A caller's `chat_template_kwargs` therefore never reached a pod at all. `enable_thinking` was merely the visible case: OCR's `continue_final_message` would have been dropped on a pod the same way. `_lp` and `_pt` are already SIGNATURE-gated -- empty for a backend that cannot take them, so they never raise TypeError. Only enable_thinking and the penalties are negotiated that way, so only those are peeled off now, and the passthrough is dropped last rather than first. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
v0.4.64 aimed one layer too high. Measured against the pod directly: content = 'We need answer in Italian... 17 x 23 = ... **391**.' reasoning_content = '' has </think> = False finish = stop The thought and the answer come back as ONE string with no separator of any kind. </think> is a SPECIAL TOKEN and vLLM decodes with skip_special_tokens, so the boundary is deleted before the text exists. No parsing downstream can split on a boundary that was removed -- the earlier reading, "extraction fails on the bare </think>", was simply wrong. coderai never passed --reasoning-parser to vLLM, for any model, ever. With it vLLM splits the two at the TOKEN level, before the tag is discarded, and reports reasoning_content as its own field; the v0.4.64 side channel and delta re-tagging then carry it to the client. Chosen from the template like the tool parser, and only for a model whose template says it thinks -- a reasoning parser on a model that does not think waits for a tag that is never coming. An explicit vllm.reasoning_parser wins, "off" included, per model or globally. AND A REGRESSION FROM v0.4.64, fixed here: with the gate switched on and no close tag ever arriving, a streamed reply was held back in full as "thinking" and the client was sent an empty message -- worse than the leak it replaced (reasoning=229 chars, content=0, measured on diogene-alpha and qwen38 alike). An unclosed thought now flushes as CONTENT. A thought truly cut off mid sentence then reads as the answer, which is untidy; an empty answer is a lost reply. With the server separating the channel this path stops being reached. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
From ONE pod, ONE set of weights, behind two aliases: qwen38 -> clean answer diogene-alpha -> "The user is asking to list hearings from 2026-10-21 to..." The entire chain of thought went to the client as the answer. Four breaks: 1. THE GATE ASKED THE NAME. _reasoning_active was a regex (qwen3|qwq| deepseek-r1|...) over the request's model name: `qwen3` matches "qwen38" and not "diogene-alpha", so one alias of one model separated its thought and the other did not. It is read from the template now -- whose generation prompt ends with '<think>\n', so the model starts INSIDE the thought and emits a bare </think>, the form that leaks when nothing looks for it. The regex stays as a last resort for a template that cannot be read. 2. THE BUFFERED FORMATTER TOOK NO `reasoning`. Where the thought WAS separated it was then dropped: no leak, but no thinking either. litellm's Message has no such field, so it is attached after the dump rather than lost -- under both spellings, since Qwen's own template reads `reasoning_content`. 3. THE PROXY HOP CARRIED NEITHER FORM. Buffered, the remote sends reasoning_content beside an already-cleaned content, which the string contract cannot hold: get_last_reasoning(), like get_last_tool_calls(). Streamed, it sends reasoning DELTAS, and dropping them meant a client saw nothing at all until the answer began -- so the <think>/</think> tags go back on as they arrive and the caller's existing reasoning gate splits them live. An all-thinking stream leaves the tag open on purpose: the gate flushes an unclosed thought as reasoning, and there is no content for a close tag to precede. 4. THINKING COULD NOT BE TURNED OFF. enable_thinking defaulted to False, so "unset" and "explicitly off" were the same value, and the field sat in _CHAT_CONSUMED_FIELDS while no backend forwarded it. It defaults to None now: unset sends nothing and the model keeps its own default (a thinking model thinks), an explicit value travels as chat_template_kwargs. That needed the POD backend to gain passthrough at all -- the local vLLM backend has forwarded unknown fields for releases, a pod got none of them, so chat_template_kwargs could never reach a pod-served model. ChatMessage silently dropped `reasoning_content`, so a previous turn's thinking never reached the template that re-renders it inside <think>. Accepted and forwarded now. The chat page hardcoded stream:false, so thinking could never arrive live there, and it read only message.content. It has `stream` and `thinking` toggles (both on), renders the thought in a block that fills AS IT ARRIVES and collapses when the answer starts, and keeps it in the history it replays. Streaming is the request's choice, not forced: cleared, it takes the buffered path exactly as before. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-
Stefy Lanza (nextime / spora ) authored
The admin model dialog has had a "Chat template parser" dropdown all along (auto/llama/mistral/chatml/phi/gemma/qwen). It is saved on the model entry, and seed_model_env carries `parser` in its keep-list so it travels to rented pods. NOTHING EVER READ IT: choosing a parser for a model changed nothing anywhere, and selection stayed a substring match on the name -- with a second, separate guess in the browser (_autoDetectParser). A tool call is text in the format the model was trained on, and the chat template is where that format is written down, so the style belongs to the model. It now resolves, strongest first: 1. the model's own `parser` setting -- that dropdown now does something; 2. the global default, read the way resolve_gemma_tool_mode reads its own; 3. the model's chat template; 4. the name. The template reading moves out of vllm_worker into codai/models/chat_style.py, with ONE style table mapping each style to both consumers -- vLLM's --tool-call-parser and coderai's own parser class -- so the two choices cannot drift apart. Qwen XML, Hermes JSON, GLM <arg_key>, Mistral [TOOL_CALLS], Llama <|python_tag|> and DeepSeek's call tokens. Cached per model path: a model's template cannot change under us, and this is consulted per request on some paths. The template does NOT override a name that matched a family. A per-family parser carries more than the tool syntax -- gemma's token repair, Qwen's repetition guard -- so one template marker should not displace a family that matched. It decides where the name places the model in no family, which fell to the catch-all before: _family_name only resolves an alias for an ADAPTER, so a plain alias like `lisa` named no family and got the generic parser even though its entry says exactly where the weights are. A value the dispatcher does not know falls back to detection rather than silently disabling tool calls, and an unreadable template changes nothing -- not knowing is survivable, inventing a parser is not. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
-