From 4c46ccfcc4523e236262cc4deb5e36b04c22bd99 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Wed, 16 Sep 2026 01:19:54 +0800 Subject: [PATCH 1/3] feat(settings): convert thought depth for Hy-series models Hy models take their thinking depth as `reasoning_effort`, whose only accepted values are `no_think`, `low` and `high`. Neither downstream client speaks that vocabulary: Claude Code sends Anthropic `thinking` (a `{type, budget_tokens}` object), and Codex sends Responses `reasoning.effort` using the finer-grained `minimal`/`low`/`medium`/`high`/`xhigh`/`max` scale. Forwarding either verbatim means the upstream rejects the request or silently ignores the caller's intent. This translates both onto the Hy vocabulary. OpenAI effort levels collapse onto the nearest Hy level rather than being dropped, and Anthropic `budget_tokens` is bucketed against the output sizes the levels correspond to (`no_think` caps at 8K, `low` sits around 16K, `high` reaches 64K). An enabled thinking block with no budget resolves to `high`, since the caller did ask for thinking. The mapping itself is fixed by the upstream contract, so the console only gets an off/on switch; it defaults to off, leaving every request byte-identical to before. Model matching is case-insensitive and prefix-based so `hy3` and `hy3-ioa` both match, while `hunyuan-*` is a separate product line with its own thinking parameter and is deliberately excluded. Conversion is skipped for non-Hy models so upstreams that already understand these fields are untouched. Gated behind CODEBUDDY_HY_THOUGHT_DEPTH (also settable as an environment variable) and labeled in all three locales. Unrecognized values fall back to off, so a mistyped setting cannot silently rewrite thinking depth for every Hy request. Co-Authored-By: Claude Fable 5 --- .env.example | 27 +-- app/settings/settings.tsx | 15 +- docs/en/guide/settings.md | 39 ++++- docs/guide/settings.md | 32 +++- docs/ja/guide/settings.md | 34 +++- lib/server/domain/config.ts | 45 +++++ lib/server/proxy/codebuddy.ts | 52 ++++-- lib/server/shared/hy-thought-depth.ts | 182 +++++++++++++++++++ messages/en-US.json | 1 + messages/ja-JP.json | 1 + messages/zh-CN.json | 1 + tests/server/hy-thought-depth.test.ts | 227 ++++++++++++++++++++++++ tests/server/units.test.ts | 242 ++++++++++++++++++++++++++ 13 files changed, 836 insertions(+), 62 deletions(-) create mode 100644 lib/server/shared/hy-thought-depth.ts create mode 100644 tests/server/hy-thought-depth.test.ts diff --git a/.env.example b/.env.example index 4f277d5..a1c39cf 100644 --- a/.env.example +++ b/.env.example @@ -13,28 +13,15 @@ CODEBUDDY_LOG_LEVEL=INFO # Defaults to 5. Fractional values are allowed; clamped to 0.1-1440. # CODEBUDDY_API_TIMEOUT_MINUTES=5 -# Local web search backend. Setting SEARXNG_URL enables the "Enable local web -# search" option in the console settings. Without it the option stays hidden. -# -# The instance must have the JSON output format enabled — under the "search" -# section of its settings.yml, set "formats: [html, json]". Many public -# instances disable it, and requests then fail with HTTP 403. -# SEARXNG_URL=https://searx.example.com -# -# Optional. Only sent when set, for instances behind a keyed proxy. -# SEARXNG_API_KEY= -# Optional. Restrict which engines are queried, space- or comma-separated, -# e.g. "google,bing" or "general". These become !bang tokens in the query -# (SearXNG has no "engines" request parameter), so each name must match an -# engine name, engine shortcut, or category on the instance. -# SEARXNG_ENGINES= -# Optional. Language code for results, e.g. en or zh -# SEARXNG_LANGUAGE= -# Optional. Results returned per query. Defaults to 5, clamped to 1-10. -# SEARXNG_MAX_RESULTS=5 -# 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=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..e0a9262 --- /dev/null +++ b/lib/server/shared/hy-thought-depth.ts @@ -0,0 +1,182 @@ +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. + */ +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..4a5a360 --- /dev/null +++ b/tests/server/hy-thought-depth.test.ts @@ -0,0 +1,227 @@ +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' && + ['hy3', 'hy2', 'hy-'].some((prefix) => + model.trim().toLowerCase().startsWith(prefix), + ), +})); + +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..c897fe2 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,80 @@ 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('recognizes Hy models case-insensitively', () => { + expect(isHyModel('hy3')).toBe(true); + expect(isHyModel('hy3-ioa')).toBe(true); + expect(isHyModel('HY3-IOA')).toBe(true); + expect(isHyModel('hy2')).toBe(true); + }); + + it('does not treat other model families as Hy models', () => { + // hunyuan-* uses a different thinking parameter, so it must not be matched. + expect(isHyModel('hunyuan-2.0-thinking')).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({}); From 9d75ccffd216202bb4f79dc562b346718097d045 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Wed, 16 Sep 2026 01:30:00 +0800 Subject: [PATCH 2/3] fix(settings): match every hy-prefixed model as a Hy model Every model id starting with `hy` is a Hy-series model, so matching was narrower than it should have been: it enumerated three prefixes, which silently missed any Hy release that did not match one of them. The upstream decides which model ids exist, so the test is now a single case-insensitive `hy` prefix check. A future `hy4` is covered without a code change. `hunyuan-*` remains excluded, but as a consequence of being a different prefix rather than of a deliberate carve-out. Also spells out that the Anthropic budget cut points are ours: the upstream documents the output size behind each level, not the reverse mapping, so the comment now says which direction is specified and which is inferred. Co-Authored-By: Claude Fable 5 --- docs/en/guide/settings.md | 6 ++++++ lib/server/shared/hy-thought-depth.ts | 8 +++++++- tests/server/hy-thought-depth.test.ts | 5 +---- tests/server/units.test.ts | 16 +++++++++++++--- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docs/en/guide/settings.md b/docs/en/guide/settings.md index fb879f6..6ed3303 100644 --- a/docs/en/guide/settings.md +++ b/docs/en/guide/settings.md @@ -37,6 +37,7 @@ vocabulary: Claude Code sends Anthropic `thinking`, while Codex sends Responses 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 +<<<<<<< HEAD does not match. Once translated, the original `thinking` block is dropped: leaving it alongside @@ -44,6 +45,11 @@ 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`). +======= +does not match. The default is `off`, which forwards requests unchanged. Seed it +before the console is opened with `CODEBUDDY_HY_THOUGHT_DEPTH` (`on` / `off`, +also `1` / `0`, `true` / `false`). +>>>>>>> a3c3abf (fix(settings): match every hy-prefixed model as a Hy model) ## Models and usage diff --git a/lib/server/shared/hy-thought-depth.ts b/lib/server/shared/hy-thought-depth.ts index e0a9262..df0c196 100644 --- a/lib/server/shared/hy-thought-depth.ts +++ b/lib/server/shared/hy-thought-depth.ts @@ -46,7 +46,13 @@ const OPENAI_EFFORT_TO_HY: Record = { /** * 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. + * 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 ( diff --git a/tests/server/hy-thought-depth.test.ts b/tests/server/hy-thought-depth.test.ts index 4a5a360..fc4504b 100644 --- a/tests/server/hy-thought-depth.test.ts +++ b/tests/server/hy-thought-depth.test.ts @@ -3,10 +3,7 @@ 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' && - ['hy3', 'hy2', 'hy-'].some((prefix) => - model.trim().toLowerCase().startsWith(prefix), - ), + typeof model === 'string' && model.trim().toLowerCase().startsWith('hy'), })); const { getHyThoughtDepthEnabled } = await import('@/lib/server/domain/config'); diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts index c897fe2..424aa21 100644 --- a/tests/server/units.test.ts +++ b/tests/server/units.test.ts @@ -5779,16 +5779,26 @@ describe('server units', () => { } }); - it('recognizes Hy models case-insensitively', () => { + 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-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-* uses a different thinking parameter, so it must not be matched. + // 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); From 5d86d9a1302124d4ea3431674ce79a712ea8e233 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Wed, 16 Sep 2026 01:49:17 +0800 Subject: [PATCH 3/3] fix(settings): drop the Anthropic thinking block once translated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translating Claude Code's `thinking` into `reasoning_effort` left the original block in place, so a Hy request carried the same intent twice in two different vocabularies. An upstream that rejects the unsupported shape — the exact case this option exists to fix — would still have failed despite also receiving the converted field, so the translation bought nothing. `resolveHyChatReasoningEffort` becomes `resolveHyChatThinking`, returning the converted effort and the `thinking` value to forward as a pair. `thinking` is dropped only when it actually produced a value; an unrecognized shape is forwarded untouched, so a request we cannot interpret is never silently rewritten. Also rebased onto main, which absorbed the local web search work. That branch introduced real boolean settings with a Switch, so the off/on string toggle is replaced by `CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED` and the bespoke normalizer is dropped in favour of the generic boolean handling. Environment and docs updated to match. Co-Authored-By: Claude Fable 5 --- .env.example | 22 +++++++++++++++++++++- docs/en/guide/settings.md | 6 ------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index a1c39cf..1168d13 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,26 @@ CODEBUDDY_LOG_LEVEL=INFO # Defaults to 5. Fractional values are allowed; clamped to 0.1-1440. # CODEBUDDY_API_TIMEOUT_MINUTES=5 +# Local web search backend. Setting SEARXNG_URL enables the "Enable local web +# search" option in the console settings. Without it the option stays hidden. +# +# The instance must have the JSON output format enabled — under the "search" +# section of its settings.yml, set "formats: [html, json]". Many public +# instances disable it, and requests then fail with HTTP 403. +# SEARXNG_URL=https://searx.example.com +# +# Optional. Only sent when set, for instances behind a keyed proxy. +# SEARXNG_API_KEY= +# Optional. Restrict which engines are queried, space- or comma-separated, +# e.g. "google,bing" or "general". These become !bang tokens in the query +# (SearXNG has no "engines" request parameter), so each name must match an +# engine name, engine shortcut, or category on the instance. +# SEARXNG_ENGINES= +# Optional. Language code for results, e.g. en or zh +# SEARXNG_LANGUAGE= +# Optional. Results returned per query. Defaults to 5, clamped to 1-10. +# SEARXNG_MAX_RESULTS=5 +# 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 @@ -20,7 +40,7 @@ CODEBUDDY_LOG_LEVEL=INFO # 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=false +# CODEBUDDY_HY_THOUGHT_DEPTH_ENABLED=false # Models are discovered from each saved credential. No model-list setting is needed. diff --git a/docs/en/guide/settings.md b/docs/en/guide/settings.md index 6ed3303..fb879f6 100644 --- a/docs/en/guide/settings.md +++ b/docs/en/guide/settings.md @@ -37,7 +37,6 @@ vocabulary: Claude Code sends Anthropic `thinking`, while Codex sends Responses 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 -<<<<<<< HEAD does not match. Once translated, the original `thinking` block is dropped: leaving it alongside @@ -45,11 +44,6 @@ 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`). -======= -does not match. The default is `off`, which forwards requests unchanged. Seed it -before the console is opened with `CODEBUDDY_HY_THOUGHT_DEPTH` (`on` / `off`, -also `1` / `0`, `true` / `false`). ->>>>>>> a3c3abf (fix(settings): match every hy-prefixed model as a Hy model) ## Models and usage