foundry_local_sdk/openai/
chat_client.rs1#![allow(deprecated)] use 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#[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 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 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
121pub type ChatCompletionStream = JsonStream<CreateChatCompletionStreamResponse>;
125
126#[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 pub fn frequency_penalty(mut self, v: f64) -> Self {
149 self.settings.frequency_penalty = Some(v);
150 self
151 }
152
153 pub fn max_tokens(mut self, v: u32) -> Self {
155 self.settings.max_tokens = Some(v);
156 self
157 }
158
159 pub fn n(mut self, v: u32) -> Self {
161 self.settings.n = Some(v);
162 self
163 }
164
165 pub fn temperature(mut self, v: f64) -> Self {
167 self.settings.temperature = Some(v);
168 self
169 }
170
171 pub fn presence_penalty(mut self, v: f64) -> Self {
173 self.settings.presence_penalty = Some(v);
174 self
175 }
176
177 pub fn top_p(mut self, v: f64) -> Self {
179 self.settings.top_p = Some(v);
180 self
181 }
182
183 pub fn top_k(mut self, v: u32) -> Self {
185 self.settings.top_k = Some(v);
186 self
187 }
188
189 pub fn random_seed(mut self, v: u64) -> Self {
191 self.settings.random_seed = Some(v);
192 self
193 }
194
195 pub fn response_format(mut self, v: ChatResponseFormat) -> Self {
197 self.settings.response_format = Some(v);
198 self
199 }
200
201 pub fn tool_choice(mut self, v: ChatToolChoice) -> Self {
203 self.settings.tool_choice = Some(v);
204 self
205 }
206
207 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 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
284fn 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 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 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 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}