Compare commits

...
Author SHA1 Message Date
Kevin Codex a084760296 feat(providers): point OpenGateway at api.twigpine.com and trust its live model list
Rename the recommended gateway provider to "Twigpine OpenGateway" and move
it to https://api.twigpine.com/v1. The catalog id (gitlawb-opengateway) and
GITLAWB_OPENGATEWAY_API_KEY stay, and "twigpine" / "twigpine opengateway"
are added as aliases.

- Default model is now xiaomi/mimo-v2.6-pro, an exact id from the live
  /v1/models list, so onboarding keeps it selected after live discovery
  (an alias default made setup jump to the first sorted model).
- The live models URL is https://api.twigpine.com/v1/models. Entries
  marked status "retired" are skipped in both the catalog parse and the
  live /models probe.
- The curated list is reduced to an offline fallback (auto,
  xiaomi/mimo-v2.6-pro, xiaomi/mimo-v2.6-flash). The live list replaces it
  whenever the gateway is reachable.
- Profiles saved with the old https://opengateway.gitlawb.com/v1 base URL
  keep resolving: the descriptor lists it in LegacyBaseURLs, and the
  resolver and picker treat it as the catalog endpoint, so the catalog env
  var binding is kept.
2026-11-09 11:25:27 +08:00
17 changed files with 384 additions and 47 deletions

No files matched your search

+7 -4
View File
@@ -455,7 +455,7 @@ func projectAPIKeyEnvAllowed(profile ProviderProfile, envName string) bool {
if baseURL == "" {
baseURL = descriptor.DefaultBaseURL
}
if !sameBaseURL(baseURL, descriptor.DefaultBaseURL) {
if !providercatalog.IsCatalogBaseURL(descriptor, baseURL) {
return false
}
return containsStringFold(descriptor.AuthEnvVars, envName)
@@ -463,7 +463,7 @@ func projectAPIKeyEnvAllowed(profile ProviderProfile, envName string) bool {
func projectBaseURLAllowed(profile ProviderProfile) bool {
descriptor, ok := catalogDescriptorForProfile(profile)
return ok && sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
return ok && providercatalog.IsCatalogBaseURL(descriptor, profile.BaseURL)
}
func hasInheritedProviderCredentialMaterial(project ProviderProfile, candidate ProviderProfile) bool {
@@ -1128,10 +1128,13 @@ func applyCatalogDescriptor(profile *ProviderProfile, descriptor providercatalog
if profile.Model == "" {
profile.Model = descriptor.DefaultModel
}
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && (!explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)) {
// A legacy catalog base URL (an earlier default that still serves the
// provider, e.g. a profile saved before a host move) counts as the catalog
// endpoint, so it keeps the catalog env var binding.
canonicalCatalogEndpoint := !explicitBaseURL || providercatalog.IsCatalogBaseURL(descriptor, profile.BaseURL)
if profile.APIKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 && canonicalCatalogEndpoint {
profile.APIKeyEnv = descriptor.AuthEnvVars[0]
}
canonicalCatalogEndpoint := !explicitBaseURL || sameBaseURL(profile.BaseURL, descriptor.DefaultBaseURL)
if len(descriptor.CustomHeaders) > 0 && canonicalCatalogEndpoint {
catalogHeaders := descriptor.CustomHeaders
if strings.EqualFold(strings.TrimSpace(descriptor.ID), "aimlapi") {
+68
View File
@@ -1357,6 +1357,74 @@ func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
}
}
func TestResolveAppliesOpenGatewayCatalogDefaults(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "gitlawb-opengateway",
"providers": [{
"name": "gitlawb-opengateway",
"catalogID": "gitlawb-opengateway"
}]
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: path,
Env: map[string]string{
"GITLAWB_OPENGATEWAY_API_KEY": "ogw_live_test",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.BaseURL != "https://api.twigpine.com/v1" {
t.Fatalf("BaseURL = %q, want https://api.twigpine.com/v1", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("Model = %q, want xiaomi/mimo-v2.6-pro", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "GITLAWB_OPENGATEWAY_API_KEY" || resolved.Provider.APIKey != "ogw_live_test" {
t.Fatalf("credential = (%q, %q), want GITLAWB_OPENGATEWAY_API_KEY env-resolved key", resolved.Provider.APIKeyEnv, resolved.Provider.APIKey)
}
}
// Profiles saved before the Twigpine rename persist the catalog's old base URL
// (https://opengateway.gitlawb.com/v1) explicitly. That host still serves the
// gateway, so those configs must keep resolving exactly as before: same host,
// same model, catalog env var still bound, credential still picked up.
func TestResolveKeepsLegacyOpenGatewayHostProfiles(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "gitlawb-opengateway",
"providers": [{
"name": "gitlawb-opengateway",
"catalogID": "gitlawb-opengateway",
"providerKind": "openai-compatible",
"baseURL": "https://opengateway.gitlawb.com/v1",
"model": "mimo-v2.5-pro"
}]
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: path,
Env: map[string]string{
"GITLAWB_OPENGATEWAY_API_KEY": "ogw_live_test",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.BaseURL != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("BaseURL = %q, want the saved legacy host kept", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "mimo-v2.5-pro" {
t.Fatalf("Model = %q, want the saved model kept", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "GITLAWB_OPENGATEWAY_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want GITLAWB_OPENGATEWAY_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "ogw_live_test" {
t.Fatalf("APIKey = %q, want env-resolved key", resolved.Provider.APIKey)
}
}
func TestResolveAppliesMiniMaxCNCatalogDefaults(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "mini-cn",
+51 -5
View File
@@ -75,6 +75,12 @@ type Descriptor struct {
// catalog-ordered lists and pickers. The recommended descriptors are the first
// entries in the catalog, in order.
Recommended bool
// LegacyBaseURLs lists earlier DefaultBaseURL values that still address
// this provider's own endpoint. Profiles saved against one keep catalog
// endpoint treatment (catalog env var binding, base-URL matching) after the
// default moves; see IsCatalogBaseURL.
LegacyBaseURLs []string
}
func RuntimeSupported(descriptor Descriptor) bool {
@@ -99,11 +105,17 @@ func RuntimeUnsupportedReason(descriptor Descriptor) string {
}
var descriptors = []Descriptor{
// GitLawb OpenGateway — the recommended default. An OpenAI-compatible gateway
// that smart-routes by model id across upstream providers (xiaomi-mimo,
// minimax, qwen, google, nvidia, tencent, z-ai). Flat /v1/chat/completions
// with a Bearer ogw_live_… key; listed first and badged in every picker.
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
// Twigpine OpenGateway (formerly GitLawb OpenGateway) — the recommended
// default. An OpenAI-compatible gateway at api.twigpine.com that smart-routes
// by model id across upstream providers. Flat /v1/chat/completions with a
// Bearer ogw_live_… key; listed first and badged in every picker. The public
// GET /v1/models lists only active models and is what pickers show when
// reachable. DefaultModel must be an exact id from that list (not an alias)
// so onboarding keeps it selected after live discovery. The id and env var
// keep their gitlawb names so saved configs keep resolving; the old host
// (opengateway.gitlawb.com) still serves clients and stays a legacy catalog
// endpoint so profiles saved against it resolve unchanged.
recommended(withLegacyBaseURLs(openAICompat("gitlawb-opengateway", "Twigpine OpenGateway", "https://api.twigpine.com/v1", "xiaomi/mimo-v2.6-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway", "twigpine opengateway", "twigpine"), "https://opengateway.gitlawb.com/v1")),
// aimlapi.com — OpenAI-compatible aggregating gateway, also badged as
// recommended (second, right after the OpenGateway default). The built-in TUI
// onboarding can register/top up an account and save the issued key; the partner
@@ -307,6 +319,37 @@ func recommended(descriptor Descriptor) Descriptor {
return descriptor
}
// withLegacyBaseURLs records earlier default base URLs that still serve the
// provider, so profiles saved before a host move keep resolving as the catalog
// endpoint.
func withLegacyBaseURLs(descriptor Descriptor, baseURLs ...string) Descriptor {
descriptor.LegacyBaseURLs = append(descriptor.LegacyBaseURLs, baseURLs...)
return descriptor
}
// IsCatalogBaseURL reports whether baseURL addresses the descriptor's own
// endpoint: its DefaultBaseURL or one of its LegacyBaseURLs, ignoring case,
// surrounding space, and a trailing slash. An empty baseURL never matches.
func IsCatalogBaseURL(descriptor Descriptor, baseURL string) bool {
normalized := normalizeBaseURL(baseURL)
if normalized == "" {
return false
}
if normalized == normalizeBaseURL(descriptor.DefaultBaseURL) {
return true
}
for _, legacy := range descriptor.LegacyBaseURLs {
if normalized == normalizeBaseURL(legacy) {
return true
}
}
return false
}
func normalizeBaseURL(baseURL string) string {
return strings.ToLower(strings.TrimRight(strings.TrimSpace(baseURL), "/"))
}
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
@@ -382,6 +425,9 @@ func cloneDescriptor(descriptor Descriptor) Descriptor {
descriptor.AuthEnvVars = append([]string{}, descriptor.AuthEnvVars...)
descriptor.SupportedAPIFormats = append([]APIFormat{}, descriptor.SupportedAPIFormats...)
descriptor.Aliases = append([]string{}, descriptor.Aliases...)
if descriptor.LegacyBaseURLs != nil {
descriptor.LegacyBaseURLs = append([]string{}, descriptor.LegacyBaseURLs...)
}
if descriptor.CustomHeaders != nil {
descriptor.CustomHeaders = copyStringMap(descriptor.CustomHeaders)
}
+60 -3
View File
@@ -77,7 +77,8 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
func TestRecommendedProvidersAreTopOfCatalog(t *testing.T) {
descriptors := All()
// The recommended providers are badged and pinned to the top of the catalog,
// in this order (OpenGateway remains the default; aimlapi.com is also badged).
// in this order (Twigpine OpenGateway remains the default; aimlapi.com is also
// badged).
wantTop := []string{"gitlawb-opengateway", "aimlapi"}
if len(descriptors) < len(wantTop) {
t.Fatalf("All() returned %d descriptors, want at least %d", len(descriptors), len(wantTop))
@@ -107,12 +108,66 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
if !ok {
t.Fatal("gitlawb-opengateway not found in catalog")
}
if descriptor.DefaultBaseURL != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://opengateway.gitlawb.com/v1")
if descriptor.Name != "Twigpine OpenGateway" {
t.Fatalf("OpenGateway name = %q, want %q", descriptor.Name, "Twigpine OpenGateway")
}
if descriptor.DefaultBaseURL != "https://api.twigpine.com/v1" {
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://api.twigpine.com/v1")
}
// The default must be an exact id from the live /v1/models list (not a
// gateway alias such as the bare "mimo-v2.6-pro"): onboarding keeps the
// default selected after live discovery only on an exact id match.
if descriptor.DefaultModel != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("OpenGateway default model = %q, want %q", descriptor.DefaultModel, "xiaomi/mimo-v2.6-pro")
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("OpenGateway transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
// Saved configs keep the original credential env var.
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"GITLAWB_OPENGATEWAY_API_KEY"}) {
t.Fatalf("OpenGateway auth env vars = %#v, want GITLAWB_OPENGATEWAY_API_KEY", descriptor.AuthEnvVars)
}
// Old and new names both resolve to the same stable catalog id.
for _, name := range []string{"gitlawb-opengateway", "GitLawb OpenGateway", "opengateway", "Twigpine OpenGateway", "twigpine"} {
got, ok := Get(name)
if !ok || got.ID != "gitlawb-opengateway" {
t.Fatalf("Get(%q) = (%q, %v), want gitlawb-opengateway", name, got.ID, ok)
}
}
}
func TestIsCatalogBaseURLAcceptsDefaultAndLegacyHosts(t *testing.T) {
gateway, ok := Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway not found in catalog")
}
for _, baseURL := range []string{
"https://api.twigpine.com/v1",
"https://API.twigpine.com/v1/ ",
"https://opengateway.gitlawb.com/v1",
"https://opengateway.gitlawb.com/v1/",
} {
if !IsCatalogBaseURL(gateway, baseURL) {
t.Fatalf("IsCatalogBaseURL(gateway, %q) = false, want true", baseURL)
}
}
for _, baseURL := range []string{"", "https://gateway.example.com/v1", "https://opengateway.gitlawb.com/v2"} {
if IsCatalogBaseURL(gateway, baseURL) {
t.Fatalf("IsCatalogBaseURL(gateway, %q) = true, want false", baseURL)
}
}
openrouter, ok := Get("openrouter")
if !ok {
t.Fatal("openrouter not found in catalog")
}
if !IsCatalogBaseURL(openrouter, "https://openrouter.ai/api/v1") || IsCatalogBaseURL(openrouter, "https://opengateway.gitlawb.com/v1") {
t.Fatal("legacy hosts must only match their own descriptor")
}
// Get returns a copy: mutating it must not leak into the catalog.
gateway.LegacyBaseURLs[0] = "https://mutated.example.com/v1"
if again, _ := Get("gitlawb-opengateway"); again.LegacyBaseURLs[0] != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("LegacyBaseURLs aliased the catalog: %#v", again.LegacyBaseURLs)
}
}
func TestAIMLAPIDescriptor(t *testing.T) {
@@ -385,6 +440,8 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
"custom_openai_compatible": "custom-openai-compatible",
"custom--anthropic compatible": "custom-anthropic-compatible",
"GitLawb OpenGateway": "gitlawb-opengateway",
"Twigpine OpenGateway": "gitlawb-opengateway",
"twigpine": "gitlawb-opengateway",
}
for input, want := range cases {
descriptor, ok := Get(input)
+6 -4
View File
@@ -675,10 +675,12 @@ func blockedAddrReason(addr netip.Addr) string {
}
// overrideHealthEndpoint returns a provider-specific connectivity probe URL when
// the default {baseURL}+/models path does not exist. GitLawb OpenGateway is a
// smart-routing gateway whose flat /v1/models endpoint 404s by design ("Use
// /v1/<provider>/<path>"), so probe its public /health endpoint at the host root
// instead, which reports real reachability without a per-model call.
// the default {baseURL}+/models path is not the right reachability check.
// Twigpine OpenGateway (formerly GitLawb OpenGateway) is a smart-routing gateway
// whose flat /v1/models 404'd on older deployments, so probe its public /health
// endpoint at the host root instead (served by api.twigpine.com and the legacy
// opengateway.gitlawb.com host), which reports real reachability without a
// per-model call.
func overrideHealthEndpoint(profile config.ProviderProfile, baseURL string) (string, bool) {
if profile.CatalogID != "gitlawb-opengateway" {
return "", false
@@ -179,6 +179,10 @@ func TestOverrideHealthEndpointOpenGateway(t *testing.T) {
if got != "https://opengateway.gitlawb.com/health" {
t.Fatalf("OpenGateway health endpoint = %q, want host-root /health", got)
}
got, ok = overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "https://api.twigpine.com/v1")
if !ok || got != "https://api.twigpine.com/health" {
t.Fatalf("Twigpine OpenGateway health endpoint = (%q, %v), want https://api.twigpine.com/health", got, ok)
}
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "openai"}, "https://api.openai.com/v1"); ok {
t.Fatal("non-OpenGateway providers must not override the health endpoint")
+10 -19
View File
@@ -225,26 +225,17 @@ var curatedModels = map[string][]Model{
},
"zai": zaiCuratedModels,
"zai-cn": zaiCuratedModels,
// OpenGateway smart-routes by model id across its upstream providers
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, tencent, z-ai).
// These are the curated coding defaults; the gateway accepts any model its
// upstreams expose, so users can also type an id the picker doesn't list.
// Twigpine OpenGateway publishes its live, active-only model list at
// GET /v1/models; pickers and onboarding show that list whenever it is
// reachable (see providermodeldiscovery.DiscoverCatalog). This is only the
// offline fallback, so keep it to ids the gateway is expected to keep
// serving: the smart router, the catalog default, and its fast sibling. Do
// not add promo or time-limited models here. Users can still type any id
// the gateway accepts.
"gitlawb-opengateway": {
{ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"},
{ID: "xiaomi/mimo-v2.5-pro", Description: "Xiaomi MiMo V2.5 Pro"},
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"},
{ID: "xiaomi/mimo-v2.5", Description: "multimodal model (Xiaomi)"},
{ID: "tencent/hy3", Description: "free Tencent HY3 model"},
{ID: "MiniMax-M3", Description: "MiniMax model"},
{ID: "minimax/minimax-m3", Description: "MiniMax M3 model"},
{ID: "qwen-plus", Description: "Qwen model"},
{ID: "qwen/qwen3.7-max", Description: "Qwen flagship coding model"},
{ID: "gemini-2.5-pro", Description: "long-context model (Google)"},
{ID: "google/gemini-3.1-flash-lite", Description: "Google Gemini 3.1 Flash Lite"},
{ID: "glm-4.6", Description: "Z.ai model"},
{ID: "z-ai/glm-5.2", Description: "GLM coding & reasoning model"},
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "NVIDIA NIM model"},
{ID: "nvidia/nemotron-3-ultra-550b-a55b:free", Description: "free Nemotron 3 Ultra reasoning MoE"},
{ID: "xiaomi/mimo-v2.6-pro", Description: "catalog default", ContextWindow: 262_144},
{ID: "auto", Description: "smart routing (picks a capable model per request)"},
{ID: "xiaomi/mimo-v2.6-flash", Description: "fast model (Xiaomi MiMo V2.6 Flash)", ContextWindow: 262_144},
},
"atomic-chat": {
{ID: "gpt-4.1", Description: "catalog default"},
@@ -74,13 +74,15 @@ func TestModelsAreProviderScoped(t *testing.T) {
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
// Offline fallback only; the live /v1/models list is shown when reachable.
provider: "gitlawb-opengateway",
want: []string{
"mimo-v2.5-pro", "tencent/hy3",
"xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "minimax/minimax-m3", "qwen/qwen3.7-max",
"google/gemini-3.1-flash-lite", "z-ai/glm-5.2", "nvidia/nemotron-3-ultra-550b-a55b:free",
want: []string{"xiaomi/mimo-v2.6-pro", "auto", "xiaomi/mimo-v2.6-flash"},
notWant: []string{
"openai/gpt-4.1", "claude-sonnet-4.5",
"mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro", "qwen-plus", "glm-4.6", "gemini-2.5-pro",
"MiniMax-M3", "nvidia/llama-3.1-nemotron-70b-instruct",
"qwen/qwen3.8-max", "qwen/qwen3.8-flash",
},
notWant: []string{"openai/gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "opencode-go-anthropic-compatible",
+14 -1
View File
@@ -54,9 +54,22 @@ func TestDefaultedOpenGatewayURL(t *testing.T) {
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/v1/models" {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://api.twigpine.com/v1/models" {
t.Fatalf("fallback = %q", got)
}
// The catalog descriptor points at the Twigpine API host.
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway missing from catalog")
}
if got := defaultedOpenGatewayURL(descriptor, ""); got != "https://api.twigpine.com/v1/models" {
t.Fatalf("catalog descriptor = %q, want https://api.twigpine.com/v1/models", got)
}
// A descriptor still carrying the legacy host keeps deriving from it, so
// old-host configs resolve unchanged (the old host still serves clients).
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://opengateway.gitlawb.com/v1"}, ""); got != "https://opengateway.gitlawb.com/v1/models" {
t.Fatalf("legacy host = %q, want https://opengateway.gitlawb.com/v1/models", got)
}
}
func TestDefaultedOpenRouterURL(t *testing.T) {
+12 -1
View File
@@ -19,7 +19,7 @@ import (
const (
DefaultModelsDevURL = "https://models.dev/api.json"
defaultOpenGatewayURL = "https://opengateway.gitlawb.com/v1/models"
defaultOpenGatewayURL = "https://api.twigpine.com/v1/models"
defaultOpenRouterURL = "https://openrouter.ai/api/v1/models"
modelsDevSource = "models.dev"
openGatewaySource = "opengateway"
@@ -120,6 +120,8 @@ func ParseModelsDevProvider(body []byte, providerID string) ([]Model, error) {
// 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.
// Retired entries (status "retired", listed by ?status=all) are skipped so a
// picker never offers a model the gateway no longer serves.
func ParseOpenGatewayCatalog(body []byte) ([]Model, error) {
items, err := parseOpenAIStyleModelList(body)
if err != nil {
@@ -127,6 +129,9 @@ func ParseOpenGatewayCatalog(body []byte) ([]Model, error) {
}
models := make([]Model, 0, len(items))
for _, item := range items {
if item.retired() {
continue
}
model := item.toModel("", openGatewaySource)
if model.ID == "" {
continue
@@ -224,6 +229,7 @@ func PublicLiveCatalog(providerID string) bool {
type remoteModel struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // OpenGateway: "active" | "retired"; absent means active
Description string `json:"description"`
ContextWindow int `json:"context_window"`
ContextWindowCamel int `json:"contextWindow"`
@@ -348,6 +354,11 @@ func (model remoteModel) toModel(key string, source string) Model {
}
}
// retired reports whether the catalog marks this entry as retired.
func (model remoteModel) retired() bool {
return strings.EqualFold(strings.TrimSpace(model.Status), "retired")
}
func normalizedServiceTiers(tiers []string, legacy []string) []string {
values := append(append([]string{}, tiers...), legacy...)
seen := map[string]bool{}
@@ -67,6 +67,58 @@ func TestParseModelsDevProviderScopesAndMapsMetadata(t *testing.T) {
}
}
func TestParseOpenGatewayCatalogSkipsRetiredModels(t *testing.T) {
// Mirrors GET https://api.twigpine.com/v1/models?status=all once the gateway
// retires models: an active entry plus a retired one carrying a successor.
body := []byte(`{
"object": "list",
"data": [
{
"id": "xiaomi/mimo-v2.6-pro",
"object": "model",
"name": "MiMo V2.6-Pro",
"description": "flagship omnimodal agent model",
"aliases": ["xiaomi/mimo-v2.5-pro", "mimo-v2.5-pro"],
"context_window": 262144,
"pricing": {"prompt": "0.000001", "completion": "0.000003"},
"supports_tools": true,
"status": "active"
},
{
"id": "qwen/qwen-plus",
"object": "model",
"name": "Qwen Plus",
"description": "retired general model",
"context_window": 131072,
"status": "retired",
"successor": "qwen/qwen3.7-max"
}
]
}`)
models, err := ParseOpenGatewayCatalog(body)
if err != nil {
t.Fatalf("ParseOpenGatewayCatalog returned error: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("models = %q, want only the active entry", got)
}
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway missing from catalog")
}
// Onboarding keeps the default selected only on an exact id match against
// the live list, so the default must be a real active id, not an alias.
if !containsModelID(models, descriptor.DefaultModel) {
t.Fatalf("default model %q missing from live list %v", descriptor.DefaultModel, modelIDs(models))
}
// A list made only of retired entries is treated as empty, not offered.
if _, err := ParseOpenGatewayCatalog([]byte(`{"data":[{"id":"old/model","status":"retired"}]}`)); err == nil {
t.Fatal("expected an error when every entry is retired")
}
}
func TestParseOpenGatewayCatalogSupportsRichModelJSON(t *testing.T) {
body := []byte(`{
"models": [
@@ -355,6 +355,7 @@ type modelsResponseItem struct {
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
Status string `json:"status"`
ContextWindow int `json:"context_window"`
ContextWindowAlt int `json:"contextWindow"`
ContextLength int `json:"context_length"`
@@ -412,6 +413,11 @@ func parseModelsResponse(body []byte) ([]Model, error) {
if visibility := strings.ToLower(strings.TrimSpace(item.Visibility)); visibility != "" && visibility != "list" {
continue
}
// OpenGateway marks lifecycle with status "active" | "retired" (retired
// entries only appear with ?status=all). Never offer a retired model.
if strings.EqualFold(strings.TrimSpace(item.Status), "retired") {
continue
}
seen[id] = true
description := firstNonEmptyDiscovery(
item.DisplayName,
@@ -346,6 +346,70 @@ func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
}
}
func TestDiscoverCatalogOpenGatewayDropsRetiredAndKeepsDefault(t *testing.T) {
// Both the catalog fetch and the live /v1/models probe return one active and
// one retired entry. Users must only ever see the active one, and the
// catalog's default model must be among the ids discovery returns (onboarding
// keeps the default selected only on an exact id match).
const body = `{"object":"list","data":[
{"id":"xiaomi/mimo-v2.6-pro","name":"MiMo V2.6-Pro","context_window":262144,"supports_tools":true,"status":"active"},
{"id":"qwen/qwen-plus","name":"Qwen Plus","context_window":131072,"status":"retired","successor":"qwen/qwen3.7-max"}
]}`
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
switch r.URL.Path {
case "/catalog/v1/models", "/v1/models":
_, _ = w.Write([]byte(body))
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer server.Close()
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway missing from catalog")
}
provider := descriptor
provider.DefaultBaseURL = server.URL + "/v1"
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
CatalogID: "gitlawb-opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/v1",
Model: descriptor.DefaultModel,
}, Options{
HTTPClient: server.Client(),
OpenGatewayURL: server.URL + "/catalog/v1/models",
})
if err != nil {
t.Fatalf("DiscoverCatalog: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("models = %q, want only the active entry", got)
}
if models[0].ID != descriptor.DefaultModel {
t.Fatalf("default model %q missing from discovered %v", descriptor.DefaultModel, modelIDs(models))
}
if strings.Join(paths, ",") != "/catalog/v1/models,/v1/models" {
t.Fatalf("requests = %v, want catalog fetch then live probe", paths)
}
}
func TestParseModelsResponseSkipsRetiredStatus(t *testing.T) {
models, err := parseModelsResponse([]byte(`{"data":[
{"id":"active-model","status":"active"},
{"id":"retired-model","status":"Retired"},
{"id":"no-status-model"}
]}`))
if err != nil {
t.Fatalf("parseModelsResponse: %v", err)
}
if got := strings.Join(modelIDs(models), ","); got != "active-model,no-status-model" {
t.Fatalf("models = %q, want retired entry dropped", got)
}
}
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
+1 -1
View File
@@ -160,7 +160,7 @@ func TestNewPassesOpenGatewayHY3ModelThrough(t *testing.T) {
for range stream {
}
if transport.request.URL.String() != "https://opengateway.gitlawb.com/v1/chat/completions" {
if transport.request.URL.String() != "https://api.twigpine.com/v1/chat/completions" {
t.Fatalf("request URL = %q, want OpenGateway chat completions", transport.request.URL.String())
}
var body map[string]any
+4 -3
View File
@@ -839,7 +839,9 @@ func providerDescriptorByBaseURL(baseURL string) (providercatalog.Descriptor, bo
if genericProviderCatalogID(descriptor.ID) {
continue
}
if normalizeProviderBaseURL(descriptor.DefaultBaseURL) == normalized {
// Includes legacy catalog hosts, so a profile saved against a provider's
// earlier default base URL still maps to its catalog entry.
if providercatalog.IsCatalogBaseURL(descriptor, normalized) {
return descriptor, true
}
}
@@ -1019,8 +1021,7 @@ func (m model) normalizeProfileForProvider(provider providercatalog.Descriptor)
}
func profileMatchesProviderBaseURL(profile config.ProviderProfile, provider providercatalog.Descriptor) bool {
baseURL := normalizeProviderBaseURL(profile.BaseURL)
return baseURL != "" && baseURL == normalizeProviderBaseURL(provider.DefaultBaseURL)
return providercatalog.IsCatalogBaseURL(provider, profile.BaseURL)
}
func (m model) applyModelPickerModelsDiscovered(msg modelPickerModelsDiscoveredMsg) model {
+17
View File
@@ -1837,3 +1837,20 @@ func TestSwitchProviderModelStillRejectsProviderWithNoCredential(t *testing.T) {
t.Fatalf("expected the credential gate to refuse, got %q (ok=%v)", text, ok)
}
}
func TestProviderDescriptorByBaseURLMapsLegacyOpenGatewayHost(t *testing.T) {
// A catalog-less profile saved against the gateway's earlier default host
// still maps to the Twigpine OpenGateway catalog entry, as does the new host.
for _, baseURL := range []string{"https://opengateway.gitlawb.com/v1", "https://api.twigpine.com/v1/"} {
descriptor, ok := providerDescriptorByBaseURL(baseURL)
if !ok || descriptor.ID != "gitlawb-opengateway" {
t.Fatalf("providerDescriptorByBaseURL(%q) = (%q, %v), want gitlawb-opengateway", baseURL, descriptor.ID, ok)
}
if !profileMatchesProviderBaseURL(config.ProviderProfile{BaseURL: baseURL}, descriptor) {
t.Fatalf("profileMatchesProviderBaseURL(%q) = false, want true", baseURL)
}
}
if _, ok := providerDescriptorByBaseURL("https://gateway.example.com/v1"); ok {
t.Fatal("an unknown host must not map to a catalog entry")
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ func TestProviderCommandOpensOnboardingWizard(t *testing.T) {
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
listView := plainRender(t, next.View())
for _, want := range []string{"Choose provider", "aimlapi.com", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
for _, want := range []string{"Choose provider", "aimlapi.com", "Twigpine OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
assertContains(t, listView, want)
}
}