lean_ctx/proxy/
models_api.rs1use axum::http::StatusCode;
24use axum::response::{IntoResponse, Json, Response};
25
26use crate::core::config::{RoutingRules, parse_route_target};
27
28const CATALOG_CREATED_UNIX: i64 = 1_735_689_600;
31const CATALOG_CREATED_RFC3339: &str = "2025-01-01T00:00:00Z";
32
33pub async fn handler(req: axum::extract::Request) -> Response {
35 let rules = crate::core::config::Config::load().proxy.routing.clone();
36 let body = if wants_anthropic_shape(&req) {
37 anthropic_model_list(&rules)
38 } else {
39 openai_model_list(&rules)
40 };
41 (StatusCode::OK, Json(body)).into_response()
42}
43
44fn wants_anthropic_shape(req: &axum::extract::Request) -> bool {
47 req.headers().contains_key("anthropic-version") || req.headers().contains_key("x-api-key")
48}
49
50fn catalog(rules: &RoutingRules) -> Vec<(String, String)> {
53 if !rules.is_active() {
54 return Vec::new();
55 }
56 rules
57 .aliases
58 .iter()
59 .map(|(alias, target)| {
60 let owned_by = parse_route_target(target)
61 .and_then(|(provider, _)| provider.map(str::to_string))
62 .unwrap_or_else(|| "gateway".to_string());
64 (alias.clone(), owned_by)
65 })
66 .collect()
67}
68
69fn openai_model_list(rules: &RoutingRules) -> serde_json::Value {
71 let data: Vec<serde_json::Value> = catalog(rules)
72 .into_iter()
73 .map(|(id, owned_by)| {
74 serde_json::json!({
75 "id": id,
76 "object": "model",
77 "created": CATALOG_CREATED_UNIX,
78 "owned_by": owned_by,
79 })
80 })
81 .collect();
82 serde_json::json!({ "object": "list", "data": data })
83}
84
85fn anthropic_model_list(rules: &RoutingRules) -> serde_json::Value {
87 let entries = catalog(rules);
88 let first_id = entries.first().map(|(id, _)| id.clone());
89 let last_id = entries.last().map(|(id, _)| id.clone());
90 let data: Vec<serde_json::Value> = entries
91 .into_iter()
92 .map(|(id, _)| {
93 serde_json::json!({
94 "type": "model",
95 "id": id,
96 "display_name": id,
97 "created_at": CATALOG_CREATED_RFC3339,
98 })
99 })
100 .collect();
101 serde_json::json!({
102 "data": data,
103 "has_more": false,
104 "first_id": first_id,
105 "last_id": last_id,
106 })
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 fn rules(aliases: &[(&str, &str)]) -> RoutingRules {
114 RoutingRules {
115 enabled: Some(true),
116 aliases: aliases
117 .iter()
118 .map(|(k, v)| (k.to_string(), v.to_string()))
119 .collect(),
120 tiers: std::collections::BTreeMap::new(),
121 }
122 }
123
124 #[test]
125 fn openai_list_exposes_aliases_with_provider_ownership() {
126 let body = openai_model_list(&rules(&[
127 ("zuehlke/fast", "foundry:deepseek-v4-flash"),
128 ("zuehlke/premium", "anthropic:claude-opus-4-5"),
129 ("claude-opus-4-5", "claude-sonnet-4-5"), ]));
131 assert_eq!(body["object"], "list");
132 let data = body["data"].as_array().expect("data array");
133 assert_eq!(data.len(), 3);
134 assert_eq!(data[0]["id"], "claude-opus-4-5");
136 assert_eq!(data[0]["owned_by"], "gateway", "model-only alias");
137 assert_eq!(data[1]["id"], "zuehlke/fast");
138 assert_eq!(data[1]["owned_by"], "foundry");
139 assert_eq!(data[2]["id"], "zuehlke/premium");
140 assert_eq!(data[2]["owned_by"], "anthropic");
141 for m in data {
142 assert_eq!(m["object"], "model");
143 assert_eq!(m["created"], CATALOG_CREATED_UNIX);
144 }
145 }
146
147 #[test]
148 fn anthropic_list_mirrors_the_same_catalog() {
149 let body = anthropic_model_list(&rules(&[
150 ("zuehlke/fast", "foundry:deepseek-v4-flash"),
151 ("zuehlke/premium", "anthropic:claude-opus-4-5"),
152 ]));
153 let data = body["data"].as_array().expect("data array");
154 assert_eq!(data.len(), 2);
155 assert_eq!(data[0]["type"], "model");
156 assert_eq!(data[0]["id"], "zuehlke/fast");
157 assert_eq!(data[0]["display_name"], "zuehlke/fast");
158 assert_eq!(body["has_more"], false);
159 assert_eq!(body["first_id"], "zuehlke/fast");
160 assert_eq!(body["last_id"], "zuehlke/premium");
161 }
162
163 #[test]
164 fn inactive_routing_yields_an_honest_empty_list() {
165 let empty = rules(&[]);
167 assert_eq!(openai_model_list(&empty)["data"], serde_json::json!([]));
168 let mut off = rules(&[("a", "b:c")]);
169 off.enabled = Some(false);
170 assert_eq!(openai_model_list(&off)["data"], serde_json::json!([]));
171 let anth = anthropic_model_list(&off);
172 assert_eq!(anth["data"], serde_json::json!([]));
173 assert_eq!(anth["first_id"], serde_json::Value::Null);
174 }
175
176 #[test]
177 fn output_is_deterministic_across_calls() {
178 let r = rules(&[("z/fast", "foundry:m1"), ("a/slow", "local:m2")]);
181 let a = serde_json::to_string(&openai_model_list(&r)).unwrap();
182 let b = serde_json::to_string(&openai_model_list(&r)).unwrap();
183 assert_eq!(a, b);
184 let data = openai_model_list(&r);
186 assert_eq!(data["data"][0]["id"], "a/slow");
187 assert_eq!(data["data"][1]["id"], "z/fast");
188 }
189
190 #[test]
191 fn shape_detection_keys_on_anthropic_headers() {
192 let openai_req = axum::http::Request::builder()
193 .uri("/v1/models")
194 .header("authorization", "Bearer gk-alice-abc")
195 .body(axum::body::Body::empty())
196 .unwrap();
197 assert!(!wants_anthropic_shape(&openai_req));
198
199 let claude_req = axum::http::Request::builder()
200 .uri("/v1/models")
201 .header("x-api-key", "gk-alice-abc")
202 .header("anthropic-version", "2023-06-01")
203 .body(axum::body::Body::empty())
204 .unwrap();
205 assert!(wants_anthropic_shape(&claude_req));
206 }
207}