From 13401cb76679618f1bb6e97dd98347d95a1623e6 Mon Sep 17 00:00:00 2001 From: yuefeng Date: Fri, 13 Nov 2026 14:44:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(qoder):=20=E6=81=A2=E5=A4=8D=20token=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E8=B4=A6=E5=8F=B7=E7=9A=84=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E7=AA=97=E5=8F=A3=E5=88=87=E6=8D=A2=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - display.go 新增 qoderContextConfigWindows,解析 PAT 网关 /model/list 中嵌套的 context_config 对象(key 为窗口标签,value 含 token_count 和可选 is_default) - 默认窗口优先取 is_default 标记项,未标记时回退到最小档 - 最大窗口取所有可选项最大值 - 当 catalog 为空时(仍在加载),不覆盖已存的窗口值,避免误删用户选择 - direct.go / plaintext.mjs 在聊天请求中同时发送 parameters.contextWindow 与原有的 context_length,与官方客户端行为对齐 - 新增单元测试覆盖 context_config 解析、toggle 行为保护及参数转发 --- .../unreleased/qoder-token-context-window.md | 7 ++ internal/control/settings.go | 6 ++ internal/control/settings_test.go | 37 ++++++++++ internal/providers/qoder/adapter_test.go | 73 +++++++++++++++++++ internal/providers/qoder/direct.go | 5 ++ internal/providers/qoder/display.go | 60 ++++++++++++++- worker/src/plaintext.mjs | 2 +- worker/test/plaintext.test.mjs | 1 + 8 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 changelog/unreleased/qoder-token-context-window.md diff --git a/changelog/unreleased/qoder-token-context-window.md b/changelog/unreleased/qoder-token-context-window.md new file mode 100644 index 00000000..b1c8e0f0 --- /dev/null +++ b/changelog/unreleased/qoder-token-context-window.md @@ -0,0 +1,7 @@ +### English + +- Restore the Qoder context-window control for PAT / token sign-in. Accounts added with a token read `/model/list` in-process, where the selectable windows live in a nested `context_config` object (`200K` / `400K` / `1M`, one tier marked default). The parser only knew the worker's flattened `default_context_window` / `available_context_windows` keys, so every token account lost the "default → larger" switch and the console could not change its context size. The adapter now reads `context_config` too, so token and browser sessions show the same window and switch. Chat requests also send the official `parameters.contextWindow` field alongside the existing `context_length`. + +### 中文 + +- 修复 token(PAT)登录的 Qoder 账号无法调整上下文窗口。token 账号在进程内直接读取 `/model/list`,可选窗口放在嵌套的 `context_config` 里(`200K` / `400K` / `1M`,其中一档标记为默认),而解析器只认识 worker 展平后的 `default_context_window` / `available_context_windows`,因此每个 token 账号都丢了「默认档 → 更大档」开关,控制台无法修改上下文。适配层现在同时读取 `context_config`,token 登录与浏览器登录显示一致的窗口和开关。聊天请求也会在原有 `context_length` 之外补发官方客户端使用的 `parameters.contextWindow`。 diff --git a/internal/control/settings.go b/internal/control/settings.go index 7b789881..71313e07 100644 --- a/internal/control/settings.go +++ b/internal/control/settings.go @@ -187,6 +187,12 @@ func (s *Settings) UpdateModelSetting(ctx context.Context, provider, modelID str case patch.MaxMode != nil && *patch.MaxMode: length = s.MaxContextLength(modelID) } + // Toggling on while the catalog has no larger tier must not erase a + // stored window: the catalog may still be loading, and writing zero deletes + // the operator's choice instead of applying a Max tier. + if patch.MaxMode != nil && *patch.MaxMode && length == 0 { + return s.ReadModelSetting(ctx, provider, modelID) + } if err := s.SetModelContext(ctx, modelID, length); err != nil { return ModelSetting{}, operationError("model_setting_failed", err.Error()) } diff --git a/internal/control/settings_test.go b/internal/control/settings_test.go index b4e94805..4effede1 100644 --- a/internal/control/settings_test.go +++ b/internal/control/settings_test.go @@ -169,3 +169,40 @@ func TestQoderContextToggle(t *testing.T) { t.Fatalf("cleared=%+v err=%v", got, err) } } + +func TestQoderContextToggleUsesCatalogTierAndKeepsStoredWindow(t *testing.T) { + svc, _, _, _ := newTestServices() + ctx := context.Background() + catalog := NewCatalog(func(bool, string, CatalogMode) ([]map[string]any, error) { + return []map[string]any{{ + "id": "qwen3.8-max", "provider": "qoder", + "catalog_context_length": 200000, "catalog_context_length_max": 1000000, + }}, nil + }) + svc.Settings.BindCatalog(catalog) + + read, err := svc.Settings.ReadModelSetting(ctx, "qoder", "qwen3.8-max") + if err != nil || read.ContextLength != 200000 || read.MaxMode { + t.Fatalf("default=%+v err=%v", read, err) + } + + on := true + got, err := svc.Settings.UpdateModelSetting(ctx, "qoder", "qwen3.8-max", nil, ProviderModelSettingPatch{MaxMode: &on}) + if err != nil || got.ContextLength != 1000000 || !got.MaxMode { + t.Fatalf("toggle on=%+v err=%v", got, err) + } + + // An empty catalog has no larger tier. Toggling on must not delete the + // stored window; it can only mean the catalog is still loading. + svc.Settings.BindCatalog(NewCatalog(func(bool, string, CatalogMode) ([]map[string]any, error) { + return nil, nil + })) + got, err = svc.Settings.UpdateModelSetting(ctx, "qoder", "qwen3.8-max", nil, ProviderModelSettingPatch{MaxMode: &on}) + if err != nil || got.ContextLength != 1000000 { + t.Fatalf("toggle without tier=%+v err=%v", got, err) + } + stored, ok, err := svc.Settings.GetModelContext(ctx, "qwen3.8-max") + if err != nil || !ok || stored != 1000000 { + t.Fatalf("stored=%d ok=%v err=%v, want the window preserved", stored, ok, err) + } +} diff --git a/internal/providers/qoder/adapter_test.go b/internal/providers/qoder/adapter_test.go index 9c00e2fc..fe9f470a 100644 --- a/internal/providers/qoder/adapter_test.go +++ b/internal/providers/qoder/adapter_test.go @@ -494,3 +494,76 @@ func TestModelInfosParsesRawGatewayCatalog(t *testing.T) { t.Fatalf("catalogIDs = %v, want qwen3.8-flash present", catalogIDs) } } + +// gatewayContextConfig mirrors the PAT gateway's /model/list context metadata: +// a label-keyed object with token_count and one marked default. +func gatewayContextConfig() map[string]any { + return map[string]any{ + "200K": map[string]any{"token_count": 200000.0, "is_default": true}, + "400K": map[string]any{"token_count": 400000.0}, + "1M": map[string]any{"token_count": 1000000.0}, + } +} + +func TestApplyModelContextReadsContextConfig(t *testing.T) { + entry := map[string]any{ + "id": "qwen3.8-max", + "max_input_tokens": 180000.0, + "context_config": gatewayContextConfig(), + } + ApplyModelContext(entry) + if entry["catalog_context_length"] != 200000 { + t.Errorf("catalog_context_length = %v, want the marked default 200000", entry["catalog_context_length"]) + } + if entry["catalog_context_length_max"] != 1000000 { + t.Errorf("catalog_context_length_max = %v, want the largest window 1000000", entry["catalog_context_length_max"]) + } + + // No default marker: the official client falls back to the smallest window. + unmarked := map[string]any{ + "id": "kimi-k3.1-pro", + "context_config": map[string]any{ + "1M": map[string]any{"token_count": 1000000.0}, + "200K": map[string]any{"token_count": 200000.0}, + }, + } + ApplyModelContext(unmarked) + if unmarked["catalog_context_length"] != 200000 { + t.Errorf("unmarked default = %v, want 200000", unmarked["catalog_context_length"]) + } +} + +func TestModelInfosCarriesContextConfigWindows(t *testing.T) { + models := ModelInfos([]map[string]any{ + { + "key": "qmodel_38max", "display_name": "Qwen3.8-Max", + "max_input_tokens": 180000.0, "context_config": gatewayContextConfig(), + }, + }) + 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) + } +} + +func TestBuildPlainChatBodySendsContextWindow(t *testing.T) { + body := BuildPlainChatBody(translate.ChatRequest{ + Model: "qmodel_38max", + Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + ContextLength: json.RawMessage("400000"), + }, time.Now()) + params, _ := body["parameters"].(map[string]any) + if got := string(params["contextWindow"].(json.RawMessage)); got != "400000" { + t.Errorf("parameters.contextWindow = %q, want 400000", got) + } + // The worker path still reads context_length, so both must carry the value. + if got := string(params["context_length"].(json.RawMessage)); got != "400000" { + t.Errorf("parameters.context_length = %q, want 400000", got) + } +} diff --git a/internal/providers/qoder/direct.go b/internal/providers/qoder/direct.go index a34b43b0..779f0cec 100644 --- a/internal/providers/qoder/direct.go +++ b/internal/providers/qoder/direct.go @@ -518,6 +518,11 @@ func BuildPlainChatBody(req translate.ChatRequest, now time.Time) map[string]any copyRaw(parameters, "response_format", req.ResponseFormat) copyRaw(parameters, "reasoning_effort", req.ReasoningEffort) copyRaw(parameters, "reasoning_budget_tokens", req.ReasoningBudgetTokens) + // Qoder's official client forwards the selected window as + // `parameters.contextWindow`. `context_length` stays alongside it because + // the worker path already emitted that field; both carry the same value so + // whichever one the gateway reads selects the same window. + copyRaw(parameters, "contextWindow", req.ContextLength) copyRaw(parameters, "context_length", req.ContextLength) copyRaw(parameters, "max_input_tokens", req.MaxInputTokens) copyRaw(parameters, "tool_choice", req.ToolChoice) diff --git a/internal/providers/qoder/display.go b/internal/providers/qoder/display.go index 96e8a1f2..921c5e93 100644 --- a/internal/providers/qoder/display.go +++ b/internal/providers/qoder/display.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "sort" "time" ) @@ -66,9 +67,15 @@ func ApplyModelContext(entry map[string]any) { } // 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). +// picks another. PAT-backed accounts read the gateway's `/model/list` directly, +// where the selectable windows live in a nested `context_config` object +// (`{"200K":{"token_count":200000,"is_default":true},"400K":{...},"1M":{...}}`). +// The worker path (legacy CLI blobs) instead forwards flattened +// `default_context_window` / `context_length`, so both shapes are accepted. func qoderDefaultContextWindow(entry map[string]any) (int, bool) { + if window, _, ok := qoderContextConfigWindows(entry); ok { + return window, true + } if window, ok := numberFieldValue(entry, "default_context_window"); ok && window > 0 { return window, true } @@ -84,6 +91,9 @@ func qoderDefaultContextWindow(entry map[string]any) (int, bool) { // qoderLargestContextWindow is the largest selectable window, when Qoder // advertises more than the default. func qoderLargestContextWindow(entry map[string]any) (int, bool) { + if _, windows, ok := qoderContextConfigWindows(entry); ok { + return windows[len(windows)-1], true + } windows, ok := intSliceField(entry, "available_context_windows") if !ok { return 0, false @@ -100,6 +110,52 @@ func qoderLargestContextWindow(entry map[string]any) (int, bool) { return largest, true } +// qoderContextConfigWindows reads the gateway's nested context metadata for +// PAT-backed accounts: /model/list reports the selectable windows as +// `context_config` ({"200K":{"token_count":200000,"is_default":true},...}). +// Returns the default window and every selectable window sorted ascending. The +// official client marks the default with `is_default` and falls back to the +// smallest selectable window, which is what this mirrors when nothing is +// marked. +func qoderContextConfigWindows(entry map[string]any) (int, []int, bool) { + raw, exists := entry["context_config"] + if !exists { + return 0, nil, false + } + config, ok := raw.(map[string]any) + if !ok || len(config) == 0 { + return 0, nil, false + } + seen := make(map[int]bool, len(config)) + windows := make([]int, 0, len(config)) + defaultWindow := 0 + for _, value := range config { + tier, ok := value.(map[string]any) + if !ok { + continue + } + window, ok := numberFieldValue(tier, "token_count") + if !ok || window <= 0 { + continue + } + if !seen[window] { + seen[window] = true + windows = append(windows, window) + } + if isDefault, _ := tier["is_default"].(bool); isDefault && defaultWindow == 0 { + defaultWindow = window + } + } + if len(windows) == 0 { + return 0, nil, false + } + sort.Ints(windows) + if defaultWindow == 0 { + defaultWindow = windows[0] + } + return defaultWindow, windows, 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/plaintext.mjs b/worker/src/plaintext.mjs index 50e86b45..3fe073a2 100644 --- a/worker/src/plaintext.mjs +++ b/worker/src/plaintext.mjs @@ -599,7 +599,7 @@ export function buildPlainChatBody({ ...(normalizedEffort !== undefined ? { reasoning_effort: normalizedEffort } : {}), ...(effectiveThinking !== undefined ? { enable_thinking: effectiveThinking } : {}), ...(reasoningBudgetTokens !== undefined ? { reasoning_budget_tokens: reasoningBudgetTokens } : {}), - ...(contextLength !== undefined ? { context_length: contextLength } : {}), + ...(contextLength !== undefined ? { contextWindow: contextLength, context_length: contextLength } : {}), ...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}), ...(temperature !== undefined ? { temperature } : {}), ...(topP !== undefined ? { top_p: topP } : {}), diff --git a/worker/test/plaintext.test.mjs b/worker/test/plaintext.test.mjs index e0174646..8c72e4ed 100644 --- a/worker/test/plaintext.test.mjs +++ b/worker/test/plaintext.test.mjs @@ -133,6 +133,7 @@ test("forwards Qoder reasoning and context parameters", () => { assert.equal(body.parameters.enable_thinking, true); assert.equal(body.parameters.reasoning_budget_tokens, 16384); assert.equal(body.parameters.context_length, 500000); + assert.equal(body.parameters.contextWindow, 500000); }); test("does not inject thinking controls into ordinary requests", () => {