Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────

Expand Down
5 changes: 5 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────

Expand Down
36 changes: 36 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ pub struct ProfileConfig {
/// 追加到请求 URL 的 query 参数(如 Azure OpenAI 的 api-version)
#[serde(default)]
pub query_params: HashMap<String, String>,
/// 深度合并进翻译后请求体的额外 JSON 字段(用户值优先,在 strip_params 之后应用)
/// 例如 Responses API 的 reasoning.effort、DashScope 的 enable_thinking/thinking_budget
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_body: Option<serde_json::Value>,
}

/// 参数剥离配置
Expand Down Expand Up @@ -220,6 +224,7 @@ impl Default for ProfileConfig {
max_tokens: None,
strip_params: StripParams::default(),
query_params: HashMap::new(),
extra_body: None,
}
}
}
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/config/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
5 changes: 5 additions & 0 deletions src/proxy/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent extra_body from overriding stream mode

When a profile configures extra_body.stream, this merge changes the upstream request after is_streaming was already computed from the inbound body in handle_messages, so the response handling can take the wrong branch. For example, a non-streaming client request with extra_body = { stream = true } makes the upstream return SSE while this code still calls resp.json(), and the inverse wraps a JSON response as an SSE stream; either reject/control-field overrides like stream or derive the response path from the post-merge body.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve max_tokens cap after extra_body merge

For OpenAICompatible profiles that set max_tokens as a cap, anthropic_to_openai(..., profile.max_tokens) caps the inbound request before this merge, but an extra_body.max_tokens value inserted here overwrites that capped value. In a profile intended to enforce max_tokens = 1024, a larger extra_body value is still sent upstream, so the configured cost/limit guard no longer holds; reapply the cap after merging or forbid max_tokens in extra_body.

Useful? React with 👍 / 👎.

}

let mut url = format!(
"{}{}",
profile.base_url.trim_end_matches('/'),
Expand Down
64 changes: 64 additions & 0 deletions src/proxy/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"}));
}
}
30 changes: 30 additions & 0 deletions website/src/content/docs/en/reference/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand All @@ -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 |

<Aside type="note">
`extra_body` is applied after `strip_params`, so an explicitly configured field is never auto-stripped. It works with all three provider types, including `DirectAnthropic` passthrough.
</Aside>

### 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.
Expand Down
30 changes: 30 additions & 0 deletions website/src/content/docs/zh-cn/reference/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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` 之后应用,显式配置的值始终生效) |

### 查询参数

Expand All @@ -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 字段 |

<Aside type="note">
`extra_body` 在 `strip_params` 之后应用,因此显式配置的字段不会被自动剥离。适用于全部三种 provider 类型,包括 `DirectAnthropic` 透传。
</Aside>

### 模型槽位映射

可选的 `[profiles.models]` 表将 Claude Code 的 `/model` 切换器槽位映射到提供商特定的模型名。当你在 Claude Code 中切换模型(例如 `/model opus`)时,Claudex 会将请求翻译为映射的模型。
Expand Down