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/qoder-token-context-window.md
Original file line number Diff line number Diff line change
@@ -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`。
6 changes: 6 additions & 0 deletions internal/control/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
37 changes: 37 additions & 0 deletions internal/control/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
73 changes: 73 additions & 0 deletions internal/providers/qoder/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
5 changes: 5 additions & 0 deletions internal/providers/qoder/direct.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
60 changes: 58 additions & 2 deletions internal/providers/qoder/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"sort"
"time"
)

Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion worker/src/plaintext.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
1 change: 1 addition & 0 deletions worker/test/plaintext.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading