From 1017200cb3ebfab1d959c035e826f887ae3aa415 Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Wed, 20 May 2026 14:26:11 -0400 Subject: [PATCH] fix(openai-compat): surface availability state for OpenAI-compatible backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI-compatible /v1/models endpoints (vllm, llama.cpp, Infinity, etc.) return only id/object/created/owned_by — no size, no state field. As a result, openAIParser left model.Size = 0 and metadata["state"] unset, so MapModelState() always fell through to "unknown" for these backends. Meanwhile, the unified converter read ep.State (the legacy string set once at discovery and never transitioned), not ep.GetEffectiveState() which consults the typed ModelState field populated by the lifecycle unifier. Combined, endpoints that successfully routed traffic still reported state: "unknown" forever in /olla/models. Two-part fix: - openAIParser: set Size = 1 sentinel. For OpenAI-compat backends, presence in the discovery response IS the availability signal — these servers only list models that are loaded and ready to serve. - unified_converter: read string(ep.GetEffectiveState()) instead of ep.State, so health-driven transitions and the typed state machine both surface in the API response. All tests pass: go test ./... Co-Authored-By: Claude Opus 4.7 --- internal/adapter/converter/unified_converter.go | 8 +++++++- internal/adapter/registry/profile/parsers.go | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/adapter/converter/unified_converter.go b/internal/adapter/converter/unified_converter.go index 961ac613..914d493f 100644 --- a/internal/adapter/converter/unified_converter.go +++ b/internal/adapter/converter/unified_converter.go @@ -74,9 +74,15 @@ func (c *UnifiedConverter) ConvertToFormat(models []*domain.UnifiedModel, filter func (c *UnifiedConverter) convertModel(model *domain.UnifiedModel) UnifiedModelData { availability := make([]EndpointStatus, 0, len(model.SourceEndpoints)) for _, ep := range model.SourceEndpoints { + // Use GetEffectiveState() rather than ep.State directly: the lifecycle + // unifier updates the typed ModelState field, while ep.State (the legacy + // string) is only set at discovery time and never transitions. Reading + // the effective state ensures health-driven transitions surface in the + // API response. GetEffectiveState() also normalises legacy string values + // ("loaded", "not-loaded", "available") to the typed enum. availability = append(availability, EndpointStatus{ Endpoint: ep.EndpointName, // Use endpoint name instead of URL - State: ep.State, + State: string(ep.GetEffectiveState()), }) } // OLLA-85: [Unification] Models with different digests fail to unify correctly. diff --git a/internal/adapter/registry/profile/parsers.go b/internal/adapter/registry/profile/parsers.go index ea91e53d..c2db8004 100644 --- a/internal/adapter/registry/profile/parsers.go +++ b/internal/adapter/registry/profile/parsers.go @@ -225,6 +225,15 @@ func (p *openAIParser) Parse(data []byte) ([]*domain.ModelInfo, error) { Name: model.ID, Type: model.Object, // always "model" in openai responses LastSeen: now, + // OpenAI-compatible /v1/models has no size or state fields. For these + // backends (vllm, llama.cpp, Infinity, etc.) the presence of a model + // in the discovery response IS the availability signal — these servers + // only list models that are loaded and ready to serve. Without a + // non-zero Size, MapModelState() falls through to "unknown" and the + // model never appears available in the unified /olla/models response. + // Set a sentinel size of 1 to signal "loaded / available, exact size + // not reported by this backend". + Size: 1, } // openai is stingy with metadata