Compare commits

..
Author SHA1 Message Date
Kevin Codex 1d03609eaa feat(providers): default Twigpine OpenGateway to the auto smart-routing model
The gateway no longer serves xiaomi/mimo-v2.6-pro for free, so a new account
with no credits that landed on the old default got HTTP 402 "Insufficient
credits" on its first request. "auto" routes a zero-balance account to a
free model and a funded one to the cheapest capable model.

Only new setups and profiles that name no model pick up the new default; a
saved model is kept. The provider id, env var, base URL and legacy host are
unchanged.

The gateway lists auto with no context window or pricing (the serving model
varies per request). The curated entry leaves the window unset on purpose, so
compaction sizes itself from modelregistry.FallbackContextWindow (200,000,
below the smallest window the gateway lists today) and the context gauges
show no guessed denominator.
2026-11-11 09:22:47 +08:00
7 changed files with 98 additions and 23 deletions

No files matched your search

+31 -2
View File
@@ -1378,14 +1378,43 @@ func TestResolveAppliesOpenGatewayCatalogDefaults(t *testing.T) {
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.Model != "auto" {
t.Fatalf("Model = %q, want auto", 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)
}
}
// The catalog default only fills a profile that names no model. A profile saved
// while MiMo V2.6 Pro was the default persisted that id, and must keep it now
// that the default is the smart router.
func TestResolveKeepsSavedOpenGatewayModel(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "gitlawb-opengateway",
"providers": [{
"name": "gitlawb-opengateway",
"catalogID": "gitlawb-opengateway",
"providerKind": "openai-compatible",
"baseURL": "https://api.twigpine.com/v1",
"model": "xiaomi/mimo-v2.6-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.Model != "xiaomi/mimo-v2.6-pro" {
t.Fatalf("Model = %q, want the saved model kept", resolved.Provider.Model)
}
}
// 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,
+5 -2
View File
@@ -111,11 +111,14 @@ var descriptors = []Descriptor{
// 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
// so onboarding keeps it selected after live discovery. It is the gateway's
// smart router, "auto": an account with no credits is routed to a free
// model instead of failing with 402 on a paid one. Only new setups and
// profiles that name no model get it; a saved model is kept. 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")),
recommended(withLegacyBaseURLs(openAICompat("gitlawb-opengateway", "Twigpine OpenGateway", "https://api.twigpine.com/v1", "auto", []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
+5 -4
View File
@@ -115,10 +115,11 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
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")
// gateway alias such as "twigpine/auto"): onboarding keeps the default
// selected after live discovery only on an exact id match. It is the smart
// router so a new account with no credits is routed to a free model.
if descriptor.DefaultModel != "auto" {
t.Fatalf("OpenGateway default model = %q, want %q", descriptor.DefaultModel, "auto")
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("OpenGateway transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
+8 -5
View File
@@ -229,12 +229,15 @@ var curatedModels = map[string][]Model{
// 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.
// serving: the smart router (the catalog default), MiMo V2.6 Pro, and its
// fast sibling. Do not add promo or time-limited models here. Users can
// still type any id the gateway accepts. "auto" carries no context window on
// purpose: the gateway reports none (the serving model varies per request),
// so compaction sizes itself from modelregistry.FallbackContextWindow and
// the context gauges show no guessed denominator.
"gitlawb-opengateway": {
{ID: "xiaomi/mimo-v2.6-pro", Description: "catalog default", ContextWindow: 262_144},
{ID: "auto", Description: "smart routing (picks a capable model per request)"},
{ID: "auto", Description: "catalog default"},
{ID: "xiaomi/mimo-v2.6-pro", Description: "general model (Xiaomi MiMo V2.6 Pro)", ContextWindow: 262_144},
{ID: "xiaomi/mimo-v2.6-flash", Description: "fast model (Xiaomi MiMo V2.6 Flash)", ContextWindow: 262_144},
},
"atomic-chat": {
+19 -1
View File
@@ -76,7 +76,7 @@ func TestModelsAreProviderScoped(t *testing.T) {
{
// Offline fallback only; the live /v1/models list is shown when reachable.
provider: "gitlawb-opengateway",
want: []string{"xiaomi/mimo-v2.6-pro", "auto", "xiaomi/mimo-v2.6-flash"},
want: []string{"auto", "xiaomi/mimo-v2.6-pro", "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",
@@ -116,6 +116,24 @@ func TestModelsAreProviderScoped(t *testing.T) {
}
}
// The offline OpenGateway list leads with the smart router, the catalog
// default. The gateway reports no context window for it (the serving model
// varies per request), so the curated entry must not invent one: an unknown
// window sizes compaction from modelregistry.FallbackContextWindow.
func TestOpenGatewayModelsLeadWithAutoDefault(t *testing.T) {
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
t.Fatal("provider gitlawb-opengateway missing from catalog")
}
models := Models(descriptor)
if len(models) == 0 || models[0].ID != "auto" || models[0].ID != descriptor.DefaultModel {
t.Fatalf("OpenGateway models = %#v, want the auto default first", modelIDs(models))
}
if models[0].ContextWindow != 0 {
t.Fatalf("auto context window = %d, want unknown (0)", models[0].ContextWindow)
}
}
func TestModelsDoNotAliasMutableCatalogState(t *testing.T) {
descriptor, ok := providercatalog.Get("minimax")
if !ok {
+22 -3
View File
@@ -69,10 +69,23 @@ 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.
// retires models: the smart router (listed with null metadata, since the
// serving model varies per request), an active entry, plus a retired one
// carrying a successor.
body := []byte(`{
"object": "list",
"data": [
{
"id": "auto",
"object": "model",
"name": "Auto (smart routing)",
"description": "picks the cheapest capable model",
"aliases": ["twigpine/auto", "gitlawb/auto"],
"context_window": null,
"pricing": null,
"supports_tools": null,
"status": "active"
},
{
"id": "xiaomi/mimo-v2.6-pro",
"object": "model",
@@ -100,8 +113,14 @@ func TestParseOpenGatewayCatalogSkipsRetiredModels(t *testing.T) {
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)
// Sorted by description label (flagship…, picks…).
if got := strings.Join(modelIDs(models), ","); got != "xiaomi/mimo-v2.6-pro,auto" {
t.Fatalf("models = %q, want only the active entries", got)
}
// The router's null context window and pricing parse as unknown (0), not
// as an error or a guessed value.
if auto := models[1]; auto.ContextWindow != 0 || auto.InputCost != 0 || auto.OutputCost != 0 {
t.Fatalf("auto metadata = %#v, want unknown context window and pricing", auto)
}
descriptor, ok := providercatalog.Get("gitlawb-opengateway")
if !ok {
@@ -347,11 +347,13 @@ 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).
// Both the catalog fetch and the live /v1/models probe return the smart
// router, one active and one retired entry. Users must only ever see the
// active ones, 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":"auto","name":"Auto (smart routing)","context_window":null,"pricing":null,"supports_tools":null,"status":"active"},
{"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"}
]}`
@@ -385,8 +387,8 @@ func TestDiscoverCatalogOpenGatewayDropsRetiredAndKeepsDefault(t *testing.T) {
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 got := strings.Join(modelIDs(models), ","); got != "auto,xiaomi/mimo-v2.6-pro" {
t.Fatalf("models = %q, want only the active entries", got)
}
if models[0].ID != descriptor.DefaultModel {
t.Fatalf("default model %q missing from discovered %v", descriptor.DefaultModel, modelIDs(models))