mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-04-25 17:25:11 +02:00
feature batch
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
13
crates/openfang-api/static/manifest.json
Normal file
13
crates/openfang-api/static/manifest.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
3
crates/openfang-api/static/sw.js
Normal file
3
crates/openfang-api/static/sw.js
Normal file
@@ -0,0 +1,3 @@
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user