From 27e58038653edb2aee02072a5c392c29a680193c Mon Sep 17 00:00:00 2001 From: doronkopit5 Date: Mon, 15 Jun 2026 22:29:05 +0300 Subject: [PATCH 1/2] fix(azure): route gpt-5.x reasoning_effort via the v1 API (api-version=preview) The Azure provider used a legacy dated api-version (e.g. 2024-10-21), which predates reasoning_effort support, so Azure silently dropped the parameter and ran reasoning models (gpt-5.4-mini) at full/default effort. Migrate chat_completions and embeddings to Azure's v1 API (/openai/v1/...?api-version=preview), passing the deployment as the body `model`. Opt-in per provider: the v1 path is used only when the provider's api_version is "preview" or "v1", so providers on dated api-versions are unchanged. Adds v1-routing unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/providers/azure/provider.rs | 107 ++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 13 deletions(-) diff --git a/src/providers/azure/provider.rs b/src/providers/azure/provider.rs index 2c5b7a52..f66c98ad 100644 --- a/src/providers/azure/provider.rs +++ b/src/providers/azure/provider.rs @@ -58,6 +58,29 @@ impl AzureProvider { fn api_version(&self) -> String { self.config.params.get("api_version").unwrap().clone() } + + /// Whether this provider targets Azure's next-generation v1 API surface + /// (`/openai/v1/...`), selected by setting `api_version` to "preview" or "v1". + /// The v1 API is required for the latest models and parameters (for example + /// `reasoning_effort` on gpt-5.x); dated api-versions keep the legacy + /// deployment-in-path form. + fn uses_v1_api(&self) -> bool { + matches!(self.api_version().as_str(), "preview" | "v1") + } + + /// Base URL for the v1 API. When `base_url` is configured it is used verbatim + /// (operators point it at the `/openai/v1` root); otherwise it is built from + /// `resource_name`. + fn v1_base(&self) -> String { + if let Some(base_url) = self.config.params.get("base_url") { + base_url.trim_end_matches('/').to_string() + } else { + format!( + "https://{}.openai.azure.com/openai/v1", + self.config.params.get("resource_name").unwrap(), + ) + } + } } #[async_trait] @@ -113,15 +136,27 @@ impl Provider for AzureProvider { let deployment = model_config.params.get("deployment").unwrap(); let api_version = self.api_version(); - let url = format!( - "{}/{}/chat/completions?api-version={}", - self.endpoint(), - deployment, - api_version - ); // Convert to Azure-specific request format - let azure_request = AzureChatCompletionRequest::from(payload.clone()); + let mut azure_request = AzureChatCompletionRequest::from(payload.clone()); + + // The v1 API routes by the `model` field in the body rather than a deployment + // in the URL path. Legacy dated api-versions keep the deployment-in-path form. + let url = if self.uses_v1_api() { + azure_request.base.model = deployment.clone(); + format!( + "{}/chat/completions?api-version={}", + self.v1_base(), + api_version + ) + } else { + format!( + "{}/{}/chat/completions?api-version={}", + self.endpoint(), + deployment, + api_version + ) + }; let response = self .http_client @@ -209,12 +244,20 @@ impl Provider for AzureProvider { let deployment = model_config.params.get("deployment").unwrap(); let api_version = self.api_version(); - let url = format!( - "{}/{}/embeddings?api-version={}", - self.endpoint(), - deployment, - api_version - ); + // The v1 API routes by the `model` field in the body rather than a deployment + // in the URL path. Legacy dated api-versions keep the deployment-in-path form. + let mut payload = payload; + let url = if self.uses_v1_api() { + payload.model = deployment.clone(); + format!("{}/embeddings?api-version={}", self.v1_base(), api_version) + } else { + format!( + "{}/{}/embeddings?api-version={}", + self.endpoint(), + deployment, + api_version + ) + }; let response = self .http_client @@ -244,6 +287,44 @@ impl Provider for AzureProvider { } } +#[cfg(test)] +mod v1_routing_tests { + use super::*; + use std::collections::HashMap; + + fn provider(params: &[(&str, &str)]) -> AzureProvider { + AzureProvider { + config: ProviderConfig { + key: "azure".to_string(), + r#type: ProviderType::Azure, + api_key: "test-key".to_string(), + params: params + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(), + }, + http_client: Client::new(), + } + } + + #[test] + fn preview_api_version_selects_v1_base() { + let p = provider(&[("resource_name", "my-res"), ("api_version", "preview")]); + assert!(p.uses_v1_api()); + assert_eq!(p.v1_base(), "https://my-res.openai.azure.com/openai/v1"); + } + + #[test] + fn dated_api_version_keeps_legacy_deployment_path() { + let p = provider(&[("resource_name", "my-res"), ("api_version", "2024-10-21")]); + assert!(!p.uses_v1_api()); + assert_eq!( + p.endpoint(), + "https://my-res.openai.azure.com/openai/deployments" + ); + } +} + #[cfg(test)] mod reasoning_effort_precedence_tests { use super::*; From 3a79c9b36d004e1f38e1b67c9d606f56f9101d02 Mon Sep 17 00:00:00 2001 From: doronkopit5 Date: Tue, 16 Jun 2026 09:45:23 +0300 Subject: [PATCH 2/2] fix(reasoning): accept 'none' (and minimal/xhigh) for nested reasoning.effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nested `reasoning.effort` validator rejected any value outside low/medium/high, so `reasoning.effort="none"` was blocked at the gateway before reaching the provider — even though gpt-5.1+ default to and accept `none`. This was inconsistent with the unvalidated top-level `reasoning_effort` field. Widen the accepted set to none/minimal/low/medium/high/xhigh (the union across current OpenAI reasoning models) and let the provider be the source of truth on per-model support; genuinely unknown values are still rejected locally. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 2 +- src/models/chat.rs | 63 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39ee42b8..68da85d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2269,7 +2269,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hub" -version = "0.9.1" +version = "0.9.3" dependencies = [ "anyhow", "async-stream", diff --git a/src/models/chat.rs b/src/models/chat.rs index af11e136..753e19c3 100644 --- a/src/models/chat.rs +++ b/src/models/chat.rs @@ -15,7 +15,7 @@ use super::usage::Usage; #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct ReasoningConfig { #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, // "low" | "medium" | "high" + pub effort: Option, // model-dependent: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens: Option, // Alternative to effort #[serde(skip_serializing_if = "Option::is_none")] @@ -28,16 +28,24 @@ impl ReasoningConfig { tracing::warn!("Both effort and max_tokens specified - prioritizing max_tokens"); } - // Only validate effort if max_tokens is not present (since max_tokens takes priority) + // Only validate effort if max_tokens is not present (since max_tokens takes priority). + // The accepted set is the union across OpenAI reasoning models — values are + // model-dependent (`none` on gpt-5.1+, `minimal` on the original GPT-5 series, + // `xhigh` on gpt-5.1-codex-max), so we accept any of them here and let the upstream + // provider be the source of truth on per-model support (it returns a clear 400 for a + // value a given model doesn't accept). if let Some(effort) = &self.effort { if effort.trim().is_empty() { if self.max_tokens.is_none() { return Err("Effort cannot be empty string".to_string()); } } else if self.max_tokens.is_none() - && !["low", "medium", "high"].contains(&effort.as_str()) + && !["none", "minimal", "low", "medium", "high", "xhigh"].contains(&effort.as_str()) { - return Err("Invalid effort value. Must be 'low', 'medium', or 'high'".to_string()); + return Err( + "Invalid effort value. Must be one of: none, minimal, low, medium, high, xhigh" + .to_string(), + ); } } @@ -157,3 +165,50 @@ pub struct ChatCompletionChoice { #[serde(skip_serializing_if = "Option::is_none")] pub logprobs: Option, } + +#[cfg(test)] +mod reasoning_effort_validation_tests { + use super::ReasoningConfig; + + fn cfg(effort: &str) -> ReasoningConfig { + ReasoningConfig { + effort: Some(effort.to_string()), + max_tokens: None, + exclude: None, + } + } + + #[test] + fn accepts_none() { + // `none` is the default reasoning effort on gpt-5.1+; it must not be + // rejected before reaching the provider. + assert!(cfg("none").validate().is_ok()); + } + + #[test] + fn accepts_minimal_and_xhigh() { + // `minimal` exists on the original GPT-5 series; `xhigh` on gpt-5.1-codex-max. + assert!(cfg("minimal").validate().is_ok()); + assert!(cfg("xhigh").validate().is_ok()); + } + + #[test] + fn still_accepts_low_medium_high() { + assert!(cfg("low").validate().is_ok()); + assert!(cfg("medium").validate().is_ok()); + assert!(cfg("high").validate().is_ok()); + } + + #[test] + fn still_rejects_unknown_value() { + // A genuine typo is still caught locally rather than round-tripping to a 400. + let err = cfg("invalid").validate().unwrap_err(); + assert!(err.contains("Invalid effort value")); + } + + #[test] + fn still_rejects_empty_value() { + let err = cfg("").validate().unwrap_err(); + assert!(err.contains("Effort cannot be empty string")); + } +}