mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a084760296 |
No files matched your search
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user