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
This commit is contained in:
Roo Code 2025-07-17 09:08:29 +00:00
parent 6cf376f832
commit e8296474ae
4 changed files with 204 additions and 5 deletions

View file

@ -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,
})

View file

@ -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)
})
})
})

View file

@ -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
}
}

View file

@ -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<Record<string, ModelInfo> | null>(null)
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {