use super::ModelConfig;
use crate::error::{Error, Result};
use std::{borrow::Cow, collections::HashMap};
use url::Url;
const DEFAULT_API_VERSION: &str = "v1";
pub(crate) fn is_azure(model: &ModelConfig) -> bool {
model.provider == "azure"
}
fn env_value(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
pub(crate) fn api_version() -> String {
env_value("AZURE_OPENAI_API_VERSION").unwrap_or_else(|| DEFAULT_API_VERSION.to_string())
}
pub(crate) fn with_api_version(model: &ModelConfig, endpoint: String) -> String {
if !is_azure(model) {
return endpoint;
}
let version = api_version();
if version == DEFAULT_API_VERSION {
return endpoint;
}
let separator = if endpoint.contains('?') { '&' } else { '?' };
format!("{endpoint}{separator}api-version={version}")
}
pub(crate) fn resolve_base_url(model: &ModelConfig) -> Result<String> {
let resolved = env_value("AZURE_OPENAI_BASE_URL")
.or_else(|| {
env_value("AZURE_OPENAI_RESOURCE_NAME")
.map(|name| format!("https://{name}.openai.azure.com/openai/v1"))
})
.or_else(|| {
let base = model.base_url.trim().trim_end_matches('/');
(!base.is_empty()).then(|| base.to_string())
});
match resolved {
Some(base_url) => normalize_base_url(&base_url),
None => Err(Error::msg(
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or \
AZURE_OPENAI_RESOURCE_NAME, or set baseUrl for the azure provider in models.json.",
)),
}
}
fn normalize_base_url(base_url: &str) -> Result<String> {
let trimmed = base_url.trim().trim_end_matches('/');
let mut url = Url::parse(trimmed)
.map_err(|_| Error::msg(format!("Invalid Azure OpenAI base URL: {base_url}")))?;
let host = url.host_str().unwrap_or("").to_ascii_lowercase();
let is_azure_host = host.ends_with(".openai.azure.com")
|| host.ends_with(".cognitiveservices.azure.com")
|| host.ends_with(".ai.azure.com");
let path = url.path().trim_end_matches('/').to_string();
if is_azure_host && matches!(path.as_str(), "" | "/openai" | "/openai/v1/responses") {
url.set_path("/openai/v1");
url.set_query(None);
}
Ok(url.to_string().trim_end_matches('/').to_string())
}
fn deployment_map(value: Option<String>) -> HashMap<String, String> {
let mut map = HashMap::new();
let Some(value) = value else {
return map;
};
for entry in value.split(',') {
let Some((model_id, deployment)) = entry.split_once('=') else {
continue;
};
let (model_id, deployment) = (model_id.trim(), deployment.trim());
if model_id.is_empty() || deployment.is_empty() {
continue;
}
map.insert(model_id.to_string(), deployment.to_string());
}
map
}
pub(crate) fn request_model_name(model: &ModelConfig) -> Cow<'_, str> {
if !is_azure(model) {
return Cow::Borrowed(&model.model_id);
}
match deployment_map(env_value("AZURE_OPENAI_DEPLOYMENT_NAME_MAP")).remove(&model.model_id) {
Some(deployment) => Cow::Owned(deployment),
None => Cow::Borrowed(&model.model_id),
}
}
pub(crate) fn resolve_if_azure(model: &ModelConfig) -> Result<Option<ModelConfig>> {
if !is_azure(model) {
return Ok(None);
}
let mut resolved = model.clone();
resolved.base_url = resolve_base_url(model)?;
Ok(Some(resolved))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{AgentDirGuard, env_key_lock};
const AZURE_ENV_NAMES: [&str; 4] = [
"AZURE_OPENAI_BASE_URL",
"AZURE_OPENAI_RESOURCE_NAME",
"AZURE_OPENAI_API_VERSION",
"AZURE_OPENAI_DEPLOYMENT_NAME_MAP",
];
#[must_use = "guard 必须存活到断言结束"]
struct AzureEnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
saved: Vec<(&'static str, Option<String>)>,
}
impl AzureEnvGuard {
fn set(pairs: &[(&'static str, &str)]) -> Self {
let lock = env_key_lock();
let saved: Vec<(&'static str, Option<String>)> = AZURE_ENV_NAMES
.iter()
.map(|n| (*n, std::env::var(n).ok()))
.collect();
for name in AZURE_ENV_NAMES {
unsafe { std::env::remove_var(name) };
}
for (key, value) in pairs {
unsafe { std::env::set_var(key, value) };
}
Self { _lock: lock, saved }
}
}
impl Drop for AzureEnvGuard {
fn drop(&mut self) {
for (name, value) in self.saved.drain(..) {
match value {
Some(value) => unsafe { std::env::set_var(name, value) },
None => unsafe { std::env::remove_var(name) },
}
}
}
}
fn azure_model(model_id: &str) -> ModelConfig {
let entry = super::super::model_resolver::find_model("azure", model_id)
.unwrap_or_else(|err| panic!("目录里应有 azure 模型 {model_id}: {err}"));
super::super::model_resolver::model_config_from_entry(&entry, Some("sk-test".into()), None)
}
#[test]
fn normalizes_azure_host_paths() {
for (input, expected) in [
(
"https://res.openai.azure.com",
"https://res.openai.azure.com/openai/v1",
),
(
"https://res.openai.azure.com/",
"https://res.openai.azure.com/openai/v1",
),
(
"https://res.openai.azure.com/openai",
"https://res.openai.azure.com/openai/v1",
),
(
"https://res.openai.azure.com/openai/v1/responses",
"https://res.openai.azure.com/openai/v1",
),
(
"https://res.cognitiveservices.azure.com",
"https://res.cognitiveservices.azure.com/openai/v1",
),
(
"https://gateway.example.com/v1/",
"https://gateway.example.com/v1",
),
] {
assert_eq!(normalize_base_url(input).unwrap(), expected, "{input}");
}
assert!(normalize_base_url("not a url").is_err());
}
#[test]
fn base_url_prefers_env_then_resource_then_catalog() {
let _ad = AgentDirGuard::temp();
{
let _env =
AzureEnvGuard::set(&[("AZURE_OPENAI_BASE_URL", "https://gateway.example.com/v1/")]);
assert_eq!(
resolve_base_url(&azure_model("gpt-5.4")).unwrap(),
"https://gateway.example.com/v1"
);
}
{
let _env = AzureEnvGuard::set(&[("AZURE_OPENAI_RESOURCE_NAME", "my-res")]);
assert_eq!(
resolve_base_url(&azure_model("gpt-5.4")).unwrap(),
"https://my-res.openai.azure.com/openai/v1"
);
}
{
let _env = AzureEnvGuard::set(&[]);
let err = resolve_base_url(&azure_model("gpt-5.4"))
.unwrap_err()
.to_string();
assert!(err.contains("AZURE_OPENAI_BASE_URL"), "{err}");
}
{
let _env = AzureEnvGuard::set(&[]);
let mut model = azure_model("gpt-5.4");
model.base_url = "https://my-gateway.example.com/openai/v1".into();
assert_eq!(
resolve_base_url(&model).unwrap(),
"https://my-gateway.example.com/openai/v1"
);
}
}
#[test]
fn deployment_map_rewrites_only_azure_request_models() {
let _ad = AgentDirGuard::temp();
let _env = AzureEnvGuard::set(&[(
"AZURE_OPENAI_DEPLOYMENT_NAME_MAP",
"gpt-5.4=my-deployment, broken, =x",
)]);
assert_eq!(request_model_name(&azure_model("gpt-5.4")), "my-deployment");
assert_eq!(request_model_name(&azure_model("gpt-5-mini")), "gpt-5-mini");
let mut other = azure_model("gpt-5.4");
other.provider = "openai".into();
assert_eq!(request_model_name(&other), "gpt-5.4");
}
#[test]
fn api_version_query_follows_env_and_provider() {
let _ad = AgentDirGuard::temp();
{
let _env = AzureEnvGuard::set(&[]);
let model = azure_model("gpt-5.4");
assert_eq!(
with_api_version(
&model,
"https://x.openai.azure.com/openai/v1/responses".into()
),
"https://x.openai.azure.com/openai/v1/responses"
);
}
{
let _env = AzureEnvGuard::set(&[("AZURE_OPENAI_API_VERSION", "2024-10-21")]);
let model = azure_model("gpt-5.4");
assert_eq!(
with_api_version(
&model,
"https://x.openai.azure.com/openai/v1/responses".into()
),
"https://x.openai.azure.com/openai/v1/responses?api-version=2024-10-21"
);
let mut other = model.clone();
other.provider = "openai".into();
assert_eq!(
with_api_version(&other, "https://api.openai.com/v1/responses".into()),
"https://api.openai.com/v1/responses"
);
}
}
#[test]
fn resolve_if_azure_passes_non_azure_through() {
let _ad = AgentDirGuard::temp();
let _env =
AzureEnvGuard::set(&[("AZURE_OPENAI_BASE_URL", "https://gateway.example.com/v1")]);
let mut other = azure_model("gpt-5.4");
other.provider = "openai".into();
assert!(resolve_if_azure(&other).unwrap().is_none());
let resolved = resolve_if_azure(&azure_model("gpt-5.4")).unwrap().unwrap();
assert_eq!(resolved.base_url, "https://gateway.example.com/v1");
assert_eq!(resolved.model_id, "gpt-5.4");
}
}