gestura_core_llm/
openai.rs

1//! OpenAI model capability and endpoint routing helpers.
2//!
3//! Gestura supports both the legacy Chat Completions endpoint and the modern
4//! Responses endpoint for OpenAI. This module centralizes the model-id
5//! heuristics that determine whether a model is suitable for agent sessions and
6//! which endpoint should be used.
7
8/// OpenAI inference API selected for a given model.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum OpenAiApi {
11    /// `/v1/chat/completions`
12    ChatCompletions,
13    /// `/v1/responses`
14    Responses,
15}
16
17const OPENAI_AGENT_MODEL_PREFIXES: &[&str] =
18    &["gpt-", "o1-", "o3-", "o4-", "o5-", "chatgpt-4o-", "codex-"];
19const OPENAI_FINE_TUNED_AGENT_MODEL_PREFIXES: &[&str] = &[
20    "ft:gpt-",
21    "ft:o1-",
22    "ft:o3-",
23    "ft:o4-",
24    "ft:o5-",
25    "ft:codex-",
26];
27const OPENAI_NON_AGENT_MARKERS: &[&str] = &[
28    "instruct",
29    "transcribe",
30    "audio",
31    "realtime",
32    "tts",
33    "moderation",
34    "embedding",
35];
36const OPENAI_LEGACY_COMPLETION_MODELS: &[&str] = &[
37    "ada",
38    "babbage",
39    "curie",
40    "davinci",
41    "babbage-002",
42    "davinci-002",
43    "gpt-3.5-turbo-instruct",
44];
45
46fn normalize_model_id(model_id: &str) -> String {
47    model_id.trim().to_ascii_lowercase()
48}
49
50fn starts_with_any(value: &str, prefixes: &[&str]) -> bool {
51    prefixes.iter().any(|prefix| value.starts_with(prefix))
52}
53
54fn contains_any(value: &str, needles: &[&str]) -> bool {
55    needles.iter().any(|needle| value.contains(needle))
56}
57
58/// Returns `true` when the model id is obviously part of the OpenAI family.
59pub fn looks_like_openai_model(model_id: &str) -> bool {
60    let model_id = normalize_model_id(model_id);
61    !model_id.is_empty()
62        && (starts_with_any(&model_id, OPENAI_AGENT_MODEL_PREFIXES)
63            || starts_with_any(&model_id, OPENAI_FINE_TUNED_AGENT_MODEL_PREFIXES)
64            || model_id.starts_with("text-")
65            || model_id.starts_with("code-"))
66}
67
68/// Returns `true` when the model id is a known legacy completion / non-agent model.
69pub fn is_known_openai_legacy_completion_model(model_id: &str) -> bool {
70    let model_id = normalize_model_id(model_id);
71    if model_id.is_empty() {
72        return false;
73    }
74
75    if model_id.starts_with("text-")
76        || model_id.starts_with("code-")
77        || model_id.starts_with("gpt-image-")
78    {
79        return true;
80    }
81
82    if OPENAI_LEGACY_COMPLETION_MODELS.contains(&model_id.as_str()) {
83        return true;
84    }
85
86    OPENAI_LEGACY_COMPLETION_MODELS.iter().any(|base| {
87        model_id.starts_with(&format!("ft:{base}:")) || model_id.starts_with(&format!("{base}:ft-"))
88    })
89}
90
91/// Returns `true` when a model should be routed to `/v1/responses`.
92pub fn is_openai_responses_api_model(model_id: &str) -> bool {
93    let model_id = normalize_model_id(model_id);
94    if model_id.is_empty() {
95        return false;
96    }
97
98    let is_chat_compatible_codex = model_id.starts_with("codex-mini-");
99    let is_responses_codex = (model_id.starts_with("codex-") || model_id.contains("-codex"))
100        && !is_chat_compatible_codex;
101
102    is_responses_codex || model_id.starts_with("gpt-5") || model_id.starts_with("o5-")
103}
104
105/// Select the OpenAI inference API to use for the supplied model id.
106pub fn openai_api_for_model(model_id: &str) -> OpenAiApi {
107    if is_openai_responses_api_model(model_id) {
108        OpenAiApi::Responses
109    } else {
110        OpenAiApi::ChatCompletions
111    }
112}
113
114/// Returns `true` when the model is suitable for Gestura agent sessions.
115pub fn is_agent_capable_openai_model(model_id: &str) -> bool {
116    let model_id = normalize_model_id(model_id);
117    if model_id.is_empty() || is_known_openai_legacy_completion_model(&model_id) {
118        return false;
119    }
120
121    let has_supported_prefix = starts_with_any(&model_id, OPENAI_AGENT_MODEL_PREFIXES)
122        || starts_with_any(&model_id, OPENAI_FINE_TUNED_AGENT_MODEL_PREFIXES);
123
124    has_supported_prefix
125        && !contains_any(&model_id, OPENAI_NON_AGENT_MARKERS)
126        && !model_id.starts_with("gpt-image-")
127}
128
129/// Returns `true` when the model should be rejected for agent sessions.
130pub fn is_openai_model_incompatible_with_agent_session(model_id: &str) -> bool {
131    let model_id = normalize_model_id(model_id);
132    if model_id.is_empty() {
133        return false;
134    }
135
136    if is_known_openai_legacy_completion_model(&model_id) {
137        return true;
138    }
139
140    if looks_like_openai_model(&model_id) {
141        return !is_agent_capable_openai_model(&model_id);
142    }
143
144    false
145}
146
147/// Build a user-facing error describing why the model cannot be used for sessions.
148pub fn openai_agent_session_model_message(model_id: &str) -> String {
149    format!(
150        "OpenAI model '{}' is not compatible with Gestura agent sessions. Gestura automatically routes OpenAI agent requests to /v1/chat/completions or /v1/responses depending on model capabilities, so choose an agent/tool-capable model such as gpt-4o, gpt-4.1, o3, o4-mini, gpt-5.4, gpt-5.3-codex, or codex-mini-latest.",
151        model_id.trim()
152    )
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn routes_modern_models_to_responses() {
161        assert_eq!(openai_api_for_model("gpt-5.4"), OpenAiApi::Responses);
162        assert_eq!(openai_api_for_model("gpt-5.3-codex"), OpenAiApi::Responses);
163        assert_eq!(openai_api_for_model("codex-1"), OpenAiApi::Responses);
164        assert_eq!(openai_api_for_model("o5-preview"), OpenAiApi::Responses);
165        assert_eq!(openai_api_for_model("gpt-4o"), OpenAiApi::ChatCompletions);
166        assert_eq!(
167            openai_api_for_model("codex-mini-latest"),
168            OpenAiApi::ChatCompletions
169        );
170    }
171
172    #[test]
173    fn recognizes_agent_capable_models() {
174        for model in [
175            "gpt-4o",
176            "gpt-4.1",
177            "o4-mini",
178            "gpt-5.4",
179            "gpt-5.3-codex",
180            "codex-1",
181            "codex-mini-latest",
182        ] {
183            assert!(
184                is_agent_capable_openai_model(model),
185                "expected {model} to be supported"
186            );
187        }
188
189        for model in [
190            "text-davinci-003",
191            "gpt-4o-transcribe",
192            "gpt-4o-audio-preview",
193            "gpt-realtime",
194            "gpt-image-1",
195        ] {
196            assert!(
197                is_openai_model_incompatible_with_agent_session(model),
198                "expected {model} to be rejected"
199            );
200        }
201    }
202}