From bdc792fed198d7564df833fe2265f79dcbd789c4 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 12:56:05 +0200 Subject: [PATCH 01/25] feat: add catalog management commands and sync functionality --- cmd/routatic-proxy/catalog.go | 72 +++++++++++++ cmd/routatic-proxy/main.go | 1 + internal/catalog/lock.go | 73 +++++++++++++ internal/catalog/lock_test.go | 169 ++++++++++++++++++++++++++++++ internal/catalog/sync.go | 101 ++++++++++++++++++ internal/catalog/sync_test.go | 192 ++++++++++++++++++++++++++++++++++ 6 files changed, 608 insertions(+) create mode 100644 cmd/routatic-proxy/catalog.go create mode 100644 internal/catalog/lock.go create mode 100644 internal/catalog/lock_test.go create mode 100644 internal/catalog/sync.go create mode 100644 internal/catalog/sync_test.go diff --git a/cmd/routatic-proxy/catalog.go b/cmd/routatic-proxy/catalog.go new file mode 100644 index 0000000..41df0ee --- /dev/null +++ b/cmd/routatic-proxy/catalog.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/routatic/proxy/internal/catalog" + "github.com/spf13/cobra" +) + +// catalogSourceURL is the default models.dev catalog URL. It is a package +// variable so tests can override it with a local server endpoint. +var catalogSourceURL = "https://models.dev/catalog.json" + +// catalogCmd returns the top-level catalog command. +func catalogCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "catalog", + Short: "Manage the models.dev catalog cache", + Long: `Download and cache the models.dev catalog locally. + +The catalog is stored under ~/.config/routatic-proxy/catalog by default and is +used to resolve canonical model names and providers at runtime.`, + } + + cmd.AddCommand(catalogSyncCmd()) + cmd.PersistentFlags().StringP("config", "c", "", "Path to config file (used to locate the catalog directory)") + + return cmd +} + +// catalogSyncCmd returns the command to sync the models.dev catalog. +func catalogSyncCmd() *cobra.Command { + return &cobra.Command{ + Use: "sync", + Short: "Download and cache the models.dev catalog", + RunE: func(cmd *cobra.Command, args []string) error { + configPath, err := cmd.Flags().GetString("config") + if err != nil { + return fmt.Errorf("read config flag: %w", err) + } + + catalogDir := resolveCatalogDir(configPath) + lock, err := catalog.Sync(catalogSourceURL, catalogDir) + if err != nil { + return fmt.Errorf("catalog sync failed: %w", err) + } + + fmt.Printf("Catalog synced to %s\n", catalogDir) + fmt.Printf(" SHA256: %s\n", lock.SHA256) + fmt.Printf(" Bytes: %d\n", lock.Bytes) + fmt.Printf(" TTL: %d hours\n", lock.TTLHours) + return nil + }, + } +} + +// resolveCatalogDir returns the directory where the catalog should be stored. +// If configPath is provided, the catalog is stored alongside that config file. +// Otherwise the default config directory is used. +func resolveCatalogDir(configPath string) string { + if configPath != "" { + return filepath.Join(filepath.Dir(configPath), "catalog") + } + + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(".config", "routatic-proxy", "catalog") + } + return filepath.Join(home, ".config", "routatic-proxy", "catalog") +} diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 0763f55..e76b95e 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -48,6 +48,7 @@ Legacy ~/.config/oc-go-cc/config.json and OC_GO_CC_* environment variables are s rootCmd.AddCommand(validateCmd()) rootCmd.AddCommand(checkCmd()) rootCmd.AddCommand(modelsCmd()) + rootCmd.AddCommand(catalogCmd()) rootCmd.AddCommand(autostartCmd()) rootCmd.AddCommand(updateCmd()) addPlatformCommands(rootCmd) diff --git a/internal/catalog/lock.go b/internal/catalog/lock.go new file mode 100644 index 0000000..ae16e77 --- /dev/null +++ b/internal/catalog/lock.go @@ -0,0 +1,73 @@ +// Package catalog downloads, validates, and caches the models.dev catalog. +package catalog + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +const lockFileName = "catalog.lock.json" +const lockTmpFileName = "catalog.lock.json.tmp" + +// Lock records metadata about a successfully synced catalog. +type Lock struct { + SourceURL string `json:"source_url"` + SyncedAt time.Time `json:"synced_at"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` + TTLHours int `json:"ttl_hours"` +} + +// Expired reports whether the lock is older than its TTL relative to now. +// A non-positive TTL is treated as already expired. +func (l *Lock) Expired(now time.Time) bool { + if l == nil || l.TTLHours <= 0 { + return true + } + return now.After(l.SyncedAt.Add(time.Duration(l.TTLHours) * time.Hour)) +} + +// WriteLock writes lock to destDir/catalog.lock.json atomically. The +// destination directory is created if it does not already exist. +func WriteLock(destDir string, lock *Lock) error { + if lock == nil { + return fmt.Errorf("lock is nil") + } + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("create destination directory: %w", err) + } + data, err := json.MarshalIndent(lock, "", " ") + if err != nil { + return fmt.Errorf("marshal lock: %w", err) + } + data = append(data, '\n') + + tmpPath := filepath.Join(destDir, lockTmpFileName) + finalPath := filepath.Join(destDir, lockFileName) + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("write lock temp file: %w", err) + } + if err := os.Rename(tmpPath, finalPath); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("rename lock file: %w", err) + } + return nil +} + +// ReadLock reads lock from destDir/catalog.lock.json. +func ReadLock(destDir string) (*Lock, error) { + path := filepath.Join(destDir, lockFileName) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read lock file: %w", err) + } + var lock Lock + if err := json.Unmarshal(data, &lock); err != nil { + return nil, fmt.Errorf("parse lock file: %w", err) + } + return &lock, nil +} diff --git a/internal/catalog/lock_test.go b/internal/catalog/lock_test.go new file mode 100644 index 0000000..0889cc4 --- /dev/null +++ b/internal/catalog/lock_test.go @@ -0,0 +1,169 @@ +package catalog + +import ( + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +func TestWriteLockNil(t *testing.T) { + err := WriteLock(t.TempDir(), nil) + if err == nil { + t.Fatalf("expected error writing nil lock, got nil") + } +} + +func TestReadLockMissing(t *testing.T) { + _, err := ReadLock(t.TempDir()) + if err == nil { + t.Fatalf("expected error reading missing lock, got nil") + } +} + +func TestLockRoundTrip(t *testing.T) { + cases := []struct { + name string + lock *Lock + }{ + { + name: "full lock round-trip", + lock: &Lock{ + SourceURL: "https://models.dev/catalog.json", + SyncedAt: time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC), + SHA256: "abcdef0123456789", + Bytes: 12345, + TTLHours: 24, + }, + }, + { + name: "zero-value lock round-trip", + lock: &Lock{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + destDir := t.TempDir() + if err := WriteLock(destDir, tc.lock); err != nil { + t.Fatalf("WriteLock error: %v", err) + } + + got, err := ReadLock(destDir) + if err != nil { + t.Fatalf("ReadLock error: %v", err) + } + if !reflect.DeepEqual(got, tc.lock) { + t.Fatalf("lock mismatch:\n got: %+v\nwant: %+v", got, tc.lock) + } + + tmpPath := filepath.Join(destDir, lockTmpFileName) + if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { + t.Fatalf("expected no temp lock file left behind, got %v", err) + } + }) + } +} + +func TestWriteLockCreatesDestDir(t *testing.T) { + base := t.TempDir() + destDir := filepath.Join(base, "nested", "catalog") + lock := &Lock{SourceURL: "https://models.dev/catalog.json"} + if err := WriteLock(destDir, lock); err != nil { + t.Fatalf("WriteLock error: %v", err) + } + if _, err := os.Stat(filepath.Join(destDir, lockFileName)); err != nil { + t.Fatalf("expected lock file in created dir: %v", err) + } +} + +func TestReadLockErrors(t *testing.T) { + cases := []struct { + name string + prepare func(destDir string) error + }{ + { + name: "missing lock file", + prepare: func(_ string) error { return nil }, + }, + { + name: "corrupted lock file JSON", + prepare: func(destDir string) error { + return os.WriteFile(filepath.Join(destDir, lockFileName), []byte("not json"), 0644) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + destDir := t.TempDir() + if err := tc.prepare(destDir); err != nil { + t.Fatalf("prepare error: %v", err) + } + if _, err := ReadLock(destDir); err == nil { + t.Fatalf("expected error, got nil") + } + }) + } +} + +func TestLockExpired(t *testing.T) { + cases := []struct { + name string + lock *Lock + now time.Time + expired bool + }{ + { + name: "nil lock is expired", + lock: nil, + now: time.Now(), + expired: true, + }, + { + name: "fresh lock is not expired", + lock: &Lock{ + SyncedAt: time.Now().Add(-1 * time.Hour), + TTLHours: 24, + }, + now: time.Now(), + expired: false, + }, + { + name: "stale lock is expired", + lock: &Lock{ + SyncedAt: time.Now().Add(-25 * time.Hour), + TTLHours: 24, + }, + now: time.Now(), + expired: true, + }, + { + name: "zero TTL is expired", + lock: &Lock{ + SyncedAt: time.Now(), + TTLHours: 0, + }, + now: time.Now(), + expired: true, + }, + { + name: "negative TTL is expired", + lock: &Lock{ + SyncedAt: time.Now(), + TTLHours: -1, + }, + now: time.Now(), + expired: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.lock.Expired(tc.now); got != tc.expired { + t.Fatalf("Expired(%v) = %v, want %v", tc.now, got, tc.expired) + } + }) + } +} diff --git a/internal/catalog/sync.go b/internal/catalog/sync.go new file mode 100644 index 0000000..452b12f --- /dev/null +++ b/internal/catalog/sync.go @@ -0,0 +1,101 @@ +package catalog + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +const ( + catalogFileName = "catalog.json" + tmpFileName = "catalog.json.tmp" + maxCatalogBytes = 50 << 20 // 50 MiB + defaultTTLHours = 24 +) + +// envelope validates that the top-level catalog JSON contains the expected +// models and providers objects. +type envelope struct { + Models map[string]json.RawMessage `json:"models"` + Providers map[string]json.RawMessage `json:"providers"` +} + +// Sync downloads the models.dev catalog from sourceURL, validates its shape, +// writes it atomically to destDir/catalog.json, and persists a lock file. +func Sync(sourceURL, destDir string) (*Lock, error) { + if sourceURL == "" { + return nil, fmt.Errorf("source URL is required") + } + if destDir == "" { + return nil, fmt.Errorf("destination directory is required") + } + + if err := os.MkdirAll(destDir, 0755); err != nil { + return nil, fmt.Errorf("create destination directory: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, sourceURL, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch catalog: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode) + } + + limited := http.MaxBytesReader(nil, resp.Body, maxCatalogBytes) + body, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("read catalog: %w", err) + } + + var env envelope + if err := json.Unmarshal(body, &env); err != nil { + return nil, fmt.Errorf("parse catalog: %w", err) + } + if env.Models == nil || env.Providers == nil { + return nil, fmt.Errorf("catalog must contain models and providers objects") + } + + sum := sha256.Sum256(body) + hash := hex.EncodeToString(sum[:]) + + tmpPath := filepath.Join(destDir, tmpFileName) + if err := os.WriteFile(tmpPath, body, 0644); err != nil { + _ = os.Remove(tmpPath) + return nil, fmt.Errorf("write catalog temp file: %w", err) + } + + finalPath := filepath.Join(destDir, catalogFileName) + if err := os.Rename(tmpPath, finalPath); err != nil { + _ = os.Remove(tmpPath) + return nil, fmt.Errorf("rename catalog file: %w", err) + } + + lock := &Lock{ + SourceURL: sourceURL, + SyncedAt: time.Now().UTC(), + SHA256: hash, + Bytes: int64(len(body)), + TTLHours: defaultTTLHours, + } + + if err := WriteLock(destDir, lock); err != nil { + return nil, fmt.Errorf("write lock: %w", err) + } + + return lock, nil +} diff --git a/internal/catalog/sync_test.go b/internal/catalog/sync_test.go new file mode 100644 index 0000000..26da2c7 --- /dev/null +++ b/internal/catalog/sync_test.go @@ -0,0 +1,192 @@ +package catalog + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSync(t *testing.T) { + validCatalog := `{"models":{"gpt-4":{}},"providers":{"openai":{}}}` + validHash := sha256.Sum256([]byte(validCatalog)) + + cases := []struct { + name string + body string + wantErr bool + wantLock bool + wantCatalog bool + }{ + { + name: "successful sync writes catalog and lock", + body: validCatalog, + wantErr: false, + wantLock: true, + wantCatalog: true, + }, + { + name: "missing providers object returns error and leaves no catalog", + body: `{"models":{"gpt-4":{}}}`, + wantErr: true, + wantLock: false, + wantCatalog: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + + destDir := t.TempDir() + lock, err := Sync(server.URL, destDir) + + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + if tc.wantLock && lock == nil { + t.Fatalf("expected non-nil lock") + } + if !tc.wantLock && lock != nil { + t.Fatalf("expected nil lock, got %+v", lock) + } + + catalogPath := filepath.Join(destDir, catalogFileName) + if tc.wantCatalog { + data, err := os.ReadFile(catalogPath) + if err != nil { + t.Fatalf("expected catalog file: %v", err) + } + if string(data) != tc.body { + t.Fatalf("catalog content mismatch: got %q, want %q", string(data), tc.body) + } + } else { + if _, err := os.Stat(catalogPath); !os.IsNotExist(err) { + t.Fatalf("expected no catalog file, got %v", err) + } + } + + lockPath := filepath.Join(destDir, lockFileName) + if tc.wantLock { + read, err := ReadLock(destDir) + if err != nil { + t.Fatalf("expected readable lock: %v", err) + } + if read.SourceURL != server.URL { + t.Fatalf("lock source URL mismatch: got %q, want %q", read.SourceURL, server.URL) + } + if read.SHA256 != hex.EncodeToString(validHash[:]) { + t.Fatalf("lock SHA256 mismatch: got %q, want %q", read.SHA256, hex.EncodeToString(validHash[:])) + } + if read.Bytes != int64(len(tc.body)) { + t.Fatalf("lock bytes mismatch: got %d, want %d", read.Bytes, len(tc.body)) + } + if read.TTLHours != defaultTTLHours { + t.Fatalf("lock TTL mismatch: got %d, want %d", read.TTLHours, defaultTTLHours) + } + } else { + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Fatalf("expected no lock file, got %v", err) + } + } + + tmpPath := filepath.Join(destDir, tmpFileName) + if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { + t.Fatalf("expected no temp file left behind, got %v", err) + } + }) + } +} + +func TestSyncOversized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Emit a valid-looking prefix so the size guard, not JSON parsing, fails. + prefix := []byte(`{"models":{`) + _, _ = w.Write(prefix) + padding := strings.Repeat("0", maxCatalogBytes+1) + _, _ = w.Write([]byte(padding)) + _, _ = w.Write([]byte(`},"providers":{}}`)) + })) + defer server.Close() + + destDir := t.TempDir() + _, err := Sync(server.URL, destDir) + if err == nil { + t.Fatalf("expected error for oversized response, got nil") + } + + for _, name := range []string{catalogFileName, tmpFileName, lockFileName} { + path := filepath.Join(destDir, name) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected no %s after oversized sync failure, got %v", name, err) + } + } +} + +func TestSyncNonOKStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + defer server.Close() + + destDir := t.TempDir() + _, err := Sync(server.URL, destDir) + if err == nil { + t.Fatalf("expected error for non-OK status, got nil") + } + if !strings.Contains(err.Error(), fmt.Sprintf("%d", http.StatusInternalServerError)) { + t.Fatalf("expected status in error, got %v", err) + } +} + +func TestSyncMissingModels(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"providers":{"openai":{}}}`)) + })) + defer server.Close() + + destDir := t.TempDir() + _, err := Sync(server.URL, destDir) + if err == nil { + t.Fatalf("expected error for missing models object, got nil") + } + if _, err := os.Stat(filepath.Join(destDir, catalogFileName)); !os.IsNotExist(err) { + t.Fatalf("expected no catalog file, got %v", err) + } +} + +func TestSyncCreatesDestDir(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":{"x":{}},"providers":{"y":{}}}`)) + })) + defer server.Close() + + base := t.TempDir() + destDir := filepath.Join(base, "nested", "catalog") + _, err := Sync(server.URL, destDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(filepath.Join(destDir, catalogFileName)); err != nil { + t.Fatalf("expected catalog file: %v", err) + } +} From 72ad026982fee9132e3beafc32af33efd5852acd Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 12:58:12 +0200 Subject: [PATCH 02/25] feat: add unit tests for catalog synchronization and directory resolution --- cmd/routatic-proxy/catalog.go | 8 +- cmd/routatic-proxy/catalog_test.go | 169 +++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 cmd/routatic-proxy/catalog_test.go diff --git a/cmd/routatic-proxy/catalog.go b/cmd/routatic-proxy/catalog.go index 41df0ee..8ee50cd 100644 --- a/cmd/routatic-proxy/catalog.go +++ b/cmd/routatic-proxy/catalog.go @@ -47,10 +47,10 @@ func catalogSyncCmd() *cobra.Command { return fmt.Errorf("catalog sync failed: %w", err) } - fmt.Printf("Catalog synced to %s\n", catalogDir) - fmt.Printf(" SHA256: %s\n", lock.SHA256) - fmt.Printf(" Bytes: %d\n", lock.Bytes) - fmt.Printf(" TTL: %d hours\n", lock.TTLHours) + cmd.Printf("Catalog synced to %s\n", catalogDir) + cmd.Printf(" SHA256: %s\n", lock.SHA256) + cmd.Printf(" Bytes: %d\n", lock.Bytes) + cmd.Printf(" TTL: %d hours\n", lock.TTLHours) return nil }, } diff --git a/cmd/routatic-proxy/catalog_test.go b/cmd/routatic-proxy/catalog_test.go new file mode 100644 index 0000000..78a5763 --- /dev/null +++ b/cmd/routatic-proxy/catalog_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestResolveCatalogDir_Default(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("cannot determine home directory: %v", err) + } + + got := resolveCatalogDir("") + want := filepath.Join(home, ".config", "routatic-proxy", "catalog") + if got != want { + t.Fatalf("resolveCatalogDir(\"\") = %q, want %q", got, want) + } +} + +func TestResolveCatalogDir_FromConfigPath(t *testing.T) { + tmp := t.TempDir() + configPath := filepath.Join(tmp, "config.json") + + got := resolveCatalogDir(configPath) + want := filepath.Join(tmp, "catalog") + if got != want { + t.Fatalf("resolveCatalogDir(%q) = %q, want %q", configPath, got, want) + } +} + +func TestCatalogSyncCmd_Help(t *testing.T) { + cmd := catalogSyncCmd() + buf := &bytes.Buffer{} + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"--help"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("help failed: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Download and cache the models.dev catalog") { + t.Fatalf("help text missing expected description: %q", out) + } +} + +func TestCatalogCmd_Help(t *testing.T) { + cmd := catalogCmd() + buf := &bytes.Buffer{} + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"--help"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("help failed: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "sync") { + t.Fatalf("help text missing sync subcommand: %q", out) + } +} + +func TestCatalogSyncCmd_Success(t *testing.T) { + catalogJSON := `{ + "models": { + "claude-sonnet-4": {"providers": ["openrouter"]} + }, + "providers": { + "openrouter": {"base_url": "https://openrouter.ai/api/v1"} + } +}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/catalog.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, catalogJSON) + })) + defer server.Close() + + // Override the package-level source URL for this test. + oldURL := catalogSourceURL + catalogSourceURL = server.URL + "/catalog.json" + t.Cleanup(func() { catalogSourceURL = oldURL }) + + catalogDir := filepath.Join(t.TempDir(), "catalog") + + root := catalogCmd() + buf := &bytes.Buffer{} + root.SetOut(buf) + root.SetErr(buf) + root.SetArgs([]string{"sync", "--config", filepath.Join(catalogDir, "config.json")}) + + if err := root.Execute(); err != nil { + t.Fatalf("sync failed: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Catalog synced to") { + t.Fatalf("output missing sync confirmation: %q", out) + } + if !strings.Contains(out, "SHA256:") { + t.Fatalf("output missing SHA256: %q", out) + } + if !strings.Contains(out, "Bytes:") { + t.Fatalf("output missing Bytes: %q", out) + } + if !strings.Contains(out, "TTL:") { + t.Fatalf("output missing TTL: %q", out) + } + + if _, err := os.Stat(filepath.Join(catalogDir, "catalog.json")); err != nil { + t.Fatalf("catalog file not written: %v", err) + } + if _, err := os.Stat(filepath.Join(catalogDir, "catalog.lock.json")); err != nil { + t.Fatalf("lock file not written: %v", err) + } +} + +func TestCatalogSyncCmd_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + defer server.Close() + + oldURL := catalogSourceURL + catalogSourceURL = server.URL + "/catalog.json" + t.Cleanup(func() { catalogSourceURL = oldURL }) + + root := catalogCmd() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"sync", "--config", filepath.Join(t.TempDir(), "config.json")}) + + if err := root.Execute(); err == nil { + t.Fatal("expected sync to fail on HTTP 500") + } else if !strings.Contains(err.Error(), "catalog sync failed") { + t.Fatalf("expected wrapped catalog sync error, got: %v", err) + } +} + +func TestCatalogSyncCmd_AddedToRoot(t *testing.T) { + root := &cobra.Command{Use: "routatic-proxy"} + root.AddCommand(catalogCmd()) + + cmd, args, err := root.Find([]string{"catalog", "sync"}) + if err != nil { + t.Fatalf("find catalog sync failed: %v", err) + } + if cmd.Name() != "sync" { + t.Fatalf("expected sync command, got %q", cmd.Name()) + } + if len(args) != 0 { + t.Fatalf("unexpected args: %v", args) + } +} From 12f0ab04d0ff5f3f99cbee1789b5182cdca0193c Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 13:37:06 +0200 Subject: [PATCH 03/25] feat: implement catalog synchronization with automatic syncing and configuration options --- cmd/routatic-proxy/catalog.go | 31 +++++ cmd/routatic-proxy/catalog_test.go | 183 ++++++++++++++++++++++++++++- cmd/routatic-proxy/main.go | 5 + internal/config/config.go | 8 ++ internal/config/loader.go | 8 ++ 5 files changed, 233 insertions(+), 2 deletions(-) diff --git a/cmd/routatic-proxy/catalog.go b/cmd/routatic-proxy/catalog.go index 8ee50cd..e6c3577 100644 --- a/cmd/routatic-proxy/catalog.go +++ b/cmd/routatic-proxy/catalog.go @@ -2,10 +2,13 @@ package main import ( "fmt" + "log/slog" "os" "path/filepath" + "time" "github.com/routatic/proxy/internal/catalog" + "github.com/routatic/proxy/internal/config" "github.com/spf13/cobra" ) @@ -70,3 +73,31 @@ func resolveCatalogDir(configPath string) string { } return filepath.Join(home, ".config", "routatic-proxy", "catalog") } + +// ensureCatalogSynced checks whether the local models.dev catalog is present +// and fresh. If the lock file is missing, corrupted, or expired relative to the +// configured max_age_hours, it re-downloads the catalog from cfg.Catalog.SourceURL. +// The now parameter makes expiry deterministic in tests. +func ensureCatalogSynced(cfg *config.Config, configPath string, now time.Time) error { + if cfg.Catalog.Enabled != nil && !*cfg.Catalog.Enabled { + slog.Info("catalog sync disabled, skipping") + return nil + } + + catalogDir := resolveCatalogDir(configPath) + lock, err := catalog.ReadLock(catalogDir) + if err != nil { + slog.Info("catalog lock missing or unreadable, syncing", "catalog_dir", catalogDir, "error", err) + _, err = catalog.Sync(cfg.Catalog.SourceURL, catalogDir) + return err + } + + if lock.Expired(now) { + slog.Info("catalog lock expired, syncing", "catalog_dir", catalogDir, "synced_at", lock.SyncedAt) + _, err = catalog.Sync(cfg.Catalog.SourceURL, catalogDir) + return err + } + + slog.Debug("catalog lock fresh, skipping sync", "catalog_dir", catalogDir, "synced_at", lock.SyncedAt) + return nil +} diff --git a/cmd/routatic-proxy/catalog_test.go b/cmd/routatic-proxy/catalog_test.go index 78a5763..af3c7f4 100644 --- a/cmd/routatic-proxy/catalog_test.go +++ b/cmd/routatic-proxy/catalog_test.go @@ -9,7 +9,10 @@ import ( "path/filepath" "strings" "testing" + "time" + "github.com/routatic/proxy/internal/catalog" + "github.com/routatic/proxy/internal/config" "github.com/spf13/cobra" ) @@ -96,13 +99,15 @@ func TestCatalogSyncCmd_Success(t *testing.T) { catalogSourceURL = server.URL + "/catalog.json" t.Cleanup(func() { catalogSourceURL = oldURL }) - catalogDir := filepath.Join(t.TempDir(), "catalog") + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + catalogDir := filepath.Join(tmpDir, "catalog") root := catalogCmd() buf := &bytes.Buffer{} root.SetOut(buf) root.SetErr(buf) - root.SetArgs([]string{"sync", "--config", filepath.Join(catalogDir, "config.json")}) + root.SetArgs([]string{"sync", "--config", configPath}) if err := root.Execute(); err != nil { t.Fatalf("sync failed: %v", err) @@ -152,6 +157,180 @@ func TestCatalogSyncCmd_ServerError(t *testing.T) { } } +func TestServeCatalog_MissingSyncs(t *testing.T) { + catalogJSON := `{ + "models": { + "claude-sonnet-4": {"providers": ["openrouter"]} + }, + "providers": { + "openrouter": {"base_url": "https://openrouter.ai/api/v1"} + } +}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/catalog.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, catalogJSON) + })) + defer server.Close() + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + catalogDir := filepath.Join(tmpDir, "catalog") + + cfg := &config.Config{ + Catalog: config.CatalogConfig{ + SourceURL: server.URL + "/catalog.json", + MaxAgeHours: 24, + }, + } + + if err := ensureCatalogSynced(cfg, configPath, time.Now().UTC()); err != nil { + t.Fatalf("ensureCatalogSynced error: %v", err) + } + + if _, err := os.Stat(filepath.Join(catalogDir, "catalog.json")); err != nil { + t.Fatalf("catalog.json not written: %v", err) + } + if _, err := os.Stat(filepath.Join(catalogDir, "catalog.lock.json")); err != nil { + t.Fatalf("catalog.lock.json not written: %v", err) + } +} + +func TestServeCatalog_ExpiredSyncs(t *testing.T) { + catalogJSON := `{ + "models": { + "claude-sonnet-4": {"providers": ["openrouter"]} + }, + "providers": { + "openrouter": {"base_url": "https://openrouter.ai/api/v1"} + } +}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/catalog.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, catalogJSON) + })) + defer server.Close() + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + catalogDir := filepath.Join(tmpDir, "catalog") + + if err := os.MkdirAll(catalogDir, 0755); err != nil { + t.Fatalf("mkdir error: %v", err) + } + oldCatalog := []byte("{\"stale\": true}") + if err := os.WriteFile(filepath.Join(catalogDir, "catalog.json"), oldCatalog, 0644); err != nil { + t.Fatalf("write old catalog error: %v", err) + } + + now := time.Now().UTC() + oldLock := &catalog.Lock{ + SourceURL: server.URL + "/catalog.json", + SyncedAt: now.Add(-25 * time.Hour), + SHA256: "0000000000000000000000000000000000000000000000000000000000000000", + Bytes: int64(len(oldCatalog)), + TTLHours: 24, + } + if err := catalog.WriteLock(catalogDir, oldLock); err != nil { + t.Fatalf("write old lock error: %v", err) + } + + cfg := &config.Config{ + Catalog: config.CatalogConfig{ + SourceURL: server.URL + "/catalog.json", + MaxAgeHours: 24, + }, + } + + if err := ensureCatalogSynced(cfg, configPath, now); err != nil { + t.Fatalf("ensureCatalogSynced error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(catalogDir, "catalog.json")) + if err != nil { + t.Fatalf("read catalog error: %v", err) + } + if string(data) != catalogJSON { + t.Fatalf("catalog.json not updated: got %q, want %q", string(data), catalogJSON) + } + + newLock, err := catalog.ReadLock(catalogDir) + if err != nil { + t.Fatalf("read new lock error: %v", err) + } + if !newLock.SyncedAt.After(oldLock.SyncedAt) { + t.Fatalf("lock synced_at not updated: got %v, want after %v", newLock.SyncedAt, oldLock.SyncedAt) + } +} + +func TestServeCatalog_FreshSkipsSync(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + catalogDir := filepath.Join(tmpDir, "catalog") + + if err := os.MkdirAll(catalogDir, 0755); err != nil { + t.Fatalf("mkdir error: %v", err) + } + existingCatalog := []byte("{\"existing\": true}") + if err := os.WriteFile(filepath.Join(catalogDir, "catalog.json"), existingCatalog, 0644); err != nil { + t.Fatalf("write existing catalog error: %v", err) + } + + // Server that would fail if hit; a fresh lock should skip sync entirely. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "should not be called", http.StatusInternalServerError) + })) + defer server.Close() + + now := time.Now().UTC() + freshLock := &catalog.Lock{ + SourceURL: server.URL + "/catalog.json", + SyncedAt: now.Add(-1 * time.Hour), + SHA256: "1111111111111111111111111111111111111111111111111111111111111111", + Bytes: int64(len(existingCatalog)), + TTLHours: 24, + } + if err := catalog.WriteLock(catalogDir, freshLock); err != nil { + t.Fatalf("write fresh lock error: %v", err) + } + + cfg := &config.Config{ + Catalog: config.CatalogConfig{ + SourceURL: server.URL + "/catalog.json", + MaxAgeHours: 24, + }, + } + + if err := ensureCatalogSynced(cfg, configPath, now); err != nil { + t.Fatalf("ensureCatalogSynced error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(catalogDir, "catalog.json")) + if err != nil { + t.Fatalf("read catalog error: %v", err) + } + if string(data) != string(existingCatalog) { + t.Fatalf("catalog.json changed when lock was fresh: got %q, want %q", string(data), string(existingCatalog)) + } + + lock, err := catalog.ReadLock(catalogDir) + if err != nil { + t.Fatalf("read lock error: %v", err) + } + if !lock.SyncedAt.Equal(freshLock.SyncedAt) { + t.Fatalf("lock synced_at changed: got %v, want %v", lock.SyncedAt, freshLock.SyncedAt) + } +} + func TestCatalogSyncCmd_AddedToRoot(t *testing.T) { root := &cobra.Command{Use: "routatic-proxy"} root.AddCommand(catalogCmd()) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index e76b95e..d0a1d74 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/routatic/proxy/internal/config" "github.com/routatic/proxy/internal/daemon" @@ -88,6 +89,10 @@ func serveCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } + if err := ensureCatalogSynced(cfg, configPath, time.Now().UTC()); err != nil { + return fmt.Errorf("failed to sync catalog: %w", err) + } + var captureLogger *debug.CaptureLogger if cfg.Logging.DebugCapture != nil && cfg.Logging.DebugCapture.Enabled { storage, err := debug.NewStorage(*cfg.Logging.DebugCapture) diff --git a/internal/config/config.go b/internal/config/config.go index 7d78b02..53e8115 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { AnthropicFirst AnthropicFirstConfig `json:"anthropic_first"` Logging LoggingConfig `json:"logging"` Debug DebugConfig `json:"debug"` + Catalog CatalogConfig `json:"catalog"` } // AnthropicFirstConfig controls direct Anthropic passthrough with OpenCode fallback. @@ -29,6 +30,13 @@ type AnthropicFirstConfig struct { BaseURL string `json:"base_url"` } +// CatalogConfig controls automatic syncing of the models.dev catalog. +type CatalogConfig struct { + MaxAgeHours int `json:"max_age_hours"` + SourceURL string `json:"source_url"` + Enabled *bool `json:"enabled,omitempty"` +} + // DebugConfig holds debug-related configuration. type DebugConfig struct { CaptureEnabled bool `json:"capture_enabled"` diff --git a/internal/config/loader.go b/internal/config/loader.go index 6b1abed..7c5bcd8 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -21,6 +21,8 @@ const ( defaultTimeoutMs = 300000 defaultLogLevel = "info" defaultAnthropicAPIURL = "https://api.anthropic.com" + defaultCatalogMaxAge = 24 + defaultCatalogSourceURL = "https://models.dev/catalog.json" defaultZenBaseURL = "https://opencode.ai/zen/v1/chat/completions" defaultZenAnthropicBaseURL = "https://opencode.ai/zen/v1/messages" @@ -282,6 +284,12 @@ func applyDefaults(cfg *Config) { if cfg.ModelOverrides == nil { cfg.ModelOverrides = make(map[string]ModelConfig) } + if cfg.Catalog.MaxAgeHours == 0 { + cfg.Catalog.MaxAgeHours = defaultCatalogMaxAge + } + if cfg.Catalog.SourceURL == "" { + cfg.Catalog.SourceURL = defaultCatalogSourceURL + } } // validate checks that all required configuration fields are present. From f6a58987024dad8b44548a7e1d3e4c581e97f8cc Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 14:18:04 +0200 Subject: [PATCH 04/25] feat: add catalog loading functionality with validation and indexing --- internal/catalog/load.go | 74 ++++++++++++++ internal/catalog/load_test.go | 179 ++++++++++++++++++++++++++++++++++ internal/catalog/resolve.go | 148 ++++++++++++++++++++++++++++ internal/catalog/types.go | 66 +++++++++++++ 4 files changed, 467 insertions(+) create mode 100644 internal/catalog/load.go create mode 100644 internal/catalog/load_test.go create mode 100644 internal/catalog/resolve.go create mode 100644 internal/catalog/types.go diff --git a/internal/catalog/load.go b/internal/catalog/load.go new file mode 100644 index 0000000..9bdfe67 --- /dev/null +++ b/internal/catalog/load.go @@ -0,0 +1,74 @@ +package catalog + +import ( + "encoding/json" + "errors" + "fmt" + "os" +) + +// IndexedCatalog is a Catalog with an additional index from provider name to +// the models that declare support for that provider. +type IndexedCatalog struct { + Catalog + providerModels map[string][]Model +} + +// ModelsForProvider returns the models that support the named provider. +// It returns nil when the provider has no indexed models. +func (ic *IndexedCatalog) ModelsForProvider(provider string) []Model { + return ic.providerModels[provider] +} + +// Load reads a catalog from path, validates its contents, and returns an +// indexed view. +func Load(path string) (*IndexedCatalog, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read catalog file: %w", err) + } + + var catalog Catalog + if err := json.Unmarshal(data, &catalog); err != nil { + return nil, fmt.Errorf("parse catalog json: %w", err) + } + + if err := validateCatalog(&catalog); err != nil { + return nil, err + } + + idx := &IndexedCatalog{ + Catalog: catalog, + providerModels: make(map[string][]Model, len(catalog.Providers)), + } + + for _, model := range catalog.Models { + for _, provider := range model.Providers { + idx.providerModels[provider] = append(idx.providerModels[provider], model) + } + } + + return idx, nil +} + +func validateCatalog(catalog *Catalog) error { + if len(catalog.Providers) == 0 { + return errors.New("catalog providers map is empty") + } + if len(catalog.Models) == 0 { + return errors.New("catalog models map is empty") + } + + for _, model := range catalog.Models { + for _, provider := range model.Providers { + if provider == "" { + return fmt.Errorf("model %q references an empty provider name", model.Name) + } + if _, ok := catalog.Providers[provider]; !ok { + return fmt.Errorf("model %q references unknown provider %q", model.Name, provider) + } + } + } + + return nil +} diff --git a/internal/catalog/load_test.go b/internal/catalog/load_test.go new file mode 100644 index 0000000..6587a4d --- /dev/null +++ b/internal/catalog/load_test.go @@ -0,0 +1,179 @@ +package catalog + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func writeTempCatalog(t *testing.T, catalog Catalog) string { + t.Helper() + data, err := json.Marshal(catalog) + if err != nil { + t.Fatalf("marshal catalog: %v", err) + } + dir := t.TempDir() + path := filepath.Join(dir, "catalog.json") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write temp catalog: %v", err) + } + return path +} + +func TestLoad_MissingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "catalog.json") + if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { + t.Fatalf("write placeholder file: %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("remove temp file: %v", err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for missing file") + } + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected os.ErrNotExist, got %v", err) + } +} + +func TestLoad_MalformedJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "catalog.json") + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatalf("write malformed json: %v", err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for malformed json") + } +} + +func TestLoad_EmptyProviders(t *testing.T) { + path := writeTempCatalog(t, Catalog{ + Providers: map[string]Provider{}, + Models: map[string]Model{ + "m1": {Name: "m1", Providers: []string{"p1"}}, + }, + }) + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for empty providers") + } + if !containsSubstring(err.Error(), "providers") { + t.Fatalf("expected error to mention providers, got %v", err) + } +} + +func TestLoad_EmptyModels(t *testing.T) { + path := writeTempCatalog(t, Catalog{ + Providers: map[string]Provider{ + "p1": {Name: "p1"}, + }, + Models: map[string]Model{}, + }) + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for empty models") + } + if !containsSubstring(err.Error(), "models") { + t.Fatalf("expected error to mention models, got %v", err) + } +} + +func TestLoad_UnknownProvider(t *testing.T) { + path := writeTempCatalog(t, Catalog{ + Providers: map[string]Provider{ + "known": {Name: "known"}, + }, + Models: map[string]Model{ + "m1": {Name: "m1", Providers: []string{"unknown"}}, + }, + }) + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for unknown provider") + } +} + +func TestLoad_EmptyProviderName(t *testing.T) { + path := writeTempCatalog(t, Catalog{ + Providers: map[string]Provider{ + "p1": {Name: "p1"}, + }, + Models: map[string]Model{ + "m1": {Name: "m1", Providers: []string{""}}, + }, + }) + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for empty provider name") + } +} + +func TestLoad_ValidCatalog(t *testing.T) { + path := writeTempCatalog(t, Catalog{ + Providers: map[string]Provider{ + "opencode-go": {Name: "opencode-go"}, + "other": {Name: "other"}, + }, + Models: map[string]Model{ + "model-a": { + Name: "model-a", + Providers: []string{"opencode-go"}, + }, + "model-b": { + Name: "model-b", + Providers: []string{"opencode-go", "other"}, + }, + }, + }) + + idx, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got, want := len(idx.Models), 2; got != want { + t.Errorf("len(Models) = %d, want %d", got, want) + } + if got, want := len(idx.Providers), 2; got != want { + t.Errorf("len(Providers) = %d, want %d", got, want) + } + + openCodeModels := idx.ModelsForProvider("opencode-go") + if got, want := len(openCodeModels), 2; got != want { + t.Errorf("len(ModelsForProvider(\"opencode-go\")) = %d, want %d", got, want) + } + + otherModels := idx.ModelsForProvider("other") + if got, want := len(otherModels), 1; got != want { + t.Errorf("len(ModelsForProvider(\"other\")) = %d, want %d", got, want) + } + + missingModels := idx.ModelsForProvider("does-not-exist") + if missingModels != nil { + t.Errorf("ModelsForProvider(\"does-not-exist\") = %v, want nil", missingModels) + } +} + +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr)) +} + +func containsSubstringHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/catalog/resolve.go b/internal/catalog/resolve.go new file mode 100644 index 0000000..39237c0 --- /dev/null +++ b/internal/catalog/resolve.go @@ -0,0 +1,148 @@ +package catalog + +import ( + "errors" + "fmt" + "slices" + "strings" +) + +// ParseModelRef parses a model reference string into a Selector. +// Supported forms: +// - lab/model@provider -> {Provider: "provider", Model: "model", Alias: "lab/model"} +// - model@provider -> {Provider: "provider", Model: "model", Alias: "model"} +// - lab/model -> {Model: "model", Alias: "lab/model"} +// - model -> {Model: "model", Alias: "model"} +func ParseModelRef(ref string) (Selector, error) { + if ref == "" { + return Selector{}, errors.New("model reference is empty") + } + + parts := strings.Split(ref, "@") + if len(parts) > 2 { + return Selector{}, fmt.Errorf("model reference %q contains multiple @ separators", ref) + } + + modelPart := parts[0] + if modelPart == "" { + return Selector{}, fmt.Errorf("model id is empty in reference %q", ref) + } + + var provider string + if len(parts) == 2 { + provider = parts[1] + } + + if idx := strings.LastIndex(modelPart, "/"); idx >= 0 { + model := modelPart[idx+1:] + if model == "" { + return Selector{}, fmt.Errorf("model id is empty in reference %q", ref) + } + return Selector{Provider: provider, Model: model, Alias: modelPart}, nil + } + + return Selector{Provider: provider, Model: modelPart, Alias: modelPart}, nil +} + +// Resolve resolves a canonical selector into a fully materialized model/provider pair. +// The selector must include a provider. +func (ic *IndexedCatalog) Resolve(sel Selector) (ResolvedModel, error) { + if sel.Provider == "" { + return ResolvedModel{}, errors.New("provider is required for canonical resolution") + } + + provider, ok := ic.Providers[sel.Provider] + if !ok { + return ResolvedModel{}, fmt.Errorf("unknown provider %q", sel.Provider) + } + + model, modelKey := ic.findModel(sel) + if modelKey == "" { + return ResolvedModel{}, fmt.Errorf("unknown model %q", sel.Model) + } + + if !slices.Contains(model.Providers, sel.Provider) { + return ResolvedModel{}, fmt.Errorf("model %q is not available on provider %q", modelKey, sel.Provider) + } + + return resolvedModel(provider, modelKey, model), nil +} + +// ResolveShort resolves a legacy short model id to a fully materialized model/provider pair. +// It first matches by model key, then by model Name. The first enabled provider declared by +// the model is selected. +func (ic *IndexedCatalog) ResolveShort(short string) (ResolvedModel, error) { + if model, ok := ic.Models[short]; ok { + return ic.resolveWithFirstEnabledProvider(model, short) + } + + for key, model := range ic.Models { + if model.Name == short { + return ic.resolveWithFirstEnabledProvider(model, key) + } + } + + return ResolvedModel{}, fmt.Errorf("unknown short model id: %q", short) +} + +// ListProviderModels returns a slice of ResolvedModel for every model that supports the +// named provider. The iteration order follows the underlying map and is non-deterministic. +// If the provider is unknown, nil is returned. +func (ic *IndexedCatalog) ListProviderModels(provider string) []ResolvedModel { + providerCfg, ok := ic.Providers[provider] + if !ok { + return nil + } + + var result []ResolvedModel + for key, model := range ic.Models { + if !slices.Contains(model.Providers, provider) { + continue + } + result = append(result, resolvedModel(providerCfg, key, model)) + } + return result +} + +func (ic *IndexedCatalog) findModel(sel Selector) (Model, string) { + if model, ok := ic.Models[sel.Model]; ok { + return model, sel.Model + } + if sel.Alias != "" { + if model, ok := ic.Models[sel.Alias]; ok { + return model, sel.Alias + } + } + return Model{}, "" +} + +func (ic *IndexedCatalog) resolveWithFirstEnabledProvider(model Model, key string) (ResolvedModel, error) { + for _, providerName := range model.Providers { + provider, ok := ic.Providers[providerName] + if !ok { + continue + } + if provider.Enabled == nil || *provider.Enabled { + return resolvedModel(provider, key, model), nil + } + } + return ResolvedModel{}, fmt.Errorf("no enabled provider for model %q", key) +} + +func resolvedModel(provider Provider, modelKey string, model Model) ResolvedModel { + return ResolvedModel{ + Provider: provider.Name, + ModelID: modelKey, + CanonicalName: modelKey, + DisplayName: model.DisplayName, + BaseURL: provider.BaseURL, + APIKey: provider.APIKey, + AnthropicToolsDisabled: provider.AnthropicToolsDisabled, + ContextWindow: model.ContextWindow, + CostInputPerM: model.CostInputPerM, + CostOutputPerM: model.CostOutputPerM, + Tools: model.Tools, + Vision: model.Vision, + Reasoning: model.Reasoning, + } +} diff --git a/internal/catalog/types.go b/internal/catalog/types.go new file mode 100644 index 0000000..fe791b3 --- /dev/null +++ b/internal/catalog/types.go @@ -0,0 +1,66 @@ +package catalog + +// Catalog is the parsed contents of a models.dev catalog. +type Catalog struct { + Providers map[string]Provider `json:"providers"` + Models map[string]Model `json:"models"` + Scenarios map[string]Scenario `json:"scenarios"` +} + +// Provider describes a model hosting endpoint. +type Provider struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Enabled *bool `json:"enabled,omitempty"` + AnthropicToolsDisabled bool `json:"anthropic_tools_disabled"` +} + +// Model describes a model available through one or more providers. +type Model struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Providers []string `json:"providers"` + ContextWindow int64 `json:"context_window"` + CostInputPerM float64 `json:"cost_input_per_m"` + CostOutputPerM float64 `json:"cost_output_per_m"` + Tools bool `json:"tools"` + Vision bool `json:"vision"` + Reasoning bool `json:"reasoning"` +} + +// Scenario describes a workload that selects a model by capability. +type Scenario struct { + Name string `json:"name"` + Description string `json:"description"` + RequiresTools *bool `json:"requires_tools,omitempty"` + RequiresVision *bool `json:"requires_vision,omitempty"` + RequiresReasoning *bool `json:"requires_reasoning,omitempty"` + MinContextWindow int64 `json:"min_context_window"` + PreferredProviders []string `json:"preferred_providers"` +} + +// Selector is a parsed model reference such as model@provider, +// lab/model@provider, or a short id. +type Selector struct { + Provider string `json:"provider"` + Model string `json:"model"` + Alias string `json:"alias"` +} + +// ResolvedModel is a fully materialized provider/model pair ready for use. +type ResolvedModel struct { + Provider string `json:"provider"` + ModelID string `json:"model_id"` + CanonicalName string `json:"canonical_name"` + DisplayName string `json:"display_name"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + AnthropicToolsDisabled bool `json:"anthropic_tools_disabled"` + ContextWindow int64 `json:"context_window"` + CostInputPerM float64 `json:"cost_input_per_m"` + CostOutputPerM float64 `json:"cost_output_per_m"` + Tools bool `json:"tools"` + Vision bool `json:"vision"` + Reasoning bool `json:"reasoning"` +} From 1b500010d1fe2e7b674e65fd3a282bd5ff3bbaa0 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 14:33:43 +0200 Subject: [PATCH 05/25] feat: implement provider model index creation and synchronization in catalog --- internal/catalog/index.go | 122 ++++++++++++++ internal/catalog/index_test.go | 109 +++++++++++++ internal/catalog/resolve_test.go | 266 +++++++++++++++++++++++++++++++ internal/catalog/sync.go | 18 +++ internal/catalog/sync_test.go | 14 +- 5 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 internal/catalog/index.go create mode 100644 internal/catalog/index_test.go create mode 100644 internal/catalog/resolve_test.go diff --git a/internal/catalog/index.go b/internal/catalog/index.go new file mode 100644 index 0000000..f1f0cc4 --- /dev/null +++ b/internal/catalog/index.go @@ -0,0 +1,122 @@ +package catalog + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" +) + +const indexFileName = "provider_model_index.json" +const indexTmpFileName = "provider_model_index.json.tmp" + +// ProviderModelIndex maps each enabled provider to the sorted keys of the +// models that declare support for that provider. +type ProviderModelIndex struct { + ProviderModels map[string][]string `json:"provider_models"` +} + +// BuildProviderIndex validates the catalog and builds an index from enabled +// provider name to sorted model keys. A provider with a nil Enabled field is +// treated as enabled; providers with Enabled explicitly set to false are +// skipped. +func BuildProviderIndex(catalog Catalog) (*ProviderModelIndex, error) { + if len(catalog.Providers) == 0 { + return nil, errors.New("catalog providers map is empty") + } + if len(catalog.Models) == 0 { + return nil, errors.New("catalog models map is empty") + } + + providerModels := make(map[string][]string) + enabledCount := 0 + + for providerName, provider := range catalog.Providers { + if provider.Enabled != nil && !*provider.Enabled { + continue + } + enabledCount++ + + for modelKey, model := range catalog.Models { + for _, mp := range model.Providers { + if mp == providerName { + providerModels[providerName] = append(providerModels[providerName], modelKey) + break + } + } + } + } + + if enabledCount == 0 { + return nil, errors.New("no enabled providers in catalog") + } + + // Deduplicate and sort each provider's model slice. A model could in + // theory list the same provider twice. + for providerName, models := range providerModels { + seen := make(map[string]struct{}, len(models)) + unique := make([]string, 0, len(models)) + for _, modelKey := range models { + if _, ok := seen[modelKey]; ok { + continue + } + seen[modelKey] = struct{}{} + unique = append(unique, modelKey) + } + sort.Strings(unique) + providerModels[providerName] = unique + } + + // Sorting the keys is not required by the contract, but it makes the + // output deterministic and easier to test. + if len(providerModels) == 0 { + return nil, errors.New("no models reference enabled providers") + } + + return &ProviderModelIndex{ProviderModels: providerModels}, nil +} + +// Write marshals the index and writes it atomically to dir/provider_model_index.json. +func (idx *ProviderModelIndex) Write(dir string) error { + if idx == nil { + return errors.New("cannot write nil index") + } + + data, err := json.Marshal(idx) + if err != nil { + return fmt.Errorf("marshal provider model index: %w", err) + } + + tmpPath := filepath.Join(dir, indexTmpFileName) + finalPath := filepath.Join(dir, indexFileName) + + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("write provider model index temp file: %w", err) + } + + if err := os.Rename(tmpPath, finalPath); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("rename provider model index file: %w", err) + } + + return nil +} + +// ReadProviderIndex reads and unmarshals the provider model index from dir. +func ReadProviderIndex(dir string) (*ProviderModelIndex, error) { + path := filepath.Join(dir, indexFileName) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read provider model index: %w", err) + } + + var idx ProviderModelIndex + if err := json.Unmarshal(data, &idx); err != nil { + return nil, fmt.Errorf("parse provider model index: %w", err) + } + + return &idx, nil +} diff --git a/internal/catalog/index_test.go b/internal/catalog/index_test.go new file mode 100644 index 0000000..0279c85 --- /dev/null +++ b/internal/catalog/index_test.go @@ -0,0 +1,109 @@ +package catalog + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func ptr(b bool) *bool { return &b } + +func TestBuildProviderIndex_Valid(t *testing.T) { + catalog := Catalog{ + Providers: map[string]Provider{ + "openai": {Name: "openai", Enabled: nil}, + "anthropic": {Name: "anthropic", Enabled: ptr(true)}, + "disabled": {Name: "disabled", Enabled: ptr(false)}, + }, + Models: map[string]Model{ + "gpt-4": { + Name: "gpt-4", + Providers: []string{"openai"}, + }, + "claude-3": { + Name: "claude-3", + Providers: []string{"anthropic", "anthropic"}, + }, + "gpt-3.5": { + Name: "gpt-3.5", + Providers: []string{"openai"}, + }, + }, + } + + idx, err := BuildProviderIndex(catalog) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := map[string][]string{ + "openai": {"gpt-3.5", "gpt-4"}, + "anthropic": {"claude-3"}, + } + + if !reflect.DeepEqual(idx.ProviderModels, want) { + t.Fatalf("index mismatch: got %+v, want %+v", idx.ProviderModels, want) + } + + if _, ok := idx.ProviderModels["disabled"]; ok { + t.Fatalf("expected disabled provider to be omitted") + } +} + +func TestBuildProviderIndex_NoEnabledProviders(t *testing.T) { + catalog := Catalog{ + Providers: map[string]Provider{ + "disabled": {Name: "disabled", Enabled: ptr(false)}, + }, + Models: map[string]Model{ + "gpt-4": {Name: "gpt-4", Providers: []string{"disabled"}}, + }, + } + + _, err := BuildProviderIndex(catalog) + if err == nil { + t.Fatalf("expected error for no enabled providers, got nil") + } +} + +func TestBuildProviderIndex_EmptyModels(t *testing.T) { + catalog := Catalog{ + Providers: map[string]Provider{ + "openai": {Name: "openai"}, + }, + Models: map[string]Model{}, + } + + _, err := BuildProviderIndex(catalog) + if err == nil { + t.Fatalf("expected error for empty models, got nil") + } +} + +func TestProviderIndex_WriteAndRead(t *testing.T) { + dir := t.TempDir() + idx := &ProviderModelIndex{ + ProviderModels: map[string][]string{ + "openai": {"gpt-3.5", "gpt-4"}, + }, + } + + if err := idx.Write(dir); err != nil { + t.Fatalf("write index: %v", err) + } + + tmpPath := filepath.Join(dir, indexTmpFileName) + if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { + t.Fatalf("expected no temp file left behind, got %v", err) + } + + read, err := ReadProviderIndex(dir) + if err != nil { + t.Fatalf("read index: %v", err) + } + + if !reflect.DeepEqual(read.ProviderModels, idx.ProviderModels) { + t.Fatalf("read mismatch: got %+v, want %+v", read.ProviderModels, idx.ProviderModels) + } +} diff --git a/internal/catalog/resolve_test.go b/internal/catalog/resolve_test.go new file mode 100644 index 0000000..043b76c --- /dev/null +++ b/internal/catalog/resolve_test.go @@ -0,0 +1,266 @@ +package catalog + +import ( + "sort" + "testing" +) + +func boolPtr(b bool) *bool { + return &b +} + +func newFixtureCatalog() *IndexedCatalog { + return &IndexedCatalog{ + Catalog: Catalog{ + Providers: map[string]Provider{ + "opencode-go": { + Name: "opencode-go", + BaseURL: "https://go.opencode.ai/v1", + Enabled: boolPtr(true), + }, + "openrouter": { + Name: "openrouter", + BaseURL: "https://openrouter.ai/api/v1", + Enabled: boolPtr(true), + }, + "disabled-provider": { + Name: "disabled-provider", + BaseURL: "https://disabled.example/v1", + Enabled: boolPtr(false), + }, + }, + Models: map[string]Model{ + "deepseek-v4-flash": { + Name: "deepseek-v4-flash", + DisplayName: "DeepSeek V4 Flash", + Providers: []string{"opencode-go"}, + ContextWindow: 128000, + Tools: true, + }, + "kimi-k2.6": { + Name: "kimi-k2.6", + DisplayName: "Kimi K2.6", + Providers: []string{"opencode-go", "openrouter"}, + ContextWindow: 256000, + }, + "legacy-name": { + Name: "old-model", + DisplayName: "Old Model", + Providers: []string{"opencode-go"}, + }, + "only-disabled": { + Name: "only-disabled", + DisplayName: "Only Disabled", + Providers: []string{"disabled-provider"}, + }, + }, + }, + } +} + +func TestParseModelRef(t *testing.T) { + tests := []struct { + name string + ref string + want Selector + wantErr bool + }{ + { + name: "lab/model@provider", + ref: "deepseek/deepseek-v4-flash@opencode-go", + want: Selector{ + Provider: "opencode-go", + Model: "deepseek-v4-flash", + Alias: "deepseek/deepseek-v4-flash", + }, + }, + { + name: "model@provider", + ref: "kimi-k2.6@opencode-go", + want: Selector{ + Provider: "opencode-go", + Model: "kimi-k2.6", + Alias: "kimi-k2.6", + }, + }, + { + name: "short model only", + ref: "kimi-k2.6", + want: Selector{ + Provider: "", + Model: "kimi-k2.6", + Alias: "kimi-k2.6", + }, + }, + { + name: "lab/model without provider", + ref: "deepseek/deepseek-v4-flash", + want: Selector{ + Provider: "", + Model: "deepseek-v4-flash", + Alias: "deepseek/deepseek-v4-flash", + }, + }, + { + name: "empty reference", + ref: "", + wantErr: true, + }, + { + name: "multiple @ separators", + ref: "a@b@c", + wantErr: true, + }, + { + name: "empty model id", + ref: "@provider", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseModelRef(tt.ref) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseModelRef(%q) error = %v, wantErr %v", tt.ref, err, tt.wantErr) + } + if err != nil { + return + } + if got != tt.want { + t.Errorf("ParseModelRef(%q) = %+v, want %+v", tt.ref, got, tt.want) + } + }) + } +} + +func TestResolve_Canonical(t *testing.T) { + ic := newFixtureCatalog() + sel := Selector{Provider: "opencode-go", Model: "deepseek-v4-flash", Alias: "deepseek/deepseek-v4-flash"} + + got, err := ic.Resolve(sel) + if err != nil { + t.Fatalf("Resolve(%+v) unexpected error: %v", sel, err) + } + + if got.Provider != "opencode-go" { + t.Errorf("Provider = %q, want %q", got.Provider, "opencode-go") + } + if got.ModelID != "deepseek-v4-flash" { + t.Errorf("ModelID = %q, want %q", got.ModelID, "deepseek-v4-flash") + } + if got.DisplayName != "DeepSeek V4 Flash" { + t.Errorf("DisplayName = %q, want %q", got.DisplayName, "DeepSeek V4 Flash") + } + if got.BaseURL != "https://go.opencode.ai/v1" { + t.Errorf("BaseURL = %q, want %q", got.BaseURL, "https://go.opencode.ai/v1") + } + if !got.Tools { + t.Errorf("Tools = %v, want true", got.Tools) + } +} + +func TestResolve_ProviderMissing(t *testing.T) { + ic := newFixtureCatalog() + sel := Selector{Model: "deepseek-v4-flash"} + + _, err := ic.Resolve(sel) + if err == nil { + t.Fatal("Resolve: expected error for missing provider, got nil") + } +} + +func TestResolve_UnknownProvider(t *testing.T) { + ic := newFixtureCatalog() + sel := Selector{Provider: "unknown", Model: "deepseek-v4-flash"} + + _, err := ic.Resolve(sel) + if err == nil { + t.Fatal("Resolve: expected error for unknown provider, got nil") + } +} + +func TestResolve_ModelNotOnProvider(t *testing.T) { + ic := newFixtureCatalog() + sel := Selector{Provider: "openrouter", Model: "deepseek-v4-flash"} + + _, err := ic.Resolve(sel) + if err == nil { + t.Fatal("Resolve: expected error for model not on provider, got nil") + } +} + +func TestResolveShort_Legacy(t *testing.T) { + ic := newFixtureCatalog() + + got, err := ic.ResolveShort("kimi-k2.6") + if err != nil { + t.Fatalf("ResolveShort(%q) unexpected error: %v", "kimi-k2.6", err) + } + if got.Provider != "opencode-go" { + t.Errorf("Provider = %q, want %q", got.Provider, "opencode-go") + } + if got.ModelID != "kimi-k2.6" { + t.Errorf("ModelID = %q, want %q", got.ModelID, "kimi-k2.6") + } +} + +func TestResolveShort_Name(t *testing.T) { + ic := newFixtureCatalog() + + got, err := ic.ResolveShort("old-model") + if err != nil { + t.Fatalf("ResolveShort(%q) unexpected error: %v", "old-model", err) + } + if got.Provider != "opencode-go" { + t.Errorf("Provider = %q, want %q", got.Provider, "opencode-go") + } + if got.ModelID != "legacy-name" { + t.Errorf("ModelID = %q, want %q", got.ModelID, "legacy-name") + } +} + +func TestResolveShort_DisabledProvider(t *testing.T) { + ic := newFixtureCatalog() + + _, err := ic.ResolveShort("only-disabled") + if err == nil { + t.Fatal("ResolveShort: expected error for model with only disabled provider, got nil") + } +} + +func TestListProviderModels(t *testing.T) { + ic := newFixtureCatalog() + + got := ic.ListProviderModels("opencode-go") + if len(got) != 3 { + t.Fatalf("ListProviderModels(%q) returned %d models, want 3", "opencode-go", len(got)) + } + + ids := make([]string, len(got)) + for i, m := range got { + ids[i] = m.ModelID + } + sort.Strings(ids) + want := []string{"deepseek-v4-flash", "kimi-k2.6", "legacy-name"} + for i := range want { + if ids[i] != want[i] { + t.Errorf("model ids = %v, want %v", ids, want) + break + } + } + + if ic.ListProviderModels("unknown") != nil { + t.Error("ListProviderModels(\"unknown\"): expected nil for unknown provider") + } + + // Only-disabled is on disabled-provider, so it should not appear on the enabled opencode-go list. + for _, m := range got { + if m.ModelID == "only-disabled" { + t.Errorf("unexpected disabled-only model %q in opencode-go list", m.ModelID) + } + if m.Provider != "opencode-go" { + t.Errorf("model %q has provider %q, want %q", m.ModelID, m.Provider, "opencode-go") + } + } +} diff --git a/internal/catalog/sync.go b/internal/catalog/sync.go index 452b12f..6e20bc9 100644 --- a/internal/catalog/sync.go +++ b/internal/catalog/sync.go @@ -70,6 +70,16 @@ func Sync(sourceURL, destDir string) (*Lock, error) { return nil, fmt.Errorf("catalog must contain models and providers objects") } + var catalog Catalog + if err := json.Unmarshal(body, &catalog); err != nil { + return nil, fmt.Errorf("parse catalog contents: %w", err) + } + + idx, err := BuildProviderIndex(catalog) + if err != nil { + return nil, fmt.Errorf("build provider index: %w", err) + } + sum := sha256.Sum256(body) hash := hex.EncodeToString(sum[:]) @@ -85,6 +95,14 @@ func Sync(sourceURL, destDir string) (*Lock, error) { return nil, fmt.Errorf("rename catalog file: %w", err) } + if err := idx.Write(destDir); err != nil { + _ = os.Remove(tmpPath) + _ = os.Remove(finalPath) + _ = os.Remove(filepath.Join(destDir, indexTmpFileName)) + _ = os.Remove(filepath.Join(destDir, indexFileName)) + return nil, fmt.Errorf("write provider index: %w", err) + } + lock := &Lock{ SourceURL: sourceURL, SyncedAt: time.Now().UTC(), diff --git a/internal/catalog/sync_test.go b/internal/catalog/sync_test.go index 26da2c7..68566ad 100644 --- a/internal/catalog/sync_test.go +++ b/internal/catalog/sync_test.go @@ -13,7 +13,7 @@ import ( ) func TestSync(t *testing.T) { - validCatalog := `{"models":{"gpt-4":{}},"providers":{"openai":{}}}` + validCatalog := `{"models":{"gpt-4":{"providers":["openai"]}},"providers":{"openai":{}}}` validHash := sha256.Sum256([]byte(validCatalog)) cases := []struct { @@ -76,10 +76,18 @@ func TestSync(t *testing.T) { if string(data) != tc.body { t.Fatalf("catalog content mismatch: got %q, want %q", string(data), tc.body) } + + indexPath := filepath.Join(destDir, indexFileName) + if _, err := os.Stat(indexPath); err != nil { + t.Fatalf("expected index file: %v", err) + } } else { if _, err := os.Stat(catalogPath); !os.IsNotExist(err) { t.Fatalf("expected no catalog file, got %v", err) } + if _, err := os.Stat(filepath.Join(destDir, indexFileName)); !os.IsNotExist(err) { + t.Fatalf("expected no index file, got %v", err) + } } lockPath := filepath.Join(destDir, lockFileName) @@ -132,7 +140,7 @@ func TestSyncOversized(t *testing.T) { t.Fatalf("expected error for oversized response, got nil") } - for _, name := range []string{catalogFileName, tmpFileName, lockFileName} { + for _, name := range []string{catalogFileName, tmpFileName, lockFileName, indexFileName, indexTmpFileName} { path := filepath.Join(destDir, name) if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("expected no %s after oversized sync failure, got %v", name, err) @@ -176,7 +184,7 @@ func TestSyncMissingModels(t *testing.T) { func TestSyncCreatesDestDir(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"models":{"x":{}},"providers":{"y":{}}}`)) + _, _ = w.Write([]byte(`{"models":{"x":{"providers":["y"]}},"providers":{"y":{}}}`)) })) defer server.Close() From bc64b662289cb6f38f215d5d38020f58a6276d4c Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 14:45:34 +0200 Subject: [PATCH 06/25] refactor: rename test functions for consistency and clarity --- internal/catalog/index_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/catalog/index_test.go b/internal/catalog/index_test.go index 0279c85..6e28e44 100644 --- a/internal/catalog/index_test.go +++ b/internal/catalog/index_test.go @@ -9,12 +9,12 @@ import ( func ptr(b bool) *bool { return &b } -func TestBuildProviderIndex_Valid(t *testing.T) { +func TestIndex_BuildProviderIndex_Valid(t *testing.T) { catalog := Catalog{ Providers: map[string]Provider{ - "openai": {Name: "openai", Enabled: nil}, + "openai": {Name: "openai", Enabled: nil}, "anthropic": {Name: "anthropic", Enabled: ptr(true)}, - "disabled": {Name: "disabled", Enabled: ptr(false)}, + "disabled": {Name: "disabled", Enabled: ptr(false)}, }, Models: map[string]Model{ "gpt-4": { @@ -51,7 +51,7 @@ func TestBuildProviderIndex_Valid(t *testing.T) { } } -func TestBuildProviderIndex_NoEnabledProviders(t *testing.T) { +func TestIndex_NoEnabledProviders(t *testing.T) { catalog := Catalog{ Providers: map[string]Provider{ "disabled": {Name: "disabled", Enabled: ptr(false)}, @@ -67,7 +67,7 @@ func TestBuildProviderIndex_NoEnabledProviders(t *testing.T) { } } -func TestBuildProviderIndex_EmptyModels(t *testing.T) { +func TestIndex_EmptyModels(t *testing.T) { catalog := Catalog{ Providers: map[string]Provider{ "openai": {Name: "openai"}, @@ -81,7 +81,7 @@ func TestBuildProviderIndex_EmptyModels(t *testing.T) { } } -func TestProviderIndex_WriteAndRead(t *testing.T) { +func TestIndex_WriteAndRead(t *testing.T) { dir := t.TempDir() idx := &ProviderModelIndex{ ProviderModels: map[string][]string{ From ba0045cd584f7cb173e6945ce9b52ad6fc1d69f0 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 15:29:19 +0200 Subject: [PATCH 07/25] feat: add OpenRouter provider support with configuration and tests --- internal/catalog/resolve_fixture_test.go | 228 +++++++++++++++++++++++ internal/catalog/testdata/catalog.json | 67 +++++++ internal/client/opencode.go | 26 +++ internal/client/opencode_test.go | 160 ++++++++++++++++ internal/config/config.go | 23 +++ internal/config/loader.go | 21 +++ internal/config/loader_test.go | 109 +++++++++++ 7 files changed, 634 insertions(+) create mode 100644 internal/catalog/resolve_fixture_test.go create mode 100644 internal/catalog/testdata/catalog.json diff --git a/internal/catalog/resolve_fixture_test.go b/internal/catalog/resolve_fixture_test.go new file mode 100644 index 0000000..52bcfaa --- /dev/null +++ b/internal/catalog/resolve_fixture_test.go @@ -0,0 +1,228 @@ +package catalog + +import ( + "sort" + "testing" +) + +func loadFixtureCatalog(t *testing.T) *IndexedCatalog { + t.Helper() + ic, err := Load("testdata/catalog.json") + if err != nil { + t.Fatalf("Load(testdata/catalog.json) failed: %v", err) + } + return ic +} + +func TestFixtureResolve_Canonical(t *testing.T) { + tests := []struct { + name string + ref string + wantProvider string + wantModelID string + wantDisplayName string + wantBaseURL string + wantAPIKey string + wantTools bool + }{ + { + name: "deepseek via opencode-go", + ref: "deepseek/deepseek-v4-flash@opencode-go", + wantProvider: "opencode-go", + wantModelID: "deepseek-v4-flash", + wantDisplayName: "DeepSeek V4 Flash", + wantBaseURL: "https://go.opencode.ai/v1", + wantAPIKey: "go-key", + wantTools: true, + }, + { + name: "kimi via openrouter", + ref: "kimi-k2.6@openrouter", + wantProvider: "openrouter", + wantModelID: "kimi-k2.6", + wantBaseURL: "https://openrouter.ai/api/v1", + wantAPIKey: "or-key", + wantTools: true, + }, + { + name: "glm via openrouter", + ref: "glm-5.2@openrouter", + wantProvider: "openrouter", + wantModelID: "glm-5.2", + wantTools: true, + }, + } + + ic := loadFixtureCatalog(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sel, err := ParseModelRef(tt.ref) + if err != nil { + t.Fatalf("ParseModelRef(%q) failed: %v", tt.ref, err) + } + + got, err := ic.Resolve(sel) + if err != nil { + t.Fatalf("Resolve(%q) unexpected error: %v", tt.ref, err) + } + + if got.Provider != tt.wantProvider { + t.Errorf("Provider = %q, want %q", got.Provider, tt.wantProvider) + } + if got.ModelID != tt.wantModelID { + t.Errorf("ModelID = %q, want %q", got.ModelID, tt.wantModelID) + } + if tt.wantDisplayName != "" && got.DisplayName != tt.wantDisplayName { + t.Errorf("DisplayName = %q, want %q", got.DisplayName, tt.wantDisplayName) + } + if tt.wantBaseURL != "" && got.BaseURL != tt.wantBaseURL { + t.Errorf("BaseURL = %q, want %q", got.BaseURL, tt.wantBaseURL) + } + if tt.wantAPIKey != "" && got.APIKey != tt.wantAPIKey { + t.Errorf("APIKey = %q, want %q", got.APIKey, tt.wantAPIKey) + } + if got.Tools != tt.wantTools { + t.Errorf("Tools = %v, want %v", got.Tools, tt.wantTools) + } + }) + } +} + +func TestFixtureResolve_Invalid(t *testing.T) { + tests := []struct { + name string + ref string + }{ + { + name: "empty provider", + ref: "deepseek-v4-flash@", + }, + { + name: "unknown provider", + ref: "deepseek-v4-flash@unknown", + }, + { + name: "model not on provider", + ref: "deepseek-v4-flash@openrouter", + }, + } + + ic := loadFixtureCatalog(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sel, err := ParseModelRef(tt.ref) + if err != nil { + t.Fatalf("ParseModelRef(%q) failed: %v", tt.ref, err) + } + + _, err = ic.Resolve(sel) + if err == nil { + t.Fatalf("Resolve(%q) expected error, got nil", tt.ref) + } + }) + } +} + +func TestFixtureResolveShort(t *testing.T) { + tests := []struct { + name string + short string + wantProvider string + wantModelID string + wantErr bool + }{ + { + name: "first enabled provider", + short: "kimi-k2.6", + wantProvider: "opencode-go", + wantModelID: "kimi-k2.6", + }, + { + name: "resolve by name", + short: "old-model", + wantProvider: "opencode-go", + wantModelID: "legacy-name", + }, + { + name: "only disabled provider", + short: "only-disabled", + wantErr: true, + }, + } + + ic := loadFixtureCatalog(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ic.ResolveShort(tt.short) + if (err != nil) != tt.wantErr { + t.Fatalf("ResolveShort(%q) error = %v, wantErr %v", tt.short, err, tt.wantErr) + } + if tt.wantErr { + return + } + + if got.Provider != tt.wantProvider { + t.Errorf("Provider = %q, want %q", got.Provider, tt.wantProvider) + } + if got.ModelID != tt.wantModelID { + t.Errorf("ModelID = %q, want %q", got.ModelID, tt.wantModelID) + } + }) + } +} + +func TestFixtureResolve_ListProviderModels(t *testing.T) { + tests := []struct { + name string + provider string + wantIDs []string + wantNil bool + }{ + { + name: "opencode-go models", + provider: "opencode-go", + wantIDs: []string{"deepseek-v4-flash", "kimi-k2.6", "legacy-name"}, + }, + { + name: "openrouter models", + provider: "openrouter", + wantIDs: []string{"kimi-k2.6", "glm-5.2"}, + }, + { + name: "unknown provider", + provider: "unknown", + wantNil: true, + }, + } + + ic := loadFixtureCatalog(t) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ic.ListProviderModels(tt.provider) + + if tt.wantNil { + if got != nil { + t.Fatalf("ListProviderModels(%q) = %v, want nil", tt.provider, got) + } + return + } + + ids := make([]string, len(got)) + for i, m := range got { + ids[i] = m.ModelID + } + sort.Strings(ids) + sort.Strings(tt.wantIDs) + + if len(ids) != len(tt.wantIDs) { + t.Fatalf("ListProviderModels(%q) returned %d models, want %d: got %v", tt.provider, len(ids), len(tt.wantIDs), ids) + } + for i := range tt.wantIDs { + if ids[i] != tt.wantIDs[i] { + t.Errorf("model ids = %v, want %v", ids, tt.wantIDs) + break + } + } + }) + } +} diff --git a/internal/catalog/testdata/catalog.json b/internal/catalog/testdata/catalog.json new file mode 100644 index 0000000..e2e4a03 --- /dev/null +++ b/internal/catalog/testdata/catalog.json @@ -0,0 +1,67 @@ +{ + "providers": { + "opencode-go": { + "name": "opencode-go", + "base_url": "https://go.opencode.ai/v1", + "api_key": "go-key", + "enabled": true + }, + "openrouter": { + "name": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "or-key", + "enabled": true + }, + "disabled-provider": { + "name": "disabled-provider", + "base_url": "https://disabled.example/v1", + "api_key": "disabled-key", + "enabled": false + } + }, + "models": { + "deepseek-v4-flash": { + "name": "deepseek-v4-flash", + "display_name": "DeepSeek V4 Flash", + "providers": ["opencode-go"], + "context_window": 128000, + "cost_input_per_m": 0.5, + "cost_output_per_m": 1.5, + "tools": true, + "vision": false, + "reasoning": false + }, + "kimi-k2.6": { + "name": "kimi-k2.6", + "display_name": "Kimi K2.6", + "providers": ["opencode-go", "openrouter"], + "context_window": 256000, + "cost_input_per_m": 2.0, + "cost_output_per_m": 6.0, + "tools": true, + "vision": true, + "reasoning": false + }, + "glm-5.2": { + "name": "glm-5.2", + "display_name": "GLM 5.2", + "providers": ["openrouter"], + "context_window": 128000, + "cost_input_per_m": 3.0, + "cost_output_per_m": 9.0, + "tools": true, + "vision": false, + "reasoning": true + }, + "legacy-name": { + "name": "old-model", + "display_name": "Old Model", + "providers": ["opencode-go"] + }, + "only-disabled": { + "name": "only-disabled", + "display_name": "Only Disabled", + "providers": ["disabled-provider"] + } + } +} diff --git a/internal/client/opencode.go b/internal/client/opencode.go index 445c08f..d045fb7 100644 --- a/internal/client/opencode.go +++ b/internal/client/opencode.go @@ -46,6 +46,7 @@ const ( ProviderOpenCodeGo = "opencode-go" ProviderOpenCodeZen = "opencode-zen" ProviderAWSBedrock = "aws-bedrock" + ProviderOpenRouter = "openrouter" ) // APIError represents an HTTP API error returned by an upstream provider. @@ -94,6 +95,10 @@ func (c *OpenCodeClient) getProviderAPIKeys(modelConfig config.ModelConfig) []st if keys := cfg.OpenCodeZen.EffectiveAPIKeys(); len(keys) > 0 { return keys } + case IsOpenRouter(modelConfig): + if keys := cfg.OpenRouter.EffectiveAPIKeys(); len(keys) > 0 { + return keys + } default: if keys := cfg.OpenCodeGo.EffectiveAPIKeys(); len(keys) > 0 { return keys @@ -148,6 +153,11 @@ func (c *OpenCodeClient) StreamIdleTimeout(modelConfig config.ModelConfig) time. if ms <= 0 { ms = cfg.OpenCodeZen.TimeoutMs } + case IsOpenRouter(modelConfig): + ms = cfg.OpenRouter.StreamTimeoutMs + if ms <= 0 { + ms = cfg.OpenRouter.TimeoutMs + } default: ms = cfg.OpenCodeGo.StreamTimeoutMs if ms <= 0 { @@ -172,6 +182,8 @@ func (c *OpenCodeClient) RequestTimeout(model config.ModelConfig) time.Duration timeoutMs = cfg.AWSBedrock.TimeoutMs case IsZen(model): timeoutMs = cfg.OpenCodeZen.TimeoutMs + case IsOpenRouter(model): + timeoutMs = cfg.OpenRouter.TimeoutMs default: timeoutMs = cfg.OpenCodeGo.TimeoutMs } @@ -199,6 +211,11 @@ func (c *OpenCodeClient) StreamingTimeout(model config.ModelConfig) time.Duratio if timeoutMs <= 0 { timeoutMs = cfg.OpenCodeZen.TimeoutMs } + case IsOpenRouter(model): + timeoutMs = cfg.OpenRouter.StreamingTimeoutMs + if timeoutMs <= 0 { + timeoutMs = cfg.OpenRouter.TimeoutMs + } default: timeoutMs = cfg.OpenCodeGo.StreamingTimeoutMs if timeoutMs <= 0 { @@ -252,6 +269,11 @@ func IsBedrock(model config.ModelConfig) bool { return Provider(model) == ProviderAWSBedrock } +// IsOpenRouter returns true if the model uses the OpenRouter provider. +func IsOpenRouter(model config.ModelConfig) bool { + return Provider(model) == ProviderOpenRouter +} + // EndpointType determines which Zen endpoint format to use. type EndpointType int @@ -305,6 +327,10 @@ func (c *OpenCodeClient) getEndpoint(modelID string, modelConfig config.ModelCon } } + if IsOpenRouter(modelConfig) { + return endpointConfig{BaseURL: cfg.OpenRouter.BaseURL, APIKey: apiKey} + } + // Default: OpenCode Go if models.IsAnthropicModel(modelID) { return endpointConfig{BaseURL: cfg.OpenCodeGo.AnthropicBaseURL, APIKey: apiKey} diff --git a/internal/client/opencode_test.go b/internal/client/opencode_test.go index d2d3d7e..12172ea 100644 --- a/internal/client/opencode_test.go +++ b/internal/client/opencode_test.go @@ -1,10 +1,15 @@ package client import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" "time" "github.com/routatic/proxy/internal/config" + "github.com/routatic/proxy/pkg/types" ) func TestIsAnthropicModelOnlyRoutesNativeAnthropicModels(t *testing.T) { @@ -844,6 +849,161 @@ func TestGetProviderAPIKeys_ProviderKeysPrecedence(t *testing.T) { } } +func TestOpenRouterKeys(t *testing.T) { + cfg := &config.Config{ + OpenRouter: config.OpenRouterConfig{ + APIKey: "openrouter-specific-key", + }, + } + atomicCfg := config.NewAtomicConfig(cfg, "") + c := NewOpenCodeClient(atomicCfg, nil) + + model := config.ModelConfig{Provider: ProviderOpenRouter, ModelID: "openrouter-model"} + got := c.getProviderAPIKeys(model) + + want := []string{"openrouter-specific-key"} + if len(got) != len(want) || got[0] != want[0] { + t.Errorf("getProviderAPIKeys() = %v, want %v", got, want) + } +} + +func TestOpenRouterEndpoint(t *testing.T) { + cfg := &config.Config{ + OpenRouter: config.OpenRouterConfig{ + BaseURL: "https://openrouter.ai/api/v1", + APIKey: "openrouter-key", + }, + } + atomicCfg := config.NewAtomicConfig(cfg, "") + c := NewOpenCodeClient(atomicCfg, nil) + + model := config.ModelConfig{Provider: ProviderOpenRouter, ModelID: "openrouter-model"} + endpoint := c.getEndpoint("openrouter-model", model) + + if endpoint.BaseURL != cfg.OpenRouter.BaseURL { + t.Errorf("getEndpoint BaseURL = %q, want %q", endpoint.BaseURL, cfg.OpenRouter.BaseURL) + } + if endpoint.APIKey != cfg.OpenRouter.APIKey { + t.Errorf("getEndpoint APIKey = %q, want %q", endpoint.APIKey, cfg.OpenRouter.APIKey) + } +} + +func TestOpenRouterTimeout(t *testing.T) { + cfg := &config.Config{ + OpenRouter: config.OpenRouterConfig{ + TimeoutMs: 120000, + StreamTimeoutMs: 180000, + StreamingTimeoutMs: 240000, + }, + } + atomicCfg := config.NewAtomicConfig(cfg, "") + c := NewOpenCodeClient(atomicCfg, nil) + + model := config.ModelConfig{Provider: ProviderOpenRouter, ModelID: "openrouter-model"} + + if got := c.RequestTimeout(model); got != 120*time.Second { + t.Errorf("RequestTimeout = %v, want 120s", got) + } + if got := c.StreamIdleTimeout(model); got != 180*time.Second { + t.Errorf("StreamIdleTimeout = %v, want 180s", got) + } + if got := c.StreamingTimeout(model); got != 240*time.Second { + t.Errorf("StreamingTimeout = %v, want 240s", got) + } +} + +func TestOpenRouterChatCompletion_UsesBearerAuth(t *testing.T) { + var gotURL string + var gotAuth string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotURL = r.URL.String() + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"resp-1","object":"chat.completion","created":1,"model":"openrouter/model","choices":[],"usage":{}}`)) + })) + defer ts.Close() + + cfg := &config.Config{ + OpenRouter: config.OpenRouterConfig{ + BaseURL: ts.URL, + APIKey: "openrouter-key", + }, + } + atomicCfg := config.NewAtomicConfig(cfg, "") + c := NewOpenCodeClient(atomicCfg, nil) + + model := config.ModelConfig{Provider: ProviderOpenRouter, ModelID: "openrouter/model"} + req := &types.ChatCompletionRequest{ + Model: "openrouter/model", + Messages: []types.ChatMessage{{Role: "user", Content: json.RawMessage(`"hello"`)}}, + } + _, err := c.ChatCompletionNonStreaming(context.Background(), "openrouter/model", req, model) + if err != nil { + t.Fatalf("ChatCompletionNonStreaming() error = %v", err) + } + + if gotURL != "/" { + t.Errorf("request URL = %q, want %q", gotURL, "/") + } + if gotAuth != "Bearer openrouter-key" { + t.Errorf("Authorization header = %q, want %q", gotAuth, "Bearer openrouter-key") + } +} + +func TestOpenRouterChatCompletion_UsesOpenRouterBaseURL(t *testing.T) { + var gotURL string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotURL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"resp-2","object":"chat.completion","created":2,"model":"openrouter/model","choices":[],"usage":{}}`)) + })) + defer ts.Close() + + cfg := &config.Config{ + OpenRouter: config.OpenRouterConfig{ + BaseURL: ts.URL, + APIKey: "openrouter-key", + }, + } + atomicCfg := config.NewAtomicConfig(cfg, "") + c := NewOpenCodeClient(atomicCfg, nil) + + model := config.ModelConfig{Provider: ProviderOpenRouter, ModelID: "openrouter/model"} + req := &types.ChatCompletionRequest{ + Model: "openrouter/model", + Messages: []types.ChatMessage{{Role: "user", Content: json.RawMessage(`"hello"`)}}, + } + _, err := c.ChatCompletionNonStreaming(context.Background(), "openrouter/model", req, model) + if err != nil { + t.Fatalf("ChatCompletionNonStreaming() error = %v", err) + } + + if gotURL != "/" { + t.Errorf("request URL = %q, want %q", gotURL, "/") + } +} + +func TestOpenRouterTimeout_FallsBackToTimeoutMs(t *testing.T) { + cfg := &config.Config{ + OpenRouter: config.OpenRouterConfig{ + TimeoutMs: 120000, + StreamTimeoutMs: 0, + StreamingTimeoutMs: 0, + }, + } + atomicCfg := config.NewAtomicConfig(cfg, "") + c := NewOpenCodeClient(atomicCfg, nil) + + model := config.ModelConfig{Provider: ProviderOpenRouter, ModelID: "openrouter-model"} + + if got := c.StreamIdleTimeout(model); got != 120*time.Second { + t.Errorf("StreamIdleTimeout = %v, want 120s", got) + } + if got := c.StreamingTimeout(model); got != 120*time.Second { + t.Errorf("StreamingTimeout = %v, want 120s", got) + } +} + func TestGetProviderAPIKeys_EmptyReturnsGlobal(t *testing.T) { cfg := &config.Config{ APIKey: "global-single-key", diff --git a/internal/config/config.go b/internal/config/config.go index 53e8115..f707b24 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ type Config struct { AWSBedrock AWSBedrockConfig `json:"aws_bedrock"` OpenCodeGo OpenCodeGoConfig `json:"opencode_go"` OpenCodeZen OpenCodeZenConfig `json:"opencode_zen"` + OpenRouter OpenRouterConfig `json:"openrouter"` AnthropicFirst AnthropicFirstConfig `json:"anthropic_first"` Logging LoggingConfig `json:"logging"` Debug DebugConfig `json:"debug"` @@ -109,6 +110,28 @@ func (c *OpenCodeGoConfig) EffectiveAPIKeys() []string { return nil } +// OpenRouterConfig holds the upstream OpenRouter API settings. +type OpenRouterConfig struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key,omitempty"` + APIKeys []string `json:"api_keys,omitempty"` + TimeoutMs int `json:"timeout_ms"` + StreamTimeoutMs int `json:"stream_timeout_ms"` + StreamingTimeoutMs int `json:"streaming_timeout_ms,omitempty"` +} + +// EffectiveAPIKeys returns the pool of API keys for OpenRouter. +// APIKeys takes precedence; falls back to the single APIKey field. +func (c *OpenRouterConfig) EffectiveAPIKeys() []string { + if len(c.APIKeys) > 0 { + return c.APIKeys + } + if c.APIKey != "" { + return []string{c.APIKey} + } + return nil +} + // OpenCodeZenConfig holds the upstream OpenCode Zen API settings. type OpenCodeZenConfig struct { BaseURL string `json:"base_url"` diff --git a/internal/config/loader.go b/internal/config/loader.go index 7c5bcd8..823d283 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -28,6 +28,8 @@ const ( defaultZenAnthropicBaseURL = "https://opencode.ai/zen/v1/messages" defaultZenResponsesBaseURL = "https://opencode.ai/zen/v1/responses" defaultZenGeminiBaseURL = "https://opencode.ai/zen/v1/models" + + defaultOpenRouterBaseURL = "https://openrouter.ai/api/v1" ) // envVarPattern matches ${ENV_VAR} placeholders in config values. @@ -192,6 +194,15 @@ func applyEnvOverrides(cfg *Config) { cfg.AWSBedrock.APIKey = "" } + if v := envValue("ROUTATIC_PROXY_OPENROUTER_API_KEY"); v != "" { + cfg.OpenRouter.APIKey = v + cfg.OpenRouter.APIKeys = nil + } + if v := envValue("ROUTATIC_PROXY_OPENROUTER_API_KEYS"); v != "" { + cfg.OpenRouter.APIKeys = parseCommaSeparatedKeys(v) + cfg.OpenRouter.APIKey = "" + } + if v := envValue("ROUTATIC_PROXY_HOST"); v != "" { cfg.Host = v } @@ -265,6 +276,9 @@ func applyDefaults(cfg *Config) { if cfg.OpenCodeZen.GeminiBaseURL == "" { cfg.OpenCodeZen.GeminiBaseURL = defaultZenGeminiBaseURL } + if cfg.OpenRouter.BaseURL == "" { + cfg.OpenRouter.BaseURL = defaultOpenRouterBaseURL + } if cfg.OpenCodeZen.TimeoutMs == 0 { cfg.OpenCodeZen.TimeoutMs = defaultTimeoutMs } @@ -334,6 +348,13 @@ func validate(cfg *Config) error { return fmt.Errorf("aws_bedrock.api_keys: %w", err) } + if err := validateSingleAPIKey(cfg.OpenRouter.APIKey); err != nil { + return fmt.Errorf("openrouter.api_key: %w", err) + } + if err := validateAPIKeys(cfg.OpenRouter.APIKeys); err != nil { + return fmt.Errorf("openrouter.api_keys: %w", err) + } + if err := validateModelOverrides(cfg.ModelOverrides); err != nil { return err } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index c5acc97..8185dbc 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -1074,6 +1074,115 @@ func containsHelper(s, substr string) bool { return false } +func TestEnvOverrides_OpenRouterSpecificKeys(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + cfgJSON := `{ + "api_key": "global-key", + "openrouter": { + "base_url": "https://openrouter.example.com/v1" + } + }` + if err := os.WriteFile(cfgPath, []byte(cfgJSON), 0644); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + _ = os.Setenv("ROUTATIC_PROXY_CONFIG", cfgPath) + _ = os.Setenv("ROUTATIC_PROXY_OPENROUTER_API_KEY", "openrouter-env-key") + defer func() { + _ = os.Unsetenv("ROUTATIC_PROXY_CONFIG") + _ = os.Unsetenv("ROUTATIC_PROXY_OPENROUTER_API_KEY") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.OpenRouter.APIKey != "openrouter-env-key" { + t.Errorf("OpenRouter.APIKey = %q, want %q", cfg.OpenRouter.APIKey, "openrouter-env-key") + } + if cfg.OpenRouter.APIKeys != nil { + t.Errorf("OpenRouter.APIKeys = %v, want nil", cfg.OpenRouter.APIKeys) + } +} + +func TestEnvOverrides_OpenRouterSpecificKeysPrecedence(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + cfgJSON := `{ + "api_key": "global-key", + "openrouter": { + "api_key": "openrouter-file-key", + "base_url": "https://openrouter.example.com/v1" + } + }` + if err := os.WriteFile(cfgPath, []byte(cfgJSON), 0644); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + _ = os.Setenv("ROUTATIC_PROXY_CONFIG", cfgPath) + _ = os.Setenv("ROUTATIC_PROXY_OPENROUTER_API_KEY", "openrouter-env-key") + defer func() { + _ = os.Unsetenv("ROUTATIC_PROXY_CONFIG") + _ = os.Unsetenv("ROUTATIC_PROXY_OPENROUTER_API_KEY") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.OpenRouter.APIKey != "openrouter-env-key" { + t.Errorf("OpenRouter.APIKey = %q, want %q", cfg.OpenRouter.APIKey, "openrouter-env-key") + } +} + +func TestEnvOverrides_OpenRouterCommaSeparatedKeys(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + cfgJSON := `{ + "api_key": "global-key", + "openrouter": { + "api_key": "openrouter-file-key", + "base_url": "https://openrouter.example.com/v1" + } + }` + if err := os.WriteFile(cfgPath, []byte(cfgJSON), 0644); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + _ = os.Setenv("ROUTATIC_PROXY_CONFIG", cfgPath) + _ = os.Setenv("ROUTATIC_PROXY_OPENROUTER_API_KEYS", "openrouter-key-1,openrouter-key-2,openrouter-key-3") + defer func() { + _ = os.Unsetenv("ROUTATIC_PROXY_CONFIG") + _ = os.Unsetenv("ROUTATIC_PROXY_OPENROUTER_API_KEYS") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + want := []string{"openrouter-key-1", "openrouter-key-2", "openrouter-key-3"} + if len(cfg.OpenRouter.APIKeys) != len(want) { + t.Errorf("OpenRouter.APIKeys = %v, want %v", cfg.OpenRouter.APIKeys, want) + return + } + for i := range cfg.OpenRouter.APIKeys { + if cfg.OpenRouter.APIKeys[i] != want[i] { + t.Errorf("OpenRouter.APIKeys[%d] = %q, want %q", i, cfg.OpenRouter.APIKeys[i], want[i]) + } + } + + if cfg.OpenRouter.APIKey != "" { + t.Errorf("OpenRouter.APIKey = %q, want empty string", cfg.OpenRouter.APIKey) + } +} + func TestDefaults_StreamingTimeoutFallback(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") From bfa3e81e4bf038809e83cbe2293e5154d3ae80bc Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 16:01:58 +0200 Subject: [PATCH 08/25] feat: enhance ModelRouter to support catalog-based model resolution and add corresponding tests --- internal/config/config.go | 1 + internal/router/model_router.go | 95 +++++++++++++-- internal/router/model_router_test.go | 176 +++++++++++++++++++++++++++ internal/server/server.go | 3 +- 4 files changed, 265 insertions(+), 10 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index f707b24..1350970 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,6 +48,7 @@ type DebugConfig struct { type ModelConfig struct { Provider string `json:"provider"` ModelID string `json:"model_id"` + ModelRef string `json:"model_ref,omitempty"` WireFormat string `json:"wire_format,omitempty"` // "auto" (default), "openai", "anthropic", "responses", "gemini" Temperature float64 `json:"temperature"` MaxTokens int `json:"max_tokens"` diff --git a/internal/router/model_router.go b/internal/router/model_router.go index a5122d7..0ccf0c4 100644 --- a/internal/router/model_router.go +++ b/internal/router/model_router.go @@ -4,13 +4,19 @@ package router import ( "fmt" + "sync" + "github.com/routatic/proxy/internal/catalog" "github.com/routatic/proxy/internal/config" ) // ModelRouter handles model selection based on scenarios. type ModelRouter struct { - atomic *config.AtomicConfig + atomic *config.AtomicConfig + catalogPath string + catMu sync.Mutex + cat *catalog.IndexedCatalog + catErr error } // NewModelRouter creates a new model router. @@ -18,6 +24,31 @@ func NewModelRouter(atomic *config.AtomicConfig) *ModelRouter { return &ModelRouter{atomic: atomic} } +// NewModelRouterWithCatalog creates a new model router that resolves model +// references and short ids through a catalog file when a model is not present +// in the legacy config map. +func NewModelRouterWithCatalog(atomic *config.AtomicConfig, catalogPath string) *ModelRouter { + return &ModelRouter{atomic: atomic, catalogPath: catalogPath} +} + +// catalog lazily loads and caches the indexed catalog. If no catalog path is +// configured it returns (nil, nil) so that legacy behavior is preserved. +func (r *ModelRouter) catalog() (*catalog.IndexedCatalog, error) { + if r.catalogPath == "" { + return nil, nil + } + + r.catMu.Lock() + defer r.catMu.Unlock() + + if r.cat != nil || r.catErr != nil { + return r.cat, r.catErr + } + + r.cat, r.catErr = catalog.Load(r.catalogPath) + return r.cat, r.catErr +} + // isRespectRequestedModel returns true when the client-specified model should be // used as the primary routing target. nil (unset in config) defaults to true; // an explicit *false from the user config is honoured. @@ -46,14 +77,17 @@ func (r *ModelRouter) resolveRequestedModel(cfg *config.Config, requestedModel s // Look up the requested model in config to inherit its settings primary, ok := cfg.Models[requestedModel] if !ok { - // Unknown model — create a bare config and inherit defaults - primary = config.ModelConfig{ - Provider: "opencode-go", - ModelID: requestedModel, - } - if def, ok := cfg.Models["default"]; ok { - primary.Temperature = def.Temperature - primary.MaxTokens = def.MaxTokens + // Not in legacy config — try the catalog before falling back to the + // legacy unknown-model behavior. + cat, _ := r.catalog() + if cat != nil { + if catalogPrimary, catalogOk := r.resolveFromCatalog(cat, requestedModel); catalogOk { + primary = catalogPrimary + } else { + primary = r.legacyUnknownModelConfig(cfg, requestedModel) + } + } else { + primary = r.legacyUnknownModelConfig(cfg, requestedModel) } } primary = config.ResolveModelConfig(primary) @@ -70,6 +104,49 @@ func (r *ModelRouter) resolveRequestedModel(cfg *config.Config, requestedModel s }, true, nil } +// resolveFromCatalog attempts to resolve a requested model string through the +// catalog. It returns the model config and true on success, otherwise false. +func (r *ModelRouter) resolveFromCatalog(cat *catalog.IndexedCatalog, requestedModel string) (config.ModelConfig, bool) { + sel, err := catalog.ParseModelRef(requestedModel) + if err != nil { + return config.ModelConfig{}, false + } + + var resolved catalog.ResolvedModel + if sel.Provider != "" { + resolved, err = cat.Resolve(sel) + } else { + resolved, err = cat.ResolveShort(requestedModel) + } + if err != nil { + return config.ModelConfig{}, false + } + + supportsTools := resolved.Tools + return config.ModelConfig{ + Provider: resolved.Provider, + ModelID: resolved.ModelID, + ModelRef: requestedModel, + Vision: resolved.Vision, + ContextWindow: int(resolved.ContextWindow), + SupportsTools: &supportsTools, + }, true +} + +// legacyUnknownModelConfig builds a bare config for an unknown model and +// inherits Temperature and MaxTokens from the default model when available. +func (r *ModelRouter) legacyUnknownModelConfig(cfg *config.Config, requestedModel string) config.ModelConfig { + primary := config.ModelConfig{ + Provider: "opencode-go", + ModelID: requestedModel, + } + if def, ok := cfg.Models["default"]; ok { + primary.Temperature = def.Temperature + primary.MaxTokens = def.MaxTokens + } + return primary +} + // Route determines which model to use for a request. // If respect_requested_model is enabled and requestedModel is provided, it overrides scenario-based routing. func (r *ModelRouter) Route(messages []MessageContent, tokenCount int, requestedModel string) (RouteResult, error) { diff --git a/internal/router/model_router_test.go b/internal/router/model_router_test.go index a9f8948..690c074 100644 --- a/internal/router/model_router_test.go +++ b/internal/router/model_router_test.go @@ -1,13 +1,80 @@ package router import ( + "os" + "path/filepath" "testing" + "github.com/routatic/proxy/internal/catalog" "github.com/routatic/proxy/internal/config" ) func boolPtr(b bool) *bool { return &b } +func writeTestCatalog(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "catalog.json") + data := []byte(`{ + "providers": { + "opencode-go": { + "name": "opencode-go", + "base_url": "https://go.opencode.ai", + "api_key": "", + "enabled": true, + "anthropic_tools_disabled": false + }, + "openrouter": { + "name": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "", + "enabled": true, + "anthropic_tools_disabled": false + } + }, + "models": { + "deepseek-v4-flash": { + "name": "deepseek-v4-flash", + "display_name": "DeepSeek V4 Flash", + "providers": ["opencode-go"], + "context_window": 1000000, + "cost_input_per_m": 0.0, + "cost_output_per_m": 0.0, + "tools": true, + "vision": false, + "reasoning": false + }, + "kimi-k2.6": { + "name": "kimi-k2.6", + "display_name": "Kimi K2.6", + "providers": ["opencode-go", "openrouter"], + "context_window": 256000, + "cost_input_per_m": 0.0, + "cost_output_per_m": 0.0, + "tools": true, + "vision": true, + "reasoning": false + }, + "glm-5": { + "name": "glm-5", + "display_name": "GLM 5", + "providers": ["opencode-go"], + "context_window": 200000, + "cost_input_per_m": 0.0, + "cost_output_per_m": 0.0, + "tools": true, + "vision": false, + "reasoning": false + } + }, + "scenarios": {} +}`) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatalf("failed to write test catalog: %v", err) + } + return path +} + func newTestAtomicConfig(cfg *config.Config) *config.AtomicConfig { return config.NewAtomicConfig(cfg, "/tmp/test-config.json") } @@ -361,3 +428,112 @@ func TestRouteWithOverride_NoFallbacksAnywhere(t *testing.T) { t.Errorf("expected 1-element chain, got %d", len(chain)) } } + +func TestResolveRequestedModel(t *testing.T) { + catalogPath := writeTestCatalog(t) + // Verify the fixture loads so the test failures are not misleading. + if _, err := catalog.Load(catalogPath); err != nil { + t.Fatalf("test catalog fixture is invalid: %v", err) + } + + cfg := &config.Config{ + RespectRequestedModel: boolPtr(true), + Models: map[string]config.ModelConfig{ + "default": { + Provider: "opencode-go", + ModelID: "kimi-k2.6", + Temperature: 0.5, + MaxTokens: 8192, + }, + "custom-model": { + Provider: "opencode-go", + ModelID: "custom-model", + Temperature: 0.3, + MaxTokens: 2048, + }, + }, + Fallbacks: map[string][]config.ModelConfig{ + "default": {{Provider: "opencode-go", ModelID: "qwen3.5-plus"}}, + }, + } + atomic := newTestAtomicConfig(cfg) + + tests := []struct { + name string + requestedModel string + needsVision bool + catalogPath string + wantProvider string + wantModelID string + wantModelRef string + wantErr bool + }{ + { + name: "lab/model@provider resolves through catalog", + requestedModel: "deepseek/deepseek-v4-flash@opencode-go", + catalogPath: catalogPath, + wantProvider: "opencode-go", + wantModelID: "deepseek-v4-flash", + wantModelRef: "deepseek/deepseek-v4-flash@opencode-go", + }, + { + name: "short id resolves through catalog", + requestedModel: "kimi-k2.6", + catalogPath: catalogPath, + wantProvider: "opencode-go", + wantModelID: "kimi-k2.6", + wantModelRef: "kimi-k2.6", + }, + { + name: "config model takes precedence over catalog", + requestedModel: "custom-model", + catalogPath: catalogPath, + wantProvider: "opencode-go", + wantModelID: "custom-model", + wantModelRef: "", + }, + { + name: "unknown model without catalog uses legacy fallback", + requestedModel: "some-unknown-model", + catalogPath: "", + wantProvider: "opencode-go", + wantModelID: "some-unknown-model", + wantModelRef: "", + }, + { + name: "vision request for non-vision catalog model returns error", + requestedModel: "glm-5", + needsVision: true, + catalogPath: catalogPath, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := NewModelRouterWithCatalog(atomic, tt.catalogPath) + result, ok, err := router.resolveRequestedModel(cfg, tt.requestedModel, tt.needsVision) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatalf("expected resolveRequestedModel to match") + } + if result.Primary.Provider != tt.wantProvider { + t.Errorf("expected provider %q, got %q", tt.wantProvider, result.Primary.Provider) + } + if result.Primary.ModelID != tt.wantModelID { + t.Errorf("expected model_id %q, got %q", tt.wantModelID, result.Primary.ModelID) + } + if result.Primary.ModelRef != tt.wantModelRef { + t.Errorf("expected model_ref %q, got %q", tt.wantModelRef, result.Primary.ModelRef) + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 09e9a01..fdf9c16 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "sync" "syscall" "time" @@ -58,7 +59,7 @@ func NewServer(atomic *config.AtomicConfig, captureLogger *debug.CaptureLogger) metrics := metrics.New() openCodeClient := client.NewOpenCodeClient(atomic, captureLogger) - modelRouter := router.NewModelRouter(atomic) + modelRouter := router.NewModelRouterWithCatalog(atomic, filepath.Join(filepath.Dir(atomic.Path()), "catalog", "catalog.json")) fallbackHandler := router.NewFallbackHandler(logger, 3, 30*time.Second) // Register providers. From 032c1dd46dcb2350021084d4498c05ca686bcca1 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 16:34:31 +0200 Subject: [PATCH 09/25] feat: enhance model resolution to handle unknown providers and add corresponding tests --- internal/config/model_registry.go | 28 +-- internal/config/model_registry_test.go | 94 +++++++++ internal/handlers/messages.go | 8 +- internal/router/model_router.go | 30 ++- internal/router/model_router_test.go | 277 ++++++++++++++++++++++++- 5 files changed, 413 insertions(+), 24 deletions(-) create mode 100644 internal/config/model_registry_test.go diff --git a/internal/config/model_registry.go b/internal/config/model_registry.go index 3d07e2b..4a028a0 100644 --- a/internal/config/model_registry.go +++ b/internal/config/model_registry.go @@ -33,19 +33,21 @@ var modelMetadata = map[string]ModelMetadata{ } func ResolveModelConfig(model ModelConfig) ModelConfig { - if meta, ok := modelMetadata[model.ModelID]; ok { - if model.ContextWindow == 0 { - model.ContextWindow = meta.ContextWindow - } - if model.MaxOutputTokens == 0 { - model.MaxOutputTokens = meta.MaxOutputTokens - } - if !model.Vision { - model.Vision = meta.Vision - } - if model.SupportsTools == nil { - v := meta.SupportsTools - model.SupportsTools = &v + if model.ModelRef == "" { + if meta, ok := modelMetadata[model.ModelID]; ok { + if model.ContextWindow == 0 { + model.ContextWindow = meta.ContextWindow + } + if model.MaxOutputTokens == 0 { + model.MaxOutputTokens = meta.MaxOutputTokens + } + if !model.Vision { + model.Vision = meta.Vision + } + if model.SupportsTools == nil { + v := meta.SupportsTools + model.SupportsTools = &v + } } } if model.ContextMargin == 0 { diff --git a/internal/config/model_registry_test.go b/internal/config/model_registry_test.go new file mode 100644 index 0000000..68f890f --- /dev/null +++ b/internal/config/model_registry_test.go @@ -0,0 +1,94 @@ +package config + +import ( + "testing" +) + +func boolPtr(b bool) *bool { + return &b +} + +func TestResolveModelConfig(t *testing.T) { + tests := []struct { + name string + input ModelConfig + expected ModelConfig + }{ + { + name: "legacy model with empty ModelRef gets hardcoded metadata", + input: ModelConfig{ + ModelID: "kimi-k2.6", + }, + expected: ModelConfig{ + ModelID: "kimi-k2.6", + ContextWindow: 256000, + MaxOutputTokens: 8192, + Vision: true, + ContextMargin: DefaultContextMargin, + SupportsTools: boolPtr(true), + }, + }, + { + name: "ModelRef present preserves explicit catalog capabilities", + input: ModelConfig{ + ModelID: "deepseek-v4-flash", + ModelRef: "deepseek/deepseek-v4-flash@opencode-go", + ContextWindow: 12345, + Vision: true, + SupportsTools: boolPtr(true), + }, + expected: ModelConfig{ + ModelID: "deepseek-v4-flash", + ModelRef: "deepseek/deepseek-v4-flash@opencode-go", + ContextWindow: 12345, + Vision: true, + ContextMargin: DefaultContextMargin, + SupportsTools: boolPtr(true), + }, + }, + { + name: "ModelRef present with zero values still gets defaults", + input: ModelConfig{ + ModelID: "deepseek-v4-flash", + ModelRef: "deepseek/deepseek-v4-flash@opencode-go", + }, + expected: ModelConfig{ + ModelID: "deepseek-v4-flash", + ModelRef: "deepseek/deepseek-v4-flash@opencode-go", + ContextMargin: DefaultContextMargin, + SupportsTools: boolPtr(true), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveModelConfig(tt.input) + + if got.ModelID != tt.expected.ModelID { + t.Errorf("ModelID = %q, want %q", got.ModelID, tt.expected.ModelID) + } + if got.ModelRef != tt.expected.ModelRef { + t.Errorf("ModelRef = %q, want %q", got.ModelRef, tt.expected.ModelRef) + } + if got.ContextWindow != tt.expected.ContextWindow { + t.Errorf("ContextWindow = %d, want %d", got.ContextWindow, tt.expected.ContextWindow) + } + if got.MaxOutputTokens != tt.expected.MaxOutputTokens { + t.Errorf("MaxOutputTokens = %d, want %d", got.MaxOutputTokens, tt.expected.MaxOutputTokens) + } + if got.Vision != tt.expected.Vision { + t.Errorf("Vision = %v, want %v", got.Vision, tt.expected.Vision) + } + if got.ContextMargin != tt.expected.ContextMargin { + t.Errorf("ContextMargin = %d, want %d", got.ContextMargin, tt.expected.ContextMargin) + } + if (got.SupportsTools == nil) != (tt.expected.SupportsTools == nil) { + t.Fatalf("SupportsTools nil mismatch: got %v, want %v", got.SupportsTools, tt.expected.SupportsTools) + } + if got.SupportsTools != nil && *got.SupportsTools != *tt.expected.SupportsTools { + t.Errorf("SupportsTools = %v, want %v", *got.SupportsTools, *tt.expected.SupportsTools) + } + }) + } +} diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index 4dd2be3..b10c461 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -340,7 +340,13 @@ func (h *MessagesHandler) HandleMessages(w http.ResponseWriter, r *http.Request) needsTools := len(anthropicReq.Tools) > 0 modelChain, routeResult, err := h.buildModelChain(anthropicReq.Model, routerMessages, tokenCount, isStreaming, anthropicReq.MaxTokens, facts.NeedsVision, needsTools) if err != nil { - h.sendError(w, http.StatusInternalServerError, "routing failed", err) + status := http.StatusInternalServerError + message := "routing failed" + if errors.Is(err, router.ErrUnknownProvider) { + status = http.StatusBadRequest + message = err.Error() + } + h.sendError(w, status, message, err) return } diff --git a/internal/router/model_router.go b/internal/router/model_router.go index 0ccf0c4..3fc1155 100644 --- a/internal/router/model_router.go +++ b/internal/router/model_router.go @@ -3,6 +3,7 @@ package router import ( + "errors" "fmt" "sync" @@ -10,6 +11,10 @@ import ( "github.com/routatic/proxy/internal/config" ) +// ErrUnknownProvider is returned when a provider-qualified model reference +// cannot be resolved because the named provider is not configured. +var ErrUnknownProvider = errors.New("unknown provider") + // ModelRouter handles model selection based on scenarios. type ModelRouter struct { atomic *config.AtomicConfig @@ -78,14 +83,23 @@ func (r *ModelRouter) resolveRequestedModel(cfg *config.Config, requestedModel s primary, ok := cfg.Models[requestedModel] if !ok { // Not in legacy config — try the catalog before falling back to the - // legacy unknown-model behavior. + // legacy unknown-model behavior. Provider-qualified references that + // fail catalog resolution are rejected with a clear error instead of + // silently falling back to a bogus provider. + sel, parseErr := catalog.ParseModelRef(requestedModel) + providerQualified := parseErr == nil && sel.Provider != "" + cat, _ := r.catalog() if cat != nil { - if catalogPrimary, catalogOk := r.resolveFromCatalog(cat, requestedModel); catalogOk { + if catalogPrimary, catalogOk := r.resolveFromCatalog(cat, requestedModel, sel); catalogOk { primary = catalogPrimary + } else if providerQualified { + return RouteResult{}, false, fmt.Errorf("model reference %q uses unknown provider %q: %w", requestedModel, sel.Provider, ErrUnknownProvider) } else { primary = r.legacyUnknownModelConfig(cfg, requestedModel) } + } else if providerQualified { + return RouteResult{}, false, fmt.Errorf("model reference %q uses unknown provider %q: %w", requestedModel, sel.Provider, ErrUnknownProvider) } else { primary = r.legacyUnknownModelConfig(cfg, requestedModel) } @@ -106,13 +120,9 @@ func (r *ModelRouter) resolveRequestedModel(cfg *config.Config, requestedModel s // resolveFromCatalog attempts to resolve a requested model string through the // catalog. It returns the model config and true on success, otherwise false. -func (r *ModelRouter) resolveFromCatalog(cat *catalog.IndexedCatalog, requestedModel string) (config.ModelConfig, bool) { - sel, err := catalog.ParseModelRef(requestedModel) - if err != nil { - return config.ModelConfig{}, false - } - +func (r *ModelRouter) resolveFromCatalog(cat *catalog.IndexedCatalog, requestedModel string, sel catalog.Selector) (config.ModelConfig, bool) { var resolved catalog.ResolvedModel + var err error if sel.Provider != "" { resolved, err = cat.Resolve(sel) } else { @@ -241,7 +251,9 @@ func (rr *RouteResult) GetModelChain() []config.ModelConfig { func (r *ModelRouter) RouteForStreaming(messages []MessageContent, tokenCount int, requestedModel string) (RouteResult, error) { cfg := r.atomic.Get() - if result, ok, err := r.resolveRequestedModel(cfg, requestedModel, false); err == nil && ok { + if result, ok, err := r.resolveRequestedModel(cfg, requestedModel, false); err != nil { + return RouteResult{}, err + } else if ok { return result, nil } diff --git a/internal/router/model_router_test.go b/internal/router/model_router_test.go index 690c074..73a0191 100644 --- a/internal/router/model_router_test.go +++ b/internal/router/model_router_test.go @@ -1,6 +1,7 @@ package router import ( + "errors" "os" "path/filepath" "testing" @@ -58,7 +59,7 @@ func writeTestCatalog(t *testing.T) string { "glm-5": { "name": "glm-5", "display_name": "GLM 5", - "providers": ["opencode-go"], + "providers": ["opencode-go", "openrouter"], "context_window": 200000, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, @@ -429,6 +430,52 @@ func TestRouteWithOverride_NoFallbacksAnywhere(t *testing.T) { } } +func TestUnknownProvider(t *testing.T) { + catalogPath := writeTestCatalog(t) + cfg := &config.Config{ + RespectRequestedModel: boolPtr(true), + Models: map[string]config.ModelConfig{ + "default": { + Provider: "opencode-go", + ModelID: "kimi-k2.6", + Temperature: 0.5, + MaxTokens: 8192, + }, + }, + Fallbacks: map[string][]config.ModelConfig{ + "default": {{Provider: "opencode-go", ModelID: "qwen3.5-plus"}}, + }, + } + atomic := newTestAtomicConfig(cfg) + router := NewModelRouterWithCatalog(atomic, catalogPath) + + t.Run("unknown provider in canonical reference returns ErrUnknownProvider", func(t *testing.T) { + _, _, err := router.resolveRequestedModel(cfg, "deepseek/deepseek-v4-flash@nonexistent-provider", false) + if err == nil { + t.Fatal("expected error for unknown provider, got nil") + } + if !errors.Is(err, ErrUnknownProvider) { + t.Fatalf("expected error to wrap ErrUnknownProvider, got %v", err) + } + }) + + t.Run("unknown short id falls back silently to opencode-go", func(t *testing.T) { + result, ok, err := router.resolveRequestedModel(cfg, "totally-unknown-short-id", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected resolveRequestedModel to match") + } + if result.Primary.Provider != "opencode-go" { + t.Errorf("expected provider opencode-go, got %q", result.Primary.Provider) + } + if result.Primary.ModelID != "totally-unknown-short-id" { + t.Errorf("expected model_id totally-unknown-short-id, got %q", result.Primary.ModelID) + } + }) +} + func TestResolveRequestedModel(t *testing.T) { catalogPath := writeTestCatalog(t) // Verify the fixture loads so the test failures are not misleading. @@ -537,3 +584,231 @@ func TestResolveRequestedModel(t *testing.T) { }) } } + +func TestRoute_CanonicalAndShortRefs(t *testing.T) { + catalogPath := writeTestCatalog(t) + + cfg := &config.Config{ + RespectRequestedModel: boolPtr(true), + Models: map[string]config.ModelConfig{ + "default": { + Provider: "opencode-go", + ModelID: "kimi-k2.6", + Temperature: 0.5, + MaxTokens: 8192, + }, + }, + Fallbacks: map[string][]config.ModelConfig{ + "default": { + {Provider: "opencode-go", ModelID: "qwen3.5-plus"}, + }, + }, + } + atomic := newTestAtomicConfig(cfg) + + tests := []struct { + name string + requested string + wantProvider string + wantModelID string + wantModelRef string + }{ + { + name: "canonical lab/model@provider", + requested: "deepseek/deepseek-v4-flash@opencode-go", + wantProvider: "opencode-go", + wantModelID: "deepseek-v4-flash", + wantModelRef: "deepseek/deepseek-v4-flash@opencode-go", + }, + { + name: "short id resolves to first enabled provider", + requested: "kimi-k2.6", + wantProvider: "opencode-go", + wantModelID: "kimi-k2.6", + wantModelRef: "kimi-k2.6", + }, + { + name: "short id with explicit provider", + requested: "kimi-k2.6@openrouter", + wantProvider: "openrouter", + wantModelID: "kimi-k2.6", + wantModelRef: "kimi-k2.6@openrouter", + }, + { + name: "provider-qualified short id", + requested: "glm-5@openrouter", + wantProvider: "openrouter", + wantModelID: "glm-5", + wantModelRef: "glm-5@openrouter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := NewModelRouterWithCatalog(atomic, catalogPath) + result, err := router.Route([]MessageContent{{Role: "user", Content: "Hello"}}, 100, tt.requested) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Scenario != ScenarioDefault { + t.Errorf("expected scenario %q, got %q", ScenarioDefault, result.Scenario) + } + if result.Primary.Provider != tt.wantProvider { + t.Errorf("expected provider %q, got %q", tt.wantProvider, result.Primary.Provider) + } + if result.Primary.ModelID != tt.wantModelID { + t.Errorf("expected model_id %q, got %q", tt.wantModelID, result.Primary.ModelID) + } + if result.Primary.ModelRef != tt.wantModelRef { + t.Errorf("expected model_ref %q, got %q", tt.wantModelRef, result.Primary.ModelRef) + } + }) + } +} + +func TestRoute_ModelOverridesPrecedence(t *testing.T) { + catalogPath := writeTestCatalog(t) + + cfg := &config.Config{ + ModelOverrides: map[string]config.ModelConfig{ + "deepseek/deepseek-v4-flash@opencode-go": { + Provider: "opencode-zen", + ModelID: "claude-sonnet-4.5", + Temperature: 0.2, + MaxTokens: 4096, + }, + "kimi-k2.6": { + Provider: "openrouter", + ModelID: "kimi-k2.6-or", + Temperature: 0.1, + MaxTokens: 1024, + }, + }, + Fallbacks: map[string][]config.ModelConfig{ + "default": { + {Provider: "opencode-go", ModelID: "qwen3.5-plus"}, + }, + }, + } + atomic := newTestAtomicConfig(cfg) + router := NewModelRouterWithCatalog(atomic, catalogPath) + + tests := []struct { + name string + requested string + wantMatch bool + wantModelID string + wantProvider string + }{ + { + name: "canonical override key matches", + requested: "deepseek/deepseek-v4-flash@opencode-go", + wantMatch: true, + wantModelID: "claude-sonnet-4.5", + wantProvider: "opencode-zen", + }, + { + name: "short override key matches", + requested: "kimi-k2.6", + wantMatch: true, + wantModelID: "kimi-k2.6-or", + wantProvider: "openrouter", + }, + { + name: "unrelated canonical ref does not match", + requested: "kimi-k2.6@openrouter", + wantMatch: false, + }, + { + name: "unrelated short id does not match", + requested: "glm-5", + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := router.RouteWithOverride(tt.requested) + if ok != tt.wantMatch { + t.Fatalf("expected match=%v, got %v", tt.wantMatch, ok) + } + if !tt.wantMatch { + return + } + if result.Scenario != ScenarioOverride { + t.Errorf("expected scenario %q, got %q", ScenarioOverride, result.Scenario) + } + if result.Primary.ModelID != tt.wantModelID { + t.Errorf("expected model_id %q, got %q", tt.wantModelID, result.Primary.ModelID) + } + if result.Primary.Provider != tt.wantProvider { + t.Errorf("expected provider %q, got %q", tt.wantProvider, result.Primary.Provider) + } + }) + } +} + +func TestRoute_LegacyConfigFixtures(t *testing.T) { + t.Run("example config fixture", func(t *testing.T) { + t.Setenv("ROUTATIC_PROXY_API_KEY", "test-key") + + cfg, err := config.LoadFromPath("/Users/mac/Dev/routatic/proxy/configs/config.example.json") + if err != nil { + t.Fatalf("failed to load example config: %v", err) + } + atomic := config.NewAtomicConfig(cfg, "/Users/mac/Dev/routatic/proxy/configs/config.example.json") + router := NewModelRouter(atomic) + + messages := []MessageContent{{Role: "user", Content: "Hello"}} + + result, err := router.Route(messages, 100, "") + if err != nil { + t.Fatalf("Route failed: %v", err) + } + if result.Primary.ModelID != "deepseek-v4-pro" { + t.Errorf("expected primary deepseek-v4-pro, got %s", result.Primary.ModelID) + } + + streamResult, err := router.RouteForStreaming(messages, 100, "") + if err != nil { + t.Fatalf("RouteForStreaming failed: %v", err) + } + if streamResult.Primary.ModelID != "deepseek-v4-flash" { + t.Errorf("expected streaming primary deepseek-v4-flash, got %s", streamResult.Primary.ModelID) + } + }) + + t.Run("inline legacy fixture", func(t *testing.T) { + cfg := &config.Config{ + RespectRequestedModel: boolPtr(false), + Models: map[string]config.ModelConfig{ + "default": {Provider: "opencode-go", ModelID: "kimi-k2.6"}, + "fast": {Provider: "opencode-go", ModelID: "qwen3.5-plus"}, + }, + Fallbacks: map[string][]config.ModelConfig{ + "default": {{Provider: "opencode-go", ModelID: "glm-5.1"}}, + "fast": {{Provider: "opencode-go", ModelID: "deepseek-v4-flash"}}, + }, + } + atomic := newTestAtomicConfig(cfg) + router := NewModelRouter(atomic) + + messages := []MessageContent{{Role: "user", Content: "Hello"}} + + result, err := router.Route(messages, 100, "") + if err != nil { + t.Fatalf("Route failed: %v", err) + } + if result.Primary.ModelID != "kimi-k2.6" { + t.Errorf("expected primary kimi-k2.6, got %s", result.Primary.ModelID) + } + + streamResult, err := router.RouteForStreaming(messages, 100, "") + if err != nil { + t.Fatalf("RouteForStreaming failed: %v", err) + } + if streamResult.Primary.ModelID != "qwen3.5-plus" { + t.Errorf("expected streaming primary qwen3.5-plus, got %s", streamResult.Primary.ModelID) + } + }) +} From 3e57c687a5f7704f9e8fa4371b7cd99600300644 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 17:11:15 +0200 Subject: [PATCH 10/25] feat: add tests for handling unknown providers in messages handler --- cmd/routatic-proxy/catalog_test.go | 6 +- cmd/routatic-proxy/main.go | 150 ++++++++++++++--------------- internal/catalog/sync.go | 2 +- internal/handlers/messages_test.go | 51 ++++++++++ 4 files changed, 130 insertions(+), 79 deletions(-) diff --git a/cmd/routatic-proxy/catalog_test.go b/cmd/routatic-proxy/catalog_test.go index af3c7f4..68d4b11 100644 --- a/cmd/routatic-proxy/catalog_test.go +++ b/cmd/routatic-proxy/catalog_test.go @@ -90,7 +90,7 @@ func TestCatalogSyncCmd_Success(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, catalogJSON) + _, _ = fmt.Fprint(w, catalogJSON) })) defer server.Close() @@ -173,7 +173,7 @@ func TestServeCatalog_MissingSyncs(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, catalogJSON) + _, _ = fmt.Fprint(w, catalogJSON) })) defer server.Close() @@ -216,7 +216,7 @@ func TestServeCatalog_ExpiredSyncs(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, catalogJSON) + _, _ = fmt.Fprint(w, catalogJSON) })) defer server.Close() diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index d0a1d74..7a26b40 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -8,9 +8,11 @@ import ( "log/slog" "os" "path/filepath" + "sort" "strings" "time" + "github.com/routatic/proxy/internal/catalog" "github.com/routatic/proxy/internal/config" "github.com/routatic/proxy/internal/daemon" "github.com/routatic/proxy/internal/debug" @@ -423,84 +425,82 @@ func checkClaudeEnv(source string, env map[string]string, expectedURL string, an // modelsCmd returns the command to list available models. func modelsCmd() *cobra.Command { - return &cobra.Command{ + var configPath string + cmd := &cobra.Command{ Use: "models", - Short: "List available OpenCode Go models", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Available OpenCode Go models:") - fmt.Println() - fmt.Println(" Model ID Endpoint Type") - fmt.Println(" ──────────────────────────────────────────────") - fmt.Println(" glm-5.2 OpenAI-compatible") - fmt.Println(" glm-5.1 OpenAI-compatible") - fmt.Println(" glm-5 OpenAI-compatible (deprecated)") - fmt.Println(" kimi-k2.7-code OpenAI-compatible") - fmt.Println(" kimi-k2.6 OpenAI-compatible") - fmt.Println(" kimi-k2.5 OpenAI-compatible") - fmt.Println(" mimo-v2.5-pro OpenAI-compatible") - fmt.Println(" mimo-v2.5 OpenAI-compatible") - fmt.Println(" minimax-m3 Anthropic-compatible") - fmt.Println(" minimax-m2.7 Anthropic-compatible") - fmt.Println(" minimax-m2.5 Anthropic-compatible") - fmt.Println(" deepseek-v4-pro OpenAI-compatible") - fmt.Println(" deepseek-v4-flash OpenAI-compatible") - fmt.Println(" qwen3.7-max Anthropic-compatible") - fmt.Println(" qwen3.7-plus Anthropic-compatible") - fmt.Println(" qwen3.6-plus Anthropic-compatible") - fmt.Println(" qwen3.5-plus Anthropic-compatible") - fmt.Println() - fmt.Println("Available OpenCode Zen models (free tier):") - fmt.Println() - fmt.Println(" deepseek-v4-flash-free OpenAI-compatible") - fmt.Println(" grok-build-0.1 OpenAI-compatible") - fmt.Println(" big-pickle OpenAI-compatible") - fmt.Println(" mimo-v2.5-free OpenAI-compatible") - fmt.Println(" north-mini-code-free OpenAI-compatible") - fmt.Println(" nemotron-3-ultra-free OpenAI-compatible") - fmt.Println() - fmt.Println("Available OpenCode Zen models (Anthropic endpoint):") - fmt.Println() - fmt.Println(" claude-fable-5 Anthropic-compatible") - fmt.Println(" claude-opus-4-8 Anthropic-compatible") - fmt.Println(" claude-opus-4-7 Anthropic-compatible") - fmt.Println(" claude-opus-4-6 Anthropic-compatible") - fmt.Println(" claude-opus-4-5 Anthropic-compatible") - fmt.Println(" claude-opus-4-1 Anthropic-compatible") - fmt.Println(" claude-sonnet-4-6 Anthropic-compatible") - fmt.Println(" claude-sonnet-4-5 Anthropic-compatible") - fmt.Println(" claude-sonnet-4 Anthropic-compatible") - fmt.Println(" claude-haiku-4-5 Anthropic-compatible") - fmt.Println(" claude-3-5-haiku Anthropic-compatible") - fmt.Println() - fmt.Println("Available OpenCode Zen models (Responses endpoint):") - fmt.Println() - fmt.Println(" gpt-5.5 Responses-compatible") - fmt.Println(" gpt-5.5-pro Responses-compatible") - fmt.Println(" gpt-5.4 Responses-compatible") - fmt.Println(" gpt-5.4-pro Responses-compatible") - fmt.Println(" gpt-5.4-mini Responses-compatible") - fmt.Println(" gpt-5.4-nano Responses-compatible") - fmt.Println(" gpt-5.3-codex Responses-compatible") - fmt.Println(" gpt-5.3-codex-spark Responses-compatible") - fmt.Println(" gpt-5.2 Responses-compatible") - fmt.Println(" gpt-5.2-codex Responses-compatible") - fmt.Println(" gpt-5.1 Responses-compatible") - fmt.Println(" gpt-5.1-codex Responses-compatible") - fmt.Println(" gpt-5.1-codex-max Responses-compatible") - fmt.Println(" gpt-5.1-codex-mini Responses-compatible") - fmt.Println(" gpt-5 Responses-compatible") - fmt.Println(" gpt-5-codex Responses-compatible") - fmt.Println(" gpt-5-nano Responses-compatible") - fmt.Println() - fmt.Println("Available OpenCode Zen models (Gemini endpoint):") - fmt.Println() - fmt.Println(" gemini-3.5-flash Gemini-compatible") - fmt.Println(" gemini-3.1-pro Gemini-compatible") - fmt.Println(" gemini-3-flash Gemini-compatible") - fmt.Println() - fmt.Println("Use these model IDs in your config.json file (model_overrides).") + Short: "List available models from the catalog", + RunE: func(cmd *cobra.Command, args []string) error { + if configPath != "" { + _ = os.Setenv("ROUTATIC_PROXY_CONFIG", configPath) + } + + cfgPath := config.ResolveConfigPath() + cfg, err := config.LoadFromPath(cfgPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + catalogDir := resolveCatalogDir(cfgPath) + catalogPath := filepath.Join(catalogDir, "catalog.json") + cat, err := catalog.Load(catalogPath) + if err != nil { + return fmt.Errorf("catalog not found. Run 'routatic-proxy catalog sync' first") + } + + globalKeys := cfg.EffectiveAPIKeys() + providerKeys := map[string][]string{ + "opencode-go": cfg.OpenCodeGo.EffectiveAPIKeys(), + "opencode-zen": cfg.OpenCodeZen.EffectiveAPIKeys(), + "aws-bedrock": cfg.AWSBedrock.EffectiveAPIKeys(), + "openrouter": cfg.OpenRouter.EffectiveAPIKeys(), + } + + var enabled []string + for provider, keys := range providerKeys { + if len(keys) > 0 || len(globalKeys) > 0 { + enabled = append(enabled, provider) + } + } + + if len(enabled) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No enabled providers found.") + return nil + } + + sort.Strings(enabled) + + var lines []string + for _, provider := range enabled { + models := cat.ListProviderModels(provider) + if len(models) == 0 { + continue + } + ids := make([]string, len(models)) + for i, m := range models { + ids[i] = m.ModelID + } + sort.Strings(ids) + for _, id := range ids { + lines = append(lines, fmt.Sprintf("%s/%s", provider, id)) + } + } + + if len(lines) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No models found for enabled providers.") + return nil + } + + for _, line := range lines { + fmt.Fprintln(cmd.OutOrStdout(), line) + } + + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), "Use these model IDs in your config.json file (model_overrides).") + return nil }, } + cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file (used to locate the catalog directory)") + return cmd } // getConfigDir returns the default configuration directory path. diff --git a/internal/catalog/sync.go b/internal/catalog/sync.go index 6e20bc9..ab84bc5 100644 --- a/internal/catalog/sync.go +++ b/internal/catalog/sync.go @@ -50,7 +50,7 @@ func Sync(sourceURL, destDir string) (*Lock, error) { if err != nil { return nil, fmt.Errorf("fetch catalog: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode) diff --git a/internal/handlers/messages_test.go b/internal/handlers/messages_test.go index 5a113bc..355b054 100644 --- a/internal/handlers/messages_test.go +++ b/internal/handlers/messages_test.go @@ -794,6 +794,57 @@ func newStreamingTestHandler(t *testing.T, upstreamURL string) *MessagesHandler } } +func TestHandleMessages_UnknownProvider(t *testing.T) { + cfg := &config.Config{ + APIKey: "test-key", + Models: map[string]config.ModelConfig{ + "default": {Provider: "opencode-go", ModelID: "kimi-k2.6"}, + }, + Fallbacks: map[string][]config.ModelConfig{ + "default": {{Provider: "opencode-go", ModelID: "glm-5"}}, + }, + } + atomicCfg := config.NewAtomicConfig(cfg, "/tmp/test-config.json") + ocClient := client.NewOpenCodeClient(atomicCfg, nil) + modelRouter := router.NewModelRouter(atomicCfg) + tokenCounter, err := token.NewCounter() + if err != nil { + t.Fatalf("NewCounter: %v", err) + } + + handler := NewMessagesHandler( + ocClient, + nil, // providerRegistry + modelRouter, + nil, // fallbackHandler + tokenCounter, + metrics.New(), + nil, // captureLogger + nil, // hist + ) + handler.logger = slog.Default() + + requestBody := `{ + "model": "deepseek/deepseek-v4-flash@nonexistent-provider", + "max_tokens": 256, + "messages": [{"role": "user", "content": "Say hello"}] + }` + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(requestBody)) + req.Header.Set("Content-Type", "application/json") + + handler.HandleMessages(recorder, req) + + if recorder.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, recorder.Code) + } + body := recorder.Body.String() + if !strings.Contains(body, "nonexistent-provider") { + t.Errorf("expected body to contain provider string, got %q", body) + } +} + func TestHandleMessages_StreamingMinimaxM3_UsesAnthropicEndpoint(t *testing.T) { var capturedBody []byte upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From b6ef72e30ea20f061c255756ddf09b6b7c8deda5 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 17:44:38 +0200 Subject: [PATCH 11/25] fix: add missing period in error message for catalog not found --- cmd/routatic-proxy/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 7a26b40..d3f06f5 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -444,7 +444,7 @@ func modelsCmd() *cobra.Command { catalogPath := filepath.Join(catalogDir, "catalog.json") cat, err := catalog.Load(catalogPath) if err != nil { - return fmt.Errorf("catalog not found. Run 'routatic-proxy catalog sync' first") + return fmt.Errorf("catalog not found. Run 'routatic-proxy catalog sync' first.") } globalKeys := cfg.EffectiveAPIKeys() From 8264dd4743391fabe9fd114186a2e1e6604fb2ec Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 18:03:05 +0200 Subject: [PATCH 12/25] feat: enhance models command to support provider filtering and improve output --- cmd/routatic-proxy/main.go | 167 ++++++++++++++++++++++++------------- 1 file changed, 107 insertions(+), 60 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index d3f06f5..839fd60 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -426,81 +426,128 @@ func checkClaudeEnv(source string, env map[string]string, expectedURL string, an // modelsCmd returns the command to list available models. func modelsCmd() *cobra.Command { var configPath string + var provider string + cmd := &cobra.Command{ Use: "models", Short: "List available models from the catalog", + Long: `List available models from the catalog. + +Without a subcommand, all enabled providers are shown. Use "models list" to +filter by provider with the --provider flag.`, RunE: func(cmd *cobra.Command, args []string) error { - if configPath != "" { - _ = os.Setenv("ROUTATIC_PROXY_CONFIG", configPath) - } + return runModelsList(cmd, configPath, provider) + }, + } - cfgPath := config.ResolveConfigPath() - cfg, err := config.LoadFromPath(cfgPath) - if err != nil { - return fmt.Errorf("failed to load config: %w", err) - } + cmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "Path to config file (used to locate the catalog directory)") + cmd.PersistentFlags().StringVar(&provider, "provider", "", "Filter models by provider") - catalogDir := resolveCatalogDir(cfgPath) - catalogPath := filepath.Join(catalogDir, "catalog.json") - cat, err := catalog.Load(catalogPath) - if err != nil { - return fmt.Errorf("catalog not found. Run 'routatic-proxy catalog sync' first.") - } + cmd.AddCommand(modelsListCmd()) - globalKeys := cfg.EffectiveAPIKeys() - providerKeys := map[string][]string{ - "opencode-go": cfg.OpenCodeGo.EffectiveAPIKeys(), - "opencode-zen": cfg.OpenCodeZen.EffectiveAPIKeys(), - "aws-bedrock": cfg.AWSBedrock.EffectiveAPIKeys(), - "openrouter": cfg.OpenRouter.EffectiveAPIKeys(), - } + return cmd +} - var enabled []string - for provider, keys := range providerKeys { - if len(keys) > 0 || len(globalKeys) > 0 { - enabled = append(enabled, provider) - } - } +// modelsListCmd returns the "models list" subcommand. +func modelsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List available models from the catalog", + RunE: func(cmd *cobra.Command, args []string) error { + configPath, _ := cmd.Flags().GetString("config") + provider, _ := cmd.Flags().GetString("provider") + return runModelsList(cmd, configPath, provider) + }, + } +} - if len(enabled) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No enabled providers found.") - return nil - } +// runModelsList prints models from the catalog, optionally filtered by provider. +func runModelsList(cmd *cobra.Command, configPath, provider string) error { + if configPath != "" { + _ = os.Setenv("ROUTATIC_PROXY_CONFIG", configPath) + } - sort.Strings(enabled) + cfgPath := config.ResolveConfigPath() + cfg, err := config.LoadFromPath(cfgPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } - var lines []string - for _, provider := range enabled { - models := cat.ListProviderModels(provider) - if len(models) == 0 { - continue - } - ids := make([]string, len(models)) - for i, m := range models { - ids[i] = m.ModelID - } - sort.Strings(ids) - for _, id := range ids { - lines = append(lines, fmt.Sprintf("%s/%s", provider, id)) - } - } + catalogDir := resolveCatalogDir(cfgPath) + catalogPath := filepath.Join(catalogDir, "catalog.json") + cat, err := catalog.Load(catalogPath) + if err != nil { + return fmt.Errorf("catalog not found. Run 'routatic-proxy catalog sync' first.") + } - if len(lines) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No models found for enabled providers.") - return nil - } + providers := selectProviders(provider, cfg) + if len(providers) == 0 { + if provider != "" { + fmt.Fprintf(cmd.OutOrStdout(), "No models found for provider %q.\n", provider) + } else { + fmt.Fprintln(cmd.OutOrStdout(), "No enabled providers found.") + } + return nil + } - for _, line := range lines { - fmt.Fprintln(cmd.OutOrStdout(), line) - } + var lines []string + for _, p := range providers { + models := cat.ListProviderModels(p) + if len(models) == 0 { + continue + } + ids := make([]string, len(models)) + for i, m := range models { + ids[i] = m.ModelID + } + sort.Strings(ids) + for _, id := range ids { + lines = append(lines, fmt.Sprintf("%s/%s", p, id)) + } + } - fmt.Fprintln(cmd.OutOrStdout()) - fmt.Fprintln(cmd.OutOrStdout(), "Use these model IDs in your config.json file (model_overrides).") - return nil - }, + if len(lines) == 0 { + if provider != "" { + fmt.Fprintf(cmd.OutOrStdout(), "No models found for provider %q.\n", provider) + } else { + fmt.Fprintln(cmd.OutOrStdout(), "No models found for enabled providers.") + } + return nil } - cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file (used to locate the catalog directory)") - return cmd + + for _, line := range lines { + fmt.Fprintln(cmd.OutOrStdout(), line) + } + + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), "Use these model IDs in your config.json file (model_overrides).") + return nil +} + +// selectProviders returns the providers to display. If provider is non-empty, +// only that provider is returned when it exists in the catalog; otherwise all +// configured (enabled) providers are returned. +func selectProviders(provider string, cfg *config.Config) []string { + if provider != "" { + return []string{provider} + } + + globalKeys := cfg.EffectiveAPIKeys() + providerKeys := map[string][]string{ + "opencode-go": cfg.OpenCodeGo.EffectiveAPIKeys(), + "opencode-zen": cfg.OpenCodeZen.EffectiveAPIKeys(), + "aws-bedrock": cfg.AWSBedrock.EffectiveAPIKeys(), + "openrouter": cfg.OpenRouter.EffectiveAPIKeys(), + } + + var enabled []string + for p, keys := range providerKeys { + if len(keys) > 0 || len(globalKeys) > 0 { + enabled = append(enabled, p) + } + } + sort.Strings(enabled) + return enabled } // getConfigDir returns the default configuration directory path. From 56ed94cf755070c426ed31538ccc3ca91ce5f0c2 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 18:21:31 +0200 Subject: [PATCH 13/25] feat: add catalog synchronization and management features with corresponding UI updates and tests --- cmd/routatic-proxy/ui_darwin.go | 14 +-- internal/gui/assets/app.js | 61 ++++++++++++- internal/gui/assets/index.html | 8 ++ internal/gui/server.go | 98 +++++++++++++++++--- internal/gui/server_test.go | 156 ++++++++++++++++++++++++++++++++ 5 files changed, 319 insertions(+), 18 deletions(-) create mode 100644 internal/gui/server_test.go diff --git a/cmd/routatic-proxy/ui_darwin.go b/cmd/routatic-proxy/ui_darwin.go index b717c9f..fd82773 100644 --- a/cmd/routatic-proxy/ui_darwin.go +++ b/cmd/routatic-proxy/ui_darwin.go @@ -352,12 +352,14 @@ Use the tray icon to reopen the window or quit entirely.`, // ── 6. Start GUI HTTP server ──────────────────────────────── guiSrv = gui.New(gui.Options{ - History: proxySrv.History, - Metrics: proxySrv.Metrics(), - AtomicConfig: atomic, - ProxyPort: cfg.Port, - StartProxy: startProxy, - StopProxy: stopProxy, + History: proxySrv.History, + Metrics: proxySrv.Metrics(), + AtomicConfig: atomic, + ProxyPort: cfg.Port, + StartProxy: startProxy, + StopProxy: stopProxy, + CatalogDir: resolveCatalogDir(configPath), + CatalogSourceURL: cfg.Catalog.SourceURL, }) guiSrv.SetProxyRunning(proxyInitiallyStarted) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 1bc9835..2f32bf2 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -32,6 +32,9 @@ const TRANSLATIONS = { 'setting.notifyDesc': 'Notify on failures or model switches', 'setting.language': 'Language', 'setting.languageDesc': 'Switch interface language', + 'setting.catalog': 'Catalog', + 'setting.catalogNotSynced': 'Catalog not synced', + 'setting.catalogAge': 'Last synced: {age}', 'section.proxyConfig': 'Proxy Configuration', 'placeholder.envOrEmpty': 'Use env var or leave empty', 'placeholder.notSet': 'Not configured', @@ -39,6 +42,7 @@ const TRANSLATIONS = { 'label.host': 'Listen Address (Host)', 'label.port': 'Listen Port (Port)', 'btn.save': 'Save & Apply Config', + 'btn.refreshCatalog': 'Refresh catalog', 'status.saving': 'Saving…', 'status.saveOk': 'Config saved successfully!', 'status.saveFail': 'Save failed: ', @@ -82,6 +86,9 @@ const TRANSLATIONS = { 'setting.notifyDesc': '请求失败或切换模型时发送系统通知', 'setting.language': '语言', 'setting.languageDesc': '切换界面语言', + 'setting.catalog': '模型目录', + 'setting.catalogNotSynced': '模型目录未同步', + 'setting.catalogAge': '上次同步:{age}', 'section.proxyConfig': '服务代理配置', 'placeholder.envOrEmpty': '使用环境变量或留空', 'placeholder.notSet': '未配置', @@ -89,6 +96,7 @@ const TRANSLATIONS = { 'label.host': '监听地址 (Host)', 'label.port': '监听端口 (Port)', 'btn.save': '保存并应用配置', + 'btn.refreshCatalog': '刷新模型目录', 'status.saving': '保存中…', 'status.saveOk': '配置保存并应用成功!', 'status.saveFail': '保存失败: ', @@ -164,7 +172,7 @@ function startPolling() { } async function refreshAll() { - await Promise.all([refreshMetrics(), refreshHistory(), refreshConfig()]); + await Promise.all([refreshMetrics(), refreshHistory(), refreshConfig(), refreshCatalogAge()]); } // Debounced refresh for manual triggers (keyboard shortcuts) @@ -335,6 +343,46 @@ async function refreshConfig() { } catch(e) {} } +/* ── /api/catalog/lock & /api/catalog/sync ─────────────────────── */ +async function refreshCatalogAge() { + try { + const r = await fetch('/api/catalog/lock'); + if (!r.ok) return; + const d = await r.json(); + const el = document.getElementById('catalog-age'); + if (!el) return; + if (!d.synced) { + el.textContent = t('setting.catalogNotSynced'); + return; + } + el.textContent = t('setting.catalogAge').replace('{age}', fmtAge(d.age_seconds)); + } catch(e) {} +} + +async function refreshCatalog() { + const btn = document.getElementById('btn-refresh-catalog'); + if (btn) { + btn.disabled = true; + btn.textContent = currentLang === 'zh' ? '同步中…' : 'Syncing…'; + } + try { + const r = await fetch('/api/catalog/sync', { method: 'POST' }); + if (r.ok) { + await refreshCatalogAge(); + } else { + const txt = await r.text(); + console.error('Catalog refresh failed:', txt); + } + } catch(e) { + console.error('Catalog refresh network error:', e); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = t('btn.refreshCatalog'); + } + } +} + /* ── Toggle actions ────────────────────────────────────────────── */ async function toggleProxy(el) { el._changing = true; @@ -397,6 +445,17 @@ function fmtDuration(ms) { return (ms / 1000).toFixed(1) + ' s'; } +function fmtAge(seconds) { + if (seconds == null || seconds < 0) return '—'; + if (seconds < 60) return seconds + (currentLang === 'zh' ? ' 秒前' : ' seconds ago'); + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + (currentLang === 'zh' ? ' 分钟前' : ' minutes ago'); + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + (currentLang === 'zh' ? ' 小时前' : ' hours ago'); + const days = Math.floor(hours / 24); + return days + (currentLang === 'zh' ? ' 天前' : ' days ago'); +} + /* ── Proxy Config Form ─────────────────────────────────────────── */ let currentProxyConfig = null; diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 2c8842c..b5bd34f 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -196,6 +196,14 @@ Chinese + +
+
+
Catalog
+
Catalog not synced
+
+ +
diff --git a/internal/gui/server.go b/internal/gui/server.go index 5bdb786..7b27a26 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -16,7 +16,9 @@ import ( "runtime" "sync" "sync/atomic" + "time" + "github.com/routatic/proxy/internal/catalog" "github.com/routatic/proxy/internal/config" "github.com/routatic/proxy/internal/daemon" "github.com/routatic/proxy/internal/history" @@ -44,19 +46,24 @@ type Server struct { proxyPort int startProxy func() error stopProxy func() error + catalogDir string + catalogSourceURL string srv *http.Server logger *slog.Logger + catalogMu sync.Mutex } // Options configures the GUI server. type Options struct { - History *history.History - Metrics *metrics.Metrics - AtomicConfig *config.AtomicConfig - ProxyPort int - StartProxy func() error - StopProxy func() error - Logger *slog.Logger + History *history.History + Metrics *metrics.Metrics + AtomicConfig *config.AtomicConfig + ProxyPort int + StartProxy func() error + StopProxy func() error + CatalogDir string + CatalogSourceURL string + Logger *slog.Logger } // New creates a new GUI server. @@ -68,10 +75,12 @@ func New(opts Options) *Server { hist: opts.History, met: opts.Metrics, atomicCfg: opts.AtomicConfig, - proxyPort: opts.ProxyPort, - startProxy: opts.StartProxy, - stopProxy: opts.StopProxy, - logger: opts.Logger, + proxyPort: opts.ProxyPort, + startProxy: opts.StartProxy, + stopProxy: opts.StopProxy, + catalogDir: opts.CatalogDir, + catalogSourceURL: opts.CatalogSourceURL, + logger: opts.Logger, } // Check initial autostart state. s.cfg.Autostart = isAutostartEnabled() @@ -130,6 +139,8 @@ func (s *Server) Start(ctx context.Context) (string, error) { mux.HandleFunc("/api/proxy/config", s.handleProxyConfig) mux.HandleFunc("/api/proxy/start", s.handleProxyStart) mux.HandleFunc("/api/proxy/stop", s.handleProxyStop) + mux.HandleFunc("/api/catalog/lock", s.handleCatalogLock) + mux.HandleFunc("/api/catalog/sync", s.handleCatalogSync) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -369,6 +380,71 @@ func (s *Server) handleProxyConfig(w http.ResponseWriter, r *http.Request) { } } +type catalogLockResponse struct { + SyncedAt *time.Time `json:"synced_at,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Bytes int64 `json:"bytes,omitempty"` + TTLHours int `json:"ttl_hours,omitempty"` + AgeSeconds int64 `json:"age_seconds"` + Synced bool `json:"synced"` +} + +func (s *Server) handleCatalogLock(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + lock, err := catalog.ReadLock(s.catalogDir) + if err != nil { + writeJSON(w, catalogLockResponse{Synced: false, AgeSeconds: -1}) + return + } + + age := time.Since(lock.SyncedAt) + resp := catalogLockResponse{ + SyncedAt: &lock.SyncedAt, + SHA256: lock.SHA256, + Bytes: lock.Bytes, + TTLHours: lock.TTLHours, + AgeSeconds: int64(age.Seconds()), + Synced: true, + } + writeJSON(w, resp) +} + +func (s *Server) handleCatalogSync(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if s.catalogSourceURL == "" || s.catalogDir == "" { + http.Error(w, "catalog sync is not configured", http.StatusServiceUnavailable) + return + } + + // Serialize manual syncs so the lock file and on-disk catalog stay consistent. + s.catalogMu.Lock() + defer s.catalogMu.Unlock() + + lock, err := catalog.Sync(s.catalogSourceURL, s.catalogDir) + if err != nil { + http.Error(w, fmt.Sprintf("catalog sync failed: %v", err), http.StatusInternalServerError) + return + } + + age := time.Since(lock.SyncedAt) + writeJSON(w, catalogLockResponse{ + SyncedAt: &lock.SyncedAt, + SHA256: lock.SHA256, + Bytes: lock.Bytes, + TTLHours: lock.TTLHours, + AgeSeconds: int64(age.Seconds()), + Synced: true, + }) +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) diff --git a/internal/gui/server_test.go b/internal/gui/server_test.go new file mode 100644 index 0000000..7a639dd --- /dev/null +++ b/internal/gui/server_test.go @@ -0,0 +1,156 @@ +package gui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/routatic/proxy/internal/catalog" +) + +func TestHandleCatalogLock_NotSynced(t *testing.T) { + s := &Server{catalogDir: t.TempDir()} + + req := httptest.NewRequest(http.MethodGet, "/api/catalog/lock", nil) + rr := httptest.NewRecorder() + s.handleCatalogLock(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + var resp catalogLockResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.Synced { + t.Fatalf("synced = true, want false") + } + if resp.AgeSeconds != -1 { + t.Fatalf("age_seconds = %d, want -1", resp.AgeSeconds) + } + if resp.SyncedAt != nil { + t.Fatalf("synced_at unexpectedly set") + } +} + +func TestHandleCatalogLock_Synced(t *testing.T) { + dir := t.TempDir() + lock := &catalog.Lock{ + SourceURL: "https://example.com/catalog.json", + SyncedAt: time.Now().UTC().Add(-2 * time.Hour), + SHA256: "abc123", + Bytes: 1234, + TTLHours: 24, + } + if err := catalog.WriteLock(dir, lock); err != nil { + t.Fatalf("write lock: %v", err) + } + + s := &Server{catalogDir: dir} + req := httptest.NewRequest(http.MethodGet, "/api/catalog/lock", nil) + rr := httptest.NewRecorder() + s.handleCatalogLock(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + var resp catalogLockResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if !resp.Synced { + t.Fatalf("synced = false, want true") + } + if resp.SHA256 != lock.SHA256 { + t.Fatalf("sha256 = %q, want %q", resp.SHA256, lock.SHA256) + } + if resp.Bytes != lock.Bytes { + t.Fatalf("bytes = %d, want %d", resp.Bytes, lock.Bytes) + } + if resp.TTLHours != lock.TTLHours { + t.Fatalf("ttl_hours = %d, want %d", resp.TTLHours, lock.TTLHours) + } + if resp.AgeSeconds < 7199 || resp.AgeSeconds > 7201 { + t.Fatalf("age_seconds = %d, want ~7200", resp.AgeSeconds) + } +} + +func TestHandleCatalogSync_NotConfigured(t *testing.T) { + s := &Server{} + + req := httptest.NewRequest(http.MethodPost, "/api/catalog/sync", nil) + rr := httptest.NewRecorder() + s.handleCatalogSync(rr, req) + + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusServiceUnavailable) + } +} + +func TestHandleCatalogSync_Success(t *testing.T) { + body := `{"models":{"gpt-4":{"providers":["openai"]}},"providers":{"openai":{}}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + dir := t.TempDir() + s := &Server{catalogSourceURL: server.URL + "/catalog.json", catalogDir: dir} + + req := httptest.NewRequest(http.MethodPost, "/api/catalog/sync", nil) + rr := httptest.NewRecorder() + s.handleCatalogSync(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + var resp catalogLockResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if !resp.Synced { + t.Fatalf("synced = false, want true") + } + if resp.Bytes != int64(len(body)) { + t.Fatalf("bytes = %d, want %d", resp.Bytes, len(body)) + } + if resp.TTLHours != 24 { + t.Fatalf("ttl_hours = %d, want 24", resp.TTLHours) + } + + // Verify the catalog and lock were actually written. + if _, err := os.Stat(filepath.Join(dir, "catalog.json")); err != nil { + t.Fatalf("catalog.json not written: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "catalog.lock.json")); err != nil { + t.Fatalf("catalog.lock.json not written: %v", err) + } +} + +func TestHandleCatalogSync_UpstreamError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + defer server.Close() + + s := &Server{catalogSourceURL: server.URL + "/catalog.json", catalogDir: t.TempDir()} + + req := httptest.NewRequest(http.MethodPost, "/api/catalog/sync", nil) + rr := httptest.NewRecorder() + s.handleCatalogSync(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusInternalServerError) + } +} From 0411bc4de8b70ecf9c78c20f486ea9a0fb6a251b Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 18:28:13 +0200 Subject: [PATCH 14/25] fix: improve error messages and streamline output in models list command --- cmd/routatic-proxy/main.go | 16 ++++++++-------- internal/gui/server.go | 22 +++++++++++----------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 839fd60..0d512e0 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -477,15 +477,15 @@ func runModelsList(cmd *cobra.Command, configPath, provider string) error { catalogPath := filepath.Join(catalogDir, "catalog.json") cat, err := catalog.Load(catalogPath) if err != nil { - return fmt.Errorf("catalog not found. Run 'routatic-proxy catalog sync' first.") + return fmt.Errorf("catalog not found; run 'routatic-proxy catalog sync' first") } providers := selectProviders(provider, cfg) if len(providers) == 0 { if provider != "" { - fmt.Fprintf(cmd.OutOrStdout(), "No models found for provider %q.\n", provider) + cmd.Printf("No models found for provider %q.\n", provider) } else { - fmt.Fprintln(cmd.OutOrStdout(), "No enabled providers found.") + cmd.Println("No enabled providers found.") } return nil } @@ -508,19 +508,19 @@ func runModelsList(cmd *cobra.Command, configPath, provider string) error { if len(lines) == 0 { if provider != "" { - fmt.Fprintf(cmd.OutOrStdout(), "No models found for provider %q.\n", provider) + cmd.Printf("No models found for provider %q.\n", provider) } else { - fmt.Fprintln(cmd.OutOrStdout(), "No models found for enabled providers.") + cmd.Println("No models found for enabled providers.") } return nil } for _, line := range lines { - fmt.Fprintln(cmd.OutOrStdout(), line) + cmd.Println(line) } - fmt.Fprintln(cmd.OutOrStdout()) - fmt.Fprintln(cmd.OutOrStdout(), "Use these model IDs in your config.json file (model_overrides).") + cmd.Println() + cmd.Println("Use these model IDs in your config.json file (model_overrides).") return nil } diff --git a/internal/gui/server.go b/internal/gui/server.go index 7b27a26..a9a3c56 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -55,15 +55,15 @@ type Server struct { // Options configures the GUI server. type Options struct { - History *history.History - Metrics *metrics.Metrics - AtomicConfig *config.AtomicConfig - ProxyPort int - StartProxy func() error - StopProxy func() error - CatalogDir string + History *history.History + Metrics *metrics.Metrics + AtomicConfig *config.AtomicConfig + ProxyPort int + StartProxy func() error + StopProxy func() error + CatalogDir string CatalogSourceURL string - Logger *slog.Logger + Logger *slog.Logger } // New creates a new GUI server. @@ -72,9 +72,9 @@ func New(opts Options) *Server { opts.Logger = slog.Default() } s := &Server{ - hist: opts.History, - met: opts.Metrics, - atomicCfg: opts.AtomicConfig, + hist: opts.History, + met: opts.Metrics, + atomicCfg: opts.AtomicConfig, proxyPort: opts.ProxyPort, startProxy: opts.StartProxy, stopProxy: opts.StopProxy, From 2c0959b6f7dbb90a6b1cddda1ea1c895ad2f528d Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 19:01:25 +0200 Subject: [PATCH 15/25] feat: add tests for models list command with provider filtering and error handling --- cmd/routatic-proxy/main_models_test.go | 142 +++++++++ internal/router/selector.go | 204 +++++++++++++ internal/router/selector_test.go | 280 ++++++++++++++++++ .../router/testdata/selector_catalog.json | 137 +++++++++ 4 files changed, 763 insertions(+) create mode 100644 cmd/routatic-proxy/main_models_test.go create mode 100644 internal/router/selector.go create mode 100644 internal/router/selector_test.go create mode 100644 internal/router/testdata/selector_catalog.json diff --git a/cmd/routatic-proxy/main_models_test.go b/cmd/routatic-proxy/main_models_test.go new file mode 100644 index 0000000..b0adb63 --- /dev/null +++ b/cmd/routatic-proxy/main_models_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// catalogFixture is a minimal catalog with three providers and one model each. +const catalogFixture = `{ + "providers": { + "opencode-go": {"name": "opencode-go", "base_url": "https://opencode.ai/zen/go/v1/chat/completions"}, + "opencode-zen": {"name": "opencode-zen", "base_url": "https://opencode.ai/zen/v1/chat/completions"}, + "openrouter": {"name": "openrouter", "base_url": "https://openrouter.ai/api/v1"} + }, + "models": { + "model-go": {"name": "model-go", "providers": ["opencode-go"]}, + "model-zen": {"name": "model-zen", "providers": ["opencode-zen"]}, + "model-router": {"name": "model-router", "providers": ["openrouter"]} + } +}` + +func writeTestConfig(t *testing.T, dir, content string) string { + t.Helper() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func writeTestCatalog(t *testing.T, dir, content string) { + t.Helper() + catalogDir := filepath.Join(dir, "catalog") + if err := os.MkdirAll(catalogDir, 0755); err != nil { + t.Fatalf("mkdir catalog: %v", err) + } + if err := os.WriteFile(filepath.Join(catalogDir, "catalog.json"), []byte(content), 0644); err != nil { + t.Fatalf("write catalog: %v", err) + } +} + +func newCaptureCommand(t *testing.T) (*cobra.Command, *bytes.Buffer) { + t.Helper() + buf := &bytes.Buffer{} + cmd := &cobra.Command{Use: "routatic-proxy"} + cmd.SetOut(buf) + cmd.SetErr(buf) + return cmd, buf +} + +func TestRunModelsList_ProviderFilter(t *testing.T) { + tmp := t.TempDir() + configPath := writeTestConfig(t, tmp, `{"api_key": "test-global-key"}`) + writeTestCatalog(t, tmp, catalogFixture) + + cmd, buf := newCaptureCommand(t) + t.Setenv("ROUTATIC_PROXY_CONFIG", configPath) + + if err := runModelsList(cmd, configPath, "opencode-zen"); err != nil { + t.Fatalf("runModelsList error: %v", err) + } + + out := buf.String() + want := "opencode-zen/model-zen" + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + for _, unexpected := range []string{"opencode-go/model-go", "openrouter/model-router"} { + if strings.Contains(out, unexpected) { + t.Fatalf("output should not contain %q:\n%s", unexpected, out) + } + } + if !strings.Contains(out, "Use these model IDs") { + t.Fatalf("output missing usage footer:\n%s", out) + } +} + +func TestRunModelsList_EnabledProviders(t *testing.T) { + tmp := t.TempDir() + // A global API key enables every provider, so all catalog providers appear. + configPath := writeTestConfig(t, tmp, `{"api_key": "test-global-key"}`) + writeTestCatalog(t, tmp, catalogFixture) + + cmd, buf := newCaptureCommand(t) + t.Setenv("ROUTATIC_PROXY_CONFIG", configPath) + + if err := runModelsList(cmd, configPath, ""); err != nil { + t.Fatalf("runModelsList error: %v", err) + } + + out := buf.String() + for _, want := range []string{"opencode-go/model-go", "opencode-zen/model-zen", "openrouter/model-router"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } +} + +func TestRunModelsList_UnknownProvider(t *testing.T) { + tmp := t.TempDir() + configPath := writeTestConfig(t, tmp, `{"api_key": "test-global-key"}`) + writeTestCatalog(t, tmp, catalogFixture) + + cmd, buf := newCaptureCommand(t) + t.Setenv("ROUTATIC_PROXY_CONFIG", configPath) + + if err := runModelsList(cmd, configPath, "unknown"); err != nil { + t.Fatalf("runModelsList error: %v", err) + } + + out := buf.String() + want := `No models found for provider "unknown".` + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + if strings.Contains(out, "Use these model IDs") { + t.Fatalf("usage footer should not appear when no models are found:\n%s", out) + } +} + +func TestRunModelsList_MissingCatalog(t *testing.T) { + tmp := t.TempDir() + configPath := writeTestConfig(t, tmp, `{"api_key": "test-global-key"}`) + // Intentionally do not write catalog.json. + + cmd, _ := newCaptureCommand(t) + t.Setenv("ROUTATIC_PROXY_CONFIG", configPath) + + err := runModelsList(cmd, configPath, "") + if err == nil { + t.Fatal("expected error for missing catalog, got nil") + } + want := "catalog not found; run 'routatic-proxy catalog sync' first" + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected error containing %q, got: %v", want, err) + } +} diff --git a/internal/router/selector.go b/internal/router/selector.go new file mode 100644 index 0000000..bc5e3f6 --- /dev/null +++ b/internal/router/selector.go @@ -0,0 +1,204 @@ +package router + +import ( + "errors" + "fmt" + "sort" + + "github.com/routatic/proxy/internal/catalog" + "github.com/routatic/proxy/internal/config" +) + +// ScenarioConstraints filters candidate models by required capabilities. +type ScenarioConstraints struct { + Tools bool + Vision bool + Context int64 + Reasoning bool +} + +// Selector selects models from a catalog according to scenario requirements +// and runtime constraints such as enabled providers and API keys. +type Selector struct { + catalog *catalog.IndexedCatalog + enabledProviders map[string]bool + cfg *config.Config +} + +// NewSelector creates a Selector from an indexed catalog and active config. +// Providers are enabled when they have an effective API key in the config +// (either a global key or a provider-specific key) and are not explicitly +// disabled in the catalog. +func NewSelector(cat *catalog.IndexedCatalog, cfg *config.Config) *Selector { + if cfg == nil { + cfg = &config.Config{} + } + enabled := enabledProviders(cfg) + for name, p := range cat.Providers { + if p.Enabled != nil && !*p.Enabled { + delete(enabled, name) + } + } + return &Selector{ + catalog: cat, + enabledProviders: enabled, + cfg: cfg, + } +} + +// SelectCheapest returns the cheapest resolved model for the named scenario +// that satisfies both the scenario requirements and the supplied constraints. +// +// Candidates are sorted by total cost per million tokens ascending. Ties are +// broken by larger context window, then by model ID. +func (s *Selector) SelectCheapest(scenario string, constraints ScenarioConstraints) (catalog.ResolvedModel, error) { + scen, ok := s.catalog.Scenarios[scenario] + if !ok { + return catalog.ResolvedModel{}, fmt.Errorf("unknown scenario %q", scenario) + } + + candidates := s.resolveCandidates(scen, constraints) + if len(candidates) == 0 { + return catalog.ResolvedModel{}, fmt.Errorf("%w: scenario %q", ErrNoCandidateModel, scenario) + } + + sort.Slice(candidates, func(i, j int) bool { + a, b := candidates[i], candidates[j] + costA := a.CostInputPerM + a.CostOutputPerM + costB := b.CostInputPerM + b.CostOutputPerM + if costA != costB { + return costA < costB + } + if a.ContextWindow != b.ContextWindow { + return a.ContextWindow > b.ContextWindow + } + return a.ModelID < b.ModelID + }) + + return candidates[0], nil +} + +// resolveCandidates enumerates all enabled provider/model pairs for a scenario +// and returns the resolved models that match the scenario requirements and +// constraints. +func (s *Selector) resolveCandidates(scen catalog.Scenario, constraints ScenarioConstraints) []catalog.ResolvedModel { + providers := s.providerSet(scen) + minContext := scen.MinContextWindow + if constraints.Context > minContext { + minContext = constraints.Context + } + + var candidates []catalog.ResolvedModel + for providerName := range providers { + provider, ok := s.catalog.Providers[providerName] + if !ok { + continue + } + for modelKey, model := range s.catalog.Models { + if !modelSupportsProvider(model, providerName) { + continue + } + if !modelMatches(model, scen, constraints, minContext) { + continue + } + candidates = append(candidates, catalog.ResolvedModel{ + Provider: provider.Name, + ModelID: modelKey, + CanonicalName: modelKey, + DisplayName: model.DisplayName, + BaseURL: provider.BaseURL, + APIKey: provider.APIKey, + AnthropicToolsDisabled: provider.AnthropicToolsDisabled, + ContextWindow: model.ContextWindow, + CostInputPerM: model.CostInputPerM, + CostOutputPerM: model.CostOutputPerM, + Tools: model.Tools, + Vision: model.Vision, + Reasoning: model.Reasoning, + }) + } + } + return candidates +} + +// providerSet returns the enabled providers that should be considered for a +// scenario. When the scenario lists preferred providers, only those that are +// enabled are returned; otherwise all enabled providers are returned. +func (s *Selector) providerSet(scen catalog.Scenario) map[string]bool { + set := make(map[string]bool) + if len(scen.PreferredProviders) > 0 { + for _, p := range scen.PreferredProviders { + if s.enabledProviders[p] { + set[p] = true + } + } + return set + } + for p := range s.enabledProviders { + set[p] = true + } + return set +} + +func modelSupportsProvider(model catalog.Model, provider string) bool { + for _, p := range model.Providers { + if p == provider { + return true + } + } + return false +} + +func modelMatches(model catalog.Model, scen catalog.Scenario, constraints ScenarioConstraints, minContext int64) bool { + if model.ContextWindow < minContext { + return false + } + if scen.RequiresTools != nil && *scen.RequiresTools && !model.Tools { + return false + } + if scen.RequiresVision != nil && *scen.RequiresVision && !model.Vision { + return false + } + if scen.RequiresReasoning != nil && *scen.RequiresReasoning && !model.Reasoning { + return false + } + if constraints.Tools && !model.Tools { + return false + } + if constraints.Vision && !model.Vision { + return false + } + if constraints.Reasoning && !model.Reasoning { + return false + } + return true +} + +// enabledProviders returns the providers that have an effective API key in the +// active config. A non-empty global API key enables all known providers. +func enabledProviders(cfg *config.Config) map[string]bool { + enabled := make(map[string]bool) + globalKeys := cfg.EffectiveAPIKeys() + providerKeys := map[string][]string{ + "opencode-go": cfg.OpenCodeGo.EffectiveAPIKeys(), + "opencode-zen": cfg.OpenCodeZen.EffectiveAPIKeys(), + "aws-bedrock": cfg.AWSBedrock.EffectiveAPIKeys(), + "openrouter": cfg.OpenRouter.EffectiveAPIKeys(), + } + for p, keys := range providerKeys { + if len(keys) > 0 || len(globalKeys) > 0 { + enabled[p] = true + } + } + return enabled +} + +// IsEnabledProvider reports whether the named provider is enabled in the +// selector's runtime configuration. +func (s *Selector) IsEnabledProvider(provider string) bool { + return s.enabledProviders[provider] +} + +// ErrNoCandidateModel is returned when SelectCheapest cannot find a model that +// matches the scenario and constraints. +var ErrNoCandidateModel = errors.New("no candidate model matches scenario and constraints") diff --git a/internal/router/selector_test.go b/internal/router/selector_test.go new file mode 100644 index 0000000..68b5067 --- /dev/null +++ b/internal/router/selector_test.go @@ -0,0 +1,280 @@ +package router + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/routatic/proxy/internal/catalog" + "github.com/routatic/proxy/internal/config" +) + +// selectorTestCatalog loads the shared fixture catalog used by selector tests. +func selectorTestCatalog(t *testing.T) *catalog.IndexedCatalog { + t.Helper() + cat, err := catalog.Load(filepath.Join("testdata", "selector_catalog.json")) + if err != nil { + t.Fatalf("load selector catalog: %v", err) + } + return cat +} + +func TestSelectCheapest_SelectsCheapestModel(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("default", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + want := "cheap-no-tools" + if got.ModelID != want { + t.Errorf("SelectCheapest(default) = %q, want %q", got.ModelID, want) + } + if got.Provider != "opencode-go" { + t.Errorf("SelectCheapest(default) provider = %q, want %q", got.Provider, "opencode-go") + } + if got.CostInputPerM+got.CostOutputPerM != 2.0 { + t.Errorf("SelectCheapest(default) total cost = %v, want 2.0", got.CostInputPerM+got.CostOutputPerM) + } +} + +func TestSelectCheapest_FiltersByToolsConstraint(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("default", ScenarioConstraints{Tools: true}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + want := "cheap-tools" + if got.ModelID != want { + t.Errorf("SelectCheapest(default, tools) = %q, want %q", got.ModelID, want) + } + if !got.Tools { + t.Errorf("SelectCheapest(default, tools).Tools = false, want true") + } +} + +func TestSelectCheapest_FiltersByVisionConstraint(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("default", ScenarioConstraints{Vision: true}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + want := "vision-model" + if got.ModelID != want { + t.Errorf("SelectCheapest(default, vision) = %q, want %q", got.ModelID, want) + } + if !got.Vision { + t.Errorf("SelectCheapest(default, vision).Vision = false, want true") + } +} + +func TestSelectCheapest_FiltersByReasoningConstraint(t *testing.T) { + cfg := &config.Config{ + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("default", ScenarioConstraints{Reasoning: true}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + want := "reasoning-model" + if got.ModelID != want { + t.Errorf("SelectCheapest(default, reasoning) = %q, want %q", got.ModelID, want) + } + if !got.Reasoning { + t.Errorf("SelectCheapest(default, reasoning).Reasoning = false, want true") + } +} + +func TestSelectCheapest_FiltersByContextConstraint(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("default", ScenarioConstraints{Context: 500000}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + want := "large-context" + if got.ModelID != want { + t.Errorf("SelectCheapest(default, context=500000) = %q, want %q", got.ModelID, want) + } + if got.ContextWindow < 500000 { + t.Errorf("SelectCheapest(default, context=500000).ContextWindow = %d, want >= 500000", got.ContextWindow) + } +} + +func TestSelectCheapest_FiltersByScenarioRequirements(t *testing.T) { + cfg := &config.Config{ + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + tests := []struct { + scenario string + want string + }{ + {"vision_required", "vision-model"}, + {"reasoning_required", "reasoning-model"}, + } + + for _, tt := range tests { + t.Run(tt.scenario, func(t *testing.T) { + got, err := selector.SelectCheapest(tt.scenario, ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + if got.ModelID != tt.want { + t.Errorf("SelectCheapest(%q) = %q, want %q", tt.scenario, got.ModelID, tt.want) + } + }) + } +} + +func TestSelectCheapest_EnabledProvidersOnly(t *testing.T) { + cat := selectorTestCatalog(t) + + tests := []struct { + name string + cfg *config.Config + scenario string + constraints ScenarioConstraints + want string + wantErr bool + }{ + { + name: "provider-specific key enables only that provider", + cfg: &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + }, + scenario: "default", + constraints: ScenarioConstraints{}, + want: "cheap-no-tools", + }, + { + name: "openrouter-only key excludes opencode-go models", + cfg: &config.Config{ + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + }, + scenario: "default", + constraints: ScenarioConstraints{}, + want: "large-context", + }, + { + name: "global key enables all providers", + cfg: &config.Config{ + APIKey: "global-key", + }, + scenario: "default", + constraints: ScenarioConstraints{}, + want: "cheap-no-tools", + }, + { + name: "disabled catalog provider is ignored even with key", + cfg: &config.Config{ + APIKeys: []string{"global-key"}, + }, + scenario: "default", + constraints: ScenarioConstraints{}, + want: "cheap-no-tools", + }, + { + name: "no keys disables all providers", + cfg: &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{}, + OpenRouter: config.OpenRouterConfig{}, + }, + scenario: "default", + constraints: ScenarioConstraints{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selector := NewSelector(cat, tt.cfg) + got, err := selector.SelectCheapest(tt.scenario, tt.constraints) + if tt.wantErr { + if err == nil { + t.Fatalf("SelectCheapest expected error, got model %q", got.ModelID) + } + return + } + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + if got.ModelID != tt.want { + t.Errorf("SelectCheapest(%q) = %q, want %q", tt.scenario, got.ModelID, tt.want) + } + }) + } +} + +func TestSelectCheapest_PreferredProvidersFilter(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("preferred_only", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + // openrouter models only, cheapest is large-context at cost 3.0 + if got.Provider != "openrouter" { + t.Errorf("SelectCheapest(preferred_only) provider = %q, want %q", got.Provider, "openrouter") + } + if got.ModelID != "large-context" { + t.Errorf("SelectCheapest(preferred_only) = %q, want %q", got.ModelID, "large-context") + } +} + +func TestSelectCheapest_NoCandidateReturnsError(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + _, err := selector.SelectCheapest("default", ScenarioConstraints{Reasoning: true}) + if err == nil { + t.Fatal("SelectCheapest expected error for unmatched constraints, got nil") + } + if !errors.Is(err, ErrNoCandidateModel) { + t.Errorf("SelectCheapest error = %v, want ErrNoCandidateModel", err) + } +} + +func TestSelectCheapest_UnknownScenarioReturnsError(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + _, err := selector.SelectCheapest("does-not-exist", ScenarioConstraints{}) + if err == nil { + t.Fatal("SelectCheapest expected error for unknown scenario, got nil") + } +} diff --git a/internal/router/testdata/selector_catalog.json b/internal/router/testdata/selector_catalog.json new file mode 100644 index 0000000..d89b616 --- /dev/null +++ b/internal/router/testdata/selector_catalog.json @@ -0,0 +1,137 @@ +{ + "providers": { + "opencode-go": { + "name": "opencode-go", + "base_url": "https://go.opencode.ai/v1", + "api_key": "go-key", + "enabled": true + }, + "openrouter": { + "name": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "or-key", + "enabled": true + }, + "disabled-provider": { + "name": "disabled-provider", + "base_url": "https://disabled.example/v1", + "api_key": "disabled-key", + "enabled": false + } + }, + "models": { + "cheap-no-tools": { + "name": "cheap-no-tools", + "display_name": "Cheap No Tools", + "providers": ["opencode-go"], + "context_window": 128000, + "cost_input_per_m": 0.5, + "cost_output_per_m": 1.5, + "tools": false, + "vision": false, + "reasoning": false + }, + "cheap-tools": { + "name": "cheap-tools", + "display_name": "Cheap Tools", + "providers": ["opencode-go"], + "context_window": 128000, + "cost_input_per_m": 0.5, + "cost_output_per_m": 1.5, + "tools": true, + "vision": false, + "reasoning": false + }, + "vision-model": { + "name": "vision-model", + "display_name": "Vision Model", + "providers": ["openrouter"], + "context_window": 256000, + "cost_input_per_m": 2.0, + "cost_output_per_m": 6.0, + "tools": true, + "vision": true, + "reasoning": false + }, + "reasoning-model": { + "name": "reasoning-model", + "display_name": "Reasoning Model", + "providers": ["openrouter"], + "context_window": 200000, + "cost_input_per_m": 3.0, + "cost_output_per_m": 9.0, + "tools": true, + "vision": false, + "reasoning": true + }, + "large-context": { + "name": "large-context", + "display_name": "Large Context", + "providers": ["opencode-go", "openrouter"], + "context_window": 1000000, + "cost_input_per_m": 1.0, + "cost_output_per_m": 2.0, + "tools": true, + "vision": false, + "reasoning": false + }, + "tie-small-context": { + "name": "tie-small-context", + "display_name": "Tie Small Context", + "providers": ["opencode-go"], + "context_window": 128000, + "cost_input_per_m": 1.0, + "cost_output_per_m": 2.0, + "tools": true, + "vision": false, + "reasoning": false + }, + "only-disabled": { + "name": "only-disabled", + "display_name": "Only Disabled", + "providers": ["disabled-provider"], + "context_window": 128000, + "cost_input_per_m": 0.1, + "cost_output_per_m": 0.1, + "tools": false, + "vision": false, + "reasoning": false + } + }, + "scenarios": { + "default": { + "name": "default", + "description": "Default scenario with no special requirements", + "min_context_window": 0 + }, + "tools_required": { + "name": "tools_required", + "description": "Scenario that requires tool support", + "requires_tools": true, + "min_context_window": 0 + }, + "vision_required": { + "name": "vision_required", + "description": "Scenario that requires vision support", + "requires_vision": true, + "min_context_window": 0 + }, + "reasoning_required": { + "name": "reasoning_required", + "description": "Scenario that requires reasoning support", + "requires_reasoning": true, + "min_context_window": 0 + }, + "long_context": { + "name": "long_context", + "description": "Scenario that requires a large context window", + "min_context_window": 500000 + }, + "preferred_only": { + "name": "preferred_only", + "description": "Scenario that prefers a specific provider", + "preferred_providers": ["openrouter"], + "min_context_window": 0 + } + } +} From b6ecfdfa53d51240708a232d1dd3c0897fcd8adc Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 19:11:01 +0200 Subject: [PATCH 16/25] feat: add cost-based routing support and enhance model resolution logic --- internal/config/config.go | 1 + internal/router/model_router.go | 103 +++++++++++++++++++++++++++----- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 1350970..8479887 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ type Config struct { Port int `json:"port"` HotReload bool `json:"hot_reload"` EnableStreamingScenarioRouting bool `json:"enable_streaming_scenario_routing"` + EnableCostBasedRouting bool `json:"enable_cost_based_routing"` RespectRequestedModel *bool `json:"respect_requested_model,omitempty"` Models map[string]ModelConfig `json:"models"` Fallbacks map[string][]ModelConfig `json:"fallbacks"` diff --git a/internal/router/model_router.go b/internal/router/model_router.go index 3fc1155..987c09f 100644 --- a/internal/router/model_router.go +++ b/internal/router/model_router.go @@ -5,6 +5,7 @@ package router import ( "errors" "fmt" + "strings" "sync" "github.com/routatic/proxy/internal/catalog" @@ -118,6 +119,59 @@ func (r *ModelRouter) resolveRequestedModel(cfg *config.Config, requestedModel s }, true, nil } +// resolvedModelToConfig converts a catalog resolved model into a runtime +// ModelConfig used by the router. +func resolvedModelToConfig(resolved catalog.ResolvedModel) config.ModelConfig { + supportsTools := resolved.Tools + return config.ModelConfig{ + Provider: resolved.Provider, + ModelID: resolved.ModelID, + ModelRef: resolved.CanonicalName, + Vision: resolved.Vision, + ContextWindow: int(resolved.ContextWindow), + SupportsTools: &supportsTools, + } +} + +// requestConstraints maps request-level requirements to scenario constraints +// used by the cost-based selector. +func requestConstraints(messages []MessageContent, tokenCount int) ScenarioConstraints { + facts := AnalyzeRequestFacts(messages) + constraints := ScenarioConstraints{ + Vision: facts.NeedsVision, + Context: int64(tokenCount), + } + latest := latestUserMessages(messages) + if hasThinkingPattern(latest) { + constraints.Reasoning = true + } + if hasToolUsage(messages) { + constraints.Tools = true + } + return constraints +} + +// hasToolUsage reports whether the request likely requires tool support based +// on message roles or tool-related keywords. +func hasToolUsage(messages []MessageContent) bool { + toolKeywords := []string{ + "tool", "function", "execute", "run command", + "bash", "shell", "python", + } + for _, msg := range messages { + if msg.Role == "tool" || msg.Role == "function" { + return true + } + lower := strings.ToLower(msg.Content) + for _, kw := range toolKeywords { + if strings.Contains(lower, kw) { + return true + } + } + } + return false +} + // resolveFromCatalog attempts to resolve a requested model string through the // catalog. It returns the model config and true on success, otherwise false. func (r *ModelRouter) resolveFromCatalog(cat *catalog.IndexedCatalog, requestedModel string, sel catalog.Selector) (config.ModelConfig, bool) { @@ -132,15 +186,9 @@ func (r *ModelRouter) resolveFromCatalog(cat *catalog.IndexedCatalog, requestedM return config.ModelConfig{}, false } - supportsTools := resolved.Tools - return config.ModelConfig{ - Provider: resolved.Provider, - ModelID: resolved.ModelID, - ModelRef: requestedModel, - Vision: resolved.Vision, - ContextWindow: int(resolved.ContextWindow), - SupportsTools: &supportsTools, - }, true + cfg := resolvedModelToConfig(resolved) + cfg.ModelRef = requestedModel + return cfg, true } // legacyUnknownModelConfig builds a bare config for an unknown model and @@ -171,9 +219,21 @@ func (r *ModelRouter) Route(messages []MessageContent, tokenCount int, requested // Otherwise, use scenario-based routing result := DetectScenario(messages, tokenCount, cfg) + scenarioKey := string(result.Scenario) + + // Get primary model for scenario. When cost-based routing is enabled and + // a non-empty catalog is available, prefer the cheapest matching catalog + // model while preserving the legacy fallback chain. + primary, ok := cfg.Models[scenarioKey] + if cat, catErr := r.catalog(); cfg.EnableCostBasedRouting && cat != nil && catErr == nil && len(cat.Models) > 0 { + constraints := requestConstraints(messages, tokenCount) + selector := NewSelector(cat, cfg) + if resolved, err := selector.SelectCheapest(scenarioKey, constraints); err == nil { + primary = resolvedModelToConfig(resolved) + ok = true + } + } - // Get primary model for scenario - primary, ok := cfg.Models[string(result.Scenario)] if !ok { if isVisionScenario(result.Scenario) { return RouteResult{}, fmt.Errorf("vision scenario %s is not configured", result.Scenario) @@ -186,7 +246,7 @@ func (r *ModelRouter) Route(messages []MessageContent, tokenCount int, requested } // Get fallbacks for scenario - fallbacks := cfg.Fallbacks[string(result.Scenario)] + fallbacks := cfg.Fallbacks[scenarioKey] if len(fallbacks) == 0 { if isVisionScenario(result.Scenario) { return RouteResult{}, fmt.Errorf("vision scenario %s has no configured vision fallbacks", result.Scenario) @@ -259,9 +319,20 @@ func (r *ModelRouter) RouteForStreaming(messages []MessageContent, tokenCount in // Otherwise, use scenario-based routing for streaming result := RouteForStreaming(messages, tokenCount, cfg) - - // Get primary model for scenario - primary, ok := cfg.Models[string(result.Scenario)] + scenarioKey := string(result.Scenario) + + // Get primary model for scenario. When cost-based routing is enabled and + // a non-empty catalog is available, prefer the cheapest matching catalog + // model while preserving the legacy fallback chain. + primary, ok := cfg.Models[scenarioKey] + if cat, catErr := r.catalog(); cfg.EnableCostBasedRouting && cat != nil && catErr == nil && len(cat.Models) > 0 { + constraints := requestConstraints(messages, tokenCount) + selector := NewSelector(cat, cfg) + if resolved, err := selector.SelectCheapest(scenarioKey, constraints); err == nil { + primary = resolvedModelToConfig(resolved) + ok = true + } + } if !ok { if isVisionScenario(result.Scenario) { return RouteResult{Scenario: result.Scenario}, fmt.Errorf("vision scenario %s is not configured", result.Scenario) @@ -278,7 +349,7 @@ func (r *ModelRouter) RouteForStreaming(messages []MessageContent, tokenCount in } // Get fallbacks for scenario - fallbacks := cfg.Fallbacks[string(result.Scenario)] + fallbacks := cfg.Fallbacks[scenarioKey] if len(fallbacks) == 0 { if isVisionScenario(result.Scenario) { fallbacks = nil From 3754f0766dfceaebbb2ac824a72fc2a00f09bc37 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 19:24:08 +0200 Subject: [PATCH 17/25] feat: implement cost-based routing with configuration and tests --- internal/config/config.go | 25 +++ internal/config/config_test.go | 149 ++++++++++++++++++ internal/router/model_router.go | 4 +- internal/router/model_router_test.go | 93 +++++++++++ .../router/testdata/selector_catalog.json | 24 +++ 5 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 internal/config/config_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 8479887..ac50c83 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ type Config struct { HotReload bool `json:"hot_reload"` EnableStreamingScenarioRouting bool `json:"enable_streaming_scenario_routing"` EnableCostBasedRouting bool `json:"enable_cost_based_routing"` + CostRouting *CostRoutingConfig `json:"cost_routing,omitempty"` RespectRequestedModel *bool `json:"respect_requested_model,omitempty"` Models map[string]ModelConfig `json:"models"` Fallbacks map[string][]ModelConfig `json:"fallbacks"` @@ -26,6 +27,30 @@ type Config struct { Catalog CatalogConfig `json:"catalog"` } +// CostRoutingConfig controls cost-aware model selection. +type CostRoutingConfig struct { + Enabled bool `json:"enabled"` + PreferProviders []string `json:"prefer_providers,omitempty"` + MaxContextWindow int64 `json:"max_context_window,omitempty"` + PenaltyPerProvider map[string]float64 `json:"penalty_per_provider,omitempty"` +} + +// CostBasedRoutingEnabled reports whether cost-aware routing should be active. +// It is enabled when either the legacy top-level flag is set or the nested +// cost_routing block explicitly enables it. +func (c *Config) CostBasedRoutingEnabled() bool { + if c == nil { + return false + } + if c.EnableCostBasedRouting { + return true + } + if c.CostRouting != nil && c.CostRouting.Enabled { + return true + } + return false +} + // AnthropicFirstConfig controls direct Anthropic passthrough with OpenCode fallback. type AnthropicFirstConfig struct { Enabled bool `json:"enabled"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2a83784 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,149 @@ +package config + +import ( + "encoding/json" + "testing" +) + +func TestCostBasedRoutingEnabled_DefaultFalse(t *testing.T) { + cfg := &Config{} + if cfg.CostBasedRoutingEnabled() { + t.Error("CostBasedRoutingEnabled() = true, want false for zero value Config") + } +} + +func TestCostBasedRoutingEnabled_TopLevelTrue(t *testing.T) { + cfg := &Config{EnableCostBasedRouting: true} + if !cfg.CostBasedRoutingEnabled() { + t.Error("CostBasedRoutingEnabled() = false, want true when EnableCostBasedRouting is true") + } +} + +func TestCostBasedRoutingEnabled_NestedTrue(t *testing.T) { + cfg := &Config{ + CostRouting: &CostRoutingConfig{Enabled: true}, + } + if !cfg.CostBasedRoutingEnabled() { + t.Error("CostBasedRoutingEnabled() = false, want true when CostRouting.Enabled is true") + } +} + +func TestCostBasedRoutingEnabled_BothTrue(t *testing.T) { + cfg := &Config{ + EnableCostBasedRouting: true, + CostRouting: &CostRoutingConfig{Enabled: true}, + } + if !cfg.CostBasedRoutingEnabled() { + t.Error("CostBasedRoutingEnabled() = false, want true when both flags are true") + } +} + +func TestCostBasedRoutingEnabled_NestedFalseTopLevelTrue(t *testing.T) { + cfg := &Config{ + EnableCostBasedRouting: true, + CostRouting: &CostRoutingConfig{Enabled: false}, + } + if !cfg.CostBasedRoutingEnabled() { + t.Error("CostBasedRoutingEnabled() = false, want true when top-level flag is true even if nested is false") + } +} + +func TestCostRoutingConfig_Parsing(t *testing.T) { + raw := `{ + "enabled": true, + "prefer_providers": ["openrouter", "aws_bedrock"], + "max_context_window": 128000, + "penalty_per_provider": { + "opencode-go": 0.1, + "openrouter": 0.05 + } + }` + + var crc CostRoutingConfig + if err := json.Unmarshal([]byte(raw), &crc); err != nil { + t.Fatalf("failed to unmarshal CostRoutingConfig: %v", err) + } + + if !crc.Enabled { + t.Error("Enabled = false, want true") + } + wantProviders := []string{"openrouter", "aws_bedrock"} + if len(crc.PreferProviders) != len(wantProviders) { + t.Fatalf("PreferProviders = %v, want %v", crc.PreferProviders, wantProviders) + } + for i, p := range wantProviders { + if crc.PreferProviders[i] != p { + t.Errorf("PreferProviders[%d] = %q, want %q", i, crc.PreferProviders[i], p) + } + } + if crc.MaxContextWindow != 128000 { + t.Errorf("MaxContextWindow = %d, want 128000", crc.MaxContextWindow) + } + if len(crc.PenaltyPerProvider) != 2 { + t.Fatalf("PenaltyPerProvider = %v, want 2 entries", crc.PenaltyPerProvider) + } + if crc.PenaltyPerProvider["opencode-go"] != 0.1 { + t.Errorf("PenaltyPerProvider[opencode-go] = %v, want 0.1", crc.PenaltyPerProvider["opencode-go"]) + } + if crc.PenaltyPerProvider["openrouter"] != 0.05 { + t.Errorf("PenaltyPerProvider[openrouter] = %v, want 0.05", crc.PenaltyPerProvider["openrouter"]) + } +} + +func TestConfig_CostRoutingField_Parsing(t *testing.T) { + raw := `{ + "api_key": "test-key", + "enable_cost_based_routing": false, + "cost_routing": { + "enabled": true, + "prefer_providers": ["openrouter"] + } + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("failed to unmarshal Config: %v", err) + } + + if cfg.CostRouting == nil { + t.Fatal("CostRouting = nil, want non-nil") + } + if !cfg.CostRouting.Enabled { + t.Error("CostRouting.Enabled = false, want true") + } + if len(cfg.CostRouting.PreferProviders) != 1 || cfg.CostRouting.PreferProviders[0] != "openrouter" { + t.Errorf("CostRouting.PreferProviders = %v, want [openrouter]", cfg.CostRouting.PreferProviders) + } + if !cfg.CostBasedRoutingEnabled() { + t.Error("CostBasedRoutingEnabled() = false, want true") + } +} + +func TestConfig_CostRoutingOmitted(t *testing.T) { + raw := `{"api_key": "test-key", "enable_cost_based_routing": true}` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("failed to unmarshal Config: %v", err) + } + + if cfg.CostRouting != nil { + t.Errorf("CostRouting = %v, want nil when omitted from JSON", cfg.CostRouting) + } + if !cfg.CostBasedRoutingEnabled() { + t.Error("CostBasedRoutingEnabled() = false, want true via legacy flag") + } +} + +func TestConfig_CostRoutingDisabled(t *testing.T) { + raw := `{"api_key": "test-key", "cost_routing": {"enabled": false}}` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("failed to unmarshal Config: %v", err) + } + + if cfg.CostBasedRoutingEnabled() { + t.Error("CostBasedRoutingEnabled() = true, want false when cost_routing.enabled is false") + } +} diff --git a/internal/router/model_router.go b/internal/router/model_router.go index 987c09f..4f4e526 100644 --- a/internal/router/model_router.go +++ b/internal/router/model_router.go @@ -225,7 +225,7 @@ func (r *ModelRouter) Route(messages []MessageContent, tokenCount int, requested // a non-empty catalog is available, prefer the cheapest matching catalog // model while preserving the legacy fallback chain. primary, ok := cfg.Models[scenarioKey] - if cat, catErr := r.catalog(); cfg.EnableCostBasedRouting && cat != nil && catErr == nil && len(cat.Models) > 0 { + if cat, catErr := r.catalog(); cfg.CostBasedRoutingEnabled() && cat != nil && catErr == nil && len(cat.Models) > 0 { constraints := requestConstraints(messages, tokenCount) selector := NewSelector(cat, cfg) if resolved, err := selector.SelectCheapest(scenarioKey, constraints); err == nil { @@ -325,7 +325,7 @@ func (r *ModelRouter) RouteForStreaming(messages []MessageContent, tokenCount in // a non-empty catalog is available, prefer the cheapest matching catalog // model while preserving the legacy fallback chain. primary, ok := cfg.Models[scenarioKey] - if cat, catErr := r.catalog(); cfg.EnableCostBasedRouting && cat != nil && catErr == nil && len(cat.Models) > 0 { + if cat, catErr := r.catalog(); cfg.CostBasedRoutingEnabled() && cat != nil && catErr == nil && len(cat.Models) > 0 { constraints := requestConstraints(messages, tokenCount) selector := NewSelector(cat, cfg) if resolved, err := selector.SelectCheapest(scenarioKey, constraints); err == nil { diff --git a/internal/router/model_router_test.go b/internal/router/model_router_test.go index 73a0191..1dfd430 100644 --- a/internal/router/model_router_test.go +++ b/internal/router/model_router_test.go @@ -748,6 +748,99 @@ func TestRoute_ModelOverridesPrecedence(t *testing.T) { } } +func TestCostBasedRouting_SelectsCheapest(t *testing.T) { + catalogPath := filepath.Join("testdata", "selector_catalog.json") + cfg := &config.Config{ + APIKey: "global-key", + EnableCostBasedRouting: true, + Models: map[string]config.ModelConfig{ + "default": {Provider: "opencode-go", ModelID: "legacy-default"}, + "complex": {Provider: "opencode-go", ModelID: "legacy-complex"}, + }, + } + atomic := config.NewAtomicConfig(cfg, "/tmp/test-config.json") + router := NewModelRouterWithCatalog(atomic, catalogPath) + + result, err := router.Route([]MessageContent{{Role: "user", Content: "Hello"}}, 100, "") + if err != nil { + t.Fatalf("Route failed: %v", err) + } + if result.Primary.ModelID != "cheap-no-tools" { + t.Errorf("default scenario: expected cheap-no-tools, got %s", result.Primary.ModelID) + } + + complex, err := router.Route([]MessageContent{{Role: "user", Content: "Architect a new microservice"}}, 100, "") + if err != nil { + t.Fatalf("Route failed: %v", err) + } + if complex.Primary.ModelID != "large-context" { + t.Errorf("complex scenario: expected large-context, got %s", complex.Primary.ModelID) + } +} + +func TestCostBasedRouting_DisabledUsesLegacy(t *testing.T) { + catalogPath := filepath.Join("testdata", "selector_catalog.json") + cfg := &config.Config{ + APIKey: "global-key", + EnableCostBasedRouting: false, + Models: map[string]config.ModelConfig{ + "default": {Provider: "opencode-go", ModelID: "legacy-default"}, + }, + } + atomic := config.NewAtomicConfig(cfg, "/tmp/test-config.json") + router := NewModelRouterWithCatalog(atomic, catalogPath) + + result, err := router.Route([]MessageContent{{Role: "user", Content: "Hello"}}, 100, "") + if err != nil { + t.Fatalf("Route failed: %v", err) + } + if result.Primary.ModelID != "legacy-default" { + t.Errorf("expected legacy-default, got %s", result.Primary.ModelID) + } +} + +func TestCostBasedRouting_FallsBackWhenNoMatch(t *testing.T) { + catalogPath := filepath.Join("testdata", "selector_catalog.json") + cfg := &config.Config{ + APIKey: "global-key", + EnableCostBasedRouting: true, + Models: map[string]config.ModelConfig{ + "background": {Provider: "opencode-go", ModelID: "legacy-background"}, + }, + } + atomic := config.NewAtomicConfig(cfg, "/tmp/test-config.json") + router := NewModelRouterWithCatalog(atomic, catalogPath) + + result, err := router.Route([]MessageContent{{Role: "user", Content: "what is the time"}}, 100, "") + if err != nil { + t.Fatalf("Route failed: %v", err) + } + if result.Primary.ModelID != "legacy-background" { + t.Errorf("expected fallback legacy-background, got %s", result.Primary.ModelID) + } +} + +func TestCostBasedRouting_RouteForStreaming(t *testing.T) { + catalogPath := filepath.Join("testdata", "selector_catalog.json") + cfg := &config.Config{ + APIKey: "global-key", + EnableCostBasedRouting: true, + Models: map[string]config.ModelConfig{ + "fast": {Provider: "opencode-go", ModelID: "legacy-fast"}, + }, + } + atomic := config.NewAtomicConfig(cfg, "/tmp/test-config.json") + router := NewModelRouterWithCatalog(atomic, catalogPath) + + result, err := router.RouteForStreaming([]MessageContent{{Role: "user", Content: "Hello"}}, 100, "") + if err != nil { + t.Fatalf("RouteForStreaming failed: %v", err) + } + if result.Primary.ModelID != "cheap-no-tools" { + t.Errorf("expected cheap-no-tools, got %s", result.Primary.ModelID) + } +} + func TestRoute_LegacyConfigFixtures(t *testing.T) { t.Run("example config fixture", func(t *testing.T) { t.Setenv("ROUTATIC_PROXY_API_KEY", "test-key") diff --git a/internal/router/testdata/selector_catalog.json b/internal/router/testdata/selector_catalog.json index d89b616..6c3819e 100644 --- a/internal/router/testdata/selector_catalog.json +++ b/internal/router/testdata/selector_catalog.json @@ -132,6 +132,30 @@ "description": "Scenario that prefers a specific provider", "preferred_providers": ["openrouter"], "min_context_window": 0 + }, + "complex": { + "name": "complex", + "description": "Complex workload requiring tools and a large context window", + "requires_tools": true, + "min_context_window": 500000 + }, + "vision": { + "name": "vision", + "description": "Simple vision workload", + "requires_vision": true, + "min_context_window": 0 + }, + "vision_complex": { + "name": "vision_complex", + "description": "Complex vision workload requiring tools", + "requires_vision": true, + "requires_tools": true, + "min_context_window": 0 + }, + "fast": { + "name": "fast", + "description": "Streaming-optimized workload prioritizing low latency", + "min_context_window": 0 } } } From 46404708492c57e785f929c9062ffaec6644cd2d Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 19:36:08 +0200 Subject: [PATCH 18/25] feat: add tests for SelectCheapest with constraint handling for tools, vision, reasoning, and context --- internal/router/selector_test.go | 135 +++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/internal/router/selector_test.go b/internal/router/selector_test.go index 68b5067..5ae293c 100644 --- a/internal/router/selector_test.go +++ b/internal/router/selector_test.go @@ -278,3 +278,138 @@ func TestSelectCheapest_UnknownScenarioReturnsError(t *testing.T) { t.Fatal("SelectCheapest expected error for unknown scenario, got nil") } } + +// TestSelectCheapest_Constraints_* exercises constraint handling with the cost +// fixture catalog, ensuring required capabilities are never sacrificed for a +// lower price. + +func TestSelectCheapest_Constraints_ToolsRequired(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("tools_required", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + if got.ModelID != "cheap-tools" { + t.Errorf("SelectCheapest(tools_required) = %q, want %q", got.ModelID, "cheap-tools") + } + if got.Provider != "opencode-go" { + t.Errorf("SelectCheapest(tools_required) provider = %q, want %q", got.Provider, "opencode-go") + } + if !got.Tools { + t.Errorf("SelectCheapest(tools_required).Tools = false, want true") + } + // cheap-no-tools has the same total cost but lacks tools and must not win. + if got.CostInputPerM+got.CostOutputPerM != 2.0 { + t.Errorf("SelectCheapest(tools_required) total cost = %v, want 2.0", got.CostInputPerM+got.CostOutputPerM) + } +} + +func TestSelectCheapest_Constraints_VisionRequired(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("vision_required", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + if got.ModelID != "vision-model" { + t.Errorf("SelectCheapest(vision_required) = %q, want %q", got.ModelID, "vision-model") + } + if got.Provider != "openrouter" { + t.Errorf("SelectCheapest(vision_required) provider = %q, want %q", got.Provider, "openrouter") + } + if !got.Vision { + t.Errorf("SelectCheapest(vision_required).Vision = false, want true") + } + // vision-model is not the cheapest overall model; cheaper non-vision models must be ignored. + if got.CostInputPerM+got.CostOutputPerM != 8.0 { + t.Errorf("SelectCheapest(vision_required) total cost = %v, want 8.0", got.CostInputPerM+got.CostOutputPerM) + } +} + +func TestSelectCheapest_Constraints_ReasoningRequired(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("reasoning_required", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + if got.ModelID != "reasoning-model" { + t.Errorf("SelectCheapest(reasoning_required) = %q, want %q", got.ModelID, "reasoning-model") + } + if got.Provider != "openrouter" { + t.Errorf("SelectCheapest(reasoning_required) provider = %q, want %q", got.Provider, "openrouter") + } + if !got.Reasoning { + t.Errorf("SelectCheapest(reasoning_required).Reasoning = false, want true") + } +} + +func TestSelectCheapest_Constraints_ContextWindow(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("long_context", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + if got.ModelID != "large-context" { + t.Errorf("SelectCheapest(long_context) = %q, want %q", got.ModelID, "large-context") + } + if got.ContextWindow < 500000 { + t.Errorf("SelectCheapest(long_context).ContextWindow = %d, want >= 500000", got.ContextWindow) + } + // Cheaper models with smaller context windows must be excluded. + if got.CostInputPerM+got.CostOutputPerM != 3.0 { + t.Errorf("SelectCheapest(long_context) total cost = %v, want 3.0", got.CostInputPerM+got.CostOutputPerM) + } +} + +func TestSelectCheapest_Constraints_CombinedVisionAndTools(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("vision_complex", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + if got.ModelID != "vision-model" { + t.Errorf("SelectCheapest(vision_complex) = %q, want %q", got.ModelID, "vision-model") + } + if got.Provider != "openrouter" { + t.Errorf("SelectCheapest(vision_complex) provider = %q, want %q", got.Provider, "openrouter") + } + if !got.Vision { + t.Errorf("SelectCheapest(vision_complex).Vision = false, want true") + } + if !got.Tools { + t.Errorf("SelectCheapest(vision_complex).Tools = false, want true") + } + // vision-model is the only model satisfying both constraints and is not the cheapest overall. + if got.CostInputPerM+got.CostOutputPerM != 8.0 { + t.Errorf("SelectCheapest(vision_complex) total cost = %v, want 8.0", got.CostInputPerM+got.CostOutputPerM) + } +} From e178a78be889f1eea152f9935a4a923f4fdf124c Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Tue, 30 Jun 2026 20:02:58 +0200 Subject: [PATCH 19/25] Update internal/catalog/sync.go Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> Signed-off-by: TUYIZERE Samuel --- internal/catalog/sync.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/catalog/sync.go b/internal/catalog/sync.go index ab84bc5..35b22b0 100644 --- a/internal/catalog/sync.go +++ b/internal/catalog/sync.go @@ -46,7 +46,8 @@ func Sync(sourceURL, destDir string) (*Lock, error) { } req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetch catalog: %w", err) } From c2e232f3a339aee05f11ddbe8c4c0c796b890206 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 20:01:21 +0200 Subject: [PATCH 20/25] fix: remove unnecessary finalPath cleanup in Sync function --- internal/catalog/sync.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/catalog/sync.go b/internal/catalog/sync.go index 35b22b0..7256a5a 100644 --- a/internal/catalog/sync.go +++ b/internal/catalog/sync.go @@ -98,7 +98,6 @@ func Sync(sourceURL, destDir string) (*Lock, error) { if err := idx.Write(destDir); err != nil { _ = os.Remove(tmpPath) - _ = os.Remove(finalPath) _ = os.Remove(filepath.Join(destDir, indexTmpFileName)) _ = os.Remove(filepath.Join(destDir, indexFileName)) return nil, fmt.Errorf("write provider index: %w", err) From b642ab9b072ea26682ef0585b6c1dd70f5f974d1 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 20:06:17 +0200 Subject: [PATCH 21/25] style: format code for consistency in Sync function --- internal/catalog/sync.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/catalog/sync.go b/internal/catalog/sync.go index 7256a5a..e7c8af5 100644 --- a/internal/catalog/sync.go +++ b/internal/catalog/sync.go @@ -46,8 +46,8 @@ func Sync(sourceURL, destDir string) (*Lock, error) { } req.Header.Set("Accept", "application/json") - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetch catalog: %w", err) } From e625f50ed5a5e3e3a60f40abd2ef5646e7ee1756 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 20:14:21 +0200 Subject: [PATCH 22/25] feat: implement automatic catalog reloading in ModelRouter --- internal/router/model_router.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/router/model_router.go b/internal/router/model_router.go index 4f4e526..f100c81 100644 --- a/internal/router/model_router.go +++ b/internal/router/model_router.go @@ -5,8 +5,10 @@ package router import ( "errors" "fmt" + "os" "strings" "sync" + "time" "github.com/routatic/proxy/internal/catalog" "github.com/routatic/proxy/internal/config" @@ -23,6 +25,7 @@ type ModelRouter struct { catMu sync.Mutex cat *catalog.IndexedCatalog catErr error + catMtime time.Time } // NewModelRouter creates a new model router. @@ -37,8 +40,8 @@ func NewModelRouterWithCatalog(atomic *config.AtomicConfig, catalogPath string) return &ModelRouter{atomic: atomic, catalogPath: catalogPath} } -// catalog lazily loads and caches the indexed catalog. If no catalog path is -// configured it returns (nil, nil) so that legacy behavior is preserved. +// catalog lazily loads and caches the indexed catalog, automatically +// reloading when the underlying file changes on disk. func (r *ModelRouter) catalog() (*catalog.IndexedCatalog, error) { if r.catalogPath == "" { return nil, nil @@ -47,11 +50,21 @@ func (r *ModelRouter) catalog() (*catalog.IndexedCatalog, error) { r.catMu.Lock() defer r.catMu.Unlock() - if r.cat != nil || r.catErr != nil { - return r.cat, r.catErr + fi, err := os.Stat(r.catalogPath) + if err != nil { + r.cat = nil + r.catErr = fmt.Errorf("stat catalog file: %w", err) + return nil, r.catErr + } + + if r.cat != nil && !fi.ModTime().After(r.catMtime) { + return r.cat, nil } r.cat, r.catErr = catalog.Load(r.catalogPath) + if r.catErr == nil { + r.catMtime = fi.ModTime() + } return r.cat, r.catErr } From ce637b490f2ccad94b4bb0a5febfe3434e35875c Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Tue, 30 Jun 2026 22:08:05 +0200 Subject: [PATCH 23/25] feat: enhance SelectCheapest and providerSet with effective penalties and preferred provider logic --- internal/router/selector.go | 87 ++++++++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 20 deletions(-) diff --git a/internal/router/selector.go b/internal/router/selector.go index bc5e3f6..fba877f 100644 --- a/internal/router/selector.go +++ b/internal/router/selector.go @@ -3,6 +3,7 @@ package router import ( "errors" "fmt" + "slices" "sort" "github.com/routatic/proxy/internal/catalog" @@ -64,8 +65,8 @@ func (s *Selector) SelectCheapest(scenario string, constraints ScenarioConstrain sort.Slice(candidates, func(i, j int) bool { a, b := candidates[i], candidates[j] - costA := a.CostInputPerM + a.CostOutputPerM - costB := b.CostInputPerM + b.CostOutputPerM + costA := a.CostInputPerM + a.CostOutputPerM + s.effectivePenalty(a.Provider) + costB := b.CostInputPerM + b.CostOutputPerM + s.effectivePenalty(b.Provider) if costA != costB { return costA < costB } @@ -83,9 +84,11 @@ func (s *Selector) SelectCheapest(scenario string, constraints ScenarioConstrain // constraints. func (s *Selector) resolveCandidates(scen catalog.Scenario, constraints ScenarioConstraints) []catalog.ResolvedModel { providers := s.providerSet(scen) - minContext := scen.MinContextWindow - if constraints.Context > minContext { - minContext = constraints.Context + minContext := max(scen.MinContextWindow, constraints.Context) + + maxContext := int64(0) + if s.cfg != nil && s.cfg.CostRouting != nil && s.cfg.CostRouting.MaxContextWindow > 0 { + maxContext = s.cfg.CostRouting.MaxContextWindow } var candidates []catalog.ResolvedModel @@ -98,6 +101,9 @@ func (s *Selector) resolveCandidates(scen catalog.Scenario, constraints Scenario if !modelSupportsProvider(model, providerName) { continue } + if maxContext > 0 && model.ContextWindow > maxContext { + continue + } if !modelMatches(model, scen, constraints, minContext) { continue } @@ -123,30 +129,62 @@ func (s *Selector) resolveCandidates(scen catalog.Scenario, constraints Scenario // providerSet returns the enabled providers that should be considered for a // scenario. When the scenario lists preferred providers, only those that are -// enabled are returned; otherwise all enabled providers are returned. +// enabled are returned; when the global cost_routing.prefer_providers is +// non-empty it is intersected with the scenario's preferred providers (or used +// alone when the scenario has none). Otherwise all enabled providers are returned. func (s *Selector) providerSet(scen catalog.Scenario) map[string]bool { - set := make(map[string]bool) - if len(scen.PreferredProviders) > 0 { - for _, p := range scen.PreferredProviders { - if s.enabledProviders[p] { - set[p] = true - } + globalPref := s.globalPreferProviders() + scenarioPref := scen.PreferredProviders + + // If neither global nor scenario has preferred providers, return all enabled. + if len(globalPref) == 0 && len(scenarioPref) == 0 { + set := make(map[string]bool, len(s.enabledProviders)) + for p := range s.enabledProviders { + set[p] = true } return set } - for p := range s.enabledProviders { - set[p] = true + + // Resolve which list to use. When both are set, intersect them. + candidates := scenarioPref + if len(globalPref) > 0 { + if len(scenarioPref) == 0 { + candidates = globalPref + } else { + // Intersect global and scenario preferred providers. + globalSet := make(map[string]struct{}, len(globalPref)) + for _, p := range globalPref { + globalSet[p] = struct{}{} + } + candidates = nil + for _, p := range scenarioPref { + if _, ok := globalSet[p]; ok { + candidates = append(candidates, p) + } + } + } + } + + set := make(map[string]bool, len(candidates)) + for _, p := range candidates { + if s.enabledProviders[p] { + set[p] = true + } } return set } -func modelSupportsProvider(model catalog.Model, provider string) bool { - for _, p := range model.Providers { - if p == provider { - return true - } +// globalPreferProviders returns the global prefer_providers list from config +// or nil when unset. +func (s *Selector) globalPreferProviders() []string { + if s.cfg == nil || s.cfg.CostRouting == nil { + return nil } - return false + return s.cfg.CostRouting.PreferProviders +} + +func modelSupportsProvider(model catalog.Model, provider string) bool { + return slices.Contains(model.Providers, provider) } func modelMatches(model catalog.Model, scen catalog.Scenario, constraints ScenarioConstraints, minContext int64) bool { @@ -199,6 +237,15 @@ func (s *Selector) IsEnabledProvider(provider string) bool { return s.enabledProviders[provider] } +// effectivePenalty returns the additional per-provider cost penalty from the +// config, or 0 when no penalty is configured for the named provider. +func (s *Selector) effectivePenalty(provider string) float64 { + if s.cfg == nil || s.cfg.CostRouting == nil || s.cfg.CostRouting.PenaltyPerProvider == nil { + return 0 + } + return s.cfg.CostRouting.PenaltyPerProvider[provider] +} + // ErrNoCandidateModel is returned when SelectCheapest cannot find a model that // matches the scenario and constraints. var ErrNoCandidateModel = errors.New("no candidate model matches scenario and constraints") From a2d1642e0cc715e76d617b66ea375ccd6199793d Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Fri, 3 Jul 2026 14:36:09 +0200 Subject: [PATCH 24/25] feat: implement cost-based routing with provider penalties and context window limits --- CLAUDE.md | 2 + CONFIGURATION.md | 49 ++++++++ configs/config.example.json | 11 ++ docs/architecture.md | 2 + docs/howto-custom-routing.md | 72 +++++++++++ docs/zh/CONFIGURATION.md | 34 ++++++ internal/router/selector_test.go | 204 +++++++++++++++++++++++++++++++ 7 files changed, 374 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 74c41d0..75443dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,8 @@ If a model's upstream doesn't support Anthropic tool format (`type: "custom"` se 4. Background (simple read-only ops, no tools) → Qwen3.7 Max 5. Default → Kimi K2.6 +**Cost-based routing:** when `cost_routing.enabled` is set, `Selector` in `internal/router/selector.go` replaces the static primary model with automatic cheapest-model selection from the catalog. It applies `max_context_window` (hard cap on context window), `prefer_providers` (global provider filter, intersected with per-scenario preferences), and `penalty_per_provider` (per-provider cost penalty added during sort). Enabled via `cost_routing.enabled` or the legacy `enable_cost_based_routing` flag. + For streaming, the router downgrades to fast models (Qwen3.7 Plus) for better TTFT. **Deprecated models:** diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 6f96c33..00400ef 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -21,6 +21,16 @@ For migration, `~/.config/oc-go-cc/config.json` is loaded when the new config fi "base_url": "https://api.anthropic.com" }, + "enable_cost_based_routing": false, + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "aws-bedrock"], + "max_context_window": 1000000, + "penalty_per_provider": { + "openrouter": 0.05 + } + }, + "models": { "default": { "provider": "opencode-go", @@ -260,6 +270,45 @@ DeepSeek V4 users can set any scenario model to `deepseek-v4-pro` or `deepseek-v Routing priority: **Long Context** > **Think** > **Background** > **Default** +## Cost-Based Routing + +When enabled, the proxy uses a catalog of model pricing data to automatically select the cheapest eligible model for each scenario, rather than always using the statically configured primary model. + +```json +{ + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "aws-bedrock"], + "max_context_window": 1000000, + "penalty_per_provider": { + "openrouter": 0.05 + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `enabled` | `bool` | Activates cost-aware model selection. Can also be set via the legacy `enable_cost_based_routing` top-level flag. | +| `prefer_providers` | `string[]` | Restricts candidate providers globally. When set, only models on these providers are considered. Intersected with per-scenario `preferred_providers` when both are set. | +| `max_context_window` | `int64` | Hard cap on candidate model context window. Models exceeding this size are excluded. `0` (default) means no cap. | +| `penalty_per_provider` | `map[string]float64` | Per-provider cost penalty added to the effective cost during selection. Use this to bias away from providers without removing them entirely. | + +When enabled, `SelectCheapest` resolves all eligible provider/model pairs for the matched scenario, applies the max context window cap, filters by the preferred providers set, and sorts by effective cost (raw cost + penalty). The cheapest candidate wins. This replaces the static `models.` primary model. + +```json +{ + "cost_routing": { + "penalty_per_provider": { + "opencode-go": 0.1, + "openrouter": 0.05 + } + } +} +``` + +Penalties are additive to the raw cost. A model on `opencode-go` with base cost 2.0 and a penalty of 0.1 has effective cost 2.1. + ## Fallback Chains When a model request fails (network error, rate limit, server error), the proxy tries the next model in the fallback chain: diff --git a/configs/config.example.json b/configs/config.example.json index 421a36d..638e1d0 100644 --- a/configs/config.example.json +++ b/configs/config.example.json @@ -5,6 +5,17 @@ "hot_reload": false, "enable_streaming_scenario_routing": false, "respect_requested_model": false, + "enable_cost_based_routing": false, + + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "aws-bedrock"], + "max_context_window": 1000000, + "penalty_per_provider": { + "openrouter": 0.05 + } + }, + "anthropic_first": { "enabled": false, "base_url": "https://api.anthropic.com" diff --git a/docs/architecture.md b/docs/architecture.md index 86777a4..3acf4fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,6 +61,8 @@ The router analyzes each request and assigns it a scenario. Each scenario maps t **Streaming override**: when `enable_streaming_scenario_routing` is false (default), streaming requests always route to the `fast` model (Qwen3.6 Plus) for better TTFT. +**Cost-based routing**: when `cost_routing.enabled` (or the legacy `enable_cost_based_routing`) is set, the `Selector` resolves all eligible models from the catalog for the matched scenario, applies the `max_context_window` cap, filters by `prefer_providers`, and sorts by effective cost (raw cost + per-provider penalty). The cheapest candidate becomes the primary model for that request, replacing the static `models.` entry. This is implemented in `internal/router/selector.go`. + ## Request Transformation Claude Code sends Anthropic Messages API format. The proxy transforms to the provider's native format: diff --git a/docs/howto-custom-routing.md b/docs/howto-custom-routing.md index a2daac1..be2f2fd 100644 --- a/docs/howto-custom-routing.md +++ b/docs/howto-custom-routing.md @@ -122,6 +122,78 @@ By default, the proxy respects the `model` field from Claude Code. Disable this "respect_requested_model": false} ``` +## Enable Cost-Based Routing + +By default, each scenario maps to a single statically configured primary model. Cost-based routing replaces this with automatic cheapest-model selection using a model pricing catalog: + +```json +{ + "cost_routing": { + "enabled": true + } +} +``` + +### Restrict to Preferred Providers + +Limit cost-based selection to a subset of providers: + +```json +{ + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "aws-bedrock"] + } +} +``` + +When a scenario also has per-scenario `preferred_providers`, the two lists are intersected. + +### Cap the Context Window + +Exclude models with context windows larger than a threshold: + +```json +{ + "cost_routing": { + "enabled": true, + "max_context_window": 500000 + } +} +``` + +Models with a context window exceeding the cap are filtered out. Set to `0` (default) for no limit. + +### Penalize Providers + +Add an artificial cost penalty to specific providers to bias selection away from them: + +```json +{ + "cost_routing": { + "enabled": true, + "penalty_per_provider": { + "openrouter": 0.05, + "opencode-go": 0.1 + } + } +} +``` + +The penalty is added to the raw per-million-token cost during sorting. A model with base cost 2.0 on a provider with a 0.1 penalty has effective cost 2.1. + +### Legacy Flag + +The top-level `enable_cost_based_routing` flag also enables cost routing: + +```json +{ + "enable_cost_based_routing": true +} +``` + +If both `enable_cost_based_routing` and `cost_routing.enabled` are set, either being `true` activates the feature. + ## Custom Scenario Detection Scenario detection is keyword-based. To add custom patterns, edit `internal/router/scenarios.go`: diff --git a/docs/zh/CONFIGURATION.md b/docs/zh/CONFIGURATION.md index 1bd3539..1016587 100644 --- a/docs/zh/CONFIGURATION.md +++ b/docs/zh/CONFIGURATION.md @@ -19,6 +19,16 @@ "port": 3456, "hot_reload": false, + "enable_cost_based_routing": false, + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "aws-bedrock"], + "max_context_window": 1000000, + "penalty_per_provider": { + "openrouter": 0.05 + } + }, + "models": { "default": { "provider": "opencode-go", @@ -223,6 +233,30 @@ DeepSeek V4 用户可以将任何场景模型设置为 `deepseek-v4-pro` 或 `de 路由优先级:**长上下文** > **思考** > **后台** > **默认** +## 基于成本的路由 + +启用后,代理使用模型定价目录自动为每个场景选择最便宜的合格模型,而非始终使用静态配置的主模型。 + +```json +{ + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "aws-bedrock"], + "max_context_window": 1000000, + "penalty_per_provider": { + "openrouter": 0.05 + } + } +} +``` + +| 字段 | 类型 | 描述 | +|------|------|------| +| `enabled` | `bool` | 激活基于成本的模型选择。也可通过旧版顶层 `enable_cost_based_routing` 标志设置。 | +| `prefer_providers` | `string[]` | 全局限制候选提供商。设置后,仅考虑这些提供商上的模型。与每场景 `preferred_providers` 交集处理。 | +| `max_context_window` | `int64` | 候选模型上下文窗口的硬上限。超过此大小的模型将被排除。`0`(默认)表示无上限。 | +| `penalty_per_provider` | `map[string]float64` | 按提供商的成本惩罚,在选择时加到有效成本上。用于在不完全移除提供商的情况下使其吸引力降低。 | + ## 降级链 当模型请求失败(网络错误、速率限制、服务器错误)时,代理尝试降级链中的下一个模型: diff --git a/internal/router/selector_test.go b/internal/router/selector_test.go index 5ae293c..bf7f142 100644 --- a/internal/router/selector_test.go +++ b/internal/router/selector_test.go @@ -413,3 +413,207 @@ func TestSelectCheapest_Constraints_CombinedVisionAndTools(t *testing.T) { t.Errorf("SelectCheapest(vision_complex) total cost = %v, want 8.0", got.CostInputPerM+got.CostOutputPerM) } } + +// TestSelectCheapest_PenaltyPerProvider verifies that cost_routing.penalty_per_provider +// inflates a provider's effective cost during selection. When opencode-go is penalised +// enough, large-context on openrouter (unpenalised, cost 3.0) becomes cheaper than +// cheap-no-tools on opencode-go (cost 2.0 + penalty). +func TestSelectCheapest_PenaltyPerProvider(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + CostRouting: &config.CostRoutingConfig{ + PenaltyPerProvider: map[string]float64{ + "opencode-go": 2.0, + }, + }, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("default", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + // Without the penalty cheap-no-tools (opencode-go, cost 2.0) would win at 2.0. + // With a 2.0 penalty on opencode-go its effective cost becomes 4.0, so + // large-context on the unpenalised openrouter (cost 3.0) should win. + if got.ModelID != "large-context" { + t.Errorf("SelectCheapest(default) = %q, want %q (penalty should flip to unpenalised provider)", got.ModelID, "large-context") + } + if got.Provider != "openrouter" { + t.Errorf("SelectCheapest(default) provider = %q, want %q", got.Provider, "openrouter") + } + // The raw cost should be 3.0 (openrouter's large-context), not the penalised cost. + if got.CostInputPerM+got.CostOutputPerM != 3.0 { + t.Errorf("SelectCheapest(default) raw cost = %v, want 3.0", got.CostInputPerM+got.CostOutputPerM) + } +} + +// TestSelectCheapest_PenaltyPerProvider_NoEffectOnUnlisted verifies that a penalty +// only applies to the named providers and does not affect unlisted providers. +func TestSelectCheapest_PenaltyPerProvider_NoEffectOnUnlisted(t *testing.T) { + cfg := &config.Config{ + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + CostRouting: &config.CostRoutingConfig{ + PenaltyPerProvider: map[string]float64{ + "nonexistent-provider": 100.0, + }, + }, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("default", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + // A penalty on a provider that does not exist in the catalog must not affect + // selection — cheap-no-tools (opencode-go, cost 2.0) should still win. + if got.ModelID != "cheap-no-tools" { + t.Errorf("SelectCheapest(default) = %q, want %q (unused penalty must not affect selection)", got.ModelID, "cheap-no-tools") + } + if got.Provider != "opencode-go" { + t.Errorf("SelectCheapest(default) provider = %q, want %q", got.Provider, "opencode-go") + } +} + +// TestSelectCheapest_MaxContextWindow verifies that cost_routing.max_context_window +// caps the context window of candidate models, filtering out those that exceed it. +func TestSelectCheapest_MaxContextWindow(t *testing.T) { + t.Run("filters models exceeding the cap", func(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + CostRouting: &config.CostRoutingConfig{ + MaxContextWindow: 200000, + }, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("default", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + // large-context (1M) and vision-model (256K) exceed the 200K cap and must be excluded. + // The cheapest remaining model is cheap-no-tools (128K) on opencode-go at cost 2.0. + if got.ModelID != "cheap-no-tools" { + t.Errorf("SelectCheapest(default) = %q, want %q", got.ModelID, "cheap-no-tools") + } + if got.ContextWindow > 200000 { + t.Errorf("SelectCheapest(default) context = %d, want <= 200000", got.ContextWindow) + } + }) + + t.Run("zero cap has no effect", func(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + CostRouting: &config.CostRoutingConfig{ + MaxContextWindow: 0, + }, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + got, err := selector.SelectCheapest("default", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + // Without the cap, large-context (1M) is eligible and expensive models are available. + // The cheapest remains cheap-no-tools. + if got.ModelID != "cheap-no-tools" { + t.Errorf("SelectCheapest(default) = %q, want %q", got.ModelID, "cheap-no-tools") + } + }) + + t.Run("cap filters all models producing error", func(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + CostRouting: &config.CostRoutingConfig{ + MaxContextWindow: 100, + }, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + _, err := selector.SelectCheapest("default", ScenarioConstraints{}) + if err == nil { + t.Fatal("SelectCheapest expected error when MaxContextWindow excludes every model, got nil") + } + if !errors.Is(err, ErrNoCandidateModel) { + t.Errorf("SelectCheapest error = %v, want ErrNoCandidateModel", err) + } + }) +} + +// TestSelectCheapest_GlobalPreferProviders verifies that +// cost_routing.prefer_providers filters the eligible provider set globally. +func TestSelectCheapest_GlobalPreferProviders(t *testing.T) { + t.Run("global pref limits to listed providers", func(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + CostRouting: &config.CostRoutingConfig{ + PreferProviders: []string{"openrouter"}, + }, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + // "default" scenario has no scenario-level preferences, so the global + // prefer_providers list is used alone. Only openrouter models are eligible. + got, err := selector.SelectCheapest("default", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + if got.Provider != "openrouter" { + t.Errorf("SelectCheapest(default) provider = %q, want %q", got.Provider, "openrouter") + } + // Cheapest openrouter model is large-context at cost 3.0. + if got.ModelID != "large-context" { + t.Errorf("SelectCheapest(default) = %q, want %q", got.ModelID, "large-context") + } + }) + + t.Run("global pref intersects with scenario pref", func(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + CostRouting: &config.CostRoutingConfig{ + PreferProviders: []string{"opencode-go"}, + }, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + // "preferred_only" scenario prefers openrouter, global pref prefers + // opencode-go. The intersection is empty → no candidates. + _, err := selector.SelectCheapest("preferred_only", ScenarioConstraints{}) + if err == nil { + t.Fatal("SelectCheapest expected error when global and scenario prefs intersect to empty, got nil") + } + if !errors.Is(err, ErrNoCandidateModel) { + t.Errorf("SelectCheapest error = %v, want ErrNoCandidateModel", err) + } + }) + + t.Run("scenario pref used when global pref is empty", func(t *testing.T) { + cfg := &config.Config{ + OpenCodeGo: config.OpenCodeGoConfig{APIKey: "go-key"}, + OpenRouter: config.OpenRouterConfig{APIKey: "or-key"}, + } + selector := NewSelector(selectorTestCatalog(t), cfg) + + // No global prefer_providers, so "preferred_only" scenario's own + // preferred_providers (openrouter) is used. + got, err := selector.SelectCheapest("preferred_only", ScenarioConstraints{}) + if err != nil { + t.Fatalf("SelectCheapest returned error: %v", err) + } + + if got.Provider != "openrouter" { + t.Errorf("SelectCheapest(preferred_only) provider = %q, want %q", got.Provider, "openrouter") + } + }) +} From 53a3f56fbf7b2ef87c19365e0dbfbbaa2f3178f9 Mon Sep 17 00:00:00 2001 From: samuel tuyizere Date: Fri, 3 Jul 2026 15:16:36 +0200 Subject: [PATCH 25/25] fix: update config path in TestRoute_LegacyConfigFixtures for consistency --- internal/router/model_router_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/router/model_router_test.go b/internal/router/model_router_test.go index 1dfd430..dc1ae2e 100644 --- a/internal/router/model_router_test.go +++ b/internal/router/model_router_test.go @@ -845,11 +845,12 @@ func TestRoute_LegacyConfigFixtures(t *testing.T) { t.Run("example config fixture", func(t *testing.T) { t.Setenv("ROUTATIC_PROXY_API_KEY", "test-key") - cfg, err := config.LoadFromPath("/Users/mac/Dev/routatic/proxy/configs/config.example.json") + cfgPath := "../../configs/config.example.json" + cfg, err := config.LoadFromPath(cfgPath) if err != nil { t.Fatalf("failed to load example config: %v", err) } - atomic := config.NewAtomicConfig(cfg, "/Users/mac/Dev/routatic/proxy/configs/config.example.json") + atomic := config.NewAtomicConfig(cfg, cfgPath) router := NewModelRouter(atomic) messages := []MessageContent{{Role: "user", Content: "Hello"}}