Skip to main content

agentic_server/handler/http/
models.rs

1use 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
15/// Static fields shared by every Codex `ModelInfo` entry.
16///
17/// Built once on first use; cloned per model and patched with the per-model
18/// values (`slug`, `display_name`, `auto_review_model_override`,
19/// `supports_reasoning_summaries`, `input_modalities`, and optionally
20/// `context_window` / `max_context_window`).
21fn 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
53/// Transform a single upstream model entry into a Codex `ModelInfo` object.
54///
55/// Returns `None` when the entry has no `id` field (malformed upstream data).
56fn 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    // vLLM uses max_model_len; other providers may use context_length
60    let context_length = m["max_model_len"].as_i64().or_else(|| m["context_length"].as_i64());
61    // Single pass over capabilities for both flags
62    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
89/// Build the Codex `ModelsResponse` from a raw upstream vLLM models payload.
90fn 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/// Query parameters for GET /v1/models.
134///
135/// Codex CLI appends `?client_version=<ver>` to identify itself; its presence
136/// triggers transformation to the Codex `ModelsResponse` shape.
137#[derive(serde::Deserialize)]
138pub struct ModelsParams {
139    client_version: Option<String>,
140}
141
142/// GET /v1/models — Codex-aware model list.
143///
144/// When `?client_version` is present (Codex CLI), fetches vLLM's model list via
145/// [`proxy_get`] and transforms it into the Codex `ModelsResponse` shape
146/// (`{ "models": [...] }` with rich metadata). Without `client_version`, the
147/// upstream response is returned unchanged via [`proxy_get`].
148pub 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}