feature batch

This commit is contained in:
jaberjaber23
2026-03-15 20:23:30 +03:00
parent 77ed954d18
commit f1ca52714d
12 changed files with 476 additions and 30 deletions

View File

@@ -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),

View File

@@ -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.

View File

@@ -5035,3 +5035,5 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Toast notification container -->
<div id="toast-container" class="toast-container" aria-live="polite"></div>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>

View File

@@ -6,7 +6,12 @@
<title>OpenFang Dashboard</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#6366f1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/contrib/auto-render.min.js"></script>
</head>

View File

@@ -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(/<pre><code/g, '<pre><button class="copy-btn" onclick="copyCode(this)">Copy</button><code');
// Open external links in new tab
@@ -34,6 +66,26 @@ function renderMarkdown(text) {
return escapeHtml(text);
}
// Render LaTeX math in the chat message container using KaTeX auto-render.
// Call this after new messages are inserted into the DOM.
function renderLatex(el) {
if (typeof renderMathInElement !== 'function') return;
var target = el || document.getElementById('messages');
if (!target) return;
try {
renderMathInElement(target, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false }
],
throwOnError: false,
trust: false
});
} catch(e) { /* KaTeX render error — ignore gracefully */ }
}
function copyCode(btn) {
var code = btn.nextElementSibling;
if (code) {

View File

@@ -1046,10 +1046,16 @@ function chatPage() {
});
},
_latexTimer: null,
scrollToBottom() {
var self = this;
var el = document.getElementById('messages');
if (el) self.$nextTick(function() { el.scrollTop = el.scrollHeight; });
if (el) self.$nextTick(function() {
el.scrollTop = el.scrollHeight;
// Debounce LaTeX rendering to avoid running on every streaming token
if (self._latexTimer) clearTimeout(self._latexTimer);
self._latexTimer = setTimeout(function() { renderLatex(el); }, 150);
});
},
addFiles(files) {

View File

@@ -0,0 +1,13 @@
{
"name": "OpenFang Agent OS",
"short_name": "OpenFang",
"description": "Open-source Agent Operating System",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#6366f1",
"icons": [
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
]
}

View File

@@ -0,0 +1,3 @@
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});

View File

@@ -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<ProviderDefaults> {
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<Arc<dyn LlmDriver>, 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<Arc<dyn LlmDriver>, 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"
);
}
}

View File

@@ -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<String>,
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");
}
}

View File

@@ -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<ProviderInfo> {
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<ModelCatalogEntry> {
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);
}
}

View File

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