feat(openai): extend additional parameters to native providers

This commit is contained in:
xiaoxi68 2026-05-18 16:16:34 +08:00
parent 7acd1b7635
commit 2b206be3c5
4 changed files with 181 additions and 7 deletions

View file

@ -161,7 +161,7 @@ async fn resolve_api_config(
base_url,
api_key,
authorization_header: None,
extra_headers: source_extra_headers(source),
extra_headers: source_extra_headers_with_overrides(source, custom_headers_raw)?,
anthropic_beta_header_mode: source_anthropic_beta_header_mode(source),
})
}
@ -434,6 +434,28 @@ fn source_extra_headers(source: ChatCompletionSource) -> HashMap<String, String>
headers
}
fn source_extra_headers_with_overrides(
source: ChatCompletionSource,
custom_headers_raw: &str,
) -> Result<HashMap<String, String>, ApplicationError> {
let mut extra_headers = source_extra_headers(source);
if custom_headers_raw.trim().is_empty() {
return Ok(extra_headers);
}
let mut custom_headers = custom_parameters::parse_string_map(custom_headers_raw)?;
for header_name in ["Authorization", "x-api-key", "api-key", "anthropic-version"] {
if take_header_value(&mut custom_headers, header_name).is_some() {
return Err(ApplicationError::ValidationError(format!(
"Additional headers cannot override reserved header: {header_name}"
)));
}
}
extra_headers.extend(custom_headers);
Ok(extra_headers)
}
fn is_zai_coding_endpoint(value: &str) -> bool {
value.trim().eq_ignore_ascii_case(ZAI_ENDPOINT_CODING)
}
@ -449,6 +471,7 @@ mod tests {
use crate::application::dto::chat_completion_dto::{
ChatCompletionGenerateRequestDto, ChatCompletionStatusRequestDto,
};
use crate::application::errors::ApplicationError;
use crate::domain::errors::DomainError;
use crate::domain::models::secret::Secrets;
use crate::domain::repositories::chat_completion_repository::ChatCompletionSource;
@ -693,4 +716,62 @@ mod tests {
Some("abc")
);
}
#[tokio::test]
async fn native_generate_merges_additional_headers() {
let secret_repository: Arc<dyn SecretRepository> = Arc::new(TestSecretRepository {
secrets: HashMap::from([("api_key_claude".to_string(), "secret".to_string())]),
});
let dto = ChatCompletionGenerateRequestDto {
payload: json!({
"chat_completion_source": "claude",
"custom_include_headers": "X-Trace: abc\nX-Debug: true"
})
.as_object()
.cloned()
.expect("payload should be object"),
};
let config =
resolve_generate_api_config(ChatCompletionSource::Claude, &dto, &secret_repository)
.await
.expect("generate config should resolve");
assert_eq!(
config.extra_headers.get("X-Trace").map(String::as_str),
Some("abc")
);
assert_eq!(
config.extra_headers.get("X-Debug").map(String::as_str),
Some("true")
);
}
#[tokio::test]
async fn native_generate_rejects_reserved_additional_headers() {
let secret_repository: Arc<dyn SecretRepository> = Arc::new(TestSecretRepository {
secrets: HashMap::from([("api_key_claude".to_string(), "secret".to_string())]),
});
let dto = ChatCompletionGenerateRequestDto {
payload: json!({
"chat_completion_source": "claude",
"custom_include_headers": "Authorization: Bearer hacked"
})
.as_object()
.cloned()
.expect("payload should be object"),
};
let error =
resolve_generate_api_config(ChatCompletionSource::Claude, &dto, &secret_repository)
.await
.expect_err("reserved headers should be rejected");
match error {
ApplicationError::ValidationError(message) => {
assert!(message.contains("Additional headers cannot override reserved header"));
}
other => panic!("unexpected error: {other:?}"),
}
}
}

View file

@ -27,12 +27,22 @@ pub(super) fn build_payload(
payload: Map<String, Value>,
) -> Result<(String, Value), ApplicationError> {
let mut payload = payload;
let include_body_raw = payload
.get("custom_include_body")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let exclude_body_raw = payload
.get("custom_exclude_body")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if source != ChatCompletionSource::DeepSeek {
prompt_post_processing::apply_custom_prompt_post_processing(&mut payload);
}
match source {
let (endpoint_path, mut upstream_payload) = match source {
ChatCompletionSource::OpenAi
| ChatCompletionSource::Groq
| ChatCompletionSource::SiliconFlow => Ok(openai::build(payload)),
@ -47,5 +57,77 @@ pub(super) fn build_payload(
ChatCompletionSource::Claude => Ok(claude::build(payload)?),
ChatCompletionSource::Makersuite => Ok(makersuite::build(payload)?),
ChatCompletionSource::VertexAi => Ok(vertexai::build(payload)?),
}?;
if matches!(
source,
ChatCompletionSource::Claude | ChatCompletionSource::DeepSeek
) {
shared::apply_custom_body_overrides(
&mut upstream_payload,
&include_body_raw,
&exclude_body_raw,
)?;
}
Ok((endpoint_path, upstream_payload))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::build_payload;
use crate::domain::repositories::chat_completion_repository::ChatCompletionSource;
#[test]
fn deepseek_applies_additional_body_overrides() {
let payload = json!({
"chat_completion_source": "deepseek",
"model": "deepseek-v4-flash",
"messages": [{"role": "user", "content": "hello"}],
"custom_include_body": "{\"x_extra\":true}",
"custom_exclude_body": "[\"temperature\"]"
})
.as_object()
.cloned()
.expect("payload must be object");
let (_, upstream) =
build_payload(ChatCompletionSource::DeepSeek, payload).expect("payload should build");
let body = upstream.as_object().expect("body must be object");
assert_eq!(
body.get("x_extra").and_then(serde_json::Value::as_bool),
Some(true)
);
assert!(body.get("temperature").is_none());
}
#[test]
fn claude_applies_additional_body_overrides() {
let payload = json!({
"chat_completion_source": "claude",
"model": "claude-sonnet-4-5",
"messages": [{"role": "user", "content": "hello"}],
"custom_include_body": "{\"metadata\":{\"feature\":\"override\"}}",
"custom_exclude_body": "[\"stream\"]"
})
.as_object()
.cloned()
.expect("payload must be object");
let (_, upstream) =
build_payload(ChatCompletionSource::Claude, payload).expect("payload should build");
let body = upstream.as_object().expect("body must be object");
assert_eq!(
body.get("metadata")
.and_then(serde_json::Value::as_object)
.and_then(|value| value.get("feature"))
.and_then(serde_json::Value::as_str),
Some("override")
);
assert!(body.get("stream").is_none());
}
}

View file

@ -3981,7 +3981,7 @@
<div class="flex-container flex">
<div id="api_button_openai" class="api_button menu_button menu_button_icon" type="submit" data-i18n="Connect">Connect</div>
<div class="api_loading menu_button menu_button_icon" data-i18n="Cancel">Cancel</div>
<div data-source="custom" id="customize_additional_parameters" class="menu_button menu_button_icon" data-i18n="Additional Parameters">Additional Parameters</div>
<div data-source="custom,claude,deepseek" id="customize_additional_parameters" class="menu_button menu_button_icon" data-i18n="Additional Parameters">Additional Parameters</div>
<div data-source="openrouter" class="menu_button menu_button_icon openrouter_authorize" title="Get your OpenRouter API token using OAuth flow. You will be redirected to openrouter.ai" data-i18n="Authorize;[title]Get your OpenRouter API token using OAuth flow. You will be redirected to openrouter.ai">Authorize</div>
<div id="test_api_button" class="menu_button menu_button_icon" title="Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!" data-i18n="Test Message;[title]Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!">Test Message</div>
</div>

View file

@ -2964,12 +2964,20 @@ export async function createGenerationParameters(settings, model, type, messages
generate_data.stop = getCustomStoppingStrings(); // Mistral shouldn't have limits on stop strings.
}
if (settings.chat_completion_source === chat_completion_sources.CUSTOM) {
generate_data.custom_url = settings.custom_url;
generate_data.custom_api_format = settings.custom_api_format;
const additionalParameterSources = [
chat_completion_sources.CUSTOM,
chat_completion_sources.CLAUDE,
chat_completion_sources.DEEPSEEK,
];
if (additionalParameterSources.includes(settings.chat_completion_source)) {
generate_data.custom_include_body = settings.custom_include_body;
generate_data.custom_exclude_body = settings.custom_exclude_body;
generate_data.custom_include_headers = settings.custom_include_headers;
}
if (settings.chat_completion_source === chat_completion_sources.CUSTOM) {
generate_data.custom_url = settings.custom_url;
generate_data.custom_api_format = settings.custom_api_format;
generate_data.custom_claude_prompt_caching =
settings.custom_api_format === custom_api_formats.CLAUDE_MESSAGES
&& Boolean(settings.custom_claude_prompt_caching);
@ -4628,11 +4636,14 @@ async function getStatusOpen() {
await validateReverseProxy();
}
if ([chat_completion_sources.CUSTOM, chat_completion_sources.CLAUDE, chat_completion_sources.DEEPSEEK].includes(oai_settings.chat_completion_source)) {
data.custom_include_headers = oai_settings.custom_include_headers;
}
if (oai_settings.chat_completion_source === chat_completion_sources.CUSTOM) {
$('.model_custom_select').empty();
data.custom_api_format = oai_settings.custom_api_format;
data.custom_url = oai_settings.custom_url;
data.custom_include_headers = oai_settings.custom_include_headers;
}
if (oai_settings.chat_completion_source === chat_completion_sources.AZURE_OPENAI) {