From e09b3f9fb5e8e18b65796424182b31d09dc49406 Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Fri, 5 Jun 2026 17:59:05 -0700 Subject: [PATCH] openai: align models list with tags (#16556) --- openai/openai.go | 9 +++- openai/openai_test.go | 42 +++++++++++++++ server/routes_list_test.go | 108 +++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/openai/openai.go b/openai/openai.go index 51d9ada7..cf8b65f2 100644 --- a/openai/openai.go +++ b/openai/openai.go @@ -408,11 +408,16 @@ func ToCompleteChunk(id string, r api.GenerateResponse) CompletionChunk { func ToListCompletion(r api.ListResponse) ListCompletion { var data []Model for _, m := range r.Models { + id := m.Model + if id == "" { + id = m.Name + } + data = append(data, Model{ - Id: m.Name, + Id: id, Object: "model", Created: m.ModifiedAt.Unix(), - OwnedBy: model.ParseName(m.Name).Namespace, + OwnedBy: model.ParseName(id).Namespace, }) } diff --git a/openai/openai_test.go b/openai/openai_test.go index 6c262e1e..81c64d6b 100644 --- a/openai/openai_test.go +++ b/openai/openai_test.go @@ -356,6 +356,48 @@ func TestFromCompleteRequest_WithLogprobs(t *testing.T) { } } +func TestToListCompletionUsesModelIdentity(t *testing.T) { + modified := time.Unix(1234567890, 0).UTC() + + result := ToListCompletion(api.ListResponse{ + Models: []api.ListModelResponse{ + { + Name: "legacy-name:latest", + Model: "namespace/exposed-model:latest", + ModifiedAt: modified, + }, + { + Name: "fallback-name:latest", + ModifiedAt: modified.Add(time.Second), + }, + }, + }) + + if result.Object != "list" { + t.Fatalf("object = %q, want list", result.Object) + } + if len(result.Data) != 2 { + t.Fatalf("models = %d, want 2", len(result.Data)) + } + + if result.Data[0].Id != "namespace/exposed-model:latest" { + t.Fatalf("id = %q, want model field", result.Data[0].Id) + } + if result.Data[0].OwnedBy != "namespace" { + t.Fatalf("owned_by = %q, want namespace", result.Data[0].OwnedBy) + } + if result.Data[0].Created != modified.Unix() { + t.Fatalf("created = %d, want %d", result.Data[0].Created, modified.Unix()) + } + + if result.Data[1].Id != "fallback-name:latest" { + t.Fatalf("fallback id = %q, want name field", result.Data[1].Id) + } + if result.Data[1].OwnedBy != "library" { + t.Fatalf("fallback owned_by = %q, want library", result.Data[1].OwnedBy) + } +} + func TestToChatCompletion_WithLogprobs(t *testing.T) { createdAt := time.Unix(1234567890, 0) resp := api.ChatResponse{ diff --git a/server/routes_list_test.go b/server/routes_list_test.go index e18f27e6..f7b8bd6e 100644 --- a/server/routes_list_test.go +++ b/server/routes_list_test.go @@ -3,13 +3,20 @@ package server import ( "context" "encoding/json" + "io" "net/http" + "net/http/httptest" + "os" "slices" "testing" + "time" "github.com/gin-gonic/gin" "github.com/ollama/ollama/api" + "github.com/ollama/ollama/manifest" + "github.com/ollama/ollama/openai" + "github.com/ollama/ollama/types/model" ) func TestList(t *testing.T) { @@ -76,3 +83,104 @@ func TestList(t *testing.T) { } } } + +func TestOpenAIListMatchesTagsModels(t *testing.T) { + gin.SetMode(gin.TestMode) + setTestHome(t, t.TempDir()) + + cache := newModelListCache() + s := Server{modelCaches: &modelCaches{modelList: cache}} + cache.Start(context.Background()) + if err := cache.Wait(context.Background()); err != nil { + t.Fatal(err) + } + + createModel := func(name string) { + t.Helper() + + _, digest := createBinFile(t, nil, nil) + w := createRequest(t, s.CreateHandler, api.CreateRequest{ + Model: name, + Files: map[string]string{"model.gguf": digest}, + Stream: &stream, + }) + if w.Code != http.StatusOK { + t.Fatalf("create %s status = %d, want 200: %s", name, w.Code, w.Body.String()) + } + } + + createModel("older-model") + createModel("newer-model") + + setManifestTime := func(name string, modified time.Time) { + t.Helper() + + parsed := model.ParseName(name) + path, err := manifest.PathForName(parsed) + if err != nil { + t.Fatalf("manifest path for %s: %v", name, err) + } + if err := os.Chtimes(path, modified, modified); err != nil { + t.Fatalf("set manifest time for %s: %v", name, err) + } + if err := cache.RefreshModel(parsed); err != nil { + t.Fatalf("refresh %s: %v", name, err) + } + } + + older := time.Unix(1000, 0).UTC() + newer := time.Unix(2000, 0).UTC() + setManifestTime("older-model:latest", older) + setManifestTime("newer-model:latest", newer) + + router, err := s.GenerateRoutes(nil) + if err != nil { + t.Fatal(err) + } + + doGet := func(path string, dest any) { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + body, err := io.ReadAll(w.Body) + if err != nil { + t.Fatalf("read %s response: %v", path, err) + } + if w.Code != http.StatusOK { + t.Fatalf("GET %s status = %d, want 200: %s", path, w.Code, string(body)) + } + if err := json.Unmarshal(body, dest); err != nil { + t.Fatalf("decode %s response: %v", path, err) + } + } + + var tags api.ListResponse + doGet("/api/tags", &tags) + + var models openai.ListCompletion + doGet("/v1/models", &models) + + if len(tags.Models) != 2 { + t.Fatalf("/api/tags models = %d, want 2: %+v", len(tags.Models), tags.Models) + } + if len(models.Data) != len(tags.Models) { + t.Fatalf("/v1/models data = %d, want %d", len(models.Data), len(tags.Models)) + } + + for i, tagModel := range tags.Models { + v1Model := models.Data[i] + if v1Model.Id != tagModel.Model { + t.Fatalf("model %d id = %q, want /api/tags model %q", i, v1Model.Id, tagModel.Model) + } + if v1Model.Created != tagModel.ModifiedAt.Unix() { + t.Fatalf("model %d created = %d, want modified_at %d", i, v1Model.Created, tagModel.ModifiedAt.Unix()) + } + } + + if got, want := models.Data[0].Id, "newer-model:latest"; got != want { + t.Fatalf("first /v1/models id = %q, want %q", got, want) + } +}