diff --git a/.env.example b/.env.example index 4f277d5..1168d13 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,13 @@ CODEBUDDY_LOG_LEVEL=INFO # Optional. Per-query timeout in milliseconds. Defaults to 15000, clamped to 1000-60000. # SEARXNG_TIMEOUT_MS=15000 +# Translate downstream thinking parameters into the reasoning_effort vocabulary +# that Hy-series models expect. Disabled by default, which forwards requests +# unchanged; enabled, it maps Claude Code's thinking and Codex's +# reasoning.effort onto no_think / low / high. +# Accepts: true / false (also 1 / 0). +# CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED=false + # Models are discovered from each saved credential. No model-list setting is needed. # Optional file-backend data directory. Default: .codebuddy_data diff --git a/app/settings/settings.tsx b/app/settings/settings.tsx index 4701571..1e4254f 100644 --- a/app/settings/settings.tsx +++ b/app/settings/settings.tsx @@ -93,11 +93,14 @@ const settingsPlaceholders: Record = { }; /** - * Settings rendered as a switch instead of a text input. `CODEBUDDY_WEB_SEARCH_ENABLED` - * is the only boolean in the config today; the server hides its label when no - * search backend is configured, so the UI only ever sees it when it is usable. + * Settings rendered as a switch instead of a text input. These are the boolean + * entries in the config; the server hides the web search label when no search + * backend is configured, so the UI only ever sees it when it is usable. */ -const BOOLEAN_SETTING_KEYS = new Set(['CODEBUDDY_WEB_SEARCH_ENABLED']); +const BOOLEAN_SETTING_KEYS = new Set([ + 'CODEBUDDY_WEB_SEARCH_ENABLED', + 'CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED', +]); const isTruthySetting = (value: SettingsValue): boolean => { return value === true || value === 'true' || value === '1'; @@ -431,7 +434,9 @@ const Settings = () => { ? translations('settingsPanel.apiTimeoutHint') : settingKey === 'CODEBUDDY_WEB_SEARCH_ENABLED' ? translations('settingsPanel.webSearchDescription') - : undefined + : settingKey === 'CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED' + ? translations('settingsPanel.hyThoughtDepthHint') + : undefined } key={settingKey} label={label} diff --git a/docs/en/guide/settings.md b/docs/en/guide/settings.md index c9195fa..fb879f6 100644 --- a/docs/en/guide/settings.md +++ b/docs/en/guide/settings.md @@ -4,14 +4,15 @@ Settings controls service parameters, credential models, usage data, and console ## Service settings -| Field | Purpose | -| ----------------------------------------- | ----------------------------------------------------------------- | -| CodeBuddy API endpoint | Upstream URL; default `https://copilot.tencent.com` | -| Admin passkey RP ID / domain | WebAuthn hostname only; do not include scheme or port | -| Authentication mode (auto/token) | Upstream authentication method | -| Network environment (internal/ioa/public) | Upstream network environment | -| Log level | Choose `DEBUG`, `INFO`, `WARNING`, or `ERROR` | -| API timeout, first token (minutes) | Abort a request that produces no first delta in time; default `5` | +| Field | Purpose | +| ----------------------------------------- | ------------------------------------------------------------------------------------------ | +| CodeBuddy API endpoint | Upstream URL; default `https://copilot.tencent.com` | +| Admin passkey RP ID / domain | WebAuthn hostname only; do not include scheme or port | +| Authentication mode (auto/token) | Upstream authentication method | +| Network environment (internal/ioa/public) | Upstream network environment | +| Log level | Choose `DEBUG`, `INFO`, `WARNING`, or `ERROR` | +| API timeout, first token (minutes) | Abort a request that produces no first delta in time; default `5` | +| Translate thought depth for Hy models | Convert downstream thinking parameters into the upstream `reasoning_effort`; default `off` | Click **Save** after changing a field. @@ -22,6 +23,28 @@ it takes. Fractional minutes are accepted, clamped to `0.1`–`1440`. Set the equivalent `CODEBUDDY_API_TIMEOUT_MINUTES` environment variable to seed the value before the console is ever opened. +Hy-series models (`hy3` and friends) accept only three `reasoning_effort` values +— `no_think`, `low` and `high` — and no downstream client speaks that +vocabulary: Claude Code sends Anthropic `thinking`, while Codex sends Responses +`reasoning.effort`. Enabling the setting converts both onto the Hy vocabulary: + +| Downstream value | Converted to | +| -------------------------------------------- | ------------ | +| `thinking.type: disabled`, `minimal`, `none` | `no_think` | +| `budget_tokens` ≤ 8K, `low`, `medium` | `low` | +| `budget_tokens` > 8K, `high`, `xhigh`, `max` | `high` | + +Any model id starting with `hy` counts as a Hy model, case-insensitively, so +`hy3` and `hy3-ioa` match today and a future `hy4` is covered without a code +change. `hunyuan-*` is a different prefix and a separate product line, so it +does not match. + +Once translated, the original `thinking` block is dropped: leaving it alongside +the converted effort would ask for the same thing twice in two vocabularies, and +would still be rejected by the upstream this conversion exists to satisfy. The +setting defaults to off, which forwards requests unchanged. Seed it before the +console is opened with `CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED` (`true` / `false`). + ## Models and usage - **Credential models** lists models for each credential; edit the list or click **Refresh**. diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 9c39b68..8dcb0ba 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -6,19 +6,35 @@ 填写或选择以下项目后点击「保存」: -| 页面字段 | 说明 | -| ------------------------------- | -------------------------------------------- | -| CodeBuddy 官方 API 端点 | 上游地址,默认 `https://copilot.tencent.com` | -| 管理员 Passkey RP ID / 域名 | WebAuthn 使用的 hostname,不要填写协议或端口 | -| 认证模式(auto/token) | 上游认证方式 | -| 网络环境(internal/ioa/public) | 上游网络环境 | -| 日志级别 | 选择 `DEBUG`、`INFO`、`WARNING` 或 `ERROR` | -| API 超时时间,首个 token(分钟) | 首个 delta 迟迟不返回时中断请求;默认 `5` | +| 页面字段 | 说明 | +| ------------------------------- | ----------------------------------------------------- | +| CodeBuddy 官方 API 端点 | 上游地址,默认 `https://copilot.tencent.com` | +| 管理员 Passkey RP ID / 域名 | WebAuthn 使用的 hostname,不要填写协议或端口 | +| 认证模式(auto/token) | 上游认证方式 | +| 网络环境(internal/ioa/public) | 上游网络环境 | +| 日志级别 | 选择 `DEBUG`、`INFO`、`WARNING` 或 `ERROR` | +| API 超时时间,首个 token(分钟) | 首个 delta 迟迟不返回时中断请求;默认 `5` | +| 为 Hy 系列模型转换思想深度 | 把下游思考参数转为上游的 `reasoning_effort`;默认关闭 | API 超时时间从发起请求开始计时,直到上游返回第一个 delta,因此它限制的是「迟迟没有开始输出」的等待 时间。一旦开始输出,即使回答较长也会允许其完成。支持小数分钟,取值范围 `0.1`~`1440`。也可以在打开 控制台之前通过环境变量 `CODEBUDDY_API_TIMEOUT_MINUTES` 预设该值。 +Hy 系列模型(`hy3` 等)只接受 `reasoning_effort` 的 `no_think` / `low` / `high` 三档,而下游客户端 +并不使用这套词表:Claude Code 发送 Anthropic `thinking`,Codex 发送 Responses `reasoning.effort`。 +开启后,本服务会把两者转换到 Hy 的词表: + +| 下游取值 | 转换结果 | +| -------------------------------------------- | ---------- | +| `thinking.type: disabled`、`minimal`、`none` | `no_think` | +| `budget_tokens` ≤ 8K、`low`、`medium` | `low` | +| `budget_tokens` > 8K、`high`、`xhigh`、`max` | `high` | + +模型名以 `hy` 开头即视为 Hy 模型(忽略大小写),因此 `hy3`、`hy3-ioa`、以及未来的 `hy4` 都会生效; +`hunyuan-*` 是另一个前缀、属于不同产品线,不会被匹配。转换成功后,原始的 `thinking` 字段会被移除, +避免用两种词表重复表达同一件事、也避免上游因收到不认识的结构而报错。默认关闭,即原样转发、不做任何 +转换。也可以通过环境变量 `CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED` 预设(`true` / `false`)。 + ## 凭证模型和用量 - 「凭证模型」列出每个凭证支持的模型;可以编辑模型列表,或点击「刷新」重新获取。 diff --git a/docs/ja/guide/settings.md b/docs/ja/guide/settings.md index 2793e86..e474f43 100644 --- a/docs/ja/guide/settings.md +++ b/docs/ja/guide/settings.md @@ -4,14 +4,15 @@ ## サービス設定 -| 項目 | 用途 | -| --------------------------------------- | ------------------------------------------------------------- | -| CodeBuddy API エンドポイント | 上流 URL。既定値は `https://copilot.tencent.com` | -| 管理者 passkey RP ID / ドメイン | WebAuthn の hostname。スキームとポートは含めない | -| 認証モード(auto/token) | 上流認証方式 | -| ネットワーク環境(internal/ioa/public) | 上流ネットワーク環境 | -| ログレベル | `DEBUG`、`INFO`、`WARNING`、`ERROR` を選択 | -| API タイムアウト・最初のトークン (分) | 最初の delta が返らないリクエストを打ち切る時間。既定値は `5` | +| 項目 | 用途 | +| --------------------------------------- | -------------------------------------------------------------------------- | +| CodeBuddy API エンドポイント | 上流 URL。既定値は `https://copilot.tencent.com` | +| 管理者 passkey RP ID / ドメイン | WebAuthn の hostname。スキームとポートは含めない | +| 認証モード(auto/token) | 上流認証方式 | +| ネットワーク環境(internal/ioa/public) | 上流ネットワーク環境 | +| ログレベル | `DEBUG`、`INFO`、`WARNING`、`ERROR` を選択 | +| API タイムアウト・最初のトークン (分) | 最初の delta が返らないリクエストを打ち切る時間。既定値は `5` | +| Hy モデルの思考深度を変換する | 下流の思考パラメーターを上流の `reasoning_effort` へ変換する。既定値は無効 | 変更後に「保存」を押します。 @@ -20,6 +21,23 @@ API タイムアウトは、リクエスト送信から上流が最初の delta 完了まで許可されます。小数分を指定でき、`0.1`〜`1440` に制限されます。コンソールを開く前に値を 設定する場合は、環境変数 `CODEBUDDY_API_TIMEOUT_MINUTES` を使用してください。 +Hy モデル(`hy3` など)は `reasoning_effort` の `no_think` / `low` / `high` のみを受け付けますが、 +下流クライアントはこの語彙を使いません。Claude Code は Anthropic の `thinking` を、Codex は +Responses の `reasoning.effort` を送信します。この設定を有効にすると、両者を Hy の語彙へ変換します。 + +| 下流の値 | 変換結果 | +| -------------------------------------------- | ---------- | +| `thinking.type: disabled`、`minimal`、`none` | `no_think` | +| `budget_tokens` ≤ 8K、`low`、`medium` | `low` | +| `budget_tokens` > 8K、`high`、`xhigh`、`max` | `high` | + +モデル名が `hy` で始まるものはすべて Hy モデルとして扱います(大文字小文字を無視)。そのため `hy3` や +`hy3-ioa` は一致し、将来の `hy4` もコード変更なしで対象になります。`hunyuan-*` は別の接頭辞・別の +製品系列のため一致しません。変換できた場合、元の `thinking` フィールドは削除されます。同じ内容を +2 つの語彙で二重に指定することを避け、この変換が対象とする上流での拒否も防ぐためです。既定値は +無効で、リクエストはそのまま転送されます。コンソールを開く前に環境変数 +`CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED` で設定できます(`true` / `false`)。 + ## モデルと使用量 - 「認証情報モデル」ではモデル一覧を編集するか、「更新」を押して再取得します。 diff --git a/lib/server/domain/config.ts b/lib/server/domain/config.ts index 612cce7..2d580ee 100644 --- a/lib/server/domain/config.ts +++ b/lib/server/domain/config.ts @@ -20,8 +20,33 @@ export interface RuntimeConfig { CODEBUDDY_LOG_LEVEL: string; CODEBUDDY_API_TIMEOUT_MINUTES: number; CODEBUDDY_WEB_SEARCH_ENABLED: boolean; + CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: boolean; } +/** + * Hy-series models take their thinking depth as `reasoning_effort` with the + * values `no_think` / `low` / `high` — a vocabulary no downstream client + * speaks. Claude Code sends Anthropic `thinking`, Codex sends Responses + * `reasoning.effort`. When this is on, those are translated onto the Hy + * vocabulary; when off, requests are forwarded exactly as they arrive. + */ + +/** + * Every model id starting with `hy` is a Hy-series model and takes the + * `reasoning_effort` vocabulary, so matching is a single case-insensitive + * prefix test rather than an enumeration of known ids: the upstream decides + * which models exist, and new `hy*` releases should be covered without a code + * change. `hunyuan-*` is a different prefix and a separate product line, so it + * is not affected. + */ +export const HY_MODEL_PREFIX = 'hy'; + +export const isHyModel = (model: string | undefined | null): boolean => { + if (typeof model !== 'string') return false; + + return model.trim().toLowerCase().startsWith(HY_MODEL_PREFIX); +}; + /** * Budget for a proxied request to produce its first delta, in minutes. It is * deliberately generous: a slow model that is thinking still has to clear it, @@ -44,6 +69,7 @@ const DEFAULT_CONFIG: RuntimeConfig = { CODEBUDDY_LOG_LEVEL: 'INFO', CODEBUDDY_API_TIMEOUT_MINUTES: DEFAULT_API_TIMEOUT_MINUTES, CODEBUDDY_WEB_SEARCH_ENABLED: false, + CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: false, }; let configMutationQueue: Promise = Promise.resolve(); @@ -59,6 +85,7 @@ const SETTING_LABELS_BY_LOCALE: Record< CODEBUDDY_LOG_LEVEL: 'Log level', CODEBUDDY_API_TIMEOUT_MINUTES: 'API timeout, first token (minutes)', CODEBUDDY_WEB_SEARCH_ENABLED: 'Enable local web search', + CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: 'Translate thought depth for Hy models', }, 'ja-JP': { CODEBUDDY_API_ENDPOINT: 'CodeBuddy API エンドポイント', @@ -68,6 +95,7 @@ const SETTING_LABELS_BY_LOCALE: Record< CODEBUDDY_LOG_LEVEL: 'ログレベル', CODEBUDDY_API_TIMEOUT_MINUTES: 'API タイムアウト・最初のトークン (分)', CODEBUDDY_WEB_SEARCH_ENABLED: 'ローカル Web 検索を有効化', + CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: 'Hy モデルの思考深度を変換する', }, 'zh-CN': { CODEBUDDY_API_ENDPOINT: 'CodeBuddy 官方 API 端点', @@ -77,6 +105,7 @@ const SETTING_LABELS_BY_LOCALE: Record< CODEBUDDY_LOG_LEVEL: '日志级别', CODEBUDDY_API_TIMEOUT_MINUTES: 'API 超时时间,首个 token(分钟)', CODEBUDDY_WEB_SEARCH_ENABLED: '启用本地 WebSearch', + CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: '为 Hy 系列模型转换思想深度', }, }; @@ -217,6 +246,11 @@ export const getActiveConfig = async (): Promise => { persisted.CODEBUDDY_WEB_SEARCH_ENABLED ?? process.env.CODEBUDDY_WEB_SEARCH_ENABLED, ), + CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: normalizeValue( + 'CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED', + persisted.CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED ?? + process.env.CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED, + ), }; }; @@ -269,6 +303,17 @@ export const getApiFirstDeltaTimeoutMs = async (): Promise => { return config.CODEBUDDY_API_TIMEOUT_MINUTES * MINUTE_MS; }; +/** + * Whether downstream thinking parameters should be translated onto the Hy + * vocabulary. Resolved per request so toggling the setting in the console takes + * effect immediately. + */ +export const getHyThoughtDepthEnabled = async (): Promise => { + const config = await getActiveConfig(); + + return config.CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED; +}; + export const getCodeBuddyApiEndpoint = async (): Promise => { const config = await getActiveConfig(); const explicit = config.CODEBUDDY_API_ENDPOINT.trim(); diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index d38ec33..a8c0442 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -24,6 +24,10 @@ import { type DebugTrace, } from '../domain/debug'; import { createErrorResponse, getRequestHeaderMap } from '../shared/http'; +import { + resolveHyChatThinking, + resolveHyResponsesReasoning, +} from '../shared/hy-thought-depth'; import { type ChatCompletionPayload, executeWebSearchLoop, @@ -850,6 +854,8 @@ const buildUpstreamBody = async ( ? body.model : (credentialModels[0] ?? (await getDefaultModel())); + const hyThinking = await resolveHyChatThinking(model, body); + return { model, messages: normalizedMessages, @@ -866,8 +872,8 @@ const buildUpstreamBody = async ( tools: body.tools, tool_choice: body.tool_choice, parallel_tool_calls: body.parallel_tool_calls, - thinking: body.thinking, - reasoning_effort: body.reasoning_effort, + thinking: hyThinking.thinking, + reasoning_effort: hyThinking.reasoningEffort, }; }; @@ -1051,13 +1057,29 @@ const getPendingStopPrefixLength = ( return 0; }; -const normalizeResponsesUpstreamBody = ( +/** + * Codex sends `reasoning.effort` in the OpenAI vocabulary, which Hy models do + * not accept, so the effort is rewritten onto the Hy vocabulary before the body + * is forwarded. + */ +const resolveHyResponsesBody = async ( body: Record, -): Record => { +): Promise> => { + const reasoning = await resolveHyResponsesReasoning( + typeof body.model === 'string' ? body.model : undefined, + body.reasoning as Record | undefined, + ); + + return { ...body, reasoning }; +}; + +const normalizeResponsesUpstreamBody = async ( + body: Record, +): Promise> => { const { messages, ...rest } = body; if (rest.input !== undefined || !Array.isArray(messages)) { - return rest; + return resolveHyResponsesBody(rest); } const systemInstructions = messages @@ -1095,12 +1117,16 @@ const normalizeResponsesUpstreamBody = ( .filter(Boolean) .join('\n\n'); - return { ...rest, ...(instructions ? { instructions } : {}), input }; + return resolveHyResponsesBody({ + ...rest, + ...(instructions ? { instructions } : {}), + input, + }); }; -const buildResponsesBodyFromChat = ( +const buildResponsesBodyFromChat = async ( body: ChatRequestBody, -): Record => { +): Promise> => { const instructions = body.messages ?.filter( (message) => message.role === 'system' || message.role === 'developer', @@ -1183,9 +1209,9 @@ const buildResponsesBodyFromChat = ( ]; }); const text = translateChatResponseFormatToResponses(body.response_format); - const reasoning = translateChatThinkingToResponses( - body.thinking, - body.reasoning_effort, + const reasoning = await resolveHyResponsesReasoning( + body.model, + translateChatThinkingToResponses(body.thinking, body.reasoning_effort), ); return { @@ -2764,7 +2790,7 @@ export const proxyChatCompletions = async ( await buildUpstreamHeaders(request, resolvedContext.auth), ); const responsesBody = { - ...buildResponsesBodyFromChat(upstreamBody), + ...(await buildResponsesBodyFromChat(upstreamBody)), stream: Boolean(body.stream), }; @@ -2978,7 +3004,7 @@ export const proxyResponsesUpstream = async ( )); setDebugTraceCredential(debugTrace, resolvedContext.credentialFilename); const upstreamBody = { - ...normalizeResponsesUpstreamBody(body), + ...(await normalizeResponsesUpstreamBody(body)), model: typeof body.model === 'string' && body.model.trim() ? body.model diff --git a/lib/server/shared/hy-thought-depth.ts b/lib/server/shared/hy-thought-depth.ts new file mode 100644 index 0000000..df0c196 --- /dev/null +++ b/lib/server/shared/hy-thought-depth.ts @@ -0,0 +1,188 @@ +import { getHyThoughtDepthEnabled, isHyModel } from '../domain/config'; + +/** + * Hy-series models (hy3 and friends) take their thinking depth as + * `reasoning_effort`, whose only accepted values are `no_think`, `low` and + * `high`. No downstream client speaks that vocabulary: + * + * - Claude Code sends Anthropic `thinking: {type, budget_tokens}`. + * - Codex sends Responses `reasoning: {effort}`, where `effort` is one of + * `minimal`/`low`/`medium`/`high`/`xhigh`/`max`. + * - Plain Chat clients send `reasoning_effort` in the OpenAI vocabulary. + * + * Forwarding any of those verbatim makes the upstream reject the request or + * silently ignore the intent, so each is converted onto the Hy vocabulary here. + * The mapping is fixed by the upstream contract rather than by configuration: + * the console switch only decides whether conversion happens at all. + */ + +export const HY_EFFORT_NO_THINK = 'no_think'; +export const HY_EFFORT_LOW = 'low'; +export const HY_EFFORT_HIGH = 'high'; + +/** + * Anthropic has no notion of "no thinking" other than omitting the block, and + * its `budget_tokens` is a token count rather than a named level. The 1_024 + * threshold is the Anthropic minimum for enabling thinking at all, so anything + * below it means the caller effectively asked for no thinking. + */ +const ANTHROPIC_MIN_THINKING_BUDGET = 1_024; + +/** + * OpenAI's `reasoning_effort` vocabulary is finer-grained than Hy's three + * levels, so neighbouring values collapse onto the closest Hy level instead of + * being dropped. + */ +const OPENAI_EFFORT_TO_HY: Record = { + high: HY_EFFORT_HIGH, + low: HY_EFFORT_LOW, + max: HY_EFFORT_HIGH, + medium: HY_EFFORT_LOW, + minimal: HY_EFFORT_NO_THINK, + none: HY_EFFORT_NO_THINK, + xhigh: HY_EFFORT_HIGH, +}; + +/** + * Anthropic `budget_tokens` is a raw token budget, not a level, so it is bucketed + * against the output sizes the Hy levels correspond to: `no_think` caps out at + * 8K, `low` is recommended around 16K and `high` reaches 64K. The upstream fixes + * the sizes but not the reverse mapping, so the cut points are ours: at or below + * 8K the request still fits `low`'s recommended envelope, above it only `high` + * can produce the output that was asked for. + * + * Below the Anthropic minimum for enabling thinking at all there is effectively + * no thinking to pay for, so it resolves to `no_think`. + */ +const anthropicBudgetToHyEffort = (budgetTokens: number): string => { + if ( + !Number.isFinite(budgetTokens) || + budgetTokens < ANTHROPIC_MIN_THINKING_BUDGET + ) { + return HY_EFFORT_NO_THINK; + } + + return budgetTokens <= 8_192 ? HY_EFFORT_LOW : HY_EFFORT_HIGH; +}; + +const normalizeEffort = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined; + const normalized = value.trim().toLowerCase(); + + return normalized || undefined; +}; + +/** + * Converts Anthropic `thinking` into the Hy `reasoning_effort` value it + * expresses. `type: 'disabled'` is the explicit "no thinking" case; otherwise + * the token budget decides the level, and a missing budget still means the + * caller asked for thinking, so it resolves to `high` rather than to nothing. + */ +export const anthropicThinkingToHyEffort = ( + thinking: { budget_tokens?: number; type?: string } | undefined, +): string | undefined => { + if (!thinking || typeof thinking !== 'object') return undefined; + + const type = normalizeEffort(thinking.type); + + if (type === 'disabled' || type === 'none') return HY_EFFORT_NO_THINK; + if (type !== 'enabled' && type !== 'adaptive') return undefined; + + const budgetTokens = + typeof thinking.budget_tokens === 'number' + ? thinking.budget_tokens + : Number.NaN; + + return Number.isFinite(budgetTokens) + ? anthropicBudgetToHyEffort(budgetTokens) + : HY_EFFORT_HIGH; +}; + +/** + * Converts an effort level from either the OpenAI or the Responses vocabulary + * into the Hy one. Unknown levels yield `undefined` so the caller can leave the + * field out rather than send something the upstream would reject. + */ +export const openaiEffortToHyEffort = (effort: unknown): string | undefined => { + const normalized = normalizeEffort(effort); + + return normalized ? OPENAI_EFFORT_TO_HY[normalized] : undefined; +}; + +/** + * Resolves the thinking fields to send upstream for a Chat request. + * + * The client's own `reasoning_effort` wins when it is already a Hy value, since + * that needs no conversion. Anthropic `thinking` is only consulted for Hy models + * because it is a different protocol's field, and applying it to a non-Hy model + * would change behaviour for upstreams that already understand it. + * + * `thinking` is reported separately so the caller can drop it once it has been + * translated: leaving the original Anthropic block in place alongside the + * converted `reasoning_effort` would still be rejected by the very upstream this + * translation exists to satisfy, and it would also ask twice, in two different + * vocabularies, for the same thing. + */ +export const resolveHyChatThinking = async ( + model: string | undefined, + body: { + reasoning_effort?: string; + thinking?: { budget_tokens?: number; type?: string }; + }, +): Promise<{ + reasoningEffort: string | undefined; + thinking: { budget_tokens?: number; type?: string } | undefined; +}> => { + const fallback = { + reasoningEffort: body.reasoning_effort, + thinking: body.thinking, + }; + + if (!(await getHyThoughtDepthEnabled())) return fallback; + if (!isHyModel(model)) return fallback; + + const clientEffort = normalizeEffort(body.reasoning_effort); + + if ( + clientEffort === HY_EFFORT_NO_THINK || + clientEffort === HY_EFFORT_LOW || + clientEffort === HY_EFFORT_HIGH + ) { + return { reasoningEffort: clientEffort, thinking: undefined }; + } + + const fromClientEffort = openaiEffortToHyEffort(body.reasoning_effort); + + if (fromClientEffort) { + return { reasoningEffort: fromClientEffort, thinking: undefined }; + } + + const fromThinking = anthropicThinkingToHyEffort(body.thinking); + + // Only drop `thinking` when it actually produced a value; an unrecognized + // shape is left alone so the request is forwarded exactly as it arrived. + return fromThinking + ? { reasoningEffort: fromThinking, thinking: undefined } + : fallback; +}; + +/** + * Resolves the `reasoning` object to send upstream for a Responses request, + * converting a non-Hy effort level onto the Hy vocabulary. + */ +export const resolveHyResponsesReasoning = async ( + model: string | undefined, + reasoning: Record | undefined, +): Promise | undefined> => { + if (!(await getHyThoughtDepthEnabled())) return reasoning; + if (!isHyModel(model)) return reasoning; + if (!reasoning || typeof reasoning !== 'object') return reasoning; + + const effort = normalizeEffort(reasoning.effort); + + if (!effort) return reasoning; + + const converted = openaiEffortToHyEffort(effort); + + return converted ? { ...reasoning, effort: converted } : reasoning; +}; diff --git a/messages/en-US.json b/messages/en-US.json index d01b008..21ae021 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -438,6 +438,7 @@ }, "settingsPanel": { "apiTimeoutHint": "Measured from when a request is sent until the upstream produces its first delta. Once output has started, a long answer is allowed to finish. Accepts fractional minutes, from 0.1 to 1440.", + "hyThoughtDepthHint": "Converts downstream thinking parameters into the upstream reasoning_effort for Hy models. Claude Code's thinking and Codex's reasoning.effort both map onto no_think / low / high. Defaults to off, which forwards requests unchanged.", "clearUsageEvents": "Clear usage event cache", "confirmClearUsageEvents": "Clear all stored usage events?", "helper": "The passkey RP ID should usually match the final site domain. HTTPS works directly; localhost is commonly allowed for local development, while other non-HTTPS domains usually cannot use passkeys.", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 27de024..cb019e5 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -438,6 +438,7 @@ }, "settingsPanel": { "apiTimeoutHint": "リクエスト送信からアップストリームが最初の delta を返すまでの時間です。出力が始まった後は、長い応答でも完了まで許可されます。0.1〜1440 の範囲で小数分を指定できます。", + "hyThoughtDepthHint": "Hy モデル向けに、下流の思考パラメーターを上流の reasoning_effort へ変換します。Claude Code の thinking と Codex の reasoning.effort はいずれも no_think / low / high へマッピングされます。既定値は off で、リクエストはそのまま転送されます。", "clearUsageEvents": "使用量イベントキャッシュを削除", "confirmClearUsageEvents": "保存済みの使用量イベントをすべて削除しますか?", "helper": "Passkey の RP ID は通常、最終的にアクセスするドメインと一致させます。HTTPS ではそのまま利用でき、localhost はローカル開発で使えることが多い一方、それ以外の非 HTTPS ドメインでは通常 passkey を利用できません。", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 0124806..f09d1b7 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -434,6 +434,7 @@ }, "settingsPanel": { "apiTimeoutHint": "从发起请求开始计时,直到上游返回第一个 delta。一旦开始输出,即使回答较长也会允许其完成。支持小数分钟,取值范围 0.1~1440。", + "hyThoughtDepthHint": "为 Hy 系列模型把下游的思考参数转换为上游的 reasoning_effort。Claude Code 的 thinking 与 Codex 的 reasoning.effort 都会映射到 no_think / low / high;默认 off,即原样转发。", "clearUsageEvents": "清空用量统计缓存", "confirmClearUsageEvents": "确认清空所有已保存的用量统计记录吗?", "helper": "Passkey RP ID 通常应与最终访问域名一致。HTTPS 站点可直接使用;localhost 在现代浏览器中通常可用于本地调试,其它非 HTTPS 域名通常无法使用 passkey。", diff --git a/tests/server/hy-thought-depth.test.ts b/tests/server/hy-thought-depth.test.ts new file mode 100644 index 0000000..fc4504b --- /dev/null +++ b/tests/server/hy-thought-depth.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/server/domain/config', () => ({ + getHyThoughtDepthEnabled: vi.fn(), + isHyModel: (model: string | undefined | null) => + typeof model === 'string' && model.trim().toLowerCase().startsWith('hy'), +})); + +const { getHyThoughtDepthEnabled } = await import('@/lib/server/domain/config'); +const { + anthropicThinkingToHyEffort, + openaiEffortToHyEffort, + resolveHyChatThinking, + resolveHyResponsesReasoning, +} = await import('@/lib/server/shared/hy-thought-depth'); + +const setEnabled = (enabled: boolean) => { + vi.mocked(getHyThoughtDepthEnabled).mockResolvedValue(enabled); +}; + +describe('hy thought depth conversion', () => { + beforeEach(() => { + vi.clearAllMocks(); + setEnabled(true); + }); + + describe('openaiEffortToHyEffort', () => { + it('maps the Openai vocabulary onto the three Hy levels', () => { + expect(openaiEffortToHyEffort('minimal')).toBe('no_think'); + expect(openaiEffortToHyEffort('none')).toBe('no_think'); + expect(openaiEffortToHyEffort('low')).toBe('low'); + expect(openaiEffortToHyEffort('medium')).toBe('low'); + expect(openaiEffortToHyEffort('high')).toBe('high'); + expect(openaiEffortToHyEffort('xhigh')).toBe('high'); + expect(openaiEffortToHyEffort('max')).toBe('high'); + }); + + it('ignores case and surrounding whitespace', () => { + expect(openaiEffortToHyEffort(' HIGH ')).toBe('high'); + }); + + it('returns undefined for unknown levels so the caller omits the field', () => { + expect(openaiEffortToHyEffort('ultracode')).toBeUndefined(); + expect(openaiEffortToHyEffort('')).toBeUndefined(); + expect(openaiEffortToHyEffort(undefined)).toBeUndefined(); + expect(openaiEffortToHyEffort(42)).toBeUndefined(); + }); + }); + + describe('anthropicThinkingToHyEffort', () => { + it('treats a disabled thinking block as no_think', () => { + expect(anthropicThinkingToHyEffort({ type: 'disabled' })).toBe( + 'no_think', + ); + }); + + it('reads a small budget as no_think', () => { + expect( + anthropicThinkingToHyEffort({ budget_tokens: 512, type: 'enabled' }), + ).toBe('no_think'); + }); + + it('reads a moderate budget as low', () => { + expect( + anthropicThinkingToHyEffort({ budget_tokens: 5_000, type: 'enabled' }), + ).toBe('low'); + expect( + anthropicThinkingToHyEffort({ budget_tokens: 8_192, type: 'enabled' }), + ).toBe('low'); + }); + + it('reads a large budget as high', () => { + expect( + anthropicThinkingToHyEffort({ budget_tokens: 10_000, type: 'enabled' }), + ).toBe('high'); + }); + + it('defaults an enabled block without a budget to high', () => { + expect(anthropicThinkingToHyEffort({ type: 'enabled' })).toBe('high'); + }); + + it('leaves unknown thinking shapes alone', () => { + expect(anthropicThinkingToHyEffort(undefined)).toBeUndefined(); + expect( + anthropicThinkingToHyEffort({ type: 'something-else' }), + ).toBeUndefined(); + }); + }); + + describe('resolveHyChatThinking', () => { + it('passes an already-Hy value through untouched', async () => { + await expect( + resolveHyChatThinking('hy3', { reasoning_effort: 'high' }), + ).resolves.toEqual({ reasoningEffort: 'high', thinking: undefined }); + await expect( + resolveHyChatThinking('hy3', { reasoning_effort: 'no_think' }), + ).resolves.toEqual({ reasoningEffort: 'no_think', thinking: undefined }); + }); + + it('converts a Codex effort onto the Hy vocabulary', async () => { + await expect( + resolveHyChatThinking('hy3', { reasoning_effort: 'medium' }), + ).resolves.toEqual({ reasoningEffort: 'low', thinking: undefined }); + await expect( + resolveHyChatThinking('hy3', { reasoning_effort: 'xhigh' }), + ).resolves.toEqual({ reasoningEffort: 'high', thinking: undefined }); + }); + + it('converts Claude Code thinking and drops the original block', async () => { + // Leaving the Anthropic block in place would still be rejected by the + // upstream this translation exists to satisfy. + await expect( + resolveHyChatThinking('hy3', { + thinking: { budget_tokens: 16_000, type: 'enabled' }, + }), + ).resolves.toEqual({ reasoningEffort: 'high', thinking: undefined }); + await expect( + resolveHyChatThinking('hy3', { thinking: { type: 'disabled' } }), + ).resolves.toEqual({ reasoningEffort: 'no_think', thinking: undefined }); + }); + + it('prefers the chat effort over the Anthropic thinking block', async () => { + await expect( + resolveHyChatThinking('hy3', { + reasoning_effort: 'no_think', + thinking: { budget_tokens: 16_000, type: 'enabled' }, + }), + ).resolves.toEqual({ reasoningEffort: 'no_think', thinking: undefined }); + }); + + it('keeps thinking for an unrecognized shape rather than dropping it', async () => { + const thinking = { type: 'something-else' }; + + await expect(resolveHyChatThinking('hy3', { thinking })).resolves.toEqual( + { reasoningEffort: undefined, thinking }, + ); + }); + + it('leaves non-Hy models untouched', async () => { + await expect( + resolveHyChatThinking('glm-5.1', { reasoning_effort: 'medium' }), + ).resolves.toEqual({ + reasoningEffort: 'medium', + thinking: undefined, + }); + + const thinking = { type: 'disabled' }; + await expect( + resolveHyChatThinking('glm-5.1', { thinking }), + ).resolves.toEqual({ reasoningEffort: undefined, thinking }); + }); + + it('does not convert when the setting is off', async () => { + setEnabled(false); + + await expect( + resolveHyChatThinking('hy3', { reasoning_effort: 'medium' }), + ).resolves.toEqual({ + reasoningEffort: 'medium', + thinking: undefined, + }); + + const thinking = { budget_tokens: 16_000, type: 'enabled' }; + await expect(resolveHyChatThinking('hy3', { thinking })).resolves.toEqual( + { reasoningEffort: undefined, thinking }, + ); + }); + + it('matches Hy model ids case-insensitively', async () => { + await expect( + resolveHyChatThinking('HY3-IOA', { reasoning_effort: 'medium' }), + ).resolves.toEqual({ reasoningEffort: 'low', thinking: undefined }); + }); + }); + + describe('resolveHyResponsesReasoning', () => { + it('rewrites a Codex effort onto the Hy vocabulary', async () => { + await expect( + resolveHyResponsesReasoning('hy3', { + effort: 'medium', + summary: 'auto', + }), + ).resolves.toEqual({ effort: 'low', summary: 'auto' }); + }); + + it('keeps the rest of the reasoning object intact', async () => { + await expect( + resolveHyResponsesReasoning('hy3', { + effort: 'xhigh', + summary: 'auto', + }), + ).resolves.toEqual({ effort: 'high', summary: 'auto' }); + }); + + it('leaves an already-Hy effort untouched', async () => { + await expect( + resolveHyResponsesReasoning('hy3', { effort: 'no_think' }), + ).resolves.toEqual({ effort: 'no_think' }); + }); + + it('leaves non-Hy models untouched', async () => { + await expect( + resolveHyResponsesReasoning('glm-5.1', { effort: 'medium' }), + ).resolves.toEqual({ effort: 'medium' }); + }); + + it('passes through a reasoning object with no effort', async () => { + await expect( + resolveHyResponsesReasoning('hy3', { summary: 'auto' }), + ).resolves.toEqual({ summary: 'auto' }); + await expect( + resolveHyResponsesReasoning('hy3', undefined), + ).resolves.toBeUndefined(); + }); + + it('does not convert when the setting is off', async () => { + setEnabled(false); + + await expect( + resolveHyResponsesReasoning('hy3', { effort: 'medium' }), + ).resolves.toEqual({ effort: 'medium' }); + }); + }); +}); diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts index efbf1c6..424aa21 100644 --- a/tests/server/units.test.ts +++ b/tests/server/units.test.ts @@ -62,7 +62,9 @@ import { getActiveConfig, getApiFirstDeltaTimeoutMs, getDefaultModel, + getHyThoughtDepthEnabled, getSettingLabels, + isHyModel, updateSettings, } from '@/lib/server/domain/config'; import { getRequestHeaderMap } from '@/lib/server/shared/http'; @@ -114,6 +116,25 @@ const makeJsonResponse = ( }); }; +/** A minimal non-streaming Chat Completions response, for the /v1/messages path. */ +const chatCompletionPayload = (model: string): Record => { + return { + choices: [{ message: { content: 'ok', role: 'assistant' } }], + model, + usage: { completion_tokens: 1, prompt_tokens: 1, total_tokens: 2 }, + }; +}; + +/** A minimal non-streaming Responses payload, for the /v1/responses path. */ +const responsesPayload = (model: string): Record => { + return { + model, + output: [ + { content: [{ text: 'ok', type: 'output_text' }], type: 'message' }, + ], + }; +}; + const waitForAsync = async ( assertion: () => Promise, timeoutMs = 1000, @@ -1862,6 +1883,153 @@ describe('server units', () => { }); }); + it('converts Claude Code thinking onto the Hy reasoning_effort', async () => { + await updateSettings({ CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: true }); + + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(makeJsonResponse(chatCompletionPayload('hy3'))); + + const response = await handleMessagesRequest( + makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 16_000, + messages: [{ content: 'think hard', role: 'user' }], + model: 'hy3', + thinking: { budget_tokens: 16_000, type: 'enabled' }, + }, + ); + + expect(response.status).toBe(200); + const body = JSON.parse( + String((fetchMock.mock.calls[0]?.[1] as RequestInit).body), + ) as Record; + + // Claude Code speaks Anthropic budget_tokens; upstream wants a named level. + expect(body.reasoning_effort).toBe('high'); + }); + + it('drops the Anthropic thinking block once it has been translated', async () => { + // Leaving the original block alongside the converted effort would still be + // rejected by the upstream this translation exists to satisfy. + await updateSettings({ CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: true }); + + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(makeJsonResponse(chatCompletionPayload('hy3'))); + + await handleMessagesRequest( + makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 16_000, + messages: [{ content: 'think hard', role: 'user' }], + model: 'hy3', + thinking: { budget_tokens: 16_000, type: 'enabled' }, + }, + ); + + const body = JSON.parse( + String((fetchMock.mock.calls[0]?.[1] as RequestInit).body), + ) as Record; + + expect(body.reasoning_effort).toBe('high'); + expect(body).not.toHaveProperty('thinking'); + }); + + it('converts Codex reasoning.effort onto the Hy vocabulary', async () => { + await updateSettings({ CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: true }); + + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(makeJsonResponse(responsesPayload('hy3'))); + + await proxyResponsesUpstream( + makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), + { + input: 'reason about this', + model: 'hy3', + reasoning: { effort: 'medium', summary: 'auto' }, + }, + ); + + const body = JSON.parse( + String((fetchMock.mock.calls[0]?.[1] as RequestInit).body), + ) as Record; + + // `medium` is not a Hy level, so it collapses onto the nearest one. + expect(body.reasoning).toEqual({ effort: 'low', summary: 'auto' }); + }); + + it('forwards Codex reasoning untouched when Hy conversion is off', async () => { + await updateSettings({ CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: false }); + + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(makeJsonResponse(chatCompletionPayload('hy3'))); + + await proxyResponsesUpstream( + makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), + { input: 'keep as is', model: 'hy3', reasoning: { effort: 'medium' } }, + ); + + const body = JSON.parse( + String((fetchMock.mock.calls[0]?.[1] as RequestInit).body), + ) as Record; + + expect(body.reasoning).toEqual({ effort: 'medium' }); + }); + + it('keeps the Anthropic thinking block when conversion is off', async () => { + await updateSettings({ CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: false }); + + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(makeJsonResponse(chatCompletionPayload('hy3'))); + + await handleMessagesRequest( + makeNextRequest('http://localhost/v1/messages', { method: 'POST' }), + { + max_tokens: 16_000, + messages: [{ content: 'think hard', role: 'user' }], + model: 'hy3', + thinking: { budget_tokens: 16_000, type: 'enabled' }, + }, + ); + + const body = JSON.parse( + String((fetchMock.mock.calls[0]?.[1] as RequestInit).body), + ) as Record; + + expect(body.thinking).toEqual({ + budget_tokens: 16_000, + type: 'enabled', + }); + expect(body).not.toHaveProperty('reasoning_effort'); + }); + + it('leaves non-Hy models untouched when conversion is on', async () => { + await updateSettings({ CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: true }); + + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(makeJsonResponse(responsesPayload('hy3'))); + + await proxyResponsesUpstream( + makeNextRequest('http://localhost/v1/responses', { method: 'POST' }), + { + input: 'no conversion', + model: 'glm-5.1', + reasoning: { effort: 'medium' }, + }, + ); + + const body = JSON.parse( + String((fetchMock.mock.calls[0]?.[1] as RequestInit).body), + ) as Record; + + expect(body.reasoning).toEqual({ effort: 'medium' }); + }); + it('covers Responses payload fallback and stop variants', async () => { const context = createProxyContextFromCredential({ data: { @@ -5564,6 +5732,90 @@ describe('server units', () => { }); }); + it('defaults Hy thought depth conversion to off', async () => { + await updateSettings({}); + + await expect(getActiveConfig()).resolves.toMatchObject({ + CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: false, + }); + await expect(getHyThoughtDepthEnabled()).resolves.toBe(false); + }); + + it('turns Hy thought depth conversion on from the console', async () => { + await updateSettings({ CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: true }); + + await expect(getHyThoughtDepthEnabled()).resolves.toBe(true); + }); + + it('accepts the truthy spellings a boolean setting arrives in', async () => { + // The console switch sends a real boolean, but the value can also arrive as + // "1"/"true" from the environment, so both have to enable it. + for (const value of [true, 'true', '1', 'TRUE']) { + await updateSettings({ CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: value }); + + await expect(getHyThoughtDepthEnabled()).resolves.toBe(true); + } + }); + + it('treats an unrecognized Hy thought depth as off', async () => { + // A garbage value must not silently rewrite thinking depth for every Hy + // request, so the conservative reading wins. + for (const value of ['yes', 'maybe', '2', '']) { + await updateSettings({ CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED: value }); + + await expect(getHyThoughtDepthEnabled()).resolves.toBe(false); + } + }); + + it('reads Hy thought depth from the environment', async () => { + // beforeEach wipes the persisted config, so the env value is the only + // thing that can be in play here. + process.env.CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED = '1'; + + try { + await expect(getHyThoughtDepthEnabled()).resolves.toBe(true); + } finally { + delete process.env.CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED; + } + }); + + it('treats any hy-prefixed model as a Hy model', () => { + // The upstream decides which ids exist, so matching is a prefix test rather + // than a list of known ids: a new hy release is covered without a code change. + expect(isHyModel('hy3')).toBe(true); + expect(isHyModel('hy3-ioa')).toBe(true); + expect(isHyModel('hy3-preview-agent-ioa')).toBe(true); + expect(isHyModel('hy2')).toBe(true); + expect(isHyModel('hy')).toBe(true); + expect(isHyModel(' hy4-future ')).toBe(true); + }); + + it('recognizes Hy models case-insensitively', () => { + expect(isHyModel('HY3-IOA')).toBe(true); + expect(isHyModel('Hy3')).toBe(true); + }); + + it('does not treat other model families as Hy models', () => { + // hunyuan-* is a different prefix, so it must not be matched. + expect(isHyModel('hunyuan-2.0-thinking')).toBe(false); + expect(isHyModel('hunyuan-chat')).toBe(false); + expect(isHyModel('glm-5.1')).toBe(false); + expect(isHyModel(undefined)).toBe(false); + expect(isHyModel('')).toBe(false); + }); + + it('labels Hy thought depth in every supported locale', () => { + expect( + getSettingLabels('en-US').CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED, + ).toBeTruthy(); + expect( + getSettingLabels('ja-JP').CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED, + ).toBeTruthy(); + expect( + getSettingLabels('zh-CN').CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED, + ).toBeTruthy(); + }); + it('defaults the API timeout to five minutes', async () => { await updateSettings({});