From 7a3be2a45a9537b316c17da76e632e9c74be2be3 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:27:04 -0400 Subject: [PATCH 1/5] feat(providers): fetch live model lists for OpenRouter and OpenGateway Prefer each aggregator public /v1/models catalog over the dead OpenGateway models.json path and models.dev-only OpenRouter mapping so the picker shows current routes with context windows and free labels. Refs #859 --- internal/providermodelcatalog/logic_test.go | 37 ++- internal/providermodelcatalog/remote.go | 244 +++++++++++++++--- internal/providermodelcatalog/remote_test.go | 103 +++++++- internal/providermodeldiscovery/discovery.go | 102 +++++++- .../providermodeldiscovery/discovery_test.go | 120 +++++++++ 5 files changed, 540 insertions(+), 66 deletions(-) diff --git a/internal/providermodelcatalog/logic_test.go b/internal/providermodelcatalog/logic_test.go index 0f3a3c225..ea10a065c 100644 --- a/internal/providermodelcatalog/logic_test.go +++ b/internal/providermodelcatalog/logic_test.go @@ -48,13 +48,25 @@ 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: "bad"}, ""); got != "https://openrouter.ai/api/v1/models" { t.Fatalf("fallback = %q", got) } } @@ -132,6 +144,25 @@ 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) + } } func containsModelID(models []Model, id string) bool { diff --git a/internal/providermodelcatalog/remote.go b/internal/providermodelcatalog/remote.go index 5d8841054..106009b07 100644 --- a/internal/providermodelcatalog/remote.go +++ b/internal/providermodelcatalog/remote.go @@ -1,6 +1,7 @@ package providermodelcatalog import ( + "bytes" "context" "encoding/json" "fmt" @@ -15,24 +16,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 +66,17 @@ 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. +func FetchOpenRouter(ctx context.Context, endpoint string, options FetchOptions) ([]Model, error) { + body, err := fetchJSON(ctx, endpoint, options.HTTPClient) + if err != nil { + return nil, err + } + return ParseOpenRouterCatalog(body) +} + func ParseModelsDevProvider(body []byte, providerID string) ([]Model, error) { var payload map[string]struct { Models map[string]remoteModel `json:"models"` @@ -89,24 +104,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 +132,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 +192,41 @@ 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"` + Modalities remoteModalities `json:"modalities"` + Architecture remoteArchitecture `json:"architecture"` } type remoteLimit struct { @@ -174,19 +246,59 @@ 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) + contextWindow := firstPositive( + model.ContextWindow, + model.ContextWindowCamel, + model.ContextLength, + model.MaxContextLength, + model.Limit.Context, + ) inputCost := firstPositiveFloat(model.InputCost, model.Cost.Input) outputCost := firstPositiveFloat(model.OutputCost, 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 +306,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 +354,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 +369,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) { diff --git a/internal/providermodelcatalog/remote_test.go b/internal/providermodelcatalog/remote_test.go index d7e83bf42..b652b57c7 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,83 @@ 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, + "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) + } + if claude.Source != "openrouter" { + t.Fatalf("source = %q, want openrouter", claude.Source) + } +} + +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..6e70a7bf5 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,16 @@ 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"` } `json:"data"` } @@ -336,11 +354,28 @@ 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, + ) + models = append(models, Model{ + ID: id, + Description: description, + ContextWindow: contextWindow, + Source: "live", + }) } sort.SliceStable(models, func(i, j int) bool { return models[i].ID < models[j].ID @@ -356,6 +391,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 +425,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 still applies the coding heuristic; OpenGateway trusts + // whatever the gateway lists (small, operator-curated set). + if providercatalog.NormalizeID(provider.ID) == "openrouter" && + !liveModelAllowedWithoutCatalog(provider, live.ID) { + continue + } + } else if !liveModelAllowedWithoutCatalog(provider, live.ID) { continue } live.Source = firstDiscoverySource(live.Source, "live") @@ -411,6 +471,24 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca return result } +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..43a774e16 100644 --- a/internal/providermodeldiscovery/discovery_test.go +++ b/internal/providermodeldiscovery/discovery_test.go @@ -52,6 +52,126 @@ 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 free", "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 models[1].ID != "nvidia/nemotron-3-ultra:free" { + // sorted by id + if models[0].ID != "nvidia/nemotron-3-ultra:free" { + t.Fatalf("models = %#v", models) + } + } + 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 !strings.Contains(byID["nvidia/nemotron-3-ultra:free"].Description, "free") { + t.Fatalf("free model description = %q", byID["nvidia/nemotron-3-ultra:free"].Description) + } +} + +func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + _, _ = 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} + ]}`)) + })) + 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 + "/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) + } + } +} + +func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + 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":"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", + APIKey: "sk-or-test", + }, Options{ + HTTPClient: server.Client(), + OpenRouterURL: server.URL + "/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", 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) + } + } +} + func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) { var requests int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From d3fb0b3f2e60e804a06ce94c639ede4a42b26770 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:07:49 -0400 Subject: [PATCH 2/5] fix(providers): parse OpenRouter string pricing and simplify sort assertions Parse OpenRouter prompt and completion string pricing fields into InputCost and OutputCost, and directly assert alphabetical model sort order in discovery tests. Refs #859 --- internal/providermodelcatalog/remote.go | 27 +++++++++++++++++-- internal/providermodelcatalog/remote_test.go | 7 +++++ .../providermodeldiscovery/discovery_test.go | 7 ++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/internal/providermodelcatalog/remote.go b/internal/providermodelcatalog/remote.go index 106009b07..a32bd40c3 100644 --- a/internal/providermodelcatalog/remote.go +++ b/internal/providermodelcatalog/remote.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "sort" + "strconv" "strings" "time" @@ -225,10 +226,16 @@ type remoteModel struct { 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 { Context int `json:"context"` Output int `json:"output"` @@ -267,8 +274,16 @@ func (model remoteModel) toModel(key string, source string) Model { model.MaxContextLength, model.Limit.Context, ) - inputCost := firstPositiveFloat(model.InputCost, model.Cost.Input) - outputCost := firstPositiveFloat(model.OutputCost, model.Cost.Output) + 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) @@ -437,6 +452,14 @@ func firstPositiveFloat(values ...float64) float64 { return 0 } +func parsePricingString(s string) float64 { + val, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil || val <= 0 { + return 0 + } + return val +} + 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 b652b57c7..7f200ebae 100644 --- a/internal/providermodelcatalog/remote_test.go +++ b/internal/providermodelcatalog/remote_test.go @@ -148,6 +148,10 @@ func TestParseOpenRouterCatalogMapsLiveMetadata(t *testing.T) { "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"] @@ -194,6 +198,9 @@ func TestParseOpenRouterCatalogMapsLiveMetadata(t *testing.T) { if strings.Join(claude.InputModalities, ",") != "text,image" || strings.Join(claude.OutputModalities, ",") != "text" { t.Fatalf("claude modalities = %#v/%#v", claude.InputModalities, claude.OutputModalities) } + if claude.InputCost != 0.000003 || claude.OutputCost != 0.000015 { + t.Fatalf("claude costs = %f/%f, want 0.000003/0.000015", claude.InputCost, claude.OutputCost) + } if claude.Source != "openrouter" { t.Fatalf("source = %q, want openrouter", claude.Source) } diff --git a/internal/providermodeldiscovery/discovery_test.go b/internal/providermodeldiscovery/discovery_test.go index 43a774e16..73142471b 100644 --- a/internal/providermodeldiscovery/discovery_test.go +++ b/internal/providermodeldiscovery/discovery_test.go @@ -65,11 +65,8 @@ func TestParseModelsResponseCapturesContextAndFree(t *testing.T) { if len(models) != 2 { t.Fatalf("models = %#v, want 2", models) } - if models[1].ID != "nvidia/nemotron-3-ultra:free" { - // sorted by id - if models[0].ID != "nvidia/nemotron-3-ultra:free" { - t.Fatalf("models = %#v", 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 { From b6c1832de0bef0e2caea6c384e02481c5ac0a6b1 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:24:57 -0400 Subject: [PATCH 3/5] fix(providers): scale live pricing and restore OpenRouter fallback Convert OpenRouter/OpenGateway per-token pricing to per-million for Model costs, and fall back to models.dev when the live OpenRouter fetch fails so the picker still degrades. --- internal/providermodelcatalog/logic_test.go | 42 ++++++++++++++++++++ internal/providermodelcatalog/remote.go | 18 +++++++-- internal/providermodelcatalog/remote_test.go | 5 ++- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/internal/providermodelcatalog/logic_test.go b/internal/providermodelcatalog/logic_test.go index ea10a065c..ea3525bf5 100644 --- a/internal/providermodelcatalog/logic_test.go +++ b/internal/providermodelcatalog/logic_test.go @@ -165,6 +165,48 @@ func TestFetchModelsDevAndOpenGatewayOverHTTP(t *testing.T) { } } +// 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) + } +} + func containsModelID(models []Model, id string) bool { for _, model := range models { if model.ID == id { diff --git a/internal/providermodelcatalog/remote.go b/internal/providermodelcatalog/remote.go index a32bd40c3..04921eb59 100644 --- a/internal/providermodelcatalog/remote.go +++ b/internal/providermodelcatalog/remote.go @@ -69,11 +69,17 @@ func FetchOpenGateway(ctx context.Context, endpoint string, options FetchOptions // 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. +// probes via the live discovery path. When the live host is unreachable, 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 { - return nil, err + fallback, fallbackErr := FetchModelsDev(ctx, "openrouter", options) + if fallbackErr == nil { + return fallback, nil + } + return nil, fmt.Errorf("%w (models.dev fallback: %v)", err, fallbackErr) } return ParseOpenRouterCatalog(body) } @@ -452,12 +458,18 @@ 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, 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 { return 0 } - return val + return val * 1e6 } func cleanStrings(values []string) []string { diff --git a/internal/providermodelcatalog/remote_test.go b/internal/providermodelcatalog/remote_test.go index 7f200ebae..7221e16ce 100644 --- a/internal/providermodelcatalog/remote_test.go +++ b/internal/providermodelcatalog/remote_test.go @@ -198,8 +198,9 @@ func TestParseOpenRouterCatalogMapsLiveMetadata(t *testing.T) { if strings.Join(claude.InputModalities, ",") != "text,image" || strings.Join(claude.OutputModalities, ",") != "text" { t.Fatalf("claude modalities = %#v/%#v", claude.InputModalities, claude.OutputModalities) } - if claude.InputCost != 0.000003 || claude.OutputCost != 0.000015 { - t.Fatalf("claude costs = %f/%f, want 0.000003/0.000015", claude.InputCost, claude.OutputCost) + // 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) From fb43a4834a7bf56bc91f98ebcd1664c113d878ed Mon Sep 17 00:00:00 2001 From: euxaristia Date: Fri, 7 Aug 2026 14:35:53 -0400 Subject: [PATCH 4/5] fix(providers): fall back on OpenRouter parse errors; cover live-only merge Route malformed OpenRouter HTTP 200 bodies through the models.dev fallback, and exercise catalog vs live payload split so live-only models stay merged. --- internal/providermodelcatalog/logic_test.go | 43 +++++++++++++++++++ internal/providermodelcatalog/remote.go | 23 ++++++---- .../providermodeldiscovery/discovery_test.go | 42 +++++++++++++----- 3 files changed, 89 insertions(+), 19 deletions(-) diff --git a/internal/providermodelcatalog/logic_test.go b/internal/providermodelcatalog/logic_test.go index ea3525bf5..71fa5d431 100644 --- a/internal/providermodelcatalog/logic_test.go +++ b/internal/providermodelcatalog/logic_test.go @@ -207,6 +207,49 @@ func TestFetchOpenRouterFallsBackToModelsDev(t *testing.T) { } } +// 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 { for _, model := range models { if model.ID == id { diff --git a/internal/providermodelcatalog/remote.go b/internal/providermodelcatalog/remote.go index 04921eb59..dd740bd8a 100644 --- a/internal/providermodelcatalog/remote.go +++ b/internal/providermodelcatalog/remote.go @@ -69,19 +69,24 @@ func FetchOpenGateway(ctx context.Context, endpoint string, options FetchOptions // 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, fall -// back to models.dev so the picker still degrades instead of failing entirely -// (both catalog and live probe hit openrouter.ai otherwise). +// 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 { - fallback, fallbackErr := FetchModelsDev(ctx, "openrouter", options) - if fallbackErr == nil { - return fallback, nil + if err == nil { + models, parseErr := ParseOpenRouterCatalog(body) + if parseErr == nil { + return models, nil } - return nil, fmt.Errorf("%w (models.dev fallback: %v)", err, fallbackErr) + err = parseErr + } + fallback, fallbackErr := FetchModelsDev(ctx, "openrouter", options) + if fallbackErr == nil { + return fallback, nil } - return ParseOpenRouterCatalog(body) + return nil, fmt.Errorf("%w (models.dev fallback: %v)", err, fallbackErr) } func ParseModelsDevProvider(body []byte, providerID string) ([]Model, error) { diff --git a/internal/providermodeldiscovery/discovery_test.go b/internal/providermodeldiscovery/discovery_test.go index 73142471b..738c1e10c 100644 --- a/internal/providermodeldiscovery/discovery_test.go +++ b/internal/providermodeldiscovery/discovery_test.go @@ -81,15 +81,24 @@ func TestParseModelsResponseCapturesContextAndFree(t *testing.T) { } 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) { - if r.URL.Path != "/v1/models" { + 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) } - _, _ = 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} - ]}`)) })) defer server.Close() @@ -106,7 +115,7 @@ func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) { // No API key: public live catalog must still load. }, Options{ HTTPClient: server.Client(), - OpenGatewayURL: server.URL + "/v1/models", + OpenGatewayURL: server.URL + "/catalog/v1/models", }) if err != nil { t.Fatalf("DiscoverCatalog: %v", err) @@ -119,12 +128,22 @@ func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) { 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; live retains it so the preferLive + // merge branch is exercised. No API key: public OpenRouter 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"]}, @@ -147,17 +166,17 @@ func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) { CatalogID: "openrouter", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: server.URL + "/api/v1", - APIKey: "sk-or-test", + // No API key: unauthenticated public listing path. }, Options{ HTTPClient: server.Client(), - OpenRouterURL: server.URL + "/api/v1/models", + 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", got) + t.Fatalf("models = %q, want live openrouter coding models including live-only claude", got) } if strings.Contains(got, "text-embedding-3-large") { t.Fatalf("models = %q, embedding model should be filtered", got) @@ -166,6 +185,9 @@ func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) { 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) + } } } From 76f24296f7b5fd826906e600772187f7df24bae9 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Fri, 7 Aug 2026 15:04:40 -0400 Subject: [PATCH 5/5] fix(providers): address remaining CodeRabbit findings on live catalogs Reject non-finite OpenRouter pricing, keep capability-marked live-only models, and harden related regression tests. --- internal/providermodelcatalog/logic_test.go | 3 ++ internal/providermodelcatalog/remote.go | 7 +-- internal/providermodelcatalog/remote_test.go | 23 +++++++++ internal/providermodeldiscovery/discovery.go | 48 ++++++++++++++----- .../providermodeldiscovery/discovery_test.go | 18 +++++-- 5 files changed, 78 insertions(+), 21 deletions(-) diff --git a/internal/providermodelcatalog/logic_test.go b/internal/providermodelcatalog/logic_test.go index 71fa5d431..9cdcdc331 100644 --- a/internal/providermodelcatalog/logic_test.go +++ b/internal/providermodelcatalog/logic_test.go @@ -66,6 +66,9 @@ func TestDefaultedOpenRouterURL(t *testing.T) { 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) } diff --git a/internal/providermodelcatalog/remote.go b/internal/providermodelcatalog/remote.go index dd740bd8a..ebf6abac2 100644 --- a/internal/providermodelcatalog/remote.go +++ b/internal/providermodelcatalog/remote.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "net/url" "sort" @@ -467,11 +468,11 @@ func firstPositiveFloat(values ...float64) float64 { // 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, map to 0 so the picker shows no price rather than a -// bogus number. +// 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 { + if err != nil || val <= 0 || math.IsNaN(val) || math.IsInf(val, 0) { return 0 } return val * 1e6 diff --git a/internal/providermodelcatalog/remote_test.go b/internal/providermodelcatalog/remote_test.go index 7221e16ce..f748f1a14 100644 --- a/internal/providermodelcatalog/remote_test.go +++ b/internal/providermodelcatalog/remote_test.go @@ -207,6 +207,29 @@ func TestParseOpenRouterCatalogMapsLiveMetadata(t *testing.T) { } } +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") diff --git a/internal/providermodeldiscovery/discovery.go b/internal/providermodeldiscovery/discovery.go index 6e70a7bf5..be75c162b 100644 --- a/internal/providermodeldiscovery/discovery.go +++ b/internal/providermodeldiscovery/discovery.go @@ -328,16 +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"` - 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"` + 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"` } @@ -370,10 +375,18 @@ func parseModelsResponse(body []byte) ([]Model, error) { 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", }) } @@ -456,10 +469,10 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca if providermodelcatalog.IsKnownNonCodingModelID(live.ID) { continue } - // OpenRouter still applies the coding heuristic; OpenGateway trusts - // whatever the gateway lists (small, operator-curated set). + // OpenRouter keeps coding-capable live-only models (tools/reasoning + // or coding-like ids). OpenGateway trusts the gateway list. if providercatalog.NormalizeID(provider.ID) == "openrouter" && - !liveModelAllowedWithoutCatalog(provider, live.ID) { + !providermodelcatalog.IsCodingModel(catalogModelFromDiscovery(live)) { continue } } else if !liveModelAllowedWithoutCatalog(provider, live.ID) { @@ -471,6 +484,15 @@ 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 != "" { diff --git a/internal/providermodeldiscovery/discovery_test.go b/internal/providermodeldiscovery/discovery_test.go index 738c1e10c..14f4fb2ee 100644 --- a/internal/providermodeldiscovery/discovery_test.go +++ b/internal/providermodeldiscovery/discovery_test.go @@ -56,7 +56,7 @@ 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 free", "context_length": 128000, "is_free": true} + {"id": "nvidia/nemotron-3-ultra:free", "name": "Nemotron", "context_length": 128000, "is_free": true} ] }`)) if err != nil { @@ -75,8 +75,8 @@ func TestParseModelsResponseCapturesContextAndFree(t *testing.T) { if byID["xiaomi/mimo-v2.5-pro"].ContextWindow != 262144 { t.Fatalf("mimo context = %d", byID["xiaomi/mimo-v2.5-pro"].ContextWindow) } - if !strings.Contains(byID["nvidia/nemotron-3-ultra:free"].Description, "free") { - t.Fatalf("free model description = %q", byID["nvidia/nemotron-3-ultra:free"].Description) + 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) } } @@ -135,8 +135,9 @@ func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) { } func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) { - // Catalog omits anthropic/claude-sonnet-4.5; live retains it so the preferLive - // merge branch is exercised. No API key: public OpenRouter listing is unauth. + // 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": @@ -148,6 +149,7 @@ func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) { _, _ = 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: @@ -178,6 +180,9 @@ func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) { 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) } @@ -188,6 +193,9 @@ func TestDiscoverCatalogOpenRouterKeepsLiveOnlyModels(t *testing.T) { 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) + } } }