mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(proxy): 添加本地代理请求覆盖功能,支持自定义请求头和请求体 (#4589)
* feat(proxy): 添加本地代理请求覆盖功能,支持自定义请求头和请求体 * fix(proxy): harden local request overrides validation * feat(proxy): 添加受保护的本地代理请求头名称验证功能 * fix(i18n): 更新本地代理请求覆盖的错误提示信息格式 --------- Co-authored-by: jason.mei <jason.mei@ucloud.cn>
This commit is contained in:
@@ -382,6 +382,21 @@ pub struct CodexChatReasoningConfig {
|
|||||||
pub output_format: Option<String>,
|
pub output_format: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local proxy request overrides applied after route/protocol transforms.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LocalProxyRequestOverrides {
|
||||||
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub headers: HashMap<String, String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub body: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalProxyRequestOverrides {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.headers.is_empty() && self.body.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 供应商元数据
|
/// 供应商元数据
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderMeta {
|
pub struct ProviderMeta {
|
||||||
@@ -466,6 +481,12 @@ pub struct ProviderMeta {
|
|||||||
/// Custom User-Agent for local proxy routing.
|
/// Custom User-Agent for local proxy routing.
|
||||||
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
|
||||||
pub custom_user_agent: Option<String>,
|
pub custom_user_agent: Option<String>,
|
||||||
|
/// Local proxy request overrides applied to the transformed upstream request.
|
||||||
|
#[serde(
|
||||||
|
rename = "localProxyRequestOverrides",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub local_proxy_request_overrides: Option<LocalProxyRequestOverrides>,
|
||||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||||
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
||||||
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
||||||
@@ -932,10 +953,11 @@ pub struct OpenCodeModelLimit {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, LocalProxyRequestOverrides,
|
||||||
ProviderManager, ProviderMeta, UniversalProvider,
|
OpenCodeProviderConfig, Provider, ProviderManager, ProviderMeta, UniversalProvider,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_meta_serializes_pricing_model_source() {
|
fn provider_meta_serializes_pricing_model_source() {
|
||||||
@@ -963,6 +985,33 @@ mod tests {
|
|||||||
assert!(value.get("pricingModelSource").is_none());
|
assert!(value.get("pricingModelSource").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_meta_roundtrips_local_proxy_request_overrides() {
|
||||||
|
let meta = ProviderMeta {
|
||||||
|
local_proxy_request_overrides: Some(LocalProxyRequestOverrides {
|
||||||
|
headers: HashMap::from([("X-Test".to_string(), "yes".to_string())]),
|
||||||
|
body: Some(json!({ "temperature": 0.2 })),
|
||||||
|
}),
|
||||||
|
..ProviderMeta::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||||
|
assert_eq!(
|
||||||
|
value["localProxyRequestOverrides"]["headers"]["X-Test"],
|
||||||
|
"yes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
value["localProxyRequestOverrides"]["body"]["temperature"],
|
||||||
|
0.2
|
||||||
|
);
|
||||||
|
|
||||||
|
let decoded: ProviderMeta =
|
||||||
|
serde_json::from_value(value).expect("deserialize ProviderMeta");
|
||||||
|
let overrides = decoded.local_proxy_request_overrides.unwrap();
|
||||||
|
assert_eq!(overrides.headers.get("X-Test"), Some(&"yes".to_string()));
|
||||||
|
assert_eq!(overrides.body.unwrap()["temperature"], 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_with_id_populates_defaults() {
|
fn provider_with_id_populates_defaults() {
|
||||||
let settings_config = json!({
|
let settings_config = json!({
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ use super::{
|
|||||||
use crate::commands::{CodexOAuthState, CopilotAuthState};
|
use crate::commands::{CodexOAuthState, CopilotAuthState};
|
||||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||||
use crate::{app_config::AppType, provider::Provider};
|
use crate::{
|
||||||
|
app_config::AppType,
|
||||||
|
provider::{LocalProxyRequestOverrides, Provider},
|
||||||
|
};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use http::Extensions;
|
use http::Extensions;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -1386,7 +1389,18 @@ impl RequestForwarder {
|
|||||||
|
|
||||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||||
let filtered_body = prepare_upstream_request_body(request_body);
|
let mut filtered_body = prepare_upstream_request_body(request_body);
|
||||||
|
if !is_copilot {
|
||||||
|
if let Some(overrides) = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.local_proxy_request_overrides.as_ref())
|
||||||
|
{
|
||||||
|
if apply_local_proxy_body_overrides(&mut filtered_body, overrides) {
|
||||||
|
filtered_body = prepare_upstream_request_body(filtered_body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写)
|
// 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写)
|
||||||
if let Some(m) = filtered_body
|
if let Some(m) = filtered_body
|
||||||
.get("model")
|
.get("model")
|
||||||
@@ -1833,6 +1847,15 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply_local_proxy_header_overrides(
|
||||||
|
&mut ordered_headers,
|
||||||
|
provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.local_proxy_request_overrides.as_ref()),
|
||||||
|
is_copilot,
|
||||||
|
);
|
||||||
|
|
||||||
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
|
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
|
||||||
|
|
||||||
// 输出请求信息日志
|
// 输出请求信息日志
|
||||||
@@ -2544,6 +2567,154 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
|||||||
format!("{truncated}...")
|
format!("{truncated}...")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_local_proxy_body_overrides(
|
||||||
|
body: &mut Value,
|
||||||
|
overrides: &LocalProxyRequestOverrides,
|
||||||
|
) -> bool {
|
||||||
|
let Some(override_body) = overrides.body.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !override_body.is_object() {
|
||||||
|
log::warn!("[LocalProxyOverrides] Ignoring body override because it is not an object");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_json_override(body, override_body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_json_override(target: &mut Value, patch: &Value) -> bool {
|
||||||
|
merge_json_override_inner(target, patch, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_json_override_inner(target: &mut Value, patch: &Value, is_top_level: bool) -> bool {
|
||||||
|
match (target, patch) {
|
||||||
|
(Value::Object(target_map), Value::Object(patch_map)) => {
|
||||||
|
let mut changed = false;
|
||||||
|
for (key, patch_value) in patch_map {
|
||||||
|
if is_top_level && key == "stream" {
|
||||||
|
log::warn!(
|
||||||
|
"[LocalProxyOverrides] Ignoring body override for protected field: stream"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match target_map.get_mut(key) {
|
||||||
|
Some(target_value) => {
|
||||||
|
changed |= merge_json_override_inner(target_value, patch_value, false);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
target_map.insert(key.clone(), patch_value.clone());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
(target_value, patch_value) => {
|
||||||
|
if target_value == patch_value {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
*target_value = patch_value.clone();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_local_proxy_header_overrides(
|
||||||
|
headers: &mut http::HeaderMap,
|
||||||
|
overrides: Option<&LocalProxyRequestOverrides>,
|
||||||
|
is_copilot: bool,
|
||||||
|
) {
|
||||||
|
if is_copilot {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(header_overrides) = overrides.map(|overrides| &overrides.headers) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (raw_name, raw_value) in header_overrides {
|
||||||
|
let header_name = raw_name.trim().to_ascii_lowercase();
|
||||||
|
if header_name.is_empty() {
|
||||||
|
log::warn!("[LocalProxyOverrides] Ignoring header override with empty name");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(name) = http::HeaderName::from_bytes(header_name.as_bytes()) else {
|
||||||
|
log::warn!("[LocalProxyOverrides] Ignoring invalid header override name: {raw_name}");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_protected_local_proxy_override_header(&name) {
|
||||||
|
log::debug!(
|
||||||
|
"[LocalProxyOverrides] Ignoring protected header override: {}",
|
||||||
|
name.as_str()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(value) = http::HeaderValue::from_str(raw_value) else {
|
||||||
|
log::warn!(
|
||||||
|
"[LocalProxyOverrides] Ignoring invalid header override value for {}",
|
||||||
|
name.as_str()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
headers.insert(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_protected_local_proxy_override_header(name: &http::HeaderName) -> bool {
|
||||||
|
matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"host"
|
||||||
|
| "content-length"
|
||||||
|
| "transfer-encoding"
|
||||||
|
| "connection"
|
||||||
|
| "proxy-authorization"
|
||||||
|
| "proxy-authenticate"
|
||||||
|
| "te"
|
||||||
|
| "trailer"
|
||||||
|
| "upgrade"
|
||||||
|
| "accept-encoding"
|
||||||
|
| "content-type"
|
||||||
|
| "authorization"
|
||||||
|
| "x-api-key"
|
||||||
|
| "x-goog-api-key"
|
||||||
|
| "chatgpt-account-id"
|
||||||
|
| "session_id"
|
||||||
|
| "x-client-request-id"
|
||||||
|
| "x-codex-window-id"
|
||||||
|
| "x-forwarded-host"
|
||||||
|
| "x-forwarded-port"
|
||||||
|
| "x-forwarded-proto"
|
||||||
|
| "forwarded"
|
||||||
|
| "cf-connecting-ip"
|
||||||
|
| "cf-ipcountry"
|
||||||
|
| "cf-ray"
|
||||||
|
| "cf-visitor"
|
||||||
|
| "true-client-ip"
|
||||||
|
| "fastly-client-ip"
|
||||||
|
| "x-azure-clientip"
|
||||||
|
| "x-azure-fdid"
|
||||||
|
| "x-azure-ref"
|
||||||
|
| "akamai-origin-hop"
|
||||||
|
| "x-akamai-config-log-detail"
|
||||||
|
| "x-request-id"
|
||||||
|
| "x-correlation-id"
|
||||||
|
| "x-trace-id"
|
||||||
|
| "x-amzn-trace-id"
|
||||||
|
| "x-b3-traceid"
|
||||||
|
| "x-b3-spanid"
|
||||||
|
| "x-b3-parentspanid"
|
||||||
|
| "x-b3-sampled"
|
||||||
|
| "traceparent"
|
||||||
|
| "tracestate"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn prepare_upstream_request_body(request_body: Value) -> Value {
|
fn prepare_upstream_request_body(request_body: Value) -> Value {
|
||||||
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
|
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
|
||||||
}
|
}
|
||||||
@@ -2607,6 +2778,7 @@ fn value_for_log(value: &Value) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
|
use crate::provider::LocalProxyRequestOverrides;
|
||||||
use axum::http::header::{HeaderValue, ACCEPT};
|
use axum::http::header::{HeaderValue, ACCEPT};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -2806,6 +2978,116 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_proxy_body_overrides_deep_merge_final_body_without_stream() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "before",
|
||||||
|
"stream": false,
|
||||||
|
"metadata": {
|
||||||
|
"keep": true,
|
||||||
|
"temperature": 1
|
||||||
|
},
|
||||||
|
"messages": [{ "role": "user", "content": "hello" }]
|
||||||
|
});
|
||||||
|
let overrides = LocalProxyRequestOverrides {
|
||||||
|
headers: HashMap::new(),
|
||||||
|
body: Some(json!({
|
||||||
|
"model": "after",
|
||||||
|
"stream": true,
|
||||||
|
"metadata": {
|
||||||
|
"temperature": 0.2,
|
||||||
|
"top_p": 0.9
|
||||||
|
},
|
||||||
|
"messages": []
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(apply_local_proxy_body_overrides(&mut body, &overrides));
|
||||||
|
|
||||||
|
assert_eq!(body["model"], "after");
|
||||||
|
assert_eq!(body["stream"], false);
|
||||||
|
assert_eq!(body["metadata"]["keep"], true);
|
||||||
|
assert_eq!(body["metadata"]["temperature"], 0.2);
|
||||||
|
assert_eq!(body["metadata"]["top_p"], 0.9);
|
||||||
|
assert_eq!(body["messages"], json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_proxy_header_overrides_replace_allowed_headers_only() {
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
http::header::USER_AGENT,
|
||||||
|
http::HeaderValue::from_static("original"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
http::header::AUTHORIZATION,
|
||||||
|
http::HeaderValue::from_static("Bearer good"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
http::header::CONTENT_TYPE,
|
||||||
|
http::HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let overrides = LocalProxyRequestOverrides {
|
||||||
|
headers: HashMap::from([
|
||||||
|
("User-Agent".to_string(), "custom".to_string()),
|
||||||
|
("X-Test".to_string(), "ok".to_string()),
|
||||||
|
("Authorization".to_string(), "Bearer bad".to_string()),
|
||||||
|
("Content-Type".to_string(), "text/plain".to_string()),
|
||||||
|
("X-Bad".to_string(), "bad\nvalue".to_string()),
|
||||||
|
]),
|
||||||
|
body: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_local_proxy_header_overrides(&mut headers, Some(&overrides), false);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(http::header::USER_AGENT)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("custom")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(http::header::AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("Bearer good")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(http::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("application/json")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("x-test").and_then(|value| value.to_str().ok()),
|
||||||
|
Some("ok")
|
||||||
|
);
|
||||||
|
assert!(headers.get("x-bad").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_proxy_header_overrides_are_skipped_for_copilot() {
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
http::header::USER_AGENT,
|
||||||
|
http::HeaderValue::from_static("copilot"),
|
||||||
|
);
|
||||||
|
let overrides = LocalProxyRequestOverrides {
|
||||||
|
headers: HashMap::from([("User-Agent".to_string(), "custom".to_string())]),
|
||||||
|
body: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_local_proxy_header_overrides(&mut headers, Some(&overrides), true);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(http::header::USER_AGENT)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("copilot")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn non_streaming_success_is_buffered_before_marking_provider_successful() {
|
async fn non_streaming_success_is_buffered_before_marking_provider_successful() {
|
||||||
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
type FetchedModel,
|
type FetchedModel,
|
||||||
} from "@/lib/api/model-fetch";
|
} from "@/lib/api/model-fetch";
|
||||||
import { CustomUserAgentField } from "./CustomUserAgentField";
|
import { CustomUserAgentField } from "./CustomUserAgentField";
|
||||||
|
import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField";
|
||||||
import type {
|
import type {
|
||||||
ProviderCategory,
|
ProviderCategory,
|
||||||
ClaudeApiFormat,
|
ClaudeApiFormat,
|
||||||
@@ -145,6 +146,10 @@ interface ClaudeFormFieldsProps {
|
|||||||
// Local proxy User-Agent override
|
// Local proxy User-Agent override
|
||||||
customUserAgent: string;
|
customUserAgent: string;
|
||||||
onCustomUserAgentChange: (value: string) => void;
|
onCustomUserAgentChange: (value: string) => void;
|
||||||
|
localProxyHeadersOverride: string;
|
||||||
|
onLocalProxyHeadersOverrideChange: (value: string) => void;
|
||||||
|
localProxyBodyOverride: string;
|
||||||
|
onLocalProxyBodyOverrideChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeFormFields({
|
export function ClaudeFormFields({
|
||||||
@@ -201,8 +206,15 @@ export function ClaudeFormFields({
|
|||||||
onFullUrlChange,
|
onFullUrlChange,
|
||||||
customUserAgent,
|
customUserAgent,
|
||||||
onCustomUserAgentChange,
|
onCustomUserAgentChange,
|
||||||
|
localProxyHeadersOverride,
|
||||||
|
onLocalProxyHeadersOverrideChange,
|
||||||
|
localProxyBodyOverride,
|
||||||
|
onLocalProxyBodyOverrideChange,
|
||||||
}: ClaudeFormFieldsProps) {
|
}: ClaudeFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const hasRequestOverrides = Boolean(
|
||||||
|
localProxyHeadersOverride.trim() || localProxyBodyOverride.trim(),
|
||||||
|
);
|
||||||
const hasAnyAdvancedValue = !!(
|
const hasAnyAdvancedValue = !!(
|
||||||
claudeModel ||
|
claudeModel ||
|
||||||
defaultHaikuModel ||
|
defaultHaikuModel ||
|
||||||
@@ -211,7 +223,8 @@ export function ClaudeFormFields({
|
|||||||
defaultFableModel ||
|
defaultFableModel ||
|
||||||
apiFormat !== "anthropic" ||
|
apiFormat !== "anthropic" ||
|
||||||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
|
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
|
||||||
customUserAgent
|
customUserAgent ||
|
||||||
|
hasRequestOverrides
|
||||||
);
|
);
|
||||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||||
|
|
||||||
@@ -963,6 +976,15 @@ export function ClaudeFormFields({
|
|||||||
value={customUserAgent}
|
value={customUserAgent}
|
||||||
onChange={onCustomUserAgentChange}
|
onChange={onCustomUserAgentChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-border-default pt-3">
|
||||||
|
<LocalProxyRequestOverridesField
|
||||||
|
headersJson={localProxyHeadersOverride}
|
||||||
|
bodyJson={localProxyBodyOverride}
|
||||||
|
onHeadersJsonChange={onLocalProxyHeadersOverrideChange}
|
||||||
|
onBodyJsonChange={onLocalProxyBodyOverrideChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type FetchedModel,
|
type FetchedModel,
|
||||||
} from "@/lib/api/model-fetch";
|
} from "@/lib/api/model-fetch";
|
||||||
import { CustomUserAgentField } from "./CustomUserAgentField";
|
import { CustomUserAgentField } from "./CustomUserAgentField";
|
||||||
|
import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
CodexApiFormat,
|
CodexApiFormat,
|
||||||
@@ -78,6 +79,10 @@ interface CodexFormFieldsProps {
|
|||||||
// Local proxy User-Agent override
|
// Local proxy User-Agent override
|
||||||
customUserAgent: string;
|
customUserAgent: string;
|
||||||
onCustomUserAgentChange: (value: string) => void;
|
onCustomUserAgentChange: (value: string) => void;
|
||||||
|
localProxyHeadersOverride: string;
|
||||||
|
onLocalProxyHeadersOverrideChange: (value: string) => void;
|
||||||
|
localProxyBodyOverride: string;
|
||||||
|
onLocalProxyBodyOverrideChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CodexCatalogRow = CodexCatalogModel & { rowId: string };
|
type CodexCatalogRow = CodexCatalogModel & { rowId: string };
|
||||||
@@ -136,6 +141,10 @@ export function CodexFormFields({
|
|||||||
speedTestEndpoints,
|
speedTestEndpoints,
|
||||||
customUserAgent,
|
customUserAgent,
|
||||||
onCustomUserAgentChange,
|
onCustomUserAgentChange,
|
||||||
|
localProxyHeadersOverride,
|
||||||
|
onLocalProxyHeadersOverrideChange,
|
||||||
|
localProxyBodyOverride,
|
||||||
|
onLocalProxyBodyOverrideChange,
|
||||||
}: CodexFormFieldsProps) {
|
}: CodexFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -150,7 +159,11 @@ export function CodexFormFields({
|
|||||||
const supportsEffort = codexChatReasoning.supportsEffort === true;
|
const supportsEffort = codexChatReasoning.supportsEffort === true;
|
||||||
|
|
||||||
// needsLocalRouting 非默认值说明预设/用户动过路由配置,需要让模型映射保持可见
|
// needsLocalRouting 非默认值说明预设/用户动过路由配置,需要让模型映射保持可见
|
||||||
const hasAnyAdvancedValue = !!customUserAgent || needsLocalRouting;
|
const hasRequestOverrides = Boolean(
|
||||||
|
localProxyHeadersOverride.trim() || localProxyBodyOverride.trim(),
|
||||||
|
);
|
||||||
|
const hasAnyAdvancedValue =
|
||||||
|
!!customUserAgent || hasRequestOverrides || needsLocalRouting;
|
||||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||||
|
|
||||||
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
||||||
@@ -480,6 +493,7 @@ export function CodexFormFields({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
"space-y-3",
|
||||||
(shouldShowSpeedTest ||
|
(shouldShowSpeedTest ||
|
||||||
(needsLocalRouting && canEditReasoning)) &&
|
(needsLocalRouting && canEditReasoning)) &&
|
||||||
"border-t border-border-default pt-3",
|
"border-t border-border-default pt-3",
|
||||||
@@ -490,6 +504,14 @@ export function CodexFormFields({
|
|||||||
value={customUserAgent}
|
value={customUserAgent}
|
||||||
onChange={onCustomUserAgentChange}
|
onChange={onCustomUserAgentChange}
|
||||||
/>
|
/>
|
||||||
|
<div className="border-t border-border-default pt-3">
|
||||||
|
<LocalProxyRequestOverridesField
|
||||||
|
headersJson={localProxyHeadersOverride}
|
||||||
|
bodyJson={localProxyBodyOverride}
|
||||||
|
onHeadersJsonChange={onLocalProxyHeadersOverrideChange}
|
||||||
|
onBodyJsonChange={onLocalProxyBodyOverrideChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */}
|
{/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FormLabel } from "@/components/ui/form";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
parseBodyOverrideJson,
|
||||||
|
parseHeaderOverrideJson,
|
||||||
|
} from "@/lib/requestOverrides";
|
||||||
|
|
||||||
|
interface LocalProxyRequestOverridesFieldProps {
|
||||||
|
headersJson: string;
|
||||||
|
bodyJson: string;
|
||||||
|
onHeadersJsonChange: (value: string) => void;
|
||||||
|
onBodyJsonChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalProxyRequestOverridesField({
|
||||||
|
headersJson,
|
||||||
|
bodyJson,
|
||||||
|
onHeadersJsonChange,
|
||||||
|
onBodyJsonChange,
|
||||||
|
}: LocalProxyRequestOverridesFieldProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const headerError = parseHeaderOverrideJson(headersJson).error;
|
||||||
|
const bodyError = parseBodyOverrideJson(bodyJson).error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FormLabel>
|
||||||
|
{t("providerForm.localProxyRequestOverrides", {
|
||||||
|
defaultValue: "本地代理请求覆盖",
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("providerForm.localProxyRequestOverridesHint", {
|
||||||
|
defaultValue:
|
||||||
|
"仅在本地路由/代理接管后生效,应用于协议转换后的上游请求。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("providerForm.localProxyHeaderOverrides", {
|
||||||
|
defaultValue: "Header 覆盖",
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
|
<Textarea
|
||||||
|
value={headersJson}
|
||||||
|
onChange={(event) => onHeadersJsonChange(event.target.value)}
|
||||||
|
placeholder={'{\n "X-Provider": "cc-switch"\n}'}
|
||||||
|
className="min-h-[132px] resize-y font-mono text-xs"
|
||||||
|
aria-invalid={Boolean(headerError)}
|
||||||
|
/>
|
||||||
|
{headerError && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t("providerForm.localProxyHeaderOverridesInvalidDetail", {
|
||||||
|
error: headerError,
|
||||||
|
defaultValue: "Header 覆盖格式错误:{{error}}",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("providerForm.localProxyBodyOverrides", {
|
||||||
|
defaultValue: "Body 覆盖",
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
|
<Textarea
|
||||||
|
value={bodyJson}
|
||||||
|
onChange={(event) => onBodyJsonChange(event.target.value)}
|
||||||
|
placeholder={'{\n "temperature": 0.2\n}'}
|
||||||
|
className="min-h-[132px] resize-y font-mono text-xs"
|
||||||
|
aria-invalid={Boolean(bodyError)}
|
||||||
|
/>
|
||||||
|
{bodyError && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t("providerForm.localProxyBodyOverridesInvalidDetail", {
|
||||||
|
error: bodyError,
|
||||||
|
defaultValue: "Body 覆盖格式错误:{{error}}",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||||||
|
import {
|
||||||
|
buildLocalProxyRequestOverrides,
|
||||||
|
formatRequestOverrideObject,
|
||||||
|
} from "@/lib/requestOverrides";
|
||||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||||
import type {
|
import type {
|
||||||
@@ -211,6 +215,10 @@ const normalizeCodexChatReasoningForSave = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LocalProxyRequestOverridesBuildResult = ReturnType<
|
||||||
|
typeof buildLocalProxyRequestOverrides
|
||||||
|
>;
|
||||||
|
|
||||||
export interface ProviderFormProps {
|
export interface ProviderFormProps {
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
@@ -358,6 +366,16 @@ function ProviderFormFull({
|
|||||||
});
|
});
|
||||||
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
|
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
|
||||||
setCustomUserAgent(initialData?.meta?.customUserAgent ?? "");
|
setCustomUserAgent(initialData?.meta?.customUserAgent ?? "");
|
||||||
|
setLocalProxyHeadersOverride(
|
||||||
|
formatRequestOverrideObject(
|
||||||
|
initialData?.meta?.localProxyRequestOverrides?.headers,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setLocalProxyBodyOverride(
|
||||||
|
formatRequestOverrideObject(
|
||||||
|
initialData?.meta?.localProxyRequestOverrides?.body,
|
||||||
|
),
|
||||||
|
);
|
||||||
}, [appId, initialData, supportsFullUrl]);
|
}, [appId, initialData, supportsFullUrl]);
|
||||||
|
|
||||||
const defaultValues: ProviderFormData = useMemo(
|
const defaultValues: ProviderFormData = useMemo(
|
||||||
@@ -414,6 +432,10 @@ function ProviderFormFull({
|
|||||||
const [softIssues, setSoftIssues] = useState<string[] | null>(null);
|
const [softIssues, setSoftIssues] = useState<string[] | null>(null);
|
||||||
const [pendingFormValues, setPendingFormValues] =
|
const [pendingFormValues, setPendingFormValues] =
|
||||||
useState<ProviderFormData | null>(null);
|
useState<ProviderFormData | null>(null);
|
||||||
|
const [
|
||||||
|
pendingLocalProxyRequestOverridesResult,
|
||||||
|
setPendingLocalProxyRequestOverridesResult,
|
||||||
|
] = useState<LocalProxyRequestOverridesBuildResult | null>(null);
|
||||||
// 确认框走的提交路径绕过了 react-hook-form 的 isSubmitting,单独追踪
|
// 确认框走的提交路径绕过了 react-hook-form 的 isSubmitting,单独追踪
|
||||||
const [isConfirmSubmitting, setIsConfirmSubmitting] = useState(false);
|
const [isConfirmSubmitting, setIsConfirmSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -517,6 +539,18 @@ function ProviderFormFull({
|
|||||||
const [customUserAgent, setCustomUserAgent] = useState<string>(
|
const [customUserAgent, setCustomUserAgent] = useState<string>(
|
||||||
() => initialData?.meta?.customUserAgent ?? "",
|
() => initialData?.meta?.customUserAgent ?? "",
|
||||||
);
|
);
|
||||||
|
const [localProxyHeadersOverride, setLocalProxyHeadersOverride] =
|
||||||
|
useState<string>(() =>
|
||||||
|
formatRequestOverrideObject(
|
||||||
|
initialData?.meta?.localProxyRequestOverrides?.headers,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [localProxyBodyOverride, setLocalProxyBodyOverride] = useState<string>(
|
||||||
|
() =>
|
||||||
|
formatRequestOverrideObject(
|
||||||
|
initialData?.meta?.localProxyRequestOverrides?.body,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
codexAuth,
|
codexAuth,
|
||||||
@@ -931,7 +965,26 @@ function ProviderFormFull({
|
|||||||
|
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const shouldApplyLocalProxyRequestOverrides =
|
||||||
|
(appId === "claude" || appId === "codex") && category !== "official";
|
||||||
|
|
||||||
const handleSubmit = async (values: ProviderFormData) => {
|
const handleSubmit = async (values: ProviderFormData) => {
|
||||||
|
const overridesResult = shouldApplyLocalProxyRequestOverrides
|
||||||
|
? buildLocalProxyRequestOverrides(
|
||||||
|
localProxyHeadersOverride,
|
||||||
|
localProxyBodyOverride,
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
if (overridesResult.error) {
|
||||||
|
toast.error(
|
||||||
|
t("providerForm.localProxyRequestOverridesInvalid", {
|
||||||
|
defaultValue: `本地代理请求覆盖格式错误:${overridesResult.error}`,
|
||||||
|
error: overridesResult.error,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 软性问题(业务约束,用户可选择仍要保存)
|
// 软性问题(业务约束,用户可选择仍要保存)
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
|
|
||||||
@@ -1169,13 +1222,27 @@ function ProviderFormFull({
|
|||||||
// 弹确认框让用户决定是否仍要保存
|
// 弹确认框让用户决定是否仍要保存
|
||||||
setSoftIssues(issues);
|
setSoftIssues(issues);
|
||||||
setPendingFormValues(values);
|
setPendingFormValues(values);
|
||||||
|
setPendingLocalProxyRequestOverridesResult(overridesResult);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await performSubmit(values);
|
await performSubmit(values, overridesResult);
|
||||||
};
|
};
|
||||||
|
|
||||||
const performSubmit = async (values: ProviderFormData) => {
|
const performSubmit = async (
|
||||||
|
values: ProviderFormData,
|
||||||
|
overridesResult: LocalProxyRequestOverridesBuildResult,
|
||||||
|
) => {
|
||||||
|
if (overridesResult.error) {
|
||||||
|
toast.error(
|
||||||
|
t("providerForm.localProxyRequestOverridesInvalid", {
|
||||||
|
defaultValue: `本地代理请求覆盖格式错误:${overridesResult.error}`,
|
||||||
|
error: overridesResult.error,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// OAuth / 其它身份识别(与 handleSubmit 保持一致)
|
// OAuth / 其它身份识别(与 handleSubmit 保持一致)
|
||||||
const isCopilotProvider =
|
const isCopilotProvider =
|
||||||
templatePreset?.providerType === "github_copilot" ||
|
templatePreset?.providerType === "github_copilot" ||
|
||||||
@@ -1398,6 +1465,9 @@ function ProviderFormFull({
|
|||||||
(appId === "claude" || appId === "codex") && category !== "official"
|
(appId === "claude" || appId === "codex") && category !== "official"
|
||||||
? customUserAgent.trim() || undefined
|
? customUserAgent.trim() || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
|
localProxyRequestOverrides: shouldApplyLocalProxyRequestOverrides
|
||||||
|
? overridesResult.overrides
|
||||||
|
: undefined,
|
||||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||||
costMultiplier: pricingConfig.enabled
|
costMultiplier: pricingConfig.enabled
|
||||||
? pricingConfig.costMultiplier
|
? pricingConfig.costMultiplier
|
||||||
@@ -2025,6 +2095,10 @@ function ProviderFormFull({
|
|||||||
onFullUrlChange={setLocalIsFullUrl}
|
onFullUrlChange={setLocalIsFullUrl}
|
||||||
customUserAgent={customUserAgent}
|
customUserAgent={customUserAgent}
|
||||||
onCustomUserAgentChange={setCustomUserAgent}
|
onCustomUserAgentChange={setCustomUserAgent}
|
||||||
|
localProxyHeadersOverride={localProxyHeadersOverride}
|
||||||
|
onLocalProxyHeadersOverrideChange={setLocalProxyHeadersOverride}
|
||||||
|
localProxyBodyOverride={localProxyBodyOverride}
|
||||||
|
onLocalProxyBodyOverrideChange={setLocalProxyBodyOverride}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2059,6 +2133,10 @@ function ProviderFormFull({
|
|||||||
speedTestEndpoints={speedTestEndpoints}
|
speedTestEndpoints={speedTestEndpoints}
|
||||||
customUserAgent={customUserAgent}
|
customUserAgent={customUserAgent}
|
||||||
onCustomUserAgentChange={setCustomUserAgent}
|
onCustomUserAgentChange={setCustomUserAgent}
|
||||||
|
localProxyHeadersOverride={localProxyHeadersOverride}
|
||||||
|
onLocalProxyHeadersOverrideChange={setLocalProxyHeadersOverride}
|
||||||
|
localProxyBodyOverride={localProxyBodyOverride}
|
||||||
|
onLocalProxyBodyOverrideChange={setLocalProxyBodyOverride}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2383,15 +2461,19 @@ function ProviderFormFull({
|
|||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (isConfirmSubmitting) return;
|
if (isConfirmSubmitting) return;
|
||||||
const values = pendingFormValues;
|
const values = pendingFormValues;
|
||||||
if (!values) {
|
const overridesResult = pendingLocalProxyRequestOverridesResult;
|
||||||
|
if (!values || !overridesResult) {
|
||||||
setSoftIssues(null);
|
setSoftIssues(null);
|
||||||
|
setPendingFormValues(null);
|
||||||
|
setPendingLocalProxyRequestOverridesResult(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsConfirmSubmitting(true);
|
setIsConfirmSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await performSubmit(values);
|
await performSubmit(values, overridesResult);
|
||||||
setSoftIssues(null);
|
setSoftIssues(null);
|
||||||
setPendingFormValues(null);
|
setPendingFormValues(null);
|
||||||
|
setPendingLocalProxyRequestOverridesResult(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ProviderForm] soft-confirm submit failed:", error);
|
console.error("[ProviderForm] soft-confirm submit failed:", error);
|
||||||
// 保留确认框和 pending values,让用户可以重试或取消
|
// 保留确认框和 pending values,让用户可以重试或取消
|
||||||
@@ -2403,6 +2485,7 @@ function ProviderFormFull({
|
|||||||
if (isConfirmSubmitting) return;
|
if (isConfirmSubmitting) return;
|
||||||
setSoftIssues(null);
|
setSoftIssues(null);
|
||||||
setPendingFormValues(null);
|
setPendingFormValues(null);
|
||||||
|
setPendingLocalProxyRequestOverridesResult(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1067,6 +1067,13 @@
|
|||||||
"customUserAgentHint": "Only takes effect when local routing/proxy takeover is enabled; replaces the User-Agent in requests forwarded to the provider API.",
|
"customUserAgentHint": "Only takes effect when local routing/proxy takeover is enabled; replaces the User-Agent in requests forwarded to the provider API.",
|
||||||
"customUserAgentInvalid": "User-Agent must not contain control characters (e.g. line breaks); otherwise it will be ignored.",
|
"customUserAgentInvalid": "User-Agent must not contain control characters (e.g. line breaks); otherwise it will be ignored.",
|
||||||
"customUserAgentPresets": "Presets",
|
"customUserAgentPresets": "Presets",
|
||||||
|
"localProxyRequestOverrides": "Local proxy request overrides",
|
||||||
|
"localProxyRequestOverridesHint": "Only takes effect after local routing/proxy takeover; applied to the transformed upstream request.",
|
||||||
|
"localProxyRequestOverridesInvalid": "Local proxy request overrides format error: {{error}}",
|
||||||
|
"localProxyHeaderOverrides": "Header overrides",
|
||||||
|
"localProxyHeaderOverridesInvalidDetail": "Header overrides format error: {{error}}",
|
||||||
|
"localProxyBodyOverrides": "Body overrides",
|
||||||
|
"localProxyBodyOverridesInvalidDetail": "Body overrides format error: {{error}}",
|
||||||
"fullUrlLabel": "Full URL",
|
"fullUrlLabel": "Full URL",
|
||||||
"fullUrlEnabled": "Full URL Mode",
|
"fullUrlEnabled": "Full URL Mode",
|
||||||
"fullUrlDisabled": "Mark as Full URL",
|
"fullUrlDisabled": "Mark as Full URL",
|
||||||
|
|||||||
@@ -1067,6 +1067,13 @@
|
|||||||
"customUserAgentHint": "ローカルルーティング/プロキシ引き継ぎが有効な場合にのみ適用され、プロバイダー API へ転送するリクエストの User-Agent を置き換えます。",
|
"customUserAgentHint": "ローカルルーティング/プロキシ引き継ぎが有効な場合にのみ適用され、プロバイダー API へ転送するリクエストの User-Agent を置き換えます。",
|
||||||
"customUserAgentInvalid": "User-Agent に制御文字(改行など)を含めることはできません。含まれている場合は無視されます。",
|
"customUserAgentInvalid": "User-Agent に制御文字(改行など)を含めることはできません。含まれている場合は無視されます。",
|
||||||
"customUserAgentPresets": "プリセット",
|
"customUserAgentPresets": "プリセット",
|
||||||
|
"localProxyRequestOverrides": "ローカルプロキシのリクエスト上書き",
|
||||||
|
"localProxyRequestOverridesHint": "ローカルルーティング/プロキシ引き継ぎ後にのみ有効で、変換後の上流リクエストへ適用されます。",
|
||||||
|
"localProxyRequestOverridesInvalid": "ローカルプロキシのリクエスト上書き形式エラー: {{error}}",
|
||||||
|
"localProxyHeaderOverrides": "Header 上書き",
|
||||||
|
"localProxyHeaderOverridesInvalidDetail": "Header 上書きの形式エラー: {{error}}",
|
||||||
|
"localProxyBodyOverrides": "Body 上書き",
|
||||||
|
"localProxyBodyOverridesInvalidDetail": "Body 上書きの形式エラー: {{error}}",
|
||||||
"fullUrlLabel": "フル URL",
|
"fullUrlLabel": "フル URL",
|
||||||
"fullUrlEnabled": "フル URL モード",
|
"fullUrlEnabled": "フル URL モード",
|
||||||
"fullUrlDisabled": "フル URL として設定",
|
"fullUrlDisabled": "フル URL として設定",
|
||||||
|
|||||||
@@ -1039,6 +1039,13 @@
|
|||||||
"customUserAgentHint": "僅在開啟本地路由/代理接管後生效,會取代轉發至供應商 API 請求中的 User-Agent。",
|
"customUserAgentHint": "僅在開啟本地路由/代理接管後生效,會取代轉發至供應商 API 請求中的 User-Agent。",
|
||||||
"customUserAgentInvalid": "User-Agent 不能包含控制字元(如換行字元),否則將被忽略。",
|
"customUserAgentInvalid": "User-Agent 不能包含控制字元(如換行字元),否則將被忽略。",
|
||||||
"customUserAgentPresets": "預設",
|
"customUserAgentPresets": "預設",
|
||||||
|
"localProxyRequestOverrides": "本地代理請求覆蓋",
|
||||||
|
"localProxyRequestOverridesHint": "僅在本地路由/代理接管後生效,套用於協定轉換後的上游請求。",
|
||||||
|
"localProxyRequestOverridesInvalid": "本地代理請求覆蓋格式錯誤:{{error}}",
|
||||||
|
"localProxyHeaderOverrides": "Header 覆蓋",
|
||||||
|
"localProxyHeaderOverridesInvalidDetail": "Header 覆蓋格式錯誤:{{error}}",
|
||||||
|
"localProxyBodyOverrides": "Body 覆蓋",
|
||||||
|
"localProxyBodyOverridesInvalidDetail": "Body 覆蓋格式錯誤:{{error}}",
|
||||||
"fullUrlLabel": "完整 URL",
|
"fullUrlLabel": "完整 URL",
|
||||||
"fullUrlEnabled": "完整 URL 模式",
|
"fullUrlEnabled": "完整 URL 模式",
|
||||||
"fullUrlDisabled": "標記為完整 URL",
|
"fullUrlDisabled": "標記為完整 URL",
|
||||||
|
|||||||
@@ -1067,6 +1067,13 @@
|
|||||||
"customUserAgentHint": "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
|
"customUserAgentHint": "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
|
||||||
"customUserAgentInvalid": "User-Agent 不能包含控制字符(如换行符),否则将被忽略。",
|
"customUserAgentInvalid": "User-Agent 不能包含控制字符(如换行符),否则将被忽略。",
|
||||||
"customUserAgentPresets": "预设",
|
"customUserAgentPresets": "预设",
|
||||||
|
"localProxyRequestOverrides": "本地代理请求覆盖",
|
||||||
|
"localProxyRequestOverridesHint": "仅在本地路由/代理接管后生效,应用于协议转换后的上游请求。",
|
||||||
|
"localProxyRequestOverridesInvalid": "本地代理请求覆盖格式错误:{{error}}",
|
||||||
|
"localProxyHeaderOverrides": "Header 覆盖",
|
||||||
|
"localProxyHeaderOverridesInvalidDetail": "Header 覆盖格式错误:{{error}}",
|
||||||
|
"localProxyBodyOverrides": "Body 覆盖",
|
||||||
|
"localProxyBodyOverridesInvalidDetail": "Body 覆盖格式错误:{{error}}",
|
||||||
"fullUrlLabel": "完整 URL",
|
"fullUrlLabel": "完整 URL",
|
||||||
"fullUrlEnabled": "完整 URL 模式",
|
"fullUrlEnabled": "完整 URL 模式",
|
||||||
"fullUrlDisabled": "标记为完整 URL",
|
"fullUrlDisabled": "标记为完整 URL",
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import type { LocalProxyRequestOverrides } from "@/types";
|
||||||
|
|
||||||
|
export interface RequestOverrideJsonResult {
|
||||||
|
value?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeaderOverrideValidationResult {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 9110 HTTP field-name token. Keep this aligned with Rust's
|
||||||
|
// http::HeaderName parser for user-facing validation.
|
||||||
|
export function isValidHttpHeaderName(name: string): boolean {
|
||||||
|
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROTECTED_LOCAL_PROXY_HEADER_NAMES = new Set([
|
||||||
|
"host",
|
||||||
|
"content-length",
|
||||||
|
"transfer-encoding",
|
||||||
|
"connection",
|
||||||
|
"proxy-authorization",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"upgrade",
|
||||||
|
"accept-encoding",
|
||||||
|
"content-type",
|
||||||
|
"authorization",
|
||||||
|
"x-api-key",
|
||||||
|
"x-goog-api-key",
|
||||||
|
"chatgpt-account-id",
|
||||||
|
"session_id",
|
||||||
|
"x-client-request-id",
|
||||||
|
"x-codex-window-id",
|
||||||
|
"x-forwarded-host",
|
||||||
|
"x-forwarded-port",
|
||||||
|
"x-forwarded-proto",
|
||||||
|
"forwarded",
|
||||||
|
"cf-connecting-ip",
|
||||||
|
"cf-ipcountry",
|
||||||
|
"cf-ray",
|
||||||
|
"cf-visitor",
|
||||||
|
"true-client-ip",
|
||||||
|
"fastly-client-ip",
|
||||||
|
"x-azure-clientip",
|
||||||
|
"x-azure-fdid",
|
||||||
|
"x-azure-ref",
|
||||||
|
"akamai-origin-hop",
|
||||||
|
"x-akamai-config-log-detail",
|
||||||
|
"x-request-id",
|
||||||
|
"x-correlation-id",
|
||||||
|
"x-trace-id",
|
||||||
|
"x-amzn-trace-id",
|
||||||
|
"x-b3-traceid",
|
||||||
|
"x-b3-spanid",
|
||||||
|
"x-b3-parentspanid",
|
||||||
|
"x-b3-sampled",
|
||||||
|
"traceparent",
|
||||||
|
"tracestate",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isProtectedLocalProxyHeaderName(name: string): boolean {
|
||||||
|
return PROTECTED_LOCAL_PROXY_HEADER_NAMES.has(name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep this aligned with Rust's http::HeaderValue runtime guard: only control
|
||||||
|
// characters other than tab are rejected.
|
||||||
|
export function isValidHttpHeaderValue(value: string): boolean {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return !/[\x00-\x08\x0a-\x1f\x7f]/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlainObject(
|
||||||
|
value: unknown,
|
||||||
|
): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRequestOverrideJson(
|
||||||
|
raw: string,
|
||||||
|
): RequestOverrideJsonResult {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (!isPlainObject(parsed)) {
|
||||||
|
return { error: "JSON must be an object" };
|
||||||
|
}
|
||||||
|
return { value: parsed };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: error instanceof Error ? error.message : "Invalid JSON",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBodyOverrideJson(raw: string): RequestOverrideJsonResult {
|
||||||
|
const parsed = parseRequestOverrideJson(raw);
|
||||||
|
if (parsed.error || !parsed.value) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(parsed.value, "stream")) {
|
||||||
|
return { error: 'Body override must not include protocol field "stream"' };
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHeaderOverrideJson(
|
||||||
|
raw: string,
|
||||||
|
): HeaderOverrideValidationResult {
|
||||||
|
const parsed = parseRequestOverrideJson(raw);
|
||||||
|
if (parsed.error || !parsed.value) {
|
||||||
|
return parsed.error ? { error: parsed.error } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const [name, value] of Object.entries(parsed.value)) {
|
||||||
|
const headerName = name.trim();
|
||||||
|
if (!headerName) {
|
||||||
|
return { error: "Header name must not be empty" };
|
||||||
|
}
|
||||||
|
if (!isValidHttpHeaderName(headerName)) {
|
||||||
|
return { error: `Header "${name}" name is not a valid HTTP token` };
|
||||||
|
}
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return { error: `Header "${name}" value must be a string` };
|
||||||
|
}
|
||||||
|
if (!isValidHttpHeaderValue(value)) {
|
||||||
|
return { error: `Header "${name}" value contains control characters` };
|
||||||
|
}
|
||||||
|
const normalizedHeaderName = headerName.toLowerCase();
|
||||||
|
if (Object.prototype.hasOwnProperty.call(headers, normalizedHeaderName)) {
|
||||||
|
return {
|
||||||
|
error: `Header "${name}" duplicates another header after case normalization`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isProtectedLocalProxyHeaderName(normalizedHeaderName)) {
|
||||||
|
return {
|
||||||
|
error: `Header "${name}" is managed by the local proxy and cannot be overridden`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
headers[normalizedHeaderName] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRequestOverrideObject(
|
||||||
|
value: Record<string, unknown> | undefined,
|
||||||
|
): string {
|
||||||
|
if (!value || Object.keys(value).length === 0) return "";
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocalProxyRequestOverrides(
|
||||||
|
headersJson: string,
|
||||||
|
bodyJson: string,
|
||||||
|
): { overrides?: LocalProxyRequestOverrides; error?: string } {
|
||||||
|
const headerResult = parseHeaderOverrideJson(headersJson);
|
||||||
|
if (headerResult.error) {
|
||||||
|
return { error: headerResult.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyResult = parseBodyOverrideJson(bodyJson);
|
||||||
|
if (bodyResult.error) {
|
||||||
|
return { error: bodyResult.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides: LocalProxyRequestOverrides = {};
|
||||||
|
if (headerResult.headers && Object.keys(headerResult.headers).length > 0) {
|
||||||
|
overrides.headers = headerResult.headers;
|
||||||
|
}
|
||||||
|
if (bodyResult.value && Object.keys(bodyResult.value).length > 0) {
|
||||||
|
overrides.body = bodyResult.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(overrides).length > 0 ? { overrides } : {};
|
||||||
|
}
|
||||||
@@ -171,6 +171,11 @@ export interface CodexChatReasoning {
|
|||||||
outputFormat?: CodexChatReasoningOutputFormat;
|
outputFormat?: CodexChatReasoningOutputFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalProxyRequestOverrides {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
||||||
export interface ProviderMeta {
|
export interface ProviderMeta {
|
||||||
// 自定义端点:以 URL 为键,值为端点信息
|
// 自定义端点:以 URL 为键,值为端点信息
|
||||||
@@ -219,6 +224,8 @@ export interface ProviderMeta {
|
|||||||
codexChatReasoning?: CodexChatReasoning;
|
codexChatReasoning?: CodexChatReasoning;
|
||||||
// Custom User-Agent for local proxy routing. Only applied by the local proxy.
|
// Custom User-Agent for local proxy routing. Only applied by the local proxy.
|
||||||
customUserAgent?: string;
|
customUserAgent?: string;
|
||||||
|
// Local proxy request overrides. Only applied by the local proxy after route transforms.
|
||||||
|
localProxyRequestOverrides?: LocalProxyRequestOverrides;
|
||||||
// 供应商类型(用于识别 Copilot 等特殊供应商)
|
// 供应商类型(用于识别 Copilot 等特殊供应商)
|
||||||
providerType?: string;
|
providerType?: string;
|
||||||
// GitHub Copilot 关联账号 ID(旧字段,保留兼容读取)
|
// GitHub Copilot 关联账号 ID(旧字段,保留兼容读取)
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ const renderCopilotForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) => {
|
|||||||
onFullUrlChange: vi.fn(),
|
onFullUrlChange: vi.fn(),
|
||||||
customUserAgent: "",
|
customUserAgent: "",
|
||||||
onCustomUserAgentChange: vi.fn(),
|
onCustomUserAgentChange: vi.fn(),
|
||||||
|
localProxyHeadersOverride: "",
|
||||||
|
onLocalProxyHeadersOverrideChange: vi.fn(),
|
||||||
|
localProxyBodyOverride: "",
|
||||||
|
onLocalProxyBodyOverrideChange: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildLocalProxyRequestOverrides,
|
||||||
|
isProtectedLocalProxyHeaderName,
|
||||||
|
isValidHttpHeaderName,
|
||||||
|
isValidHttpHeaderValue,
|
||||||
|
parseBodyOverrideJson,
|
||||||
|
parseHeaderOverrideJson,
|
||||||
|
parseRequestOverrideJson,
|
||||||
|
} from "@/lib/requestOverrides";
|
||||||
|
|
||||||
|
describe("requestOverrides", () => {
|
||||||
|
it("treats empty JSON fields as unset", () => {
|
||||||
|
expect(buildLocalProxyRequestOverrides("", " ")).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses header and body override objects", () => {
|
||||||
|
expect(
|
||||||
|
buildLocalProxyRequestOverrides(
|
||||||
|
'{ "X-Test": "ok" }',
|
||||||
|
'{ "temperature": 0.2 }',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
overrides: {
|
||||||
|
headers: { "x-test": "ok" },
|
||||||
|
body: { temperature: 0.2 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-object body overrides", () => {
|
||||||
|
expect(parseRequestOverrideJson("[]").error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-string header values", () => {
|
||||||
|
expect(parseHeaderOverrideJson('{ "X-Test": 1 }').error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid header names", () => {
|
||||||
|
expect(isValidHttpHeaderName("X-Test")).toBe(true);
|
||||||
|
expect(isValidHttpHeaderName("X Foo")).toBe(false);
|
||||||
|
expect(isValidHttpHeaderName("Authorization:")).toBe(false);
|
||||||
|
expect(parseHeaderOverrideJson('{ "X Foo": "bar" }').error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate header names after case normalization", () => {
|
||||||
|
expect(
|
||||||
|
parseHeaderOverrideJson('{ "X-Foo": "a", "x-foo": "b" }').error,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects protected proxy-managed header names", () => {
|
||||||
|
expect(isProtectedLocalProxyHeaderName("Content-Type")).toBe(true);
|
||||||
|
expect(isProtectedLocalProxyHeaderName("authorization")).toBe(true);
|
||||||
|
expect(isProtectedLocalProxyHeaderName("X-Test")).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseHeaderOverrideJson('{ "content-type": "text/plain" }').error,
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
parseHeaderOverrideJson('{ "Authorization": "Bearer x" }').error,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches backend header value control-character rule", () => {
|
||||||
|
expect(isValidHttpHeaderValue("hello\tworld")).toBe(true);
|
||||||
|
expect(isValidHttpHeaderValue("hello\nworld")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stream in body overrides", () => {
|
||||||
|
expect(parseBodyOverrideJson('{ "stream": true }').error).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
buildLocalProxyRequestOverrides("", '{ "stream": false }').error,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user