Skip to main content

zeroclaw_providers/
azure_openai.rs

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    /// `[providers.models.azure.<alias>]` config-key alias.
14    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        // Azure OpenAI does not have a lightweight models endpoint,
572        // so warmup is a no-op to avoid unnecessary API calls.
573        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        // This test verifies the model_provider stores the credential correctly
637        // and that the auth header name is "api-key" (verified via the
638        // implementation in chat_with_system which uses .header("api-key", ...)).
639        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        // Azure requests should NOT contain a model field (deployment is in the URL)
706        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}