mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
6cf376f832
commit
e8296474ae
4 changed files with 204 additions and 5 deletions
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
124
src/utils/__tests__/azure-url-parser.test.ts
Normal file
124
src/utils/__tests__/azure-url-parser.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
53
src/utils/azure-url-parser.ts
Normal file
53
src/utils/azure-url-parser.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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][]>(() => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue