diff --git a/changelog/unreleased/free-model-quota-exemption.md b/changelog/unreleased/free-model-quota-exemption.md new file mode 100644 index 00000000..1d71b349 --- /dev/null +++ b/changelog/unreleased/free-model-quota-exemption.md @@ -0,0 +1,7 @@ +### English + +- Keep quota-exhausted accounts routable for free models. Qoder prices some catalog models at zero (`limited_time_free` tag or a zero `price_factor`), and requests for them spend no credits, but the routing pool filtered every quota-exceeded account unconditionally — leaving those accounts' free models with no account at all. The pool now records which catalog entries the provider prices at zero alongside the cached model list, and the quota filter exempts a request whose model is on that list. Pricing-unknown models are not assumed free, and the exemption is scoped per model, so exhausted accounts stay out of paid routes. + +### 中文 + +- 额度耗尽的账号现在仍可路由到免费模型。Qoder 目录中部分模型单价为零(`limited_time_free` 标签或 `price_factor` 为零),请求不消耗积分,但路由池原先无条件排除所有额度耗尽的账号,导致这些账号的免费模型无账号可用。路由池现在在缓存模型列表的同时记录上游报价为零的模型,额度过滤对命中该名单的请求予以豁免。未报价的模型不视为免费;豁免按请求模型限定,额度耗尽的账号依旧不会出现在付费模型的路由上。 diff --git a/changelog/unreleased/qoder-native-model-mapping.md b/changelog/unreleased/qoder-native-model-mapping.md new file mode 100644 index 00000000..958fd87d --- /dev/null +++ b/changelog/unreleased/qoder-native-model-mapping.md @@ -0,0 +1,7 @@ +### English + +- Send the catalog's gateway model key on Qoder chat. The model catalog publishes a friendly public ID (`qwen3.8-flash`) alongside a distinct gateway key (`qfmodel`). The worker resolved the public ID through the per-account catalog before sending, but the in-process COSY client filled `model_config.key` with whatever the caller asked for. On an account whose gateway routes only the mapped key, that request was never answered — the gateway held it open and the client waited out its own timeout with no error and no bytes in return. The pool now records the public-ID-to-gateway-key mapping from the catalog, and the chat request carries the mapped key. An account whose catalog reports no distinct key keeps its public spelling. + +### 中文 + +- Qoder 聊天请求现在发送目录里的网关模型键。模型目录同时给出一个易读的公网 ID(`qwen3.8-flash`)和一个不同的网关键(`qfmodel`)。Worker 发送前会用账号目录把公网 ID 解析一遍,但进程内 COSY 客户端此前直接把调用方传入的名字填进 `model_config.key`。在只按映射键路由的账号上,这个请求不会得到任何响应——网关把它挂着,客户端一直等到自己的超时,既没有错误也没有数据。路由池现在记录目录给出的「公网 ID → 网关键」映射,聊天请求携带映射后的键。目录未给出不同键的账号保持公网 ID 原样。 \ No newline at end of file diff --git a/internal/executor/free_model_quota_test.go b/internal/executor/free_model_quota_test.go new file mode 100644 index 00000000..502b072f --- /dev/null +++ b/internal/executor/free_model_quota_test.go @@ -0,0 +1,164 @@ +package executor + +import "testing" + +// An account whose paid quota is exhausted can still serve a model the provider +// prices at zero: a free model spends no credits, so the upstream does not +// reject it. Filtering such an account out on quota alone would leave a +// zero-credit account off a route that in fact works. +func TestPickRouteKeepsExhaustedAccountForFreeModel(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ + ID: "acc-exhausted", + Provider: "qoder", + Region: "global", + Models: []string{"qwen3.8-flash", "ultimate"}, + FreeModels: []string{"qwen3.8-flash"}, + Quota: &QuotaSnapshot{Exceeded: true, Total: 0}, + }) + + free := RouteQuery{PublicModel: "qwen3.8-flash"} + item, ok := pool.PickRoute(free) + if !ok { + t.Fatal("free model: PickRoute returned no account, want the exhausted one") + } + if item.ID != "acc-exhausted" { + t.Fatalf("free model: picked %q, want acc-exhausted", item.ID) + } + + paid := RouteQuery{PublicModel: "ultimate"} + if _, ok := pool.PickRoute(paid); ok { + t.Fatal("paid model: PickRoute returned the exhausted account, want none") + } +} + +// A model the catalog has not priced is not assumed free. Unknown pricing must +// keep the account filtered out rather than gamble a request on it. +func TestPickRouteKeepsExhaustedAccountFilteredWhenPricingUnknown(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ + ID: "acc-exhausted", + Provider: "qoder", + Region: "global", + Models: []string{"qwen3.8-flash"}, + Quota: &QuotaSnapshot{Exceeded: true, Total: 0}, + }) + + if _, ok := pool.PickRoute(RouteQuery{PublicModel: "qwen3.8-flash"}); ok { + t.Fatal("unknown pricing: PickRoute returned the exhausted account, want none") + } +} + +// The exemption is scoped to the free model. Once the same account is asked for +// a paid model it must be filtered out again, so one free request cannot keep +// it on a paid route. +func TestPickRouteExemptionDoesNotLeakToPaidModels(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ + ID: "acc-exhausted", + Provider: "qoder", + Region: "global", + Models: []string{"free-one", "paid-one"}, + FreeModels: []string{"free-one"}, + Quota: &QuotaSnapshot{Exceeded: true}, + }) + + for i := 0; i < 3; i++ { + if _, ok := pool.PickRoute(RouteQuery{PublicModel: "free-one"}); !ok { + t.Fatalf("free-one attempt %d: no account", i) + } + if _, ok := pool.PickRoute(RouteQuery{PublicModel: "paid-one"}); ok { + t.Fatalf("paid-one attempt %d: exhausted account leaked onto the paid route", i) + } + } +} + +// RemoveModel must drop the id from the free list too, or a model the account +// stopped serving would keep exempting it from quota filtering. +func TestRemoveModelDropsFreeModelEntry(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ + ID: "acc", + Provider: "qoder", + Region: "global", + Models: []string{"free-one", "other"}, + FreeModels: []string{"free-one", "other"}, + }) + pool.RemoveModel("acc", "free-one") + item, ok := pool.ByID("acc") + if !ok { + t.Fatal("account missing from pool") + } + for _, model := range item.FreeModels { + if CanonicalModelID(model) == CanonicalModelID("free-one") { + t.Fatalf("removed model still in FreeModels: %v", item.FreeModels) + } + } + if len(item.FreeModels) != 1 { + t.Fatalf("FreeModels = %v, want only the surviving entry", item.FreeModels) + } +} + +// A runtime Upsert — process registration, refresh, restart — carries no +// pricing, so FreeModels must survive it the same way Models does. Losing the +// free list would drop an exhausted account off its free-model routes until the +// next catalog refresh, and that is a route which works and costs no credits. +func TestUpsertKeepsFreeModelsWhenPricingUnsupplied(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ + ID: "acc", + Provider: "qoder", + Region: "global", + Models: []string{"free-one", "other"}, + FreeModels: []string{"free-one"}, + Quota: &QuotaSnapshot{Exceeded: true, Total: 0}, + }) + + pool.Upsert(Item{ID: "acc", Provider: "qoder", Region: "global"}) + + item, ok := pool.ByID("acc") + if !ok { + t.Fatal("account missing from pool") + } + if len(item.FreeModels) != 1 || CanonicalModelID(item.FreeModels[0]) != CanonicalModelID("free-one") { + t.Fatalf("FreeModels = %v after Upsert, want [free-one]", item.FreeModels) + } + if len(item.Models) != 2 { + t.Fatalf("Models = %v after Upsert, want the two cached entries", item.Models) + } + if _, ok := pool.PickRoute(RouteQuery{PublicModel: "free-one"}); !ok { + t.Fatal("free model: PickRoute returned no account after Upsert, want the exhausted one") + } + if _, ok := pool.PickRoute(RouteQuery{PublicModel: "other"}); ok { + t.Fatal("paid model: PickRoute returned the exhausted account, want none") + } +} + +// An explicit free list still overrides the carried-forward one, so a catalog +// refresh that reprices a model drops the stale entry instead of merging it. +func TestUpsertReplacesFreeModelsWhenSupplied(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ + ID: "acc", + Provider: "qoder", + Region: "global", + Models: []string{"was-free", "now-free"}, + FreeModels: []string{"was-free"}, + }) + + pool.Upsert(Item{ + ID: "acc", + Provider: "qoder", + Region: "global", + Models: []string{"was-free", "now-free"}, + FreeModels: []string{"now-free"}, + }) + + item, ok := pool.ByID("acc") + if !ok { + t.Fatal("account missing from pool") + } + if len(item.FreeModels) != 1 || CanonicalModelID(item.FreeModels[0]) != CanonicalModelID("now-free") { + t.Fatalf("FreeModels = %v, want [now-free]", item.FreeModels) + } +} diff --git a/internal/executor/native_model_test.go b/internal/executor/native_model_test.go new file mode 100644 index 00000000..94f538da --- /dev/null +++ b/internal/executor/native_model_test.go @@ -0,0 +1,91 @@ +package executor + +import "testing" + +// Qoder's catalog publishes a friendly public ID whose gateway key differs +// ("qwen3.8-flash" behind "qfmodel"). Sending the public ID leaves the gateway +// waiting without answering, so the request must carry the recorded key. +func TestNativeModelIDPrefersRecordedMapping(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ID: "acc", Provider: "qoder", Region: "global"}) + pool.MergeModels("acc", []string{"qwen3.8-flash", "qfmodel"}) + pool.MergeNativeModels("acc", map[string]string{"qwen3.8-flash": "qfmodel"}) + + item, ok := pool.ByID("acc") + if !ok { + t.Fatal("account missing from pool") + } + if got := NativeModelID(item, "qwen3.8-flash"); got != "qfmodel" { + t.Fatalf("native = %q, want qfmodel", got) + } + // The gateway key itself has no mapping and keeps its own spelling. + if got := NativeModelID(item, "qfmodel"); got != "qfmodel" { + t.Fatalf("native for mapped key = %q", got) + } +} + +// A catalog that reports no distinct key must leave the public ID untouched: +// mapping is per-account and accounts can disagree about the same model. +func TestNativeModelIDWithoutMappingKeepsPublicID(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ID: "acc", Provider: "qoder", Region: "global"}) + pool.MergeModels("acc", []string{"qwen3.8-flash"}) + + item, _ := pool.ByID("acc") + if got := NativeModelID(item, "qwen3.8-flash"); got != "qwen3.8-flash" { + t.Fatalf("native = %q, want the public id", got) + } +} + +// Self-mappings and unknown models are dropped at merge time so the lookup +// path stays a plain map read. +func TestMergeNativeModelsSkipsSelfMappings(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ID: "acc", Provider: "qoder", Region: "global"}) + pool.MergeNativeModels("acc", map[string]string{ + "hy3": "hy3", + "": "orphan", + "auto": "auto", + "a": "", + "b": "native-b", + }) + item, _ := pool.ByID("acc") + if len(item.NativeModels) != 1 || item.NativeModels["b"] != "native-b" { + t.Fatalf("mapping = %v", item.NativeModels) + } +} + +// Dropping a model from the catalog must drop its mapping too, or a stale +// gateway key would keep routing to a model the account no longer serves. +func TestRemoveModelDropsNativeMapping(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ID: "acc", Provider: "qoder", Region: "global"}) + pool.MergeModelsWithPricing("acc", []string{"qwen3.8-flash"}, []string{"qwen3.8-flash"}) + pool.MergeNativeModels("acc", map[string]string{"qwen3.8-flash": "qfmodel"}) + + pool.RemoveModel("acc", "qwen3.8-flash") + item, _ := pool.ByID("acc") + if _, ok := item.NativeModels["qwen3.8-flash"]; ok { + t.Fatalf("mapping survived removal: %v", item.NativeModels) + } + if len(item.FreeModels) != 0 { + t.Fatalf("free list = %v", item.FreeModels) + } +} + +// The mapping is catalog data, so a runtime Upsert that carries no catalog must +// not drop it. Losing the mapping sends the public model ID upstream, which is +// exactly the failure the mapping exists to prevent. +func TestUpsertKeepsNativeModelsWhenCatalogUnsupplied(t *testing.T) { + pool := NewPool(nil, nil) + pool.Upsert(Item{ID: "acc", Provider: "qoder", Region: "global"}) + pool.MergeModels("acc", []string{"public-id", "gateway-key"}) + pool.MergeNativeModels("acc", map[string]string{"public-id": "gateway-key"}) + + pool.Upsert(Item{ID: "acc", Provider: "qoder", Region: "global"}) + + item, _ := pool.ByID("acc") + if got := NativeModelID(item, "public-id"); got != "gateway-key" { + t.Fatalf("native after Upsert = %q, want gateway-key", got) + } +} diff --git a/internal/executor/pool.go b/internal/executor/pool.go index e2fa974f..eefbfd6d 100644 --- a/internal/executor/pool.go +++ b/internal/executor/pool.go @@ -38,6 +38,18 @@ type Item struct { // spelling so Trae config_name case is preserved. Models []string ModelsAt time.Time + // NativeModels maps a canonical public model ID onto the provider-native + // key its own catalog uses for that model. Qoder's catalog carries a + // distinct mapped_key ("qfmodel") behind a friendly public ID, and its + // gateway only routes the mapped key; sending the public ID leaves the + // gateway waiting without answering. A public ID absent from this map + // keeps its own spelling. + NativeModels map[string]string + // FreeModels are the public IDs in Models the provider prices at zero. + // A zero-price model spends no credits, so an account whose quota is + // exhausted can still serve it and must not be filtered out on quota + // alone. Nil means the provider reported no pricing. + FreeModels []string // ProvenModels are public IDs this account has actually served. A // later catalog refresh must not drop a model that just succeeded: // WorkBuddy CLI snapshots can omit a live ID and would otherwise @@ -206,11 +218,17 @@ func itemHasModel(item Item, publicModel string) bool { // NativeModelID returns the provider-native catalog spelling for a public // model. Trae config_name is case-sensitive; routing matches on the // canonical form, but the upstream request must keep the original ID. +// Qoder's catalog reports a mapped_key that differs from the public ID, and +// its gateway routes only the mapped key, so a recorded mapping wins over the +// catalog's own spelling. func NativeModelID(item Item, publicModel string) string { want := routeModel(publicModel) if want == "" { return strings.TrimSpace(publicModel) } + if native := strings.TrimSpace(item.NativeModels[want]); native != "" { + return native + } for _, model := range item.Models { if CanonicalModelID(model) == want { return model @@ -238,8 +256,26 @@ func itemReady(item Item) bool { return item.Ready == nil || *item.Ready } +// itemServesFreeModel reports whether publicModel is one this account prices at +// zero. A zero-price model spends no credits, so an exhausted account can still +// serve it; filtering it out on quota alone would drop the account from routes +// that would in fact work. Models the catalog has not priced are not assumed +// free — the price is unknown, and the account stays filtered out. +func itemServesFreeModel(item Item, publicModel string) bool { + want := routeModel(publicModel) + if want == "" || len(item.FreeModels) == 0 { + return false + } + for _, model := range item.FreeModels { + if CanonicalModelID(model) == want { + return true + } + } + return false +} + func routeBaseMatches(item Item, q RouteQuery) bool { - if item.Quota != nil && item.Quota.Exceeded { + if item.Quota != nil && item.Quota.Exceeded && !itemServesFreeModel(item, q.PublicModel) { return false } if !itemReady(item) { @@ -1041,6 +1077,48 @@ func (p *Pool) SetRuntimeState(id, state string, nextRestartAt time.Time, backof } func (p *Pool) MergeModels(id string, models []string) { + p.MergeModelsWithPricing(id, models, nil) +} + +// MergeNativeModels records the provider-native key for each public model. +// Only accounts whose catalog actually reports a distinct mapped key need it; +// a nil map means "every model keeps its public spelling". +func (p *Pool) MergeNativeModels(id string, nativeModels map[string]string) { + if p == nil || id == "" { + return + } + copied := make(map[string]string, len(nativeModels)) + for public, native := range nativeModels { + key := CanonicalModelID(public) + value := strings.TrimSpace(native) + if key == "" || key == "auto" || value == "" || key == CanonicalModelID(value) { + continue + } + copied[key] = value + } + if len(copied) == 0 { + return + } + p.mu.Lock() + defer p.mu.Unlock() + for i := range p.items { + if p.items[i].ID == id { + if p.items[i].NativeModels == nil { + p.items[i].NativeModels = make(map[string]string, len(copied)) + } + for key, value := range copied { + p.items[i].NativeModels[key] = value + } + return + } + } +} + +// MergeModelsWithPricing replaces an account's cached catalog and records which +// of those models the provider prices at zero. A nil free list means the +// provider reported no pricing, which is treated as "nothing known is free" +// rather than "everything is free". +func (p *Pool) MergeModelsWithPricing(id string, models, freeModels []string) { if p == nil || id == "" { return } @@ -1058,11 +1136,26 @@ func (p *Pool) MergeModels(id string, models []string) { seen[canonical] = struct{}{} copied = append(copied, native) } + free := make([]string, 0, len(freeModels)) + freeSeen := map[string]struct{}{} + for _, model := range freeModels { + native := strings.TrimSpace(model) + canonical := CanonicalModelID(native) + if canonical == "" || canonical == "auto" || native == "" { + continue + } + if _, ok := freeSeen[canonical]; ok { + continue + } + freeSeen[canonical] = struct{}{} + free = append(free, native) + } p.mu.Lock() defer p.mu.Unlock() for i := range p.items { if p.items[i].ID == id { p.items[i].Models = copied + p.items[i].FreeModels = free p.items[i].ModelsAt = time.Now() return } @@ -1091,6 +1184,18 @@ func (p *Pool) RemoveModel(id, model string) { } p.items[i].Models = next } + if p.items[i].FreeModels != nil { + nextFree := make([]string, 0, len(p.items[i].FreeModels)) + for _, existing := range p.items[i].FreeModels { + if CanonicalModelID(existing) != want { + nextFree = append(nextFree, existing) + } + } + p.items[i].FreeModels = nextFree + } + if p.items[i].NativeModels != nil { + delete(p.items[i].NativeModels, want) + } dropProvenModel(&p.items[i], want) // Force the manager to refresh this account's catalog on the next // request instead of trusting the now-mutated snapshot for the TTL. @@ -1287,6 +1392,21 @@ func (p *Pool) Upsert(item Item) { item.Models = p.items[i].Models item.ModelsAt = p.items[i].ModelsAt } + // FreeModels is pricing metadata read from the catalog, not request + // state. A runtime Upsert — process registration, refresh, restart — + // carries no pricing, so without this carry-forward the free list is + // wiped and the account drops off free-model routes until the next + // catalog refresh repopulates it. A refresh that does carry a free + // list still overrides the carried-forward one. + if item.FreeModels == nil { + item.FreeModels = p.items[i].FreeModels + } + // Same reasoning for the gateway-key mapping: a runtime Upsert does + // not re-read the catalog, so losing it would send the public model + // ID upstream again. + if item.NativeModels == nil { + item.NativeModels = p.items[i].NativeModels + } if item.ProvenModels == nil { item.ProvenModels = p.items[i].ProvenModels } diff --git a/internal/providers/qoder/adapter.go b/internal/providers/qoder/adapter.go index 1533121f..0362f6d3 100644 --- a/internal/providers/qoder/adapter.go +++ b/internal/providers/qoder/adapter.go @@ -352,6 +352,41 @@ func CatalogIDsFromInfos(models []providers.ModelInfo) []string { return ids } +// NativeCatalogIDsFromInfos maps each public model ID onto the key Qoder's +// gateway actually routes, taken from the catalog's mapped_key. Models whose +// native key is the public ID itself are left out: they need no mapping, and a +// self-mapping entry would be redundant on every request. +func NativeCatalogIDsFromInfos(models []providers.ModelInfo) map[string]string { + out := make(map[string]string) + for _, model := range models { + public := strings.TrimSpace(model.PublicModel) + native := strings.TrimSpace(model.NativeModel) + if public == "" || native == "" { + continue + } + if accounts.CanonicalModelID(public) == accounts.CanonicalModelID(native) { + continue + } + out[accounts.CanonicalModelID(public)] = native + } + return out +} + +// FreeCatalogIDsFromInfos returns the same id spellings CatalogIDsFromInfos +// produces, restricted to the models Qoder prices at zero. A free model spends +// no credits, so the routing pool must know about it to keep serving it from an +// account whose paid quota is exhausted. +func FreeCatalogIDsFromInfos(models []providers.ModelInfo) []string { + var ids []string + for _, model := range models { + if !model.Free { + continue + } + ids = append(ids, model.PublicModel, model.NativeModel, model.DisplayName) + } + return ids +} + func (c *Client) Probe(ctx context.Context, accountID string) (providers.AccountHealth, error) { workerURL, err := c.lookup(accountID) if err != nil { diff --git a/internal/providers/qoder/native_model_test.go b/internal/providers/qoder/native_model_test.go new file mode 100644 index 00000000..52d2383d --- /dev/null +++ b/internal/providers/qoder/native_model_test.go @@ -0,0 +1,37 @@ +package qoder + +import ( + "testing" + + "github.com/caigee-cmd/cli2api/internal/providers" +) + +// Qoder's catalog gives a friendly public ID and a distinct gateway key. The +// chat request must carry the gateway key, or the gateway waits without +// answering. +func TestNativeCatalogIDsFromInfosKeepsMappedKey(t *testing.T) { + got := NativeCatalogIDsFromInfos([]providers.ModelInfo{ + {PublicModel: "qwen3.8-flash", NativeModel: "qfmodel", DisplayName: "Qwen3.8-Flash"}, + {PublicModel: "hy3", NativeModel: "hy3"}, + {PublicModel: "", NativeModel: "orphan"}, + {PublicModel: "no-native"}, + }) + if len(got) != 1 { + t.Fatalf("mapping = %v", got) + } + if got["qwen3.8-flash"] != "qfmodel" { + t.Fatalf("qwen3.8-flash -> %q", got["qwen3.8-flash"]) + } + if _, ok := got["hy3"]; ok { + t.Fatalf("self-mapping kept: %v", got) + } +} + +func TestNativeCatalogIDsFromInfosFoldsCanonicalForm(t *testing.T) { + got := NativeCatalogIDsFromInfos([]providers.ModelInfo{ + {PublicModel: "Qwen3.8-Flash", NativeModel: "qfmodel"}, + }) + if got["qwen3.8-flash"] != "qfmodel" { + t.Fatalf("mapping = %v", got) + } +} diff --git a/internal/runtime/manager_catalog.go b/internal/runtime/manager_catalog.go index e2f3b819..1c89b346 100644 --- a/internal/runtime/manager_catalog.go +++ b/internal/runtime/manager_catalog.go @@ -84,7 +84,8 @@ func (m *Manager) fetchAccountModels(ctx context.Context, item Item) { } return } - m.pool.MergeModels(item.ID, qoder.CatalogIDsFromInfos(models)) + m.pool.MergeModelsWithPricing(item.ID, qoder.CatalogIDsFromInfos(models), qoder.FreeCatalogIDsFromInfos(models)) + m.pool.MergeNativeModels(item.ID, qoder.NativeCatalogIDsFromInfos(models)) return } client := qoder.WorkerClient{