From 6b5ad8c73ade42bbdc0137e1d588458936b448e0 Mon Sep 17 00:00:00 2001 From: Geoffrey Pamart Date: Mon, 20 Jul 2026 11:23:22 +0200 Subject: [PATCH] feat(profile): add extra_body deep-merged into translated request bodies Providers accept parameters that protocol translation never produces: reasoning effort on the OpenAI Responses API, enable_thinking / thinking_budget on DashScope, and similar provider-specific fields. Add an optional per-profile [profiles.extra_body] table that is deep-merged into the translated request body (objects merge recursively, scalars are overwritten). It is applied after strip_params, so an explicitly configured field is never auto-stripped, and it works with all three provider types. Docs: EN + zh-CN config reference, config.example.toml/yaml. --- config.example.toml | 6 ++ config.example.yaml | 5 ++ src/config/mod.rs | 36 +++++++++++ src/config/profile.rs | 3 + src/proxy/handler.rs | 5 ++ src/proxy/util.rs | 64 +++++++++++++++++++ .../src/content/docs/en/reference/config.mdx | 30 +++++++++ .../content/docs/zh-cn/reference/config.mdx | 30 +++++++++ 8 files changed, 179 insertions(+) diff --git a/config.example.toml b/config.example.toml index ae2522a..35404ae 100644 --- a/config.example.toml +++ b/config.example.toml @@ -53,6 +53,12 @@ claude = "claude-sonnet-4-20250514" # # [profiles.query_params] # URL query params (e.g. Azure api-version) # api-version = "2024-12-01-preview" +# +# [profiles.extra_body] # extra JSON deep-merged into the request body +# enable_thinking = true # e.g. DashScope thinking mode +# +# [profiles.extra_body.reasoning] # e.g. Responses API reasoning effort +# effort = "high" # ─── Profiles ─────────────────────────────────────────── diff --git a/config.example.yaml b/config.example.yaml index 22ad8d1..19fcc22 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -54,6 +54,11 @@ model_aliases: # # query_params: # URL query params (e.g. Azure api-version) # api-version: "2024-12-01-preview" +# +# extra_body: # extra JSON deep-merged into the request body +# enable_thinking: true # e.g. DashScope thinking mode +# reasoning: # e.g. Responses API reasoning effort +# effort: high # ─── Profiles ───────────────────────────────────────── diff --git a/src/config/mod.rs b/src/config/mod.rs index aea0500..32cc3bc 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -127,6 +127,10 @@ pub struct ProfileConfig { /// 追加到请求 URL 的 query 参数(如 Azure OpenAI 的 api-version) #[serde(default)] pub query_params: HashMap, + /// 深度合并进翻译后请求体的额外 JSON 字段(用户值优先,在 strip_params 之后应用) + /// 例如 Responses API 的 reasoning.effort、DashScope 的 enable_thinking/thinking_budget + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extra_body: Option, } /// 参数剥离配置 @@ -220,6 +224,7 @@ impl Default for ProfileConfig { max_tokens: None, strip_params: StripParams::default(), query_params: HashMap::new(), + extra_body: None, } } } @@ -688,6 +693,37 @@ mod tests { assert!(config.profiles[0].enabled); } + #[test] + fn test_parse_profile_extra_body() { + let toml_str = r#" + [[profiles]] + name = "codex" + base_url = "https://chatgpt.com/backend-api/codex" + default_model = "gpt-5.3-codex" + + [profiles.extra_body] + enable_thinking = true + + [profiles.extra_body.reasoning] + effort = "high" + "#; + let config: ClaudexConfig = toml::from_str(toml_str).unwrap(); + let extra = config.profiles[0].extra_body.as_ref().unwrap(); + assert_eq!(extra["reasoning"]["effort"], "high"); + assert_eq!(extra["enable_thinking"], true); + // 未配置时保持 None(不序列化回配置文件) + let minimal: ClaudexConfig = toml::from_str( + r#" + [[profiles]] + name = "t" + base_url = "http://localhost" + default_model = "m" + "#, + ) + .unwrap(); + assert!(minimal.profiles[0].extra_body.is_none()); + } + #[test] fn test_default_hyperlinks_is_auto() { let config = ClaudexConfig::default(); diff --git a/src/config/profile.rs b/src/config/profile.rs index e346bdf..3720a65 100644 --- a/src/config/profile.rs +++ b/src/config/profile.rs @@ -41,6 +41,9 @@ pub async fn show_profile(config: &ClaudexConfig, name: &str) -> Result<()> { if !profile.custom_headers.is_empty() { println!("Custom Headers: {:?}", profile.custom_headers); } + if let Some(extra_body) = &profile.extra_body { + println!("Extra Body: {extra_body}"); + } Ok(()) } diff --git a/src/proxy/handler.rs b/src/proxy/handler.rs index b6dc915..4caec02 100644 --- a/src/proxy/handler.rs +++ b/src/proxy/handler.rs @@ -343,6 +343,11 @@ async fn try_forward( let mut translated = adapter.translate_request(body, profile)?; adapter.filter_translated_body(&mut translated.body, profile); + // extra_body 在 strip_params 之后合并:用户显式配置的字段优先于自动剥离 + if let Some(extra) = &profile.extra_body { + super::util::merge_extra_body(&mut translated.body, extra); + } + let mut url = format!( "{}{}", profile.base_url.trim_end_matches('/'), diff --git a/src/proxy/util.rs b/src/proxy/util.rs index b3abbee..712d786 100644 --- a/src/proxy/util.rs +++ b/src/proxy/util.rs @@ -40,6 +40,27 @@ pub fn format_key_preview(key: &str) -> String { } } +/// 将 profile 的 extra_body 深度合并进翻译后的请求体: +/// 两边都是对象时递归合并,否则以 extra 的值覆盖。 +/// 顶层 extra 不是对象时不做任何修改(配置错误由 validate 提示)。 +pub fn merge_extra_body(body: &mut Value, extra: &Value) { + let (Some(base), Some(ext)) = (body.as_object_mut(), extra.as_object()) else { + return; + }; + for (k, v) in ext { + let merged_nested = match base.get_mut(k) { + Some(slot) if slot.is_object() && v.is_object() => { + merge_extra_body(slot, v); + true + } + _ => false, + }; + if !merged_nested { + base.insert(k.clone(), v.clone()); + } + } +} + /// 构造 Anthropic 格式的错误 JSON pub fn to_anthropic_error(status: u16, message: &str) -> Value { let error_type = match status { @@ -122,4 +143,47 @@ mod tests { assert_eq!(err["error"]["type"], "authentication_error"); assert_eq!(err["error"]["message"], "invalid key"); } + + #[test] + fn test_merge_extra_body_inserts_missing_key() { + let mut body = json!({"model": "gpt-5.3-codex", "input": []}); + merge_extra_body(&mut body, &json!({"reasoning": {"effort": "high"}})); + assert_eq!(body["reasoning"]["effort"], "high"); + assert_eq!(body["model"], "gpt-5.3-codex"); + } + + #[test] + fn test_merge_extra_body_deep_merges_objects() { + let mut body = json!({"reasoning": {"summary": "auto"}}); + merge_extra_body(&mut body, &json!({"reasoning": {"effort": "high"}})); + // 已有的兄弟字段保留,新字段合入 + assert_eq!(body["reasoning"]["summary"], "auto"); + assert_eq!(body["reasoning"]["effort"], "high"); + } + + #[test] + fn test_merge_extra_body_overwrites_scalars() { + let mut body = json!({"temperature": 1.0, "enable_thinking": false}); + merge_extra_body( + &mut body, + &json!({"enable_thinking": true, "thinking_budget": 32768}), + ); + assert_eq!(body["enable_thinking"], true); + assert_eq!(body["thinking_budget"], 32768); + assert_eq!(body["temperature"], 1.0); + } + + #[test] + fn test_merge_extra_body_object_replaces_scalar() { + let mut body = json!({"reasoning": "none"}); + merge_extra_body(&mut body, &json!({"reasoning": {"effort": "low"}})); + assert_eq!(body["reasoning"]["effort"], "low"); + } + + #[test] + fn test_merge_extra_body_non_object_extra_is_noop() { + let mut body = json!({"model": "m"}); + merge_extra_body(&mut body, &json!("not-an-object")); + assert_eq!(body, json!({"model": "m"})); + } } diff --git a/website/src/content/docs/en/reference/config.mdx b/website/src/content/docs/en/reference/config.mdx index 2568a6a..83df1ab 100644 --- a/website/src/content/docs/en/reference/config.mdx +++ b/website/src/content/docs/en/reference/config.mdx @@ -87,6 +87,10 @@ strip_params = "auto" # "auto" | "none" | ["temperature", "top_p"] [profiles.query_params] # api-version = "2024-12-01-preview" +# Extra JSON deep-merged into the translated request body (optional) +[profiles.extra_body] +# enable_thinking = true + # Model slot mapping (optional) [profiles.models] haiku = "grok-3-mini-beta" @@ -111,6 +115,7 @@ opus = "grok-3-beta" | `enabled` | boolean | `true` | Whether this profile is active | | `max_tokens` | integer | — | Cap max output tokens sent to provider. When set, overrides the `max_tokens` in requests | | `strip_params` | string/array | `"auto"` | Parameters to strip from requests. `"auto"` detects known endpoints; `"none"` sends all; array strips specific params | +| `extra_body` | map | — | Extra JSON deep-merged into the translated request body (applied after `strip_params`, so explicit values always win) | ### Query Parameters @@ -125,6 +130,31 @@ api-version = "2024-12-01-preview" Used primarily for Azure OpenAI (`api-version`), but works with any provider. +### Extra Body + +The optional `[profiles.extra_body]` table is deep-merged into the translated request body before it is sent to the provider. Objects are merged recursively; scalars and arrays are overwritten by the configured value. This lets you set provider-specific parameters that the protocol translation does not produce by itself. + +```toml +# OpenAI Responses API: request a specific reasoning effort +[profiles.extra_body.reasoning] +effort = "high" +``` + +```toml +# DashScope (Qwen): enable thinking mode with a budget +[profiles.extra_body] +enable_thinking = true +thinking_budget = 32768 +``` + +| Field | Type | Description | +|-------|------|-------------| +| `extra_body` | map | JSON fields deep-merged into every translated request body | + + + ### Model Slot Mapping The optional `[profiles.models]` table maps Claude Code's `/model` switcher slots to provider-specific model names. When you switch models inside Claude Code (e.g., `/model opus`), Claudex translates the request to the mapped model. diff --git a/website/src/content/docs/zh-cn/reference/config.mdx b/website/src/content/docs/zh-cn/reference/config.mdx index 8f81cf8..0ef44fc 100644 --- a/website/src/content/docs/zh-cn/reference/config.mdx +++ b/website/src/content/docs/zh-cn/reference/config.mdx @@ -87,6 +87,10 @@ strip_params = "auto" # "auto" | "none" | ["temperature", "top_p"] [profiles.query_params] # api-version = "2024-12-01-preview" +# 深度合并进翻译后请求体的额外 JSON 字段(可选) +[profiles.extra_body] +# enable_thinking = true + # 模型槽位映射(可选) [profiles.models] haiku = "grok-3-mini-beta" @@ -111,6 +115,7 @@ opus = "grok-3-beta" | `enabled` | boolean | `true` | 是否激活此 profile | | `max_tokens` | integer | -- | 限制发送给提供商的最大输出 token 数。设置时覆盖请求中的 `max_tokens` | | `strip_params` | string/array | `"auto"` | 从请求中剥离的参数。`"auto"` 检测已知端点;`"none"` 发送全部;数组指定特定参数 | +| `extra_body` | map | -- | 深度合并进翻译后请求体的额外 JSON 字段(在 `strip_params` 之后应用,显式配置的值始终生效) | ### 查询参数 @@ -125,6 +130,31 @@ api-version = "2024-12-01-preview" 主要用于 Azure OpenAI(`api-version`),但适用于任何提供商。 +### 额外请求体字段 + +可选的 `[profiles.extra_body]` 表会在请求发送给提供商之前深度合并进翻译后的请求体。对象递归合并;标量和数组以配置值覆盖。用于设置协议翻译本身不会产生的提供商特定参数。 + +```toml +# OpenAI Responses API:指定 reasoning effort +[profiles.extra_body.reasoning] +effort = "high" +``` + +```toml +# DashScope(Qwen):开启思考模式并设置预算 +[profiles.extra_body] +enable_thinking = true +thinking_budget = 32768 +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `extra_body` | map | 深度合并进每个翻译后请求体的 JSON 字段 | + + + ### 模型槽位映射 可选的 `[profiles.models]` 表将 Claude Code 的 `/model` 切换器槽位映射到提供商特定的模型名。当你在 Claude Code 中切换模型(例如 `/model opus`)时,Claudex 会将请求翻译为映射的模型。