From 5db0a19b9dc0261bc799aec9cbdfc3c0c90f5668 Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Wed, 22 Jul 2026 14:15:04 +0200 Subject: [PATCH 1/5] fix: catalog sync, validation, and models list Four related fixes for the catalog subsystem: 1. validateCatalog now skips models with unknown providers instead of failing fatally (upstream catalog has dirty data). 2. ListProviderModels uses the pre-built ProviderModels index instead of iterating Models with a key prefix, which doesn't work with the current catalog format where model keys use native provider names. 3. catalog sync now migrates the downloaded JSON to SQLite so that models list and other SQLite-backed commands can find it. 4. models list without --provider now shows all catalog providers that have models, instead of only showing configured routing providers (which have no models of their own). 5. Missing catalog is now a printed message instead of a fatal error. --- cmd/routatic-proxy/catalog.go | 23 +++++++++++ cmd/routatic-proxy/main.go | 56 ++++++++++++-------------- cmd/routatic-proxy/main_models_test.go | 18 ++++++--- internal/catalog/load.go | 10 ++++- internal/catalog/resolve.go | 12 ++++++ 5 files changed, 82 insertions(+), 37 deletions(-) diff --git a/cmd/routatic-proxy/catalog.go b/cmd/routatic-proxy/catalog.go index d1ecb9db..251bba4b 100644 --- a/cmd/routatic-proxy/catalog.go +++ b/cmd/routatic-proxy/catalog.go @@ -98,6 +98,29 @@ func catalogSyncCmd() *cobra.Command { cmd.Printf(" SHA256: %s\n", lock.SHA256) cmd.Printf(" Bytes: %d\n", lock.Bytes) cmd.Printf(" TTL: %d hours\n", lock.TTLHours) + + // Migrate the downloaded JSON catalog to SQLite so that + // 'routatic-proxy models list' and other SQLite-backed commands + // can find it. + jsonPath := filepath.Join(catalogDir, "catalog.json") + db, err := storage.Open(storage.DefaultConfig) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer func() { _ = db.Close() }() + + ctx := cmd.Context() + migrated, err := catalog.MigrateFromJSON(ctx, db, jsonPath) + if err != nil { + return fmt.Errorf("migrate catalog to SQLite: %w", err) + } + + if migrated { + cmd.Println(" Catalog migrated to SQLite database") + } else { + cmd.Println(" SQLite catalog already up to date") + } + return nil }, } diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 0612ef60..728484b3 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -387,12 +387,12 @@ Press Ctrl+C to stop the server.`, return fmt.Errorf("start gui server: %w", err) } - // Open GUI (macOS: native webview, Linux/Windows: print URL) - if err := openGUI(guiURL); err != nil { - slog.Warn("GUI error", "error", err) - fmt.Printf("\nDashboard: %s\n", guiURL) - fmt.Println("\nPress Ctrl+C to stop.") - } + // Open GUI (macOS: native webview, Linux/Windows: print URL) + if err := openGUI(guiURL, cancel); err != nil { + slog.Warn("GUI error", "error", err) + fmt.Printf("\nDashboard: %s\n", guiURL) + fmt.Println("\nPress Ctrl+C to stop.") + } } else { fmt.Println("\nRunning in headless mode (no dashboard). Press Ctrl+C to stop.") } @@ -763,10 +763,19 @@ func runModelsList(cmd *cobra.Command, configPath, provider string) error { cat, err := catalog.LoadFromSQLite(ctx, db) if err != nil { - return fmt.Errorf("catalog not found; run 'routatic-proxy catalog sync' first") + cmd.Println("catalog not found; run 'routatic-proxy catalog sync' first") + return nil + } + + // When a specific provider is requested, use it directly. Otherwise + // show all providers that have models in the catalog. + var providers []string + if provider != "" { + providers = []string{provider} + } else { + providers = catalogProviders(cat) } - providers := selectProviders(provider, cfg) if len(providers) == 0 { if provider != "" { cmd.Printf("No models found for provider %q.\n", provider) @@ -810,30 +819,17 @@ func runModelsList(cmd *cobra.Command, configPath, provider string) error { 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) +// catalogProviders returns all provider names from the catalog that have at +// least one model, sorted. +func catalogProviders(cat *catalog.IndexedCatalog) []string { + providers := make([]string, 0, len(cat.ProviderModels)) + for p := range cat.ProviderModels { + if len(cat.ProviderModels[p]) > 0 { + providers = append(providers, p) } } - sort.Strings(enabled) - return enabled + sort.Strings(providers) + return providers } // 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 index f58323a4..89d8e0bb 100644 --- a/cmd/routatic-proxy/main_models_test.go +++ b/cmd/routatic-proxy/main_models_test.go @@ -167,15 +167,21 @@ func TestRunModelsList_MissingCatalog(t *testing.T) { configPath := writeTestConfig(t, tmp, `{"api_key": "test-global-key"}`) // Intentionally do not write catalog.json. - cmd, _ := newCaptureCommand(t) + // Override the storage path to use a temp directory so we don't + // accidentally pick up the user's real catalog. + origDBPath := storage.DefaultConfig.DatabasePath + storage.DefaultConfig.DatabasePath = filepath.Join(tmp, "data.db") + defer func() { storage.DefaultConfig.DatabasePath = origDBPath }() + + cmd, buf := newCaptureCommand(t) t.Setenv("ROUTATIC_PROXY_CONFIG", configPath) err := runModelsList(cmd, configPath, "") - if err == nil { - t.Fatal("expected error for missing catalog, got nil") + if err != nil { + t.Fatalf("unexpected error: %v", err) } - 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) + output := buf.String() + if !strings.Contains(output, "catalog not found") { + t.Fatalf("expected output containing %q, got: %s", "catalog not found", output) } } diff --git a/internal/catalog/load.go b/internal/catalog/load.go index 349966ed..21c7516a 100644 --- a/internal/catalog/load.go +++ b/internal/catalog/load.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "os" ) @@ -60,15 +61,22 @@ func validateCatalog(catalog *Catalog) error { return errors.New("catalog models map is empty") } + var hasUnknown bool 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) + slog.Warn("skipping model with unknown provider", "model", key, "provider", provider) + delete(catalog.Models, key) + hasUnknown = true } } + if hasUnknown && len(catalog.Models) == 0 { + return errors.New("all models reference unknown providers, catalog is unusable") + } + return nil } diff --git a/internal/catalog/resolve.go b/internal/catalog/resolve.go index f78b9122..9c24c8e1 100644 --- a/internal/catalog/resolve.go +++ b/internal/catalog/resolve.go @@ -150,6 +150,18 @@ func (ic *IndexedCatalog) ListProviderModels(provider string) []ResolvedModel { return nil } + // Prefer the pre-built ProviderModels index (populated during Load / + // LoadFromSQLite). Fall back to iterating Models by key prefix for + // backward compatibility with hand-built catalogs (e.g. tests). + if len(ic.ProviderModels) > 0 { + models := ic.ProviderModels[provider] + result := make([]ResolvedModel, 0, len(models)) + for _, model := range models { + result = append(result, resolvedModel(providerCfg, model.ID, model)) + } + return result + } + prefix := provider + "/" var result []ResolvedModel for key, model := range ic.Models { From 4ab0308849085025038e514c151a6c114b714d5b Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Thu, 23 Jul 2026 10:57:28 +0200 Subject: [PATCH 2/5] fix: remove stray cancel argument from openGUI call The openGUI signature is func(string) error on all platforms; the cancel argument was a merge artifact from the GUI crash fix branch. --- cmd/routatic-proxy/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 728484b3..8942b04a 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -388,7 +388,7 @@ Press Ctrl+C to stop the server.`, } // Open GUI (macOS: native webview, Linux/Windows: print URL) - if err := openGUI(guiURL, cancel); err != nil { + if err := openGUI(guiURL); err != nil { slog.Warn("GUI error", "error", err) fmt.Printf("\nDashboard: %s\n", guiURL) fmt.Println("\nPress Ctrl+C to stop.") From c5455df4a4e01e9dcbb2aead2ea8a4fad5d39be6 Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Thu, 23 Jul 2026 11:09:07 +0200 Subject: [PATCH 3/5] fix: indent Open GUI comment at correct depth --- cmd/routatic-proxy/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 8942b04a..128c90d5 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -387,8 +387,8 @@ Press Ctrl+C to stop the server.`, return fmt.Errorf("start gui server: %w", err) } - // Open GUI (macOS: native webview, Linux/Windows: print URL) - if err := openGUI(guiURL); err != nil { + // Open GUI (macOS: native webview, Linux/Windows: print URL) + if err := openGUI(guiURL); err != nil { slog.Warn("GUI error", "error", err) fmt.Printf("\nDashboard: %s\n", guiURL) fmt.Println("\nPress Ctrl+C to stop.") From 3ae12c1f1026c184e2c65e6958718d5d04447dbc Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Thu, 23 Jul 2026 11:20:51 +0200 Subject: [PATCH 4/5] fix: gofmt cmd/routatic-proxy/main.go --- cmd/routatic-proxy/main.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 128c90d5..5b698640 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -389,10 +389,10 @@ Press Ctrl+C to stop the server.`, // Open GUI (macOS: native webview, Linux/Windows: print URL) if err := openGUI(guiURL); err != nil { - slog.Warn("GUI error", "error", err) - fmt.Printf("\nDashboard: %s\n", guiURL) - fmt.Println("\nPress Ctrl+C to stop.") - } + slog.Warn("GUI error", "error", err) + fmt.Printf("\nDashboard: %s\n", guiURL) + fmt.Println("\nPress Ctrl+C to stop.") + } } else { fmt.Println("\nRunning in headless mode (no dashboard). Press Ctrl+C to stop.") } From 1acbba8201b3d91891d4f65516c28501b18403c6 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Thu, 23 Jul 2026 12:58:22 +0200 Subject: [PATCH 5/5] fix(catalog): avoid modifying map during iteration and add validation logging - Add structured logging when catalog fails to load in models list command - Fix map iteration safety by collecting invalid model keys before deletion - Replace boolean flag with slice to track models pending removal from catalog - Add clarifying comment about package-level variable mutation in test - Prevent potential runtime errors from concurrent map modification --- cmd/routatic-proxy/main.go | 1 + cmd/routatic-proxy/main_models_test.go | 1 + internal/catalog/load.go | 11 +++++++---- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 8f3066a7..8e6d9fa0 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -774,6 +774,7 @@ func runModelsList(cmd *cobra.Command, configPath, provider string) error { cat, err := catalog.LoadFromSQLite(ctx, db) if err != nil { + slog.Warn("catalog not available", "error", err) cmd.Println("catalog not found; run 'routatic-proxy catalog sync' first") return nil } diff --git a/cmd/routatic-proxy/main_models_test.go b/cmd/routatic-proxy/main_models_test.go index 89d8e0bb..1bc66e4d 100644 --- a/cmd/routatic-proxy/main_models_test.go +++ b/cmd/routatic-proxy/main_models_test.go @@ -169,6 +169,7 @@ func TestRunModelsList_MissingCatalog(t *testing.T) { // Override the storage path to use a temp directory so we don't // accidentally pick up the user's real catalog. + // NOTE: mutates package-level var — this test must NOT use t.Parallel(). origDBPath := storage.DefaultConfig.DatabasePath storage.DefaultConfig.DatabasePath = filepath.Join(tmp, "data.db") defer func() { storage.DefaultConfig.DatabasePath = origDBPath }() diff --git a/internal/catalog/load.go b/internal/catalog/load.go index 21c7516a..fb5fa3f3 100644 --- a/internal/catalog/load.go +++ b/internal/catalog/load.go @@ -61,7 +61,7 @@ func validateCatalog(catalog *Catalog) error { return errors.New("catalog models map is empty") } - var hasUnknown bool + var toDelete []string for key := range catalog.Models { provider := ProviderFromModelKey(key) if provider == "" { @@ -69,12 +69,15 @@ func validateCatalog(catalog *Catalog) error { } if _, ok := catalog.Providers[provider]; !ok { slog.Warn("skipping model with unknown provider", "model", key, "provider", provider) - delete(catalog.Models, key) - hasUnknown = true + toDelete = append(toDelete, key) } } - if hasUnknown && len(catalog.Models) == 0 { + for _, key := range toDelete { + delete(catalog.Models, key) + } + + if len(toDelete) > 0 && len(catalog.Models) == 0 { return errors.New("all models reference unknown providers, catalog is unusable") }