gestura_core_llm/
openai.rs1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum OpenAiApi {
11 ChatCompletions,
13 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
58pub 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
68pub 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
91pub 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
105pub 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
114pub 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
129pub 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
147pub 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}