agentic_server/handler/http/
models.rs1use std::sync::OnceLock;
2
3use axum::extract::{Query, State};
4use axum::http::HeaderMap;
5use axum::response::{IntoResponse, Response};
6use http::StatusCode;
7use serde_json::{Value, json};
8use tracing::warn;
9
10use agentic_core::proxy::{ProxyBody, ProxyResponse, error_response, proxy_get};
11
12use super::super::common::convert_response;
13use crate::app::AppState;
14
15fn codex_model_template() -> &'static Value {
22 static TEMPLATE: OnceLock<Value> = OnceLock::new();
23 TEMPLATE.get_or_init(|| {
24 json!({
25 "supported_in_api": true,
26 "priority": 1,
27 "shell_type": "shell_command",
28 "visibility": "list",
29 "base_instructions": "",
30 "supported_reasoning_levels": [
31 {"effort": "low", "description": "Fast responses with lighter reasoning"},
32 {"effort": "medium", "description": "Balances speed and reasoning depth"},
33 {"effort": "high", "description": "Greater reasoning depth for complex problems"}
34 ],
35 "default_reasoning_summary": "auto",
36 "support_verbosity": false,
37 "default_verbosity": null,
38 "apply_patch_tool_type": "freeform",
39 "web_search_tool_type": "text",
40 "truncation_policy": {"mode": "bytes", "limit": 100_000},
41 "supports_parallel_tool_calls": true,
42 "supports_image_detail_original": false,
43 "effective_context_window_percent": 95,
44 "experimental_supported_tools": [],
45 "supports_search_tool": false,
46 "use_responses_lite": false,
47 "tool_mode": null,
48 "multi_agent_version": null,
49 })
50 })
51}
52
53fn upstream_model_to_codex(m: &Value) -> Option<Value> {
57 let id = m["id"].as_str()?.to_owned();
58 let display_name = m.get("name").and_then(Value::as_str).unwrap_or(&id).to_owned();
59 let context_length = m["max_model_len"].as_i64().or_else(|| m["context_length"].as_i64());
61 let (supports_reasoning, supports_image) = m["capabilities"].as_array().map_or((false, false), |c| {
63 c.iter().fold((false, false), |(r, i), v| {
64 let s = v.as_str();
65 (r || s == Some("reasoning"), i || s == Some("image"))
66 })
67 });
68 let input_modalities = if supports_image {
69 json!(["text", "image"])
70 } else {
71 json!(["text"])
72 };
73
74 let mut model = codex_model_template().clone();
75 let obj = model.as_object_mut().expect("template is object");
76 obj.insert("slug".into(), json!(id));
77 obj.insert("display_name".into(), json!(display_name));
78 obj.insert("auto_review_model_override".into(), json!(id));
79 obj.insert("supports_reasoning_summaries".into(), json!(supports_reasoning));
80 obj.insert("input_modalities".into(), input_modalities);
81 if let Some(ctx) = context_length {
82 obj.insert("context_window".into(), json!(ctx));
83 obj.insert("max_context_window".into(), json!(ctx));
84 }
85
86 Some(model)
87}
88
89fn build_codex_models_response(upstream_bytes: &[u8]) -> Value {
91 let models: Vec<Value> = serde_json::from_slice::<Value>(upstream_bytes)
92 .ok()
93 .and_then(|mut v| match v["data"].take() {
94 Value::Array(arr) => Some(arr),
95 _ => None,
96 })
97 .into_iter()
98 .flatten()
99 .filter_map(|m| upstream_model_to_codex(&m))
100 .collect();
101 json!({ "models": models })
102}
103
104pub async fn health() -> impl IntoResponse {
105 StatusCode::OK
106}
107
108pub async fn ready(State(state): State<AppState>) -> impl IntoResponse {
109 let base = state.llm_api_base.trim_end_matches('/');
110 let url = format!("{base}/health");
111
112 let client = reqwest::Client::builder()
113 .timeout(std::time::Duration::from_secs(2))
114 .build();
115
116 let Ok(client) = client else {
117 return StatusCode::SERVICE_UNAVAILABLE;
118 };
119
120 match client.get(&url).send().await {
121 Ok(resp) if resp.status().is_success() => StatusCode::OK,
122 Ok(resp) => {
123 warn!("LLM backend not ready: {}", resp.status());
124 StatusCode::SERVICE_UNAVAILABLE
125 }
126 Err(e) => {
127 warn!("LLM backend unreachable: {e}");
128 StatusCode::SERVICE_UNAVAILABLE
129 }
130 }
131}
132
133#[derive(serde::Deserialize)]
138pub struct ModelsParams {
139 client_version: Option<String>,
140}
141
142pub async fn models(State(state): State<AppState>, headers: HeaderMap, Query(params): Query<ModelsParams>) -> Response {
149 let upstream = proxy_get("/v1/models", &headers, &state.proxy_state).await;
150
151 if params.client_version.is_none() {
152 return convert_response(upstream);
153 }
154
155 let ProxyBody::Full(upstream_bytes) = upstream.body else {
156 return convert_response(error_response(
157 http::StatusCode::BAD_GATEWAY,
158 "upstream_unavailable",
159 "unexpected streaming response from /v1/models",
160 ));
161 };
162
163 if !upstream.status.is_success() {
164 return convert_response(ProxyResponse {
165 body: ProxyBody::Full(upstream_bytes),
166 ..upstream
167 });
168 }
169
170 axum::Json(build_codex_models_response(&upstream_bytes)).into_response()
171}