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 @@ {
+ 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());
+ }
}