answer to the name the catalogue publishes (v0.5.7)

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: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
parent 30f224e3
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.5.6" __version__ = "0.5.7"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -517,12 +517,13 @@ async def _run_transcription_inner( ...@@ -517,12 +517,13 @@ async def _run_transcription_inner(
return point so diarization + word timestamps are handled uniformly.""" return point so diarization + word timestamps are handled uniformly."""
# Check if the requested model maps to a configured whisper-server instance first. # Check if the requested model maps to a configured whisper-server instance first.
# Try alias round-robin resolution before direct ID lookup. # Try alias round-robin resolution before direct ID lookup.
whisper_model_id = multi_model_manager.resolve_whisper_alias_model_id(model) # Resolve by every name that identifies the same runner: its id, an alias,
whisper_server = ( # or the weights path /v1/models advertises. Asking by the advertised id
multi_model_manager.whisper_servers.get(whisper_model_id) # used to match nothing and fall through to the HuggingFace loader, which
if whisper_model_id is not None # reported a whisper.cpp file as a missing hub repo.
else multi_model_manager.whisper_servers.get(model) whisper_model_id = multi_model_manager.resolve_whisper_server_key(model)
) whisper_server = (multi_model_manager.whisper_servers.get(whisper_model_id)
if whisper_model_id else None)
if whisper_server is not None: if whisper_server is not None:
await asyncio.to_thread( await asyncio.to_thread(
multi_model_manager.request_model, requested_model=model, model_type="audio") multi_model_manager.request_model, requested_model=model, model_type="audio")
......
...@@ -2349,6 +2349,58 @@ class MultiModelManager: ...@@ -2349,6 +2349,58 @@ class MultiModelManager:
self._whisper_alias_counters[name] = idx + 1 self._whisper_alias_counters[name] = idx + 1
return self.whisper_servers.get(ids[idx]) return self.whisper_servers.get(ids[idx])
def resolve_whisper_server_key(self, name: str) -> Optional[str]:
"""The registered whisper-server runner that serves ``name``, or None.
A whisper-server model is described by TWO models.json rows: a runner
(``model_path`` + ``server_path`` + ``port``, registered under its
``id``) and a model row naming the weights (``path``, no
``model_path``), which is skipped at registration as "config, not a
runner". But it is the model row that gives ``/v1/models`` its id, so a
client reading the catalogue asks for the weights' name -- and that name
matched no runner key, no alias, and so no whisper-server at all. The
request then fell through to the generic audio path, which tried to
treat ``whisper-large-v3-turbo-q8_0.bin`` as a HuggingFace repo id:
500 ... is not a local folder and is not a valid model identifier
listed on 'https://huggingface.co/models'
So every name that identifies the same weights resolves here: the
runner's own id, an alias (round-robin across its runners), and the
weights path by full path, basename, or basename without extension.
"""
if not name:
return None
if name in self.whisper_servers:
return name
mid = self.resolve_whisper_alias_model_id(name)
if mid and mid in self.whisper_servers:
return mid
import os as _os
want = str(name)
wants = {want, _os.path.basename(want)}
wants.add(_os.path.splitext(_os.path.basename(want))[0])
wants = {w for w in wants if w}
# Prefer an idle runner, so naming the weights load-balances the way an
# alias does instead of always landing on the first one registered.
matches = []
for key, wsm in self.whisper_servers.items():
mp = getattr(wsm, "_model_path", None)
if not mp:
continue
cands = {str(mp), _os.path.basename(str(mp)),
_os.path.splitext(_os.path.basename(str(mp)))[0]}
if wants & cands:
matches.append(key)
if not matches:
return None
try:
idle = [k for k in matches if self.whisper_servers[k].is_idle()]
except Exception:
idle = []
choices = idle or matches
return choices[0] if len(choices) == 1 else random.choice(choices)
def resolve_whisper_alias_model_id(self, name: str) -> Optional[str]: def resolve_whisper_alias_model_id(self, name: str) -> Optional[str]:
"""Return an idle whisper-server model id for an alias, else a random one.""" """Return an idle whisper-server model id for an alias, else a random one."""
ids = self.whisper_aliases.get(name) ids = self.whisper_aliases.get(name)
......
...@@ -233,3 +233,22 @@ def test_an_explicit_zero_layer_count_still_reserves_nothing(): ...@@ -233,3 +233,22 @@ def test_an_explicit_zero_layer_count_still_reserves_nothing():
est = _estimate({"path": "/AI/guffcache/x-Q4_K_P.gguf", est = _estimate({"path": "/AI/guffcache/x-Q4_K_P.gguf",
"used_vram_gb": 12.0, "n_gpu_layers": 0}) "used_vram_gb": 12.0, "n_gpu_layers": 0})
assert est == pytest.approx(0.0) assert est == pytest.approx(0.0)
def test_a_stored_measurement_of_zero_is_not_a_measurement():
"""An estimate of 0 means 'nothing measured', never 'measured as free' --
on every path, not just the configured field."""
est = _estimate({"path": "/AI/guffcache/x-Q4_K_P.gguf",
"used_vram_gb": 0, "measured_vram_gb": 0,
"load_in_4bit": True, "n_gpu_layers": -1})
assert est == 0.0 or est > 0.0 # must not crash; 0 only if unsizable
def test_zero_does_not_win_even_with_force_vram_update():
"""force_vram_update makes the measurement authoritative -- a zero
'measurement' must not become authoritative with it."""
est = _estimate({"path": "/AI/guffcache/x-Q4_K_P.gguf",
"used_vram_gb": 14.0, "measured_vram_gb": 0,
"force_vram_update": True,
"load_in_4bit": True, "n_gpu_layers": -1})
assert est >= 14.0, "a zero measurement must not override a real config value"
"""A whisper-server model is advertised under a name that routed to nothing.
Two models.json rows describe one whisper-server: a RUNNER (model_path +
server_path + port, registered under its id) and a MODEL row naming the weights
(path, no model_path), which registration skips. ``/v1/models`` takes its id
from the model row, so the catalogue advertised an id that matched no runner --
and the request fell through to the generic audio path, which reported the
whisper.cpp file as a missing HuggingFace repo.
"""
import types
import pytest
from codai.models.manager import MultiModelManager
class _WSM:
def __init__(self, model_path, idle=True):
self._model_path = model_path
self._idle = idle
def is_idle(self):
return self._idle
def _mgr(servers, aliases=None):
m = object.__new__(MultiModelManager)
m.whisper_servers = dict(servers)
m.whisper_aliases = dict(aliases or {})
return m
GGUF = "/AI/guffcache/whisper-large-v3-q8_0.gguf"
BIN = "/AI/guffcache/whisper-large-v3-turbo-q8_0.bin"
def test_the_runners_own_id_still_resolves():
m = _mgr({"whisper3": _WSM(BIN)})
assert m.resolve_whisper_server_key("whisper3") == "whisper3"
def test_an_alias_still_resolves():
m = _mgr({"whisper0": _WSM(GGUF), "whisper1": _WSM(GGUF)},
{"whisper": ["whisper0", "whisper1"]})
assert m.resolve_whisper_server_key("whisper") in ("whisper0", "whisper1")
def test_the_advertised_basename_resolves():
"""The reported bug, exactly: this is the id /v1/models hands a client."""
m = _mgr({"whisper3": _WSM(BIN)})
assert m.resolve_whisper_server_key("whisper-large-v3-turbo-q8_0.bin") == "whisper3"
def test_the_advertised_full_path_resolves():
m = _mgr({"whisper3": _WSM(BIN)})
assert m.resolve_whisper_server_key(BIN) == "whisper3"
def test_the_basename_without_extension_resolves():
m = _mgr({"whisper3": _WSM(BIN)})
assert m.resolve_whisper_server_key("whisper-large-v3-turbo-q8_0") == "whisper3"
def test_naming_the_weights_picks_an_idle_runner_of_the_several_sharing_them():
"""Three runners serve one file; naming the file should balance, not always
land on whichever registered first."""
m = _mgr({"w0": _WSM(GGUF, idle=False),
"w1": _WSM(GGUF, idle=True),
"w2": _WSM(GGUF, idle=False)})
assert m.resolve_whisper_server_key("whisper-large-v3-q8_0.gguf") == "w1"
def test_a_different_model_does_not_resolve():
m = _mgr({"whisper3": _WSM(BIN)})
assert m.resolve_whisper_server_key("crisperwhisper") is None
assert m.resolve_whisper_server_key("whisper-large-v3-q8_0.gguf") is None
def test_an_empty_name_resolves_to_nothing():
m = _mgr({"whisper3": _WSM(BIN)})
assert m.resolve_whisper_server_key("") is None
assert m.resolve_whisper_server_key(None) is None
def test_a_runner_without_a_model_path_is_not_matched_by_accident():
m = _mgr({"broken": _WSM(None)})
assert m.resolve_whisper_server_key("whisper-large-v3-q8_0.gguf") is None
def test_transcriptions_uses_the_resolver():
"""Guard the call site: the old one asked whisper_servers directly with the
client's string, which is what missed."""
import inspect
from codai.api import transcriptions
src = inspect.getsource(transcriptions._run_transcription_inner)
assert "resolve_whisper_server_key" in src
assert "whisper_servers.get(model)" not in src
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment