Merge remote-tracking branch 'origin/pr/383' into codex/pr-376-377-383-384-combined

This commit is contained in:
fawney19
2026-05-06 02:37:09 +08:00
6 changed files with 573 additions and 45 deletions

View File

@@ -10,6 +10,7 @@ pub(crate) mod ndjson;
mod oauth_retry;
#[cfg(test)]
pub(crate) mod remote_compat;
mod response_header_rules;
mod server;
pub(crate) mod stream;
mod stream_pump;
@@ -30,6 +31,9 @@ pub(crate) use self::fallback::{
should_retry_next_local_candidate_stream, should_retry_next_local_candidate_sync,
should_stop_local_candidate_failover_stream, should_stop_local_candidate_failover_sync,
};
pub(crate) use self::response_header_rules::{
apply_endpoint_response_header_rules, attach_provider_response_headers_to_report_context,
};
pub(crate) use crate::orchestration::{
append_local_failover_policy_to_value, LocalFailoverAnalysis, LocalFailoverDecision,
};

View File

@@ -0,0 +1,101 @@
use std::collections::BTreeMap;
use aether_contracts::ExecutionPlan;
use serde_json::{Map, Value};
use tracing::warn;
use crate::{AppState, GatewayError};
const RESPONSE_HEADER_RULES_KEY: &str = "response_header_rules";
const RESPONSE_HEADER_RULES_CAMEL_KEY: &str = "responseHeaderRules";
const PROVIDER_RESPONSE_HEADERS_CONTEXT_KEY: &str = "provider_response_headers";
const RESPONSE_HEADER_RULE_PROTECTED_KEYS: &[&str] = &["content-length"];
fn endpoint_response_header_rules_from_config(config: Option<&Value>) -> Option<&Value> {
let config = config?.as_object()?;
config
.get(RESPONSE_HEADER_RULES_KEY)
.or_else(|| config.get(RESPONSE_HEADER_RULES_CAMEL_KEY))
.filter(|value| !value.is_null())
}
async fn read_endpoint_response_header_rules(state: &AppState, endpoint_id: &str) -> Option<Value> {
let endpoint_id = endpoint_id.trim();
if endpoint_id.is_empty() {
return None;
}
let endpoint_id = endpoint_id.to_string();
match state
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id))
.await
{
Ok(endpoints) => endpoints.into_iter().next().and_then(|endpoint| {
endpoint_response_header_rules_from_config(endpoint.config.as_ref()).cloned()
}),
Err(err) => {
warn!(
event_name = "response_header_rules_endpoint_read_failed",
log_type = "ops",
endpoint_id = %endpoint_id,
error = ?err,
"gateway failed to read endpoint response header rules; skipping response header edits"
);
None
}
}
}
pub(crate) async fn apply_endpoint_response_header_rules(
state: &AppState,
plan: &ExecutionPlan,
headers: &mut BTreeMap<String, String>,
response_body: Option<&Value>,
) -> Result<(), GatewayError> {
let Some(rules) = read_endpoint_response_header_rules(state, plan.endpoint_id.as_str()).await
else {
return Ok(());
};
if !rules.is_array() {
warn!(
event_name = "response_header_rules_invalid_shape",
log_type = "ops",
endpoint_id = %plan.endpoint_id,
"gateway skipped endpoint response header rules because response_header_rules is not an array"
);
return Ok(());
}
let empty_body = Value::Null;
let body = response_body.unwrap_or(&empty_body);
if !crate::provider_transport::apply_local_header_rules(
headers,
Some(&rules),
RESPONSE_HEADER_RULE_PROTECTED_KEYS,
body,
response_body,
) {
return Err(GatewayError::Internal(
"response_header_rules 应用失败".to_string(),
));
}
Ok(())
}
pub(crate) fn attach_provider_response_headers_to_report_context(
report_context: Option<Value>,
provider_headers: &BTreeMap<String, String>,
) -> Option<Value> {
let provider_headers = serde_json::to_value(provider_headers).ok()?;
let mut object = match report_context {
Some(Value::Object(object)) => object,
Some(other) => Map::from_iter([("seed".to_string(), other)]),
None => Map::new(),
};
object.insert(
PROVIDER_RESPONSE_HEADERS_CONTEXT_KEY.to_string(),
provider_headers,
);
Some(Value::Object(object))
}

View File

@@ -65,6 +65,7 @@ use crate::execution_runtime::transport::{
DirectSyncExecutionRuntime, DirectUpstreamStreamExecution, ExecutionRuntimeTransportError,
};
use crate::execution_runtime::{
apply_endpoint_response_header_rules, attach_provider_response_headers_to_report_context,
local_failover_response_text, resolve_core_stream_direct_finalize_report_kind,
resolve_core_stream_error_finalize_report_kind,
resolve_local_candidate_failover_analysis_stream, should_fallback_to_control_stream,
@@ -839,6 +840,8 @@ async fn execute_stream_from_frame_stream(
"execution runtime stream must start with headers frame".to_string(),
));
};
let report_context =
attach_provider_response_headers_to_report_context(report_context, &headers);
let mut buffered_frames = VecDeque::new();
let mut stream_terminal_summary: Option<ExecutionStreamTerminalSummary> = None;
if status_code == 200 && should_probe_success_failover_before_stream(&headers) {
@@ -1069,6 +1072,10 @@ async fn execute_stream_from_frame_stream(
return Ok(None);
}
let mut client_headers = headers.clone();
apply_endpoint_response_header_rules(state, &plan, &mut client_headers, body_json.as_ref())
.await?;
let payload = build_stream_sync_payload(
trace_id,
stream_error_finalize_kind
@@ -1078,7 +1085,7 @@ async fn execute_stream_from_frame_stream(
.to_string(),
report_context,
status_code,
headers,
client_headers,
body_json,
body_base64,
None,
@@ -1510,6 +1517,8 @@ async fn execute_stream_from_frame_stream(
});
}
apply_endpoint_response_header_rules(state, &plan, &mut headers, None).await?;
let request_id = request_id.to_string();
let candidate_id = candidate_id.map(ToOwned::to_owned);
let (tx, mut rx) = mpsc::channel::<Result<Bytes, IoError>>(16);

View File

@@ -30,7 +30,8 @@ use crate::execution_runtime::remote_compat::post_sync_plan_to_remote_execution_
use crate::execution_runtime::submission::submit_local_core_error_or_sync_finalize;
use crate::execution_runtime::transport::DirectSyncExecutionRuntime;
use crate::execution_runtime::{
analyze_local_candidate_failover_sync, local_failover_response_text,
analyze_local_candidate_failover_sync, apply_endpoint_response_header_rules,
attach_provider_response_headers_to_report_context, local_failover_response_text,
resolve_core_sync_error_finalize_report_kind, should_fallback_to_control_sync,
should_finalize_sync_response, LocalFailoverDecision,
};
@@ -539,6 +540,11 @@ pub(crate) async fn execute_execution_runtime_sync(
}
let status_code = result.status_code;
let has_body_bytes = body_base64.is_some();
let report_context =
attach_provider_response_headers_to_report_context(report_context, &headers);
let mut client_headers = headers.clone();
apply_endpoint_response_header_rules(state, &plan, &mut client_headers, body_json.as_ref())
.await?;
let explicit_finalize = should_finalize_sync_response(report_kind.as_deref());
let mapped_error_finalize_kind =
resolve_core_sync_error_finalize_report_kind(plan_kind, &result, body_json.as_ref());
@@ -549,7 +555,7 @@ pub(crate) async fn execute_execution_runtime_sync(
plan_kind,
&report_context,
status_code,
&headers,
&client_headers,
&body_json,
&body_base64,
&result.telemetry,
@@ -694,7 +700,7 @@ pub(crate) async fn execute_execution_runtime_sync(
finalize_report_kind,
report_context,
status_code,
headers,
client_headers,
body_json,
body_base64,
telemetry,
@@ -863,7 +869,7 @@ pub(crate) async fn execute_execution_runtime_sync(
report_kind.unwrap_or_default(),
report_context,
status_code,
headers,
client_headers,
body_json,
body_base64,
telemetry,

View File

@@ -136,7 +136,8 @@ pub struct SyncTerminalUsagePayloadSeed {
pub status_code: u16,
pub response_time_ms: Option<u64>,
pub first_byte_time_ms: Option<u64>,
pub response_headers: Option<Value>,
pub provider_response_headers: Option<Value>,
pub client_response_headers: Option<Value>,
pub provider_response_full: Option<Value>,
pub provider_response_body_state: Option<UsageBodyCaptureState>,
pub client_response: Option<Value>,
@@ -150,7 +151,8 @@ pub struct StreamTerminalUsagePayloadSeed {
pub status_code: u16,
pub response_time_ms: Option<u64>,
pub first_byte_time_ms: Option<u64>,
pub response_headers: Option<Value>,
pub provider_response_headers: Option<Value>,
pub client_response_headers: Option<Value>,
pub provider_response_full: Option<Value>,
pub provider_response_body_state: Option<UsageBodyCaptureState>,
pub client_response: Option<Value>,
@@ -729,7 +731,10 @@ pub fn build_sync_terminal_usage_payload_seed(
false,
false,
));
let response_headers = headers_to_json(&payload.headers);
let context = payload.report_context.as_ref().and_then(Value::as_object);
let provider_response_headers = context_usage_value(context, "provider_response_headers")
.or_else(|| headers_to_json(&payload.headers));
let client_response_headers = headers_to_json(&payload.headers);
SyncTerminalUsagePayloadSeed {
report_kind: payload.report_kind.clone(),
status_code: payload.status_code,
@@ -738,7 +743,8 @@ pub fn build_sync_terminal_usage_payload_seed(
.as_ref()
.and_then(|value| value.elapsed_ms),
first_byte_time_ms: payload.telemetry.as_ref().and_then(|value| value.ttfb_ms),
response_headers,
provider_response_headers,
client_response_headers,
provider_response_full,
provider_response_body_state,
client_response,
@@ -755,7 +761,10 @@ pub fn build_sync_terminal_usage_payload_seed(
pub fn build_stream_terminal_usage_payload_seed(
payload: &GatewayStreamReportRequest,
) -> StreamTerminalUsagePayloadSeed {
let response_headers = headers_to_json(&payload.headers);
let context = payload.report_context.as_ref().and_then(Value::as_object);
let provider_response_headers = context_usage_value(context, "provider_response_headers")
.or_else(|| headers_to_json(&payload.headers));
let client_response_headers = headers_to_json(&payload.headers);
StreamTerminalUsagePayloadSeed {
report_kind: payload.report_kind.clone(),
status_code: payload.status_code,
@@ -764,7 +773,8 @@ pub fn build_stream_terminal_usage_payload_seed(
.as_ref()
.and_then(|value| value.elapsed_ms),
first_byte_time_ms: payload.telemetry.as_ref().and_then(|value| value.ttfb_ms),
response_headers,
provider_response_headers,
client_response_headers,
provider_response_full: decode_body_for_storage(payload.provider_body_base64.as_deref()),
provider_response_body_state: payload.provider_body_state,
client_response: decode_body_for_storage(payload.client_body_base64.as_deref()),
@@ -791,7 +801,8 @@ pub fn build_sync_terminal_usage_seed(
status_code,
response_time_ms,
first_byte_time_ms,
response_headers,
provider_response_headers,
client_response_headers,
provider_response_full,
provider_response_body_state,
client_response,
@@ -842,9 +853,9 @@ pub fn build_sync_terminal_usage_seed(
client_response_body_state,
},
routing: context_seed.routing,
provider_response_headers: response_headers.clone(),
provider_response_headers,
provider_response: provider_response_full,
client_response_headers: response_headers,
client_response_headers,
client_response,
request_metadata: context_seed.request_metadata,
audit_payload: capture_metadata,
@@ -862,7 +873,8 @@ pub fn build_stream_terminal_usage_seed(
status_code,
response_time_ms,
first_byte_time_ms,
response_headers,
provider_response_headers,
client_response_headers,
provider_response_full,
provider_response_body_state,
client_response,
@@ -912,9 +924,9 @@ pub fn build_stream_terminal_usage_seed(
client_response_body_state,
},
routing: context_seed.routing,
provider_response_headers: response_headers.clone(),
provider_response_headers,
provider_response: provider_response_full,
client_response_headers: response_headers,
client_response_headers,
client_response,
request_metadata: context_seed.request_metadata,
audit_payload: capture_metadata,

View File

@@ -186,7 +186,7 @@
</div>
</div>
<!-- 请求规则(合并请求头请求体规则) -->
<!-- 请求/响应规则(请求头请求体和响应头规则) -->
<Collapsible v-model:open="endpointRulesExpanded[endpoint.id]">
<div class="flex items-center gap-2">
<!-- 有规则时显示可折叠的触发器 -->
@@ -202,7 +202,7 @@
class="w-4 h-4 transition-transform text-muted-foreground"
:class="{ 'rotate-90': endpointRulesExpanded[endpoint.id] }"
/>
<span class="text-sm font-medium">请求规则</span>
<span class="text-sm font-medium">请求/响应规则</span>
<Badge
variant="secondary"
class="text-xs"
@@ -216,12 +216,12 @@
v-else
class="text-sm text-muted-foreground py-1.5"
>
请求规则
请求/响应规则
</span>
<div class="flex-1" />
<div class="flex items-center gap-1 shrink-0">
<Button
v-if="hasRulesChanges(endpoint) || hasBodyRulesChanges(endpoint)"
v-if="hasRulesChanges(endpoint) || hasBodyRulesChanges(endpoint) || hasResponseHeaderRulesChanges(endpoint)"
variant="ghost"
size="icon"
class="h-7 w-7"
@@ -251,6 +251,16 @@
<Plus class="w-3 h-3 mr-1" />
请求体
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
title="添加响应头规则"
@click="handleAddEndpointResponseRule(endpoint.id)"
>
<Plus class="w-3 h-3 mr-1" />
响应头
</Button>
<Button
v-if="isFixedProvider && hasDefaultBodyRules(endpoint.api_format)"
variant="ghost"
@@ -268,7 +278,7 @@
<CollapsibleContent class="pt-3">
<div class="space-y-2">
<div
v-if="getEndpointRulesCount(endpoint) > 1 || getEndpointBodyRulesCount(endpoint) > 1"
v-if="getEndpointRulesCount(endpoint) > 1 || getEndpointBodyRulesCount(endpoint) > 1 || getEndpointResponseRulesCount(endpoint) > 1"
class="flex items-center gap-1.5 text-xs text-muted-foreground px-2"
>
<GripVertical class="w-3.5 h-3.5" />
@@ -396,6 +406,128 @@
/>
</template>
<!-- 响应头规则列表 -->
<template
v-for="(rule, index) in getEndpointEditResponseRules(endpoint.id)"
:key="`response-header-${index}`"
>
<div
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-sky-500/60 bg-muted/30"
:class="[
isResponseRuleDragging(endpoint.id, index) ? 'opacity-60 border-sky-500 bg-sky-500/5' : '',
isResponseRuleDragOver(endpoint.id, index) ? 'ring-1 ring-sky-500/40 bg-sky-500/10' : ''
]"
@dragover.prevent="handleResponseRuleDragOver(endpoint.id, index)"
@dragleave="handleResponseRuleDragLeave(endpoint.id, index)"
@drop.prevent="handleResponseRuleDrop(endpoint.id, index)"
>
<button
type="button"
class="h-7 w-6 shrink-0 inline-flex items-center justify-center rounded-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted cursor-grab active:cursor-grabbing"
title="拖拽排序"
draggable="true"
@dragstart="(e) => handleResponseRuleDragStart(endpoint.id, index, e)"
@dragend="() => handleResponseRuleDragEnd(endpoint.id)"
>
<GripVertical class="w-3.5 h-3.5" />
</button>
<span
class="text-[10px] font-semibold text-sky-600 dark:text-sky-400 shrink-0"
title="响应头"
>R</span>
<Select
:model-value="rule.action"
:open="responseRuleSelectOpen[`${endpoint.id}-${index}`]"
@update:model-value="(v) => updateEndpointResponseRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')"
@update:open="(v) => handleResponseRuleSelectOpen(endpoint.id, index, v)"
>
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="set">
覆写
</SelectItem>
<SelectItem value="drop">
删除
</SelectItem>
<SelectItem value="rename">
重命名
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
:class="rule.condition ? 'text-primary' : ''"
title="条件触发"
@click="toggleEndpointResponseRuleCondition(endpoint.id, index)"
>
<Filter class="w-3 h-3" />
</Button>
<template v-if="rule.action === 'set'">
<Input
:model-value="rule.key"
placeholder="响应头名称"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointResponseRuleField(endpoint.id, index, 'key', v)"
/>
<span class="text-muted-foreground text-xs">=</span>
<Input
:model-value="rule.value"
placeholder=""
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointResponseRuleField(endpoint.id, index, 'value', v)"
/>
</template>
<template v-else-if="rule.action === 'drop'">
<Input
:model-value="rule.key"
placeholder="要删除的响应头"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointResponseRuleField(endpoint.id, index, 'key', v)"
/>
</template>
<template v-else-if="rule.action === 'rename'">
<Input
:model-value="rule.from"
placeholder="原名"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointResponseRuleField(endpoint.id, index, 'from', v)"
/>
<span class="text-muted-foreground text-xs">→</span>
<Input
:model-value="rule.to"
placeholder="新名"
size="sm"
class="flex-1 min-w-0 h-7 text-xs"
@update:model-value="(v) => updateEndpointResponseRuleField(endpoint.id, index, 'to', v)"
/>
</template>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@click="removeEndpointResponseRule(endpoint.id, index)"
>
<X class="w-3 h-3" />
</Button>
</div>
<EndpointConditionEditor
v-if="rule.condition"
:model-value="rule.condition"
path-hint="响应体字段路径"
removable
@update:model-value="(condition) => updateEndpointResponseRuleCondition(endpoint.id, index, condition)"
@remove="clearEndpointResponseRuleCondition(endpoint.id, index)"
/>
</template>
<div
v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
class="flex items-center gap-1 text-xs text-muted-foreground px-2"
@@ -936,6 +1068,7 @@ interface EndpointEditState {
path: string
upstreamStreamPolicy: string
rules: EditableRule[]
responseRules: EditableRule[]
bodyRules: EditableBodyRule[]
}
@@ -974,6 +1107,7 @@ const proxyNodesStore = useProxyNodesStore()
// 规则 Select 的展开状态(与 Collapsible 分开管理)
const ruleSelectOpen = ref<Record<string, boolean>>({})
const responseRuleSelectOpen = ref<Record<string, boolean>>({})
// 打开规则选择器时关闭其他所有下拉
function handleRuleSelectOpen(endpointId: string, index: number, open: boolean) {
@@ -983,10 +1117,30 @@ function handleRuleSelectOpen(endpointId: string, index: number, open: boolean)
Object.keys(ruleSelectOpen.value).forEach(key => {
ruleSelectOpen.value[key] = false
})
Object.keys(responseRuleSelectOpen.value).forEach(key => {
responseRuleSelectOpen.value[key] = false
})
}
ruleSelectOpen.value[`${endpointId}-${index}`] = open
}
// 打开响应头规则选择器时关闭其他所有下拉
function handleResponseRuleSelectOpen(endpointId: string, index: number, open: boolean) {
if (open) {
formatSelectOpen.value = false
Object.keys(ruleSelectOpen.value).forEach(key => {
ruleSelectOpen.value[key] = false
})
Object.keys(responseRuleSelectOpen.value).forEach(key => {
responseRuleSelectOpen.value[key] = false
})
Object.keys(bodyRuleSelectOpen.value).forEach(key => {
bodyRuleSelectOpen.value[key] = false
})
}
responseRuleSelectOpen.value[`${endpointId}-${index}`] = open
}
// 打开格式选择器时关闭其他所有下拉
function handleFormatSelectOpen(open: boolean) {
if (open) {
@@ -994,6 +1148,9 @@ function handleFormatSelectOpen(open: boolean) {
Object.keys(ruleSelectOpen.value).forEach(key => {
ruleSelectOpen.value[key] = false
})
Object.keys(responseRuleSelectOpen.value).forEach(key => {
responseRuleSelectOpen.value[key] = false
})
Object.keys(bodyRuleSelectOpen.value).forEach(key => {
bodyRuleSelectOpen.value[key] = false
})
@@ -1009,6 +1166,9 @@ function handleBodyRuleSelectOpen(endpointId: string, index: number, open: boole
Object.keys(ruleSelectOpen.value).forEach(key => {
ruleSelectOpen.value[key] = false
})
Object.keys(responseRuleSelectOpen.value).forEach(key => {
responseRuleSelectOpen.value[key] = false
})
Object.keys(bodyRuleSelectOpen.value).forEach(key => {
bodyRuleSelectOpen.value[key] = false
})
@@ -1024,6 +1184,14 @@ function clearHeaderRuleSelectOpen(endpointId: string) {
})
}
function clearResponseRuleSelectOpen(endpointId: string) {
Object.keys(responseRuleSelectOpen.value).forEach((key) => {
if (key.startsWith(`${endpointId}-`)) {
delete responseRuleSelectOpen.value[key]
}
})
}
function clearBodyRuleSelectOpen(endpointId: string) {
Object.keys(bodyRuleSelectOpen.value).forEach((key) => {
if (key.startsWith(`${endpointId}-`)) {
@@ -1044,15 +1212,28 @@ function isBodyRuleDragging(endpointId: string, index: number): boolean {
return bodyRuleDraggedIndex.value[endpointId] === index
}
function isResponseRuleDragging(endpointId: string, index: number): boolean {
return responseRuleDraggedIndex.value[endpointId] === index
}
function isBodyRuleDragOver(endpointId: string, index: number): boolean {
return bodyRuleDragOverIndex.value[endpointId] === index
}
function isResponseRuleDragOver(endpointId: string, index: number): boolean {
return responseRuleDragOverIndex.value[endpointId] === index
}
function clearHeaderRuleDragState(endpointId: string) {
headerRuleDraggedIndex.value[endpointId] = null
headerRuleDragOverIndex.value[endpointId] = null
}
function clearResponseRuleDragState(endpointId: string) {
responseRuleDraggedIndex.value[endpointId] = null
responseRuleDragOverIndex.value[endpointId] = null
}
function clearBodyRuleDragState(endpointId: string) {
bodyRuleDraggedIndex.value[endpointId] = null
bodyRuleDragOverIndex.value[endpointId] = null
@@ -1099,6 +1280,47 @@ function handleHeaderRuleDragEnd(endpointId: string) {
clearHeaderRuleDragState(endpointId)
}
function handleResponseRuleDragStart(endpointId: string, index: number, event: DragEvent) {
const rules = getEndpointEditResponseRules(endpointId)
if (!rules[index]) return
responseRuleDraggedIndex.value[endpointId] = index
responseRuleDragOverIndex.value[endpointId] = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', `response:${endpointId}:${index}`)
}
}
function handleResponseRuleDragOver(endpointId: string, index: number) {
const dragged = responseRuleDraggedIndex.value[endpointId]
if (dragged === null || dragged === undefined || dragged === index) return
responseRuleDragOverIndex.value[endpointId] = index
}
function handleResponseRuleDragLeave(endpointId: string, index: number) {
if (responseRuleDragOverIndex.value[endpointId] === index) {
responseRuleDragOverIndex.value[endpointId] = null
}
}
function handleResponseRuleDrop(endpointId: string, targetIndex: number) {
const dragIndex = responseRuleDraggedIndex.value[endpointId]
clearResponseRuleDragState(endpointId)
if (dragIndex === null || dragIndex === undefined || dragIndex === targetIndex) return
const rules = getEndpointEditResponseRules(endpointId)
if (dragIndex < 0 || dragIndex >= rules.length || targetIndex < 0 || targetIndex >= rules.length) return
const [draggedRule] = rules.splice(dragIndex, 1)
rules.splice(targetIndex, 0, draggedRule)
clearResponseRuleSelectOpen(endpointId)
}
function handleResponseRuleDragEnd(endpointId: string) {
clearResponseRuleDragState(endpointId)
}
function handleBodyRuleDragStart(endpointId: string, index: number, event: DragEvent) {
const rules = getEndpointEditBodyRules(endpointId)
if (!rules[index]) return
@@ -1166,6 +1388,8 @@ const bodyRuleHelpOpenEndpointId = ref<string | null>(null)
// 规则拖拽状态(按 endpoint 维度)
const headerRuleDraggedIndex = ref<Record<string, number | null>>({})
const headerRuleDragOverIndex = ref<Record<string, number | null>>({})
const responseRuleDraggedIndex = ref<Record<string, number | null>>({})
const responseRuleDragOverIndex = ref<Record<string, number | null>>({})
const bodyRuleDraggedIndex = ref<Record<string, number | null>>({})
const bodyRuleDragOverIndex = ref<Record<string, number | null>>({})
@@ -1189,6 +1413,13 @@ const RESERVED_HEADERS = new Set([
'host',
])
const RESERVED_RESPONSE_HEADERS = new Set([
'content-length',
])
const RESPONSE_HEADER_RULES_CONFIG_KEY = 'response_header_rules'
const RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY = 'responseHeaderRules'
// 系统保留的 body 字段名(不允许用户设置)
const RESERVED_BODY_FIELDS = new Set([
'model',
@@ -1496,6 +1727,21 @@ function emptyHeaderRule(): EditableRule {
return { action: 'set', key: '', value: '', from: '', to: '', condition: null }
}
function editableHeaderRulesFromRules(rules: HeaderRule[] | null | undefined): EditableRule[] {
if (!Array.isArray(rules)) return []
const editableRules: EditableRule[] = []
for (const rule of rules) {
if (rule.action === 'set') {
editableRules.push({ ...emptyHeaderRule(), action: 'set', key: rule.key, value: rule.value || '', condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'drop') {
editableRules.push({ ...emptyHeaderRule(), action: 'drop', key: rule.key, condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'rename') {
editableRules.push({ ...emptyHeaderRule(), action: 'rename', from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
}
}
return editableRules
}
function emptyBodyRule(action: BodyRuleAction = 'set'): EditableBodyRule {
return {
action,
@@ -1515,18 +1761,8 @@ function emptyBodyRule(action: BodyRuleAction = 'set'): EditableBodyRule {
// 初始化端点的编辑状态
function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
const rules: EditableRule[] = []
if (endpoint.header_rules && endpoint.header_rules.length > 0) {
for (const rule of endpoint.header_rules) {
if (rule.action === 'set') {
rules.push({ ...emptyHeaderRule(), action: 'set', key: rule.key, value: rule.value || '', condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'drop') {
rules.push({ ...emptyHeaderRule(), action: 'drop', key: rule.key, condition: conditionToEditable(rule.condition) })
} else if (rule.action === 'rename') {
rules.push({ ...emptyHeaderRule(), action: 'rename', from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
}
}
}
const rules = editableHeaderRulesFromRules(endpoint.header_rules)
const responseRules = editableHeaderRulesFromRules(getEndpointResponseHeaderRules(endpoint))
const bodyRules: EditableBodyRule[] = []
if (endpoint.body_rules && endpoint.body_rules.length > 0) {
@@ -1565,6 +1801,7 @@ function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
path: endpoint.custom_path || '',
upstreamStreamPolicy: getEndpointUpstreamStreamPolicy(endpoint),
rules,
responseRules,
bodyRules,
}
}
@@ -1603,6 +1840,27 @@ function getEndpointEditRules(endpointId: string): EditableRule[] {
return []
}
function getEndpointResponseHeaderRules(endpoint: ProviderEndpoint): HeaderRule[] {
const raw = (endpoint as ProviderEndpoint & { response_header_rules?: unknown }).response_header_rules
?? endpoint.config?.[RESPONSE_HEADER_RULES_CONFIG_KEY]
?? endpoint.config?.[RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY]
return Array.isArray(raw) ? (raw as HeaderRule[]) : []
}
function getEndpointEditResponseRules(endpointId: string): EditableRule[] {
const state = endpointEditStates.value[endpointId]
if (state) {
return state.responseRules
}
const endpoint = localEndpoints.value.find(e => e.id === endpointId)
if (endpoint) {
const newState = initEndpointEditState(endpoint)
endpointEditStates.value[endpointId] = newState
return newState.responseRules
}
return []
}
// 添加规则(同时自动展开折叠)
function handleAddEndpointRule(endpointId: string) {
const rules = getEndpointEditRules(endpointId)
@@ -1611,6 +1869,12 @@ function handleAddEndpointRule(endpointId: string) {
endpointRulesExpanded.value[endpointId] = true
}
function handleAddEndpointResponseRule(endpointId: string) {
const rules = getEndpointEditResponseRules(endpointId)
rules.push(emptyHeaderRule())
endpointRulesExpanded.value[endpointId] = true
}
// 删除规则
function removeEndpointRule(endpointId: string, index: number) {
const rules = getEndpointEditRules(endpointId)
@@ -1619,6 +1883,13 @@ function removeEndpointRule(endpointId: string, index: number) {
clearHeaderRuleSelectOpen(endpointId)
}
function removeEndpointResponseRule(endpointId: string, index: number) {
const rules = getEndpointEditResponseRules(endpointId)
rules.splice(index, 1)
clearResponseRuleDragState(endpointId)
clearResponseRuleSelectOpen(endpointId)
}
// 更新规则类型
function updateEndpointRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
const rules = getEndpointEditRules(endpointId)
@@ -1628,6 +1899,14 @@ function updateEndpointRuleAction(endpointId: string, index: number, action: 'se
}
}
function updateEndpointResponseRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
const currentCondition = rules[index].condition
rules[index] = { ...emptyHeaderRule(), action, condition: currentCondition }
}
}
// 更新规则字段
function updateEndpointRuleField(endpointId: string, index: number, field: 'key' | 'value' | 'from' | 'to', value: string) {
const rules = getEndpointEditRules(endpointId)
@@ -1636,6 +1915,13 @@ function updateEndpointRuleField(endpointId: string, index: number, field: 'key'
}
}
function updateEndpointResponseRuleField(endpointId: string, index: number, field: 'key' | 'value' | 'from' | 'to', value: string) {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
rules[index][field] = value
}
}
function toggleEndpointRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
@@ -1643,6 +1929,13 @@ function toggleEndpointRuleCondition(endpointId: string, index: number) {
}
}
function toggleEndpointResponseRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
rules[index].condition = rules[index].condition ? null : createEmptyConditionLeaf()
}
}
function updateEndpointRuleCondition(endpointId: string, index: number, condition: EditableConditionNode) {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
@@ -1650,6 +1943,13 @@ function updateEndpointRuleCondition(endpointId: string, index: number, conditio
}
}
function updateEndpointResponseRuleCondition(endpointId: string, index: number, condition: EditableConditionNode) {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
rules[index].condition = condition
}
}
function clearEndpointRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditRules(endpointId)
if (rules[index]) {
@@ -1657,6 +1957,13 @@ function clearEndpointRuleCondition(endpointId: string, index: number) {
}
}
function clearEndpointResponseRuleCondition(endpointId: string, index: number) {
const rules = getEndpointEditResponseRules(endpointId)
if (rules[index]) {
rules[index].condition = null
}
}
// 验证规则 key针对特定端点
function validateRuleKeyForEndpoint(endpointId: string, key: string, index: number): string | null {
const trimmedKey = key.trim().toLowerCase()
@@ -1734,6 +2041,18 @@ function getEndpointRulesCount(endpoint: ProviderEndpoint): number {
return endpoint.header_rules?.length || 0
}
function getEndpointResponseRulesCount(endpoint: ProviderEndpoint): number {
const state = endpointEditStates.value[endpoint.id]
if (state) {
return state.responseRules.filter(r => {
if (r.action === 'set' || r.action === 'drop') return r.key.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim()
return false
}).length
}
return getEndpointResponseHeaderRules(endpoint).length
}
// 检查端点是否有任何规则(包括正在编辑的空规则)
function _hasAnyRules(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
@@ -2040,9 +2359,9 @@ function _hasAnyBodyRules(endpoint: ProviderEndpoint): boolean {
return (endpoint.body_rules?.length || 0) > 0
}
// 获取端点的总规则数量(请求头 + 请求体)
// 获取端点的总规则数量(请求头 + 请求体 + 响应头
function getTotalRulesCount(endpoint: ProviderEndpoint): number {
return getEndpointRulesCount(endpoint) + getEndpointBodyRulesCount(endpoint)
return getEndpointRulesCount(endpoint) + getEndpointBodyRulesCount(endpoint) + getEndpointResponseRulesCount(endpoint)
}
// 格式化请求头规则的显示标签
@@ -2256,12 +2575,8 @@ function hasUrlChanges(endpoint: ProviderEndpoint): boolean {
}
// 检查端点规则是否有修改
function hasRulesChanges(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (!state) return false
const originalRules = endpoint.header_rules || []
const editedRules = state.rules.filter(r => {
function editableHeaderRulesChanged(edited: EditableRule[], originalRules: HeaderRule[]): boolean {
const editedRules = edited.filter(r => {
if (r.action === 'set' || r.action === 'drop') return r.key.trim()
if (r.action === 'rename') return r.from.trim() && r.to.trim()
return false
@@ -2284,10 +2599,22 @@ function hasRulesChanges(endpoint: ProviderEndpoint): boolean {
return false
}
function hasRulesChanges(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (!state) return false
return editableHeaderRulesChanged(state.rules, endpoint.header_rules || [])
}
function hasResponseHeaderRulesChanges(endpoint: ProviderEndpoint): boolean {
const state = endpointEditStates.value[endpoint.id]
if (!state) return false
return editableHeaderRulesChanged(state.responseRules, getEndpointResponseHeaderRules(endpoint))
}
// 检查端点是否有修改URL、路径或规则
// 注:当前模板直接使用各子函数,此聚合函数保留供未来使用
function _hasEndpointChanges(endpoint: ProviderEndpoint): boolean {
return hasUrlChanges(endpoint) || hasRulesChanges(endpoint) || hasBodyRulesChanges(endpoint)
return hasUrlChanges(endpoint) || hasRulesChanges(endpoint) || hasBodyRulesChanges(endpoint) || hasResponseHeaderRulesChanges(endpoint)
}
// 重置端点修改
@@ -2315,7 +2642,7 @@ async function handleResetBodyRulesToDefault(endpoint: ProviderEndpoint) {
body_rules: defaultRules,
})
state.bodyRules = resetState.bodyRules
endpointRulesExpanded.value[endpoint.id] = (state.rules.length + state.bodyRules.length) > 0
endpointRulesExpanded.value[endpoint.id] = (state.rules.length + state.responseRules.length + state.bodyRules.length) > 0
clearBodyRuleDragState(endpoint.id)
clearBodyRuleSelectOpen(endpoint.id)
success('已重置请求体为默认规则请点击保存生效')
@@ -2344,6 +2671,16 @@ function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null {
return result.length > 0 ? result : null
}
function endpointConfigWithResponseHeaderRules(endpoint: ProviderEndpoint, rules: HeaderRule[] | null): Record<string, unknown> | null {
const merged: Record<string, unknown> = { ...(endpoint.config || {}) }
delete merged[RESPONSE_HEADER_RULES_CONFIG_KEY]
delete merged[RESPONSE_HEADER_RULES_CAMEL_CONFIG_KEY]
if (rules && rules.length > 0) {
merged[RESPONSE_HEADER_RULES_CONFIG_KEY] = rules
}
return Object.keys(merged).length > 0 ? merged : null
}
function getHeaderValidationErrorForEndpoint(endpointId: string): string | null {
const rules = getEndpointEditRules(endpointId)
for (let i = 0; i < rules.length; i++) {
@@ -2364,6 +2701,50 @@ function getHeaderValidationErrorForEndpoint(endpointId: string): string | null
return null
}
function validateResponseHeaderNameForEndpoint(endpointId: string, name: string, index: number, field: 'key' | 'from' | 'to'): string | null {
const trimmedName = name.trim().toLowerCase()
if (!trimmedName) return null
if ((field === 'key' || field === 'to') && RESERVED_RESPONSE_HEADERS.has(trimmedName)) {
return `"${name}" 是系统保留的响应头`
}
const rules = getEndpointEditResponseRules(endpointId)
const duplicate = rules.findIndex(
(r, i) => i !== index && (
((r.action === 'set' || r.action === 'drop') && r.key.trim().toLowerCase() === trimmedName) ||
(r.action === 'rename' && (field === 'from'
? r.from.trim().toLowerCase() === trimmedName
: r.to.trim().toLowerCase() === trimmedName))
)
)
if (duplicate >= 0) {
return field === 'from' ? '该响应头已被其他规则处理' : '响应头名称重复'
}
return null
}
function getResponseHeaderValidationErrorForEndpoint(endpointId: string): string | null {
const rules = getEndpointEditResponseRules(endpointId)
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]
const prefix = `第 ${i + 1} 条响应头规则:`
if (rule.action === 'set' || rule.action === 'drop') {
const err = validateResponseHeaderNameForEndpoint(endpointId, rule.key, i, 'key')
if (err) return `${prefix}${err}`
} else if (rule.action === 'rename') {
const fromErr = validateResponseHeaderNameForEndpoint(endpointId, rule.from, i, 'from')
if (fromErr) return `${prefix}${fromErr}`
const toErr = validateResponseHeaderNameForEndpoint(endpointId, rule.to, i, 'to')
if (toErr) return `${prefix}${toErr}`
}
const conditionErr = validateEditableCondition(rule.condition)
if (conditionErr) return `${prefix}${conditionErr}`
}
return null
}
// 新端点选择的格式的默认路径
const newEndpointDefaultPath = computed(() => {
// 使用填写的 base_url 或 provider 的 website 来判断是否是 Codex 端点
@@ -2389,9 +2770,12 @@ onMounted(() => {
watch(() => props.modelValue, (open) => {
bodyRuleHelpOpenEndpointId.value = null
ruleSelectOpen.value = {}
responseRuleSelectOpen.value = {}
bodyRuleSelectOpen.value = {}
headerRuleDraggedIndex.value = {}
headerRuleDragOverIndex.value = {}
responseRuleDraggedIndex.value = {}
responseRuleDragOverIndex.value = {}
bodyRuleDraggedIndex.value = {}
bodyRuleDragOverIndex.value = {}
if (open) {
@@ -2403,7 +2787,7 @@ watch(() => props.modelValue, (open) => {
for (const endpoint of localEndpoints.value) {
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
// 有规则时默认展开
const hasRules = (endpoint.header_rules?.length || 0) + (endpoint.body_rules?.length || 0) > 0
const hasRules = (endpoint.header_rules?.length || 0) + getEndpointResponseHeaderRules(endpoint).length + (endpoint.body_rules?.length || 0) > 0
endpointRulesExpanded.value[endpoint.id] = hasRules
}
void preloadDefaultBodyRules(localEndpoints.value)
@@ -2443,6 +2827,12 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
return
}
const responseHeaderErr = getResponseHeaderValidationErrorForEndpoint(endpoint.id)
if (responseHeaderErr) {
showError(responseHeaderErr)
return
}
// 检查请求体规则是否有验证错误
const bodyErr = getBodyValidationErrorForEndpoint(endpoint.id)
if (bodyErr) {
@@ -2461,6 +2851,12 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
}
if (hasRulesChanges(endpoint)) payload.header_rules = rulesToHeaderRules(state.rules)
if (hasResponseHeaderRulesChanges(endpoint)) {
payload.config = endpointConfigWithResponseHeaderRules(
endpoint,
rulesToHeaderRules(state.responseRules),
)
}
if (hasBodyRulesChanges(endpoint)) payload.body_rules = rulesToBodyRules(state.bodyRules)
// 注upstreamStreamPolicy 现在由头部按钮直接保存,不在此处处理