From 0e092778e57f689f57da757657c9bf27506576ab Mon Sep 17 00:00:00 2001 From: whitedotjsx Date: Tue, 8 Sep 2026 02:44:07 -0500 Subject: [PATCH] feat: dynamically fetch CommandCode model catalog - Fetch the official model list from the CommandCode docs registry (https://commandcode.ai/docs/reference/cli/models) on startup and every 6h - /v1/models now returns the live catalog (68 models) with the static list as fallback until the first fetch completes - MapModel resolves against the catalog: exact ids, short names (kimi-k2.5 -> moonshotai/Kimi-K2.5) and punctuation-insensitive matches (gemini38flash -> google/gemini-3.8-flash) - Static alias table kept as fallback; add unit tests for parsing, resolution and fallback --- .gitignore | 1 + README.md | 13 +- internal/proxy/catalog.go | 247 +++++++++++++++++++++++++++++++++ internal/proxy/catalog_test.go | 164 ++++++++++++++++++++++ internal/proxy/model.go | 15 +- internal/proxy/proxy.go | 38 ++--- 6 files changed, 446 insertions(+), 32 deletions(-) create mode 100644 internal/proxy/catalog.go create mode 100644 internal/proxy/catalog_test.go diff --git a/.gitignore b/.gitignore index d90563d..37fdcd2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # Drafts & Claude config .drafts/ .claude/ +.opencode/ # OS files .DS_Store diff --git a/README.md b/README.md index 96572ed..02bff7a 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,17 @@ curl -N http://127.0.0.1:55990/v1/chat/completions \ ## Supported model aliases -The proxy accepts full model IDs and these short aliases: +The proxy accepts full model IDs, short names (`kimi-k2.5` → `moonshotai/Kimi-K2.5`), and punctuation-insensitive variants (`gemini38flash` → `google/gemini-3.8-flash`). + +On startup and then every 6 hours, the proxy fetches the official model list from: + +```text +https://commandcode.ai/docs/reference/cli/models +``` + +This is the same model registry that backs the Command Code CLI (`--list-models` / `/model` picker), so the proxy's `/v1/models` always reflects the latest available models without code changes. The fetched catalog is cached in memory; `MapModel` resolves against it first (exact id, short name, and punctuation-insensitive match), falling back to the static list below until the first fetch completes. + +Built-in alias fallbacks (used before the first catalog fetch succeeds): | Alias | Maps to | | --- | --- | @@ -196,6 +206,7 @@ Unknown model names are passed through unchanged. │ ├── commandcode.go │ └── openai.go ├── proxy + │ ├── catalog.go │ ├── convert.go │ ├── model.go │ └── proxy.go diff --git a/internal/proxy/catalog.go b/internal/proxy/catalog.go new file mode 100644 index 0000000..9feaceb --- /dev/null +++ b/internal/proxy/catalog.go @@ -0,0 +1,247 @@ +package proxy + +import ( + "fmt" + "io" + "log" + "net/http" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/dev2k6/command-code-proxy-server/internal/api" +) + +const ( + modelCatalogURL = "https://commandcode.ai/docs/reference/cli/models" + modelCatalogRefreshEvery = 6 * time.Hour + modelCatalogRetryAfter = 5 * time.Minute + modelCatalogMaxResponseSize = 8 << 20 +) + +// modelIDRowRegex matches a model-id block inside the models reference +// page. The link target is always https://commandcode.ai/models/ and the +// code block is the canonical id (e.g. "deepseek/deepseek-v4-flash"). +var modelIDRowRegex = regexp.MustCompile(`]*>([^<]+)`) + +// modelCatalog holds the latest model list fetched from CommandCode. +// It is populated in the background by StartModelRefresher. +type modelCatalog struct { + mu sync.RWMutex + ids []string + byID map[string]string + byShort map[string]string + byNorm map[string]string + loaded bool +} + +var catalog = &modelCatalog{} + +// update replaces the catalog contents and rebuilds lookup indexes. +func (c *modelCatalog) update(ids []string) { + c.mu.Lock() + defer c.mu.Unlock() + + sorted := append([]string(nil), ids...) + sort.Strings(sorted) + + c.ids = sorted + c.byID = map[string]string{} + c.byShort = map[string]string{} + c.byNorm = map[string]string{} + + for _, id := range sorted { + lower := strings.ToLower(id) + c.byID[lower] = id + + short := id + if i := strings.LastIndex(id, "/"); i >= 0 { + short = id[i+1:] + } + ls := strings.ToLower(short) + if _, ok := c.byShort[ls]; !ok { + c.byShort[ls] = id + } + key := normalizeModelID(short) + if _, ok := c.byNorm[key]; !ok { + c.byNorm[key] = id + } + } + c.loaded = true +} + +// resolve maps a client model name to a canonical CommandCode model id using +// the fetched catalog. It supports exact ids, short names (name after "/") +// and punctuation-insensitive matches. It returns "" when the catalog is not +// loaded yet or nothing matches. +func (c *modelCatalog) resolve(name string) string { + c.mu.RLock() + defer c.mu.RUnlock() + + if !c.loaded { + return "" + } + key := strings.ToLower(strings.TrimSpace(name)) + if key == "" { + return "" + } + if id, ok := c.byID[key]; ok { + return id + } + if id, ok := c.byShort[key]; ok { + return id + } + if id, ok := c.byNorm[normalizeModelID(key)]; ok { + return id + } + return "" +} + +// loaded reports whether the catalog has been populated at least once. +func (c *modelCatalog) isLoaded() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.loaded +} + +// idList returns a sorted copy of the catalog model ids. +func (c *modelCatalog) idList() []string { + c.mu.RLock() + defer c.mu.RUnlock() + return append([]string(nil), c.ids...) +} + +// normalizeModelID strips every non-alphanumeric character and lowercases. +func normalizeModelID(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} + +// parseModelIDs extracts canonical model ids from the CommandCode models +// reference page HTML. +func parseModelIDs(html string) []string { + seen := map[string]bool{} + var ids []string + for _, m := range modelIDRowRegex.FindAllStringSubmatch(html, -1) { + id := strings.TrimSpace(m[1]) + if id == "" || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + return ids +} + +// fetchModelIDs fetches the CommandCode models reference page and parses the +// model ids out of it. +func fetchModelIDs() ([]string, error) { + return fetchModelIDsFrom(modelCatalogURL) +} + +// fetchModelIDsFrom fetches url and parses model ids out of the response. +func fetchModelIDsFrom(url string) ([]string, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("fetch model catalog: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("model catalog returned %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, modelCatalogMaxResponseSize)) + if err != nil { + return nil, fmt.Errorf("read model catalog: %w", err) + } + + ids := parseModelIDs(string(body)) + if len(ids) == 0 { + return nil, fmt.Errorf("no model ids found in catalog") + } + return ids, nil +} + +// StartModelRefresher fetches the CommandCode model catalog in the background +// and keeps it fresh. It runs until the process exits. +func (p *Proxy) StartModelRefresher() { + go func() { + for { + ids, err := fetchModelIDs() + if err != nil { + log.Printf("model catalog refresh failed: %v", err) + time.Sleep(modelCatalogRetryAfter) + continue + } + catalog.update(ids) + if p.Debug { + log.Printf("model catalog updated: %d models", len(ids)) + } + time.Sleep(modelCatalogRefreshEvery) + } + }() +} + +// ownedByFor derives an owned_by value from a model id. +func ownedByFor(id string) string { + if i := strings.LastIndex(id, "/"); i >= 0 { + return id[:i] + } + return "commandcode" +} + +// catalogModels builds the OpenAI-compatible model list from the catalog. +func catalogModels() []api.OpenAIModel { + ids := catalog.idList() + out := make([]api.OpenAIModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.OpenAIModel{ + ID: id, + Object: "model", + Created: 0, + OwnedBy: ownedByFor(id), + }) + } + return out +} + +// staticModels is the fallback list used until the catalog is fetched. +func staticModels() []api.OpenAIModel { + return []api.OpenAIModel{ + // MoonshotAI + {ID: "moonshotai/Kimi-K2.6", Object: "model", Created: 0, OwnedBy: "moonshotai"}, + {ID: "moonshotai/Kimi-K2.5", Object: "model", Created: 0, OwnedBy: "moonshotai"}, + // ZhipuAI + {ID: "zai-org/GLM-5.1", Object: "model", Created: 0, OwnedBy: "zhipuai"}, + {ID: "zai-org/GLM-5", Object: "model", Created: 0, OwnedBy: "zhipuai"}, + // MiniMaxAI + {ID: "MiniMaxAI/MiniMax-M2.7", Object: "model", Created: 0, OwnedBy: "minimaxai"}, + {ID: "MiniMaxAI/MiniMax-M2.5", Object: "model", Created: 0, OwnedBy: "minimaxai"}, + {ID: "MiniMaxAI/MiniMax-M3", Object: "model", Created: 0, OwnedBy: "minimaxai"}, + // DeepSeek + {ID: "deepseek/deepseek-v4-pro", Object: "model", Created: 0, OwnedBy: "deepseek"}, + {ID: "deepseek/deepseek-v4-flash", Object: "model", Created: 0, OwnedBy: "deepseek"}, + // Qwen + {ID: "Qwen/Qwen3.6-Max-Preview", Object: "model", Created: 0, OwnedBy: "qwen"}, + {ID: "Qwen/Qwen3.6-Plus", Object: "model", Created: 0, OwnedBy: "qwen"}, + // StepFun + {ID: "stepfun/Step-3.5-Flash", Object: "model", Created: 0, OwnedBy: "stepfun"}, + {ID: "stepfun/Step-3.7-Flash", Object: "model", Created: 0, OwnedBy: "stepfun"}, + // Qwen (3.7 line) + {ID: "Qwen/Qwen3.7-Max-Free", Object: "model", Created: 0, OwnedBy: "qwen"}, + {ID: "Qwen/Qwen3.7-Max", Object: "model", Created: 0, OwnedBy: "qwen"}, + // Xiaomi MiMo + {ID: "xiaomi/mimo-v2.5-pro", Object: "model", Created: 0, OwnedBy: "xiaomi"}, + {ID: "xiaomi/mimo-v2.5", Object: "model", Created: 0, OwnedBy: "xiaomi"}, + // Google + {ID: "google/gemini-3.1-flash-lite", Object: "model", Created: 0, OwnedBy: "google"}, + } +} diff --git a/internal/proxy/catalog_test.go b/internal/proxy/catalog_test.go new file mode 100644 index 0000000..2b5b1d4 --- /dev/null +++ b/internal/proxy/catalog_test.go @@ -0,0 +1,164 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +const testCatalogHTML = ` + + + + + +deepseek/deepseek-v4-flash +
deepseek/deepseek-v4-proDeepSeek V4 Pro
deepseek/deepseek-v4-flashDeepSeek V4 Flash
claude-sonnet-5Claude Sonnet 5
Qwen/Qwen3.8-MaxQwen 3.8 Max
+` + +func TestParseModelIDs(t *testing.T) { + got := parseModelIDs(testCatalogHTML) + want := []string{"deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "claude-sonnet-5", "Qwen/Qwen3.8-Max"} + if len(got) != len(want) { + t.Fatalf("parseModelIDs returned %d ids %v, want %d %v", len(got), got, len(want), want) + } + for i, w := range want { + if got[i] != w { + t.Errorf("id[%d] = %q, want %q", i, got[i], w) + } + } +} + +func TestParseModelIDsNonTableCodeIgnored(t *testing.T) { + got := parseModelIDs(`

Run cmd --model deepseek/deepseek-v4-flash and /model

`) + if len(got) != 0 { + t.Errorf("expected no ids from prose code blocks, got %v", got) + } +} + +func TestCatalogResolve(t *testing.T) { + c := &modelCatalog{} + c.update([]string{"deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash-vision-exp", "claude-sonnet-5", "Qwen/Qwen3.8-Max"}) + + cases := []struct { + in, want string + }{ + // full ids, case-insensitive + {"deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-pro"}, + {"DeepSeek/DeepSeek-V4-Pro", "deepseek/deepseek-v4-pro"}, + // short names (name after "/") + {"deepseek-v4-pro", "deepseek/deepseek-v4-pro"}, + {"claude-sonnet-5", "claude-sonnet-5"}, + // punctuation-insensitive + {"deepseekv4pro", "deepseek/deepseek-v4-pro"}, + {"qwen38max", "Qwen/Qwen3.8-Max"}, + {"qwen-3.8-max", "Qwen/Qwen3.8-Max"}, + // unknown + {"some/unknown-model", ""}, + {"", ""}, + } + for _, cse := range cases { + got := c.resolve(cse.in) + if got != cse.want { + t.Errorf("resolve(%q) = %q, want %q", cse.in, got, cse.want) + } + } +} + +func TestMapModelUsesCatalog(t *testing.T) { + // Saved fields to restore the singleton afterwards. + catalog.mu.Lock() + savedIDs, savedByID, savedByShort, savedByNorm, savedLoaded := catalog.ids, catalog.byID, catalog.byShort, catalog.byNorm, catalog.loaded + catalog.mu.Unlock() + defer func() { + catalog.mu.Lock() + catalog.ids, catalog.byID, catalog.byShort, catalog.byNorm, catalog.loaded = savedIDs, savedByID, savedByShort, savedByNorm, savedLoaded + catalog.mu.Unlock() + }() + + // Catalog not loaded -> static fallback + old := catalog + old.mu.Lock() + old.loaded = false + old.mu.Unlock() + + if got := MapModel("deepseek-v4-flash"); got != "deepseek/deepseek-v4-flash" { + t.Errorf("static fallback: MapModel = %q", got) + } + // Catalog loaded -> dynamic resolution wins over static table + catalog.update([]string{"deepseek/deepseek-v4-flash-fast", "moonshotai/Kimi-K3", "google/gemini-3.8-flash"}) + if got := MapModel("deepseek-v4-flash"); got != "deepseek/deepseek-v4-flash" { + t.Errorf("catalog should shadow static: MapModel = %q", got) + } + if got := MapModel("deepseek-v4-flash-fast"); got != "deepseek/deepseek-v4-flash-fast" { + t.Errorf("catalog new model: MapModel = %q", got) + } + if got := MapModel("kimi-k3"); got != "moonshotai/Kimi-K3" { + t.Errorf("short name from catalog: MapModel = %q", got) + } + if got := MapModel("gemini38flash"); got != "google/gemini-3.8-flash" { + t.Errorf("punctuation-insensitive: MapModel = %q", got) + } + // Model only in static table still resolves + if got := MapModel("glm-5.1"); got != "zai-org/GLM-5.1" { + t.Errorf("static-only alias: MapModel = %q", got) + } +} + +func TestFetchModelIDsFrom(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(testCatalogHTML)) + })) + defer srv.Close() + + ids, err := fetchModelIDsFrom(srv.URL) + if err != nil { + t.Fatalf("fetchModelIDsFrom failed: %v", err) + } + if len(ids) != 4 { + t.Fatalf("expected 4 ids, got %d: %v", len(ids), ids) + } +} + +func TestFetchModelIDsFromError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + if _, err := fetchModelIDsFrom(srv.URL); err == nil { + t.Fatal("expected error for non-200 response") + } +} + +func TestCatalogModelsBuildsList(t *testing.T) { + catalog.update([]string{"deepseek/deepseek-v4-pro", "claude-sonnet-5"}) + models := catalogModels() + if len(models) != 2 { + t.Fatalf("catalogModels returned %d models, want 2", len(models)) + } + if models[0].ID != "claude-sonnet-5" && models[0].ID != "deepseek/deepseek-v4-pro" { + t.Errorf("unexpected first model %q", models[0].ID) + } + for _, m := range models { + if m.Object != "model" { + t.Errorf("model %q object = %q", m.ID, m.Object) + } + } + if models[0].OwnedBy == "" { + t.Errorf("owned_by missing for %q", models[0].ID) + } +} + +func TestOwnedByFor(t *testing.T) { + cases := []struct{ in, want string }{ + {"deepseek/deepseek-v4-pro", "deepseek"}, + {"Qwen/Qwen3.8-Max", "Qwen"}, + {"claude-sonnet-5", "commandcode"}, + } + for _, c := range cases { + if got := ownedByFor(c.in); got != c.want { + t.Errorf("ownedByFor(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/proxy/model.go b/internal/proxy/model.go index 9e95845..a9e106d 100644 --- a/internal/proxy/model.go +++ b/internal/proxy/model.go @@ -2,8 +2,21 @@ package proxy import "strings" -// Map model name if client sends short name +// MapModel maps a client model name to a CommandCode model id. +// It first resolves against the dynamically fetched model catalog (supports +// exact ids, short names, and punctuation-insensitive matches). If the +// catalog is not loaded yet or the name is not found, it falls back to the +// static alias table, and finally passes unknown names through unchanged. func MapModel(name string) string { + if id := catalog.resolve(name); id != "" { + return id + } + return staticMapModel(name) +} + +// staticMapModel handles well-known short aliases while the catalog is still +// loading (or as a fallback for names the catalog does not contain). +func staticMapModel(name string) string { switch strings.ToLower(name) { case "deepseek-v4-pro", "deepseek-v4", "deepseek-pro": return "deepseek/deepseek-v4-pro" diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 0418b0e..6524c7a 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -68,11 +68,13 @@ type Proxy struct { // NewProxy creates a new proxy instance func NewProxy(apiKey string) *Proxy { - return &Proxy{ + p := &Proxy{ APIKey: apiKey, BaseURL: defaultBaseURL, Client: &http.Client{Timeout: defaultTimeout}, } + p.StartModelRefresher() + return p } // BuildRequest builds the CommandCode request body @@ -668,37 +670,13 @@ func responseItemsToMessages(items []any) []api.OpenAIMessage { // HandleModels handles the /v1/models endpoint func (p *Proxy) HandleModels(w http.ResponseWriter, r *http.Request) { + data := staticModels() + if catalog.isLoaded() { + data = catalogModels() + } models := api.OpenAIModelList{ Object: "list", - Data: []api.OpenAIModel{ - // MoonshotAI - {ID: "moonshotai/Kimi-K2.6", Object: "model", Created: 0, OwnedBy: "moonshotai"}, - {ID: "moonshotai/Kimi-K2.5", Object: "model", Created: 0, OwnedBy: "moonshotai"}, - // ZhipuAI - {ID: "zai-org/GLM-5.1", Object: "model", Created: 0, OwnedBy: "zhipuai"}, - {ID: "zai-org/GLM-5", Object: "model", Created: 0, OwnedBy: "zhipuai"}, - // MiniMaxAI - {ID: "MiniMaxAI/MiniMax-M2.7", Object: "model", Created: 0, OwnedBy: "minimaxai"}, - {ID: "MiniMaxAI/MiniMax-M2.5", Object: "model", Created: 0, OwnedBy: "minimaxai"}, - {ID: "MiniMaxAI/MiniMax-M3", Object: "model", Created: 0, OwnedBy: "minimaxai"}, - // DeepSeek - {ID: "deepseek/deepseek-v4-pro", Object: "model", Created: 0, OwnedBy: "deepseek"}, - {ID: "deepseek/deepseek-v4-flash", Object: "model", Created: 0, OwnedBy: "deepseek"}, - // Qwen - {ID: "Qwen/Qwen3.6-Max-Preview", Object: "model", Created: 0, OwnedBy: "qwen"}, - {ID: "Qwen/Qwen3.6-Plus", Object: "model", Created: 0, OwnedBy: "qwen"}, - // StepFun - {ID: "stepfun/Step-3.5-Flash", Object: "model", Created: 0, OwnedBy: "stepfun"}, - {ID: "stepfun/Step-3.7-Flash", Object: "model", Created: 0, OwnedBy: "stepfun"}, - // Qwen (3.7 line) - {ID: "Qwen/Qwen3.7-Max-Free", Object: "model", Created: 0, OwnedBy: "qwen"}, - {ID: "Qwen/Qwen3.7-Max", Object: "model", Created: 0, OwnedBy: "qwen"}, - // Xiaomi MiMo - {ID: "xiaomi/mimo-v2.5-pro", Object: "model", Created: 0, OwnedBy: "xiaomi"}, - {ID: "xiaomi/mimo-v2.5", Object: "model", Created: 0, OwnedBy: "xiaomi"}, - // Google - {ID: "google/gemini-3.1-flash-lite", Object: "model", Created: 0, OwnedBy: "google"}, - }, + Data: data, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(models)