1use 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
40pub 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#[derive(Debug, Clone)]
98pub(super) struct RegistryProviderId {
99 pub id: String,
100 pub local: bool,
101}
102
103pub(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
112pub(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
122pub(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
134pub(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 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 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 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
250pub(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 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 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 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}