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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.
- Store an empty client-key provider allowlist as `[]` instead of JSON `null`
- Keep the v0.2.20 bytes for provider-model settings migration 007, and accept the later tab-indented checksum so existing databases can boot
- Explain on System when the host updater is missing, instead of showing the raw Unix socket error
- Show Trae Max-context and per-model reasoning controls, and WorkBuddy reasoning levels, on Models; persist those defaults and send them on chat

### 中文

Expand All @@ -30,6 +31,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.
- 客户端密钥未限制供应商时写入 `[]`,不再存成 JSON `null`
- 007 供应商模型设置迁移保持 v0.2.20 原文,并接受后来误改缩进后的 checksum,已有数据库可以启动
- 系统页在没有宿主机更新器时说明更新条件,不再直接显示 Unix socket 报错
- 模型页为 Trae 显示 Max 上下文和推理强度,为 WorkBuddy 显示推理档位;默认值会保存,并在对话请求里带上

## 0.2.20 - 2026-08-29

Expand Down
6 changes: 3 additions & 3 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ Hard constraints:

- Never edit a shipped migration's SQL bytes. Whitespace, comments, quote style, and statement order all change the checksum.
- `gofmt` / indent of the surrounding Go is fine. Indenting the text inside the raw string is not.
- New columns, indexes, or tables go in the next numbered file (`008_…`). Do not append to an applied file.
- New columns, indexes, or tables go in the next numbered file (`009_…`). Do not append to an applied file.
- Do not `UPDATE schema_migrations` on a live database to make a rewritten file pass. That hides the mismatch and can leave hosts on different schemas.
- Do not delete, rename, or reorder applied filenames.

Expand All @@ -475,7 +475,7 @@ Keep the menu short. Login is a gate, not a nav item.
| `/login` | no | Console password |
| `/` | Overview | Runtime pulse + request stats |
| `/accounts` | Accounts | Qoder login + pool |
| `/providers` | Models | Catalog + per-model context-window defaults; filter by provider and paginate |
| `/providers` | Models | Catalog + per-model defaults (Qoder context window, Trae Max / reasoning, WorkBuddy reasoning); filter by provider and paginate |
| `/access` | Access | Base URL + quick chat; account and model dropdowns follow the selected account catalog |
| `/logs` | Logs | Request history + runtime process output |
| `/keys` | API keys | Named client keys with optional provider allowlists |
Expand Down Expand Up @@ -597,7 +597,7 @@ After UI edits:
| `frontend/src/pages/LoginPage.tsx` | Console gate |
| `frontend/src/pages/AccountsPage.tsx` | Qoder login + pool |
| `frontend/src/components/account/` | Compact account card, runtime meter, quota fill |
| `frontend/src/pages/ProvidersPage.tsx` | Model catalog + context-window defaults |
| `frontend/src/pages/ProvidersPage.tsx` | Model catalog + per-model context / Max / reasoning defaults |
| `frontend/src/pages/LogsPage.tsx` | Request history + runtime logs |
| `frontend/src/pages/SystemPage.tsx` | Managed next-version update |
| `frontend/src/components/layout/` | Shell / menu |
Expand Down
2 changes: 1 addition & 1 deletion docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ WorkBuddy2API 把 **WorkBuddy CN / CodeBuddy**(`copilot.tencent.com` / `www.co

CN:`GET {chatBase}/console/enterprises/personal/models`。国际站这条 console 路径是 OIDC 页面(未登录 302 到 Keycloak,带 Bearer 500 HTML),必须改打 `GET https://www.workbuddy.ai/v2/enterprises/personal/models`。`GET /api/models` 目录失败用 503,不要 502,避免反代把 JSON 换成 HTML 错误页。

只暴露 CLI agent 的模型 ID(`cli` / `CLI` / `codebuddy` / `workbuddy`),再和 `data.models` 交叉,跳过 `disabled`。上游没有 agents 时,退回全部未禁用模型。字段用 `maxInputTokens` / `maxOutputTokens`,不是 `contextWindow`。按账号拉目录失败时,`GET /api/models?account=` 返回明确错误,不把空列表当成「没有模型」。
只暴露 CLI agent 的模型 ID(`cli` / `CLI` / `codebuddy` / `workbuddy`),再和 `data.models` 交叉,跳过 `disabled`。上游没有 agents 时,退回全部未禁用模型。字段用 `maxInputTokens` / `maxOutputTokens`,不是 `contextWindow`。推理档位读模型上的 `reasoning`:`supportedEfforts` / `defaultEffort` / `effort` / `canDisableThinking` / `onlyReasoning`。聊天请求发 `reasoning: {effort, summary:"auto"}`;`max` 按官方 CLI 映射成 `xhigh`。WorkBuddy 没有 Trae 那种 Max 开关,大上下文就是目录里的 `maxInputTokens`。按账号拉目录失败时,`GET /api/models?account=` 返回明确错误,不把空列表当成「没有模型」。

参考仓库失败时回退静态表。本仓库对 Qoder 的原则是 **catalog 失败就报错,不猜**。WorkBuddy 应沿用同一原则:动态目录失败返回明确错误,不内置一份会过期的模型白名单。

Expand Down
1 change: 1 addition & 0 deletions docs/PROVIDERS_TRAE_SOLO.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ qoder-anchor: [docs/DESIGN.md](./DESIGN.md) 的控制面 / 执行面边界
- `POST {AgentHost}/api/ide/v1/get_detail_param`
- Body 形态:`function`、`config_names: null`、`need_prompt: false`、`poly_prompt: true`
- 用 `config_info_list`:`config_name` → native/public id,`display_config.display_name` → 展示名。空列表当错误。
- live 目录里 `reasoning_effort_config` 和 `context_window_tokens.max` 经常是空的。Max 还要看 `model_extra_config.v2_max_mode_enabled` / `display_config.max_mode` / `is_dollar_max`;推理还要看 `reasoning_effort_options`、`display_contact_config.reasoning.enable`,以及官方客户端实际使用的 `light` / `high` / `extra_high`(发出去映射成 `low` / `high` / `xhigh`)。
- 本仓库原则与 WorkBuddy 相同:**catalog 失败就报错,不回退过期静态表**。`glm-5.2` / `glm-5.3` 只是实测样例,不是白名单。

### SSE
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,27 @@ export function updateTraeMaxMode(modelKey: string, maxMode: boolean) {
model: string
provider: string
max_mode: boolean
reasoning_effort?: string
context_custom: boolean
}>(`/api/models/trae/${encodeURIComponent(modelKey)}`, {
method: 'PATCH',
body: JSON.stringify({ max_mode: maxMode }),
})
}

export function updateProviderReasoning(provider: 'trae' | 'workbuddy', modelKey: string, reasoningEffort: string) {
return api<{
model: string
provider: string
max_mode: boolean
reasoning_effort?: string
context_custom: boolean
}>(`/api/models/${provider}/${encodeURIComponent(modelKey)}`, {
method: 'PATCH',
body: JSON.stringify({ reasoning_effort: reasoningEffort }),
})
}

export function rewarmWorker(accountId?: string) {
if (!accountId) throw new Error('account id required')
return api(`/api/accounts/${encodeURIComponent(accountId)}/rewarm`, { method: 'POST', body: '{}' })
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export type ModelInfo = {
prompt_max_tokens?: number
reasoning_options?: string[]
reasoning_default?: string
reasoning_effort?: string
reasoning_type?: string
can_disable_thinking?: boolean
}

export type Overview = {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/ModelDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ export function ModelDetailsModal({ model, t, onClose }: Props) {
{options.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{options.map((level) => (
<Chip key={level} size="sm" variant="soft" color={level === model.reasoning_default ? 'success' : 'default'}>
{level}{level === model.reasoning_default ? ` · ${t('defaultValue')}` : ''}
<Chip key={level} size="sm" variant="soft" color={level === (model.reasoning_effort || model.reasoning_default) ? 'success' : 'default'}>
{level}{level === model.reasoning_default ? ` · ${t('defaultValue')}` : ''}{level === model.reasoning_effort && level !== model.reasoning_default ? ` · ${t('custom')}` : ''}
</Chip>
))}
</div>
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,16 @@ export const messages: Record<Lang, Dict> = {
contextMaxUnavailable: 'This model has no Max context switch.',
maxMode: 'Max',
catalogWindow: 'Catalog window',
reasoningLevels: 'Reasoning levels',
reasoningLevels: 'Reasoning',
reasoningFixed: 'Fixed thinking: {type}',
noReasoningLevels: 'This model has no selectable reasoning levels.',
reasoningSaved: 'Saved reasoning for {model}: {level}.',
reasoningLevel_none: 'Off',
reasoningLevel_low: 'Low',
reasoningLevel_medium: 'Medium',
reasoningLevel_high: 'High',
reasoningLevel_xhigh: 'Extra high',
reasoningLevel_max: 'Max',
modelDetails: 'Details',
promptMaxTokens: 'Prompt max',
maxOutputTokens: 'Output max',
Expand Down Expand Up @@ -809,6 +816,13 @@ export const messages: Record<Lang, Dict> = {
reasoningLevels: '推理强度',
reasoningFixed: '固定思考:{type}',
noReasoningLevels: '这个模型没有可选的推理档位。',
reasoningSaved: '已保存 {model} 的推理强度:{level}。',
reasoningLevel_none: '关闭',
reasoningLevel_low: '低',
reasoningLevel_medium: '中',
reasoningLevel_high: '高',
reasoningLevel_xhigh: '极高',
reasoningLevel_max: '最大',
modelDetails: '详情',
promptMaxTokens: '输入上限',
maxOutputTokens: '输出上限',
Expand Down
69 changes: 60 additions & 9 deletions frontend/src/pages/ProvidersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button, Card, Chip, Input, Table } from '@heroui/react'
import { Cube, ArrowClockwise, ArrowCounterClockwise, FloppyDisk, MagnifyingGlass, Info } from '@phosphor-icons/react'
import { useI18n } from '@/hooks/useI18n'
import { useOverview } from '@/hooks/useOverview'
import { refreshModels, updateModelContext, updateTraeMaxMode } from '@/api/overview'
import { refreshModels, updateModelContext, updateProviderReasoning, updateTraeMaxMode } from '@/api/overview'
import type { Overview } from '@/api/types'
import { ProviderMark } from '@/components/ProviderMark'
import { ModelDetailsModal, formatTokens } from '@/components/ModelDetailsModal'
Expand Down Expand Up @@ -35,6 +35,12 @@ function routedModelName(model: ModelInfo) {
return routeName && routeName !== (model.display_name || model.id) ? routeName : ''
}

function reasoningLabel(t: (key: string, vars?: Record<string, string | number>) => string, level: string) {
const key = `reasoningLevel_${level}`
const label = t(key)
return label === key ? level : label
}

export function ProvidersPage() {
const { t } = useI18n()
const { overview, loading, setOverview } = useOverview()
Expand Down Expand Up @@ -109,16 +115,31 @@ export function ProvidersPage() {
if (modelSettingsKey(item) !== key || modelProvider(item) !== 'trae') return item
const dev = item.catalog_context_length || item.default_context_length || item.context_length
const max = item.catalog_context_length_max
const effort = item.reasoning_effort || item.reasoning_default || ''
return {
...item,
max_mode: maxMode,
context_custom: maxMode,
context_custom: maxMode || Boolean(effort && effort !== item.reasoning_default),
context_length: maxMode && max ? max : dev,
}
})
setOverview({ ...(overview || {}), models: nextModels })
}

function updateReasoningInOverview(model: ModelInfo, effort: string) {
const key = modelSettingsKey(model)
const provider = modelProvider(model)
const nextModels = models.map((item) => {
if (modelSettingsKey(item) !== key || modelProvider(item) !== provider) return item
return {
...item,
reasoning_effort: effort,
context_custom: Boolean(item.max_mode) || Boolean(effort && effort !== item.reasoning_default),
}
})
setOverview({ ...(overview || {}), models: nextModels })
}

async function onRefresh() {
setBusy(true)
setMessage('')
Expand Down Expand Up @@ -157,6 +178,25 @@ export function ProvidersPage() {
}
}

async function onReasoningChange(model: ModelInfo, effort: string) {
const provider = modelProvider(model)
if (provider !== 'trae' && provider !== 'workbuddy') return
const key = modelSettingsKey(model)
setSavingKey(key)
setMessage('')
setMessageError(false)
try {
await updateProviderReasoning(provider, key, effort)
updateReasoningInOverview(model, effort)
setMessage(t('reasoningSaved', { model: model.id, level: reasoningLabel(t, effort) }))
} catch (error) {
setMessageError(true)
setMessage(error instanceof Error ? error.message : String(error))
} finally {
setSavingKey('')
}
}

async function onToggleTraeMax(model: ModelInfo, maxMode: boolean) {
if (!model.supports_max_mode) {
setMessageError(true)
Expand Down Expand Up @@ -297,10 +337,10 @@ export function ProvidersPage() {
/>
<span className="mono text-[10px] text-muted">tokens</span>
</div>
) : provider === 'trae' ? (
<div className="flex min-w-52 items-center gap-3">
) : (
<div className="flex min-w-64 flex-wrap items-center gap-3">
<span className="mono text-xs text-muted">{formatTokens(model.catalog_context_length || model.context_length)}</span>
{model.supports_max_mode ? (
{provider === 'trae' && model.supports_max_mode ? (
<div className="flex items-center gap-2">
<CompactSwitch
isSelected={Boolean(model.max_mode)}
Expand All @@ -310,12 +350,23 @@ export function ProvidersPage() {
/>
<span className="text-[11px] text-muted">{t('maxMode')}{model.catalog_context_length_max ? ` ${formatTokens(model.catalog_context_length_max)}` : ''}</span>
</div>
) : (
) : null}
{(model.reasoning_options || []).length > 1 ? (
<FilterSelect
className="min-w-28"
ariaLabel={`${model.id} ${t('reasoningLevels')}`}
value={model.reasoning_effort || model.reasoning_default || model.reasoning_options?.[0] || ''}
onChange={(next) => { if (next) void onReasoningChange(model, next) }}
options={(model.reasoning_options || []).map((level) => ({ id: level, label: reasoningLabel(t, level) }))}
/>
) : (model.reasoning_options || []).length === 1 ? (
<span className="text-[11px] text-muted">{reasoningLabel(t, model.reasoning_options![0])}</span>
) : model.reasoning_type ? (
<span className="text-[11px] text-muted">{t('reasoningFixed', { type: model.reasoning_type })}</span>
) : provider === 'trae' && !model.supports_max_mode ? (
<span className="text-[11px] text-muted">{t('catalogWindow')}</span>
)}
) : null}
</div>
) : (
<span className="mono text-xs text-muted">{formatTokens(model.catalog_context_length || model.context_length)}</span>
)}
</Table.Cell>
<Table.Cell>
Expand Down
2 changes: 2 additions & 0 deletions internal/accounts/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ CREATE TABLE IF NOT EXISTS api_keys (
);
CREATE INDEX IF NOT EXISTS api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS api_keys_created_at ON api_keys(created_at DESC);`},
{filename: "009_provider_model_reasoning.sql", sql: `
ALTER TABLE provider_model_settings ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT '';`},
}

const schemaMigrationsDDL = `
Expand Down
50 changes: 39 additions & 11 deletions internal/accounts/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,42 +347,70 @@ func providerModelKey(provider, modelID string) (string, string, error) {
return provider, modelID, nil
}

func (s *Store) SetProviderModelMaxMode(ctx context.Context, provider, modelID string, maxMode bool) error {
type ProviderModelSetting struct {
MaxMode bool
ReasoningEffort string
}

func (s *Store) SetProviderModelSetting(ctx context.Context, provider, modelID string, setting ProviderModelSetting) error {
provider, modelID, err := providerModelKey(provider, modelID)
if err != nil {
return err
}
if !maxMode {
setting.ReasoningEffort = strings.ToLower(strings.TrimSpace(setting.ReasoningEffort))
if !setting.MaxMode && setting.ReasoningEffort == "" {
_, err := s.db.ExecContext(ctx, `DELETE FROM provider_model_settings WHERE provider = ? AND model_id = ?`, provider, modelID)
if err != nil {
return fmt.Errorf("delete provider model setting: %w", err)
}
return nil
}
maxMode := 0
if setting.MaxMode {
maxMode = 1
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO provider_model_settings (provider, model_id, max_mode, updated_at) VALUES (?, ?, 1, ?)
ON CONFLICT(provider, model_id) DO UPDATE SET max_mode=1, updated_at=excluded.updated_at`,
provider, modelID, formatTime(time.Now().UTC()))
INSERT INTO provider_model_settings (provider, model_id, max_mode, reasoning_effort, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(provider, model_id) DO UPDATE SET max_mode=excluded.max_mode, reasoning_effort=excluded.reasoning_effort, updated_at=excluded.updated_at`,
provider, modelID, maxMode, setting.ReasoningEffort, formatTime(time.Now().UTC()))
if err != nil {
return fmt.Errorf("save provider model setting: %w", err)
}
return nil
}

func (s *Store) GetProviderModelMaxMode(ctx context.Context, provider, modelID string) (bool, error) {
func (s *Store) GetProviderModelSetting(ctx context.Context, provider, modelID string) (ProviderModelSetting, error) {
provider, modelID, err := providerModelKey(provider, modelID)
if err != nil {
return false, err
return ProviderModelSetting{}, err
}
var maxMode int
err = s.db.QueryRowContext(ctx, `SELECT max_mode FROM provider_model_settings WHERE provider = ? AND model_id = ?`, provider, modelID).Scan(&maxMode)
var effort string
err = s.db.QueryRowContext(ctx, `SELECT max_mode, reasoning_effort FROM provider_model_settings WHERE provider = ? AND model_id = ?`, provider, modelID).Scan(&maxMode, &effort)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
return ProviderModelSetting{}, nil
}
if err != nil {
return ProviderModelSetting{}, fmt.Errorf("get provider model setting: %w", err)
}
return ProviderModelSetting{MaxMode: maxMode != 0, ReasoningEffort: strings.TrimSpace(effort)}, nil
}

func (s *Store) SetProviderModelMaxMode(ctx context.Context, provider, modelID string, maxMode bool) error {
setting, err := s.GetProviderModelSetting(ctx, provider, modelID)
if err != nil {
return err
}
setting.MaxMode = maxMode
return s.SetProviderModelSetting(ctx, provider, modelID, setting)
}

func (s *Store) GetProviderModelMaxMode(ctx context.Context, provider, modelID string) (bool, error) {
setting, err := s.GetProviderModelSetting(ctx, provider, modelID)
if err != nil {
return false, fmt.Errorf("get provider model setting: %w", err)
return false, err
}
return maxMode != 0, nil
return setting.MaxMode, nil
}

func (s *Store) ListProviderModelMaxModes(ctx context.Context, provider string) (map[string]bool, error) {
Expand Down
Loading
Loading