From 947b2ea1bc033154ac1c1c1124b4a3f9dfd75773 Mon Sep 17 00:00:00 2001 From: BrianBoyCN <175931469+BrianBoyCN@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:03:31 +0800 Subject: [PATCH 1/3] feat(qoder): show per-model price and limited-time-free badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qoder's model catalog carries a per-model `price_factor` (the multiplier its own client renders as `0.50x Credit`) plus an `is_free` flag and a `tags` array, but the worker's snapshot dropped them, so the console showed no price for any Qoder account (Global or CN). - worker/src/catalog.mjs: forward `price_factor`, `is_free`, and `tags`, keeping them absent when upstream omits them so a missing price is never read as 0. - qoder adapter: map them onto the console's `credits`/`free` on both catalog paths (in-process ModelInfos and the legacy worker DisplayCatalog that Qoder actually serves). The free signal follows the Qoder client's own label rule: the `limited_time_free` tag (or a zero factor) marks a model free, and a positive factor is priced. This deliberately does NOT treat a bare `is_free` as free: Qwen3.8-Max reports `is_free: true` alongside `price_factor: 0.5`, and the Qoder client still labels it `0.50x Credit`, so the console now shows a multiplier instead of the contradictory "免费 / x0.5" pair. Verified against a live Qoder CN account: all 14 models report a factor (0/0.1/0.2/0.5/0.6/0.8/1.4); qwen3.8-flash (factor 0) is free, qwen3.8-max (factor 0.5) is priced. --- changelog/unreleased/qoder-model-credits.md | 7 ++ internal/providers/qoder/adapter.go | 82 +++++++++++++++- internal/providers/qoder/adapter_test.go | 103 ++++++++++++++++++++ internal/providers/qoder/display.go | 26 +++++ worker/src/catalog.mjs | 16 +++ worker/test/catalog.test.mjs | 27 +++++ 6 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 changelog/unreleased/qoder-model-credits.md diff --git a/changelog/unreleased/qoder-model-credits.md b/changelog/unreleased/qoder-model-credits.md new file mode 100644 index 0000000..ef38eae --- /dev/null +++ b/changelog/unreleased/qoder-model-credits.md @@ -0,0 +1,7 @@ +### English + +- Show Qoder model pricing on the account's model list. Qoder reports a per-model `price_factor` (the multiplier its own client renders as `0.50x Credit`) plus `is_free`/`tags`, but the worker dropped them, so the console showed no price for any Qoder account. The worker now forwards them and the adapter renders the multiplier as the console's credits text. The free badge follows the Qoder client's own rule — the `limited_time_free` tag (or a zero factor) is free, a positive factor is priced — so a model that reports `is_free` alongside a real multiplier (Qwen3.8-Max: `is_free` + `0.5`) shows its multiplier instead of the contradictory "免费 / x0.5" pair. Applies to Qoder Global and Qoder CN. + +### 中文 + +- 账号的模型列表现在会显示 Qoder 模型价格。Qoder 每个模型都带 `price_factor`(其客户端显示为 `0.50x Credit` 的那个倍率)以及 `is_free`/`tags`,但 worker 之前把它们丢掉了,所以控制台对所有 Qoder 账号都不显示价格。现在 worker 透传这些字段,适配层把倍率渲染为控制台的额度文案。免费标记对齐 Qoder 客户端自身的规则——带 `limited_time_free` 标签(或倍率为 0)才算免费,正倍率即视为计费——因此像 Qwen3.8-Max 这种「`is_free` 为真但同时带 0.5 倍率」的模型会显示倍率,而不再出现自相矛盾的「免费 / x0.5」。Qoder 国际版与国内版均适用。 diff --git a/internal/providers/qoder/adapter.go b/internal/providers/qoder/adapter.go index 6bf8580..c81caf1 100644 --- a/internal/providers/qoder/adapter.go +++ b/internal/providers/qoder/adapter.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "sync" "time" @@ -163,8 +164,8 @@ func ModelInfos(entries []map[string]any) []providers.ModelInfo { PublicModel: publicID, NativeModel: native, DisplayName: stringField(entry, "display_name"), - Credits: stringField(entry, "credits"), - Free: boolField(entry, "free"), + Credits: qoderEntryCredits(entry), + Free: qoderEntryFree(entry), Capabilities: providers.ModelCapabilities{ ContextWindow: numberField(entry, "context_length"), Reasoning: boolField(entry, "is_reasoning"), @@ -180,6 +181,83 @@ func ModelInfos(entries []map[string]any) []providers.ModelInfo { return out } +// qoderEntryCredits renders a catalog entry's price as the console credits text, +// mirroring the Qoder client label: a model tagged `limited_time_free` reads as +// "0" (the console then shows its free badge); otherwise the numeric +// `price_factor` renders as `x`. An explicit upstream `credits` string +// wins. Returns "" when Qoder reported neither. +func qoderEntryCredits(entry map[string]any) string { + if explicit := stringField(entry, "credits"); explicit != "" { + return explicit + } + if qoderEntryLimitedTimeFree(entry) { + return "0" + } + factor, ok := floatField(entry, "price_factor") + if !ok { + return "" + } + if factor <= 0 { + return "0" + } + return "x" + strconv.FormatFloat(factor, 'f', -1, 64) +} + +// qoderEntryLimitedTimeFree is the Qoder client's own free signal: the +// `limited_time_free` tag. +func qoderEntryLimitedTimeFree(entry map[string]any) bool { + for _, tag := range stringSliceField(entry, "tags") { + if strings.EqualFold(strings.TrimSpace(tag), "limited_time_free") { + return true + } + } + return false +} + +// qoderEntryFree reports whether the model should carry the console's free +// badge. It is derived from the same credits it renders, so the badge can never +// contradict a positive multiplier: a `limited_time_free` tag or a zero factor +// is free, and a positive factor is not (Qwen3.8-Max reports is_free=true with +// a 0.5 factor and the Qoder client still shows "0.50x Credit"). +func qoderEntryFree(entry map[string]any) bool { + if qoderEntryLimitedTimeFree(entry) { + return true + } + factor, ok := floatField(entry, "price_factor") + return ok && factor <= 0 +} + +func stringSliceField(entry map[string]any, key string) []string { + if typed, ok := entry[key].([]string); ok { + return typed + } + raw, ok := entry[key].([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + if text, ok := item.(string); ok { + out = append(out, text) + } + } + return out +} + +func floatField(entry map[string]any, key string) (float64, bool) { + switch value := entry[key].(type) { + case float64: + return value, true + case int: + return float64(value), true + case json.Number: + f, err := value.Float64() + return f, err == nil + default: + return 0, false + } +} + func CatalogIDsFromInfos(models []providers.ModelInfo) []string { ids := make([]string, 0, len(models)*3) for _, model := range models { diff --git a/internal/providers/qoder/adapter_test.go b/internal/providers/qoder/adapter_test.go index 78a41eb..98f2d02 100644 --- a/internal/providers/qoder/adapter_test.go +++ b/internal/providers/qoder/adapter_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/caigee-cmd/cli2api/internal/providers" "github.com/caigee-cmd/cli2api/internal/translate" ) @@ -267,3 +268,105 @@ func TestBuildChatPayloadTokenPrecedenceAndOmission(t *testing.T) { } } } + +func TestModelInfosMapsQoderPriceFields(t *testing.T) { + models := ModelInfos([]map[string]any{ + {"id": "kimi-k3", "mapped_key": "kmodel_latest", "display_name": "Kimi-K3", "price_factor": 1.4}, + {"id": "qwen3.7-plus", "mapped_key": "qmodel", "display_name": "Qwen3.7-Plus", "price_factor": 0.1}, + {"id": "qwen3.8-flash", "mapped_key": "qfmodel", "display_name": "Qwen3.8-Flash", "price_factor": 0.0, "is_free": true}, + {"id": "qwen3.8-max", "mapped_key": "qmodel_38max", "display_name": "Qwen3.8-Max", "price_factor": 0.5, "is_free": true}, + {"id": "auto", "mapped_key": "auto", "display_name": "Auto", "price_factor": 0.5}, + // No price data at all: neither credits nor free may be invented. + {"id": "no-price", "mapped_key": "npmodel", "display_name": "NoPrice"}, + }) + + byID := map[string]providers.ModelInfo{} + for _, m := range models { + byID[m.PublicModel] = m + } + + if got := byID["kimi-k3"].Credits; got != "x1.4" { + t.Errorf("kimi-k3 credits = %q, want x1.4", got) + } + if got := byID["qwen3.7-plus"].Credits; got != "x0.1" { + t.Errorf("qwen3.7-plus credits = %q, want x0.1", got) + } + if got := byID["auto"].Credits; got != "x0.5" { + t.Errorf("auto credits = %q, want x0.5", got) + } + + free := byID["qwen3.8-flash"] + if !free.Free || free.Credits != "0" { + t.Errorf("qwen3.8-flash free=%v credits=%q, want free + \"0\"", free.Free, free.Credits) + } + // A dual is_free + positive factor model (Qwen3.8-Max) is NOT free: the + // Qoder client still labels it with its multiplier. + dual := byID["qwen3.8-max"] + if dual.Free || dual.Credits != "x0.5" { + t.Errorf("qwen3.8-max free=%v credits=%q, want not-free + x0.5", dual.Free, dual.Credits) + } + for id, m := range byID { + if id == "qwen3.8-flash" { + continue + } + if m.Free { + t.Errorf("%s must not be flagged free", id) + } + } + if priced := byID["no-price"]; priced.Credits != "" || priced.Free { + t.Errorf("no-price must stay unpriced: credits=%q free=%v", priced.Credits, priced.Free) + } +} + +func TestModelInfosPrefersExplicitCreditsText(t *testing.T) { + models := ModelInfos([]map[string]any{ + {"id": "x", "mapped_key": "xk", "display_name": "X", "credits": "2x credits", "price_factor": 0.5}, + }) + if len(models) != 1 || models[0].Credits != "2x credits" { + t.Fatalf("explicit credits text must win: %+v", models) + } +} + +func TestApplyModelPricingMapsWorkerFields(t *testing.T) { + entry := map[string]any{"id": "kimi-k3", "price_factor": 1.4} + ApplyModelPricing(entry) + if entry["credits"] != "x1.4" { + t.Errorf("credits = %v, want x1.4", entry["credits"]) + } + if _, ok := entry["free"]; ok { + t.Errorf("paid model must not be flagged free: %v", entry["free"]) + } + + freeEntry := map[string]any{"id": "qwen3.8-flash", "price_factor": 0.0, "is_free": true} + ApplyModelPricing(freeEntry) + if freeEntry["credits"] != "0" || freeEntry["free"] != true { + t.Errorf("free model = %+v, want credits 0 + free", freeEntry) + } + + // is_free=true with a positive factor (Qwen3.8-Max) must NOT be free and + // must show the multiplier, matching the Qoder client label. + dual := map[string]any{"id": "qwen3.8-max", "price_factor": 0.5, "is_free": true} + ApplyModelPricing(dual) + if dual["credits"] != "x0.5" { + t.Errorf("dual credits = %v, want x0.5", dual["credits"]) + } + if _, ok := dual["free"]; ok { + t.Errorf("dual model must not be flagged free: %v", dual["free"]) + } + + // limited_time_free tag is the Qoder free signal. + tagged := map[string]any{"id": "tagged", "price_factor": 0.5, "tags": []any{"limited_time_free"}} + ApplyModelPricing(tagged) + if tagged["credits"] != "0" || tagged["free"] != true { + t.Errorf("limited_time_free = %+v, want credits 0 + free", tagged) + } + + unpriced := map[string]any{"id": "unknown"} + ApplyModelPricing(unpriced) + if _, ok := unpriced["credits"]; ok { + t.Errorf("unpriced model must not gain credits: %+v", unpriced) + } + if _, ok := unpriced["free"]; ok { + t.Errorf("unpriced model must not be flagged free: %+v", unpriced) + } +} diff --git a/internal/providers/qoder/display.go b/internal/providers/qoder/display.go index 6ef95ed..3ab34c9 100644 --- a/internal/providers/qoder/display.go +++ b/internal/providers/qoder/display.go @@ -29,5 +29,31 @@ func (s DisplayCatalog) Models(ctx context.Context, id string, refresh bool) ([] } return nil, nil } + for _, entry := range entries { + ApplyModelPricing(entry) + } return entries, nil } + +// ApplyModelPricing writes the display catalog's credits/free keys onto a raw +// Qoder worker model entry. The worker forwards `price_factor` (the multiplier +// the Qoder client renders as e.g. "1.50x") and `is_free`; the console and +// gateway read `credits`/`free`. Qoder reports the price per model, so no +// cross-region reconciliation is needed. Keys already present are left as-is, +// and nothing is written when Qoder reported no price, so an unpriced model is +// never shown as free. +func ApplyModelPricing(entry map[string]any) { + if entry == nil { + return + } + if _, ok := entry["credits"]; !ok { + if credits := qoderEntryCredits(entry); credits != "" { + entry["credits"] = credits + } + } + if _, ok := entry["free"]; !ok { + if qoderEntryFree(entry) { + entry["free"] = true + } + } +} diff --git a/worker/src/catalog.mjs b/worker/src/catalog.mjs index de58187..2f15915 100644 --- a/worker/src/catalog.mjs +++ b/worker/src/catalog.mjs @@ -6,6 +6,13 @@ function cleanString(value) { return typeof value === "string" ? value.trim() : ""; } +// numberOrUndefined keeps a numeric upstream field only when it is actually a +// finite number, so an absent price_factor stays absent instead of becoming 0 +// (which the console would read as "free"). +function numberOrUndefined(value) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + export function createModelCatalogSnapshot(entries) { const routes = new Map(); const models = []; @@ -24,6 +31,12 @@ export function createModelCatalogSnapshot(entries) { max_input_tokens: Number(rawEntry.max_input_tokens) || undefined, is_reasoning: typeof rawEntry.is_reasoning === "boolean" ? rawEntry.is_reasoning : undefined, is_vl: typeof rawEntry.is_vl === "boolean" ? rawEntry.is_vl : undefined, + // Qoder reports each model's price multiplier and free flag; the Go + // adapter renders them (credits text + free badge). Kept as undefined + // when upstream omits them so the console shows nothing rather than 0. + price_factor: numberOrUndefined(rawEntry.price_factor), + is_free: typeof rawEntry.is_free === "boolean" ? rawEntry.is_free : undefined, + tags: Array.isArray(rawEntry.tags) && rawEntry.tags.length > 0 ? rawEntry.tags.map(String) : undefined, }; routes.set(id, route); @@ -39,6 +52,9 @@ export function createModelCatalogSnapshot(entries) { owned_by: "qoder", context_length: route.max_input_tokens, is_reasoning: route.is_reasoning, + price_factor: route.price_factor, + is_free: route.is_free, + tags: route.tags, }); } } diff --git a/worker/test/catalog.test.mjs b/worker/test/catalog.test.mjs index 1206213..cd4fa01 100644 --- a/worker/test/catalog.test.mjs +++ b/worker/test/catalog.test.mjs @@ -22,3 +22,30 @@ test("permits native Qoder keys and excludes disabled catalog entries", () => { assert.equal(resolveCatalogModel(snapshot, "gmodel")?.display_name, "GLM-5.3"); assert.equal(resolveCatalogModel(snapshot, "old"), null); }); + +test("surfaces Qoder price_factor and is_free without inventing values", () => { + const snapshot = createModelCatalogSnapshot([ + { key: "kmodel_latest", display_name: "Kimi-K3", price_factor: 1.4, is_free: false }, + { key: "qfmodel", display_name: "Qwen3.8-Flash", price_factor: 0, is_free: true }, + // No price reported: must stay absent, not become 0 (which reads as free). + { key: "npmodel", display_name: "NoPrice" }, + ]); + + const byId = Object.fromEntries(snapshot.models.map((m) => [m.id, m])); + assert.equal(byId["kimi-k3"].price_factor, 1.4); + assert.equal(byId["kimi-k3"].is_free, false); + assert.equal(byId["qwen3.8-flash"].price_factor, 0); + assert.equal(byId["qwen3.8-flash"].is_free, true); + assert.equal(byId["noprice"].price_factor, undefined); + assert.equal(byId["noprice"].is_free, undefined); +}); + +test("forwards tags so the free badge follows Qoder's limited_time_free signal", () => { + const snapshot = createModelCatalogSnapshot([ + { key: "a", display_name: "Tagged", price_factor: 0.5, is_free: true, tags: ["limited_time_free"] }, + { key: "b", display_name: "Dual", price_factor: 0.5, is_free: true }, + ]); + const byId = Object.fromEntries(snapshot.models.map((m) => [m.id, m])); + assert.deepEqual(byId["tagged"].tags, ["limited_time_free"]); + assert.equal(byId["dual"].tags, undefined); +}); From dec037fb535b9b39552074ebaf71ff3d39170b99 Mon Sep 17 00:00:00 2001 From: BrianBoyCN <175931469+BrianBoyCN@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:56:33 +0800 Subject: [PATCH 2/3] feat(qoder): use upstream per-model context windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qoder reports a `default_context_window` and a set of `available_context_windows` per model, but the worker only kept `max_input_tokens`, so the console defaulted every Qoder model to a hardcoded 180000 — under-sizing glm-5.3-flash (1M) and over-sizing deepseek-v4-pro (96K). - worker/src/catalog.mjs: forward `default_context_window`, `available_context_windows`, and `max_output_tokens`, absent when upstream omits them. - qoder adapter: map them to `catalog_context_length` (upstream default) and `catalog_context_length_max` (largest selectable window) on both catalog paths, and onto ModelCapabilities.ContextWindow / ContextWindowMax / MaxOutput for the in-process path. - control: prefer the catalog's window over the static fallback in both DecorateModelsWithContext and the per-model setting response, via a new Settings.DefaultContextLength backed by Catalog.ModelContextLength bound during app assembly. DefaultContextForModel stays as the last-resort default when Qoder reports no window. Verified against a live Qoder CN account: qwen3.8-max defaults to 200000 with a 1M max tier, glm-5.3-flash to 1M, deepseek-v4-pro to 96000. --- .../unreleased/qoder-model-context-window.md | 7 ++ internal/app/app.go | 3 + internal/control/catalog.go | 25 ++++++ internal/control/catalog_policy.go | 8 ++ internal/control/settings.go | 25 +++++- internal/providers/qoder/adapter.go | 78 +++++++++++++++---- internal/providers/qoder/adapter_test.go | 61 +++++++++++++++ internal/providers/qoder/display.go | 62 +++++++++++++++ worker/src/catalog.mjs | 20 +++++ worker/test/catalog.test.mjs | 15 ++++ 10 files changed, 288 insertions(+), 16 deletions(-) create mode 100644 changelog/unreleased/qoder-model-context-window.md diff --git a/changelog/unreleased/qoder-model-context-window.md b/changelog/unreleased/qoder-model-context-window.md new file mode 100644 index 0000000..c71f593 --- /dev/null +++ b/changelog/unreleased/qoder-model-context-window.md @@ -0,0 +1,7 @@ +### English + +- Use Qoder's real per-model context windows instead of a hardcoded 180000. Qoder reports a `default_context_window` and a set of `available_context_windows` per model, but only `max_input_tokens` was kept, so every Qoder model showed the same 180000 window — under-sizing glm-5.3-flash (1M) and over-sizing deepseek-v4-pro (96K). The worker now forwards the window metadata and the console defaults each model to the window Qoder actually reports, with the largest selectable window exposed as the model's max tier. Falls back to the previous default only when Qoder reports no window. + +### 中文 + +- Qoder 模型改用上游真实上下文窗口,不再一律硬编码 180000。Qoder 每个模型都会上报 `default_context_window` 与一组 `available_context_windows`,但此前只保留了 `max_input_tokens`,导致所有 Qoder 模型都显示同一个 180000 窗口——把 glm-5.3-flash(1M)压小、把 deepseek-v4-pro(96K)放大。现在 worker 透传窗口元数据,控制台按 Qoder 实际上报的窗口作为每个模型的默认值,并把可选窗口里的最大值作为该模型的 Max 档。仅当上游未上报窗口时才回退到旧的默认值。 diff --git a/internal/app/app.go b/internal/app/app.go index 000e47e..3d63302 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -142,6 +142,9 @@ func New(cfg config.Config) *App { } a.CrossProviderModelPool.Store(crossProviderModelPool) a.Control.Catalog = appsvc.NewCatalog(a.FetchWorkerModelsForMode) + if a.Control.Settings != nil { + a.Control.Settings.BindCatalog(a.Control.Catalog) + } // Auth copies and all executor copies read the same atomic live key. // Cfg.ProxyAPIKey and Executor.WorkerKey remain bootstrap snapshots. a.Executor.WorkerKeySource = a.Auth.ConsoleKey diff --git a/internal/control/catalog.go b/internal/control/catalog.go index 8de567d..df1aa87 100644 --- a/internal/control/catalog.go +++ b/internal/control/catalog.go @@ -89,6 +89,31 @@ func (c *Catalog) CachedCount(accountID string, mode CatalogMode) int { return len(entry.Models) } +// ModelContextLength resolves a model's default context window from the cached +// (or freshly fetched) merged catalog. It returns ok=false when the model or its +// window is unknown, so callers fall back to the static default. +func (c *Catalog) ModelContextLength(modelID string) (int, bool) { + if c == nil { + return 0, false + } + models, err := c.Get(false, "", CatalogModeMerge) + if err != nil { + return 0, false + } + key := ModelContextKey(modelID) + for _, model := range models { + id, _ := model["id"].(string) + if ModelContextKey(id) != key { + continue + } + if window, ok := catalogInt(model["catalog_context_length"]); ok && window > 0 { + return window, true + } + return 0, false + } + return 0, false +} + func (c *Catalog) snapshot(accountID string, mode CatalogMode) []map[string]any { if c == nil { return nil diff --git a/internal/control/catalog_policy.go b/internal/control/catalog_policy.go index 4b549dd..90af328 100644 --- a/internal/control/catalog_policy.go +++ b/internal/control/catalog_policy.go @@ -316,7 +316,15 @@ func DecorateModelsWithContext(ctx context.Context, settings *Settings, models [ case "trae", "workbuddy": decorateProviderSettings(ctx, settings, item, provider, settingsKey) default: + // Prefer the window Qoder actually reports for this model over the + // hardcoded fallback, so the console never mis-sizes a model (for + // example glm-5.3-flash at 1M or deepseek-v4-pro at 96K). defaultValue := DefaultContextForModel(settingsKey) + if catalogWindow, ok := catalogInt(item["catalog_context_length"]); ok && catalogWindow > 0 { + defaultValue = catalogWindow + } else if settings != nil { + defaultValue = settings.DefaultContextLength(settingsKey) + } value, custom := configured[settingsKey] if !custom { value = defaultValue diff --git a/internal/control/settings.go b/internal/control/settings.go index c32590c..6ee0899 100644 --- a/internal/control/settings.go +++ b/internal/control/settings.go @@ -9,6 +9,9 @@ import ( type Settings struct { store accounts.AccountStore + // catalog is optional. When bound, per-model context defaults come from the + // upstream catalog instead of the hardcoded fallback. + catalog *Catalog } func NewSettings(store accounts.AccountStore) *Settings { @@ -18,6 +21,26 @@ func NewSettings(store accounts.AccountStore) *Settings { return &Settings{store: store} } +// BindCatalog lets settings resolve per-model context defaults from the display +// catalog. It is called during process assembly, before the server serves. +func (s *Settings) BindCatalog(catalog *Catalog) { + if s == nil { + return + } + s.catalog = catalog +} + +// DefaultContextLength is the model's default context window: the upstream +// value the catalog reported when available, otherwise the static fallback. +func (s *Settings) DefaultContextLength(modelID string) int { + if s != nil && s.catalog != nil { + if window, ok := s.catalog.ModelContextLength(modelID); ok { + return window + } + } + return DefaultContextForModel(modelID) +} + func (s *Settings) GetSecret(ctx context.Context, name string) (string, bool, error) { return s.store.GetSecret(ctx, name) } @@ -94,7 +117,7 @@ func (s *Settings) ReadModelSetting(ctx context.Context, provider, modelID strin if err != nil { return ModelSetting{}, operationError("model_setting_failed", err.Error()) } - defaultValue := DefaultContextForModel(modelID) + defaultValue := s.DefaultContextLength(modelID) if !custom { value = defaultValue } diff --git a/internal/providers/qoder/adapter.go b/internal/providers/qoder/adapter.go index c81caf1..84a0c1b 100644 --- a/internal/providers/qoder/adapter.go +++ b/internal/providers/qoder/adapter.go @@ -149,6 +149,60 @@ func (c *Client) Models(ctx context.Context, accountID string) ([]providers.Mode return ModelInfos(entries), nil } +func numberField(entry map[string]any, key string) int { + if value, ok := numberFieldValue(entry, key); ok { + return value + } + return 0 +} + +func numberFieldValue(entry map[string]any, key string) (int, bool) { + switch value := entry[key].(type) { + case float64: + return int(value), true + case int: + return value, true + case json.Number: + n, err := value.Int64() + return int(n), err == nil + default: + return 0, false + } +} + +func intSliceField(entry map[string]any, key string) ([]int, bool) { + raw, ok := entry[key].([]any) + if !ok { + if typed, ok := entry[key].([]int); ok { + return typed, len(typed) > 0 + } + return nil, false + } + out := make([]int, 0, len(raw)) + for _, item := range raw { + switch value := item.(type) { + case float64: + out = append(out, int(value)) + case int: + out = append(out, value) + case json.Number: + if n, err := value.Int64(); err == nil { + out = append(out, int(n)) + } + } + } + return out, len(out) > 0 +} + +// contextWindowDefault is the model's default context window: Qoder's +// `default_context_window` when present, else the legacy `context_length`. +func contextWindowDefault(entry map[string]any) int { + if window, ok := qoderDefaultContextWindow(entry); ok { + return window + } + return 0 +} + func ModelInfos(entries []map[string]any) []providers.ModelInfo { out := make([]providers.ModelInfo, 0, len(entries)) for _, entry := range entries { @@ -167,12 +221,20 @@ func ModelInfos(entries []map[string]any) []providers.ModelInfo { Credits: qoderEntryCredits(entry), Free: qoderEntryFree(entry), Capabilities: providers.ModelCapabilities{ - ContextWindow: numberField(entry, "context_length"), + ContextWindow: contextWindowDefault(entry), Reasoning: boolField(entry, "is_reasoning"), Tools: true, Images: true, }, } + if maxWindow, ok := qoderLargestContextWindow(entry); ok { + if maxWindow > info.Capabilities.ContextWindow { + info.Capabilities.ContextWindowMax = maxWindow + } + } + if output, ok := numberFieldValue(entry, "max_output_tokens"); ok && output > 0 { + info.Capabilities.MaxOutput = output + } if info.PublicModel == "" && info.NativeModel == "" { continue } @@ -609,17 +671,3 @@ func boolField(entry map[string]any, key string) bool { value, _ := entry[key].(bool) return value } - -func numberField(entry map[string]any, key string) int { - switch value := entry[key].(type) { - case float64: - return int(value) - case int: - return value - case json.Number: - n, _ := value.Int64() - return int(n) - default: - return 0 - } -} diff --git a/internal/providers/qoder/adapter_test.go b/internal/providers/qoder/adapter_test.go index 98f2d02..96b07f0 100644 --- a/internal/providers/qoder/adapter_test.go +++ b/internal/providers/qoder/adapter_test.go @@ -370,3 +370,64 @@ func TestApplyModelPricingMapsWorkerFields(t *testing.T) { t.Errorf("unpriced model must not be flagged free: %+v", unpriced) } } + +func TestApplyModelContextUsesUpstreamWindows(t *testing.T) { + // qwen3.8-max: default window 200000, selectable up to 1M. + entry := map[string]any{ + "id": "qwen3.8-max", + "context_length": 180000, + "default_context_window": 200000, + "available_context_windows": []any{200000.0, 400000.0, 1000000.0}, + "max_output_tokens": 32000.0, + } + ApplyModelContext(entry) + if entry["catalog_context_length"] != 200000 { + t.Errorf("catalog_context_length = %v, want 200000", entry["catalog_context_length"]) + } + if entry["catalog_context_length_max"] != 1000000 { + t.Errorf("catalog_context_length_max = %v, want 1000000", entry["catalog_context_length_max"]) + } + if got, _ := numberFieldValue(entry, "max_output_tokens"); got != 32000 { + t.Errorf("max_output_tokens = %v, want 32000", entry["max_output_tokens"]) + } + + // glm-5.3-flash: max_input_tokens 1M, no higher selectable window. + flash := map[string]any{"id": "glm-5.3-flash", "context_length": 1000000.0} + ApplyModelContext(flash) + if flash["catalog_context_length"] != 1000000 { + t.Errorf("glm-5.3-flash window = %v, want 1000000", flash["catalog_context_length"]) + } + if _, ok := flash["catalog_context_length_max"]; ok { + t.Errorf("no available_context_windows -> no max tier: %v", flash["catalog_context_length_max"]) + } + + // No context metadata: nothing invented. + bare := map[string]any{"id": "unknown"} + ApplyModelContext(bare) + if _, ok := bare["catalog_context_length"]; ok { + t.Errorf("bare entry must not gain a window: %+v", bare) + } +} + +func TestModelInfosCarriesContextWindows(t *testing.T) { + models := ModelInfos([]map[string]any{ + { + "id": "qwen3.8-max", "mapped_key": "qmodel_38max", "display_name": "Qwen3.8-Max", + "default_context_window": 200000.0, "available_context_windows": []any{200000.0, 1000000.0}, + "max_output_tokens": 32000.0, + }, + }) + if len(models) != 1 { + t.Fatalf("models = %+v", models) + } + caps := models[0].Capabilities + if caps.ContextWindow != 200000 { + t.Errorf("ContextWindow = %d, want 200000", caps.ContextWindow) + } + if caps.ContextWindowMax != 1000000 { + t.Errorf("ContextWindowMax = %d, want 1000000", caps.ContextWindowMax) + } + if caps.MaxOutput != 32000 { + t.Errorf("MaxOutput = %d, want 32000", caps.MaxOutput) + } +} diff --git a/internal/providers/qoder/display.go b/internal/providers/qoder/display.go index 3ab34c9..6bb226f 100644 --- a/internal/providers/qoder/display.go +++ b/internal/providers/qoder/display.go @@ -31,10 +31,72 @@ func (s DisplayCatalog) Models(ctx context.Context, id string, refresh bool) ([] } for _, entry := range entries { ApplyModelPricing(entry) + ApplyModelContext(entry) } return entries, nil } +// ApplyModelContext writes the display catalog's context keys from the context +// metadata Qoder reports per model. The worker forwards `default_context_window` +// (the window the Qoder client selects by default) and `available_context_windows` +// (the selectable set); the console reads `catalog_context_length` / +// `catalog_context_length_max`. Without this the console fell back to a +// hardcoded window (180000) for every model, mis-sizing models such as +// glm-5.3-flash (1M) and deepseek-v4-pro (96K). Keys already present are left +// as-is, and nothing is written when Qoder reported no context metadata. +func ApplyModelContext(entry map[string]any) { + if entry == nil { + return + } + if _, ok := entry["catalog_context_length"]; !ok { + if window, ok := qoderDefaultContextWindow(entry); ok { + entry["catalog_context_length"] = window + } + } + if _, ok := entry["catalog_context_length_max"]; !ok { + if maxWindow, ok := qoderLargestContextWindow(entry); ok { + entry["catalog_context_length_max"] = maxWindow + } + } + if _, ok := entry["max_output_tokens"]; !ok { + if output, ok := numberFieldValue(entry, "max_output_tokens"); ok && output > 0 { + entry["max_output_tokens"] = output + } + } +} + +// qoderDefaultContextWindow is the window the model runs at unless the operator +// picks another: Qoder's `default_context_window`, else the legacy +// `context_length` (the worker's max_input_tokens). +func qoderDefaultContextWindow(entry map[string]any) (int, bool) { + if window, ok := numberFieldValue(entry, "default_context_window"); ok && window > 0 { + return window, true + } + if window, ok := numberFieldValue(entry, "context_length"); ok && window > 0 { + return window, true + } + return 0, false +} + +// qoderLargestContextWindow is the largest selectable window, when Qoder +// advertises more than the default. +func qoderLargestContextWindow(entry map[string]any) (int, bool) { + windows, ok := intSliceField(entry, "available_context_windows") + if !ok { + return 0, false + } + largest := 0 + for _, window := range windows { + if window > largest { + largest = window + } + } + if largest <= 0 { + return 0, false + } + return largest, true +} + // ApplyModelPricing writes the display catalog's credits/free keys onto a raw // Qoder worker model entry. The worker forwards `price_factor` (the multiplier // the Qoder client renders as e.g. "1.50x") and `is_free`; the console and diff --git a/worker/src/catalog.mjs b/worker/src/catalog.mjs index 2f15915..76fa37c 100644 --- a/worker/src/catalog.mjs +++ b/worker/src/catalog.mjs @@ -13,6 +13,17 @@ function numberOrUndefined(value) { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +// numberArrayOrUndefined keeps a positive-integer array, dropping anything else +// so downstream never sees a malformed window list. +function numberArrayOrUndefined(value) { + if (!Array.isArray(value)) return undefined; + const out = []; + for (const item of value) { + if (typeof item === "number" && Number.isInteger(item) && item > 0) out.push(item); + } + return out.length > 0 ? out : undefined; +} + export function createModelCatalogSnapshot(entries) { const routes = new Map(); const models = []; @@ -37,6 +48,12 @@ export function createModelCatalogSnapshot(entries) { price_factor: numberOrUndefined(rawEntry.price_factor), is_free: typeof rawEntry.is_free === "boolean" ? rawEntry.is_free : undefined, tags: Array.isArray(rawEntry.tags) && rawEntry.tags.length > 0 ? rawEntry.tags.map(String) : undefined, + // Qoder's context metadata. The catalog only kept max_input_tokens; these + // carry the real default window and the selectable window set, which the + // console uses instead of a hardcoded fallback. + default_context_window: numberOrUndefined(rawEntry.default_context_window ?? rawEntry.defaultContextWindow), + available_context_windows: numberArrayOrUndefined(rawEntry.available_context_windows ?? rawEntry.availableContextWindows), + max_output_tokens: numberOrUndefined(rawEntry.max_output_tokens), }; routes.set(id, route); @@ -55,6 +72,9 @@ export function createModelCatalogSnapshot(entries) { price_factor: route.price_factor, is_free: route.is_free, tags: route.tags, + default_context_window: route.default_context_window, + available_context_windows: route.available_context_windows, + max_output_tokens: route.max_output_tokens, }); } } diff --git a/worker/test/catalog.test.mjs b/worker/test/catalog.test.mjs index cd4fa01..994547a 100644 --- a/worker/test/catalog.test.mjs +++ b/worker/test/catalog.test.mjs @@ -49,3 +49,18 @@ test("forwards tags so the free badge follows Qoder's limited_time_free signal", assert.deepEqual(byId["tagged"].tags, ["limited_time_free"]); assert.equal(byId["dual"].tags, undefined); }); + +test("forwards Qoder context metadata (default + selectable windows)", () => { + const snapshot = createModelCatalogSnapshot([ + { key: "qmodel_38max", display_name: "Qwen3.8-Max", context_length: 180000, + default_context_window: 200000, available_context_windows: [200000, 400000, 1000000], + max_output_tokens: 32000 }, + { key: "bare", display_name: "Bare" }, + ]); + const byId = Object.fromEntries(snapshot.models.map((m) => [m.id, m])); + assert.equal(byId["qwen3.8-max"].default_context_window, 200000); + assert.deepEqual(byId["qwen3.8-max"].available_context_windows, [200000, 400000, 1000000]); + assert.equal(byId["qwen3.8-max"].max_output_tokens, 32000); + assert.equal(byId["bare"].default_context_window, undefined); + assert.equal(byId["bare"].available_context_windows, undefined); +}); From 77f810a8de3c02e42248c16c81e07c10089516f8 Mon Sep 17 00:00:00 2001 From: BrianBoyCN <175931469+BrianBoyCN@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:34:47 +0800 Subject: [PATCH 3/3] feat(qoder): present context window like Trae's max-context switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qoder has no upstream is_max_mode flag, so the console showed a raw number input defaulting to a hardcoded 180000. Align Qoder with the existing Trae max-context UX instead: a "default → larger" window with a switch. - control: `decorateQoderContext` mirrors `decorateProviderSettings` — `supports_max_mode` when the catalog advertises a larger tier, `max_mode` derived from the stored window. Qoder settings now read/write the numeric window (Settings.MaxContextLength) so the toggle maps onto the value the adapter already forwards; the static default is only a last resort. - frontend: Qoder models use the same window display + CompactSwitch as Trae with a Qoder-specific hint, and drop the number input / save / reset row. Refs #232 --- .../unreleased/qoder-model-context-window.md | 4 +- frontend/src/api/overview.ts | 15 ++- frontend/src/components/ModelDetailsModal.tsx | 2 +- frontend/src/i18n/messages.ts | 2 + frontend/src/pages/ProvidersPage.tsx | 121 ++---------------- internal/console/models.go | 8 ++ internal/control/catalog.go | 22 +++- internal/control/catalog_policy.go | 38 +++++- internal/control/catalog_source_test.go | 25 ++++ internal/control/settings.go | 47 +++++++ internal/control/settings_test.go | 35 +++++ ...t-LDlaPdaZ.js => TrafficChart-DwhHb3Ff.js} | 2 +- .../webui/static/assets/index-AqNpHvC8.js | 28 ---- .../webui/static/assets/index-Bji0eFaz.js | 28 ++++ ...{index-MXEsySTj.css => index-C7Gx8_z7.css} | 2 +- internal/webui/static/index.html | 4 +- 16 files changed, 227 insertions(+), 156 deletions(-) rename internal/webui/static/assets/{TrafficChart-LDlaPdaZ.js => TrafficChart-DwhHb3Ff.js} (99%) delete mode 100644 internal/webui/static/assets/index-AqNpHvC8.js create mode 100644 internal/webui/static/assets/index-Bji0eFaz.js rename internal/webui/static/assets/{index-MXEsySTj.css => index-C7Gx8_z7.css} (92%) diff --git a/changelog/unreleased/qoder-model-context-window.md b/changelog/unreleased/qoder-model-context-window.md index c71f593..e1af6eb 100644 --- a/changelog/unreleased/qoder-model-context-window.md +++ b/changelog/unreleased/qoder-model-context-window.md @@ -1,7 +1,7 @@ ### English -- Use Qoder's real per-model context windows instead of a hardcoded 180000. Qoder reports a `default_context_window` and a set of `available_context_windows` per model, but only `max_input_tokens` was kept, so every Qoder model showed the same 180000 window — under-sizing glm-5.3-flash (1M) and over-sizing deepseek-v4-pro (96K). The worker now forwards the window metadata and the console defaults each model to the window Qoder actually reports, with the largest selectable window exposed as the model's max tier. Falls back to the previous default only when Qoder reports no window. +- Present Qoder's context window like Trae's Max-context switch. Qoder reports a default window and a larger selectable window per model but has no upstream toggle, so the console now shows the same "default → larger + switch" control it uses for Trae instead of a raw number input. Turning it on sends the model's largest window as the request's `context_length`; turning it off clears the override so requests fall back to the model's default. The window values come from Qoder's own catalog (`default_context_window` / `available_context_windows`) rather than a hardcoded 180000, so glm-5.3-flash shows 1M instead of being capped at 180k. Qoder Global and CN. ### 中文 -- Qoder 模型改用上游真实上下文窗口,不再一律硬编码 180000。Qoder 每个模型都会上报 `default_context_window` 与一组 `available_context_windows`,但此前只保留了 `max_input_tokens`,导致所有 Qoder 模型都显示同一个 180000 窗口——把 glm-5.3-flash(1M)压小、把 deepseek-v4-pro(96K)放大。现在 worker 透传窗口元数据,控制台按 Qoder 实际上报的窗口作为每个模型的默认值,并把可选窗口里的最大值作为该模型的 Max 档。仅当上游未上报窗口时才回退到旧的默认值。 +- Qoder 的上下文窗口改为按 Trae 的「更大上下文」开关呈现。Qoder 每个模型都会上报默认窗口和一个可选更大窗口,但上游没有开关字段,因此控制台不再用裸数字输入框,而是复用 Trae 同款的「默认档 → 更大档 + 开关」。开启时把该模型的最大窗口作为请求的 `context_length` 发出;关闭时清除覆盖值,请求回落到模型默认窗口。窗口数值来自 Qoder 自身目录(`default_context_window` / `available_context_windows`),不再是硬编码的 180000,因此 glm-5.3-flash 显示 1M 而不再被压到 180k。国际版与国内版均适用。 diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts index e270e7e..6d011a1 100644 --- a/frontend/src/api/overview.ts +++ b/frontend/src/api/overview.ts @@ -106,15 +106,26 @@ export function updateModelContext(modelKey: string, contextLength: number) { } export function updateTraeMaxMode(modelKey: string, maxMode: boolean) { + return updateProviderMaxMode('trae', modelKey, maxMode) +} + +export function updateProviderMaxMode(provider: string, modelKey: string, maxMode: boolean, contextWindow?: number | null) { return api<{ model: string provider: string max_mode: boolean reasoning_effort?: string context_custom: boolean - }>(`/api/models/trae/${encodeURIComponent(modelKey)}`, { + context_length?: number + default_context_length?: number + }>(`/api/models/${encodeURIComponent(provider)}/${encodeURIComponent(modelKey)}`, { method: 'PATCH', - body: JSON.stringify({ max_mode: maxMode }), + // Qoder has no is_max_mode upstream: its toggle maps onto the numeric + // window, so send the target window explicitly. Trae ignores context_length + // and switches on max_mode alone. + body: JSON.stringify( + provider === 'qoder' && maxMode && contextWindow ? { max_mode: maxMode, context_length: contextWindow } : { max_mode: maxMode }, + ), }) } diff --git a/frontend/src/components/ModelDetailsModal.tsx b/frontend/src/components/ModelDetailsModal.tsx index edf647e..67b7cee 100644 --- a/frontend/src/components/ModelDetailsModal.tsx +++ b/frontend/src/components/ModelDetailsModal.tsx @@ -56,7 +56,7 @@ export function ModelDetailsModal({ model, t, onClose }: Props) {
{t('contextWindowCol')}
{formatTokens(windowDev)}{windowMax && windowMax !== windowDev ? ` → ${formatTokens(windowMax)}` : ''}
- {model.supports_max_mode ?

{t('maxModeHint')}

: null} + {model.supports_max_mode ?

{provider === 'qoder' ? t('qoderMaxModeHint') : t('maxModeHint')}

: null} {!model.supports_max_mode && windowMax && windowMax !== windowDev ?

{t('workbuddyContextHint')}

: null} {traeTier ? (traeTier.prompt_max_tokens ?
{t('promptMaxTokens')}: {formatTokens(traeTier.prompt_max_tokens)}
: null) diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index ce17d76..06e7ae0 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -427,6 +427,7 @@ export const messages: Record = { contextMaxUnavailable: 'This model has no Max context switch.', maxMode: 'Max context', maxModeHint: 'Trae larger context window, often 200k to 1M. Not a plan or quota. Used only when the request omits is_max_mode; an explicit request still wins. Unsupported models never send this switch.', + qoderMaxModeHint: 'Qoder larger context window. Not a plan or quota. When on, requests use the model’s largest window; when off (or when a request sets its own context) the default window applies.', catalogWindow: 'Catalog size', catalogWindowHint: 'This Trae model has no Max-context switch. The number is the catalog window.', workbuddyContextHint: 'WorkBuddy desktop default and optional context budgets from the catalog. This is a local compact budget, not Trae is_max_mode. Chat requests do not send a window switch.', @@ -1071,6 +1072,7 @@ export const messages: Record = { contextMaxUnavailable: '这个模型没有更大上下文开关。', maxMode: '更大上下文', maxModeHint: 'Trae 的更大上下文窗口,常见是 200k 提到 1M,不是套餐也不是额度。只在请求没带 is_max_mode 时使用;请求里写了仍以请求为准。模型不支持时不会发送这个开关。', + qoderMaxModeHint: 'Qoder 的更大上下文窗口,不是套餐也不是额度。开启时请求使用该模型的最大窗口;关闭时(或请求自带上下文窗口时)使用默认窗口。', catalogWindow: '目录默认窗口', catalogWindowHint: '这个 Trae 模型没有更大上下文开关,数字是目录里的默认窗口。', workbuddyContextHint: 'WorkBuddy 桌面端目录里的默认窗口和可选窗口,用于本地压缩预算,不是 Trae 的 is_max_mode。聊天请求不会发送窗口开关。', diff --git a/frontend/src/pages/ProvidersPage.tsx b/frontend/src/pages/ProvidersPage.tsx index 854f9e9..064fb32 100644 --- a/frontend/src/pages/ProvidersPage.tsx +++ b/frontend/src/pages/ProvidersPage.tsx @@ -1,9 +1,9 @@ import { useEffect, useMemo, useState } from 'react' -import { Button, Card, Chip, Input, Table, Tooltip } from '@heroui/react' -import { Cube, ArrowClockwise, ArrowCounterClockwise, FloppyDisk, MagnifyingGlass, Info } from '@phosphor-icons/react' +import { Button, Card, Chip, Table, Tooltip } from '@heroui/react' +import { Cube, ArrowClockwise, MagnifyingGlass, Info } from '@phosphor-icons/react' import { useI18n } from '@/hooks/useI18n' import { useOverview } from '@/hooks/useOverview' -import { fetchModelsCached, fetchProviders, refreshModels, updateModelContext, updateProviderReasoning, updateTraeMaxMode, type ProviderDescriptor } from '@/api/overview' +import { fetchModelsCached, fetchProviders, refreshModels, updateProviderMaxMode, updateProviderReasoning, type ProviderDescriptor } from '@/api/overview' import type { Overview } from '@/api/types' import { ProviderMark } from '@/components/ProviderMark' import { ModelDetailsModal, formatTokens } from '@/components/ModelDetailsModal' @@ -84,40 +84,18 @@ function HintLabel({ label, hint }: { label: string; hint: string }) { function ModelContextControls({ model, - drafts, saving, t, - onDraft, onToggleTraeMax, onReasoningChange, }: { model: ModelInfo - drafts: Record saving: boolean t: Translate - onDraft: (key: string, value: string) => void onToggleTraeMax: (model: ModelInfo, selected: boolean) => void onReasoningChange: (model: ModelInfo, next: string) => void }) { - const key = modelSettingsKey(model) const provider = modelProvider(model) - if (provider === 'qoder') { - return ( -
- onDraft(key, event.target.value)} - aria-label={`${model.id} ${t('contextWindowCol')}`} - /> - -
- ) - } return (
@@ -129,7 +107,7 @@ function ModelContextControls({ {provider === 'workbuddy' && model.catalog_context_length_max && model.catalog_context_length_max !== (model.catalog_context_length || model.context_length) ? ( ) : null} - {provider === 'trae' && model.supports_max_mode ? ( + {(provider === 'trae' || provider === 'qoder') && model.supports_max_mode ? (
) : null} @@ -167,31 +145,13 @@ function ModelContextControls({ function ModelActions({ model, - saving, t, - onSave, - onReset, onDetails, }: { model: ModelInfo - saving: boolean t: Translate - onSave: (model: ModelInfo) => void - onReset: (model: ModelInfo) => void onDetails: (model: ModelInfo) => void }) { - if (modelProvider(model) === 'qoder') { - return ( -
- - -
- ) - } return (