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 four 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};
38use crate::core::ocla::types::{ConnectorJob, OclaResult, ScheduledJob};
39
40/// Select and schedule a connector through the real provider pipeline.
41///
42/// Explicitly requested, available providers win. Otherwise Active Inference
43/// ranks available providers using the persisted provider-bandit model. The
44/// requested connector remains a safe deferred fallback when providers have
45/// not been initialized or authenticated yet.
46pub fn schedule_connector(job: &ConnectorJob, sequence: u64) -> OclaResult<ScheduledJob> {
47    job.context.validate()?;
48    if job.connector_id.trim().is_empty() {
49        return Err(crate::core::ocla::types::OclaError::InvalidRequest(
50            "connector_id is required".into(),
51        ));
52    }
53
54    let project_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
55    crate::core::providers::init::init_with_project_root(Some(&project_root));
56    let available = crate::core::providers::global_registry().available_provider_ids();
57    let task = format!("{} {}", job.connector_id, job.payload_ref);
58    let mut bandit =
59        crate::core::provider_bandit::ProviderBandit::load(project_root.to_str().unwrap_or("."));
60    let (provider_id, action) = select_provider(&job.connector_id, &task, &available, &mut bandit);
61
62    Ok(ScheduledJob {
63        job_ref: format!("job:{}:{sequence}", job.connector_id),
64        queue_ref: format!("provider:{provider_id}:{action}"),
65    })
66}
67
68fn select_provider(
69    requested: &str,
70    task: &str,
71    available: &[String],
72    bandit: &mut crate::core::provider_bandit::ProviderBandit,
73) -> (String, String) {
74    if available.iter().any(|id| id == requested) {
75        return (requested.to_string(), "dispatch".into());
76    }
77
78    if let Some(prediction) =
79        crate::core::active_inference::predict_preloads(task, available, bandit, 1)
80            .into_iter()
81            .next()
82    {
83        return (prediction.provider_id, prediction.action);
84    }
85
86    (requested.to_string(), "dispatch".into())
87}
88
89/// Request extension carrying the registry identity of the serving provider.
90///
91/// Shape ≠ identity (module docs): the forward path only knows the wire shape
92/// ("OpenAI"), but usage metering must attribute to the provider *identity*
93/// ("foundry", "local") — otherwise every OpenAI-shaped registry entry shows
94/// up as "OpenAI" in `usage_events` and the admin breakdown (enterprise#20).
95/// `local` carries the entry's resolved local-inference flag so shadow-rate
96/// billing works for non-loopback local endpoints too (host.docker.internal).
97#[derive(Debug, Clone)]
98pub(super) struct RegistryProviderId {
99    pub id: String,
100    pub local: bool,
101}
102
103/// Registry ids that share the Grok / xAI dual-rail (subscription `grok-chat`,
104/// API-key `xai`). OpenAI wire shape; separate `proxy status` bucket.
105pub(super) fn is_grok_provider_id(id: &str) -> bool {
106    matches!(
107        id.trim().to_ascii_lowercase().as_str(),
108        "grok-chat" | "xai" | "grok"
109    )
110}
111
112/// Registry ids that share the Command Code gateway rail (`commandcode`).
113/// Separate `proxy status` bucket so traffic does not fold into the wire-shape
114/// line.
115pub(super) fn is_commandcode_provider_id(id: &str) -> bool {
116    matches!(
117        id.trim().to_ascii_lowercase().as_str(),
118        "commandcode" | "command-code"
119    )
120}
121
122/// Per-upstream stats label for a registry route.
123///
124/// Wire shape stays `OpenAI`/`Anthropic`/… for compression; identity for
125/// `ProxyStats` may differ (Grok must not fold into the OpenAI line).
126pub(super) fn stats_label<'a>(registry_id: Option<&str>, shape_label: &'a str) -> &'a str {
127    match registry_id {
128        Some(id) if is_grok_provider_id(id) => "Grok",
129        Some(id) if is_commandcode_provider_id(id) => "CommandCode",
130        _ => shape_label,
131    }
132}
133
134/// True when an OpenAI-shaped registry request should use the Responses
135/// compressor (`input` / `function_call_output`) rather than Chat Completions.
136pub(super) fn is_openai_responses_path(path: &str) -> bool {
137    let path = path.trim_end_matches('/');
138    path == "/responses"
139        || path == "/v1/responses"
140        || path.starts_with("/responses/")
141        || path.starts_with("/v1/responses/")
142}
143
144pub async fn handler(
145    State(state): State<ProxyState>,
146    Path((id, rest)): Path<(String, String)>,
147    mut req: Request<Body>,
148) -> Result<Response, StatusCode> {
149    let Some(provider) = state.upstream_snapshot().provider_by_id(&id).cloned() else {
150        tracing::warn!("lean-ctx proxy: unknown registry provider '{id}' (404)");
151        return Err(StatusCode::NOT_FOUND);
152    };
153    req.extensions_mut().insert(RegistryProviderId {
154        id: provider.id.clone(),
155        local: provider.local,
156    });
157
158    // Strip the `/providers/{id}` prefix so the upstream sees the bare provider
159    // path: `/providers/foundry/v1/chat/completions` → `/v1/chat/completions`.
160    let path = format!("/{rest}");
161    let uri = match req.uri().query() {
162        Some(q) => format!("{path}?{q}").parse::<axum::http::Uri>(),
163        None => path.parse::<axum::http::Uri>(),
164    }
165    .map_err(|_| StatusCode::BAD_REQUEST)?;
166    *req.uri_mut() = uri;
167
168    if provider.shape == WireShape::Bedrock {
169        super::bedrock::validate_invoke_request(&req)?;
170        super::bedrock::attach_signing_context(&provider, &mut req)?;
171    } else if provider.api_key_env.is_some() {
172        inject_gateway_credential(&provider, req.headers_mut())?;
173    }
174
175    match provider.shape {
176        WireShape::Anthropic => {
177            forward::forward_request(
178                State(state),
179                req,
180                &provider.base_url,
181                "/v1/messages",
182                super::bedrock::passthrough_request_body,
183                "Anthropic",
184                &[],
185            )
186            .await
187        }
188        WireShape::OpenAi => {
189            // Built-in OpenAI routes pick Chat Completions vs Responses by path
190            // (`/v1/chat/completions` vs `/v1/responses`). Registry providers
191            // must do the same: Grok CLI hits `/providers/grok-chat/v1/responses`
192            // with `function_call_output` in `input`, which the Chat compressor
193            // ignores (it only rewrites `messages`). Path already has the
194            // `/providers/{id}` prefix stripped above.
195            if is_openai_responses_path(req.uri().path()) {
196                forward::forward_request(
197                    State(state),
198                    req,
199                    &provider.base_url,
200                    "/v1/responses",
201                    super::openai_responses::compress_request_body,
202                    "OpenAI",
203                    &[],
204                )
205                .await
206            } else {
207                forward::forward_request(
208                    State(state),
209                    req,
210                    &provider.base_url,
211                    "/v1/chat/completions",
212                    super::openai::compress_request_body,
213                    "OpenAI",
214                    &[],
215                )
216                .await
217            }
218        }
219        WireShape::Gemini => {
220            // Gemini carries the model in the URL path, not the body (#840).
221            let model = super::usage::gemini_model_from_path(req.uri().path());
222            forward::forward_request(
223                State(state),
224                req,
225                &provider.base_url,
226                "/",
227                move |body, size| {
228                    super::google::compress_request_body(body, size, model.as_deref())
229                },
230                "Gemini",
231                &["application/x-ndjson"],
232            )
233            .await
234        }
235        WireShape::Bedrock => {
236            forward::forward_request(
237                State(state),
238                req,
239                &provider.base_url,
240                "/",
241                super::bedrock::passthrough_request_body,
242                "Bedrock",
243                &["application/vnd.amazon.eventstream"],
244            )
245            .await
246        }
247    }
248}
249
250/// Replace every caller credential header with the gateway-held key from the
251/// entry's `api_key_env`, in the header dialect of the provider's shape. A
252/// configured-but-missing env var is a deployment error and must surface
253/// loudly (502), never silently forward the caller's lean-ctx token upstream.
254pub(super) fn inject_gateway_credential(
255    provider: &ResolvedProvider,
256    headers: &mut HeaderMap,
257) -> Result<(), StatusCode> {
258    let env_name = provider
259        .api_key_env
260        .as_deref()
261        .expect("caller checked api_key_env");
262    let key = std::env::var(env_name)
263        .ok()
264        .filter(|k| !k.trim().is_empty());
265    let Some(key) = key else {
266        tracing::error!(
267            "lean-ctx proxy: provider '{}' configures api_key_env='{env_name}' but the \
268             variable is unset/empty — cannot authenticate upstream (502)",
269            provider.id
270        );
271        return Err(StatusCode::BAD_GATEWAY);
272    };
273
274    // The caller authenticated against the gateway (Bearer token); none of its
275    // credential headers may leak upstream.
276    for h in ["authorization", "x-api-key", "api-key", "x-goog-api-key"] {
277        headers.remove(h);
278    }
279
280    let value = |v: String| {
281        HeaderValue::from_str(&v).map_err(|_| {
282            tracing::error!(
283                "lean-ctx proxy: provider '{}' key from {env_name} contains invalid header bytes",
284                provider.id
285            );
286            StatusCode::BAD_GATEWAY
287        })
288    };
289    match provider.shape {
290        WireShape::Anthropic => {
291            headers.insert("x-api-key", value(key)?);
292        }
293        WireShape::OpenAi => {
294            // Bearer for OpenAI-compatible endpoints; `api-key` additionally
295            // covers Azure deployments that only read that header.
296            headers.insert("api-key", value(key.clone())?);
297            headers.insert("authorization", value(format!("Bearer {key}"))?);
298        }
299        WireShape::Gemini => {
300            headers.insert("x-goog-api-key", value(key)?);
301        }
302        WireShape::Bedrock => unreachable!("Bedrock uses SigV4, not api_key_env"),
303    }
304    Ok(())
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn select_provider_honors_available_explicit_connector() {
313        let available = vec!["github".to_string(), "jira".to_string()];
314        let mut bandit = crate::core::provider_bandit::ProviderBandit::new();
315        assert_eq!(
316            select_provider("jira", "bug investigation", &available, &mut bandit),
317            ("jira".to_string(), "dispatch".to_string())
318        );
319    }
320
321    #[test]
322    fn select_provider_uses_active_inference_priority() {
323        let available = vec!["github".to_string(), "jira".to_string()];
324        let mut bandit = crate::core::provider_bandit::ProviderBandit::new();
325        assert_eq!(
326            select_provider("automatic", "investigate a bug", &available, &mut bandit),
327            ("github".to_string(), "issues".to_string())
328        );
329    }
330
331    #[test]
332    fn stats_label_maps_grok_rails_to_grok_bucket() {
333        assert_eq!(stats_label(Some("grok-chat"), "OpenAI"), "Grok");
334        assert_eq!(stats_label(Some("xai"), "OpenAI"), "Grok");
335        assert_eq!(stats_label(Some("GROK"), "OpenAI"), "Grok");
336        assert_eq!(stats_label(Some("foundry"), "OpenAI"), "OpenAI");
337        assert_eq!(stats_label(None, "OpenAI"), "OpenAI");
338        assert_eq!(stats_label(Some("local"), "OpenAI"), "OpenAI");
339    }
340
341    #[test]
342    fn is_grok_provider_id_accepts_dual_rail_ids() {
343        assert!(is_grok_provider_id("grok-chat"));
344        assert!(is_grok_provider_id("xai"));
345        assert!(is_grok_provider_id(" Grok "));
346        assert!(!is_grok_provider_id("openai"));
347        assert!(!is_grok_provider_id("foundry"));
348    }
349
350    #[test]
351    fn stats_label_maps_commandcode_rail_to_commandcode_bucket() {
352        assert_eq!(stats_label(Some("commandcode"), "OpenAI"), "CommandCode");
353        assert_eq!(stats_label(Some("command-code"), "OpenAI"), "CommandCode");
354        assert_eq!(stats_label(Some(" CommandCode "), "OpenAI"), "CommandCode");
355    }
356
357    #[test]
358    fn is_commandcode_provider_id_accepts_rail_ids() {
359        assert!(is_commandcode_provider_id("commandcode"));
360        assert!(is_commandcode_provider_id("command-code"));
361        assert!(is_commandcode_provider_id(" COMMANDCODE "));
362        assert!(!is_commandcode_provider_id("openai"));
363        assert!(!is_commandcode_provider_id("grok"));
364    }
365
366    #[test]
367    fn is_openai_responses_path_detects_responses_api() {
368        assert!(is_openai_responses_path("/v1/responses"));
369        assert!(is_openai_responses_path("/v1/responses/"));
370        assert!(is_openai_responses_path("/responses"));
371        assert!(is_openai_responses_path(
372            "/v1/responses/resp_123/input_items"
373        ));
374        assert!(!is_openai_responses_path("/v1/chat/completions"));
375        assert!(!is_openai_responses_path("/v1/models"));
376        assert!(!is_openai_responses_path("/v1/responsesx"));
377    }
378
379    fn provider(shape: WireShape, api_key_env: Option<&str>) -> ResolvedProvider {
380        ResolvedProvider {
381            id: "test".into(),
382            shape,
383            base_url: "https://example.invalid".into(),
384            api_key_env: api_key_env.map(str::to_string),
385            aws_region: None,
386            local: false,
387        }
388    }
389
390    #[test]
391    fn injection_replaces_caller_credentials_per_shape() {
392        let _lock = crate::core::data_dir::test_env_lock();
393        crate::test_env::set_var("LC_TEST_PROVIDER_KEY", "sk-upstream");
394
395        for (shape, expect_header, expect_value) in [
396            (WireShape::Anthropic, "x-api-key", "sk-upstream"),
397            (WireShape::OpenAi, "authorization", "Bearer sk-upstream"),
398            (WireShape::Gemini, "x-goog-api-key", "sk-upstream"),
399        ] {
400            let mut headers = HeaderMap::new();
401            headers.insert("authorization", "Bearer lean-ctx-token".parse().unwrap());
402            headers.insert("x-api-key", "caller-key".parse().unwrap());
403            inject_gateway_credential(&provider(shape, Some("LC_TEST_PROVIDER_KEY")), &mut headers)
404                .expect("key present");
405
406            assert_eq!(
407                headers.get(expect_header).unwrap().to_str().unwrap(),
408                expect_value,
409                "{shape:?} must carry the gateway key in its native header"
410            );
411            // The caller's gateway token must never leak upstream.
412            let leaked = headers
413                .iter()
414                .any(|(_, v)| v.to_str().is_ok_and(|v| v.contains("lean-ctx-token")));
415            assert!(!leaked, "caller bearer token leaked upstream for {shape:?}");
416            if shape != WireShape::Anthropic {
417                assert!(
418                    headers.get("x-api-key").is_none(),
419                    "stale caller x-api-key must be stripped for {shape:?}"
420                );
421            }
422        }
423        crate::test_env::remove_var("LC_TEST_PROVIDER_KEY");
424    }
425
426    #[test]
427    fn openai_shape_also_sets_azure_api_key_header() {
428        let _lock = crate::core::data_dir::test_env_lock();
429        crate::test_env::set_var("LC_TEST_PROVIDER_KEY2", "fk-123");
430        let mut headers = HeaderMap::new();
431        inject_gateway_credential(
432            &provider(WireShape::OpenAi, Some("LC_TEST_PROVIDER_KEY2")),
433            &mut headers,
434        )
435        .unwrap();
436        assert_eq!(headers.get("api-key").unwrap(), "fk-123");
437        crate::test_env::remove_var("LC_TEST_PROVIDER_KEY2");
438    }
439
440    #[test]
441    fn missing_key_env_is_a_loud_bad_gateway() {
442        let _lock = crate::core::data_dir::test_env_lock();
443        crate::test_env::remove_var("LC_TEST_PROVIDER_KEY_MISSING");
444        let mut headers = HeaderMap::new();
445        headers.insert("authorization", "Bearer lean-ctx-token".parse().unwrap());
446        let err = inject_gateway_credential(
447            &provider(WireShape::OpenAi, Some("LC_TEST_PROVIDER_KEY_MISSING")),
448            &mut headers,
449        )
450        .unwrap_err();
451        assert_eq!(err, StatusCode::BAD_GATEWAY);
452    }
453}