1use futures::future::BoxFuture;
52use serde::de::DeserializeOwned;
53
54use crate::error::LlmError;
55use crate::provider::{
56 ChatExtras, ChatResponse, ChatStream, LlmProvider, Message, Role, ToolDefinition,
57 cached_schema, short_type_name,
58};
59
60mod private {
61 pub trait Sealed {}
62 impl<T: super::LlmProvider> Sealed for T {}
63}
64
65pub trait LlmProviderDyn: private::Sealed + std::fmt::Debug + Send + Sync {
74 fn context_window(&self) -> Option<usize>;
76
77 fn chat<'a>(&'a self, messages: &'a [Message]) -> BoxFuture<'a, Result<String, LlmError>>;
83
84 fn chat_stream<'a>(
90 &'a self,
91 messages: &'a [Message],
92 ) -> BoxFuture<'a, Result<ChatStream, LlmError>>;
93
94 fn supports_streaming(&self) -> bool;
96
97 fn embed<'a>(&'a self, text: &'a str) -> BoxFuture<'a, Result<Vec<f32>, LlmError>>;
103
104 fn embed_batch<'a>(
110 &'a self,
111 texts: &'a [&'a str],
112 ) -> BoxFuture<'a, Result<Vec<Vec<f32>>, LlmError>>;
113
114 fn supports_embeddings(&self) -> bool;
116
117 fn name(&self) -> &str;
119
120 fn model_identifier(&self) -> &str;
122
123 fn effective_model_identifier(&self) -> &str;
126
127 fn supports_vision(&self) -> bool;
129
130 fn supports_tool_use(&self) -> bool;
132
133 fn chat_with_tools<'a>(
139 &'a self,
140 messages: &'a [Message],
141 tools: &'a [ToolDefinition],
142 ) -> BoxFuture<'a, Result<ChatResponse, LlmError>>;
143
144 fn last_cache_usage(&self) -> Option<(u64, u64)>;
147
148 fn last_usage(&self) -> Option<(u64, u64)>;
151
152 fn last_reasoning_tokens(&self) -> Option<u64> {
157 None
158 }
159
160 fn take_compaction_summary(&self) -> Option<String>;
162
163 fn chat_with_extras<'a>(
169 &'a self,
170 messages: &'a [Message],
171 ) -> BoxFuture<'a, Result<(String, ChatExtras), LlmError>>;
172
173 #[must_use]
175 fn debug_request_json(
176 &self,
177 messages: &[Message],
178 tools: &[ToolDefinition],
179 stream: bool,
180 ) -> serde_json::Value;
181
182 fn list_models(&self) -> Vec<String>;
184
185 fn supports_structured_output(&self) -> bool;
187}
188
189impl<T: LlmProvider + std::fmt::Debug + Send + Sync + 'static> LlmProviderDyn for T {
190 fn context_window(&self) -> Option<usize> {
191 LlmProvider::context_window(self)
192 }
193
194 fn chat<'a>(&'a self, messages: &'a [Message]) -> BoxFuture<'a, Result<String, LlmError>> {
195 Box::pin(LlmProvider::chat(self, messages))
196 }
197
198 fn chat_stream<'a>(
199 &'a self,
200 messages: &'a [Message],
201 ) -> BoxFuture<'a, Result<ChatStream, LlmError>> {
202 Box::pin(LlmProvider::chat_stream(self, messages))
203 }
204
205 fn supports_streaming(&self) -> bool {
206 LlmProvider::supports_streaming(self)
207 }
208
209 fn embed<'a>(&'a self, text: &'a str) -> BoxFuture<'a, Result<Vec<f32>, LlmError>> {
210 Box::pin(LlmProvider::embed(self, text))
211 }
212
213 fn embed_batch<'a>(
214 &'a self,
215 texts: &'a [&'a str],
216 ) -> BoxFuture<'a, Result<Vec<Vec<f32>>, LlmError>> {
217 Box::pin(LlmProvider::embed_batch(self, texts))
218 }
219
220 fn supports_embeddings(&self) -> bool {
221 LlmProvider::supports_embeddings(self)
222 }
223
224 fn name(&self) -> &str {
225 LlmProvider::name(self)
226 }
227
228 fn model_identifier(&self) -> &str {
229 LlmProvider::model_identifier(self)
230 }
231
232 fn effective_model_identifier(&self) -> &str {
233 LlmProvider::effective_model_identifier(self)
234 }
235
236 fn supports_vision(&self) -> bool {
237 LlmProvider::supports_vision(self)
238 }
239
240 fn supports_tool_use(&self) -> bool {
241 LlmProvider::supports_tool_use(self)
242 }
243
244 fn chat_with_tools<'a>(
245 &'a self,
246 messages: &'a [Message],
247 tools: &'a [ToolDefinition],
248 ) -> BoxFuture<'a, Result<ChatResponse, LlmError>> {
249 Box::pin(LlmProvider::chat_with_tools(self, messages, tools))
250 }
251
252 fn last_cache_usage(&self) -> Option<(u64, u64)> {
253 LlmProvider::last_cache_usage(self)
254 }
255
256 fn last_usage(&self) -> Option<(u64, u64)> {
257 LlmProvider::last_usage(self)
258 }
259
260 fn take_compaction_summary(&self) -> Option<String> {
261 LlmProvider::take_compaction_summary(self)
262 }
263
264 fn chat_with_extras<'a>(
265 &'a self,
266 messages: &'a [Message],
267 ) -> BoxFuture<'a, Result<(String, ChatExtras), LlmError>> {
268 Box::pin(LlmProvider::chat_with_extras(self, messages))
269 }
270
271 fn debug_request_json(
272 &self,
273 messages: &[Message],
274 tools: &[ToolDefinition],
275 stream: bool,
276 ) -> serde_json::Value {
277 LlmProvider::debug_request_json(self, messages, tools, stream)
278 }
279
280 fn list_models(&self) -> Vec<String> {
281 LlmProvider::list_models(self)
282 }
283
284 fn supports_structured_output(&self) -> bool {
285 LlmProvider::supports_structured_output(self)
286 }
287}
288
289#[tracing::instrument(name = "llm.provider_dyn.chat_typed_dyn", skip_all)]
333pub async fn chat_typed_dyn<T, P>(provider: &P, messages: &[Message]) -> Result<T, LlmError>
334where
335 T: DeserializeOwned + schemars::JsonSchema + 'static,
336 P: ?Sized + LlmProviderDyn,
337{
338 let (_, schema_json) = cached_schema::<T>()?;
339 let type_name = short_type_name::<T>();
340
341 let instruction = format!(
342 "Respond with a valid JSON object matching this schema. \
343 Output ONLY the JSON, no markdown fences or extra text.\n\n\
344 Type: {type_name}\nSchema:\n```json\n{schema_json}\n```"
345 );
346
347 let mut augmented = messages.to_vec();
348 augmented.insert(0, Message::from_legacy(Role::System, instruction));
349
350 let raw = provider.chat(&augmented).await?;
351 let cleaned = strip_json_fences(&raw);
352 match serde_json::from_str::<T>(cleaned) {
353 Ok(val) => Ok(val),
354 Err(first_err) => {
355 augmented.push(Message::from_legacy(Role::Assistant, &raw));
356 augmented.push(Message::from_legacy(
357 Role::User,
358 format!(
359 "Your response was not valid JSON. Error: {first_err}. \
360 Please output ONLY valid JSON matching the schema."
361 ),
362 ));
363 let retry_raw = provider.chat(&augmented).await?;
364 let retry_cleaned = strip_json_fences(&retry_raw);
365 serde_json::from_str::<T>(retry_cleaned)
366 .map_err(|e| LlmError::StructuredParse(format!("parse failed after retry: {e}")))
367 }
368 }
369}
370
371fn strip_json_fences(s: &str) -> &str {
373 s.trim()
374 .trim_start_matches("```json")
375 .trim_start_matches("```")
376 .trim_end_matches("```")
377 .trim()
378}
379
380#[cfg(test)]
381mod tests {
382 use std::sync::Arc;
383
384 use super::*;
385 use crate::provider::{ChatStream, StreamChunk};
386
387 #[derive(Debug)]
388 struct StubProvider {
389 response: String,
390 }
391
392 impl LlmProvider for StubProvider {
393 async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
394 Ok(self.response.clone())
395 }
396
397 async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
398 let response = LlmProvider::chat(self, messages).await?;
399 Ok(Box::pin(tokio_stream::once(Ok(StreamChunk::Content(
400 response,
401 )))))
402 }
403
404 fn supports_streaming(&self) -> bool {
405 false
406 }
407
408 async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
409 Ok(vec![0.1, 0.2, 0.3])
410 }
411
412 fn supports_embeddings(&self) -> bool {
413 false
414 }
415
416 fn name(&self) -> &'static str {
417 "stub"
418 }
419 }
420
421 #[tokio::test]
422 async fn dyn_chat_works() {
423 let provider: Arc<dyn LlmProviderDyn> = Arc::new(StubProvider {
424 response: "hello".into(),
425 });
426 let msgs = vec![Message::from_legacy(Role::User, "test")];
427 let result = provider.chat(&msgs).await.unwrap();
428 assert_eq!(result, "hello");
429 }
430
431 #[tokio::test]
432 async fn dyn_embed_works() {
433 let provider: Arc<dyn LlmProviderDyn> = Arc::new(StubProvider {
434 response: String::new(),
435 });
436 let result = provider.embed("hello").await.unwrap();
437 assert_eq!(result, vec![0.1_f32, 0.2, 0.3]);
438 }
439
440 #[test]
441 fn dyn_sync_methods_forward_correctly() {
442 let provider: Arc<dyn LlmProviderDyn> = Arc::new(StubProvider {
443 response: String::new(),
444 });
445 assert_eq!(provider.name(), "stub");
446 assert!(!provider.supports_streaming());
447 assert!(!provider.supports_embeddings());
448 assert!(provider.context_window().is_none());
449 assert!(provider.last_cache_usage().is_none());
450 assert!(provider.last_usage().is_none());
451 }
452
453 #[derive(Debug, serde::Deserialize, schemars::JsonSchema, PartialEq)]
454 struct TestOutput {
455 value: String,
456 }
457
458 #[tokio::test]
459 async fn chat_typed_dyn_happy_path() {
460 let provider: Arc<dyn LlmProviderDyn> = Arc::new(StubProvider {
461 response: r#"{"value": "hello"}"#.into(),
462 });
463 let msgs = vec![Message::from_legacy(Role::User, "test")];
464 let result: TestOutput = chat_typed_dyn(&*provider, &msgs).await.unwrap();
465 assert_eq!(
466 result,
467 TestOutput {
468 value: "hello".into()
469 }
470 );
471 }
472
473 #[tokio::test]
474 async fn chat_typed_dyn_strips_fences() {
475 let provider: Arc<dyn LlmProviderDyn> = Arc::new(StubProvider {
476 response: "```json\n{\"value\": \"fenced\"}\n```".into(),
477 });
478 let msgs = vec![Message::from_legacy(Role::User, "test")];
479 let result: TestOutput = chat_typed_dyn(&*provider, &msgs).await.unwrap();
480 assert_eq!(
481 result,
482 TestOutput {
483 value: "fenced".into()
484 }
485 );
486 }
487}