refactor(rules): 规则引擎容错优化,无效规则条目跳过而非中止整个规则集

- header/body rules 的 _are_locally_supported 简化为仅检查是否为数组
- apply 逻辑中遇到格式错误/不支持的规则条目改为 continue 跳过,而非 return false
- 允许非字符串 header value,自动序列化为 JSON 字符串
- 宽松处理无效 regex flag,不再拒绝整条规则

fix(gateway): Claude CLI 路由仅检查 bearer 头,不排斥同时携带 x-api-key 的请求

feat(observability): 监控链路候选展示解密 auth_config 的账号标签和 OAuth 计划类型
This commit is contained in:
fawney19
2026-04-25 19:46:52 +08:00
parent 00744c0ce5
commit 912a92cd1a
7 changed files with 438 additions and 184 deletions

View File

@@ -12,6 +12,12 @@ use axum::{
use serde_json::{json, Value};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AdminMonitoringKeyAccountDisplay {
pub label: Option<String>,
pub oauth_plan_type: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AdminMonitoringRoute {
AuditLogs,
@@ -263,6 +269,18 @@ pub fn build_admin_monitoring_trace_provider_stats_payload_response(
pub fn build_admin_monitoring_trace_request_payload_response(
trace: &DecisionTrace,
usage: Option<&StoredRequestUsageAudit>,
) -> Response<Body> {
build_admin_monitoring_trace_request_payload_response_with_key_accounts(
trace,
usage,
&BTreeMap::new(),
)
}
pub fn build_admin_monitoring_trace_request_payload_response_with_key_accounts(
trace: &DecisionTrace,
usage: Option<&StoredRequestUsageAudit>,
key_accounts: &BTreeMap<String, AdminMonitoringKeyAccountDisplay>,
) -> Response<Body> {
let usage_candidate_id =
usage.and_then(|item| resolve_admin_monitoring_usage_candidate_id(trace, item));
@@ -274,7 +292,11 @@ pub fn build_admin_monitoring_trace_request_payload_response(
.as_deref()
.filter(|candidate_id| *candidate_id == item.candidate.id.as_str())
.and(usage);
build_admin_monitoring_trace_request_candidate_payload(item, matched_usage)
build_admin_monitoring_trace_request_candidate_payload_with_key_accounts(
item,
matched_usage,
key_accounts,
)
})
.collect::<Vec<_>>();
Json(json!({
@@ -290,8 +312,24 @@ pub fn build_admin_monitoring_trace_request_payload_response(
pub fn build_admin_monitoring_trace_request_candidate_payload(
item: &DecisionTraceCandidate,
usage: Option<&StoredRequestUsageAudit>,
) -> Value {
build_admin_monitoring_trace_request_candidate_payload_with_key_accounts(
item,
usage,
&BTreeMap::new(),
)
}
pub fn build_admin_monitoring_trace_request_candidate_payload_with_key_accounts(
item: &DecisionTraceCandidate,
usage: Option<&StoredRequestUsageAudit>,
key_accounts: &BTreeMap<String, AdminMonitoringKeyAccountDisplay>,
) -> Value {
let candidate = &item.candidate;
let key_account = candidate
.key_id
.as_deref()
.and_then(|key_id| key_accounts.get(key_id));
json!({
"id": candidate.id,
"request_id": candidate.request_id,
@@ -310,13 +348,13 @@ pub fn build_admin_monitoring_trace_request_candidate_payload(
"endpoint_format_acceptance_config": item.endpoint_format_acceptance_config,
"key_id": candidate.key_id,
"key_name": item.provider_key_name,
"key_account_label": serde_json::Value::Null,
"key_account_label": key_account.and_then(|item| item.label.clone()),
"key_preview": serde_json::Value::Null,
"key_auth_type": item.provider_key_auth_type,
"key_api_formats": item.provider_key_api_formats,
"key_internal_priority": item.provider_key_internal_priority,
"key_global_priority_by_format": item.provider_key_global_priority_by_format,
"key_oauth_plan_type": serde_json::Value::Null,
"key_oauth_plan_type": key_account.and_then(|item| item.oauth_plan_type.clone()),
"key_capabilities": item.provider_key_capabilities,
"required_capabilities": candidate.required_capabilities,
"status": candidate.status,

View File

@@ -236,6 +236,21 @@ mod tests {
}
}
fn codex_default_body_rules() -> Value {
json!([
{"action":"drop","path":"max_output_tokens"},
{"action":"drop","path":"temperature"},
{"action":"drop","path":"top_p"},
{"action":"set","path":"store","value":false},
{
"action":"set",
"path":"instructions",
"value":"You are GPT-5.",
"condition":{"path":"instructions","op":"not_exists"}
}
])
}
#[test]
fn builds_request_body_for_all_standard_surface_pairs_in_sync_and_stream_modes() {
for client_api_format in STANDARD_SURFACES {
@@ -269,6 +284,45 @@ mod tests {
}
}
#[test]
fn applies_codex_body_rules_for_all_standard_sources_to_openai_cli() {
let body_rules = codex_default_body_rules();
for client_api_format in STANDARD_SURFACES {
let (mut request, request_path) = sample_request_for(client_api_format);
if let Some(object) = request.as_object_mut() {
object.insert("temperature".to_string(), json!(0.7));
object.insert("top_p".to_string(), json!(0.8));
}
let converted = build_standard_request_body(
&request,
client_api_format,
"gpt-5.5",
"codex",
"openai:cli",
request_path,
true,
Some(&body_rules),
Some("key-1"),
)
.unwrap_or_else(|| {
panic!("{client_api_format} -> openai:cli should build with codex body rules")
});
assert_eq!(converted["model"], "gpt-5.5");
assert_eq!(converted["stream"], true);
assert_eq!(converted["store"], false);
assert!(converted.get("max_output_tokens").is_none());
assert!(converted.get("temperature").is_none());
assert!(converted.get("top_p").is_none());
assert!(
converted.get("instructions").is_some(),
"{client_api_format} -> openai:cli should keep or inject instructions"
);
}
}
#[test]
fn builds_openai_chat_request_from_claude_chat_source() {
let request = json!({

View File

@@ -32,50 +32,7 @@ pub fn header_rules_are_locally_supported(rules: Option<&Value>) -> bool {
let Some(rules) = rules else {
return true;
};
let Some(rules) = rules.as_array() else {
return false;
};
rules.iter().all(|rule| {
let Some(rule) = rule.as_object() else {
return false;
};
if rule
.get("condition")
.is_some_and(|value| !value.is_null() && !condition_is_locally_supported(value))
{
return false;
}
match rule
.get("action")
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("set") => {
rule.get("key")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& rule.get("value").is_some_and(Value::is_string)
}
Some("drop") => rule
.get("key")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty()),
Some("rename") => {
rule.get("from")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& rule
.get("to")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
}
_ => false,
}
})
rules.is_array()
}
pub fn apply_local_header_rules(
@@ -98,11 +55,11 @@ pub fn apply_local_header_rules(
for rule in rules {
let Some(rule) = rule.as_object() else {
return false;
continue;
};
if let Some(condition) = rule.get("condition").filter(|value| !value.is_null()) {
if !condition_is_locally_supported(condition) {
return false;
continue;
}
if !evaluate_local_condition(body, condition, original_body) {
continue;
@@ -118,135 +75,66 @@ pub fn apply_local_header_rules(
{
Some("set") => {
let Some(key) = rule.get("key").and_then(Value::as_str).map(str::trim) else {
return false;
};
let Some(value) = rule.get("value").and_then(Value::as_str) else {
return false;
continue;
};
let key = key.to_ascii_lowercase();
if !protected_keys.contains(&key) {
headers.insert(key, value.to_string());
if key.is_empty() || protected_keys.contains(&key) {
continue;
}
let value = rule
.get("value")
.map(header_rule_value_to_string)
.unwrap_or_default();
headers.insert(key, value);
}
Some("drop") => {
let Some(key) = rule.get("key").and_then(Value::as_str).map(str::trim) else {
return false;
continue;
};
let key = key.to_ascii_lowercase();
if !protected_keys.contains(&key) {
if !key.is_empty() && !protected_keys.contains(&key) {
headers.remove(&key);
}
}
Some("rename") => {
let Some(from) = rule.get("from").and_then(Value::as_str).map(str::trim) else {
return false;
continue;
};
let Some(to) = rule.get("to").and_then(Value::as_str).map(str::trim) else {
return false;
continue;
};
let from = from.to_ascii_lowercase();
let to = to.to_ascii_lowercase();
if protected_keys.contains(&from) || protected_keys.contains(&to) {
if from.is_empty()
|| to.is_empty()
|| protected_keys.contains(&from)
|| protected_keys.contains(&to)
{
continue;
}
if let Some(value) = headers.remove(&from) {
headers.insert(to, value);
}
}
_ => return false,
_ => continue,
}
}
true
}
fn header_rule_value_to_string(value: &Value) -> String {
value
.as_str()
.map(str::to_string)
.unwrap_or_else(|| value.to_string())
}
pub fn body_rules_are_locally_supported(rules: Option<&Value>) -> bool {
let Some(rules) = rules else {
return true;
};
let Some(rules) = rules.as_array() else {
return false;
};
rules.iter().all(|rule| {
let Some(rule) = rule.as_object() else {
return false;
};
if rule
.get("condition")
.is_some_and(|value| !value.is_null() && !condition_is_locally_supported(value))
{
return false;
}
match rule
.get("action")
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("set") | Some("drop") | Some("append") => rule
.get("path")
.and_then(Value::as_str)
.and_then(parse_body_path)
.is_some(),
Some("rename") => {
rule.get("from")
.and_then(Value::as_str)
.and_then(parse_body_path)
.is_some()
&& rule
.get("to")
.and_then(Value::as_str)
.and_then(parse_body_path)
.is_some()
}
Some("insert") => {
rule.get("path")
.and_then(Value::as_str)
.and_then(parse_body_path)
.is_some()
&& rule.get("index").and_then(parse_insert_index).is_some()
}
Some("regex_replace") => {
let Some(path) = rule
.get("path")
.and_then(Value::as_str)
.and_then(parse_body_path)
else {
return false;
};
let Some(pattern) = rule.get("pattern").and_then(Value::as_str) else {
return false;
};
let Some(_replacement) = rule.get("replacement").and_then(Value::as_str) else {
return false;
};
let Some(flags) = rule.get("flags").map_or(Some(""), |value| value.as_str()) else {
return false;
};
let Some(_count) = rule
.get("count")
.map_or(Some(0usize), parse_non_negative_count)
else {
return false;
};
!path.is_empty() && !pattern.is_empty() && compile_regex(pattern, flags).is_some()
}
Some("name_style") => {
rule.get("path")
.and_then(Value::as_str)
.and_then(parse_body_path)
.is_some()
&& rule
.get("style")
.and_then(Value::as_str)
.is_some_and(valid_name_style)
}
_ => false,
}
})
rules.is_array()
}
pub fn body_rules_handle_path(rules: Option<&Value>, path: &str) -> bool {
@@ -308,14 +196,14 @@ pub fn apply_local_body_rules(
for rule in rules {
let Some(rule) = rule.as_object() else {
return false;
continue;
};
let condition = rule.get("condition").filter(|value| !value.is_null());
let item_condition = condition.is_some_and(condition_has_item_ref);
if let Some(condition) = condition {
if !condition_is_locally_supported(condition) {
return false;
continue;
}
if !item_condition && !evaluate_local_condition(body, condition, original_body) {
continue;
@@ -335,7 +223,7 @@ pub fn apply_local_body_rules(
.and_then(Value::as_str)
.and_then(parse_body_path)
else {
return false;
continue;
};
let targets = iter_wildcard_targets(
body,
@@ -363,7 +251,7 @@ pub fn apply_local_body_rules(
.and_then(Value::as_str)
.and_then(parse_body_path)
else {
return false;
continue;
};
for target_path in iter_wildcard_targets(
body,
@@ -383,14 +271,14 @@ pub fn apply_local_body_rules(
.and_then(Value::as_str)
.and_then(parse_body_path)
else {
return false;
continue;
};
let Some(to) = rule
.get("to")
.and_then(Value::as_str)
.and_then(parse_body_path)
else {
return false;
continue;
};
if has_wildcard(&from) || has_wildcard(&to) {
continue;
@@ -403,7 +291,7 @@ pub fn apply_local_body_rules(
.and_then(Value::as_str)
.and_then(parse_body_path)
else {
return false;
continue;
};
let value = rule.get("value").cloned().unwrap_or(Value::Null);
for target_path in iter_wildcard_targets(
@@ -428,10 +316,10 @@ pub fn apply_local_body_rules(
.and_then(Value::as_str)
.and_then(parse_body_path)
else {
return false;
continue;
};
let Some(index) = rule.get("index").and_then(parse_insert_index) else {
return false;
continue;
};
if has_wildcard(&path) {
continue;
@@ -450,28 +338,24 @@ pub fn apply_local_body_rules(
.and_then(Value::as_str)
.and_then(parse_body_path)
else {
return false;
continue;
};
let Some(pattern) = rule.get("pattern").and_then(Value::as_str) else {
return false;
continue;
};
let Some(replacement) = rule.get("replacement").and_then(Value::as_str) else {
return false;
continue;
};
let Some(flags) = rule.get("flags").map_or(Some(""), |value| value.as_str()) else {
return false;
};
let Some(count) = rule
let flags = rule.get("flags").and_then(Value::as_str).unwrap_or("");
let count = rule
.get("count")
.map_or(Some(0usize), parse_non_negative_count)
else {
return false;
};
.and_then(parse_non_negative_count)
.unwrap_or(0);
if pattern.is_empty() {
return false;
continue;
}
let Some(pattern) = compile_regex(pattern, flags) else {
return false;
continue;
};
for target_path in iter_wildcard_targets(
body,
@@ -501,13 +385,13 @@ pub fn apply_local_body_rules(
.and_then(Value::as_str)
.and_then(parse_body_path)
else {
return false;
continue;
};
let Some(style) = rule.get("style").and_then(Value::as_str) else {
return false;
continue;
};
if !valid_name_style(style) {
return false;
continue;
}
for target_path in iter_wildcard_targets(
body,
@@ -526,7 +410,7 @@ pub fn apply_local_body_rules(
}
}
}
_ => return false,
_ => continue,
}
}
@@ -1156,7 +1040,7 @@ fn compile_regex(pattern: &str, flags: &str) -> Option<Regex> {
's' => {
builder.dot_matches_new_line(true);
}
_ => return None,
_ => {}
}
}
builder.build().ok()
@@ -1629,16 +1513,87 @@ mod tests {
}
#[test]
fn body_rules_reject_invalid_regex_flags_and_negative_count() {
fn body_rules_tolerate_invalid_regex_flags_and_negative_count() {
let invalid_flags = serde_json::json!([
{"action":"regex_replace","path":"text","pattern":"foo","replacement":"bar","flags":"ix"}
]);
let invalid_count = serde_json::json!([
{"action":"regex_replace","path":"text","pattern":"foo","replacement":"bar","count":-1}
]);
let mut flags_body = serde_json::json!({"text":"foo"});
let mut count_body = serde_json::json!({"text":"foo foo"});
assert!(!body_rules_are_locally_supported(Some(&invalid_flags)));
assert!(!body_rules_are_locally_supported(Some(&invalid_count)));
assert!(body_rules_are_locally_supported(Some(&invalid_flags)));
assert!(body_rules_are_locally_supported(Some(&invalid_count)));
assert!(apply_local_body_rules(
&mut flags_body,
Some(&invalid_flags),
None
));
assert!(apply_local_body_rules(
&mut count_body,
Some(&invalid_count),
None
));
assert_eq!(flags_body["text"], "bar");
assert_eq!(count_body["text"], "bar bar");
}
#[test]
fn body_rules_skip_invalid_entries_without_rejecting_whole_body() {
let rules = serde_json::json!([
{"action":"set","path":".bad","value":1},
{"action":"drop","path":"missing."},
{"action":"regex_replace","path":"text","pattern":"(","replacement":"x"},
{"op":"remove","path":"/legacy"},
{"action":"set","path":"ok","value":true}
]);
let mut body = serde_json::json!({
"text": "keep",
"legacy": true
});
assert!(apply_local_body_rules(&mut body, Some(&rules), None));
assert_eq!(body["text"], "keep");
assert_eq!(body["legacy"], true);
assert_eq!(body["ok"], true);
}
#[test]
fn header_rules_skip_invalid_entries_without_rejecting_whole_headers() {
let rules = serde_json::json!([
{"action":"set","key":"","value":"bad"},
{"action":"set","key":"x-json","value":{"nested":true}},
{"action":"drop","key":null},
{"action":"rename","from":"x-missing","to":""},
{"op":"remove","key":"x-legacy"},
{"action":"set","key":"x-ok","value":"yes"}
]);
let mut headers = std::collections::BTreeMap::from([(
"authorization".to_string(),
"Bearer keep".to_string(),
)]);
assert!(header_rules_are_locally_supported(Some(&rules)));
assert!(apply_local_header_rules(
&mut headers,
Some(&rules),
&["authorization"],
&serde_json::json!({}),
None,
));
assert_eq!(
headers.get("authorization").map(String::as_str),
Some("Bearer keep")
);
assert_eq!(
headers.get("x-json").map(String::as_str),
Some("{\"nested\":true}")
);
assert_eq!(headers.get("x-ok").map(String::as_str), Some("yes"));
assert!(!headers.contains_key("x-legacy"));
}
#[test]