diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index 829018b850..86c32fac88 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -160,6 +160,44 @@ pub fn remove_openrouter_management_api_key() -> Result<(), String> { settings.save().map_err(|error| error.to_string()) } +// ── Azure OpenAI API version ───────────────────────────────────────── + +fn azure_openai_provider(provider_id: &str) -> Result { + let id = parse_provider_arg(provider_id)?; + if id != codexbar::core::ProviderId::AzureOpenAI { + return Err(format!( + "Provider '{provider_id}' does not expose an Azure OpenAI API-version picker" + )); + } + Ok(id) +} + +#[tauri::command] +pub fn get_provider_azure_api_version(provider_id: String) -> Result, String> { + let id = azure_openai_provider(&provider_id)?; + Ok(ApiKeys::load() + .api_version(id.cli_name()) + .map(ToOwned::to_owned)) +} + +#[tauri::command] +pub fn set_provider_azure_api_version( + provider_id: String, + api_version: String, +) -> Result<(), String> { + let id = azure_openai_provider(&provider_id)?; + let value = api_version.trim(); + if value.len() > 128 || value.chars().any(char::is_control) { + return Err("Azure OpenAI API version is invalid".to_string()); + } + let mut keys = ApiKeys::load(); + keys.set_api_version( + id.cli_name(), + (!value.is_empty()).then_some(value.to_string()), + ); + keys.save().map_err(|error| error.to_string()) +} + // ── Per-provider cookie source + region ─────────────────────────────── /// Map a CLI-name string to a `ProviderId` whose cookie source is exposed in diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index f1f3f07de0..83720c9d6b 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -238,6 +238,8 @@ fn main() { commands::has_openrouter_management_api_key, commands::set_openrouter_management_api_key, commands::remove_openrouter_management_api_key, + commands::get_provider_azure_api_version, + commands::set_provider_azure_api_version, commands::get_provider_cookie_source_options, commands::set_provider_region, commands::get_provider_region_options, diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index a22f68a0e1..7de5475e02 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -446,6 +446,17 @@ export function setProviderGatewayUrl( return invoke("set_provider_gateway_url", { providerId, gatewayUrl }); } +export function getProviderAzureApiVersion(providerId: string): Promise { + return invoke("get_provider_azure_api_version", { providerId }); +} + +export function setProviderAzureApiVersion( + providerId: string, + apiVersion: string, +): Promise { + return invoke("set_provider_azure_api_version", { providerId, apiVersion }); +} + // ── Phase 6d — credential detection ────────────────────────────────── export function openPath(path: string): Promise { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 9da9e5742d..c51643f5e6 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -46,6 +46,7 @@ import { ProviderIssueNotice } from "./sections/ProviderIssueNotice"; import { CredentialStorageSection } from "./sections/CredentialStorageSection"; import { CredentialsDispatcher } from "./sections/CredentialsDispatcher"; import { WayfinderGatewaySection } from "./sections/WayfinderGatewaySection"; +import { AzureApiVersionSection } from "./sections/AzureApiVersionSection"; interface Props { providerId: string | null; @@ -348,6 +349,13 @@ export function ProviderDetailPane({ t={t} onChanged={reload} /> + {detail.id === "azureopenai" && ( + + )} {detail.id === "codex" && } void; +} + +const BUILT_IN_OPTIONS = [ + { value: "", label: "Default" }, + { value: "v1", label: "OpenAI-compatible v1" }, +]; + +export function AzureApiVersionSection({ + providerId, + disabled, + onChanged, +}: Props) { + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let stale = false; + setError(null); + void getProviderAzureApiVersion(providerId) + .then((next) => { + if (!stale) setValue(next ?? ""); + }) + .catch((reason: unknown) => { + if (!stale) setError(String(reason)); + }); + return () => { + stale = true; + }; + }, [providerId]); + + const options = useMemo(() => { + if (!value || BUILT_IN_OPTIONS.some((option) => option.value === value)) { + return BUILT_IN_OPTIONS; + } + return [...BUILT_IN_OPTIONS, { value, label: value }]; + }, [value]); + + const handleChange = async (next: string) => { + if (next === value || busy || disabled) return; + setBusy(true); + setError(null); + try { + await setProviderAzureApiVersion(providerId, next); + setValue(next); + onChanged(); + } catch (reason: unknown) { + setError(String(reason)); + } finally { + setBusy(false); + } + }; + + return ( +
+

Azure OpenAI API version

+ +

+ Default uses AZURE_OPENAI_API_VERSION, then 2024-10-21. +

+ {error &&

{error}

} +
+ ); +} diff --git a/rust/src/providers/azureopenai.rs b/rust/src/providers/azureopenai.rs index 21acd3cb41..552b82983d 100644 --- a/rust/src/providers/azureopenai.rs +++ b/rust/src/providers/azureopenai.rs @@ -102,17 +102,51 @@ impl AzureOpenAIProvider { fn resolve_config(ctx: &FetchContext) -> Result { if let Some(raw) = ctx.api_key.as_deref().and_then(clean_string) { - return Self::parse_saved_config(&raw); + let config = Self::parse_saved_config(&raw)?; + return Ok(Self::apply_saved_api_version( + config, + &raw, + ApiKeys::load().api_version("azureopenai"), + )); } if let Some(config) = Self::config_from_env()? { return Ok(config); } if let Some(raw) = ApiKeys::load().get("azureopenai") { - return Self::parse_saved_config(raw); + let config = Self::parse_saved_config(raw)?; + return Ok(Self::apply_saved_api_version( + config, + raw, + ApiKeys::load().api_version("azureopenai"), + )); } Err(ProviderError::AuthRequired) } + fn apply_saved_api_version( + mut config: AzureOpenAIConfig, + raw: &str, + stored_api_version: Option<&str>, + ) -> AzureOpenAIConfig { + if !Self::has_explicit_api_version(raw) + && let Some(api_version) = stored_api_version.and_then(clean_string) + { + config.api_version = api_version; + } + config + } + + fn has_explicit_api_version(raw: &str) -> bool { + if let Ok(value) = serde_json::from_str::(raw) { + return value + .get("api_version") + .and_then(serde_json::Value::as_str) + .and_then(clean_string) + .is_some(); + } + raw.split('|').nth(3).and_then(clean_string).is_some() + } + fn config_from_env() -> Result, ProviderError> { let Some(api_key) = clean_env("AZURE_OPENAI_API_KEY") else { return Ok(None); @@ -357,6 +391,27 @@ mod tests { assert_eq!(config.api_version, "v1"); } + #[test] + fn stored_api_version_overrides_missing_composite_version() { + let config = + AzureOpenAIProvider::parse_saved_config("key|example.openai.azure.com|chat-prod") + .unwrap(); + let config = AzureOpenAIProvider::apply_saved_api_version( + config, + "key|example.openai.azure.com|chat-prod", + Some("v1"), + ); + assert_eq!(config.api_version, "v1"); + } + + #[test] + fn explicit_saved_api_version_wins_over_picker_value() { + let raw = r#"{"api_key":"key","endpoint":"example.openai.azure.com","deployment":"chat-prod","api_version":"2024-10-21"}"#; + let config = AzureOpenAIProvider::parse_saved_config(raw).unwrap(); + let config = AzureOpenAIProvider::apply_saved_api_version(config, raw, Some("v1")); + assert_eq!(config.api_version, "2024-10-21"); + } + #[test] fn rejects_insecure_or_tricky_endpoint_overrides() { assert!(AzureOpenAIProvider::parse_endpoint("http://example.com").is_err()); diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index 36402a93e6..e2d842d60a 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -15,6 +15,10 @@ pub struct ApiKeyEntry { /// Optional label for the key (e.g., "Personal", "Work") #[serde(default)] pub label: Option, + /// Azure OpenAI API-version override kept alongside the credential. + /// `None` inherits `AZURE_OPENAI_API_VERSION` and the provider default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_version: Option, } impl ApiKeys { @@ -57,16 +61,39 @@ impl ApiKeys { /// Set API key for a provider pub fn set(&mut self, provider_id: &str, api_key: &str, label: Option<&str>) { let now = chrono::Utc::now().format("%Y-%m-%d %H:%M").to_string(); + let api_version = self + .keys + .get(provider_id) + .and_then(|entry| entry.api_version.clone()); self.keys.insert( provider_id.to_string(), ApiKeyEntry { api_key: api_key.to_string(), saved_at: now, label: label.map(|s| s.to_string()), + api_version, }, ); } + /// Get a provider-specific API-version override, if one is stored. + pub fn api_version(&self, provider_id: &str) -> Option<&str> { + self.keys + .get(provider_id) + .and_then(|entry| entry.api_version.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + } + + /// Store or clear a provider-specific API-version override. + pub fn set_api_version(&mut self, provider_id: &str, api_version: Option) { + if let Some(entry) = self.keys.get_mut(provider_id) { + entry.api_version = api_version + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + } + } + /// Remove API key for a provider pub fn remove(&mut self, provider_id: &str) { self.keys.remove(provider_id); @@ -579,3 +606,29 @@ pub fn get_api_key_providers() -> Vec { }, ] } + +#[cfg(test)] +mod tests { + use super::ApiKeys; + + #[test] + fn api_version_survives_api_key_update() { + let mut keys = ApiKeys::default(); + keys.set("azureopenai", "key", Some("work")); + keys.set_api_version("azureopenai", Some("v1".to_string())); + keys.set("azureopenai", "new-key", None); + + assert_eq!(keys.get("azureopenai"), Some("new-key")); + assert_eq!(keys.api_version("azureopenai"), Some("v1")); + } + + #[test] + fn clearing_api_version_removes_the_override() { + let mut keys = ApiKeys::default(); + keys.set("azureopenai", "key", None); + keys.set_api_version("azureopenai", Some("2025-01-01".to_string())); + keys.set_api_version("azureopenai", None); + + assert_eq!(keys.api_version("azureopenai"), None); + } +}