Skip to main content

foundry_local_sdk/openai/
chat_client.rs

1//! OpenAI-compatible chat completions client.
2#![allow(deprecated)] // this module implements the deprecated OpenAI facade
3
4use std::collections::HashMap;
5
6use async_openai::types::chat::{
7    ChatCompletionRequestMessage, ChatCompletionTools, CreateChatCompletionResponse,
8    CreateChatCompletionStreamResponse,
9};
10use serde_json::{json, Value};
11
12use crate::detail::native::NativeModel;
13use crate::detail::session::{run_openai_json_streaming, NativeSession};
14use crate::detail::task::spawn_blocking;
15use crate::error::{FoundryLocalError, Result};
16use crate::types::{ChatResponseFormat, ChatToolChoice};
17
18use super::json_stream::JsonStream;
19
20/// Tuning knobs for chat completion requests.
21///
22/// Use the chainable setter methods to configure, e.g.:
23///
24/// ```ignore
25/// let client = model.create_chat_client()
26///     .temperature(0.7)
27///     .max_tokens(256);
28/// ```
29#[derive(Debug, Clone, Default)]
30pub struct ChatClientSettings {
31    frequency_penalty: Option<f64>,
32    max_tokens: Option<u32>,
33    n: Option<u32>,
34    temperature: Option<f64>,
35    presence_penalty: Option<f64>,
36    top_p: Option<f64>,
37    top_k: Option<u32>,
38    random_seed: Option<u64>,
39    response_format: Option<ChatResponseFormat>,
40    tool_choice: Option<ChatToolChoice>,
41}
42
43impl ChatClientSettings {
44    fn serialize(&self) -> Value {
45        let mut map = serde_json::Map::new();
46
47        if let Some(v) = self.frequency_penalty {
48            map.insert("frequency_penalty".into(), json!(v));
49        }
50        if let Some(v) = self.max_tokens {
51            map.insert("max_tokens".into(), json!(v));
52        }
53        if let Some(v) = self.n {
54            map.insert("n".into(), json!(v));
55        }
56        if let Some(v) = self.presence_penalty {
57            map.insert("presence_penalty".into(), json!(v));
58        }
59        if let Some(v) = self.temperature {
60            map.insert("temperature".into(), json!(v));
61        }
62        if let Some(v) = self.top_p {
63            map.insert("top_p".into(), json!(v));
64        }
65
66        if let Some(ref rf) = self.response_format {
67            let mut rf_map = serde_json::Map::new();
68            match rf {
69                ChatResponseFormat::Text => {
70                    rf_map.insert("type".into(), json!("text"));
71                }
72                ChatResponseFormat::JsonObject => {
73                    rf_map.insert("type".into(), json!("json_object"));
74                }
75                ChatResponseFormat::JsonSchema(schema) => {
76                    rf_map.insert("type".into(), json!("json_schema"));
77                    rf_map.insert("json_schema".into(), json!(schema));
78                }
79                ChatResponseFormat::LarkGrammar(grammar) => {
80                    rf_map.insert("type".into(), json!("lark_grammar"));
81                    rf_map.insert("lark_grammar".into(), json!(grammar));
82                }
83            }
84            map.insert("response_format".into(), Value::Object(rf_map));
85        }
86
87        if let Some(ref tc) = self.tool_choice {
88            // Match the native `*-json` contract (see MapGuidance /
89            // chat_completions_converter.cc): `none`/`auto`/`required` are plain
90            // strings, while a named function is `{"type":"function",
91            // "function":{"name":"…"}}`.
92            let tc_value = match tc {
93                ChatToolChoice::None => json!("none"),
94                ChatToolChoice::Auto => json!("auto"),
95                ChatToolChoice::Required => json!("required"),
96                ChatToolChoice::Function(name) => json!({
97                    "type": "function",
98                    "function": { "name": name },
99                }),
100            };
101            map.insert("tool_choice".into(), tc_value);
102        }
103
104        // Foundry-specific metadata for settings that don't map directly to
105        // the OpenAI spec.
106        let mut metadata: HashMap<String, String> = HashMap::new();
107        if let Some(k) = self.top_k {
108            metadata.insert("top_k".into(), k.to_string());
109        }
110        if let Some(s) = self.random_seed {
111            metadata.insert("random_seed".into(), s.to_string());
112        }
113        if !metadata.is_empty() {
114            map.insert("metadata".into(), json!(metadata));
115        }
116
117        Value::Object(map)
118    }
119}
120
121/// A stream of [`CreateChatCompletionStreamResponse`] chunks.
122///
123/// Returned by [`ChatClient::complete_streaming_chat`].
124pub type ChatCompletionStream = JsonStream<CreateChatCompletionStreamResponse>;
125
126/// Client for OpenAI-compatible chat completions backed by a local model.
127#[deprecated(
128    since = "2.0.0",
129    note = "The OpenAI direct clients are deprecated; use the Session API instead \
130            (`ChatSession::new(&model)`)."
131)]
132pub struct ChatClient {
133    model_id: String,
134    model: NativeModel,
135    settings: ChatClientSettings,
136}
137
138impl ChatClient {
139    pub(crate) fn new(model_id: &str, model: NativeModel) -> Self {
140        Self {
141            model_id: model_id.to_owned(),
142            model,
143            settings: ChatClientSettings::default(),
144        }
145    }
146
147    /// Set the frequency penalty.
148    pub fn frequency_penalty(mut self, v: f64) -> Self {
149        self.settings.frequency_penalty = Some(v);
150        self
151    }
152
153    /// Set the maximum number of tokens to generate.
154    pub fn max_tokens(mut self, v: u32) -> Self {
155        self.settings.max_tokens = Some(v);
156        self
157    }
158
159    /// Set the number of completions to generate.
160    pub fn n(mut self, v: u32) -> Self {
161        self.settings.n = Some(v);
162        self
163    }
164
165    /// Set the sampling temperature.
166    pub fn temperature(mut self, v: f64) -> Self {
167        self.settings.temperature = Some(v);
168        self
169    }
170
171    /// Set the presence penalty.
172    pub fn presence_penalty(mut self, v: f64) -> Self {
173        self.settings.presence_penalty = Some(v);
174        self
175    }
176
177    /// Set the nucleus sampling probability.
178    pub fn top_p(mut self, v: f64) -> Self {
179        self.settings.top_p = Some(v);
180        self
181    }
182
183    /// Set the top-k sampling parameter (Foundry extension).
184    pub fn top_k(mut self, v: u32) -> Self {
185        self.settings.top_k = Some(v);
186        self
187    }
188
189    /// Set the random seed for reproducible results (Foundry extension).
190    pub fn random_seed(mut self, v: u64) -> Self {
191        self.settings.random_seed = Some(v);
192        self
193    }
194
195    /// Set the desired response format.
196    pub fn response_format(mut self, v: ChatResponseFormat) -> Self {
197        self.settings.response_format = Some(v);
198        self
199    }
200
201    /// Set the tool choice strategy.
202    pub fn tool_choice(mut self, v: ChatToolChoice) -> Self {
203        self.settings.tool_choice = Some(v);
204        self
205    }
206
207    /// Perform a non-streaming chat completion.
208    pub async fn complete_chat(
209        &self,
210        messages: &[ChatCompletionRequestMessage],
211        tools: Option<&[ChatCompletionTools]>,
212    ) -> Result<CreateChatCompletionResponse> {
213        if messages.is_empty() {
214            return Err(FoundryLocalError::Validation {
215                reason: "messages must be a non-empty array".into(),
216            });
217        }
218
219        let request = self.build_request(messages, tools, false)?;
220        let request_json = serde_json::to_string(&request)?;
221        let model = self.model.clone();
222
223        let raw = spawn_blocking(move || {
224            let session = NativeSession::create(&model)?;
225            session.run_openai_json(&request_json)
226        })
227        .await?;
228
229        let parsed: CreateChatCompletionResponse = serde_json::from_str(&raw)?;
230        Ok(parsed)
231    }
232
233    /// Perform a streaming chat completion, returning a [`ChatCompletionStream`].
234    ///
235    /// Use the stream with `futures_core::StreamExt::next()` or
236    /// `tokio_stream::StreamExt::next()`.
237    pub async fn complete_streaming_chat(
238        &self,
239        messages: &[ChatCompletionRequestMessage],
240        tools: Option<&[ChatCompletionTools]>,
241    ) -> Result<ChatCompletionStream> {
242        if messages.is_empty() {
243            return Err(FoundryLocalError::Validation {
244                reason: "messages must be a non-empty array".into(),
245            });
246        }
247
248        let request = self.build_request(messages, tools, true)?;
249        let request_json = serde_json::to_string(&request)?;
250        let model = self.model.clone();
251
252        let session = spawn_blocking(move || NativeSession::create(&model)).await?;
253        let rx = run_openai_json_streaming(session, request_json, Box::new(normalize_chat_chunk));
254        Ok(ChatCompletionStream::new(rx))
255    }
256
257    fn build_request(
258        &self,
259        messages: &[ChatCompletionRequestMessage],
260        tools: Option<&[ChatCompletionTools]>,
261        stream: bool,
262    ) -> Result<Value> {
263        let settings_value = self.settings.serialize();
264        let mut map = match settings_value {
265            Value::Object(m) => m,
266            _ => serde_json::Map::new(),
267        };
268
269        map.insert("model".into(), json!(self.model_id));
270        map.insert("messages".into(), serde_json::to_value(messages)?);
271
272        if stream {
273            map.insert("stream".into(), json!(true));
274        }
275
276        if let Some(t) = tools {
277            map.insert("tools".into(), serde_json::to_value(t)?);
278        }
279
280        Ok(Value::Object(map))
281    }
282}
283
284/// Normalize a streamed chat chunk so it parses as a
285/// [`CreateChatCompletionStreamResponse`].
286///
287/// Foundry Local streams tool calls under `"message"` instead of the standard
288/// `"delta"`; rewrite each such choice and ensure tool calls carry an `index`.
289/// Chunks that are not valid JSON are passed through unchanged so the stream
290/// surfaces the original parse error.
291fn normalize_chat_chunk(text: String) -> Option<String> {
292    let mut value: Value = match serde_json::from_str(&text) {
293        Ok(v) => v,
294        Err(_) => return Some(text),
295    };
296
297    if let Some(choices) = value.get_mut("choices").and_then(Value::as_array_mut) {
298        for choice in choices {
299            let Some(obj) = choice.as_object_mut() else {
300                continue;
301            };
302            if obj.contains_key("message") && !obj.contains_key("delta") {
303                if let Some(mut message) = obj.remove("message") {
304                    if let Some(tool_calls) =
305                        message.get_mut("tool_calls").and_then(Value::as_array_mut)
306                    {
307                        for (i, tc) in tool_calls.iter_mut().enumerate() {
308                            if let Some(tc_obj) = tc.as_object_mut() {
309                                tc_obj.entry("index").or_insert_with(|| json!(i));
310                            }
311                        }
312                    }
313                    obj.insert("delta".into(), message);
314                }
315            }
316        }
317    }
318
319    serde_json::to_string(&value).ok().or(Some(text))
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::types::{ChatResponseFormat, ChatToolChoice};
326
327    fn serialize_with(f: impl FnOnce(&mut ChatClientSettings)) -> Value {
328        let mut s = ChatClientSettings::default();
329        f(&mut s);
330        s.serialize()
331    }
332
333    #[test]
334    fn response_format_json_schema_uses_snake_case_key() {
335        // The native `*-json` converter (MapGuidance) reads `json_schema`, not
336        // `jsonSchema`; a camelCase key is silently dropped.
337        let v = serialize_with(|s| {
338            s.response_format = Some(ChatResponseFormat::JsonSchema(
339                "{\"type\":\"object\"}".into(),
340            ));
341        });
342        let rf = &v["response_format"];
343        assert_eq!(rf["type"], "json_schema");
344        assert_eq!(rf["json_schema"], "{\"type\":\"object\"}");
345        assert!(
346            rf.get("jsonSchema").is_none(),
347            "must not emit camelCase key"
348        );
349    }
350
351    #[test]
352    fn response_format_lark_grammar_uses_snake_case_key() {
353        let v = serialize_with(|s| {
354            s.response_format = Some(ChatResponseFormat::LarkGrammar("start: WORD+".into()));
355        });
356        let rf = &v["response_format"];
357        assert_eq!(rf["type"], "lark_grammar");
358        assert_eq!(rf["lark_grammar"], "start: WORD+");
359        assert!(
360            rf.get("larkGrammar").is_none(),
361            "must not emit camelCase key"
362        );
363    }
364
365    #[test]
366    fn tool_choice_simple_modes_are_plain_strings() {
367        // Native reads none/auto/required as JSON strings (tc.is_string()).
368        for (choice, expected) in [
369            (ChatToolChoice::None, "none"),
370            (ChatToolChoice::Auto, "auto"),
371            (ChatToolChoice::Required, "required"),
372        ] {
373            let v = serialize_with(|s| s.tool_choice = Some(choice));
374            assert_eq!(v["tool_choice"], expected);
375        }
376    }
377
378    #[test]
379    fn tool_choice_function_nests_name_under_function() {
380        // Native reads the target as tc["function"]["name"].
381        let v = serialize_with(|s| {
382            s.tool_choice = Some(ChatToolChoice::Function("get_weather".into()));
383        });
384        let tc = &v["tool_choice"];
385        assert_eq!(tc["type"], "function");
386        assert_eq!(tc["function"]["name"], "get_weather");
387        assert!(
388            tc.get("name").is_none(),
389            "name must be nested under `function`"
390        );
391    }
392
393    #[test]
394    fn foundry_metadata_carries_top_k_and_random_seed() {
395        let v = serialize_with(|s| {
396            s.top_k = Some(40);
397            s.random_seed = Some(7);
398        });
399        assert_eq!(v["metadata"]["top_k"], "40");
400        assert_eq!(v["metadata"]["random_seed"], "7");
401    }
402}