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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Send WorkBuddy Deepseek V4.1 Flash thinking as official top-level reasoning fields so streamed thinking comes back
- Show WorkBuddy catalog context budgets (default and optional window) on the model list without inventing a Trae Max switch

### 中文

- WorkBuddy 的 Deepseek V4.1 Flash 改为发送官方顶层思考字段,流式思考内容可以返回
- 模型列表展示 WorkBuddy 目录里的默认和可选上下文窗口,不套用 Trae 的更大上下文开关

## 0.4.8 - 2026-09-11

### English

- Preserve cached model routes when the dynamic model catalog is temporarily unavailable, while refreshing genuine model misses
- Let WorkBuddy accounts choose their automatic daily check-in time during creation, with a 21:00 retry for failed attempts
- Enlarge the account-name input in the create-account wizard
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/ModelDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function ModelDetailsModal({ model, t, onClose }: Props) {
<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 && windowMax && windowMax !== windowDev ? <p className="mt-1 text-[11px] leading-5 text-muted">{t('workbuddyContextHint')}</p> : null}
{model.prompt_max_tokens ? <div className="mt-1 text-[11px] text-muted">{t('promptMaxTokens')}: {formatTokens(model.prompt_max_tokens)}</div> : null}
{model.max_output_tokens ? <div className="text-[11px] text-muted">{t('maxOutputTokens')}: {formatTokens(model.max_output_tokens)}</div> : null}
</section>
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 @@ -413,6 +413,7 @@ export const messages: Record<Lang, Dict> = {
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.',
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.',
reasoningLevels: 'Reasoning intensity',
reasoningHint: 'Default thinking effort when the request omits reasoning_effort and related flags. This does not lock every call to that level, and it does not cap a higher client value. A level the model does not allow falls back to an allowed option.',
reasoningFixed: 'Fixed thinking: {type}',
Expand Down Expand Up @@ -1021,6 +1022,7 @@ export const messages: Record<Lang, Dict> = {
maxModeHint: 'Trae 的更大上下文窗口,常见是 200k 提到 1M,不是套餐也不是额度。只在请求没带 is_max_mode 时使用;请求里写了仍以请求为准。模型不支持时不会发送这个开关。',
catalogWindow: '目录默认窗口',
catalogWindowHint: '这个 Trae 模型没有更大上下文开关,数字是目录里的默认窗口。',
workbuddyContextHint: 'WorkBuddy 桌面端目录里的默认窗口和可选窗口,用于本地压缩预算,不是 Trae 的 is_max_mode。聊天请求不会发送窗口开关。',
reasoningLevels: '推理强度',
reasoningHint: '请求没指定推理强度时使用的默认档位。选了不会强制每次都走这一档,也不会限制客户端改用其他允许档位。不在列表里的值会回退到可用档位,而不是锁死。',
reasoningFixed: '固定思考:{type}',
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/pages/ProvidersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,15 @@ function ModelContextControls({
}
return (
<div className="flex min-w-0 flex-wrap items-center gap-3">
<span className="mono text-xs text-muted">{formatTokens(model.catalog_context_length || model.context_length)}</span>
<span className="mono text-xs text-muted">
{formatTokens(model.catalog_context_length || model.context_length)}
{model.catalog_context_length_max && model.catalog_context_length_max !== (model.catalog_context_length || model.context_length)
? ` → ${formatTokens(model.catalog_context_length_max)}`
: ''}
</span>
{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 ? (
<div className="flex items-center gap-2">
<CompactSwitch
Expand Down
2 changes: 1 addition & 1 deletion internal/api/modelsettings.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func (s *Server) decorateProviderSettings(ctx context.Context, item map[string]a
dev, _ := asInt(item["catalog_context_length"])
max, _ := asInt(item["catalog_context_length_max"])
supportsMax, _ := item["supports_max_mode"].(bool)
if !supportsMax && max > 0 && max != dev {
if provider == "trae" && !supportsMax && max > 0 && max != dev {
supportsMax = true
item["supports_max_mode"] = true
}
Expand Down
78 changes: 67 additions & 11 deletions internal/providers/workbuddy/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,23 @@ type catalogReasoning struct {
Summary string `json:"summary"`
}

type catalogContextWindow struct {
DefaultLength int `json:"defaultLength"`
SupportedLengths []int `json:"supportedLengths"`
}

type catalogModelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
MaxInputTokens int `json:"maxInputTokens"`
MaxOutputTokens int `json:"maxOutputTokens"`
Disabled bool `json:"disabled"`
OnlyReasoning bool `json:"onlyReasoning"`
SupportsReasoning bool `json:"supportsReasoning"`
SupportsImages bool `json:"supportsImages"`
SupportsToolCall bool `json:"supportsToolCall"`
Reasoning catalogReasoning `json:"reasoning"`
ID string `json:"id"`
Name string `json:"name"`
MaxInputTokens int `json:"maxInputTokens"`
MaxOutputTokens int `json:"maxOutputTokens"`
Disabled bool `json:"disabled"`
OnlyReasoning bool `json:"onlyReasoning"`
SupportsReasoning bool `json:"supportsReasoning"`
SupportsImages bool `json:"supportsImages"`
SupportsToolCall bool `json:"supportsToolCall"`
ContextWindow catalogContextWindow `json:"contextWindow"`
Reasoning catalogReasoning `json:"reasoning"`
}

func catalogModel(model catalogModelEntry) providers.ModelInfo {
Expand All @@ -49,12 +55,14 @@ func catalogModel(model catalogModelEntry) providers.ModelInfo {
if canDisable && (len(options) > 0 || model.SupportsReasoning) && !containsLevel(options, "none") {
options = append([]string{"none"}, options...)
}
window, windowMax := contextWindows(model)
return providers.ModelInfo{
NativeModel: model.ID,
PublicModel: model.ID,
DisplayName: model.Name,
Capabilities: providers.ModelCapabilities{
ContextWindow: model.MaxInputTokens,
ContextWindow: window,
ContextWindowMax: windowMax,
MaxOutput: model.MaxOutputTokens,
Tools: true,
Images: model.SupportsImages,
Expand All @@ -66,6 +74,54 @@ func catalogModel(model catalogModelEntry) providers.ModelInfo {
}
}

func contextWindows(model catalogModelEntry) (int, int) {
cap := model.MaxInputTokens
seen := map[int]struct{}{}
var lengths []int
for _, value := range model.ContextWindow.SupportedLengths {
if value <= 0 || (cap > 0 && value > cap) {
continue
}
if _, dup := seen[value]; dup {
continue
}
seen[value] = struct{}{}
lengths = append(lengths, value)
}
for i := 0; i < len(lengths); i++ {
for j := i + 1; j < len(lengths); j++ {
if lengths[j] < lengths[i] {
lengths[i], lengths[j] = lengths[j], lengths[i]
}
}
}
dev := model.ContextWindow.DefaultLength
if dev <= 0 || (cap > 0 && dev > cap) || (len(lengths) > 0 && !containsWindow(lengths, dev)) {
if len(lengths) > 0 {
dev = lengths[0]
} else {
dev = cap
}
}
max := cap
if len(lengths) > 0 {
max = lengths[len(lengths)-1]
}
if max == dev {
max = 0
}
return dev, max
}

func containsWindow(values []int, want int) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}

func containsLevel(options []string, level string) bool {
for _, option := range options {
if option == level {
Expand Down
55 changes: 53 additions & 2 deletions internal/providers/workbuddy/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,15 @@ func TestModelsParsesReasoningOptions(t *testing.T) {
"onlyReasoning": true, "supportsReasoning": true,
"reasoning": map[string]any{"effort": "medium"},
},
{
"id": "deepseek-v4.1-flash", "name": "Deepseek-V4.1-Flash",
"maxInputTokens": 1000000, "maxOutputTokens": 128000,
"onlyReasoning": true, "supportsReasoning": true,
"contextWindow": map[string]any{"defaultLength": 300000, "supportedLengths": []int{300000, 1000000}},
"reasoning": map[string]any{"effort": "high", "summary": "auto"},
},
},
"agents": []map[string]any{{"name": "cli", "models": []string{"glm-5.3", "glm-5.2"}}},
"agents": []map[string]any{{"name": "cli", "models": []string{"glm-5.3", "glm-5.2", "deepseek-v4.1-flash"}}},
}})
}))
defer server.Close()
Expand All @@ -318,7 +325,7 @@ func TestModelsParsesReasoningOptions(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(models) != 2 {
if len(models) != 3 {
t.Fatalf("models=%+v", models)
}
glm53 := models[0]
Expand All @@ -332,6 +339,13 @@ func TestModelsParsesReasoningOptions(t *testing.T) {
if glm52.Capabilities.CanDisableThinking || glm52.Capabilities.ReasoningDefault != "medium" || strings.Join(glm52.Capabilities.ReasoningOptions, ",") != "medium" {
t.Fatalf("glm-5.2 caps=%+v", glm52.Capabilities)
}
if glm52.Capabilities.ContextWindow != 1000000 || glm52.Capabilities.ContextWindowMax != 0 {
t.Fatalf("glm-5.2 window=%+v", glm52.Capabilities)
}
ds := models[2]
if ds.NativeModel != "deepseek-v4.1-flash" || ds.Capabilities.ContextWindow != 300000 || ds.Capabilities.ContextWindowMax != 1000000 || ds.Capabilities.MaxMode {
t.Fatalf("deepseek window=%+v", ds.Capabilities)
}
}

func TestChatRequestSendsCatalogReasoningEffort(t *testing.T) {
Expand Down Expand Up @@ -369,6 +383,43 @@ func TestChatRequestSendsCatalogReasoningEffort(t *testing.T) {
}
}

func TestChatRequestSendsOfficialReasoningFieldsForDeepseekFlash(t *testing.T) {
payload, _ := Credential{AccessToken: "at", UID: "u1", Domain: "codebuddy.cn", ExpiresAt: 4102444800}.Encode()
store := &memStore{items: map[string][]byte{"acc1": payload}}
var got map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == pathModelsCN {
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"models": []map[string]any{{
"id": "deepseek-v4.1-flash", "name": "Deepseek V4.1 Flash",
"onlyReasoning": true, "supportsReasoning": true,
"reasoning": map[string]any{"effort": "high", "summary": "auto"},
}},
"agents": []map[string]any{{"name": "cli", "models": []string{"deepseek-v4.1-flash"}}},
}})
return
}
_ = json.NewDecoder(r.Body).Decode(&got)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(chatSSE))
}))
defer server.Close()
client := NewClient(store)
client.http = server.Client()
client.http.Transport = rewriteTransport{server: server.URL, round: server.Client().Transport}
if _, err := client.ChatNonStream(context.Background(), "acc1", translate.ChatRequest{
Model: "deepseek-v4.1-flash", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}},
}); err != nil {
t.Fatal(err)
}
if _, ok := got["reasoning"]; ok {
t.Fatalf("deepseek kept nested reasoning: %v", got["reasoning"])
}
if got["reasoning_effort"] != "high" || got["reasoning_summary"] != "auto" || got["verbosity"] != "high" {
t.Fatalf("deepseek fields=%v", got)
}
}

// Regression: the console stores provider settings under the canonical model
// key (lowercase, _ folded to -); chat-time lookups must use the same form.
func TestChatRequestFindsStoredReasoningByCanonicalKey(t *testing.T) {
Expand Down
41 changes: 38 additions & 3 deletions internal/providers/workbuddy/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@ package workbuddy

import (
"encoding/json"
"strings"

"github.com/caigee-cmd/cli2api/internal/accounts"
"github.com/caigee-cmd/cli2api/internal/providers"
"github.com/caigee-cmd/cli2api/internal/translate"
)

// officialTopLevelReasoningModels need the 2.132.0 CLI field names. The
// nested reasoning object that GLM / Hy4 still accept returns no thinking
// tokens for these ids.
var officialTopLevelReasoningModels = map[string]struct{}{
"deepseek-v4.1-flash": {},
}

func requestedReasoningLevel(req translate.ChatRequest) string {
if len(req.ReasoningEffort) > 0 {
var value any
Expand Down Expand Up @@ -58,23 +67,49 @@ func applyChatReasoning(obj map[string]any, req translate.ChatRequest, storedLev
}
level = providers.ResolveReasoningLevel(level, caps)
if level == "" {
delete(obj, "reasoning")
clearChatReasoning(obj)
return
}
if level == "none" {
if !caps.CanDisableThinking {
level = providers.ResolveReasoningLevel(caps.ReasoningDefault, caps)
if level == "" || level == "none" {
delete(obj, "reasoning")
clearChatReasoning(obj)
return
}
} else {
delete(obj, "reasoning")
clearChatReasoning(obj)
return
}
}
if level == "max" {
level = "xhigh"
}
if usesTopLevelReasoningFields(req.Model) {
delete(obj, "reasoning")
obj["reasoning_effort"] = level
obj["reasoning_summary"] = "auto"
obj["verbosity"] = "high"
return
}
delete(obj, "reasoning_effort")
delete(obj, "reasoning_summary")
delete(obj, "verbosity")
obj["reasoning"] = map[string]any{"effort": level, "summary": "auto"}
}

func clearChatReasoning(obj map[string]any) {
delete(obj, "reasoning")
delete(obj, "reasoning_effort")
delete(obj, "reasoning_summary")
delete(obj, "verbosity")
}

func usesTopLevelReasoningFields(model string) bool {
key := accounts.CanonicalModelID(model)
if i := strings.LastIndex(key, "/"); i >= 0 {
key = key[i+1:]
}
_, ok := officialTopLevelReasoningModels[key]
return ok
}
55 changes: 55 additions & 0 deletions internal/providers/workbuddy/mapping_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package workbuddy

import (
"encoding/json"
"testing"

"github.com/caigee-cmd/cli2api/internal/providers"
"github.com/caigee-cmd/cli2api/internal/translate"
)

func TestApplyChatReasoningKeepsNestedObjectForGLM(t *testing.T) {
obj := map[string]any{}
applyChatReasoning(obj, translate.ChatRequest{Model: "glm-5.3", ReasoningEffort: json.RawMessage(`"high"`)}, "", providers.ModelCapabilities{
ReasoningOptions: []string{"none", "low", "high", "xhigh"},
ReasoningDefault: "high",
})
reasoning, _ := obj["reasoning"].(map[string]any)
if reasoning["effort"] != "high" || reasoning["summary"] != "auto" {
t.Fatalf("glm reasoning=%v", obj["reasoning"])
}
for _, key := range []string{"reasoning_effort", "reasoning_summary", "verbosity"} {
if _, ok := obj[key]; ok {
t.Fatalf("glm unexpectedly set %s=%v", key, obj[key])
}
}
}

func TestApplyChatReasoningUsesOfficialFieldsForDeepseekFlash(t *testing.T) {
caps := providers.ModelCapabilities{
ReasoningOptions: []string{"high"},
ReasoningDefault: "high",
CanDisableThinking: false,
}
for _, model := range []string{"deepseek-v4.1-flash", "DeepSeek_V4.1_Flash", "workbuddy/deepseek-v4.1-flash"} {
obj := map[string]any{"reasoning": map[string]any{"effort": "stale"}}
applyChatReasoning(obj, translate.ChatRequest{Model: model}, "", caps)
if _, ok := obj["reasoning"]; ok {
t.Fatalf("%s kept nested reasoning: %v", model, obj["reasoning"])
}
if obj["reasoning_effort"] != "high" || obj["reasoning_summary"] != "auto" || obj["verbosity"] != "high" {
t.Fatalf("%s official fields=%v", model, obj)
}
}
}

func TestApplyChatReasoningDeepseekUsesCatalogDefault(t *testing.T) {
obj := map[string]any{}
applyChatReasoning(obj, translate.ChatRequest{Model: "deepseek-v4.1-flash"}, "", providers.ModelCapabilities{
ReasoningOptions: []string{"high"},
ReasoningDefault: "high",
})
if obj["reasoning_effort"] != "high" {
t.Fatalf("default effort=%v", obj["reasoning_effort"])
}
}

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-sgsO79xb.js"></script>
<script type="module" crossorigin src="/assets/index-DyfuUCZL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DTnXvv8L.css">
</head>
<body>
Expand Down
Loading