Skip to main content

lean_ctx/proxy/
providers.rs

1//! Universal provider routes — the request-path half of the
2//! universal-provider-framework (enterprise#7).
3//!
4//! `/providers/{id}/...` forwards to the `[[proxy.providers]]` registry entry
5//! with that id, speaking the entry's declared [`WireShape`]. A new
6//! OpenAI/Anthropic/Gemini-compatible endpoint (Azure AI Foundry, OpenRouter,
7//! Groq, vLLM, a corporate gateway…) is therefore pure configuration — no code
8//! change, no rebuild:
9//!
10//! ```toml
11//! [[proxy.providers]]
12//! id = "foundry"
13//! shape = "openai"
14//! base_url = "https://my-resource.services.ai.azure.com"
15//! api_key_env = "FOUNDRY_API_KEY"   # optional: gateway-held credential
16//! ```
17//!
18//! Shape ≠ identity: the proxy understands three wire dialects (the shapes) and
19//! any number of provider identities map onto them. Compression, introspection
20//! and usage metering all run exactly as they do for the built-in routes of the
21//! same shape.
22//!
23//! When `api_key_env` is set, the gateway holds the upstream credential and the
24//! caller authenticates with the lean-ctx Bearer token only: every incoming
25//! credential header is stripped and replaced by the configured key (the caller
26//! never needs — or sees — the provider key). Without `api_key_env` the
27//! caller's own credentials are forwarded verbatim, exactly like the built-ins.
28
29use axum::{
30    body::Body,
31    extract::{Path, State},
32    http::{HeaderMap, HeaderValue, Request, StatusCode},
33    response::Response,
34};
35
36use super::{ProxyState, forward};
37use crate::core::config::{ResolvedProvider, WireShape};
38
39/// Request extension carrying the registry identity of the serving provider.
40///
41/// Shape ≠ identity (module docs): the forward path only knows the wire shape
42/// ("OpenAI"), but usage metering must attribute to the provider *identity*
43/// ("foundry", "local") — otherwise every OpenAI-shaped registry entry shows
44/// up as "OpenAI" in `usage_events` and the admin breakdown (enterprise#20).
45/// `local` carries the entry's resolved local-inference flag so shadow-rate
46/// billing works for non-loopback local endpoints too (host.docker.internal).
47#[derive(Debug, Clone)]
48pub(super) struct RegistryProviderId {
49    pub id: String,
50    pub local: bool,
51}
52
53pub async fn handler(
54    State(state): State<ProxyState>,
55    Path((id, rest)): Path<(String, String)>,
56    mut req: Request<Body>,
57) -> Result<Response, StatusCode> {
58    let Some(provider) = state.upstream_snapshot().provider_by_id(&id).cloned() else {
59        tracing::warn!("lean-ctx proxy: unknown registry provider '{id}' (404)");
60        return Err(StatusCode::NOT_FOUND);
61    };
62    req.extensions_mut().insert(RegistryProviderId {
63        id: provider.id.clone(),
64        local: provider.local,
65    });
66
67    // Strip the `/providers/{id}` prefix so the upstream sees the bare provider
68    // path: `/providers/foundry/v1/chat/completions` → `/v1/chat/completions`.
69    let path = format!("/{rest}");
70    let uri = match req.uri().query() {
71        Some(q) => format!("{path}?{q}").parse::<axum::http::Uri>(),
72        None => path.parse::<axum::http::Uri>(),
73    }
74    .map_err(|_| StatusCode::BAD_REQUEST)?;
75    *req.uri_mut() = uri;
76
77    if provider.api_key_env.is_some() {
78        inject_gateway_credential(&provider, req.headers_mut())?;
79    }
80
81    match provider.shape {
82        WireShape::Anthropic => {
83            forward::forward_request(
84                State(state),
85                req,
86                &provider.base_url,
87                "/v1/messages",
88                super::anthropic::compress_request_body,
89                "Anthropic",
90                &[],
91            )
92            .await
93        }
94        WireShape::OpenAi => {
95            forward::forward_request(
96                State(state),
97                req,
98                &provider.base_url,
99                "/v1/chat/completions",
100                super::openai::compress_request_body,
101                "OpenAI",
102                &[],
103            )
104            .await
105        }
106        WireShape::Gemini => {
107            // Gemini carries the model in the URL path, not the body (#840).
108            let model = super::usage::gemini_model_from_path(req.uri().path());
109            forward::forward_request(
110                State(state),
111                req,
112                &provider.base_url,
113                "/",
114                move |body, size| {
115                    super::google::compress_request_body(body, size, model.as_deref())
116                },
117                "Gemini",
118                &["application/x-ndjson"],
119            )
120            .await
121        }
122    }
123}
124
125/// Replace every caller credential header with the gateway-held key from the
126/// entry's `api_key_env`, in the header dialect of the provider's shape. A
127/// configured-but-missing env var is a deployment error and must surface
128/// loudly (502), never silently forward the caller's lean-ctx token upstream.
129pub(super) fn inject_gateway_credential(
130    provider: &ResolvedProvider,
131    headers: &mut HeaderMap,
132) -> Result<(), StatusCode> {
133    let env_name = provider
134        .api_key_env
135        .as_deref()
136        .expect("caller checked api_key_env");
137    let key = std::env::var(env_name)
138        .ok()
139        .filter(|k| !k.trim().is_empty());
140    let Some(key) = key else {
141        tracing::error!(
142            "lean-ctx proxy: provider '{}' configures api_key_env='{env_name}' but the \
143             variable is unset/empty — cannot authenticate upstream (502)",
144            provider.id
145        );
146        return Err(StatusCode::BAD_GATEWAY);
147    };
148
149    // The caller authenticated against the gateway (Bearer token); none of its
150    // credential headers may leak upstream.
151    for h in ["authorization", "x-api-key", "api-key", "x-goog-api-key"] {
152        headers.remove(h);
153    }
154
155    let value = |v: String| {
156        HeaderValue::from_str(&v).map_err(|_| {
157            tracing::error!(
158                "lean-ctx proxy: provider '{}' key from {env_name} contains invalid header bytes",
159                provider.id
160            );
161            StatusCode::BAD_GATEWAY
162        })
163    };
164    match provider.shape {
165        WireShape::Anthropic => {
166            headers.insert("x-api-key", value(key)?);
167        }
168        WireShape::OpenAi => {
169            // Bearer for OpenAI-compatible endpoints; `api-key` additionally
170            // covers Azure deployments that only read that header.
171            headers.insert("api-key", value(key.clone())?);
172            headers.insert("authorization", value(format!("Bearer {key}"))?);
173        }
174        WireShape::Gemini => {
175            headers.insert("x-goog-api-key", value(key)?);
176        }
177    }
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn provider(shape: WireShape, api_key_env: Option<&str>) -> ResolvedProvider {
186        ResolvedProvider {
187            id: "test".into(),
188            shape,
189            base_url: "https://example.invalid".into(),
190            api_key_env: api_key_env.map(str::to_string),
191            local: false,
192        }
193    }
194
195    #[test]
196    fn injection_replaces_caller_credentials_per_shape() {
197        let _lock = crate::core::data_dir::test_env_lock();
198        crate::test_env::set_var("LC_TEST_PROVIDER_KEY", "sk-upstream");
199
200        for (shape, expect_header, expect_value) in [
201            (WireShape::Anthropic, "x-api-key", "sk-upstream"),
202            (WireShape::OpenAi, "authorization", "Bearer sk-upstream"),
203            (WireShape::Gemini, "x-goog-api-key", "sk-upstream"),
204        ] {
205            let mut headers = HeaderMap::new();
206            headers.insert("authorization", "Bearer lean-ctx-token".parse().unwrap());
207            headers.insert("x-api-key", "caller-key".parse().unwrap());
208            inject_gateway_credential(&provider(shape, Some("LC_TEST_PROVIDER_KEY")), &mut headers)
209                .expect("key present");
210
211            assert_eq!(
212                headers.get(expect_header).unwrap().to_str().unwrap(),
213                expect_value,
214                "{shape:?} must carry the gateway key in its native header"
215            );
216            // The caller's gateway token must never leak upstream.
217            let leaked = headers
218                .iter()
219                .any(|(_, v)| v.to_str().is_ok_and(|v| v.contains("lean-ctx-token")));
220            assert!(!leaked, "caller bearer token leaked upstream for {shape:?}");
221            if shape != WireShape::Anthropic {
222                assert!(
223                    headers.get("x-api-key").is_none(),
224                    "stale caller x-api-key must be stripped for {shape:?}"
225                );
226            }
227        }
228        crate::test_env::remove_var("LC_TEST_PROVIDER_KEY");
229    }
230
231    #[test]
232    fn openai_shape_also_sets_azure_api_key_header() {
233        let _lock = crate::core::data_dir::test_env_lock();
234        crate::test_env::set_var("LC_TEST_PROVIDER_KEY2", "fk-123");
235        let mut headers = HeaderMap::new();
236        inject_gateway_credential(
237            &provider(WireShape::OpenAi, Some("LC_TEST_PROVIDER_KEY2")),
238            &mut headers,
239        )
240        .unwrap();
241        assert_eq!(headers.get("api-key").unwrap(), "fk-123");
242        crate::test_env::remove_var("LC_TEST_PROVIDER_KEY2");
243    }
244
245    #[test]
246    fn missing_key_env_is_a_loud_bad_gateway() {
247        let _lock = crate::core::data_dir::test_env_lock();
248        crate::test_env::remove_var("LC_TEST_PROVIDER_KEY_MISSING");
249        let mut headers = HeaderMap::new();
250        headers.insert("authorization", "Bearer lean-ctx-token".parse().unwrap());
251        let err = inject_gateway_credential(
252            &provider(WireShape::OpenAi, Some("LC_TEST_PROVIDER_KEY_MISSING")),
253            &mut headers,
254        )
255        .unwrap_err();
256        assert_eq!(err, StatusCode::BAD_GATEWAY);
257    }
258}