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};
38
39#[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 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 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
125pub(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 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 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 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}