Skip to main content

falsegreen_agent/
inference.rs

1use std::fmt;
2use std::io::Read;
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7use thiserror::Error;
8
9const OPENAI_RESPONSE_MAX_BYTES: usize = 2 * 1024 * 1024;
10const OPENAI_RESPONSE_MAX_DEPTH: usize = 64;
11const OPENAI_RESPONSE_MAX_NODES: usize = 100_000;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ModelCapabilities {
15    pub identifier: String,
16    pub repository: Option<String>,
17    pub artifact: Option<String>,
18    pub artifact_sha256: Option<String>,
19    pub quantization: Option<String>,
20    pub chat_template: Option<String>,
21    pub context_window_tokens: Option<u32>,
22    pub native_tools: bool,
23    #[serde(default)]
24    pub qualification: Option<ModelQualification>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct ModelQualification {
29    pub profile_name: String,
30    pub revision: String,
31    pub expected_artifact: String,
32    pub runtime: String,
33    pub runtime_version: String,
34    pub runtime_commit: String,
35    pub accelerator: String,
36    pub architecture: String,
37    pub mtp_enabled: bool,
38    pub artifact_validated: bool,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct ProviderCapabilities {
43    pub provider: String,
44    pub wire_protocol: String,
45    pub model: ModelCapabilities,
46    pub streaming: bool,
47    #[serde(default)]
48    pub runtime_provenance: Option<RuntimeProvenance>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct RuntimeProvenance {
53    pub runtime: String,
54    pub version: String,
55    pub commit: String,
56    pub distribution: String,
57    pub artifact: String,
58    #[serde(default)]
59    pub artifact_sha256: Option<String>,
60    pub platform: String,
61    pub backend: String,
62    #[serde(default)]
63    pub accelerator: Option<String>,
64    #[serde(default)]
65    pub driver: Option<String>,
66    #[serde(default)]
67    pub launch_arguments: Vec<String>,
68    #[serde(default)]
69    pub context_tokens: Option<u32>,
70    #[serde(default)]
71    pub chat_template: Option<String>,
72    #[serde(default)]
73    pub mtp_enabled: Option<bool>,
74    pub qualified_stack: bool,
75    pub qualification_note: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct InferenceRequest {
80    pub context: String,
81    #[serde(default)]
82    pub messages: Vec<Value>,
83    pub max_output_tokens: u32,
84    pub temperature: f32,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct ToolCall {
90    #[serde(default)]
91    pub id: Option<String>,
92    pub name: String,
93    pub arguments: Value,
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
98pub enum ModelAction {
99    Tool {
100        #[serde(default)]
101        tool_call_id: Option<String>,
102        tool: String,
103        arguments: Value,
104    },
105    ToolBatch {
106        calls: Vec<ToolCall>,
107    },
108    CandidateReady {
109        summary: String,
110    },
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct ModelResponse {
116    pub content: Option<String>,
117    pub tool_calls: Vec<ToolCall>,
118    pub usage: Option<Value>,
119}
120
121#[derive(Error)]
122pub enum InferenceError {
123    #[error("inference request timed out")]
124    Timeout,
125    #[error("inference server is unavailable")]
126    Unavailable(String),
127    #[error("inference response exceeded the size limit")]
128    ResponseTooLarge,
129    #[error("inference response contained invalid UTF-8")]
130    InvalidUtf8,
131    // The response body is retained only for the in-process diagnostic path.
132    // It is intentionally excluded from Display so callers cannot accidentally
133    // persist provider secrets, prompts, or model output in durable events.
134    #[error("inference server returned HTTP {status}")]
135    HttpStatus { status: u16, body: String },
136    #[error("malformed inference response")]
137    Malformed(String),
138}
139
140impl fmt::Debug for InferenceError {
141    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142        let mut debug = formatter.debug_struct("InferenceError");
143        match self {
144            Self::Timeout => debug.field("kind", &"timeout"),
145            Self::HttpStatus { status, body } => debug
146                .field("kind", &"http_status")
147                .field("status", status)
148                .field("response_bytes", &body.len()),
149            Self::Unavailable(_) => debug.field("kind", &"transport"),
150            Self::ResponseTooLarge => debug.field("kind", &"response_too_large"),
151            Self::InvalidUtf8 => debug.field("kind", &"invalid_utf8"),
152            Self::Malformed(_) => debug.field("kind", &"malformed_response"),
153        };
154        debug.finish()
155    }
156}
157
158impl InferenceError {
159    /// Return bounded, provider-neutral metadata suitable for durable audit
160    /// events. No diagnostic detail or response body is included.
161    #[must_use]
162    pub fn audit_metadata(&self) -> Value {
163        match self {
164            Self::Timeout => json!({"reason_code": "provider_timeout"}),
165            Self::HttpStatus { status, body } => json!({
166                "reason_code": "provider_http_status",
167                "http_status": status,
168                "response_bytes": body.len(),
169            }),
170            Self::Unavailable(_) => json!({"reason_code": "provider_transport"}),
171            Self::ResponseTooLarge => json!({"reason_code": "provider_response_too_large"}),
172            Self::InvalidUtf8 => json!({"reason_code": "provider_invalid_utf8"}),
173            Self::Malformed(_) => json!({"reason_code": "provider_malformed_json"}),
174        }
175    }
176}
177
178pub trait InferenceProvider {
179    fn capabilities(&self) -> ProviderCapabilities;
180    fn complete(&mut self, request: &InferenceRequest) -> Result<ModelResponse, InferenceError>;
181}
182
183#[derive(Debug, Clone)]
184pub struct OpenAiCompatibleProvider {
185    endpoint: String,
186    model: ModelCapabilities,
187    api_key: Option<String>,
188    timeout: Duration,
189    runtime_provenance: Option<RuntimeProvenance>,
190    tool_schemas: Value,
191}
192
193impl OpenAiCompatibleProvider {
194    #[must_use]
195    pub fn new(
196        endpoint: impl Into<String>,
197        model: impl Into<String>,
198        api_key: Option<String>,
199        timeout: Duration,
200    ) -> Self {
201        Self {
202            endpoint: endpoint.into().trim_end_matches('/').to_owned(),
203            model: ModelCapabilities {
204                identifier: model.into(),
205                repository: None,
206                artifact: None,
207                artifact_sha256: None,
208                quantization: None,
209                chat_template: None,
210                context_window_tokens: None,
211                native_tools: true,
212                qualification: None,
213            },
214            api_key,
215            timeout,
216            runtime_provenance: None,
217            tool_schemas: tool_schemas(),
218        }
219    }
220
221    #[must_use]
222    pub fn with_model_capabilities(mut self, model: ModelCapabilities) -> Self {
223        self.model = model;
224        self
225    }
226
227    #[must_use]
228    pub fn with_runtime_provenance(mut self, provenance: RuntimeProvenance) -> Self {
229        self.runtime_provenance = Some(provenance);
230        self
231    }
232
233    #[must_use]
234    pub fn with_tool_schemas(mut self, tool_schemas: Value) -> Self {
235        self.tool_schemas = tool_schemas;
236        self
237    }
238
239    fn request_body(&self, request: &InferenceRequest) -> Value {
240        let mut messages = vec![
241            json!({"role": "system", "content": "Follow the supplied policy and use only the declared tools."}),
242            json!({"role": "user", "content": request.context}),
243        ];
244        messages.extend(request.messages.clone());
245        if !request.messages.is_empty() {
246            messages.push(json!({
247                "role": "user",
248                "content": "Continue from the recorded tool interaction using the current objective state above. Choose exactly one next action."
249            }));
250        }
251        json!({
252            "model": self.model.identifier,
253            "messages": messages,
254            "temperature": request.temperature,
255            "max_tokens": request.max_output_tokens,
256            "tools": self.tool_schemas,
257            "tool_choice": "auto",
258            "stream": false
259        })
260    }
261}
262
263impl InferenceProvider for OpenAiCompatibleProvider {
264    fn capabilities(&self) -> ProviderCapabilities {
265        ProviderCapabilities {
266            provider: "openai-compatible-http".to_owned(),
267            wire_protocol: "openai-chat-completions-v1".to_owned(),
268            model: self.model.clone(),
269            streaming: false,
270            runtime_provenance: self.runtime_provenance.clone(),
271        }
272    }
273
274    fn complete(&mut self, request: &InferenceRequest) -> Result<ModelResponse, InferenceError> {
275        let url = format!("{}/v1/chat/completions", self.endpoint);
276        let body = serde_json::to_string(&self.request_body(request))
277            .map_err(|error| InferenceError::Malformed(error.to_string()))?;
278        let agent = ureq::AgentBuilder::new().timeout(self.timeout).build();
279        let mut http_request = agent
280            .post(&url)
281            .set("content-type", "application/json")
282            .set("accept", "application/json");
283        if let Some(api_key) = &self.api_key {
284            http_request = http_request.set("authorization", &format!("Bearer {api_key}"));
285        }
286        let response = match http_request.send_string(&body) {
287            Ok(response) => response,
288            Err(ureq::Error::Status(status, response)) => {
289                let body = read_bounded(response.into_reader(), 64 * 1024)
290                    .unwrap_or_else(|error| format!("unreadable error body: {error}"));
291                return Err(InferenceError::HttpStatus { status, body });
292            }
293            Err(ureq::Error::Transport(error)) => {
294                let detail = error.to_string();
295                if detail.to_ascii_lowercase().contains("timed out")
296                    || detail.to_ascii_lowercase().contains("timeout")
297                {
298                    return Err(InferenceError::Timeout);
299                }
300                return Err(InferenceError::Unavailable(detail));
301            }
302        };
303        let body = read_bounded_bytes(response.into_reader(), OPENAI_RESPONSE_MAX_BYTES)
304            .map_err(inference_read_error)?;
305        parse_openai_response(&body)
306    }
307}
308
309pub fn parse_action(response: &ModelResponse) -> Result<ModelAction, InferenceError> {
310    if !response.tool_calls.is_empty() {
311        if response
312            .tool_calls
313            .iter()
314            .any(|call| !call.arguments.is_object())
315        {
316            return Err(InferenceError::Malformed(
317                "tool arguments must be a JSON object".to_owned(),
318            ));
319        }
320        if response
321            .tool_calls
322            .iter()
323            .any(|call| call.name == "candidate_ready")
324        {
325            if response.tool_calls.len() != 1 {
326                return Err(InferenceError::Malformed(
327                    "candidate_ready cannot be batched with tool calls".to_owned(),
328                ));
329            }
330            let summary = response.tool_calls[0]
331                .arguments
332                .get("summary")
333                .and_then(Value::as_str)
334                .filter(|summary| !summary.is_empty())
335                .ok_or_else(|| {
336                    InferenceError::Malformed(
337                        "candidate_ready requires a non-empty summary".to_owned(),
338                    )
339                })?;
340            return Ok(ModelAction::CandidateReady {
341                summary: summary.to_owned(),
342            });
343        }
344        if response.tool_calls.len() > 1 {
345            return Ok(ModelAction::ToolBatch {
346                calls: response.tool_calls.clone(),
347            });
348        }
349        let call = &response.tool_calls[0];
350        return Ok(ModelAction::Tool {
351            tool_call_id: call.id.clone(),
352            tool: call.name.clone(),
353            arguments: call.arguments.clone(),
354        });
355    }
356    let content = response.content.as_deref().ok_or_else(|| {
357        InferenceError::Malformed("response has no content or tool call".to_owned())
358    })?;
359    let action: ModelAction = serde_json::from_str(content).map_err(|error| {
360        InferenceError::Malformed(format!("fallback action must be strict JSON: {error}"))
361    })?;
362    match &action {
363        ModelAction::Tool { arguments, .. } if !arguments.is_object() => {
364            return Err(InferenceError::Malformed(
365                "tool arguments must be a JSON object".to_owned(),
366            ));
367        }
368        ModelAction::ToolBatch { calls }
369            if calls.is_empty() || calls.iter().any(|call| !call.arguments.is_object()) =>
370        {
371            return Err(InferenceError::Malformed(
372                "tool batch must contain calls with JSON-object arguments".to_owned(),
373            ));
374        }
375        _ => {}
376    }
377    Ok(action)
378}
379
380fn parse_openai_response(body: &[u8]) -> Result<ModelResponse, InferenceError> {
381    std::str::from_utf8(body).map_err(|_| InferenceError::InvalidUtf8)?;
382    scan_json_bounded(
383        body,
384        OPENAI_RESPONSE_MAX_DEPTH,
385        OPENAI_RESPONSE_MAX_NODES,
386        OPENAI_RESPONSE_MAX_BYTES,
387    )?;
388    let response: OpenAiResponse = serde_json::from_slice(body)
389        .map_err(|error| InferenceError::Malformed(format!("invalid JSON: {error}")))?;
390    let message = response
391        .choices
392        .first()
393        .and_then(|choice| choice.message.as_ref())
394        .ok_or_else(|| InferenceError::Malformed("missing choices[0].message".to_owned()))?;
395    let mut tool_calls = Vec::new();
396    for call in message.tool_calls.as_deref().unwrap_or_default() {
397        let function = call
398            .function
399            .as_ref()
400            .ok_or_else(|| InferenceError::Malformed("tool call missing function".to_owned()))?;
401        let name = function
402            .name
403            .as_deref()
404            .ok_or_else(|| InferenceError::Malformed("tool call missing name".to_owned()))?;
405        let arguments = function
406            .arguments
407            .as_deref()
408            .ok_or_else(|| InferenceError::Malformed("tool call missing arguments".to_owned()))?;
409        scan_json_bounded(
410            arguments.as_bytes(),
411            OPENAI_RESPONSE_MAX_DEPTH,
412            OPENAI_RESPONSE_MAX_NODES,
413            OPENAI_RESPONSE_MAX_BYTES,
414        )?;
415        let arguments = serde_json::from_str(arguments).map_err(|error| {
416            InferenceError::Malformed(format!("tool arguments are invalid JSON: {error}"))
417        })?;
418        tool_calls.push(ToolCall {
419            id: call.id.clone(),
420            name: name.to_owned(),
421            arguments,
422        });
423    }
424    Ok(ModelResponse {
425        content: message.content.clone(),
426        tool_calls,
427        // Usage is optional telemetry and is decoded through a tiny typed
428        // projection rather than an unrestricted outer JSON tree.
429        usage: response
430            .usage
431            .and_then(|usage| serde_json::to_value(usage).ok()),
432    })
433}
434
435fn inference_read_error(error: std::io::Error) -> InferenceError {
436    if error.kind() == std::io::ErrorKind::InvalidData {
437        InferenceError::InvalidUtf8
438    } else if error.to_string().contains("response exceeded size limit") {
439        InferenceError::ResponseTooLarge
440    } else {
441        InferenceError::Malformed("bounded response read failed".to_owned())
442    }
443}
444
445#[derive(Debug, Deserialize)]
446struct OpenAiResponse {
447    #[serde(default)]
448    choices: Vec<OpenAiChoice>,
449    #[serde(default)]
450    usage: Option<OpenAiUsage>,
451}
452
453#[derive(Debug, Serialize, Deserialize)]
454struct OpenAiUsage {
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    prompt_tokens: Option<u64>,
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    completion_tokens: Option<u64>,
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    total_tokens: Option<u64>,
461}
462
463#[derive(Debug, Deserialize)]
464struct OpenAiChoice {
465    message: Option<OpenAiMessage>,
466}
467
468#[derive(Debug, Deserialize)]
469struct OpenAiMessage {
470    content: Option<String>,
471    #[serde(default)]
472    tool_calls: Option<Vec<OpenAiToolCall>>,
473}
474
475#[derive(Debug, Deserialize)]
476struct OpenAiToolCall {
477    id: Option<String>,
478    function: Option<OpenAiFunction>,
479}
480
481#[derive(Debug, Deserialize)]
482struct OpenAiFunction {
483    name: Option<String>,
484    arguments: Option<String>,
485}
486
487fn read_bounded(mut reader: impl Read, maximum: usize) -> std::io::Result<String> {
488    let bytes = read_bounded_bytes(&mut reader, maximum)?;
489    String::from_utf8(bytes)
490        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
491}
492
493fn read_bounded_bytes(mut reader: impl Read, maximum: usize) -> std::io::Result<Vec<u8>> {
494    let limit = u64::try_from(maximum + 1).expect("response bound fits u64");
495    let mut bytes = Vec::new();
496    reader.by_ref().take(limit).read_to_end(&mut bytes)?;
497    if bytes.len() > maximum {
498        return Err(std::io::Error::other("response exceeded size limit"));
499    }
500    Ok(bytes)
501}
502
503/// Scan untrusted JSON without constructing a recursive `serde_json::Value`
504/// tree. This is deliberately shared by the outer OpenAI response and nested
505/// tool-argument payloads.
506fn scan_json_bounded(
507    bytes: &[u8],
508    max_depth: usize,
509    max_nodes: usize,
510    max_string_bytes: usize,
511) -> Result<(), InferenceError> {
512    struct Scanner<'a> {
513        bytes: &'a [u8],
514        index: usize,
515        nodes: usize,
516        max_depth: usize,
517        max_nodes: usize,
518        max_string_bytes: usize,
519    }
520    impl Scanner<'_> {
521        fn error(&self, message: &str) -> InferenceError {
522            InferenceError::Malformed(format!("{message} at byte {}", self.index))
523        }
524        fn ws(&mut self) {
525            while self
526                .bytes
527                .get(self.index)
528                .is_some_and(|byte| matches!(byte, b' ' | b'\n' | b'\r' | b'\t'))
529            {
530                self.index += 1;
531            }
532        }
533        fn consume(&mut self, expected: u8) -> bool {
534            if self.bytes.get(self.index) == Some(&expected) {
535                self.index += 1;
536                true
537            } else {
538                false
539            }
540        }
541        fn value(&mut self, depth: usize) -> Result<(), InferenceError> {
542            if depth > self.max_depth {
543                return Err(self.error("JSON nesting exceeds outer response bound"));
544            }
545            self.nodes = self.nodes.saturating_add(1);
546            if self.nodes > self.max_nodes {
547                return Err(self.error("JSON node count exceeds outer response bound"));
548            }
549            self.ws();
550            match self.bytes.get(self.index).copied() {
551                Some(b'{') => self.object(depth),
552                Some(b'[') => self.array(depth),
553                Some(b'"') => self.string(),
554                Some(b't') => self.literal(b"true"),
555                Some(b'f') => self.literal(b"false"),
556                Some(b'n') => self.literal(b"null"),
557                Some(b'-' | b'0'..=b'9') => self.number(),
558                _ => Err(self.error("invalid JSON value")),
559            }
560        }
561        fn object(&mut self, depth: usize) -> Result<(), InferenceError> {
562            self.index += 1;
563            self.ws();
564            let mut keys = std::collections::BTreeSet::new();
565            if self.consume(b'}') {
566                return Ok(());
567            }
568            loop {
569                self.ws();
570                let start = self.index;
571                if !self.bytes.get(self.index).is_some_and(|byte| *byte == b'"') {
572                    return Err(self.error("object key must be a string"));
573                }
574                self.string()?;
575                let key: String = serde_json::from_slice(&self.bytes[start..self.index])
576                    .map_err(|error| self.error(&format!("invalid object key: {error}")))?;
577                if !keys.insert(key) {
578                    return Err(self.error("duplicate JSON object key"));
579                }
580                self.ws();
581                if !self.consume(b':') {
582                    return Err(self.error("object key is missing ':'"));
583                }
584                self.value(depth + 1)?;
585                self.ws();
586                if self.consume(b'}') {
587                    return Ok(());
588                }
589                if !self.consume(b',') {
590                    return Err(self.error("object member is missing ','"));
591                }
592            }
593        }
594        fn array(&mut self, depth: usize) -> Result<(), InferenceError> {
595            self.index += 1;
596            self.ws();
597            if self.consume(b']') {
598                return Ok(());
599            }
600            loop {
601                self.value(depth + 1)?;
602                self.ws();
603                if self.consume(b']') {
604                    return Ok(());
605                }
606                if !self.consume(b',') {
607                    return Err(self.error("array item is missing ','"));
608                }
609            }
610        }
611        fn string(&mut self) -> Result<(), InferenceError> {
612            let start = self.index;
613            self.index += 1;
614            let mut escaped = false;
615            while let Some(byte) = self.bytes.get(self.index).copied() {
616                self.index += 1;
617                if escaped {
618                    escaped = false;
619                    continue;
620                }
621                if byte == b'\\' {
622                    escaped = true;
623                    continue;
624                }
625                if byte == b'"' {
626                    if self.index.saturating_sub(start) > self.max_string_bytes {
627                        return Err(self.error("JSON string exceeds outer response bound"));
628                    }
629                    return Ok(());
630                }
631                if byte < 0x20 {
632                    return Err(self.error("JSON string contains a control byte"));
633                }
634            }
635            Err(self.error("unterminated JSON string"))
636        }
637        fn literal(&mut self, literal: &[u8]) -> Result<(), InferenceError> {
638            if self.bytes.get(self.index..self.index + literal.len()) != Some(literal) {
639                return Err(self.error("invalid JSON literal"));
640            }
641            self.index += literal.len();
642            Ok(())
643        }
644        fn number(&mut self) -> Result<(), InferenceError> {
645            let start = self.index;
646            while self
647                .bytes
648                .get(self.index)
649                .is_some_and(|byte| matches!(byte, b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9'))
650            {
651                self.index += 1;
652            }
653            if start == self.index {
654                return Err(self.error("invalid JSON number"));
655            }
656            Ok(())
657        }
658    }
659    let mut scanner = Scanner {
660        bytes,
661        index: 0,
662        nodes: 0,
663        max_depth,
664        max_nodes,
665        max_string_bytes,
666    };
667    scanner.ws();
668    scanner.value(1)?;
669    scanner.ws();
670    if scanner.index != bytes.len() {
671        return Err(scanner.error("trailing bytes after JSON value"));
672    }
673    Ok(())
674}
675
676#[must_use]
677pub fn tool_schemas() -> Value {
678    json!([
679        function_tool(
680            "read_file",
681            "Read a UTF-8 file within the workspace",
682            json!({
683                "type": "object", "properties": {"path": {"type": "string", "description": "Workspace-relative path preferred; every path must remain inside the workspace and .. is rejected"}}, "required": ["path"], "additionalProperties": false
684            })
685        ),
686        function_tool(
687            "search",
688            "Search workspace text with ripgrep",
689            json!({
690                "type": "object", "properties": {"query": {"type": "string"}, "path": {"type": "string", "description": "Optional workspace-confined directory or file; relative path preferred"}}, "required": ["query"], "additionalProperties": false
691            })
692        ),
693        function_tool(
694            "apply_patch",
695            "Apply one validated unified diff within the workspace. Include --- and +++ file headers and @@ hunks.",
696            json!({
697                "type": "object", "properties": {"patch": {"type": "string"}}, "required": ["patch"], "additionalProperties": false
698            })
699        ),
700        function_tool(
701            "shell",
702            "Run one bounded argv command within the workspace",
703            json!({
704                "type": "object", "properties": {"argv": {"type": "array", "items": {"type": "string"}}, "cwd": {"type": "string"}, "timeout_ms": {"type": "integer"}, "env": {"type": "object", "additionalProperties": {"type": "string"}}}, "required": ["argv"], "additionalProperties": false
705            })
706        ),
707        function_tool(
708            "git",
709            "Inspect objective Git state",
710            json!({
711                "type": "object", "properties": {"operation": {"type": "string", "enum": ["status", "diff", "show"]}, "revision": {"type": "string"}}, "required": ["operation"], "additionalProperties": false
712            })
713        ),
714        function_tool(
715            "candidate_ready",
716            "Submit the exact current workspace for independent FalseGreen verification. This does not mean Accepted.",
717            json!({
718                "type": "object", "properties": {"summary": {"type": "string"}}, "required": ["summary"], "additionalProperties": false
719            })
720        )
721    ])
722}
723
724fn function_tool(name: &str, description: &str, parameters: Value) -> Value {
725    json!({"type": "function", "function": {"name": name, "description": description, "parameters": parameters}})
726}
727
728#[cfg(test)]
729mod tests {
730    use std::io::{Read, Write};
731    use std::net::TcpListener;
732    use std::thread;
733    use std::time::Duration;
734
735    use serde_json::json;
736
737    use super::{
738        InferenceError, InferenceProvider, InferenceRequest, ModelAction, ModelResponse,
739        OPENAI_RESPONSE_MAX_BYTES, OPENAI_RESPONSE_MAX_DEPTH, OPENAI_RESPONSE_MAX_NODES,
740        OpenAiCompatibleProvider, ToolCall, parse_action, parse_openai_response,
741        read_bounded_bytes, scan_json_bounded,
742    };
743
744    fn serve_once(body: &'static str, delay: Duration) -> String {
745        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
746        let address = listener.local_addr().expect("address");
747        thread::spawn(move || {
748            let (mut stream, _) = listener.accept().expect("accept");
749            let mut request = [0_u8; 8192];
750            let _ = stream.read(&mut request);
751            thread::sleep(delay);
752            write!(
753                stream,
754                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
755                body.len(),
756                body
757            )
758            .expect("write");
759        });
760        format!("http://{address}")
761    }
762
763    fn request() -> InferenceRequest {
764        InferenceRequest {
765            context: "test".to_owned(),
766            messages: Vec::new(),
767            max_output_tokens: 10,
768            temperature: 0.0,
769        }
770    }
771
772    #[test]
773    fn accepts_valid_completion_and_native_tool_call() {
774        let endpoint = serve_once(
775            r#"{"choices":[{"message":{"content":null,"tool_calls":[{"function":{"name":"read_file","arguments":"{\"path\":\"README.md\"}"}}]}}]}"#,
776            Duration::ZERO,
777        );
778        let mut provider =
779            OpenAiCompatibleProvider::new(endpoint, "test-model", None, Duration::from_secs(1));
780        let response = provider.complete(&request()).expect("completion");
781        assert_eq!(
782            parse_action(&response).expect("action"),
783            ModelAction::Tool {
784                tool_call_id: None,
785                tool: "read_file".to_owned(),
786                arguments: json!({"path": "README.md"})
787            }
788        );
789    }
790
791    #[test]
792    fn accepts_multiple_independent_native_tool_calls() {
793        let response = ModelResponse {
794            content: None,
795            tool_calls: vec![
796                ToolCall {
797                    id: Some("one".to_owned()),
798                    name: "read_file".to_owned(),
799                    arguments: json!({"path": "src/lib.rs"}),
800                },
801                ToolCall {
802                    id: Some("two".to_owned()),
803                    name: "read_file".to_owned(),
804                    arguments: json!({"path": "Cargo.toml"}),
805                },
806            ],
807            usage: None,
808        };
809        assert!(matches!(
810            parse_action(&response).expect("batch"),
811            ModelAction::ToolBatch { calls } if calls.len() == 2
812        ));
813    }
814
815    #[test]
816    fn maps_native_candidate_signal_without_granting_acceptance() {
817        let response = ModelResponse {
818            content: None,
819            tool_calls: vec![ToolCall {
820                id: Some("candidate".to_owned()),
821                name: "candidate_ready".to_owned(),
822                arguments: json!({"summary": "tests pass"}),
823            }],
824            usage: None,
825        };
826        assert_eq!(
827            parse_action(&response).expect("candidate action"),
828            ModelAction::CandidateReady {
829                summary: "tests pass".to_owned()
830            }
831        );
832    }
833
834    #[test]
835    fn parses_strict_fallback_completion() {
836        let response = ModelResponse {
837            content: Some(r#"{"action":"candidate_ready","summary":"tests pass"}"#.to_owned()),
838            tool_calls: Vec::new(),
839            usage: None,
840        };
841        assert_eq!(
842            parse_action(&response).expect("action"),
843            ModelAction::CandidateReady {
844                summary: "tests pass".to_owned()
845            }
846        );
847    }
848
849    #[test]
850    fn rejects_malformed_response() {
851        let endpoint = serve_once("{}", Duration::ZERO);
852        let mut provider =
853            OpenAiCompatibleProvider::new(endpoint, "test-model", None, Duration::from_secs(1));
854        assert!(matches!(
855            provider.complete(&request()),
856            Err(InferenceError::Malformed(_))
857        ));
858    }
859
860    #[test]
861    fn reports_timeout() {
862        let endpoint = serve_once(
863            r#"{"choices":[{"message":{"content":"{}"}}]}"#,
864            Duration::from_millis(200),
865        );
866        let mut provider =
867            OpenAiCompatibleProvider::new(endpoint, "test-model", None, Duration::from_millis(30));
868        assert!(matches!(
869            provider.complete(&request()),
870            Err(InferenceError::Timeout)
871        ));
872    }
873
874    #[test]
875    fn reports_server_unavailable() {
876        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
877        let endpoint = format!("http://{}", listener.local_addr().expect("address"));
878        drop(listener);
879        let mut provider =
880            OpenAiCompatibleProvider::new(endpoint, "test-model", None, Duration::from_millis(100));
881        assert!(matches!(
882            provider.complete(&request()),
883            Err(InferenceError::Unavailable(_))
884        ));
885    }
886
887    #[test]
888    fn outer_response_scanner_bounds_depth_nodes_duplicates_and_utf8() {
889        let valid = br#"{"choices":[{"message":{"content":"ok"}}]}"#;
890        assert!(parse_openai_response(valid).is_ok());
891        let duplicate = br#"{"choices":[],"choices":[]}"#;
892        assert!(parse_openai_response(duplicate).is_err());
893        let mut deep = Vec::new();
894        for _ in 0..5_000 {
895            deep.extend_from_slice(b"[");
896        }
897        deep.extend_from_slice(b"null");
898        for _ in 0..5_000 {
899            deep.extend_from_slice(b"]");
900        }
901        assert!(parse_openai_response(&deep).is_err());
902        let mut huge_array = b"[".to_vec();
903        for index in 0..100_001 {
904            if index > 0 {
905                huge_array.push(b',');
906            }
907            huge_array.extend_from_slice(b"0");
908        }
909        huge_array.push(b']');
910        assert!(
911            scan_json_bounded(
912                &huge_array,
913                OPENAI_RESPONSE_MAX_DEPTH,
914                OPENAI_RESPONSE_MAX_NODES,
915                OPENAI_RESPONSE_MAX_BYTES,
916            )
917            .is_err()
918        );
919        assert!(
920            parse_openai_response(b"{\"choices\":[{\"message\":{\"content\":\"\xff\"}}]}").is_err()
921        );
922    }
923
924    #[test]
925    fn outer_response_size_limit_is_exact() {
926        let prefix = br#"{"choices":[{"message":{"content":""#;
927        let suffix = br#""}}]}"#;
928        for target in [OPENAI_RESPONSE_MAX_BYTES - 1, OPENAI_RESPONSE_MAX_BYTES] {
929            let content_len = target - prefix.len() - suffix.len();
930            let mut body = Vec::with_capacity(target);
931            body.extend_from_slice(prefix);
932            body.extend(std::iter::repeat_n(b'a', content_len));
933            body.extend_from_slice(suffix);
934            assert_eq!(body.len(), target);
935            assert!(parse_openai_response(&body).is_ok());
936            assert_eq!(
937                read_bounded_bytes(std::io::Cursor::new(body), OPENAI_RESPONSE_MAX_BYTES)
938                    .expect("within bound")
939                    .len(),
940                target
941            );
942        }
943        let over = vec![b'x'; OPENAI_RESPONSE_MAX_BYTES + 1];
944        assert!(read_bounded_bytes(std::io::Cursor::new(over), OPENAI_RESPONSE_MAX_BYTES).is_err());
945    }
946}