Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/free-model-quota-exemption.md
Original file line number Diff line number Diff line change
@@ -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` 为零),请求不消耗积分,但路由池原先无条件排除所有额度耗尽的账号,导致这些账号的免费模型无账号可用。路由池现在在缓存模型列表的同时记录上游报价为零的模型,额度过滤对命中该名单的请求予以豁免。未报价的模型不视为免费;豁免按请求模型限定,额度耗尽的账号依旧不会出现在付费模型的路由上。
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-native-model-mapping.md
Original file line number Diff line number Diff line change
@@ -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 原样。
164 changes: 164 additions & 0 deletions internal/executor/free_model_quota_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
91 changes: 91 additions & 0 deletions internal/executor/native_model_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading