@ondeinference / ed / commits / 15620a5

ed-acp: on-device inference with Provider::Local

A third provider beside Onde Cloud and OpenAI API compatible endpoints: <PREFIX>_PROVIDER=local runs the agent's turns on the device with onde, offline and without a key. Behind an optional `local` feature, so cloud-only agents don't build mistral.rs; without it the provider can still be chosen and every turn says how to enable it. - LlmClient::complete converts the turn loop's OpenAI message and tool JSON to onde ChatTurns and ToolDefinitions and runs onde's stateless ChatEngine::complete, so sessions keep working as they do on the cloud: persisted, loaded and replayed with their tool calls. The reasoning and text of a turn are reported once the turn ends, since onde doesn't stream tool-aware turns yet. - One engine per process, loaded with the session's model on first use (downloaded the first time, with a thought saying so). Models come from onde's tool-calling catalogue, which models() lists. Default: Qwen3 4B Instruct 2507, or Qwen3 1.7B on iOS and Android. - No key is needed, so AUTH_REQUIRED doesn't apply and check_auth passes. A stored ONDE_API_KEY is not read as this provider's key. - --setup offers "On-device" when the feature is compiled in. Needs onde 1.4 (ChatEngine::complete). Until it is published the workspace patches onde to a sibling checkout. Checked end to end through LlmClient::complete with Qwen3 4B Instruct 2507: the model called the offered tool, then answered from its result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 20:01 UTC 15620a57f7da1599e72011450c2bce015897af02
7 files changed +429 -10
Cargo.lock
+2 -2
@@ -1965,8 +1965,10 @@ dependencies = [
1965 "async-trait",
1966 "base64 0.23.1",
1967 "directories",
1968 + "ed-acp",
1969 "ed-mcp",
1970 "futures",
1971 + "onde",
1972 "reqwest 0.13.5",
1973 "serde",
1974 "serde_json",
@@ -4998,8 +5000,6 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
5000 [[package]]
5001 name = "onde"
5002 version = "1.3.1"
5001 -source = "registry+https://github.com/rust-lang/crates.io-index"
5002 -checksum = "7145ab132138de1f7b57707d37a5f7dbcb0dee36560742689270c162359478b5"
5003 dependencies = [
5004 "anyhow",
5005 "cc",
Cargo.toml
+3
@@ -73,3 +73,6 @@ ed-acp-testkit = { version = "1.2.1", path = "crates/ed-acp-testkit" }
73 # the graph is silently ignored.
74 [patch.crates-io]
75 sysctl = { git = "https://github.com/setoelkahfi/sysctl-rs.git", branch = "feature/watchos-0.6" }
76 +# ChatEngine::complete (onde 1.4) is not on crates.io yet. Remove once it is, and require
77 +# onde 1.4 above.
78 +onde = { path = "../onde" }
crates/ed-acp/Cargo.toml
+8
@@ -11,8 +11,14 @@ authors.workspace = true
11 repository.workspace = true
12 homepage.workspace = true
13
14 +[features]
15 +# On-device inference with onde (`<PREFIX>_PROVIDER=local`). Off by default: it builds
16 +# mistral.rs and its model runtime, which a cloud-only agent doesn't need.
17 +local = ["dep:onde"]
18 +
19 [dependencies]
20 ed-mcp = { workspace = true, features = ["acp"] }
21 +onde = { workspace = true, optional = true }
22 agent-client-protocol.workspace = true
23 async-trait.workspace = true
24 tokio = { workspace = true, features = ["full"] }
@@ -30,3 +36,5 @@ base64.workspace = true
36
37 [dev-dependencies]
38 tokio = { version = "1", features = ["full"] }
39 +# Turn the `local` feature on for this crate's own tests.
40 +ed-acp = { path = ".", features = ["local"] }
crates/ed-acp/src/cli.rs
+24 -4
@@ -7,7 +7,8 @@ use anyhow::Context;
7
8 use crate::AgentInfo;
9 use crate::llm::{
10 - LlmClient, LlmConfig, LlmEnv, OPENAI_DEFAULT_BASE_URL, OPENAI_DEFAULT_MODEL, Provider,
10 + LOCAL_DEFAULT_MODEL, LlmClient, LlmConfig, LlmEnv, OPENAI_DEFAULT_BASE_URL,
11 + OPENAI_DEFAULT_MODEL, Provider,
12 };
13
14 /// Log to stderr, filtered by `RUST_LOG`. Stdout is reserved for ACP.
@@ -76,11 +77,17 @@ pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> {
77 println!(
78 " 2) OpenAI API compatible endpoint (OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL)"
79 );
80 + let local = cfg!(feature = "local");
81 + if local {
82 + println!(" 3) On-device (runs here, no key; downloads a model)");
83 + }
84 + let last = if local { 3 } else { 2 };
85 let provider = loop {
80 - match prompt_line("\nChoose a provider [1-2]: ")?.as_str() {
86 + match prompt_line(&format!("\nChoose a provider [1-{last}]: "))?.as_str() {
87 "" | "1" | "onde" => break Provider::Onde,
88 "2" | "openai" => break Provider::OpenAi,
83 - _ => println!("Please enter 1 or 2."),
89 + "3" | "local" if local => break Provider::Local,
90 + _ => println!("Please enter a number from 1 to {last}."),
91 }
92 };
93 let mut vars = vec![(info.env.var("PROVIDER"), provider.name().to_string())];
@@ -104,6 +111,14 @@ pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> {
111 vars.push((var.to_string(), value));
112 }
113 }
114 + Provider::Local => {
115 + println!(
116 + "\nThe model ({}) downloads the first time the agent needs it.",
117 + LOCAL_DEFAULT_MODEL
118 + );
119 + println!("Set {} to pick another one.", info.env.var("MODEL"));
120 + return write_env(info, &vars);
121 + }
122 }
123 let key_var = provider.key_var();
124 let key = loop {
@@ -132,6 +147,11 @@ pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> {
147 Err(e) => anyhow::bail!("\nKey check failed: {e:#}\nNothing was written."),
148 }
149
150 + write_env(info, &vars)
151 +}
152 +
153 +/// Store `vars` in the agent's `env` file (mode 0600), keeping its other lines.
154 +fn write_env(info: &AgentInfo, vars: &[(String, String)]) -> anyhow::Result<()> {
155 let dir = info
156 .env
157 .config_dir()
@@ -139,7 +159,7 @@ pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> {
159 std::fs::create_dir_all(&dir)?;
160 let path = dir.join("env");
161 let existing = std::fs::read_to_string(&path).unwrap_or_default();
142 - std::fs::write(&path, with_vars(&existing, &vars))?;
162 + std::fs::write(&path, with_vars(&existing, vars))?;
163 #[cfg(unix)]
164 {
165 use std::os::unix::fs::PermissionsExt;
crates/ed-acp/src/lib.rs
+1
@@ -36,6 +36,7 @@ mod agent;
36 pub mod cli;
37 pub mod content;
38 pub mod llm;
39 +mod local;
40 mod server;
41 pub mod store;
42 pub mod tools;
crates/ed-acp/src/llm.rs
+58 -4
@@ -21,6 +21,15 @@ pub const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
21 /// Model for a generic endpoint when neither `<PREFIX>_MODEL` nor `OPENAI_MODEL` is set.
22 pub const OPENAI_DEFAULT_MODEL: &str = "gpt-4o-mini";
23
24 +/// The on-device model when `<PREFIX>_MODEL` is unset: Qwen3 1.7B on phones, where memory is
25 +/// tight, Qwen3 4B Instruct 2507 elsewhere. Both are in onde's tool-calling catalog.
26 +#[cfg(any(target_os = "ios", target_os = "android"))]
27 +pub const LOCAL_DEFAULT_MODEL: &str = "bartowski/Qwen_Qwen3-1.7B-GGUF";
28 +/// The on-device model when `<PREFIX>_MODEL` is unset: Qwen3 1.7B on phones, where memory is
29 +/// tight, Qwen3 4B Instruct 2507 elsewhere. Both are in onde's tool-calling catalog.
30 +#[cfg(not(any(target_os = "ios", target_os = "android")))]
31 +pub const LOCAL_DEFAULT_MODEL: &str = "bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF";
32 +
33 /// Where completions come from. Both speak the OpenAI chat completions API.
34 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
35 pub enum Provider {
@@ -29,6 +38,10 @@ pub enum Provider {
38 Onde,
39 /// Any OpenAI API compatible endpoint: `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_MODEL`.
40 OpenAi,
41 + /// On-device inference with onde: no endpoint and no key. `<PREFIX>_MODEL` names a model
42 + /// from onde's tool-calling catalog. Needs the `local` feature; without it every turn fails
43 + /// with an error saying so.
44 + Local,
45 }
46
47 impl Provider {
@@ -37,6 +50,7 @@ impl Provider {
50 match self {
51 Self::Onde => "onde",
52 Self::OpenAi => "openai",
53 + Self::Local => "local",
54 }
55 }
56
@@ -44,15 +58,17 @@ impl Provider {
58 match name.to_ascii_lowercase().as_str() {
59 "onde" | "onde-cloud" | "ondeinference" => Some(Self::Onde),
60 "openai" => Some(Self::OpenAi),
61 + "local" | "on-device" | "ondevice" => Some(Self::Local),
62 _ => None,
63 }
64 }
65
51 - /// The variable holding this provider's key.
66 + /// The variable holding this provider's key. Empty for [`Provider::Local`], which has none.
67 pub fn key_var(self) -> &'static str {
68 match self {
69 Self::Onde => API_KEY_VAR,
70 Self::OpenAi => "OPENAI_API_KEY",
71 + Self::Local => "",
72 }
73 }
74
@@ -61,6 +77,7 @@ impl Provider {
77 match self {
78 Self::Onde => "Onde Inference",
79 Self::OpenAi => "the OpenAI API compatible endpoint",
80 + Self::Local => "on-device inference",
81 }
82 }
83 }
@@ -218,11 +235,19 @@ impl LlmConfig {
235 .or_else(|| get("OPENAI_MODEL"))
236 .unwrap_or_else(|| OPENAI_DEFAULT_MODEL.into()),
237 ),
238 + Provider::Local => (
239 + String::new(),
240 + get(&env.var("MODEL")).unwrap_or_else(|| LOCAL_DEFAULT_MODEL.into()),
241 + ),
242 };
243 Self {
244 provider,
245 base_url: base_url.trim_end_matches('/').to_string(),
225 - api_key: get(provider.key_var()),
246 + // On-device inference has no key, and `get("")` must not find one in an env file.
247 + api_key: match provider {
248 + Provider::Local => None,
249 + _ => get(provider.key_var()),
250 + },
251 model,
252 }
253 }
@@ -256,6 +281,25 @@ mod tests {
281 assert_eq!(config(&[("ONDE_API_KEY", "")]).api_key, None);
282 }
283
284 + #[test]
285 + fn local_needs_no_key() {
286 + let c = config(&[
287 + ("SPLITFIRE_PROVIDER", "local"),
288 + ("ONDE_API_KEY", "app:secret"),
289 + ]);
290 + assert_eq!(c.provider, Provider::Local);
291 + assert_eq!(c.model, LOCAL_DEFAULT_MODEL);
292 + // A stored Onde key is not this provider's.
293 + assert_eq!(c.api_key, None);
294 + assert!(LlmClient::new(c).has_api_key());
295 + assert_eq!(Provider::parse("on-device"), Some(Provider::Local));
296 + let picked = config(&[
297 + ("SPLITFIRE_PROVIDER", "local"),
298 + ("SPLITFIRE_MODEL", "bartowski/Qwen_Qwen3-8B-GGUF"),
299 + ]);
300 + assert_eq!(picked.model, "bartowski/Qwen_Qwen3-8B-GGUF");
301 + }
302 +
303 #[test]
304 fn base_url_and_model_overrides_apply() {
305 let c = config(&[
@@ -541,15 +585,19 @@ impl LlmClient {
585 &self.config
586 }
587
544 - /// Whether an API key is configured.
588 + /// Whether the provider can be used: an API key is configured, or the provider needs none
589 + /// ([`Provider::Local`]). The agent answers `AUTH_REQUIRED` when this is false.
590 pub fn has_api_key(&self) -> bool {
546 - self.config.api_key.is_some()
591 + self.config.provider == Provider::Local || self.config.api_key.is_some()
592 }
593
594 /// Verify the configured key with a minimal chat completion.
595 /// Returns `Err` when no key is set, the key is rejected, or the endpoint is unreachable.
596 pub async fn check_auth(&self) -> Result<()> {
597 let provider = self.config.provider;
598 + if provider == Provider::Local {
599 + return Ok(());
600 + }
601 let Some(key) = &self.config.api_key else {
602 bail!("no {} configured", provider.key_var());
603 };
@@ -583,6 +631,9 @@ impl LlmClient {
631
632 /// List the models the configured endpoint serves (`GET {base_url}/models`).
633 pub async fn models(&self) -> Result<Vec<ModelInfo>> {
634 + if self.config.provider == Provider::Local {
635 + return Ok(crate::local::catalog());
636 + }
637 let url = format!("{}/models", self.config.base_url);
638 let mut req = self.http.get(&url);
639 if let Some(key) = &self.config.api_key {
@@ -621,6 +672,9 @@ impl LlmClient {
672 tools: &Value,
673 mut on_delta: impl FnMut(Delta<'_>),
674 ) -> Result<Completion> {
675 + if self.config.provider == Provider::Local {
676 + return crate::local::complete(model, messages, tools, on_delta).await;
677 + }
678 if self.config.api_key.is_none() {
679 bail!(
680 "No API key configured. Set {} in the environment or run the agent's `--setup`",
crates/ed-acp/src/local.rs new
+333
@@ -0,0 +1,333 @@
1 +//! [`Provider::Local`](crate::llm::Provider::Local): the turn loop on an on-device model, with
2 +//! onde.
3 +//!
4 +//! The rest of `ed-acp` keeps the conversation as OpenAI chat-completions JSON and calls
5 +//! [`LlmClient::complete`](crate::llm::LlmClient::complete) once per model turn with all of it.
6 +//! This module converts that JSON to onde's [`ChatTurn`]s and runs
7 +//! [`ChatEngine::complete`], which takes the whole conversation each call and leaves the
8 +//! engine's own history alone. One engine serves the process; it loads the requested model on
9 +//! first use (downloading it the first time) and swaps it when the session picks another.
10 +//!
11 +//! onde doesn't stream tool-aware turns yet, so a turn's reasoning and text are reported
12 +//! through `on_delta` once the turn ends.
13 +//!
14 +//! Without the `local` feature the provider is still selectable but every turn fails with an
15 +//! error that says how to enable it.
16 +
17 +#[cfg(feature = "local")]
18 +pub use enabled::{catalog, complete};
19 +
20 +#[cfg(not(feature = "local"))]
21 +pub use disabled::{catalog, complete};
22 +
23 +#[cfg(not(feature = "local"))]
24 +mod disabled {
25 + use serde_json::Value;
26 +
27 + use crate::llm::{Completion, Delta, ModelInfo};
28 +
29 + pub fn catalog() -> Vec<ModelInfo> {
30 + Vec::new()
31 + }
32 +
33 + pub async fn complete(
34 + _model: &str,
35 + _messages: &[Value],
36 + _tools: &Value,
37 + _on_delta: impl FnMut(Delta<'_>),
38 + ) -> anyhow::Result<Completion> {
39 + anyhow::bail!(
40 + "this agent was built without on-device inference; rebuild it with ed-acp's `local` \
41 + feature, or choose another provider"
42 + )
43 + }
44 +}
45 +
46 +#[cfg(feature = "local")]
47 +mod enabled {
48 + use std::sync::OnceLock;
49 +
50 + use anyhow::{Context, Result, anyhow, bail};
51 + use onde::inference::models::{SUPPORTED_MODEL_INFO, TOOL_CALLING_MODELS};
52 + use onde::inference::{
53 + ChatEngine, ChatTurn, GgufModelConfig, ToolCallRequest as OndeCall, ToolCallingSupport,
54 + ToolDefinition,
55 + };
56 + use serde_json::Value;
57 + use tokio::sync::Mutex;
58 +
59 + use crate::llm::{Completion, Delta, ModelInfo, ToolCallRequest, Usage};
60 +
61 + /// The process's engine and the model loaded in it. The mutex is held across a turn, so
62 + /// sessions take turns on the one model instead of swapping it under each other.
63 + struct Local {
64 + engine: ChatEngine,
65 + loaded: Mutex<Option<String>>,
66 + }
67 +
68 + fn local() -> &'static Local {
69 + static LOCAL: OnceLock<Local> = OnceLock::new();
70 + LOCAL.get_or_init(|| Local {
71 + engine: ChatEngine::new(),
72 + loaded: Mutex::new(None),
73 + })
74 + }
75 +
76 + /// onde's tool-calling models, the ones an agent can drive.
77 + pub fn catalog() -> Vec<ModelInfo> {
78 + TOOL_CALLING_MODELS
79 + .iter()
80 + .map(|id| ModelInfo {
81 + id: (*id).to_string(),
82 + owned_by: SUPPORTED_MODEL_INFO
83 + .iter()
84 + .find(|info| info.id == *id)
85 + .map(|info| format!("{} (on device)", info.name)),
86 + })
87 + .collect()
88 + }
89 +
90 + pub async fn complete(
91 + model: &str,
92 + messages: &[Value],
93 + tools: &Value,
94 + mut on_delta: impl FnMut(Delta<'_>),
95 + ) -> Result<Completion> {
96 + let turns = to_turns(messages)?;
97 + let tools = to_tools(tools);
98 + let local = local();
99 + let mut loaded = local.loaded.lock().await;
100 + if loaded.as_deref() != Some(model) {
101 + let config = GgufModelConfig::from_supported_model_id(model)
102 + .ok_or_else(|| anyhow!("{model} is not an on-device model onde supports"))?;
103 + if onde::inference::models::tool_calling_support(model) != ToolCallingSupport::Supported
104 + {
105 + bail!("{model} can't call tools reliably, so it can't run an agent");
106 + }
107 + on_delta(Delta::Reasoning(&format!(
108 + "Loading {} on this device. The first use downloads it.\n",
109 + config.display_name
110 + )));
111 + tracing::info!("loading on-device model {model}");
112 + *loaded = None;
113 + local
114 + .engine
115 + .load_gguf_model(config, None, None)
116 + .await
117 + .with_context(|| format!("loading {model}"))?;
118 + *loaded = Some(model.to_string());
119 + }
120 + let result = local
121 + .engine
122 + .complete(turns, &tools, None)
123 + .await
124 + .context("on-device inference")?;
125 + drop(loaded);
126 +
127 + if let Some(reasoning) = &result.reasoning {
128 + on_delta(Delta::Reasoning(reasoning));
129 + }
130 + if !result.text.is_empty() {
131 + on_delta(Delta::Text(&result.text));
132 + }
133 + Ok(Completion {
134 + content: result.text,
135 + tool_calls: result
136 + .tool_calls
137 + .into_iter()
138 + .map(|call| ToolCallRequest {
139 + id: call.id,
140 + name: call.function_name,
141 + arguments: call.arguments,
142 + extra_content: None,
143 + })
144 + .collect(),
145 + finish_reason: Some(result.finish_reason),
146 + usage: Some(Usage {
147 + prompt_tokens: result.prompt_tokens as u64,
148 + completion_tokens: result.completion_tokens as u64,
149 + total_tokens: (result.prompt_tokens + result.completion_tokens) as u64,
150 + }),
151 + })
152 + }
153 +
154 + /// OpenAI chat messages, as the turn loop keeps them, to onde turns.
155 + pub(super) fn to_turns(messages: &[Value]) -> Result<Vec<ChatTurn>> {
156 + messages
157 + .iter()
158 + .map(|message| {
159 + let role = message.get("role").and_then(Value::as_str).unwrap_or("");
160 + let content = text_of(message.get("content"));
161 + Ok(match role {
162 + "system" | "developer" => ChatTurn::System(content),
163 + "user" => ChatTurn::User(content),
164 + "assistant" => ChatTurn::Assistant {
165 + content,
166 + tool_calls: message
167 + .get("tool_calls")
168 + .and_then(Value::as_array)
169 + .map(|calls| calls.iter().map(to_call).collect())
170 + .unwrap_or_default(),
171 + },
172 + "tool" => ChatTurn::Tool {
173 + tool_call_id: message
174 + .get("tool_call_id")
175 + .and_then(Value::as_str)
176 + .unwrap_or_default()
177 + .to_string(),
178 + content,
179 + },
180 + other => bail!("unexpected message role {other:?}"),
181 + })
182 + })
183 + .collect()
184 + }
185 +
186 + fn to_call(call: &Value) -> OndeCall {
187 + let field = |pointer: &str| {
188 + call.pointer(pointer)
189 + .and_then(Value::as_str)
190 + .unwrap_or_default()
191 + .to_string()
192 + };
193 + OndeCall {
194 + id: field("/id"),
195 + function_name: field("/function/name"),
196 + arguments: field("/function/arguments"),
197 + }
198 + }
199 +
200 + /// A message's text: a string, or the text parts of a content array. On-device models here
201 + /// are text-only, so other parts become a short placeholder.
202 + fn text_of(content: Option<&Value>) -> String {
203 + match content {
204 + Some(Value::String(text)) => text.clone(),
205 + Some(Value::Array(parts)) => parts
206 + .iter()
207 + .map(|part| match part.get("type").and_then(Value::as_str) {
208 + Some("text") => part
209 + .get("text")
210 + .and_then(Value::as_str)
211 + .unwrap_or_default()
212 + .to_string(),
213 + Some(kind) => format!("[{kind} omitted: the on-device model reads text only]"),
214 + None => String::new(),
215 + })
216 + .filter(|text| !text.is_empty())
217 + .collect::<Vec<_>>()
218 + .join("\n"),
219 + _ => String::new(),
220 + }
221 + }
222 +
223 + /// OpenAI `tools` entries to onde tool definitions.
224 + pub(super) fn to_tools(tools: &Value) -> Vec<ToolDefinition> {
225 + tools
226 + .as_array()
227 + .map(|tools| {
228 + tools
229 + .iter()
230 + .filter_map(|tool| {
231 + let function = tool.get("function")?;
232 + Some(ToolDefinition {
233 + name: function.get("name")?.as_str()?.to_string(),
234 + description: function
235 + .get("description")
236 + .and_then(Value::as_str)
237 + .unwrap_or_default()
238 + .to_string(),
239 + parameters_schema: function
240 + .get("parameters")
241 + .cloned()
242 + .unwrap_or_else(|| serde_json::json!({"type": "object"}))
243 + .to_string(),
244 + })
245 + })
246 + .collect()
247 + })
248 + .unwrap_or_default()
249 + }
250 +}
251 +
252 +#[cfg(all(test, feature = "local"))]
253 +mod tests {
254 + use onde::inference::{ChatTurn, ToolCallRequest};
255 + use serde_json::json;
256 +
257 + use super::enabled::{catalog, to_tools, to_turns};
258 + use crate::llm::LOCAL_DEFAULT_MODEL;
259 +
260 + #[test]
261 + fn the_default_model_is_in_the_catalog() {
262 + assert!(catalog().iter().any(|m| m.id == LOCAL_DEFAULT_MODEL));
263 + assert!(catalog().iter().all(|m| {
264 + m.owned_by
265 + .as_deref()
266 + .is_some_and(|o| o.ends_with("(on device)"))
267 + }));
268 + }
269 +
270 + #[test]
271 + fn messages_become_turns() {
272 + let messages = vec![
273 + json!({"role": "system", "content": "You are SplitFire."}),
274 + json!({"role": "user", "content": [
275 + {"type": "text", "text": "What key is this?"},
276 + {"type": "image_url", "image_url": {"url": "data:..."}}
277 + ]}),
278 + json!({"role": "assistant", "content": "", "tool_calls": [
279 + {"id": "call_1", "type": "function",
280 + "function": {"name": "analyze_audio", "arguments": "{\"path\":\"a.wav\"}"}}
281 + ]}),
282 + json!({"role": "tool", "tool_call_id": "call_1", "content": "Key: A minor"}),
283 + json!({"role": "assistant", "content": "A minor."}),
284 + ];
285 + let turns = to_turns(&messages).unwrap();
286 + assert_eq!(
287 + turns,
288 + vec![
289 + ChatTurn::System("You are SplitFire.".into()),
290 + ChatTurn::User(
291 + "What key is this?\n[image_url omitted: the on-device model reads text only]"
292 + .into()
293 + ),
294 + ChatTurn::Assistant {
295 + content: String::new(),
296 + tool_calls: vec![ToolCallRequest {
297 + id: "call_1".into(),
298 + function_name: "analyze_audio".into(),
299 + arguments: "{\"path\":\"a.wav\"}".into(),
300 + }],
301 + },
302 + ChatTurn::Tool {
303 + tool_call_id: "call_1".into(),
304 + content: "Key: A minor".into(),
305 + },
306 + ChatTurn::Assistant {
307 + content: "A minor.".into(),
308 + tool_calls: vec![],
309 + },
310 + ]
311 + );
312 + assert!(to_turns(&[json!({"role": "narrator", "content": "x"})]).is_err());
313 + }
314 +
315 + #[test]
316 + fn tools_become_definitions() {
317 + let tools = json!([
318 + {"type": "function", "function": {
319 + "name": "theory_chord", "description": "Spell a chord.",
320 + "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}
321 + }},
322 + {"type": "function", "function": {"name": "no_params"}},
323 + {"type": "not-a-function"}
324 + ]);
325 + let defs = to_tools(&tools);
326 + assert_eq!(defs.len(), 2);
327 + assert_eq!(defs[0].name, "theory_chord");
328 + assert_eq!(defs[0].description, "Spell a chord.");
329 + let schema: serde_json::Value = serde_json::from_str(&defs[0].parameters_schema).unwrap();
330 + assert_eq!(schema["properties"]["symbol"]["type"], "string");
331 + assert_eq!(defs[1].parameters_schema, r#"{"type":"object"}"#);
332 + }
333 +}