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