Skip to content

feat(routing): keep quota-exhausted accounts on free-model routes - #298

Merged
caigee-cmd merged 3 commits into
caigee-cmd:mainfrom
metal-huang:pr/free-model-quota-exemption
Oct 15, 2026
Merged

caigee-cmd merged 3 commits into
caigee-cmd:mainfrom
metal-huang:pr/free-model-quota-exemption

Conversation

@metal-huang

@metal-huang metal-huang commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

中文

改动概要

  • Qoder 目录中部分模型报价为零(limited_time_free 标签或 price_factor 为零),请求这些模型不消耗积分。但 routeBaseMatches 无条件排除所有额度耗尽的账号,于是额度为 0 的账号连自己的免费模型路由也一起丢掉——表现是账号停在 quota_exhausted,而它本来还能服务的免费模型没有任何账号可用。
  • 路由池现在在缓存模型列表的同时记录上游报价为零的模型(FreeCatalogIDsFromInfos → Item.FreeModels),额度过滤对命中该名单的请求予以豁免。上游未报价的模型不视为免费;豁免按请求模型限定,同一账号请求付费模型时照旧被过滤;RemoveModel 同步清理免费名单。
  • (第二个 commit) Qoder 目录给出一个易读的公网 ID 和一个不同的网关键。Worker 发送前会用账号目录把公网 ID 解析一遍,但进程内 COSY 客户端此前直接把调用方传入的名字填进 model_config.key。在只按网关键路由的账号上,这个请求不会得到任何响应——网关把它挂着,客户端一直等到自己的超时,既没有错误也没有数据。路由池现在记录目录给出的「公网 ID → 网关键」映射(Item.NativeModels),聊天请求携带映射后的键;目录未给出不同键的账号保持公网 ID 原样。
  • 这两个 commit 是同一条链路的前后两步:前者让请求轮得到额度耗尽的账号,后者让这个账号发出去的请求能被网关认。只合其中一个,免费模型在这类账号上仍然不通。
  • 与 fix(qoder): pair tool calls whose assistant turn has null content #297 无共享文件,合并顺序无关。

测试计划

  • go test ./internal/executor/ -run "ExhaustedAccount|ExemptionDoesNotLeak|RemoveModelDropsFree" — 新增 4 个用例:免费模型仍被路由、付费模型仍被过滤、价格未知不假定为免费、RemoveModel 清理免费名单
  • go test ./internal/executor/ ./internal/providers/qoder/ ./internal/runtime/ — 全部通过,含新增的 5 个映射用例
  • 对照实验:两个账号 × 两种模型名共四格,只有「额度耗尽账号 + 公网 ID」一格失败,映射键在两个账号上都通;修复后该格恢复正常,同一账号的付费模型与不存在的模型名仍被挡
  • go vet 对涉及包无告警
  • Added changelog/unreleased/free-model-quota-exemption.md、changelog/unreleased/qoder-native-model-mapping.md
  • No tokens, auth blobs, raw captures, or host details in the diff

English

Summary

  • Qoder prices some catalog models at zero (limited_time_free tag or a zero price_factor); requests for them spend no credits. routeBaseMatches filtered every quota-exceeded account unconditionally, so an exhausted account lost even its free-model routes — it sat at quota_exhausted while the free models it can still serve had no account at all.
  • The pool now records the zero-priced catalog entries alongside the cached model list (FreeCatalogIDsFromInfos → Item.FreeModels), and the quota filter exempts a request whose model is on that list. Models the catalog has not priced are not assumed free; the exemption is scoped per model, so exhausted accounts stay out of paid routes; RemoveModel prunes the free list too.
  • (second commit) Qoder's catalog publishes a friendly public ID alongside a distinct gateway key. 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 (Item.NativeModels), and the chat request carries the mapped key; an account whose catalog reports no distinct key keeps its public spelling.
  • The two commits are consecutive steps of one path: the first lets a request reach an exhausted account, the second makes that account's request recognizable to the gateway. With only one of them merged, free models still do not work on such an account.
  • No shared files with fix(qoder): pair tool calls whose assistant turn has null content #297; either order merges cleanly.

Test plan

  • go test ./internal/executor/ -run "ExhaustedAccount|ExemptionDoesNotLeak|RemoveModelDropsFree" — 4 new cases: free model still routed, paid model still filtered, pricing-unknown not assumed free, RemoveModel prunes the free list
  • go test ./internal/executor/ ./internal/providers/qoder/ ./internal/runtime/ — all pass, including 5 new mapping cases
  • Controlled comparison across two accounts × two model spellings: only the "exhausted account + public ID" cell failed, and the mapped key worked on both accounts; after the fix that cell recovers while the same account's paid model and an unknown model stay refused
  • go vet clean on the touched packages
  • Added changelog/unreleased/free-model-quota-exemption.md and changelog/unreleased/qoder-native-model-mapping.md
  • No tokens, auth blobs, raw captures, or host details in the diff

Qoder prices some catalog models at zero — they carry `limited_time_free` or a
zero `price_factor`, and the console already renders them with a free badge.
A request for such a model spends no credits, so an account whose paid quota is
exhausted can still serve it.

`routeBaseMatches` filtered every quota-exceeded account out unconditionally, so
those accounts lost every route, including the free ones. That is why a Qoder
account with `total: 0` sat at `quota_exhausted` while the free models it can
still serve — qwen3.8-flash among them, which records 0 credits across every
logged request — had no account at all.

Carry the free-model list alongside the cached catalog: `CatalogIDsFromInfos`
already flattens `ModelInfo` into ids and dropped the `Free` flag on the way, so
add `FreeCatalogIDsFromInfos` and record it on the pool item next to `Models`.
The exemption is scoped to the requested model, so the same account stays
filtered out of paid routes. Models the catalog has not priced are not assumed
free — unknown pricing keeps the account out, rather than gambling a request on
it. `RemoveModel` now prunes the free list too, so a model the account stopped
serving cannot keep exempting it.
Qoder's model catalog publishes a friendly public ID alongside a distinct
gateway key. The worker resolved the public ID through the per-account
catalog before sending a chat, 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 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.
@metal-huang

Copy link
Copy Markdown
Contributor Author

补充一个 commit — fix(qoder): send the catalog's gateway model key on chat。

中文

修的是什么

Qoder 的模型目录给出一个易读的公网 ID 和一个不同的网关键。Worker 发送聊天前会用账号目录把公网 ID 解析一遍;但进程内 COSY 客户端此前直接把调用方传入的名字填进 model_config.key。在只按网关键路由的账号上,这个请求不会得到任何响应——网关把它挂着,客户端一直等到自己的超时,既没有错误也没有数据。

路由池现在记录「公网 ID → 网关键」的映射(Item.NativeModels),聊天请求携带映射后的键。目录未给出不同键的账号保持公网 ID 原样,因此对那些公网 ID 就是原生 ID 的 provider 没有任何行为变化。

怎么定位的

两条链路各加一处临时打印,输出即将发出的 HTTP 请求(URL、全部 header、解码后的 body;密钥类 header 掩码成长度+md5)。同一账号上对比两条链路,33 个 body 字段里只有一个不同——model_config.key。随后做对照实验,只变模型拼写:

公网 ID 网关键
账号 A 200,1.4s 200,2.7s
账号 B 一直挂到超时 200,2.0s

验证

  • 原来失败的那一格(账号 B + 公网 ID):修复后连续三次 1.6s / 2.0s / 2.0s
  • 账号 A + 公网 ID:仍然 200(回归)
  • 账号 B + 付费模型、账号 B + 目录里不存在的模型名:仍然立即被挡
  • go test ./internal/executor/ ./internal/providers/qoder/ ./internal/runtime/ 全通过,含 5 个新增映射用例

关于两个 commit

它们是同一条链路的前后两步,不是两个独立修复:前一个 commit 让请求能轮到额度耗尽的账号,后一个让这个账号发出去的请求能被网关认。只合其中一个,这类账号上的免费模型仍然不通——所以两个 commit 放在这个 PR 里,而不是另开一个。

diff 中不含 token、凭据、抓包内容、账号标识或主机信息。


English

What it fixes

Qoder's catalog publishes a friendly public ID alongside a distinct gateway key. The worker resolves the public ID through the per-account catalog before sending a chat; 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 (Item.NativeModels), and the chat request carries the mapped key. An account whose catalog reports no distinct key keeps its public spelling, so nothing changes for providers whose public ID is already the native ID.

How it was found

Both chains were instrumented to print the exact request about to go out (URL, headers, decoded body; secret headers masked to length+md5). Across the two chains on the same account, one field of 33 differed — model_config.key. A controlled comparison then varied only the model spelling:

public ID mapped key
account A 200, 1.4s 200, 2.7s
account B hangs until timeout 200, 2.0s

Verification

  • The failing cell (account B + public ID): 1.6s / 2.0s / 2.0s across three consecutive requests after the fix
  • account A + public ID: still 200 (regression check)
  • account B + a paid model, and account B + a model not in the catalog: both still refused immediately
  • go test ./internal/executor/ ./internal/providers/qoder/ ./internal/runtime/ all pass, 5 new mapping cases

On the two commits

They are consecutive steps of one path rather than two independent fixes. The first commit lets a request reach a quota-exhausted account; the second makes that account's request recognizable to the gateway. Merging only one of them leaves free models unusable on such an account — which is why both live here rather than in a separate PR.

No tokens, credentials, captured traffic, account identifiers, or host details in the diff.

@caigee-cmd caigee-cmd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审查结论:Approved,有 1 个必修项 + 1 个流程建议

整体质量很高。free-model 豁免的设计我特别认可两点:

  • 豁免按请求模型限定(itemServesFreeModel(item, q.PublicModel)),且 routeMatches 仍要求 itemHasModel 通过,所以耗尽账号不会被放进付费路由。测试 TestPickRouteExemptionDoesNotLeakToPaidModels 也覆盖了这一点。
  • 未报价不假定免费。Free 只在 limited_time_free 或 price_factor<=0 时为真,且 routeModel("auto") == "",所以 auto 请求永远不会被豁免 —— 定价未知时保持过滤,这是正确的 fail-closed 方向。

MergeModelsWithPricing 的 canonicalize + dedup、RemoveModel 对 FreeModels 的同步清理都做对了。

必修:Upsert 没有 carry-forward FreeModels

internal/executor/pool.go

Upsert 在 item.Models == nil 时把 Models / ModelsAt 从前一个条目带过来,但 FreeModels 没有对应处理。运行时的三条 Upsert 调用(manager_process.go 的进程注册/刷新/重启)都不带 pricing,所以每次运行时 Upsert 都会把免费名单清空,账号随即从免费路由上掉下去,直到下一次 catalog 刷新才恢复。

这段时间里该账号的免费模型(不花积分、上游会正常应答的请求)无账号可用 —— 是白丢的可用路由,且依赖刷新时机自愈。

建议在 Models 的 carry-forward 旁边补上(我已本地验证,go test ./... / go vet / gofmt 全过):

			if item.Models == nil {
				item.Models = p.items[i].Models
				item.ModelsAt = p.items[i].ModelsAt
			}
			// FreeModels is pricing metadata, not request state. A runtime
			// Upsert (process registration, refresh) 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.
			if item.FreeModels == nil {
				item.FreeModels = p.items[i].FreeModels
			}
			if item.ProvenModels == nil {
				item.ProvenModels = p.items[i].ProvenModels
			}

配一个回归测试(internal/executor/free_model_quota_test.go),锁住 carry-forward 本身和"显式传入时仍可覆盖"两条:

// A runtime Upsert — process registration or refresh — 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, a route that 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{"qwen3.8-flash", "ultimate"},
		FreeModels: []string{"qwen3.8-flash"},
		Quota:      &QuotaSnapshot{Exceeded: true, Total: 0},
	})

	// The runtime path re-registers the account without catalog or pricing.
	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("qwen3.8-flash") {
		t.Fatalf("FreeModels = %v after Upsert, want [qwen3.8-flash]", 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: "qwen3.8-flash"}); !ok {
		t.Fatal("free model: PickRoute returned no account after Upsert, want the exhausted one")
	}
	if _, ok := pool.PickRoute(RouteQuery{PublicModel: "ultimate"}); 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 to zero clears the stale entry.
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)
	}
}

流程:这个 PR 打包了两个不相关的功能

PR 标题是 free-model 豁免,但 diff 里还包含一整套 Qoder native model 映射:

  • Item.NativeModels / Pool.MergeNativeModels
  • qoder.NativeCatalogIDsFromInfos(读 catalog 的 mapped_key)
  • internal/executor/native_model_test.go、internal/providers/qoder/native_model_test.go
  • changelog fragment qoder-native-model-mapping.md

这是两个独立的修复,建议拆成两个 PR。我看了 native mapping 那部分,它本身是自洽的(self-mapping 会被跳过、canonical form 折叠、MergeNativeModels 用 map 语义逐 key 合并而非替换、RemoveModel 会同步 delete),测试也够,质量没问题 —— 只是不该和 free-model 挤在一个 PR 里,否则回滚和 bisect 时会互相牵动。

如果你们不介意打包,请在 PR 描述里说明它包含两部分,至少让 review 的人知道自己在看什么。

其他

  • 本地验证:go build ./...、go test ./...(全量通过)、go vet ./...、gofmt -l(仅 internal/store/migrations.go,为 main 既有基线)、release-notes.py validate(exit 0)。打上补丁后又跑了 -race 覆盖 executor / runtime / providers/qoder,全过。
  • 这个分支的 CI 是 action_required,需要人工批准 fork 的 workflow run 才会跑。合并前麻烦点一下。

补丁落地后我可以直接合。

… Upsert

Upsert already carries Models forward when the caller supplies none, so a
runtime re-registration — process start, health refresh, restart — does not
drop the cached catalog. FreeModels and NativeModels were added afterwards
and missed that carry-forward, so every runtime Upsert wiped both: the free
list leaves an exhausted account off its free-model routes until the next
catalog refresh, and the mapping sends the public model ID upstream again,
which is the silent hang the mapping exists to prevent.

Carry both forward on the same terms as Models, and cover each with a
regression test: one locks that an Upsert without pricing keeps the carried
list and that an explicit list still replaces it.
@metal-huang

metal-huang commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

已按审查意见补上,第三个 commit daf50d6 已推。

中文

必修项已修:Upsert 现在在 Models 的 carry-forward 旁边一并 carry FreeModels,语义与 Models 相同——调用方没传就沿用前一条目的,传了就用传入的(所以目录刷新改价时旧条目会被覆盖而不是合并)。配了 CR 里建议的那两个回归测试,另外断言里我用了通用模型名(free-one / other)而不是具体模型名。

同一个补丁还带上了 NativeModels 的 carry-forward。它和 FreeModels 是同一类东西——都是目录读来的元数据,都在运行时 Upsert 里被抹掉;而抹掉它的后果比免费名单更严重(就是这次修的那个静默挂起)。只修一个不改另一个,等于把同一个 bug 留在旁边。相应的回归测试也加了一个(TestUpsertKeepsNativeModelsWhenCatalogUnsupplied)。这一处超出了 CR 给的补丁范围,如果你希望严格照原补丁来,我可以把 NativeModels 那部分撤掉单独提。

关于打包两个功能:认可这个意见,PR 描述里已经明确写出包含两部分并说明为什么放在同一个 PR 里(只合其中一个,免费模型在这类账号上仍然不通)。如果你仍然倾向拆成两个 PR,我可以把 native mapping 拆出去重开一个,并在描述里声明依赖 #298。

验证:go test ./internal/executor/ ./internal/providers/qoder/ ./internal/runtime/ 全过,go vet ./internal/executor/ 无告警,gofmt -l 无输出。


English

The must-fix is in: Upsert now carries FreeModels forward next to Models, on the same terms — a caller that supplies none inherits the previous entry's list, and a caller that does supply one overrides it, so a catalog refresh that reprices a model replaces the stale entry rather than merging it. Added the two regression tests from the review, with generic model names (free-one / other) instead of specific ones.

The same patch also carries NativeModels forward. It is the same kind of data as FreeModels — both are metadata read from the catalog, and both were wiped by runtime Upserts. The consequence there is worse than the free list: it sends the public model ID upstream, which is the silent hang the mapping was added to prevent. Fixing one and leaving the other would leave the same bug next to it, so it came along; its own regression test is included. This goes beyond the patch in the review — say the word and I will split the NativeModels half out.

On bundling two features: agreed, and the description now states plainly that the PR contains both and why they belong together (merging only one leaves free models unusable on such an account). If you would still rather have two PRs, I can split the native mapping into a new one that declares the dependency on #298.

Verification: go test ./internal/executor/ ./internal/providers/qoder/ ./internal/runtime/ all pass, go vet ./internal/executor/ clean, gofmt -l empty.

@caigee-cmd caigee-cmd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FreeModels carry-forward is in and correct — verified locally:

go build ./...      OK
go vet ./...        OK
go test ./...       OK — 24 packages, 0 failures
gofmt -l .          only internal/store/migrations.go (pre-existing on main)
release-notes validate  exit 0

Both tests land exactly where they should: TestUpsertKeepsFreeModelsWhenPricingUnsupplied proves the free list survives a runtime Upsert, and TestUpsertReplacesFreeModelsWhenSupplied proves an explicit pricing payload still replaces it rather than merging — so the exemption can't go stale after a catalog re-prices a model. That's the pair I wanted; the second one is the one that makes the first safe.

Splitting the changelog into two fragments was the right call — the free-model exemption and the Qoder native model mapping are independent features that happen to share a PR, and separate fragments mean they can be described and released independently. Both read well.

One last note for the record, not a blocker: this PR still carries two features (routing exemption + Qoder model-key mapping). They're now cleanly separable in review, the tests cover both, and both are user-visible, so I'm not asking you to split a green PR over it. But next time, opening the routing change and the Qoder key-mapping change as separate PRs would let each get its own focused review — the model-key one in particular is the kind of change that wants live-account acceptance against a gateway-routed account, which is awkward to do while it's riding along with a routing change.

Approving and merging.

@caigee-cmd
caigee-cmd merged commit 0505b1e into caigee-cmd:main Oct 15, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants