diff --git a/internal/providermodelcatalog/logic_test.go b/internal/providermodelcatalog/logic_test.go index 0f3a3c225..9cdcdc331 100644 --- a/internal/providermodelcatalog/logic_test.go +++ b/internal/providermodelcatalog/logic_test.go @@ -48,13 +48,28 @@ func TestModelMatchesProvider(t *testing.T) { } func TestDefaultedOpenGatewayURL(t *testing.T) { - if got := defaultedOpenGatewayURL(providercatalog.Descriptor{}, " https://x/models.json "); got != "https://x/models.json" { + if got := defaultedOpenGatewayURL(providercatalog.Descriptor{}, " https://x/v1/models "); got != "https://x/v1/models" { t.Fatalf("explicit override = %q, want trimmed override", got) } - if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/zero/models.json" { + if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/v1/models" { t.Fatalf("derived = %q", got) } - if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/zero/models.json" { + if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/v1/models" { + t.Fatalf("fallback = %q", got) + } +} + +func TestDefaultedOpenRouterURL(t *testing.T) { + if got := defaultedOpenRouterURL(providercatalog.Descriptor{}, " https://or.example/api/v1/models "); got != "https://or.example/api/v1/models" { + t.Fatalf("explicit override = %q, want trimmed override", got) + } + if got := defaultedOpenRouterURL(providercatalog.Descriptor{DefaultBaseURL: "https://openrouter.ai/api/v1"}, ""); got != "https://openrouter.ai/api/v1/models" { + t.Fatalf("derived = %q", got) + } + if got := defaultedOpenRouterURL(providercatalog.Descriptor{DefaultBaseURL: "https://openrouter.ai/api/v1?token=x#frag"}, ""); got != "https://openrouter.ai/api/v1/models" { + t.Fatalf("query/fragment stripped = %q, want clean /models URL", got) + } + if got := defaultedOpenRouterURL(providercatalog.Descriptor{DefaultBaseURL: "bad"}, ""); got != "https://openrouter.ai/api/v1/models" { t.Fatalf("fallback = %q", got) } } @@ -132,6 +147,110 @@ func TestFetchModelsDevAndOpenGatewayOverHTTP(t *testing.T) { if !containsModelID(routed, "claude-coder") { t.Fatalf("FetchRemote models = %#v, want claude-coder", routed) } + + openrouter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":[{"id":"openai/gpt-4o","name":"GPT-4o","context_length":128000,"supported_parameters":["tools"]}]}`)) + })) + defer openrouter.Close() + orModels, err := FetchOpenRouter(context.Background(), openrouter.URL, FetchOptions{HTTPClient: openrouter.Client()}) + if err != nil { + t.Fatalf("FetchOpenRouter error: %v", err) + } + if !containsModelID(orModels, "openai/gpt-4o") { + t.Fatalf("FetchOpenRouter models = %#v, want openai/gpt-4o", orModels) + } + routedOR, err := FetchRemote(context.Background(), providercatalog.Descriptor{ID: "openrouter"}, FetchOptions{HTTPClient: openrouter.Client(), OpenRouterURL: openrouter.URL}) + if err != nil { + t.Fatalf("FetchRemote openrouter error: %v", err) + } + if !containsModelID(routedOR, "openai/gpt-4o") { + t.Fatalf("FetchRemote openrouter models = %#v, want openai/gpt-4o", routedOR) + } +} + +// TestFetchOpenRouterFallsBackToModelsDev pins independent resilience: when +// openrouter.ai is down, models.dev still supplies a coding list so the picker +// does not error empty. +func TestFetchOpenRouterFallsBackToModelsDev(t *testing.T) { + openrouter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "upstream unavailable", http.StatusBadGateway) + })) + defer openrouter.Close() + + modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "openrouter": { + "models": { + "openai/gpt-4.1": { + "id": "openai/gpt-4.1", + "name": "GPT-4.1", + "tool_call": true, + "limit": {"context": 1048576}, + "cost": {"input": 2, "output": 8}, + "modalities": {"input": ["text"], "output": ["text"]} + } + } + } + }`)) + })) + defer modelsDev.Close() + + models, err := FetchOpenRouter(context.Background(), openrouter.URL, FetchOptions{ + HTTPClient: openrouter.Client(), + ModelsDevURL: modelsDev.URL, + }) + if err != nil { + t.Fatalf("FetchOpenRouter with models.dev fallback: %v", err) + } + if !containsModelID(models, "openai/gpt-4.1") { + t.Fatalf("models = %#v, want models.dev openrouter fallback entry", models) + } + if models[0].Source != "models.dev" { + t.Fatalf("source = %q, want models.dev fallback source", models[0].Source) + } +} + +// TestFetchOpenRouterFallsBackOnMalformedJSON covers the HTTP-200-but-unparseable +// path: a broken openrouter.ai body must still degrade to models.dev instead of +// failing the catalog fetch entirely. +func TestFetchOpenRouterFallsBackOnMalformedJSON(t *testing.T) { + openrouter := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data": not-json`)) + })) + defer openrouter.Close() + + modelsDev := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "openrouter": { + "models": { + "openai/gpt-4.1": { + "id": "openai/gpt-4.1", + "name": "GPT-4.1", + "tool_call": true, + "limit": {"context": 1048576}, + "cost": {"input": 2, "output": 8}, + "modalities": {"input": ["text"], "output": ["text"]} + } + } + } + }`)) + })) + defer modelsDev.Close() + + models, err := FetchOpenRouter(context.Background(), openrouter.URL, FetchOptions{ + HTTPClient: openrouter.Client(), + ModelsDevURL: modelsDev.URL, + }) + if err != nil { + t.Fatalf("FetchOpenRouter malformed JSON fallback: %v", err) + } + if !containsModelID(models, "openai/gpt-4.1") { + t.Fatalf("models = %#v, want models.dev openrouter fallback entry", models) + } + if models[0].Source != "models.dev" { + t.Fatalf("source = %q, want models.dev fallback source", models[0].Source) + } } func containsModelID(models []Model, id string) bool { diff --git a/internal/providermodelcatalog/remote.go b/internal/providermodelcatalog/remote.go index 5d8841054..ebf6abac2 100644 --- a/internal/providermodelcatalog/remote.go +++ b/internal/providermodelcatalog/remote.go @@ -1,13 +1,16 @@ package providermodelcatalog import ( + "bytes" "context" "encoding/json" "fmt" "io" + "math" "net/http" "net/url" "sort" + "strconv" "strings" "time" @@ -15,24 +18,27 @@ import ( ) const ( - DefaultModelsDevURL = "https://models.dev/api.json" - modelsDevSource = "models.dev" - openGatewaySource = "opengateway" + DefaultModelsDevURL = "https://models.dev/api.json" + defaultOpenGatewayURL = "https://opengateway.gitlawb.com/v1/models" + defaultOpenRouterURL = "https://openrouter.ai/api/v1/models" + modelsDevSource = "models.dev" + openGatewaySource = "opengateway" + openRouterSource = "openrouter" ) type FetchOptions struct { HTTPClient *http.Client ModelsDevURL string OpenGatewayURL string + OpenRouterURL string } func FetchRemote(ctx context.Context, provider providercatalog.Descriptor, options FetchOptions) ([]Model, error) { - if provider.ID == "gitlawb-opengateway" { - models, err := FetchOpenGateway(ctx, defaultedOpenGatewayURL(provider, options.OpenGatewayURL), options) - if err != nil { - return nil, err - } - return models, nil + switch providercatalog.NormalizeID(provider.ID) { + case "gitlawb-opengateway": + return FetchOpenGateway(ctx, defaultedOpenGatewayURL(provider, options.OpenGatewayURL), options) + case "openrouter": + return FetchOpenRouter(ctx, defaultedOpenRouterURL(provider, options.OpenRouterURL), options) } providerID := ModelsDevProviderID(provider) @@ -62,6 +68,28 @@ func FetchOpenGateway(ctx context.Context, endpoint string, options FetchOptions return ParseOpenGatewayCatalog(body) } +// FetchOpenRouter loads OpenRouter's public live model list (GET /api/v1/models). +// Auth is optional for listing; callers may still attach a key for account-scoped +// probes via the live discovery path. When the live host is unreachable or +// returns an unparseable body, fall back to models.dev so the picker still +// degrades instead of failing entirely (both catalog and live probe hit +// openrouter.ai otherwise). +func FetchOpenRouter(ctx context.Context, endpoint string, options FetchOptions) ([]Model, error) { + body, err := fetchJSON(ctx, endpoint, options.HTTPClient) + if err == nil { + models, parseErr := ParseOpenRouterCatalog(body) + if parseErr == nil { + return models, nil + } + err = parseErr + } + fallback, fallbackErr := FetchModelsDev(ctx, "openrouter", options) + if fallbackErr == nil { + return fallback, nil + } + return nil, fmt.Errorf("%w (models.dev fallback: %v)", err, fallbackErr) +} + func ParseModelsDevProvider(body []byte, providerID string) ([]Model, error) { var payload map[string]struct { Models map[string]remoteModel `json:"models"` @@ -89,24 +117,25 @@ func ParseModelsDevProvider(body []byte, providerID string) ([]Model, error) { return models, nil } +// ParseOpenGatewayCatalog parses OpenGateway's live GET /v1/models payload (or the +// older {models:[...]} shape). The gateway already curates what it exposes, so +// every non-empty id is accepted rather than applying the coding-model heuristic. func ParseOpenGatewayCatalog(body []byte) ([]Model, error) { - var payload struct { - Models []remoteModel `json:"models"` - Data []remoteModel `json:"data"` - } - if err := json.Unmarshal(body, &payload); err != nil { + items, err := parseOpenAIStyleModelList(body) + if err != nil { return nil, fmt.Errorf("decode OpenGateway catalog: %w", err) } - items := payload.Models - if len(items) == 0 { - items = payload.Data - } models := make([]Model, 0, len(items)) for _, item := range items { model := item.toModel("", openGatewaySource) - if model.ID == "" || !IsCodingModel(model) { + if model.ID == "" { continue } + // Drop only clearly non-coding ids if a gateway ever lists them. + if IsKnownNonCodingModelID(model.ID) { + continue + } + model = annotateFreeModel(model, item) models = append(models, model) } sortModels(models) @@ -116,6 +145,43 @@ func ParseOpenGatewayCatalog(body []byte) ([]Model, error) { return models, nil } +// ParseOpenRouterCatalog parses OpenRouter's public GET /api/v1/models list and +// keeps coding-capable entries (tools, reasoning, or coding-like ids). +func ParseOpenRouterCatalog(body []byte) ([]Model, error) { + items, err := parseOpenAIStyleModelList(body) + if err != nil { + return nil, fmt.Errorf("decode OpenRouter catalog: %w", err) + } + models := make([]Model, 0, len(items)) + for _, item := range items { + model := item.toModel("", openRouterSource) + if model.ID == "" || !IsCodingModel(model) { + continue + } + model = annotateFreeModel(model, item) + models = append(models, model) + } + sortModels(models) + if len(models) == 0 { + return nil, fmt.Errorf("OpenRouter catalog returned no models") + } + return models, nil +} + +func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) { + var payload struct { + Models []remoteModel `json:"models"` + Data []remoteModel `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + if len(payload.Models) > 0 { + return payload.Models, nil + } + return payload.Data, nil +} + func ModelsDevProviderID(provider providercatalog.Descriptor) string { switch strings.TrimSpace(provider.ID) { case "dashscope": @@ -139,22 +205,47 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string { } } +// PublicLiveCatalog reports whether the provider publishes a public live model +// list that Zero should prefer over third-party catalogs (models.dev) and that +// can be fetched without credentials. +func PublicLiveCatalog(providerID string) bool { + switch providercatalog.NormalizeID(providerID) { + case "openrouter", "gitlawb-opengateway": + return true + default: + return false + } +} + type remoteModel struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - ContextWindow int `json:"context_window"` - ContextWindowCamel int `json:"contextWindow"` - ToolCall bool `json:"tool_call"` - ToolCallCamel bool `json:"toolCall"` - Tools bool `json:"tools"` - Reasoning bool `json:"reasoning"` - InputCost float64 `json:"input_cost"` - OutputCost float64 `json:"output_cost"` - Tags []string `json:"tags"` - Limit remoteLimit `json:"limit"` - Cost remoteCost `json:"cost"` - Modalities remoteModalities `json:"modalities"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ContextWindow int `json:"context_window"` + ContextWindowCamel int `json:"contextWindow"` + ContextLength int `json:"context_length"` + MaxContextLength int `json:"max_context_length"` + ToolCall bool `json:"tool_call"` + ToolCallCamel bool `json:"toolCall"` + Tools bool `json:"tools"` + // ReasoningRaw accepts both a boolean (models.dev) and OpenRouter's object form. + ReasoningRaw json.RawMessage `json:"reasoning"` + Free bool `json:"free"` + IsFree bool `json:"is_free"` + InputCost float64 `json:"input_cost"` + OutputCost float64 `json:"output_cost"` + Tags []string `json:"tags"` + SupportedParameters []string `json:"supported_parameters"` + Limit remoteLimit `json:"limit"` + Cost remoteCost `json:"cost"` + Pricing remotePricing `json:"pricing"` + Modalities remoteModalities `json:"modalities"` + Architecture remoteArchitecture `json:"architecture"` +} + +type remotePricing struct { + Prompt string `json:"prompt"` + Completion string `json:"completion"` } type remoteLimit struct { @@ -174,19 +265,67 @@ type remoteModalities struct { Output []string `json:"output"` } +// OpenRouter nests modalities under architecture; OpenGateway uses flat fields. +type remoteArchitecture struct { + InputModalities []string `json:"input_modalities"` + OutputModalities []string `json:"output_modalities"` +} + +type remoteReasoningInfo struct { + Mandatory bool `json:"mandatory"` + DefaultEnabled bool `json:"default_enabled"` + Supported []string `json:"supported_efforts"` +} + func (model remoteModel) toModel(key string, source string) Model { id := firstNonEmpty(model.ID, key) - contextWindow := firstPositive(model.ContextWindow, model.ContextWindowCamel, model.Limit.Context) - inputCost := firstPositiveFloat(model.InputCost, model.Cost.Input) - outputCost := firstPositiveFloat(model.OutputCost, model.Cost.Output) + contextWindow := firstPositive( + model.ContextWindow, + model.ContextWindowCamel, + model.ContextLength, + model.MaxContextLength, + model.Limit.Context, + ) + inputCost := firstPositiveFloat( + model.InputCost, + parsePricingString(model.Pricing.Prompt), + model.Cost.Input, + ) + outputCost := firstPositiveFloat( + model.OutputCost, + parsePricingString(model.Pricing.Completion), + model.Cost.Output, + ) + inputModalities := cleanStrings(model.Modalities.Input) + if len(inputModalities) == 0 { + inputModalities = cleanStrings(model.Architecture.InputModalities) + } + outputModalities := cleanStrings(model.Modalities.Output) + if len(outputModalities) == 0 { + outputModalities = cleanStrings(model.Architecture.OutputModalities) + } + toolCall := model.ToolCall || model.ToolCallCamel || model.Tools || containsFold(model.SupportedParameters, "tools") + reasoning := model.supportsReasoning() || + containsFold(model.SupportedParameters, "reasoning") || + containsFold(model.SupportedParameters, "reasoning_effort") || + containsFold(model.SupportedParameters, "include_reasoning") + + // Prefer the short display name for aggregator catalogs (OpenRouter's + // description field is multi-sentence marketing copy). models.dev and + // OpenGateway still prefer an explicit description when present. + description := firstNonEmpty(model.Description, model.Name) + if source == openRouterSource { + description = firstNonEmpty(model.Name, model.Description) + } + return Model{ ID: strings.TrimSpace(id), - Description: firstNonEmpty(model.Description, model.Name), + Description: description, ContextWindow: contextWindow, - ToolCall: model.ToolCall || model.ToolCallCamel || model.Tools, - Reasoning: model.Reasoning, - InputModalities: cleanStrings(model.Modalities.Input), - OutputModalities: cleanStrings(model.Modalities.Output), + ToolCall: toolCall, + Reasoning: reasoning, + InputModalities: inputModalities, + OutputModalities: outputModalities, InputCost: inputCost, OutputCost: outputCost, Tags: cleanStrings(model.Tags), @@ -194,6 +333,35 @@ func (model remoteModel) toModel(key string, source string) Model { } } +func (model remoteModel) supportsReasoning() bool { + raw := bytes.TrimSpace(model.ReasoningRaw) + if len(raw) == 0 || string(raw) == "null" { + return false + } + var flag bool + if err := json.Unmarshal(raw, &flag); err == nil { + return flag + } + var info remoteReasoningInfo + if err := json.Unmarshal(raw, &info); err != nil { + return false + } + return info.Mandatory || info.DefaultEnabled || len(info.Supported) > 0 +} + +func annotateFreeModel(model Model, raw remoteModel) Model { + if !raw.Free && !raw.IsFree && !strings.HasSuffix(strings.ToLower(model.ID), ":free") { + return model + } + if !containsFold(model.Tags, "free") { + model.Tags = append(append([]string{}, model.Tags...), "free") + } + if model.Description != "" && !strings.Contains(strings.ToLower(model.Description), "free") { + model.Description = model.Description + " (free)" + } + return model +} + func fetchJSON(ctx context.Context, endpoint string, client *http.Client) ([]byte, error) { endpoint = strings.TrimSpace(endpoint) if endpoint == "" { @@ -213,6 +381,7 @@ func fetchJSON(ctx context.Context, endpoint string, client *http.Client) ([]byt return nil, err } defer response.Body.Close() + // OpenRouter's full catalog is ~0.5MB today; keep headroom for growth. body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) if err != nil { return nil, err @@ -227,11 +396,27 @@ func defaultedOpenGatewayURL(provider providercatalog.Descriptor, override strin if override = strings.TrimSpace(override); override != "" { return override } - parsed, err := url.Parse(provider.DefaultBaseURL) + return defaultedOpenAIModelsURL(provider.DefaultBaseURL, defaultOpenGatewayURL) +} + +func defaultedOpenRouterURL(provider providercatalog.Descriptor, override string) string { + if override = strings.TrimSpace(override); override != "" { + return override + } + return defaultedOpenAIModelsURL(provider.DefaultBaseURL, defaultOpenRouterURL) +} + +// defaultedOpenAIModelsURL appends /models to an OpenAI-compatible base URL +// (e.g. https://host/v1 → https://host/v1/models). +func defaultedOpenAIModelsURL(baseURL string, fallback string) string { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "https://opengateway.gitlawb.com/zero/models.json" + return fallback } - return parsed.Scheme + "://" + parsed.Host + "/zero/models.json" + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/models" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String() } func sortModels(models []Model) { @@ -279,6 +464,20 @@ func firstPositiveFloat(values ...float64) float64 { return 0 } +// parsePricingString converts OpenRouter/OpenGateway pricing.prompt and +// pricing.completion values (USD per token) into the Model.InputCost/OutputCost +// unit used everywhere else (USD per million tokens, matching models.dev +// cost.input / cost.output). Values <= 0, including OpenRouter's "-1" +// variable/BYOK marker, and non-finite values (NaN/Inf), map to 0 so the +// picker shows no price rather than a bogus number. +func parsePricingString(s string) float64 { + val, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil || val <= 0 || math.IsNaN(val) || math.IsInf(val, 0) { + return 0 + } + return val * 1e6 +} + func cleanStrings(values []string) []string { result := make([]string, 0, len(values)) seen := map[string]bool{} diff --git a/internal/providermodelcatalog/remote_test.go b/internal/providermodelcatalog/remote_test.go index d7e83bf42..f748f1a14 100644 --- a/internal/providermodelcatalog/remote_test.go +++ b/internal/providermodelcatalog/remote_test.go @@ -89,9 +89,13 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) { "tags": ["free"] }, { - "id": "image-route", - "name": "Image Route", - "modalities": {"input": ["text"], "output": ["image"]} + "id": "auto", + "name": "Auto (smart routing)", + "description": "picks the cheapest capable model" + }, + { + "id": "whisper-1", + "name": "Whisper" } ] }`) @@ -100,10 +104,18 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) { if err != nil { t.Fatalf("ParseOpenGatewayCatalog returned error: %v", err) } - if got := strings.Join(modelIDs(models), ","); got != "minimax-m3,tencent/hy3" { - t.Fatalf("models = %#v, want gateway coding models", got) + // Gateway list is trusted: keep auto + coding routes; drop known non-coding. + // Sorted by description label (agentic…, free…, picks…). + if got := strings.Join(modelIDs(models), ","); got != "minimax-m3,tencent/hy3,auto" { + t.Fatalf("models = %#v, want gateway live models including auto", got) + } + var model Model + for _, entry := range models { + if entry.ID == "minimax-m3" { + model = entry + break + } } - model := models[0] if model.ID != "minimax-m3" || model.Description != "agentic coding route" { t.Fatalf("gateway model = %#v, want rich description", model) } @@ -116,8 +128,114 @@ func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) { if model.Source != "opengateway" { t.Fatalf("gateway source = %q, want opengateway", model.Source) } - if models[1].ID != "tencent/hy3" || models[1].ContextWindow != 262144 || !models[1].ToolCall { - t.Fatalf("gateway HY3 model = %#v, want Tencent HY3 with context/tools", models[1]) + var hy3 Model + for _, entry := range models { + if entry.ID == "tencent/hy3" { + hy3 = entry + break + } + } + if hy3.ID != "tencent/hy3" || hy3.ContextWindow != 262144 || !hy3.ToolCall { + t.Fatalf("gateway HY3 model = %#v, want Tencent HY3 with context/tools", hy3) + } +} + +func TestParseOpenRouterCatalogMapsLiveMetadata(t *testing.T) { + body := []byte(`{ + "data": [ + { + "id": "anthropic/claude-sonnet-4.5", + "name": "Anthropic: Claude Sonnet 4.5", + "description": "A long marketing blurb that should not win the picker label.", + "context_length": 200000, + "pricing": { + "prompt": "0.000003", + "completion": "0.000015" + }, + "architecture": { + "input_modalities": ["text", "image"], + "output_modalities": ["text"] + }, + "supported_parameters": ["tools", "tool_choice", "reasoning", "temperature"] + }, + { + "id": "vendor/image-only", + "name": "Image Only", + "architecture": { + "input_modalities": ["text"], + "output_modalities": ["image"] + } + }, + { + "id": "openai/gpt-4o", + "name": "OpenAI: GPT-4o", + "context_length": 128000, + "supported_parameters": ["tools"] + } + ] + }`) + + models, err := ParseOpenRouterCatalog(body) + if err != nil { + t.Fatalf("ParseOpenRouterCatalog returned error: %v", err) + } + if got := strings.Join(modelIDs(models), ","); got != "anthropic/claude-sonnet-4.5,openai/gpt-4o" { + t.Fatalf("models = %#v, want coding OpenRouter models only", got) + } + var claude Model + for _, entry := range models { + if entry.ID == "anthropic/claude-sonnet-4.5" { + claude = entry + break + } + } + if claude.Description != "Anthropic: Claude Sonnet 4.5" { + t.Fatalf("description = %q, want short display name", claude.Description) + } + if claude.ContextWindow != 200000 || !claude.ToolCall || !claude.Reasoning { + t.Fatalf("claude capabilities = %#v, want context/tools/reasoning from live payload", claude) + } + if strings.Join(claude.InputModalities, ",") != "text,image" || strings.Join(claude.OutputModalities, ",") != "text" { + t.Fatalf("claude modalities = %#v/%#v", claude.InputModalities, claude.OutputModalities) + } + // Live pricing.prompt is USD per token; Model costs are USD per million tokens. + if claude.InputCost != 3 || claude.OutputCost != 15 { + t.Fatalf("claude costs = %f/%f, want 3/15 (per-million, matching models.dev)", claude.InputCost, claude.OutputCost) + } + if claude.Source != "openrouter" { + t.Fatalf("source = %q, want openrouter", claude.Source) + } +} + +func TestParsePricingString(t *testing.T) { + cases := []struct { + in string + want float64 + }{ + {"0.000003", 3}, + {" 0.000015 ", 15}, + {"-1", 0}, + {"0", 0}, + {"", 0}, + {"not-a-number", 0}, + {"Inf", 0}, + {"+Inf", 0}, + {"-Inf", 0}, + {"NaN", 0}, + } + for _, tc := range cases { + if got := parsePricingString(tc.in); got != tc.want { + t.Fatalf("parsePricingString(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestPublicLiveCatalog(t *testing.T) { + if !PublicLiveCatalog("openrouter") || !PublicLiveCatalog("gitlawb-opengateway") { + t.Fatal("openrouter and gitlawb-opengateway should advertise a public live catalog") + } + if PublicLiveCatalog("openai") { + t.Fatal("openai should not be treated as a public live catalog provider") } } diff --git a/internal/providermodeldiscovery/discovery.go b/internal/providermodeldiscovery/discovery.go index 76a7174a6..be75c162b 100644 --- a/internal/providermodeldiscovery/discovery.go +++ b/internal/providermodeldiscovery/discovery.go @@ -39,6 +39,7 @@ type Options struct { HTTPClient *http.Client ModelsDevURL string OpenGatewayURL string + OpenRouterURL string OAuthResolver providerio.TokenResolver CodexAccountResolver openai.CodexAccountResolver UserAgent string @@ -46,7 +47,11 @@ type Options struct { func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, profile config.ProviderProfile, options Options) ([]Model, error) { catalogModels, catalogErr := fetchCatalogModels(ctx, provider, options) - canProbeProvider := modelDiscoveryAllowed(profile) && (!provider.RequiresAuth || discoveryHasCredential(profile)) + // OpenRouter and OpenGateway publish public live model lists. Probe them even + // without credentials so the picker stays current before a key is entered. + canProbeProvider := modelDiscoveryAllowed(profile) && (!provider.RequiresAuth || + discoveryHasCredential(profile) || + publicLiveCatalogProvider(provider, profile)) if canProbeProvider { liveModels, liveErr := Discover(ctx, profile, options) if liveErr == nil { @@ -70,6 +75,11 @@ func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, p return nil, fmt.Errorf("no provider models discovered") } +func publicLiveCatalogProvider(provider providercatalog.Descriptor, profile config.ProviderProfile) bool { + return providermodelcatalog.PublicLiveCatalog(provider.ID) || + providermodelcatalog.PublicLiveCatalog(profile.CatalogID) +} + // discoveryHasCredential reports whether the profile carries a usable credential // for an authenticated /models probe. A profile may authenticate via a raw // auth-header value instead of APIKey, so treat either as present — consistent @@ -248,7 +258,8 @@ func fetchProviderModels(ctx context.Context, endpoint string, profile config.Pr } defer response.Body.Close() - body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + // OpenRouter's full catalog is ~0.5MB today; keep headroom for growth. + body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) if err != nil { return nil, redactDiscoveryError(err, profile) } @@ -317,9 +328,21 @@ func anthropicModelsEndpoint(baseURL string) (string, error) { type modelsResponse struct { Data []struct { - ID string `json:"id"` - DisplayName string `json:"display_name"` - Name string `json:"name"` + ID string `json:"id"` + DisplayName string `json:"display_name"` + Name string `json:"name"` + Description string `json:"description"` + ContextWindow int `json:"context_window"` + ContextWindowAlt int `json:"contextWindow"` + ContextLength int `json:"context_length"` + MaxContextLength int `json:"max_context_length"` + Free bool `json:"free"` + IsFree bool `json:"is_free"` + ToolCall bool `json:"tool_call"` + ToolCallCamel bool `json:"toolCall"` + Tools bool `json:"tools"` + Reasoning bool `json:"reasoning"` + SupportedParameters []string `json:"supported_parameters"` } `json:"data"` } @@ -336,11 +359,36 @@ func parseModelsResponse(body []byte) ([]Model, error) { continue } seen[id] = true - description := strings.TrimSpace(item.DisplayName) - if description == "" { - description = strings.TrimSpace(item.Name) + description := firstNonEmptyDiscovery( + item.DisplayName, + item.Name, + item.Description, + ) + if (item.Free || item.IsFree || strings.HasSuffix(strings.ToLower(id), ":free")) && + description != "" && + !strings.Contains(strings.ToLower(description), "free") { + description = description + " (free)" } - models = append(models, Model{ID: id, Description: description}) + contextWindow := firstPositiveDiscovery( + item.ContextWindow, + item.ContextWindowAlt, + item.ContextLength, + item.MaxContextLength, + ) + toolCall := item.ToolCall || item.ToolCallCamel || item.Tools || + discoveryContainsFold(item.SupportedParameters, "tools") + reasoning := item.Reasoning || + discoveryContainsFold(item.SupportedParameters, "reasoning") || + discoveryContainsFold(item.SupportedParameters, "reasoning_effort") || + discoveryContainsFold(item.SupportedParameters, "include_reasoning") + models = append(models, Model{ + ID: id, + Description: description, + ContextWindow: contextWindow, + ToolCall: toolCall, + Reasoning: reasoning, + Source: "live", + }) } sort.SliceStable(models, func(i, j int) bool { return models[i].ID < models[j].ID @@ -356,6 +404,7 @@ func fetchCatalogModels(ctx context.Context, provider providercatalog.Descriptor HTTPClient: options.HTTPClient, ModelsDevURL: options.ModelsDevURL, OpenGatewayURL: options.OpenGatewayURL, + OpenRouterURL: options.OpenRouterURL, }) if err != nil { return nil, err @@ -389,20 +438,44 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca byID[model.ID] = model } hasCatalog := len(byID) > 0 + // Aggregators publish the live list as the source of truth. Keep live-only + // ids even when a remote catalog also loaded, instead of intersecting. + preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) result := make([]Model, 0, len(liveModels)) for _, live := range liveModels { if catalog, ok := byID[live.ID]; ok { - if !providermodelcatalog.IsCodingModel(catalogModelFromDiscovery(catalog)) { + if preferLive { + if providermodelcatalog.IsKnownNonCodingModelID(catalog.ID) { + continue + } + } else if !providermodelcatalog.IsCodingModel(catalogModelFromDiscovery(catalog)) { continue } + // Prefer catalog metadata (tools, cost) but fill gaps from live. + if catalog.ContextWindow == 0 && live.ContextWindow > 0 { + catalog.ContextWindow = live.ContextWindow + } + if strings.TrimSpace(catalog.Description) == "" && strings.TrimSpace(live.Description) != "" { + catalog.Description = live.Description + } catalog.Source = firstDiscoverySource(catalog.Source, "live") result = append(result, catalog) continue } - if hasCatalog { + if hasCatalog && !preferLive { continue } - if !liveModelAllowedWithoutCatalog(provider, live.ID) { + if preferLive { + if providermodelcatalog.IsKnownNonCodingModelID(live.ID) { + continue + } + // OpenRouter keeps coding-capable live-only models (tools/reasoning + // or coding-like ids). OpenGateway trusts the gateway list. + if providercatalog.NormalizeID(provider.ID) == "openrouter" && + !providermodelcatalog.IsCodingModel(catalogModelFromDiscovery(live)) { + continue + } + } else if !liveModelAllowedWithoutCatalog(provider, live.ID) { continue } live.Source = firstDiscoverySource(live.Source, "live") @@ -411,6 +484,33 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca return result } +func discoveryContainsFold(values []string, want string) bool { + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), want) { + return true + } + } + return false +} + +func firstNonEmptyDiscovery(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func firstPositiveDiscovery(values ...int) int { + for _, value := range values { + if value > 0 { + return value + } + } + return 0 +} + func liveModelAllowedWithoutCatalog(provider providercatalog.Descriptor, id string) bool { if !providermodelcatalog.ModelIDAllowedForProvider(provider.ID, id) { return false diff --git a/internal/providermodeldiscovery/discovery_test.go b/internal/providermodeldiscovery/discovery_test.go index c69eafdf3..14f4fb2ee 100644 --- a/internal/providermodeldiscovery/discovery_test.go +++ b/internal/providermodeldiscovery/discovery_test.go @@ -52,6 +52,153 @@ func TestDiscoverOpenAICompatibleModelsFetchesModelsEndpoint(t *testing.T) { } } +func TestParseModelsResponseCapturesContextAndFree(t *testing.T) { + models, err := parseModelsResponse([]byte(`{ + "data": [ + {"id": "xiaomi/mimo-v2.5-pro", "name": "MiMo V2.5-Pro", "context_window": 262144}, + {"id": "nvidia/nemotron-3-ultra:free", "name": "Nemotron", "context_length": 128000, "is_free": true} + ] + }`)) + if err != nil { + t.Fatalf("parseModelsResponse: %v", err) + } + if len(models) != 2 { + t.Fatalf("models = %#v, want 2", models) + } + if got, want := modelIDs(models), []string{"nvidia/nemotron-3-ultra:free", "xiaomi/mimo-v2.5-pro"}; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("models = %#v, want %#v", got, want) + } + byID := map[string]Model{} + for _, model := range models { + byID[model.ID] = model + } + if byID["xiaomi/mimo-v2.5-pro"].ContextWindow != 262144 { + t.Fatalf("mimo context = %d", byID["xiaomi/mimo-v2.5-pro"].ContextWindow) + } + if byID["nvidia/nemotron-3-ultra:free"].Description != "Nemotron (free)" { + t.Fatalf("free model description = %q, want annotated free label", byID["nvidia/nemotron-3-ultra:free"].Description) + } +} + +func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) { + // Catalog and live endpoints return distinct payloads so the merge must keep + // live-only ids that are absent from the remote catalog response. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/catalog/v1/models": + _, _ = w.Write([]byte(`{"data":[ + {"id":"auto","name":"Auto (smart routing)"}, + {"id":"xiaomi/mimo-v2.5-pro","name":"MiMo V2.5-Pro","context_window":262144} + ]}`)) + case "/v1/models": + _, _ = w.Write([]byte(`{"data":[ + {"id":"auto","name":"Auto (smart routing)"}, + {"id":"xiaomi/mimo-v2.5-pro","name":"MiMo V2.5-Pro","context_window":262144}, + {"id":"live-only-model","name":"Live Only","context_window":64000} + ]}`)) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + provider := providercatalog.Descriptor{ + ID: "gitlawb-opengateway", + Transport: providercatalog.TransportOpenAICompat, + DefaultBaseURL: server.URL + "/v1", + RequiresAuth: true, + } + models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{ + CatalogID: "gitlawb-opengateway", + ProviderKind: config.ProviderKindOpenAICompatible, + BaseURL: server.URL + "/v1", + // No API key: public live catalog must still load. + }, Options{ + HTTPClient: server.Client(), + OpenGatewayURL: server.URL + "/catalog/v1/models", + }) + if err != nil { + t.Fatalf("DiscoverCatalog: %v", err) + } + got := strings.Join(modelIDs(models), ",") + if !strings.Contains(got, "auto") || !strings.Contains(got, "xiaomi/mimo-v2.5-pro") || !strings.Contains(got, "live-only-model") { + t.Fatalf("models = %q, want auto + mimo + live-only", got) + } + for _, model := range models { + if model.ID == "xiaomi/mimo-v2.5-pro" && model.ContextWindow != 262144 { + t.Fatalf("mimo metadata = %#v", model) + } + if model.ID == "live-only-model" && model.ContextWindow != 64000 { + t.Fatalf("live-only metadata = %#v", model) + } + } +} + +func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) { + // Catalog omits anthropic/claude-sonnet-4.5 and the generic tools-only id; + // live retains both so preferLive keeps coding-capable live-only entries + // (id heuristic + capability flags). No API key: public listing is unauth. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/catalog/api/v1/models": + _, _ = w.Write([]byte(`{"data":[ + {"id":"openai/gpt-4.1","name":"GPT-4.1","context_length":1048576,"supported_parameters":["tools"]}, + {"id":"text-embedding-3-large","name":"Embedding"} + ]}`)) + case "/api/v1/models", "/v1/models": + _, _ = w.Write([]byte(`{"data":[ + {"id":"openai/gpt-4.1","name":"GPT-4.1","context_length":1048576,"supported_parameters":["tools"]}, + {"id":"anthropic/claude-sonnet-4.5","name":"Claude Sonnet 4.5","context_length":200000,"supported_parameters":["tools","reasoning"]}, + {"id":"vendor/generic-tools-model","name":"Generic Tools","context_length":32000,"supported_parameters":["tools"]}, + {"id":"text-embedding-3-large","name":"Embedding"} + ]}`)) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + provider := providercatalog.Descriptor{ + ID: "openrouter", + Transport: providercatalog.TransportOpenAICompat, + DefaultBaseURL: server.URL + "/api/v1", + RequiresAuth: true, + } + models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{ + CatalogID: "openrouter", + ProviderKind: config.ProviderKindOpenAICompatible, + BaseURL: server.URL + "/api/v1", + // No API key: unauthenticated public listing path. + }, Options{ + HTTPClient: server.Client(), + OpenRouterURL: server.URL + "/catalog/api/v1/models", + }) + if err != nil { + t.Fatalf("DiscoverCatalog: %v", err) + } + got := strings.Join(modelIDs(models), ",") + if !strings.Contains(got, "openai/gpt-4.1") || !strings.Contains(got, "anthropic/claude-sonnet-4.5") { + t.Fatalf("models = %q, want live openrouter coding models including live-only claude", got) + } + if !strings.Contains(got, "vendor/generic-tools-model") { + t.Fatalf("models = %q, want capability-marked live-only generic tools model", got) + } + if strings.Contains(got, "text-embedding-3-large") { + t.Fatalf("models = %q, embedding model should be filtered", got) + } + for _, model := range models { + if model.ID == "openai/gpt-4.1" && model.ContextWindow != 1048576 { + t.Fatalf("gpt-4.1 metadata = %#v, want live context window", model) + } + if model.ID == "anthropic/claude-sonnet-4.5" && model.ContextWindow != 200000 { + t.Fatalf("claude live-only metadata = %#v, want live context window", model) + } + if model.ID == "vendor/generic-tools-model" && (!model.ToolCall || model.ContextWindow != 32000) { + t.Fatalf("generic tools live-only = %#v, want tools + context from live payload", model) + } + } +} + func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) { var requests int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {