Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions cmd/routatic-proxy/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
Expand Down
45 changes: 21 additions & 24 deletions cmd/routatic-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 13 additions & 6 deletions cmd/routatic-proxy/main_models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
13 changes: 12 additions & 1 deletion internal/catalog/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
)

Expand Down Expand Up @@ -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
}
12 changes: 12 additions & 0 deletions internal/catalog/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading