diff --git a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts index 27e659c1af..9c930a33ec 100644 --- a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts @@ -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' } diff --git a/src/renderer/src/utils/__tests__/api.test.ts b/src/renderer/src/utils/__tests__/api.test.ts index e854445fc5..5b9d0f64f6 100644 --- a/src/renderer/src/utils/__tests__/api.test.ts +++ b/src/renderer/src/utils/__tests__/api.test.ts @@ -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('') + }) + }) }) diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 845187eb80..72d44c5c25 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -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` 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` 开头并可选跟随 `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(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(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, '') +}