Studio: hint at Model auto-switch in the OpenAI "No model loaded" 400 (#7006)

This commit is contained in:
oobabooga 2026-07-10 17:48:27 -03:00 committed by GitHub
commit 7bfa209623
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 79 additions and 7 deletions

View file

@ -3344,6 +3344,21 @@ def _automatic_model_load_may_run() -> bool:
return get_openai_auto_switch_enabled() or get_auto_unload_idle_seconds() > 0
def _no_model_loaded_detail(base: str) -> str:
"""Append a pointer to the opt-in auto-switch toggle to a "no model loaded"
error, but only when it's off. Auto-switch (default off) cold-loads a
requested downloaded GGUF, so an off toggle is the usual reason a request
naming a listed model still 400/503s; surface the fix. With it on the name
simply didn't resolve to a local GGUF, so the hint would mislead and is omitted."""
from utils.openai_auto_switch_settings import get_openai_auto_switch_enabled
if get_openai_auto_switch_enabled():
return base
return base + (
" Or enable Model auto-switch (Settings > API) to load a requested model automatically."
)
async def _maybe_auto_switch_model(
requested_model: Optional[str],
fastapi_request: Request,
@ -6451,7 +6466,7 @@ async def openai_chat_completions(
if not backend.active_model_name:
raise HTTPException(
status_code = 400,
detail = "No model loaded. Call POST /inference/load first.",
detail = _no_model_loaded_detail("No model loaded. Call POST /inference/load first."),
)
# Clean public id so the response never echoes a local path; the audio
# branch below receives this sanitized label too.
@ -9184,7 +9199,7 @@ async def openai_completions(request: Request, current_subject: str = Depends(ge
if not llama_backend.is_loaded:
raise HTTPException(
status_code = 503,
detail = "No GGUF model loaded. Load a GGUF model first.",
detail = _no_model_loaded_detail("No GGUF model loaded. Load a GGUF model first."),
)
if not isinstance(body, dict):
# Re-read to re-raise a malformed-body error (post-503, pre-feature behavior);
@ -9400,7 +9415,7 @@ async def openai_embeddings(request: Request, current_subject: str = Depends(get
if not llama_backend.is_loaded:
raise HTTPException(
status_code = 503,
detail = "No GGUF model loaded. Load a GGUF model first.",
detail = _no_model_loaded_detail("No GGUF model loaded. Load a GGUF model first."),
)
if not isinstance(body, dict):
# Re-read to re-raise a malformed-body error (post-503, pre-feature behavior);
@ -10140,7 +10155,7 @@ async def _responses_stream(
# so the client sees a useful error instead of a dangling stream.
raise HTTPException(
status_code = 400,
detail = (
detail = _no_model_loaded_detail(
"Streaming /v1/responses requires a GGUF model loaded via "
"llama-server. Use non-streaming /v1/responses, "
"/v1/chat/completions, or load a GGUF model."
@ -11379,7 +11394,7 @@ async def anthropic_count_tokens(
if not llama_backend.is_loaded:
raise HTTPException(
status_code = 503,
detail = "No GGUF model loaded. Load a GGUF model first.",
detail = _no_model_loaded_detail("No GGUF model loaded. Load a GGUF model first."),
)
# Same Anthropic → OpenAI translation as anthropic_messages: system is
@ -11452,7 +11467,7 @@ async def anthropic_messages(
if not llama_backend.is_loaded and not _automatic_model_load_may_run():
raise HTTPException(
status_code = 503,
detail = "No GGUF model loaded. Load a GGUF model first.",
detail = _no_model_loaded_detail("No GGUF model loaded. Load a GGUF model first."),
)
# max_tokens is a required field on the Anthropic Messages API; real Anthropic
@ -11503,7 +11518,7 @@ async def anthropic_messages(
if not llama_backend.is_loaded:
raise HTTPException(
status_code = 503,
detail = "No GGUF model loaded. Load a GGUF model first.",
detail = _no_model_loaded_detail("No GGUF model loaded. Load a GGUF model first."),
)
# Advertised repo id after an auto-switch load, else a clean public id, never

View file

@ -3037,3 +3037,60 @@ def test_acquire_swap_gate_is_cancellation_safe():
inference_route._auto_switch_process_lock.release()
asyncio.run(asyncio.wait_for(main(), timeout = 5))
def test_no_model_loaded_detail_appends_hint_only_when_off(monkeypatch):
# The "no model loaded" errors point at the opt-in auto-switch toggle so a
# request naming a listed-but-unloaded model is self-explanatory -- but only
# when it's off. With it on the name simply didn't resolve, so no hint.
base = "No GGUF model loaded. Load a GGUF model first."
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: False)
off = inference_route._no_model_loaded_detail(base)
assert off.startswith(base)
assert "Model auto-switch" in off and "Settings > API" in off
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: True)
assert inference_route._no_model_loaded_detail(base) == base
def _run_responses_stream_no_model(monkeypatch, *, enabled, active_model_name):
# Drive _responses_stream's GGUF-not-loaded guard: llama backend unloaded,
# inference backend maybe holding a non-GGUF model. Returns the 400 detail.
from fastapi import HTTPException
from models.inference import ResponsesRequest, ChatMessage
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: enabled)
monkeypatch.setattr(
inference_route, "get_llama_cpp_backend", lambda: _FakeBackend(loaded_id = None)
)
monkeypatch.setattr(
inference_route,
"get_inference_backend",
lambda: type("_B", (), {"active_model_name": active_model_name})(),
)
payload = ResponsesRequest(model = "unsloth/Qwen3.5-4B-GGUF", stream = True)
messages = [ChatMessage(role = "user", content = "hi")]
with pytest.raises(HTTPException) as exc:
asyncio.run(inference_route._responses_stream(payload, messages, None))
assert exc.value.status_code == 400
return exc.value.detail
def test_responses_stream_hint_matches_toggle_regardless_of_active_model(monkeypatch):
# Streaming /v1/responses shares the GGUF-only 400 with the other "no model
# loaded" sites, so the auto-switch hint attaches whenever the toggle is
# off -- including while a non-GGUF model is active, since auto-switch
# evicts it to load a resolved GGUF (_maybe_auto_switch_model's resolver
# branch has no active-model guard, unlike its reload-stash branch). Only
# the toggle being on suppresses it.
hinted = _run_responses_stream_no_model(monkeypatch, enabled = False, active_model_name = None)
assert "Model auto-switch" in hinted
on = _run_responses_stream_no_model(monkeypatch, enabled = True, active_model_name = None)
assert "Model auto-switch" not in on
non_gguf_loaded = _run_responses_stream_no_model(
monkeypatch, enabled = False, active_model_name = "unsloth/Llama-3.2-1B-Instruct"
)
assert "Model auto-switch" in non_gguf_loaded