Skip to content

Studio: opt-in OpenAI /v1 model auto-switch and idle keep-warm#6392

Merged
wasimysaid merged 88 commits into
mainfrom
studio-openai-auto-switch-model
Jul 1, 2026
Merged

Studio: opt-in OpenAI /v1 model auto-switch and idle keep-warm#6392
wasimysaid merged 88 commits into
mainfrom
studio-openai-auto-switch-model

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

The OpenAI-compatible endpoints (/v1/chat/completions, /v1/completions, /v1/messages) serve whichever GGUF is currently loaded and ignore the request model field, so an OpenAI SDK client that changes model never reloads. This adds an opt-in setting that switches to a different downloaded local GGUF named by the request, similar to how llama-swap routes by the request model.

Behavior

  • Off by default. Existing drop-in behavior is unchanged until enabled.
  • When enabled and the request model names a downloaded local GGUF different from the loaded one, Studio loads it before serving, reusing the /load path so its dedup, tensor fallback, and threading apply.
  • Unknown or arbitrary model strings still serve the loaded model, so drop-in OpenAI compatibility is preserved and no remote download is ever triggered (only already-downloaded local models are eligible).
  • An optional auto_unload_idle_seconds setting unloads the model after it has been idle for that many seconds to free VRAM. A pure-ASGI middleware tracks in-flight inference requests so a long stream is never unloaded mid-response. Default 0 (disabled).
  • The idle TTL can also be set at startup with the UNSLOTH_MODEL_IDLE_TTL env var. Unlike the stored setting (which stays gated on auto-switch), the env value is a standalone default that enables idle-unload even with auto-switch off, for headless or container deploys. An explicit UI/API value still overrides it, and the settings GET reflects the env default when nothing is stored.

Both settings live in the app_settings store and are exposed via GET/PUT /api/settings/openai-auto-switch.

A manual /load or /unload stays a deliberate action and tears down the current model immediately; only the automatic paths (idle-unload and auto-switch) defer to an active request. When Studio is driven over the API (sk-unsloth key auth), starting training is refused with 409 while a request is in flight, since training frees VRAM by unloading the chat model; the UI (session auth) still starts training and coexists or frees VRAM as before.

Testing

  • studio/backend/tests/test_openai_auto_switch.py: flag off does not load; unknown model falls through; already-loaded does not reload; a known unloaded model switches once with the right LoadRequest; two concurrent requests for the same model load once; load failure propagates; resolver splits repo:variant and matches case-insensitively; settings coercion; the UNSLOTH_MODEL_IDLE_TTL env override (standalone default, stored value overrides, invalid ignored); API-initiated training defers to active inference while the UI does not.
  • Verified live with the openai Python SDK: setting off, model=<other> served the loaded model unchanged; setting on, model=<downloaded gguf> switched the backend to a new llama-server, and model=gpt-4o-mini (unknown) still served the loaded model. Idle TTL unloaded the model after the configured seconds.

The OpenAI-compatible endpoints serve whichever GGUF is loaded and ignore the
request model field, so an OpenAI client that changes model never reloads. Add
an opt-in setting that, when a /v1 request names a downloaded local GGUF
different from the loaded one, loads it before serving by reusing the existing
/load path (its dedup, tensor fallback, and threading apply). Unknown names
still serve the loaded model, so drop-in compatibility is preserved and no
remote download is triggered.

Also add an optional idle auto-unload (TTL keep-warm): a pure-ASGI middleware
tracks in-flight inference requests so a stream is never unloaded mid-response,
and a lifespan loop unloads the model after the configured idle seconds. Both
settings default off and live in the app_settings store, exposed via
GET/PUT /api/settings/openai-auto-switch.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces opt-in controls for OpenAI-compatible model auto-switching and idle auto-unloading (TTL keep-warm) for local llama.cpp models. Feedback highlights several critical improvements: ensuring that newly loaded or changed models reset the activity timer to prevent premature unloading, fixing model resolution on Windows where drive letter colons interfere with variant partitioning, checking for string types during index building to avoid potential AttributeError exceptions, and comparing model variants in _maybe_auto_switch_model to allow switching between different quantizations of the same model.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +54 to +56
def _is_idle(ttl_seconds: float) -> bool:
with _lock:
return _inflight == 0 and (time.monotonic() - _last_active) >= ttl_seconds

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Add a helper function reset_activity_timer() to reset the _last_active timestamp when a model is newly loaded or changed. This is critical because _last_active is otherwise only updated during active inference requests. If a model is loaded after the server has been running or idle for longer than auto_unload_idle_seconds, the keep-warm loop will immediately unload the newly loaded model on its next iteration.

Suggested change
def _is_idle(ttl_seconds: float) -> bool:
with _lock:
return _inflight == 0 and (time.monotonic() - _last_active) >= ttl_seconds
def _is_idle(ttl_seconds: float) -> bool:
with _lock:
return _inflight == 0 and (time.monotonic() - _last_active) >= ttl_seconds
def reset_activity_timer() -> None:
global _last_active
with _lock:
_last_active = time.monotonic()

Comment on lines +94 to +111

async def idle_unload_loop(poll_seconds: float = 15.0) -> None:
"""Unload the loaded GGUF once idle past the configured TTL. Inert when off."""
from utils.openai_auto_switch_settings import get_auto_unload_idle_seconds
while True:
await asyncio.sleep(poll_seconds)
try:
ttl = get_auto_unload_idle_seconds()
if ttl <= 0 or not _is_idle(ttl):
continue
from routes.inference import get_llama_cpp_backend

backend = get_llama_cpp_backend()
# Re-check idle just before unload to shrink the arrival race window.
if backend.is_loaded and _is_idle(ttl):
await asyncio.to_thread(backend.unload_model)
logger.info("Idle auto-unload: freed GGUF after %ss idle", ttl)
except Exception as exc:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Update idle_unload_loop to track model load transitions and reset the activity timer using reset_activity_timer(). This ensures that newly loaded or changed models are not immediately unloaded if the idle TTL has already elapsed since the last request or server startup.

async def idle_unload_loop(poll_seconds: float = 15.0) -> None:
    """Unload the loaded GGUF once idle past the configured TTL. Inert when off."""
    from utils.openai_auto_switch_settings import get_auto_unload_idle_seconds

    was_loaded = False
    last_model = None

    while True:
        await asyncio.sleep(poll_seconds)
        try:
            from routes.inference import get_llama_cpp_backend
            backend = get_llama_cpp_backend()

            # Reset the idle timer when a model is newly loaded or changed
            is_loaded = backend.is_loaded
            model_id = backend.model_identifier if is_loaded else None
            if is_loaded:
                if not was_loaded or model_id != last_model:
                    reset_activity_timer()
                    was_loaded = True
                    last_model = model_id
            else:
                was_loaded = False
                last_model = None

            ttl = get_auto_unload_idle_seconds()
            if ttl <= 0 or not _is_idle(ttl):
                continue
            # Re-check idle just before unload to shrink the arrival race window.
            if backend.is_loaded and _is_idle(ttl):
                await asyncio.to_thread(backend.unload_model)
                logger.info("Idle auto-unload: freed GGUF after %ss idle", ttl)
                was_loaded = False
                last_model = None
        except Exception as exc:
            logger.debug("idle_unload_loop iteration failed: %s", exc)

Comment on lines +68 to +72
base, _, variant = requested.strip().partition(":")
loader_id = _index().get(base.strip().lower())
if loader_id is None:
return None
return loader_id, (variant.strip() or None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

On Windows, absolute paths starting with a drive letter (e.g., C:\models\model.gguf) contain a colon. Using partition(":") splits the string at the drive letter colon, resulting in base being "C" and variant being the rest of the path, which completely breaks model resolution for local paths on Windows.

We can fix this by using rpartition(":") to split from the right, and checking if the partitioned variant part contains path separators (\ or /). If it does, it means the colon was part of a Windows path rather than a variant separator.

Suggested change
base, _, variant = requested.strip().partition(":")
loader_id = _index().get(base.strip().lower())
if loader_id is None:
return None
return loader_id, (variant.strip() or None)
if ":" in requested:
base, _, variant = requested.strip().rpartition(":")
if "\\" in variant or "/" in variant:
base = requested.strip()
variant = ""
else:
base = requested.strip()
variant = ""
loader_id = _index().get(base.strip().lower())
if loader_id is None:
return None
return loader_id, (variant.strip() or None)

Comment thread studio/backend/routes/inference.py Outdated
Comment on lines +1531 to +1537
loaded = backend.model_identifier if backend.is_loaded else None
if loaded and loaded.lower() == target_id.lower():
return
async with _auto_switch_lock:
loaded = backend.model_identifier if backend.is_loaded else None
if loaded and loaded.lower() == target_id.lower():
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The _maybe_auto_switch_model function only compares the base model identifier (loaded.lower() == target_id.lower()) and completely ignores the requested variant. If a user requests a different variant of the currently loaded model (e.g., switching from Q4_K_M to Q8_0), the auto-switch check will falsely assume the model is already loaded and skip loading the new variant.

We should also compare the loaded variant (backend.hf_variant) with the requested variant.

    loaded = backend.model_identifier if backend.is_loaded else None
    loaded_variant = backend.hf_variant if backend.is_loaded else None
    if loaded and loaded.lower() == target_id.lower():
        if not variant or (loaded_variant and loaded_variant.lower() == variant.lower()):
            return
    async with _auto_switch_lock:
        loaded = backend.model_identifier if backend.is_loaded else None
        loaded_variant = backend.hf_variant if backend.is_loaded else None
        if loaded and loaded.lower() == target_id.lower():
            if not variant or (loaded_variant and loaded_variant.lower() == variant.lower()):
                return
References
  1. Ensure GGUF variant lookups are case-insensitive by lowercasing both the keys built from on-disk filenames and the lookup keys (e.g., variant.quant.lower()) to prevent detection failures due to casing mismatches.

Comment on lines +41 to +43
for key in (info.id, getattr(info, "model_id", None), getattr(info, "display_name", None)):
if key:
index.setdefault(key.strip().lower(), loader_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Defensively verify that key is a string before calling .strip().lower(). If info.id, model_id, or display_name is of an unexpected type (such as None or an integer), calling string methods on it will raise an AttributeError and crash the index building process.

Suggested change
for key in (info.id, getattr(info, "model_id", None), getattr(info, "display_name", None)):
if key:
index.setdefault(key.strip().lower(), loader_id)
for key in (info.id, getattr(info, "model_id", None), getattr(info, "display_name", None)):
if isinstance(key, str):
index.setdefault(key.strip().lower(), loader_id)

danielhanchen and others added 2 commits June 17, 2026 11:31
…load stamp

Follow-ups from review of the opt-in OpenAI auto-switch path:

1. Variant-aware dedup. _maybe_auto_switch_model compared only the repo id, so
   requesting another quant of the loaded repo (e.g. Q4_K_M loaded, Q8_0 asked)
   was served by the old quant. Compare hf_variant too, matching /load dedup.

2. Streaming /v1/responses now calls the auto-switch hook. It went straight into
   _responses_stream and only checked is_loaded, so stream=True could serve the
   old model or 400. Non-streaming already routed through chat completions; the
   hook is idempotent once loaded.

3. resolve_local_gguf tries an exact id match before splitting a trailing
   :VARIANT, so local ids that contain a colon (e.g. a Windows path) resolve
   instead of being cut at the drive letter.

4. Idle keep-warm stamps activity on a load/swap transition. _last_active was
   only refreshed by inference requests, so a model loaded after the server sat
   idle past the TTL could be unloaded before its first request.

Tests cover each case.
@danielhanchen

Copy link
Copy Markdown
Member Author

Thanks for the review. Pushed follow-ups in 98e8de4:

  • Variant-aware dedup: _maybe_auto_switch_model now compares hf_variant too, so requesting another quant of the loaded repo (Q4_K_M loaded, Q8_0 asked) reloads instead of serving the old quant, matching the /load dedup.
  • Streaming /v1/responses now calls the auto-switch hook (it previously went straight into _responses_stream and only checked is_loaded). The hook is idempotent once loaded, so the non-streaming path that re-checks via chat completions is unaffected.
  • resolve_local_gguf tries an exact id match before splitting a trailing :VARIANT, so local ids that contain a colon (e.g. a Windows path) resolve instead of being cut at the drive letter.
  • Idle keep-warm stamps activity on a load/swap transition, so a model loaded after the server has sat idle past the TTL is not unloaded before its first request.

On the isinstance suggestion in _build_index: the existing if key: guard already skips None, and those fields are typed str, so I left it as is. Tests added for each case.

danielhanchen and others added 4 commits June 17, 2026 12:38
The new streaming-responses test passed in isolation but failed under the CI's
randomized collection order with "object has no attribute 'state'": it passed a
bare object() as the request and stubbed only one dispatcher, so an ordering
where the real dispatcher ran hit request.state. Give the request a state and
stub both dispatchers; the test still asserts the hook fires before dispatch.
…time

The behavioral version executed openai_responses and relied on stubbing its
callees, which a randomized collection order in CI could defeat (the real
dispatcher ran and hit request attributes). Assert on the function source that
the hook precedes both dispatchers instead; the hook's runtime behavior is
already covered by the direct _maybe_auto_switch_model tests.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces opt-in idle auto-unload (TTL keep-warm) and automatic model switching for local llama.cpp GGUF models, allowing the backend to transparently swap models based on incoming OpenAI-compatible requests and free VRAM when idle. The feedback focuses on preventing event loop starvation by making the local model index resolution asynchronous, as it currently performs synchronous disk I/O on the request hot path. Additionally, the reviewer recommends restricting the keep-warm middleware to POST requests to avoid CORS preflight requests resetting the idle timer, defensively validating that the requested model is a string, and updating the test suite to support these asynchronous changes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +47 to +57
def _index() -> dict[str, str]:
global _scan
now = time.monotonic()
with _lock:
ts, cached = _scan
if now - ts < _CACHE_TTL_S:
return cached
fresh = _build_index()
with _lock:
_scan = (now, fresh)
return fresh

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Building the local model index (_build_index) performs synchronous disk I/O (_scan_models_dir and _scan_hf_cache) which can be slow depending on the size of the Hugging Face cache. Since this is called on the hot path of every incoming chat completion request, doing this synchronously on the main thread will block the ASGI event loop and starve concurrent requests. Making _index asynchronous and offloading the blocking disk scan to a thread pool using asyncio.to_thread prevents event loop starvation.

Suggested change
def _index() -> dict[str, str]:
global _scan
now = time.monotonic()
with _lock:
ts, cached = _scan
if now - ts < _CACHE_TTL_S:
return cached
fresh = _build_index()
with _lock:
_scan = (now, fresh)
return fresh
async def _index() -> dict[str, str]:
global _scan
now = time.monotonic()
with _lock:
ts, cached = _scan
if now - ts < _CACHE_TTL_S:
return cached
import asyncio
fresh = await asyncio.to_thread(_build_index)
with _lock:
_scan = (now, fresh)
return fresh

Comment on lines +60 to +80
def resolve_local_gguf(requested: str) -> Optional[tuple[str, Optional[str]]]:
"""Return ``(loader_id, gguf_variant)`` for a downloaded local match, else None.

``requested`` may be ``repo`` or ``repo:VARIANT``. An exact id match wins
first so ids that themselves contain a colon (e.g. a Windows path) still
resolve; only then is a trailing ``:VARIANT`` split off the last colon.
"""
if not requested or not requested.strip():
return None
requested = requested.strip()
index = _index()
loader_id = index.get(requested.lower())
if loader_id is not None:
return loader_id, None
base, sep, variant = requested.rpartition(":")
if not sep:
return None
loader_id = index.get(base.strip().lower())
if loader_id is None:
return None
return loader_id, (variant.strip() or None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To support the asynchronous index building, resolve_local_gguf should be made asynchronous and await the _index() call. Additionally, since the requested model parameter is parsed directly from untrusted client JSON payloads (e.g., in /v1/completions), we should defensively verify that requested is indeed a string using isinstance(requested, str) to prevent AttributeError crashes if a non-string value is passed.

async def resolve_local_gguf(requested: str) -> Optional[tuple[str, Optional[str]]]:
    """Return ``(loader_id, gguf_variant)`` for a downloaded local match, else None.

    ``requested`` may be ``repo`` or ``repo:VARIANT``. An exact id match wins
    first so ids that themselves contain a colon (e.g. a Windows path) still
    resolve; only then is a trailing ``:VARIANT`` split off the last colon.
    """
    if not isinstance(requested, str) or not requested.strip():
        return None
    requested = requested.strip()
    index = await _index()
    loader_id = index.get(requested.lower())
    if loader_id is not None:
        return loader_id, None
    base, sep, variant = requested.rpartition(":")
    if not sep:
        return None
    loader_id = index.get(base.strip().lower())
    if loader_id is None:
        return None
    return loader_id, (variant.strip() or None)

Comment thread studio/backend/routes/inference.py Outdated
Comment on lines +1955 to +1957
if not requested_model or not get_openai_auto_switch_enabled():
return
resolved = resolve_local_gguf(requested_model)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since resolve_local_gguf has been updated to be asynchronous to avoid blocking the event loop, it must be awaited here.

Suggested change
if not requested_model or not get_openai_auto_switch_enabled():
return
resolved = resolve_local_gguf(requested_model)
if not requested_model or not get_openai_auto_switch_enabled():
return
resolved = await resolve_local_gguf(requested_model)

Comment on lines +72 to +75
async def __call__(self, scope, receive, send):
if scope.get("type") != "http" or not _is_inference_path(scope.get("path", "")):
await self.app(scope, receive, send)
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The middleware currently tracks any HTTP request matching the inference path suffixes, including CORS OPTIONS preflight requests. This can prematurely reset the idle keep-warm timer (_last_active) even when no actual inference is performed. Restricting the tracking to POST requests ensures that only actual inference requests keep the model warm.

Suggested change
async def __call__(self, scope, receive, send):
if scope.get("type") != "http" or not _is_inference_path(scope.get("path", "")):
await self.app(scope, receive, send)
return
async def __call__(self, scope, receive, send):
if (
scope.get("type") != "http"
or scope.get("method") != "POST"
or not _is_inference_path(scope.get("path", ""))
):
await self.app(scope, receive, send)
return

Comment on lines +58 to +62
def _wire(monkeypatch, *, enabled, resolves_to, backend, recorder):
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: enabled)
monkeypatch.setattr(resolver, "resolve_local_gguf", lambda _m: resolves_to)
monkeypatch.setattr(inference_route, "get_llama_cpp_backend", lambda: backend)
monkeypatch.setattr(inference_route, "load_model", recorder)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since resolve_local_gguf is now an asynchronous function, the mock in _wire must return a coroutine instead of a synchronous value to prevent TypeError when awaited.

Suggested change
def _wire(monkeypatch, *, enabled, resolves_to, backend, recorder):
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: enabled)
monkeypatch.setattr(resolver, "resolve_local_gguf", lambda _m: resolves_to)
monkeypatch.setattr(inference_route, "get_llama_cpp_backend", lambda: backend)
monkeypatch.setattr(inference_route, "load_model", recorder)
def _wire(monkeypatch, *, enabled, resolves_to, backend, recorder):
async def mock_resolve(_m):
return resolves_to
monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: enabled)
monkeypatch.setattr(resolver, "resolve_local_gguf", mock_resolve)
monkeypatch.setattr(inference_route, "get_llama_cpp_backend", lambda: backend)
monkeypatch.setattr(inference_route, "load_model", recorder)

Comment on lines +208 to +218
def test_resolver_matches_and_splits_variant(monkeypatch):
monkeypatch.setattr(resolver, "_build_index", lambda: {"unsloth/b-gguf": "unsloth/B-GGUF"})
resolver._scan = (0.0, {}) # force a rescan
assert resolver.resolve_local_gguf("unsloth/B-GGUF:UD-Q5_K_XL") == (
"unsloth/B-GGUF",
"UD-Q5_K_XL",
)
assert resolver.resolve_local_gguf("unsloth/B-GGUF") == ("unsloth/B-GGUF", None)
assert resolver.resolve_local_gguf("totally/unknown") is None
assert resolver.resolve_local_gguf("") is None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since resolve_local_gguf is now asynchronous, we must run it using asyncio.run to evaluate the assertions correctly.

Suggested change
def test_resolver_matches_and_splits_variant(monkeypatch):
monkeypatch.setattr(resolver, "_build_index", lambda: {"unsloth/b-gguf": "unsloth/B-GGUF"})
resolver._scan = (0.0, {}) # force a rescan
assert resolver.resolve_local_gguf("unsloth/B-GGUF:UD-Q5_K_XL") == (
"unsloth/B-GGUF",
"UD-Q5_K_XL",
)
assert resolver.resolve_local_gguf("unsloth/B-GGUF") == ("unsloth/B-GGUF", None)
assert resolver.resolve_local_gguf("totally/unknown") is None
assert resolver.resolve_local_gguf("") is None
def test_resolver_matches_and_splits_variant(monkeypatch):
monkeypatch.setattr(resolver, "_build_index", lambda: {"unsloth/b-gguf": "unsloth/B-GGUF"})
resolver._scan = (0.0, {})
assert asyncio.run(resolver.resolve_local_gguf("unsloth/B-GGUF:UD-Q5_K_XL")) == (
"unsloth/B-GGUF",
"UD-Q5_K_XL",
)
assert asyncio.run(resolver.resolve_local_gguf("unsloth/B-GGUF")) == ("unsloth/B-GGUF", None)
assert asyncio.run(resolver.resolve_local_gguf("totally/unknown")) is None
assert asyncio.run(resolver.resolve_local_gguf("")) is None

Comment on lines +220 to +227
def test_resolver_exact_id_with_colon_wins(monkeypatch):
# A local id that itself contains a colon (e.g. a Windows path) must match
# exactly rather than being split at the drive-letter colon.
win = r"C:\models\foo.gguf"
monkeypatch.setattr(resolver, "_build_index", lambda: {win.lower(): win})
resolver._scan = (0.0, {})
assert resolver.resolve_local_gguf(win) == (win, None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since resolve_local_gguf is now asynchronous, we must run it using asyncio.run to evaluate the assertions correctly.

Suggested change
def test_resolver_exact_id_with_colon_wins(monkeypatch):
# A local id that itself contains a colon (e.g. a Windows path) must match
# exactly rather than being split at the drive-letter colon.
win = r"C:\models\foo.gguf"
monkeypatch.setattr(resolver, "_build_index", lambda: {win.lower(): win})
resolver._scan = (0.0, {})
assert resolver.resolve_local_gguf(win) == (win, None)
def test_resolver_exact_id_with_colon_wins(monkeypatch):
# A local id that itself contains a colon (e.g. a Windows path) must match
# exactly rather than being split at the drive-letter colon.
win = r"C:\models\foo.gguf"
monkeypatch.setattr(resolver, "_build_index", lambda: {win.lower(): win})
resolver._scan = (0.0, {})
assert asyncio.run(resolver.resolve_local_gguf(win)) == (win, None)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 04bf834a48

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread studio/backend/routes/inference.py Outdated
Comment on lines +1968 to +1972
await load_model(
LoadRequest(model_path = target_id, gguf_variant = variant),
fastapi_request,
current_subject = current_subject,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not swap models while another /v1 request is in flight

When auto-switch is enabled, a second /v1 request for a different downloaded model reaches this load_model call even if another /v1/chat/completions, /v1/responses, or /v1/messages stream is still active. The new keep-warm middleware only protects the idle-unload loop; it is not consulted here, and LlamaCppBackend.load_model() kills the existing llama-server process during its phase-1 reload. In that concurrent-stream scenario, the first client can lose its response mid-generation just because another request named a different model, so the swap should wait for or reject while other in-flight inference is using the current server.

Useful? React with 👍 / 👎.

Comment on lines +32 to +34
found = _scan_models_dir(Path("./models").resolve()) + _scan_hf_cache(
_resolve_hf_cache_dir()
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter the auto-switch index to GGUF models only

In environments with any Hugging Face safetensors repo already present in the cache, _scan_hf_cache() still returns that repo even when it has no GGUF files, and this resolver indexes it as a resolve_local_gguf() target. A /v1/completions request whose model matches such a cached non-GGUF repo will call the auto-switch hook, unload the currently working GGUF through /load, load the transformers backend instead, and then fail the completions request because no GGUF is loaded; chat requests also silently switch to non-GGUF despite this feature being documented as GGUF-only. The index needs to exclude non-GGUF cache entries before they can trigger auto-switch.

Useful? React with 👍 / 👎.

Comment on lines +120 to +123
if current != seen_model:
seen_model = current
if current is not None:
_note_activity()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stamp activity when reloading the same model

When idle auto-unload is enabled, /load requests bypass the middleware and this loop only refreshes _last_active when the model identifier changes. If the user reloads the same GGUF after it has been idle longer than the TTL, for example to apply context/cache/template changes, current == seen_model so the fresh server inherits the old idle timestamp and can be unloaded on the next poll before serving a request. Stamp activity on every successful load/reload or track a load generation rather than only identifier changes.

Useful? React with 👍 / 👎.

loader_id = index.get(base.strip().lower())
if loader_id is None:
return None
return loader_id, (variant.strip() or None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify requested GGUF variants are cached before switching

For a cached HF GGUF repo, this returns any repo:VARIANT as a local match once the base repo is indexed, without checking that the requested variant's GGUF files are actually present in the cache. If only metadata or a different quant is cached, auto-switch then calls /load, and LlamaCppBackend._download_gguf() resolves and downloads the missing multi-GB variant from Hugging Face, contradicting the documented guarantee that auto-switch only uses already-downloaded models. Only return a variant when the corresponding cached GGUF snapshot files exist.

Useful? React with 👍 / 👎.

Comment on lines +27 to +31
_INFERENCE_SUFFIXES = (
"/chat/completions",
"/completions",
"/messages",
"/embeddings",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track every llama-server endpoint before idle unload

This suffix allowlist skips existing llama-server consumers such as /api/inference/audio/generate and /v1/messages/count_tokens; those routes call the loaded GGUF backend but never increment _inflight. When the idle TTL has already elapsed, starting one of those requests does not refresh _last_active, so the background loop can unload the server while audio generation or strict token counting is still running. Add these routes, or track backend use more centrally, before enabling auto-unload.

Useful? React with 👍 / 👎.

… race gate

Second-pass review follow-ups on the opt-in auto-switch path:

1. /v1/embeddings now calls the auto-switch hook before the loaded-state check,
   matching the other model-bearing OpenAI endpoints (the keep-warm middleware
   already treats embeddings as inference).

2. The resolver index is now GGUF-only. The local-model scanners also surface
   Transformers/safetensors repos; without a filter, auto-switch could unload
   the GGUF and route a request into the non-GGUF loader. _has_local_gguf checks
   a direct .gguf, a models-dir folder, and the HF-cache snapshots layout.

3. Idle keep-warm now holds an asyncio gate across the idle check and the
   unload, and a request bumps inflight under the same gate, so the loop can no
   longer unload in the window between "looks idle" and the kill.

Tests cover each. Broader local-model source parity (LM Studio, Ollama, legacy
caches, custom scan folders) is a follow-up; missing one of those today just
falls through to the loaded model.
@danielhanchen

Copy link
Copy Markdown
Member Author

Second review pass follow-ups, pushed in 0ed5fcc:

  • /v1/embeddings now calls the auto-switch hook before the loaded-state check, like the other model-bearing OpenAI endpoints. The keep-warm middleware already counts embeddings as inference, so this closes that asymmetry.
  • The resolver index is now GGUF-only. The local-model scanners also surface Transformers/safetensors repos, so without a filter auto-switch could unload the GGUF and route a request into the non-GGUF loader. _has_local_gguf covers a direct .gguf, a models-dir folder, and the HF-cache snapshots/*/ layout.
  • Idle keep-warm now holds an asyncio gate across the idle check and the unload, and a request bumps the in-flight counter under the same gate, so the loop can no longer unload in the gap between "looks idle" and the kill.

Tests added for each. On broader local-model source parity (LM Studio, Ollama, legacy/default caches, custom scan folders): the resolver currently indexes ./models plus the active HF cache. Missing a model from one of the other sources fails safe today (the request falls through to the loaded model), so I am leaving full source parity as a follow-up rather than duplicating the whole local-model scan here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0ed5fccf58

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".



def _is_inference_path(path: str) -> bool:
return path.startswith(_INFERENCE_PREFIXES) and path.endswith(_INFERENCE_SUFFIXES)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude external provider calls from GGUF keep-warm

When auto-unload is enabled, this path-only predicate counts every /v1/chat/completions request as llama activity. However, openai_chat_completions() returns from the provider_id/provider_type branch before touching the local llama_backend, so a client making periodic external-provider calls while a GGUF is loaded keeps _last_active fresh and the idle loop never frees VRAM. Track actual GGUF backend use, or skip provider-routed requests, instead of treating all matching paths as local inference.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/inference.py Outdated
JSON).
"""
llama_backend = get_llama_cpp_backend()
await _maybe_auto_switch_model(payload.model, request, current_subject)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move Anthropic auto-switch after required-field validation

For /v1/messages requests whose model names a cached local GGUF but that omit the required max_tokens, this hook loads/unloads the global GGUF before the handler immediately raises the 400 in the max_tokens check below. A malformed Anthropic request can therefore evict the currently loaded model even though no generation will run; validate the required fields before calling the auto-switch hook.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/inference.py Outdated
"""
# Hook here so the streaming path switches too; non-streaming re-checks via
# openai_chat_completions, which is idempotent once the model is loaded.
await _maybe_auto_switch_model(payload.model, request, current_subject)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate Responses input before auto-switching

For /v1/responses requests whose model resolves to a cached GGUF but whose input is empty or omitted, this hook loads the new model before _normalise_responses_input() and the if not messages 400 below. A bad request can therefore evict the current model and spend load time/VRAM even though generation never starts; move the hook after the no-input validation or into the validated dispatch path.

Useful? React with 👍 / 👎.

Comment on lines +56 to +58
found = _scan_models_dir(Path("./models").resolve()) + _scan_hf_cache(
_resolve_hf_cache_dir()
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include all local model sources in auto-switch index

This index only scans ./models plus the active HF cache, but the local-model API also adds legacy/default HF caches, LM Studio directories, and custom/Ollama scan folders. When a user requests a GGUF id returned from one of those sources, such as an ollama/... model from a custom folder, resolve_local_gguf() returns None and the request silently falls through to whichever model is already loaded instead of switching to the requested local model. Build the index from the same sources as /api/models/local.

Useful? React with 👍 / 👎.

return p.suffix.lower() == ".gguf"
return (
next(p.glob("*.gguf"), None) is not None
or next(p.glob("snapshots/*/*.gguf"), None) is not None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recurse HF snapshots when detecting cached GGUFs

This HF-cache check only matches .gguf files directly under snapshots/<rev>/, but the existing GGUF utilities explicitly support cached variants nested under quant subdirectories such as snapshots/<rev>/BF16/...gguf. In that layout, a fully downloaded local GGUF repo is excluded from the auto-switch index, so a /v1 request for its repo id falls through to the currently loaded model instead of switching. Use the same recursive GGUF discovery as list_local_gguf_variants().

Useful? React with 👍 / 👎.

…h coverage

Third-pass review follow-ups on the opt-in auto-switch path:

1. The resolver is now variant-aware via list_local_gguf_variants. It indexes
   only the quants actually on disk, recursing snapshots and quant subdirs such
   as the nested per-quant folders, so a requested repo:VARIANT resolves only
   when that quant is local and a bare repo resolves to a concrete local quant.
   This fixes two gaps: the previous shallow glob rejected nested-variant GGUF
   repos, and a request for an uncached quant could send /load down the remote
   download path, breaking the local-only contract.

2. /v1/messages/count_tokens now auto-switches like its sibling /v1/messages, so
   a count uses the requested model's tokenizer.

3. /api/inference/audio/generate (direct GGUF TTS) is now tracked as in-flight
   inference, so the idle loop cannot unload the model mid-generation.

Tests cover each. Two reviewer items are left as follow-ups: indexing the
remaining local sources (LM Studio, Ollama, legacy/default caches, custom scan
folders), which fails safe today by falling through to the loaded model; and
fully serializing concurrent different-model requests, an inherent limit of the
single-slot llama backend that the opt-in feature is not designed around.
@danielhanchen

Copy link
Copy Markdown
Member Author

Pushed a5d291ee addressing a third review pass over the auto-switch path. Summary of what changed and what is intentionally deferred:

Fixed

  • Variant-aware local resolver. The resolver now builds its index through list_local_gguf_variants instead of a shallow snapshots/*/*.gguf glob. It records only the quants that are actually on disk and recurses into nested quant subdirectories (for example a snapshot that stores each quant in its own folder). Two concrete bugs fall out of this: repos that lay their GGUFs out per-quant are no longer rejected, and a request for repo:VARIANT whose quant is not cached locally now returns no match instead of handing /load a remote download. A bare repo resolves to a concrete local quant. This keeps the local-only, no-surprise-download contract intact.
  • /v1/messages/count_tokens auto-switches. It now runs the same hook as its sibling /v1/messages before counting, so the count uses the requested model's tokenizer rather than whatever happens to be loaded.
  • /api/inference/audio/generate keep-warm tracking. Direct GGUF TTS is a real inference path and can outlive the idle TTL, so it is now counted as in-flight. The idle loop can no longer unload the model mid-generation.

Each has test coverage in test_openai_auto_switch.py (variant filtering and recursion, the count_tokens hook ordering, and the audio path being treated as inference). Full suite stays green under randomized ordering.

Deferred, with rationale

  • Broader local sources. The index currently covers ./models and the active HF cache. A model that lives only in an LM Studio / Ollama / legacy or custom-scan location will not be matched, which fails safe: the request falls through to the loaded model exactly as it does for any unrecognized name. Widening the scan is a clean follow-up that does not change the contract.
  • Concurrent different-model requests. The llama backend is single-slot, so two simultaneous requests naming different models can still race on the load. Auto-switch is opt-in and aimed at sequential use; fully serializing cross-model concurrency is a larger change to the backend rather than to this feature and is out of scope here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d05d866497

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +83 to +85
if get_auto_unload_idle_seconds() <= 0:
await self.app(scope, receive, send)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track streams before enabling idle unload

When auto-unload is turned on while an inference request that began with TTL=0 is still streaming, this early return means that request never increments _inflight or refreshes _last_active. The background loop starts honoring the new TTL immediately, so if the previous activity timestamp is already older than the TTL it can unload the active llama-server mid-response. Track inference paths regardless of the current setting, or stamp/start before this branch, so toggling the setting cannot kill an already-running request.

Useful? React with 👍 / 👎.

Comment on lines +78 to +80
for key in (info.id, getattr(info, "model_id", None), getattr(info, "display_name", None)):
if key:
index.setdefault(key.strip().lower(), entry)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not auto-switch on ambiguous display names

When two downloaded GGUF repos share the same basename/display name (for example different orgs publishing Foo-GGUF), this indexes the short display_name and keeps whichever entry was scanned first. A /v1 request using that displayed name can therefore load a different repo than intended, which is easy to miss because the fully-qualified ids are unique but the short key is not; either skip non-unique display names or require the exact id/model_id for auto-switching.

Useful? React with 👍 / 👎.

Comment on lines +51 to +53
variants, _ = list_local_gguf_variants(path)
quants = tuple(v.quant for v in variants if getattr(v, "quant", None))
return _LocalGgufEntry(loader_id, quants) if quants else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require cached companions before auto-switching

For HF GGUF repos that need companion files, this marks the repo switchable as soon as a main quant is present, but the subsequent /load path can still call _download_mmproj() for vision models or _download_mtp() for separate draft heads. If those companion GGUFs are not already cached, a /v1 request can fetch new files from Hugging Face despite the auto-switch guarantee that only already-downloaded models are used; include companion availability in the resolver or suppress companion downloads for this path.

Useful? React with 👍 / 👎.

…itch-model

# Conflicts:
#	studio/backend/main.py
#	studio/backend/routes/inference.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7bf12d55f3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread studio/backend/routes/inference.py Outdated
return
# Reuse the load route so its dedup, tensor fallback, and threading apply.
await load_model(
LoadRequest(model_path = target_id, gguf_variant = variant),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load HF-cache hits without requiring Hub access

When auto-switch resolves a GGUF from the Hugging Face cache, target_id is the repo id from _scan_hf_cache, but this /load call supplies neither an hf_token nor a cache-local snapshot path. For already-downloaded private/gated GGUF repos, ModelConfig.from_identifier() treats that id as remote and detect_gguf_model_remote() returns None on GatedRepoError before using the local cache, so a /v1 request for a model the resolver just indexed can fail or fall into the non-GGUF path instead of switching. Pass a cache-local GGUF path or otherwise force cached GGUF resolution for resolver hits.

Useful? React with 👍 / 👎.

Comment on lines +111 to +113
entry = index.get(requested.lower())
if entry is not None:
return entry.loader_id, (entry.variants[0] if entry.variants else None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prefer the normal GGUF quant for bare requests

When a cached GGUF repo has multiple quants and the OpenAI client sends the bare repo/display name, this returns entry.variants[0]. Those variants come from list_local_gguf_variants(), which sorts by file size descending, while the normal /load path without a variant uses _pick_best_gguf() with the Q4-oriented quant preference; as a result auto-switch can unexpectedly load BF16/F32 or another largest cached quant and OOM/slow down where the same bare load would choose a smaller preferred quant. Use the existing quant preference here, or leave the variant unset while still preventing downloads.

Useful? React with 👍 / 👎.

… 500

The auto-switch hook calls resolve_local_gguf without its own guard, and
/v1/completions and /v1/embeddings pass body.get("model") through unchanged.
A non-string model (e.g. {"model": 123}) or any internal scan failure would
then raise out of the resolver and turn a request that would otherwise be
served by the loaded model into a 500, breaking the drop-in compatibility the
feature is built on.

Guard the resolver at its boundary: reject non-string input up front and wrap
the lookup so any failure returns None (fall through to the loaded model).
Add regression tests for both paths.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@danielhanchen

Copy link
Copy Markdown
Member Author

Ran an extended simulation pass over this branch to confirm it solves the real gap (the /v1 endpoints ignored the request model and always served the loaded one) without changing existing behavior, and found and fixed one robustness hole.

Found and fixed (41fe17ba): the hook calls resolve_local_gguf without its own guard, and /v1/completions and /v1/embeddings pass body.get("model") through unchanged. A non-string model such as {"model": 123}, or any internal scan failure, would raise out of the resolver and turn a request that would otherwise be served by the loaded model into a 500. That breaks the drop-in contract. The resolver now rejects non-string input up front and wraps its lookup so any failure returns None and falls through to the loaded model. Regression tests added.

Simulation coverage (77 cases, all green under randomized ordering):

  • End-to-end through the live /v1/chat/completions route via TestClient: OFF is a no-op (200, served by the loaded model, no switch); ON + unknown name falls through (200); ON + already-loaded does not reload; ON + a known local model different from the loaded one triggers exactly one load before generation; a malformed non-string model never 500s.
  • Resolver against real filesystem layouts: HF-cache snapshot symlinks (Linux/Mac) and Windows-style blob copies both detected; a dangling snapshot symlink does not create a phantom variant; nested per-quant sharded dirs detected; a bare repo resolves to the largest local quant deterministically (sorted by size); an uncached quant resolves to None so /load never fetches remotely; case-insensitive variant match; Windows drive-colon ids match exactly rather than splitting; safetensors/empty/mmproj-only and missing-path inputs all fall through safely.
  • Keep-warm ASGI middleware: passes straight through when disabled; counts in-flight correctly; holds the model across an entire streaming response and releases exactly on the final frame; balances on client disconnect; never goes negative under 50 concurrent requests; ignores non-HTTP scopes. The middleware follows the standard pure-ASGI pattern (wrap send, detect the final frame via more_body, forward every message unchanged), so streaming is untouched.
  • Idle loop: unloads only after the TTL when idle and not in-flight; protects in-flight and freshly loaded models; inert when disabled; survives an unload exception.
  • Settings: bool/int coercion matrices, defaults when unset, set/get round-trip, cache invalidation on write, legacy string-valued rows, and storage-read failures all fall back to the safe default.

Compatibility notes: both settings default off, so with the feature disabled the request path is byte-for-byte unchanged. No new third-party dependency is introduced (the three new modules are stdlib plus existing backend imports). The feature adds no hardware or platform-specific code; model loading is delegated to the existing /load route, so the GPU/CPU and OS handling is inherited unchanged.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

…reload-only, mmproj/stash)

Four eviction/correctness fixes on the opt-in /v1 auto-switch path:

- /v1/messages/count_tokens now carries the same require_vision guard as
  /messages, so an image count naming a text-only GGUF can't evict a loaded
  vision model for a swap that can't serve the request.

- /audio/generate is now reload-only. A local GGUF's audio-input capability
  is not a cheap pre-load probe (the companion mmproj signal can't tell an
  audio projector from a vision one, and codec TTS ships no projector), so
  resolving the client model could load a text/vision-only target and evict
  the working audio model before the audio check fails. Only the idle-stash
  restore runs here; switching TTS models is an explicit /load.

- The resolver no longer treats a standalone mmproj .gguf as a servable
  model. _scan_models_dir's standalone-file pass does not filter mmproj the
  way its directory scan does, so /v1/models could advertise a projector and
  a switch could load it over the real weights.

- A non-GGUF (Transformers/Unsloth) load and a deliberate /unload now clear
  the idle reload stash, so a manual load/unload is never superseded by a
  stale idle-freed GGUF that the next /v1 request resurrects.
@danielhanchen

Copy link
Copy Markdown
Member Author

Went through the latest review. Pushed four eviction/correctness fixes and kept four items deferred with reasons.

Fixed

  1. /v1/messages/count_tokens now carries the same require_vision guard as /messages. It shares the same translation but was missing the guard, so an image count naming a downloaded text-only GGUF could evict a loaded vision model for a swap that can't serve the request.

  2. /audio/generate is now reload-only. A local GGUF's audio-input capability is not a cheap pre-load probe: the companion mmproj signal can't tell an audio projector from a vision one, and codec-based TTS ships no projector at all. Resolving the client model could therefore load a text- or vision-only target and evict the working audio model before the audio backend check fails. Only the idle-stash restore runs on this path now; switching TTS models is an explicit /load.

  3. The resolver no longer treats a standalone mmproj .gguf as a servable model. _scan_models_dir's standalone-file pass does not filter mmproj the way its directory scan does, so /v1/models could advertise a projector and a switch could try to load it over the real weights. Filtered it in _local_gguf_entry (the directory branch was already mmproj-free via list_local_gguf_variants).

  4. A non-GGUF (Transformers/Unsloth) load and a deliberate /unload now clear the idle reload stash. Previously only the GGUF load branch cleared it, so a manual load/unload could be superseded by a stale idle-freed GGUF that the next /v1 request resurrects. The idle loop unloads via the backend directly (not the route), so clearing on the route never fights keep-warm.

Deferred (intentional)

  • Rejecting n > 1 before the switch: the non-streaming GGUF text path actually supports n > 1 (it returns multiple choices), so n > 1 is not target-independent. A blanket pre-switch rejection would false-reject valid requests. The external-provider path already rejects it before its early return, and the local streaming/tool/audio paths reject it after the switch where the serving path is known.

  • A separate audio-input capability check for chat audio_base64: the require_vision guard already blocks the common case (a text-only target). Distinguishing an audio mmproj from a vision mmproj is not a cheap filesystem probe, so a vision-only target is still best-effort and rejected after the load rather than before.

  • Decoding legacy image_base64 before the switch: require_vision already blocks a text-only target, so the residual is a malformed image naming another vision GGUF, which would need a full base64 decode on the request hot path to catch.

  • The training-start vs new-inference race: the in-flight check is a best-effort guard. Closing it fully means holding the inference lifecycle gate across the whole training setup and the before_spawn unload, and the before_spawn hook cannot itself refuse the job (its exceptions are swallowed by design), so there is no contained low-risk fix here.

Backend suite is green against the baseline (identical failure set, only the flaky orphaned-server count test differs run to run).

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bccfbcd1b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +8809 to +8813
await _maybe_auto_switch_model(
_switch_model_for_payload(payload),
request,
current_subject,
require_vision = _messages_have_image(messages),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report advertised ids in streamed Responses

When stream: true and this hook auto-switches to a cached HF GGUF, the load uses the concrete snapshot path and stores the public repo id in _openai_advertised_id. The streaming helper still builds every response.*.response.model from public_model_id(llama_backend.model_identifier), so streamed Responses can report a snapshot hash/local folder name while /v1/models, chat completions, and non-streaming Responses report the advertised repo id; use _llama_public_model_id() in the streaming path after switching.

Useful? React with 👍 / 👎.

Comment on lines +2515 to +2516
if _already_serving():
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record advertised ids when aliases already serve

When the active model was loaded by its local path but the request uses an advertised alias that resolves to that same path (for example an LM Studio publisher/model id), this early return skips the assignment of _openai_advertised_id below. The request is served correctly, but /v1/models and responses continue to report the path-derived basename and can still list the alias as loaded: false; set the advertised id before returning when the resolved alias is already serving.

Useful? React with 👍 / 👎.

Two model-id reporting fixes so an auto-switched cached HF GGUF is named by
its repo id everywhere, not its snapshot path:

- Streamed /v1/responses envelopes now derive the model id from
  _llama_public_model_id (which prefers _openai_advertised_id) instead of the
  raw model_identifier. After an auto-switch the identifier is the snapshot
  path while the repo id lives in _openai_advertised_id, so the stream used to
  report a snapshot basename while /v1/models, chat completions, and
  non-streaming Responses all reported the repo id.

- When an advertised alias already resolves to the loaded model (a model
  loaded by local path, requested by its repo or LM Studio id), the
  already-serving early return now records the alias as the advertised id, so
  /v1/models and responses report the alias and mark it loaded instead of the
  path-derived basename. Resolver branch only; safe lock-free because an
  in-flight request blocks any concurrent swap via the single-slot busy guard.
@danielhanchen

Copy link
Copy Markdown
Member Author

Went through the latest review. Both findings were real model-id reporting inconsistencies after an auto-switch; fixed both.

  1. Streamed /v1/responses now derives the envelope model id from _llama_public_model_id (which prefers _openai_advertised_id) instead of the raw model_identifier. After an auto-switch to a cached HF GGUF the identifier is the snapshot path while the repo id lives in _openai_advertised_id, so the stream used to report a snapshot basename while /v1/models, chat completions, and non-streaming Responses all reported the repo id.

  2. When an advertised alias already resolves to the loaded model (a model loaded by local path, requested by its repo or LM Studio id), the already-serving early return now records the alias as the advertised id. Previously /v1/models and responses reported the path-derived basename and listed the alias as loaded: false. Scoped to the resolver branch (the reload-stash override id can be the bare path); safe lock-free because an in-flight request blocks any concurrent swap via the single-slot busy guard, so the loaded model can't change under the write.

Both have regression tests, and the backend suite is green against the baseline.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9fee0f73a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return prompt != ""
if isinstance(prompt, (list, tuple)):
return len(prompt) > 0
return prompt is not None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject object prompts before auto-switching

When automatic loading is enabled, this treats any non-null prompt as valid, including objects. A malformed /v1/completions request such as {"model":"org/B-GGUF","prompt":{}} can therefore load the named GGUF before the downstream llama-server completions call rejects the invalid prompt shape, so a deterministic client error still evicts the previously loaded model; restrict the pre-switch pass to accepted prompt shapes before calling the switch hook.

Useful? React with 👍 / 👎.

return inp != ""
if isinstance(inp, (list, tuple)):
return len(inp) > 0
return inp is not None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject object embedding inputs before switching

When automatic loading is enabled, this returns true for any non-null input, including objects. A malformed /v1/embeddings request like {"model":"org/B-GGUF","input":{}} will pass this guard, run _auto_switch_from_request_body, and load the requested GGUF before llama-server rejects the invalid embeddings input shape, turning a client 400 into a persistent model eviction; validate the input shape before switching.

Useful? React with 👍 / 👎.

Comment on lines +5305 to +5309
await _maybe_auto_switch_model(
_switch_model_for_payload(payload),
request,
current_subject,
require_vision = _needs_vision,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate audio payloads before switching

When a chat request includes audio_base64 and names another downloaded GGUF, this hook can load that model before the later audio size/decode checks run. An oversized or undecodable audio payload will then be rejected only after the resident model has been evicted, so validate the audio field before this switch just like the other request-shape checks.

Useful? React with 👍 / 👎.

Comment thread studio/backend/routes/inference.py Outdated
Comment on lines +5266 to +5270
payload.enable_tools is True
or bool(payload.enabled_tools)
or bool(payload.tools)
or bool(payload.openai_code_exec_container_id)
or bool(payload.anthropic_code_exec_container_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include MCP tool mode in confirm precheck

For local chat requests with confirm_tool_calls=true, stream=false, and mcp_enabled=true (or tools forced on by the process policy), this pre-switch guard does not fire because it only checks explicit tool fields. The request can therefore auto-switch to the named GGUF and only later hit the local-tool confirm rejection, so include every local tool-enablement path in this guard before loading.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

…o/mcp confirm)

Four more validate-before-switch guards so a deterministic client error never
evicts the resident model on the opt-in /v1 auto-switch path:

- /v1/completions rejects an object/number prompt (only a string or array is
  valid) before the switch, instead of loading the named GGUF and letting
  llama-server reject the shape afterward.

- /v1/embeddings rejects an object/number input the same way.

- Chat rejects an oversized audio_base64 upload (413) before the switch. The
  size cap is a cheap, target-independent length check; the decode itself
  stays post-switch to avoid decoding a valid upload twice.

- The chat confirm-without-stream pre-switch guard now mirrors the tool loop's
  actual enablement: _effective_enable_tools (honoring a CLI --enable-tools
  policy) and mcp_enabled (which opens the tool loop on its own but defers to a
  CLI --disable-tools policy). Previously a confirm+no-stream request with only
  mcp_enabled slipped past and 400'd after the swap.
@danielhanchen

Copy link
Copy Markdown
Member Author

Went through the latest review. All four were the same validate-before-switch class (a deterministic client error evicting the resident model before it is rejected); fixed all four.

  1. /v1/completions now rejects an object or number prompt (only a string or array is valid) before the switch, instead of loading the named GGUF and letting llama-server reject the shape after the swap. The previous presence check treated any non-null value as usable.

  2. /v1/embeddings rejects an object or number input the same way.

  3. Chat rejects an oversized audio_base64 upload (413) before the switch. The size cap is a cheap, target-independent length check, so it belongs before the load. The decode itself stays post-switch so a valid upload is not decoded twice.

  4. The chat confirm-without-stream pre-switch guard now mirrors the tool loop's actual enablement: _effective_enable_tools (which honors a CLI --enable-tools policy hard-override) and mcp_enabled (which opens the tool loop on its own but still defers to a CLI --disable-tools policy). Previously a confirm_tool_calls=true, stream=false, mcp_enabled=true request slipped past the guard and only 400'd after the swap.

Each has a regression test, and the backend suite is green against the baseline.

…itch-model

# Conflicts:
#	studio/backend/main.py
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a271e147a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"id": public_model_id(llama_backend.model_identifier),
# Advertised repo id after an auto-switch load, else a clean public id,
# never the absolute .gguf path (which leaks the host filesystem layout).
"id": _llama_public_model_id(llama_backend),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve raw-path retrieval after advertising aliases

When an auto-switch load records _openai_advertised_id, /v1/models now exposes that repo id here, but the legacy raw-id fallback in openai_retrieve_model still searches the catalog for public_model_id(raw). For a client that cached an older absolute GGUF path, GET /v1/models/{raw_path} will match model_id_matches(...) and then fail to find the advertised repo-id object, returning 404 even though the model is loaded; the fallback needs to resolve to the same advertised id used for the loaded entry.

Useful? React with 👍 / 👎.

bool(_pre_parsed[2]) or _request_has_image(payload) or bool(payload.audio_base64)
)

await _maybe_auto_switch_model(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate unsupported chat shapes before switching

With auto-switch enabled, this hook runs before several target-independent local chat rejections that still happen later, such as stream=true with n > 1 in the GGUF streaming branch or audio input combined with client-tool passthrough. A request naming a downloaded model B in one of those unsupported shapes will load B here and then return 400, evicting the previously loaded model A for a request that could have been rejected up front; move those cheap validations into the pre-switch block with the existing malformed-tool/confirm/audio-size guards.

Useful? React with 👍 / 👎.

if now - ts < _CACHE_TTL_S:
return cached
fresh = _build_index()
_scan = (now, fresh)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stamp resolver cache after scans complete

The resolver cache timestamp is captured before _build_index() walks the models directory, HF caches, LM Studio folders, and custom scan roots. On installations with enough local models for that scan to exceed the 5-second TTL, the freshly built cache is already expired when it is stored, so every subsequent auto-switch request rebuilds the filesystem index instead of reusing it; stamp _scan with a fresh time.monotonic() after _build_index() returns, as the catalog cache does.

Useful? React with 👍 / 👎.

started = False
try:
async with _unload_gate():
_note_start()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not let unauthenticated requests keep models warm

The middleware increments the keep-warm in-flight count before FastAPI authentication runs, so a POST to /v1/chat/completions with no/invalid credentials still reaches _note_end() through the 401 response and refreshes _last_active even though it never touched llama.cpp. When idle unload is enabled on an exposed server, repeated unauthenticated probes can therefore prevent VRAM from being freed (and briefly trip the same in-flight guards); only successful local-inference requests, or at least non-401/403 responses, should stamp model activity.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Member Author

/gemini review

…ep-warm auth

Four fixes from review:

- GET /v1/models/{id} legacy raw-path fallback now maps the raw identifier to
  the same public id its /v1/models entry uses. After an auto-switch load the
  identifier is the snapshot path while the entry is keyed by the advertised
  repo id, so a client that cached the old absolute path no longer 404s on a
  model that is in fact loaded.

- stream=true with n>1 is now rejected before the switch. Only the
  non-streaming GGUF path returns multiple choices, so streaming n>1 is invalid
  on every local serving path; both fields are known pre-switch, so it must not
  load model B only to 400 and evict model A. Non-streaming n>1 stays
  post-switch where the serving path decides.

- The resolver index cache is stamped after _build_index, not with the pre-scan
  timestamp. On installs with enough local models for the multi-root scan to
  exceed the 5s TTL, the cache was stored already expired and every request
  rebuilt it.

- The keep-warm middleware no longer stamps model activity for 401/403
  responses. It runs before FastAPI auth, so unauthenticated probes used to
  refresh the idle timer without touching llama.cpp; they now decrement the
  in-flight count without keeping the model warm.
@danielhanchen

Copy link
Copy Markdown
Member Author

Went through the latest review. All four were real; fixed three fully and one partially (the part that can be validated up front).

  1. GET /v1/models/{id} raw-path fallback. After an auto-switch load the backend identifier is the snapshot path while the /v1/models entry is keyed by the advertised repo id, but the legacy fallback resolved the raw id with public_model_id(path) and searched for that, so a client that cached the old absolute path got a 404 for a model that is in fact loaded. The fallback now maps each raw identifier to the same public id its entry uses (the advertised repo id for the llama backend) and returns the loaded object.

  2. Streaming n > 1 before the switch. Only the non-streaming GGUF path returns multiple choices, so stream=true with n > 1 is invalid on every local serving path and both fields are known pre-switch. It now 400s before the switch instead of loading model B and evicting model A. The non-streaming n > 1 cases stay post-switch, where the serving path decides whether the shape is supported (the non-streaming GGUF text path does support it, so a blanket pre-switch reject would false-reject valid requests). The related audio-input plus client-tool passthrough rejection stays post-switch on purpose: it is gated on the loaded model's supports_tools, which is only known after the switch, so pre-checking it would false-reject requests to non-tool models.

  3. Resolver index cache TTL. The cache timestamp was captured before _build_index() ran, so on an install with enough local models for the multi-root scan to exceed the 5s TTL the cache was stored already expired and every request rebuilt it. It is now stamped after the scan completes, like the catalog cache.

  4. Keep-warm activity on unauthenticated requests. The middleware runs before FastAPI auth, so a 401/403 reached _note_end() and refreshed the idle timer without ever touching llama.cpp, letting repeated unauthenticated probes keep the model warm on an exposed server. It now decrements the in-flight count without stamping activity for 401/403 responses.

Each has a regression test. The backend suite is green against the merged-main baseline (the only run-to-run differences are the pre-existing flaky live-server and GPU global-state tests, which flip independently of these changes).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an opt-in model auto-switching and idle auto-unload (TTL keep-warm) feature for the local llama.cpp model, allowing transparent loading of downloaded GGUFs based on incoming OpenAI-compatible requests and freeing VRAM when idle. It adds a background loop, ASGI middleware to track in-flight requests, a local model resolver, and corresponding UI settings. Feedback on the changes includes ensuring string types are passed to path resolution functions, defensively verifying string types in the model resolver index to prevent AttributeError, explicitly checking for None in database helper functions to avoid deleting falsy values, and importing timezone in studio_db.py to resolve a potential NameError.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

try:
if (p / "snapshots").is_dir():
from routes.models import _resolve_hf_cache_realpath
real = _resolve_hf_cache_realpath(p)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass str(p) instead of the Path object p to _resolve_hf_cache_realpath to maintain compatibility, as downstream path resolution functions often expect a string representation of the path.

Suggested change
real = _resolve_hf_cache_realpath(p)
real = _resolve_hf_cache_realpath(str(p))

if rp in seen_hf:
return []
seen_hf.add(rp)
return _scan_hf_cache(directory)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass the resolved absolute path string rp to _scan_hf_cache instead of the original directory argument (which could be a Path object or a relative path). This guarantees type safety and consistency, as some downstream functions in _scan_hf_cache expect a string.

Suggested change
return _scan_hf_cache(directory)
return _scan_hf_cache(rp)

Comment on lines +213 to +215
for key in (raw_id, getattr(info, "model_id", None), getattr(info, "display_name", None)):
if key:
index.setdefault(key.strip().lower(), entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Defensively verify that key is a string before calling .strip().lower(). If any scanner returns a non-string value for id, model_id, or display_name, this will raise an AttributeError and crash the index build.

Suggested change
for key in (raw_id, getattr(info, "model_id", None), getattr(info, "display_name", None)):
if key:
index.setdefault(key.strip().lower(), entry)
for key in (raw_id, getattr(info, "model_id", None), getattr(info, "display_name", None)):
if isinstance(key, str) and key.strip():
index.setdefault(key.strip().lower(), entry)

Comment on lines +1705 to +1708
if entry_value:
current[entry_key] = entry_value
else:
current.pop(entry_key, None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use if entry_value is not None: instead of if entry_value: to avoid unintended deletion of entries when a falsy but valid value (such as an empty dictionary {}) is passed.

Checking for None explicitly is a Python best practice for optional parameters to prevent bugs with falsy values.

Suggested change
if entry_value:
current[entry_key] = entry_value
else:
current.pop(entry_key, None)
if entry_value is not None:
current[entry_key] = entry_value
else:
current.pop(entry_key, None)

current[entry_key] = entry_value
else:
current.pop(entry_key, None)
now = datetime.now(timezone.utc).isoformat()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import timezone from datetime (i.e., from datetime import timezone) to avoid a NameError at runtime, as timezone is used here directly. If only the datetime class is imported, timezone will not be defined.

@wasimysaid
wasimysaid merged commit 5211b50 into main Jul 1, 2026
1 check was pending
@wasimysaid
wasimysaid deleted the studio-openai-auto-switch-model branch July 1, 2026 13:42
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an opt-in model auto-switching and idle auto-unloading (TTL keep-warm) feature for local GGUF models in Unsloth Studio. It includes a background loop to unload idle models, a pure ASGI middleware to track in-flight requests, and a local model resolver to map OpenAI request model strings to on-disk GGUFs. Additionally, settings endpoints and frontend UI components are added to configure these options. The code review feedback highlights three high-severity issues in routes/inference.py where validation checks for tool_choice and tools rely on isinstance(..., dict) checks. If these payloads are parsed as Pydantic models rather than raw dictionaries, the validation will be silently bypassed. It is recommended to defensively convert these objects to dictionaries before performing the checks.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +5377 to +5380
_tc = payload.tool_choice
if isinstance(_tc, dict) and _tc.get("type") == "function":
_tc_fn = _tc.get("function")
_tc_name = _tc_fn.get("name") if isinstance(_tc_fn, dict) else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If payload.tool_choice is parsed as a Pydantic model (which is standard for ChatCompletionToolChoiceOption in FastAPI), isinstance(_tc, dict) will evaluate to False, silently bypassing this validation check. We should defensively convert _tc and _tc_fn to dictionaries if they are Pydantic models (using model_dump or dict) before performing dictionary-specific checks.

        _tc = payload.tool_choice
        _tc_dict = _tc if isinstance(_tc, dict) else (getattr(_tc, "model_dump", getattr(_tc, "dict", lambda: {})))() if _tc else {}
        if isinstance(_tc_dict, dict) and _tc_dict.get("type") == "function":
            _tc_fn = _tc_dict.get("function")
            _tc_fn_dict = _tc_fn if isinstance(_tc_fn, dict) else (getattr(_tc_fn, "model_dump", getattr(_tc_fn, "dict", lambda: {})))() if _tc_fn else {}
            _tc_name = _tc_fn_dict.get("name") if isinstance(_tc_fn_dict, dict) else None

Comment on lines +8924 to +8927
for _tool in payload.tools or []:
if not isinstance(_tool, dict) or _tool.get("type") != "function":
continue
_name = _tool.get("name")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If payload.tools contains Pydantic models instead of raw dictionaries, isinstance(_tool, dict) will evaluate to False, silently bypassing the validation check. We should defensively convert _tool to a dictionary if it is a Pydantic model before performing dictionary-specific checks.

    for _tool in payload.tools or []:
        _tool_dict = _tool if isinstance(_tool, dict) else (getattr(_tool, "model_dump", getattr(_tool, "dict", lambda: {})))() if _tool else {}
        if not isinstance(_tool_dict, dict) or _tool_dict.get("type") != "function":
            continue
        _name = _tool_dict.get("name")

Comment on lines +8942 to +8944
_tc = payload.tool_choice
if isinstance(_tc, dict) and _tc.get("type") == "function":
_tc_name = _tc.get("name")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If payload.tool_choice is parsed as a Pydantic model, isinstance(_tc, dict) will evaluate to False, silently bypassing the validation check. We should defensively convert _tc to a dictionary if it is a Pydantic model before performing dictionary-specific checks.

    _tc = payload.tool_choice
    _tc_dict = _tc if isinstance(_tc, dict) else (getattr(_tc, "model_dump", getattr(_tc, "dict", lambda: {})))() if _tc else {}
    if isinstance(_tc_dict, dict) and _tc_dict.get("type") == "function":
        _tc_name = _tc_dict.get("name")

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 5211b506e1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants