1use std::collections::BTreeMap;
2
3use async_trait::async_trait;
4use serde_json::{json, Value};
5
6use crate::http::{secret_header, Http};
7use crate::stream::{self, parse_data, Accumulator, ChatStream, StreamEvent};
8use crate::{
9 ChatRequest, ChatResponse, EmbeddingProvider, Embeddings, ImageSource, LlmError, LlmProvider,
10 Message, ModelInfo, Part, Result, Role, StopReason, Usage,
11};
12
13#[derive(Clone)]
22pub struct OpenAiProvider {
23 http: Http,
24 api_key: String,
25 base_url: String,
26 auth: Auth,
27 config_error: Option<String>,
29}
30
31const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
33
34#[derive(Clone, Copy, Debug)]
36enum Auth {
37 Bearer,
39 ApiKeyHeader,
41}
42
43impl std::fmt::Debug for OpenAiProvider {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.debug_struct("OpenAiProvider")
46 .field("api_key", &"<redacted>")
47 .field("base_url", &self.base_url)
48 .field("max_retries", &self.http.max_retries)
49 .finish()
50 }
51}
52
53impl OpenAiProvider {
54 pub fn new(api_key: impl Into<String>) -> Self {
55 Self {
56 http: Http::default(),
57 api_key: api_key.into(),
58 base_url: OPENAI_BASE_URL.to_string(),
59 auth: Auth::Bearer,
60 config_error: None,
61 }
62 }
63
64 fn with_host_label(mut self, what: &str, value: &str) -> Self {
67 let valid = !value.is_empty()
68 && value.len() <= 63
69 && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
70 if !valid {
71 self.config_error = Some(format!(
72 "OpenAiProvider: {what} `{value}` may only contain a-z, A-Z, 0-9 and '-'"
73 ));
74 }
75 self
76 }
77
78 fn preset(api_key: impl Into<String>, base_url: &str) -> Self {
79 Self::new(api_key).with_base_url(base_url)
80 }
81
82 pub fn azure(resource: &str, api_key: impl Into<String>) -> Self {
85 let mut provider = Self::preset(
86 api_key,
87 &format!("https://{resource}.openai.azure.com/openai/v1"),
88 );
89 provider.auth = Auth::ApiKeyHeader;
90 provider.with_host_label("resource", resource)
91 }
92
93 pub fn bedrock(region: &str, api_key: impl Into<String>) -> Self {
96 Self::preset(
97 api_key,
98 &format!("https://bedrock-runtime.{region}.amazonaws.com/openai/v1"),
99 )
100 .with_host_label("region", region)
101 }
102
103 pub fn groq(api_key: impl Into<String>) -> Self {
105 Self::preset(api_key, "https://api.groq.com/openai/v1")
106 }
107
108 pub fn mistral(api_key: impl Into<String>) -> Self {
110 Self::preset(api_key, "https://api.mistral.ai/v1")
111 }
112
113 pub fn deepseek(api_key: impl Into<String>) -> Self {
115 Self::preset(api_key, "https://api.deepseek.com/v1")
116 }
117
118 pub fn xai(api_key: impl Into<String>) -> Self {
120 Self::preset(api_key, "https://api.x.ai/v1")
121 }
122
123 pub fn openrouter(api_key: impl Into<String>) -> Self {
125 Self::preset(api_key, "https://openrouter.ai/api/v1")
126 }
127
128 pub fn together(api_key: impl Into<String>) -> Self {
130 Self::preset(api_key, "https://api.together.xyz/v1")
131 }
132
133 pub fn fireworks(api_key: impl Into<String>) -> Self {
135 Self::preset(api_key, "https://api.fireworks.ai/inference/v1")
136 }
137
138 pub fn perplexity(api_key: impl Into<String>) -> Self {
140 Self::preset(api_key, "https://api.perplexity.ai")
141 }
142
143 pub fn ollama() -> Self {
145 Self::preset("", "http://localhost:11434/v1")
146 }
147
148 pub fn lm_studio() -> Self {
150 Self::preset("", "http://localhost:1234/v1")
151 }
152
153 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
155 self.base_url = base_url.into().trim_end_matches('/').to_string();
156 self
157 }
158
159 pub fn with_client(mut self, client: reqwest::Client) -> Self {
161 self.http.client = client;
162 self
163 }
164
165 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
167 self.http.max_retries = max_retries;
168 self
169 }
170
171 fn request(&self, method: reqwest::Method, path: &str) -> Result<reqwest::RequestBuilder> {
172 if let Some(error) = &self.config_error {
173 return Err(LlmError::InvalidArgument(error.clone()));
174 }
175 let mut builder = self
176 .http
177 .client
178 .request(method, format!("{}{path}", self.base_url));
179 if !self.api_key.is_empty() {
180 builder = match self.auth {
181 Auth::Bearer => builder.header(
182 reqwest::header::AUTHORIZATION,
183 secret_header("OpenAiProvider", &format!("Bearer {}", self.api_key))?,
184 ),
185 Auth::ApiKeyHeader => {
186 builder.header("api-key", secret_header("OpenAiProvider", &self.api_key)?)
187 }
188 };
189 }
190 Ok(builder)
191 }
192
193 fn is_openai(&self) -> bool {
196 self.base_url == OPENAI_BASE_URL || matches!(self.auth, Auth::ApiKeyHeader)
197 }
198
199 fn stream_usage(&self) -> bool {
201 !self.base_url.starts_with("https://api.mistral.ai")
202 }
203
204 pub(crate) fn build_body(req: &ChatRequest, openai: bool) -> Value {
205 let mut messages = Vec::new();
206 if let Some(system) = &req.system {
207 messages.push(json!({ "role": "system", "content": system }));
208 }
209 for message in &req.messages {
210 messages.extend(to_openai_messages(message));
211 }
212
213 let mut body = json!({ "model": req.model, "messages": messages });
214
215 if !req.tools.is_empty() {
216 let tools: Vec<Value> = req
217 .tools
218 .iter()
219 .map(|t| {
220 json!({
221 "type": "function",
222 "function": {
223 "name": t.name,
224 "description": t.description,
225 "parameters": t.parameters,
226 }
227 })
228 })
229 .collect();
230 body["tools"] = json!(tools);
231 }
232 if let Some(max_tokens) = req.max_tokens {
233 let field = if openai {
234 "max_completion_tokens"
235 } else {
236 "max_tokens"
237 };
238 body[field] = json!(max_tokens);
239 }
240 if let Some(temperature) = req.temperature {
241 body["temperature"] = json!(temperature);
242 }
243 if let Some(format) = &req.response_format {
244 body["response_format"] = json!({
245 "type": "json_schema",
246 "json_schema": { "name": format.name, "schema": format.schema, "strict": true },
247 });
248 }
249 if let Some(effort) = req.reasoning {
250 body["reasoning_effort"] = json!(effort.as_str());
251 }
252 body
253 }
254}
255
256pub(crate) fn image_url_part(source: &ImageSource) -> Value {
258 let url = match source {
259 ImageSource::Url { url } => url.clone(),
260 ImageSource::Base64 { media_type, data } => format!("data:{media_type};base64,{data}"),
261 };
262 json!({ "type": "image_url", "image_url": { "url": url } })
263}
264
265pub(crate) fn to_openai_messages(message: &Message) -> Vec<Value> {
266 if message.role == Role::Tool {
267 return message
269 .parts
270 .iter()
271 .filter_map(|p| match p {
272 Part::ToolResult {
273 call_id, content, ..
274 } => Some(json!({
275 "role": "tool",
276 "tool_call_id": call_id,
277 "content": content,
278 })),
279 _ => None,
280 })
281 .collect();
282 }
283
284 let role = match message.role {
285 Role::System => "system",
286 Role::User => "user",
287 Role::Assistant | Role::Tool => "assistant",
288 };
289 let tool_calls: Vec<Value> = message
290 .parts
291 .iter()
292 .filter_map(|p| match p {
293 Part::ToolCall {
294 id,
295 name,
296 arguments,
297 ..
298 } => Some(json!({
299 "id": id,
300 "type": "function",
301 "function": { "name": name, "arguments": arguments_json(arguments) },
302 })),
303 _ => None,
304 })
305 .collect();
306
307 let content = if message.has_images() {
308 let parts: Vec<Value> = message
310 .parts
311 .iter()
312 .filter_map(|p| match p {
313 Part::Text { text } if !text.is_empty() => {
314 Some(json!({ "type": "text", "text": text }))
315 }
316 Part::Image { source } => Some(image_url_part(source)),
317 _ => None,
318 })
319 .collect();
320 json!(parts)
321 } else {
322 let text = message.text_content();
323 if text.is_empty() && !tool_calls.is_empty() {
324 Value::Null
325 } else {
326 json!(text)
327 }
328 };
329 let mut msg = json!({ "role": role, "content": content });
330 if !tool_calls.is_empty() {
331 msg["tool_calls"] = json!(tool_calls);
332 }
333 vec![msg]
334}
335
336pub(crate) fn arguments_json(arguments: &Value) -> String {
339 match arguments {
340 Value::String(raw) => raw.clone(),
341 other => other.to_string(),
342 }
343}
344
345pub(crate) fn parse_tool_calls(calls: &Value) -> Vec<Part> {
347 let Some(calls) = calls.as_array() else {
348 return Vec::new();
349 };
350 calls
351 .iter()
352 .map(|call| Part::ToolCall {
353 id: call["id"].as_str().unwrap_or_default().to_string(),
354 name: call["function"]["name"]
355 .as_str()
356 .unwrap_or_default()
357 .to_string(),
358 arguments: parse_arguments(call["function"]["arguments"].as_str().unwrap_or_default()),
359 signature: None,
360 })
361 .collect()
362}
363
364pub(crate) fn parse_arguments(raw: &str) -> Value {
367 if raw.trim().is_empty() {
368 json!({})
369 } else {
370 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
371 }
372}
373
374fn parse_response(v: &Value) -> Result<ChatResponse> {
375 let choice = v["choices"]
376 .get(0)
377 .ok_or_else(|| LlmError::InvalidResponse("missing field `choices[0]`".into()))?;
378 let message = &choice["message"];
379
380 let mut parts = Vec::new();
381 let reasoning = message["reasoning_content"]
383 .as_str()
384 .or_else(|| message["reasoning"].as_str());
385 if let Some(text) = reasoning.filter(|t| !t.is_empty()) {
386 parts.push(Part::Reasoning {
387 text: text.to_string(),
388 raw: Value::Null,
389 });
390 }
391 if let Some(text) = message["content"].as_str().filter(|t| !t.is_empty()) {
392 parts.push(Part::text(text));
393 }
394 let refusal = message["refusal"].as_str().filter(|t| !t.is_empty());
396 if let Some(text) = refusal {
397 parts.push(Part::text(text));
398 }
399 parts.extend(parse_tool_calls(&message["tool_calls"]));
400
401 let stop_reason = match choice["finish_reason"].as_str() {
402 _ if refusal.is_some() => StopReason::Refusal,
403 Some("stop") => StopReason::EndTurn,
404 Some("tool_calls" | "function_call") => StopReason::ToolUse,
405 Some("length") => StopReason::MaxTokens,
406 Some("content_filter") => StopReason::Refusal,
407 other => StopReason::Other(other.unwrap_or("unknown").to_string()),
408 };
409
410 Ok(ChatResponse {
411 model: v["model"].as_str().unwrap_or_default().to_string(),
412 message: Message::new(Role::Assistant, parts),
413 stop_reason,
414 usage: usage(&v["usage"], "prompt_tokens", "completion_tokens"),
415 })
416}
417
418pub(crate) fn usage(u: &Value, input: &str, output: &str) -> Usage {
419 let tokens = |field: &str| u32::try_from(u[field].as_u64().unwrap_or(0)).unwrap_or(u32::MAX);
420 Usage {
421 input_tokens: tokens(input),
422 output_tokens: tokens(output),
423 }
424}
425
426#[derive(Default)]
428struct OpenAiStream {
429 model: String,
430 content: String,
431 reasoning: String,
432 refusal: String,
433 calls: BTreeMap<u64, (String, String, String)>,
435 finish_reason: Option<String>,
436 usage: Value,
437 done: bool,
438}
439
440impl Accumulator for OpenAiStream {
441 fn on_event(&mut self, _event: &str, data: &str) -> Result<Vec<StreamEvent>> {
442 if data.trim() == "[DONE]" {
443 self.done = true;
444 return Ok(Vec::new());
445 }
446 let chunk = parse_data(data)?;
447 if chunk["error"].is_object() {
448 return Err(LlmError::Api(Box::new(crate::ApiError::from_stream_error(
449 &chunk["error"],
450 ))));
451 }
452 if let Some(model) = chunk["model"].as_str() {
453 self.model = model.to_string();
454 }
455 if chunk["usage"].is_object() {
456 self.usage = chunk["usage"].clone();
457 }
458 let mut out = Vec::new();
459 let Some(choice) = chunk["choices"].get(0) else {
460 return Ok(out);
461 };
462 let delta = &choice["delta"];
463 if let Some(text) = delta["reasoning_content"]
464 .as_str()
465 .or_else(|| delta["reasoning"].as_str())
466 {
467 if !text.is_empty() {
468 self.reasoning.push_str(text);
469 out.push(StreamEvent::ReasoningDelta(text.to_string()));
470 }
471 }
472 if let Some(text) = delta["content"].as_str().filter(|t| !t.is_empty()) {
473 self.content.push_str(text);
474 out.push(StreamEvent::TextDelta(text.to_string()));
475 }
476 if let Some(text) = delta["refusal"].as_str().filter(|t| !t.is_empty()) {
477 self.refusal.push_str(text);
478 out.push(StreamEvent::TextDelta(text.to_string()));
479 }
480 for call in delta["tool_calls"].as_array().into_iter().flatten() {
481 let index = call["index"].as_u64().unwrap_or(0);
482 let entry = self.calls.entry(index).or_default();
483 if let Some(id) = call["id"].as_str().filter(|s| !s.is_empty()) {
484 entry.0 = id.to_string();
485 }
486 if let Some(name) = call["function"]["name"]
488 .as_str()
489 .filter(|s| !s.is_empty() && entry.1.is_empty())
490 {
491 entry.1 = name.to_string();
492 out.push(StreamEvent::ToolCallStart {
493 id: entry.0.clone(),
494 name: entry.1.clone(),
495 });
496 }
497 if let Some(args) = call["function"]["arguments"]
498 .as_str()
499 .filter(|s| !s.is_empty())
500 {
501 entry.2.push_str(args);
502 out.push(StreamEvent::ToolCallDelta {
503 id: entry.0.clone(),
504 arguments: args.to_string(),
505 });
506 }
507 }
508 if let Some(reason) = choice["finish_reason"].as_str() {
509 self.finish_reason = Some(reason.to_string());
510 self.done = true;
511 }
512 Ok(out)
513 }
514
515 fn finish(&mut self) -> Result<ChatResponse> {
516 if !self.done {
517 return Err(crate::stream::ended_early());
518 }
519 let tool_calls: Vec<Value> = self
520 .calls
521 .values()
522 .map(|(id, name, args)| json!({ "id": id, "function": { "name": name, "arguments": args } }))
523 .collect();
524 let mut message = json!({ "content": self.content, "tool_calls": tool_calls });
525 if !self.reasoning.is_empty() {
526 message["reasoning_content"] = json!(self.reasoning);
527 }
528 if !self.refusal.is_empty() {
529 message["refusal"] = json!(self.refusal);
530 }
531 parse_response(&json!({
532 "model": self.model,
533 "choices": [{ "message": message, "finish_reason": self.finish_reason }],
534 "usage": self.usage,
535 }))
536 }
537}
538
539#[async_trait]
540impl LlmProvider for OpenAiProvider {
541 async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
542 crate::validate(
544 "OpenAiProvider",
545 &self.api_key,
546 false,
547 &self.base_url,
548 64,
549 req,
550 )?;
551 let builder = self
552 .request(reqwest::Method::POST, "/chat/completions")?
553 .json(&Self::build_body(req, self.is_openai()));
554 parse_response(&self.http.json(builder).await?)
555 }
556
557 async fn chat_stream(&self, req: &ChatRequest) -> Result<ChatStream> {
558 crate::validate(
559 "OpenAiProvider",
560 &self.api_key,
561 false,
562 &self.base_url,
563 64,
564 req,
565 )?;
566 let mut body = Self::build_body(req, self.is_openai());
567 body["stream"] = json!(true);
568 if self.stream_usage() {
569 body["stream_options"] = json!({ "include_usage": true });
570 }
571 let builder = self
572 .request(reqwest::Method::POST, "/chat/completions")?
573 .json(&body);
574 let response = self.http.send(builder).await?;
575 Ok(stream::drive(response, Box::<OpenAiStream>::default()))
576 }
577
578 async fn list_models(&self) -> Result<Vec<ModelInfo>> {
579 crate::validate_endpoint("OpenAiProvider", &self.api_key, false, &self.base_url)?;
580 let v = self
581 .http
582 .json(self.request(reqwest::Method::GET, "/models")?)
583 .await?;
584 Ok(v["data"]
585 .as_array()
586 .into_iter()
587 .flatten()
588 .filter_map(|m| m["id"].as_str())
589 .map(|id| ModelInfo {
590 id: id.to_string(),
591 display_name: None,
592 })
593 .collect())
594 }
595}
596
597#[async_trait]
598impl EmbeddingProvider for OpenAiProvider {
599 async fn embed(&self, model: &str, inputs: &[&str]) -> Result<Embeddings> {
600 crate::validate_endpoint("OpenAiProvider", &self.api_key, false, &self.base_url)?;
601 validate_embed_args(model, inputs)?;
602 let builder = self
603 .request(reqwest::Method::POST, "/embeddings")?
604 .json(&json!({ "model": model, "input": inputs }));
605 let v = self.http.json(builder).await?;
606 let mut data: Vec<&Value> = v["data"].as_array().into_iter().flatten().collect();
607 data.sort_by_key(|d| d["index"].as_u64().unwrap_or(0));
609 Ok(Embeddings {
610 vectors: data.iter().map(|d| float_vec(&d["embedding"])).collect(),
611 usage: usage(&v["usage"], "prompt_tokens", "completion_tokens"),
612 })
613 }
614}
615
616pub(crate) fn validate_embed_args(model: &str, inputs: &[&str]) -> Result<()> {
617 if model.trim().is_empty() {
618 return Err(LlmError::InvalidArgument("model must not be empty".into()));
619 }
620 if inputs.is_empty() {
621 return Err(LlmError::InvalidArgument("inputs must not be empty".into()));
622 }
623 Ok(())
624}
625
626pub(crate) fn float_vec(v: &Value) -> Vec<f32> {
627 v.as_array()
628 .into_iter()
629 .flatten()
630 .filter_map(Value::as_f64)
631 .map(|x| x as f32)
632 .collect()
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use crate::{ReasoningEffort, Tool};
639
640 #[test]
641 fn builds_tool_request() {
642 let req = ChatRequest::new("gpt-4o")
643 .system("sys")
644 .max_tokens(100)
645 .tool(Tool::new("f", "d", json!({"type": "object"})))
646 .message(Message::user("hi"));
647 let body = OpenAiProvider::build_body(&req, true);
648 assert_eq!(body["messages"][0]["role"], "system");
649 assert_eq!(body["messages"][1]["content"], "hi");
650 assert_eq!(body["tools"][0]["function"]["name"], "f");
651 assert_eq!(body["max_completion_tokens"], 100);
652 }
653
654 #[test]
655 fn builds_images_json_schema_and_reasoning() {
656 let req = ChatRequest::new("gpt-4o")
657 .json_schema("answer", json!({"type": "object"}))
658 .reasoning(ReasoningEffort::High)
659 .message(Message::user_with_image(
660 "what is this?",
661 Part::image_base64("image/png", "AAAA"),
662 ));
663 let body = OpenAiProvider::build_body(&req, true);
664 let content = &body["messages"][0]["content"];
665 assert_eq!(content[0]["type"], "text");
666 assert_eq!(content[1]["image_url"]["url"], "data:image/png;base64,AAAA");
667 assert_eq!(body["response_format"]["json_schema"]["name"], "answer");
668 assert_eq!(body["reasoning_effort"], "high");
669 }
670
671 #[test]
672 fn parses_tool_call_response() {
673 let v = json!({
674 "model": "gpt-4o",
675 "choices": [{
676 "finish_reason": "tool_calls",
677 "message": {
678 "content": null,
679 "tool_calls": [{
680 "id": "call_1",
681 "type": "function",
682 "function": { "name": "f", "arguments": "{\"x\":1}" }
683 }]
684 }
685 }],
686 "usage": { "prompt_tokens": 5, "completion_tokens": 7 }
687 });
688 let resp = parse_response(&v).unwrap();
689 assert_eq!(resp.stop_reason, StopReason::ToolUse);
690 assert_eq!(resp.tool_calls()[0].arguments["x"], 1);
691 assert_eq!(resp.usage.output_tokens, 7);
692 }
693
694 #[test]
695 fn tolerates_empty_and_truncated_arguments() {
696 let v = json!({
697 "choices": [{
698 "finish_reason": "length",
699 "message": {
700 "content": null,
701 "tool_calls": [
702 { "id": "a", "function": { "name": "f", "arguments": "" } },
703 { "id": "b", "function": { "name": "f", "arguments": "{\"x\":" } }
704 ]
705 }
706 }]
707 });
708 let resp = parse_response(&v).unwrap();
709 let calls = resp.tool_calls();
710 assert_eq!(calls[0].arguments, json!({}));
711 assert_eq!(calls[1].arguments, json!("{\"x\":"));
712 assert_eq!(resp.stop_reason, StopReason::MaxTokens);
713 }
714
715 #[test]
716 fn maps_refusal_field() {
717 let v = json!({
718 "choices": [{
719 "finish_reason": "stop",
720 "message": { "content": null, "refusal": "I can't help with that." }
721 }]
722 });
723 let resp = parse_response(&v).unwrap();
724 assert_eq!(resp.stop_reason, StopReason::Refusal);
725 assert_eq!(resp.text(), "I can't help with that.");
726 }
727
728 #[test]
729 fn stream_accumulates_text_and_tool_calls() {
730 let mut acc = OpenAiStream::default();
731 let events = [
732 r#"{"model":"gpt-4o","choices":[{"delta":{"content":"Hel"}}]}"#,
733 r#"{"choices":[{"delta":{"content":"lo"}}]}"#,
734 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"f","arguments":"{\"x\""}}]}}]}"#,
735 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":1}"}}]},"finish_reason":"tool_calls"}]}"#,
736 r#"{"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":4}}"#,
737 "[DONE]",
738 ];
739 let mut deltas = String::new();
740 for e in events {
741 for ev in acc.on_event("", e).unwrap() {
742 if let StreamEvent::TextDelta(t) = ev {
743 deltas.push_str(&t);
744 }
745 }
746 }
747 let resp = acc.finish().unwrap();
748 assert_eq!(deltas, "Hello");
749 assert_eq!(resp.text(), "Hello");
750 assert_eq!(resp.tool_calls()[0].arguments["x"], 1);
751 assert_eq!(resp.stop_reason, StopReason::ToolUse);
752 assert_eq!(resp.usage.output_tokens, 4);
753 }
754
755 #[test]
756 fn debug_redacts_api_key() {
757 let debug = format!("{:?}", OpenAiProvider::new("sk-secret"));
758 assert!(!debug.contains("sk-secret"));
759 }
760}
761
762#[cfg(test)]
763mod regression_tests {
764 use super::*;
765
766 #[test]
767 fn raw_string_arguments_are_not_double_encoded() {
768 let msg = Message::new(
769 Role::Assistant,
770 vec![Part::ToolCall {
771 id: "c".into(),
772 name: "f".into(),
773 arguments: json!("{\"x\":"),
774 signature: None,
775 }],
776 );
777 let sent = to_openai_messages(&msg);
778 assert_eq!(sent[0]["tool_calls"][0]["function"]["arguments"], "{\"x\":");
779 }
780
781 #[test]
782 fn compatible_servers_get_max_tokens_and_openai_gets_max_completion_tokens() {
783 let req = ChatRequest::new("m")
784 .max_tokens(9)
785 .message(Message::user("x"));
786 assert_eq!(
787 OpenAiProvider::build_body(&req, true)["max_completion_tokens"],
788 9
789 );
790 assert_eq!(OpenAiProvider::build_body(&req, false)["max_tokens"], 9);
791 assert!(OpenAiProvider::new("k").is_openai());
792 assert!(OpenAiProvider::azure("res", "k").is_openai());
793 assert!(!OpenAiProvider::groq("k").is_openai());
794 assert!(!OpenAiProvider::mistral("k").stream_usage());
795 }
796
797 #[test]
798 fn repeated_tool_name_chunks_are_not_concatenated() {
799 let mut acc = OpenAiStream::default();
800 let chunk = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c","function":{"name":"f","arguments":"{}"}}]}}]}"#;
801 let first = acc.on_event("", chunk).unwrap();
802 let second = acc.on_event("", r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"f"}}]},"finish_reason":"tool_calls"}]}"#).unwrap();
803 assert!(first
804 .iter()
805 .any(|e| matches!(e, StreamEvent::ToolCallStart { .. })));
806 assert!(!second
807 .iter()
808 .any(|e| matches!(e, StreamEvent::ToolCallStart { .. })));
809 assert_eq!(acc.finish().unwrap().tool_calls()[0].name, "f");
810 }
811}