Skip to content
Open
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-model-context-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- 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 的上下文窗口改为按 Trae 的「更大上下文」开关呈现。Qoder 每个模型都会上报默认窗口和一个可选更大窗口,但上游没有开关字段,因此控制台不再用裸数字输入框,而是复用 Trae 同款的「默认档 → 更大档 + 开关」。开启时把该模型的最大窗口作为请求的 `context_length` 发出;关闭时清除覆盖值,请求回落到模型默认窗口。窗口数值来自 Qoder 自身目录(`default_context_window` / `available_context_windows`),不再是硬编码的 180000,因此 glm-5.3-flash 显示 1M 而不再被压到 180k。国际版与国内版均适用。
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-model-credits.md
Original file line number Diff line number Diff line change
@@ -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 国际版与国内版均适用。
15 changes: 13 additions & 2 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
),
})
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ModelDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function ModelDetailsModal({ model, t, onClose }: Props) {
<section>
<div className="text-xs font-medium text-muted">{t('contextWindowCol')}</div>
<div className="mt-1 text-sm">{formatTokens(windowDev)}{windowMax && windowMax !== windowDev ? ` → ${formatTokens(windowMax)}` : ''}</div>
{model.supports_max_mode ? <p className="mt-1 text-[11px] leading-5 text-muted">{t('maxModeHint')}</p> : null}
{model.supports_max_mode ? <p className="mt-1 text-[11px] leading-5 text-muted">{provider === 'qoder' ? t('qoderMaxModeHint') : t('maxModeHint')}</p> : null}
{!model.supports_max_mode && windowMax && windowMax !== windowDev ? <p className="mt-1 text-[11px] leading-5 text-muted">{t('workbuddyContextHint')}</p> : null}
{traeTier
? (traeTier.prompt_max_tokens ? <div className="mt-1 text-[11px] text-muted">{t('promptMaxTokens')}: {formatTokens(traeTier.prompt_max_tokens)}</div> : null)
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ export const messages: Record<Lang, Dict> = {
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.',
Expand Down Expand Up @@ -1071,6 +1072,7 @@ export const messages: Record<Lang, Dict> = {
contextMaxUnavailable: '这个模型没有更大上下文开关。',
maxMode: '更大上下文',
maxModeHint: 'Trae 的更大上下文窗口,常见是 200k 提到 1M,不是套餐也不是额度。只在请求没带 is_max_mode 时使用;请求里写了仍以请求为准。模型不支持时不会发送这个开关。',
qoderMaxModeHint: 'Qoder 的更大上下文窗口,不是套餐也不是额度。开启时请求使用该模型的最大窗口;关闭时(或请求自带上下文窗口时)使用默认窗口。',
catalogWindow: '目录默认窗口',
catalogWindowHint: '这个 Trae 模型没有更大上下文开关,数字是目录里的默认窗口。',
workbuddyContextHint: 'WorkBuddy 桌面端目录里的默认窗口和可选窗口,用于本地压缩预算,不是 Trae 的 is_max_mode。聊天请求不会发送窗口开关。',
Expand Down
121 changes: 10 additions & 111 deletions frontend/src/pages/ProvidersPage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, string>
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 (
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Input
className="w-full max-w-40"
type="number"
min={1024}
max={4000000}
step={1024}
value={drafts[key] ?? String(model.context_length || model.default_context_length || '')}
onChange={(event) => onDraft(key, event.target.value)}
aria-label={`${model.id} ${t('contextWindowCol')}`}
/>
<HintLabel label="tokens" hint={t('qoderContextHint')} />
</div>
)
}
return (
<div className="flex min-w-0 flex-wrap items-center gap-3">
<span className="mono text-xs text-muted">
Expand All @@ -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) ? (
<HintLabel label={t('catalogWindow')} hint={t('workbuddyContextHint')} />
) : null}
{provider === 'trae' && model.supports_max_mode ? (
{(provider === 'trae' || provider === 'qoder') && model.supports_max_mode ? (
<div className="flex items-center gap-2">
<CompactSwitch
isSelected={Boolean(model.max_mode)}
Expand All @@ -139,7 +117,7 @@ function ModelContextControls({
/>
<HintLabel
label={`${t('maxMode')}${model.catalog_context_length_max ? ` ${formatTokens(model.catalog_context_length_max)}` : ''}`}
hint={t('maxModeHint')}
hint={provider === 'qoder' ? t('qoderMaxModeHint') : t('maxModeHint')}
/>
</div>
) : null}
Expand Down Expand Up @@ -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 (
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="secondary" isPending={saving} onPress={() => onSave(model)}>
<FloppyDisk size={14} />{t('save')}
</Button>
<Button size="sm" variant="ghost" isDisabled={saving || !model.context_custom} onPress={() => onReset(model)} aria-label={t('resetDefault')}>
<ArrowCounterClockwise size={14} />
</Button>
</div>
)
}
return (
<Button size="sm" variant="ghost" onPress={() => onDetails(model)}>
<Info size={14} />{t('modelDetails')}
Expand All @@ -211,7 +171,6 @@ export function ProvidersPage() {
const [savingKey, setSavingKey] = useState('')
const [message, setMessage] = useState('')
const [messageError, setMessageError] = useState(false)
const [drafts, setDrafts] = useState<Record<string, string>>({})
const [detailModel, setDetailModel] = useState<ModelInfo | null>(null)
const [models, setModels] = useState<ModelInfo[]>([])
const [modelsLoading, setModelsLoading] = useState(true)
Expand Down Expand Up @@ -268,25 +227,12 @@ export function ProvidersPage() {

if ((loading && !overview) || modelsLoading) return <ProvidersPageSkeleton />

function updateModelInOverview(model: ModelInfo, result: Awaited<ReturnType<typeof updateModelContext>>) {
const key = modelSettingsKey(model)
const nextModels = models.map((item) => modelSettingsKey(item) === key
? {
...item,
settings_key: result.model,
context_length: result.context_length,
default_context_length: result.default_context_length,
context_custom: result.context_custom,
}
: item)
setModels(nextModels)
setDrafts((current) => ({ ...current, [key]: String(result.context_length) }))
}

function updateTraeInOverview(model: ModelInfo, maxMode: boolean) {
const key = modelSettingsKey(model)
const provider = modelProvider(model)
const nextModels = models.map((item) => {
if (modelSettingsKey(item) !== key || modelProvider(item) !== 'trae') return item
if (modelSettingsKey(item) !== key || modelProvider(item) !== provider) return item
const effort = item.reasoning_effort || item.reasoning_default || ''
const itemDev = item.catalog_context_length || item.default_context_length || item.context_length || 0
const itemMax = item.catalog_context_length_max || 0
Expand All @@ -298,7 +244,7 @@ export function ProvidersPage() {
// *_max at render time, so turning max mode off restores the default.
context_length: maxMode && itemMax ? itemMax : itemDev,
default_context_length: itemDev,
context_custom: maxMode || Boolean(effort && effort !== item.reasoning_default),
context_custom: provider === 'qoder' ? maxMode : maxMode || Boolean(effort && effort !== item.reasoning_default),
}
})
setModels(nextModels)
Expand Down Expand Up @@ -333,28 +279,6 @@ export function ProvidersPage() {
}
}

async function onSave(model: ModelInfo) {
const key = modelSettingsKey(model)
const value = Number(drafts[key] ?? model.context_length ?? model.default_context_length)
if (!Number.isInteger(value) || value < 1024 || value > 4_000_000) {
setMessageError(true)
setMessage(t('contextInvalid'))
return
}
setSavingKey(key)
setMessage('')
setMessageError(false)
try {
const result = await updateModelContext(key, value)
updateModelInOverview(model, result)
setMessage(t('contextSaved', { model: model.id }))
} catch (error) {
setMessageError(true)
setMessage(error instanceof Error ? error.message : String(error))
} finally {
setSavingKey('')
}
}

async function onReasoningChange(model: ModelInfo, effort: string) {
const provider = modelProvider(model)
Expand Down Expand Up @@ -382,11 +306,12 @@ export function ProvidersPage() {
return
}
const key = modelSettingsKey(model)
const provider = modelProvider(model)
setSavingKey(key)
setMessage('')
setMessageError(false)
try {
await updateTraeMaxMode(key, maxMode)
await updateProviderMaxMode(provider, key, maxMode, model.catalog_context_length_max)
updateTraeInOverview(model, maxMode)
setMessage(maxMode ? t('contextMaxOn', { model: model.id }) : t('contextMaxOff', { model: model.id }))
} catch (error) {
Expand All @@ -397,22 +322,6 @@ export function ProvidersPage() {
}
}

async function onReset(model: ModelInfo) {
const key = modelSettingsKey(model)
setSavingKey(key)
setMessage('')
setMessageError(false)
try {
const result = await updateModelContext(key, 0)
updateModelInOverview(model, result)
setMessage(t('contextReset', { model: model.id }))
} catch (error) {
setMessageError(true)
setMessage(error instanceof Error ? error.message : String(error))
} finally {
setSavingKey('')
}
}

return (
<div className="space-y-6">
Expand Down Expand Up @@ -507,19 +416,14 @@ export function ProvidersPage() {
</div>
<ModelContextControls
model={model}
drafts={drafts}
saving={saving}
t={t}
onDraft={(nextKey, value) => setDrafts((current) => ({ ...current, [nextKey]: value }))}
onToggleTraeMax={(item, selected) => void onToggleTraeMax(item, selected)}
onReasoningChange={(item, next) => void onReasoningChange(item, next)}
/>
<ModelActions
model={model}
saving={saving}
t={t}
onSave={(item) => void onSave(item)}
onReset={(item) => void onReset(item)}
onDetails={setDetailModel}
/>
</article>
Expand Down Expand Up @@ -573,10 +477,8 @@ export function ProvidersPage() {
<Table.Cell>
<ModelContextControls
model={model}
drafts={drafts}
saving={saving}
t={t}
onDraft={(nextKey, value) => setDrafts((current) => ({ ...current, [nextKey]: value }))}
onToggleTraeMax={(item, selected) => void onToggleTraeMax(item, selected)}
onReasoningChange={(item, next) => void onReasoningChange(item, next)}
/>
Expand All @@ -589,10 +491,7 @@ export function ProvidersPage() {
<Table.Cell>
<ModelActions
model={model}
saving={saving}
t={t}
onSave={(item) => void onSave(item)}
onReset={(item) => void onReset(item)}
onDetails={setDetailModel}
/>
</Table.Cell>
Expand Down
3 changes: 3 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions internal/console/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ func modelSettingResponse(setting control.ModelSetting) map[string]any {
"max_mode": setting.MaxMode, "reasoning_effort": setting.ReasoningEffort,
"context_custom": setting.ContextCustom,
}
case "qoder":
return map[string]any{
"model": setting.Model, "provider": setting.Provider,
"max_mode": setting.MaxMode,
"context_length": setting.ContextLength,
"default_context_length": setting.DefaultContextLength,
"context_custom": setting.ContextCustom,
}
default:
return map[string]any{
"model": setting.Model, "context_length": setting.ContextLength,
Expand Down
Loading