1 //! Inference backend abstraction.
2 //!
3 //! The agent loop only needs to send a turn (optionally with tools) and return
4 //! tool results. This module defines that seam as the `InferenceBackend` trait
5 //! plus a few neutral types, with two implementations:
6 //!
7 //! - `LocalBackend` runs on-device through the `onde` crate (`ChatEngine`).
8 //! - `OpenAiBackend` talks to any OpenAI-compatible HTTP endpoint, configured by
9 //! `base_url`, `api_key`, and `model`.
10 //!
11 //! The trait exposes neither `onde` nor OpenAI types, so the loop does not depend
12 //! on a specific backend.
13 //!
14 //! The whole backend seam is wired up only through the interactive client, which
15 //! is `#[cfg(unix)]` (see `run_interactive` in `main.rs` and `mod tui` in
16 //! `chat.rs`). On non-Unix targets the binary runs ACP-only and drives `onde`
17 //! directly, so every item here is legitimately unused there. Suppress the
18 //! dead-code lint on those targets only — Unix builds still get full coverage.
19 #![cfg_attr(not(unix), allow(dead_code))]
20
21 use std::sync::Arc;
22
23 use async_trait::async_trait;
24 use onde::inference::{ChatEngine, ToolDefinition};
25 use serde::Deserialize;
26 use tokio::sync::Mutex;
27
28 // ── Neutral types ───────────────────────────────────────────────────────────────
29
30 /// A tool the model may call, in a provider-neutral form. `parameters_schema` is
31 /// a JSON Schema encoded as a string (matching how siGit already declares tools).
32 #[derive(Debug, Clone)]
33 pub struct ToolSpec {
34 pub name: String,
35 pub description: String,
36 pub parameters_schema: String,
37 }
38
39 /// A tool call requested by the model.
40 #[derive(Debug, Clone)]
41 pub struct ToolCall {
42 pub id: String,
43 pub name: String,
44 /// Arguments as a JSON-encoded string.
45 pub arguments: String,
46 }
47
48 /// The output of executing one tool call, fed back to the model.
49 #[derive(Debug, Clone)]
50 pub struct ToolResult {
51 pub tool_call_id: String,
52 pub content: String,
53 }
54
55 /// The result of one assistant turn: free text and/or tool calls.
56 #[derive(Debug, Clone, Default)]
57 pub struct TurnResult {
58 pub text: String,
59 pub tool_calls: Vec<ToolCall>,
60 }
61
62 /// Backend errors are plain strings. Callers map them to ACP errors.
63 pub type BackendError = String;
64
65 // ── The trait ───────────────────────────────────────────────────────────────────
66
67 /// A swappable inference backend driving siGit Code's agent loop.
68 #[async_trait]
69 pub trait InferenceBackend: Send + Sync {
70 /// Start an assistant turn from a new user message, offering `tools`.
71 async fn send_message_with_tools(
72 &self,
73 text: &str,
74 tools: &[ToolSpec],
75 ) -> Result<TurnResult, BackendError>;
76
77 /// Continue the turn by returning tool results. `tools` may be `None` on the
78 /// final round to force a text answer.
79 async fn send_tool_results(
80 &self,
81 results: Vec<ToolResult>,
82 tools: Option<&[ToolSpec]>,
83 ) -> Result<TurnResult, BackendError>;
84
85 /// Whether inference runs over the network (a configured provider) rather
86 /// than on-device. Drives UI labelling so the displayed model can't claim a
87 /// local model while requests actually go to the cloud.
88 fn is_remote(&self) -> bool;
89 }
90
91 // ── Local backend (onde ChatEngine) ──────────────────────────────────────────────
92
93 /// On-device inference. A thin adapter over `onde::ChatEngine`.
94 pub struct LocalBackend {
95 engine: Arc<ChatEngine>,
96 }
97
98 impl LocalBackend {
99 pub fn new(engine: Arc<ChatEngine>) -> Self {
100 Self { engine }
101 }
102 }
103
104 fn to_onde_tools(tools: &[ToolSpec]) -> Vec<ToolDefinition> {
105 tools
106 .iter()
107 .map(|tool| ToolDefinition {
108 name: tool.name.clone(),
109 description: tool.description.clone(),
110 parameters_schema: tool.parameters_schema.clone(),
111 })
112 .collect()
113 }
114
115 #[async_trait]
116 impl InferenceBackend for LocalBackend {
117 async fn send_message_with_tools(
118 &self,
119 text: &str,
120 tools: &[ToolSpec],
121 ) -> Result<TurnResult, BackendError> {
122 let onde_tools = to_onde_tools(tools);
123 let result = self
124 .engine
125 .send_message_with_tools(text, &onde_tools)
126 .await
127 .map_err(|error| error.to_string())?;
128 Ok(onde_result_to_turn(result))
129 }
130
131 async fn send_tool_results(
132 &self,
133 results: Vec<ToolResult>,
134 tools: Option<&[ToolSpec]>,
135 ) -> Result<TurnResult, BackendError> {
136 let onde_results: Vec<onde::inference::ToolResult> = results
137 .into_iter()
138 .map(|result| onde::inference::ToolResult {
139 tool_call_id: result.tool_call_id,
140 content: result.content,
141 })
142 .collect();
143 let onde_tools = tools.map(to_onde_tools);
144 let result = self
145 .engine
146 .send_tool_results(onde_results, onde_tools.as_deref())
147 .await
148 .map_err(|error| error.to_string())?;
149 Ok(onde_result_to_turn(result))
150 }
151
152 fn is_remote(&self) -> bool {
153 false
154 }
155 }
156
157 /// Convert an `onde` tool-aware result into the neutral [`TurnResult`].
158 fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
159 TurnResult {
160 text: result.text,
161 tool_calls: result
162 .tool_calls
163 .into_iter()
164 .map(|call| ToolCall {
165 id: call.id,
166 name: call.function_name,
167 arguments: call.arguments,
168 })
169 .collect(),
170 }
171 }
172
173 // ── OpenAI-compatible backend ─────────────────────────────────────────────────────
174
175 /// Inference against any OpenAI-compatible Chat Completions endpoint.
176 ///
177 /// Conversation state is held client-side and replayed on every request, so the
178 /// endpoint can be stateless. Standard OpenAI function-calling is used end to
179 /// end (`tools`, `choices[].message.tool_calls`, `role: "tool"` follow-ups).
180 pub struct OpenAiBackend {
181 base_url: String,
182 api_key: String,
183 model: String,
184 http: reqwest::Client,
185 /// The full message list sent on each request (system + turns + tool results).
186 history: Mutex<Vec<serde_json::Value>>,
187 }
188
189 impl OpenAiBackend {
190 /// Build a backend for `{base_url, api_key, model}`, seeding the optional
191 /// system prompt. `base_url` should include the API root (e.g. ending in
192 /// `/v1`); the chat path is appended.
193 pub fn new(
194 base_url: impl Into<String>,
195 api_key: impl Into<String>,
196 model: impl Into<String>,
197 system_prompt: Option<String>,
198 ) -> Self {
199 let mut history = Vec::new();
200 if let Some(prompt) = system_prompt {
201 history.push(serde_json::json!({ "role": "system", "content": prompt }));
202 }
203 Self {
204 base_url: base_url.into(),
205 api_key: api_key.into(),
206 model: model.into(),
207 http: reqwest::Client::new(),
208 history: Mutex::new(history),
209 }
210 }
211
212 fn tools_json(tools: &[ToolSpec]) -> Vec<serde_json::Value> {
213 tools
214 .iter()
215 .map(|tool| {
216 // parameters_schema is a JSON string; parse it, defaulting to an
217 // empty object schema if malformed.
218 let parameters: serde_json::Value = serde_json::from_str(&tool.parameters_schema)
219 .unwrap_or_else(|_| serde_json::json!({ "type": "object", "properties": {} }));
220 serde_json::json!({
221 "type": "function",
222 "function": {
223 "name": tool.name,
224 "description": tool.description,
225 "parameters": parameters,
226 }
227 })
228 })
229 .collect()
230 }
231
232 /// POST the current history (plus `tools`) and apply the assistant reply to
233 /// history, returning the neutral turn result.
234 async fn complete(&self, tools: Option<&[ToolSpec]>) -> Result<TurnResult, BackendError> {
235 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
236
237 let mut body = serde_json::json!({
238 "model": self.model,
239 "messages": *self.history.lock().await,
240 "stream": false,
241 });
242 if let Some(tools) = tools
243 && !tools.is_empty()
244 {
245 body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
246 }
247
248 let response = self
249 .http
250 .post(&url)
251 .bearer_auth(&self.api_key)
252 .json(&body)
253 .send()
254 .await
255 .map_err(|error| format!("request to {url} failed: {error}"))?;
256
257 if !response.status().is_success() {
258 let status = response.status();
259 let detail = response.text().await.unwrap_or_default();
260 return Err(format!("endpoint returned {status}: {detail}"));
261 }
262
263 let parsed: ChatCompletion = response
264 .json()
265 .await
266 .map_err(|error| format!("response parse error: {error}"))?;
267
268 let message = parsed
269 .choices
270 .into_iter()
271 .next()
272 .map(|choice| choice.message)
273 .ok_or_else(|| "endpoint returned no choices".to_string())?;
274
275 let text = message.content.clone().unwrap_or_default();
276 let tool_calls: Vec<ToolCall> = message
277 .tool_calls
278 .iter()
279 .flatten()
280 .map(|call| ToolCall {
281 id: call.id.clone(),
282 name: call.function.name.clone(),
283 arguments: call.function.arguments.clone(),
284 })
285 .collect();
286
287 // Record the assistant turn so later tool results have context.
288 self.history.lock().await.push(message.into_history_value());
289
290 Ok(TurnResult { text, tool_calls })
291 }
292 }
293
294 #[async_trait]
295 impl InferenceBackend for OpenAiBackend {
296 async fn send_message_with_tools(
297 &self,
298 text: &str,
299 tools: &[ToolSpec],
300 ) -> Result<TurnResult, BackendError> {
301 self.history
302 .lock()
303 .await
304 .push(serde_json::json!({ "role": "user", "content": text }));
305 self.complete(Some(tools)).await
306 }
307
308 async fn send_tool_results(
309 &self,
310 results: Vec<ToolResult>,
311 tools: Option<&[ToolSpec]>,
312 ) -> Result<TurnResult, BackendError> {
313 {
314 let mut history = self.history.lock().await;
315 for result in results {
316 history.push(serde_json::json!({
317 "role": "tool",
318 "tool_call_id": result.tool_call_id,
319 "content": result.content,
320 }));
321 }
322 }
323 self.complete(tools).await
324 }
325
326 fn is_remote(&self) -> bool {
327 true
328 }
329 }
330
331 // ── OpenAI response shapes ────────────────────────────────────────────────────────
332
333 #[derive(Debug, Deserialize)]
334 struct ChatCompletion {
335 #[serde(default)]
336 choices: Vec<CompletionChoice>,
337 }
338
339 #[derive(Debug, Deserialize)]
340 struct CompletionChoice {
341 message: ResponseMessage,
342 }
343
344 #[derive(Debug, Deserialize)]
345 struct ResponseMessage {
346 #[serde(default)]
347 content: Option<String>,
348 #[serde(default)]
349 tool_calls: Option<Vec<ResponseToolCall>>,
350 }
351
352 impl ResponseMessage {
353 /// Reconstruct the assistant message for replay in history, preserving any
354 /// tool calls so the follow-up request is well-formed.
355 fn into_history_value(self) -> serde_json::Value {
356 let mut message = serde_json::json!({ "role": "assistant" });
357 message["content"] = match self.content {
358 Some(text) => serde_json::Value::String(text),
359 None => serde_json::Value::Null,
360 };
361 if let Some(tool_calls) = self.tool_calls {
362 message["tool_calls"] = serde_json::json!(
363 tool_calls
364 .into_iter()
365 .map(|call| serde_json::json!({
366 "id": call.id,
367 "type": "function",
368 "function": {
369 "name": call.function.name,
370 "arguments": call.function.arguments,
371 }
372 }))
373 .collect::<Vec<_>>()
374 );
375 }
376 message
377 }
378 }
379
380 #[derive(Debug, Deserialize)]
381 struct ResponseToolCall {
382 id: String,
383 function: ResponseFunction,
384 }
385
386 #[derive(Debug, Deserialize)]
387 struct ResponseFunction {
388 name: String,
389 #[serde(default)]
390 arguments: String,
391 }
392
393 #[cfg(test)]
394 mod tests {
395 use super::*;
396
397 #[test]
398 fn tools_json_wraps_function_schema() {
399 let tools = vec![ToolSpec {
400 name: "read_file".to_string(),
401 description: "Read a file".to_string(),
402 parameters_schema: r#"{"type":"object","properties":{"path":{"type":"string"}}}"#
403 .to_string(),
404 }];
405 let json = OpenAiBackend::tools_json(&tools);
406 assert_eq!(json[0]["type"], "function");
407 assert_eq!(json[0]["function"]["name"], "read_file");
408 assert_eq!(
409 json[0]["function"]["parameters"]["properties"]["path"]["type"],
410 "string"
411 );
412 }
413
414 #[test]
415 fn malformed_schema_falls_back_to_empty_object() {
416 let tools = vec![ToolSpec {
417 name: "x".to_string(),
418 description: String::new(),
419 parameters_schema: "not json".to_string(),
420 }];
421 let json = OpenAiBackend::tools_json(&tools);
422 assert_eq!(json[0]["function"]["parameters"]["type"], "object");
423 }
424
425 #[test]
426 fn assistant_message_with_tool_calls_round_trips() {
427 let message = ResponseMessage {
428 content: None,
429 tool_calls: Some(vec![ResponseToolCall {
430 id: "call_1".to_string(),
431 function: ResponseFunction {
432 name: "read_file".to_string(),
433 arguments: r#"{"path":"a.rs"}"#.to_string(),
434 },
435 }]),
436 };
437 let value = message.into_history_value();
438 assert_eq!(value["role"], "assistant");
439 assert!(value["content"].is_null());
440 assert_eq!(value["tool_calls"][0]["id"], "call_1");
441 assert_eq!(value["tool_calls"][0]["type"], "function");
442 assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file");
443 }
444 }