agent_framework_foundry_local/
lib.rs1pub mod convert;
53
54use std::collections::{HashMap, VecDeque};
55use std::sync::Arc;
56
57use agent_framework_core::client::{ChatClient, ChatStream};
58use agent_framework_core::error::{Error, Result};
59use agent_framework_core::streaming::Utf8StreamDecoder;
60use agent_framework_core::types::{ChatOptions, ChatResponse, ChatResponseUpdate, Message};
61use futures::StreamExt;
62use serde_json::Value;
63
64const DEFAULT_BASE_URL: &str = "http://localhost:5273/v1";
66
67const FOUNDRY_LOCAL_ENDPOINT_ENV: &str = "FOUNDRY_LOCAL_ENDPOINT";
70
71const FOUNDRY_LOCAL_API_KEY_ENV: &str = "FOUNDRY_LOCAL_API_KEY";
75
76fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<f64> {
80 headers
81 .get(reqwest::header::RETRY_AFTER)
82 .and_then(|v| v.to_str().ok())
83 .and_then(|s| s.trim().parse::<f64>().ok())
84 .filter(|s| s.is_finite() && *s >= 0.0)
85}
86
87#[derive(Clone)]
89pub struct FoundryLocalChatClient {
90 inner: Arc<Inner>,
91}
92
93#[derive(Clone)]
94struct Inner {
95 http: reqwest::Client,
96 base_url: String,
97 model: String,
98 api_key: Option<String>,
102}
103
104impl std::fmt::Debug for FoundryLocalChatClient {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.debug_struct("FoundryLocalChatClient")
107 .field("base_url", &self.inner.base_url)
108 .field("model", &self.inner.model)
109 .field("has_api_key", &self.inner.api_key.is_some())
110 .finish_non_exhaustive()
111 }
112}
113
114impl FoundryLocalChatClient {
115 pub fn new(model: impl Into<String>) -> Self {
118 Self {
119 inner: Arc::new(Inner {
120 http: reqwest::Client::new(),
121 base_url: DEFAULT_BASE_URL.to_string(),
122 model: model.into(),
123 api_key: None,
124 }),
125 }
126 }
127
128 pub fn from_env(model: impl Into<String>) -> Result<Self> {
135 let mut client = Self::new(model);
136 if let Ok(endpoint) = std::env::var(FOUNDRY_LOCAL_ENDPOINT_ENV) {
137 if !endpoint.trim().is_empty() {
138 client = client.with_base_url(endpoint);
139 }
140 }
141 if let Ok(api_key) = std::env::var(FOUNDRY_LOCAL_API_KEY_ENV) {
142 if !api_key.trim().is_empty() {
143 client = client.with_api_key(api_key);
144 }
145 }
146 Ok(client)
147 }
148
149 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
153 Arc::make_mut(&mut self.inner).base_url = base_url.into();
154 self
155 }
156
157 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
161 Arc::make_mut(&mut self.inner).api_key = Some(api_key.into());
162 self
163 }
164
165 pub fn model(&self) -> &str {
167 &self.inner.model
168 }
169
170 pub fn base_url(&self) -> &str {
172 &self.inner.base_url
173 }
174
175 fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
176 let model = options
177 .model
178 .clone()
179 .unwrap_or_else(|| self.inner.model.clone());
180 convert::build_request(messages, options, &model, stream)
181 }
182
183 async fn post(&self, body: &Value) -> Result<reqwest::Response> {
184 let url = format!(
185 "{}/chat/completions",
186 self.inner.base_url.trim_end_matches('/')
187 );
188 let mut req = self.inner.http.post(&url).json(body);
189 if let Some(key) = &self.inner.api_key {
190 req = req.bearer_auth(key);
191 }
192 let resp = req
193 .send()
194 .await
195 .map_err(|e| Error::service(format!("request failed: {e}")))?;
196 if !resp.status().is_success() {
197 let status = resp.status();
198 let retry_after = parse_retry_after(resp.headers());
199 let text = resp.text().await.unwrap_or_default();
200 return Err(agent_framework_openai::classify_service_error(
204 status.as_u16(),
205 &text,
206 format!("Foundry Local API error {status}: {text}"),
207 retry_after,
208 ));
209 }
210 Ok(resp)
211 }
212}
213
214#[async_trait::async_trait]
215impl ChatClient for FoundryLocalChatClient {
216 async fn get_response(
217 &self,
218 messages: Vec<Message>,
219 options: ChatOptions,
220 ) -> Result<ChatResponse> {
221 let body = self.build_body(&messages, &options, false);
222 let resp = self.post(&body).await?;
223 let value: Value = resp
224 .json()
225 .await
226 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
227 Ok(agent_framework_openai::convert::parse_response(&value))
228 }
229
230 async fn get_streaming_response(
231 &self,
232 messages: Vec<Message>,
233 options: ChatOptions,
234 ) -> Result<ChatStream> {
235 let body = self.build_body(&messages, &options, true);
236 let resp = self.post(&body).await?;
237 Ok(parse_sse_stream(resp).boxed())
238 }
239
240 fn model(&self) -> Option<&str> {
241 Some(&self.inner.model)
242 }
243}
244
245type ByteStream =
246 std::pin::Pin<Box<dyn futures::Stream<Item = reqwest::Result<bytes::Bytes>> + Send>>;
247
248fn parse_sse_stream(
250 resp: reqwest::Response,
251) -> impl futures::Stream<Item = Result<ChatResponseUpdate>> + Send {
252 let byte_stream: ByteStream = Box::pin(resp.bytes_stream());
253 futures::stream::unfold(
254 SseState {
255 byte_stream,
256 buffer: String::new(),
257 utf8: Utf8StreamDecoder::new(),
258 queued: VecDeque::new(),
259 tool_ids: HashMap::new(),
260 done: false,
261 },
262 |mut state| async move {
263 loop {
264 if let Some(update) = state.queued.pop_front() {
265 return Some((Ok(update), state));
266 }
267 if state.done {
268 return None;
269 }
270 match state.byte_stream.next().await {
271 Some(Ok(bytes)) => {
272 let decoded = state.utf8.push(&bytes);
273 state.buffer.push_str(&decoded);
274 while let Some(pos) = state.buffer.find('\n') {
275 let line = state.buffer[..pos].trim().to_string();
276 state.buffer.drain(..=pos);
277 if let Some(data) = line.strip_prefix("data:") {
278 let data = data.trim();
279 if data == "[DONE]" {
280 return drain_or_end(state);
281 }
282 if data.is_empty() {
283 continue;
284 }
285 if let Ok(value) = serde_json::from_str::<Value>(data) {
286 if let Some(err) = value.get("error") {
287 let msg = err
288 .get("message")
289 .and_then(Value::as_str)
290 .unwrap_or("unknown stream error")
291 .to_string();
292 state.done = true;
293 return Some((Err(Error::service(msg)), state));
294 }
295 if let Some(update) =
296 convert::parse_delta(&value, &mut state.tool_ids)
297 {
298 state.queued.push_back(update);
299 }
300 }
301 }
302 }
303 }
304 Some(Err(e)) => {
305 state.done = true;
306 return Some((Err(Error::service(format!("stream error: {e}"))), state));
307 }
308 None => return drain_or_end(state),
309 }
310 }
311 },
312 )
313}
314
315struct SseState {
317 byte_stream: ByteStream,
318 buffer: String,
319 utf8: Utf8StreamDecoder,
320 queued: VecDeque<ChatResponseUpdate>,
321 tool_ids: HashMap<i64, String>,
322 done: bool,
323}
324
325fn drain_or_end(mut state: SseState) -> Option<(Result<ChatResponseUpdate>, SseState)> {
326 match state.queued.pop_front() {
327 Some(update) => {
328 state.done = true;
329 Some((Ok(update), state))
330 }
331 None => None,
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
342 fn default_base_url_is_localhost_v1() {
343 let client = FoundryLocalChatClient::new("phi-3.5-mini");
344 assert_eq!(client.base_url(), "http://localhost:5273/v1");
345 assert_eq!(client.model(), "phi-3.5-mini");
346 }
347
348 #[test]
349 fn with_base_url_overrides_default() {
350 let client = FoundryLocalChatClient::new("phi-3.5-mini")
351 .with_base_url("http://example.test:5273/v1");
352 assert_eq!(client.base_url(), "http://example.test:5273/v1");
353 }
354
355 #[test]
356 fn with_api_key_sets_bearer_token() {
357 let client = FoundryLocalChatClient::new("phi-3.5-mini").with_api_key("secret");
358 assert_eq!(client.inner.api_key.as_deref(), Some("secret"));
359 }
360
361 #[test]
362 fn no_api_key_by_default() {
363 let client = FoundryLocalChatClient::new("phi-3.5-mini");
364 assert!(client.inner.api_key.is_none());
365 }
366
367 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
374
375 #[test]
376 fn from_env_never_fails_without_env_vars() {
377 let _guard = ENV_MUTEX.lock().unwrap();
378 unsafe {
381 std::env::remove_var(FOUNDRY_LOCAL_ENDPOINT_ENV);
382 std::env::remove_var(FOUNDRY_LOCAL_API_KEY_ENV);
383 }
384 let client = FoundryLocalChatClient::from_env("phi-3.5-mini").unwrap();
385 assert_eq!(client.base_url(), DEFAULT_BASE_URL);
386 assert_eq!(client.model(), "phi-3.5-mini");
387 assert!(client.inner.api_key.is_none());
388 }
389
390 #[test]
391 fn from_env_reads_endpoint_and_api_key() {
392 let _guard = ENV_MUTEX.lock().unwrap();
393 unsafe {
395 std::env::set_var(FOUNDRY_LOCAL_ENDPOINT_ENV, "http://192.168.1.50:5273/v1");
396 std::env::set_var(FOUNDRY_LOCAL_API_KEY_ENV, "secret");
397 }
398 let client = FoundryLocalChatClient::from_env("phi-3.5-mini").unwrap();
399 assert_eq!(client.base_url(), "http://192.168.1.50:5273/v1");
400 assert_eq!(client.inner.api_key.as_deref(), Some("secret"));
401 unsafe {
402 std::env::remove_var(FOUNDRY_LOCAL_ENDPOINT_ENV);
403 std::env::remove_var(FOUNDRY_LOCAL_API_KEY_ENV);
404 }
405 }
406
407 #[test]
412 fn build_body_uses_client_default_model_when_options_model_unset() {
413 let client = FoundryLocalChatClient::new("phi-3.5-mini");
414 let body = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
415 assert_eq!(body["model"], serde_json::json!("phi-3.5-mini"));
416 assert_eq!(
417 body["messages"],
418 serde_json::json!([{ "role": "user", "content": "hi" }])
419 );
420 }
421
422 #[test]
423 fn build_body_prefers_per_request_model() {
424 let client = FoundryLocalChatClient::new("phi-3.5-mini");
425 let options = ChatOptions {
426 model: Some("qwen2.5".to_string()),
427 ..ChatOptions::new()
428 };
429 let body = client.build_body(&[Message::user("hi")], &options, false);
430 assert_eq!(body["model"], serde_json::json!("qwen2.5"));
431 }
432
433 #[test]
434 fn build_body_sets_stream_flag() {
435 let client = FoundryLocalChatClient::new("phi-3.5-mini");
436 let body = client.build_body(&[Message::user("hi")], &ChatOptions::new(), true);
437 assert_eq!(body["stream"], serde_json::json!(true));
438 }
439
440 fn sse_bytes(lines: &[String]) -> bytes::Bytes {
445 bytes::Bytes::from(lines.join("\n") + "\n\n")
446 }
447
448 async fn collect_via_state(text: bytes::Bytes) -> Vec<ChatResponseUpdate> {
449 let stream = futures::stream::once(async move { Ok::<_, reqwest::Error>(text) });
450 let byte_stream: ByteStream = Box::pin(stream);
451 let mut state = SseState {
452 byte_stream,
453 buffer: String::new(),
454 utf8: Utf8StreamDecoder::new(),
455 queued: VecDeque::new(),
456 tool_ids: HashMap::new(),
457 done: false,
458 };
459 let mut updates = Vec::new();
460 if let Some(Ok(bytes)) = state.byte_stream.next().await {
461 let decoded = state.utf8.push(&bytes);
462 state.buffer.push_str(&decoded);
463 while let Some(pos) = state.buffer.find('\n') {
464 let line = state.buffer[..pos].trim().to_string();
465 state.buffer.drain(..=pos);
466 let Some(data) = line.strip_prefix("data:") else {
467 continue;
468 };
469 let data = data.trim();
470 if data.is_empty() || data == "[DONE]" {
471 continue;
472 }
473 let value: Value = serde_json::from_str(data).unwrap();
474 if let Some(update) = convert::parse_delta(&value, &mut state.tool_ids) {
475 updates.push(update);
476 }
477 }
478 }
479 updates
480 }
481
482 #[tokio::test]
483 async fn streaming_chunk_produces_text_update() {
484 let chunk = serde_json::json!({
485 "id": "chatcmpl-1",
486 "model": "phi-3.5-mini",
487 "choices": [{ "delta": { "role": "assistant", "content": "Hello" }, "finish_reason": null }],
488 });
489 let bytes = sse_bytes(&[format!("data: {chunk}")]);
490 let updates = collect_via_state(bytes).await;
491 assert_eq!(updates.len(), 1);
492 let resp = ChatResponse::from_updates(updates);
493 assert_eq!(resp.text(), "Hello");
494 assert_eq!(resp.response_id.as_deref(), Some("chatcmpl-1"));
495 }
496
497 #[tokio::test]
498 async fn streaming_tool_call_and_finish_reason_accumulate() {
499 let call_chunk = serde_json::json!({
500 "id": "chatcmpl-2",
501 "choices": [{
502 "delta": { "tool_calls": [{ "index": 0, "id": "call_1", "function": { "name": "get_weather", "arguments": "{}" } }] },
503 "finish_reason": null,
504 }],
505 });
506 let finish_chunk = serde_json::json!({
507 "id": "chatcmpl-2",
508 "choices": [{ "delta": {}, "finish_reason": "tool_calls" }],
509 });
510 let bytes = sse_bytes(&[
511 format!("data: {call_chunk}"),
512 format!("data: {finish_chunk}"),
513 ]);
514 let updates = collect_via_state(bytes).await;
515 assert_eq!(updates.len(), 2);
516 let resp = ChatResponse::from_updates(updates);
517 let calls = resp.function_calls();
518 assert_eq!(calls.len(), 1);
519 assert_eq!(calls[0].call_id, "call_1");
520 assert_eq!(calls[0].name, "get_weather");
521 assert_eq!(
522 resp.finish_reason,
523 Some(agent_framework_core::types::FinishReason::tool_calls())
524 );
525 }
526
527 #[tokio::test]
528 async fn streaming_done_sentinel_ends_stream_without_extra_update() {
529 let chunk = serde_json::json!({
530 "id": "chatcmpl-3",
531 "choices": [{ "delta": { "content": "hi" }, "finish_reason": null }],
532 });
533 let bytes = sse_bytes(&[format!("data: {chunk}"), "data: [DONE]".to_string()]);
534 let updates = collect_via_state(bytes).await;
535 assert_eq!(updates.len(), 1);
540 }
541
542 }