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