1use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use std::borrow::Cow;
14
15#[derive(Debug, Clone, PartialEq, Eq, Default)]
16pub enum Model {
17 Gpt54,
18 #[default]
19 Gpt53Codex,
20 Gpt53CodexSpark,
21 Gpt53,
22 Glm47,
23 Custom(String),
24}
25
26impl Serialize for Model {
27 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
28 where
29 S: serde::Serializer,
30 {
31 serializer.serialize_str(self.as_str())
32 }
33}
34
35impl<'de> Deserialize<'de> for Model {
36 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
37 where
38 D: serde::Deserializer<'de>,
39 {
40 let value = String::deserialize(deserializer)?;
41 value.parse().map_err(serde::de::Error::custom)
42 }
43}
44
45impl Model {
46 pub fn as_str(&self) -> &str {
47 match self {
48 Model::Gpt54 => "gpt-5.4",
49 Model::Gpt53Codex => "gpt-5.3-codex",
50 Model::Gpt53CodexSpark => "gpt-5.3-codex-spark",
51 Model::Gpt53 => "gpt-5.3",
52 Model::Glm47 => "zai-coding-plan/glm-4.7",
53 Model::Custom(value) => value.as_str(),
54 }
55 }
56}
57
58impl std::str::FromStr for Model {
59 type Err = &'static str;
60
61 fn from_str(value: &str) -> Result<Self, Self::Err> {
62 let trimmed = value.trim();
63 if trimmed.is_empty() {
64 return Err("model cannot be empty");
65 }
66 Ok(match trimmed {
67 "gpt-5.4" => Model::Gpt54,
68 "gpt-5.3-codex" => Model::Gpt53Codex,
69 "gpt-5.3-codex-spark" => Model::Gpt53CodexSpark,
70 "gpt-5.3" => Model::Gpt53,
71 "zai-coding-plan/glm-4.7" => Model::Glm47,
72 other => Model::Custom(other.to_string()),
73 })
74 }
75}
76
77impl schemars::JsonSchema for Model {
79 fn schema_name() -> Cow<'static, str> {
80 "Model".into()
81 }
82
83 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
84 schemars::json_schema!({
85 "oneOf": [
86 {
87 "type": "string",
88 "const": "gpt-5.4",
89 "description": "OpenAI GPT-5.4 (default Codex model)"
90 },
91 {
92 "type": "string",
93 "const": "gpt-5.3-codex",
94 "description": "OpenAI GPT-5.3 Codex"
95 },
96 {
97 "type": "string",
98 "const": "gpt-5.3-codex-spark",
99 "description": "OpenAI GPT-5.3 Codex Spark (fast)"
100 },
101 {
102 "type": "string",
103 "const": "gpt-5.3",
104 "description": "OpenAI GPT-5.3"
105 },
106 {
107 "type": "string",
108 "const": "zai-coding-plan/glm-4.7",
109 "description": "ZhipuAI GLM-4.7"
110 },
111 {
112 "type": "string",
113 "description": "Custom model identifier",
114 "minLength": 1
115 }
116 ]
117 })
118 }
119}
120
121#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
122#[serde(rename_all = "snake_case")]
123pub enum ReasoningEffort {
124 Low,
125 #[default]
126 Medium,
127 High,
128 #[serde(rename = "xhigh")]
129 #[schemars(rename = "xhigh")]
130 XHigh,
131}
132
133#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
134#[serde(rename_all = "snake_case")]
135pub enum ModelEffort {
136 #[default]
137 Default,
138 Low,
139 Medium,
140 High,
141 #[serde(rename = "xhigh")]
142 #[schemars(rename = "xhigh")]
143 XHigh,
144}
145
146impl ModelEffort {
147 pub fn as_reasoning_effort(self) -> Option<ReasoningEffort> {
148 match self {
149 ModelEffort::Default => None,
150 ModelEffort::Low => Some(ReasoningEffort::Low),
151 ModelEffort::Medium => Some(ReasoningEffort::Medium),
152 ModelEffort::High => Some(ReasoningEffort::High),
153 ModelEffort::XHigh => Some(ReasoningEffort::XHigh),
154 }
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::{Model, ModelEffort, ReasoningEffort};
161
162 #[test]
163 fn model_parses_known_variants() {
164 assert_eq!("gpt-5.4".parse::<Model>().unwrap(), Model::Gpt54);
165 assert_eq!("gpt-5.3-codex".parse::<Model>().unwrap(), Model::Gpt53Codex);
166 assert_eq!(
167 "gpt-5.3-codex-spark".parse::<Model>().unwrap(),
168 Model::Gpt53CodexSpark
169 );
170 assert_eq!("gpt-5.3".parse::<Model>().unwrap(), Model::Gpt53);
171 assert_eq!(
172 "zai-coding-plan/glm-4.7".parse::<Model>().unwrap(),
173 Model::Glm47
174 );
175 }
176
177 #[test]
178 fn model_parses_custom_values() {
179 let custom = "claude-opus-4".parse::<Model>().unwrap();
180 assert_eq!(custom, Model::Custom("claude-opus-4".to_string()));
181 assert_eq!(custom.as_str(), "claude-opus-4");
182 }
183
184 #[test]
185 fn model_rejects_empty_string() {
186 let result = "".parse::<Model>();
187 assert!(result.is_err());
188 assert!(result.unwrap_err().contains("cannot be empty"));
189 }
190
191 #[test]
192 fn model_serializes_to_string() {
193 let model = Model::Gpt54;
194 let json = serde_json::to_string(&model).unwrap();
195 assert_eq!(json, "\"gpt-5.4\"");
196
197 let model = Model::Gpt53Codex;
198 let json = serde_json::to_string(&model).unwrap();
199 assert_eq!(json, "\"gpt-5.3-codex\"");
200
201 let model = Model::Gpt53CodexSpark;
202 let json = serde_json::to_string(&model).unwrap();
203 assert_eq!(json, "\"gpt-5.3-codex-spark\"");
204 }
205
206 #[test]
207 fn model_deserializes_from_string() {
208 let model: Model = serde_json::from_str("\"sonnet\"").unwrap();
209 assert_eq!(model, Model::Custom("sonnet".to_string()));
210 }
211
212 #[test]
213 fn reasoning_effort_parses_snake_case() {
214 let effort: ReasoningEffort = serde_json::from_str("\"low\"").unwrap();
215 assert_eq!(effort, ReasoningEffort::Low);
216 let effort: ReasoningEffort = serde_json::from_str("\"medium\"").unwrap();
217 assert_eq!(effort, ReasoningEffort::Medium);
218 let effort: ReasoningEffort = serde_json::from_str("\"high\"").unwrap();
219 assert_eq!(effort, ReasoningEffort::High);
220 let effort: ReasoningEffort = serde_json::from_str("\"xhigh\"").unwrap();
221 assert_eq!(effort, ReasoningEffort::XHigh);
222 }
223
224 #[test]
225 fn model_effort_converts_to_reasoning_effort() {
226 assert_eq!(ModelEffort::Default.as_reasoning_effort(), None);
227 assert_eq!(
228 ModelEffort::Low.as_reasoning_effort(),
229 Some(ReasoningEffort::Low)
230 );
231 assert_eq!(
232 ModelEffort::Medium.as_reasoning_effort(),
233 Some(ReasoningEffort::Medium)
234 );
235 assert_eq!(
236 ModelEffort::High.as_reasoning_effort(),
237 Some(ReasoningEffort::High)
238 );
239 assert_eq!(
240 ModelEffort::XHigh.as_reasoning_effort(),
241 Some(ReasoningEffort::XHigh)
242 );
243 }
244
245 #[test]
246 fn model_json_schema_includes_known_models() {
247 use schemars::JsonSchema;
248
249 let schema = Model::json_schema(&mut schemars::SchemaGenerator::default());
250 let schema_json = serde_json::to_string(&schema).unwrap();
251
252 assert!(
254 schema_json.contains("gpt-5.4"),
255 "schema should list gpt-5.4"
256 );
257 assert!(
258 schema_json.contains("gpt-5.3-codex"),
259 "schema should list gpt-5.3-codex"
260 );
261 assert!(
262 schema_json.contains("gpt-5.3-codex-spark"),
263 "schema should list gpt-5.3-codex-spark"
264 );
265 assert!(
266 schema_json.contains("gpt-5.3"),
267 "schema should list gpt-5.3"
268 );
269 assert!(
270 schema_json.contains("zai-coding-plan/glm-4.7"),
271 "schema should list glm-4.7"
272 );
273
274 assert!(schema_json.contains("oneOf"), "schema should use oneOf");
276
277 assert!(
279 schema_json.contains("Custom model identifier"),
280 "schema should have custom fallback"
281 );
282 }
283}