From f1ca52714d8ac050c199590eca9880fae5721fd1 Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Sun, 15 Mar 2026 20:23:30 +0300 Subject: [PATCH] feature batch --- crates/openfang-api/src/server.rs | 2 + crates/openfang-api/src/webchat.rs | 28 ++++ crates/openfang-api/static/index_body.html | 2 + crates/openfang-api/static/index_head.html | 5 + crates/openfang-api/static/js/app.js | 54 ++++++- crates/openfang-api/static/js/pages/chat.js | 8 +- crates/openfang-api/static/manifest.json | 13 ++ crates/openfang-api/static/sw.js | 3 + crates/openfang-runtime/src/drivers/mod.rs | 131 +++++++++++++++-- crates/openfang-runtime/src/drivers/openai.rs | 132 ++++++++++++++++-- crates/openfang-runtime/src/model_catalog.rs | 116 ++++++++++++++- crates/openfang-types/src/model_catalog.rs | 12 ++ 12 files changed, 476 insertions(+), 30 deletions(-) create mode 100644 crates/openfang-api/static/manifest.json create mode 100644 crates/openfang-api/static/sw.js diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index d6a5b6be..b6980928 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -122,6 +122,8 @@ pub async fn build_router( .route("/", axum::routing::get(webchat::webchat_page)) .route("/logo.png", axum::routing::get(webchat::logo_png)) .route("/favicon.ico", axum::routing::get(webchat::favicon_ico)) + .route("/manifest.json", axum::routing::get(webchat::manifest_json)) + .route("/sw.js", axum::routing::get(webchat::sw_js)) .route( "/api/metrics", axum::routing::get(routes::prometheus_metrics), diff --git a/crates/openfang-api/src/webchat.rs b/crates/openfang-api/src/webchat.rs index 6eb8c890..ec8ada30 100644 --- a/crates/openfang-api/src/webchat.rs +++ b/crates/openfang-api/src/webchat.rs @@ -46,6 +46,34 @@ pub async fn favicon_ico() -> impl IntoResponse { ) } +/// Embedded PWA manifest for installable web app support. +const MANIFEST_JSON: &str = include_str!("../static/manifest.json"); + +/// Embedded service worker for PWA support. +const SW_JS: &str = include_str!("../static/sw.js"); + +/// GET /manifest.json — Serve the PWA web app manifest. +pub async fn manifest_json() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/manifest+json"), + (header::CACHE_CONTROL, "public, max-age=86400, immutable"), + ], + MANIFEST_JSON, + ) +} + +/// GET /sw.js — Serve the PWA service worker. +pub async fn sw_js() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + SW_JS, + ) +} + /// GET / — Serve the OpenFang Dashboard single-page application. /// /// Returns the full SPA with ETag header based on package version for caching. diff --git a/crates/openfang-api/static/index_body.html b/crates/openfang-api/static/index_body.html index 488480d6..539f1aa2 100644 --- a/crates/openfang-api/static/index_body.html +++ b/crates/openfang-api/static/index_body.html @@ -5035,3 +5035,5 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
+ + diff --git a/crates/openfang-api/static/index_head.html b/crates/openfang-api/static/index_head.html index 89816c52..e052c081 100644 --- a/crates/openfang-api/static/index_head.html +++ b/crates/openfang-api/static/index_head.html @@ -6,7 +6,12 @@ OpenFang Dashboard + + + + + diff --git a/crates/openfang-api/static/js/app.js b/crates/openfang-api/static/js/app.js index 9ec6bd75..6e1132c3 100644 --- a/crates/openfang-api/static/js/app.js +++ b/crates/openfang-api/static/js/app.js @@ -24,7 +24,39 @@ function escapeHtml(text) { function renderMarkdown(text) { if (!text) return ''; if (typeof marked !== 'undefined') { - var html = marked.parse(text); + // Protect LaTeX blocks from marked.js mangling (underscores, backslashes, etc.) + var latexBlocks = []; + var protected_ = text; + // Protect display math $$...$$ first (greedy across lines) + protected_ = protected_.replace(/\$\$([\s\S]+?)\$\$/g, function(match) { + var idx = latexBlocks.length; + latexBlocks.push(match); + return '\x00LATEX' + idx + '\x00'; + }); + // Protect inline math $...$ (single line, not empty, not starting/ending with space) + protected_ = protected_.replace(/\$([^\s$](?:[^$]*[^\s$])?)\$/g, function(match) { + var idx = latexBlocks.length; + latexBlocks.push(match); + return '\x00LATEX' + idx + '\x00'; + }); + // Protect \[...\] display math + protected_ = protected_.replace(/\\\[([\s\S]+?)\\\]/g, function(match) { + var idx = latexBlocks.length; + latexBlocks.push(match); + return '\x00LATEX' + idx + '\x00'; + }); + // Protect \(...\) inline math + protected_ = protected_.replace(/\\\(([\s\S]+?)\\\)/g, function(match) { + var idx = latexBlocks.length; + latexBlocks.push(match); + return '\x00LATEX' + idx + '\x00'; + }); + + var html = marked.parse(protected_); + // Restore LaTeX blocks + for (var i = 0; i < latexBlocks.length; i++) { + html = html.replace('\x00LATEX' + i + '\x00', latexBlocks[i]); + } // Add copy buttons to code blocks html = html.replace(/
 {
+  event.respondWith(fetch(event.request));
+});
diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs
index c6939eab..b25c6aed 100644
--- a/crates/openfang-runtime/src/drivers/mod.rs
+++ b/crates/openfang-runtime/src/drivers/mod.rs
@@ -14,14 +14,14 @@ pub mod qwen_code;
 
 use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
 use openfang_types::model_catalog::{
-    AI21_BASE_URL, ANTHROPIC_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, COHERE_BASE_URL,
-    DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL,
-    KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL,
-    MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL,
-    PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL,
-    TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL,
-    VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL,
-    ZHIPU_CODING_BASE_URL,
+    AI21_BASE_URL, ANTHROPIC_BASE_URL, AZURE_OPENAI_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL,
+    COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL,
+    HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
+    MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL,
+    OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
+    REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
+    VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
+    ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
 };
 use std::sync::Arc;
 
@@ -221,6 +221,11 @@ fn provider_defaults(provider: &str) -> Option {
             api_key_env: "NVIDIA_API_KEY",
             key_required: true,
         }),
+        "azure" | "azure-openai" => Some(ProviderDefaults {
+            base_url: AZURE_OPENAI_BASE_URL,
+            api_key_env: "AZURE_OPENAI_API_KEY",
+            key_required: true,
+        }),
         _ => None,
     }
 }
@@ -345,6 +350,26 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr
         )));
     }
 
+    // Azure OpenAI — deployment-based URL with `api-key` header
+    if provider == "azure" || provider == "azure-openai" {
+        let api_key = config
+            .api_key
+            .clone()
+            .or_else(|| std::env::var("AZURE_OPENAI_API_KEY").ok())
+            .ok_or_else(|| {
+                LlmError::MissingApiKey(
+                    "Set AZURE_OPENAI_API_KEY environment variable for Azure OpenAI".to_string(),
+                )
+            })?;
+        let base_url = config.base_url.clone().ok_or_else(|| LlmError::Api {
+            status: 0,
+            message: "Azure OpenAI requires base_url — set it to \
+                      https://{resource}.openai.azure.com/openai/deployments"
+                .to_string(),
+        })?;
+        return Ok(Arc::new(openai::OpenAIDriver::new_azure(api_key, base_url)));
+    }
+
     // Kimi for Code — Anthropic-compatible endpoint
     if provider == "kimi_coding" {
         let api_key = config
@@ -421,7 +446,7 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr
     Err(LlmError::Api {
         status: 0,
         message: format!(
-            "Unknown provider '{}'. Supported: anthropic, gemini, openai, groq, openrouter, \
+            "Unknown provider '{}'. Supported: anthropic, gemini, openai, azure, groq, openrouter, \
              deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \
              cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \
              chutes, venice, nvidia, codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
@@ -525,6 +550,7 @@ pub fn known_providers() -> &'static [&'static str] {
         "codex",
         "claude-code",
         "qwen-code",
+        "azure",
     ]
 }
 
@@ -628,7 +654,8 @@ mod tests {
         assert!(providers.contains(&"codex"));
         assert!(providers.contains(&"claude-code"));
         assert!(providers.contains(&"qwen-code"));
-        assert_eq!(providers.len(), 36);
+        assert!(providers.contains(&"azure"));
+        assert_eq!(providers.len(), 37);
     }
 
     #[test]
@@ -743,4 +770,88 @@ mod tests {
         let driver = create_driver(&config);
         assert!(driver.is_ok());
     }
+
+    #[test]
+    fn test_provider_defaults_azure() {
+        let d = provider_defaults("azure").unwrap();
+        assert_eq!(d.base_url, ""); // Azure requires user-supplied URL
+        assert_eq!(d.api_key_env, "AZURE_OPENAI_API_KEY");
+        assert!(d.key_required);
+    }
+
+    #[test]
+    fn test_provider_defaults_azure_openai_alias() {
+        let d = provider_defaults("azure-openai").unwrap();
+        assert_eq!(d.api_key_env, "AZURE_OPENAI_API_KEY");
+        assert!(d.key_required);
+    }
+
+    #[test]
+    fn test_azure_driver_creation_with_key_and_url() {
+        let config = DriverConfig {
+            provider: "azure".to_string(),
+            api_key: Some("test-azure-key".to_string()),
+            base_url: Some(
+                "https://myresource.openai.azure.com/openai/deployments".to_string(),
+            ),
+            skip_permissions: true,
+        };
+        let driver = create_driver(&config);
+        assert!(driver.is_ok(), "Azure driver with key + URL should succeed");
+    }
+
+    #[test]
+    fn test_azure_driver_no_key_errors() {
+        let config = DriverConfig {
+            provider: "azure".to_string(),
+            api_key: None,
+            base_url: Some(
+                "https://myresource.openai.azure.com/openai/deployments".to_string(),
+            ),
+            skip_permissions: true,
+        };
+        let result = create_driver(&config);
+        assert!(result.is_err(), "Azure driver without key should error");
+        let err = result.err().unwrap().to_string();
+        assert!(
+            err.contains("AZURE_OPENAI_API_KEY"),
+            "Error should mention AZURE_OPENAI_API_KEY: {}",
+            err
+        );
+    }
+
+    #[test]
+    fn test_azure_driver_no_url_errors() {
+        let config = DriverConfig {
+            provider: "azure".to_string(),
+            api_key: Some("test-azure-key".to_string()),
+            base_url: None,
+            skip_permissions: true,
+        };
+        let result = create_driver(&config);
+        assert!(result.is_err(), "Azure driver without URL should error");
+        let err = result.err().unwrap().to_string();
+        assert!(
+            err.contains("base_url"),
+            "Error should mention base_url: {}",
+            err
+        );
+    }
+
+    #[test]
+    fn test_azure_openai_alias_driver_creation() {
+        let config = DriverConfig {
+            provider: "azure-openai".to_string(),
+            api_key: Some("test-azure-key".to_string()),
+            base_url: Some(
+                "https://myresource.openai.azure.com/openai/deployments".to_string(),
+            ),
+            skip_permissions: true,
+        };
+        let driver = create_driver(&config);
+        assert!(
+            driver.is_ok(),
+            "azure-openai alias should create driver successfully"
+        );
+    }
 }
diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs
index 11b9d6f4..54f55181 100644
--- a/crates/openfang-runtime/src/drivers/openai.rs
+++ b/crates/openfang-runtime/src/drivers/openai.rs
@@ -12,12 +12,17 @@ use serde::{Deserialize, Serialize};
 use tracing::{debug, warn};
 use zeroize::Zeroizing;
 
+/// Azure OpenAI API version query parameter.
+const AZURE_API_VERSION: &str = "2024-10-21";
+
 /// OpenAI-compatible API driver.
 pub struct OpenAIDriver {
     api_key: Zeroizing,
     base_url: String,
     client: reqwest::Client,
     extra_headers: Vec<(String, String)>,
+    /// When true, uses Azure OpenAI URL format and `api-key` header.
+    azure_mode: bool,
 }
 
 impl OpenAIDriver {
@@ -31,6 +36,25 @@ impl OpenAIDriver {
                 .build()
                 .unwrap_or_default(),
             extra_headers: Vec::new(),
+            azure_mode: false,
+        }
+    }
+
+    /// Create a driver configured for Azure OpenAI.
+    ///
+    /// Azure uses a deployment-based URL scheme and `api-key` header instead of
+    /// `Authorization: Bearer`.  The `base_url` should be the deployments root,
+    /// e.g. `https://{resource}.openai.azure.com/openai/deployments`.
+    pub fn new_azure(api_key: String, base_url: String) -> Self {
+        Self {
+            api_key: Zeroizing::new(api_key),
+            base_url,
+            client: reqwest::Client::builder()
+                .user_agent(crate::USER_AGENT)
+                .build()
+                .unwrap_or_default(),
+            extra_headers: Vec::new(),
+            azure_mode: true,
         }
     }
 
@@ -46,6 +70,40 @@ impl OpenAIDriver {
         self.extra_headers = headers;
         self
     }
+
+    /// Build the chat completions URL for the given model.
+    ///
+    /// Standard OpenAI: `{base_url}/chat/completions`
+    /// Azure OpenAI:    `{base_url}/{model}/chat/completions?api-version=2024-10-21`
+    fn chat_url(&self, model: &str) -> String {
+        if self.azure_mode {
+            format!(
+                "{}/{}/chat/completions?api-version={}",
+                self.base_url.trim_end_matches('/'),
+                model,
+                AZURE_API_VERSION,
+            )
+        } else {
+            format!("{}/chat/completions", self.base_url)
+        }
+    }
+
+    /// Apply authentication headers to the request builder.
+    ///
+    /// Standard: `Authorization: Bearer {key}`
+    /// Azure:    `api-key: {key}`
+    fn apply_auth(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
+        if self.api_key.as_str().is_empty() {
+            return builder;
+        }
+        if self.azure_mode {
+            builder = builder.header("api-key", self.api_key.as_str());
+        } else {
+            builder =
+                builder.header("authorization", format!("Bearer {}", self.api_key.as_str()));
+        }
+        builder
+    }
 }
 
 #[derive(Debug, Serialize)]
@@ -410,19 +468,16 @@ impl LlmDriver for OpenAIDriver {
 
         let max_retries = 3;
         for attempt in 0..=max_retries {
-            let url = format!("{}/chat/completions", self.base_url);
+            let url = self.chat_url(&request.model);
             debug!(url = %url, attempt, "Sending OpenAI API request");
 
-            let mut req_builder = self
+            let req_builder = self
                 .client
                 .post(&url)
                 .header("content-type", "application/json")
                 .json(&oai_request);
 
-            if !self.api_key.as_str().is_empty() {
-                req_builder = req_builder
-                    .header("authorization", format!("Bearer {}", self.api_key.as_str()));
-            }
+            let mut req_builder = self.apply_auth(req_builder);
             for (k, v) in &self.extra_headers {
                 req_builder = req_builder.header(k, v);
             }
@@ -869,19 +924,16 @@ impl LlmDriver for OpenAIDriver {
         // Retry loop for the initial HTTP request
         let max_retries = 3;
         for attempt in 0..=max_retries {
-            let url = format!("{}/chat/completions", self.base_url);
+            let url = self.chat_url(&request.model);
             debug!(url = %url, attempt, "Sending OpenAI streaming request");
 
-            let mut req_builder = self
+            let req_builder = self
                 .client
                 .post(&url)
                 .header("content-type", "application/json")
                 .json(&oai_request);
 
-            if !self.api_key.as_str().is_empty() {
-                req_builder = req_builder
-                    .header("authorization", format!("Bearer {}", self.api_key.as_str()));
-            }
+            let mut req_builder = self.apply_auth(req_builder);
             for (k, v) in &self.extra_headers {
                 req_builder = req_builder.header(k, v);
             }
@@ -1717,4 +1769,60 @@ mod tests {
         assert!(msg.content.is_none());
         assert!(msg.reasoning_content.is_none());
     }
+
+    // ── Azure OpenAI tests ──────────────────────────────────────────
+
+    #[test]
+    fn test_azure_driver_creation() {
+        let driver = OpenAIDriver::new_azure(
+            "test-key".to_string(),
+            "https://myresource.openai.azure.com/openai/deployments".to_string(),
+        );
+        assert!(driver.azure_mode);
+    }
+
+    #[test]
+    fn test_standard_driver_not_azure() {
+        let driver = OpenAIDriver::new(
+            "test-key".to_string(),
+            "https://api.openai.com/v1".to_string(),
+        );
+        assert!(!driver.azure_mode);
+    }
+
+    #[test]
+    fn test_azure_chat_url() {
+        let driver = OpenAIDriver::new_azure(
+            "test-key".to_string(),
+            "https://myresource.openai.azure.com/openai/deployments".to_string(),
+        );
+        let url = driver.chat_url("my-gpt4o-deployment");
+        assert_eq!(
+            url,
+            "https://myresource.openai.azure.com/openai/deployments/my-gpt4o-deployment/chat/completions?api-version=2024-10-21"
+        );
+    }
+
+    #[test]
+    fn test_azure_chat_url_trailing_slash() {
+        let driver = OpenAIDriver::new_azure(
+            "test-key".to_string(),
+            "https://myresource.openai.azure.com/openai/deployments/".to_string(),
+        );
+        let url = driver.chat_url("gpt-4o");
+        assert_eq!(
+            url,
+            "https://myresource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21"
+        );
+    }
+
+    #[test]
+    fn test_standard_chat_url() {
+        let driver = OpenAIDriver::new(
+            "test-key".to_string(),
+            "https://api.openai.com/v1".to_string(),
+        );
+        let url = driver.chat_url("gpt-4o");
+        assert_eq!(url, "https://api.openai.com/v1/chat/completions");
+    }
 }
diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs
index b0e12de2..7ffe402a 100644
--- a/crates/openfang-runtime/src/model_catalog.rs
+++ b/crates/openfang-runtime/src/model_catalog.rs
@@ -5,11 +5,12 @@
 
 use openfang_types::model_catalog::{
     AuthStatus, ModelCatalogEntry, ModelTier, ProviderInfo, AI21_BASE_URL, ANTHROPIC_BASE_URL,
-    BEDROCK_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL,
-    FIREWORKS_BASE_URL, GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL,
-    HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
-    MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL,
-    OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
+    AZURE_OPENAI_BASE_URL, BEDROCK_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL,
+    COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL,
+    GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL,
+    LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL,
+    MOONSHOT_BASE_URL, NVIDIA_NIM_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
+    OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
     REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
     VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
     ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
@@ -752,6 +753,16 @@ fn builtin_providers() -> Vec {
             auth_status: AuthStatus::Missing,
             model_count: 0,
         },
+        // ── Azure OpenAI ───────────────────────────────────────────
+        ProviderInfo {
+            id: "azure".into(),
+            display_name: "Azure OpenAI".into(),
+            api_key_env: "AZURE_OPENAI_API_KEY".into(),
+            base_url: AZURE_OPENAI_BASE_URL.into(),
+            key_required: true,
+            auth_status: AuthStatus::Missing,
+            model_count: 0,
+        },
         // ── OpenAI Codex ────────────────────────────────────────────
         ProviderInfo {
             id: "codex".into(),
@@ -1410,6 +1421,67 @@ fn builtin_models() -> Vec {
             aliases: vec![],
         },
         // ══════════════════════════════════════════════════════════════
+        // Azure OpenAI (4)
+        // These represent common Azure deployment names. Users deploy models
+        // under their own deployment names, so these are illustrative defaults.
+        // ══════════════════════════════════════════════════════════════
+        ModelCatalogEntry {
+            id: "azure/gpt-4o".into(),
+            display_name: "GPT-4o (Azure)".into(),
+            provider: "azure".into(),
+            tier: ModelTier::Smart,
+            context_window: 128_000,
+            max_output_tokens: 16_384,
+            input_cost_per_m: 2.50,
+            output_cost_per_m: 10.0,
+            supports_tools: true,
+            supports_vision: true,
+            supports_streaming: true,
+            aliases: vec![],
+        },
+        ModelCatalogEntry {
+            id: "azure/gpt-4o-mini".into(),
+            display_name: "GPT-4o Mini (Azure)".into(),
+            provider: "azure".into(),
+            tier: ModelTier::Fast,
+            context_window: 128_000,
+            max_output_tokens: 16_384,
+            input_cost_per_m: 0.15,
+            output_cost_per_m: 0.60,
+            supports_tools: true,
+            supports_vision: true,
+            supports_streaming: true,
+            aliases: vec![],
+        },
+        ModelCatalogEntry {
+            id: "azure/gpt-4.1".into(),
+            display_name: "GPT-4.1 (Azure)".into(),
+            provider: "azure".into(),
+            tier: ModelTier::Frontier,
+            context_window: 1_047_576,
+            max_output_tokens: 32_768,
+            input_cost_per_m: 2.00,
+            output_cost_per_m: 8.00,
+            supports_tools: true,
+            supports_vision: true,
+            supports_streaming: true,
+            aliases: vec![],
+        },
+        ModelCatalogEntry {
+            id: "azure/gpt-4.1-mini".into(),
+            display_name: "GPT-4.1 Mini (Azure)".into(),
+            provider: "azure".into(),
+            tier: ModelTier::Fast,
+            context_window: 1_047_576,
+            max_output_tokens: 32_768,
+            input_cost_per_m: 0.40,
+            output_cost_per_m: 1.60,
+            supports_tools: true,
+            supports_vision: true,
+            supports_streaming: true,
+            aliases: vec![],
+        },
+        // ══════════════════════════════════════════════════════════════
         // Groq (11)
         // ══════════════════════════════════════════════════════════════
         ModelCatalogEntry {
@@ -3737,7 +3809,7 @@ mod tests {
     #[test]
     fn test_catalog_has_providers() {
         let catalog = ModelCatalog::new();
-        assert_eq!(catalog.list_providers().len(), 40);
+        assert_eq!(catalog.list_providers().len(), 41);
     }
 
     #[test]
@@ -4128,4 +4200,36 @@ mod tests {
         let entry = catalog.find_model("qwen-code").unwrap();
         assert_eq!(entry.id, "qwen-code/qwen3-coder");
     }
+
+    #[test]
+    fn test_azure_provider_in_catalog() {
+        let catalog = ModelCatalog::new();
+        let azure = catalog.get_provider("azure").unwrap();
+        assert_eq!(azure.display_name, "Azure OpenAI");
+        assert_eq!(azure.api_key_env, "AZURE_OPENAI_API_KEY");
+        assert!(azure.key_required);
+        assert!(azure.base_url.is_empty()); // user must supply their own
+    }
+
+    #[test]
+    fn test_azure_models() {
+        let catalog = ModelCatalog::new();
+        let models = catalog.models_by_provider("azure");
+        assert_eq!(models.len(), 4);
+        assert!(models.iter().any(|m| m.id == "azure/gpt-4o"));
+        assert!(models.iter().any(|m| m.id == "azure/gpt-4o-mini"));
+        assert!(models.iter().any(|m| m.id == "azure/gpt-4.1"));
+        assert!(models.iter().any(|m| m.id == "azure/gpt-4.1-mini"));
+    }
+
+    #[test]
+    fn test_azure_model_lookup() {
+        let catalog = ModelCatalog::new();
+        let entry = catalog.find_model("azure/gpt-4o").unwrap();
+        assert_eq!(entry.provider, "azure");
+        assert_eq!(entry.display_name, "GPT-4o (Azure)");
+        assert_eq!(entry.tier, ModelTier::Smart);
+        assert!(entry.supports_tools);
+        assert!(entry.supports_vision);
+    }
 }
diff --git a/crates/openfang-types/src/model_catalog.rs b/crates/openfang-types/src/model_catalog.rs
index 3fce8e7b..ea098f6f 100644
--- a/crates/openfang-types/src/model_catalog.rs
+++ b/crates/openfang-types/src/model_catalog.rs
@@ -53,6 +53,12 @@ pub const VOLCENGINE_CODING_BASE_URL: &str = "https://ark.cn-beijing.volces.com/
 // ── Chutes.ai ────────────────────────────────────────────────────
 pub const CHUTES_BASE_URL: &str = "https://llm.chutes.ai/v1";
 
+// ── Azure OpenAI ────────────────────────────────────────────────────
+/// Azure OpenAI requires a per-resource URL. Users must set their own via
+/// `base_url` or `[provider_urls] azure = "https://{resource}.openai.azure.com/openai/deployments"`.
+/// This constant is intentionally empty — it is never used as a default.
+pub const AZURE_OPENAI_BASE_URL: &str = "";
+
 // ── AWS Bedrock ───────────────────────────────────────────────────
 pub const BEDROCK_BASE_URL: &str = "https://bedrock-runtime.us-east-1.amazonaws.com";
 
@@ -298,4 +304,10 @@ mod tests {
         assert_eq!(parsed.auth_status, AuthStatus::Configured);
         assert_eq!(parsed.model_count, 3);
     }
+
+    #[test]
+    fn test_azure_openai_base_url_empty() {
+        // Azure requires user-supplied URL, so the constant must be empty.
+        assert!(AZURE_OPENAI_BASE_URL.is_empty());
+    }
 }