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 #[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 #[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: 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
503fn 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}