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

- Return cached provider models immediately while refreshing expired catalogs in the background, and deduplicate concurrent catalog loads

### 中文

- 供应商模型目录优先立即返回缓存,并在缓存过期后后台刷新,同时合并并发目录请求

## 0.4.1 - 2026-09-09

### English
Expand Down
44 changes: 42 additions & 2 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,56 @@ export function loginWithPat(pat: string, accountId?: string) {
})
}

type ModelsResponse = { data?: Overview['models'] }

type ModelsMemoryEntry = {
data: ModelsResponse
at: number
pending?: Promise<ModelsResponse>
}

const modelsMemoryTTL = 30_000
const modelsMemoryCache = new Map<string, ModelsMemoryEntry>()

function modelsMemoryKey(accountId?: string) {
return accountId || '*'
}

export function fetchModels(accountId?: string, refresh = false) {
const q = new URLSearchParams()
if (refresh) q.set('refresh', '1')
if (accountId) q.set('account', accountId)
const query = q.toString()
return api<{ data?: Overview['models'] }>(`/api/models${query ? `?${query}` : ''}`)
return api<ModelsResponse>(`/api/models${query ? `?${query}` : ''}`)
}

export function fetchModelsCached(accountId?: string) {
const key = modelsMemoryKey(accountId)
const cached = modelsMemoryCache.get(key)
if (cached && Date.now() - cached.at < modelsMemoryTTL) {
return Promise.resolve(cached.data)
}
if (cached?.pending) return cached.pending
const pending = fetchModels(accountId).then((data) => {
modelsMemoryCache.set(key, { data, at: Date.now() })
return data
}).finally(() => {
const current = modelsMemoryCache.get(key)
if (current?.pending === pending) {
modelsMemoryCache.set(key, { data: current.data, at: current.at })
}
})
modelsMemoryCache.set(key, { data: cached?.data || {}, at: cached?.at || 0, pending })
return pending
}

export function refreshModels(accountId?: string) {
return fetchModels(accountId, true)
const key = modelsMemoryKey(accountId)
modelsMemoryCache.delete(key)
return fetchModels(accountId, true).then((data) => {
modelsMemoryCache.set(key, { data, at: Date.now() })
return data
})
}

export function updateModelContext(modelKey: string, contextLength: number) {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/ProvidersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button, Card, Chip, Input, Table, Tooltip } from '@heroui/react'
import { Cube, ArrowClockwise, ArrowCounterClockwise, FloppyDisk, MagnifyingGlass, Info } from '@phosphor-icons/react'
import { useI18n } from '@/hooks/useI18n'
import { useOverview } from '@/hooks/useOverview'
import { fetchModels, refreshModels, updateModelContext, updateProviderReasoning, updateTraeMaxMode } from '@/api/overview'
import { fetchModelsCached, refreshModels, updateModelContext, updateProviderReasoning, updateTraeMaxMode } from '@/api/overview'
import type { Overview } from '@/api/types'
import { ProviderMark } from '@/components/ProviderMark'
import { ModelDetailsModal, formatTokens } from '@/components/ModelDetailsModal'
Expand Down Expand Up @@ -182,7 +182,7 @@ export function ProvidersPage() {
const [modelsLoading, setModelsLoading] = useState(true)
useEffect(() => {
let cancelled = false
void fetchModels()
void fetchModelsCached()
.then((data) => { if (!cancelled) setModels(data.data || []) })
.catch(() => undefined)
.finally(() => { if (!cancelled) setModelsLoading(false) })
Expand Down
70 changes: 53 additions & 17 deletions internal/api/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,12 @@ type modelsAPICacheEntry struct {
at time.Time
}

type modelsAPIRefresh struct {
done chan struct{}
models []map[string]any
err error
}

func (s *Server) handleModelsAPI(w http.ResponseWriter, r *http.Request) {
refresh := r.URL.Query().Get("refresh") == "1"
models, err := s.fetchModelsAPI(refresh, s.requestedAccount(r))
Expand Down Expand Up @@ -603,29 +609,59 @@ func cloneModelList(models []map[string]any) []map[string]any {
return out
}

// fetchModelsAPI serves GET /api/models from a 5-minute snapshot. Overview and
// /v1/models keep calling fetchWorkerModelsFor directly so they stay live.
// fetchModelsAPI serves GET /api/models from a 5-minute snapshot. Expired
// snapshots are returned immediately while one background refresh updates the
// cache. A cold cache waits for the one in-flight refresh instead of starting
// duplicate upstream catalog requests.
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.Lock()
entry, hasCache := s.modelsAPICache[key]
if !refresh && hasCache && time.Since(entry.at) < modelsAPICacheTTL {
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
return cloneModelList(entry.models), nil
}
s.modelsAPICacheMu.Lock()
if s.modelsAPICache == nil {
s.modelsAPICache = map[string]modelsAPICacheEntry{}
if !refresh && hasCache {
_ = s.startModelsAPIRefreshLocked(key, true, accountID)
s.modelsAPICacheMu.Unlock()
return cloneModelList(entry.models), nil
}
s.modelsAPICache[key] = modelsAPICacheEntry{models: cloneModelList(models), at: time.Now()}
refreshing := s.startModelsAPIRefreshLocked(key, refresh, accountID)
s.modelsAPICacheMu.Unlock()
return cloneModelList(models), nil
<-refreshing.done
if refreshing.err != nil {
return nil, refreshing.err
}
return cloneModelList(refreshing.models), nil
}

func (s *Server) startModelsAPIRefreshLocked(key string, force bool, accountID string) *modelsAPIRefresh {
if s.modelsAPIRefresh == nil {
s.modelsAPIRefresh = map[string]*modelsAPIRefresh{}
}
if refreshing, ok := s.modelsAPIRefresh[key]; ok {
return refreshing
}
refreshing := &modelsAPIRefresh{done: make(chan struct{})}
s.modelsAPIRefresh[key] = refreshing
go func() {
models, err := s.fetchWorkerModelsFor(force, accountID)
refreshing.models = models
refreshing.err = err
if err == nil {
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()
}
s.modelsAPICacheMu.Lock()
delete(s.modelsAPIRefresh, key)
s.modelsAPICacheMu.Unlock()
close(refreshing.done)
}()
return refreshing
}

type chatHTTPError struct {
Expand Down
1 change: 1 addition & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type Server struct {
updateJob *systemUpdateJob
modelsAPICacheMu sync.Mutex
modelsAPICache map[string]modelsAPICacheEntry
modelsAPIRefresh map[string]*modelsAPIRefresh
statsCacheMu sync.Mutex
statsCache map[string]statsCacheEntry
}
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-Ci76vQMf.js"></script>
<script type="module" crossorigin src="/assets/index-BAjBDEmY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cz5uICSf.css">
</head>
<body>
Expand Down
Loading