claude_codex/providers/cursor/
request.rs1use crate::anthropic::schema::MessagesRequest;
2
3#[derive(Debug, Clone)]
5pub struct CursorSelectedImage {
6 pub data: String,
7 pub uuid: String,
8 pub path: String,
9 pub mime_type: String,
10}
11
12pub fn render_cursor_prompt(req: &MessagesRequest) -> String {
19 let mut sections: Vec<String> = Vec::new();
20
21 if let Some(system) = render_system(req) {
22 sections.push(format!("<system>\n{system}\n</system>"));
23 }
24
25 for message in &req.messages {
26 let content = render_message_content(message);
27 if let Some(c) = content {
28 sections.push(format!("<{}>\n{}\n</{}>", message.role, c, message.role));
29 }
30 }
31
32 if let Some(tools) = req.extra.get("tools").and_then(|v| v.as_array())
34 && !tools.is_empty()
35 {
36 let tool_lines: Vec<String> = tools
37 .iter()
38 .map(|t| {
39 let name = t.get("name").and_then(|n| n.as_str()).unwrap_or("");
40 let description = t.get("description").and_then(|d| d.as_str()).unwrap_or("");
41 let input_schema = t
42 .get("input_schema")
43 .cloned()
44 .unwrap_or(serde_json::Value::Object(Default::default()));
45 serde_json::json!({
46 "name": name,
47 "description": description,
48 "input_schema": input_schema,
49 })
50 .to_string()
51 })
52 .collect();
53 if !tool_lines.is_empty() {
54 sections.push(format!("<tools>\n{}\n</tools>", tool_lines.join("\n")));
55 }
56 }
57
58 sections.join("\n\n")
59}
60
61pub fn cursor_selected_images(req: &MessagesRequest) -> Vec<CursorSelectedImage> {
66 let mut images: Vec<CursorSelectedImage> = Vec::new();
67 let mut index: u32 = 0;
68
69 for message in &req.messages {
70 let blocks = message_blocks(message);
71 for block in &blocks {
72 collect_image_blocks(block, &mut index, &mut images);
73 }
74 }
75
76 images
77}
78
79fn render_system(req: &MessagesRequest) -> Option<String> {
84 let system_value = req.extra.get("system")?;
85 let text = match system_value {
86 serde_json::Value::String(s) => s.clone(),
87 serde_json::Value::Array(blocks) => {
88 let parts: Vec<&str> = blocks
89 .iter()
90 .filter_map(|b| {
91 if b.get("type").and_then(|t| t.as_str()) == Some("text") {
92 b.get("text").and_then(|t| t.as_str())
93 } else {
94 None
95 }
96 })
97 .filter(|line| !line.starts_with("x-anthropic-billing-header:"))
98 .collect();
99 if parts.is_empty() {
100 return None;
101 }
102 parts.join("\n\n")
103 }
104 _ => return None,
105 };
106 if text.is_empty() {
107 return None;
108 }
109 Some(text)
110}
111
112fn render_message_content(message: &crate::anthropic::schema::Message) -> Option<String> {
113 let blocks = message_blocks(message);
114 let rendered: Vec<String> = blocks.iter().filter_map(render_block).collect();
115 if rendered.is_empty() {
116 None
117 } else {
118 Some(rendered.join("\n\n"))
119 }
120}
121
122fn render_block(block: &serde_json::Value) -> Option<String> {
123 let block_type = block.get("type").and_then(|t| t.as_str())?;
124 match block_type {
125 "text" => block
126 .get("text")
127 .and_then(|t| t.as_str())
128 .map(|s| s.to_string()),
129 "thinking" => {
130 let text = block.get("thinking").and_then(|t| t.as_str()).unwrap_or("");
131 Some(format!("<thinking>\n{text}\n</thinking>"))
132 }
133 "image" => {
134 let source = block.get("source")?;
135 match source.get("type").and_then(|t| t.as_str()) {
136 Some("url") => {
137 let url = source.get("url").and_then(|u| u.as_str()).unwrap_or("");
138 Some(format!("[image: {url}]"))
139 }
140 _ => {
141 let media_type = source
142 .get("media_type")
143 .and_then(|m| m.as_str())
144 .unwrap_or("unknown");
145 let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
146 Some(format!(
147 "[image: {media_type}, {} base64 chars]",
148 data.len()
149 ))
150 }
151 }
152 }
153 "tool_use" => {
154 let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
155 let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
156 let input = block
157 .get("input")
158 .and_then(|i| serde_json::to_string(i).ok())
159 .unwrap_or_else(|| "{}".to_string());
160 Some(format!(
161 "<tool_use id=\"{id}\" name=\"{name}\">\n{input}\n</tool_use>"
162 ))
163 }
164 "tool_result" => {
165 let tool_use_id = block
166 .get("tool_use_id")
167 .and_then(|t| t.as_str())
168 .unwrap_or("");
169 let is_error = block
170 .get("is_error")
171 .and_then(|e| e.as_bool())
172 .unwrap_or(false);
173 let error_attr = if is_error { " is_error=\"true\"" } else { "" };
174 let content = render_tool_result_content(block);
175 Some(format!(
176 "<tool_result tool_use_id=\"{tool_use_id}\"{error_attr}>\n{content}\n</tool_result>"
177 ))
178 }
179 "server_tool_use" => {
180 let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
181 let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
182 let input = block
183 .get("input")
184 .and_then(|i| serde_json::to_string(i).ok())
185 .unwrap_or_else(|| "{}".to_string());
186 Some(format!(
187 "<server_tool_use id=\"{id}\" name=\"{name}\">\n{input}\n</server_tool_use>"
188 ))
189 }
190 "web_search_tool_result" => {
191 let tool_use_id = block
192 .get("tool_use_id")
193 .and_then(|t| t.as_str())
194 .unwrap_or("");
195 let content = block
196 .get("content")
197 .and_then(|c| serde_json::to_string(c).ok())
198 .unwrap_or_else(|| "{}".to_string());
199 Some(format!(
200 "<web_search_tool_result tool_use_id=\"{tool_use_id}\">\n{content}\n</web_search_tool_result>"
201 ))
202 }
203 _ => {
204 block
206 .get("text")
207 .and_then(|t| t.as_str())
208 .map(|s| s.to_string())
209 }
210 }
211}
212
213fn render_tool_result_content(block: &serde_json::Value) -> String {
214 let content = match block.get("content") {
215 Some(serde_json::Value::String(s)) => return s.clone(),
216 Some(serde_json::Value::Array(arr)) => arr.clone(),
217 _ => return String::new(),
218 };
219
220 let parts: Vec<String> = content
221 .iter()
222 .filter_map(render_tool_result_block)
223 .collect();
224 parts.join("\n\n")
225}
226
227fn render_tool_result_block(block: &serde_json::Value) -> Option<String> {
228 let block_type = block.get("type").and_then(|t| t.as_str())?;
229 match block_type {
230 "text" | "image" | "tool_use" | "tool_result" | "thinking" => render_block(block),
231 _ => {
232 let type_str = block_type.to_string();
233 Some(format!("[unsupported tool result block: {type_str}]"))
234 }
235 }
236}
237
238fn message_blocks(message: &crate::anthropic::schema::Message) -> Vec<serde_json::Value> {
239 match &message.content {
240 serde_json::Value::String(s) => {
241 vec![serde_json::json!({"type": "text", "text": s})]
242 }
243 serde_json::Value::Array(arr) => arr.clone(),
244 _ => Vec::new(),
245 }
246}
247
248fn collect_image_blocks(
249 block: &serde_json::Value,
250 index: &mut u32,
251 images: &mut Vec<CursorSelectedImage>,
252) {
253 if block.get("type").and_then(|t| t.as_str()) == Some("image") {
254 let source = match block.get("source") {
255 Some(s) => s,
256 None => return,
257 };
258 if source.get("type").and_then(|t| t.as_str()) != Some("base64") {
259 return;
260 }
261 let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
262 let media_type = source
263 .get("media_type")
264 .and_then(|m| m.as_str())
265 .unwrap_or("image/png");
266 let uuid = uuid::Uuid::new_v4().to_string();
267 *index += 1;
268 let extension = image_extension(media_type);
269 images.push(CursorSelectedImage {
270 data: data.to_string(),
271 uuid,
272 path: format!("claude-image-{index}.{extension}"),
273 mime_type: media_type.to_string(),
274 });
275 return;
276 }
277
278 if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
280 let content = match block.get("content") {
281 Some(serde_json::Value::Array(arr)) => arr.clone(),
282 _ => return,
283 };
284 for child in &content {
285 let child_type = child.get("type").and_then(|t| t.as_str());
286 matches!(
287 child_type,
288 Some("text" | "image" | "tool_use" | "tool_result" | "thinking")
289 );
290 collect_image_blocks(child, index, images);
291 }
292 }
293}
294
295fn image_extension(media_type: &str) -> &'static str {
296 match media_type {
297 "image/jpeg" => "jpg",
298 "image/png" => "png",
299 "image/gif" => "gif",
300 "image/webp" => "webp",
301 _ => "img",
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn renders_system_message() {
311 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
312 "model": "cursor:gpt-5.5",
313 "system": "be direct",
314 "messages": [{"role": "user", "content": "hello"}]
315 }))
316 .unwrap();
317 let rendered = render_cursor_prompt(&req);
318 assert!(rendered.contains("<system>"));
319 assert!(rendered.contains("be direct"));
320 assert!(rendered.contains("</system>"));
321 assert!(rendered.contains("<user>"));
322 assert!(rendered.contains("hello"));
323 assert!(rendered.contains("</user>"));
324 }
325
326 #[test]
327 fn renders_tools_section() {
328 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
329 "model": "cursor:gpt-5.5",
330 "messages": [{"role": "user", "content": "hi"}],
331 "tools": [{"name": "Read", "description": "read files", "input_schema": {"type": "object"}}]
332 }))
333 .unwrap();
334 let rendered = render_cursor_prompt(&req);
335 assert!(rendered.contains("<tools>"));
336 assert!(rendered.contains("Read"));
337 }
338
339 #[test]
340 fn filters_billing_headers_from_system() {
341 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
342 "model": "cursor:gpt-5.5",
343 "system": [
344 {"type": "text", "text": "keep this"},
345 {"type": "text", "text": "x-anthropic-billing-header: skip-me"}
346 ],
347 "messages": [{"role": "user", "content": "hello"}]
348 }))
349 .unwrap();
350 let rendered = render_cursor_prompt(&req);
351 assert!(rendered.contains("keep this"));
352 assert!(!rendered.contains("x-anthropic-billing-header"));
353 }
354
355 #[test]
356 fn collects_selected_images() {
357 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
358 "model": "cursor:gpt-5.5",
359 "messages": [{
360 "role": "user",
361 "content": [
362 {"type": "text", "text": "hi"},
363 {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AAAA"}}
364 ]
365 }]
366 }))
367 .unwrap();
368 let images = cursor_selected_images(&req);
369 assert_eq!(images.len(), 1);
370 assert_eq!(images[0].mime_type, "image/png");
371 assert_eq!(images[0].data, "AAAA");
372 }
373
374 #[test]
375 fn skips_url_images_in_selected() {
376 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
377 "model": "cursor:gpt-5.5",
378 "messages": [{
379 "role": "user",
380 "content": [
381 {"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}}
382 ]
383 }]
384 }))
385 .unwrap();
386 let images = cursor_selected_images(&req);
387 assert_eq!(images.len(), 0);
388 }
389
390 #[test]
391 fn renders_url_image_placeholder() {
392 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
393 "model": "cursor:gpt-5.5",
394 "messages": [{
395 "role": "user",
396 "content": [
397 {"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}}
398 ]
399 }]
400 }))
401 .unwrap();
402 let rendered = render_cursor_prompt(&req);
403 assert!(rendered.contains("[image: https://example.com/img.png]"));
404 }
405
406 #[test]
407 fn renders_thinking_blocks() {
408 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
409 "model": "cursor:gpt-5.5",
410 "messages": [{"role": "assistant", "content": [
411 {"type": "thinking", "thinking": "let me think..."},
412 {"type": "text", "text": "done"}
413 ]}]
414 }))
415 .unwrap();
416 let rendered = render_cursor_prompt(&req);
417 assert!(rendered.contains("<thinking>"));
418 assert!(rendered.contains("let me think..."));
419 assert!(rendered.contains("done"));
420 }
421
422 #[test]
423 fn renders_tool_use_blocks() {
424 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
425 "model": "cursor:gpt-5.5",
426 "messages": [{"role": "assistant", "content": [
427 {"type": "tool_use", "id": "tu1", "name": "Read", "input": {"path": "/tmp"}}
428 ]}]
429 }))
430 .unwrap();
431 let rendered = render_cursor_prompt(&req);
432 assert!(rendered.contains("<tool_use id=\"tu1\" name=\"Read\">"));
433 }
434
435 #[test]
436 fn renders_tool_result_with_content_blocks() {
437 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
438 "model": "cursor:gpt-5.5",
439 "messages": [{"role": "user", "content": [
440 {"type": "tool_result", "tool_use_id": "tu1", "content": [
441 {"type": "text", "text": "file contents"}
442 ]}
443 ]}]
444 }))
445 .unwrap();
446 let rendered = render_cursor_prompt(&req);
447 assert!(rendered.contains("<tool_result tool_use_id=\"tu1\">"));
448 assert!(rendered.contains("file contents"));
449 }
450
451 #[test]
452 fn handles_unsupported_block_types() {
453 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
454 "model": "cursor:gpt-5.5",
455 "messages": [{"role": "user", "content": [
456 {"type": "unknown_block", "text": "some fallback text"}
457 ]}]
458 }))
459 .unwrap();
460 let rendered = render_cursor_prompt(&req);
461 assert!(rendered.contains("some fallback text"));
463 }
464
465 #[test]
466 fn empty_messages_renders_emptyish() {
467 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
468 "model": "cursor:gpt-5.5",
469 "messages": [{"role": "user", "content": ""}]
470 }))
471 .unwrap();
472 let rendered = render_cursor_prompt(&req);
473 assert!(rendered.is_empty() || !rendered.is_empty());
474 }
475
476 #[test]
477 fn tool_result_with_nested_image() {
478 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
479 "model": "cursor:gpt-5.5",
480 "messages": [{"role": "user", "content": [
481 {"type": "tool_result", "tool_use_id": "tu1", "content": [
482 {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "BBBB"}}
483 ]}
484 ]}]
485 }))
486 .unwrap();
487 let images = cursor_selected_images(&req);
488 assert_eq!(images.len(), 1);
489 assert_eq!(images[0].mime_type, "image/jpeg");
490 assert_eq!(images[0].data, "BBBB");
491 }
492
493 #[test]
494 fn renders_server_tool_use() {
495 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
496 "model": "cursor:gpt-5.5",
497 "messages": [{"role": "assistant", "content": [
498 {"type": "server_tool_use", "id": "st1", "name": "WebSearch", "input": {"query": "rust"}}
499 ]}]
500 }))
501 .unwrap();
502 let rendered = render_cursor_prompt(&req);
503 assert!(rendered.contains("<server_tool_use id=\"st1\" name=\"WebSearch\">"));
504 }
505
506 #[test]
507 fn renders_web_search_tool_result() {
508 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
509 "model": "cursor:gpt-5.5",
510 "messages": [{"role": "user", "content": [
511 {"type": "web_search_tool_result", "tool_use_id": "ws1", "content": {"results": []}}
512 ]}]
513 }))
514 .unwrap();
515 let rendered = render_cursor_prompt(&req);
516 assert!(rendered.contains("<web_search_tool_result tool_use_id=\"ws1\">"));
517 }
518}