Skip to main content

lean_ctx/core/context_kernel/
provider_parity.rs

1//! Provider detection and provider-neutral usage normalization.
2
3use serde_json::Value;
4
5use super::token_envelope::{ProviderKind, TokenEnvelope};
6
7const SUPPORTED: [ProviderKind; 7] = [
8    ProviderKind::OpenAi,
9    ProviderKind::Anthropic,
10    ProviderKind::Gemini,
11    ProviderKind::OpenRouter,
12    ProviderKind::Bedrock,
13    ProviderKind::Azure,
14    ProviderKind::Local,
15];
16
17/// Detects a provider from its API base URL.
18pub fn detect_provider(base_url: &str) -> ProviderKind {
19    let url = base_url.to_ascii_lowercase();
20    if url.contains("api.openai.com") {
21        ProviderKind::OpenAi
22    } else if url.contains("api.anthropic.com") {
23        ProviderKind::Anthropic
24    } else if url.contains("generativelanguage.googleapis.com")
25        || url.contains("aiplatform.googleapis.com")
26    {
27        ProviderKind::Gemini
28    } else if url.contains("bedrock-runtime") && url.contains("amazonaws.com") {
29        ProviderKind::Bedrock
30    } else if url.contains("openai.azure.com")
31        || url.contains("services.ai.azure.com")
32        || url.contains("cognitiveservices.azure.com")
33    {
34        ProviderKind::Azure
35    } else if url.contains("openrouter.ai") {
36        ProviderKind::OpenRouter
37    } else if url.contains("localhost") || url.contains("127.0.0.1") || url.contains("0.0.0.0") {
38        ProviderKind::Local
39    } else {
40        ProviderKind::Unknown
41    }
42}
43
44fn token(usage: &Value, path: &[&str]) -> usize {
45    path.iter()
46        .try_fold(usage, |value, key| value.get(key))
47        .and_then(Value::as_u64)
48        .and_then(|value| usize::try_from(value).ok())
49        .unwrap_or(0)
50}
51
52/// Creates a canonical token envelope from provider-specific usage JSON.
53pub fn envelope_from_usage(provider: ProviderKind, model: &str, usage: &Value) -> TokenEnvelope {
54    let (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens) =
55        match provider {
56            ProviderKind::Anthropic => (
57                token(usage, &["input_tokens"]),
58                token(usage, &["output_tokens"]),
59                token(usage, &["cache_read_input_tokens"]),
60                token(usage, &["cache_creation_input_tokens"]),
61                0,
62            ),
63            ProviderKind::Gemini => (
64                token(usage, &["promptTokenCount"]),
65                token(usage, &["candidatesTokenCount"]),
66                token(usage, &["cachedContentTokenCount"]),
67                0,
68                0,
69            ),
70            ProviderKind::Bedrock => (
71                token(usage, &["inputTokens"]),
72                token(usage, &["outputTokens"]),
73                0,
74                0,
75                0,
76            ),
77            ProviderKind::OpenAi
78            | ProviderKind::Azure
79            | ProviderKind::OpenRouter
80            | ProviderKind::Local
81            | ProviderKind::Unknown => (
82                token(usage, &["prompt_tokens"]),
83                token(usage, &["completion_tokens"]),
84                token(usage, &["prompt_tokens_details", "cached_tokens"]),
85                0,
86                token(usage, &["completion_tokens_details", "reasoning_tokens"]),
87            ),
88        };
89
90    TokenEnvelope {
91        model: model.to_owned(),
92        provider,
93        input_tokens,
94        output_tokens,
95        cache_read_tokens,
96        cache_write_tokens,
97        reasoning_tokens,
98        cost_usd: None,
99        tokens_saved: 0,
100        is_retry: false,
101    }
102}
103
104/// Returns the stable human-readable name for a provider.
105pub const fn provider_display_name(kind: ProviderKind) -> &'static str {
106    match kind {
107        ProviderKind::OpenAi => "OpenAI",
108        ProviderKind::Anthropic => "Anthropic",
109        ProviderKind::Gemini => "Gemini",
110        ProviderKind::Bedrock => "Bedrock",
111        ProviderKind::Azure => "Azure",
112        ProviderKind::OpenRouter => "OpenRouter",
113        ProviderKind::Local => "Local",
114        ProviderKind::Unknown => "Unknown",
115    }
116}
117
118/// Returns every provider with a supported canonical usage mapping.
119pub const fn all_supported() -> &'static [ProviderKind] {
120    &SUPPORTED
121}
122
123#[cfg(test)]
124mod tests {
125    use serde_json::json;
126
127    use super::{detect_provider, envelope_from_usage};
128    use crate::core::context_kernel::token_envelope::ProviderKind;
129
130    macro_rules! detect_test {
131        ($name:ident, $url:expr, $kind:expr) => {
132            #[test]
133            fn $name() {
134                assert_eq!(detect_provider($url), $kind);
135            }
136        };
137    }
138
139    detect_test!(
140        detect_openai,
141        "https://api.openai.com/v1",
142        ProviderKind::OpenAi
143    );
144    detect_test!(
145        detect_anthropic,
146        "https://api.anthropic.com",
147        ProviderKind::Anthropic
148    );
149    detect_test!(
150        detect_bedrock,
151        "https://bedrock-runtime.us-east-1.amazonaws.com",
152        ProviderKind::Bedrock
153    );
154    detect_test!(
155        detect_azure_foundry,
156        "https://westus.services.ai.azure.com",
157        ProviderKind::Azure
158    );
159    detect_test!(
160        detect_azure_classic,
161        "https://tenant.openai.azure.com",
162        ProviderKind::Azure
163    );
164    detect_test!(
165        detect_gemini,
166        "https://generativelanguage.googleapis.com",
167        ProviderKind::Gemini
168    );
169    detect_test!(
170        detect_localhost,
171        "http://localhost:11434",
172        ProviderKind::Local
173    );
174    detect_test!(detect_unknown, "https://example.com", ProviderKind::Unknown);
175
176    #[test]
177    fn envelope_openai() {
178        let value = json!({"prompt_tokens": 100, "completion_tokens": 50});
179        let envelope = envelope_from_usage(ProviderKind::OpenAi, "gpt", &value);
180        assert_eq!((envelope.input_tokens, envelope.output_tokens), (100, 50));
181    }
182
183    #[test]
184    fn envelope_anthropic() {
185        let value = json!({
186            "input_tokens": 100,
187            "output_tokens": 50,
188            "cache_read_input_tokens": 20
189        });
190        let envelope = envelope_from_usage(ProviderKind::Anthropic, "claude", &value);
191        assert_eq!(envelope.input_tokens, 100);
192        assert_eq!(envelope.output_tokens, 50);
193        assert_eq!(envelope.cache_read_tokens, 20);
194    }
195
196    #[test]
197    fn envelope_empty_safe() {
198        let envelope = envelope_from_usage(ProviderKind::Unknown, "unknown", &json!({}));
199        assert_eq!(envelope.input_tokens, 0);
200        assert_eq!(envelope.output_tokens, 0);
201        assert_eq!(envelope.cache_read_tokens, 0);
202        assert_eq!(envelope.cache_write_tokens, 0);
203        assert_eq!(envelope.reasoning_tokens, 0);
204    }
205}