wip
keypair34 committed
Apr 14, 2026 at 18:43 UTC
2f66cf9a0ed53b506ffe57713e2e05d7ebcdba92
6 files changed
+679
-13
Cargo.lock
+1
@@ -3628,6 +3628,7 @@ dependencies = [
3628
"mistralrs",
3629
"mistralrs-core",
3630
"serde",
3631
+ "serde_json",
3632
"thiserror 2.0.18",
3633
"tokio",
3634
"tsync",
Cargo.toml
+1
@@ -25,6 +25,7 @@ anyhow = "1"
25
home = "0.5.4"
26
log = "^0.4"
27
serde = { version = "1.0", features = ["derive"] }
28
+serde_json = "1.0"
29
tokio = { version = "1", features = ["macros", "rt", "sync"] }
30
tsync = "2"
31
src/inference/engine.rs
+571
-11
@@ -94,7 +94,22 @@ use super::types::*;
94
target_os = "linux",
95
target_os = "android"
96
))]
97
-use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
97
+use mistralrs::{
98
+ CalledFunction, Function, GgufModelBuilder, Model, RequestBuilder, TextMessageRole, Tool,
99
+ ToolCallResponse, ToolCallType, ToolChoice, ToolType,
100
+};
101
+
102
+#[cfg(any(
103
+ target_os = "macos",
104
+ target_os = "ios",
105
+ target_os = "tvos",
106
+ target_os = "visionos",
107
+ target_os = "watchos",
108
+ target_os = "windows",
109
+ target_os = "linux",
110
+ target_os = "android"
111
+))]
112
+use std::collections::HashMap;
113
114
// ISQ types are only used by load_isq_model, which is macOS-only.
115
#[cfg(target_os = "macos")]
@@ -166,13 +181,41 @@ struct LoadedModel {
181
/// Configuration used to load this model (kept for status reporting).
182
config: LoadedModelConfig,
183
/// Conversation history (system prompt is stored separately).
169
- history: Vec<ChatMessage>,
184
+ history: Vec<HistoryEntry>,
185
/// System prompt prepended to every request.
186
system_prompt: Option<String>,
187
/// Sampling parameters for generation.
188
sampling: SamplingConfig,
189
}
190
191
+/// An entry in the conversation history (internal only).
192
+/// Supports tool-related messages alongside regular text.
193
+#[cfg(any(
194
+ target_os = "macos",
195
+ target_os = "ios",
196
+ target_os = "tvos",
197
+ target_os = "visionos",
198
+ target_os = "watchos",
199
+ target_os = "windows",
200
+ target_os = "linux",
201
+ target_os = "android"
202
+))]
203
+#[derive(Debug, Clone)]
204
+enum HistoryEntry {
205
+ /// A regular text message (user, assistant, system).
206
+ Text(ChatMessage),
207
+ /// An assistant response that includes tool calls.
208
+ AssistantToolCall {
209
+ content: String,
210
+ tool_calls: Vec<ToolCallRequest>,
211
+ },
212
+ /// A tool execution result.
213
+ ToolResult {
214
+ tool_call_id: String,
215
+ content: String,
216
+ },
217
+}
218
+
219
// ═════════════════════════════════════════════════════════════════════════════
220
// ChatEngine — the public API
221
// ═════════════════════════════════════════════════════════════════════════════
@@ -519,10 +562,24 @@ impl ChatEngine {
562
// ── Conversation history ─────────────────────────────────────────────
563
564
/// Get a clone of the full conversation history.
565
+ ///
566
+ /// Tool-related history entries are converted back to plain
567
+ /// [`ChatMessage`] values (assistant tool-call entries become assistant
568
+ /// messages with just their text content; tool results are omitted).
569
pub async fn history(&self) -> Vec<ChatMessage> {
570
let guard = self.inner.lock().await;
571
match guard.as_ref() {
525
- Some(loaded) => loaded.history.clone(),
572
+ Some(loaded) => loaded
573
+ .history
574
+ .iter()
575
+ .filter_map(|entry| match entry {
576
+ HistoryEntry::Text(msg) => Some(msg.clone()),
577
+ HistoryEntry::AssistantToolCall { content, .. } => {
578
+ Some(ChatMessage::assistant(content))
579
+ }
580
+ HistoryEntry::ToolResult { .. } => None,
581
+ })
582
+ .collect(),
583
None => Vec::new(),
584
}
585
}
@@ -548,7 +605,7 @@ impl ChatEngine {
605
/// Useful for restoring a saved conversation or injecting context.
606
pub async fn push_history(&self, message: ChatMessage) {
607
if let Some(loaded) = self.inner.lock().await.as_mut() {
551
- loaded.history.push(message);
608
+ loaded.history.push(HistoryEntry::Text(message));
609
}
610
}
611
@@ -612,8 +669,12 @@ impl ChatEngine {
669
{
670
let mut guard = self.inner.lock().await;
671
if let Some(loaded) = guard.as_mut() {
615
- loaded.history.push(ChatMessage::user(user_message));
616
- loaded.history.push(ChatMessage::assistant(reply.clone()));
672
+ loaded
673
+ .history
674
+ .push(HistoryEntry::Text(ChatMessage::user(user_message)));
675
+ loaded
676
+ .history
677
+ .push(HistoryEntry::Text(ChatMessage::assistant(reply.clone())));
678
}
679
}
680
@@ -797,10 +858,12 @@ impl ChatEngine {
858
{
859
let mut guard = inner_ref.lock().await;
860
if let Some(loaded) = guard.as_mut() {
800
- loaded.history.push(ChatMessage::user(user_msg_clone));
861
loaded
862
.history
803
- .push(ChatMessage::assistant(assembled.trim()));
863
+ .push(HistoryEntry::Text(ChatMessage::user(user_msg_clone)));
864
+ loaded
865
+ .history
866
+ .push(HistoryEntry::Text(ChatMessage::assistant(assembled.trim())));
867
}
868
}
869
@@ -828,6 +891,283 @@ impl ChatEngine {
891
892
Ok(rx)
893
}
894
+
895
+ // ── Tool-aware inference (Rust-only) ─────────────────────────────────
896
+
897
+ /// Send a user message with tool definitions available. Non-streaming.
898
+ ///
899
+ /// If the model decides to call tools, [`ToolAwareResult::tool_calls`]
900
+ /// will be non-empty and `finish_reason` will typically be `"tool_calls"`.
901
+ ///
902
+ /// The user message and response (including any tool calls) are
903
+ /// automatically added to conversation history.
904
+ pub async fn send_message_with_tools(
905
+ &self,
906
+ user_message: impl Into<String>,
907
+ tools: &[ToolDefinition],
908
+ ) -> Result<ToolAwareResult, InferenceError> {
909
+ let user_message = user_message.into();
910
+
911
+ let (model, request) = {
912
+ let guard = self.inner.lock().await;
913
+ let loaded = guard.as_ref().ok_or(InferenceError::NoModelLoaded)?;
914
+ let request = build_request_with_tools(loaded, &user_message, tools);
915
+ (loaded.model.clone(), request)
916
+ };
917
+
918
+ log::info!(
919
+ "ChatEngine: tool inference START — message: \"{}\"",
920
+ truncate_for_log(&user_message, 100)
921
+ );
922
+
923
+ let start = std::time::Instant::now();
924
+ let response =
925
+ model
926
+ .send_chat_request(request)
927
+ .await
928
+ .map_err(|e| InferenceError::Inference {
929
+ reason: e.to_string(),
930
+ })?;
931
+ let elapsed = start.elapsed();
932
+
933
+ let choice = &response.choices[0];
934
+ let reply = choice
935
+ .message
936
+ .content
937
+ .as_ref()
938
+ .map(|c| c.trim().to_string())
939
+ .unwrap_or_default();
940
+ let finish_reason = choice.finish_reason.clone();
941
+ let tool_calls = parse_tool_calls(choice);
942
+
943
+ log::info!(
944
+ "ChatEngine: tool inference END — {} — tool_calls: {} — reply: \"{}\"",
945
+ format_duration(elapsed),
946
+ tool_calls.len(),
947
+ truncate_for_log(&reply, 100)
948
+ );
949
+
950
+ // Persist to history.
951
+ {
952
+ let mut guard = self.inner.lock().await;
953
+ if let Some(loaded) = guard.as_mut() {
954
+ loaded
955
+ .history
956
+ .push(HistoryEntry::Text(ChatMessage::user(&user_message)));
957
+ if tool_calls.is_empty() {
958
+ loaded
959
+ .history
960
+ .push(HistoryEntry::Text(ChatMessage::assistant(&reply)));
961
+ } else {
962
+ loaded.history.push(HistoryEntry::AssistantToolCall {
963
+ content: reply.clone(),
964
+ tool_calls: tool_calls.clone(),
965
+ });
966
+ }
967
+ }
968
+ }
969
+
970
+ Ok(ToolAwareResult {
971
+ text: reply,
972
+ tool_calls,
973
+ duration_secs: elapsed.as_secs_f64(),
974
+ duration_display: format_duration(elapsed),
975
+ finish_reason,
976
+ })
977
+ }
978
+
979
+ /// Send tool execution results back to the model. Non-streaming.
980
+ ///
981
+ /// Call this after executing the tools from a previous
982
+ /// [`send_message_with_tools`](Self::send_message_with_tools) response.
983
+ /// Pass the same (or updated) tool definitions if the model should be
984
+ /// allowed to make further tool calls.
985
+ pub async fn send_tool_results(
986
+ &self,
987
+ results: Vec<ToolResult>,
988
+ tools: Option<&[ToolDefinition]>,
989
+ ) -> Result<ToolAwareResult, InferenceError> {
990
+ let (model, request) = {
991
+ let guard = self.inner.lock().await;
992
+ let loaded = guard.as_ref().ok_or(InferenceError::NoModelLoaded)?;
993
+ let request = build_tool_results_request(loaded, &results, tools);
994
+ (loaded.model.clone(), request)
995
+ };
996
+
997
+ log::info!(
998
+ "ChatEngine: tool results inference START — {} results",
999
+ results.len()
1000
+ );
1001
+
1002
+ let start = std::time::Instant::now();
1003
+ let response =
1004
+ model
1005
+ .send_chat_request(request)
1006
+ .await
1007
+ .map_err(|e| InferenceError::Inference {
1008
+ reason: e.to_string(),
1009
+ })?;
1010
+ let elapsed = start.elapsed();
1011
+
1012
+ let choice = &response.choices[0];
1013
+ let reply = choice
1014
+ .message
1015
+ .content
1016
+ .as_ref()
1017
+ .map(|c| c.trim().to_string())
1018
+ .unwrap_or_default();
1019
+ let finish_reason = choice.finish_reason.clone();
1020
+ let tool_calls = parse_tool_calls(choice);
1021
+
1022
+ log::info!(
1023
+ "ChatEngine: tool results inference END — {} — tool_calls: {} — reply: \"{}\"",
1024
+ format_duration(elapsed),
1025
+ tool_calls.len(),
1026
+ truncate_for_log(&reply, 100)
1027
+ );
1028
+
1029
+ // Persist to history.
1030
+ {
1031
+ let mut guard = self.inner.lock().await;
1032
+ if let Some(loaded) = guard.as_mut() {
1033
+ for result in &results {
1034
+ loaded.history.push(HistoryEntry::ToolResult {
1035
+ tool_call_id: result.tool_call_id.clone(),
1036
+ content: result.content.clone(),
1037
+ });
1038
+ }
1039
+ if tool_calls.is_empty() {
1040
+ loaded
1041
+ .history
1042
+ .push(HistoryEntry::Text(ChatMessage::assistant(&reply)));
1043
+ } else {
1044
+ loaded.history.push(HistoryEntry::AssistantToolCall {
1045
+ content: reply.clone(),
1046
+ tool_calls: tool_calls.clone(),
1047
+ });
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ Ok(ToolAwareResult {
1053
+ text: reply,
1054
+ tool_calls,
1055
+ duration_secs: elapsed.as_secs_f64(),
1056
+ duration_display: format_duration(elapsed),
1057
+ finish_reason,
1058
+ })
1059
+ }
1060
+
1061
+ /// Stream tool execution results back to the model.
1062
+ ///
1063
+ /// Similar to [`send_tool_results`](Self::send_tool_results) but returns
1064
+ /// a streaming receiver. Tool calls in the streaming response are NOT
1065
+ /// parsed — this is intended for the final text response after all tool
1066
+ /// rounds complete.
1067
+ pub async fn stream_tool_results(
1068
+ &self,
1069
+ results: Vec<ToolResult>,
1070
+ tools: Option<Vec<ToolDefinition>>,
1071
+ ) -> Result<tokio::sync::mpsc::Receiver<StreamChunk>, InferenceError> {
1072
+ let (model, request) = {
1073
+ let guard = self.inner.lock().await;
1074
+ let loaded = guard.as_ref().ok_or(InferenceError::NoModelLoaded)?;
1075
+ let request = build_tool_results_request(loaded, &results, tools.as_deref());
1076
+ (loaded.model.clone(), request)
1077
+ };
1078
+
1079
+ let (tx, rx) = tokio::sync::mpsc::channel::<StreamChunk>(64);
1080
+
1081
+ let inner_ptr = &self.inner as *const Mutex<Option<LoadedModel>>;
1082
+ // SAFETY: ChatEngine is stored in a Lazy static or equivalent with
1083
+ // 'static lifetime. The spawned task cannot outlive the engine.
1084
+ let inner_ref: &'static Mutex<Option<LoadedModel>> = unsafe { &*inner_ptr };
1085
+
1086
+ let results_clone = results;
1087
+
1088
+ tokio::task::spawn(async move {
1089
+ let stream_result = model.stream_chat_request(request).await;
1090
+
1091
+ match stream_result {
1092
+ Ok(mut stream) => {
1093
+ let mut assembled = String::new();
1094
+ let mut last_finish_reason = None;
1095
+
1096
+ while let Some(response) = stream.next().await {
1097
+ match response {
1098
+ mistralrs::Response::Chunk(chunk) => {
1099
+ if let Some(choice) = chunk.choices.first() {
1100
+ if let Some(ref text) = choice.delta.content {
1101
+ assembled.push_str(text);
1102
+ let _ = tx
1103
+ .send(StreamChunk {
1104
+ delta: text.clone(),
1105
+ done: false,
1106
+ finish_reason: None,
1107
+ })
1108
+ .await;
1109
+ }
1110
+ if let Some(ref reason) = choice.finish_reason {
1111
+ last_finish_reason = Some(reason.clone());
1112
+ }
1113
+ }
1114
+ }
1115
+ mistralrs::Response::Done(_) => break,
1116
+ mistralrs::Response::InternalError(e) => {
1117
+ log::error!("stream_tool_results internal error: {}", e);
1118
+ break;
1119
+ }
1120
+ mistralrs::Response::ValidationError(e) => {
1121
+ log::error!("stream_tool_results validation error: {}", e);
1122
+ break;
1123
+ }
1124
+ mistralrs::Response::ModelError(msg, _) => {
1125
+ log::error!("stream_tool_results model error: {}", msg);
1126
+ break;
1127
+ }
1128
+ _ => break,
1129
+ }
1130
+ }
1131
+
1132
+ // Persist to history.
1133
+ {
1134
+ let mut guard = inner_ref.lock().await;
1135
+ if let Some(loaded) = guard.as_mut() {
1136
+ for result in &results_clone {
1137
+ loaded.history.push(HistoryEntry::ToolResult {
1138
+ tool_call_id: result.tool_call_id.clone(),
1139
+ content: result.content.clone(),
1140
+ });
1141
+ }
1142
+ loaded
1143
+ .history
1144
+ .push(HistoryEntry::Text(ChatMessage::assistant(assembled.trim())));
1145
+ }
1146
+ }
1147
+
1148
+ let _ = tx
1149
+ .send(StreamChunk {
1150
+ delta: String::new(),
1151
+ done: true,
1152
+ finish_reason: last_finish_reason,
1153
+ })
1154
+ .await;
1155
+ }
1156
+ Err(e) => {
1157
+ log::error!("stream_tool_results failed: {}", e);
1158
+ let _ = tx
1159
+ .send(StreamChunk {
1160
+ delta: String::new(),
1161
+ done: true,
1162
+ finish_reason: Some(format!("error: {}", e)),
1163
+ })
1164
+ .await;
1165
+ }
1166
+ }
1167
+ });
1168
+
1169
+ Ok(rx)
1170
+ }
1171
}
1172
1173
// ═════════════════════════════════════════════════════════════════════════════
@@ -877,8 +1217,19 @@ fn build_request(loaded: &LoadedModel, user_message: &str) -> RequestBuilder {
1217
}
1218
1219
// Replay conversation history for multi-turn context.
880
- for turn in &loaded.history {
881
- req = req.add_message(chat_role_to_mistral(&turn.role), &turn.content);
1220
+ for entry in &loaded.history {
1221
+ match entry {
1222
+ HistoryEntry::Text(msg) => {
1223
+ req = req.add_message(chat_role_to_mistral(&msg.role), &msg.content);
1224
+ }
1225
+ HistoryEntry::AssistantToolCall { content, .. } => {
1226
+ // When replaying without tools, just use the text content.
1227
+ req = req.add_message(TextMessageRole::Assistant, content);
1228
+ }
1229
+ HistoryEntry::ToolResult { .. } => {
1230
+ // Skip tool results when not in tool mode.
1231
+ }
1232
+ }
1233
}
1234
1235
// Append the current user message.
@@ -887,6 +1238,193 @@ fn build_request(loaded: &LoadedModel, user_message: &str) -> RequestBuilder {
1238
req
1239
}
1240
1241
+/// Build a request with tool support, properly replaying tool-related history.
1242
+#[cfg(any(
1243
+ target_os = "macos",
1244
+ target_os = "ios",
1245
+ target_os = "tvos",
1246
+ target_os = "visionos",
1247
+ target_os = "watchos",
1248
+ target_os = "windows",
1249
+ target_os = "linux",
1250
+ target_os = "android"
1251
+))]
1252
+fn build_request_with_tools(
1253
+ loaded: &LoadedModel,
1254
+ user_message: &str,
1255
+ tools: &[ToolDefinition],
1256
+) -> RequestBuilder {
1257
+ let mut req = RequestBuilder::new();
1258
+ req = apply_sampling(req, &loaded.sampling);
1259
+
1260
+ if let Some(ref sp) = loaded.system_prompt {
1261
+ req = req.add_message(TextMessageRole::System, sp);
1262
+ }
1263
+
1264
+ req = replay_history_with_tools(req, &loaded.history);
1265
+
1266
+ req = req.add_message(TextMessageRole::User, user_message);
1267
+
1268
+ // Add tool definitions.
1269
+ req = attach_tools(req, tools);
1270
+
1271
+ req
1272
+}
1273
+
1274
+/// Build a request for sending tool results back to the model.
1275
+#[cfg(any(
1276
+ target_os = "macos",
1277
+ target_os = "ios",
1278
+ target_os = "tvos",
1279
+ target_os = "visionos",
1280
+ target_os = "watchos",
1281
+ target_os = "windows",
1282
+ target_os = "linux",
1283
+ target_os = "android"
1284
+))]
1285
+fn build_tool_results_request(
1286
+ loaded: &LoadedModel,
1287
+ results: &[ToolResult],
1288
+ tools: Option<&[ToolDefinition]>,
1289
+) -> RequestBuilder {
1290
+ let mut req = RequestBuilder::new();
1291
+ req = apply_sampling(req, &loaded.sampling);
1292
+
1293
+ if let Some(ref sp) = loaded.system_prompt {
1294
+ req = req.add_message(TextMessageRole::System, sp);
1295
+ }
1296
+
1297
+ // Replay full history (including tool call entries).
1298
+ req = replay_history_with_tools(req, &loaded.history);
1299
+
1300
+ // Add new tool results.
1301
+ for result in results {
1302
+ req = req.add_tool_message(&result.content, &result.tool_call_id);
1303
+ }
1304
+
1305
+ // Optionally add tools for further rounds.
1306
+ if let Some(tool_defs) = tools {
1307
+ req = attach_tools(req, tool_defs);
1308
+ }
1309
+
1310
+ req
1311
+}
1312
+
1313
+/// Replay conversation history entries onto a [`RequestBuilder`], preserving
1314
+/// tool call and tool result messages.
1315
+#[cfg(any(
1316
+ target_os = "macos",
1317
+ target_os = "ios",
1318
+ target_os = "tvos",
1319
+ target_os = "visionos",
1320
+ target_os = "watchos",
1321
+ target_os = "windows",
1322
+ target_os = "linux",
1323
+ target_os = "android"
1324
+))]
1325
+fn replay_history_with_tools(mut req: RequestBuilder, history: &[HistoryEntry]) -> RequestBuilder {
1326
+ for entry in history {
1327
+ match entry {
1328
+ HistoryEntry::Text(msg) => {
1329
+ req = req.add_message(chat_role_to_mistral(&msg.role), &msg.content);
1330
+ }
1331
+ HistoryEntry::AssistantToolCall {
1332
+ content,
1333
+ tool_calls,
1334
+ } => {
1335
+ let mistral_tcs: Vec<ToolCallResponse> = tool_calls
1336
+ .iter()
1337
+ .map(|tc| ToolCallResponse {
1338
+ index: 0,
1339
+ id: tc.id.clone(),
1340
+ tp: ToolCallType::Function,
1341
+ function: CalledFunction {
1342
+ name: tc.function_name.clone(),
1343
+ arguments: tc.arguments.clone(),
1344
+ },
1345
+ })
1346
+ .collect();
1347
+ req = req.add_message_with_tool_call(
1348
+ TextMessageRole::Assistant,
1349
+ content.clone(),
1350
+ mistral_tcs,
1351
+ );
1352
+ }
1353
+ HistoryEntry::ToolResult {
1354
+ tool_call_id,
1355
+ content,
1356
+ } => {
1357
+ req = req.add_tool_message(content, tool_call_id);
1358
+ }
1359
+ }
1360
+ }
1361
+ req
1362
+}
1363
+
1364
+/// Attach [`ToolDefinition`] slices as mistralrs [`Tool`] values and set
1365
+/// [`ToolChoice::Auto`].
1366
+#[cfg(any(
1367
+ target_os = "macos",
1368
+ target_os = "ios",
1369
+ target_os = "tvos",
1370
+ target_os = "visionos",
1371
+ target_os = "watchos",
1372
+ target_os = "windows",
1373
+ target_os = "linux",
1374
+ target_os = "android"
1375
+))]
1376
+fn attach_tools(mut req: RequestBuilder, tools: &[ToolDefinition]) -> RequestBuilder {
1377
+ if !tools.is_empty() {
1378
+ let mistral_tools: Vec<Tool> = tools
1379
+ .iter()
1380
+ .map(|td| {
1381
+ let params: HashMap<String, serde_json::Value> =
1382
+ serde_json::from_str(&td.parameters_schema).unwrap_or_default();
1383
+ Tool {
1384
+ tp: ToolType::Function,
1385
+ function: Function {
1386
+ description: Some(td.description.clone()),
1387
+ name: td.name.clone(),
1388
+ parameters: Some(params),
1389
+ strict: Some(true),
1390
+ },
1391
+ }
1392
+ })
1393
+ .collect();
1394
+ req = req.set_tools(mistral_tools);
1395
+ req = req.set_tool_choice(ToolChoice::Auto);
1396
+ }
1397
+ req
1398
+}
1399
+
1400
+/// Parse tool calls from a mistralrs response choice.
1401
+#[cfg(any(
1402
+ target_os = "macos",
1403
+ target_os = "ios",
1404
+ target_os = "tvos",
1405
+ target_os = "visionos",
1406
+ target_os = "watchos",
1407
+ target_os = "windows",
1408
+ target_os = "linux",
1409
+ target_os = "android"
1410
+))]
1411
+fn parse_tool_calls(choice: &mistralrs::Choice) -> Vec<ToolCallRequest> {
1412
+ choice
1413
+ .message
1414
+ .tool_calls
1415
+ .as_ref()
1416
+ .map(|tcs| {
1417
+ tcs.iter()
1418
+ .map(|tc| ToolCallRequest {
1419
+ id: tc.id.clone(),
1420
+ function_name: tc.function.name.clone(),
1421
+ arguments: tc.function.arguments.clone(),
1422
+ })
1423
+ .collect()
1424
+ })
1425
+ .unwrap_or_default()
1426
+}
1427
+
1428
/// Apply a [`SamplingConfig`] to a [`RequestBuilder`].
1429
#[cfg(any(
1430
target_os = "macos",
@@ -923,7 +1461,7 @@ fn apply_sampling(mut req: RequestBuilder, sampling: &SamplingConfig) -> Request
1461
req
1462
}
1463
926
-/// Convert [`ChatRole`] → [`TextMessageRole`].
1464
+/// Convert a [`ChatRole`] to a [`TextMessageRole`].
1465
#[cfg(any(
1466
target_os = "macos",
1467
target_os = "ios",
@@ -1161,6 +1699,28 @@ impl GgufModelConfig {
1699
}
1700
}
1701
1702
+ /// Qwen 3 4B (GGUF Q4_K_M) — tool-calling-capable, ~2.7 GB.
1703
+ pub fn qwen3_4b() -> Self {
1704
+ Self {
1705
+ model_id: super::models::BARTOWSKI_QWEN3_4B_GGUF.into(),
1706
+ files: vec![super::models::QWEN3_4B_GGUF_FILE.into()],
1707
+ tok_model_id: None,
1708
+ display_name: "Qwen 3 4B (Q4_K_M)".into(),
1709
+ approx_memory: "~2.7 GB".into(),
1710
+ }
1711
+ }
1712
+
1713
+ /// Qwen 3 1.7B (GGUF Q4_K_M) — lightweight tool-calling, ~1.3 GB.
1714
+ pub fn qwen3_1_7b() -> Self {
1715
+ Self {
1716
+ model_id: super::models::BARTOWSKI_QWEN3_1_7B_GGUF.into(),
1717
+ files: vec![super::models::QWEN3_1_7B_GGUF_FILE.into()],
1718
+ tok_model_id: None,
1719
+ display_name: "Qwen 3 1.7B (Q4_K_M)".into(),
1720
+ approx_memory: "~1.3 GB".into(),
1721
+ }
1722
+ }
1723
+
1724
/// Return the platform-appropriate default **coding** model config.
1725
///
1726
/// - iOS / Android → Qwen 2.5 Coder 1.5B (~941 MB, fits mobile memory budgets)
src/inference/mod.rs
+15
-2
@@ -67,6 +67,19 @@ pub mod types;
67
pub use engine::ChatEngine;
68
pub use ffi::{OndeChatEngine, StreamChunkListener};
69
pub use types::{
70
- ChatMessage, ChatRole, EngineInfo, EngineStatus, GgufModelConfig, InferenceError,
71
- InferenceResult, IsqModelConfig, SamplingConfig, StreamChunk,
70
+ ChatMessage,
71
+ ChatRole,
72
+ EngineInfo,
73
+ EngineStatus,
74
+ GgufModelConfig,
75
+ InferenceError,
76
+ InferenceResult,
77
+ IsqModelConfig,
78
+ SamplingConfig,
79
+ StreamChunk,
80
+ // Tool calling (Rust-only, not UniFFI-exported)
81
+ ToolAwareResult,
82
+ ToolCallRequest,
83
+ ToolDefinition,
84
+ ToolResult,
85
};
src/inference/models.rs
+37
@@ -63,12 +63,33 @@ pub const QWEN25_3B_GGUF_FILE: &str = "Qwen2.5-3B-Instruct-Q4_K_M.gguf";
63
/// Base model repo used for the HF tokenizer (tokenizer.json + tokenizer_config.json).
64
pub const QWEN25_3B_TOK_MODEL_ID: &str = "Qwen/Qwen2.5-3B-Instruct";
65
66
+/// Pre-quantized Qwen 3 4B (GGUF Q4_K_M) — tool-calling-capable coding model (~2.7 GB).
67
+///
68
+/// Qwen 3 is the first Qwen family supported by mistral.rs for structured tool calling
69
+/// (via the `<tool_call>` / `</tool_call>` XML format). The 4B variant offers a good
70
+/// balance of tool-calling reliability and memory footprint for macOS desktops.
71
+///
72
+/// bartowski's GGUF embeds the full tokenizer and chat template.
73
+pub const BARTOWSKI_QWEN3_4B_GGUF: &str = "bartowski/Qwen_Qwen3-4B-GGUF";
74
+/// The specific GGUF filename to download from the bartowski Qwen3 4B repo.
75
+pub const QWEN3_4B_GGUF_FILE: &str = "Qwen_Qwen3-4B-Q4_K_M.gguf";
76
+
77
+/// Pre-quantized Qwen 3 1.7B (GGUF Q4_K_M) — lightweight tool-calling model (~1.3 GB).
78
+///
79
+/// Smallest Qwen 3 variant with tool calling support. Suitable for mobile devices
80
+/// where the 4B model would be too large.
81
+pub const BARTOWSKI_QWEN3_1_7B_GGUF: &str = "bartowski/Qwen_Qwen3-1.7B-GGUF";
82
+/// The specific GGUF filename for the Qwen3 1.7B repo.
83
+pub const QWEN3_1_7B_GGUF_FILE: &str = "Qwen_Qwen3-1.7B-Q4_K_M.gguf";
84
+
85
/// All model IDs that the Onde inference engine supports.
86
/// Used by `list_local_hf_models` to filter the HuggingFace cache
87
/// to only show models that can actually be used for generation.
88
pub const SUPPORTED_MODELS: &[&str] = &[
89
BARTOWSKI_QWEN25_1_5B_INSTRUCT_GGUF,
90
BARTOWSKI_QWEN25_3B_INSTRUCT_GGUF,
91
+ BARTOWSKI_QWEN3_4B_GGUF,
92
+ BARTOWSKI_QWEN3_1_7B_GGUF,
93
];
94
95
/// Rich metadata for a supported model, used by the frontend to display
@@ -117,4 +138,20 @@ pub const SUPPORTED_MODEL_INFO: &[SupportedModelInfo] = &[
138
// Exact file size from HuggingFace API siblings[].size.
139
expected_size_bytes: 1_929_903_264,
140
},
141
+ SupportedModelInfo {
142
+ id: BARTOWSKI_QWEN3_4B_GGUF,
143
+ name: "Qwen 3 4B (GGUF)",
144
+ org: "Qwen / Alibaba",
145
+ description: "Tool-calling-capable model for macOS — balanced quality and size (~2.7 GB). \
146
+ First Qwen family with structured tool calling support in mistral.rs.",
147
+ expected_size_bytes: 2_600_000_000,
148
+ },
149
+ SupportedModelInfo {
150
+ id: BARTOWSKI_QWEN3_1_7B_GGUF,
151
+ name: "Qwen 3 1.7B (GGUF)",
152
+ org: "Qwen / Alibaba",
153
+ description: "Lightweight tool-calling model for mobile (~1.3 GB). \
154
+ Smallest Qwen 3 variant with tool calling support.",
155
+ expected_size_bytes: 1_282_439_584,
156
+ },
157
];
src/inference/types.rs
+54
@@ -374,6 +374,60 @@ pub fn format_duration(d: std::time::Duration) -> String {
374
}
375
}
376
377
+// ── Tool calling types (Rust-only) ──────────────────────────────────────────
378
+//
379
+// These types are intentionally NOT annotated with `uniffi::Record` /
380
+// `uniffi::Enum` — they are for Rust consumers only. The UniFFI surface
381
+// area (Swift / Kotlin) remains unchanged.
382
+
383
+/// Definition of a tool the model can invoke.
384
+#[derive(Debug, Clone)]
385
+pub struct ToolDefinition {
386
+ /// Tool function name (e.g. `"get_weather"`).
387
+ pub name: String,
388
+ /// Human-readable description of what the tool does.
389
+ pub description: String,
390
+ /// JSON Schema for function parameters, as a serialised JSON string.
391
+ pub parameters_schema: String,
392
+}
393
+
394
+/// A tool call requested by the model.
395
+#[derive(Debug, Clone)]
396
+pub struct ToolCallRequest {
397
+ /// Unique identifier for this tool call (used to correlate results).
398
+ pub id: String,
399
+ /// Name of the function the model wants to invoke.
400
+ pub function_name: String,
401
+ /// JSON string of the function arguments.
402
+ pub arguments: String,
403
+}
404
+
405
+/// Result of executing a tool, to send back to the model.
406
+#[derive(Debug, Clone)]
407
+pub struct ToolResult {
408
+ /// The `id` from the corresponding [`ToolCallRequest`].
409
+ pub tool_call_id: String,
410
+ /// The tool's output (plain text or JSON).
411
+ pub content: String,
412
+}
413
+
414
+/// Result from a tool-aware inference call (Rust-only, not UniFFI-exported).
415
+#[derive(Debug, Clone)]
416
+pub struct ToolAwareResult {
417
+ /// Text content of the assistant reply (may be empty when the model
418
+ /// decides to call tools instead of responding with text).
419
+ pub text: String,
420
+ /// Tool calls requested by the model. Empty when the model responds
421
+ /// with text only.
422
+ pub tool_calls: Vec<ToolCallRequest>,
423
+ /// Wall-clock inference duration in seconds.
424
+ pub duration_secs: f64,
425
+ /// Human-readable duration string (e.g. `"1.3s"`).
426
+ pub duration_display: String,
427
+ /// Finish reason reported by the model (e.g. `"stop"`, `"tool_calls"`).
428
+ pub finish_reason: String,
429
+}
430
+
431
#[cfg(test)]
432
mod tests {
433
use super::*;