mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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
This commit is contained in:
parent
c13fa75c3b
commit
a5986eca7e
325
desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh
Executable file
325
desktop/scripts/e2e-deepseek-thinking-ui-agent-browser.sh
Executable file
@ -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 <<NODE
|
||||
import { readFileSync } from 'node:fs'
|
||||
const captures = JSON.parse(readFileSync('${ARTIFACT_DIR}/captures.json', 'utf8'))
|
||||
const main = captures.find((capture) => 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 <<NODE
|
||||
import { readFileSync } from 'node:fs'
|
||||
const captures = JSON.parse(readFileSync('${ARTIFACT_DIR}/captures.json', 'utf8'))
|
||||
const openai = captures.find((capture) => 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}"
|
||||
@ -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']
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user