From e8296474ae225e161bc5883745e632f6d4a9b192 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 17 Jul 2025 09:08:29 +0000 Subject: [PATCH] feat: eliminate Azure OpenAI API version duplication - Add URL parsing utility to extract API version from Base URL - Modify OpenAI provider to use extracted API version when azureApiVersion field is not set - Update UI to show helpful message when API version is detected in URL - Add comprehensive tests for URL parsing functionality Fixes #5805 --- src/api/providers/openai.ts | 24 +++- src/utils/__tests__/azure-url-parser.test.ts | 124 ++++++++++++++++++ src/utils/azure-url-parser.ts | 53 ++++++++ .../settings/providers/OpenAICompatible.tsx | 8 ++ 4 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 src/utils/__tests__/azure-url-parser.test.ts create mode 100644 src/utils/azure-url-parser.ts diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index f5e4e4c985..312d4112c1 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -13,6 +13,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { XmlMatcher } from "../../utils/xml-matcher" +import { extractApiVersionFromUrl, isAzureOpenAiUrl, removeApiVersionFromUrl } from "../../utils/azure-url-parser" import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToR1Format } from "../transform/r1-format" @@ -35,12 +36,25 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl super() this.options = options - const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1" + const originalBaseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1" const apiKey = this.options.openAiApiKey ?? "not-provided" const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) const urlHost = this._getUrlHost(this.options.openAiBaseUrl) const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure + // Extract API version from URL if present and no explicit azureApiVersion is set + let effectiveApiVersion = this.options.azureApiVersion + let baseURL = originalBaseURL + + if (isAzureOpenAi && !effectiveApiVersion) { + const extractedVersion = extractApiVersionFromUrl(originalBaseURL) + if (extractedVersion) { + effectiveApiVersion = extractedVersion + // For AzureOpenAI client, remove api-version from baseURL since it's passed separately + baseURL = removeApiVersionFromUrl(originalBaseURL) + } + } + const headers = { ...DEFAULT_HEADERS, ...(this.options.openAiHeaders || {}), @@ -49,10 +63,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl if (isAzureAiInference) { // Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure this.client = new OpenAI({ - baseURL, + baseURL: originalBaseURL, // Keep original URL for AI Inference apiKey, defaultHeaders: headers, - defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" }, + defaultQuery: { "api-version": effectiveApiVersion || "2024-05-01-preview" }, }) } else if (isAzureOpenAi) { // Azure API shape slightly differs from the core API shape: @@ -60,12 +74,12 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl this.client = new AzureOpenAI({ baseURL, apiKey, - apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion, + apiVersion: effectiveApiVersion || azureOpenAiDefaultApiVersion, defaultHeaders: headers, }) } else { this.client = new OpenAI({ - baseURL, + baseURL: originalBaseURL, apiKey, defaultHeaders: headers, }) diff --git a/src/utils/__tests__/azure-url-parser.test.ts b/src/utils/__tests__/azure-url-parser.test.ts new file mode 100644 index 0000000000..28ad8e1820 --- /dev/null +++ b/src/utils/__tests__/azure-url-parser.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest' +import { extractApiVersionFromUrl, isAzureOpenAiUrl, removeApiVersionFromUrl } from '../azure-url-parser' + +describe('azure-url-parser', () => { + describe('extractApiVersionFromUrl', () => { + it('should extract API version from Azure OpenAI URL', () => { + const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?api-version=2024-05-01-preview' + const result = extractApiVersionFromUrl(url) + expect(result).toBe('2024-05-01-preview') + }) + + it('should extract API version from URL with multiple query parameters', () => { + const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?foo=bar&api-version=2024-12-01-preview&baz=qux' + const result = extractApiVersionFromUrl(url) + expect(result).toBe('2024-12-01-preview') + }) + + it('should return null when no api-version parameter exists', () => { + const url = 'https://api.openai.com/v1/chat/completions' + const result = extractApiVersionFromUrl(url) + expect(result).toBeNull() + }) + + it('should return null for invalid URLs', () => { + const invalidUrl = 'not-a-valid-url' + const result = extractApiVersionFromUrl(invalidUrl) + expect(result).toBeNull() + }) + + it('should handle empty api-version parameter', () => { + const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?api-version=' + const result = extractApiVersionFromUrl(url) + expect(result).toBe('') + }) + + it('should handle URL without query parameters', () => { + const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions' + const result = extractApiVersionFromUrl(url) + expect(result).toBeNull() + }) + }) + + describe('isAzureOpenAiUrl', () => { + it('should return true for Azure OpenAI URLs with .openai.azure.com', () => { + const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions' + const result = isAzureOpenAiUrl(url) + expect(result).toBe(true) + }) + + it('should return true for Azure URLs ending with .azure.com', () => { + const url = 'https://myservice.azure.com/api/v1' + const result = isAzureOpenAiUrl(url) + expect(result).toBe(true) + }) + + it('should return true for URLs with /openai/deployments/ path', () => { + const url = 'https://custom-domain.com/openai/deployments/mymodel/chat/completions' + const result = isAzureOpenAiUrl(url) + expect(result).toBe(true) + }) + + it('should return false for regular OpenAI URLs', () => { + const url = 'https://api.openai.com/v1/chat/completions' + const result = isAzureOpenAiUrl(url) + expect(result).toBe(false) + }) + + it('should return false for other API URLs', () => { + const url = 'https://api.anthropic.com/v1/messages' + const result = isAzureOpenAiUrl(url) + expect(result).toBe(false) + }) + + it('should return false for invalid URLs', () => { + const invalidUrl = 'not-a-valid-url' + const result = isAzureOpenAiUrl(invalidUrl) + expect(result).toBe(false) + }) + + it('should handle case insensitive hostname matching', () => { + const url = 'https://MYRESOURCE.OPENAI.AZURE.COM/openai/deployments/mymodel' + const result = isAzureOpenAiUrl(url) + expect(result).toBe(true) + }) + }) + + describe('removeApiVersionFromUrl', () => { + it('should remove api-version parameter from URL', () => { + const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?api-version=2024-05-01-preview' + const result = removeApiVersionFromUrl(url) + expect(result).toBe('https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions') + }) + + it('should remove api-version parameter while preserving other parameters', () => { + const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?foo=bar&api-version=2024-05-01-preview&baz=qux' + const result = removeApiVersionFromUrl(url) + expect(result).toBe('https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?foo=bar&baz=qux') + }) + + it('should return original URL when no api-version parameter exists', () => { + const url = 'https://api.openai.com/v1/chat/completions?foo=bar' + const result = removeApiVersionFromUrl(url) + expect(result).toBe(url) + }) + + it('should return original URL for invalid URLs', () => { + const invalidUrl = 'not-a-valid-url' + const result = removeApiVersionFromUrl(invalidUrl) + expect(result).toBe(invalidUrl) + }) + + it('should handle URL with only api-version parameter', () => { + const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?api-version=2024-05-01-preview' + const result = removeApiVersionFromUrl(url) + expect(result).toBe('https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions') + }) + + it('should handle URL without query parameters', () => { + const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions' + const result = removeApiVersionFromUrl(url) + expect(result).toBe(url) + }) + }) +}) \ No newline at end of file diff --git a/src/utils/azure-url-parser.ts b/src/utils/azure-url-parser.ts new file mode 100644 index 0000000000..1188932476 --- /dev/null +++ b/src/utils/azure-url-parser.ts @@ -0,0 +1,53 @@ +/** + * Utility functions for parsing Azure OpenAI URLs and extracting API versions + */ + +/** + * Extracts the API version from an Azure OpenAI URL query parameter + * @param url The Azure OpenAI URL that may contain an api-version query parameter + * @returns The extracted API version string, or null if not found + */ +export function extractApiVersionFromUrl(url: string): string | null { + try { + const urlObj = new URL(url) + return urlObj.searchParams.get('api-version') + } catch (error) { + // Invalid URL format + return null + } +} + +/** + * Checks if a URL appears to be an Azure OpenAI URL + * @param url The URL to check + * @returns True if the URL appears to be an Azure OpenAI URL + */ +export function isAzureOpenAiUrl(url: string): boolean { + try { + const urlObj = new URL(url) + const host = urlObj.host.toLowerCase() + + // Check for Azure OpenAI hostname patterns + return host.includes('.openai.azure.com') || + host.endsWith('.azure.com') || + urlObj.pathname.includes('/openai/deployments/') + } catch (error) { + return false + } +} + +/** + * Removes the api-version query parameter from a URL + * @param url The URL to clean + * @returns The URL without the api-version parameter + */ +export function removeApiVersionFromUrl(url: string): string { + try { + const urlObj = new URL(url) + urlObj.searchParams.delete('api-version') + return urlObj.toString() + } catch (error) { + // Return original URL if parsing fails + return url + } +} \ No newline at end of file diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index 736b0253c4..bbf567183e 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -12,6 +12,8 @@ import { openAiModelInfoSaneDefaults, } from "@roo-code/types" +import { extractApiVersionFromUrl, isAzureOpenAiUrl } from "../../../../../src/utils/azure-url-parser" + import { ExtensionMessage } from "@roo/ExtensionMessage" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -41,6 +43,12 @@ export const OpenAICompatible = ({ const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion) const [openAiLegacyFormatSelected, setOpenAiLegacyFormatSelected] = useState(!!apiConfiguration?.openAiLegacyFormat) + // Check if API version can be extracted from the base URL + const baseUrl = apiConfiguration?.openAiBaseUrl || "" + const extractedApiVersion = extractApiVersionFromUrl(baseUrl) + const isAzureUrl = isAzureOpenAiUrl(baseUrl) + const showApiVersionExtraction = isAzureUrl && extractedApiVersion && !azureApiVersionSelected + const [openAiModels, setOpenAiModels] = useState | null>(null) const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {