Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -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",
Expand Down
56 changes: 55 additions & 1 deletion internal/api/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading