1mod anthropic;
8mod openai;
9
10use serde_json::{Map, Value};
11
12use crate::error::ComputerError;
13use crate::protocol::Action;
14
15pub use anthropic::translate as translate_anthropic;
16pub use openai::translate as translate_openai;
17
18pub(crate) fn as_object(value: &Value) -> Result<&Map<String, Value>, ComputerError> {
19 value.as_object().ok_or_else(|| {
20 ComputerError::InvalidArguments("adapter request must be a JSON object".into())
21 })
22}
23
24pub(crate) fn require_i32(object: &Map<String, Value>, key: &str) -> Result<i32, ComputerError> {
25 match object.get(key) {
26 Some(value) => number_i32(value, key),
27 None => Err(ComputerError::InvalidArguments(format!(
28 "{key} is required"
29 ))),
30 }
31}
32
33pub(crate) fn optional_i32(
34 object: &Map<String, Value>,
35 key: &str,
36) -> Result<Option<i32>, ComputerError> {
37 match object.get(key) {
38 None | Some(Value::Null) => Ok(None),
39 Some(value) => number_i32(value, key).map(Some),
40 }
41}
42
43pub(crate) fn require_string(
44 object: &Map<String, Value>,
45 key: &str,
46) -> Result<String, ComputerError> {
47 match object.get(key) {
48 Some(Value::String(text)) if !text.is_empty() => Ok(text.clone()),
49 Some(Value::String(_)) => Err(ComputerError::InvalidArguments(format!(
50 "{key} must not be empty"
51 ))),
52 Some(_) => Err(ComputerError::InvalidArguments(format!(
53 "{key} must be a string"
54 ))),
55 None => Err(ComputerError::InvalidArguments(format!(
56 "{key} is required"
57 ))),
58 }
59}
60
61pub(crate) fn coordinate_pair(value: &Value, key: &str) -> Result<(i32, i32), ComputerError> {
62 let Some(pair) = value.as_array() else {
63 return Err(ComputerError::InvalidArguments(format!(
64 "{key} must be [x, y]"
65 )));
66 };
67 if pair.len() != 2 {
68 return Err(ComputerError::InvalidArguments(format!(
69 "{key} must be [x, y]"
70 )));
71 }
72 Ok((number_i32(&pair[0], key)?, number_i32(&pair[1], key)?))
73}
74
75pub(crate) fn number_i32(value: &Value, key: &str) -> Result<i32, ComputerError> {
76 let Some(number) = value.as_f64() else {
77 return Err(ComputerError::InvalidArguments(format!(
78 "{key} must be a number"
79 )));
80 };
81 if !number.is_finite() || number < f64::from(i32::MIN) || number > f64::from(i32::MAX) {
82 return Err(ComputerError::InvalidArguments(format!(
83 "{key} is out of range"
84 )));
85 }
86 Ok(number.round() as i32)
87}
88
89pub fn translate(adapter: &str, value: &Value) -> Result<Action, ComputerError> {
91 match adapter {
92 "openai" => translate_openai(value),
93 "anthropic" => translate_anthropic(value),
94 other => Err(ComputerError::InvalidArguments(format!(
95 "unknown adapter {other:?}"
96 ))),
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::error::ComputerError;
104 use serde_json::Value;
105 use std::fs;
106 use std::path::Path;
107
108 fn assert_fixtures(dir: &str, translate: fn(&Value) -> Result<Action, ComputerError>) {
109 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
110 .join("tests/fixtures")
111 .join(dir);
112 let mut paths: Vec<_> = fs::read_dir(&root)
113 .unwrap_or_else(|error| panic!("read {}: {error}", root.display()))
114 .map(|entry| entry.unwrap().path())
115 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
116 .collect();
117 paths.sort();
118 assert!(!paths.is_empty(), "no fixtures in {}", root.display());
119 for path in paths {
120 let fixture: Value = serde_json::from_str(
121 &fs::read_to_string(&path).unwrap_or_else(|error| panic!("{error}")),
122 )
123 .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
124 let translated = translate(&fixture["request"]);
125 if let Some(code) = fixture.get("error_code").and_then(Value::as_str) {
126 let error = match translated {
127 Err(error) => error,
128 Ok(action) => panic!("{} translated to {action:?}", path.display()),
129 };
130 assert_eq!(error.code(), code, "{}", path.display());
131 if let Some(needle) = fixture.get("error_contains").and_then(Value::as_str) {
132 assert!(
133 error.to_string().contains(needle),
134 "{}: {error}",
135 path.display()
136 );
137 }
138 } else {
139 let expected: Action = serde_json::from_value(fixture["expect"].clone())
140 .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
141 let action = match translated {
142 Ok(action) => action,
143 Err(error) => panic!("{}: {error}", path.display()),
144 };
145 assert_eq!(action, expected, "{}", path.display());
146 }
147 }
148 }
149
150 #[test]
151 fn openai_fixtures_match_the_expected_action() {
152 assert_fixtures("openai", translate_openai);
153 }
154
155 #[test]
156 fn anthropic_fixtures_match_the_expected_action() {
157 assert_fixtures("anthropic", translate_anthropic);
158 }
159
160 #[test]
161 fn openai_wheel_is_middle_and_back_and_forward_are_buttons() {
162 let wheel = translate_openai(&serde_json::json!({
163 "action": {"type": "click", "x": 1, "y": 2, "button": "wheel"}
164 }))
165 .unwrap();
166 assert_eq!(
167 wheel,
168 Action::Click {
169 x: Some(1),
170 y: Some(2),
171 button: crate::types::MouseButton::Middle,
172 double: false,
173 count: 1,
174 }
175 );
176 let back = translate_openai(&serde_json::json!({
177 "type": "click", "x": 1, "y": 2, "button": "back"
178 }))
179 .unwrap();
180 assert_eq!(
181 back,
182 Action::Click {
183 x: Some(1),
184 y: Some(2),
185 button: crate::types::MouseButton::Back,
186 double: false,
187 count: 1,
188 }
189 );
190 let forward = translate_openai(&serde_json::json!({
191 "type": "click", "x": 1, "y": 2, "button": "forward"
192 }))
193 .unwrap();
194 assert_eq!(
195 forward,
196 Action::Click {
197 x: Some(1),
198 y: Some(2),
199 button: crate::types::MouseButton::Forward,
200 double: false,
201 count: 1,
202 }
203 );
204 }
205
206 #[test]
207 fn anthropic_return_key_and_unknown_action() {
208 let key = translate_anthropic(&serde_json::json!({
209 "action": "key",
210 "text": "Return"
211 }))
212 .unwrap();
213 assert_eq!(
214 key,
215 Action::Key {
216 keys: "Return".into()
217 }
218 );
219 let unknown = translate_anthropic(&serde_json::json!({"action": "nope"})).unwrap_err();
220 assert_eq!(unknown.code(), "invalid_arguments");
221 assert!(unknown.to_string().contains("nope"));
222 }
223}