From a5986eca7e5a57bbaf965b82464324b850fea567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 2 May 2026 12:00:22 +0800 Subject: [PATCH] Prevent DeepSeek disabled-thinking effort conflicts DeepSeek-compatible Anthropic endpoints reject requests that combine an explicit disabled-thinking block with an effort value. The desktop UI can create that shape when users disable thinking and keep medium effort selected, so the request builder now omits effort whenever it will send explicit disabled thinking. Constraint: DeepSeek-compatible Anthropic endpoints reject disabled thinking with effort Rejected: Only patch Desktop provider settings | hidden title and side request paths still share the request builder Confidence: high Scope-risk: narrow Directive: Keep explicit disabled-thinking providers from sending effort unless the upstream contract changes Tested: UI repro on pre-fix DeepSeek provider path reproduced the 400 Tested: desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh Tested: bun test src/utils/__tests__/thinking.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/title-service.test.ts Tested: git diff --check Not-tested: Real DeepSeek API key live call --- .../e2e-deepseek-thinking-ui-agent-browser.sh | 325 ++++++++++++++++++ src/services/api/claude.ts | 32 +- 2 files changed, 345 insertions(+), 12 deletions(-) create mode 100755 desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh diff --git a/desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh b/desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh new file mode 100755 index 00000000..9c2b2e53 --- /dev/null +++ b/desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh @@ -0,0 +1,325 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required for this UI E2E" >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ARTIFACT_DIR="${ARTIFACT_DIR:-/tmp/cc-haha-e2e-deepseek-thinking-$$}" +CONFIG_DIR="${ARTIFACT_DIR}/config" +UPSTREAM_PORT="${UPSTREAM_PORT:-49391}" +API_PORT="${API_PORT:-49392}" +WEB_PORT="${WEB_PORT:-49393}" +SESSION_NAME="${SESSION_NAME:-cc-haha-deepseek-thinking-ui-$$}" + +mkdir -p "${ARTIFACT_DIR}" + +UPSTREAM_PID="" +SERVER_PID="" +WEB_PID="" + +cleanup() { + agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true + if [[ -n "${WEB_PID}" ]]; then kill "${WEB_PID}" >/dev/null 2>&1 || true; fi + if [[ -n "${SERVER_PID}" ]]; then kill "${SERVER_PID}" >/dev/null 2>&1 || true; fi + if [[ -n "${UPSTREAM_PID}" ]]; then kill "${UPSTREAM_PID}" >/dev/null 2>&1 || true; fi + wait "${WEB_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${UPSTREAM_PID}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +ARTIFACT_DIR="${ARTIFACT_DIR}" UPSTREAM_PORT="${UPSTREAM_PORT}" node --input-type=module <<'NODE' & +import http from 'node:http' +import { mkdirSync, writeFileSync } from 'node:fs' + +const artifactDir = process.env.ARTIFACT_DIR +const port = Number(process.env.UPSTREAM_PORT) +mkdirSync(artifactDir, { recursive: true }) +const captures = [] + +function record(req, body) { + captures.push({ + ts: new Date().toISOString(), + method: req.method, + url: req.url, + headers: req.headers, + body, + }) + writeFileSync(`${artifactDir}/captures.json`, JSON.stringify(captures, null, 2)) +} + +function anthropicSse() { + return [ + 'event: message_start', + 'data: {"type":"message_start","message":{"id":"msg_ui_test","type":"message","role":"assistant","model":"deepseek-v4-pro","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}}', + '', + 'event: content_block_start', + 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}', + '', + 'event: content_block_delta', + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}', + '', + 'event: content_block_stop', + 'data: {"type":"content_block_stop","index":0}', + '', + 'event: message_delta', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}', + '', + 'event: message_stop', + 'data: {"type":"message_stop"}', + '', + ].join('\n') +} + +function openaiChatSse() { + return [ + 'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}', + '', + 'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]}', + '', + 'data: {"id":"chatcmpl-ui","object":"chat.completion.chunk","created":1,"model":"deepseek-v4-pro","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', + '', + 'data: [DONE]', + '', + ].join('\n') +} + +http.createServer((req, res) => { + let raw = '' + req.on('data', chunk => { raw += chunk }) + req.on('end', () => { + let body = {} + try { + body = raw ? JSON.parse(raw) : {} + } catch { + body = { raw } + } + record(req, body) + + const hasConflict = + body?.thinking?.type === 'disabled' && + body?.output_config?.effort !== undefined + if (hasConflict) { + res.writeHead(400, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ + error: { + message: 'thinking options type cannot be disabled when reasoning_effort is set', + type: 'invalid_request_error', + param: null, + code: 'invalid_request_error', + }, + })) + return + } + + if (req.url?.includes('/v1/chat/completions')) { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end(openaiChatSse()) + return + } + + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end(anthropicSse()) + }) +}).listen(port, '127.0.0.1', () => { + console.log(`fake upstream listening on ${port}`) +}) +NODE +UPSTREAM_PID=$! + +( + cd "${ROOT_DIR}" + CLAUDE_CONFIG_DIR="${CONFIG_DIR}" SERVER_PORT="${API_PORT}" \ + bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}" +) & +SERVER_PID=$! + +( + cd "${ROOT_DIR}/desktop" + VITE_DESKTOP_SERVER_URL="http://127.0.0.1:${API_PORT}" \ + bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" +) & +WEB_PID=$! + +wait_for_url() { + local url="$1" + for _ in {1..80}; do + if curl -fsS "${url}" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + echo "Timed out waiting for ${url}" >&2 + exit 1 +} + +wait_for_url "http://127.0.0.1:${API_PORT}/health" +wait_for_url "http://127.0.0.1:${WEB_PORT}" + +AB=(agent-browser --session "${SESSION_NAME}") +APP_URL="http://127.0.0.1:${WEB_PORT}" +UPSTREAM_URL="http://127.0.0.1:${UPSTREAM_PORT}" + +"${AB[@]}" open "${APP_URL}" >/dev/null + +ui_eval() { + "${AB[@]}" eval "$1" >/dev/null +} + +ui_eval " +const buttonByName = (name) => [...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === name); +buttonByName('设置')?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('添加服务商')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +const setValue = (el, value) => { + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); +}; +const byPlaceholder = (placeholder) => [...document.querySelectorAll('input,textarea')] + .find((node) => node.getAttribute('placeholder') === placeholder); +setValue(byPlaceholder('https://api.example.com/anthropic'), '${UPSTREAM_URL}/anthropic'); +setValue(byPlaceholder('sk-...'), 'ui-test-key'); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === '添加' && !node.disabled) + ?.click(); +" +"${AB[@]}" wait 700 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('设为默认')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').includes('通用')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === '中') + ?.click(); +const thinking = [...document.querySelectorAll('input[type=\"checkbox\"]')] + .find((node) => node.getAttribute('aria-label') === '启用思考模式'); +if (thinking?.checked) thinking.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval "document.querySelector('button[aria-label=\"Close 设置\"]')?.click();" +"${AB[@]}" wait 300 >/dev/null +"${AB[@]}" fill textarea "Say ok" >/dev/null +"${AB[@]}" eval "[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('运行') && !node.disabled)?.click();" >/dev/null +"${AB[@]}" wait 5000 >/dev/null + +node --input-type=module < capture.url.includes('/anthropic/v1/messages?beta=true')) +if (!main) throw new Error('No DeepSeek Anthropic UI request captured') +if (main.body?.thinking?.type !== 'disabled') throw new Error('DeepSeek request did not disable thinking') +if (main.body?.output_config?.effort !== undefined) { + throw new Error('DeepSeek request still sends output_config.effort with disabled thinking') +} +NODE + +ui_eval " +const buttonByName = (name) => [...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === name); +buttonByName('设置')?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('添加服务商')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === 'Custom') + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('Anthropic Messages')) + ?.click(); +" +"${AB[@]}" wait 100 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').includes('OpenAI Chat Completions')) + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +const setValue = (el, value) => { + const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); +}; +const byPlaceholder = (placeholder) => [...document.querySelectorAll('input,textarea')] + .find((node) => node.getAttribute('placeholder') === placeholder); +setValue(byPlaceholder('服务商名称'), 'Custom OpenAI DeepSeek'); +setValue(byPlaceholder('https://api.example.com/anthropic'), '${UPSTREAM_URL}'); +setValue(byPlaceholder('sk-...'), 'ui-test-key'); +setValue(byPlaceholder('Model ID'), 'deepseek-v4-pro'); +const sameAsMain = [...document.querySelectorAll('input')] + .filter((node) => node.getAttribute('placeholder') === '与主模型相同'); +setValue(sameAsMain[0], 'deepseek-v4-flash'); +setValue(sameAsMain[1], 'deepseek-v4-pro'); +setValue(sameAsMain[2], 'deepseek-v4-pro'); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.textContent || '').trim() === '添加' && !node.disabled) + ?.click(); +" +"${AB[@]}" wait 700 >/dev/null +ui_eval " +const buttons = [...document.querySelectorAll('button')] + .filter((node) => (node.textContent || '').includes('设为默认')); +buttons.at(-1)?.click(); +" +"${AB[@]}" wait 300 >/dev/null +ui_eval "document.querySelector('button[aria-label=\"Close 设置\"]')?.click();" +"${AB[@]}" wait 300 >/dev/null +ui_eval " +[...document.querySelectorAll('button')] + .find((node) => (node.getAttribute('aria-label') || node.textContent || '').trim() === '新建会话') + ?.click(); +" +"${AB[@]}" wait 300 >/dev/null +"${AB[@]}" fill textarea "Say ok through custom openai" >/dev/null +"${AB[@]}" eval "[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('运行') && !node.disabled)?.click();" >/dev/null +"${AB[@]}" wait 5000 >/dev/null + +node --input-type=module < capture.url.includes('/v1/chat/completions')) +if (!openai) throw new Error('No Custom OpenAI Chat UI request captured') +if (openai.body?.thinking !== undefined) throw new Error('OpenAI Chat request leaked Anthropic thinking') +if (openai.body?.output_config !== undefined) throw new Error('OpenAI Chat request leaked Anthropic output_config') +if (openai.body?.reasoning_effort !== undefined) throw new Error('OpenAI Chat request unexpectedly sent reasoning_effort') +NODE + +echo "UI E2E passed. Artifacts: ${ARTIFACT_DIR}" diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 3e567955..fada8f82 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1557,17 +1557,28 @@ async function* queryModel( : [] const extraBodyParams = getExtraBodyParams(bedrockBetas) + const hasThinking = + thinkingConfig.type !== 'disabled' && + !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING) + const modelCanThink = modelSupportsThinking(options.model) + const sendsExplicitDisabledThinking = + !(hasThinking && modelCanThink) && shouldSendExplicitDisabledThinking() + const outputConfig: BetaOutputConfig = { ...((extraBodyParams.output_config as BetaOutputConfig) ?? {}), } - configureEffortParams( - effort, - outputConfig, - extraBodyParams, - betasParams, - options.model, - ) + if (sendsExplicitDisabledThinking) { + delete outputConfig.effort + } else { + configureEffortParams( + effort, + outputConfig, + extraBodyParams, + betasParams, + options.model, + ) + } configureTaskBudgetParams( options.taskBudget, @@ -1594,15 +1605,12 @@ async function* queryModel( options.maxOutputTokensOverride || getMaxOutputTokensForModel(options.model) - const hasThinking = - thinkingConfig.type !== 'disabled' && - !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING) let thinking: BetaMessageStreamParams['thinking'] | undefined = undefined // IMPORTANT: Do not change the adaptive-vs-budget thinking selection below // without notifying the model launch DRI and research. This is a sensitive // setting that can greatly affect model quality and bashing. - if (hasThinking && modelSupportsThinking(options.model)) { + if (hasThinking && modelCanThink) { if ( !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING) && modelSupportsAdaptiveThinking(options.model) @@ -1628,7 +1636,7 @@ async function* queryModel( type: 'enabled', } satisfies BetaMessageStreamParams['thinking'] } - } else if (shouldSendExplicitDisabledThinking()) { + } else if (sendsExplicitDisabledThinking) { thinking = { type: 'disabled', } as unknown as BetaMessageStreamParams['thinking']