diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 218cae25..d4b8080d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,4 +56,4 @@ jobs: uses: golangci/golangci-lint-action@v7 with: version: latest - args: --timeout 5m + args: --timeout 5m \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6179d9d6..ae5f686d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,10 @@ dist/ brag-output-** .DS_Store .kimchi +.worktrees/ +.planning +# Spartan AI config (contains API keys) +.spartan/ai.env +.claude + +router.test diff --git a/.spartan/config.yaml b/.spartan/config.yaml new file mode 100644 index 00000000..12a63da5 --- /dev/null +++ b/.spartan/config.yaml @@ -0,0 +1,58 @@ +# .spartan/config.yaml — Generated by /spartan:init-rules +# Validate: /spartan:lint-rules +# Auto-detect rules from code: /spartan:scan-rules + +# ─── Stack & Architecture ─────────────────────────────────────────── + +stack: go-standard +architecture: clean + +# ─── Rules ────────────────────────────────────────────────────────── + +rules: + shared: [] + # Add rules that apply to both backend and frontend + + backend: + - rules/auto-detected/ERROR_HANDLING.md + - rules/auto-detected/CONSTRUCTOR_PATTERN.md + - rules/auto-detected/CONTEXT_PROPAGATION.md + - rules/auto-detected/SLOG_LOGGING.md + - rules/auto-detected/TEST_HELPERS.md + - rules/auto-detected/JSON_RAWMESSAGE.md + - rules/auto-detected/SYNC_MUTEX.md + + frontend: [] + # Add frontend-specific rules + +# ─── File Type Mapping ────────────────────────────────────────────── + +file-types: + backend: [".go"] + frontend: [] + migration: [".sql"] + config: [".yaml", ".yml", ".json", ".toml"] + +# ─── Review Stages ────────────────────────────────────────────────── + +review-stages: + - correctness + - stack-conventions + - test-coverage + - architecture + - database-api + - security + - documentation-gaps + +# ─── Build Commands ───────────────────────────────────────────────── + +commands: + test: + backend: "make test" + frontend: "" + build: + backend: "make build" + frontend: "" + lint: + backend: "make lint" + frontend: "" diff --git a/CLAUDE.md b/CLAUDE.md index 74c41d06..7fed145f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,15 +5,31 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands ```bash -make build # Build binary to bin/routatic-proxy +make build # Build binary to bin/routatic-proxy (CGO disabled by default) make run # Run without building make test # Run tests with race detector make lint # go vet + test make clean # Remove build artifacts make install # Build and install to $GOPATH/bin make dist # Cross-compile for all platforms + +## Build with tray support (Linux/macOS) +# Linux: sudo dnf install libappindicator-gtk3-devel # Fedora/RHEL +# Linux: sudo apt install libayatana-appindicator3-dev # Ubuntu/Debian +CGO_ENABLED=1 make build + +## The 'ui' command opens the GUI dashboard +./bin/routatic-proxy ui # Browser-based on Linux, native window on macOS ``` +### Platform-specific notes + +**Linux:** The default build uses `CGO_ENABLED=0` and opens the GUI in your default browser via `xdg-open`. For system tray support, build with `CGO_ENABLED=1` after installing the `libappindicator-gtk3-devel` (Fedora/RHEL) or `libayatana-appindicator3-dev` (Ubuntu/Debian) package. + +**macOS:** The `ui` command opens a native window with system tray integration. Requires CGO with Cocoa headers. For builds without CGO, use `CGO_ENABLED=0 make build` which opens the browser-based GUI. + +**Windows:** The `ui` command is not supported. Use CLI only or run the proxy with `make run`. + Run a single test: `go test ./internal/router/ -v` ## Architecture @@ -55,6 +71,21 @@ 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. + +**Catalog schema:** Models are keyed as `provider/model-name` (e.g., `opencode-go/glm-5.2`). The catalog (`~/.config/routatic-proxy/catalog/catalog.json`) contains: +- `providers` — Provider definitions with `name`, `base_url`, `enabled` +- `models` — Model definitions keyed by full key with fields: + - `id` — Full key (matches the map key) + - `name` — Display name + - `limit.context` — Context window size + - `rates.input`/`rates.output` — Cost per million tokens + - `tool_call` — Whether tools are supported + - `modalities.input`/`output` — Input/output types (`["text"]` or `["text", "image"]` for vision) + - `reasoning` — Whether reasoning mode is supported + +Resolution functions in `internal/catalog/resolve.go` extract the provider from the key prefix. `ResolvedModel.ModelID` is the model name only (without provider prefix); `ResolvedModel.CanonicalName` is the full key. + 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 6f96c33c..00400ef0 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/CONTRIBUTING.md b/CONTRIBUTING.md index 07d98f68..ac6cf024 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,25 @@ 4. Push to your fork and open a pull request against `main` 5. Describe what your PR does and link any related issues +### Pre-push Hooks + +This repository uses git hooks to ensure code quality. Install them once after cloning: + +```bash +./scripts/install-hooks.sh +``` + +The pre-push hook runs these checks before allowing a push: +- **Code formatting** (`gofmt`) — ensures consistent formatting +- **Linting** (`go vet`) — catches common errors +- **Tests** (`make test`) — runs all tests with race detector +- **Build** (`make build`) — verifies the project compiles + +To bypass hooks temporarily (not recommended): +```bash +git push --no-verify +``` + ## Code Style This project follows standard Go conventions: diff --git a/Makefile b/Makefile index 423e8966..d9564bcd 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ CMD = ./cmd/routatic-proxy # ── Development ──────────────────────────────────────────────────── build: - go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD) + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD) @ln -sf $(BINARY) bin/$(LEGACY_BINARY) build-ui: @@ -24,29 +24,27 @@ run: go run -ldflags "$(LDFLAGS)" $(CMD) test: - go test ./... -v -race + go test ./internal/... ./pkg/... ./cmd/... -v -race vet: go vet ./... +GOBIN=$(shell go env GOPATH)/bin + lint: - @which golangci-lint > /dev/null || (echo "golangci-lint not found, please install it: https://golangci-lint.run/usage/install/" && exit 1) @echo "Running gofmt..." @test -z "$$(gofmt -d . | tee /dev/stderr)" || (echo "gofmt check failed" && exit 1) - @echo "Running golangci-lint..." - golangci-lint run --timeout 5m + @echo "Running go vet..." + CGO_ENABLED=0 go vet ./... + @echo "Lint checks passed!" clean: rm -rf bin/ dist/ install: build - cp bin/$(BINARY) $(GOPATH)/bin/$(BINARY) 2>/dev/null || \ - cp bin/$(BINARY) $(HOME)/go/bin/$(BINARY) 2>/dev/null || \ - go install -ldflags "$(LDFLAGS)" $(CMD) - @INSTALL_DIR="$$(go env GOPATH 2>/dev/null)/bin"; \ - if [ -x "$$INSTALL_DIR/$(BINARY)" ]; then \ - ln -sf "$(BINARY)" "$$INSTALL_DIR/$(LEGACY_BINARY)"; \ - fi + @mkdir -p $(GOBIN) + cp bin/$(BINARY) $(GOBIN)/$(BINARY) + ln -sf $(BINARY) $(GOBIN)/$(LEGACY_BINARY) # ── Docker ───────────────────────────────────────────────────────── diff --git a/README.md b/README.md index b1255906..673cecf9 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ routatic-proxy --version Show version | [MODELS.md](MODELS.md) | Model capabilities, costs, and routing recommendations | | [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup, architecture, how it works | | [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Common issues and debug mode | +| [docs/fedora-setup.md](docs/fedora-setup.md) | Fedora 44 setup guide (installation, systemd, SELinux) | | [docs/architecture.md](docs/architecture.md) | System design, request flow, module overview | | [docs/reference-api.md](docs/reference-api.md) | HTTP API reference (endpoints, streaming, errors) | | [docs/howto-add-model.md](docs/howto-add-model.md) | Adding new models (zero code changes) | diff --git a/cmd/routatic-proxy/catalog.go b/cmd/routatic-proxy/catalog.go new file mode 100644 index 00000000..d1ecb9db --- /dev/null +++ b/cmd/routatic-proxy/catalog.go @@ -0,0 +1,166 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/routatic/proxy/internal/catalog" + "github.com/routatic/proxy/internal/config" + "github.com/routatic/proxy/internal/storage" + "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.AddCommand(catalogExportCmd()) + cmd.PersistentFlags().StringP("config", "c", "", "Path to config file (used to locate the catalog directory)") + + return cmd +} + +func catalogExportCmd() *cobra.Command { + var outputPath string + + cmd := &cobra.Command{ + Use: "export", + Short: "Export the SQLite catalog to JSON", + Long: `Export the catalog from SQLite to a JSON file for backup or debugging. + +The output file can be used as a backup or for manual inspection. +By default exports to the catalog directory as catalog-export.json.`, + 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) + if outputPath == "" { + outputPath = filepath.Join(catalogDir, "catalog-export.json") + } + + db, err := storage.Open(storage.DefaultConfig) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer func() { _ = db.Close() }() + + ctx := cmd.Context() + if err := catalog.ExportJSON(ctx, db, outputPath); err != nil { + return fmt.Errorf("export catalog: %w", err) + } + + cmd.Printf("Catalog exported to %s\n", outputPath) + return nil + }, + } + + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output file path (default: catalog/catalog-export.json)") + + 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 + } + + // Skip sync if no source URL is configured. + if cfg.Catalog.SourceURL == "" { + slog.Debug("catalog source URL not configured, skipping sync") + 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 +} + +// ensureDatabase ensures the SQLite database exists and is initialized. +// It creates the database directory and schema if missing. +func ensureDatabase() error { + db, err := storage.Open(storage.DefaultConfig) + if err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + defer func() { _ = db.Close() }() + + slog.Info("initialized sqlite database", "path", storage.DefaultConfig.DatabasePath) + return nil +} diff --git a/cmd/routatic-proxy/catalog_test.go b/cmd/routatic-proxy/catalog_test.go new file mode 100644 index 00000000..0d05070e --- /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": { + "openrouter/claude-sonnet-4": {"id": "openrouter/claude-sonnet-4", "name": "Claude Sonnet 4"} + }, + "providers": { + "openrouter": {"name": "openrouter", "base_url": "https://openrouter.ai/api/v1", "enabled": true} + } +}` + + 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": { + "openrouter/claude-sonnet-4": {"id": "openrouter/claude-sonnet-4", "name": "Claude Sonnet 4"} + }, + "providers": { + "openrouter": {"name": "openrouter", "base_url": "https://openrouter.ai/api/v1", "enabled": true} + } +}` + + 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": { + "openrouter/claude-sonnet-4": {"id": "openrouter/claude-sonnet-4", "name": "Claude Sonnet 4"} + }, + "providers": { + "openrouter": {"name": "openrouter", "base_url": "https://openrouter.ai/api/v1", "enabled": true} + } +}` + + 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/init_provider.go b/cmd/routatic-proxy/init_provider.go new file mode 100644 index 00000000..20ada548 --- /dev/null +++ b/cmd/routatic-proxy/init_provider.go @@ -0,0 +1,638 @@ +package main + +import ( + "fmt" + "sort" + "strings" +) + +// ProviderPreset contains provider-specific configuration defaults +// and a generator for the config template. +type ProviderPreset struct { + Name string + EnvVarName string // Environment variable for API key + Description string + BaseURL string + Generator func() string // Returns the JSON config template for this provider +} + +// Supported provider presets. +var providerPresets = map[string]ProviderPreset{ + "opencode-go": { + Name: "OpenCode Go", + EnvVarName: "ROUTATIC_PROXY_OPENCODE_GO_API_KEY", + Description: "OpenCode Go subscription - $5/month with powerful coding models", + BaseURL: "https://opencode.ai/zen/go/v1/chat/completions", + Generator: getOpenCodeGoConfig, + }, + "opencode-zen": { + Name: "OpenCode Zen", + EnvVarName: "ROUTATIC_PROXY_OPENCODE_ZEN_API_KEY", + Description: "OpenCode Zen - pay-as-you-go access to Claude, GPT, Gemini, and more", + BaseURL: "https://opencode.ai/zen/v1/chat/completions", + Generator: getOpenCodeZenConfig, + }, + "aws-bedrock": { + Name: "AWS Bedrock", + EnvVarName: "ROUTATIC_PROXY_AWS_BEDROCK_API_KEY", + Description: "AWS Bedrock Mantle - run models on your own AWS infrastructure", + BaseURL: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", + Generator: getAWSBedrockConfig, + }, + "openrouter": { + Name: "OpenRouter", + EnvVarName: "ROUTATIC_PROXY_OPENROUTER_API_KEY", + Description: "OpenRouter - unified API for 100+ models from multiple providers", + BaseURL: "https://openrouter.ai/api/v1/chat/completions", + Generator: getOpenRouterConfig, + }, +} + +// getProviderConfig returns a config template optimized for a specific provider. +// Supported providers are derived from providerPresets so there is only one +// registry to maintain. +func getProviderConfig(provider string) (string, error) { + preset, ok := providerPresets[provider] + if !ok { + supported := make([]string, 0, len(providerPresets)) + for p := range providerPresets { + supported = append(supported, p) + } + sort.Strings(supported) + return "", fmt.Errorf("unknown provider %q; supported: %s", provider, strings.Join(supported, ", ")) + } + return preset.Generator(), nil +} + +// getOpenRouterConfig returns a config optimized for OpenRouter. +func getOpenRouterConfig() string { + return `{ + "api_key": "${ROUTATIC_PROXY_API_KEY}", + "host": "127.0.0.1", + "port": 3456, + "hot_reload": false, + "enable_streaming_scenario_routing": false, + "respect_requested_model": false, + + "models": { + "default": { + "provider": "openrouter", + "model_id": "anthropic/claude-sonnet-4", + "temperature": 0.7, + "max_tokens": 8192 + }, + "background": { + "provider": "openrouter", + "model_id": "openai/gpt-4o-mini", + "temperature": 0.5, + "max_tokens": 2048 + }, + "think": { + "provider": "openrouter", + "model_id": "anthropic/claude-sonnet-4", + "temperature": 0.7, + "max_tokens": 8192 + }, + "complex": { + "provider": "openrouter", + "model_id": "anthropic/claude-opus-4", + "temperature": 0.7, + "max_tokens": 8192 + }, + "long_context": { + "provider": "openrouter", + "model_id": "google/gemini-2.5-pro", + "temperature": 0.7, + "max_tokens": 16384, + "context_threshold": 80000 + }, + "fast": { + "provider": "openrouter", + "model_id": "openai/gpt-4o-mini", + "temperature": 0.7, + "max_tokens": 4096 + } + }, + + "fallbacks": { + "default": [ + { "provider": "openrouter", "model_id": "anthropic/claude-3.5-sonnet" }, + { "provider": "openrouter", "model_id": "openai/gpt-4o" } + ], + "background": [ + { "provider": "openrouter", "model_id": "meta-llama/llama-3.3-70b-instruct" } + ], + "think": [ + { "provider": "openrouter", "model_id": "anthropic/claude-opus-4" } + ], + "complex": [ + { "provider": "openrouter", "model_id": "anthropic/claude-sonnet-4" } + ], + "long_context": [ + { "provider": "openrouter", "model_id": "anthropic/claude-sonnet-4" } + ], + "fast": [ + { "provider": "openrouter", "model_id": "openai/gpt-4o-mini" } + ] + }, + + "openrouter": { + "base_url": "https://openrouter.ai/api/v1/chat/completions", + "api_key": "${ROUTATIC_PROXY_OPENROUTER_API_KEY}", + "api_keys": [], + "timeout_ms": 300000, + "stream_timeout_ms": 60000, + "streaming_timeout_ms": 600000 + }, + + "logging": { + "level": "info", + "requests": true + } +} +` +} + +// getAWSBedrockConfig returns a config optimized for AWS Bedrock. +func getAWSBedrockConfig() string { + return `{ + "api_key": "${ROUTATIC_PROXY_API_KEY}", + "host": "127.0.0.1", + "port": 3456, + "hot_reload": false, + "enable_streaming_scenario_routing": false, + "respect_requested_model": false, + + "models": { + "default": { + "provider": "aws-bedrock", + "model_id": "anthropic.claude-sonnet-4-20250514-v1:0", + "temperature": 0.7, + "max_tokens": 8192 + }, + "background": { + "provider": "aws-bedrock", + "model_id": "amazon.nova-lite-v1:0", + "temperature": 0.5, + "max_tokens": 2048 + }, + "think": { + "provider": "aws-bedrock", + "model_id": "anthropic.claude-sonnet-4-20250514-v1:0", + "temperature": 0.7, + "max_tokens": 8192 + }, + "complex": { + "provider": "aws-bedrock", + "model_id": "anthropic.claude-opus-4-20250514-v1:0", + "temperature": 0.7, + "max_tokens": 8192 + }, + "long_context": { + "provider": "aws-bedrock", + "model_id": "anthropic.claude-sonnet-4-20250514-v1:0", + "temperature": 0.7, + "max_tokens": 16384, + "context_threshold": 80000 + }, + "fast": { + "provider": "aws-bedrock", + "model_id": "amazon.nova-lite-v1:0", + "temperature": 0.7, + "max_tokens": 4096 + } + }, + + "fallbacks": { + "default": [ + { "provider": "aws-bedrock", "model_id": "anthropic.claude-3-5-sonnet-20241022-v2:0" }, + { "provider": "aws-bedrock", "model_id": "amazon.nova-pro-v1:0" } + ], + "background": [ + { "provider": "aws-bedrock", "model_id": "amazon.nova-micro-v1:0" } + ], + "think": [ + { "provider": "aws-bedrock", "model_id": "anthropic.claude-opus-4-20250514-v1:0" } + ], + "complex": [ + { "provider": "aws-bedrock", "model_id": "anthropic.claude-sonnet-4-20250514-v1:0" } + ], + "long_context": [ + { "provider": "aws-bedrock", "model_id": "anthropic.claude-sonnet-4-20250514-v1:0" } + ], + "fast": [ + { "provider": "aws-bedrock", "model_id": "amazon.nova-lite-v1:0" } + ] + }, + + "aws_bedrock": { + "base_url": "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", + "anthropic_base_url": "https://bedrock-mantle.us-east-1.api.aws/v1/messages", + "api_key": "${ROUTATIC_PROXY_AWS_BEDROCK_API_KEY}", + "api_keys": [], + "project_id": "", + "wire_format": "openai", + "timeout_ms": 300000, + "stream_timeout_ms": 60000, + "streaming_timeout_ms": 600000 + }, + + "logging": { + "level": "info", + "requests": true + } +} +` +} + +// getOpenCodeZenConfig returns a config optimized for OpenCode Zen. +func getOpenCodeZenConfig() string { + return `{ + "api_key": "${ROUTATIC_PROXY_API_KEY}", + "host": "127.0.0.1", + "port": 3456, + "hot_reload": false, + "enable_streaming_scenario_routing": false, + "respect_requested_model": false, + + "models": { + "default": { + "provider": "opencode-zen", + "model_id": "claude-sonnet-4.5", + "temperature": 0.7, + "max_tokens": 8192 + }, + "background": { + "provider": "opencode-zen", + "model_id": "nemotron-3-ultra-free", + "temperature": 0.5, + "max_tokens": 2048 + }, + "think": { + "provider": "opencode-zen", + "model_id": "claude-opus-4-8", + "temperature": 0.7, + "max_tokens": 8192 + }, + "complex": { + "provider": "opencode-zen", + "model_id": "claude-opus-4-8", + "temperature": 0.7, + "max_tokens": 8192 + }, + "long_context": { + "provider": "opencode-zen", + "model_id": "gemini-3.1-pro", + "temperature": 0.7, + "max_tokens": 16384, + "context_threshold": 80000 + }, + "fast": { + "provider": "opencode-zen", + "model_id": "gemini-3.5-flash", + "temperature": 0.7, + "max_tokens": 4096 + } + }, + + "fallbacks": { + "default": [ + { "provider": "opencode-zen", "model_id": "claude-sonnet-4" }, + { "provider": "opencode-zen", "model_id": "gemini-3.1-pro" } + ], + "background": [ + { "provider": "opencode-zen", "model_id": "mimo-v2.5-free" }, + { "provider": "opencode-zen", "model_id": "deepseek-v4-flash-free" } + ], + "think": [ + { "provider": "opencode-zen", "model_id": "claude-opus-4-6" } + ], + "complex": [ + { "provider": "opencode-zen", "model_id": "claude-opus-4-5" } + ], + "long_context": [ + { "provider": "opencode-zen", "model_id": "claude-sonnet-4.5" } + ], + "fast": [ + { "provider": "opencode-zen", "model_id": "gemini-3-flash" } + ] + }, + + "model_overrides": { + "claude-fable-5": { + "provider": "opencode-zen", + "model_id": "claude-fable-5", + "temperature": 0.7, + "max_tokens": 8192 + }, + "claude-opus-4-8": { + "provider": "opencode-zen", + "model_id": "claude-opus-4-8", + "temperature": 0.7, + "max_tokens": 8192 + }, + "claude-sonnet-4.5": { + "provider": "opencode-zen", + "model_id": "claude-sonnet-4.5", + "temperature": 0.7, + "max_tokens": 8192 + }, + "gemini-3.5-flash": { + "provider": "opencode-zen", + "model_id": "gemini-3.5-flash", + "temperature": 0.7, + "max_tokens": 8192 + }, + "gemini-3.1-pro": { + "provider": "opencode-zen", + "model_id": "gemini-3.1-pro", + "temperature": 0.7, + "max_tokens": 8192 + } + }, + + "opencode_zen": { + "base_url": "https://opencode.ai/zen/v1/chat/completions", + "anthropic_base_url": "https://opencode.ai/zen/v1/messages", + "responses_base_url": "https://opencode.ai/zen/v1/responses", + "gemini_base_url": "https://opencode.ai/zen/v1/models", + "api_key": "${ROUTATIC_PROXY_OPENCODE_ZEN_API_KEY}", + "api_keys": [], + "timeout_ms": 300000, + "streaming_timeout_ms": 600000 + }, + + "logging": { + "level": "info", + "requests": true + } +} +` +} + +// getOpenCodeGoConfig returns the default config optimized for OpenCode Go. +func getOpenCodeGoConfig() string { + // This is the same as getDefaultConfig() but explicit for provider + return `{ + "api_key": "${ROUTATIC_PROXY_API_KEY}", + "host": "127.0.0.1", + "port": 3456, + "hot_reload": false, + "enable_streaming_scenario_routing": false, + "respect_requested_model": false, + "anthropic_first": { + "enabled": false, + "base_url": "https://api.anthropic.com" + }, + "models": { + "background": { + "provider": "opencode-go", + "model_id": "deepseek-v4-flash", + "temperature": 0.5, + "max_tokens": 2048 + }, + "default": { + "provider": "opencode-go", + "model_id": "deepseek-v4-pro", + "temperature": 0.7, + "max_tokens": 8192, + "reasoning_effort": "max", + "thinking": { "type": "enabled" } + }, + "long_context": { + "provider": "opencode-go", + "model_id": "minimax-m3", + "temperature": 0.7, + "max_tokens": 16384, + "context_threshold": 80000 + }, + "think": { + "provider": "opencode-go", + "model_id": "glm-5.2", + "temperature": 0.7, + "max_tokens": 8192 + }, + "complex": { + "provider": "opencode-go", + "model_id": "deepseek-v4-pro", + "temperature": 0.7, + "max_tokens": 8192, + "reasoning_effort": "max", + "thinking": { "type": "enabled" } + }, + "fast": { + "provider": "opencode-go", + "model_id": "deepseek-v4-flash", + "temperature": 0.7, + "max_tokens": 4096 + }, + "glm-5.2": { + "provider": "opencode-go", + "model_id": "glm-5.2", + "temperature": 0.7, + "max_tokens": 8192 + }, + "kimi-k2.7-code": { + "provider": "opencode-go", + "model_id": "kimi-k2.7-code", + "temperature": 0.7, + "max_tokens": 32768 + }, + "qwen3.7-plus": { + "provider": "opencode-go", + "model_id": "qwen3.7-plus", + "temperature": 0.7, + "max_tokens": 8192 + }, + "qwen3.7-max": { + "provider": "opencode-go", + "model_id": "qwen3.7-max", + "temperature": 0.7, + "max_tokens": 8192 + } + }, + "fallbacks": { + "background": [ + { "provider": "opencode-go", "model_id": "qwen3.7-plus" }, + { "provider": "opencode-go", "model_id": "qwen3.7-max" }, + { "provider": "opencode-zen", "model_id": "nemotron-3-ultra-free" }, + { "provider": "opencode-zen", "model_id": "mimo-v2.5-free" }, + { "provider": "opencode-zen", "model_id": "deepseek-v4-flash-free" } + ], + "default": [ + { "provider": "opencode-go", "model_id": "qwen3.7-plus" }, + { "provider": "opencode-go", "model_id": "qwen3.7-max" }, + { "provider": "opencode-zen", "model_id": "nemotron-3-ultra-free" }, + { "provider": "opencode-zen", "model_id": "mimo-v2.5-free" }, + { "provider": "opencode-zen", "model_id": "deepseek-v4-flash-free" } + ], + "long_context": [ + { "provider": "opencode-go", "model_id": "qwen3.7-plus" }, + { "provider": "opencode-go", "model_id": "qwen3.7-max" }, + { "provider": "opencode-zen", "model_id": "nemotron-3-ultra-free" }, + { "provider": "opencode-zen", "model_id": "mimo-v2.5-free" }, + { "provider": "opencode-zen", "model_id": "deepseek-v4-flash-free" } + ], + "think": [ + { "provider": "opencode-go", "model_id": "qwen3.7-plus" }, + { "provider": "opencode-go", "model_id": "qwen3.7-max" }, + { "provider": "opencode-zen", "model_id": "nemotron-3-ultra-free" }, + { "provider": "opencode-zen", "model_id": "mimo-v2.5-free" }, + { "provider": "opencode-zen", "model_id": "deepseek-v4-flash-free" } + ], + "complex": [ + { "provider": "opencode-go", "model_id": "qwen3.7-plus" }, + { "provider": "opencode-go", "model_id": "qwen3.7-max" }, + { "provider": "opencode-zen", "model_id": "nemotron-3-ultra-free" }, + { "provider": "opencode-zen", "model_id": "mimo-v2.5-free" }, + { "provider": "opencode-zen", "model_id": "deepseek-v4-flash-free" } + ], + "fast": [ + { "provider": "opencode-go", "model_id": "qwen3.7-plus" }, + { "provider": "opencode-go", "model_id": "qwen3.7-max" }, + { "provider": "opencode-zen", "model_id": "nemotron-3-ultra-free" }, + { "provider": "opencode-zen", "model_id": "mimo-v2.5-free" }, + { "provider": "opencode-zen", "model_id": "deepseek-v4-flash-free" } + ], + "glm-5.2": [ + { "provider": "opencode-go", "model_id": "glm-5.1" }, + { "provider": "opencode-go", "model_id": "kimi-k2.6" } + ], + "kimi-k2.7-code": [ + { "provider": "opencode-go", "model_id": "kimi-k2.6" }, + { "provider": "opencode-go", "model_id": "glm-5.1" } + ], + "qwen3.7-plus": [ + { "provider": "opencode-go", "model_id": "qwen3.6-plus" }, + { "provider": "opencode-go", "model_id": "kimi-k2.6" } + ], + "qwen3.7-max": [ + { "provider": "opencode-go", "model_id": "qwen3.7-plus" }, + { "provider": "opencode-go", "model_id": "kimi-k2.6" } + ] + }, + "model_overrides": { + "deepseek-v4-pro": { + "provider": "opencode-zen", + "model_id": "deepseek-v4-pro", + "temperature": 0.7, + "max_tokens": 8192, + "reasoning_effort": "max", + "thinking": { + "type": "enabled" + } + }, + "deepseek-v4-flash-free": { + "provider": "opencode-zen", + "model_id": "deepseek-v4-flash-free", + "temperature": 0.7, + "max_tokens": 4096 + }, + "grok-build-0.1": { + "provider": "opencode-zen", + "model_id": "grok-build-0.1", + "temperature": 0.7, + "max_tokens": 4096 + }, + "big-pickle": { + "provider": "opencode-zen", + "model_id": "big-pickle", + "temperature": 0.7, + "max_tokens": 4096 + }, + "mimo-v2.5-free": { + "provider": "opencode-zen", + "model_id": "mimo-v2.5-free", + "temperature": 0.7, + "max_tokens": 4096 + }, + "north-mini-code-free": { + "provider": "opencode-zen", + "model_id": "north-mini-code-free", + "temperature": 0.7, + "max_tokens": 4096 + }, + "nemotron-3-ultra-free": { + "provider": "opencode-zen", + "model_id": "nemotron-3-ultra-free", + "temperature": 0.7, + "max_tokens": 4096 + }, + "claude-fable-5": { + "provider": "opencode-zen", + "model_id": "claude-fable-5", + "temperature": 0.7, + "max_tokens": 8192 + }, + "claude-opus-4-8": { + "provider": "opencode-zen", + "model_id": "claude-opus-4-8", + "temperature": 0.7, + "max_tokens": 8192 + }, + "claude-opus-4-6": { + "provider": "opencode-zen", + "model_id": "claude-opus-4-6", + "temperature": 0.7, + "max_tokens": 8192 + }, + "claude-opus-4-5": { + "provider": "opencode-zen", + "model_id": "claude-opus-4-5", + "temperature": 0.7, + "max_tokens": 8192 + }, + "claude-opus-4-1": { + "provider": "opencode-zen", + "model_id": "claude-opus-4-1", + "temperature": 0.7, + "max_tokens": 8192 + }, + "claude-sonnet-4": { + "provider": "opencode-zen", + "model_id": "claude-sonnet-4", + "temperature": 0.7, + "max_tokens": 8192 + }, + "gemini-3.5-flash": { + "provider": "opencode-zen", + "model_id": "gemini-3.5-flash", + "temperature": 0.7, + "max_tokens": 8192 + }, + "gemini-3.1-pro": { + "provider": "opencode-zen", + "model_id": "gemini-3.1-pro", + "temperature": 0.7, + "max_tokens": 8192 + }, + "gemini-3-flash": { + "provider": "opencode-zen", + "model_id": "gemini-3-flash", + "temperature": 0.7, + "max_tokens": 8192 + } + }, + "opencode_go": { + "base_url": "https://opencode.ai/zen/go/v1/chat/completions", + "anthropic_base_url": "https://opencode.ai/zen/go/v1/messages", + "api_key": "", + "api_keys": [], + "timeout_ms": 300000 + }, + "opencode_zen": { + "base_url": "https://opencode.ai/zen/v1/chat/completions", + "anthropic_base_url": "https://opencode.ai/zen/v1/messages", + "responses_base_url": "https://opencode.ai/zen/v1/responses", + "gemini_base_url": "https://opencode.ai/zen/v1/models", + "api_key": "", + "api_keys": [], + "timeout_ms": 300000 + }, + "logging": { + "level": "info", + "requests": true + } +} +` +} diff --git a/cmd/routatic-proxy/init_provider_test.go b/cmd/routatic-proxy/init_provider_test.go new file mode 100644 index 00000000..315c8ab1 --- /dev/null +++ b/cmd/routatic-proxy/init_provider_test.go @@ -0,0 +1,247 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInitCmd_ProviderFlag(t *testing.T) { + tests := []struct { + name string + provider string + wantProvider string + wantErr bool + wantErrContain string + }{ + { + name: "openrouter provider", + provider: "openrouter", + wantProvider: "openrouter", + }, + { + name: "aws-bedrock provider", + provider: "aws-bedrock", + wantProvider: "aws-bedrock", + }, + { + name: "opencode-zen provider", + provider: "opencode-zen", + wantProvider: "opencode-zen", + }, + { + name: "opencode-go provider", + provider: "opencode-go", + wantProvider: "opencode-go", + }, + { + name: "unknown provider returns error", + provider: "unknown-provider", + wantErr: true, + wantErrContain: "unknown provider", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Use ROUTATIC_PROXY_CONFIG to control config location + t.Setenv("ROUTATIC_PROXY_CONFIG", configPath) + + // Create init command + cmd := initCmd() + cmd.SetArgs([]string{"--provider", tt.provider}) + + // Run the command + err := cmd.Execute() + + if tt.wantErr { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.wantErrContain) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Verify config file was created + data, err := os.ReadFile(configPath) + if err != nil { + t.Errorf("failed to read config file: %v", err) + return + } + + // Parse and verify it's valid JSON + var cfg map[string]interface{} + if err := json.Unmarshal(data, &cfg); err != nil { + t.Errorf("config is not valid JSON: %v", err) + return + } + + // Check that models section exists + models, ok := cfg["models"].(map[string]interface{}) + if !ok { + t.Error("config missing 'models' section") + return + } + + // Verify default model uses the correct provider + defaultModel, ok := models["default"].(map[string]interface{}) + if !ok { + t.Error("config missing 'models.default' section") + return + } + + provider, _ := defaultModel["provider"].(string) + if provider != tt.wantProvider { + t.Errorf("default model provider = %q, want %q", provider, tt.wantProvider) + } + }) + } +} + +func TestInitCmd_NoProviderFlag(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Use ROUTATIC_PROXY_CONFIG to control config location + t.Setenv("ROUTATIC_PROXY_CONFIG", configPath) + + // Create init command without provider flag + cmd := initCmd() + + // Run the command + err := cmd.Execute() + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Verify config file was created + data, err := os.ReadFile(configPath) + if err != nil { + t.Errorf("failed to read config file: %v", err) + return + } + + // Parse and verify it's valid JSON + var cfg map[string]interface{} + if err := json.Unmarshal(data, &cfg); err != nil { + t.Errorf("config is not valid JSON: %v", err) + return + } + + // Default config should use opencode-go + models, ok := cfg["models"].(map[string]interface{}) + if !ok { + t.Error("config missing 'models' section") + return + } + + defaultModel, ok := models["default"].(map[string]interface{}) + if !ok { + t.Error("config missing 'models.default' section") + return + } + + provider, _ := defaultModel["provider"].(string) + if provider != "opencode-go" { + t.Errorf("default model provider = %q, want 'opencode-go'", provider) + } +} + +func TestInitCmd_ConfigAlreadyExists(t *testing.T) { + // Create temp directory with existing config + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + + // Write existing config + if err := os.WriteFile(configPath, []byte(`{"existing": true}`), 0600); err != nil { + t.Fatalf("failed to write existing config: %v", err) + } + + // Use ROUTATIC_PROXY_CONFIG to control config location + t.Setenv("ROUTATIC_PROXY_CONFIG", configPath) + + // Create init command + cmd := initCmd() + + // Run the command - should not error, just inform user + err := cmd.Execute() + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Verify existing config was NOT overwritten + data, err := os.ReadFile(configPath) + if err != nil { + t.Errorf("failed to read config file: %v", err) + return + } + + var cfg map[string]interface{} + if err := json.Unmarshal(data, &cfg); err != nil { + t.Errorf("config is not valid JSON: %v", err) + return + } + + if _, ok := cfg["existing"]; !ok { + t.Error("existing config was overwritten") + } +} + +func TestGetProviderConfig_UnknownProvider(t *testing.T) { + _, err := getProviderConfig("invalid") + if err == nil { + t.Error("expected error for unknown provider") + } +} + +func TestGetProviderConfig_AllProviders(t *testing.T) { + providers := []string{"opencode-go", "opencode-zen", "aws-bedrock", "openrouter"} + + for _, provider := range providers { + t.Run(provider, func(t *testing.T) { + config, err := getProviderConfig(provider) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Verify it's valid JSON + var cfg map[string]interface{} + if err := json.Unmarshal([]byte(config), &cfg); err != nil { + t.Errorf("config for %s is not valid JSON: %v", provider, err) + return + } + + // Verify models section exists + models, ok := cfg["models"].(map[string]interface{}) + if !ok { + t.Errorf("config for %s missing 'models' section", provider) + return + } + + // Verify default model uses correct provider + defaultModel, ok := models["default"].(map[string]interface{}) + if !ok { + t.Errorf("config for %s missing 'models.default' section", provider) + return + } + + actualProvider, _ := defaultModel["provider"].(string) + if actualProvider != provider { + t.Errorf("default model provider = %q, want %q", actualProvider, provider) + } + }) + } +} diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 0763f55a..0aed47a8 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -8,12 +8,16 @@ 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" "github.com/routatic/proxy/internal/server" + "github.com/routatic/proxy/internal/storage" "github.com/spf13/cobra" ) @@ -48,6 +52,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 +92,15 @@ 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) + } + + // Ensure SQLite database exists. + if err := ensureDatabase(); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + var captureLogger *debug.CaptureLogger if cfg.Logging.DebugCapture != nil && cfg.Logging.DebugCapture.Enabled { storage, err := debug.NewStorage(*cfg.Logging.DebugCapture) @@ -252,12 +266,23 @@ func statusCmd() *cobra.Command { // initCmd returns the command to create a default configuration file. func initCmd() *cobra.Command { - return &cobra.Command{ + var provider string + + cmd := &cobra.Command{ Use: "init", Short: "Create default configuration file", + Long: `Create a default configuration file optimized for a specific provider. + +The --provider flag pre-configures the config with provider-specific defaults: + - opencode-go: OpenCode Go subscription ($5/month, powerful coding models) + - opencode-zen: OpenCode Zen (pay-as-you-go, Claude/GPT/Gemini) + - aws-bedrock: AWS Bedrock Mantle (run models on your AWS infrastructure) + - openrouter: OpenRouter (unified API for 100+ models) + +Without --provider, a default config optimized for OpenCode Go is created.`, RunE: func(cmd *cobra.Command, args []string) error { - configDir := getConfigDir() - configPath := filepath.Join(configDir, "config.json") + // Resolve config path, respecting ROUTATIC_PROXY_CONFIG env var. + configPath := config.ResolveConfigPath() // Check if config already exists if _, err := os.Stat(configPath); err == nil { @@ -266,19 +291,54 @@ func initCmd() *cobra.Command { return nil } - if err := os.MkdirAll(configDir, 0700); err != nil { + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } - if err := os.WriteFile(configPath, []byte(getDefaultConfig()), 0600); err != nil { + // Get the appropriate config template + var configContent string + var err error + if provider != "" { + configContent, err = getProviderConfig(provider) + if err != nil { + return err + } + } else { + configContent = getDefaultConfig() + } + + if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { return fmt.Errorf("failed to write config file: %w", err) } - fmt.Printf("Created default config at %s\n", configPath) - fmt.Println("Edit the file and add your OpenCode Go API key.") + // Initialize database. + db, err := storage.Open(storage.DefaultConfig) + if err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + defer func() { _ = db.Close() }() + + // Print helpful message based on provider + fmt.Printf("Created config at %s\n", configPath) + fmt.Printf("Initialized database at %s\n", storage.DefaultConfig.DatabasePath) + if provider != "" { + preset, ok := providerPresets[provider] + if ok { + fmt.Printf("\nProvider: %s\n", preset.Name) + fmt.Printf("Set your API key: export %s=your-api-key-here\n", preset.EnvVarName) + } + } else { + fmt.Println("\nEdit the file and add your OpenCode Go API key.") + fmt.Println("Or use --provider to generate a provider-specific config.") + } return nil }, } + + cmd.Flags().StringVar(&provider, "provider", "", + "Provider preset: opencode-go, opencode-zen, aws-bedrock, openrouter") + + return cmd } // validateCmd returns the command to validate the configuration file. @@ -298,6 +358,11 @@ func validateCmd() *cobra.Command { return fmt.Errorf("invalid config: %w", err) } + // Ensure SQLite database exists. + if err := ensureDatabase(); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + fmt.Println("Configuration is valid!") fmt.Printf(" Host: %s\n", cfg.Host) fmt.Printf(" Port: %d\n", cfg.Port) @@ -417,90 +482,146 @@ 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 } -// getConfigDir returns the default configuration directory path. -func getConfigDir() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", "routatic-proxy") +// 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) + } + + storageCfg := storage.DefaultConfig + if cfg.Storage != nil { + storageCfg = storage.Config{ + DatabasePath: cfg.Storage.DatabasePath, + RetentionDays: cfg.Storage.RetentionDays, + VacuumOnStartup: cfg.Storage.VacuumOnStartup, + WALEnabled: cfg.Storage.WALEnabled, + } + } + + db, err := storage.Open(storageCfg) + if err != nil { + return fmt.Errorf("failed to open storage: %w", err) + } + defer func() { _ = db.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cat, err := catalog.LoadFromSQLite(ctx, db) + 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 } // autostartCmd returns the command to manage autostart on login. diff --git a/cmd/routatic-proxy/main_models_test.go b/cmd/routatic-proxy/main_models_test.go new file mode 100644 index 00000000..f58323a4 --- /dev/null +++ b/cmd/routatic-proxy/main_models_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/routatic/proxy/internal/catalog" + "github.com/routatic/proxy/internal/storage" + "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", "enabled": true}, + "opencode-zen": {"name": "opencode-zen", "base_url": "https://opencode.ai/zen/v1/chat/completions", "enabled": true}, + "openrouter": {"name": "openrouter", "base_url": "https://openrouter.ai/api/v1", "enabled": true} + }, + "models": { + "opencode-go/model-go": {"id": "opencode-go/model-go", "name": "Model Go"}, + "opencode-zen/model-zen": {"id": "opencode-zen/model-zen", "name": "Model Zen"}, + "openrouter/model-router": {"id": "openrouter/model-router", "name": "Model Router"} + } +}` + +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 writeTestConfigWithDB(t *testing.T, dir, dbPath string) string { + t.Helper() + config := `{"api_key": "test-global-key", "storage": {"database_path": "` + dbPath + `"}}` + return writeTestConfig(t, dir, config) +} + +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) + } + jsonPath := filepath.Join(catalogDir, "catalog.json") + if err := os.WriteFile(jsonPath, []byte(content), 0644); err != nil { + t.Fatalf("write catalog: %v", err) + } +} + +func migrateTestCatalogToSQLite(t *testing.T, dir string) { + t.Helper() + + jsonPath := filepath.Join(dir, "catalog", "catalog.json") + dbPath := filepath.Join(dir, "data.db") + + storageCfg := storage.DefaultConfig + storageCfg.DatabasePath = dbPath + + db, err := storage.Open(storageCfg) + if err != nil { + t.Fatalf("open storage: %v", err) + } + defer func() { _ = db.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := catalog.MigrateFromJSON(ctx, db, jsonPath); err != nil { + t.Fatalf("migrate 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() + dbPath := filepath.Join(tmp, "data.db") + writeTestCatalog(t, tmp, catalogFixture) + migrateTestCatalogToSQLite(t, tmp) + configPath := writeTestConfigWithDB(t, tmp, dbPath) + + 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() + dbPath := filepath.Join(tmp, "data.db") + writeTestCatalog(t, tmp, catalogFixture) + migrateTestCatalogToSQLite(t, tmp) + configPath := writeTestConfigWithDB(t, tmp, dbPath) + + 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() + dbPath := filepath.Join(tmp, "data.db") + writeTestCatalog(t, tmp, catalogFixture) + migrateTestCatalogToSQLite(t, tmp) + configPath := writeTestConfigWithDB(t, tmp, dbPath) + + 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 b717c9f7..7dccda79 100644 --- a/cmd/routatic-proxy/ui_darwin.go +++ b/cmd/routatic-proxy/ui_darwin.go @@ -36,14 +36,12 @@ static inline void makeWindowKeyAndActive(void* windowPtr) { static inline void setupMacMenus() { NSMenu *mainMenu = [[NSMenu alloc] init]; - // 1. Application Menu NSMenuItem *appMenuItem = [[NSMenuItem alloc] init]; [mainMenu addItem:appMenuItem]; NSMenu *appMenu = [[NSMenu alloc] init]; [appMenu addItemWithTitle:@"Quit RoutaticProxy" action:@selector(terminate:) keyEquivalent:@"q"]; [appMenuItem setSubmenu:appMenu]; - // 2. Edit Menu (Critical for Copy/Paste) NSMenuItem *editMenuItem = [[NSMenuItem alloc] init]; [mainMenu addItem:editMenuItem]; NSMenu *editMenu = [[NSMenu alloc] initWithTitle:@"Edit"]; @@ -124,12 +122,10 @@ func openWebview() { currentWv.SetTitle("routatic-proxy Console") currentWv.SetSize(860, 560, webview.HintNone) - // Setup Mac copy/paste menu bar setupMenusOnce.Do(func() { C.setupMacMenus() }) - // Register window close observer winPtr := currentWv.Window() C.registerWindowCloseObserver(winPtr) C.makeWindowKeyAndActive(winPtr) @@ -138,9 +134,6 @@ func openWebview() { wvMu.Unlock() } -// uiCmd is the "routatic-proxy ui" command (macOS only). -// It starts the proxy in the same process, then opens a webview dashboard -// with a system tray icon. var uiCmd = &cobra.Command{ Use: "ui", Short: "Launch GUI dashboard (macOS only)", @@ -148,7 +141,6 @@ var uiCmd = &cobra.Command{ The proxy runs in the background; closing the window keeps it running. Use the tray icon to reopen the window or quit entirely.`, RunE: func(cmd *cobra.Command, args []string) error { - // ── 1. Load config ────────────────────────────────────────── configPath, _ := cmd.Flags().GetString("config") if configPath == "" { configPath = config.ResolveConfigPath() @@ -156,7 +148,6 @@ Use the tray icon to reopen the window or quit entirely.`, _ = os.Setenv("ROUTATIC_PROXY_CONFIG", configPath) } - // Auto-initialize config file if it does not exist. if _, err := os.Stat(configPath); os.IsNotExist(err) { slog.Info("Config file not found, auto-initializing default config", "path", configPath) configDir := filepath.Dir(configPath) @@ -173,7 +164,6 @@ Use the tray icon to reopen the window or quit entirely.`, if err != nil { initialConfigValid = false slog.Warn("Failed to load config (will require GUI configuration)", "error", err) - // Construct a valid default config so the proxy structure and GUI can start cfg = &config.Config{ Host: "127.0.0.1", Port: 3456, @@ -195,19 +185,15 @@ Use the tray icon to reopen the window or quit entirely.`, } } - if initialConfigValid { - // Check if keys are placeholders or empty - if cfg.APIKey == "" && len(cfg.APIKeys) == 0 && - (cfg.OpenCodeGo.APIKey == "" || strings.Contains(cfg.OpenCodeGo.APIKey, "${")) && - (cfg.OpenCodeZen.APIKey == "" || strings.Contains(cfg.OpenCodeZen.APIKey, "${")) { - initialConfigValid = false - slog.Info("Config has no valid API keys set yet, waiting for GUI configuration") - } + if initialConfigValid && cfg.APIKey == "" && len(cfg.APIKeys) == 0 && + (cfg.OpenCodeGo.APIKey == "" || strings.Contains(cfg.OpenCodeGo.APIKey, "${")) && + (cfg.OpenCodeZen.APIKey == "" || strings.Contains(cfg.OpenCodeZen.APIKey, "${")) { + initialConfigValid = false + slog.Info("Config has no valid API keys set yet, waiting for GUI configuration") } atomic := config.NewAtomicConfig(cfg, config.ResolveConfigPath()) - // ── 2. Debug capture (optional) ───────────────────────────── var captureLogger *debug.CaptureLogger if cfg.Logging.DebugCapture != nil && cfg.Logging.DebugCapture.Enabled { storage, err := debug.NewStorage(*cfg.Logging.DebugCapture) @@ -218,13 +204,11 @@ Use the tray icon to reopen the window or quit entirely.`, defer func() { _ = captureLogger.Close() }() } - // ── 3. Create proxy server (does not Start() yet) ─────────── proxySrv, err := server.NewServer(atomic, captureLogger) if err != nil { return fmt.Errorf("create proxy server: %w", err) } - // ── 4. Context + signals ──────────────────────────────────── ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -239,11 +223,10 @@ Use the tray icon to reopen the window or quit entirely.`, if stopProxy != nil { _ = stopProxy() } - cancel() // trigger context cancellation so deferred cleanup runs + cancel() tray.Quit() }() - // ── 5. Start proxy ────────────────────────────────────────── proxyErrCh := make(chan error, 1) var isProxyRunning bool var connectedToExisting bool @@ -258,7 +241,6 @@ Use the tray icon to reopen the window or quit entirely.`, return nil } - // Validate key presence dynamically currentCfg := atomic.Get() if currentCfg.APIKey == "" && len(currentCfg.APIKeys) == 0 && (currentCfg.OpenCodeGo.APIKey == "" || strings.Contains(currentCfg.OpenCodeGo.APIKey, "${")) && @@ -266,7 +248,6 @@ Use the tray icon to reopen the window or quit entirely.`, return fmt.Errorf("API Key is empty. Please set it in Settings first") } - // Probe for existing proxy instance before starting a new one. healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", currentCfg.Port) client := &http.Client{Timeout: 2 * time.Second} resp, probeErr := client.Get(healthURL) @@ -274,7 +255,6 @@ Use the tray icon to reopen the window or quit entirely.`, _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() if resp.StatusCode == http.StatusOK { - // External proxy already running — connect to it instead of starting a new one. slog.Info("Existing proxy detected on port, connecting to it", "port", currentCfg.Port) isProxyRunning = true connectedToExisting = true @@ -286,7 +266,6 @@ Use the tray icon to reopen the window or quit entirely.`, } } - // No existing proxy found — start a local instance. isProxyRunning = true connectedToExisting = false if guiSrv != nil { @@ -310,7 +289,6 @@ Use the tray icon to reopen the window or quit entirely.`, select { case proxyErrCh <- err: default: - // Channel already full or nobody listening — error was already logged above. } } }() @@ -332,7 +310,6 @@ Use the tray icon to reopen the window or quit entirely.`, } tray.SetRunning(false) - // Only shut down if we own the server (not connected to an external one). if !wasConnected { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() @@ -350,14 +327,15 @@ 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) @@ -366,10 +344,8 @@ Use the tray icon to reopen the window or quit entirely.`, return fmt.Errorf("start gui server: %w", err) } - // Save parameters globally for the main-thread CGO callbacks globalGUIURL = guiURL - // ── 7. System tray (runs on main thread to prevent Cocoa crashes) ── autostartEnabled := false if home, err := os.UserHomeDir(); err == nil { plistPath := filepath.Join(home, "Library", "LaunchAgents", daemon.LaunchAgent+".plist") @@ -377,7 +353,6 @@ Use the tray icon to reopen the window or quit entirely.`, autostartEnabled = (err == nil) } - // Open webview asynchronously after a short delay on the main thread go func() { time.Sleep(500 * time.Millisecond) C.DispatchOpenWindow() @@ -394,7 +369,6 @@ Use the tray icon to reopen the window or quit entirely.`, guiSrv.SetProxyRunning(true) tray.SetRunning(true) } else { - // Toggle back off in gui if starting failed guiSrv.SetProxyRunning(false) tray.SetRunning(false) } diff --git a/cmd/routatic-proxy/ui_darwin_nocgo.go b/cmd/routatic-proxy/ui_darwin_nocgo.go new file mode 100644 index 00000000..91136c33 --- /dev/null +++ b/cmd/routatic-proxy/ui_darwin_nocgo.go @@ -0,0 +1,13 @@ +//go:build darwin && !cgo + +package main + +import ( + "github.com/spf13/cobra" +) + +func addPlatformCommands(rootCmd *cobra.Command) { +} + +func setupDefaultCommand() { +} diff --git a/cmd/routatic-proxy/ui_linux_nocgo.go b/cmd/routatic-proxy/ui_linux_nocgo.go new file mode 100644 index 00000000..6eb4e879 --- /dev/null +++ b/cmd/routatic-proxy/ui_linux_nocgo.go @@ -0,0 +1,260 @@ +//go:build linux + +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/routatic/proxy/internal/config" + "github.com/routatic/proxy/internal/debug" + "github.com/routatic/proxy/internal/gui" + "github.com/routatic/proxy/internal/server" + "github.com/spf13/cobra" +) + +func openBrowser(target string) error { + cmd := exec.Command("xdg-open", target) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Start() +} + +// uiCmd is the "routatic-proxy ui" command (Linux, no CGO / no tray). +// It starts the proxy in the same process, opens the dashboard in the default +// browser, and waits for SIGINT/SIGTERM. +var uiCmd = &cobra.Command{ + Use: "ui", + Short: "Launch GUI dashboard", + Long: `Start the proxy server and open the graphical dashboard in your browser. +The proxy runs in the background; closing the browser leaves it running. +Press Ctrl+C to stop.`, + RunE: func(cmd *cobra.Command, args []string) error { + // ── 1. Load config ────────────────────────────────────────── + configPath, _ := cmd.Flags().GetString("config") + if configPath == "" { + configPath = config.ResolveConfigPath() + } else { + _ = os.Setenv("ROUTATIC_PROXY_CONFIG", configPath) + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + slog.Info("Config file not found, auto-initializing default config", "path", configPath) + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + if err := os.WriteFile(configPath, []byte(getDefaultConfig()), 0600); err != nil { + return fmt.Errorf("failed to write default config file: %w", err) + } + } + + cfg, err := config.Load() + initialConfigValid := true + if err != nil { + initialConfigValid = false + slog.Warn("Failed to load config (will require GUI configuration)", "error", err) + cfg = &config.Config{ + Host: "127.0.0.1", Port: 3456, + Logging: config.LoggingConfig{Level: "info"}, + OpenCodeGo: config.OpenCodeGoConfig{ + BaseURL: "https://opencode.ai/zen/go/v1/chat/completions", + AnthropicBaseURL: "https://opencode.ai/zen/go/v1/messages", + TimeoutMs: 300000, + }, + OpenCodeZen: config.OpenCodeZenConfig{ + BaseURL: "https://opencode.ai/zen/v1/chat/completions", + AnthropicBaseURL: "https://opencode.ai/zen/v1/messages", + ResponsesBaseURL: "https://opencode.ai/zen/v1/responses", + GeminiBaseURL: "https://opencode.ai/zen/v1/models", + TimeoutMs: 300000, + }, + } + } + + if initialConfigValid && + cfg.APIKey == "" && len(cfg.APIKeys) == 0 && + (cfg.OpenCodeGo.APIKey == "" || strings.Contains(cfg.OpenCodeGo.APIKey, "${")) && + (cfg.OpenCodeZen.APIKey == "" || strings.Contains(cfg.OpenCodeZen.APIKey, "${")) { + initialConfigValid = false + slog.Info("Config has no valid API keys set yet, waiting for GUI configuration") + } + + atomic := config.NewAtomicConfig(cfg, config.ResolveConfigPath()) + + // ── 2. Debug capture (optional) ───────────────────────────── + var captureLogger *debug.CaptureLogger + if cfg.Logging.DebugCapture != nil && cfg.Logging.DebugCapture.Enabled { + storage, err := debug.NewStorage(*cfg.Logging.DebugCapture) + if err != nil { + return fmt.Errorf("failed to create debug storage: %w", err) + } + captureLogger = debug.NewCaptureLogger(storage, true) + defer func() { _ = captureLogger.Close() }() + } + + // ── 3. Create proxy server ────────────────────────────────── + proxySrv, err := server.NewServer(atomic, captureLogger) + if err != nil { + return fmt.Errorf("create proxy server: %w", err) + } + + // ── 4. Context + signals ──────────────────────────────────── + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var startProxy func() error + var stopProxy func() error + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + slog.Info("Received signal, exiting...") + if stopProxy != nil { + _ = stopProxy() + } + cancel() + }() + + // ── 5. Start proxy ────────────────────────────────────────── + var isProxyRunning bool + var connectedToExisting bool + var proxySrvMu sync.Mutex + var guiSrv *gui.Server + + // Function to check and connect to existing proxy + checkExistingProxy := func() bool { + currentCfg := atomic.Get() + healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", currentCfg.Port) + client := &http.Client{Timeout: 2 * time.Second} + resp, probeErr := client.Get(healthURL) + if probeErr == nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + slog.Info("Existing proxy detected on port, connecting to it", "port", currentCfg.Port) + return true + } + } + return false + } + + // Check for existing proxy BEFORE creating GUI server + connectedToExisting = checkExistingProxy() + isProxyRunning = connectedToExisting + + startProxy = func() error { + proxySrvMu.Lock() + defer proxySrvMu.Unlock() + if isProxyRunning { + return nil + } + currentCfg := atomic.Get() + if currentCfg.APIKey == "" && len(currentCfg.APIKeys) == 0 && + (currentCfg.OpenCodeGo.APIKey == "" || strings.Contains(currentCfg.OpenCodeGo.APIKey, "${")) && + (currentCfg.OpenCodeZen.APIKey == "" || strings.Contains(currentCfg.OpenCodeZen.APIKey, "${")) { + return fmt.Errorf("API Key is empty. Please set it in Settings first") + } + + isProxyRunning = true + connectedToExisting = false + if guiSrv != nil { + guiSrv.SetProxyRunning(true) + guiSrv.SetConnectedToExisting(false) + } + go func() { + srvErr := proxySrv.Start() + proxySrvMu.Lock() + isProxyRunning = false + proxySrvMu.Unlock() + if guiSrv != nil { + guiSrv.SetProxyRunning(false) + } + if srvErr != nil && srvErr != http.ErrServerClosed { + slog.Error("proxy server stopped with error", "error", srvErr) + } + }() + return nil + } + + stopProxy = func() error { + proxySrvMu.Lock() + defer proxySrvMu.Unlock() + if !isProxyRunning { + return nil + } + wasConnected := connectedToExisting + isProxyRunning = false + connectedToExisting = false + if guiSrv != nil { + guiSrv.SetProxyRunning(false) + guiSrv.SetConnectedToExisting(false) + } + if !wasConnected { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + return proxySrv.Shutdown(shutdownCtx) + } + return nil + } + + if initialConfigValid { + if err := startProxy(); err != nil { + slog.Warn("Failed to auto-start proxy on boot", "error", err) + } + } + + // ── 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, + CatalogDir: resolveCatalogDir(configPath), + CatalogSourceURL: cfg.Catalog.SourceURL, + }) + + // Set the connected flag now that guiSrv exists + guiSrv.SetProxyRunning(isProxyRunning) + guiSrv.SetConnectedToExisting(connectedToExisting) + + guiURL, err := guiSrv.Start(ctx) + if err != nil { + return fmt.Errorf("start gui server: %w", err) + } + + // ── 7. Open browser ───────────────────────────────────────── + slog.Info("Opening browser", "url", guiURL) + if err := openBrowser(guiURL); err != nil { + slog.Warn("Failed to open browser (xdg-open may not be available)", "error", err) + fmt.Printf("\nDashboard URL: %s\n", guiURL) + } + + // ── 8. Wait for signal ────────────────────────────────────── + fmt.Println("\nPress Ctrl+C to stop the proxy and exit.") + <-ctx.Done() + return nil + }, +} + +func addPlatformCommands(rootCmd *cobra.Command) { + uiCmd.Flags().String("config", "", "Config file path") + rootCmd.AddCommand(uiCmd) +} + +func setupDefaultCommand() {} diff --git a/cmd/routatic-proxy/ui_other.go b/cmd/routatic-proxy/ui_other.go new file mode 100644 index 00000000..93a5dab2 --- /dev/null +++ b/cmd/routatic-proxy/ui_other.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin + +package main + +import "github.com/spf13/cobra" + +func addPlatformCommands(rootCmd *cobra.Command) {} + +func setupDefaultCommand() {} diff --git a/cmd/routatic-proxy/ui_stub.go b/cmd/routatic-proxy/ui_stub.go deleted file mode 100644 index 6c717b6c..00000000 --- a/cmd/routatic-proxy/ui_stub.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build !darwin || !cgo - -package main - -import "github.com/spf13/cobra" - -func addPlatformCommands(rootCmd *cobra.Command) { - // No-op for non-macOS platforms -} - -func setupDefaultCommand() { - // No-op for non-macOS platforms -} diff --git a/configs/config.example.json b/configs/config.example.json index 421a36d7..638e1d0d 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 86777a44..3ac4ed8e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,6 +61,50 @@ 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`. + +### Catalog Schema + +The catalog (`~/.config/routatic-proxy/catalog/catalog.json`) is downloaded from `models.dev` and uses provider-prefixed model keys: + +```json +{ + "providers": { + "opencode-go": { + "name": "opencode-go", + "base_url": "https://opencode.ai/zen/go/v1/chat/completions", + "enabled": true + } + }, + "models": { + "opencode-go/kimi-k2.6": { + "id": "opencode-go/kimi-k2.6", + "name": "Kimi K2.6", + "limit": { "context": 256000 }, + "rates": { "input": 0.5, "output": 1.5 }, + "tool_call": true, + "modalities": { "input": ["text", "image"], "output": ["text"] }, + "reasoning": false + } + } +} +``` + +Key fields: +- `id` — Full model key (`provider/model-name`) +- `name` — Display name +- `limit.context` — Context window size (tokens) +- `rates.input`/`rates.output` — Cost per million tokens +- `tool_call` — Whether the model supports tool calls +- `modalities.input` — `["text"]` or `["text", "image"]` for vision support +- `reasoning` — Whether the model supports reasoning mode + +Resolution in `internal/catalog/resolve.go`: +- `ProviderFromModelKey(key)` extracts the provider prefix +- `ModelNameFromKey(key)` extracts the model name portion +- `ResolvedModel.ModelID` contains the model name only (e.g., `kimi-k2.6`) +- `ResolvedModel.CanonicalName` contains the full key (e.g., `opencode-go/kimi-k2.6`) + ## Request Transformation Claude Code sends Anthropic Messages API format. The proxy transforms to the provider's native format: diff --git a/docs/fedora-setup.md b/docs/fedora-setup.md new file mode 100644 index 00000000..69bb44e3 --- /dev/null +++ b/docs/fedora-setup.md @@ -0,0 +1,506 @@ +# Fedora 44 Setup Guide + +This guide covers setting up, configuring, and using routatic-proxy on Fedora 44. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Installation Methods](#installation-methods) +- [Configuration](#configuration) +- [Running the Proxy](#running-the-proxy) +- [Configuring Claude Code](#configuring-claude-code) +- [Systemd Service Setup](#systemd-service-setup) +- [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +Before installing routatic-proxy, ensure you have: + +1. **An OpenCode account** with an API key from [opencode.ai](https://opencode.ai/) +2. **Claude Code CLI** installed (optional, for using with Claude Code) +3. **Basic familiarity** with the terminal + +### System Requirements + +- Fedora 44 (or compatible Fedora version) +- Internet connectivity for API calls +- At least 100MB disk space + +--- + +## Installation Methods + +### Method 1: Download Pre-built Binary (Recommended) + +Download the latest Linux binary from the [Releases page](https://github.com/routatic/proxy/releases): + +```bash +# Download for x86_64 (most common) +curl -L -o routatic-proxy https://github.com/routatic/proxy/releases/latest/download/routatic-proxy_linux-amd64 + +# Download for ARM64 (aarch64) +curl -L -o routatic-proxy https://github.com/routatic/proxy/releases/latest/download/routatic-proxy_linux-arm64 + +# Make executable and move to PATH +chmod +x routatic-proxy +sudo mv routatic-proxy /usr/local/bin/ + +# Verify installation +routatic-proxy --version +``` + +### Method 2: Build from Source + +Building from source requires Go 1.25.0 or later. + +#### Install Go on Fedora 44 + +```bash +# Install Go using dnf +sudo dnf install golang + +# Verify Go installation +go version +``` + +If the dnf version is older than 1.25.0, install Go manually: + +```bash +# Download Go 1.25 (or latest) +wget https://go.dev/dl/go1.25.0.linux-amd64.tar.gz + +# Extract to /usr/local +sudo tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz + +# Add to PATH (add to ~/.bashrc for persistence) +export PATH=$PATH:/usr/local/go/bin + +# Verify +go version +``` + +#### Build routatic-proxy + +```bash +# Clone the repository +git clone https://github.com/routatic/proxy.git +cd proxy + +# Build the binary +make build + +# The binary is now at bin/routatic-proxy +# Optionally install system-wide +sudo make install + +# Verify +routatic-proxy --version +``` + +### Method 3: Docker + +Install Docker on Fedora 44: + +```bash +# Install Docker +sudo dnf install docker docker-compose + +# Enable and start Docker +sudo systemctl enable --now docker + +# Add your user to docker group (optional, for non-root access) +sudo usermod -aG docker $USER +# Log out and back in for group changes to take effect +``` + +Run routatic-proxy with Docker: + +```bash +# Clone the repository +git clone https://github.com/routatic/proxy.git +cd proxy + +# Create environment file with your API key +cp .env.example .env +# Edit .env and add your API key + +# Build and run +make docker-up + +# Or manually: +docker build -t routatic-proxy . +docker run -d --restart unless-stopped --name routatic-proxy \ + --env-file .env -p 3456:3456 routatic-proxy +``` + +--- + +## Configuration + +### Initialize Configuration + +```bash +# Create default config file +routatic-proxy init +``` + +This creates `~/.config/routatic-proxy/config.json` with default settings. + +### Configure API Key + +You have three options for setting your API key: + +#### Option 1: Environment Variable (Recommended) + +```bash +# Add to ~/.bashrc for persistence +echo 'export ROUTATIC_PROXY_API_KEY=sk-opencode-your-key-here' >> ~/.bashrc +source ~/.bashrc +``` + +#### Option 2: Edit Config File + +```bash +# Edit the config file +nano ~/.config/routatic-proxy/config.json +``` + +Find the `api_key` field and replace it: + +```json +{ + "api_key": "sk-opencode-your-key-here", + ... +} +``` + +#### Option 3: Provider-Specific Keys + +For advanced setups with multiple providers: + +```bash +# OpenCode Go key +export ROUTATIC_PROXY_OPENCODE_GO_API_KEY=sk-opencode-go-key + +# OpenCode Zen key +export ROUTATIC_PROXY_OPENCODE_ZEN_API_KEY=sk-opencode-zen-key + +# AWS Bedrock key +export ROUTATIC_PROXY_AWS_BEDROCK_API_KEY=your-bedrock-key +``` + +### Validate Configuration + +```bash +routatic-proxy validate +``` + +### View Available Models + +```bash +routatic-proxy models +``` + +--- + +## Running the Proxy + +### Foreground Mode + +```bash +routatic-proxy serve +``` + +The proxy runs on `http://127.0.0.1:3456` by default. Press `Ctrl+C` to stop. + +### Background Mode + +```bash +# Start in background +routatic-proxy serve -b + +# Check status +routatic-proxy status + +# Stop the proxy +routatic-proxy stop +``` + +### Custom Port + +```bash +routatic-proxy serve --port 8080 +``` + +--- + +## Configuring Claude Code + +### Install Claude Code CLI + +If you haven't installed Claude Code yet: + +```bash +# Install via npm (requires Node.js) +npm install -g @anthropic-ai/claude-code + +# Or download directly +curl -L https://claude.ai/code/install.sh | bash +``` + +### Environment Variables + +Set the environment variables to route Claude Code through routatic-proxy: + +```bash +# Add to ~/.bashrc for persistence +echo 'export ANTHROPIC_BASE_URL=http://127.0.0.1:3456' >> ~/.bashrc +echo 'export ANTHROPIC_AUTH_TOKEN=unused' >> ~/.bashrc +source ~/.bashrc +``` + +### Run Claude Code + +```bash +claude +``` + +Claude Code will now route all requests through routatic-proxy to your configured upstream providers. + +--- + +## Systemd Service Setup + +For production use, run routatic-proxy as a systemd service. + +### Create Service File + +```bash +sudo nano /etc/systemd/system/routatic-proxy.service +``` + +Paste the following content: + +```ini +[Unit] +Description=Routatic Proxy Service +After=network.target + +[Service] +Type=simple +User=%USER% +Group=%USER% +WorkingDirectory=/home/%USER% +ExecStart=/usr/local/bin/routatic-proxy serve +Restart=on-failure +RestartSec=5 + +# Environment variables +Environment="ROUTATIC_PROXY_API_KEY=sk-opencode-your-key-here" + +# Or load from a file +# EnvironmentFile=/home/%USER%/.config/routatic-proxy/env + +# Security settings +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +Replace `%USER%` with your actual username. + +### Enable and Start Service + +```bash +# Reload systemd daemon +sudo systemctl daemon-reload + +# Enable auto-start on boot +sudo systemctl enable routatic-proxy + +# Start the service +sudo systemctl start routatic-proxy + +# Check status +sudo systemctl status routatic-proxy + +# View logs +journalctl -u routatic-proxy -f +``` + +### Managing the Service + +```bash +# Stop +sudo systemctl stop routatic-proxy + +# Restart +sudo systemctl restart routatic-proxy + +# View logs +journalctl -u routatic-proxy --since "1 hour ago" +``` + +--- + +## Auto-start on Login + +For per-user auto-start without systemd: + +```bash +# Enable autostart +routatic-proxy autostart enable + +# Check status +routatic-proxy autostart status + +# Disable autostart +routatic-proxy autostart disable +``` + +--- + +## Troubleshooting + +### Common Issues + +#### 1. Port Already in Use + +```bash +# Check what's using port 3456 +sudo ss -tlnp | grep 3456 + +# Kill the process if needed +sudo kill -9 + +# Or use a different port +routatic-proxy serve --port 8080 +``` + +#### 2. Permission Denied + +```bash +# Ensure the binary is executable +chmod +x /usr/local/bin/routatic-proxy + +# Check config directory permissions +ls -la ~/.config/routatic-proxy/ +``` + +#### 3. Connection Refused + +```bash +# Check if the proxy is running +routatic-proxy status + +# Check firewall (Fedora uses firewalld) +sudo firewall-cmd --list-ports +sudo firewall-cmd --add-port=3456/tcp --permanent +sudo firewall-cmd --reload +``` + +#### 4. API Key Not Recognized + +```bash +# Verify environment variable +echo $ROUTATIC_PROXY_API_KEY + +# Check config file +cat ~/.config/routatic-proxy/config.json | grep api_key + +# Validate config +routatic-proxy validate +``` + +### Debug Mode + +Enable verbose logging for troubleshooting: + +```bash +# Set log level via environment +export ROUTATIC_PROXY_LOG_LEVEL=debug +routatic-proxy serve + +# Or in config file +# ~/.config/routatic-proxy/config.json: +{ + "logging": { + "level": "debug", + "requests": true + } +} +``` + +### SELinux Considerations + +Fedora uses SELinux by default. If you encounter permission issues: + +```bash +# Check SELinux status +sestatus + +# If enforcing and having issues, check audit logs +sudo ausearch -m avc -ts recent + +# For custom binary locations, you may need to set context +sudo chcon -t bin_t /usr/local/bin/routatic-proxy +``` + +### View Logs + +```bash +# If running as systemd service +journalctl -u routatic-proxy -f + +# If running in background mode +# Logs go to stdout, view with: +routatic-proxy logs +``` + +--- + +## Updating + +### Binary Update + +```bash +# Check for updates +routatic-proxy update --check + +# Update to latest version +routatic-proxy update + +# Skip confirmation +routatic-proxy update --yes +``` + +### Manual Update + +```bash +# Download new version +curl -L -o routatic-proxy https://github.com/routatic/proxy/releases/latest/download/routatic-proxy_linux-amd64 +chmod +x routatic-proxy +sudo mv routatic-proxy /usr/local/bin/ +``` + +--- + +## Additional Resources + +- [CONFIGURATION.md](../CONFIGURATION.md) - Full configuration reference +- [MODELS.md](../MODELS.md) - Model capabilities and routing +- [TROUBLESHOOTING.md](../TROUBLESHOOTING.md) - General troubleshooting guide +- [CONTRIBUTING.md](../CONTRIBUTING.md) - Development setup + +--- + +## Getting Help + +- **Discord**: [Join the community](https://discord.gg/pUrfwfTFxM) +- **GitHub Issues**: [Report bugs or request features](https://github.com/routatic/proxy/issues) diff --git a/docs/howto-add-model.md b/docs/howto-add-model.md index 72c23e44..ada87f05 100644 --- a/docs/howto-add-model.md +++ b/docs/howto-add-model.md @@ -17,22 +17,7 @@ Determine which upstream provider the model uses and which endpoint format it ac | `aws-bedrock` | `/v1/chat/completions` | OpenAI Chat Completions (Bedrock Mantle) | | `aws-bedrock` | `/v1/messages` | Anthropic Messages (Bedrock Mantle, requires `wire_format: "anthropic"`) | -## Step 2: Add Model Metadata - -Edit `internal/config/model_registry.go` and add the model to `modelMetadata`: - -```go -"my-new-model": { - ContextWindow: 256000, - MaxOutputTokens: 8192, - Vision: false, - SupportsTools: true, -}, -``` - -This metadata is used by `ResolveModelConfig` to fill in defaults when the model is referenced in config. - -## Step 3: Add Endpoint Classification (Zen only) +## Step 2: Add Endpoint Classification (Zen only) If the model uses Zen, add it to the appropriate classifier in `internal/models/classifier.go`: @@ -78,7 +63,7 @@ func IsAnthropicModel(modelID string) bool { **Note:** These classification functions are shared between `internal/client` and `internal/provider` packages to ensure consistent routing. -## Step 4: Add to Config +## Step 3: Add to Config Add the model to your `config.json`: @@ -124,7 +109,7 @@ Add the model to your `config.json`: } ``` -## Step 5: Test +## Step 4: Test ```bash # Validate config @@ -173,17 +158,66 @@ This forces the request through the Chat Completions transform path. ### Models with vision support -Set `"vision": true` in the model metadata to enable image routing: +Set `"vision": true` in the model config to enable image routing: -```go -"my-vision-model": { - ContextWindow: 256000, - MaxOutputTokens: 8192, - Vision: true, - SupportsTools: true, -}, +```json +{ + "my-vision-model": { + "provider": "opencode-go", + "model_id": "my-vision-model", + "vision": true + } +} ``` ### Temperature constraints Some models have hard temperature requirements (e.g., kimi-k2.7-code requires temperature=1). Add constraints in `constrainTemperature` in `internal/transformer/request.go`. + +## Cost-Based Routing + +When `cost_routing.enabled` is true, the proxy uses a catalog of model pricing data to automatically select the cheapest eligible model for each scenario. + +The catalog is downloaded from `models.dev` and cached locally in `~/.config/routatic-proxy/catalog/`. The catalog schema uses provider-prefixed model keys: + +```json +{ + "providers": { + "opencode-go": { + "name": "opencode-go", + "base_url": "https://opencode.ai/zen/go/v1/chat/completions", + "enabled": true + } + }, + "models": { + "opencode-go/my-new-model": { + "id": "opencode-go/my-new-model", + "name": "My New Model", + "limit": { "context": 128000 }, + "rates": { "input": 1.0, "output": 2.0 }, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] } + } + } +} +``` + +### Key catalog fields: + +| Field | Description | +|-------|-------------| +| `id` | Full model key (`provider/model-name`) | +| `name` | Display name | +| `limit.context` | Context window size (tokens) | +| `rates.input` | Cost per million input tokens | +| `rates.output` | Cost per million output tokens | +| `tool_call` | Whether the model supports tools | +| `modalities.input` | Input types: `["text"]` or `["text", "image"]` for vision | +| `modalities.output` | Output types: usually `["text"]` | +| `reasoning` | Whether the model supports reasoning mode | + +To add a model to the cost-based routing catalog, submit a PR to the models.dev repository or run: + +```bash +routatic-proxy catalog sync --force +``` diff --git a/docs/howto-custom-routing.md b/docs/howto-custom-routing.md index 0e9d8f97..86ffedae 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 b4402f05..aed2b54b 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", @@ -251,6 +261,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/go.mod b/go.mod index 6a9953e3..8fe8cc44 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( require ( github.com/dlclark/regexp2 v1.10.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect @@ -20,8 +21,15 @@ require ( github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect github.com/go-stack/stack v1.8.0 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.5 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.53.0 // indirect ) diff --git a/go.sum b/go.sum index 1c00596a..75144c20 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= @@ -24,16 +26,24 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= @@ -48,9 +58,18 @@ github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6 github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/internal/catalog/export.go b/internal/catalog/export.go new file mode 100644 index 00000000..45b4688f --- /dev/null +++ b/internal/catalog/export.go @@ -0,0 +1,41 @@ +package catalog + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// WriteFile marshals a catalog to JSON and writes it atomically to path. +func WriteFile(path string, catalog *Catalog) error { + if catalog == nil { + return fmt.Errorf("catalog is nil") + } + + data, err := json.MarshalIndent(catalog, "", " ") + if err != nil { + return fmt.Errorf("marshal catalog: %w", err) + } + + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("write catalog temp file: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("rename catalog file: %w", err) + } + + return nil +} + +// WriteFileToDir writes the catalog to dir/catalog.json atomically. +func WriteFileToDir(dir string, catalog *Catalog) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + return WriteFile(filepath.Join(dir, catalogFileName), catalog) +} diff --git a/internal/catalog/index.go b/internal/catalog/index.go new file mode 100644 index 00000000..3338e438 --- /dev/null +++ b/internal/catalog/index.go @@ -0,0 +1,121 @@ +package catalog + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +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++ + + prefix := providerName + "/" + for modelKey := range catalog.Models { + if strings.HasPrefix(modelKey, prefix) { + providerModels[providerName] = append(providerModels[providerName], modelKey) + } + } + } + + 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 00000000..94dfe2c2 --- /dev/null +++ b/internal/catalog/index_test.go @@ -0,0 +1,168 @@ +package catalog + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func ptr(b bool) *bool { return &b } + +func TestIndex_BuildProviderIndex_Valid(t *testing.T) { + cat := 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{ + "openai/gpt-4": { + ID: "openai/gpt-4", + Name: "gpt-4", + }, + "anthropic/claude-3": { + ID: "anthropic/claude-3", + Name: "claude-3", + }, + "openai/gpt-3.5": { + ID: "openai/gpt-3.5", + Name: "gpt-3.5", + }, + }, + } + + idx, err := BuildProviderIndex(cat) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := map[string][]string{ + "openai": {"openai/gpt-3.5", "openai/gpt-4"}, + "anthropic": {"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) { + cat := Catalog{ + Providers: map[string]Provider{ + "disabled": {Name: "disabled", Enabled: ptr(false)}, + }, + Models: map[string]Model{ + "disabled/gpt-4": {ID: "disabled/gpt-4", Name: "gpt-4"}, + }, + } + + _, err := BuildProviderIndex(cat) + if err == nil { + t.Fatalf("expected error for no enabled providers, got nil") + } +} + +func TestIndex_EmptyModels(t *testing.T) { + cat := Catalog{ + Providers: map[string]Provider{ + "openai": {Name: "openai"}, + }, + Models: map[string]Model{}, + } + + _, err := BuildProviderIndex(cat) + if err == nil { + t.Fatalf("expected error for empty models, got nil") + } +} + +func TestIndex_ModelsNoMatchEnabledProviders(t *testing.T) { + cat := Catalog{ + Providers: map[string]Provider{ + "openai": {Name: "openai"}, + "disabled": {Name: "disabled", Enabled: ptr(false)}, + }, + Models: map[string]Model{ + "disabled/only-model": {ID: "disabled/only-model", Name: "only-model"}, + }, + } + + _, err := BuildProviderIndex(cat) + if err == nil { + t.Fatalf("expected error for no models matching enabled providers, got nil") + } +} + +func TestIndex_WriteReadRoundTrip(t *testing.T) { + dir := t.TempDir() + + cat := Catalog{ + Providers: map[string]Provider{ + "openai": {Name: "openai"}, + }, + Models: map[string]Model{ + "openai/gpt-4": {ID: "openai/gpt-4", Name: "gpt-4"}, + }, + } + + idx, err := BuildProviderIndex(cat) + if err != nil { + t.Fatalf("BuildProviderIndex: %v", err) + } + + if err := idx.Write(dir); err != nil { + t.Fatalf("Write: %v", err) + } + + readIdx, err := ReadProviderIndex(dir) + if err != nil { + t.Fatalf("ReadProviderIndex: %v", err) + } + + if !reflect.DeepEqual(idx.ProviderModels, readIdx.ProviderModels) { + t.Fatalf("round-trip mismatch: got %+v, want %+v", readIdx.ProviderModels, idx.ProviderModels) + } +} + +func TestIndex_ReadMissingFile(t *testing.T) { + dir := t.TempDir() + + _, err := ReadProviderIndex(dir) + if err == nil { + t.Fatal("expected error for missing index file") + } +} + +func TestIndex_WriteToMissingDir(t *testing.T) { + idx := &ProviderModelIndex{ProviderModels: map[string][]string{"p": {"m"}}} + err := idx.Write("/nonexistent/path") + if err == nil { + t.Fatal("expected error for missing directory") + } +} + +func TestIndex_WriteNilIndex(t *testing.T) { + dir := t.TempDir() + var idx *ProviderModelIndex + err := idx.Write(dir) + if err == nil { + t.Fatal("expected error for nil index") + } +} + +func TestReadProviderIndex_InvalidJSON(t *testing.T) { + dir := t.TempDir() + badPath := filepath.Join(dir, "provider_model_index.json") + if err := os.WriteFile(badPath, []byte("not json"), 0644); err != nil { + t.Fatal(err) + } + _, err := ReadProviderIndex(dir) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} diff --git a/internal/catalog/load.go b/internal/catalog/load.go new file mode 100644 index 00000000..349966ed --- /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 key, model := range catalog.Models { + provider := ProviderFromModelKey(key) + if provider != "" { + 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 key := range catalog.Models { + provider := ProviderFromModelKey(key) + if provider == "" { + return fmt.Errorf("model key %q does not include a provider prefix (expected format: provider/model)", key) + } + if _, ok := catalog.Providers[provider]; !ok { + return fmt.Errorf("model key %q references unknown provider %q", key, provider) + } + } + + return nil +} diff --git a/internal/catalog/load_test.go b/internal/catalog/load_test.go new file mode 100644 index 00000000..9b842259 --- /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{ + "p1/m1": {ID: "p1/m1", Name: "m1"}, + }, + }) + + _, 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{ + "unknown/m1": {ID: "unknown/m1", Name: "m1"}, + }, + }) + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for unknown provider") + } +} + +func TestLoad_ModelKeyNoProviderPrefix(t *testing.T) { + path := writeTempCatalog(t, Catalog{ + Providers: map[string]Provider{ + "p1": {Name: "p1"}, + }, + Models: map[string]Model{ + "no-prefix-model": {ID: "no-prefix-model", Name: "no-prefix-model"}, + }, + }) + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for model key without provider prefix") + } +} + +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{ + "opencode-go/model-a": { + ID: "opencode-go/model-a", + Name: "model-a", + }, + "other/model-b": { + ID: "other/model-b", + Name: "model-b", + }, + }, + }) + + 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), 1; 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 00000000..ae16e77c --- /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 00000000..0889cc45 --- /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/migrate.go b/internal/catalog/migrate.go new file mode 100644 index 00000000..a857ece4 --- /dev/null +++ b/internal/catalog/migrate.go @@ -0,0 +1,377 @@ +package catalog + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/routatic/proxy/internal/storage" +) + +// Migration stats record +type MigrationStats struct { + Providers int + Models int +} + +// MigrateFromJSON reads existing catalog.json and imports to SQLite. +// Returns true if migration happened, false if JSON didn't exist or already migrated. +func MigrateFromJSON(ctx context.Context, db *storage.Database, jsonPath string) (bool, error) { + repo := storage.NewCatalogRepo(db) + + lastSync, err := repo.LastSync(ctx) + if err != nil { + return false, fmt.Errorf("check last sync: %w", err) + } + if !lastSync.IsZero() { + return false, nil + } + + idx, err := Load(jsonPath) + if err != nil { + return false, fmt.Errorf("load catalog from JSON: %w", err) + } + + providers := make([]storage.ProviderRecord, 0, len(idx.Providers)) + for name, p := range idx.Providers { + providers = append(providers, storage.ProviderRecord{ + Name: name, + BaseURL: p.BaseURL, + APIKey: p.APIKey, + Enabled: p.Enabled, + AnthropicToolsDisabled: p.AnthropicToolsDisabled, + }) + } + + models := make([]storage.ModelRecord, 0, len(idx.Models)) + for key, m := range idx.Models { + models = append(models, storage.ModelRecord{ + ID: key, + Name: m.Name, + Reasoning: m.Reasoning, + ToolCall: m.ToolCall, + Vision: m.SupportsVision(), + ContextWindow: m.ContextWindow(), + CostInput: m.CostInputPerM(), + CostOutput: m.CostOutputPerM(), + }) + } + + if err := repo.UpsertBatch(ctx, providers, models); err != nil { + return false, fmt.Errorf("import catalog to SQLite: %w", err) + } + + return true, nil +} + +// ExportJSON exports SQLite catalog to JSON for backup/debugging. +func ExportJSON(ctx context.Context, db *storage.Database, jsonPath string) error { + repo := storage.NewCatalogRepo(db) + + idx, err := repo.Load(ctx) + if err != nil { + return fmt.Errorf("load catalog from SQLite: %w", err) + } + + catalog := &Catalog{ + Providers: make(map[string]Provider, len(idx.Providers)), + Models: make(map[string]Model, len(idx.Models)), + } + + for name, p := range idx.Providers { + catalog.Providers[name] = Provider{ + Name: p.Name, + BaseURL: p.BaseURL, + APIKey: p.APIKey, + Enabled: p.Enabled, + AnthropicToolsDisabled: p.AnthropicToolsDisabled, + } + } + + for key, m := range idx.Models { + model := Model{ + ID: ModelNameFromKey(key), + Name: m.Name, + Reasoning: m.Reasoning, + ToolCall: m.ToolCall, + } + + if m.Vision { + model.Modalities.Input = []string{"text", "image"} + } else { + model.Modalities.Input = []string{"text"} + } + model.Modalities.Output = []string{"text"} + + if m.Limit != nil { + model.Limit = &Limit{Context: m.Limit.Context} + } + if m.Rates != nil { + model.Rates = &Rates{ + Input: m.Rates.Input, + Output: m.Rates.Output, + } + } + + catalog.Models[key] = model + } + + return WriteFile(jsonPath, catalog) +} + +// LoadFromSQLite loads the catalog from SQLite and returns an IndexedCatalog. +func LoadFromSQLite(ctx context.Context, db *storage.Database) (*IndexedCatalog, error) { + repo := storage.NewCatalogRepo(db) + + storageIdx, err := repo.Load(ctx) + if err != nil { + return nil, err + } + + cat := &Catalog{ + Providers: make(map[string]Provider, len(storageIdx.Providers)), + Models: make(map[string]Model, len(storageIdx.Models)), + } + + for name, p := range storageIdx.Providers { + cat.Providers[name] = Provider{ + Name: p.Name, + BaseURL: p.BaseURL, + APIKey: p.APIKey, + Enabled: p.Enabled, + AnthropicToolsDisabled: p.AnthropicToolsDisabled, + } + } + + for key, m := range storageIdx.Models { + model := Model{ + ID: ModelNameFromKey(key), + Name: m.Name, + Reasoning: m.Reasoning, + ToolCall: m.ToolCall, + } + + if m.Vision { + model.Modalities.Input = []string{"text", "image"} + } else { + model.Modalities.Input = []string{"text"} + } + model.Modalities.Output = []string{"text"} + + if m.Limit != nil { + model.Limit = &Limit{Context: m.Limit.Context} + } + if m.Rates != nil { + model.Rates = &Rates{ + Input: m.Rates.Input, + Output: m.Rates.Output, + } + } + + cat.Models[key] = model + } + + idx := &IndexedCatalog{ + Catalog: *cat, + ProviderModels: make(map[string][]Model, len(storageIdx.ProviderModels)), + } + + for prov, models := range storageIdx.ProviderModels { + converted := make([]Model, len(models)) + for i, m := range models { + converted[i] = Model{ + ID: m.ID, + Name: m.Name, + Reasoning: m.Reasoning, + ToolCall: m.ToolCall, + } + if m.Vision { + converted[i].Modalities.Input = []string{"text", "image"} + } else { + converted[i].Modalities.Input = []string{"text"} + } + converted[i].Modalities.Output = []string{"text"} + if m.Limit != nil { + converted[i].Limit = &Limit{Context: m.Limit.Context} + } + if m.Rates != nil { + converted[i].Rates = &Rates{Input: m.Rates.Input, Output: m.Rates.Output} + } + } + idx.ProviderModels[prov] = converted + } + + return idx, nil +} + +// SyncToSQLite downloads the catalog from sourceURL and imports it to SQLite. +func SyncToSQLite(ctx context.Context, db *storage.Database, sourceURL string) error { + if sourceURL == "" { + return fmt.Errorf("source URL is required") + } + if db == nil { + return fmt.Errorf("database is required") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return 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 fmt.Errorf("fetch catalog: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode) + } + + limited := http.MaxBytesReader(nil, resp.Body, maxCatalogBytes) + body, err := io.ReadAll(limited) + if err != nil { + return fmt.Errorf("read catalog: %w", err) + } + + var env envelope + if err := json.Unmarshal(body, &env); err != nil { + return fmt.Errorf("parse catalog: %w", err) + } + if env.Models == nil || env.Providers == nil { + return fmt.Errorf("catalog must contain models and providers objects") + } + + var catalog Catalog + if err := json.Unmarshal(body, &catalog); err != nil { + return fmt.Errorf("parse catalog contents: %w", err) + } + + providers := make([]storage.ProviderRecord, 0, len(catalog.Providers)) + for name, p := range catalog.Providers { + providers = append(providers, storage.ProviderRecord{ + Name: name, + BaseURL: p.BaseURL, + APIKey: p.APIKey, + Enabled: p.Enabled, + AnthropicToolsDisabled: p.AnthropicToolsDisabled, + }) + } + + models := make([]storage.ModelRecord, 0, len(catalog.Models)) + for key, m := range catalog.Models { + models = append(models, storage.ModelRecord{ + ID: key, + Name: m.Name, + Reasoning: m.Reasoning, + ToolCall: m.ToolCall, + Vision: m.SupportsVision(), + ContextWindow: m.ContextWindow(), + CostInput: m.CostInputPerM(), + CostOutput: m.CostOutputPerM(), + }) + } + + repo := storage.NewCatalogRepo(db) + if err := repo.UpsertBatch(ctx, providers, models); err != nil { + return fmt.Errorf("upsert catalog: %w", err) + } + + return nil +} + +// SyncStats holds statistics from a catalog sync operation. +type SyncStats struct { + Providers int + Models int + Duration time.Duration +} + +// SyncToSQLiteWithStats downloads the catalog and returns sync statistics. +func SyncToSQLiteWithStats(ctx context.Context, db *storage.Database, sourceURL string) (*SyncStats, error) { + start := time.Now() + + if sourceURL == "" { + return nil, fmt.Errorf("source URL is required") + } + if db == nil { + return nil, fmt.Errorf("database is required") + } + + req, err := http.NewRequestWithContext(ctx, 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) + } + + providers := make([]storage.ProviderRecord, 0, len(catalog.Providers)) + for name, p := range catalog.Providers { + providers = append(providers, storage.ProviderRecord{ + Name: name, + BaseURL: p.BaseURL, + APIKey: p.APIKey, + Enabled: p.Enabled, + AnthropicToolsDisabled: p.AnthropicToolsDisabled, + }) + } + + models := make([]storage.ModelRecord, 0, len(catalog.Models)) + for key, m := range catalog.Models { + models = append(models, storage.ModelRecord{ + ID: key, + Name: m.Name, + Reasoning: m.Reasoning, + ToolCall: m.ToolCall, + Vision: m.SupportsVision(), + ContextWindow: m.ContextWindow(), + CostInput: m.CostInputPerM(), + CostOutput: m.CostOutputPerM(), + }) + } + + repo := storage.NewCatalogRepo(db) + if err := repo.UpsertBatch(ctx, providers, models); err != nil { + return nil, fmt.Errorf("upsert catalog: %w", err) + } + + return &SyncStats{ + Providers: len(providers), + Models: len(models), + Duration: time.Since(start), + }, nil +} diff --git a/internal/catalog/migrate_test.go b/internal/catalog/migrate_test.go new file mode 100644 index 00000000..31b53890 --- /dev/null +++ b/internal/catalog/migrate_test.go @@ -0,0 +1,72 @@ +package catalog + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/routatic/proxy/internal/storage" +) + +const testCatalogFixture = `{ + "providers": { + "opencode-go": {"name": "opencode-go", "base_url": "https://opencode.ai/zen/go/v1/chat/completions", "enabled": true}, + "opencode-zen": {"name": "opencode-zen", "base_url": "https://opencode.ai/zen/v1/chat/completions", "enabled": true} + }, + "models": { + "opencode-go/model-go": {"id": "opencode-go/model-go", "name": "Model Go"}, + "opencode-zen/model-zen": {"id": "opencode-zen/model-zen", "name": "Model Zen"} + } +}` + +func TestMigrateFromJSON(t *testing.T) { + tmp := t.TempDir() + + catalogDir := filepath.Join(tmp, "catalog") + if err := os.MkdirAll(catalogDir, 0755); err != nil { + t.Fatalf("mkdir catalog: %v", err) + } + jsonPath := filepath.Join(catalogDir, "catalog.json") + if err := os.WriteFile(jsonPath, []byte(testCatalogFixture), 0644); err != nil { + t.Fatalf("write catalog: %v", err) + } + + dbPath := filepath.Join(tmp, "data.db") + storageCfg := storage.DefaultConfig + storageCfg.DatabasePath = dbPath + + db, err := storage.Open(storageCfg) + if err != nil { + t.Fatalf("open storage: %v", err) + } + defer func() { _ = db.Close() }() + + ctx := context.Background() + + start := time.Now() + migrated, err := MigrateFromJSON(ctx, db, jsonPath) + elapsed := time.Since(start) + + t.Logf("MigrateFromJSON took: %v", elapsed) + + if err != nil { + t.Fatalf("MigrateFromJSON: %v", err) + } + if !migrated { + t.Fatal("expected migrated=true, got false") + } + + idx, err := LoadFromSQLite(ctx, db) + if err != nil { + t.Fatalf("LoadFromSQLite: %v", err) + } + + if len(idx.Providers) != 2 { + t.Errorf("expected 2 providers, got %d", len(idx.Providers)) + } + if len(idx.Models) != 2 { + t.Errorf("expected 2 models, got %d", len(idx.Models)) + } +} diff --git a/internal/catalog/resolve.go b/internal/catalog/resolve.go new file mode 100644 index 00000000..f78b9122 --- /dev/null +++ b/internal/catalog/resolve.go @@ -0,0 +1,226 @@ +package catalog + +import ( + "errors" + "fmt" + "sort" + "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 ProviderFromModelKey(modelKey) != 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, then by key suffix. All matches are +// collected before checking provider availability, so an enabled provider on a lower-priority +// match won't be shadowed by a disabled provider on a higher-priority match. +func (ic *IndexedCatalog) ResolveShort(short string) (ResolvedModel, error) { + if model, ok := ic.Models[short]; ok { + return ic.resolveWithFirstEnabledProvider(model, short) + } + + var matches []string + for key, model := range ic.Models { + if model.Name == short { + matches = append(matches, key) + } + } + for key := range ic.Models { + if modelNameFromKey(key) == short { + matches = append(matches, key) + } + } + + if len(matches) > 0 { + return ic.resolveFromMatches(short, matches) + } + + return ResolvedModel{}, fmt.Errorf("unknown short model id: %q", short) +} + +func (ic *IndexedCatalog) resolveFromMatches(short string, matches []string) (ResolvedModel, error) { + sort.Strings(matches) + + var enabled []string + var missingProviders []string + var disabledProviders []string + + for _, key := range matches { + providerName := ProviderFromModelKey(key) + provider, ok := ic.Providers[providerName] + if !ok { + missingProviders = append(missingProviders, providerName) + continue + } + if provider.Enabled != nil && !*provider.Enabled { + disabledProviders = append(disabledProviders, providerName) + continue + } + enabled = append(enabled, key) + } + + if len(enabled) == 0 { + if len(missingProviders) > 0 && len(disabledProviders) == 0 { + return ResolvedModel{}, fmt.Errorf("model %q exists but provider(s) %q not found in catalog", short, strings.Join(missingProviders, ", ")) + } + if len(disabledProviders) > 0 && len(missingProviders) == 0 { + return ResolvedModel{}, fmt.Errorf("model %q exists but all providers %q are disabled", short, strings.Join(disabledProviders, ", ")) + } + return ResolvedModel{}, fmt.Errorf("model %q exists but providers %q not found and providers %q are disabled", short, strings.Join(missingProviders, ", "), strings.Join(disabledProviders, ", ")) + } + + if len(enabled) == 1 { + key := enabled[0] + model := ic.Models[key] + return ic.resolveWithFirstEnabledProvider(model, key) + } + + var providers []string + for _, key := range enabled { + providers = append(providers, ProviderFromModelKey(key)) + } + sort.Strings(providers) + return ResolvedModel{}, fmt.Errorf("ambiguous model %q: available on multiple providers [%s] - use provider/model-id format", short, strings.Join(providers, ", ")) +} + +// 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 + } + + prefix := provider + "/" + var result []ResolvedModel + for key, model := range ic.Models { + if !strings.HasPrefix(key, prefix) { + 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 + } + // Try alias: if user asked "xai/grok-4.5", look it up directly. + if sel.Alias != "" { + if model, ok := ic.Models[sel.Alias]; ok { + return model, sel.Alias + } + } + // Try full key "provider/model-name" built from model name. + // If sel.Provider is set, prefer matching that provider. + var fallbackKeys []string + for key, model := range ic.Models { + if modelNameFromKey(key) == sel.Model { + if sel.Provider != "" && ProviderFromModelKey(key) == sel.Provider { + return model, key + } + // Collect candidates for fallback (when no provider-specific match) + fallbackKeys = append(fallbackKeys, key) + } + } + // Fall back to any provider. Use deterministic order. + if len(fallbackKeys) > 0 { + sort.Strings(fallbackKeys) + key := fallbackKeys[0] + return ic.Models[key], key + } + return Model{}, "" +} + +func (ic *IndexedCatalog) resolveWithFirstEnabledProvider(model Model, key string) (ResolvedModel, error) { + providerName := ProviderFromModelKey(key) + if providerName == "" { + return ResolvedModel{}, fmt.Errorf("model key %q has no provider prefix", key) + } + provider, ok := ic.Providers[providerName] + if !ok { + return ResolvedModel{}, fmt.Errorf("provider %q from model key %q not found in catalog", providerName, key) + } + if provider.Enabled != nil && !*provider.Enabled { + return ResolvedModel{}, fmt.Errorf("provider %q for model %q is disabled", providerName, key) + } + return resolvedModel(provider, key, model), nil +} + +func resolvedModel(provider Provider, modelKey string, model Model) ResolvedModel { + return ResolvedModel{ + Provider: provider.Name, + ModelID: modelNameFromKey(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.SupportsTools(), + Vision: model.SupportsVision(), + Reasoning: model.Reasoning, + } +} diff --git a/internal/catalog/resolve_fixture_test.go b/internal/catalog/resolve_fixture_test.go new file mode 100644 index 00000000..d6062f0f --- /dev/null +++ b/internal/catalog/resolve_fixture_test.go @@ -0,0 +1,221 @@ +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: "vision via openrouter", + ref: "vision-model@openrouter", + wantProvider: "openrouter", + wantModelID: "vision-model", + wantBaseURL: "https://openrouter.ai/api/v1", + wantAPIKey: "or-key", + 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: "DeepSeek V4 Flash", + wantProvider: "opencode-go", + wantModelID: "deepseek-v4-flash", + }, + { + name: "resolve by key suffix", + short: "deepseek-v4-flash", + wantProvider: "opencode-go", + wantModelID: "deepseek-v4-flash", + }, + { + 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", "large-context", "tie-small-context"}, + }, + { + name: "openrouter models", + provider: "openrouter", + wantIDs: []string{"reasoning-model", "vision-model"}, + }, + { + 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 00000000..055829aa --- /dev/null +++ b/internal/catalog/resolve_test.go @@ -0,0 +1,337 @@ +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{ + "opencode-go/deepseek-v4-flash": { + ID: "opencode-go/deepseek-v4-flash", + Name: "DeepSeek V4 Flash", + ToolCall: true, + Reasoning: false, + Limit: &Limit{Context: 128000}, + }, + "opencode-go/kimi-k2.6": { + ID: "opencode-go/kimi-k2.6", + Name: "Kimi K2.6", + ToolCall: false, + Reasoning: false, + Limit: &Limit{Context: 256000}, + }, + "openrouter/kimi-k2.6": { + ID: "openrouter/kimi-k2.6", + Name: "Kimi K2.6", + ToolCall: false, + Reasoning: false, + Limit: &Limit{Context: 256000}, + }, + "opencode-go/legacy-name": { + ID: "opencode-go/legacy-name", + Name: "Old Model", + ToolCall: false, + Reasoning: false, + }, + "disabled-provider/only-disabled": { + ID: "disabled-provider/only-disabled", + Name: "Only Disabled", + ToolCall: false, + Reasoning: false, + }, + }, + }, + } +} + +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.CanonicalName != "opencode-go/deepseek-v4-flash" { + t.Errorf("CanonicalName = %q, want %q", got.CanonicalName, "opencode-go/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("DeepSeek V4 Flash") + if err != nil { + t.Fatalf("ResolveShort(%q) unexpected error: %v", "DeepSeek V4 Flash", 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.CanonicalName != "opencode-go/deepseek-v4-flash" { + t.Errorf("CanonicalName = %q, want %q", got.CanonicalName, "opencode-go/deepseek-v4-flash") + } +} + +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") + } + if got.CanonicalName != "opencode-go/legacy-name" { + t.Errorf("CanonicalName = %q, want %q", got.CanonicalName, "opencode-go/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 TestResolveShort_AmbiguousModel(t *testing.T) { + ic := newFixtureCatalog() + + _, err := ic.ResolveShort("kimi-k2.6") + if err == nil { + t.Fatal("ResolveShort: expected ambiguity error for model on multiple providers, got nil") + } + if !containsAll(err.Error(), "ambiguous", "kimi-k2.6", "opencode-go", "openrouter") { + t.Errorf("error message = %q, want mention of ambiguity and available providers", err.Error()) + } +} + +func TestResolveShort_UnambiguousWithSingleEnabledProvider(t *testing.T) { + ic := newFixtureCatalog() + + provider := ic.Providers["openrouter"] + provider.Enabled = boolPtr(false) + ic.Providers["openrouter"] = provider + + got, err := ic.ResolveShort("kimi-k2.6") + if err != nil { + t.Fatalf("ResolveShort: unexpected error: %v", 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 containsAll(s string, substrs ...string) bool { + for _, sub := range substrs { + if !contains(s, sub) { + return false + } + } + return true +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || containsAt(s, sub)) +} + +func containsAt(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +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") + } + + for _, m := range got { + if m.ModelID == "disabled-provider/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 00000000..e7c8af57 --- /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 00000000..757f6ea9 --- /dev/null +++ b/internal/catalog/sync_test.go @@ -0,0 +1,199 @@ +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":{"openai/gpt-4":{"id":"openai/gpt-4","name":"gpt-4"}},"providers":{"openai":{}}}` + validHash := sha256.Sum256([]byte(validCatalog)) + + cases := []struct { + name string + body string + wantErr bool + wantLock bool + wantCatalog bool + }{ + { + name: "successful sync writes catalog and lock", + body: validCatalog, + wantErr: false, + wantLock: true, + wantCatalog: true, + }, + { + name: "missing providers object returns error and leaves no catalog", + body: `{"models":{"openai/gpt-4":{"id":"openai/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") + 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":{"y/x":{"id":"y/x","name":"x"}},"providers":{"y":{}}}`)) + })) + defer server.Close() + + base := t.TempDir() + destDir := filepath.Join(base, "nested", "catalog") + _, err := Sync(server.URL, destDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(filepath.Join(destDir, catalogFileName)); err != nil { + t.Fatalf("expected catalog file: %v", err) + } +} diff --git a/internal/catalog/testdata/catalog.json b/internal/catalog/testdata/catalog.json new file mode 100644 index 00000000..ff613bd2 --- /dev/null +++ b/internal/catalog/testdata/catalog.json @@ -0,0 +1,72 @@ +{ + "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": { + "opencode-go/deepseek-v4-flash": { + "id": "opencode-go/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 128000, "output": 4096 } + }, + "opencode-go/large-context": { + "id": "opencode-go/large-context", + "name": "Large Context", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 1000000, "output": 32768 } + }, + "opencode-go/tie-small-context": { + "id": "opencode-go/tie-small-context", + "name": "Tie Small Context", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 128000, "output": 4096 } + }, + "openrouter/vision-model": { + "id": "openrouter/vision-model", + "name": "Vision Model", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text", "image"], "output": ["text"] }, + "limit": { "context": 256000, "output": 8192 } + }, + "openrouter/reasoning-model": { + "id": "openrouter/reasoning-model", + "name": "Reasoning Model", + "reasoning": true, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 200000, "output": 8192 } + }, + "disabled-provider/only-disabled": { + "id": "disabled-provider/only-disabled", + "name": "Only Disabled", + "reasoning": false, + "tool_call": false, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 128000, "output": 4096 } + } + } +} diff --git a/internal/catalog/types.go b/internal/catalog/types.go new file mode 100644 index 00000000..6734455f --- /dev/null +++ b/internal/catalog/types.go @@ -0,0 +1,154 @@ +package catalog + +import "strings" + +// 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"` +} + +// Modalities describes the input/output formats a model supports. +type Modalities struct { + Input []string `json:"input"` + Output []string `json:"output"` +} + +// Limit describes model usage limits. +type Limit struct { + Context int64 `json:"context"` + Output int64 `json:"output"` +} + +// Rates describes model pricing per million tokens. +type Rates struct { + Input float64 `json:"input"` + Output float64 `json:"output"` +} + +// Model describes a model available through one or more providers. +// The provider is encoded in the model key (e.g. "xai/grok-4.5"). +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + Reasoning bool `json:"reasoning"` + ToolCall bool `json:"tool_call"` + Modalities Modalities `json:"modalities"` + Limit *Limit `json:"limit,omitempty"` + Rates *Rates `json:"rates,omitempty"` +} + +// DisplayName returns the model's display name. +func (m Model) DisplayName() string { + return m.Name +} + +// SupportsTools returns whether the model supports tool calls. +func (m Model) SupportsTools() bool { + return m.ToolCall +} + +// SupportsVision returns whether the model supports image inputs. +func (m Model) SupportsVision() bool { + for _, mod := range m.Modalities.Input { + if mod == "image" { + return true + } + } + return false +} + +// ContextWindow returns the model's context window limit, or 0 if unknown. +func (m Model) ContextWindow() int64 { + if m.Limit != nil { + return m.Limit.Context + } + return 0 +} + +// CostInputPerM returns the input cost per million tokens, or 0 if unknown. +func (m Model) CostInputPerM() float64 { + if m.Rates != nil { + return m.Rates.Input + } + return 0 +} + +// CostOutputPerM returns the output cost per million tokens, or 0 if unknown. +func (m Model) CostOutputPerM() float64 { + if m.Rates != nil { + return m.Rates.Output + } + return 0 +} + +// ProviderFromModelKey extracts the provider name from a model key +// of the form "provider/model-name". Returns "" if no separator found. +func ProviderFromModelKey(key string) string { + idx := strings.IndexByte(key, '/') + if idx < 0 { + return "" + } + return key[:idx] +} + +// ModelNameFromKey extracts the model name portion from a model key +// of the form "provider/model-name". Returns the full key if no separator. +func ModelNameFromKey(key string) string { + idx := strings.IndexByte(key, '/') + if idx < 0 { + return key + } + return key[idx+1:] +} + +// modelNameFromKey is an alias for internal use. +func modelNameFromKey(key string) string { + return ModelNameFromKey(key) +} + +// 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 31d8aa7c..b9b7a0ea 100644 --- a/internal/client/opencode.go +++ b/internal/client/opencode.go @@ -51,6 +51,7 @@ const ( ProviderOpenCodeGo = "opencode-go" ProviderOpenCodeZen = "opencode-zen" ProviderAWSBedrock = "aws-bedrock" + ProviderOpenRouter = "openrouter" ) // APIError represents an HTTP API error returned by an upstream provider. @@ -99,6 +100,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 @@ -126,6 +131,8 @@ func ProviderKeyCount(atomicCfg *config.AtomicConfig, provider string) int { keys = cfg.OpenCodeZen.EffectiveAPIKeys() case ProviderAWSBedrock: keys = cfg.AWSBedrock.EffectiveAPIKeys() + case ProviderOpenRouter: + keys = cfg.OpenRouter.EffectiveAPIKeys() default: // Unknown provider - default to global keys keys = cfg.EffectiveAPIKeys() @@ -185,6 +192,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 { @@ -209,6 +221,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 } @@ -236,6 +250,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 { @@ -298,6 +317,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 @@ -359,6 +383,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 d2d3d7ef..12172ea1 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 ca526516..9db65f49 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,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"` @@ -22,9 +24,36 @@ 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"` + Storage *StorageConfig `json:"storage,omitempty"` +} + +// 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. @@ -33,6 +62,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"` @@ -43,6 +79,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"` @@ -109,6 +146,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"` @@ -143,6 +202,14 @@ type LoggingConfig struct { DebugCapture *DebugCapture `json:"debug_capture,omitempty"` } +// StorageConfig controls persistent storage settings. +type StorageConfig struct { + DatabasePath string `json:"database_path"` + RetentionDays int `json:"retention_days"` + VacuumOnStartup bool `json:"vacuum_on_startup"` + WALEnabled bool `json:"wal_enabled"` +} + // DebugCapture controls request/response capture for debugging. type DebugCapture struct { Enabled bool `json:"enabled"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..2a83784e --- /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 6b1abedc..823d283c 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 c5acc97f..8185dbce 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 f1da79ef..568fc133 100644 --- a/internal/config/model_registry.go +++ b/internal/config/model_registry.go @@ -44,19 +44,21 @@ var modelMetadata = map[string]ModelMetadata{ // a ModelConfig so capacity filtering and scenario routing see accurate // per-model limits. 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 00000000..68f890fd --- /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 1bc9835b..4dc10bc8 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -8,6 +8,7 @@ const TRANSLATIONS = { 'status.connected': 'Connected', 'tab.overview': 'Overview', 'tab.history': 'History', + 'tab.fallback': 'Fallback', 'tab.settings': 'Settings', 'metric.total': 'Total Requests', 'metric.success': 'Success', @@ -32,6 +33,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 +43,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: ', @@ -49,6 +54,68 @@ const TRANSLATIONS = { 'badge.fail': 'Fail', 'port.info': 'Listening port: —', 'save.unloaded': 'Config not loaded, cannot save', + 'fallback.scenario': 'Scenario', + 'fallback.default': 'Default', + 'fallback.streaming': 'Streaming', + 'fallback.longContext': 'Long Context', + 'fallback.chainOrder': 'Fallback Chain Order', + 'fallback.addModel': '+ Add Model', + 'fallback.preview': 'Preview', + 'fallback.save': 'Save', + 'fallback.empty': 'No models configured', + 'fallback.previewTitle': 'Fallback Chain Preview', + 'fallback.selectModel': 'Select a model', + 'fallback.saving': 'Saving fallback chain...', + 'fallback.saved': 'Fallback chain saved successfully!', + 'fallback.saveFailed': 'Failed to save fallback chain', + 'fallback.noChanges': 'No changes to save', + 'perf.lastHour': 'Last Hour', + 'perf.last24h': 'Last 24 Hours', + 'perf.last7d': 'Last 7 Days', + 'perf.allTime': 'All Time', + 'perf.th.model': 'Model', + 'perf.th.count': 'Count', + 'perf.th.successRate': 'Success %', + 'perf.th.avg': 'Avg (ms)', + 'perf.th.p50': 'P50', + 'perf.th.p90': 'P90', + 'perf.th.p99': 'P99', + 'perf.empty': 'No performance data', + 'setting.backup': 'Backup Configuration', + 'setting.backupDesc': 'Export current config as JSON file', + 'setting.restore': 'Restore Configuration', + 'setting.restoreDesc': 'Import config from JSON file', + 'btn.export': 'Export', + 'btn.import': 'Import', + 'label.anonymize': 'Anonymize', + 'status.exporting': 'Exporting...', + 'status.exportOk': 'Config exported successfully!', + 'status.exportFail': 'Export failed: ', + 'status.importing': 'Importing...', + 'status.importOk': 'Config imported successfully!', + 'status.importFail': 'Import failed: ', + 'status.importInvalid': 'Invalid config file', + 'modal.importPreview': 'Import Preview', + 'modal.importConfirm': 'Apply this configuration?', + 'btn.apply': 'Apply', + 'btn.cancel': 'Cancel', + 'setting.testModel': 'Test Model', + 'setting.testModelDesc': 'Send a quick test request to verify model connectivity', + 'btn.testModel': 'Test Model', + 'test.title': 'Quick Model Test', + 'test.selectModel': 'Select a model...', + 'test.send': 'Send', + 'test.promptPlaceholder': 'Enter your prompt...', + 'test.latency': 'Latency:', + 'test.tokens': 'Tokens:', + 'test.copy': 'Copy', + 'test.copied': 'Copied!', + 'test.sending': 'Sending...', + 'test.noModel': 'Please select a model', + 'test.noPrompt': 'Please enter a prompt', + 'test.error': 'Error: ', + 'test.networkError': 'Network error', + 'tab.performance': 'Performance', }, zh: { 'lang.toggle': 'English', @@ -58,6 +125,7 @@ const TRANSLATIONS = { 'status.connected': '已连接', 'tab.overview': '概览', 'tab.history': '历史请求', + 'tab.fallback': '降级策略', 'tab.settings': '设置', 'metric.total': '总请求数', 'metric.success': '成功', @@ -82,6 +150,9 @@ const TRANSLATIONS = { 'setting.notifyDesc': '请求失败或切换模型时发送系统通知', 'setting.language': '语言', 'setting.languageDesc': '切换界面语言', + 'setting.catalog': '模型目录', + 'setting.catalogNotSynced': '模型目录未同步', + 'setting.catalogAge': '上次同步:{age}', 'section.proxyConfig': '服务代理配置', 'placeholder.envOrEmpty': '使用环境变量或留空', 'placeholder.notSet': '未配置', @@ -89,6 +160,7 @@ const TRANSLATIONS = { 'label.host': '监听地址 (Host)', 'label.port': '监听端口 (Port)', 'btn.save': '保存并应用配置', + 'btn.refreshCatalog': '刷新模型目录', 'status.saving': '保存中…', 'status.saveOk': '配置保存并应用成功!', 'status.saveFail': '保存失败: ', @@ -99,6 +171,69 @@ const TRANSLATIONS = { 'badge.fail': '失败', 'port.info': '监听端口:—', 'save.unloaded': '未加载当前配置,无法保存', + 'setting.testModel': '测试模型', + 'setting.testModelDesc': '发送快速测试请求以验证模型连接', + 'btn.testModel': '测试模型', + 'test.title': '快速模型测试', + 'test.selectModel': '选择模型...', + 'test.send': '发送', + 'test.promptPlaceholder': '输入测试提示词...', + 'test.latency': '延迟:', + 'test.tokens': 'Token:', + 'test.copy': '复制', + 'test.copied': '已复制!', + 'test.sending': '发送中...', + 'test.noModel': '请选择模型', + 'test.noPrompt': '请输入提示词', + 'test.error': '错误:', + 'test.networkError': '网络错误', + 'fallback.scenario': '使用场景', + 'fallback.default': '默认', + 'fallback.streaming': '流式请求', + 'fallback.longContext': '长上下文', + 'fallback.chainOrder': '降级链顺序', + 'fallback.addModel': '+ 添加模型', + 'fallback.preview': '预览', + 'fallback.save': '保存', + 'fallback.empty': '未配置模型', + 'fallback.previewTitle': '降级链预览', + 'fallback.selectModel': '选择模型', + 'fallback.saving': '保存中...', + 'fallback.saved': '降级链保存成功!', + 'fallback.saveFailed': '保存失败', + 'fallback.noChanges': '无更改', + 'perf.lastHour': '最近 1 小时', + 'perf.last24h': '最近 24 小时', + 'perf.last7d': '最近 7 天', + 'perf.allTime': '全部时间', + 'perf.th.model': '模型', + 'perf.th.count': '请求数', + 'perf.th.successRate': '成功率', + 'perf.th.avg': '平均延迟', + 'perf.th.p50': 'P50', + 'perf.th.p90': 'P90', + 'perf.th.p99': 'P99', + 'perf.empty': '暂无性能数据', + 'setting.backup': '备份配置', + 'setting.backupDesc': '导出当前配置为 JSON 文件', + 'setting.restore': '恢复配置', + 'setting.restoreDesc': '从 JSON 文件导入配置', + 'btn.export': '导出', + 'btn.import': '导入', + 'label.anonymize': '脱敏', + 'status.exporting': '导出中...', + 'status.exportOk': '配置导出成功!', + 'status.exportFail': '导出失败:', + 'status.importing': '导入中...', + 'status.importOk': '配置导入成功!', + 'status.importFail': '导入失败:', + 'status.importInvalid': '无效的配置文件', + 'modal.importPreview': '导入预览', + 'modal.importConfirm': '应用此配置?', + 'btn.apply': '应用', + 'btn.cancel': '取消', + 'tab.logs': '日志', + 'tab.performance': '性能', } }; @@ -134,6 +269,7 @@ function toggleLanguage() { // Re-render dynamic content renderModelList(lastModelCounts); renderHistory(); + PerfModule.render(); } // Apply translations on load @@ -147,6 +283,99 @@ let allHistory = []; let currentFilter = ''; let lastModelCounts = {}; +/* ── Performance Module ───────────────────────────────────────────── */ +const PerfModule = { + data: [], + sortField: 'count', + sortDir: 'desc', + timeRange: 'all', + + init() { + const timeRangeSelect = document.getElementById('perf-time-range'); + if (timeRangeSelect) { + timeRangeSelect.addEventListener('change', (e) => { + this.timeRange = e.target.value; + this.refresh(); + }); + } + + document.querySelectorAll('.perf-table .sortable').forEach(th => { + th.addEventListener('click', () => { + const field = th.dataset.sort; + if (this.sortField === field) { + this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc'; + } else { + this.sortField = field; + this.sortDir = 'desc'; + } + document.querySelectorAll('.perf-table .sortable').forEach(s => { + s.classList.remove('asc', 'desc'); + s.setAttribute('aria-sort', 'none'); + }); + th.classList.add(this.sortDir); + th.setAttribute('aria-sort', this.sortDir === 'asc' ? 'ascending' : 'descending'); + this.render(); + }); + }); + }, + + async refresh() { + try { + const r = await fetch('/api/perf/models?range=' + encodeURIComponent(this.timeRange)); + if (!r.ok) return; + this.data = await r.json() || []; + this.render(); + } catch (e) { + console.error('PerfModule refresh failed:', e); + } + }, + + render() { + const tbody = document.getElementById('perf-tbody'); + if (!tbody) return; + + if (this.data.length === 0) { + tbody.innerHTML = '' + t('empty.noData') + ''; + return; + } + + const sorted = [...this.data].sort((a, b) => { + let aVal = a[this.sortField]; + let bVal = b[this.sortField]; + if (aVal == null) aVal = 0; + if (bVal == null) bVal = 0; + if (typeof aVal === 'string') aVal = aVal.toLowerCase(); + if (typeof bVal === 'string') bVal = bVal.toLowerCase(); + if (aVal < bVal) return this.sortDir === 'asc' ? -1 : 1; + if (aVal > bVal) return this.sortDir === 'asc' ? 1 : -1; + return 0; + }); + + tbody.innerHTML = sorted.map(row => { + const successRate = row.count > 0 ? (row.success / row.count * 100).toFixed(1) : 0; + const successClass = successRate >= 99 ? 'success-rate' : (successRate >= 95 ? '' : 'error-rate'); + return ` + + ${escapeHtml(row.model)} + ${fmt(row.count)} + ${successRate}% + ${fmt(row.avg_ms)} + ${fmt(row.p50_ms)} + ${fmt(row.p90_ms)} + ${fmt(row.p99_ms)} + + `; + }).join(''); + }, + + getLatencyClass(ms) { + if (ms == null) return ''; + if (ms < 1000) return 'latency-cell latency-fast'; + if (ms < 2000) return 'latency-cell latency-medium'; + return 'latency-cell latency-slow'; + } +}; + /* ── Tab switching ─────────────────────────────────────────────── */ document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { @@ -158,13 +387,25 @@ document.querySelectorAll('.tab').forEach(tab => { }); /* ── Polling ───────────────────────────────────────────────────── */ +let perfPollTimer = null; +let perfPollCounter = 0; + function startPolling() { refreshAll(); + PerfModule.init(); + PerfModule.refresh(); setInterval(refreshAll, 3000); + perfPollTimer = setInterval(() => { + perfPollCounter++; + if (perfPollCounter >= 2) { + PerfModule.refresh(); + perfPollCounter = 0; + } + }, 3000); } 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 +576,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 +678,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; @@ -739,6 +1031,9 @@ function executeCommand(action) { case 'goto-history': document.querySelector('[data-tab="history"]').click(); break; + case 'goto-performance': + document.querySelector('[data-tab="performance"]').click(); + break; case 'goto-settings': document.querySelector('[data-tab="settings"]').click(); break; @@ -748,6 +1043,7 @@ function executeCommand(action) { } } + commandPalette?.addEventListener('click', function(e) { if (e.target === commandPalette) closeCommandPalette(); }); @@ -776,16 +1072,18 @@ document.addEventListener('keydown', function(e) { document.getElementById('history-search')?.focus(); } } - // Tab shortcuts: Cmd/Ctrl + 1/2/3 - if ((e.metaKey || e.ctrlKey) && ['1', '2', '3'].includes(e.key)) { + // Tab shortcuts: Cmd/Ctrl + 1/2/3/4/5/6 + if ((e.metaKey || e.ctrlKey) && ['1', '2', '3', '4', '5', '6'].includes(e.key)) { e.preventDefault(); - const tabs = ['overview', 'history', 'settings']; + const tabs = ['overview', 'history', 'performance', 'fallback', 'settings']; document.querySelector(`[data-tab="${tabs[parseInt(e.key) - 1]}"]`)?.click(); } // Escape to close modals (use if-else to ensure only one action) if (e.key === 'Escape') { if (commandPaletteOpen) { closeCommandPalette(); + } else if (TestModule.testModal?.classList.contains('visible')) { + TestModule.close(); } else if (modal.classList.contains('visible')) { closeHistoryModal(); } @@ -815,7 +1113,597 @@ function initAccordions() { // Initialize on load document.addEventListener('DOMContentLoaded', initAccordions); +/* ── Config Backup/Restore ─────────────────────────────────────── */ +async function exportConfig() { + const anonymize = document.getElementById('export-anonymize').checked; + const btn = document.getElementById('btn-export-config'); + btn.disabled = true; + btn.textContent = t('status.exporting'); + + try { + const url = '/api/config/export?anonymize=' + anonymize; + const response = await fetch(url); + if (!response.ok) { + throw new Error(await response.text()); + } + + const blob = await response.blob(); + const downloadUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = 'routatic-proxy-config.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(downloadUrl); + + showSaveStatus(t('status.exportOk'), 'success'); + } catch (e) { + showSaveStatus(t('status.exportFail') + e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = t('btn.export'); + applyTranslations(); + } +} + +function importConfig() { + document.getElementById('import-file').click(); +} + +async function handleConfigImport(file) { + if (!file || file.type !== 'application/json') { + showSaveStatus(t('status.importInvalid'), 'error'); + return; + } + + const btn = document.getElementById('btn-import-config'); + btn.disabled = true; + btn.textContent = t('status.importing'); + + try { + const content = await file.text(); + const config = JSON.parse(content); + + const previewHtml = ` +
+ ${t('modal.importConfirm')} +
+
${escapeHtml(JSON.stringify(config, null, 2))}
+ `; + + modalBody.innerHTML = previewHtml; + document.getElementById('modal-title').textContent = t('modal.importPreview'); + + const footerHtml = ` +
+ + +
+ `; + + const existingFooter = modal.querySelector('.modal-footer'); + if (existingFooter) existingFooter.remove(); + + modal.querySelector('.modal-content').insertAdjacentHTML('beforeend', footerHtml); + + modal.classList.add('visible'); + + document.getElementById('btn-import-cancel').onclick = () => { + modal.classList.remove('visible'); + const footer = modal.querySelector('.modal-footer'); + if (footer) footer.remove(); + }; + + document.getElementById('btn-import-apply').onclick = async () => { + try { + const response = await fetch('/api/config/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: config, apply: true }) + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + modal.classList.remove('visible'); + const footer = modal.querySelector('.modal-footer'); + if (footer) footer.remove(); + + showSaveStatus(t('status.importOk'), 'success'); + await loadProxyConfig(); + } catch (e) { + showSaveStatus(t('status.importFail') + e.message, 'error'); + } + }; + } catch (e) { + showSaveStatus(t('status.importFail') + e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = t('btn.import'); + applyTranslations(); + document.getElementById('import-file').value = ''; + } +} + +document.addEventListener('DOMContentLoaded', () => { + document.getElementById('btn-export-config')?.addEventListener('click', exportConfig); + document.getElementById('btn-import-config')?.addEventListener('click', importConfig); + document.getElementById('import-file')?.addEventListener('change', function(e) { + if (e.target.files && e.target.files[0]) { + handleConfigImport(e.target.files[0]); + } + }); +}); + +/* ── Fallback Chain Editor ─────────────────────────────────────── */ +const FallbackModule = { + chains: { + default: [], + streaming: [], + 'long-context': [] + }, + currentScenario: 'default', + originalChains: null, + availableModels: [], + + init() { + this.loadConfig(); + }, + + async loadConfig() { + try { + const r = await fetch('/api/proxy/config'); + if (!r.ok) return; + const config = await r.json(); + + this.availableModels = config.models || []; + + this.chains = { + default: this.parseFallbackChain(config, 'default'), + streaming: this.parseFallbackChain(config, 'streaming'), + 'long-context': this.parseFallbackChain(config, 'long_context') + }; + + this.originalChains = JSON.parse(JSON.stringify(this.chains)); + this.renderChain(); + } catch (e) { + console.error('Failed to load fallback config:', e); + } + }, + + parseFallbackChain(config, scenario) { + const key = scenario === 'long-context' ? 'long_context' : scenario; + if (config.router_config && config.router_config.scenario_fallbacks && config.router_config.scenario_fallbacks[key]) { + return [...config.router_config.scenario_fallbacks[key]]; + } + return []; + }, + + renderChain() { + const list = document.getElementById('fallback-chain'); + const chain = this.chains[this.currentScenario]; + + if (!chain || chain.length === 0) { + list.innerHTML = '
  • ' + t('fallback.empty') + '
  • '; + list.classList.remove('has-items'); + return; + } + + list.classList.add('has-items'); + list.innerHTML = chain.map((modelId, index) => { + const model = this.availableModels.find(m => m.id === modelId); + const displayName = model ? (model.display_name || model.id) : modelId; + const provider = model ? model.provider : ''; + return ` +
  • + ⋮⋮ + ${escapeHtml(displayName)} + ${provider ? '' + escapeHtml(provider) + '' : ''} + +
  • + `; + }).join(''); + + this.setupDragDrop(); + }, + + setupDragDrop() { + const items = document.querySelectorAll('.fallback-item'); + + items.forEach(item => { + item.addEventListener('dragstart', (e) => this.onDragStart(e)); + item.addEventListener('dragover', (e) => this.onDragOver(e)); + item.addEventListener('dragleave', (e) => this.onDragLeave(e)); + item.addEventListener('drop', (e) => this.onDrop(e)); + item.addEventListener('dragend', (e) => this.onDragEnd(e)); + }); + }, + + onDragStart(e) { + e.target.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', e.target.dataset.index); + }, + + onDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const dragging = document.querySelector('.fallback-item.dragging'); + if (dragging !== e.currentTarget) { + e.currentTarget.classList.add('drag-over'); + } + }, + + onDragLeave(e) { + e.currentTarget.classList.remove('drag-over'); + }, + + onDrop(e) { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10); + const toIndex = parseInt(e.currentTarget.dataset.index, 10); + + e.currentTarget.classList.remove('drag-over'); + + if (fromIndex !== toIndex) { + const chain = this.chains[this.currentScenario]; + const [removed] = chain.splice(fromIndex, 1); + chain.splice(toIndex, 0, removed); + this.renderChain(); + } + }, + + onDragEnd(e) { + e.target.classList.remove('dragging'); + document.querySelectorAll('.fallback-item').forEach(item => { + item.classList.remove('drag-over'); + }); + }, + + onScenarioChange() { + const select = document.getElementById('fallback-scenario'); + this.currentScenario = select.value; + this.renderChain(); + document.getElementById('fallback-preview').style.display = 'none'; + }, + + addModel() { + const modelOptions = this.availableModels + .filter(m => !this.chains[this.currentScenario].includes(m.id)) + .map(m => ``) + .join(''); + + if (!modelOptions) { + alert(currentLang === 'zh' ? '没有可用模型' : 'No available models'); + return; + } + + const selectHtml = ``; + const confirmed = confirm( + (currentLang === 'zh' ? '选择模型添加到降级链:\n\n' : 'Select a model to add:\n\n') + + this.availableModels.filter(m => !this.chains[this.currentScenario].includes(m.id)) + .map(m => `${m.display_name || m.id} (${m.provider})`).join('\n') + ); + + if (confirmed) { + const modelId = prompt( + currentLang === 'zh' ? '输入模型ID:' : 'Enter model ID:', + this.availableModels.filter(m => !this.chains[this.currentScenario].includes(m.id))[0]?.id || '' + ); + + if (modelId && !this.chains[this.currentScenario].includes(modelId)) { + const model = this.availableModels.find(m => m.id === modelId); + if (model) { + this.chains[this.currentScenario].push(modelId); + this.renderChain(); + } else { + alert(currentLang === 'zh' ? '无效的模型ID' : 'Invalid model ID'); + } + } + } + }, + + removeModel(index) { + this.chains[this.currentScenario].splice(index, 1); + this.renderChain(); + }, + + preview() { + const previewEl = document.getElementById('fallback-preview'); + const contentEl = document.getElementById('fallback-preview-content'); + const chain = this.chains[this.currentScenario]; + + if (!chain || chain.length === 0) { + contentEl.innerHTML = '
    ' + t('fallback.empty') + '
    '; + } else { + contentEl.innerHTML = '
    ' + + chain.map((modelId, i) => { + const model = this.availableModels.find(m => m.id === modelId); + const displayName = model ? (model.display_name || model.id) : modelId; + return ` + ${escapeHtml(displayName)} + ${i < chain.length - 1 ? '' : ''} + `; + }).join('') + + '
    '; + } + + previewEl.style.display = 'block'; + }, + + async save() { + const hasChanges = this.originalChains && ( + JSON.stringify(this.chains) !== JSON.stringify(this.originalChains) + ); + + if (!hasChanges) { + showSaveStatus(t('fallback.noChanges'), 'success'); + return; + } + + const saveBtn = document.querySelector('.fallback-actions .btn-primary'); + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.textContent = t('fallback.saving'); + } + + try { + const patch = { + router_config: { + scenario_fallbacks: { + default: this.chains.default, + streaming: this.chains.streaming, + long_context: this.chains['long-context'] + } + } + }; + + const r = await fetch('/api/proxy/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch) + }); + + if (r.ok) { + showSaveStatus(t('fallback.saved'), 'success'); + this.originalChains = JSON.parse(JSON.stringify(this.chains)); + await loadProxyConfig(); + } else { + const txt = await r.text(); + showSaveStatus(t('fallback.saveFailed') + ': ' + txt, 'error'); + } + } catch (e) { + showSaveStatus(t('fallback.saveFailed'), 'error'); + } finally { + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.textContent = t('fallback.save'); + } + } + } +}; + +document.addEventListener('DOMContentLoaded', () => { + FallbackModule.init(); +}); + /* ── Boot ──────────────────────────────────────────────────────── */ loadProxyConfig(); startPolling(); +const TestModule = { + testModal: null, + testPrompt: null, + testResponse: null, + testModelSelect: null, + testLatency: null, + testTokens: null, + testSendBtn: null, + testCopyBtn: null, + testModalClose: null, + testHistoryHint: null, + + STORAGE_KEY: 'routatic-test-prompt-history', + MAX_HISTORY: 5, + + init() { + this.testModal = document.getElementById('test-modal'); + this.testPrompt = document.getElementById('test-prompt'); + this.testResponse = document.getElementById('test-response'); + this.testModelSelect = document.getElementById('test-model'); + this.testLatency = document.getElementById('test-latency'); + this.testTokens = document.getElementById('test-tokens'); + this.testSendBtn = document.getElementById('btn-test-send'); + this.testCopyBtn = document.getElementById('btn-test-copy'); + this.testModalClose = document.getElementById('test-modal-close'); + this.testHistoryHint = document.getElementById('test-history-hint'); + + document.getElementById('btn-test-model')?.addEventListener('click', () => this.open()); + this.testModalClose?.addEventListener('click', () => this.close()); + this.testModal?.addEventListener('click', (e) => { + if (e.target === this.testModal) this.close(); + }); + this.testSendBtn?.addEventListener('click', () => this.sendTest()); + this.testCopyBtn?.addEventListener('click', () => this.copyResponse()); + this.testPrompt?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.sendTest(); + } + }); + this.loadHistory(); + }, + + open() { + this.populateModels(); + this.testModal?.classList.add('visible'); + if (this.testPrompt) { + this.testPrompt.value = ''; + this.testPrompt.focus(); + } + this.resetResponse(); + }, + + close() { + this.testModal?.classList.remove('visible'); + }, + + async populateModels() { + if (!this.testModelSelect) return; + this.testModelSelect.innerHTML = ''; + + try { + const r = await fetch('/api/metrics'); + if (!r.ok) return; + const data = await r.json(); + const models = Object.keys(data.model_counts || {}); + models.sort().forEach(m => { + const opt = document.createElement('option'); + opt.value = m; + opt.textContent = m; + this.testModelSelect.appendChild(opt); + }); + } catch (e) {} + }, + + resetResponse() { + if (this.testResponse) this.testResponse.innerHTML = ''; + if (this.testLatency) this.testLatency.textContent = '—'; + if (this.testTokens) this.testTokens.textContent = '—'; + }, + + async sendTest() { + if (!this.testPrompt || !this.testModelSelect || !this.testResponse) return; + + const model = this.testModelSelect.value; + const prompt = this.testPrompt.value.trim(); + if (!model) { + this.resetResponse(); + if (this.testResponse) this.testResponse.innerHTML = `
    ${t('test.noModel')}
    `; + return; + } + if (!prompt) { + this.resetResponse(); + if (this.testResponse) this.testResponse.innerHTML = `
    ${t('test.noPrompt')}
    `; + return; + } + + this.saveToHistory(prompt); + this.testSendBtn.disabled = true; + this.testSendBtn.textContent = t('test.sending'); + this.resetResponse(); + + const start = performance.now(); + try { + const r = await fetch('/v1/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: model, + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }] + }) + }); + + const latency = Math.round(performance.now() - start); + if (this.testLatency) this.testLatency.textContent = latency + ' ms'; + + if (!r.ok) { + this.testResponse.innerHTML = ''; + const pre = document.createElement('pre'); + pre.textContent = t('test.error') + r.status + ': ' + (await r.text()); + this.testResponse.appendChild(pre); + return; + } + const text = await r.text(); + let content = text; + try { + const j = JSON.parse(text); + if (j.content && Array.isArray(j.content)) { + content = j.content.map(c => c.text || '').join('\n'); + } else if (j.error) { + content = 'Error: ' + (j.error.message || JSON.stringify(j.error)); + } + } catch (_) {} + + const pre = document.createElement('pre'); + pre.textContent = content; + this.testResponse.innerHTML = ''; + this.testResponse.appendChild(pre); + + const usage = this.extractUsage(text); + if (usage && this.testTokens) { + this.testTokens.textContent = `${usage.input || 0} in / ${usage.output || 0} out`; + } + } catch (e) { + const pre = document.createElement('pre'); + pre.textContent = t('test.error') + e.message; + this.testResponse.innerHTML = ''; + this.testResponse.appendChild(pre); + } finally { + this.testSendBtn.disabled = false; + this.testSendBtn.textContent = t('test.send'); + } + }, + + extractUsage(text) { + try { + const j = JSON.parse(text); + if (j.usage) return { input: j.usage.input_tokens, output: j.usage.output_tokens }; + } catch (_) {} + const m = text.match(/"input_tokens":\s*(\d+).*?"output_tokens":\s*(\d+)/s); + if (m) return { input: parseInt(m[1]), output: parseInt(m[2]) }; + return null; + }, + + loadHistory() { + try { + const history = JSON.parse(localStorage.getItem(this.STORAGE_KEY) || '[]'); + if (history.length > 0 && this.testHistoryHint) { + this.testHistoryHint.innerHTML = history.slice(0, this.MAX_HISTORY) + .map(p => `${escapeHtml(p.substring(0, 20))}${p.length > 20 ? '...' : ''}`) + .join(''); + this.testHistoryHint.querySelectorAll('span').forEach((el, i) => { + el.addEventListener('click', () => { + const history = JSON.parse(localStorage.getItem(this.STORAGE_KEY) || '[]'); + if (history[i]) { + this.testPrompt.value = history[i]; + this.testPrompt.focus(); + } + }); + }); + } + } catch (e) {} + }, + + saveToHistory(prompt) { + try { + let history = JSON.parse(localStorage.getItem(this.STORAGE_KEY) || '[]'); + history = [prompt, ...history.filter(p => p !== prompt)].slice(0, this.MAX_HISTORY); + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(history)); + this.loadHistory(); + } catch (e) {} + }, + + async copyResponse() { + const pre = this.testResponse.querySelector('pre'); + if (!pre || !pre.textContent) return; + + try { + await navigator.clipboard.writeText(pre.textContent); + const originalText = this.testCopyBtn.innerHTML; + this.testCopyBtn.innerHTML = `${t('test.copied')}`; + this.testCopyBtn.classList.add('copied'); + setTimeout(() => { + this.testCopyBtn.innerHTML = originalText; + this.testCopyBtn.classList.remove('copied'); + this.testCopyBtn.classList.remove('copied'); + }, 2000); + } catch (e) {} + } +}; + +document.addEventListener('DOMContentLoaded', () => TestModule.init()); + diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 2c8842c0..d28883f6 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -35,6 +35,8 @@ @@ -99,6 +101,49 @@ + + + + +
    +
    + +
    +
    + + + + + + + + + + + + + + + +
    Model Count Success % Avg (ms) P50 P90 P99
    No data yet
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + Fallback Chain Order + +
    +
      +
    • No models configured
    • +
    +
    +
    + + +
    +
    @@ -196,6 +315,45 @@ Chinese
    + +
    +
    +
    Catalog
    +
    Catalog not synced
    +
    + +
    + +
    +
    +
    Test Model
    +
    Send a quick test request to verify model connectivity
    +
    + +
    + +
    +
    +
    Backup Configuration
    +
    Export current config as JSON file
    +
    +
    + + +
    +
    + +
    +
    +
    Restore Configuration
    +
    Import config from JSON file
    +
    + + +
    diff --git a/internal/gui/assets/style.css b/internal/gui/assets/style.css index 6e142502..ac31b189 100644 --- a/internal/gui/assets/style.css +++ b/internal/gui/assets/style.css @@ -467,6 +467,37 @@ input:checked + .toggle-slider::before { background: var(--border); } +.btn-group { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.checkbox-inline { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; +} + +.checkbox-inline input[type="checkbox"] { + margin: 0; + cursor: pointer; +} + +.btn-primary.btn-small { + background: var(--accent); + color: #ffffff; + border-color: var(--accent); +} + +.btn-primary.btn-small:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + .save-status { margin-top: 10px; font-size: 12px; @@ -911,3 +942,399 @@ input:checked + .toggle-slider::before { border-radius: var(--radius-sm); font-family: monospace; } + +/* ── Fallback Chain Editor ──────────────────────────────────────── */ +.fallback-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-4); + box-shadow: var(--shadow); +} + +.fallback-toolbar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.fallback-toolbar .form-group { + flex: 0 0 auto; +} + +.fallback-toolbar label { + font-size: var(--text-sm); + font-weight: 500; + margin-bottom: var(--space-1); + display: block; + color: var(--text); +} + +.fallback-toolbar select { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface2); + color: var(--text); + font-size: var(--text-sm); + min-width: 180px; + cursor: pointer; +} + +.fallback-actions { + display: flex; + gap: var(--space-2); +} + +.fallback-chain-container { + margin-top: var(--space-3); +} + +.fallback-chain-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-2); + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-muted); +} + +.fallback-chain { + list-style: none; + padding: 0; + margin: 0; + min-height: 60px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + background: var(--bg); +} + +.fallback-chain.has-items { + border-style: solid; + background: transparent; +} + +.fallback-item { + display: flex; + align-items: center; + padding: var(--space-3); + margin: var(--space-1) 0; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: grab; + transition: background 0.15s, box-shadow 0.15s; +} + +.fallback-item:first-child { + margin-top: 0; +} + +.fallback-item:last-child { + margin-bottom: 0; +} + +.fallback-item:hover { + background: var(--surface); + box-shadow: var(--shadow-sm); +} + +.fallback-item:active { + cursor: grabbing; +} + +.fallback-item.dragging { + opacity: 0.5; + background: var(--surface); +} + +.fallback-item.drag-over { + border-top: 2px solid var(--accent); +} + +.fallback-item .handle { + margin-right: var(--space-2); + color: var(--text-muted); + font-size: 14px; +} + +.fallback-item .model-name { + flex: 1; + font-size: var(--text-sm); + font-weight: 500; +} + +.fallback-item .model-meta { + font-size: var(--text-xs); + color: var(--text-muted); + margin-left: var(--space-2); +} + +.fallback-item .remove-btn { + opacity: 0; + transition: opacity 0.2s; + background: none; + border: none; + padding: var(--space-1); + cursor: pointer; + color: var(--error); + font-size: 16px; + border-radius: var(--radius-sm); +} + +.fallback-item:hover .remove-btn { + opacity: 1; +} + +.fallback-item .remove-btn:hover { + background: rgba(220, 53, 69, 0.1); +} + +.fallback-preview { + margin-top: var(--space-4); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-4); + box-shadow: var(--shadow); +} + +.fallback-preview-header { + font-size: var(--text-md); + font-weight: 600; + margin-bottom: var(--space-3); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border); +} + +.fallback-preview-chain { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); +} + +.fallback-preview-model { + display: inline-flex; + align-items: center; + padding: var(--space-2) var(--space-3); + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: var(--text-sm); +} + +.fallback-preview-model.primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.fallback-preview-arrow { + color: var(--text-muted); + font-size: 18px; +} + +/* ── Quick Model Test Modal ───────────────────────────────────── */ +.test-modal-content { + max-width: 800px; +} + +.test-toolbar { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.test-toolbar .filter-select { + flex: 1; +} + +.test-prompt-wrap { + position: relative; + margin-bottom: var(--space-3); +} + +#test-prompt { + width: 100%; + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg); + color: var(--text); + font-size: var(--text-sm); + font-family: inherit; + resize: vertical; + min-height: 80px; + outline: none; + user-select: text; + -webkit-user-select: text; +} + +#test-prompt:focus { + border-color: var(--accent); + box-shadow: var(--shadow-focus); +} + +#test-prompt::placeholder { + color: var(--text-muted); +} + +.test-history-hint { + position: absolute; + bottom: 8px; + right: 8px; + display: flex; + gap: 4px; + opacity: 0.6; +} + +.test-history-hint span { + padding: 2px 6px; + background: var(--surface2); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + color: var(--text-muted); + cursor: pointer; + transition: opacity 0.15s; +} + +.test-history-hint span:hover { + opacity: 1; + background: var(--border); +} + +.test-response { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-3); + min-height: 150px; + max-height: 400px; + overflow: auto; + margin-bottom: var(--space-3); +} + +.test-response pre { + margin: 0; + font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; + font-size: var(--text-xs); + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} + +.test-response.loading { + opacity: 0.6; +} + +.test-response.error pre { + color: var(--error); +} + +.test-footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.test-metrics { + display: flex; + gap: var(--space-4); + font-size: var(--text-sm); + color: var(--text-muted); +} + +.test-metrics strong { + color: var(--text); + font-variant-numeric: tabular-nums; +} + +#btn-test-copy { + display: flex; + align-items: center; + gap: 4px; +} + +#btn-test-copy.copied { + background: var(--success); + color: white; + border-color: var(--success); +} + +/* ── Performance Table ───────────────────────────────────────────── */ +.perf-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + margin-bottom: var(--space-3); +} + +.perf-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} + +.perf-table th, +.perf-table td { + padding: var(--space-2) var(--space-3); + text-align: left; + border-bottom: 1px solid var(--border); +} + +.perf-table th { + background: var(--surface); + color: var(--text-muted); + font-weight: 500; + position: sticky; + top: 0; + white-space: nowrap; +} + +.perf-table td { + vertical-align: middle; + white-space: nowrap; +} + +.perf-table tr:hover td { + background: var(--surface2); +} + +.perf-model { + font-weight: 500; +} + +.latency-cell { + font-family: monospace; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); +} + +.latency-fast { + background: rgba(40, 167, 69, 0.15); + color: var(--success); +} + +.latency-medium { + background: rgba(253, 126, 20, 0.15); + color: var(--warning); +} + +.latency-slow { + background: rgba(220, 53, 69, 0.15); + color: var(--error); +} + +.success-rate { + color: var(--success); + font-weight: 500; +} + +.error-rate { + color: var(--error); + font-weight: 500; +} diff --git a/internal/gui/catalog_stats_test.go b/internal/gui/catalog_stats_test.go new file mode 100644 index 00000000..e3e5a5f0 --- /dev/null +++ b/internal/gui/catalog_stats_test.go @@ -0,0 +1,148 @@ +package gui + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/routatic/proxy/internal/storage" +) + +func TestHandleCatalogStats_NoStorage(t *testing.T) { + s := &Server{} + + req := httptest.NewRequest(http.MethodGet, "/api/catalog/stats", nil) + rec := httptest.NewRecorder() + + s.handleCatalogStats(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rec.Code) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if available, ok := resp["available"].(bool); !ok || available { + t.Error("expected available=false") + } +} + +func TestHandleCatalogStats_EmptyCatalog(t *testing.T) { + tmp := t.TempDir() + dbPath := tmp + "/test.db" + + storageCfg := storage.DefaultConfig + storageCfg.DatabasePath = dbPath + + db, err := storage.Open(storageCfg) + if err != nil { + t.Fatalf("failed to open storage: %v", err) + } + defer func() { _ = db.Close() }() + + s := &Server{storage: db} + + req := httptest.NewRequest(http.MethodGet, "/api/catalog/stats", nil) + rec := httptest.NewRecorder() + + s.handleCatalogStats(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rec.Code) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if available, ok := resp["available"].(bool); !ok || available { + t.Error("expected available=false for empty catalog") + } +} + +func TestHandleCatalogStats_WithCatalog(t *testing.T) { + tmp := t.TempDir() + dbPath := tmp + "/test.db" + + storageCfg := storage.DefaultConfig + storageCfg.DatabasePath = dbPath + + db, err := storage.Open(storageCfg) + if err != nil { + t.Fatalf("failed to open storage: %v", err) + } + defer func() { _ = db.Close() }() + + repo := storage.NewCatalogRepo(db) + ctx := context.Background() + + enabled := true + providers := []storage.ProviderRecord{ + {Name: "provider-a", Enabled: &enabled}, + {Name: "provider-b", Enabled: &enabled}, + } + models := []storage.ModelRecord{ + {ID: "provider-a/model-1", Name: "Model 1", ToolCall: true, Vision: true}, + {ID: "provider-a/model-2", Name: "Model 2", ToolCall: true, Reasoning: true}, + {ID: "provider-b/model-3", Name: "Model 3", ToolCall: true}, + } + + if err := repo.UpsertBatch(ctx, providers, models); err != nil { + t.Fatalf("failed to seed catalog: %v", err) + } + + s := &Server{storage: db} + + req := httptest.NewRequest(http.MethodGet, "/api/catalog/stats", nil) + rec := httptest.NewRecorder() + + s.handleCatalogStats(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rec.Code) + } + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + if available, ok := resp["available"].(bool); !ok || !available { + t.Error("expected available=true") + } + + if totalProviders, ok := resp["total_providers"].(float64); !ok || int(totalProviders) != 2 { + t.Errorf("expected total_providers=2, got %v", resp["total_providers"]) + } + + if totalModels, ok := resp["total_models"].(float64); !ok || int(totalModels) != 3 { + t.Errorf("expected total_models=3, got %v", resp["total_models"]) + } + + if modelsWithTools, ok := resp["models_with_tools"].(float64); !ok || int(modelsWithTools) != 3 { + t.Errorf("expected models_with_tools=3, got %v", resp["models_with_tools"]) + } + + if modelsWithVision, ok := resp["models_with_vision"].(float64); !ok || int(modelsWithVision) != 1 { + t.Errorf("expected models_with_vision=1, got %v", resp["models_with_vision"]) + } +} + +func TestHandleCatalogStats_MethodNotAllowed(t *testing.T) { + s := &Server{} + + req := httptest.NewRequest(http.MethodPost, "/api/catalog/stats", nil) + rec := httptest.NewRecorder() + + s.handleCatalogStats(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status 405, got %d", rec.Code) + } +} diff --git a/internal/gui/config_io.go b/internal/gui/config_io.go new file mode 100644 index 00000000..edf5057c --- /dev/null +++ b/internal/gui/config_io.go @@ -0,0 +1,159 @@ +package gui + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "regexp" + "strings" + + "github.com/routatic/proxy/internal/config" +) + +var sensitiveFieldPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)api[_-]?key`), + regexp.MustCompile(`(?i)token`), + regexp.MustCompile(`(?i)secret`), + regexp.MustCompile(`(?i)password`), + regexp.MustCompile(`(?i)credential`), +} + +func anonymizeConfig(cfg *config.Config) *config.Config { + data, err := json.Marshal(cfg) + if err != nil { + return cfg + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return cfg + } + + anonymizeMap(raw) + + result := &config.Config{} + data, _ = json.Marshal(raw) + _ = json.Unmarshal(data, result) + return result +} + +func anonymizeMap(m map[string]interface{}) { + for key, value := range m { + if shouldAnonymize(key) { + switch v := value.(type) { + case string: + if v != "" { + m[key] = "***REDACTED***" + } + case []interface{}: + m[key] = []string{"***REDACTED***"} + } + } else if nested, ok := value.(map[string]interface{}); ok { + anonymizeMap(nested) + } + } +} + +func shouldAnonymize(key string) bool { + lowerKey := strings.ToLower(key) + for _, pattern := range sensitiveFieldPatterns { + if pattern.MatchString(lowerKey) { + return true + } + } + return false +} + +func (s *Server) handleConfigExport(w http.ResponseWriter, r *http.Request) { + if s.atomicCfg == nil { + http.Error(w, "config not available", http.StatusServiceUnavailable) + return + } + + cfg := s.atomicCfg.Get() + + anonymize := r.URL.Query().Get("anonymize") == "true" + if anonymize { + cfg = anonymizeConfig(cfg) + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", "attachment; filename=routatic-proxy-config.json") + + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + _ = encoder.Encode(cfg) +} + +func (s *Server) handleConfigImport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if s.atomicCfg == nil { + http.Error(w, "config not available", http.StatusServiceUnavailable) + return + } + + var req struct { + Config json.RawMessage `json:"config"` + Apply bool `json:"apply"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) + return + } + + var cfg config.Config + if err := json.Unmarshal(req.Config, &cfg); err != nil { + http.Error(w, fmt.Sprintf("invalid config: %v", err), http.StatusBadRequest) + return + } + + if cfg.Host == "" { + http.Error(w, "host is required", http.StatusBadRequest) + return + } + if cfg.Port < 1 || cfg.Port > 65535 { + http.Error(w, "port must be between 1 and 65535", http.StatusBadRequest) + return + } + + resp := map[string]interface{}{ + "valid": true, + "config": cfg, + } + + if req.Apply { + configPath := s.atomicCfg.Path() + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + http.Error(w, fmt.Sprintf("failed to marshal config: %v", err), http.StatusInternalServerError) + return + } + if err := writeFileAtomic(configPath, data, 0600); err != nil { + http.Error(w, fmt.Sprintf("failed to write config: %v", err), http.StatusInternalServerError) + return + } + + if err := s.atomicCfg.Reload(); err != nil { + http.Error(w, fmt.Sprintf("failed to reload config: %v", err), http.StatusInternalServerError) + return + } + + resp["applied"] = true + } + + writeJSON(w, resp) +} + +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, perm); err != nil { + return err + } + return os.Rename(tmpPath, path) +} diff --git a/internal/gui/perf.go b/internal/gui/perf.go new file mode 100644 index 00000000..b0c5bdb5 --- /dev/null +++ b/internal/gui/perf.go @@ -0,0 +1,159 @@ +package gui + +import ( + "net/http" + "time" + + "github.com/routatic/proxy/internal/storage" +) + +func (s *Server) handlePerformance(w http.ResponseWriter, r *http.Request) { + rangeParam := r.URL.Query().Get("range") + since := storage.ParseTimeRange(rangeParam) + + type modelPerf struct { + Model string `json:"model"` + Count int64 `json:"count"` + Success int64 `json:"success"` + Failed int64 `json:"failed"` + AvgMs int64 `json:"avg_ms"` + P50Ms int64 `json:"p50_ms"` + P90Ms int64 `json:"p90_ms"` + P99Ms int64 `json:"p99_ms"` + MinMs int64 `json:"min_ms"` + MaxMs int64 `json:"max_ms"` + } + + result := make(map[string]modelPerf) + + if s.storage != nil { + latency := storage.NewLatency(s.storage) + + modelStats, err := latency.GetStats(since) + if err == nil { + for _, stat := range modelStats { + result[stat.Model] = modelPerf{ + Model: stat.Model, + Count: stat.Count, + AvgMs: stat.Avg.Milliseconds(), + P50Ms: stat.P50.Milliseconds(), + P90Ms: stat.P90.Milliseconds(), + P99Ms: stat.P99.Milliseconds(), + MinMs: stat.Min.Milliseconds(), + MaxMs: stat.Max.Milliseconds(), + } + } + } + + successCounts, failureCounts, _ := latency.GetSuccessCounts(since) + for model, count := range successCounts { + if perf, exists := result[model]; exists { + perf.Success = count + result[model] = perf + } else { + result[model] = modelPerf{ + Model: model, + Success: count, + Failed: failureCounts[model], + } + } + } + for model, count := range failureCounts { + if perf, exists := result[model]; exists { + perf.Failed = count + result[model] = perf + } + } + } else if s.met != nil { + snap := s.met.GetSnapshot() + modelStats := s.met.GetModelLatencyStats() + + for _, stat := range modelStats { + result[stat.Model] = modelPerf{ + Model: stat.Model, + Count: stat.Count, + AvgMs: stat.Avg.Milliseconds(), + P50Ms: stat.P50.Milliseconds(), + P90Ms: stat.P90.Milliseconds(), + P99Ms: stat.P99.Milliseconds(), + MinMs: stat.Min.Milliseconds(), + MaxMs: stat.Max.Milliseconds(), + } + } + + for model, count := range snap.ModelCounts { + if perf, exists := result[model]; exists { + perf.Success = snap.ModelSuccess[model] + perf.Failed = snap.ModelFailed[model] + result[model] = perf + } else { + result[model] = modelPerf{ + Model: model, + Count: count, + Success: snap.ModelSuccess[model], + Failed: snap.ModelFailed[model], + } + } + } + } + + var output []modelPerf + for _, perf := range result { + output = append(output, perf) + } + + writeJSON(w, output) +} + +func (s *Server) handlePerformanceAggregate(w http.ResponseWriter, r *http.Request) { + rangeParam := r.URL.Query().Get("range") + since := storage.ParseTimeRange(rangeParam) + + type aggregate struct { + TotalRequests int64 `json:"total_requests"` + TotalSuccess int64 `json:"total_success"` + TotalFailed int64 `json:"total_failed"` + AvgLatencyMs int64 `json:"avg_latency_ms"` + } + + agg := aggregate{} + + if s.storage != nil { + latency := storage.NewLatency(s.storage) + latencyStats, err := latency.GetStats(since) + if err == nil && len(latencyStats) > 0 { + var totalCount int64 + var totalLatency time.Duration + for _, stat := range latencyStats { + totalCount += stat.Count + totalLatency += stat.Avg * time.Duration(stat.Count) + } + if totalCount > 0 { + agg.AvgLatencyMs = (totalLatency / time.Duration(totalCount)).Milliseconds() + } + } + + successCounts, failureCounts, _ := latency.GetSuccessCounts(since) + for _, s := range successCounts { + agg.TotalSuccess += s + } + for _, f := range failureCounts { + agg.TotalFailed += f + } + agg.TotalRequests = agg.TotalSuccess + agg.TotalFailed + } else if s.met != nil { + snap := s.met.GetSnapshot() + agg.TotalRequests = snap.RequestsReceived + agg.TotalSuccess = snap.RequestsSuccess + agg.TotalFailed = snap.RequestsFailed + if len(snap.Latencies) > 0 { + var sum time.Duration + for _, lat := range snap.Latencies { + sum += lat + } + agg.AvgLatencyMs = (sum / time.Duration(len(snap.Latencies))).Milliseconds() + } + } + + writeJSON(w, agg) +} diff --git a/internal/gui/server.go b/internal/gui/server.go index 5bdb7864..086d3e72 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -16,11 +16,14 @@ 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" "github.com/routatic/proxy/internal/metrics" + "github.com/routatic/proxy/internal/storage" ) //go:embed assets/* @@ -44,19 +47,27 @@ type Server struct { proxyPort int startProxy func() error stopProxy func() error + catalogDir string + catalogSourceURL string srv *http.Server logger *slog.Logger + catalogMu sync.Mutex + + storage *storage.Database } // 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 + Storage *storage.Database } // New creates a new GUI server. @@ -65,31 +76,45 @@ 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, + + storage: opts.Storage, } // Check initial autostart state. s.cfg.Autostart = isAutostartEnabled() return s } -// isAutostartEnabled checks whether autostart is currently enabled on macOS. +// isAutostartEnabled checks whether autostart is currently enabled. +// On macOS it checks ~/Library/LaunchAgents/{LaunchAgent}.plist. +// On Linux it checks ~/.config/autostart/{LaunchAgent}.desktop. func isAutostartEnabled() bool { - if runtime.GOOS != "darwin" { - return false - } home, err := os.UserHomeDir() if err != nil { return false } - plist := filepath.Join(home, "Library", "LaunchAgents", daemon.LaunchAgent+".plist") - _, err = os.Stat(plist) - return err == nil + + if runtime.GOOS == "darwin" { + plist := filepath.Join(home, "Library", "LaunchAgents", daemon.LaunchAgent+".plist") + _, err = os.Stat(plist) + return err == nil + } + + if runtime.GOOS == "linux" { + desktop := filepath.Join(home, ".config", "autostart", daemon.LaunchAgent+".desktop") + _, err = os.Stat(desktop) + return err == nil + } + + return false } // SetProxyRunning updates the running state (called by the proxy lifecycle). @@ -130,6 +155,16 @@ 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) + + // New endpoints for advanced GUI features + + mux.HandleFunc("/api/config/export", s.handleConfigExport) + mux.HandleFunc("/api/config/import", s.handleConfigImport) + mux.HandleFunc("/api/perf/models", s.handlePerformance) + mux.HandleFunc("/api/perf/aggregate", s.handlePerformanceAggregate) + mux.HandleFunc("/api/catalog/stats", s.handleCatalogStats) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -369,6 +404,146 @@ 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 (s *Server) handleCatalogStats(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if s.storage == nil { + writeJSON(w, map[string]any{ + "available": false, + "error": "storage not configured", + }) + return + } + + repo := storage.NewCatalogRepo(s.storage) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + idx, err := repo.Load(ctx) + if err != nil { + writeJSON(w, map[string]any{ + "available": false, + "error": err.Error(), + }) + return + } + + lastSync, _ := repo.LastSync(ctx) + + providersByEnabled := make(map[string]int) + for _, p := range idx.Providers { + enabled := "disabled" + if p.Enabled != nil && *p.Enabled { + enabled = "enabled" + } + providersByEnabled[enabled]++ + } + + modelsByProvider := make(map[string]int) + for prov := range idx.Providers { + modelsByProvider[prov] = len(idx.ProviderModels[prov]) + } + + totalModels := len(idx.Models) + modelsWithTools := 0 + modelsWithVision := 0 + modelsWithReasoning := 0 + for _, m := range idx.Models { + if m.ToolCall { + modelsWithTools++ + } + if m.Vision { + modelsWithVision++ + } + if m.Reasoning { + modelsWithReasoning++ + } + } + + resp := map[string]any{ + "available": true, + "last_sync": lastSync, + "total_providers": len(idx.Providers), + "providers_enabled": providersByEnabled["enabled"], + "providers_disabled": providersByEnabled["disabled"], + "total_models": totalModels, + "models_with_tools": modelsWithTools, + "models_with_vision": modelsWithVision, + "models_with_reasoning": modelsWithReasoning, + "models_by_provider": modelsByProvider, + } + + writeJSON(w, resp) +} + 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 00000000..3a8c0c26 --- /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":{"openai/gpt-4":{"id":"openai/gpt-4","name":"GPT-4"}},"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 79229106..a78cf283 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -47,6 +47,7 @@ type MessagesHandler struct { metrics *metrics.Metrics captureLogger *debug.CaptureLogger history *history.History // optional: nil means no GUI history + storage StorageWriter // optional: SQLite persistence for requests/latency } // responseWriter wraps http.ResponseWriter to track if headers were written. @@ -205,6 +206,7 @@ func NewMessagesHandler( metrics *metrics.Metrics, captureLogger *debug.CaptureLogger, hist *history.History, + storage StorageWriter, ) *MessagesHandler { return &MessagesHandler{ client: openCodeClient, @@ -223,6 +225,7 @@ func NewMessagesHandler( metrics: metrics, captureLogger: captureLogger, history: hist, + storage: storage, } } @@ -340,7 +343,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 } @@ -361,9 +370,9 @@ func (h *MessagesHandler) HandleMessages(w http.ResponseWriter, r *http.Request) } if isStreaming { - h.handleStreaming(w, r, &anthropicReq, normalizedReq, modelChain, rawBody, routeResult.Scenario) + h.handleStreaming(w, r, &anthropicReq, normalizedReq, modelChain, rawBody, routeResult.Scenario, requestID) } else { - h.handleNonStreaming(w, r, &anthropicReq, normalizedReq, modelChain, rawBody, routeResult.Scenario) + h.handleNonStreaming(w, r, &anthropicReq, normalizedReq, modelChain, rawBody, routeResult.Scenario, requestID) } } @@ -466,6 +475,7 @@ func (h *MessagesHandler) handleStreaming( modelChain []config.ModelConfig, rawBody json.RawMessage, scenario router.Scenario, + requestID string, ) { clientCtx := r.Context() @@ -538,18 +548,28 @@ func (h *MessagesHandler) handleStreaming( "cache_read_input_tokens", rw.usage.cacheReadInputTokens, "cache_creation_input_tokens", rw.usage.cacheCreationInputTokens, ) + rec := history.RequestRecord{ + ID: requestID, + Model: model.ModelID, + Provider: model.Provider, + Scenario: string(scenario), + StartTime: streamStart, + Duration: latency, + InputTokens: rw.usage.inputTokens, + OutputTokens: rw.usage.outputTokens, + Streaming: true, + Success: true, + } if h.history != nil { - h.history.Add(history.RequestRecord{ - Model: model.ModelID, - Provider: model.Provider, - Scenario: string(scenario), - StartTime: streamStart, - Duration: latency, - InputTokens: rw.usage.inputTokens, - OutputTokens: rw.usage.outputTokens, - Streaming: true, - Success: true, - }) + h.history.Add(rec) + } + if h.storage != nil { + if err := h.storage.InsertRequest(rec); err != nil { + h.logger.Warn("failed to insert request into storage", "error", err) + } + if err := h.storage.InsertLatency(model.ModelID, latency); err != nil { + h.logger.Warn("failed to insert latency sample into storage", "error", err) + } } } @@ -568,7 +588,7 @@ func (h *MessagesHandler) handleStreaming( "model", model.ModelID, "idle_timeout", idleTimeout) if rw.ssePayloadWritten { h.sendStreamError(rw, "stream idle after SSE payload started") - h.metrics.RecordFailure() + h.metrics.RecordFailureForModel(model.ModelID) return false // abort } return true // continue to next model @@ -576,7 +596,7 @@ func (h *MessagesHandler) handleStreaming( h.logger.Warn(action+" streaming failed", "model", model.ModelID, "error", err) if rw.ssePayloadWritten { h.sendStreamError(rw, "all upstream models failed after SSE payload started") - h.metrics.RecordFailure() + h.metrics.RecordFailureForModel(model.ModelID) return false // abort — cannot fallback after SSE payload started } return true // continue to next model @@ -1017,6 +1037,7 @@ func (h *MessagesHandler) handleNonStreaming( modelChain []config.ModelConfig, rawBody json.RawMessage, scenario router.Scenario, + requestID string, ) { ctx := r.Context() startTime := time.Now() @@ -1075,7 +1096,7 @@ func (h *MessagesHandler) handleNonStreaming( h.logger.Info("request context canceled during non-streaming fallback", "error", err) return } - h.metrics.RecordFailure() + h.metrics.RecordFailureForModel(result.ModelID) h.sendError(w, http.StatusBadGateway, "all models failed", err) return } @@ -1104,18 +1125,28 @@ func (h *MessagesHandler) handleNonStreaming( outputTokens = msgResp.Usage.OutputTokens } + rec := history.RequestRecord{ + ID: requestID, + Model: result.ModelID, + Provider: provider, + Scenario: string(scenario), + StartTime: startTime, + Duration: latency, + InputTokens: inputTokens, + OutputTokens: outputTokens, + Streaming: false, + Success: true, + } if h.history != nil { - h.history.Add(history.RequestRecord{ - Model: result.ModelID, - Provider: provider, - Scenario: string(scenario), - StartTime: startTime, - Duration: latency, - InputTokens: inputTokens, - OutputTokens: outputTokens, - Streaming: false, - Success: true, - }) + h.history.Add(rec) + } + if h.storage != nil { + if err := h.storage.InsertRequest(rec); err != nil { + h.logger.Warn("failed to insert request into storage", "error", err) + } + if err := h.storage.InsertLatency(result.ModelID, latency); err != nil { + h.logger.Warn("failed to insert latency sample into storage", "error", err) + } } w.Header().Set("Content-Type", "application/json") diff --git a/internal/handlers/messages_test.go b/internal/handlers/messages_test.go index 5a113bc2..7df796cd 100644 --- a/internal/handlers/messages_test.go +++ b/internal/handlers/messages_test.go @@ -428,6 +428,7 @@ func TestHandleStreaming_UsageLimitSkipsRemainingProviderModels(t *testing.T) { }, nil, router.ScenarioDefault, + "", ) if goCalls != 1 || zenCalls != 1 { t.Fatalf("goCalls=%d zenCalls=%d; want 1 each", goCalls, zenCalls) @@ -668,7 +669,7 @@ func TestHandleStreaming_GoAnthropicModel_SendsRawAnthropicBody(t *testing.T) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() - handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{}, chain, rawBody, router.Scenario("")) + handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{}, chain, rawBody, router.Scenario(""), "") if len(capturedBody) == 0 { t.Fatal("upstream received no body") @@ -763,7 +764,7 @@ func TestHandleStreaming_GoAnthropicModel_FallsThroughOnError(t *testing.T) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() - handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{}, chain, rawBody, router.Scenario("")) + handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{}, chain, rawBody, router.Scenario(""), "") finalCount := atomic.LoadInt32(&callCount) if finalCount != 2 { @@ -794,6 +795,58 @@ 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 + nil, // storage + ) + 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) { @@ -852,6 +905,7 @@ func TestHandleMessages_StreamingMinimaxM3_UsesAnthropicEndpoint(t *testing.T) { metrics.New(), nil, // captureLogger nil, // hist + nil, // storage ) handler.logger = slog.Default() @@ -966,6 +1020,7 @@ func TestHandleNonStreaming_GoAnthropicModel_ReplacesModelInBody(t *testing.T) { metrics.New(), nil, // captureLogger nil, // hist + nil, // storage ) handler.logger = slog.Default() @@ -1083,6 +1138,7 @@ func TestHandleNonStreaming_ZenAnthropicModel_ReplacesModelInBody(t *testing.T) metrics.New(), nil, // captureLogger nil, // hist + nil, // storage ) handler.logger = slog.Default() @@ -1197,7 +1253,7 @@ func TestHandleStreaming_ConfigurableTimeout(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{Stream: true}, chain, rawBody, router.Scenario("")) + handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{Stream: true}, chain, rawBody, router.Scenario(""), "") }() select { @@ -1249,7 +1305,7 @@ func TestHandleStreaming_ClientContextCanceled_StopsFallback(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{Stream: true}, chain, rawBody, router.Scenario("")) + handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{Stream: true}, chain, rawBody, router.Scenario(""), "") }() select { @@ -1306,7 +1362,7 @@ func TestHandleStreaming_ClientDisconnectsDuringStream_StopsFallback(t *testing. done := make(chan struct{}) go func() { defer close(done) - handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{Stream: true}, chain, rawBody, router.Scenario("")) + handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{Stream: true}, chain, rawBody, router.Scenario(""), "") }() time.Sleep(100 * time.Millisecond) @@ -1384,7 +1440,7 @@ func TestHandleStreaming_PerModelTimeoutFallback(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{Stream: true}, chain, rawBody, router.Scenario("")) + handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{Stream: true}, chain, rawBody, router.Scenario(""), "") }() select { @@ -1447,6 +1503,7 @@ func TestHandleNonStreaming_ParentContextCanceled_No502(t *testing.T) { m, nil, // captureLogger nil, // hist + nil, // storage ) handler.logger = slog.Default() @@ -1529,6 +1586,7 @@ func TestHandleNonStreaming_ParentDeadlineExceeded_No502(t *testing.T) { m, nil, // captureLogger nil, // hist + nil, // storage ) handler.logger = slog.Default() @@ -1638,7 +1696,7 @@ func TestHandleStreaming_AnthropicRaw_NoKeepaliveInjection(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{Stream: true}, chain, rawBody, router.Scenario("")) + handler.handleStreaming(recorder, req.WithContext(ctx), &anthropicReq, &core.NormalizedRequest{Stream: true}, chain, rawBody, router.Scenario(""), "") }() time.Sleep(1000 * time.Millisecond) diff --git a/internal/handlers/storage_adapter.go b/internal/handlers/storage_adapter.go new file mode 100644 index 00000000..aa723528 --- /dev/null +++ b/internal/handlers/storage_adapter.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "time" + + "github.com/routatic/proxy/internal/history" + "github.com/routatic/proxy/internal/storage" +) + +type StorageWriter interface { + InsertRequest(rec history.RequestRecord) error + InsertLatency(model string, latency time.Duration) error +} + +type StorageAdapter struct { + requests *storage.Requests + latency *storage.Latency +} + +func NewStorageAdapter(db *storage.Database) *StorageAdapter { + return &StorageAdapter{ + requests: storage.NewRequests(db), + latency: storage.NewLatency(db), + } +} + +func (s *StorageAdapter) InsertRequest(rec history.RequestRecord) error { + return s.requests.Insert(rec) +} + +func (s *StorageAdapter) InsertLatency(model string, latency time.Duration) error { + return s.latency.Insert(model, latency) +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index a8de6425..77ec1ef0 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -2,6 +2,8 @@ package metrics import ( + "math" + "sort" "sync" "sync/atomic" "time" @@ -26,13 +28,28 @@ type Metrics struct { // By model modelCounts map[string]*atomic.Int64 modelMu sync.RWMutex + + // Per-model success/failure for accurate success rates + modelSuccess map[string]int64 + modelSuccessMu sync.RWMutex + modelFailed map[string]int64 + modelFailedMu sync.RWMutex + + // Per-model latency tracking + modelLatencies map[string][]time.Duration + modelLatMu sync.RWMutex + maxPerModelSamples int } // New creates a new metrics instance. func New() *Metrics { return &Metrics{ - maxLatencySamples: 1000, - modelCounts: make(map[string]*atomic.Int64), + maxLatencySamples: 1000, + maxPerModelSamples: 100, + modelCounts: make(map[string]*atomic.Int64), + modelSuccess: make(map[string]int64), + modelFailed: make(map[string]int64), + modelLatencies: make(map[string][]time.Duration), } } @@ -50,6 +67,11 @@ func (m *Metrics) RecordSuccess(model string, latency time.Duration) { m.upstreamCalls.Add(1) m.recordLatency(latency) m.recordModel(model) + m.recordModelLatency(model, latency) + + m.modelSuccessMu.Lock() + m.modelSuccess[model]++ + m.modelSuccessMu.Unlock() } // RecordFailure records a failed request. @@ -57,6 +79,15 @@ func (m *Metrics) RecordFailure() { m.requestsFailed.Add(1) } +// RecordFailureForModel records a failed request for a specific model. +func (m *Metrics) RecordFailureForModel(model string) { + m.requestsFailed.Add(1) + + m.modelFailedMu.Lock() + m.modelFailed[model]++ + m.modelFailedMu.Unlock() +} + // RecordRateLimited records a rate-limited request. func (m *Metrics) RecordRateLimited() { m.rateLimited.Add(1) @@ -79,6 +110,17 @@ func (m *Metrics) recordLatency(latency time.Duration) { m.latencies = append(m.latencies, latency) } +func (m *Metrics) recordModelLatency(model string, latency time.Duration) { + m.modelLatMu.Lock() + defer m.modelLatMu.Unlock() + + samples := m.modelLatencies[model] + if len(samples) >= m.maxPerModelSamples { + samples = samples[1:] + } + m.modelLatencies[model] = append(samples, latency) +} + func (m *Metrics) recordModel(model string) { m.modelMu.Lock() defer m.modelMu.Unlock() @@ -103,6 +145,21 @@ func (m *Metrics) GetSnapshot() Snapshot { } m.modelMu.RUnlock() + // Collect per-model success/failure counts + modelSuccess := make(map[string]int64) + modelFailed := make(map[string]int64) + m.modelSuccessMu.RLock() + for k, v := range m.modelSuccess { + modelSuccess[k] = v + } + m.modelSuccessMu.RUnlock() + + m.modelFailedMu.RLock() + for k, v := range m.modelFailed { + modelFailed[k] = v + } + m.modelFailedMu.RUnlock() + return Snapshot{ RequestsReceived: m.requestsReceived.Load(), RequestsStreamed: m.requestsStreamed.Load(), @@ -113,6 +170,8 @@ func (m *Metrics) GetSnapshot() Snapshot { Deduplicated: m.deduplicated.Load(), Latencies: latencies, ModelCounts: modelCounts, + ModelSuccess: modelSuccess, + ModelFailed: modelFailed, } } @@ -127,6 +186,86 @@ type Snapshot struct { Deduplicated int64 Latencies []time.Duration ModelCounts map[string]int64 + ModelSuccess map[string]int64 // Per-model success counts + ModelFailed map[string]int64 // Per-model failure counts +} + +// ModelLatencyStats holds latency statistics for a single model. +type ModelLatencyStats struct { + Model string + Count int64 + Avg time.Duration + P50 time.Duration + P90 time.Duration + P99 time.Duration + Min time.Duration + Max time.Duration +} + +// GetModelLatencyStats returns latency statistics for all models. +func (m *Metrics) GetModelLatencyStats() []ModelLatencyStats { + m.modelLatMu.RLock() + defer m.modelLatMu.RUnlock() + + var stats []ModelLatencyStats + for model, samples := range m.modelLatencies { + if len(samples) == 0 { + continue + } + stats = append(stats, calculateModelStats(model, samples)) + } + return stats +} + +func calculateModelStats(model string, samples []time.Duration) ModelLatencyStats { + if len(samples) == 0 { + return ModelLatencyStats{Model: model} + } + + sorted := make([]time.Duration, len(samples)) + copy(sorted, samples) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + + var sum time.Duration + for _, d := range sorted { + sum += d + } + + count := len(sorted) + avg := sum / time.Duration(count) + + p50Idx := int(math.Ceil(float64(count)*0.50)) - 1 + p90Idx := int(math.Ceil(float64(count)*0.90)) - 1 + p99Idx := int(math.Ceil(float64(count)*0.99)) - 1 + if p50Idx < 0 { + p50Idx = 0 + } + if p90Idx < 0 { + p90Idx = 0 + } + if p99Idx < 0 { + p99Idx = 0 + } + if p50Idx >= count { + p50Idx = count - 1 + } + if p90Idx >= count { + p90Idx = count - 1 + } + if p99Idx >= count { + p99Idx = count - 1 + } + + return ModelLatencyStats{ + Model: model, + Count: int64(count), + Avg: avg, + P50: sorted[p50Idx], + P90: sorted[p90Idx], + P99: sorted[p99Idx], + Min: sorted[0], + Max: sorted[count-1], + } } // CalculateP95 calculates the p95 latency from the snapshot. diff --git a/internal/router/model_router.go b/internal/router/model_router.go index 5e9defa7..73066a5f 100644 --- a/internal/router/model_router.go +++ b/internal/router/model_router.go @@ -1,21 +1,68 @@ package router import ( + "context" + "errors" "fmt" + "strings" + "sync" + "time" + "github.com/routatic/proxy/internal/catalog" "github.com/routatic/proxy/internal/config" + "github.com/routatic/proxy/internal/storage" ) -// ModelRouter handles model selection based on scenarios. +var ErrUnknownProvider = errors.New("unknown provider") + type ModelRouter struct { - atomic *config.AtomicConfig + atomic *config.AtomicConfig + db *storage.Database + catalogPath string + catMu sync.Mutex + cat *catalog.IndexedCatalog + catErr error + catCache time.Time } -// NewModelRouter creates a new model router. func NewModelRouter(atomic *config.AtomicConfig) *ModelRouter { return &ModelRouter{atomic: atomic} } +func NewModelRouterWithDB(atomic *config.AtomicConfig, db *storage.Database) *ModelRouter { + return &ModelRouter{atomic: atomic, db: db} +} + +func NewModelRouterWithCatalog(atomic *config.AtomicConfig, catalogPath string) *ModelRouter { + return &ModelRouter{atomic: atomic, catalogPath: catalogPath} +} + +func (r *ModelRouter) catalog() (*catalog.IndexedCatalog, error) { + if r.db == nil && r.catalogPath == "" { + return nil, nil + } + + r.catMu.Lock() + defer r.catMu.Unlock() + + if r.cat != nil && time.Since(r.catCache) < 30*time.Second { + return r.cat, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if r.db != nil { + r.cat, r.catErr = catalog.LoadFromSQLite(ctx, r.db) + } else if r.catalogPath != "" { + r.cat, r.catErr = catalog.Load(r.catalogPath) + } + if r.catErr == nil { + r.catCache = time.Now() + } + 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. @@ -44,14 +91,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) @@ -68,6 +127,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) { @@ -82,9 +227,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) @@ -97,7 +254,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) @@ -162,15 +319,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) @@ -187,7 +357,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 a9f8948e..ebaab7e3 100644 --- a/internal/router/model_router_test.go +++ b/internal/router/model_router_test.go @@ -1,12 +1,86 @@ 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": { + "opencode-go/deepseek-v4-flash": { + "id": "opencode-go/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "limit": {"context": 1000000}, + "rates": {"input": 0.0, "output": 0.0}, + "tool_call": true, + "modalities": {"input": ["text"], "output": ["text"]} + }, + "opencode-go/kimi-k2.6": { + "id": "opencode-go/kimi-k2.6", + "name": "Kimi K2.6", + "limit": {"context": 256000}, + "rates": {"input": 0.0, "output": 0.0}, + "tool_call": true, + "modalities": {"input": ["text", "image"], "output": ["text"]} + }, + "openrouter/kimi-k2.6": { + "id": "openrouter/kimi-k2.6", + "name": "Kimi K2.6", + "limit": {"context": 256000}, + "rates": {"input": 0.0, "output": 0.0}, + "tool_call": true, + "modalities": {"input": ["text", "image"], "output": ["text"]} + }, + "opencode-go/glm-5": { + "id": "opencode-go/glm-5", + "name": "GLM 5", + "limit": {"context": 200000}, + "rates": {"input": 0.0, "output": 0.0}, + "tool_call": true, + "modalities": {"input": ["text"], "output": ["text"]} + }, + "openrouter/glm-5": { + "id": "openrouter/glm-5", + "name": "GLM 5", + "limit": {"context": 200000}, + "rates": {"input": 0.0, "output": 0.0}, + "tool_call": true, + "modalities": {"input": ["text"], "output": ["text"]} + } + } +}`) + + 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 +435,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: "deepseek-v4-flash", + catalogPath: catalogPath, + wantProvider: "opencode-go", + wantModelID: "deepseek-v4-flash", + wantModelRef: "deepseek-v4-flash", + }, + { + 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: "deepseek-v4-flash", + wantProvider: "opencode-go", + wantModelID: "deepseek-v4-flash", + wantModelRef: "deepseek-v4-flash", + }, + { + 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 00000000..ac8b2099 --- /dev/null +++ b/internal/router/selector.go @@ -0,0 +1,259 @@ +package router + +import ( + "errors" + "fmt" + "sort" + + "github.com/routatic/proxy/internal/catalog" + "github.com/routatic/proxy/internal/config" +) + +// ScenarioConstraints filters candidate models by required capabilities. +type ScenarioConstraints struct { + Tools bool + Vision bool + Context int64 + Reasoning bool +} + +// Selector selects models from a catalog according to scenario requirements +// and runtime constraints such as enabled providers and API keys. +type Selector struct { + catalog *catalog.IndexedCatalog + enabledProviders map[string]bool + cfg *config.Config +} + +// NewSelector creates a Selector from an indexed catalog and active config. +// Providers are enabled when they have an effective API key in the config +// (either a global key or a provider-specific key) and are not explicitly +// disabled in the catalog. +func NewSelector(cat *catalog.IndexedCatalog, cfg *config.Config) *Selector { + if cfg == nil { + cfg = &config.Config{} + } + enabled := enabledProviders(cfg) + for name, p := range cat.Providers { + if p.Enabled != nil && !*p.Enabled { + delete(enabled, name) + } + } + return &Selector{ + catalog: cat, + enabledProviders: enabled, + cfg: cfg, + } +} + +// SelectCheapest returns the cheapest resolved model for the named scenario +// that satisfies both the scenario requirements and the supplied constraints. +// +// Candidates are sorted by total cost per million tokens ascending. Ties are +// broken by larger context window, then by model ID. +func (s *Selector) SelectCheapest(scenario string, constraints ScenarioConstraints) (catalog.ResolvedModel, error) { + scen, ok := s.catalog.Scenarios[scenario] + if !ok { + return catalog.ResolvedModel{}, fmt.Errorf("unknown scenario %q", scenario) + } + + candidates := s.resolveCandidates(scen, constraints) + if len(candidates) == 0 { + return catalog.ResolvedModel{}, fmt.Errorf("%w: scenario %q", ErrNoCandidateModel, scenario) + } + + sort.Slice(candidates, func(i, j int) bool { + a, b := candidates[i], candidates[j] + costA := a.CostInputPerM + a.CostOutputPerM + 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(modelKey, 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: catalog.ModelNameFromKey(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.SupportsTools(), + Vision: model.SupportsVision(), + 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 { + // Handle nil config by returning all enabled providers. + if s.cfg == nil { + set := make(map[string]bool, len(s.enabledProviders)) + for p := range s.enabledProviders { + set[p] = true + } + return set + } + + 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(modelKey string, provider string) bool { + return catalog.ProviderFromModelKey(modelKey) == 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.SupportsTools() { + return false + } + if scen.RequiresVision != nil && *scen.RequiresVision && !model.SupportsVision() { + return false + } + if scen.RequiresReasoning != nil && *scen.RequiresReasoning && !model.Reasoning { + return false + } + if constraints.Tools && !model.SupportsTools() { + return false + } + if constraints.Vision && !model.SupportsVision() { + 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 00000000..bf7f1426 --- /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 00000000..80df5e2e --- /dev/null +++ b/internal/router/testdata/selector_catalog.json @@ -0,0 +1,156 @@ +{ + "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": { + "opencode-go/cheap-no-tools": { + "id": "opencode-go/cheap-no-tools", + "name": "Cheap No Tools", + "reasoning": false, + "tool_call": false, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 128000, "output": 4096 }, + "rates": { "input": 0.5, "output": 1.5 } + }, + "opencode-go/cheap-tools": { + "id": "opencode-go/cheap-tools", + "name": "Cheap Tools", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 128000, "output": 4096 }, + "rates": { "input": 0.5, "output": 1.5 } + }, + "openrouter/vision-model": { + "id": "openrouter/vision-model", + "name": "Vision Model", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text", "image"], "output": ["text"] }, + "limit": { "context": 256000, "output": 8192 }, + "rates": { "input": 3.0, "output": 5.0 } + }, + "openrouter/reasoning-model": { + "id": "openrouter/reasoning-model", + "name": "Reasoning Model", + "reasoning": true, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 200000, "output": 8192 }, + "rates": { "input": 2.0, "output": 6.0 } + }, + "opencode-go/large-context": { + "id": "opencode-go/large-context", + "name": "Large Context", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 1000000, "output": 32768 }, + "rates": { "input": 1.0, "output": 2.0 } + }, + "openrouter/large-context": { + "id": "openrouter/large-context", + "name": "Large Context", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 1000000, "output": 32768 }, + "rates": { "input": 1.0, "output": 2.0 } + }, + "opencode-go/tie-small-context": { + "id": "opencode-go/tie-small-context", + "name": "Tie Small Context", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 128000, "output": 4096 }, + "rates": { "input": 0.5, "output": 1.5 } + }, + "disabled-provider/only-disabled": { + "id": "disabled-provider/only-disabled", + "name": "Only Disabled", + "reasoning": false, + "tool_call": false, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 128000, "output": 4096 }, + "rates": { "input": 0.1, "output": 0.1 } + } + }, + "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 c08c1823..ccea04fb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,19 +22,22 @@ import ( "github.com/routatic/proxy/internal/provider" "github.com/routatic/proxy/internal/router" "github.com/routatic/proxy/internal/status" + "github.com/routatic/proxy/internal/storage" "github.com/routatic/proxy/internal/token" ) // Server represents the proxy server. type Server struct { - atomic *config.AtomicConfig - httpSrv *http.Server - mux http.Handler - mu sync.Mutex - logger *slog.Logger - levelVar *slog.LevelVar - History *history.History // exported so the ui command can read it - metrics *metrics.Metrics // stored for Metrics() getter + atomic *config.AtomicConfig + httpSrv *http.Server + mux http.Handler + mu sync.Mutex + logger *slog.Logger + levelVar *slog.LevelVar + History *history.History // exported so the ui command can read it + metrics *metrics.Metrics // stored for Metrics() getter + storage *storage.Database + retention *storage.Retention } // NewServer creates a new proxy server. @@ -58,7 +61,6 @@ func NewServer(atomic *config.AtomicConfig, captureLogger *debug.CaptureLogger) metrics := metrics.New() openCodeClient := client.NewOpenCodeClient(atomic, captureLogger) - modelRouter := router.NewModelRouter(atomic) fallbackHandler := router.NewFallbackHandler(logger, 3, 30*time.Second) fallbackHandler.SetAtomicConfig(atomic) @@ -74,7 +76,43 @@ func NewServer(atomic *config.AtomicConfig, captureLogger *debug.CaptureLogger) // Create history ring buffer (1000 entries, in-memory). hist := history.New(1000) + // Initialize SQLite storage first so catalog can use it. + var db *storage.Database + var retention *storage.Retention + storageCfg := storage.DefaultConfig + if cfg.Storage != nil { + storageCfg = storage.Config{ + DatabasePath: cfg.Storage.DatabasePath, + RetentionDays: cfg.Storage.RetentionDays, + VacuumOnStartup: cfg.Storage.VacuumOnStartup, + WALEnabled: cfg.Storage.WALEnabled, + } + } + if storageCfg.DatabasePath != "" { + db, err = storage.Open(storageCfg) + if err != nil { + logger.Warn("failed to open storage database, falling back to in-memory", "error", err) + } else { + logger.Info("storage database opened", "path", db.Path()) + retention = storage.NewRetention(db, storageCfg.RetentionDays) + retention.Start() + } + } + + // Create model router with SQLite catalog support. + var modelRouter *router.ModelRouter + if db != nil { + modelRouter = router.NewModelRouterWithDB(atomic, db) + } else { + modelRouter = router.NewModelRouter(atomic) + } + // Create handlers. + var storageWriter handlers.StorageWriter + if db != nil { + storageWriter = handlers.NewStorageAdapter(db) + } + messagesHandler := handlers.NewMessagesHandler( openCodeClient, providerRegistry, @@ -84,6 +122,7 @@ func NewServer(atomic *config.AtomicConfig, captureLogger *debug.CaptureLogger) metrics, captureLogger, hist, + storageWriter, ) healthHandler := handlers.NewHealthHandler(tokenCounter, fallbackHandler, metrics, statusStore) @@ -113,13 +152,15 @@ func NewServer(atomic *config.AtomicConfig, captureLogger *debug.CaptureLogger) } srv := &Server{ - atomic: atomic, - httpSrv: httpSrv, - mux: mux, - logger: logger, - levelVar: levelVar, - History: hist, - metrics: metrics, + atomic: atomic, + httpSrv: httpSrv, + mux: mux, + logger: logger, + levelVar: levelVar, + History: hist, + metrics: metrics, + storage: db, + retention: retention, } // Register callback to update log level on config reload @@ -136,6 +177,11 @@ func (s *Server) Metrics() *metrics.Metrics { return s.metrics } +// Storage returns the SQLite storage instance. +func (s *Server) Storage() *storage.Database { + return s.storage +} + // Start starts the server with graceful shutdown. func (s *Server) Start() error { cfg := s.atomic.Get() @@ -164,6 +210,14 @@ func (s *Server) Start() error { <-ctx.Done() s.logger.Info("shutting down server...") + if s.retention != nil { + s.retention.Stop() + } + + if s.storage != nil { + _ = s.storage.Close() + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/internal/storage/catalog.go b/internal/storage/catalog.go new file mode 100644 index 00000000..d2db20ea --- /dev/null +++ b/internal/storage/catalog.go @@ -0,0 +1,487 @@ +package storage + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" +) + +type CatalogRepo struct { + db *Database +} + +func NewCatalogRepo(db *Database) *CatalogRepo { + return &CatalogRepo{db: db} +} + +type ProviderRecord struct { + Name string + BaseURL string + APIKey string + Enabled *bool + AnthropicToolsDisabled bool +} + +type ModelRecord struct { + ID string + Name string + Reasoning bool + ToolCall bool + Vision bool + ContextWindow int64 + CostInput float64 + CostOutput float64 +} + +type Provider struct { + Name string + BaseURL string + APIKey string + Enabled *bool + AnthropicToolsDisabled bool +} + +type Modalities struct { + Input []string + Output []string +} + +type Limit struct { + Context int64 + Output int64 +} + +type Rates struct { + Input float64 + Output float64 +} + +type Model struct { + ID string + Name string + Reasoning bool + ToolCall bool + Vision bool + Modalities Modalities + Limit *Limit + Rates *Rates +} + +type Catalog struct { + Providers map[string]Provider + Models map[string]Model +} + +type IndexedCatalog struct { + Catalog + ProviderModels map[string][]Model +} + +func (m Model) ContextWindow() int64 { + if m.Limit != nil { + return m.Limit.Context + } + return 0 +} + +func (m Model) CostInputPerM() float64 { + if m.Rates != nil { + return m.Rates.Input + } + return 0 +} + +func (m Model) CostOutputPerM() float64 { + if m.Rates != nil { + return m.Rates.Output + } + return 0 +} + +func (r *CatalogRepo) UpsertBatch(ctx context.Context, providers []ProviderRecord, models []ModelRecord) error { + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + now := time.Now().UTC().Format(time.RFC3339) + + for _, p := range providers { + enabled := 1 + if p.Enabled != nil && !*p.Enabled { + enabled = 0 + } + anthropicToolsDisabled := 0 + if p.AnthropicToolsDisabled { + anthropicToolsDisabled = 1 + } + + _, err := tx.ExecContext(ctx, ` + INSERT OR REPLACE INTO providers (name, base_url, api_key, enabled, anthropic_tools_disabled, created_at) + VALUES (?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM providers WHERE name = ?), ?)) + `, + p.Name, p.BaseURL, p.APIKey, enabled, anthropicToolsDisabled, p.Name, now) + if err != nil { + return err + } + } + + for _, m := range models { + provider := providerFromModelKey(m.ID) + modelName := modelNameFromKey(m.ID) + + supportsTools := 1 + if !m.ToolCall { + supportsTools = 0 + } + supportsVision := 0 + if m.Vision { + supportsVision = 1 + } + supportsReasoning := 0 + if m.Reasoning { + supportsReasoning = 1 + } + + _, err := tx.ExecContext(ctx, ` + INSERT OR REPLACE INTO models (id, provider, name, display_name, context_window, cost_input_per_m, cost_output_per_m, supports_tools, supports_vision, supports_reasoning, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM models WHERE id = ?), ?)) + `, + m.ID, provider, modelName, m.Name, m.ContextWindow, m.CostInput, m.CostOutput, + supportsTools, supportsVision, supportsReasoning, m.ID, now) + if err != nil { + return err + } + } + + _, err = tx.ExecContext(ctx, ` + INSERT OR REPLACE INTO schema_info (key, value) VALUES ('catalog_last_sync', ?) + `, now) + if err != nil { + return err + } + + return tx.Commit() +} + +func (r *CatalogRepo) Load(ctx context.Context) (*IndexedCatalog, error) { + providers := make(map[string]Provider) + models := make(map[string]Model) + + rows, err := r.db.DB().QueryContext(ctx, ` + SELECT name, base_url, api_key, enabled, anthropic_tools_disabled + FROM providers + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var p Provider + var enabled sql.NullBool + var anthropicToolsDisabled int + + if err := rows.Scan(&p.Name, &p.BaseURL, &p.APIKey, &enabled, &anthropicToolsDisabled); err != nil { + return nil, err + } + if enabled.Valid { + p.Enabled = &enabled.Bool + } + p.AnthropicToolsDisabled = anthropicToolsDisabled == 1 + providers[p.Name] = p + } + + if err := rows.Err(); err != nil { + return nil, err + } + + rows, err = r.db.DB().QueryContext(ctx, ` + SELECT id, provider, name, context_window, cost_input_per_m, cost_output_per_m, + supports_tools, supports_vision, supports_reasoning + FROM models + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var m Model + var provider string + var displayName string + var contextWindow sql.NullInt64 + var costInput, costOutput sql.NullFloat64 + var supportsTools, supportsVision, supportsReasoning int + + if err := rows.Scan(&m.ID, &provider, &displayName, &contextWindow, &costInput, &costOutput, + &supportsTools, &supportsVision, &supportsReasoning); err != nil { + return nil, err + } + m.Name = displayName + m.ToolCall = supportsTools == 1 + m.Reasoning = supportsReasoning == 1 + m.Vision = supportsVision == 1 + + if contextWindow.Valid { + m.Limit = &Limit{Context: contextWindow.Int64} + } + if costInput.Valid || costOutput.Valid { + m.Rates = &Rates{} + if costInput.Valid { + m.Rates.Input = costInput.Float64 + } + if costOutput.Valid { + m.Rates.Output = costOutput.Float64 + } + } + + if m.Vision { + m.Modalities.Input = []string{"text", "image"} + } else { + m.Modalities.Input = []string{"text"} + } + m.Modalities.Output = []string{"text"} + + models[m.ID] = m + } + + if err := rows.Err(); err != nil { + return nil, err + } + + if len(providers) == 0 { + return nil, errors.New("catalog providers map is empty") + } + if len(models) == 0 { + return nil, errors.New("catalog models map is empty") + } + + for key := range models { + prov := providerFromModelKey(key) + if prov == "" { + return nil, errors.New("model key missing provider prefix") + } + if _, ok := providers[prov]; !ok { + return nil, errors.New("model references unknown provider") + } + } + + cat := &Catalog{ + Providers: providers, + Models: models, + } + + idx := &IndexedCatalog{ + Catalog: *cat, + ProviderModels: make(map[string][]Model, len(providers)), + } + + for key, model := range models { + prov := providerFromModelKey(key) + if prov != "" { + idx.ProviderModels[prov] = append(idx.ProviderModels[prov], model) + } + } + + return idx, nil +} + +func (r *CatalogRepo) GetModel(ctx context.Context, id string) (*Model, error) { + var m Model + var displayName string + var contextWindow sql.NullInt64 + var costInput, costOutput sql.NullFloat64 + var supportsTools, supportsVision, supportsReasoning int + + err := r.db.DB().QueryRowContext(ctx, ` + SELECT id, name, context_window, cost_input_per_m, cost_output_per_m, + supports_tools, supports_vision, supports_reasoning + FROM models + WHERE id = ? + `, id).Scan(&m.ID, &displayName, &contextWindow, &costInput, &costOutput, + &supportsTools, &supportsVision, &supportsReasoning) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + m.Name = displayName + m.ToolCall = supportsTools == 1 + m.Reasoning = supportsReasoning == 1 + m.Vision = supportsVision == 1 + + if contextWindow.Valid { + m.Limit = &Limit{Context: contextWindow.Int64} + } + if costInput.Valid || costOutput.Valid { + m.Rates = &Rates{} + if costInput.Valid { + m.Rates.Input = costInput.Float64 + } + if costOutput.Valid { + m.Rates.Output = costOutput.Float64 + } + } + + if m.Vision { + m.Modalities.Input = []string{"text", "image"} + } else { + m.Modalities.Input = []string{"text"} + } + m.Modalities.Output = []string{"text"} + + return &m, nil +} + +func (r *CatalogRepo) ListModelsByProvider(ctx context.Context, provider string) ([]Model, error) { + rows, err := r.db.DB().QueryContext(ctx, ` + SELECT id, name, context_window, cost_input_per_m, cost_output_per_m, + supports_tools, supports_vision, supports_reasoning + FROM models + WHERE provider = ? + `, provider) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var result []Model + for rows.Next() { + var m Model + var displayName string + var contextWindow sql.NullInt64 + var costInput, costOutput sql.NullFloat64 + var supportsTools, supportsVision, supportsReasoning int + + if err := rows.Scan(&m.ID, &displayName, &contextWindow, &costInput, &costOutput, + &supportsTools, &supportsVision, &supportsReasoning); err != nil { + return nil, err + } + + m.Name = displayName + m.ToolCall = supportsTools == 1 + m.Reasoning = supportsReasoning == 1 + m.Vision = supportsVision == 1 + + if contextWindow.Valid { + m.Limit = &Limit{Context: contextWindow.Int64} + } + if costInput.Valid || costOutput.Valid { + m.Rates = &Rates{} + if costInput.Valid { + m.Rates.Input = costInput.Float64 + } + if costOutput.Valid { + m.Rates.Output = costOutput.Float64 + } + } + + if m.Vision { + m.Modalities.Input = []string{"text", "image"} + } else { + m.Modalities.Input = []string{"text"} + } + m.Modalities.Output = []string{"text"} + + result = append(result, m) + } + + return result, rows.Err() +} + +func (r *CatalogRepo) LastSync(ctx context.Context) (time.Time, error) { + var syncedAt sql.NullString + err := r.db.DB().QueryRowContext(ctx, ` + SELECT value FROM schema_info WHERE key = 'catalog_last_sync' + `).Scan(&syncedAt) + if err == sql.ErrNoRows { + return time.Time{}, nil + } + if err != nil { + return time.Time{}, err + } + + if !syncedAt.Valid { + return time.Time{}, nil + } + + return time.Parse(time.RFC3339, syncedAt.String) +} + +func (r *CatalogRepo) SetLastSync(ctx context.Context, t time.Time) error { + _, err := r.db.DB().ExecContext(ctx, ` + INSERT OR REPLACE INTO schema_info (key, value) VALUES ('catalog_last_sync', ?) + `, t.Format(time.RFC3339)) + return err +} + +func providerFromModelKey(key string) string { + idx := strings.IndexByte(key, '/') + if idx < 0 { + return "" + } + return key[:idx] +} + +func modelNameFromKey(key string) string { + idx := strings.IndexByte(key, '/') + if idx < 0 { + return key + } + return key[idx+1:] +} + +type ProviderModel struct { + ModelID string + DisplayName string + ToolCall bool + Reasoning bool + Vision bool + Context int64 + CostInput float64 + CostOutput float64 +} + +func (ic *IndexedCatalog) ModelsForProvider(provider string) []Model { + return ic.ProviderModels[provider] +} + +func (ic *IndexedCatalog) ListProviderModels(provider string) []ProviderModel { + models := ic.ProviderModels[provider] + if models == nil { + return nil + } + result := make([]ProviderModel, len(models)) + for i, m := range models { + result[i] = ProviderModel{ + ModelID: ModelNameFromKey(m.ID), + DisplayName: m.Name, + ToolCall: m.ToolCall, + Reasoning: m.Reasoning, + Vision: m.Vision, + } + if m.Limit != nil { + result[i].Context = m.Limit.Context + } + if m.Rates != nil { + result[i].CostInput = m.Rates.Input + result[i].CostOutput = m.Rates.Output + } + } + return result +} + +func ModelNameFromKey(key string) string { + idx := strings.IndexByte(key, '/') + if idx < 0 { + return key + } + return key[idx+1:] +} diff --git a/internal/storage/database.go b/internal/storage/database.go new file mode 100644 index 00000000..e5f86853 --- /dev/null +++ b/internal/storage/database.go @@ -0,0 +1,214 @@ +// Package storage provides SQLite-based persistent storage for the proxy. +package storage + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + _ "modernc.org/sqlite" +) + +type Database struct { + db *sql.DB + path string + mu sync.RWMutex +} + +type Config struct { + DatabasePath string `json:"database_path"` + RetentionDays int `json:"retention_days"` + VacuumOnStartup bool `json:"vacuum_on_startup"` + WALEnabled bool `json:"wal_enabled"` +} + +var DefaultConfig = Config{ + DatabasePath: "~/.local/share/routatic-proxy/data.db", + RetentionDays: 7, + VacuumOnStartup: false, + WALEnabled: true, +} + +func Open(cfg Config) (*Database, error) { + path := expandPath(cfg.DatabasePath) + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + + dsn := path + if cfg.WALEnabled { + dsn = path + "?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=5000" + } + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + + database := &Database{ + db: db, + path: path, + } + + if err := database.initSchema(ctx); err != nil { + _ = database.Close() + return nil, fmt.Errorf("init schema: %w", err) + } + + if cfg.VacuumOnStartup { + if _, err := db.ExecContext(ctx, "VACUUM"); err != nil { + _ = database.Close() + return nil, fmt.Errorf("vacuum: %w", err) + } + } + + return database, nil +} + +func (d *Database) initSchema(ctx context.Context) error { + schema := ` + CREATE TABLE IF NOT EXISTS requests ( + id TEXT PRIMARY KEY, + model TEXT NOT NULL, + provider TEXT, + scenario TEXT, + start_time TIMESTAMP NOT NULL, + duration_ms INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + streaming INTEGER, + success INTEGER, + error_msg TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_requests_start_time ON requests(start_time); + CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model); + CREATE INDEX IF NOT EXISTS idx_requests_created_at ON requests(created_at); + + CREATE TABLE IF NOT EXISTS latency_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + model TEXT NOT NULL, + latency_ms INTEGER NOT NULL, + recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_latency_model_time ON latency_samples(model, recorded_at); + CREATE INDEX IF NOT EXISTS idx_latency_recorded_at ON latency_samples(recorded_at); + + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL, + message TEXT, + field TEXT, + value TEXT, + recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_logs_recorded_at ON logs(recorded_at); + CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level); + + CREATE TABLE IF NOT EXISTS schema_info ( + key TEXT PRIMARY KEY, + value TEXT + ); + + INSERT OR IGNORE INTO schema_info (key, value) VALUES ('version', '1'); + + CREATE TABLE IF NOT EXISTS providers ( + name TEXT PRIMARY KEY, + base_url TEXT, + api_key TEXT, + enabled INTEGER DEFAULT 1, + anthropic_tools_disabled INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_providers_enabled ON providers(enabled); + + CREATE TABLE IF NOT EXISTS models ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + name TEXT NOT NULL, + display_name TEXT, + context_window INTEGER, + cost_input_per_m REAL, + cost_output_per_m REAL, + supports_tools INTEGER DEFAULT 1, + supports_vision INTEGER DEFAULT 0, + supports_reasoning INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (provider) REFERENCES providers(name) + ); + + CREATE INDEX IF NOT EXISTS idx_models_provider ON models(provider); + CREATE INDEX IF NOT EXISTS idx_models_name ON models(name); + + CREATE TABLE IF NOT EXISTS scenarios ( + key TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + requires_tools INTEGER, + requires_vision INTEGER, + requires_reasoning INTEGER, + min_context_window INTEGER, + preferred_providers TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_scenarios_name ON scenarios(name); + ` + + _, err := d.db.ExecContext(ctx, schema) + return err +} + +func (d *Database) Close() error { + d.mu.Lock() + defer d.mu.Unlock() + if d.db != nil { + return d.db.Close() + } + return nil +} + +func (d *Database) DB() *sql.DB { + return d.db +} + +func (d *Database) Path() string { + return d.path +} + +func (d *Database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) { + return d.db.BeginTx(ctx, opts) +} + +func expandPath(path string) string { + if len(path) >= 2 && path[:2] == "~/" { + home, err := os.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(home, path[2:]) + } + return path +} diff --git a/internal/storage/latency.go b/internal/storage/latency.go new file mode 100644 index 00000000..024306d1 --- /dev/null +++ b/internal/storage/latency.go @@ -0,0 +1,194 @@ +package storage + +import ( + "context" + "math" + "sort" + "time" +) + +type Latency struct { + db *Database +} + +func NewLatency(db *Database) *Latency { + return &Latency{db: db} +} + +func (l *Latency) Insert(model string, latency time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := l.db.DB().ExecContext(ctx, ` + INSERT INTO latency_samples (model, latency_ms, recorded_at) + VALUES (?, ?, ?) + `, model, latency.Milliseconds(), time.Now().Format(time.RFC3339Nano)) + + return err +} + +type ModelLatencyStats struct { + Model string + Count int64 + Avg time.Duration + P50 time.Duration + P90 time.Duration + P99 time.Duration + Min time.Duration + Max time.Duration +} + +func (l *Latency) GetStats(since time.Time) ([]ModelLatencyStats, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + query := ` + SELECT model, latency_ms + FROM latency_samples + WHERE recorded_at >= ? + ORDER BY model + ` + + rows, err := l.db.DB().QueryContext(ctx, query, since.Format(time.RFC3339Nano)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + samplesByModel := make(map[string][]int64) + for rows.Next() { + var model string + var latencyMs int64 + if err := rows.Scan(&model, &latencyMs); err != nil { + return nil, err + } + samplesByModel[model] = append(samplesByModel[model], latencyMs) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + var stats []ModelLatencyStats + for model, samples := range samplesByModel { + stats = append(stats, calculateStats(model, samples)) + } + + return stats, nil +} + +func (l *Latency) GetSuccessCounts(since time.Time) (map[string]int64, map[string]int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + query := ` + SELECT model, success, COUNT(*) + FROM requests + WHERE start_time >= ? + GROUP BY model, success + ` + + rows, err := l.db.DB().QueryContext(ctx, query, since.Format(time.RFC3339Nano)) + if err != nil { + return nil, nil, err + } + defer func() { _ = rows.Close() }() + + successCounts := make(map[string]int64) + failureCounts := make(map[string]int64) + + for rows.Next() { + var model string + var success, count int64 + if err := rows.Scan(&model, &success, &count); err != nil { + return nil, nil, err + } + if success == 1 { + successCounts[model] = count + } else { + failureCounts[model] = count + } + } + + return successCounts, failureCounts, rows.Err() +} + +func (l *Latency) DeleteBefore(before time.Time) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := l.db.DB().ExecContext(ctx, ` + DELETE FROM latency_samples WHERE recorded_at < ? + `, before.Format(time.RFC3339Nano)) + if err != nil { + return 0, err + } + + return result.RowsAffected() +} + +func calculateStats(model string, samples []int64) ModelLatencyStats { + if len(samples) == 0 { + return ModelLatencyStats{Model: model} + } + + sorted := make([]int64, len(samples)) + copy(sorted, samples) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + + var sum int64 + for _, ms := range sorted { + sum += ms + } + + count := len(sorted) + avg := sum / int64(count) + + p50Idx := int(float64(count)*0.50) - 1 + p90Idx := int(math.Ceil(float64(count)*0.90)) - 1 + p99Idx := int(math.Ceil(float64(count)*0.99)) - 1 + if p50Idx < 0 { + p50Idx = 0 + } + if p90Idx < 0 { + p90Idx = 0 + } + if p99Idx < 0 { + p99Idx = 0 + } + if p50Idx >= count { + p50Idx = count - 1 + } + if p90Idx >= count { + p90Idx = count - 1 + } + if p99Idx >= count { + p99Idx = count - 1 + } + + return ModelLatencyStats{ + Model: model, + Count: int64(count), + Avg: time.Duration(avg) * time.Millisecond, + P50: time.Duration(sorted[p50Idx]) * time.Millisecond, + P90: time.Duration(sorted[p90Idx]) * time.Millisecond, + P99: time.Duration(sorted[p99Idx]) * time.Millisecond, + Min: time.Duration(sorted[0]) * time.Millisecond, + Max: time.Duration(sorted[count-1]) * time.Millisecond, + } +} + +func ParseTimeRange(rangeParam string) time.Time { + switch rangeParam { + case "1h": + return time.Now().Add(-1 * time.Hour) + case "24h": + return time.Now().Add(-24 * time.Hour) + case "7d": + return time.Now().Add(-7 * 24 * time.Hour) + case "30d": + return time.Now().Add(-30 * 24 * time.Hour) + default: + return time.Time{} + } +} diff --git a/internal/storage/logs.go b/internal/storage/logs.go new file mode 100644 index 00000000..ab56dca8 --- /dev/null +++ b/internal/storage/logs.go @@ -0,0 +1,129 @@ +package storage + +import ( + "context" + "database/sql" + "time" +) + +type Logs struct { + db *Database +} + +func NewLogs(db *Database) *Logs { + return &Logs{db: db} +} + +type LogEntry struct { + Time time.Time `json:"time"` + Level string `json:"level"` + Message string `json:"message"` + Field string `json:"field,omitempty"` + Value string `json:"value,omitempty"` +} + +func (l *Logs) Insert(level, message, field, value string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := l.db.DB().ExecContext(ctx, ` + INSERT INTO logs (level, message, field, value, recorded_at) + VALUES (?, ?, ?, ?, ?) + `, level, message, field, value, time.Now().Format(time.RFC3339Nano)) + + return err +} + +func (l *Logs) Last(n int) ([]LogEntry, error) { + if n <= 0 { + n = 200 + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + rows, err := l.db.DB().QueryContext(ctx, ` + SELECT level, message, field, value, recorded_at + FROM logs + ORDER BY recorded_at DESC + LIMIT ? + `, n) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return scanLogs(rows) +} + +func (l *Logs) Since(since time.Time, level string) ([]LogEntry, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var query string + var args []interface{} + + if level != "" && level != "all" { + query = ` + SELECT level, message, field, value, recorded_at + FROM logs + WHERE recorded_at >= ? AND level = ? + ORDER BY recorded_at DESC + ` + args = []interface{}{since.Format(time.RFC3339Nano), level} + } else { + query = ` + SELECT level, message, field, value, recorded_at + FROM logs + WHERE recorded_at >= ? + ORDER BY recorded_at DESC + ` + args = []interface{}{since.Format(time.RFC3339Nano)} + } + + rows, err := l.db.DB().QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return scanLogs(rows) +} + +func (l *Logs) DeleteBefore(before time.Time) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := l.db.DB().ExecContext(ctx, ` + DELETE FROM logs WHERE recorded_at < ? + `, before.Format(time.RFC3339Nano)) + if err != nil { + return 0, err + } + + return result.RowsAffected() +} + +func scanLogs(rows *sql.Rows) ([]LogEntry, error) { + var entries []LogEntry + for rows.Next() { + var entry LogEntry + var recordedAtStr string + + err := rows.Scan( + &entry.Level, + &entry.Message, + &entry.Field, + &entry.Value, + &recordedAtStr, + ) + if err != nil { + return nil, err + } + + entry.Time, _ = time.Parse(time.RFC3339Nano, recordedAtStr) + entries = append(entries, entry) + } + + return entries, rows.Err() +} diff --git a/internal/storage/requests.go b/internal/storage/requests.go new file mode 100644 index 00000000..17e94f5f --- /dev/null +++ b/internal/storage/requests.go @@ -0,0 +1,161 @@ +package storage + +import ( + "context" + "database/sql" + "time" + + "github.com/routatic/proxy/internal/history" +) + +type Requests struct { + db *Database +} + +func NewRequests(db *Database) *Requests { + return &Requests{db: db} +} + +func (r *Requests) Insert(rec history.RequestRecord) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := r.db.DB().ExecContext(ctx, ` + INSERT OR REPLACE INTO requests ( + id, model, provider, scenario, start_time, duration_ms, + input_tokens, output_tokens, streaming, success, error_msg + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + rec.ID, + rec.Model, + rec.Provider, + rec.Scenario, + rec.StartTime.Format(time.RFC3339Nano), + rec.Duration.Milliseconds(), + rec.InputTokens, + rec.OutputTokens, + boolToInt(rec.Streaming), + boolToInt(rec.Success), + rec.ErrorMsg, + ) + + return err +} + +func (r *Requests) Last(n int) ([]history.RequestRecord, error) { + if n <= 0 { + n = 1000 + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + rows, err := r.db.DB().QueryContext(ctx, ` + SELECT id, model, provider, scenario, start_time, duration_ms, + input_tokens, output_tokens, streaming, success, error_msg + FROM requests + ORDER BY start_time DESC + LIMIT ? + `, n) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return scanRequests(rows) +} + +func (r *Requests) Since(since time.Time) ([]history.RequestRecord, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + rows, err := r.db.DB().QueryContext(ctx, ` + SELECT id, model, provider, scenario, start_time, duration_ms, + input_tokens, output_tokens, streaming, success, error_msg + FROM requests + WHERE start_time >= ? + ORDER BY start_time DESC + `, since.Format(time.RFC3339Nano)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return scanRequests(rows) +} + +func (r *Requests) Count() (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var count int64 + err := r.db.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM requests`).Scan(&count) + return count, err +} + +func (r *Requests) CountSince(since time.Time) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var count int64 + err := r.db.DB().QueryRowContext(ctx, ` + SELECT COUNT(*) FROM requests WHERE start_time >= ? + `, since.Format(time.RFC3339Nano)).Scan(&count) + return count, err +} + +func (r *Requests) DeleteBefore(before time.Time) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := r.db.DB().ExecContext(ctx, ` + DELETE FROM requests WHERE created_at < ? + `, before.Format(time.RFC3339Nano)) + if err != nil { + return 0, err + } + + return result.RowsAffected() +} + +func scanRequests(rows *sql.Rows) ([]history.RequestRecord, error) { + var records []history.RequestRecord + for rows.Next() { + var rec history.RequestRecord + var startTimeStr string + var streaming, success int + + err := rows.Scan( + &rec.ID, + &rec.Model, + &rec.Provider, + &rec.Scenario, + &startTimeStr, + &rec.Duration, + &rec.InputTokens, + &rec.OutputTokens, + &streaming, + &success, + &rec.ErrorMsg, + ) + if err != nil { + return nil, err + } + + rec.StartTime, _ = time.Parse(time.RFC3339Nano, startTimeStr) + rec.Streaming = streaming == 1 + rec.Success = success == 1 + rec.Duration = time.Duration(rec.Duration) * time.Millisecond + + records = append(records, rec) + } + + return records, rows.Err() +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/storage/retention.go b/internal/storage/retention.go new file mode 100644 index 00000000..c7ff263b --- /dev/null +++ b/internal/storage/retention.go @@ -0,0 +1,98 @@ +package storage + +import ( + "context" + "log/slog" + "time" +) + +type Retention struct { + db *Database + days int + interval time.Duration + stopCh chan struct{} + doneCh chan struct{} +} + +func NewRetention(db *Database, days int) *Retention { + if days <= 0 { + days = 7 + } + return &Retention{ + db: db, + days: days, + interval: 1 * time.Hour, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +func (r *Retention) Start() { + go r.run() +} + +func (r *Retention) Stop() { + close(r.stopCh) + <-r.doneCh +} + +func (r *Retention) run() { + defer close(r.doneCh) + + ticker := time.NewTicker(r.interval) + defer ticker.Stop() + + r.runOnce() + + for { + select { + case <-r.stopCh: + return + case <-ticker.C: + r.runOnce() + } + } +} + +func (r *Retention) runOnce() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + before := time.Now().AddDate(0, 0, -r.days) + + stats := struct { + requestsDeleted int64 + latencyDeleted int64 + logsDeleted int64 + }{} + + if requests, err := r.db.DB().ExecContext(ctx, `DELETE FROM requests WHERE created_at < ?`, before.Format(time.RFC3339Nano)); err == nil { + stats.requestsDeleted, _ = requests.RowsAffected() + } + + if latency, ok := r.db.DB().ExecContext(ctx, `DELETE FROM latency_samples WHERE recorded_at < ?`, before.Format(time.RFC3339Nano)); ok == nil { + stats.latencyDeleted, _ = latency.RowsAffected() + } + + if logs, ok := r.db.DB().ExecContext(ctx, `DELETE FROM logs WHERE recorded_at < ?`, before.Format(time.RFC3339Nano)); ok == nil { + stats.logsDeleted, _ = logs.RowsAffected() + } + + if stats.requestsDeleted > 0 || stats.latencyDeleted > 0 || stats.logsDeleted > 0 { + slog.Debug("retention cleanup", + "requests_deleted", stats.requestsDeleted, + "latency_deleted", stats.latencyDeleted, + "logs_deleted", stats.logsDeleted, + "retention_days", r.days) + } +} + +func (r *Retention) SetDays(days int) { + if days > 0 { + r.days = days + } +} + +func (r *Retention) Days() int { + return r.days +} diff --git a/internal/transformer/stream.go b/internal/transformer/stream.go index 859d56af..7a2ac90f 100644 --- a/internal/transformer/stream.go +++ b/internal/transformer/stream.go @@ -10,7 +10,7 @@ import ( "net" "net/http" "sort" - "strings" + "sync" "time" "github.com/routatic/proxy/pkg/types" @@ -24,6 +24,17 @@ var ErrClientDisconnected = fmt.Errorf("client disconnected") // partition). The handler decides whether to fall back to another model. var ErrStreamIdle = fmt.Errorf("upstream stream idle") +// readBufPool pools read buffers for streaming operations. +// sync.Pool reduces GC pressure under concurrent stream load by reusing +// 4KB buffers across goroutines instead of allocating fresh ones per read. +// Pool stores pointers to slices to avoid allocation on Put (SA6002). +var readBufPool = sync.Pool{ + New: func() any { + b := make([]byte, 4096) + return &b + }, +} + // IsIdleTimeout reports whether err is a read-timeout (network deadline // exceeded on an otherwise live stream). func IsIdleTimeout(err error) bool { @@ -205,7 +216,7 @@ func (h *StreamHandler) ProxyStream( // Read directly from response body without buffering. // Use a tight loop with a line buffer - no bufio.Reader. contentIndex := 0 - var lineBuf bytes.Buffer + var lineBuf []byte contentStarted := false reasoningStarted := false stopSent := false @@ -213,8 +224,9 @@ func (h *StreamHandler) ProxyStream( startedToolCalls := make(map[int]int) // maps OpenAI tool call index → Anthropic content block index decodeErrors := 0 // consecutive SSE decode failures - // Read in larger chunks for efficiency, then parse lines - readBuf := make([]byte, 4096) + // Get a buffer from the pool; return it when done. + readBuf := readBufPool.Get().(*[]byte) + defer readBufPool.Put(readBuf) // Start the idle watchdog. Each successful read pings the watchdog so // the stream lives as long as data keeps flowing. If no bytes arrive @@ -231,33 +243,30 @@ func (h *StreamHandler) ProxyStream( } // Read chunk from upstream - n, err := openaiResp.Read(readBuf) + n, err := openaiResp.Read(*readBuf) if n > 0 { // Data is flowing — reset the idle watchdog so the stream // lives as long as data keeps arriving. ping() // Process bytes immediately for i := 0; i < n; i++ { - b := readBuf[i] + b := (*readBuf)[i] if b == '\n' { - line := lineBuf.String() - lineBuf.Reset() - // Process complete line - if err := h.processSSELine(w, flusher, line, &contentIndex, &contentStarted, &reasoningStarted, &stopSent, &toolUseCount, startedToolCalls, originalModel, &decodeErrors); err != nil { + if err := h.processSSELine(w, flusher, lineBuf, &contentIndex, &contentStarted, &reasoningStarted, &stopSent, &toolUseCount, startedToolCalls, originalModel, &decodeErrors); err != nil { return err } + lineBuf = lineBuf[:0] } else { - lineBuf.WriteByte(b) + lineBuf = append(lineBuf, b) } } } if err == io.EOF { // Process any remaining data in buffer - if lineBuf.Len() > 0 { - line := lineBuf.String() - if err := h.processSSELine(w, flusher, line, &contentIndex, &contentStarted, &reasoningStarted, &stopSent, &toolUseCount, startedToolCalls, originalModel, &decodeErrors); err != nil { + if len(lineBuf) > 0 { + if err := h.processSSELine(w, flusher, lineBuf, &contentIndex, &contentStarted, &reasoningStarted, &stopSent, &toolUseCount, startedToolCalls, originalModel, &decodeErrors); err != nil { return err } } @@ -349,7 +358,7 @@ func (h *StreamHandler) ProxyStream( func (h *StreamHandler) processSSELine( w http.ResponseWriter, flusher http.Flusher, - line string, + line []byte, contentIndex *int, contentStarted *bool, reasoningStarted *bool, @@ -359,25 +368,25 @@ func (h *StreamHandler) processSSELine( originalModel string, decodeErrors *int, ) error { - line = strings.TrimSpace(line) + line = bytes.TrimSpace(line) // Skip empty lines - if line == "" { + if len(line) == 0 { return nil } // Skip non-data lines (event: lines, id: lines, etc.) - if !strings.HasPrefix(line, "data: ") { + if !bytes.HasPrefix(line, []byte("data: ")) { return nil } - data := strings.TrimPrefix(line, "data: ") - if data == "" { + data := line[6:] + if len(data) == 0 { return nil } // Handle [DONE] marker - if data == "[DONE]" { + if bytes.Equal(data, []byte("[DONE]")) { return nil } @@ -387,11 +396,11 @@ func (h *StreamHandler) processSSELine( // correctly. Otherwise reasoning_content gets silently dropped, and on the // next turn DeepSeek rejects the request with: // "The reasoning_content in the thinking mode must be passed back to the API." - if !strings.Contains(data, `"reasoning_content"`) && - !strings.Contains(data, `"finish_reason"`) && - !strings.Contains(data, `"tool_calls"`) && - !strings.Contains(data, `"usage"`) { - if idx := strings.Index(data, `"delta":{"content":"`); idx != -1 { + if !bytes.Contains(data, []byte(`"reasoning_content"`)) && + !bytes.Contains(data, []byte(`"finish_reason"`)) && + !bytes.Contains(data, []byte(`"tool_calls"`)) && + !bytes.Contains(data, []byte(`"usage"`)) { + if idx := bytes.Index(data, []byte(`"delta":{"content":"`)); idx != -1 { // Walk past JSON escape sequences to find the real closing // quote. A naive strings.Index would stop at an escaped // \" inside the content. @@ -410,7 +419,7 @@ func (h *StreamHandler) processSSELine( } if end != -1 { content := data[start : start+end] - if content != "" { + if len(content) > 0 { if !*contentStarted { // If reasoning was already started, close it first if *reasoningStarted { @@ -435,7 +444,7 @@ func (h *StreamHandler) processSSELine( // Send content_block_delta delta := types.Delta{ Type: "text_delta", - Text: content, + Text: string(content), } event := types.MessageEvent{ Type: "content_block_delta", @@ -458,7 +467,7 @@ func (h *StreamHandler) processSSELine( // For tool calls and other complex cases, fall back to full JSON parsing var chunk types.ChatCompletionChunk - if err := json.Unmarshal([]byte(data), &chunk); err != nil { + if err := json.Unmarshal(data, &chunk); err != nil { // Track consecutive decode failures. A transient glitch is tolerated, // but persistent corruption terminates the stream rather than silently // dropping content. @@ -818,10 +827,11 @@ func (h *StreamHandler) ProxyResponsesStream( flusher.Flush() contentIndex := 0 - var lineBuf bytes.Buffer + var lineBuf []byte contentStarted := false stopSent := false - readBuf := make([]byte, 4096) + readBuf := readBufPool.Get().(*[]byte) + defer readBufPool.Put(readBuf) ping := StartIdleWatchdog(clientCtx, cancel, idleTimeout) @@ -832,27 +842,25 @@ func (h *StreamHandler) ProxyResponsesStream( default: } - n, err := responsesResp.Read(readBuf) + n, err := responsesResp.Read(*readBuf) if n > 0 { ping() for i := 0; i < n; i++ { - b := readBuf[i] + b := (*readBuf)[i] if b == '\n' { - line := lineBuf.String() - lineBuf.Reset() - if err := h.processResponsesSSELine(w, flusher, line, &contentIndex, &contentStarted, &stopSent, originalModel); err != nil { + if err := h.processResponsesSSELine(w, flusher, lineBuf, &contentIndex, &contentStarted, &stopSent, originalModel); err != nil { return err } + lineBuf = lineBuf[:0] } else { - lineBuf.WriteByte(b) + lineBuf = append(lineBuf, b) } } } if err == io.EOF { - if lineBuf.Len() > 0 { - line := lineBuf.String() - if err := h.processResponsesSSELine(w, flusher, line, &contentIndex, &contentStarted, &stopSent, originalModel); err != nil { + if len(lineBuf) > 0 { + if err := h.processResponsesSSELine(w, flusher, lineBuf, &contentIndex, &contentStarted, &stopSent, originalModel); err != nil { return err } } @@ -907,24 +915,24 @@ func (h *StreamHandler) ProxyResponsesStream( func (h *StreamHandler) processResponsesSSELine( w http.ResponseWriter, flusher http.Flusher, - line string, + line []byte, contentIndex *int, contentStarted *bool, stopSent *bool, originalModel string, ) error { - line = strings.TrimSpace(line) - if line == "" || !strings.HasPrefix(line, "data: ") { + line = bytes.TrimSpace(line) + if len(line) == 0 || !bytes.HasPrefix(line, []byte("data: ")) { return nil } - data := strings.TrimPrefix(line, "data: ") - if data == "" || data == "[DONE]" { + data := line[6:] + if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { return nil } var chunk types.ResponsesChunk - if err := json.Unmarshal([]byte(data), &chunk); err != nil { + if err := json.Unmarshal(data, &chunk); err != nil { return nil } @@ -1010,10 +1018,11 @@ func (h *StreamHandler) ProxyGeminiStream( flusher.Flush() contentIndex := 0 - var lineBuf bytes.Buffer + var lineBuf []byte contentStarted := false stopSent := false - readBuf := make([]byte, 4096) + readBuf := readBufPool.Get().(*[]byte) + defer readBufPool.Put(readBuf) ping := StartIdleWatchdog(clientCtx, cancel, idleTimeout) @@ -1024,27 +1033,25 @@ func (h *StreamHandler) ProxyGeminiStream( default: } - n, err := geminiResp.Read(readBuf) + n, err := geminiResp.Read(*readBuf) if n > 0 { ping() for i := 0; i < n; i++ { - b := readBuf[i] + b := (*readBuf)[i] if b == '\n' { - line := lineBuf.String() - lineBuf.Reset() - if err := h.processGeminiSSELine(w, flusher, line, &contentIndex, &contentStarted, &stopSent, originalModel); err != nil { + if err := h.processGeminiSSELine(w, flusher, lineBuf, &contentIndex, &contentStarted, &stopSent, originalModel); err != nil { return err } + lineBuf = lineBuf[:0] } else { - lineBuf.WriteByte(b) + lineBuf = append(lineBuf, b) } } } if err == io.EOF { - if lineBuf.Len() > 0 { - line := lineBuf.String() - if err := h.processGeminiSSELine(w, flusher, line, &contentIndex, &contentStarted, &stopSent, originalModel); err != nil { + if len(lineBuf) > 0 { + if err := h.processGeminiSSELine(w, flusher, lineBuf, &contentIndex, &contentStarted, &stopSent, originalModel); err != nil { return err } } @@ -1099,24 +1106,24 @@ func (h *StreamHandler) ProxyGeminiStream( func (h *StreamHandler) processGeminiSSELine( w http.ResponseWriter, flusher http.Flusher, - line string, + line []byte, contentIndex *int, contentStarted *bool, stopSent *bool, originalModel string, ) error { - line = strings.TrimSpace(line) - if line == "" || !strings.HasPrefix(line, "data: ") { + line = bytes.TrimSpace(line) + if len(line) == 0 || !bytes.HasPrefix(line, []byte("data: ")) { return nil } - data := strings.TrimPrefix(line, "data: ") - if data == "" { + data := line[6:] + if len(data) == 0 { return nil } var chunk types.GeminiStreamChunk - if err := json.Unmarshal([]byte(data), &chunk); err != nil { + if err := json.Unmarshal(data, &chunk); err != nil { return nil } diff --git a/internal/tray/tray.go b/internal/tray/tray_darwin.go similarity index 71% rename from internal/tray/tray.go rename to internal/tray/tray_darwin.go index 6cea35ec..2da57724 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray_darwin.go @@ -1,17 +1,11 @@ -//go:build darwin +//go:build darwin && cgo -// Package tray manages the macOS system tray icon and menu. -// -// NOTE: Run must be called exactly once per process. The package-level -// menu item variables (mStatus, mStart, etc.) are populated during Run's -// onReady callback and are not safe for concurrent use across multiple calls. package tray import ( "github.com/getlantern/systray" ) -// Callbacks holds the functions the tray calls when menu items are clicked. type Callbacks struct { InitiallyRunning bool InitiallyAutostart bool @@ -22,8 +16,6 @@ type Callbacks struct { OnQuit func() } -// Run initialises the system tray and blocks until quit. -// Call this on the main thread (required by macOS). func Run(cb Callbacks) { systray.Run(func() { onReady(cb) }, func() {}) } @@ -40,7 +32,7 @@ var ( func onReady(cb Callbacks) { systray.SetTitle("") systray.SetTooltip("routatic-proxy") - setIcon(false) // start with stopped icon + setIcon(false) mStatus = systray.AddMenuItem("● Stopped", "") mStatus.Disable() @@ -59,7 +51,6 @@ func onReady(cb Callbacks) { mQuit = systray.AddMenuItem("Quit", "") - // Set initial state safely now that menu items are created SetRunning(cb.InitiallyRunning) SetAutostart(cb.InitiallyAutostart) @@ -98,7 +89,6 @@ func onReady(cb Callbacks) { }() } -// SetRunning updates the tray menu to reflect proxy running state. func SetRunning(running bool) { if mStatus == nil || mStart == nil || mStop == nil { return @@ -116,7 +106,6 @@ func SetRunning(running bool) { } } -// SetAutostart updates the autostart checkbox state. func SetAutostart(enabled bool) { if mAutostart == nil { return @@ -128,8 +117,6 @@ func SetAutostart(enabled bool) { } } -// setIcon sets a minimal text icon (systray title) depending on state. -// A real app would embed an .icns; here we use a unicode bullet. func setIcon(running bool) { if running { systray.SetTitle("▶") @@ -138,7 +125,6 @@ func setIcon(running bool) { } } -// Quit terminates the systray loop and removes the icon. func Quit() { systray.Quit() } diff --git a/internal/tray/tray_stub.go b/internal/tray/tray_stub.go new file mode 100644 index 00000000..c57d2f50 --- /dev/null +++ b/internal/tray/tray_stub.go @@ -0,0 +1,21 @@ +//go:build !(darwin && cgo) + +package tray + +type Callbacks struct { + InitiallyRunning bool + InitiallyAutostart bool + OnOpen func() + OnStart func() + OnStop func() + OnAutostart func(enabled bool) + OnQuit func() +} + +func Run(cb Callbacks) {} + +func SetRunning(running bool) {} + +func SetAutostart(enabled bool) {} + +func Quit() {} diff --git a/rules/auto-detected/CONSTRUCTOR_PATTERN.md b/rules/auto-detected/CONSTRUCTOR_PATTERN.md new file mode 100644 index 00000000..aa426597 --- /dev/null +++ b/rules/auto-detected/CONSTRUCTOR_PATTERN.md @@ -0,0 +1,55 @@ +# Constructor Pattern + +> Auto-detected by /spartan:scan-rules — review and edit as needed. + +Use `New` functions to create instances. Return pointer types. Apply sensible defaults for nil/zero parameters. + +## CORRECT + +```go +// Constructor with defaults +func NewFallbackHandler(logger *slog.Logger, threshold int, timeout time.Duration) *FallbackHandler { + if logger == nil { + logger = slog.Default() + } + if threshold <= 0 { + threshold = 3 // sensible default + } + if timeout <= 0 { + timeout = 30 * time.Second + } + return &FallbackHandler{ + logger: logger, + threshold: threshold, + timeout: timeout, + } +} + +// Simple constructor +func NewRequestTransformer() *RequestTransformer { + return &RequestTransformer{} +} +``` + +## WRONG + +```go +// No defaults, caller must know magic values +func NewFallbackHandler(threshold int, timeout time.Duration) *FallbackHandler { + return &FallbackHandler{threshold: threshold, timeout: timeout} + // Panics or misbehaves if threshold=0 +} + +// Inconsistent naming +func CreateFallbackHandler(...) *FallbackHandler +func MakeFallbackHandler(...) *FallbackHandler +``` + +## Quick Reference + +| Aspect | Convention | +|--------|-----------| +| Naming | `New` | +| Return | Pointer to struct | +| Nil params | Apply sensible defaults | +| Zero values | Apply sensible defaults | diff --git a/rules/auto-detected/CONTEXT_PROPAGATION.md b/rules/auto-detected/CONTEXT_PROPAGATION.md new file mode 100644 index 00000000..4924dba0 --- /dev/null +++ b/rules/auto-detected/CONTEXT_PROPAGATION.md @@ -0,0 +1,63 @@ +# Context Propagation + +> Auto-detected by /spartan:scan-rules — review and edit as needed. + +Context is always the first parameter. Use per-attempt contexts with timeouts for operations that can fail independently. Check cancellation at loop boundaries. + +## CORRECT + +```go +// Context as first parameter +func (h *FallbackHandler) ExecuteWithFallback( + ctx context.Context, + models []config.ModelConfig, + executor func(context.Context, config.ModelConfig) ([]byte, error), +) (*FallbackResult, []byte, error) { + for i, model := range models { + // Check cancellation at loop start + if err := ctx.Err(); err != nil { + return nil, nil, err + } + + // Per-attempt context with timeout + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + body, err := executor(attemptCtx, model) + cancel() // Always cancel + + if err == nil { + return result, body, nil + } + } +} + +// Distinguish client disconnect from upstream timeout +if clientCtx.Err() != nil { + return ErrClientDisconnected +} +return ErrStreamIdle +``` + +## WRONG + +```go +// Context not first parameter +func Execute(models []Model, ctx context.Context) error + +// No cancellation check in loops +for _, model := range models { + body, err := executor(ctx, model) // Blocks forever on hung upstream +} + +// Missing cancel call +attemptCtx, _ := context.WithTimeout(ctx, timeout) +// defer cancel() // Missing! +``` + +## Quick Reference + +| Aspect | Convention | +|--------|-----------| +| Position | First parameter | +| Per-attempt | `context.WithTimeout` for each upstream call | +| Cleanup | Always `defer cancel()` or explicit `cancel()` | +| Loop check | `if ctx.Err() != nil` at loop boundary | diff --git a/rules/auto-detected/ERROR_HANDLING.md b/rules/auto-detected/ERROR_HANDLING.md new file mode 100644 index 00000000..5fa79839 --- /dev/null +++ b/rules/auto-detected/ERROR_HANDLING.md @@ -0,0 +1,61 @@ +# Structured Error Handling + +> Auto-detected by /spartan:scan-rules — review and edit as needed. + +Use sentinel errors at package level for common failures, wrap errors with context using `fmt.Errorf`, and provide error classification functions for callers to make decisions. + +## CORRECT + +```go +// internal/core/errors.go +var ( + ErrModelNotFound = errors.New("model not found") + ErrProviderNotFound = errors.New("provider not found") + ErrRateLimited = errors.New("rate limited by provider") +) + +// Wrap with context +if err := json.Unmarshal(rawBody, &req); err != nil { + return fmt.Errorf("failed to parse request body: %w", err) +} + +// Classification function +func IsRetryableError(err error) bool { + if err == nil { + return false + } + var apiErr *client.APIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode >= 500 + } + return false +} +``` + +## WRONG + +```go +// No sentinel errors, inline string comparisons +if err.Error() == "rate limit" { + // Fragile - breaks if error message changes +} + +// No error wrapping +if err != nil { + return err // Loses context +} + +// No classification helpers +if strings.Contains(err.Error(), "timeout") { + // Every caller duplicates this logic +} +``` + +## Quick Reference + +| Aspect | Convention | +|--------|-----------| +| Sentinel errors | `var Err... = errors.New("...")` at package level | +| Wrapping | `fmt.Errorf("context: %w", err)` | +| Classification | `func IsXError(err error) bool` functions | +| Nil check | Always check `err == nil` first in classifiers | diff --git a/rules/auto-detected/JSON_RAWMESSAGE.md b/rules/auto-detected/JSON_RAWMESSAGE.md new file mode 100644 index 00000000..e9f8b40b --- /dev/null +++ b/rules/auto-detected/JSON_RAWMESSAGE.md @@ -0,0 +1,73 @@ +# JSON RawMessage for Polymorphic Fields + +> Auto-detected by /spartan:scan-rules — review and edit as needed. + +Use `json.RawMessage` for fields that accept multiple types (string or array). Provide accessor methods that handle both formats. Delay parsing until needed. + +## CORRECT + +```go +// Field accepts string OR array +type MessageRequest struct { + System json.RawMessage `json:"system,omitempty"` + Messages []Message `json:"messages"` +} + +// Accessor handles both formats +func (r *MessageRequest) SystemText() string { + if len(r.System) == 0 { + return "" + } + // Try string first + var s string + if err := json.Unmarshal(r.System, &s); err == nil { + return s + } + // Try array of content blocks + var blocks []SystemContentBlock + if err := json.Unmarshal(r.System, &blocks); err == nil { + var text string + for _, b := range blocks { + if b.Type == "text" { + text += b.Text + } + } + return text + } + return string(r.System) +} + +// Content field also polymorphic +type Message struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +func (m *Message) ContentBlocks() []ContentBlock { + // Try string first, then array +} +``` + +## WRONG + +```go +// Assumes only one format +type MessageRequest struct { + System string `json:"system"` // Breaks if upstream sends array +} + +// No accessor, inline parsing everywhere +func processRequest(req *MessageRequest) { + var system string + json.Unmarshal(req.System, &system) // Duplicated logic +} +``` + +## Quick Reference + +| Aspect | Convention | +|--------|-----------| +| Field type | `json.RawMessage` for polymorphic fields | +| Accessor | `func (t *T) FieldName() Type` method | +| Order | Try simpler format first (string before array) | +| Fallback | Return raw string if all parsing fails | diff --git a/rules/auto-detected/SLOG_LOGGING.md b/rules/auto-detected/SLOG_LOGGING.md new file mode 100644 index 00000000..b9cd28ec --- /dev/null +++ b/rules/auto-detected/SLOG_LOGGING.md @@ -0,0 +1,61 @@ +# Structured Logging with Slog + +> Auto-detected by /spartan:scan-rules — review and edit as needed. + +Use `log/slog` with key-value pairs. Include contextual fields like `model_id`, `provider`, `attempt`. Log at appropriate levels: Debug for routine operations, Info for significant events, Warn for recoverable issues, Error for failures. + +## CORRECT + +```go +// Structured key-value logging +h.logger.Info("attempting model", + "model", model.ModelID, + "provider", model.Provider, + "attempt", i+1, + "total", totalModels, +) + +// Debug for routine operations +h.logger.Debug("client disconnected during stream") + +// Warn for recoverable issues +h.logger.Warn("model failed, trying fallback", + "model", model.ModelID, + "error", err, + "remaining", totalModels-i-1, +) + +// Error for failures +h.logger.Error("request error", + "status", statusCode, + "message", message, + "error", err, +) + +// Nil logger fallback +if logger == nil { + logger = slog.Default() +} +``` + +## WRONG + +```go +// Unstructured printf-style logging +log.Printf("attempting model %s (attempt %d/%d)", model.ModelID, i+1, total) + +// Wrong level +h.logger.Error("model failed, trying fallback") // Should be Warn + +// Missing context +h.logger.Info("request completed") // Which model? How long? +``` + +## Quick Reference + +| Aspect | Convention | +|--------|-----------| +| Package | `log/slog` | +| Levels | Debug < Info < Warn < Error | +| Format | Key-value pairs after message | +| Nil safety | Default to `slog.Default()` | diff --git a/rules/auto-detected/SYNC_MUTEX.md b/rules/auto-detected/SYNC_MUTEX.md new file mode 100644 index 00000000..8278f34e --- /dev/null +++ b/rules/auto-detected/SYNC_MUTEX.md @@ -0,0 +1,75 @@ +# Sync.Mutex for Concurrent State + +> Auto-detected by /spartan:scan-rules — review and edit as needed. + +Use `sync.Mutex` for protecting shared state. Prefer pointer mutex embedded in structs. Always lock/unlock in the same method. Use `defer mu.Unlock()` for safety. + +## CORRECT + +```go +// Pointer mutex in struct +type CircuitBreaker struct { + mu sync.Mutex + state CircuitState + failureCount int + lastFailureTime time.Time +} + +// Lock with defer for safety +func (cb *CircuitBreaker) AllowRequest() bool { + cb.mu.Lock() + defer cb.mu.Unlock() + + switch cb.state { + case CircuitClosed: + return true + case CircuitOpen: + if time.Since(cb.lastFailureTime) > cb.recoveryTimeout { + cb.state = CircuitHalfOpen + return true + } + return false + } + return false +} + +// Separate mutexes for separate concerns +type FallbackHandler struct { + mu sync.Mutex // For provider blocking + circuitBreakers map[string]*CircuitBreaker + cbMu sync.Mutex // Separate lock for circuit breaker map +} +``` + +## WRONG + +```go +// Value mutex (copies are useless) +type CircuitBreaker struct { + mu sync.Mutex // Non-pointer, works but unusual +} + +// Missing unlock on error path +func (cb *CircuitBreaker) Allow() bool { + cb.mu.Lock() + if cb.state == CircuitOpen { + return false // Forgot to unlock! + } + cb.mu.Unlock() + return true +} + +// Single mutex for everything +type Handler struct { + mu sync.Mutex // Protects everything - too coarse +} +``` + +## Quick Reference + +| Aspect | Convention | +|--------|-----------| +| Declaration | `mu sync.Mutex` (pointer receiver methods work) | +| Lock pattern | `mu.Lock(); defer mu.Unlock()` | +| Granularity | Separate mutexes for independent state | +| RLock | Use `sync.RWMutex` when reads dominate | diff --git a/rules/auto-detected/TEST_HELPERS.md b/rules/auto-detected/TEST_HELPERS.md new file mode 100644 index 00000000..1e7c8bb6 --- /dev/null +++ b/rules/auto-detected/TEST_HELPERS.md @@ -0,0 +1,74 @@ +# Test Helper Functions + +> Auto-detected by /spartan:scan-rules — review and edit as needed. + +Mark helper functions with `t.Helper()` for better error line numbers. Create factory functions for test fixtures. Use descriptive test names that explain the scenario. + +## CORRECT + +```go +// Helper function with t.Helper() +func newTestMessagesHandler(t *testing.T, cfg *config.Config) *MessagesHandler { + t.Helper() + return &MessagesHandler{ + modelRouter: router.NewModelRouter(config.NewAtomicConfig(cfg, "/tmp/test-config.json")), + logger: slog.Default(), + } +} + +// Factory for test fixtures +func newStreamingTestHandler(t *testing.T, upstreamURL string) *MessagesHandler { + t.Helper() + cfg := &config.Config{ + APIKey: "test-key", + OpenCodeGo: config.OpenCodeGoConfig{ + AnthropicBaseURL: upstreamURL, + BaseURL: upstreamURL, + TimeoutMs: 5000, + }, + } + // ... +} + +// Descriptive test name +func TestBuildModelChain_Override_AppendsScenarioChainDeduped(t *testing.T) { + // Test that override chain appends scenario chain with duplicates removed +} + +// Helper for extracting test data +func chainIDs(chain []config.ModelConfig) []string { + out := make([]string, len(chain)) + for i, m := range chain { + out[i] = m.ModelID + } + return out +} +``` + +## WRONG + +```go +// Missing t.Helper() +func newTestHandler(t *testing.T) *Handler { + return &Handler{} // Error line points here, not test +} + +// Vague test name +func TestBuildModelChain(t *testing.T) { + // What aspect of BuildModelChain? +} + +// Duplicated fixture setup in every test +func TestX(t *testing.T) { + cfg := &config.Config{...} // Copied 20 times +} +``` + +## Quick Reference + +| Aspect | Convention | +|--------|-----------| +| Marker | `t.Helper()` at start of helper | +| Factories | `newTest` functions | +| Naming | `Test__` | +| Extraction | Small helpers like `chainIDs` for assertions | diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100755 index 00000000..ff7e78c0 --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,92 @@ +#!/bin/bash +# +# Pre-push hook: Runs quality checks before pushing to remote +# Ensures code is properly formatted, passes linting, tests, and builds +# + +set -e +set -o pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}=== Pre-push checks ===${NC}" +echo "" + +# Get the current branch +BRANCH=$(git rev-parse --abbrev-ref HEAD) +echo -e "Branch: ${GREEN}${BRANCH}${NC}" +echo "" + +# Store original directory +ORIGINAL_DIR=$(pwd) + +# Function to check if we're pushing to a protected branch +is_protected_branch() { + [[ "$BRANCH" == "main" || "$BRANCH" == "master" || "$BRANCH" == "develop" ]] +} + +# Skip checks for protected branches (they usually have branch protection rules) +if is_protected_branch; then + echo -e "${YELLOW}Pushing to protected branch '${BRANCH}'${NC}" + echo -e "${YELLOW}Skipping pre-push checks (branch protection should handle this)${NC}" + exit 0 +fi + +# Check 1: Format check (gofmt) +echo -e "${YELLOW}[1/5] Checking code formatting...${NC}" +FORMAT_ISSUES=$(gofmt -d .) +if [ -n "$FORMAT_ISSUES" ]; then + echo -e "${RED}✗ Code is not properly formatted${NC}" + echo "" + echo "Run 'gofmt -w .' to fix formatting issues:" + echo "$FORMAT_ISSUES" + exit 1 +fi +echo -e "${GREEN}✓ Code formatting check passed${NC}" +echo "" + +# Check 2: Lint check (go vet) +echo -e "${YELLOW}[2/5] Running go vet...${NC}" +CGO_ENABLED=0 go vet ./... +echo -e "${GREEN}✓ Go vet passed${NC}" +echo "" + +# Check 3: golangci-lint +echo -e "${YELLOW}[3/5] Running golangci-lint...${NC}" +if command -v golangci-lint &> /dev/null; then + if ! golangci-lint run --timeout 5m; then + echo -e "${RED}✗ golangci-lint failed${NC}" + exit 1 + fi + echo -e "${GREEN}✓ golangci-lint passed${NC}" +else + echo -e "${YELLOW}⚠ golangci-lint not installed, skipping${NC}" + echo " Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" +fi +echo "" + +# Check 4: Tests +echo -e "${YELLOW}[4/5] Running tests...${NC}" +go test ./internal/... ./pkg/... ./cmd/... -v -race 2>&1 | while IFS= read -r line; do + # Suppress verbose test output, show summary + if [[ "$line" =~ ^(PASS|FAIL|===\s+RUN|---\s+(PASS|FAIL)|coverage:) ]]; then + echo " $line" + fi +done +echo -e "${GREEN}✓ Tests passed${NC}" +echo "" + +# Check 5: Build +echo -e "${YELLOW}[5/5] Building project...${NC}" +CGO_ENABLED=0 go build -o /tmp/routatic-proxy-build-test ./cmd/routatic-proxy +rm -f /tmp/routatic-proxy-build-test +echo -e "${GREEN}✓ Build successful${NC}" +echo "" + +echo -e "${GREEN}=== All pre-push checks passed! ===${NC}" +echo "" +exit 0 diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 00000000..16276468 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# +# Install git hooks for this repository +# This script creates symlinks from .git/hooks to scripts/git-hooks/ +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOKS_DIR="$(git rev-parse --git-dir)/hooks" + +echo "Installing git hooks..." +echo "" + +# List of hooks to install +HOOKS=("pre-push") + +for HOOK in "${HOOKS[@]}"; do + TARGET="${SCRIPT_DIR}/git-hooks/${HOOK}" + LINK="${HOOKS_DIR}/${HOOK}" + + if [ -f "$LINK" ] && [ ! -L "$LINK" ]; then + echo "Backup existing ${HOOK} to ${HOOK}.backup" + mv "$LINK" "${LINK}.backup" + fi + + # Create relative symlink + REL_PATH=$(realpath --relative-to="$HOOKS_DIR" "$TARGET") + ln -sf "$REL_PATH" "$LINK" + + echo "✓ Installed ${HOOK}" +done + +echo "" +echo "Git hooks installed successfully!" +echo "" +echo "Pre-push: Runs full checks (format, lint, tests, build)" +echo "" +echo "To bypass hooks temporarily, use:" +echo " git commit --no-verify" +echo " git push --no-verify" diff --git "a/testdata/catalog.json\"" "b/testdata/catalog.json\"" new file mode 100644 index 00000000..11f1d8ac --- /dev/null +++ "b/testdata/catalog.json\"" @@ -0,0 +1,73 @@ +{ + "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": { + "opencode-go/deepseek-v4-flash": { + "id": "opencode-go/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 128000, "output": 4096 } + }, + "openrouter/vision-model": { + "id": "openrouter/vision-model", + "name": "Vision Model", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text", "image"], "output": ["text"] }, + "limit": { "context": 256000, "output": 8192 } + }, + "openrouter/reasoning-model": { + "id": "openrouter/reasoning-model", + "name": "Reasoning Model", + "reasoning": true, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 200000, "output": 8192 } + }, + "opencode-go/large-context": { + "id": "opencode-go/large-context", + "name": "Large Context", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 1000000, "output": 32768 } + }, + "opencode-go/tie-small-context": { + "id": "opencode-go/tie-small-context", + "name": "Tie Small Context", + "reasoning": false, + "tool_call": true, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 128000, "output": 4096 } + }, + "disabled-provider/only-disabled": { + "id": "disabled-provider/only-disabled", + "name": "Only Disabled", + "reasoning": false, + "tool_call": false, + "modalities": { "input": ["text"], "output": ["text"] }, + "limit": { "context": 128000, "output": 4096 } + } + }, + "scenarios": {} +} \ No newline at end of file