1use std::io::{Read, Write};
17use std::net::{TcpStream, ToSocketAddrs};
18use std::time::{Duration, Instant};
19
20use regex::Regex;
21use serde_json::{json, Value};
22
23use crate::formal_ai_files::{describe_file_call, file_tool, normalize_file_args, FileArgs};
24use crate::formal_ai_policy::{EXIT_BUDGET, EXIT_PROTOCOL, EXIT_UNAVAILABLE};
25
26pub const API_PREFIX: &str = "/api/openai/v1";
27pub const BASH_NAMES: [&str; 3] = ["bash", "shell", "run_command"];
29pub const TOOL_NAMES: [&str; 6] = ["bash", "shell", "run_command", "read", "edit", "write"];
31const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
32const POLL: Duration = Duration::from_millis(100);
33
34pub fn bash_tool() -> Value {
36 json!({
37 "type": "function",
38 "function": {
39 "name": "bash",
40 "description": "Run one command in the working directory. Pipes, redirects, globs and variables are not supported.",
41 "parameters": {
42 "type": "object",
43 "properties": { "command": { "type": "string" } },
44 "required": ["command"],
45 },
46 },
47 })
48}
49
50pub fn offered_tools(effects: &str) -> Vec<Value> {
53 let names: &[&str] = if effects == "workspace-write" {
54 &["read", "edit", "write"]
55 } else {
56 &["read"]
57 };
58 std::iter::once(bash_tool())
59 .chain(names.iter().map(|name| file_tool(name)))
60 .collect()
61}
62
63#[derive(Debug, Clone, PartialEq)]
65pub struct ClientError {
66 pub message: String,
67 pub exit_code: i32,
68 pub cancelled: bool,
69 pub details: Option<Value>,
72}
73
74impl ClientError {
75 pub fn protocol(message: impl Into<String>) -> Self {
76 Self::with_code(message, EXIT_PROTOCOL)
77 }
78
79 pub fn with_code(message: impl Into<String>, exit_code: i32) -> Self {
80 Self {
81 message: message.into(),
82 exit_code,
83 cancelled: false,
84 details: None,
85 }
86 }
87
88 pub fn with_details(mut self, details: Value) -> Self {
89 self.details = Some(details);
90 self
91 }
92}
93
94const PREVIEW_CHARS: usize = 500;
95const MESSAGE_CHARS: usize = 300;
96const REDACTED: &str = "[REDACTED]";
97
98fn sanitizers() -> &'static [(Regex, &'static str); 4] {
99 static PATTERNS: std::sync::OnceLock<[(Regex, &'static str); 4]> = std::sync::OnceLock::new();
100 PATTERNS.get_or_init(|| {
101 let compile = |pattern: &str| Regex::new(pattern).expect("valid sanitizer pattern");
102 [
103 (
104 compile(r#"(?i)((?:proxy-)?authorization["']?\s*[:=]\s*["']?)[^"'\r\n,;}]*"#),
105 "${1}[REDACTED]",
106 ),
107 (
108 compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{4,}"),
109 "${1} [REDACTED]",
110 ),
111 (
112 compile(
113 r#"(?i)((?:api[_-]?key|access[_-]?token|token|secret|password|passwd)["']?\s*[:=]\s*["']?)[^"'\s,;}]+"#,
114 ),
115 "${1}[REDACTED]",
116 ),
117 (compile(r"[\x00-\x1f\x7f]+"), " "),
118 ]
119 })
120}
121
122pub fn sanitize_diagnostic(text: &str, limit: usize, secrets: &[&str]) -> String {
126 let mut clean = text.to_string();
127 for secret in secrets.iter().filter(|secret| !secret.is_empty()) {
128 clean = clean.replace(secret, REDACTED);
129 }
130 for (pattern, replacement) in sanitizers() {
131 clean = pattern.replace_all(&clean, *replacement).to_string();
132 }
133 let clean = clean.trim();
134 let count = clean.chars().count();
135 if count > limit {
136 format!(
137 "{}… [{} more chars]",
138 clean.chars().take(limit).collect::<String>(),
139 count - limit
140 )
141 } else {
142 clean.to_string()
143 }
144}
145
146pub fn http_error_details(status: u16, text: &str, secrets: &[&str]) -> Value {
149 let body: Value = serde_json::from_str(text).unwrap_or(Value::Null);
150 let error = body.get("error").unwrap_or(&Value::Null);
151 let (kind, message) = if error.is_object() {
152 (
153 error.get("type").and_then(Value::as_str),
154 error.get("message").and_then(Value::as_str),
155 )
156 } else if let Some(text) = error.as_str() {
157 (None, Some(text))
158 } else {
159 (None, body.get("message").and_then(Value::as_str))
160 };
161 json!({
162 "httpStatus": status,
163 "errorType": kind.map(|kind| sanitize_diagnostic(kind, 100, secrets)),
164 "errorMessage": message.map(|message| sanitize_diagnostic(message, MESSAGE_CHARS, secrets)),
165 "bodyPreview": sanitize_diagnostic(text, PREVIEW_CHARS, secrets),
166 "bodyChars": text.encode_utf16().count(),
167 })
168}
169
170pub fn http_error_message(details: &Value) -> String {
172 let text = |key: &str| details[key].as_str().filter(|text| !text.is_empty());
173 let cause = match (text("errorType"), text("errorMessage")) {
174 (Some(kind), Some(message)) => format!("{}: {}", kind, message),
175 (None, Some(message)) => message.to_string(),
176 _ => {
177 let preview: String = text("bodyPreview")
178 .unwrap_or("")
179 .chars()
180 .take(MESSAGE_CHARS)
181 .collect();
182 if preview.is_empty() {
183 "(empty body)".to_string()
184 } else {
185 preview
186 }
187 }
188 };
189 format!(
190 "Formal AI returned HTTP {}: {}",
191 details["httpStatus"], cause
192 )
193}
194
195pub const GAP_SCHEMAS: [(&str, &[&str]); 2] = [
197 (
198 "workspace-edit-capability-gap/v1",
199 &["EditToolUnavailable", "ReadToolUnavailable"],
200 ),
201 (
202 "workspace-change-verification-gap/v1",
203 &["VerificationRunToolUnavailable"],
204 ),
205];
206const CAPABILITIES: [&str; 6] = ["read", "edit", "multi_edit", "write", "run", "bash"];
207
208pub fn recognize_capability_gap(content: &str, messages: &[Value]) -> Option<Value> {
213 let text = content.trim();
214 if !text.starts_with('{') || !text.ends_with('}') {
215 return None;
216 }
217 let gap: Value = serde_json::from_str(text).ok()?;
218 let schema = gap.get("schema").and_then(Value::as_str)?;
219 let (_, reasons) = GAP_SCHEMAS.iter().find(|(name, _)| *name == schema)?;
220 let reason = gap.get("reason").and_then(Value::as_str)?;
221 let target = gap.get("target").and_then(Value::as_str)?;
222 let missing = match gap.get("missingCapabilities") {
223 None => Vec::new(),
224 Some(value) => value.as_array()?.clone(),
225 };
226 let known = missing
227 .iter()
228 .all(|c| c.as_str().is_some_and(|c| CAPABILITIES.contains(&c)));
229 if !reasons.contains(&reason)
230 || gap.get("complete") != Some(&Value::Bool(false))
231 || gap.get("verified") != Some(&Value::Bool(false))
232 || target.trim().is_empty()
233 || !known
234 {
235 return None;
236 }
237 let echoed = messages.iter().any(|m| {
238 m["role"] == "tool"
239 && match &m["content"] {
240 Value::String(content) => content.contains(text),
241 Value::Null => false,
242 other => other.to_string().contains(text),
243 }
244 });
245 if echoed {
246 return None;
247 }
248 Some(json!({
249 "schema": schema,
250 "target": target,
251 "reason": reason,
252 "missingCapabilities": missing,
253 "complete": false,
254 "verified": false,
255 }))
256}
257
258#[derive(Debug, Clone, PartialEq, Default)]
260pub struct ToolCall {
261 pub id: Option<String>,
262 pub name: String,
264 pub tool: String,
266 pub command: String,
268 pub arguments: String,
270 pub args: Option<FileArgs>,
272}
273
274#[derive(Debug, Clone, PartialEq, Default)]
276pub struct Completion {
277 pub content: String,
278 pub finish_reason: Option<String>,
279 pub tool_calls: Vec<ToolCall>,
280 pub handler: Option<String>,
281}
282
283fn value_text(value: &Value) -> String {
284 match value {
285 Value::String(text) => text.clone(),
286 other => other.to_string(),
287 }
288}
289
290pub fn parse_completion(body: &Value) -> Result<Completion, ClientError> {
292 let choice = body
293 .get("choices")
294 .and_then(Value::as_array)
295 .and_then(|choices| choices.first())
296 .filter(|choice| choice.get("message").is_some_and(Value::is_object))
297 .ok_or_else(|| ClientError::protocol("Formal AI response has no choices[0].message"))?;
298 let message = &choice["message"];
299 let handler = message
300 .get("thinking_steps")
301 .and_then(Value::as_array)
302 .and_then(|steps| {
303 steps
304 .iter()
305 .find(|s| s.get("step").and_then(Value::as_str) == Some("dispatch_handler"))
306 })
307 .map(|step| value_text(step.get("detail").unwrap_or(&Value::Null)));
308 let mut tool_calls = Vec::new();
309 if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) {
310 for call in calls {
311 let function = call.get("function");
312 let name = function
313 .and_then(|f| f.get("name"))
314 .and_then(Value::as_str)
315 .unwrap_or("");
316 if !TOOL_NAMES.contains(&name) {
317 let shown = if name.is_empty() { "undefined" } else { name };
318 return Err(ClientError::protocol(format!(
319 "Formal AI called an unsupported tool \"{}\"",
320 shown
321 )));
322 }
323 let raw = function
324 .and_then(|f| f.get("arguments"))
325 .and_then(Value::as_str)
326 .filter(|text| !text.is_empty())
327 .unwrap_or("{}");
328 let args: Value = serde_json::from_str(raw).map_err(|_| {
329 ClientError::protocol(format!("Formal AI sent non-JSON arguments for {}", name))
330 })?;
331 let id = call
332 .get("id")
333 .and_then(Value::as_str)
334 .filter(|id| !id.is_empty())
335 .map(str::to_string);
336 if !BASH_NAMES.contains(&name) {
337 let normalized = normalize_file_args(name, &args).map_err(|problem| {
338 ClientError::protocol(format!("Formal AI called {}", problem))
339 })?;
340 tool_calls.push(ToolCall {
341 id,
342 name: name.to_string(),
343 tool: name.to_string(),
344 command: describe_file_call(name, &normalized),
345 arguments: raw.to_string(),
346 args: Some(normalized),
347 });
348 continue;
349 }
350 let command = args
351 .get("command")
352 .filter(|v| !v.is_null())
353 .or_else(|| args.get("cmd"))
354 .and_then(Value::as_str)
355 .filter(|text| !text.trim().is_empty())
356 .ok_or_else(|| {
357 ClientError::protocol(format!("Formal AI called {} without a command", name))
358 })?;
359 tool_calls.push(ToolCall {
360 id,
361 name: name.to_string(),
362 tool: "bash".to_string(),
363 command: command.to_string(),
364 ..ToolCall::default()
365 });
366 }
367 }
368 Ok(Completion {
369 content: message
370 .get("content")
371 .and_then(Value::as_str)
372 .unwrap_or("")
373 .to_string(),
374 finish_reason: choice
375 .get("finish_reason")
376 .and_then(Value::as_str)
377 .filter(|reason| !reason.is_empty())
378 .map(str::to_string),
379 tool_calls,
380 handler,
381 })
382}
383
384pub trait CompletionClient {
386 fn complete(
388 &mut self,
389 messages: &[Value],
390 cancel: &dyn Fn() -> bool,
391 ) -> Result<Completion, ClientError>;
392 fn requests(&self) -> u64;
394}
395
396#[derive(Debug, Clone, PartialEq)]
398pub enum HttpError {
399 Timeout,
400 Cancelled,
401 Io(String),
402}
403
404pub fn parse_http_url(url: &str) -> Result<(String, u16, String), String> {
406 let rest = url
407 .strip_prefix("http://")
408 .ok_or_else(|| format!("not an http:// URL: {}", url))?;
409 let (authority, prefix) = match rest.find('/') {
410 Some(index) => (&rest[..index], rest[index..].trim_end_matches('/')),
411 None => (rest, ""),
412 };
413 let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
414 let end = stripped
415 .find(']')
416 .ok_or_else(|| format!("invalid host in {}", url))?;
417 let port = stripped[end + 1..].strip_prefix(':').unwrap_or("80");
418 (stripped[..end].to_string(), port)
419 } else {
420 match authority.rsplit_once(':') {
421 Some((host, port)) => (host.to_string(), port),
422 None => (authority.to_string(), "80"),
423 }
424 };
425 if host.is_empty() {
426 return Err(format!("missing host in {}", url));
427 }
428 let port = port
429 .parse::<u16>()
430 .map_err(|_| format!("invalid port in {}", url))?;
431 Ok((host, port, prefix.to_string()))
432}
433
434fn header_end(buf: &[u8]) -> Option<usize> {
435 buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4)
436}
437
438fn decode_chunked(mut body: &[u8]) -> Option<Vec<u8>> {
440 let mut out = Vec::new();
441 loop {
442 let line_end = body.windows(2).position(|w| w == b"\r\n")?;
443 let size_text = String::from_utf8_lossy(&body[..line_end]);
444 let size_text = size_text.split(';').next().unwrap_or("").trim();
445 let size = usize::from_str_radix(size_text, 16).ok()?;
446 body = &body[line_end + 2..];
447 if size == 0 {
448 return Some(out);
449 }
450 if body.len() < size + 2 {
451 return None;
452 }
453 out.extend_from_slice(&body[..size]);
454 body = &body[size + 2..];
455 }
456}
457
458struct Head {
459 status: u16,
460 content_length: Option<usize>,
461 chunked: bool,
462}
463
464fn parse_head(head: &[u8]) -> Result<Head, HttpError> {
465 let text = String::from_utf8_lossy(head);
466 let mut lines = text.split("\r\n");
467 let status = lines
468 .next()
469 .and_then(|line| line.split_whitespace().nth(1))
470 .and_then(|code| code.parse::<u16>().ok())
471 .ok_or_else(|| HttpError::Io("malformed HTTP response".to_string()))?;
472 let mut content_length = None;
473 let mut chunked = false;
474 for line in lines {
475 if let Some((name, value)) = line.split_once(':') {
476 let name = name.trim().to_ascii_lowercase();
477 let value = value.trim();
478 if name == "content-length" {
479 content_length = value.parse::<usize>().ok();
480 } else if name == "transfer-encoding" && value.to_ascii_lowercase().contains("chunked")
481 {
482 chunked = true;
483 }
484 }
485 }
486 Ok(Head {
487 status,
488 content_length,
489 chunked,
490 })
491}
492
493fn complete_body(buf: &[u8], closed: bool) -> Result<Option<(u16, Vec<u8>)>, HttpError> {
495 let Some(end) = header_end(buf) else {
496 return if closed {
497 Err(HttpError::Io(
498 "connection closed before a response".to_string(),
499 ))
500 } else {
501 Ok(None)
502 };
503 };
504 let head = parse_head(&buf[..end])?;
505 let body = &buf[end..];
506 if head.chunked {
507 return match decode_chunked(body) {
508 Some(decoded) => Ok(Some((head.status, decoded))),
509 None if closed => Err(HttpError::Io("truncated chunked response".to_string())),
510 None => Ok(None),
511 };
512 }
513 match head.content_length {
514 Some(length) if body.len() >= length => Ok(Some((head.status, body[..length].to_vec()))),
515 Some(_) if closed => Err(HttpError::Io("truncated response".to_string())),
516 Some(_) => Ok(None),
517 None if closed => Ok(Some((head.status, body.to_vec()))),
518 None => Ok(None),
519 }
520}
521
522fn io_error(error: std::io::Error) -> HttpError {
523 HttpError::Io(error.to_string())
524}
525
526pub fn http_request(
528 base_url: &str,
529 method: &str,
530 path: &str,
531 body: Option<&str>,
532 timeout: Duration,
533 cancel: &dyn Fn() -> bool,
534) -> Result<(u16, String), HttpError> {
535 let (host, port, prefix) = parse_http_url(base_url).map_err(HttpError::Io)?;
536 let deadline = Instant::now() + timeout;
537 let remaining = || deadline.saturating_duration_since(Instant::now());
538 let mut last = HttpError::Io(format!("cannot resolve {}", host));
539 let mut stream = None;
540 for addr in (host.as_str(), port).to_socket_addrs().map_err(io_error)? {
541 if remaining().is_zero() {
542 return Err(HttpError::Timeout);
543 }
544 match TcpStream::connect_timeout(&addr, remaining()) {
545 Ok(connected) => {
546 stream = Some(connected);
547 break;
548 }
549 Err(error) if error.kind() == std::io::ErrorKind::TimedOut => {
550 last = HttpError::Timeout;
551 }
552 Err(error) => last = io_error(error),
553 }
554 }
555 let mut stream = stream.ok_or(last)?;
556 let mut request = format!(
557 "{} {}{} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\nAccept: application/json\r\n",
558 method, prefix, path, host, port
559 );
560 if let Some(body) = body {
561 request.push_str(&format!(
562 "Content-Type: application/json\r\nContent-Length: {}\r\n",
563 body.len()
564 ));
565 }
566 request.push_str("\r\n");
567 request.push_str(body.unwrap_or(""));
568 stream
569 .set_write_timeout(Some(remaining().max(Duration::from_millis(1))))
570 .map_err(io_error)?;
571 stream.write_all(request.as_bytes()).map_err(io_error)?;
572 stream.set_read_timeout(Some(POLL)).map_err(io_error)?;
573 let mut buf = Vec::new();
574 let mut chunk = [0u8; 16 * 1024];
575 loop {
576 if cancel() {
577 return Err(HttpError::Cancelled);
578 }
579 if remaining().is_zero() {
580 return Err(HttpError::Timeout);
581 }
582 match stream.read(&mut chunk) {
583 Ok(0) => {
584 let (status, body) = complete_body(&buf, true)?.unwrap_or_default();
585 return Ok((status, String::from_utf8_lossy(&body).to_string()));
586 }
587 Ok(n) => {
588 buf.extend_from_slice(&chunk[..n]);
589 if buf.len() > MAX_RESPONSE_BYTES {
590 return Err(HttpError::Io("response too large".to_string()));
591 }
592 if let Some((status, body)) = complete_body(&buf, false)? {
593 return Ok((status, String::from_utf8_lossy(&body).to_string()));
594 }
595 }
596 Err(error)
597 if matches!(
598 error.kind(),
599 std::io::ErrorKind::WouldBlock
600 | std::io::ErrorKind::TimedOut
601 | std::io::ErrorKind::Interrupted
602 ) => {}
603 Err(error) => return Err(io_error(error)),
604 }
605 }
606}
607
608pub struct HttpClient {
610 pub base_url: String,
611 pub max_retries: u64,
612 pub deadline: Instant,
613 pub requests: u64,
614 pub tools: Vec<Value>,
616}
617
618impl HttpClient {
619 pub fn new(base_url: &str, max_retries: u64, deadline: Instant) -> Self {
621 Self {
622 base_url: base_url.to_string(),
623 max_retries,
624 deadline,
625 requests: 0,
626 tools: offered_tools("read-only"),
627 }
628 }
629
630 pub fn with_tools(mut self, tools: Vec<Value>) -> Self {
632 self.tools = tools;
633 self
634 }
635}
636
637fn budget_error() -> ClientError {
638 ClientError::with_code(
639 "time budget exhausted while waiting for Formal AI",
640 EXIT_BUDGET,
641 )
642}
643
644impl CompletionClient for HttpClient {
645 fn complete(
646 &mut self,
647 messages: &[Value],
648 cancel: &dyn Fn() -> bool,
649 ) -> Result<Completion, ClientError> {
650 let body = json!({
651 "model": "formal-ai",
652 "messages": messages,
653 "tools": self.tools,
654 })
655 .to_string();
656 let mut last_error = ClientError::protocol("Formal AI was not asked");
657 let mut attempts: u64 = 0;
658 for _ in 0..=self.max_retries {
659 let remaining = self.deadline.saturating_duration_since(Instant::now());
660 if remaining.is_zero() {
661 return Err(budget_error());
662 }
663 self.requests += 1;
664 attempts += 1;
665 let path = "/chat/completions";
666 let full = format!("{}{}", API_PREFIX, path);
667 match http_request(
668 &self.base_url,
669 "POST",
670 &full,
671 Some(&body),
672 remaining,
673 cancel,
674 ) {
675 Ok((status, text)) => {
676 if !(200..300).contains(&status) {
677 let mut details = http_error_details(status, &text, &[]);
678 details["attempts"] = json!(attempts);
679 let error = ClientError::protocol(http_error_message(&details))
680 .with_details(details);
681 if status >= 500 {
684 last_error = error;
685 continue;
686 }
687 return Err(error);
688 }
689 let parsed: Value = serde_json::from_str(&text).map_err(|_| {
690 ClientError::protocol("Formal AI returned a non-JSON response")
691 })?;
692 return parse_completion(&parsed);
693 }
694 Err(HttpError::Cancelled) => {
695 let mut error = ClientError::protocol("cancelled");
696 error.cancelled = true;
697 return Err(error);
698 }
699 Err(HttpError::Timeout) => return Err(budget_error()),
700 Err(HttpError::Io(message)) => {
701 let cause = sanitize_diagnostic(&message, MESSAGE_CHARS, &[]);
702 last_error = ClientError::with_code(
703 format!("Formal AI request failed: {}", cause),
704 EXIT_UNAVAILABLE,
705 )
706 .with_details(json!({ "cause": cause, "attempts": attempts }));
707 }
708 }
709 }
710 let retries = attempts.saturating_sub(1);
711 if retries > 0 {
712 let plural = if retries == 1 { "y" } else { "ies" };
713 last_error.message =
714 format!("{} (after {} retr{})", last_error.message, retries, plural);
715 }
716 if let Some(details) = last_error.details.as_mut() {
717 details["attempts"] = json!(attempts);
718 }
719 Err(last_error)
720 }
721
722 fn requests(&self) -> u64 {
723 self.requests
724 }
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730
731 #[test]
732 fn parses_tool_calls_and_handler() {
733 let body = json!({"choices": [{"finish_reason": "tool_calls", "message": {
734 "content": "",
735 "tool_calls": [{"id": "c1", "function": {"name": "bash", "arguments": "{\"command\":\"ls\"}"}}],
736 "thinking_steps": [{"step": "dispatch_handler", "detail": "list_files"}]
737 }}]});
738 let completion = parse_completion(&body).unwrap();
739 assert_eq!(completion.handler.as_deref(), Some("list_files"));
740 assert_eq!(completion.finish_reason.as_deref(), Some("tool_calls"));
741 assert_eq!(
742 completion.tool_calls,
743 vec![ToolCall {
744 id: Some("c1".to_string()),
745 name: "bash".to_string(),
746 tool: "bash".to_string(),
747 command: "ls".to_string(),
748 ..ToolCall::default()
749 }]
750 );
751 }
752
753 #[test]
754 fn rejects_malformed_completions() {
755 let cases = [
756 (json!({}), "Formal AI response has no choices[0].message"),
757 (
758 json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "python", "arguments": "{}"}}]}}]}),
759 "Formal AI called an unsupported tool \"python\"",
760 ),
761 (
762 json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "bash", "arguments": "ls"}}]}}]}),
763 "Formal AI sent non-JSON arguments for bash",
764 ),
765 (
766 json!({"choices": [{"message": {"tool_calls": [{"function": {"name": "shell", "arguments": "{\"cmd\":\" \"}"}}]}}]}),
767 "Formal AI called shell without a command",
768 ),
769 ];
770 for (body, message) in cases {
771 let error = parse_completion(&body).unwrap_err();
772 assert_eq!(error.message, message);
773 assert_eq!(error.exit_code, EXIT_PROTOCOL);
774 }
775 }
776
777 #[test]
778 fn accepts_cmd_arguments() {
779 let body = json!({"choices": [{"message": {"content": "ok",
780 "tool_calls": [{"function": {"name": "run_command", "arguments": "{\"cmd\":\"pwd\"}"}}]}}]});
781 let completion = parse_completion(&body).unwrap();
782 assert_eq!(completion.tool_calls[0].command, "pwd");
783 assert_eq!(completion.tool_calls[0].id, None);
784 assert_eq!(completion.content, "ok");
785 }
786
787 #[test]
788 fn parses_http_urls() {
789 assert_eq!(
790 parse_http_url("http://127.0.0.1:8080").unwrap(),
791 ("127.0.0.1".to_string(), 8080, String::new())
792 );
793 assert_eq!(
794 parse_http_url("http://localhost/base/").unwrap(),
795 ("localhost".to_string(), 80, "/base".to_string())
796 );
797 assert_eq!(
798 parse_http_url("http://[::1]:9/").unwrap(),
799 ("::1".to_string(), 9, String::new())
800 );
801 assert!(parse_http_url("https://example.com").is_err());
802 assert!(parse_http_url("http://:80").is_err());
803 }
804
805 #[test]
806 fn decodes_content_length_and_chunked_bodies() {
807 let fixed = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nokEXTRA";
808 assert_eq!(
809 complete_body(fixed, false).unwrap(),
810 Some((200, b"ok".to_vec()))
811 );
812 let partial = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nok";
813 assert_eq!(complete_body(partial, false).unwrap(), None);
814 assert!(complete_body(partial, true).is_err());
815 let chunked = b"HTTP/1.1 503 Busy\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n";
816 assert_eq!(
817 complete_body(chunked, false).unwrap(),
818 Some((503, b"abcde".to_vec()))
819 );
820 let open = b"HTTP/1.1 200 OK\r\n\r\nbody";
821 assert_eq!(complete_body(open, false).unwrap(), None);
822 assert_eq!(
823 complete_body(open, true).unwrap(),
824 Some((200, b"body".to_vec()))
825 );
826 }
827
828 #[test]
829 fn unreachable_server_is_unavailable_after_retries() {
830 let mut client = HttpClient::new(
832 "http://127.0.0.1:9",
833 1,
834 Instant::now() + Duration::from_secs(10),
835 );
836 let error = client.complete(&[], &|| false).unwrap_err();
837 assert_eq!(client.requests(), 2);
838 if error.exit_code == EXIT_UNAVAILABLE {
839 assert!(error.message.starts_with("Formal AI request failed: "));
840 assert!(error.message.ends_with(" (after 1 retry)"));
841 } else {
842 assert_eq!(error.exit_code, EXIT_BUDGET);
843 }
844 }
845}
846
847#[cfg(test)]
848#[path = "formal_ai_client_cases.rs"]
849mod formal_ai_client_cases;