Skip to content

Commit e829647

Browse files
committed
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
1 parent 6cf376f commit e829647

File tree

4 files changed

+204
-5
lines changed

4 files changed

+204
-5
lines changed

src/api/providers/openai.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import type { ApiHandlerOptions } from "../../shared/api"
1414

1515
import { XmlMatcher } from "../../utils/xml-matcher"
16+
import { extractApiVersionFromUrl, isAzureOpenAiUrl, removeApiVersionFromUrl } from "../../utils/azure-url-parser"
1617

1718
import { convertToOpenAiMessages } from "../transform/openai-format"
1819
import { convertToR1Format } from "../transform/r1-format"
@@ -35,12 +36,25 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
3536
super()
3637
this.options = options
3738

38-
const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1"
39+
const originalBaseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1"
3940
const apiKey = this.options.openAiApiKey ?? "not-provided"
4041
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
4142
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
4243
const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure
4344

45+
// Extract API version from URL if present and no explicit azureApiVersion is set
46+
let effectiveApiVersion = this.options.azureApiVersion
47+
let baseURL = originalBaseURL
48+
49+
if (isAzureOpenAi && !effectiveApiVersion) {
50+
const extractedVersion = extractApiVersionFromUrl(originalBaseURL)
51+
if (extractedVersion) {
52+
effectiveApiVersion = extractedVersion
53+
// For AzureOpenAI client, remove api-version from baseURL since it's passed separately
54+
baseURL = removeApiVersionFromUrl(originalBaseURL)
55+
}
56+
}
57+
4458
const headers = {
4559
...DEFAULT_HEADERS,
4660
...(this.options.openAiHeaders || {}),
@@ -49,23 +63,23 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
4963
if (isAzureAiInference) {
5064
// Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure
5165
this.client = new OpenAI({
52-
baseURL,
66+
baseURL: originalBaseURL, // Keep original URL for AI Inference
5367
apiKey,
5468
defaultHeaders: headers,
55-
defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" },
69+
defaultQuery: { "api-version": effectiveApiVersion || "2024-05-01-preview" },
5670
})
5771
} else if (isAzureOpenAi) {
5872
// Azure API shape slightly differs from the core API shape:
5973
// https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
6074
this.client = new AzureOpenAI({
6175
baseURL,
6276
apiKey,
63-
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
77+
apiVersion: effectiveApiVersion || azureOpenAiDefaultApiVersion,
6478
defaultHeaders: headers,
6579
})
6680
} else {
6781
this.client = new OpenAI({
68-
baseURL,
82+
baseURL: originalBaseURL,
6983
apiKey,
7084
defaultHeaders: headers,
7185
})
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { extractApiVersionFromUrl, isAzureOpenAiUrl, removeApiVersionFromUrl } from '../azure-url-parser'
3+
4+
describe('azure-url-parser', () => {
5+
describe('extractApiVersionFromUrl', () => {
6+
it('should extract API version from Azure OpenAI URL', () => {
7+
const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?api-version=2024-05-01-preview'
8+
const result = extractApiVersionFromUrl(url)
9+
expect(result).toBe('2024-05-01-preview')
10+
})
11+
12+
it('should extract API version from URL with multiple query parameters', () => {
13+
const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?foo=bar&api-version=2024-12-01-preview&baz=qux'
14+
const result = extractApiVersionFromUrl(url)
15+
expect(result).toBe('2024-12-01-preview')
16+
})
17+
18+
it('should return null when no api-version parameter exists', () => {
19+
const url = 'https://api.openai.com/v1/chat/completions'
20+
const result = extractApiVersionFromUrl(url)
21+
expect(result).toBeNull()
22+
})
23+
24+
it('should return null for invalid URLs', () => {
25+
const invalidUrl = 'not-a-valid-url'
26+
const result = extractApiVersionFromUrl(invalidUrl)
27+
expect(result).toBeNull()
28+
})
29+
30+
it('should handle empty api-version parameter', () => {
31+
const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?api-version='
32+
const result = extractApiVersionFromUrl(url)
33+
expect(result).toBe('')
34+
})
35+
36+
it('should handle URL without query parameters', () => {
37+
const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions'
38+
const result = extractApiVersionFromUrl(url)
39+
expect(result).toBeNull()
40+
})
41+
})
42+
43+
describe('isAzureOpenAiUrl', () => {
44+
it('should return true for Azure OpenAI URLs with .openai.azure.com', () => {
45+
const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions'
46+
const result = isAzureOpenAiUrl(url)
47+
expect(result).toBe(true)
48+
})
49+
50+
it('should return true for Azure URLs ending with .azure.com', () => {
51+
const url = 'https://myservice.azure.com/api/v1'
52+
const result = isAzureOpenAiUrl(url)
53+
expect(result).toBe(true)
54+
})
55+
56+
it('should return true for URLs with /openai/deployments/ path', () => {
57+
const url = 'https://custom-domain.com/openai/deployments/mymodel/chat/completions'
58+
const result = isAzureOpenAiUrl(url)
59+
expect(result).toBe(true)
60+
})
61+
62+
it('should return false for regular OpenAI URLs', () => {
63+
const url = 'https://api.openai.com/v1/chat/completions'
64+
const result = isAzureOpenAiUrl(url)
65+
expect(result).toBe(false)
66+
})
67+
68+
it('should return false for other API URLs', () => {
69+
const url = 'https://api.anthropic.com/v1/messages'
70+
const result = isAzureOpenAiUrl(url)
71+
expect(result).toBe(false)
72+
})
73+
74+
it('should return false for invalid URLs', () => {
75+
const invalidUrl = 'not-a-valid-url'
76+
const result = isAzureOpenAiUrl(invalidUrl)
77+
expect(result).toBe(false)
78+
})
79+
80+
it('should handle case insensitive hostname matching', () => {
81+
const url = 'https://MYRESOURCE.OPENAI.AZURE.COM/openai/deployments/mymodel'
82+
const result = isAzureOpenAiUrl(url)
83+
expect(result).toBe(true)
84+
})
85+
})
86+
87+
describe('removeApiVersionFromUrl', () => {
88+
it('should remove api-version parameter from URL', () => {
89+
const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?api-version=2024-05-01-preview'
90+
const result = removeApiVersionFromUrl(url)
91+
expect(result).toBe('https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions')
92+
})
93+
94+
it('should remove api-version parameter while preserving other parameters', () => {
95+
const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?foo=bar&api-version=2024-05-01-preview&baz=qux'
96+
const result = removeApiVersionFromUrl(url)
97+
expect(result).toBe('https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?foo=bar&baz=qux')
98+
})
99+
100+
it('should return original URL when no api-version parameter exists', () => {
101+
const url = 'https://api.openai.com/v1/chat/completions?foo=bar'
102+
const result = removeApiVersionFromUrl(url)
103+
expect(result).toBe(url)
104+
})
105+
106+
it('should return original URL for invalid URLs', () => {
107+
const invalidUrl = 'not-a-valid-url'
108+
const result = removeApiVersionFromUrl(invalidUrl)
109+
expect(result).toBe(invalidUrl)
110+
})
111+
112+
it('should handle URL with only api-version parameter', () => {
113+
const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions?api-version=2024-05-01-preview'
114+
const result = removeApiVersionFromUrl(url)
115+
expect(result).toBe('https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions')
116+
})
117+
118+
it('should handle URL without query parameters', () => {
119+
const url = 'https://myresource.openai.azure.com/openai/deployments/mymodel/chat/completions'
120+
const result = removeApiVersionFromUrl(url)
121+
expect(result).toBe(url)
122+
})
123+
})
124+
})

src/utils/azure-url-parser.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Utility functions for parsing Azure OpenAI URLs and extracting API versions
3+
*/
4+
5+
/**
6+
* Extracts the API version from an Azure OpenAI URL query parameter
7+
* @param url The Azure OpenAI URL that may contain an api-version query parameter
8+
* @returns The extracted API version string, or null if not found
9+
*/
10+
export function extractApiVersionFromUrl(url: string): string | null {
11+
try {
12+
const urlObj = new URL(url)
13+
return urlObj.searchParams.get('api-version')
14+
} catch (error) {
15+
// Invalid URL format
16+
return null
17+
}
18+
}
19+
20+
/**
21+
* Checks if a URL appears to be an Azure OpenAI URL
22+
* @param url The URL to check
23+
* @returns True if the URL appears to be an Azure OpenAI URL
24+
*/
25+
export function isAzureOpenAiUrl(url: string): boolean {
26+
try {
27+
const urlObj = new URL(url)
28+
const host = urlObj.host.toLowerCase()
29+
30+
// Check for Azure OpenAI hostname patterns
31+
return host.includes('.openai.azure.com') ||
32+
host.endsWith('.azure.com') ||
33+
urlObj.pathname.includes('/openai/deployments/')
34+
} catch (error) {
35+
return false
36+
}
37+
}
38+
39+
/**
40+
* Removes the api-version query parameter from a URL
41+
* @param url The URL to clean
42+
* @returns The URL without the api-version parameter
43+
*/
44+
export function removeApiVersionFromUrl(url: string): string {
45+
try {
46+
const urlObj = new URL(url)
47+
urlObj.searchParams.delete('api-version')
48+
return urlObj.toString()
49+
} catch (error) {
50+
// Return original URL if parsing fails
51+
return url
52+
}
53+
}

webview-ui/src/components/settings/providers/OpenAICompatible.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
openAiModelInfoSaneDefaults,
1313
} from "@roo-code/types"
1414

15+
import { extractApiVersionFromUrl, isAzureOpenAiUrl } from "../../../../../src/utils/azure-url-parser"
16+
1517
import { ExtensionMessage } from "@roo/ExtensionMessage"
1618

1719
import { useAppTranslation } from "@src/i18n/TranslationContext"
@@ -41,6 +43,12 @@ export const OpenAICompatible = ({
4143
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
4244
const [openAiLegacyFormatSelected, setOpenAiLegacyFormatSelected] = useState(!!apiConfiguration?.openAiLegacyFormat)
4345

46+
// Check if API version can be extracted from the base URL
47+
const baseUrl = apiConfiguration?.openAiBaseUrl || ""
48+
const extractedApiVersion = extractApiVersionFromUrl(baseUrl)
49+
const isAzureUrl = isAzureOpenAiUrl(baseUrl)
50+
const showApiVersionExtraction = isAzureUrl && extractedApiVersion && !azureApiVersionSelected
51+
4452
const [openAiModels, setOpenAiModels] = useState<Record<string, ModelInfo> | null>(null)
4553

4654
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {

0 commit comments

Comments
 (0)