1use crate::traits::{
2 ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
3 ModelProvider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall, ToolsPayload,
4};
5use async_trait::async_trait;
6use reqwest::Client;
7use serde::{Deserialize, Serialize};
8use zeroclaw_api::tool::ToolSpec;
9
10const DEFAULT_API_VERSION: &str = "2024-08-01-preview";
11
12pub struct AzureOpenAiModelProvider {
13 alias: String,
15 credential: Option<String>,
16 #[allow(dead_code)]
17 resource_name: String,
18 #[allow(dead_code)]
19 deployment_name: String,
20 api_version: String,
21 base_url: String,
22}
23
24#[derive(Debug, Serialize)]
25struct ChatRequest {
26 messages: Vec<Message>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 temperature: Option<f64>,
29}
30
31#[derive(Debug, Serialize)]
32struct Message {
33 role: String,
34 content: String,
35}
36
37#[derive(Debug, Deserialize)]
38struct ChatResponse {
39 choices: Vec<Choice>,
40}
41
42#[derive(Debug, Deserialize)]
43struct Choice {
44 message: ResponseMessage,
45}
46
47#[derive(Debug, Deserialize)]
48struct ResponseMessage {
49 #[serde(default)]
50 content: Option<String>,
51 #[serde(default)]
52 reasoning_content: Option<String>,
53}
54
55impl ResponseMessage {
56 fn effective_content(&self) -> String {
57 match &self.content {
58 Some(c) if !c.is_empty() => c.clone(),
59 _ => self.reasoning_content.clone().unwrap_or_default(),
60 }
61 }
62}
63
64#[derive(Debug, Serialize)]
65struct NativeChatRequest {
66 messages: Vec<NativeMessage>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 temperature: Option<f64>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 tools: Option<Vec<NativeToolSpec>>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 tool_choice: Option<String>,
73}
74
75#[derive(Debug, Serialize)]
76struct NativeMessage {
77 role: String,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 content: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 tool_call_id: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 tool_calls: Option<Vec<NativeToolCall>>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 reasoning_content: Option<String>,
86}
87
88#[derive(Debug, Serialize, Deserialize)]
89struct NativeToolSpec {
90 #[serde(rename = "type")]
91 kind: String,
92 function: NativeToolFunctionSpec,
93}
94
95#[derive(Debug, Serialize, Deserialize)]
96struct NativeToolFunctionSpec {
97 name: String,
98 description: String,
99 parameters: serde_json::Value,
100}
101
102fn parse_native_tool_spec(value: serde_json::Value) -> anyhow::Result<NativeToolSpec> {
103 let spec: NativeToolSpec = serde_json::from_value(value).map_err(|e| {
104 ::zeroclaw_log::record!(
105 WARN,
106 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
107 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
108 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
109 "azure_openai: invalid tool spec"
110 );
111 anyhow::Error::msg(format!("Invalid Azure OpenAI tool specification: {e}"))
112 })?;
113
114 if spec.kind != "function" {
115 anyhow::bail!(
116 "Invalid Azure OpenAI tool specification: unsupported tool type '{}', expected 'function'",
117 spec.kind
118 );
119 }
120
121 Ok(spec)
122}
123
124#[derive(Debug, Serialize, Deserialize)]
125struct NativeToolCall {
126 #[serde(skip_serializing_if = "Option::is_none")]
127 id: Option<String>,
128 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
129 kind: Option<String>,
130 function: NativeFunctionCall,
131}
132
133#[derive(Debug, Serialize, Deserialize)]
134struct NativeFunctionCall {
135 name: String,
136 arguments: String,
137}
138
139#[derive(Debug, Deserialize)]
140struct NativeChatResponse {
141 choices: Vec<NativeChoice>,
142 #[serde(default)]
143 usage: Option<UsageInfo>,
144}
145
146#[derive(Debug, Deserialize)]
147struct UsageInfo {
148 #[serde(default)]
149 prompt_tokens: Option<u64>,
150 #[serde(default)]
151 completion_tokens: Option<u64>,
152}
153
154#[derive(Debug, Deserialize)]
155struct NativeChoice {
156 message: NativeResponseMessage,
157}
158
159#[derive(Debug, Deserialize)]
160struct NativeResponseMessage {
161 #[serde(default)]
162 content: Option<String>,
163 #[serde(default)]
164 reasoning_content: Option<String>,
165 #[serde(default)]
166 tool_calls: Option<Vec<NativeToolCall>>,
167}
168
169impl NativeResponseMessage {
170 fn effective_content(&self) -> Option<String> {
171 match &self.content {
172 Some(c) if !c.is_empty() => Some(c.clone()),
173 _ => self.reasoning_content.clone(),
174 }
175 }
176}
177
178impl AzureOpenAiModelProvider {
179 pub fn new(
180 alias: &str,
181 credential: Option<&str>,
182 resource_name: &str,
183 deployment_name: &str,
184 api_version: Option<&str>,
185 ) -> Self {
186 let version = api_version.unwrap_or(DEFAULT_API_VERSION);
187 let base_url = format!(
188 "https://{}.openai.azure.com/openai/deployments/{}",
189 resource_name, deployment_name
190 );
191 Self {
192 alias: alias.to_string(),
193 credential: credential.map(ToString::to_string),
194 resource_name: resource_name.to_string(),
195 deployment_name: deployment_name.to_string(),
196 api_version: version.to_string(),
197 base_url,
198 }
199 }
200 fn chat_completions_url(&self) -> String {
201 format!(
202 "{}/chat/completions?api-version={}",
203 self.base_url, self.api_version
204 )
205 }
206
207 fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
208 tools.map(|items| {
209 items
210 .iter()
211 .map(|tool| NativeToolSpec {
212 kind: "function".to_string(),
213 function: NativeToolFunctionSpec {
214 name: tool.name.clone(),
215 description: tool.description.clone(),
216 parameters: tool.parameters.clone(),
217 },
218 })
219 .collect()
220 })
221 }
222
223 fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
224 messages
225 .iter()
226 .map(|m| {
227 if m.role == "assistant"
228 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content)
229 && let Some(tool_calls_value) = value.get("tool_calls")
230 && let Ok(parsed_calls) =
231 serde_json::from_value::<Vec<ProviderToolCall>>(tool_calls_value.clone())
232 {
233 let tool_calls = parsed_calls
234 .into_iter()
235 .map(|tc| NativeToolCall {
236 id: Some(tc.id),
237 kind: Some("function".to_string()),
238 function: NativeFunctionCall {
239 name: tc.name,
240 arguments: tc.arguments,
241 },
242 })
243 .collect::<Vec<_>>();
244 let content = value
245 .get("content")
246 .and_then(serde_json::Value::as_str)
247 .map(ToString::to_string);
248 let reasoning_content = value
249 .get("reasoning_content")
250 .and_then(serde_json::Value::as_str)
251 .map(ToString::to_string);
252 return NativeMessage {
253 role: "assistant".to_string(),
254 content,
255 tool_call_id: None,
256 tool_calls: Some(tool_calls),
257 reasoning_content,
258 };
259 }
260
261 if m.role == "tool"
262 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content)
263 {
264 let tool_call_id = value
265 .get("tool_call_id")
266 .and_then(serde_json::Value::as_str)
267 .map(ToString::to_string);
268 let content = value
269 .get("content")
270 .and_then(serde_json::Value::as_str)
271 .map(ToString::to_string);
272 return NativeMessage {
273 role: "tool".to_string(),
274 content,
275 tool_call_id,
276 tool_calls: None,
277 reasoning_content: None,
278 };
279 }
280
281 NativeMessage {
282 role: m.role.clone(),
283 content: Some(m.content.clone()),
284 tool_call_id: None,
285 tool_calls: None,
286 reasoning_content: None,
287 }
288 })
289 .collect()
290 }
291
292 fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
293 let text = message.effective_content();
294 let reasoning_content = message.reasoning_content.clone();
295 let tool_calls = message
296 .tool_calls
297 .unwrap_or_default()
298 .into_iter()
299 .map(|tc| ProviderToolCall {
300 id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
301 name: tc.function.name,
302 arguments: tc.function.arguments,
303 extra_content: None,
304 })
305 .collect::<Vec<_>>();
306
307 ProviderChatResponse {
308 text,
309 tool_calls,
310 usage: None,
311 reasoning_content,
312 }
313 }
314
315 fn http_client(&self) -> Client {
316 zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
317 "model_provider.azure_openai",
318 120,
319 10,
320 )
321 }
322}
323
324#[async_trait]
325impl ModelProvider for AzureOpenAiModelProvider {
326 fn capabilities(&self) -> ProviderCapabilities {
327 ProviderCapabilities {
328 native_tool_calling: true,
329 vision: true,
330 prompt_caching: false,
331 extended_thinking: false,
332 }
333 }
334
335 fn convert_tools(&self, tools: &[ToolSpec]) -> ToolsPayload {
336 ToolsPayload::OpenAI {
337 tools: tools
338 .iter()
339 .map(|tool| {
340 serde_json::json!({
341 "type": "function",
342 "function": {
343 "name": tool.name,
344 "description": tool.description,
345 "parameters": tool.parameters,
346 }
347 })
348 })
349 .collect(),
350 }
351 }
352
353 fn supports_native_tools(&self) -> bool {
354 true
355 }
356
357 fn supports_vision(&self) -> bool {
358 true
359 }
360
361 async fn chat_with_system(
362 &self,
363 system_prompt: Option<&str>,
364 message: &str,
365 _model: &str,
366 temperature: Option<f64>,
367 ) -> anyhow::Result<String> {
368 let credential = self.credential.as_ref().ok_or_else(|| {
369 ::zeroclaw_log::record!(
370 ERROR,
371 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
372 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
373 .with_attrs(::serde_json::json!({"missing": "credentials"})),
374 "azure_openai: API key not configured"
375 );
376 anyhow::Error::msg(
377 "Azure OpenAI API key not set. Set AZURE_OPENAI_API_KEY or edit config.toml.",
378 )
379 })?;
380
381 let mut messages = Vec::new();
382
383 if let Some(sys) = system_prompt {
384 messages.push(Message {
385 role: "system".to_string(),
386 content: sys.to_string(),
387 });
388 }
389
390 messages.push(Message {
391 role: "user".to_string(),
392 content: message.to_string(),
393 });
394
395 let request = ChatRequest {
396 messages,
397 temperature,
398 };
399
400 let response = self
401 .http_client()
402 .post(self.chat_completions_url())
403 .header("api-key", credential.as_str())
404 .json(&request)
405 .send()
406 .await?;
407
408 if !response.status().is_success() {
409 return Err(super::api_error("Azure OpenAI", response).await);
410 }
411
412 let chat_response: ChatResponse = response.json().await?;
413
414 chat_response
415 .choices
416 .into_iter()
417 .next()
418 .map(|c| c.message.effective_content())
419 .ok_or_else(|| {
420 ::zeroclaw_log::record!(
421 ERROR,
422 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
423 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
424 "azure_openai: empty choices in response"
425 );
426 anyhow::Error::msg("No response from Azure OpenAI")
427 })
428 }
429
430 async fn chat(
431 &self,
432 request: ProviderChatRequest<'_>,
433 _model: &str,
434 temperature: Option<f64>,
435 ) -> anyhow::Result<ProviderChatResponse> {
436 let credential = self.credential.as_ref().ok_or_else(|| {
437 ::zeroclaw_log::record!(
438 ERROR,
439 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
440 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
441 .with_attrs(::serde_json::json!({"missing": "credentials"})),
442 "azure_openai: API key not configured"
443 );
444 anyhow::Error::msg(
445 "Azure OpenAI API key not set. Set AZURE_OPENAI_API_KEY or edit config.toml.",
446 )
447 })?;
448
449 let tools = Self::convert_tools(request.tools);
450 let native_request = NativeChatRequest {
451 messages: Self::convert_messages(request.messages),
452 temperature,
453 tool_choice: tools.as_ref().map(|_| "auto".to_string()),
454 tools,
455 };
456
457 let response = self
458 .http_client()
459 .post(self.chat_completions_url())
460 .header("api-key", credential.as_str())
461 .json(&native_request)
462 .send()
463 .await?;
464
465 if !response.status().is_success() {
466 return Err(super::api_error("Azure OpenAI", response).await);
467 }
468
469 let native_response: NativeChatResponse = response.json().await?;
470 let usage = native_response.usage.map(|u| TokenUsage {
471 input_tokens: u.prompt_tokens,
472 output_tokens: u.completion_tokens,
473 cached_input_tokens: None,
474 });
475 let message = native_response
476 .choices
477 .into_iter()
478 .next()
479 .map(|c| c.message)
480 .ok_or_else(|| {
481 ::zeroclaw_log::record!(
482 ERROR,
483 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
484 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
485 "azure_openai: empty choices in response"
486 );
487 anyhow::Error::msg("No response from Azure OpenAI")
488 })?;
489 let mut result = Self::parse_native_response(message);
490 result.usage = usage;
491 Ok(result)
492 }
493
494 async fn chat_with_tools(
495 &self,
496 messages: &[ChatMessage],
497 tools: &[serde_json::Value],
498 _model: &str,
499 temperature: Option<f64>,
500 ) -> anyhow::Result<ProviderChatResponse> {
501 let credential = self.credential.as_ref().ok_or_else(|| {
502 ::zeroclaw_log::record!(
503 ERROR,
504 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
505 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
506 .with_attrs(::serde_json::json!({"missing": "credentials"})),
507 "azure_openai: API key not configured"
508 );
509 anyhow::Error::msg(
510 "Azure OpenAI API key not set. Set AZURE_OPENAI_API_KEY or edit config.toml.",
511 )
512 })?;
513
514 let native_tools: Option<Vec<NativeToolSpec>> = if tools.is_empty() {
515 None
516 } else {
517 Some(
518 tools
519 .iter()
520 .cloned()
521 .map(parse_native_tool_spec)
522 .collect::<Result<Vec<_>, _>>()?,
523 )
524 };
525
526 let native_request = NativeChatRequest {
527 messages: Self::convert_messages(messages),
528 temperature,
529 tool_choice: native_tools.as_ref().map(|_| "auto".to_string()),
530 tools: native_tools,
531 };
532
533 let response = self
534 .http_client()
535 .post(self.chat_completions_url())
536 .header("api-key", credential.as_str())
537 .json(&native_request)
538 .send()
539 .await?;
540
541 if !response.status().is_success() {
542 return Err(super::api_error("Azure OpenAI", response).await);
543 }
544
545 let native_response: NativeChatResponse = response.json().await?;
546 let usage = native_response.usage.map(|u| TokenUsage {
547 input_tokens: u.prompt_tokens,
548 output_tokens: u.completion_tokens,
549 cached_input_tokens: None,
550 });
551 let message = native_response
552 .choices
553 .into_iter()
554 .next()
555 .map(|c| c.message)
556 .ok_or_else(|| {
557 ::zeroclaw_log::record!(
558 ERROR,
559 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
560 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
561 "azure_openai: empty choices in response"
562 );
563 anyhow::Error::msg("No response from Azure OpenAI")
564 })?;
565 let mut result = Self::parse_native_response(message);
566 result.usage = usage;
567 Ok(result)
568 }
569
570 async fn warmup(&self) -> anyhow::Result<()> {
571 Ok(())
574 }
575}
576
577impl ::zeroclaw_api::attribution::Attributable for AzureOpenAiModelProvider {
578 fn role(&self) -> ::zeroclaw_api::attribution::Role {
579 ::zeroclaw_api::attribution::Role::Provider(
580 ::zeroclaw_api::attribution::ProviderKind::Model(
581 ::zeroclaw_api::attribution::ModelProviderKind::Azure,
582 ),
583 )
584 }
585 fn alias(&self) -> &str {
586 &self.alias
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn url_construction_default_version() {
596 let p =
597 AzureOpenAiModelProvider::new("test", Some("test-key"), "my-resource", "gpt-4o", None);
598 assert_eq!(
599 p.chat_completions_url(),
600 "https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-08-01-preview"
601 );
602 }
603
604 #[test]
605 fn url_construction_custom_version() {
606 let p = AzureOpenAiModelProvider::new(
607 "test",
608 Some("test-key"),
609 "my-resource",
610 "gpt-4o",
611 Some("2024-06-01"),
612 );
613 assert_eq!(
614 p.chat_completions_url(),
615 "https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-06-01"
616 );
617 }
618
619 #[test]
620 fn url_construction_preserves_resource_and_deployment() {
621 let p = AzureOpenAiModelProvider::new(
622 "test",
623 Some("key"),
624 "contoso-ai",
625 "my-gpt35-deployment",
626 None,
627 );
628 let url = p.chat_completions_url();
629 assert!(url.contains("contoso-ai.openai.azure.com"));
630 assert!(url.contains("/deployments/my-gpt35-deployment/"));
631 assert!(url.contains("api-version=2024-08-01-preview"));
632 }
633
634 #[test]
635 fn auth_header_uses_api_key_not_bearer() {
636 let p = AzureOpenAiModelProvider::new(
640 "test",
641 Some("my-azure-key"),
642 "resource",
643 "deployment",
644 None,
645 );
646 assert_eq!(p.credential.as_deref(), Some("my-azure-key"));
647 }
648
649 #[test]
650 fn creates_with_credential() {
651 let p = AzureOpenAiModelProvider::new(
652 "test",
653 Some("azure-test-credential"),
654 "resource",
655 "deployment",
656 None,
657 );
658 assert_eq!(p.credential.as_deref(), Some("azure-test-credential"));
659 assert_eq!(p.resource_name, "resource");
660 assert_eq!(p.deployment_name, "deployment");
661 assert_eq!(p.api_version, DEFAULT_API_VERSION);
662 }
663
664 #[test]
665 fn creates_without_credential() {
666 let p = AzureOpenAiModelProvider::new("test", None, "resource", "deployment", None);
667 assert!(p.credential.is_none());
668 }
669
670 #[tokio::test]
671 async fn chat_fails_without_key() {
672 let p = AzureOpenAiModelProvider::new("test", None, "resource", "deployment", None);
673 let result = p.chat_with_system(None, "hello", "gpt-4o", Some(0.7)).await;
674 assert!(result.is_err());
675 assert!(result.unwrap_err().to_string().contains("API key not set"));
676 }
677
678 #[tokio::test]
679 async fn chat_with_system_fails_without_key() {
680 let p = AzureOpenAiModelProvider::new("test", None, "resource", "deployment", None);
681 let result = p
682 .chat_with_system(Some("You are ZeroClaw"), "test", "gpt-4o", Some(0.5))
683 .await;
684 assert!(result.is_err());
685 }
686
687 #[test]
688 fn request_serializes_with_system_message() {
689 let req = ChatRequest {
690 messages: vec![
691 Message {
692 role: "system".to_string(),
693 content: "You are ZeroClaw".to_string(),
694 },
695 Message {
696 role: "user".to_string(),
697 content: "hello".to_string(),
698 },
699 ],
700 temperature: Some(0.7),
701 };
702 let json = serde_json::to_string(&req).unwrap();
703 assert!(json.contains("\"role\":\"system\""));
704 assert!(json.contains("\"role\":\"user\""));
705 assert!(!json.contains("\"model\""));
707 }
708
709 #[test]
710 fn request_serializes_without_system() {
711 let req = ChatRequest {
712 messages: vec![Message {
713 role: "user".to_string(),
714 content: "hello".to_string(),
715 }],
716 temperature: Some(0.0),
717 };
718 let json = serde_json::to_string(&req).unwrap();
719 assert!(!json.contains("system"));
720 assert!(json.contains("\"temperature\":0.0"));
721 }
722
723 #[test]
724 fn response_deserializes_single_choice() {
725 let json = r#"{"choices":[{"message":{"content":"Hi!"}}]}"#;
726 let resp: ChatResponse = serde_json::from_str(json).unwrap();
727 assert_eq!(resp.choices.len(), 1);
728 assert_eq!(resp.choices[0].message.effective_content(), "Hi!");
729 }
730
731 #[test]
732 fn response_deserializes_empty_choices() {
733 let json = r#"{"choices":[]}"#;
734 let resp: ChatResponse = serde_json::from_str(json).unwrap();
735 assert!(resp.choices.is_empty());
736 }
737
738 #[test]
739 fn response_deserializes_multiple_choices() {
740 let json = r#"{"choices":[{"message":{"content":"A"}},{"message":{"content":"B"}}]}"#;
741 let resp: ChatResponse = serde_json::from_str(json).unwrap();
742 assert_eq!(resp.choices.len(), 2);
743 assert_eq!(resp.choices[0].message.effective_content(), "A");
744 }
745
746 #[test]
747 fn tool_call_response_parsing() {
748 let json = r#"{"choices":[{"message":{
749 "content":"Let me check",
750 "tool_calls":[{
751 "id":"call_abc123",
752 "type":"function",
753 "function":{"name":"shell","arguments":"{\"command\":\"ls\"}"}
754 }]
755 }}],"usage":{"prompt_tokens":50,"completion_tokens":25}}"#;
756 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
757 let message = resp.choices.into_iter().next().unwrap().message;
758 let parsed = AzureOpenAiModelProvider::parse_native_response(message);
759 assert_eq!(parsed.text.as_deref(), Some("Let me check"));
760 assert_eq!(parsed.tool_calls.len(), 1);
761 assert_eq!(parsed.tool_calls[0].id, "call_abc123");
762 assert_eq!(parsed.tool_calls[0].name, "shell");
763 assert!(parsed.tool_calls[0].arguments.contains("ls"));
764 }
765
766 #[test]
767 fn tool_call_response_without_id_generates_uuid() {
768 let json = r#"{"choices":[{"message":{
769 "content":null,
770 "tool_calls":[{
771 "function":{"name":"test","arguments":"{}"}
772 }]
773 }}]}"#;
774 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
775 let message = resp.choices.into_iter().next().unwrap().message;
776 let parsed = AzureOpenAiModelProvider::parse_native_response(message);
777 assert_eq!(parsed.tool_calls.len(), 1);
778 assert!(!parsed.tool_calls[0].id.is_empty());
779 }
780
781 #[tokio::test]
782 async fn chat_with_tools_fails_without_key() {
783 let p = AzureOpenAiModelProvider::new("test", None, "resource", "deployment", None);
784 let messages = vec![ChatMessage::user("hello".to_string())];
785 let tools = vec![serde_json::json!({
786 "type": "function",
787 "function": {
788 "name": "shell",
789 "description": "Run a shell command",
790 "parameters": {
791 "type": "object",
792 "properties": {
793 "command": { "type": "string" }
794 },
795 "required": ["command"]
796 }
797 }
798 })];
799 let result = p
800 .chat_with_tools(&messages, &tools, "gpt-4o", Some(0.7))
801 .await;
802 assert!(result.is_err());
803 assert!(result.unwrap_err().to_string().contains("API key not set"));
804 }
805
806 #[test]
807 fn native_response_parses_usage() {
808 let json = r#"{
809 "choices": [{"message": {"content": "Hello"}}],
810 "usage": {"prompt_tokens": 100, "completion_tokens": 50}
811 }"#;
812 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
813 let usage = resp.usage.unwrap();
814 assert_eq!(usage.prompt_tokens, Some(100));
815 assert_eq!(usage.completion_tokens, Some(50));
816 }
817
818 #[test]
819 fn capabilities_reports_native_tools_and_vision() {
820 let p = AzureOpenAiModelProvider::new("test", Some("key"), "resource", "deployment", None);
821 let caps = <AzureOpenAiModelProvider as ModelProvider>::capabilities(&p);
822 assert!(caps.native_tool_calling);
823 assert!(caps.vision);
824 }
825
826 #[test]
827 fn supports_native_tools_returns_true() {
828 let p = AzureOpenAiModelProvider::new("test", Some("key"), "resource", "deployment", None);
829 assert!(p.supports_native_tools());
830 }
831
832 #[test]
833 fn supports_vision_returns_true() {
834 let p = AzureOpenAiModelProvider::new("test", Some("key"), "resource", "deployment", None);
835 assert!(p.supports_vision());
836 }
837
838 #[tokio::test]
839 async fn warmup_is_noop() {
840 let p = AzureOpenAiModelProvider::new("test", None, "resource", "deployment", None);
841 let result = p.warmup().await;
842 assert!(result.is_ok());
843 }
844
845 #[test]
846 fn custom_api_version_stored() {
847 let p = AzureOpenAiModelProvider::new(
848 "test",
849 Some("key"),
850 "resource",
851 "deployment",
852 Some("2025-01-01"),
853 );
854 assert_eq!(p.api_version, "2025-01-01");
855 }
856}