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
13 changes: 13 additions & 0 deletions changelog/unreleased/catalog-auto-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
### English

- The display catalog now reloads in the background every 10 minutes by default instead of only when a client asks, so a model an upstream adds appears in `/v1/models` without a manual refresh. Set `CLI2API_CATALOG_REFRESH_INTERVAL` (Go duration such as `15m`, or plain minutes) to change the interval; `0` disables the loop.
- The last successful catalog is persisted in SQLite and restored on boot, and a failed catalog refresh now keeps serving the previous model list instead of returning an empty one.
- `GET /v1/models` reports `catalog.synced_at` and `catalog.stale` beside `data`; upstream error text stays on the console endpoint so a scoped API key cannot read another provider's failure.
- `GET /api/model-catalog` (console) returns the catalog sync state with per-provider last success time, last error, and model count; `POST /api/model-catalog` forces a refresh.

### 中文

- 模型目录改为后台自动刷新,默认每 10 分钟一次,不再只在客户端请求时才拉取,上游新增的模型无需手动刷新就会出现在 `/v1/models`。刷新间隔用 `CLI2API_CATALOG_REFRESH_INTERVAL` 调整(Go duration 如 `15m`,或纯数字表示分钟),设为 `0` 关闭定时刷新。
- 最近一次成功的模型目录会写入 SQLite 并在启动时恢复;目录刷新失败时继续使用上一次的有效列表,不再返回空列表。
- `GET /v1/models` 在 `data` 旁边新增 `catalog.synced_at` 与 `catalog.stale`;上游错误文本只留在控制台接口,限定供应商的 API Key 读不到其他供应商的失败原因。
- 控制台 `GET /api/model-catalog` 返回目录同步状态(每个供应商的上次成功时间、失败原因、模型数量),`POST /api/model-catalog` 立即刷新一次。
7 changes: 7 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ func New(cfg config.Config) *App {
}
a.CrossProviderModelPool.Store(crossProviderModelPool)
a.Control.Catalog = appsvc.NewCatalog(a.FetchWorkerModelsForMode)
a.Control.Catalog.Store = a.Manager.Store()
a.Control.Catalog.Interval = cfg.CatalogRefreshInterval
a.Control.Catalog.Health = appsvc.NewCatalogHealth()
// Restore the last successful catalog before the first request so a restart
// serves the previous model list instead of an empty one.
a.Control.Catalog.Restore(context.Background())
go a.Control.Catalog.RunRefresher(stopLogs)
if a.Control.Settings != nil {
a.Control.Settings.BindCatalog(a.Control.Catalog)
}
Expand Down
13 changes: 13 additions & 0 deletions internal/app/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ func (a *App) newGateway() *apigateway.Handler {
Models: func(refresh bool, accountID string) ([]map[string]any, error) {
return a.fetchDisplayModels(refresh, accountID, control.CatalogModeMerge)
},
CatalogStatus: func() map[string]any {
if a.Control == nil || a.Control.Catalog == nil {
return nil
}
status := a.Control.Catalog.Status()
// The public listing reports freshness only: a scoped API key must not
// read another provider's upstream error text. The console endpoint
// carries the details.
return map[string]any{
"synced_at": status.SyncedAt,
"stale": status.Stale,
}
},
}
if a.Control != nil {
h.ModelContexts = a.Control.Settings
Expand Down
11 changes: 10 additions & 1 deletion internal/app/workerproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,23 @@ func (a *App) catalogSource() *control.CatalogSource {
item, ok := a.Pool.First()
return item.URL, ok
}}
return &control.CatalogSource{Providers: a.Providers, WorkerModels: worker.Models, Accounts: func() []control.CatalogAccount {
return &control.CatalogSource{Providers: a.Providers, WorkerModels: worker.Models, Health: a.catalogHealth(), Accounts: func() []control.CatalogAccount {
var out []control.CatalogAccount
for _, item := range a.Pool.Items() {
out = append(out, control.CatalogAccount{ID: item.ID, Provider: item.Provider, Region: item.Region, Worker: item.URL != ""})
}
return out
}}
}

// catalogHealth exposes the display catalog's per-provider recorder so a
// provider-level catalog fetch lands in the same status the console serves.
func (a *App) catalogHealth() *control.CatalogHealth {
if a == nil || a.Control == nil || a.Control.Catalog == nil {
return nil
}
return a.Control.Catalog.Health
}
func (a *App) FetchWorkerModelsFor(refresh bool, accountID string) ([]map[string]any, error) {
return a.FetchWorkerModelsForMode(refresh, accountID, control.CatalogModeMerge)
}
Expand Down
105 changes: 69 additions & 36 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,35 @@ import (
"os"
"strconv"
"strings"
"time"
)

// DefaultCatalogRefreshInterval is the documented default for the background
// model-catalog reload. Operators can change it with
// CLI2API_CATALOG_REFRESH_INTERVAL (a Go duration such as 30s or 15m, or plain
// minutes); 0 disables the loop and leaves only manual/console refreshes.
const DefaultCatalogRefreshInterval = 10 * time.Minute

type Config struct {
Host string
Port int
ProxyAPIKey string
ProxyURL string
MaxRetryAccounts int
QoderHome string
DataDir string
RuntimeDir string
WorkerBasePort int
NodeBinary string
WorkerDaemonPath string
QoderCLIPath string
QoderCNCLIPath string
PlainTemplatePath string
UpdateSocketPath string
UpdateAgentURL string
UpdateAgentToken string
UpdateGitHubToken string
Host string
Port int
ProxyAPIKey string
ProxyURL string
MaxRetryAccounts int
QoderHome string
DataDir string
RuntimeDir string
WorkerBasePort int
NodeBinary string
WorkerDaemonPath string
QoderCLIPath string
QoderCNCLIPath string
PlainTemplatePath string
UpdateSocketPath string
UpdateAgentURL string
UpdateAgentToken string
UpdateGitHubToken string
CatalogRefreshInterval time.Duration
}

func Load() (Config, error) {
Expand Down Expand Up @@ -70,27 +78,52 @@ func Load() (Config, error) {
runtimeDir = "/tmp/cli2api-runtime"
}
return Config{
Host: host,
Port: port,
ProxyAPIKey: "",
ProxyURL: strings.TrimSpace(os.Getenv("QODER_PROXY_URL")),
MaxRetryAccounts: maxRetryAccounts,
QoderHome: home,
DataDir: dataDir,
RuntimeDir: runtimeDir,
WorkerBasePort: workerBasePort,
NodeBinary: firstNonEmpty(os.Getenv("QODER_NODE_BINARY"), "node"),
WorkerDaemonPath: firstNonEmpty(os.Getenv("QODER_WORKER_DAEMON"), "worker/src/daemon.mjs"),
QoderCLIPath: firstNonEmpty(os.Getenv("QODERCLI_JS"), "/usr/local/lib/node_modules/@qoder-ai/qodercli/bundle/qodercli.js"),
QoderCNCLIPath: firstNonEmpty(os.Getenv("QODERCNCLI_JS"), "/usr/local/lib/node_modules/@qodercn-ai/qoderclicn/bundle/qoderclicn.js"),
PlainTemplatePath: firstNonEmpty(os.Getenv("PLAIN_TEMPLATE_PATH"), "worker/last-plain.sample.json"),
UpdateSocketPath: firstNonEmpty(os.Getenv("UPDATE_SOCKET_PATH"), "/run/cli2api-updater/updater.sock"),
UpdateAgentURL: strings.TrimSpace(os.Getenv("UPDATE_AGENT_URL")),
UpdateAgentToken: strings.TrimSpace(os.Getenv("UPDATE_AGENT_TOKEN")),
UpdateGitHubToken: strings.TrimSpace(os.Getenv("UPDATE_GITHUB_TOKEN")),
Host: host,
Port: port,
ProxyAPIKey: "",
ProxyURL: strings.TrimSpace(os.Getenv("QODER_PROXY_URL")),
MaxRetryAccounts: maxRetryAccounts,
QoderHome: home,
DataDir: dataDir,
RuntimeDir: runtimeDir,
WorkerBasePort: workerBasePort,
NodeBinary: firstNonEmpty(os.Getenv("QODER_NODE_BINARY"), "node"),
WorkerDaemonPath: firstNonEmpty(os.Getenv("QODER_WORKER_DAEMON"), "worker/src/daemon.mjs"),
QoderCLIPath: firstNonEmpty(os.Getenv("QODERCLI_JS"), "/usr/local/lib/node_modules/@qoder-ai/qodercli/bundle/qodercli.js"),
QoderCNCLIPath: firstNonEmpty(os.Getenv("QODERCNCLI_JS"), "/usr/local/lib/node_modules/@qodercn-ai/qoderclicn/bundle/qoderclicn.js"),
PlainTemplatePath: firstNonEmpty(os.Getenv("PLAIN_TEMPLATE_PATH"), "worker/last-plain.sample.json"),
UpdateSocketPath: firstNonEmpty(os.Getenv("UPDATE_SOCKET_PATH"), "/run/cli2api-updater/updater.sock"),
UpdateAgentURL: strings.TrimSpace(os.Getenv("UPDATE_AGENT_URL")),
UpdateAgentToken: strings.TrimSpace(os.Getenv("UPDATE_AGENT_TOKEN")),
UpdateGitHubToken: strings.TrimSpace(os.Getenv("UPDATE_GITHUB_TOKEN")),
CatalogRefreshInterval: catalogRefreshInterval(os.Getenv("CLI2API_CATALOG_REFRESH_INTERVAL")),
}, nil
}

// catalogRefreshInterval parses CLI2API_CATALOG_REFRESH_INTERVAL: a Go duration
// ("90s", "15m") or a bare number of minutes ("10"). Unset keeps the default,
// a negative or zero value disables the background loop, and garbage falls back
// to the default with a log line rather than failing the boot.
func catalogRefreshInterval(raw string) time.Duration {
value := strings.TrimSpace(raw)
if value == "" {
return DefaultCatalogRefreshInterval
}
if d, err := time.ParseDuration(value); err == nil {
if d < 0 {
return 0
}
return d
}
if minutes, err := strconv.Atoi(value); err == nil {
if minutes < 0 {
return 0
}
return time.Duration(minutes) * time.Minute
}
return DefaultCatalogRefreshInterval
}

func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
Expand Down
20 changes: 20 additions & 0 deletions internal/console/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ func (h *Handler) HandleModelsAPI(w http.ResponseWriter, r *http.Request) {
})
}

// HandleCatalogStatus reports the model-catalog sync state. GET only reads it;
// POST forces one full reload first and answers with the state that resulted.
// The body is 200 in both cases — a partial or failed refresh is described by
// last_error and the per-provider rows, not by the HTTP status.
func (h *Handler) HandleCatalogStatus(w http.ResponseWriter, r *http.Request) {
if h == nil || h.Control == nil || h.Control.Catalog == nil {
writeErr(w, http.StatusServiceUnavailable, "catalog_unavailable", "model catalog is not configured")
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, h.Control.Catalog.Status())
case http.MethodPost:
status, _ := h.Control.Catalog.Refresh()
writeJSON(w, http.StatusOK, status)
default:
writeErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET or POST only")
}
}

func (h *Handler) HandleModelSetting(w http.ResponseWriter, r *http.Request) {
provider, modelKey := splitModelSettingPath(r.URL.Path, r.URL.Query().Get("provider"))
if modelKey == "" {
Expand Down
Loading
Loading