QVAC-23389 fix[api]: honor preload:false via lazy-load, keep DELETE reversible - #3906
QVAC-23389 fix[api]: honor preload:false via lazy-load, keep DELETE reversible#3906lauripiisang wants to merge 5 commits into
Conversation
…eversible - lazy-load a model on the first request that names it when preload:false; concurrent first requests share a single load (per-registry in-flight dedup) - DELETE /v1/models resets the alias to IDLE instead of removing it, so it reloads on the next request instead of becoming permanently unavailable - GET /v1/models lists all configured models; GET /v1/models/:id returns any configured alias (loading is transparent) - route every inference gate (require-model, audio speech, vector-stores) through a shared ensureReady helper - add a loadModelOverride test seam; cover lazy-load, concurrency, reload-after-DELETE (unit + e2e) - align serve docs: serve-openai.md, website http-server/configuration, README
Review StatusCurrent Status: ❌ PENDING Pending reviews: Needs 1 more from Management, Team Lead, or Member. |
License compliance — cleanNo new dependency license findings in this PR. Warn-only (shadow) mode — this check does not block merges yet. Updated automatically by the canonical license compliance workflow. NOTICE presence (advisory)Missing NOTICE (advisory, does not block):
|
opaninakuffo
left a comment
There was a problem hiding this comment.
lazy-load + reversible DELETE is the right fix, and the happy path is covered (unit + e2e). one hole: DELETE during an in-flight load is a no-op that still returns {deleted:true}, then setReady brings the model back.
🔴 requesting changes
| return existing | ||
| } | ||
|
|
||
| const load = runLoad(alias, registry, logger, loadOverride ?? defaultLoad).finally(() => { |
There was a problem hiding this comment.
The new in-flight map is keyed by alias, so it collapses concurrent requests for the same model but not across different ones. Two requests naming two different preload: false aliases each start a full load, and N aliases give N at once.
preloadModels just above, on line 55, still loads one at a time (for … await), so the same set of models that loads comfortably in sequence at startup can now land in parallel at request time. A config that is fine with preload: true could get tight on memory with preload: false under the same traffic.
Worth considering alongside this: these loads have no deadline, and nothing cancels one if the client disconnects. The server leaves Fastify's requestTimeout and connectionTimeout at the default 0 (serve/index.ts:125, unchanged here), which was harmless while no request path could start model I/O, but now means a single request can hold a socket for as long as the download takes.
There was a problem hiding this comment.
Addressed in e5ffbac with a new serve.load config block + mirrored CLI flags:
concurrency(default 1, mirrors preload's sequential loading) — distinct-alias lazy loads no longer fan out to N parallel loads.--load-concurrency.timeoutMs(default null) — bounds a load; on expiry the SDK load is cancelled and the request gets503 model_load_timeout.--load-timeout.cancelOnDisconnect(default true) — a client disconnecting mid-load cancels the load, but only once no other request is still waiting on that same (shared) load; refcounted in the newLoadManager.--no-cancel-load-on-disconnect.lazy(default true) —--no-lazy-loaddisables request-time loads entirely (unloaded →503 model_not_loaded), the bounded-latency mode for operators who don't want in-request downloads.
Concurrency + refcounted cancel + timeout are covered in load-manager.test.ts.
| if (!registryEntry || registryEntry.state !== ctx.registry.STATES.READY) { | ||
| throw new HttpError(503, 'model_not_ready', `Model "${modelName}" is not loaded yet.`) | ||
| } | ||
| const registryEntry = await ensureReady(ctx, alias, modelEntry, modelName) |
There was a problem hiding this comment.
The speech gate now goes through ensureReady, so a preload: false TTS model is served on first request — but GET /v1/audio/models further down at line 500 was left as registry.getReady().filter(entry => entry.endpointCategory === 'speech').
So after this change such a model shows up in /v1/models, works fine on /v1/audio/speech, and is missing from /v1/audio/models until something loads it. Explicit { src, type } entries default to preload: false, which is the usual shape for a TTS entry, so this may come up fairly often.
packages/cli/docs/serve-openai.md:724 still describes that endpoint as "the speech-capable subset of /v1/models" and names Open WebUI's TTS model selector as the consumer, so the selector would come up empty on a fresh server. Keying it off serveConfig.models, the same way line 58 of routes/models.ts now does, would line the two back up.
There was a problem hiding this comment.
Fixed — /v1/audio/models now lists configured speech models from serveConfig.models (same as /v1/models), so a preload:false TTS model shows up before its first use and the Open WebUI selector isn't empty on a fresh server.
| async () => ({ | ||
| object: 'list' as const, | ||
| data: app.qvac.registry.getReady().map(toModelObject) | ||
| data: [...app.qvac.serveConfig.models.keys()].map((alias) => toModelObject(app, alias)) |
There was a problem hiding this comment.
Listing every configured alias here removes the last HTTP-visible signal of load state. Together with GET /v1/models/{id} no longer filtering on READY and DELETE keeping the alias, the listing is now identical before and after an unload — ModelObject on line 10 carries only {id, object, created, owned_by}.
The case this affects is the warm-up poll the old description invited ("List all currently-loaded (READY) models"): poll /v1/models until the alias appears, then send traffic. That loop now returns straight away, and the first real request carries the whole cold start. The new e2e test reads app.qvac.registry directly to assert the unload, which points at the same gap.
An extra non-standard state field on the model object would bring the signal back without upsetting OpenAI clients, since they ignore unknown fields. This may already be part of the follow-up mentioned in the description.
There was a problem hiding this comment.
Added — model objects on /v1/models and /v1/models/:id now carry a non-standard state field (idle/loading/ready/error), so the warm-up poll works again without loading the model. OpenAI clients ignore the unknown field. This is the listing seed for QVAC-23390.
| // Reverse of a load: keep the alias registered so it can lazy-reload, but drop | ||
| // the SDK handle and return it to IDLE. Used by unload so DELETE stays | ||
| // reversible (the entry must survive for the next request to reload it). | ||
| function markUnloaded(modelId: string): void { |
There was a problem hiding this comment.
markUnloaded takes over the one place that called remove, which leaves remove (line 173, declared on line 90) without callers anywhere in src or test. Might be worth dropping while it's fresh.
There was a problem hiding this comment.
Done — registry.remove is removed (interface, impl, and return).
- add serve.load config (lazy, concurrency, timeoutMs, cancelOnDisconnect) with mirrored CLI flags (--no-lazy-load, --load-concurrency, --load-timeout, --no-cancel-load-on-disconnect) - extract a LoadManager: per-alias dedup, concurrency semaphore, per-load timeout (cancels the SDK load), and refcounted cancel-on-disconnect that only cancels once no request still waits on the shared load - lazy off returns 503 model_not_loaded; timeout returns 503 model_load_timeout - fix DELETE during an in-flight load: unload now waits for the load to settle, then unloads, so it can no longer report success while the model reloads - GET /v1/models and /v1/models/:id expose a non-standard `state` field so load state is visible without triggering a load - /v1/audio/models lists configured speech models (not just READY), matching /v1/models so lazy TTS models appear before first use - swagger summary + docs wording (model_not_ready is now a rare fallback); drop the now-unused registry.remove - tests: LoadManager unit suite (dedup, concurrency, timeout, refcount cancel), lazy-off / timeout / state HTTP e2e; docs updated (serve-openai, website configuration/http-server, README)
|
Thanks both — all review points addressed in e5ffbac. Correctness
Load management (
All four have CLI flags. Logic lives in a new Listing
Cleanups
Local: unit 474/474 (+ 1 unrelated live-network OpenAI-spec fetch), non-model e2e 125/125, model tier 75/75. |
- make load-manager cancel/unload injectable (LoadManagerDeps) for testing - unit: last-waiter-abort cancels SDK load + unloads late completion; timeout cancels - unit: ensureReady cancels on client disconnect, keeps loading when disabled - e2e: spawned --no-lazy-load returns model_not_loaded; --load-* flags parse/validate
…adManager - cancelled (client-disconnect) and timed-out loads now reset the entry to IDLE and log the event, instead of surfacing as state: error in the model listing - genuine load failures still set ERROR - buildServer now constructs loadManager as a real required field (no undefined-cast placeholder); the load-fn resolves the test override via an accessor-backed ref
|
Follow-up on two self-review items on top of the earlier fixes:
Coverage added ( Local: unit 478/478 (+1 unrelated live-network OpenAI-spec fetch), non-model e2e 128/128, model tier 75/75. All prior inline threads were addressed on e5ffbac. |
🎯 What problem does this PR solve?
qvac serve openaihad a preload / lifecycle gap:preload: falsenever worked. Such models were registered but never loaded, and every request returned503 model_not_readyforever — even though the docs promised lazy cold-start loading.DELETE /v1/models/{id}removed the alias from the registry with no reload path, so the model stayed resolvable from config but permanently unavailable until a full server restart.📝 How does it solve it?
preload: falsenow loads the first time a request names it. Loading is concurrency-safe: simultaneous first requests share a single load (per-registry in-flight dedup), and a failed cold start surfaces as503 model_load_failedand retries on the next request.DELETE /v1/models/{id}now resets the alias toIDLE(drops the SDK handle) instead of removing it, so the next request reloads it.GET /v1/modelslists all configured models (loaded or not) andGET /v1/models/{id}returns any configured alias — loading is transparent to the client.require-model, audio speech, vector-stores embedding) go through one sharedensureReadyhelper.preloaddefault asymmetry is unchanged (constant entries defaulttrue, explicit{ src, type }defaultfalse);--model <alias>still forces a warm start.packages/cli/docs/serve-openai.md, websitehttp-server/configuration, and the CLI README.Dedicated load endpoints and listing the full SDK model catalog (with config introspection) are intentionally left as a follow-up; the code is structured to keep that path open.
🧪 How was it tested?
test/lifecycle.test.ts: lazy-load state machine, concurrent-load dedup (single SDK load), error-then-retry, and reload-after-unload.preload: falseembedding on first request;GET /v1/modelslists all configured; DELETE keeps the alias listed + reloads on the next request.loadModelOverridetest seam (mirrorstranscribeOverride) so the model gate can be exercised without a heavy real load; migrated the video HTTP-layer tests to it.config.test.tsassertions locking the keptpreloaddefaults.🔌 API Changes
HTTP surface behavior (non-breaking — naming a configured model just works):