fix: handle Gemini API version correctly for Cloudflare Gateway URLs (#11543)
* refactor(api): extract version regex into constant for reuse Move the version matching regex pattern into a module-level constant to improve code reuse and maintainability. The functionality remains unchanged. * refactor(api): rename regex constant and use dynamic regex construction Use a string pattern for version regex to allow dynamic construction and improve maintainability. Rename constant to better reflect its purpose. * feat(api): add getLastApiVersion utility function Implement a utility function to extract the last API version segment from URLs. This is useful for handling cases where multiple version segments exist in the path and we need to determine the most specific version being used. Add comprehensive test cases covering various URL patterns and edge cases. * feat(api): add utility to remove trailing API version from URLs Add withoutTrailingApiVersion function to clean up URLs by removing version segments at the end of paths. This helps standardize API endpoint URLs when version is not needed. * refactor(api): rename isSupportedAPIVerion to supportApiVersion for clarity * fix(gemini): handle api version dynamically for non-vertex providers Use getLastApiVersion utility to determine the latest API version for non-vertex providers instead of hardcoding to v1beta * feat(api): add function to extract trailing API version from URL Add getTrailingApiVersion utility function to specifically extract API version segments that appear at the end of URLs. This complements existing version-related utilities and helps handle cases where we only care about the final version in the path. * refactor(gemini): use getTrailingApiVersion instead of getLastApiVersion The function name was changed to better reflect its purpose of extracting the trailing API version from the URL. The logic was also simplified and made more explicit. * refactor(api): remove unused getLastApiVersion function The function was removed as it was no longer needed, simplifying the API version handling to only use trailing version detection. The trailing version regex was extracted to a constant for reuse.
This commit is contained in:
@@ -46,6 +46,7 @@ import type {
|
||||
GeminiSdkRawOutput,
|
||||
GeminiSdkToolCall
|
||||
} from '@renderer/types/sdk'
|
||||
import { getTrailingApiVersion, withoutTrailingApiVersion } from '@renderer/utils'
|
||||
import { isToolUseModeFunction } from '@renderer/utils/assistant'
|
||||
import {
|
||||
geminiFunctionCallToMcpTool,
|
||||
@@ -163,6 +164,10 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
return models
|
||||
}
|
||||
|
||||
override getBaseURL(): string {
|
||||
return withoutTrailingApiVersion(super.getBaseURL())
|
||||
}
|
||||
|
||||
override async getSdkInstance() {
|
||||
if (this.sdkInstance) {
|
||||
return this.sdkInstance
|
||||
@@ -188,6 +193,13 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
if (this.provider.isVertex) {
|
||||
return 'v1'
|
||||
}
|
||||
|
||||
// Extract trailing API version from the URL
|
||||
const trailingVersion = getTrailingApiVersion(this.provider.apiHost || '')
|
||||
if (trailingVersion) {
|
||||
return trailingVersion
|
||||
}
|
||||
|
||||
return 'v1beta'
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
formatApiKeys,
|
||||
formatAzureOpenAIApiHost,
|
||||
formatVertexApiHost,
|
||||
getTrailingApiVersion,
|
||||
hasAPIVersion,
|
||||
maskApiKey,
|
||||
routeToEndpoint,
|
||||
splitApiKeyString,
|
||||
validateApiHost
|
||||
validateApiHost,
|
||||
withoutTrailingApiVersion
|
||||
} from '../api'
|
||||
|
||||
vi.mock('@renderer/store', () => {
|
||||
@@ -316,4 +318,90 @@ describe('api', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTrailingApiVersion', () => {
|
||||
it('extracts trailing API version from URL', () => {
|
||||
expect(getTrailingApiVersion('https://api.example.com/v1')).toBe('v1')
|
||||
expect(getTrailingApiVersion('https://api.example.com/v2')).toBe('v2')
|
||||
})
|
||||
|
||||
it('extracts trailing API version with alpha/beta suffix', () => {
|
||||
expect(getTrailingApiVersion('https://api.example.com/v2alpha')).toBe('v2alpha')
|
||||
expect(getTrailingApiVersion('https://api.example.com/v3beta')).toBe('v3beta')
|
||||
})
|
||||
|
||||
it('extracts trailing API version with trailing slash', () => {
|
||||
expect(getTrailingApiVersion('https://api.example.com/v1/')).toBe('v1')
|
||||
expect(getTrailingApiVersion('https://api.example.com/v2beta/')).toBe('v2beta')
|
||||
})
|
||||
|
||||
it('returns undefined when API version is in the middle of path', () => {
|
||||
expect(getTrailingApiVersion('https://api.example.com/v1/chat')).toBeUndefined()
|
||||
expect(getTrailingApiVersion('https://api.example.com/v1/completions')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when no trailing version exists', () => {
|
||||
expect(getTrailingApiVersion('https://api.example.com')).toBeUndefined()
|
||||
expect(getTrailingApiVersion('https://api.example.com/api')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('extracts trailing version from complex URLs', () => {
|
||||
expect(getTrailingApiVersion('https://api.example.com/service/v1')).toBe('v1')
|
||||
expect(getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/google-ai-studio/v1beta')).toBe('v1beta')
|
||||
})
|
||||
|
||||
it('only extracts the trailing version when multiple versions exist', () => {
|
||||
expect(getTrailingApiVersion('https://api.example.com/v1/service/v2')).toBe('v2')
|
||||
expect(
|
||||
getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxxxxx/google-ai-studio/google-ai-studio/v1beta')
|
||||
).toBe('v1beta')
|
||||
})
|
||||
|
||||
it('returns undefined for empty string', () => {
|
||||
expect(getTrailingApiVersion('')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('withoutTrailingApiVersion', () => {
|
||||
it('removes trailing API version from URL', () => {
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/v1')).toBe('https://api.example.com')
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/v2')).toBe('https://api.example.com')
|
||||
})
|
||||
|
||||
it('removes trailing API version with alpha/beta suffix', () => {
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/v2alpha')).toBe('https://api.example.com')
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/v3beta')).toBe('https://api.example.com')
|
||||
})
|
||||
|
||||
it('removes trailing API version with trailing slash', () => {
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/v1/')).toBe('https://api.example.com')
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/v2beta/')).toBe('https://api.example.com')
|
||||
})
|
||||
|
||||
it('does not remove API version in the middle of path', () => {
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/v1/chat')).toBe('https://api.example.com/v1/chat')
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/v1/completions')).toBe(
|
||||
'https://api.example.com/v1/completions'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns URL unchanged when no trailing version exists', () => {
|
||||
expect(withoutTrailingApiVersion('https://api.example.com')).toBe('https://api.example.com')
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/api')).toBe('https://api.example.com/api')
|
||||
})
|
||||
|
||||
it('handles complex URLs with version at the end', () => {
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/service/v1')).toBe('https://api.example.com/service')
|
||||
})
|
||||
|
||||
it('handles URLs with multiple versions but only removes the trailing one', () => {
|
||||
expect(withoutTrailingApiVersion('https://api.example.com/v1/service/v2')).toBe(
|
||||
'https://api.example.com/v1/service'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(withoutTrailingApiVersion('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,19 @@ export function formatApiKeys(value: string): string {
|
||||
return value.replaceAll(',', ',').replaceAll('\n', ',')
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a version segment in a path that starts with `/v<number>` and optionally
|
||||
* continues with `alpha` or `beta`. The segment may be followed by `/` or the end
|
||||
* of the string (useful for cases like `/v3alpha/resources`).
|
||||
*/
|
||||
const VERSION_REGEX_PATTERN = '\\/v\\d+(?:alpha|beta)?(?=\\/|$)'
|
||||
|
||||
/**
|
||||
* Matches an API version at the end of a URL (with optional trailing slash).
|
||||
* Used to detect and extract versions only from the trailing position.
|
||||
*/
|
||||
const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i
|
||||
|
||||
/**
|
||||
* 判断 host 的 path 中是否包含形如版本的字符串(例如 /v1、/v2beta 等),
|
||||
*
|
||||
@@ -21,16 +34,14 @@ export function formatApiKeys(value: string): string {
|
||||
export function hasAPIVersion(host?: string): boolean {
|
||||
if (!host) return false
|
||||
|
||||
// 匹配路径中以 `/v<number>` 开头并可选跟随 `alpha` 或 `beta` 的版本段,
|
||||
// 该段后面可以跟 `/` 或字符串结束(用于匹配诸如 `/v3alpha/resources` 的情况)。
|
||||
const versionRegex = /\/v\d+(?:alpha|beta)?(?=\/|$)/i
|
||||
const regex = new RegExp(VERSION_REGEX_PATTERN, 'i')
|
||||
|
||||
try {
|
||||
const url = new URL(host)
|
||||
return versionRegex.test(url.pathname)
|
||||
return regex.test(url.pathname)
|
||||
} catch {
|
||||
// 若无法作为完整 URL 解析,则当作路径直接检测
|
||||
return versionRegex.test(host)
|
||||
return regex.test(host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +66,7 @@ export function withoutTrailingSlash<T extends string>(url: T): T {
|
||||
* Formats an API host URL by normalizing it and optionally appending an API version.
|
||||
*
|
||||
* @param host - The API host URL to format. Leading/trailing whitespace will be trimmed and trailing slashes removed.
|
||||
* @param isSupportedAPIVerion - Whether the API version is supported. Defaults to `true`.
|
||||
* @param supportApiVersion - Whether the API version is supported. Defaults to `true`.
|
||||
* @param apiVersion - The API version to append if needed. Defaults to `'v1'`.
|
||||
*
|
||||
* @returns The formatted API host URL. If the host is empty after normalization, returns an empty string.
|
||||
@@ -67,13 +78,13 @@ export function withoutTrailingSlash<T extends string>(url: T): T {
|
||||
* formatApiHost('https://api.example.com#') // Returns 'https://api.example.com#'
|
||||
* formatApiHost('https://api.example.com/v2', true, 'v1') // Returns 'https://api.example.com/v2'
|
||||
*/
|
||||
export function formatApiHost(host?: string, isSupportedAPIVerion: boolean = true, apiVersion: string = 'v1'): string {
|
||||
export function formatApiHost(host?: string, supportApiVersion: boolean = true, apiVersion: string = 'v1'): string {
|
||||
const normalizedHost = withoutTrailingSlash(trim(host))
|
||||
if (!normalizedHost) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (normalizedHost.endsWith('#') || !isSupportedAPIVerion || hasAPIVersion(normalizedHost)) {
|
||||
if (normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost)) {
|
||||
return normalizedHost
|
||||
}
|
||||
return `${normalizedHost}/${apiVersion}`
|
||||
@@ -213,3 +224,50 @@ export function splitApiKeyString(keyStr: string): string[] {
|
||||
.map((k) => k.replace(/\\,/g, ','))
|
||||
.filter((k) => k)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the trailing API version segment from a URL path.
|
||||
*
|
||||
* This function extracts API version patterns (e.g., `v1`, `v2beta`) from the end of a URL.
|
||||
* Only versions at the end of the path are extracted, not versions in the middle.
|
||||
* The returned version string does not include leading or trailing slashes.
|
||||
*
|
||||
* @param {string} url - The URL string to parse.
|
||||
* @returns {string | undefined} The trailing API version found (e.g., 'v1', 'v2beta'), or undefined if none found.
|
||||
*
|
||||
* @example
|
||||
* getTrailingApiVersion('https://api.example.com/v1') // 'v1'
|
||||
* getTrailingApiVersion('https://api.example.com/v2beta/') // 'v2beta'
|
||||
* getTrailingApiVersion('https://api.example.com/v1/chat') // undefined (version not at end)
|
||||
* getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/v1beta') // 'v1beta'
|
||||
* getTrailingApiVersion('https://api.example.com') // undefined
|
||||
*/
|
||||
export function getTrailingApiVersion(url: string): string | undefined {
|
||||
const match = url.match(TRAILING_VERSION_REGEX)
|
||||
|
||||
if (match) {
|
||||
// Extract version without leading slash and trailing slash
|
||||
return match[0].replace(/^\//, '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the trailing API version segment from a URL path.
|
||||
*
|
||||
* This function removes API version patterns (e.g., `/v1`, `/v2beta`) from the end of a URL.
|
||||
* Only versions at the end of the path are removed, not versions in the middle.
|
||||
*
|
||||
* @param {string} url - The URL string to process.
|
||||
* @returns {string} The URL with the trailing API version removed, or the original URL if no trailing version found.
|
||||
*
|
||||
* @example
|
||||
* withoutTrailingApiVersion('https://api.example.com/v1') // 'https://api.example.com'
|
||||
* withoutTrailingApiVersion('https://api.example.com/v2beta/') // 'https://api.example.com'
|
||||
* withoutTrailingApiVersion('https://api.example.com/v1/chat') // 'https://api.example.com/v1/chat' (no change)
|
||||
* withoutTrailingApiVersion('https://api.example.com') // 'https://api.example.com'
|
||||
*/
|
||||
export function withoutTrailingApiVersion(url: string): string {
|
||||
return url.replace(TRAILING_VERSION_REGEX, '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user