1use super::Ctx;
17use crate::i18n::Lang;
18use crate::report::{Group, ProbeResult};
19use std::collections::BTreeMap;
20
21const G: Group = Group::Channel;
22
23pub const ANTHROPIC_OFFICIAL: &str = "anthropic-official";
26pub const OPENAI_OFFICIAL: &str = "openai-official";
27pub const AWS_BEDROCK: &str = "aws-bedrock";
28pub const GOOGLE_VERTEX: &str = "google-vertex";
29pub const AWS_APIGATEWAY: &str = "aws-apigateway";
30pub const AZURE_FOUNDRY: &str = "azure-foundry";
31pub const TRANSPARENT_RELAY: &str = "transparent-relay";
32pub const UNKNOWN_PROXY: &str = "unknown-proxy";
33
34pub fn display(key: &str, lang: Lang) -> String {
39 match key {
40 ANTHROPIC_OFFICIAL => t!(lang, "Anthropic (first-party)", "Anthropic 官方"),
41 OPENAI_OFFICIAL => t!(lang, "OpenAI (first-party)", "OpenAI 官方"),
42 AWS_BEDROCK => "AWS Bedrock".to_string(),
43 GOOGLE_VERTEX => "Google Vertex".to_string(),
44 AWS_APIGATEWAY => "AWS API Gateway".to_string(),
45 AZURE_FOUNDRY => "Azure AI Foundry".to_string(),
46 TRANSPARENT_RELAY => t!(lang, "Transparent relay", "透明中继"),
47 UNKNOWN_PROXY => t!(lang, "Unknown proxy", "未知代理"),
48 other => other.to_string(),
49 }
50}
51
52const TIER1: &[(&str, &str, &str, &str)] = &[
55 ("OpenRouter", "gen-", "", "x-generation-id"),
56 ("Cloudflare AI Gateway", "", "cf-aig-", ""),
57 (AZURE_FOUNDRY, "", "", "apim-request-id"),
58 ("LiteLLM", "", "x-litellm-", ""),
59 ("Helicone", "", "helicone-", ""),
60 ("Portkey", "", "x-portkey-", ""),
61 ("Kong Gateway", "", "x-kong-", ""),
62 ("Alibaba DashScope", "", "x-dashscope-", ""),
63 ("New-API", "", "", "x-new-api-version"),
64 ("One-API", "", "", "x-oneapi-request-id"),
65 ("Fastly", "", "", "x-served-by"),
66];
67
68pub async fn run(ctx: &Ctx) -> Vec<ProbeResult> {
69 let headers = merge_headers(&ctx.headers.lock().unwrap());
70 let ids = ctx.message_ids.lock().unwrap().clone();
71 let bodies = ctx.raw_bodies.lock().unwrap().join("\n");
72 vec![
73 classify(&headers, &ids, &bodies, ctx.lang),
74 official_headers(&headers, ctx.lang),
75 multi_hop(&headers, &ids, ctx.lang),
76 ]
77}
78
79fn merge_headers(all: &[BTreeMap<String, String>]) -> BTreeMap<String, String> {
82 let mut out = BTreeMap::new();
83 for h in all {
84 for (k, v) in h {
85 out.entry(k.clone()).or_insert_with(|| v.clone());
86 }
87 }
88 out
89}
90
91#[derive(Debug, Clone)]
92pub struct Classification {
93 pub key: String,
95 pub confidence: f64,
96 pub tier: u8,
97 pub evidence: Vec<String>,
99 pub hops: Vec<String>,
100}
101
102pub fn classify_signals(
104 headers: &BTreeMap<String, String>,
105 ids: &[String],
106 body: &str,
107 l: Lang,
108) -> Classification {
109 let mut evidence = Vec::new();
110 let mut hop_keys: Vec<&str> = Vec::new();
111
112 for (key, id_prefix, header_prefix, exact) in TIER1 {
114 let mut hit: Option<String> = None;
115 if !id_prefix.is_empty() && ids.iter().any(|i| i.starts_with(id_prefix)) {
116 hit = Some(t!(
117 l,
118 "message ID prefix {id_prefix}",
119 "消息 ID 前缀 {id_prefix}"
120 ));
121 }
122 if hit.is_none() && !header_prefix.is_empty() {
123 if let Some(k) = headers.keys().find(|k| k.starts_with(header_prefix)) {
124 hit = Some(t!(l, "response header {k}", "响应头 {k}"));
125 }
126 }
127 if hit.is_none() && !exact.is_empty() && headers.contains_key(*exact) {
128 hit = Some(t!(l, "response header {exact}", "响应头 {exact}"));
129 }
130 if let Some(why) = hit {
131 hop_keys.push(key);
132 evidence.push(format!("{} — {why}", display(key, l)));
133 }
134 }
135 if let Some(first) = hop_keys.first() {
136 return Classification {
137 key: first.to_string(),
138 confidence: 1.0,
139 tier: 1,
140 evidence,
141 hops: hop_keys.iter().map(|k| display(k, l)).collect(),
142 };
143 }
144
145 let mut scores: BTreeMap<&str, f64> = BTreeMap::new();
147 let mut bump = |k: &'static str, w: f64, why: String, ev: &mut Vec<String>| {
148 *scores.entry(k).or_insert(0.0) += w;
149 ev.push(why);
150 };
151
152 if let Some(k) = headers.keys().find(|k| k.starts_with("x-amzn-bedrock-")) {
153 bump(
154 AWS_BEDROCK,
155 1.0,
156 t!(l, "response header {k}", "响应头 {k}"),
157 &mut evidence,
158 );
159 }
160 if ids.iter().any(|i| i.starts_with("msg_bdrk_")) {
161 bump(
162 AWS_BEDROCK,
163 1.0,
164 t!(l, "message ID prefix msg_bdrk_", "消息 ID 前缀 msg_bdrk_"),
165 &mut evidence,
166 );
167 }
168 if body.contains("bedrock-2023-05-31") {
169 bump(
170 AWS_BEDROCK,
171 0.9,
172 t!(
173 l,
174 "body contains bedrock-2023-05-31",
175 "body 含 bedrock-2023-05-31"
176 ),
177 &mut evidence,
178 );
179 }
180 if ids.iter().any(|i| i.starts_with("msg_vrtx_")) {
181 bump(
182 GOOGLE_VERTEX,
183 1.0,
184 t!(l, "message ID prefix msg_vrtx_", "消息 ID 前缀 msg_vrtx_"),
185 &mut evidence,
186 );
187 }
188 if body.contains("vertex-2023-10-16") {
189 bump(
190 GOOGLE_VERTEX,
191 0.9,
192 t!(
193 l,
194 "body contains vertex-2023-10-16",
195 "body 含 vertex-2023-10-16"
196 ),
197 &mut evidence,
198 );
199 }
200 if let Some(k) = headers.keys().find(|k| k.starts_with("x-goog-")) {
201 bump(
202 GOOGLE_VERTEX,
203 1.0,
204 t!(l, "response header {k}", "响应头 {k}"),
205 &mut evidence,
206 );
207 }
208 if headers
209 .get("server")
210 .map(|s| s.to_ascii_lowercase().contains("google"))
211 .unwrap_or(false)
212 {
213 bump(
215 GOOGLE_VERTEX,
216 0.5,
217 t!(l, "Server header contains google", "Server 头含 google"),
218 &mut evidence,
219 );
220 }
221 if headers.contains_key("x-amz-apigw-id") || headers.contains_key("apigw-requestid") {
222 bump(
223 AWS_APIGATEWAY,
224 0.8,
225 t!(l, "response header x-amz-apigw-id", "响应头 x-amz-apigw-id"),
226 &mut evidence,
227 );
228 }
229 if headers.keys().any(|k| {
230 k.starts_with("anthropic-ratelimit-")
231 || k.starts_with("anthropic-priority-")
232 || k.starts_with("anthropic-fast-")
233 }) {
234 bump(
235 ANTHROPIC_OFFICIAL,
236 0.95,
237 t!(
238 l,
239 "response headers anthropic-ratelimit-* / priority-*",
240 "响应头 anthropic-ratelimit-* / priority-*"
241 ),
242 &mut evidence,
243 );
244 }
245 if headers
246 .get("request-id")
247 .map(|r| r.starts_with("req_"))
248 .unwrap_or(false)
249 {
250 bump(
251 ANTHROPIC_OFFICIAL,
252 0.6,
253 t!(l, "request-id prefixed req_", "request-id 前缀 req_"),
254 &mut evidence,
255 );
256 }
257 if headers.contains_key("openai-organization") || headers.contains_key("openai-processing-ms") {
258 bump(
259 OPENAI_OFFICIAL,
260 0.9,
261 t!(l, "response headers openai-*", "响应头 openai-*"),
262 &mut evidence,
263 );
264 }
265
266 if let Some((&winner, &score)) = scores
267 .iter()
268 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap().then(b.0.cmp(a.0)))
271 {
272 if score > 0.0 {
273 return Classification {
274 key: winner.to_string(),
275 confidence: score.min(1.0),
276 tier: 2,
277 evidence,
278 hops: scores.keys().map(|k| display(k, l)).collect(),
279 };
280 }
281 }
282
283 let native_anthropic = ids.iter().any(|i| i.starts_with("msg_01") && i.len() >= 20);
285 if native_anthropic {
286 evidence.push(t!(
287 l,
288 "A native-format Anthropic message ID with no vendor headers at all",
289 "原生格式的 Anthropic 消息 ID,但没有任何官方响应头"
290 ));
291 return Classification {
292 key: TRANSPARENT_RELAY.to_string(),
293 confidence: 0.5,
294 tier: 3,
295 evidence,
296 hops: vec![display(TRANSPARENT_RELAY, l)],
297 };
298 }
299 Classification {
300 key: UNKNOWN_PROXY.to_string(),
301 confidence: 0.0,
302 tier: 3,
303 evidence,
304 hops: Vec::new(),
305 }
306}
307
308fn classify(
309 headers: &BTreeMap<String, String>,
310 ids: &[String],
311 body: &str,
312 l: Lang,
313) -> ProbeResult {
314 let c = classify_signals(headers, ids, body, l);
315 let name = display(&c.key, l);
316 let mut p = ProbeResult::new(
317 "channel_signature",
318 ts!(l, "Channel signature", "渠道签名识别"),
319 G,
320 )
321 .weight(2)
322 .neutral()
323 .metric("channel", c.key.clone())
324 .metric("channel_display", name.clone())
325 .metric("tier", c.tier)
326 .metric("confidence", c.confidence)
327 .metric("headers_seen", headers.len())
328 .metric("hops", c.hops.join(" -> "));
329 for e in &c.evidence {
330 p = p.finding(e.clone());
331 }
332 match c.tier {
333 1 => p.pass(t!(l, "Identified decisively: {name}", "确定性识别:{name}")),
334 2 => p.pass(t!(
335 l,
336 "{name} ({:.0}% confidence)",
337 "{name}(置信度 {:.0}%)",
338 c.confidence * 100.0
339 )),
340 _ if c.confidence > 0.0 => p.warn(t!(l, "Inferred as {name}", "推断为{name}")),
341 _ => p.warn(t!(
342 l,
343 "No channel markers at all; the origin cannot be determined",
344 "没有任何渠道特征,来源无法确定"
345 )),
346 }
347}
348
349fn official_headers(headers: &BTreeMap<String, String>, l: Lang) -> ProbeResult {
350 let p = ProbeResult::new(
351 "official_headers",
352 ts!(l, "Vendor header fingerprint", "官方响应头指纹"),
353 G,
354 )
355 .weight(1);
356 let markers: Vec<&str> = [
357 "anthropic-ratelimit-requests-limit",
358 "anthropic-ratelimit-tokens-limit",
359 "openai-organization",
360 "openai-processing-ms",
361 "x-ratelimit-limit-requests",
362 "cf-ray",
363 "request-id",
364 ]
365 .iter()
366 .filter(|m| headers.contains_key(**m) || headers.keys().any(|k| k.starts_with(*m)))
367 .copied()
368 .collect();
369
370 let p = p
371 .metric("marker_count", markers.len())
372 .metric("markers", markers.join(", "));
373
374 if markers.len() >= 3 {
375 p.pass(t!(
376 l,
377 "{} vendor marker headers found",
378 "检出 {} 项官方特征头",
379 markers.len()
380 ))
381 } else if markers.is_empty() {
382 p.warn(t!(
383 l,
384 "No vendor marker headers at all",
385 "没有任何官方特征响应头"
386 ))
387 .finding(t!(
388 l,
389 "Relays commonly strip these. It does not prove anything is fake, \
390 but one corroborating signal is gone",
391 "中转层通常会剥掉这些头;这本身不证明是假的,但少了一条佐证"
392 ))
393 } else {
394 p.warn(t!(
395 l,
396 "Only {} vendor marker header(s)",
397 "只有 {} 项官方特征头",
398 markers.len()
399 ))
400 }
401}
402
403fn multi_hop(headers: &BTreeMap<String, String>, ids: &[String], l: Lang) -> ProbeResult {
404 let p = ProbeResult::new(
405 "multi_hop",
406 ts!(l, "Multi-hop forwarding", "多跳转发检测"),
407 G,
408 )
409 .weight(1)
410 .neutral();
411 let mut vendors: Vec<String> = Vec::new();
412 for (key, id_prefix, header_prefix, exact) in TIER1 {
413 let hit = (!id_prefix.is_empty() && ids.iter().any(|i| i.starts_with(id_prefix)))
414 || (!header_prefix.is_empty() && headers.keys().any(|k| k.starts_with(header_prefix)))
415 || (!exact.is_empty() && headers.contains_key(*exact));
416 if hit {
417 vendors.push(display(key, l));
418 }
419 }
420 let generic = ["via", "x-forwarded-for", "x-forwarded-host", "forwarded"]
422 .iter()
423 .filter(|k| headers.contains_key(**k))
424 .count();
425
426 let p = p
427 .metric("vendor_hops", vendors.len())
428 .metric("generic_forward_headers", generic)
429 .metric("vendors", vendors.join(" -> "));
430
431 if vendors.len() >= 2 {
432 p.fail(t!(
433 l,
434 "{} relay hops detected: {}",
435 "检出 {} 层中转:{}",
436 vendors.len(),
437 vendors.join(" -> ")
438 ))
439 .finding(t!(
440 l,
441 "The request is forwarded more than once before reaching the model; \
442 every hop can rewrite content and billing",
443 "请求在到达模型前被转发了不止一次,每一跳都有机会改写内容与计费"
444 ))
445 } else if vendors.len() == 1 && generic > 0 {
446 p.warn(t!(
447 l,
448 "{} plus {generic} generic forwarding header(s)",
449 "{} + {generic} 个通用转发头",
450 vendors[0]
451 ))
452 } else if vendors.len() == 1 {
453 p.pass(t!(l, "Single relay hop: {}", "单层中转:{}", vendors[0]))
454 } else {
455 p.pass(t!(l, "No multi-hop forwarding detected", "未检出多跳转发"))
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn hdrs(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
464 pairs
465 .iter()
466 .map(|(k, v)| (k.to_string(), v.to_string()))
467 .collect()
468 }
469
470 const L: Lang = Lang::En;
471
472 #[test]
473 fn tier1_id_prefix_is_decisive() {
474 let c = classify_signals(&hdrs(&[]), &["gen-abc123".into()], "", L);
475 assert_eq!(c.key, "OpenRouter");
476 assert_eq!(c.tier, 1);
477 assert_eq!(c.confidence, 1.0);
478 }
479
480 #[test]
481 fn tier1_header_prefix_is_decisive() {
482 let c = classify_signals(&hdrs(&[("cf-aig-cache-status", "MISS")]), &[], "", L);
483 assert_eq!(c.key, "Cloudflare AI Gateway");
484 assert_eq!(c.tier, 1);
485
486 let c = classify_signals(&hdrs(&[("x-litellm-version", "1.0")]), &[], "", L);
487 assert_eq!(c.key, "LiteLLM");
488
489 let c = classify_signals(&hdrs(&[("x-new-api-version", "0.6")]), &[], "", L);
490 assert_eq!(c.key, "New-API");
491 }
492
493 #[test]
494 fn tier2_scores_bedrock_across_signals() {
495 let c = classify_signals(
496 &hdrs(&[("x-amzn-bedrock-input-token-count", "12")]),
497 &["msg_bdrk_01xyz".into()],
498 "\"anthropic_version\":\"bedrock-2023-05-31\"",
499 L,
500 );
501 assert_eq!(c.key, AWS_BEDROCK);
502 assert_eq!(c.tier, 2);
503 assert_eq!(c.confidence, 1.0, "multiple signals clamp at 1.0");
504 assert!(c.evidence.len() >= 3);
505 }
506
507 #[test]
508 fn tier2_recognises_anthropic_official() {
509 let c = classify_signals(
510 &hdrs(&[
511 ("anthropic-ratelimit-requests-limit", "50"),
512 ("request-id", "req_011AB"),
513 ]),
514 &["msg_01ABCDEFGHIJKLMNOPQRSTU".into()],
515 "",
516 L,
517 );
518 assert_eq!(c.key, ANTHROPIC_OFFICIAL);
519 assert_eq!(c.tier, 2);
520 }
521
522 #[test]
523 fn tier3_infers_transparent_relay_from_native_id_alone() {
524 let c = classify_signals(&hdrs(&[]), &["msg_01ABCDEFGHIJKLMNOPQRSTU".into()], "", L);
525 assert_eq!(c.key, TRANSPARENT_RELAY);
526 assert_eq!(c.tier, 3);
527 assert_eq!(c.confidence, 0.5);
528 }
529
530 #[test]
531 fn no_signals_at_all_yields_unknown_not_a_guess() {
532 let c = classify_signals(&hdrs(&[("content-type", "application/json")]), &[], "", L);
533 assert_eq!(c.key, UNKNOWN_PROXY);
534 assert_eq!(c.confidence, 0.0);
535 assert!(c.hops.is_empty());
536 }
537
538 #[test]
539 fn a_short_msg_01_id_does_not_count_as_native() {
540 let c = classify_signals(&hdrs(&[]), &["msg_01short".into()], "", L);
542 assert_eq!(c.key, UNKNOWN_PROXY);
543 }
544
545 #[test]
546 fn the_key_never_changes_with_the_display_language() {
547 let h = hdrs(&[("anthropic-ratelimit-requests-limit", "50")]);
550 let en = classify_signals(&h, &[], "", Lang::En);
551 let zh = classify_signals(&h, &[], "", Lang::Zh);
552 assert_eq!(en.key, zh.key);
553 assert_eq!(en.tier, zh.tier);
554 assert_eq!(en.confidence, zh.confidence);
555 assert_ne!(en.evidence, zh.evidence);
557 }
558
559 #[test]
560 fn vendor_names_are_proper_nouns_and_do_not_translate() {
561 assert_eq!(display("LiteLLM", Lang::Zh), "LiteLLM");
562 assert_eq!(display("OpenRouter", Lang::En), "OpenRouter");
563 assert_eq!(display(AWS_BEDROCK, Lang::Zh), "AWS Bedrock");
564 assert_ne!(
566 display(TRANSPARENT_RELAY, Lang::En),
567 display(TRANSPARENT_RELAY, Lang::Zh)
568 );
569 }
570
571 #[test]
572 fn multi_hop_flags_two_vendors() {
573 let r = multi_hop(
574 &hdrs(&[("x-litellm-version", "1"), ("helicone-id", "h1")]),
575 &[],
576 L,
577 );
578 assert_eq!(r.status, crate::report::Status::Fail);
579 assert_eq!(r.metric_f64("vendor_hops"), Some(2.0));
580 }
581
582 #[test]
583 fn single_vendor_hop_is_not_a_failure() {
584 let r = multi_hop(&hdrs(&[("x-litellm-version", "1")]), &[], L);
585 assert_eq!(r.status, crate::report::Status::Pass);
586 }
587
588 #[test]
589 fn merge_headers_unions_across_responses() {
590 let merged = merge_headers(&[
591 hdrs(&[("a", "1")]),
592 hdrs(&[("b", "2")]),
593 hdrs(&[("a", "override-ignored")]),
594 ]);
595 assert_eq!(merged.len(), 2);
596 assert_eq!(merged["a"], "1", "first value wins");
597 }
598
599 #[test]
600 fn classification_is_deterministic_for_tied_tier2_scores() {
601 let h = hdrs(&[("x-amz-apigw-id", "x"), ("request-id", "req_1")]);
602 assert_eq!(
603 classify_signals(&h, &[], "", L).key,
604 classify_signals(&h, &[], "", L).key
605 );
606 }
607}