Skip to main content

ralph/contracts/
model.rs

1//! Model-related configuration contracts.
2//!
3//! Responsibilities:
4//! - Define the Model enum and model effort settings.
5//! - Handle custom serialization for model identifiers.
6//!
7//! Not handled here:
8//! - Runner definitions (see `super::runner`).
9//! - Core config structs (see `super::config`).
10
11use 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
77// Manual JsonSchema implementation for Model since it has custom Serialize/Deserialize
78impl 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        // Verify known models are in schema
253        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        // Verify oneOf structure
275        assert!(schema_json.contains("oneOf"), "schema should use oneOf");
276
277        // Verify custom model fallback exists
278        assert!(
279            schema_json.contains("Custom model identifier"),
280            "schema should have custom fallback"
281        );
282    }
283}