diff --git a/cmd/routatic-proxy/catalog.go b/cmd/routatic-proxy/catalog.go index d1ecb9d..251bba4 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 5fb4a41..8e6d9fa 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -774,10 +774,20 @@ 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") + slog.Warn("catalog not available", "error", err) + 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) @@ -821,30 +831,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 f58323a..1bc66e4 100644 --- a/cmd/routatic-proxy/main_models_test.go +++ b/cmd/routatic-proxy/main_models_test.go @@ -167,15 +167,22 @@ 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. + // 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 }() + + 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 349966e..fb5fa3f 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,25 @@ func validateCatalog(catalog *Catalog) error { return errors.New("catalog models map is empty") } + var toDelete []string 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) + toDelete = append(toDelete, key) } } + 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") + } + return nil } diff --git a/internal/catalog/resolve.go b/internal/catalog/resolve.go index f78b912..9c24c8e 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 {