diff --git a/CHANGELOG.md b/CHANGELOG.md index 224997d..e3817e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,18 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. - Generate Devin chat and account-status protobuf types from extracted descriptors with a manual update command; builds and CI use committed Go bindings without downloading releases or regenerating schemas +- Neutralize Codex/Desktop MCP-looking tool names for Devin (`mcp__*`, `list_mcp_*`, and any name containing `mcp`) into reversible `cx_tool_*` aliases, restore the originals on tool calls for local execution, scrub residual MCP text on fallback, and if upstream still returns an MCP configuration `permission_denied` retry by stripping those tools then keeping only core local tools (`exec_command` / `write_stdin` / `view_image` / `request_user_input`) with minimal schemas; log each fallback stage and never drop all tools +- Ignore orphan `tool_choice` when Codex compact / recovery turns send a choice with no remaining tools, instead of failing with `tool_choice requires tools` +- Keep session affinity from pinning a later model onto an empty-catalog account, so a Deepseek compact after a Devin turn routes to WorkBuddy instead of Devin ### 中文 - OpenAI、Anthropic 与 Responses 流式转发会保留上游的类型化错误,避免无效的 Devin 请求被错误地冷却账号,同时传输中断仍可重试 - Devin 的缓存读取与写入会显示在 OpenAI 兼容 usage 中,prompt 总数包含全部上游输入 token - 新增手动更新命令,提取 descriptor 并生成 Devin 聊天与账号状态 protobuf 类型;构建与 CI 直接使用已提交的 Go 文件,不下载发行包或重新生成 schema +- Devin 会把 Codex/Desktop 带 MCP 语义的工具名(`mcp__*`、`list_mcp_*` 以及名称含 `mcp` 的工具)中性化为可逆的 `cx_tool_*` 别名,并在返回的 tool_calls 中还原原名供本地执行;若上游仍返回 MCP 配置类 `permission_denied`,会先清洗残留 MCP 文案并去掉这些工具再试,再失败则只保留核心本地工具(`exec_command` / `write_stdin` / `view_image` / `request_user_input`,最小 schema),每次 fallback 都会打日志,且不再清空全部 tools +- Codex compact / 恢复轮次如果带了 `tool_choice` 但 tools 已被规范化为空,会忽略这个孤立的 `tool_choice`,不再报 `tool_choice requires tools` +- 会话粘性不会再把后续模型钉到空 catalog 账号上,因此 Devin 之后的 Deepseek compact 会走 WorkBuddy,而不是误打到 Devin ## 0.5.6 - 2026-09-17 diff --git a/internal/api/chat.go b/internal/api/chat.go index 84b8f54..c5cd77a 100644 --- a/internal/api/chat.go +++ b/internal/api/chat.go @@ -800,10 +800,10 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "invalid_request", "messages required") return } - if err := translate.ValidateChatRequest(req); err != nil { - writeErr(w, http.StatusBadRequest, "invalid_request", err.Error()) - return - } +if err := translate.ValidateChatRequest(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } execution, err := s.prepareChatExecution(r, req) if err != nil { writeChatHTTPError(w, err) diff --git a/internal/executor/chat.go b/internal/executor/chat.go index 389d14e..fdb958c 100644 --- a/internal/executor/chat.go +++ b/internal/executor/chat.go @@ -179,6 +179,24 @@ func itemProvider(item accounts.Item) string { return accounts.NormalizeProviderFamily(item.Provider) } +// stickyAccountCanServeModel keeps a bound cooling empty-catalog account on +// the same model so regional escape still works, but does not let that +// unknown catalog pin a later, different model (Devin → Deepseek compact). +func stickyAccountCanServeModel(item accounts.Item, publicModel string) bool { + if item.Models != nil { + return accounts.ItemCouldServeModel(item, publicModel) + } + if strings.TrimSpace(publicModel) == "" { + return true + } + if len(item.ProvenModels) == 0 { + return true + } + probe := item + probe.Models = []string{} + return accounts.ItemCouldServeModel(probe, publicModel) +} + func (e ChatExecutor) prepareRouting(ctx context.Context, prefer, providerFilter string, req translate.ChatRequest) (string, string, string, routingPlan) { prefer = strings.TrimSpace(prefer) providerFilter = strings.ToLower(strings.TrimSpace(providerFilter)) @@ -209,10 +227,10 @@ func (e ChatExecutor) prepareRouting(ctx context.Context, prefer, providerFilter e.SessionAffinity.RecordEscape("provider_not_allowed") return "", providerFilter, "", plan } - if !accounts.ItemCouldServeModel(item, publicModel) { - e.SessionAffinity.RecordEscape("model_unavailable") - return "", providerFilter, "", plan - } +if !stickyAccountCanServeModel(item, publicModel) { + e.SessionAffinity.RecordEscape("model_unavailable") + return "", providerFilter, "", plan + } return item.ID, itemProvider(item), accounts.NormalizeRegion(item.Region), routingPlan{ Source: routingSticky, SessionKey: plan.SessionKey, BoundAccount: item.ID, PublicModel: publicModel, } diff --git a/internal/executor/session_affinity_test.go b/internal/executor/session_affinity_test.go index 2e6d787..d580ce9 100644 --- a/internal/executor/session_affinity_test.go +++ b/internal/executor/session_affinity_test.go @@ -85,6 +85,31 @@ func TestChatNonStreamSessionAffinityAndPinPriority(t *testing.T) { } } +func TestChatNonStreamSessionAffinityEscapesUnknownCatalogOnLaterModel(t *testing.T) { + server := func(id string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"model":"`+id+`","choices":[{"message":{"content":"`+id+`"},"finish_reason":"stop"}],"usage":{"source":"upstream"}}`) + })) + } + devin := server("devin") + defer devin.Close() + workbuddy := server("workbuddy") + defer workbuddy.Close() + + pool := accounts.NewPool(nil, nil) + pool.Upsert(accounts.Item{ID: "devin", URL: devin.URL, Provider: "devin", Region: "global", Runtime: "child_process", Models: []string{"gpt-5-6-sol"}, ProvenModels: []string{"gpt-5-6-sol"}}) + pool.Upsert(accounts.Item{ID: "workbuddy", URL: workbuddy.URL, Provider: "workbuddy", Region: "global", Runtime: "child_process", Models: []string{"deepseek-v4.1-flash"}}) + executor := NewChatExecutor(pool, "") + executor.SessionAffinity.Bind("compact-session", "devin") + + result, err := executor.ChatNonStream(WithSessionKey(context.Background(), "compact-session"), translate.ChatRequest{ + Model: "deepseek-v4.1-flash", Messages: []translate.ChatMessage{{Role: "user", Content: "compact"}}, + }, "", "") + if err != nil || result.AccountID != "workbuddy" || result.Routing != routingPool { + t.Fatalf("result = %+v, err=%v", result, err) + } +} + func TestChatNonStreamSessionAffinityEscapesWithinBoundRegion(t *testing.T) { server := func(id string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/providers/devin/chat.go b/internal/providers/devin/chat.go index 6d482f6..1ee7799 100644 --- a/internal/providers/devin/chat.go +++ b/internal/providers/devin/chat.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "log" "net/http" "strings" "time" @@ -16,8 +18,14 @@ import ( type chatRequestBuild struct { httpReq *http.Request + payload ChatPayload originalByAlias map[string]string toolsDiag string + // fallbackStage: + // 0=initial + // 1=strip MCP-looking tools + // 2=keep only core local tools with minimal schemas (final) + fallbackStage int } func (c *Client) ChatNonStream(ctx context.Context, accountID string, req translate.ChatRequest) (providers.ChatOutcome, error) { @@ -25,29 +33,32 @@ func (c *Client) ChatNonStream(ctx context.Context, accountID string, req transl if err != nil { return providers.ChatOutcome{}, err } - built, err := c.buildChatHTTPRequest(ctx, credential, req) - if err != nil { - return providers.ChatOutcome{}, err - } client, err := c.httpClient(ctx, accountID) if err != nil { return providers.ChatOutcome{}, err } client.Timeout = 0 - resp, err := client.Do(built.httpReq) + + built, err := c.buildChatHTTPRequest(ctx, credential, req) if err != nil { return providers.ChatOutcome{}, err } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - return providers.ChatOutcome{}, classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag) - } - aggregate, err := aggregateConnectStream(resp.Body, built.originalByAlias, built.toolsDiag) - if err != nil { - return providers.ChatOutcome{}, err + var lastErr error + for { + outcome, denial, err := c.chatNonStreamOnce(client, built, req.Model) + if err == nil { + return outcome, nil + } + lastErr = err + if !denial { + return providers.ChatOutcome{}, err + } + fallback, ok := c.buildNextMCPFallback(ctx, credential, built) + if !ok { + return providers.ChatOutcome{}, lastErr + } + built = fallback } - return outcomeFromAggregate(aggregate, firstNonEmpty(req.Model, aggregate.Model)), nil } func (c *Client) ChatStream(ctx context.Context, accountID string, req translate.ChatRequest) (*http.Response, error) { @@ -55,29 +66,146 @@ func (c *Client) ChatStream(ctx context.Context, accountID string, req translate if err != nil { return nil, err } - built, err := c.buildChatHTTPRequest(ctx, credential, req) + client, err := c.httpClient(ctx, accountID) if err != nil { return nil, err } - client, err := c.httpClient(ctx, accountID) + client.Timeout = 0 + + built, err := c.buildChatHTTPRequest(ctx, credential, req) if err != nil { return nil, err } - client.Timeout = 0 + var lastErr error + for { + resp, denial, err := c.chatStreamOnce(client, built) + if err == nil { + return rewriteConnectStream(resp, firstNonEmpty(req.Model, "devin"), built.originalByAlias, built.toolsDiag) + } + lastErr = err + if !denial { + return nil, err + } + fallback, ok := c.buildNextMCPFallback(ctx, credential, built) + if !ok { + return nil, lastErr + } + built = fallback + } +} + +func (c *Client) chatNonStreamOnce(client *http.Client, built chatRequestBuild, model string) (providers.ChatOutcome, bool, error) { resp, err := client.Do(built.httpReq) if err != nil { - return nil, err + return providers.ChatOutcome{}, false, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + err := classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag) + return providers.ChatOutcome{}, isMCPConfigDenialError(err), err + } + aggregate, err := aggregateConnectStream(resp.Body, built.originalByAlias, built.toolsDiag) + if err != nil { + return providers.ChatOutcome{}, isMCPConfigDenialError(err), err + } + return outcomeFromAggregate(aggregate, firstNonEmpty(model, aggregate.Model)), false, nil +} + +func (c *Client) chatStreamOnce(client *http.Client, built chatRequestBuild) (*http.Response, bool, error) { + resp, err := client.Do(built.httpReq) + if err != nil { + return nil, false, err } if resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) resp.Body.Close() - return nil, classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag) + err := classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag) + return nil, isMCPConfigDenialError(err), err + } + // MCP configuration denials often arrive as an end-stream trailer on HTTP + // 200 before any content. Peek while we still have a fallback left + // (through core_tools). Do not drop all tools. + if built.fallbackStage < 2 { + peeked, denialErr, perr := peekConnectStreamMCPDenial(resp.Body, built.toolsDiag) + if perr != nil { + resp.Body.Close() + return nil, false, perr + } + if denialErr != nil { + resp.Body.Close() + return nil, true, denialErr + } + resp.Body = io.NopCloser(io.MultiReader(bytes.NewReader(peeked), resp.Body)) } - return rewriteConnectStream(resp, firstNonEmpty(req.Model, "devin"), built.originalByAlias, built.toolsDiag) + return resp, false, nil } func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential, req translate.ChatRequest) (chatRequestBuild, error) { payload := BuildChatPayload(req, currentLevels()) + return c.buildChatHTTPRequestFromPayload(ctx, credential, payload, 0) +} + +func (c *Client) buildNextMCPFallback(ctx context.Context, credential Credential, built chatRequestBuild) (chatRequestBuild, bool) { + switch built.fallbackStage { + case 0: + if len(built.payload.Tools) == 0 && !promptHasMCPSemantics(built.payload) { + return chatRequestBuild{}, false + } + payload := built.payload + before := len(payload.Tools) + payload.Tools = stripMCPSemanticTools(payload.Tools, payload.OriginalByAlias) + payload.Tools = scrubMCPTextFromTools(payload.Tools) + payload.Prompts = scrubMCPToolCallsFromPrompts(payload.Prompts, payload.OriginalByAlias) + payload.System = scrubMCPText(payload.System) + payload.ToolsDiag = appendFallbackDiag(built.toolsDiag, "fallback=strip_mcp", payload.Tools) + logMCPFallback("strip_mcp", before, payload.Tools, payload.ToolsDiag) + next, err := c.buildChatHTTPRequestFromPayload(ctx, credential, payload, 1) + if err != nil { + return chatRequestBuild{}, false + } + return next, true + case 1: + before := len(built.payload.Tools) + // Final fallback: always keep the core local tools with minimal + // schemas. Never drop all tools — that leaves the model unable to read + // or run commands. + payload := built.payload + payload.Tools = coreLocalTools() + payload.Prompts = scrubMCPToolCallsFromPrompts(payload.Prompts, payload.OriginalByAlias) + payload.System = scrubMCPText(payload.System) + payload.ToolsDiag = appendFallbackDiag(built.toolsDiag, "fallback=core_tools", payload.Tools) + logMCPFallback("core_tools", before, payload.Tools, payload.ToolsDiag) + next, err := c.buildChatHTTPRequestFromPayload(ctx, credential, payload, 2) + if err != nil { + return chatRequestBuild{}, false + } + return next, true + default: + return chatRequestBuild{}, false + } +} + +func appendFallbackDiag(base, label string, tools []Tool) string { + suffix := label + " out(" + fmt.Sprintf("%d", len(tools)) + ")" + base = strings.TrimSpace(base) + if base == "" { + return suffix + } + return base + " | " + suffix +} + +func logMCPFallback(stage string, before int, tools []Tool, diag string) { + names := make([]string, 0, len(tools)) + for _, tool := range tools { + if name := strings.TrimSpace(tool.Name); name != "" { + names = append(names, name) + } + } + log.Printf("devin mcp fallback stage=%s before_tools=%d after_tools=%d names=%v diag=%s", stage, before, len(tools), names, diag) +} + +func (c *Client) buildChatHTTPRequestFromPayload(ctx context.Context, credential Credential, payload ChatPayload, fallbackStage int) (chatRequestBuild, error) { proto, err := BuildGetChatMessageRequest( credential.SessionToken, credential.DeviceSeed, @@ -107,11 +235,64 @@ func (c *Client) buildChatHTTPRequest(ctx context.Context, credential Credential httpReq.Header["User-Agent"] = []string{""} return chatRequestBuild{ httpReq: httpReq, + payload: payload, originalByAlias: payload.OriginalByAlias, toolsDiag: payload.ToolsDiag, + fallbackStage: fallbackStage, }, nil } +func isMCPConfigDenialError(err error) bool { + if err == nil { + return false + } + var providerErr *providers.Error + if errors.As(err, &providerErr) { + return isDevinMCPConfigDenial(strings.ToLower(providerErr.Message)) + } + return isDevinMCPConfigDenial(strings.ToLower(err.Error())) +} + +// peekConnectStreamMCPDenial reads Connect frames until the stream either +// produces visible output or ends. If it ends with an MCP configuration +// denial and no output, the denial error is returned so the caller can retry. +// Otherwise the exact bytes consumed are returned for splicing back into Body. +func peekConnectStreamMCPDenial(r io.Reader, toolsDiag string) (peeked []byte, denialErr error, err error) { + var buf bytes.Buffer + tee := io.TeeReader(r, &buf) + sawOutput := false + for { + flag, payload, readErr := ReadConnectFrame(tee) + if readErr == io.EOF { + break + } + if readErr != nil { + return buf.Bytes(), nil, readErr + } + if flag&ConnectFlagEndStream != 0 { + if status, trailerErr := ParseTrailerError(payload); trailerErr != nil { + classified := classifiedErrorWithToolsDiag(status, trailerErr.Error(), toolsDiag) + if !sawOutput && isMCPConfigDenialError(classified) { + return buf.Bytes(), classified, nil + } + // Non-MCP trailer error (or denial after output): let the + // normal rewrite path surface it from the spliced bytes. + return buf.Bytes(), nil, nil + } + return buf.Bytes(), nil, nil + } + frame, parseErr := ParseFrame(payload) + if parseErr != nil { + return buf.Bytes(), nil, nil + } + if frame.ContentText != "" || frame.ThinkingText != "" || len(frame.ToolCallDeltas) > 0 { + sawOutput = true + return buf.Bytes(), nil, nil + } + } + return buf.Bytes(), nil, nil +} + type aggregateResult struct { Model string Content string diff --git a/internal/providers/devin/devin_test.go b/internal/providers/devin/devin_test.go index 964d67b..59b9d39 100644 --- a/internal/providers/devin/devin_test.go +++ b/internal/providers/devin/devin_test.go @@ -12,12 +12,16 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" "github.com/caigee-cmd/cli2api/internal/accounts" "github.com/caigee-cmd/cli2api/internal/providers" + apipb "github.com/caigee-cmd/cli2api/internal/providers/devin/devinpb/api_server_pb" + commonpb "github.com/caigee-cmd/cli2api/internal/providers/devin/devinpb/codeium_common_pb" "github.com/caigee-cmd/cli2api/internal/translate" + "google.golang.org/protobuf/proto" ) func TestFormatSessionToken(t *testing.T) { @@ -666,6 +670,7 @@ func TestParseToolsAliasesMCPNamespace(t *testing.T) { {"type":"function","function":{"name":"exec_command","description":"run","parameters":{"type":"object"}}}, {"type":"function","function":{"name":"mcp__computer-use__left_click","description":"click","parameters":{"type":"object"}}}, {"type":"function","function":{"name":"MCP__plugin_chrome__click","description":"click","parameters":{"type":"object"}}}, + {"type":"function","function":{"name":"list_mcp_resources","description":"list","parameters":{"type":"object"}}}, {"type":"function","function":{"name":"web_search","description":"search","parameters":{"type":"object"}}} ]`) historyCalls, _ := json.Marshal([]map[string]any{{ @@ -685,14 +690,14 @@ func TestParseToolsAliasesMCPNamespace(t *testing.T) { }, Tools: raw, }, nil) - if len(payload.Tools) != 4 { - t.Fatalf("tools=%d want 4: %+v", len(payload.Tools), payload.Tools) + if len(payload.Tools) != 5 { + t.Fatalf("tools=%d want 5: %+v", len(payload.Tools), payload.Tools) } - wantAlias := "mcp_computer_use_left_click" + wantAlias := makeDevinToolAlias("mcp__computer-use__left_click") foundAlias := false for _, tool := range payload.Tools { - if strings.HasPrefix(strings.ToLower(tool.Name), "mcp__") { - t.Fatalf("mcp tool leaked into payload: %s", tool.Name) + if strings.Contains(strings.ToLower(tool.Name), "mcp") { + t.Fatalf("mcp semantics leaked into payload: %s", tool.Name) } if tool.Name == wantAlias { foundAlias = true @@ -713,6 +718,10 @@ func TestParseToolsAliasesMCPNamespace(t *testing.T) { if restoreToolName(wantAlias, payload.OriginalByAlias) != "mcp__computer-use__left_click" { t.Fatalf("restore failed") } + listAlias := makeDevinToolAlias("list_mcp_resources") + if payload.OriginalByAlias[listAlias] != "list_mcp_resources" { + t.Fatalf("list_mcp_resources not aliased: %v", payload.OriginalByAlias) + } } func TestParseToolsExpandsNamespaceAndDropsHostedShells(t *testing.T) { @@ -740,11 +749,14 @@ func TestParseToolsExpandsNamespaceAndDropsHostedShells(t *testing.T) { if tool.Name == "web_search" || tool.Name == "computer-use" { t.Fatalf("hosted shell leaked as tool: %s", tool.Name) } + if tool.Name != "lookup" && strings.Contains(strings.ToLower(tool.Name), "mcp") { + t.Fatalf("mcp semantics leaked into payload: %s", tool.Name) + } } want := map[string]bool{ - "lookup": true, - "mcp_computer_use_left_click": true, - "mcp_computer_use_type": true, + "lookup": true, + makeDevinToolAlias("mcp__computer-use__left_click"): true, + makeDevinToolAlias("mcp__computer-use__type"): true, } if len(payload.Tools) != 3 { t.Fatalf("tools=%v want 3", names) @@ -754,15 +766,81 @@ func TestParseToolsExpandsNamespaceAndDropsHostedShells(t *testing.T) { t.Fatalf("unexpected tool %q in %v", tool.Name, names) } } - if payload.OriginalByAlias["mcp_computer_use_left_click"] != "mcp__computer-use__left_click" { + leftAlias := makeDevinToolAlias("mcp__computer-use__left_click") + if payload.OriginalByAlias[leftAlias] != "mcp__computer-use__left_click" { t.Fatalf("reverse map=%v", payload.OriginalByAlias) } } +func TestCodexToolsetAliasesHaveNoMCPSemantics(t *testing.T) { + names := []string{ + "exec_command", "write_stdin", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", + "request_user_input", "view_image", + "multi_agent_v1__close_agent", "multi_agent_v1__resume_agent", "multi_agent_v1__send_input", + "multi_agent_v1__spawn_agent", "multi_agent_v1__wait_agent", + "mcp__codex_app__automation_update", "mcp__codex_app__create_thread", "mcp__node_repl__js", + "get_goal", "create_goal", "update_goal", + } + tools := make([]map[string]any, 0, len(names)) + for _, name := range names { + tools = append(tools, map[string]any{ + "type": "function", + "function": map[string]any{ + "name": name, + "parameters": map[string]any{"type": "object"}, + }, + }) + } + raw, err := json.Marshal(tools) + if err != nil { + t.Fatal(err) + } + payload := BuildChatPayload(translate.ChatRequest{ + Model: "swe-2", + Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + Tools: raw, + }, nil) + if len(payload.Tools) != len(names) { + t.Fatalf("tools=%d want %d", len(payload.Tools), len(names)) + } + keptPlain := map[string]bool{} + for _, tool := range payload.Tools { + if strings.Contains(strings.ToLower(tool.Name), "mcp") { + t.Fatalf("outbound still has mcp semantics: %s", tool.Name) + } + keptPlain[tool.Name] = true + } + for _, plain := range []string{"exec_command", "write_stdin", "view_image", "get_goal", "multi_agent_v1__close_agent"} { + if !keptPlain[plain] { + t.Fatalf("plain tool %q was renamed unexpectedly: %+v", plain, payload.Tools) + } + } + if restoreToolName(makeDevinToolAlias("list_mcp_resources"), payload.OriginalByAlias) != "list_mcp_resources" { + t.Fatalf("list_mcp_resources restore failed: %v", payload.OriginalByAlias) + } + stripped := stripMCPSemanticTools(payload.Tools, payload.OriginalByAlias) + if countMCPSemanticTools(stripped, payload.OriginalByAlias) != 0 { + t.Fatalf("strip left mcp tools: %+v", stripped) + } + if len(stripped) == 0 || len(stripped) >= len(payload.Tools) { + t.Fatalf("strip count=%d from %d", len(stripped), len(payload.Tools)) + } +} + func TestChatStreamRestoresMCPToolName(t *testing.T) { original := "mcp__computer-use__left_click" - alias := "mcp_computer_use_left_click" - toolFrame := []byte("\x32\x2e\x0a\x06call_1\x12\x1bmcp_computer_use_left_click\x1a\x07{\"x\":2}\x28\x0a") + alias := makeDevinToolAlias(original) + toolFrame, err := proto.Marshal(&apipb.GetChatMessageResponse{ + DeltaToolCalls: []*commonpb.ChatToolCall{{ + Id: "call_1", + Name: alias, + ArgumentsJson: `{"x":2}`, + }}, + StopReason: commonpb.StopReason_STOP_REASON_FUNCTION_CALL, + }) + if err != nil { + t.Fatal(err) + } var buf bytes.Buffer buf.Write(WrapConnectEnvelope(toolFrame)) @@ -780,6 +858,9 @@ func TestChatStreamRestoresMCPToolName(t *testing.T) { if !bytes.Contains(body, []byte(alias)) { t.Errorf("upstream request missing aliased mcp name") } + if bytes.Contains(body, []byte("mcp_computer_use_left_click")) { + t.Errorf("upstream request still uses soft mcp_ alias") + } w.Header().Set("Content-Type", ContentTypeConnectProto) _, _ = w.Write(buf.Bytes()) })) @@ -821,6 +902,231 @@ func TestChatStreamRestoresMCPToolName(t *testing.T) { } } +func TestChatNonStreamFallsBackAfterMCPConfigDenial(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != PathGetChatMessage { + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + n := calls.Add(1) + w.Header().Set("Content-Type", ContentTypeConnectProto) + if n == 1 { + if !bytes.Contains(body, []byte(makeDevinToolAlias("list_mcp_resources"))) { + t.Errorf("first request missing aliased list_mcp_resources") + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`permission_denied: Unable to process request due to an MCP configuration issue.`)) + return + } + if bytes.Contains(body, []byte(makeDevinToolAlias("list_mcp_resources"))) || bytes.Contains(body, []byte("list_mcp_resources")) { + t.Errorf("fallback request still contains mcp tool") + } + if !bytes.Contains(body, []byte("exec_command")) { + t.Errorf("fallback request dropped plain exec_command") + } + var buf bytes.Buffer + buf.Write(WrapConnectEnvelope([]byte("\x1a\x02OK"))) + buf.Write(WrapConnectEnvelopeWithFlag(ConnectFlagEndStream, []byte(`{}`))) + _, _ = w.Write(buf.Bytes()) + })) + defer server.Close() + + store := newMemStore() + store.accounts["acc1"] = accounts.Account{ID: "acc1", Provider: "devin", ProviderRegion: "global"} + cred := Credential{SessionToken: FormatSessionToken("eyJabc.def.ghi"), DeviceSeed: "seed", BaseURL: server.URL} + payload, err := cred.Encode() + if err != nil { + t.Fatal(err) + } + store.creds["acc1"] = payload + client := NewClient(store) + client.SetBases(AppBase, APIBase, server.URL) + + tools := json.RawMessage(`[ + {"type":"function","function":{"name":"exec_command","parameters":{"type":"object"}}}, + {"type":"function","function":{"name":"list_mcp_resources","parameters":{"type":"object"}}}, + {"type":"function","function":{"name":"mcp__codex_app__create_thread","parameters":{"type":"object"}}} + ]`) + out, err := client.ChatNonStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "swe-2", + Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + Tools: tools, + }) + if err != nil { + t.Fatal(err) + } + if out.Content != "OK" { + t.Fatalf("content=%q", out.Content) + } + if calls.Load() != 2 { + t.Fatalf("calls=%d want 2", calls.Load()) + } +} + +func TestChatStreamFallsBackAfterImmediateMCPTrailer(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != PathGetChatMessage { + http.NotFound(w, r) + return + } + n := calls.Add(1) + w.Header().Set("Content-Type", ContentTypeConnectProto) + var buf bytes.Buffer + if n == 1 { + buf.Write(WrapConnectEnvelopeWithFlag(ConnectFlagEndStream, []byte(`{"error":{"code":"permission_denied","message":"Unable to process request due to an MCP configuration issue."}}`))) + _, _ = w.Write(buf.Bytes()) + return + } + buf.Write(WrapConnectEnvelope([]byte("\x1a\x02OK"))) + buf.Write(WrapConnectEnvelopeWithFlag(ConnectFlagEndStream, []byte(`{}`))) + _, _ = w.Write(buf.Bytes()) + })) + defer server.Close() + + store := newMemStore() + store.accounts["acc1"] = accounts.Account{ID: "acc1", Provider: "devin", ProviderRegion: "global"} + cred := Credential{SessionToken: FormatSessionToken("eyJabc.def.ghi"), DeviceSeed: "seed", BaseURL: server.URL} + payload, err := cred.Encode() + if err != nil { + t.Fatal(err) + } + store.creds["acc1"] = payload + client := NewClient(store) + client.SetBases(AppBase, APIBase, server.URL) + + tools := json.RawMessage(`[ + {"type":"function","function":{"name":"exec_command","parameters":{"type":"object"}}}, + {"type":"function","function":{"name":"mcp__node_repl__js","parameters":{"type":"object"}}} + ]`) + resp, err := client.ChatStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "swe-2", + Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + Tools: tools, + }) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "OK") { + t.Fatalf("body=%s", body) + } + if calls.Load() != 2 { + t.Fatalf("calls=%d want 2", calls.Load()) + } +} + +func TestChatStreamKeepsCoreToolsAfterStripStillDenied(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != PathGetChatMessage { + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + n := calls.Add(1) + w.Header().Set("Content-Type", ContentTypeConnectProto) + var buf bytes.Buffer + switch n { + case 1: + if !bytes.Contains(body, []byte(makeDevinToolAlias("list_mcp_resources"))) { + t.Errorf("initial request missing aliased list_mcp_resources") + } + buf.Write(WrapConnectEnvelopeWithFlag(ConnectFlagEndStream, []byte(`{"error":{"code":"permission_denied","message":"Unable to process request due to an MCP configuration issue."}}`))) + _, _ = w.Write(buf.Bytes()) + return + case 2: + if !bytes.Contains(body, []byte("exec_command")) { + t.Errorf("strip_mcp fallback missing exec_command") + } + if bytes.Contains(body, []byte("list_mcp_resources")) || bytes.Contains(body, []byte(makeDevinToolAlias("list_mcp_resources"))) { + t.Errorf("strip_mcp fallback still has mcp tool") + } + buf.Write(WrapConnectEnvelopeWithFlag(ConnectFlagEndStream, []byte(`{"error":{"code":"permission_denied","message":"Unable to process request due to an MCP configuration issue."}}`))) + _, _ = w.Write(buf.Bytes()) + return + default: + if !bytes.Contains(body, []byte("exec_command")) { + t.Errorf("core_tools fallback missing exec_command") + } + if !bytes.Contains(body, []byte("write_stdin")) || !bytes.Contains(body, []byte("view_image")) || !bytes.Contains(body, []byte("request_user_input")) { + t.Errorf("core_tools fallback missing one of the core tools") + } + if bytes.Contains(body, []byte("MCP configuration")) || bytes.Contains(body, []byte("mcp_server")) { + t.Errorf("core_tools fallback still carries MCP wording") + } + if bytes.Contains(body, []byte("get_goal")) || bytes.Contains(body, []byte("multi_agent_v1__")) { + t.Errorf("core_tools fallback kept non-core tools") + } + buf.Write(WrapConnectEnvelope([]byte("\x1a\x02OK"))) + buf.Write(WrapConnectEnvelopeWithFlag(ConnectFlagEndStream, []byte(`{}`))) + _, _ = w.Write(buf.Bytes()) + } + })) + defer server.Close() + + store := newMemStore() + store.accounts["acc1"] = accounts.Account{ID: "acc1", Provider: "devin", ProviderRegion: "global"} + cred := Credential{SessionToken: FormatSessionToken("eyJabc.def.ghi"), DeviceSeed: "seed", BaseURL: server.URL} + payload, err := cred.Encode() + if err != nil { + t.Fatal(err) + } + store.creds["acc1"] = payload + client := NewClient(store) + client.SetBases(AppBase, APIBase, server.URL) + + tools := json.RawMessage(`[ + {"type":"function","function":{"name":"exec_command","description":"Runs a command alongside MCP configuration","parameters":{"type":"object","properties":{"mcp_server":{"type":"string"}}}}}, + {"type":"function","function":{"name":"write_stdin","description":"write","parameters":{"type":"object"}}}, + {"type":"function","function":{"name":"get_goal","description":"goal","parameters":{"type":"object"}}}, + {"type":"function","function":{"name":"list_mcp_resources","parameters":{"type":"object"}}} + ]`) + resp, err := client.ChatStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "swe-2", + Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + Tools: tools, + }) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "OK") { + t.Fatalf("body=%s", body) + } + if calls.Load() != 3 { + t.Fatalf("calls=%d want 3", calls.Load()) + } +} + +func TestKeepCoreLocalToolsUsesMinimalSchemas(t *testing.T) { + in := []Tool{ + {Name: "get_goal", Description: "goal", Parameters: json.RawMessage(`{"type":"object"}`)}, + {Name: "exec_command", Description: "Runs a command alongside MCP configuration", Parameters: json.RawMessage(`{"type":"object","properties":{"mcp_server":{"type":"string"}}}`)}, + {Name: "write_stdin", Description: "write", Parameters: json.RawMessage(`{"type":"object"}`)}, + } + got := keepCoreLocalTools(in) + if len(got) != 2 { + t.Fatalf("tools=%+v want 2", got) + } + if got[0].Name != "exec_command" || got[1].Name != "write_stdin" { + t.Fatalf("order=%+v", got) + } + if strings.Contains(strings.ToLower(got[0].Description), "mcp") || strings.Contains(strings.ToLower(string(got[0].Parameters)), "mcp") { + t.Fatalf("exec_command still has mcp wording: %+v", got[0]) + } +} + func TestCredentialErrorsDoNotLeakToken(t *testing.T) { secret := "eyJsuper.secret.token.value" raw := []byte(`{"session_token":"` + secret + `"}`) diff --git a/internal/providers/devin/payload.go b/internal/providers/devin/payload.go index 1a665b6..6a91994 100644 --- a/internal/providers/devin/payload.go +++ b/internal/providers/devin/payload.go @@ -335,7 +335,15 @@ func newToolAliasMaps() *toolAliasMaps { } func needsDevinToolAlias(name string) bool { - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(name)), "mcp__") + lower := strings.ToLower(strings.TrimSpace(name)) + if lower == "" { + return false + } + // Codex Desktop ships both mcp__server__tool names and MCP meta-tools + // like list_mcp_resources. Devin treats any of these as an MCP + // configuration surface and rejects the whole request, so alias every + // name that still carries mcp semantics. + return strings.Contains(lower, "mcp") } func (m *toolAliasMaps) alias(original string) string { @@ -362,17 +370,228 @@ func (m *toolAliasMaps) alias(original string) string { } func makeDevinToolAlias(original string) string { - alias := strings.ReplaceAll(original, "__", "_") - alias = strings.ReplaceAll(alias, "-", "_") - alias = strings.TrimSpace(alias) - if alias == "" { - alias = "mcp_tool" + // Always use a neutral hash alias. Softening mcp__ to mcp_ still trips + // Devin's MCP configuration check. + sum := sha256.Sum256([]byte(original)) + alias := "cx_tool_" + hex.EncodeToString(sum[:8]) + if len(alias) > maxDevinToolAliasLen { + return alias[:maxDevinToolAliasLen] } - if len(alias) <= maxDevinToolAliasLen && !strings.Contains(alias, "__") { - return alias + return alias +} + +// stripMCPSemanticTools drops tools that were aliased from MCP-looking names +// (or still look like MCP). Used for the one-shot fallback retry after an MCP +// configuration denial so ordinary Codex tools can still proceed. +func stripMCPSemanticTools(tools []Tool, originalByAlias map[string]string) []Tool { + if len(tools) == 0 { + return nil } - sum := sha256.Sum256([]byte(original)) - return "mcp_" + hex.EncodeToString(sum[:8]) + out := make([]Tool, 0, len(tools)) + for _, tool := range tools { + original := tool.Name + if mapped, ok := originalByAlias[tool.Name]; ok && mapped != "" { + original = mapped + } + if needsDevinToolAlias(original) || needsDevinToolAlias(tool.Name) { + continue + } + out = append(out, tool) + } + return out +} + +func countMCPSemanticTools(tools []Tool, originalByAlias map[string]string) int { + count := 0 + for _, tool := range tools { + original := tool.Name + if mapped, ok := originalByAlias[tool.Name]; ok && mapped != "" { + original = mapped + } + if needsDevinToolAlias(original) || needsDevinToolAlias(tool.Name) { + count++ + } + } + return count +} + +func promptHasMCPSemantics(payload ChatPayload) bool { + if strings.Contains(strings.ToLower(payload.System), "mcp") { + return true + } + for _, prompt := range payload.Prompts { + if strings.Contains(strings.ToLower(prompt.Content), "mcp") { + return true + } + if strings.Contains(strings.ToLower(prompt.Thinking), "mcp") { + return true + } + for _, call := range prompt.ToolCalls { + name := call.Name + if mapped, ok := payload.OriginalByAlias[name]; ok && mapped != "" { + name = mapped + } + if needsDevinToolAlias(name) || needsDevinToolAlias(call.Name) { + return true + } + if strings.Contains(strings.ToLower(call.Arguments), "mcp") { + return true + } + } + } + for _, tool := range payload.Tools { + if toolLooksLikeMCP(tool, payload.OriginalByAlias) { + return true + } + } + return false +} + +func toolLooksLikeMCP(tool Tool, originalByAlias map[string]string) bool { + original := tool.Name + if mapped, ok := originalByAlias[tool.Name]; ok && mapped != "" { + original = mapped + } + if needsDevinToolAlias(original) || needsDevinToolAlias(tool.Name) { + return true + } + if strings.Contains(strings.ToLower(tool.Description), "mcp") { + return true + } + return strings.Contains(strings.ToLower(string(tool.Parameters)), "mcp") +} + +func scrubMCPText(text string) string { + if text == "" || !strings.Contains(strings.ToLower(text), "mcp") { + return text + } + // Keep structure readable while removing the upstream-triggering token. + replacer := strings.NewReplacer( + "MCP", "tool", + "mcp", "tool", + "Mcp", "tool", + ) + return replacer.Replace(text) +} + +func scrubMCPTextFromTools(tools []Tool) []Tool { + if len(tools) == 0 { + return nil + } + out := make([]Tool, 0, len(tools)) + for _, tool := range tools { + tool.Description = scrubMCPText(tool.Description) + if len(tool.Parameters) > 0 { + tool.Parameters = json.RawMessage(scrubMCPText(string(tool.Parameters))) + } + out = append(out, tool) + } + return out +} + +func scrubMCPToolCallsFromPrompts(prompts []Prompt, originalByAlias map[string]string) []Prompt { + if len(prompts) == 0 { + return prompts + } + out := make([]Prompt, len(prompts)) + copy(out, prompts) + for i := range out { + out[i].Content = scrubMCPText(out[i].Content) + out[i].Thinking = scrubMCPText(out[i].Thinking) + if len(out[i].ToolCalls) == 0 { + continue + } + calls := make([]ToolCall, 0, len(out[i].ToolCalls)) + for _, call := range out[i].ToolCalls { + name := call.Name + if mapped, ok := originalByAlias[name]; ok && mapped != "" { + name = mapped + } + if needsDevinToolAlias(name) || needsDevinToolAlias(call.Name) { + continue + } + call.Arguments = scrubMCPText(call.Arguments) + calls = append(calls, call) + } + out[i].ToolCalls = calls + } + return out +} + +// Core local Codex tools that remain useful after MCP tools are stripped. +// Keep this list tight: these are the ones needed for reading/running locally. +var coreLocalToolOrder = []string{ + "exec_command", + "write_stdin", + "view_image", + "request_user_input", +} + +var coreLocalToolSchemas = map[string]struct { + description string + parameters string +}{ + "exec_command": { + description: "Runs a command and returns its output.", + parameters: `{"type":"object","properties":{"cmd":{"type":"string"},"workdir":{"type":"string"},"timeout_ms":{"type":"number"}},"required":["cmd"]}`, + }, + "write_stdin": { + description: "Writes characters to a running command session.", + parameters: `{"type":"object","properties":{"chars":{"type":"string"},"session_id":{"type":"string"}},"required":["chars"]}`, + }, + "view_image": { + description: "Views a local image file.", + parameters: `{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}`, + }, + "request_user_input": { + description: "Asks the user a question.", + parameters: `{"type":"object","properties":{"question":{"type":"string"}},"required":["question"]}`, + }, +} + +// coreLocalTools returns the always-available core local tools with sanitized +// descriptions/schemas that do not carry Codex MCP wording. +func coreLocalTools() []Tool { + out := make([]Tool, 0, len(coreLocalToolOrder)) + for _, name := range coreLocalToolOrder { + schema := coreLocalToolSchemas[name] + out = append(out, Tool{ + Name: name, + Description: schema.description, + Parameters: json.RawMessage(schema.parameters), + }) + } + return out +} + +// keepCoreLocalTools returns only the core local tools present in the inbound +// set, rewritten onto the sanitized schemas. Prefer coreLocalTools() for the +// final MCP fallback so exec_command remains available even if the client did +// not send it in that turn's tools array. +func keepCoreLocalTools(tools []Tool) []Tool { + if len(tools) == 0 { + return nil + } + byName := map[string]Tool{} + for _, tool := range tools { + name := strings.TrimSpace(tool.Name) + if _, ok := coreLocalToolSchemas[name]; ok { + byName[name] = tool + } + } + out := make([]Tool, 0, len(coreLocalToolOrder)) + for _, name := range coreLocalToolOrder { + if _, ok := byName[name]; !ok { + continue + } + schema := coreLocalToolSchemas[name] + out = append(out, Tool{ + Name: name, + Description: schema.description, + Parameters: json.RawMessage(schema.parameters), + }) + } + return out } func restoreToolName(name string, originalByAlias map[string]string) string { diff --git a/internal/translate/compat.go b/internal/translate/compat.go index e1c743c..1c016ac 100644 --- a/internal/translate/compat.go +++ b/internal/translate/compat.go @@ -119,21 +119,21 @@ func TranslateAnthropicMessages(request AnthropicMessagesRequest) (ChatRequest, if err != nil { return ChatRequest{}, err } - chat.Tools = tools - toolChoice, err := translateAnthropicToolChoice(request.ToolChoice) - if err != nil { - return ChatRequest{}, err - } - chat.ToolChoice = toolChoice - chat.ParallelToolCalls = anthropicParallelToolCalls(request.ToolChoice) - if effort := anthropicReasoningEffort(request.OutputConfig); len(effort) > 0 { - chat.ReasoningEffort = effort - } - if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil { - return ChatRequest{}, err +chat.Tools = tools + toolChoice, err := translateAnthropicToolChoice(request.ToolChoice) + if err != nil { + return ChatRequest{}, err + } + chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice) + chat.ParallelToolCalls = anthropicParallelToolCalls(request.ToolChoice) + if effort := anthropicReasoningEffort(request.OutputConfig); len(effort) > 0 { + chat.ReasoningEffort = effort + } + if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil { + return ChatRequest{}, err + } + return chat, nil } - return chat, nil -} func TranslateResponses(request ResponsesRequest) (ChatRequest, error) { if strings.TrimSpace(request.PreviousID) != "" || !emptyJSON(request.Conversation) { @@ -175,21 +175,24 @@ func TranslateResponses(request ResponsesRequest) (ChatRequest, error) { if err != nil { return ChatRequest{}, err } - chat.Tools = tools - toolChoice, err := translateResponsesToolChoice(request.ToolChoice) - if err != nil { - return ChatRequest{}, err - } - chat.ToolChoice = toolChoice - chat.ResponseFormat, err = translateResponsesTextFormat(request.Text) - if err != nil { - return ChatRequest{}, err - } - if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil { - return ChatRequest{}, err +chat.Tools = tools + toolChoice, err := translateResponsesToolChoice(request.ToolChoice) + if err != nil { + return ChatRequest{}, err + } + // Codex Desktop compact / recovery turns can keep a tool_choice while + // tools normalize to empty (hosted shells dropped, etc). Drop the + // orphan choice instead of failing the whole turn. + chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice) + chat.ResponseFormat, err = translateResponsesTextFormat(request.Text) + if err != nil { + return ChatRequest{}, err + } + if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil { + return ChatRequest{}, err + } + return chat, nil } - return chat, nil -} func anthropicMessageParts(raw json.RawMessage) (any, []compatibilityToolCall, []compatibilityToolResult, error) { if rawText, ok := rawJSONString(raw); ok { @@ -636,16 +639,75 @@ func anthropicParallelToolCalls(raw json.RawMessage) *bool { return nil } -func ValidateChatRequest(request ChatRequest) error { +func ValidateChatRequest(request *ChatRequest) error { + if request == nil { + return fmt.Errorf("request required") + } if strings.TrimSpace(request.Model) == "" { return fmt.Errorf("model required") } if len(request.MaxTokens) > 0 && len(request.MaxCompletionTokens) > 0 { return fmt.Errorf("max_tokens and max_completion_tokens are mutually exclusive") } + request.ToolChoice = sanitizeToolChoice(request.Tools, request.ToolChoice) return validateToolChoice(request.Tools, request.ToolChoice) } +// sanitizeToolChoice drops orphan tool_choice values when tools are empty, and +// clears named tool choices that no longer exist after tool normalization. +func sanitizeToolChoice(tools, choice json.RawMessage) json.RawMessage { + if emptyJSON(choice) { + return nil + } + if emptyJSON(tools) { + return nil + } + var declared []map[string]json.RawMessage + if json.Unmarshal(tools, &declared) != nil || len(declared) == 0 { + return nil + } + names := make(map[string]bool, len(declared)) + for _, tool := range declared { + name, _ := rawJSONString(tool["name"]) + if name == "" { + fn, _ := tool["function"] + var function map[string]json.RawMessage + if json.Unmarshal(fn, &function) == nil { + name, _ = rawJSONString(function["name"]) + } + } + if name != "" { + names[strings.TrimSpace(name)] = true + } + } + if text, ok := rawJSONString(choice); ok { + switch strings.ToLower(strings.TrimSpace(text)) { + case "none", "auto", "required": + return choice + default: + if names[strings.TrimSpace(text)] { + return choice + } + return nil + } + } + var object map[string]json.RawMessage + if json.Unmarshal(choice, &object) != nil { + return choice + } + name := rawMapString(object, "name") + if name == "" { + var function map[string]json.RawMessage + if json.Unmarshal(object["function"], &function) == nil { + name, _ = rawJSONString(function["name"]) + } + } + if name != "" && !names[strings.TrimSpace(name)] { + return nil + } + return choice +} + func validateToolChoice(tools, choice json.RawMessage) error { if emptyJSON(choice) { return nil diff --git a/internal/translate/compat_test.go b/internal/translate/compat_test.go index 5893ea5..a5d268e 100644 --- a/internal/translate/compat_test.go +++ b/internal/translate/compat_test.go @@ -6,6 +6,38 @@ import ( "testing" ) +func TestTranslateResponsesDropsOrphanToolChoiceWhenToolsEmpty(t *testing.T) { + chat, err := TranslateResponses(ResponsesRequest{ + Model: "devin/gpt-5-6-sol", + Input: json.RawMessage(`[{"role":"user","content":[{"type":"input_text","text":"compact this conversation"}]}]`), + Tools: json.RawMessage(`[{"type":"mcp","server_label":"codex_app"},{"type":"web_search"}]`), + ToolChoice: json.RawMessage(`{"type":"function","name":"exec_command"}`), + }) + if err != nil { + t.Fatal(err) + } + if len(chat.Tools) > 0 { + t.Fatalf("tools=%s want empty after hosted shells drop", chat.Tools) + } + if len(chat.ToolChoice) > 0 { + t.Fatalf("tool_choice=%s want dropped", chat.ToolChoice) + } +} + +func TestValidateChatRequestDropsOrphanToolChoice(t *testing.T) { + req := ChatRequest{ + Model: "devin/gpt-5-6-sol", + Messages: []ChatMessage{{Role: "user", Content: "compact"}}, + ToolChoice: json.RawMessage(`"auto"`), + } + if err := ValidateChatRequest(&req); err != nil { + t.Fatal(err) + } + if len(req.ToolChoice) > 0 { + t.Fatalf("tool_choice=%s want dropped", req.ToolChoice) + } +} + func TestTranslateResponsesUnquotesFunctionCallArguments(t *testing.T) { request := ResponsesRequest{ Model: "qoder/glm-5.2", diff --git a/internal/translate/session_seed_test.go b/internal/translate/session_seed_test.go index c6da7af..20b2d5b 100644 --- a/internal/translate/session_seed_test.go +++ b/internal/translate/session_seed_test.go @@ -5,6 +5,21 @@ import ( "testing" ) +func TestContentSessionSeedDiffersByModel(t *testing.T) { + shared := []ChatMessage{ + {Role: "system", Content: "you are a bot"}, + {Role: "user", Content: "compact this conversation"}, + } + devin := ContentSessionSeed(ChatRequest{Model: "devin/gpt-5-6-sol", Messages: shared}) + deepseek := ContentSessionSeed(ChatRequest{Model: "deepseek-v4.1-flash", Messages: shared}) + if devin == "" || deepseek == "" { + t.Fatalf("expected seeds, got devin=%q deepseek=%q", devin, deepseek) + } + if devin == deepseek { + t.Fatal("different models must not share a session seed") + } +} + func TestContentSessionSeedStableAcrossLaterTurns(t *testing.T) { first := ChatRequest{ Model: "GLM-5.2",