| 1 | package cmd |
| 2 | |
| 3 | import ( |
| 4 | "encoding/csv" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | |
| 10 | "github.com/spf13/cobra" |
| 11 | "sourcecraft.dev/bigbes/claudio/config" |
| 12 | "sourcecraft.dev/bigbes/claudio/provider" |
| 13 | "sourcecraft.dev/bigbes/claudio/provider/copilot" |
| 14 | "sourcecraft.dev/bigbes/claudio/provider/deepseek" |
| 15 | "sourcecraft.dev/bigbes/claudio/provider/kimi" |
| 16 | "sourcecraft.dev/bigbes/claudio/provider/mistral" |
| 17 | "sourcecraft.dev/bigbes/claudio/provider/nvidia" |
| 18 | "sourcecraft.dev/bigbes/claudio/provider/ollama" |
| 19 | "sourcecraft.dev/bigbes/claudio/provider/openrouter" |
| 20 | ) |
| 21 | |
| 22 | var exportModelsOutput string |
| 23 | |
| 24 | var exportModelsCmd = &cobra.Command{ |
| 25 | Use: "export-models", |
| 26 | Short: "Export the full model catalog of every cloud provider to a CSV file", |
| 27 | Long: `Fetch the full model catalog from every cloud provider that exposes a |
| 28 | live /models endpoint and write the union to a CSV file. |
| 29 |
|
| 30 | Providers hit live (API key required, pulled from the config file): |
| 31 | deepseek, mistral, nvidia, kimi, kimi-api-cn, kimi-api-intl, copilot, |
| 32 | ollama-cloud |
| 33 |
|
| 34 | Providers hit live without a key: |
| 35 | openrouter |
| 36 |
|
| 37 | Providers with a curated static catalog (no /models endpoint): |
| 38 | zai, zai-coding |
| 39 |
|
| 40 | Local, passthrough, and config-only providers (ollama, lmstudio, vkproxy, |
| 41 | claude, custom) are skipped — this command is about cloud model catalogs.`, |
| 42 | RunE: runExportModels, |
| 43 | } |
| 44 | |
| 45 | func init() { |
| 46 | exportModelsCmd.Flags().StringVarP(&exportModelsOutput, "output", "o", "models.csv", "Path to output CSV file") |
| 47 | rootCmd.AddCommand(exportModelsCmd) |
| 48 | } |
| 49 | |
| 50 | type exportedModel struct { |
| 51 | providerKey string |
| 52 | providerName string |
| 53 | modelID string |
| 54 | displayName string |
| 55 | family string |
| 56 | vendor string |
| 57 | contextWindow int |
| 58 | source string // "api" | "static" |
| 59 | } |
| 60 | |
| 61 | func runExportModels(cmd *cobra.Command, args []string) error { |
| 62 | cfg, err := config.Load() |
| 63 | if err != nil { |
| 64 | return fmt.Errorf("loading config: %w", err) |
| 65 | } |
| 66 | |
| 67 | var rows []exportedModel |
| 68 | for _, key := range provider.Order { |
| 69 | p, ok := provider.Registry[key] |
| 70 | if !ok { |
| 71 | continue |
| 72 | } |
| 73 | pc := cfg.Providers[key] |
| 74 | models, err := fetchCloudModels(key, p, pc) |
| 75 | if err != nil { |
| 76 | fmt.Fprintf(os.Stderr, "skip %s: %v\n", key, err) |
| 77 | continue |
| 78 | } |
| 79 | for i := range models { |
| 80 | models[i].providerKey = key |
| 81 | models[i].providerName = p.Name |
| 82 | } |
| 83 | rows = append(rows, models...) |
| 84 | fmt.Fprintf(os.Stderr, "ok %s: %d models\n", key, len(models)) |
| 85 | } |
| 86 | |
| 87 | rows = dedupAndSort(rows) |
| 88 | |
| 89 | f, err := os.Create(exportModelsOutput) |
| 90 | if err != nil { |
| 91 | return fmt.Errorf("creating %s: %w", exportModelsOutput, err) |
| 92 | } |
| 93 | defer f.Close() |
| 94 | |
| 95 | w := csv.NewWriter(f) |
| 96 | header := []string{"provider", "provider_name", "model_id", "display_name", "family", "vendor", "context_window", "source"} |
| 97 | if err := w.Write(header); err != nil { |
| 98 | return fmt.Errorf("writing header: %w", err) |
| 99 | } |
| 100 | for _, r := range rows { |
| 101 | ctx := "" |
| 102 | if r.contextWindow > 0 { |
| 103 | ctx = fmt.Sprintf("%d", r.contextWindow) |
| 104 | } |
| 105 | if err := w.Write([]string{ |
| 106 | r.providerKey, r.providerName, r.modelID, r.displayName, r.family, r.vendor, ctx, r.source, |
| 107 | }); err != nil { |
| 108 | return fmt.Errorf("writing row: %w", err) |
| 109 | } |
| 110 | } |
| 111 | w.Flush() |
| 112 | if err := w.Error(); err != nil { |
| 113 | return fmt.Errorf("flushing CSV: %w", err) |
| 114 | } |
| 115 | |
| 116 | fmt.Printf("Wrote %d models to %s\n", len(rows), exportModelsOutput) |
| 117 | return nil |
| 118 | } |
| 119 | |
| 120 | // fetchCloudModels returns the full model catalog for a cloud provider. |
| 121 | // Returns an error when the provider is unsupported (local, passthrough) or |
| 122 | // when credentials are missing for one that requires them. |
| 123 | func fetchCloudModels(key string, p provider.Provider, pc config.ProviderConfig) ([]exportedModel, error) { |
| 124 | baseURL := pc.BaseURL |
| 125 | if baseURL == "" { |
| 126 | baseURL = p.BaseURL |
| 127 | } |
| 128 | |
| 129 | switch key { |
| 130 | case "deepseek": |
| 131 | if pc.APIKey == "" { |
| 132 | return nil, fmt.Errorf("no API key configured") |
| 133 | } |
| 134 | ms, err := deepseek.ListModels(pc.APIKey) |
| 135 | if err != nil { |
| 136 | return nil, err |
| 137 | } |
| 138 | out := make([]exportedModel, 0, len(ms)) |
| 139 | for _, m := range ms { |
| 140 | out = append(out, exportedModel{modelID: m.ID, displayName: m.DisplayName(), source: "api"}) |
| 141 | } |
| 142 | return out, nil |
| 143 | |
| 144 | case "mistral": |
| 145 | if pc.APIKey == "" { |
| 146 | return nil, fmt.Errorf("no API key configured") |
| 147 | } |
| 148 | ms, err := mistral.ListModels(pc.APIKey) |
| 149 | if err != nil { |
| 150 | return nil, err |
| 151 | } |
| 152 | out := make([]exportedModel, 0, len(ms)) |
| 153 | for _, m := range ms { |
| 154 | out = append(out, exportedModel{modelID: m.ID, displayName: m.DisplayName(), source: "api"}) |
| 155 | } |
| 156 | return out, nil |
| 157 | |
| 158 | case "nvidia": |
| 159 | if pc.APIKey == "" { |
| 160 | return nil, fmt.Errorf("no API key configured") |
| 161 | } |
| 162 | ms, err := nvidia.ListModels(pc.APIKey) |
| 163 | if err != nil { |
| 164 | return nil, err |
| 165 | } |
| 166 | out := make([]exportedModel, 0, len(ms)) |
| 167 | for _, m := range ms { |
| 168 | out = append(out, exportedModel{modelID: m.ID, displayName: m.DisplayName(), vendor: m.OwnedBy, source: "api"}) |
| 169 | } |
| 170 | return out, nil |
| 171 | |
| 172 | case "openrouter": |
| 173 | ms, err := openrouter.ListModels(pc.APIKey) |
| 174 | if err != nil { |
| 175 | return nil, err |
| 176 | } |
| 177 | out := make([]exportedModel, 0, len(ms)) |
| 178 | for _, m := range ms { |
| 179 | out = append(out, exportedModel{ |
| 180 | modelID: m.ID, |
| 181 | displayName: m.DisplayName(), |
| 182 | contextWindow: m.ContextLength, |
| 183 | source: "api", |
| 184 | }) |
| 185 | } |
| 186 | return out, nil |
| 187 | |
| 188 | case "kimi-api-cn", "kimi-api-intl": |
| 189 | if pc.APIKey == "" { |
| 190 | return nil, fmt.Errorf("no API key configured") |
| 191 | } |
| 192 | base := baseURL |
| 193 | if !strings.HasSuffix(base, "/") { |
| 194 | base += "/" |
| 195 | } |
| 196 | ms, err := kimi.ListModels(base, pc.APIKey) |
| 197 | if err != nil { |
| 198 | return nil, err |
| 199 | } |
| 200 | out := make([]exportedModel, 0, len(ms)) |
| 201 | for _, m := range ms { |
| 202 | out = append(out, exportedModel{ |
| 203 | modelID: m.ID, |
| 204 | displayName: m.GetName(), |
| 205 | contextWindow: m.ContextLength, |
| 206 | source: "api", |
| 207 | }) |
| 208 | } |
| 209 | return out, nil |
| 210 | |
| 211 | case "copilot": |
| 212 | if pc.APIKey == "" { |
| 213 | return nil, fmt.Errorf("no OAuth token — run 'claudio config' to sign in") |
| 214 | } |
| 215 | ms, err := copilot.ListModels(pc.APIKey) |
| 216 | if err != nil { |
| 217 | return nil, err |
| 218 | } |
| 219 | out := make([]exportedModel, 0, len(ms)) |
| 220 | for _, m := range ms { |
| 221 | out = append(out, exportedModel{ |
| 222 | modelID: m.ID, |
| 223 | displayName: m.DisplayName(), |
| 224 | vendor: m.Vendor, |
| 225 | contextWindow: m.ContextWindow, |
| 226 | source: "api", |
| 227 | }) |
| 228 | } |
| 229 | return out, nil |
| 230 | |
| 231 | case "ollama-cloud": |
| 232 | ms, err := ollama.ListModels(baseURL, pc.APIKey) |
| 233 | if err != nil { |
| 234 | return nil, err |
| 235 | } |
| 236 | out := make([]exportedModel, 0, len(ms)) |
| 237 | for _, m := range ms { |
| 238 | out = append(out, exportedModel{ |
| 239 | modelID: m.Name, |
| 240 | displayName: m.DisplayName(), |
| 241 | family: m.Family(), |
| 242 | contextWindow: m.ContextLength, |
| 243 | source: "api", |
| 244 | }) |
| 245 | } |
| 246 | return out, nil |
| 247 | |
| 248 | case "zai", "zai-coding": |
| 249 | sm := provider.StaticModels[key] |
| 250 | if len(sm) == 0 { |
| 251 | return nil, fmt.Errorf("no static catalog") |
| 252 | } |
| 253 | out := make([]exportedModel, 0, len(sm)) |
| 254 | for _, m := range sm { |
| 255 | out = append(out, exportedModel{ |
| 256 | modelID: m.ID, |
| 257 | displayName: m.Name, |
| 258 | family: m.Family, |
| 259 | contextWindow: p.ContextWindow, |
| 260 | source: "static", |
| 261 | }) |
| 262 | } |
| 263 | return out, nil |
| 264 | } |
| 265 | |
| 266 | return nil, fmt.Errorf("no cloud catalog endpoint") |
| 267 | } |
| 268 | |
| 269 | func dedupAndSort(rows []exportedModel) []exportedModel { |
| 270 | sort.SliceStable(rows, func(i, j int) bool { |
| 271 | if rows[i].providerKey != rows[j].providerKey { |
| 272 | return rows[i].providerKey < rows[j].providerKey |
| 273 | } |
| 274 | return rows[i].modelID < rows[j].modelID |
| 275 | }) |
| 276 | seen := make(map[string]bool) |
| 277 | out := rows[:0] |
| 278 | for _, r := range rows { |
| 279 | k := r.providerKey + "\x00" + r.modelID |
| 280 | if seen[k] { |
| 281 | continue |
| 282 | } |
| 283 | seen[k] = true |
| 284 | out = append(out, r) |
| 285 | } |
| 286 | return out |
| 287 | } |
| 288 | |