1use anyhow::{Context, Result};
11use futures::StreamExt;
12use genai::chat::{
13 ChatMessage, ChatOptions, ChatRequest, ChatStreamEvent, Tool, ToolCall, ToolChoice,
14 ToolResponse,
15};
16
17use crate::llm_provider::GenAIProvider;
18
19#[derive(Debug, Clone, PartialEq)]
21pub struct CoreToolSpec {
22 pub name: String,
23 pub description: String,
24 pub schema: serde_json::Value,
25 pub strict: bool,
26}
27
28#[derive(Debug, Clone, PartialEq)]
30pub struct CoreToolCall {
31 pub call_id: String,
32 pub name: String,
33 pub arguments: serde_json::Value,
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub enum CoreTurnOutput {
39 ToolCalls(Vec<CoreToolCall>),
40 Text(String),
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum CoreTurnStreamEvent {
49 Text(String),
50 Reasoning(String),
51}
52
53#[derive(Debug, Clone, PartialEq)]
55pub enum CoreMessage {
56 System { content: String },
57 User { content: String },
58 Assistant { content: String },
59 AssistantToolCalls { calls: Vec<CoreToolCall> },
60 ToolResponse { call_id: String, content: String },
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum CoreToolChoice {
66 Auto,
67 None,
68 Required,
69 Specific(String),
70}
71
72#[derive(Debug, Clone, Default, PartialEq)]
74pub struct CoreGenerationConfig {
75 pub max_tokens: Option<u32>,
76 pub temperature: Option<f32>,
77 pub stop_sequences: Vec<String>,
78}
79
80fn render_message(message: &CoreMessage) -> ChatMessage {
82 match message {
83 CoreMessage::System { content } => ChatMessage::system(content.clone()),
84 CoreMessage::User { content } => ChatMessage::user(content.clone()),
85 CoreMessage::Assistant { content } => ChatMessage::assistant(content.clone()),
86 CoreMessage::AssistantToolCalls { calls } => ChatMessage::from(
87 calls
88 .iter()
89 .map(|call| ToolCall {
90 call_id: call.call_id.clone(),
91 fn_name: call.name.clone(),
92 fn_arguments: call.arguments.clone(),
93 thought_signatures: None,
94 })
95 .collect::<Vec<_>>(),
96 ),
97 CoreMessage::ToolResponse { call_id, content } => {
98 ChatMessage::from(ToolResponse::new(call_id.clone(), content.clone()))
99 }
100 }
101}
102
103fn render_tool(spec: &CoreToolSpec) -> Tool {
105 let tool = Tool::new(spec.name.clone())
106 .with_description(spec.description.clone())
107 .with_schema(spec.schema.clone());
108 if spec.strict {
109 tool.with_strict(true)
110 } else {
111 tool
112 }
113}
114
115fn render_choice(choice: &CoreToolChoice) -> ToolChoice {
116 match choice {
117 CoreToolChoice::Auto => ToolChoice::Auto,
118 CoreToolChoice::None => ToolChoice::None,
119 CoreToolChoice::Required => ToolChoice::Required,
120 CoreToolChoice::Specific(name) => ToolChoice::Tool { name: name.clone() },
121 }
122}
123
124fn render_request(messages: &[CoreMessage], tools: &[CoreToolSpec]) -> ChatRequest {
125 let rendered = messages.iter().map(render_message).collect();
126 let mut request = ChatRequest::new(rendered);
127 if !tools.is_empty() {
128 request = request.with_tools(tools.iter().map(render_tool).collect::<Vec<_>>());
129 }
130 request
131}
132
133fn render_options(choice: &CoreToolChoice, config: &CoreGenerationConfig) -> ChatOptions {
134 let mut options = ChatOptions::default().with_tool_choice(render_choice(choice));
135 if let Some(max_tokens) = config.max_tokens {
136 options = options.with_max_tokens(max_tokens);
137 }
138 if let Some(temperature) = config.temperature {
139 options = options.with_temperature(f64::from(temperature));
140 }
141 if !config.stop_sequences.is_empty() {
142 options = options.with_stop_sequences(config.stop_sequences.clone());
143 }
144 options
145}
146
147impl GenAIProvider {
148 pub async fn chat_turn_with_tools(
154 &self,
155 model: &str,
156 messages: &[CoreMessage],
157 tools: &[CoreToolSpec],
158 choice: CoreToolChoice,
159 ) -> Result<CoreTurnOutput> {
160 self.chat_turn_configured(
161 model,
162 messages,
163 tools,
164 choice,
165 CoreGenerationConfig::default(),
166 )
167 .await
168 }
169
170 pub async fn chat_turn_configured(
172 &self,
173 model: &str,
174 messages: &[CoreMessage],
175 tools: &[CoreToolSpec],
176 choice: CoreToolChoice,
177 config: CoreGenerationConfig,
178 ) -> Result<CoreTurnOutput> {
179 let request = render_request(messages, tools);
180 let options = render_options(&choice, &config);
181
182 let response = self
183 .client()
184 .exec_chat(model, request, Some(&options))
185 .await
186 .with_context(|| format!("tool-calling chat turn against {model:?}"))?;
187
188 self.add_tokens(response.usage.total_tokens.map(|t| t as usize).unwrap_or(0))
189 .await;
190
191 let calls: Vec<CoreToolCall> = response
192 .tool_calls()
193 .into_iter()
194 .map(|call| CoreToolCall {
195 call_id: call.call_id.clone(),
196 name: call.fn_name.clone(),
197 arguments: call.fn_arguments.clone(),
198 })
199 .collect();
200 if !calls.is_empty() {
201 return Ok(CoreTurnOutput::ToolCalls(calls));
202 }
203 Ok(CoreTurnOutput::Text(
204 response.first_text().unwrap_or_default().to_string(),
205 ))
206 }
207
208 pub async fn stream_tool_turn(
214 &self,
215 model: &str,
216 messages: &[CoreMessage],
217 tools: &[CoreToolSpec],
218 choice: CoreToolChoice,
219 mut emit: impl FnMut(CoreTurnStreamEvent),
220 ) -> Result<CoreTurnOutput> {
221 let request = render_request(messages, tools);
222 let options = render_options(&choice, &CoreGenerationConfig::default())
223 .with_capture_content(true)
224 .with_capture_tool_calls(true)
225 .with_capture_usage(true);
226 let response = self
227 .client()
228 .exec_chat_stream(model, request, Some(&options))
229 .await
230 .with_context(|| format!("streaming tool-aware chat turn against {model:?}"))?;
231 let mut stream = response.stream;
232 let mut end = None;
233
234 while let Some(event) = stream.next().await {
235 match event? {
236 ChatStreamEvent::ReasoningChunk(chunk) if !chunk.content.is_empty() => {
237 emit(CoreTurnStreamEvent::Reasoning(chunk.content));
238 }
239 ChatStreamEvent::End(value) => {
240 end = Some(value);
241 break;
242 }
243 ChatStreamEvent::Start
244 | ChatStreamEvent::Chunk(_)
245 | ChatStreamEvent::ReasoningChunk(_)
246 | ChatStreamEvent::ToolCallChunk(_)
247 | ChatStreamEvent::ThoughtSignatureChunk(_) => {}
248 }
249 }
250
251 let end = end.context("tool-aware model stream ended without a terminal event")?;
252 self.add_tokens(
253 end.captured_usage
254 .as_ref()
255 .and_then(|usage| usage.total_tokens)
256 .map(|tokens| tokens as usize)
257 .unwrap_or(0),
258 )
259 .await;
260
261 let calls = end
262 .captured_tool_calls()
263 .unwrap_or_default()
264 .into_iter()
265 .map(|call| CoreToolCall {
266 call_id: call.call_id.clone(),
267 name: call.fn_name.clone(),
268 arguments: call.fn_arguments.clone(),
269 })
270 .collect::<Vec<_>>();
271 if !calls.is_empty() {
272 return Ok(CoreTurnOutput::ToolCalls(calls));
273 }
274
275 let text = end.captured_first_text().unwrap_or_default().to_string();
276 if !text.is_empty() {
277 emit(CoreTurnStreamEvent::Text(text.clone()));
278 }
279 Ok(CoreTurnOutput::Text(text))
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn renders_every_message_role() {
289 let messages = [
290 CoreMessage::System {
291 content: "s".into(),
292 },
293 CoreMessage::User {
294 content: "u".into(),
295 },
296 CoreMessage::Assistant {
297 content: "a".into(),
298 },
299 CoreMessage::AssistantToolCalls {
300 calls: vec![CoreToolCall {
301 call_id: "c1".into(),
302 name: "read_file".into(),
303 arguments: serde_json::json!({"path": "x"}),
304 }],
305 },
306 CoreMessage::ToolResponse {
307 call_id: "c1".into(),
308 content: "ok".into(),
309 },
310 ];
311 let rendered: Vec<ChatMessage> = messages.iter().map(render_message).collect();
312 assert_eq!(rendered.len(), 5);
313 use genai::chat::ChatRole;
314 assert_eq!(rendered[0].role, ChatRole::System);
315 assert_eq!(rendered[3].role, ChatRole::Assistant);
316 assert_eq!(rendered[4].role, ChatRole::Tool);
317 }
318
319 #[test]
320 fn strict_flag_reaches_the_vendor_tool() {
321 let spec = CoreToolSpec {
322 name: "edit_file".into(),
323 description: "d".into(),
324 schema: serde_json::json!({"type": "object"}),
325 strict: true,
326 };
327 let tool = render_tool(&spec);
328 assert_eq!(tool.strict, Some(true));
329 let lax = render_tool(&CoreToolSpec {
330 strict: false,
331 ..spec
332 });
333 assert_eq!(lax.strict, None);
334 }
335
336 #[test]
337 fn choice_policies_map_one_to_one() {
338 assert!(matches!(
339 render_choice(&CoreToolChoice::Auto),
340 ToolChoice::Auto
341 ));
342 assert!(matches!(
343 render_choice(&CoreToolChoice::Required),
344 ToolChoice::Required
345 ));
346 assert!(matches!(
347 render_choice(&CoreToolChoice::Specific("grep".into())),
348 ToolChoice::Tool { name } if name == "grep"
349 ));
350 }
351}