From 1113519b658ed635723045a5bd3ea21e4f2e3958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Sun, 6 Sep 2026 20:09:18 +0800 Subject: [PATCH] fix: cache GET /api/models for five minutes Keep Overview and /v1/models live. refresh=1 still bypasses the snapshot so operators can force a catalog reload without waiting out the TTL. --- CHANGELOG.md | 4 +++ internal/api/auth_test.go | 60 +++++++++++++++++++++++++++++++++++++++ internal/api/chat.go | 56 +++++++++++++++++++++++++++++++++++- internal/api/server.go | 2 ++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee7fdd2..83d5798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,12 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. ### English +- Cache `GET /api/models` for 5 minutes so the console catalog page does not re-hit WorkBuddy or Trae on every load; `?refresh=1` still fetches live. Overview stays uncached. + ### 中文 +- `GET /api/models` 缓存 5 分钟,控制台模型页不再每次都打 WorkBuddy / Trae 目录;`?refresh=1` 仍即时拉取。Overview 不缓存。 + ## 0.2.48 - 2026-09-06 ### English diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index b32f747..25b6855 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -228,6 +229,65 @@ func TestModelsAPICatalogFailureUses503(t *testing.T) { } } +type countingCatalog struct { + hits atomic.Int32 + models []providers.ModelInfo +} + +func (c *countingCatalog) Models(context.Context, string) ([]providers.ModelInfo, error) { + c.hits.Add(1) + return c.models, nil +} + +func TestModelsAPICachesCatalogForFiveMinutes(t *testing.T) { + srv := New(config.Config{ + Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret", + QoderHome: t.TempDir(), DataDir: t.TempDir(), + }) + defer srv.Close() + catalog := &countingCatalog{models: []providers.ModelInfo{{ + NativeModel: "glm-5.3", PublicModel: "glm-5.3", DisplayName: "GLM", + }}} + srv.pool.Upsert(accounts.Item{ID: "wb-cn", Provider: "workbuddy", Runtime: string(providers.RuntimeInProcess)}) + srv.providers.Register(providers.Adapter{ID: "workbuddy", Models: catalog}) + + getModels := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + return rec + } + + first := getModels("/api/models?account=wb-cn") + if first.Code != http.StatusOK { + t.Fatalf("first /api/models = %d %s", first.Code, first.Body.String()) + } + second := getModels("/api/models?account=wb-cn") + if second.Code != http.StatusOK { + t.Fatalf("cached /api/models = %d %s", second.Code, second.Body.String()) + } + if catalog.hits.Load() != 1 { + t.Fatalf("cached /api/models hits = %d, want 1", catalog.hits.Load()) + } + + overview := getModels("/api/overview") + if overview.Code != http.StatusOK { + t.Fatalf("/api/overview = %d %s", overview.Code, overview.Body.String()) + } + if catalog.hits.Load() != 2 { + t.Fatalf("overview must not use /api/models cache, hits = %d", catalog.hits.Load()) + } + + refreshed := getModels("/api/models?account=wb-cn&refresh=1") + if refreshed.Code != http.StatusOK { + t.Fatalf("refresh /api/models = %d %s", refreshed.Code, refreshed.Body.String()) + } + if catalog.hits.Load() != 3 { + t.Fatalf("refresh=1 hits = %d, want 3", catalog.hits.Load()) + } +} + func TestLegacyDiagnosticRoutesAreRemoved(t *testing.T) { srv := New(config.Config{ Host: "127.0.0.1", diff --git a/internal/api/chat.go b/internal/api/chat.go index 3727980..90fc5c6 100644 --- a/internal/api/chat.go +++ b/internal/api/chat.go @@ -543,9 +543,16 @@ func (s *Server) filterModelsForIdentity(r *http.Request, models []map[string]an return filtered } +const modelsAPICacheTTL = 5 * time.Minute + +type modelsAPICacheEntry struct { + models []map[string]any + at time.Time +} + func (s *Server) handleModelsAPI(w http.ResponseWriter, r *http.Request) { refresh := r.URL.Query().Get("refresh") == "1" - models, err := s.fetchWorkerModelsFor(refresh, s.requestedAccount(r)) + models, err := s.fetchModelsAPI(refresh, s.requestedAccount(r)) if err != nil { // 503, not 502: some reverse proxies replace origin 502 JSON with // their own HTML error page, which the console then renders as the @@ -559,6 +566,53 @@ func (s *Server) handleModelsAPI(w http.ResponseWriter, r *http.Request) { }) } +func modelsAPICacheKey(accountID string) string { + if strings.TrimSpace(accountID) == "" { + return "*" + } + return accountID +} + +func cloneModelList(models []map[string]any) []map[string]any { + if models == nil { + return nil + } + out := make([]map[string]any, 0, len(models)) + for _, model := range models { + item := make(map[string]any, len(model)) + for key, value := range model { + item[key] = value + } + out = append(out, item) + } + return out +} + +// fetchModelsAPI serves GET /api/models from a 5-minute snapshot. Overview and +// /v1/models keep calling fetchWorkerModelsFor directly so they stay live. +func (s *Server) fetchModelsAPI(refresh bool, accountID string) ([]map[string]any, error) { + key := modelsAPICacheKey(accountID) + if !refresh { + s.modelsAPICacheMu.Lock() + entry, ok := s.modelsAPICache[key] + s.modelsAPICacheMu.Unlock() + if ok && time.Since(entry.at) < modelsAPICacheTTL { + return cloneModelList(entry.models), nil + } + } + models, err := s.fetchWorkerModelsFor(refresh, accountID) + if err != nil { + return nil, err + } + s.modelsAPICacheMu.Lock() + if s.modelsAPICache == nil { + s.modelsAPICache = map[string]modelsAPICacheEntry{} + } + s.modelsAPICache[key] = modelsAPICacheEntry{models: cloneModelList(models), at: time.Now()} + s.modelsAPICacheMu.Unlock() + return cloneModelList(models), nil +} + func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only") diff --git a/internal/api/server.go b/internal/api/server.go index 297db7f..4bf320f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -50,6 +50,8 @@ type Server struct { updateRunning atomic.Bool updateMu sync.Mutex updateJob *systemUpdateJob + modelsAPICacheMu sync.Mutex + modelsAPICache map[string]modelsAPICacheEntry } func New(cfg config.Config) *Server {