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/cmd/routatic-proxy/catalog.go b/cmd/routatic-proxy/catalog.go new file mode 100644 index 0000000..e6c3577 --- /dev/null +++ b/cmd/routatic-proxy/catalog.go @@ -0,0 +1,103 @@ +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" +) + +// 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) + } + + 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 + }, + } +} + +// 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") +} + +// 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 new file mode 100644 index 0000000..68d4b11 --- /dev/null +++ b/cmd/routatic-proxy/catalog_test.go @@ -0,0 +1,348 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/routatic/proxy/internal/catalog" + "github.com/routatic/proxy/internal/config" + "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 }) + + 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", configPath}) + + 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 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()) + + 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) + } +} diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 0763f55..0d512e0 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -8,8 +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" @@ -48,6 +51,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) @@ -87,6 +91,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) @@ -417,84 +425,129 @@ 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 + var provider 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", + 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 { + return runModelsList(cmd, configPath, provider) }, } + + 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") + + cmd.AddCommand(modelsListCmd()) + + return cmd +} + +// 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) + }, + } +} + +// 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) + } + + 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") + } + + providers := selectProviders(provider, cfg) + if len(providers) == 0 { + if provider != "" { + cmd.Printf("No models found for provider %q.\n", provider) + } else { + cmd.Println("No enabled providers found.") + } + return nil + } + + 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)) + } + } + + if len(lines) == 0 { + if provider != "" { + cmd.Printf("No models found for provider %q.\n", provider) + } else { + cmd.Println("No models found for enabled providers.") + } + return nil + } + + for _, line := range lines { + cmd.Println(line) + } + + cmd.Println() + cmd.Println("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. 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/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/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/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..6e28e44 --- /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 TestIndex_BuildProviderIndex_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 TestIndex_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 TestIndex_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 TestIndex_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/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/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/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/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/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 new file mode 100644 index 0000000..e7c8af5 --- /dev/null +++ b/internal/catalog/sync.go @@ -0,0 +1,119 @@ +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") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch catalog: %w", err) + } + defer func() { _ = 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") + } + + 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[:]) + + 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) + } + + if err := idx.Write(destDir); err != nil { + _ = os.Remove(tmpPath) + _ = 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(), + 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..68566ad --- /dev/null +++ b/internal/catalog/sync_test.go @@ -0,0 +1,200 @@ +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"]}},"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) + } + + 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) + 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, 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) + } + } +} + +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"]}},"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) + } +} 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/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"` +} 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 7d78b02..ac50c83 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,8 @@ 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"` + 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"` @@ -18,9 +20,35 @@ 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"` + 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. @@ -29,6 +57,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"` @@ -39,6 +74,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"` @@ -101,6 +137,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/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/config/loader.go b/internal/config/loader.go index 6b1abed..823d283 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -21,11 +21,15 @@ 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" 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. @@ -190,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 } @@ -263,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 } @@ -282,6 +298,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. @@ -326,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") 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/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..a9a3c56 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. @@ -65,13 +72,15 @@ func New(opts Options) *Server { opts.Logger = slog.Default() } s := &Server{ - hist: opts.History, - met: opts.Metrics, - atomicCfg: opts.AtomicConfig, - proxyPort: opts.ProxyPort, - startProxy: opts.StartProxy, - stopProxy: opts.StopProxy, - logger: opts.Logger, + hist: opts.History, + met: opts.Metrics, + atomicCfg: opts.AtomicConfig, + 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) + } +} 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/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) { diff --git a/internal/router/model_router.go b/internal/router/model_router.go index a5122d7..f100c81 100644 --- a/internal/router/model_router.go +++ b/internal/router/model_router.go @@ -3,14 +3,29 @@ package router import ( + "errors" "fmt" + "os" + "strings" + "sync" + "time" + "github.com/routatic/proxy/internal/catalog" "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 + atomic *config.AtomicConfig + catalogPath string + catMu sync.Mutex + cat *catalog.IndexedCatalog + catErr error + catMtime time.Time } // NewModelRouter creates a new model router. @@ -18,6 +33,41 @@ 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, automatically +// reloading when the underlying file changes on disk. +func (r *ModelRouter) catalog() (*catalog.IndexedCatalog, error) { + if r.catalogPath == "" { + return nil, nil + } + + r.catMu.Lock() + defer r.catMu.Unlock() + + 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 +} + // 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 +96,26 @@ 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. 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, 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) } } primary = config.ResolveModelConfig(primary) @@ -70,6 +132,92 @@ 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) { + var resolved catalog.ResolvedModel + var err error + if sel.Provider != "" { + resolved, err = cat.Resolve(sel) + } else { + resolved, err = cat.ResolveShort(requestedModel) + } + if err != nil { + return config.ModelConfig{}, false + } + + cfg := resolvedModelToConfig(resolved) + cfg.ModelRef = requestedModel + return cfg, 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) { @@ -84,9 +232,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.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 { + 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) @@ -99,7 +259,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) @@ -164,15 +324,28 @@ 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 } // Otherwise, use scenario-based routing for streaming result := RouteForStreaming(messages, tokenCount, cfg) + scenarioKey := string(result.Scenario) - // Get primary model for scenario - primary, ok := cfg.Models[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.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 { + 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) @@ -189,7 +362,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 diff --git a/internal/router/model_router_test.go b/internal/router/model_router_test.go index a9f8948..dc1ae2e 100644 --- a/internal/router/model_router_test.go +++ b/internal/router/model_router_test.go @@ -1,13 +1,81 @@ package router import ( + "errors" + "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", "openrouter"], + "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 +429,480 @@ func TestRouteWithOverride_NoFallbacksAnywhere(t *testing.T) { t.Errorf("expected 1-element chain, got %d", len(chain)) } } + +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. + 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) + } + }) + } +} + +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 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") + + 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, cfgPath) + 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) + } + }) +} diff --git a/internal/router/selector.go b/internal/router/selector.go new file mode 100644 index 0000000..fba877f --- /dev/null +++ b/internal/router/selector.go @@ -0,0 +1,251 @@ +package router + +import ( + "errors" + "fmt" + "slices" + "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 + s.effectivePenalty(a.Provider) + costB := b.CostInputPerM + b.CostOutputPerM + s.effectivePenalty(b.Provider) + 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 := 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 + 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 maxContext > 0 && model.ContextWindow > maxContext { + 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; 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 { + 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 + } + + // 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 +} + +// 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 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 { + 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] +} + +// 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") diff --git a/internal/router/selector_test.go b/internal/router/selector_test.go new file mode 100644 index 0000000..bf7f142 --- /dev/null +++ b/internal/router/selector_test.go @@ -0,0 +1,619 @@ +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") + } +} + +// 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) + } +} + +// 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") + } + }) +} diff --git a/internal/router/testdata/selector_catalog.json b/internal/router/testdata/selector_catalog.json new file mode 100644 index 0000000..6c3819e --- /dev/null +++ b/internal/router/testdata/selector_catalog.json @@ -0,0 +1,161 @@ +{ + "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 + }, + "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 + } + } +} 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.