Skip to content

Commit 6793a8a

Browse files
committed
feat(model): 支持 extra_body
说明: - 在模型设置中新增 extra_body JSON 配置与保留字段校验 - 将模型级 extra_body 平铺到 OpenAI-compatible 聊天请求体 操作: - 仅 OpenAI-compatible Chat Completions 生效,辅助请求不继承该配置
1 parent 82b5798 commit 6793a8a

25 files changed

Lines changed: 350 additions & 3 deletions

File tree

src-tauri/crates/agent/src/bridge.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ fn convert_request(
244244
.and_then(|overrides| overrides.use_max_completion_tokens),
245245
thinking_param_style: model_param_overrides
246246
.and_then(|overrides| overrides.thinking_param_style.clone()),
247+
extra_body: model_param_overrides.and_then(|overrides| overrides.extra_body.clone()),
247248
}
248249
}
249250

@@ -495,6 +496,7 @@ mod tests {
495496
reasoning_profile: Some("siliconflow_enable_thinking".to_string()),
496497
reasoning_options: None,
497498
reasoning_default: None,
499+
extra_body: None,
498500
}
499501
}
500502

src-tauri/crates/core/src/db.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ fn empty_param_overrides() -> ModelParamOverrides {
149149
reasoning_profile: None,
150150
reasoning_options: None,
151151
reasoning_default: None,
152+
extra_body: None,
152153
}
153154
}
154155

src-tauri/crates/core/src/repo/provider_import.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ fn empty_param_overrides_for_import(provider_type: &ProviderType) -> Option<Mode
256256
reasoning_profile: Some(profile),
257257
reasoning_options: None,
258258
reasoning_default: None,
259+
extra_body: None,
259260
})
260261
}
261262

src-tauri/crates/core/src/types.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ pub struct ModelParamOverrides {
269269
pub reasoning_options: Option<Vec<String>>,
270270
/// Optional default reasoning option key for this model.
271271
pub reasoning_default: Option<String>,
272+
/// Model-specific extra JSON body fields for OpenAI-compatible chat requests.
273+
pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
272274
}
273275

274276
// === Conversation & Message ===
@@ -981,6 +983,9 @@ pub struct ChatRequest {
981983
/// Thinking parameter format: "reasoning_effort" (default) or "enable_thinking" (SiliconFlow).
982984
#[serde(skip_serializing_if = "Option::is_none")]
983985
pub thinking_param_style: Option<String>,
986+
/// Extra JSON body fields flattened into OpenAI-compatible chat requests.
987+
#[serde(skip_serializing_if = "Option::is_none")]
988+
pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
984989
}
985990

986991
#[derive(Debug, Clone, Serialize, Deserialize)]

src-tauri/crates/providers/src/anthropic.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ mod tests {
405405
reasoning_profile: Some(reasoning_profile.to_string()),
406406
use_max_completion_tokens: None,
407407
thinking_param_style: None,
408+
extra_body: None,
408409
}
409410
}
410411

src-tauri/crates/providers/src/gemini.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ mod tests {
456456
reasoning_profile: Some("gemini_thinking_level".to_string()),
457457
use_max_completion_tokens: None,
458458
thinking_param_style: None,
459+
extra_body: None,
459460
}
460461
}
461462

src-tauri/crates/providers/src/openai_compat.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,19 @@ struct StreamOptions {
132132
include_usage: bool,
133133
}
134134

135+
const RESERVED_EXTRA_BODY_FIELDS: &[&str] = &[
136+
"model",
137+
"messages",
138+
"stream",
139+
"stream_options",
140+
"tools",
141+
"temperature",
142+
"top_p",
143+
"max_tokens",
144+
"max_completion_tokens",
145+
"reasoning_effort",
146+
];
147+
135148
#[derive(Serialize)]
136149
struct OpenAIMessage {
137150
role: String,
@@ -727,6 +740,22 @@ fn normalized_max_completion_tokens<P: OpenAICompatPolicy>(
727740
})
728741
}
729742

743+
fn merge_model_extra_body(
744+
extra: &mut Map<String, Value>,
745+
custom: Option<&Map<String, Value>>,
746+
) {
747+
let Some(custom) = custom else {
748+
return;
749+
};
750+
751+
for (key, value) in custom {
752+
if RESERVED_EXTRA_BODY_FIELDS.contains(&key.as_str()) {
753+
continue;
754+
}
755+
extra.insert(key.clone(), value.clone());
756+
}
757+
}
758+
730759
fn build_request<P: OpenAICompatPolicy>(
731760
policy: &P,
732761
request: &ChatRequest,
@@ -740,7 +769,8 @@ fn build_request<P: OpenAICompatPolicy>(
740769
let effort = r.reasoning_effort.clone()?;
741770
policy.normalize_reasoning_effort(&r.level, effort)
742771
});
743-
let extra = policy.extra_body_fields(reasoning.as_ref());
772+
let mut extra = policy.extra_body_fields(reasoning.as_ref());
773+
merge_model_extra_body(&mut extra, request.extra_body.as_ref());
744774

745775
// Use max_completion_tokens only when the model/request contract requires it.
746776
let use_completion_tokens = policy.use_max_completion_tokens(request);
@@ -838,6 +868,7 @@ mod tests {
838868
reasoning_profile: None,
839869
use_max_completion_tokens: None,
840870
thinking_param_style: None,
871+
extra_body: None,
841872
}
842873
}
843874

@@ -1394,6 +1425,53 @@ mod tests {
13941425
assert!(serialized.get("temperature").is_none());
13951426
assert!(serialized.get("top_p").is_none());
13961427
}
1428+
1429+
#[test]
1430+
fn openai_compat_flattens_model_extra_body_fields() {
1431+
let mut request = base_chat_request("gpt-4o");
1432+
request.extra_body = Some(
1433+
serde_json::json!({
1434+
"enable_thinking": true,
1435+
"vendor_options": {
1436+
"trace": "enabled"
1437+
}
1438+
})
1439+
.as_object()
1440+
.expect("object")
1441+
.clone(),
1442+
);
1443+
1444+
let body = build_request(&OpenAIPolicy, &request, &request.messages, true);
1445+
let serialized = serde_json::to_value(body).expect("request json");
1446+
1447+
assert_eq!(serialized["enable_thinking"], json!(true));
1448+
assert_eq!(serialized["vendor_options"]["trace"], json!("enabled"));
1449+
assert!(serialized.get("extra_body").is_none());
1450+
}
1451+
1452+
#[test]
1453+
fn openai_compat_extra_body_cannot_override_core_fields() {
1454+
let mut request = base_chat_request("gpt-4o");
1455+
request.extra_body = Some(
1456+
serde_json::json!({
1457+
"model": "other-model",
1458+
"stream": false,
1459+
"max_tokens": 1,
1460+
"enable_thinking": true
1461+
})
1462+
.as_object()
1463+
.expect("object")
1464+
.clone(),
1465+
);
1466+
1467+
let body = build_request(&OpenAIPolicy, &request, &request.messages, true);
1468+
let serialized = serde_json::to_value(body).expect("request json");
1469+
1470+
assert_eq!(serialized["model"], json!("gpt-4o"));
1471+
assert_eq!(serialized["stream"], json!(true));
1472+
assert_eq!(serialized["max_tokens"], json!(300_000));
1473+
assert_eq!(serialized["enable_thinking"], json!(true));
1474+
}
13971475
}
13981476

13991477
#[async_trait]

src-tauri/crates/providers/src/openai_responses.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,7 @@ mod tests {
11761176
reasoning_profile: None,
11771177
use_max_completion_tokens: None,
11781178
thinking_param_style: None,
1179+
extra_body: None,
11791180
};
11801181
let built = build_request(&request, false);
11811182
assert_eq!(built.max_output_tokens, Some(100));
@@ -1204,6 +1205,7 @@ mod tests {
12041205
reasoning_profile: None,
12051206
use_max_completion_tokens: None,
12061207
thinking_param_style: None,
1208+
extra_body: None,
12071209
};
12081210
let built = build_request(&request, false);
12091211
assert_eq!(built.max_output_tokens, Some(16));
@@ -1230,6 +1232,7 @@ mod tests {
12301232
reasoning_profile: Some("openai_responses_reasoning".to_string()),
12311233
use_max_completion_tokens: None,
12321234
thinking_param_style: None,
1235+
extra_body: None,
12331236
};
12341237
let built = build_request(&request, false);
12351238
let reasoning = built.reasoning.expect("reasoning should be sent");

src-tauri/crates/providers/src/reasoning.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ mod tests {
198198
reasoning_profile: None,
199199
use_max_completion_tokens: None,
200200
thinking_param_style: None,
201+
extra_body: None,
201202
}
202203
}
203204

src-tauri/src/commands/conversations.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,12 @@ fn resolve_chat_model_params(
314314
}
315315
}
316316

317+
fn model_extra_body_from_overrides(
318+
model_param_overrides: Option<&ModelParamOverrides>,
319+
) -> Option<serde_json::Map<String, serde_json::Value>> {
320+
model_param_overrides.and_then(|params| params.extra_body.clone())
321+
}
322+
317323
pub(crate) async fn persist_attachments(
318324
state: &AppState,
319325
conversation_id: &str,
@@ -1970,6 +1976,7 @@ fn build_search_query_request(
19701976
reasoning_profile: None,
19711977
use_max_completion_tokens,
19721978
thinking_param_style: None,
1979+
extra_body: None,
19731980
}
19741981
}
19751982

@@ -2169,6 +2176,7 @@ async fn generate_ai_title_with(
21692176
reasoning_profile: None,
21702177
use_max_completion_tokens,
21712178
thinking_param_style: None,
2179+
extra_body: None,
21722180
};
21732181

21742182
let registry = ProviderRegistry::create_default();
@@ -2843,6 +2851,7 @@ fn spawn_stream_task(
28432851
reasoning_profile: reasoning_profile.clone(),
28442852
use_max_completion_tokens,
28452853
thinking_param_style: thinking_param_style.clone(),
2854+
extra_body: model_extra_body_from_overrides(model_param_overrides.as_ref()),
28462855
};
28472856

28482857
let mut stream = adapter.chat_stream(&ctx, request);
@@ -4417,6 +4426,7 @@ async fn do_compress(
44174426
reasoning_profile: None,
44184427
use_max_completion_tokens: comp_use_max,
44194428
thinking_param_style: None,
4429+
extra_body: None,
44204430
};
44214431

44224432
let ctx = ProviderRequestContext {
@@ -4721,9 +4731,31 @@ mod tests {
47214731
reasoning_profile: None,
47224732
reasoning_options: None,
47234733
reasoning_default: None,
4734+
extra_body: None,
47244735
}
47254736
}
47264737

4738+
#[test]
4739+
fn model_extra_body_is_cloned_from_model_param_overrides() {
4740+
let extra_body = serde_json::json!({
4741+
"enable_thinking": true,
4742+
"thinking": {
4743+
"type": "enabled"
4744+
}
4745+
})
4746+
.as_object()
4747+
.expect("object")
4748+
.clone();
4749+
let mut overrides = test_param_overrides(None, None, None);
4750+
overrides.extra_body = Some(extra_body.clone());
4751+
4752+
assert_eq!(
4753+
model_extra_body_from_overrides(Some(&overrides)),
4754+
Some(extra_body)
4755+
);
4756+
assert_eq!(model_extra_body_from_overrides(None), None);
4757+
}
4758+
47274759
fn test_docx_bytes(text: &str) -> Vec<u8> {
47284760
let cursor = Cursor::new(Vec::new());
47294761
let mut archive = zip::ZipWriter::new(cursor);

0 commit comments

Comments
 (0)