Skip to main content

llm_verify/probes/
channel.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Channel provenance — which relays this request actually passed through.
3//!
4//! Costs nothing extra: every signal is read out of response headers, message
5//! IDs and bodies that earlier probes already collected. Relay software is
6//! chatty about itself in headers, so a three-tier classifier gets a long way.
7//!
8//! Tier 1 — a vendor-exclusive header prefix or ID format. Decisive.
9//! Tier 2 — shared infrastructure, scored across several weaker signals.
10//! Tier 3 — a native-looking ID with nothing else. Inferred transparent relay.
11//!
12//! Channels are identified by a **stable key**, never by their display name.
13//! The verdict layer routes on those keys, so a translated label can never
14//! change how an endpoint gets classified.
15
16use super::Ctx;
17use crate::i18n::Lang;
18use crate::report::{Group, ProbeResult};
19use std::collections::BTreeMap;
20
21const G: Group = Group::Channel;
22
23// ── stable channel keys ────────────────────────────────────────────────────
24
25pub 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
34/// Human-readable name for a channel key.
35///
36/// Most relay vendors are proper nouns and read identically in both languages;
37/// only the descriptive keys need translating.
38pub 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
52/// `(key, id-prefix, header-prefix, exact-header)`. A match on any populated
53/// field is decisive for that vendor.
54const 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
79/// Union of every header seen this run. A relay that only stamps some
80/// responses still gets caught.
81fn 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    /// Stable key, never localised.
94    pub key: String,
95    pub confidence: f64,
96    pub tier: u8,
97    /// Evidence sentences, already in the caller's language.
98    pub evidence: Vec<String>,
99    pub hops: Vec<String>,
100}
101
102/// Pure classifier, separated from the probe wrapper so it is directly testable.
103pub 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    // ── Tier 1 ─────────────────────────────────────────────────────────────
113    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    // ── Tier 2 ─────────────────────────────────────────────────────────────
146    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        // A server banner is self-reported and trivially spoofed; weight it low.
214        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        // Deterministic tie-break by key so the same inputs always classify
269        // the same way.
270        .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    // ── Tier 3 ─────────────────────────────────────────────────────────────
284    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    // A generic forwarding header is one more hop even without a vendor name.
421    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        // Guards against a relay minting "msg_01" + a few chars to look native.
541        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        // The verdict layer routes on this key, so a translation must not be
548        // able to alter how an endpoint is classified.
549        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        // ...while the evidence itself is localised.
556        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        // Descriptive keys do translate.
565        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}