diff --git a/internal/providers/workbuddy/client.go b/internal/providers/workbuddy/client.go index c8a1fef..8a8a8e0 100644 --- a/internal/providers/workbuddy/client.go +++ b/internal/providers/workbuddy/client.go @@ -417,14 +417,6 @@ func (c *Client) Models(ctx context.Context, accountID string) ([]providers.Mode } func (c *Client) chatRequest(ctx context.Context, accountID string, credential Credential, req translate.ChatRequest) (*http.Request, error) { - body := map[string]any{ - "model": upstreamModelID(req.Model), - "messages": req.Messages, - "max_tokens": req.MaxTokens, - "temperature": req.Temperature, - "tools": req.Tools, - "tool_choice": req.ToolChoice, - } caps := c.capsFor(req.Model) storedLevel := "" if setter, ok := c.store.(interface { @@ -436,10 +428,21 @@ func (c *Client) chatRequest(ctx context.Context, accountID string, credential C storedLevel = stored.ReasoningEffort } } - if len(caps.ReasoningOptions) == 0 && accountID != "" { + // Resolve the upstream model after the live catalog is warm. WorkBuddy has + // renamed deepseek-v4.1-flash from the old deep-model alias; sending the + // stale alias lets upstream silently fall back to another model. + if (!c.hasCatalogEntry(req.Model) || len(caps.ReasoningOptions) == 0) && accountID != "" { _, _ = c.Models(ctx, accountID) caps = c.capsFor(req.Model) } + body := map[string]any{ + "model": c.upstreamModelID(req.Model), + "messages": req.Messages, + "max_tokens": req.MaxTokens, + "temperature": req.Temperature, + "tools": req.Tools, + "tool_choice": req.ToolChoice, + } applyChatReasoning(body, req, storedLevel, caps) payload, err := json.Marshal(body) if err != nil { @@ -1012,11 +1015,39 @@ var workbuddyModelAliases = map[string]string{ "deepseek-v4.1-flash": "deep-model", } -func upstreamModelID(model string) string { +func (c *Client) hasCatalogEntry(model string) bool { + model = strings.TrimSpace(model) + if model == "" || c == nil { + return false + } canonical := accounts.CanonicalModelID(model) - for alias, nativeModel := range workbuddyModelAliases { - if accounts.CanonicalModelID(alias) == canonical { - return nativeModel + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.catalog[model]; ok { + return true + } + _, ok := c.catalog[canonical] + return ok +} + +// upstreamModelID returns the native ID to send upstream. For current +// WorkBuddy catalogs that already expose deepseek-v4.1-flash natively, that +// ID is used as-is. Older catalogs that only expose deep-model still work +// because appendAliasModels indexes the public alias onto a ModelInfo whose +// NativeModel is deep-model. There is intentionally no hardcoded rewrite +// here: blindly mapping to deep-model on a cold/missing catalog is what made +// production silently fall back to kimi/glm. +func (c *Client) upstreamModelID(model string) string { + canonical := accounts.CanonicalModelID(model) + if c != nil { + c.mu.Lock() + info, ok := c.catalog[model] + if !ok { + info, ok = c.catalog[canonical] + } + c.mu.Unlock() + if ok && strings.TrimSpace(info.NativeModel) != "" { + return info.NativeModel } } return model diff --git a/internal/providers/workbuddy/client_test.go b/internal/providers/workbuddy/client_test.go index 14e7b39..5be2eb6 100644 --- a/internal/providers/workbuddy/client_test.go +++ b/internal/providers/workbuddy/client_test.go @@ -526,6 +526,9 @@ func TestChatRequestSendsOfficialReasoningFieldsForDeepseekFlash(t *testing.T) { }); err != nil { t.Fatal(err) } + if got["model"] != "deepseek-v4.1-flash" { + t.Fatalf("upstream model=%v body=%v", got["model"], got) + } if _, ok := got["reasoning"]; ok { t.Fatalf("deepseek kept nested reasoning: %v", got["reasoning"]) } @@ -679,6 +682,68 @@ func TestChatRequestMapsDeepseekAliasToNativeModel(t *testing.T) { } } +// Regression: once WorkBuddy publishes deepseek-v4.1-flash as a native CLI +// model, chat must send that ID instead of the stale deep-model alias. +func TestChatRequestKeepsNativeDeepseekWhenCatalogHasIt(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", "maxInputTokens": 1000000, + "supportsReasoning": true, "onlyReasoning": true, + "reasoning": map[string]any{"defaultEffort": "high", "supportedEfforts": []string{"high"}}, + }}, + "agents": []map[string]any{{"name": "cli", "models": []string{"deepseek-v4.1-flash"}}}, + }}) + return + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode chat body: %v", err) + } + 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 got["model"] != "deepseek-v4.1-flash" { + t.Fatalf("upstream model=%v body=%v", got["model"], got) + } +} + +func TestUpstreamModelIDDoesNotHardcodeStaleDeepModel(t *testing.T) { + client := NewClient(&memStore{items: map[string][]byte{}}) + if got := client.upstreamModelID("deepseek-v4.1-flash"); got != "deepseek-v4.1-flash" { + t.Fatalf("cold catalog rewrite = %q", got) + } + client.rememberCatalog([]providers.ModelInfo{{ + NativeModel: "deepseek-v4.1-flash", + PublicModel: "deepseek-v4.1-flash", + DisplayName: "Deepseek-V4.1-Flash", + }}) + if got := client.upstreamModelID("deepseek-v4.1-flash"); got != "deepseek-v4.1-flash" { + t.Fatalf("native catalog rewrite = %q", got) + } + client.rememberCatalog([]providers.ModelInfo{{ + NativeModel: "deep-model", + PublicModel: "deepseek-v4.1-flash", + DisplayName: "Deepseek-V4.1-Flash", + }}) + if got := client.upstreamModelID("deepseek-v4.1-flash"); got != "deep-model" { + t.Fatalf("legacy alias rewrite = %q", got) + } +} + func TestModelsAcceptsGlobalCLIAgentNamesAndUsesAccountRegion(t *testing.T) { payload, _ := Credential{AccessToken: "at", UID: "u1", Domain: "codebuddy.cn", ExpiresAt: 4102444800}.Encode() store := &memStore{items: map[string][]byte{"acc1": payload}, region: "global"}