fix: Format provider API hosts in API server & refactor shared utilities (#13198)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: SuYao <sy20010504@gmail.com>
This commit is contained in:
Phantom
2026-03-19 00:41:20 +08:00
committed by GitHub
parent e4112ba172
commit d081b05c87
20 changed files with 805 additions and 689 deletions

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import { formatAzureOpenAIApiHost, formatOllamaApiHost } from '../api'
describe('provider api utils', () => {
describe('formatAzureOpenAIApiHost', () => {
it('normalizes trailing segments and disables auto version append', () => {
expect(formatAzureOpenAIApiHost('https://example.openai.azure.com/')).toBe(
'https://example.openai.azure.com/openai'
)
expect(formatAzureOpenAIApiHost('https://example.openai.azure.com/openai/')).toBe(
'https://example.openai.azure.com/openai'
)
})
})
describe('formatOllamaApiHost', () => {
it('removes trailing slash and appends /api for basic hosts', () => {
expect(formatOllamaApiHost('https://api.ollama.com/')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('http://localhost:11434/')).toBe('http://localhost:11434/api')
})
it('appends /api when no suffix is present', () => {
expect(formatOllamaApiHost('https://api.ollama.com')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('http://localhost:11434')).toBe('http://localhost:11434/api')
})
it('removes /v1 suffix and appends /api', () => {
expect(formatOllamaApiHost('https://api.ollama.com/v1')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('http://localhost:11434/v1/')).toBe('http://localhost:11434/api')
})
it('removes /api suffix and keeps /api', () => {
expect(formatOllamaApiHost('https://api.ollama.com/api')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('http://localhost:11434/api/')).toBe('http://localhost:11434/api')
})
it('removes /chat suffix and appends /api', () => {
expect(formatOllamaApiHost('https://api.ollama.com/chat')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('http://localhost:11434/chat/')).toBe('http://localhost:11434/api')
})
it('handles multiple suffix combinations correctly', () => {
expect(formatOllamaApiHost('https://api.ollama.com/v1/chat')).toBe('https://api.ollama.com/v1/api')
expect(formatOllamaApiHost('https://api.ollama.com/chat/v1')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('https://api.ollama.com/api/chat')).toBe('https://api.ollama.com/api/api')
})
it('preserves complex paths while handling suffixes', () => {
expect(formatOllamaApiHost('https://api.ollama.com/custom/path')).toBe('https://api.ollama.com/custom/path/api')
expect(formatOllamaApiHost('https://api.ollama.com/custom/path/')).toBe('https://api.ollama.com/custom/path/api')
expect(formatOllamaApiHost('https://api.ollama.com/custom/path/v1')).toBe(
'https://api.ollama.com/custom/path/api'
)
})
it('handles edge cases with multiple slashes', () => {
expect(formatOllamaApiHost('https://api.ollama.com//')).toBe('https://api.ollama.com//api')
expect(formatOllamaApiHost('https://api.ollama.com///v1///')).toBe('https://api.ollama.com///v1///api')
})
it('handles localhost with different ports', () => {
expect(formatOllamaApiHost('http://localhost:3000')).toBe('http://localhost:3000/api')
expect(formatOllamaApiHost('http://127.0.0.1:11434/')).toBe('http://127.0.0.1:11434/api')
expect(formatOllamaApiHost('https://localhost:8080/v1')).toBe('https://localhost:8080/api')
})
it('handles IP addresses', () => {
expect(formatOllamaApiHost('http://192.168.1.100:11434')).toBe('http://192.168.1.100:11434/api')
expect(formatOllamaApiHost('https://10.0.0.1:8080/v1/')).toBe('https://10.0.0.1:8080/api')
})
it('handles empty strings and edge cases', () => {
expect(formatOllamaApiHost('')).toBe('/api')
expect(formatOllamaApiHost('/')).toBe('/api')
})
it('preserves protocol and handles mixed case', () => {
expect(formatOllamaApiHost('HTTPS://API.OLLAMA.COM')).toBe('HTTPS://API.OLLAMA.COM/api')
expect(formatOllamaApiHost('HTTP://localhost:11434/V1/')).toBe('HTTP://localhost:11434/V1/api')
})
})
})

View File

@@ -0,0 +1,23 @@
import { formatApiHost, withoutTrailingSlash } from '@shared/utils'
/**
* 格式化 Ollama 的 API 主机地址。
*/
export function formatOllamaApiHost(host: string): string {
const normalizedHost = withoutTrailingSlash(host)
?.replace(/\/v1$/, '')
?.replace(/\/api$/, '')
?.replace(/\/chat$/, '')
return formatApiHost(normalizedHost + '/api', false)
}
/**
* Format Azure OpenAI API host address.
*/
export function formatAzureOpenAIApiHost(host: string): string {
const normalizedHost = withoutTrailingSlash(host)
?.replace(/\/v1$/, '')
.replace(/\/openai$/, '')
// NOTE: AISDK会添加上`v1`
return formatApiHost(normalizedHost + '/openai', false)
}

View File

@@ -0,0 +1,2 @@
export * from './api'
export * from './types'

View File

@@ -0,0 +1,30 @@
import type { AzureOpenAIProvider, Provider, VertexProvider } from '@types'
export function isAnthropicProvider(provider: Provider): boolean {
return provider.type === 'anthropic'
}
export function isOllamaProvider(provider: Provider): boolean {
return provider.type === 'ollama'
}
export function isGeminiProvider(provider: Provider): boolean {
return provider.type === 'gemini'
}
export function isAzureOpenAIProvider(provider: Provider): provider is AzureOpenAIProvider {
return provider.type === 'azure-openai'
}
// FIXME: #13194
export function isVertexProvider(provider: Provider): provider is VertexProvider {
return provider.type === 'vertexai'
}
export function isPerplexityProvider(provider: Provider): boolean {
return provider.id === 'perplexity'
}
export function isCherryAIProvider(provider: Provider): boolean {
return provider.id === 'cherryai'
}

View File

@@ -5,6 +5,8 @@
* Used by both the Code Tools page and the Anthropic SDK client.
*/
import { SystemProviderIds } from '@types'
/**
* Silicon provider models that support Anthropic API endpoint.
* These models can be used with Claude Code via the Anthropic-compatible API.
@@ -46,3 +48,25 @@ export function isSiliconAnthropicCompatibleModel(modelId: string): boolean {
* Silicon provider's Anthropic API host URL.
*/
export const SILICON_ANTHROPIC_API_HOST = 'https://api.siliconflow.cn'
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = [
'deepseek',
'moonshot',
'zhipu',
'dashscope',
'modelscope',
'minimax',
'longcat',
SystemProviderIds.qiniu,
SystemProviderIds.silicon,
SystemProviderIds.mimo,
SystemProviderIds.openrouter
]
export const CLAUDE_SUPPORTED_PROVIDERS = [
'aihubmix',
'dmxapi',
'new-api',
'cherryin',
'302ai',
...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS
]

View File

@@ -0,0 +1,329 @@
import { describe, expect, it } from 'vitest'
import {
formatApiHost,
formatApiKeys,
getTrailingApiVersion,
hasAPIVersion,
isWithTrailingSharp,
withoutTrailingApiVersion,
withoutTrailingSharp
} from '..'
describe('api', () => {
describe('formatApiHost', () => {
it('returns empty string for falsy host', () => {
expect(formatApiHost('')).toBe('')
expect(formatApiHost(undefined)).toBe('')
})
it('appends api version when missing', () => {
expect(formatApiHost('https://api.example.com')).toBe('https://api.example.com/v1')
expect(formatApiHost('http://localhost:5173/')).toBe('http://localhost:5173/v1')
expect(formatApiHost(' https://api.openai.com ')).toBe('https://api.openai.com/v1')
})
it('keeps original host when api version already present', () => {
expect(formatApiHost('https://api.volces.com/api/v3')).toBe('https://api.volces.com/api/v3')
expect(formatApiHost('http://localhost:5173/v2beta')).toBe('http://localhost:5173/v2beta')
})
it('supports custom api version parameter', () => {
expect(formatApiHost('https://api.example.com', true, 'v2')).toBe('https://api.example.com/v2')
})
it('keeps host untouched when api version unsupported', () => {
expect(formatApiHost('https://api.example.com', false)).toBe('https://api.example.com')
})
it('removes trailing # and does not append api version when host ends with #', () => {
expect(formatApiHost('https://api.example.com#')).toBe('https://api.example.com')
expect(formatApiHost('http://localhost:5173/#')).toBe('http://localhost:5173/')
expect(formatApiHost(' https://api.openai.com/# ')).toBe('https://api.openai.com/')
})
it('handles trailing # with custom api version settings', () => {
expect(formatApiHost('https://api.example.com#', true, 'v2')).toBe('https://api.example.com')
expect(formatApiHost('https://api.example.com#', false, 'v2')).toBe('https://api.example.com')
})
it('handles host with both trailing # and existing api version', () => {
expect(formatApiHost('https://api.example.com/v2#')).toBe('https://api.example.com/v2')
expect(formatApiHost('https://api.example.com/v3beta#')).toBe('https://api.example.com/v3beta')
})
it('trims whitespace before processing trailing #', () => {
expect(formatApiHost(' https://api.example.com# ')).toBe('https://api.example.com')
expect(formatApiHost('\thttps://api.example.com#\n')).toBe('https://api.example.com')
})
})
describe('hasAPIVersion', () => {
it('detects numeric version suffix', () => {
expect(hasAPIVersion('https://api.example.com/v1')).toBe(true)
expect(hasAPIVersion('http://localhost:3000/v2beta')).toBe(true)
expect(hasAPIVersion('/v3alpha/resources')).toBe(true)
})
it('returns false when no version found', () => {
expect(hasAPIVersion('https://api.example.com')).toBe(false)
expect(hasAPIVersion('')).toBe(false)
expect(hasAPIVersion(undefined)).toBe(false)
})
it('return false when starting without v character', () => {
expect(hasAPIVersion('https://api.example.com/a1v')).toBe(false)
expect(hasAPIVersion('/av1/users')).toBe(false)
})
it('return false when starting with v- word', () => {
expect(hasAPIVersion('https://api.example.com/vendor')).toBe(false)
})
})
describe('formatApiKeys', () => {
it('normalizes chinese commas and new lines', () => {
expect(formatApiKeys('key1key2\nkey3')).toBe('key1,key2,key3')
})
it('returns empty string unchanged', () => {
expect(formatApiKeys('')).toBe('')
})
})
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()
})
it('returns undefined when URL ends with # regardless of version', () => {
expect(getTrailingApiVersion('https://api.example.com/v1#')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/v2beta#')).toBeUndefined()
expect(getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1#')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/service/v1#')).toBeUndefined()
})
it('handles URLs with # and trailing slash correctly', () => {
expect(getTrailingApiVersion('https://api.example.com/v1/#')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/v2beta/#')).toBeUndefined()
})
it('handles URLs with version followed by # and additional path', () => {
expect(getTrailingApiVersion('https://api.example.com/v1#endpoint')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/v2beta#chat/completions')).toBeUndefined()
})
it('handles complex URLs with multiple # characters', () => {
expect(getTrailingApiVersion('https://api.example.com/v1#path#')).toBeUndefined()
expect(getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/v2beta#')).toBeUndefined()
})
it('handles URLs ending with # when version is not at the end', () => {
expect(getTrailingApiVersion('https://api.example.com/v1/service#')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/v1/api/chat#')).toBeUndefined()
})
it('distinguishes between URLs with and without trailing #', () => {
expect(getTrailingApiVersion('https://api.example.com/v1')).toBe('v1')
expect(getTrailingApiVersion('https://api.example.com/v2beta')).toBe('v2beta')
expect(getTrailingApiVersion('https://api.example.com/v1#')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/v2beta#')).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('')
})
})
describe('isWithTrailingSharp', () => {
it('returns true when URL ends with #', () => {
expect(isWithTrailingSharp('https://api.example.com#')).toBe(true)
expect(isWithTrailingSharp('http://localhost:3000#')).toBe(true)
expect(isWithTrailingSharp('#')).toBe(true)
})
it('returns false when URL does not end with #', () => {
expect(isWithTrailingSharp('https://api.example.com')).toBe(false)
expect(isWithTrailingSharp('http://localhost:3000')).toBe(false)
expect(isWithTrailingSharp('')).toBe(false)
})
it('returns false when URL has # in the middle but not at the end', () => {
expect(isWithTrailingSharp('https://api.example.com#path')).toBe(false)
expect(isWithTrailingSharp('https://api.example.com#section/path')).toBe(false)
expect(isWithTrailingSharp('https://api.example.com#path#other')).toBe(false)
})
it('handles URLs with multiple # characters', () => {
expect(isWithTrailingSharp('https://api.example.com##')).toBe(true)
expect(isWithTrailingSharp('https://api.example.com#path#')).toBe(true)
expect(isWithTrailingSharp('https://api.example.com###')).toBe(true)
})
it('handles URLs with trailing whitespace after #', () => {
expect(isWithTrailingSharp('https://api.example.com# ')).toBe(false)
expect(isWithTrailingSharp('https://api.example.com#\t')).toBe(false)
expect(isWithTrailingSharp('https://api.example.com#\n')).toBe(false)
})
it('handles URLs with whitespace before trailing #', () => {
expect(isWithTrailingSharp(' https://api.example.com#')).toBe(true)
expect(isWithTrailingSharp('\thttps://localhost:3000#')).toBe(true)
})
it('preserves type safety with generic parameter', () => {
const url1: string = 'https://api.example.com#'
const url2 = 'https://example.com' as const
expect(isWithTrailingSharp(url1)).toBe(true)
expect(isWithTrailingSharp(url2)).toBe(false)
})
it('handles complex real-world URLs', () => {
expect(isWithTrailingSharp('https://open.cherryin.net/v1/chat/completions#')).toBe(true)
expect(isWithTrailingSharp('https://api.openai.com/v1/engines/gpt-4#')).toBe(true)
expect(isWithTrailingSharp('https://gateway.ai.cloudflare.com/v1/xxx/v1beta#')).toBe(true)
expect(isWithTrailingSharp('https://open.cherryin.net/v1/chat/completions')).toBe(false)
expect(isWithTrailingSharp('https://api.openai.com/v1/engines/gpt-4')).toBe(false)
expect(isWithTrailingSharp('https://gateway.ai.cloudflare.com/v1/xxx/v1beta')).toBe(false)
})
it('handles edge cases', () => {
expect(isWithTrailingSharp('#')).toBe(true)
expect(isWithTrailingSharp(' #')).toBe(true)
expect(isWithTrailingSharp('# ')).toBe(false)
expect(isWithTrailingSharp('path#')).toBe(true)
expect(isWithTrailingSharp('/path/with/trailing/#')).toBe(true)
expect(isWithTrailingSharp('/path/without/trailing/')).toBe(false)
})
})
describe('withoutTrailingSharp', () => {
it('removes trailing # from URL', () => {
expect(withoutTrailingSharp('https://api.example.com#')).toBe('https://api.example.com')
expect(withoutTrailingSharp('http://localhost:3000#')).toBe('http://localhost:3000')
})
it('returns URL unchanged when no trailing #', () => {
expect(withoutTrailingSharp('https://api.example.com')).toBe('https://api.example.com')
expect(withoutTrailingSharp('http://localhost:3000')).toBe('http://localhost:3000')
})
it('handles URLs with multiple # characters but only removes trailing one', () => {
expect(withoutTrailingSharp('https://api.example.com#path#')).toBe('https://api.example.com#path')
})
it('handles URLs with # in the middle (not trailing)', () => {
expect(withoutTrailingSharp('https://api.example.com#section/path')).toBe('https://api.example.com#section/path')
expect(withoutTrailingSharp('https://api.example.com/v1/chat/completions#')).toBe(
'https://api.example.com/v1/chat/completions'
)
})
it('handles empty string', () => {
expect(withoutTrailingSharp('')).toBe('')
})
it('handles single character #', () => {
expect(withoutTrailingSharp('#')).toBe('')
})
it('preserves whitespace around the URL (pure function)', () => {
expect(withoutTrailingSharp(' https://api.example.com# ')).toBe(' https://api.example.com# ')
expect(withoutTrailingSharp('\thttps://api.example.com#\n')).toBe('\thttps://api.example.com#\n')
})
it('only removes exact trailing # character', () => {
expect(withoutTrailingSharp('https://api.example.com# ')).toBe('https://api.example.com# ')
expect(withoutTrailingSharp(' https://api.example.com#')).toBe(' https://api.example.com')
expect(withoutTrailingSharp('https://api.example.com#\t')).toBe('https://api.example.com#\t')
})
it('handles URLs ending with multiple # characters', () => {
expect(withoutTrailingSharp('https://api.example.com##')).toBe('https://api.example.com#')
expect(withoutTrailingSharp('https://api.example.com###')).toBe('https://api.example.com##')
})
it('preserves URL with trailing # and other content', () => {
expect(withoutTrailingSharp('https://api.example.com/v1#')).toBe('https://api.example.com/v1')
expect(withoutTrailingSharp('https://api.example.com/v2beta#')).toBe('https://api.example.com/v2beta')
})
})
})

View File

@@ -0,0 +1,36 @@
import { trim } from 'lodash'
import { hasAPIVersion, withoutTrailingSharp, withoutTrailingSlash } from './utils'
export * from './utils'
/**
* 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 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.
* If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host with trailing '#' removed.
* Otherwise, returns the host with the API version appended.
*
* @example
* formatApiHost('https://api.example.com/') // Returns 'https://api.example.com/v1'
* 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, supportApiVersion: boolean = true, apiVersion: string = 'v1'): string {
const normalizedHost = withoutTrailingSlash(trim(host))
if (!normalizedHost) {
return ''
}
const shouldAppendApiVersion = !(normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost))
if (shouldAppendApiVersion) {
return `${normalizedHost}/${apiVersion}`
} else {
return withoutTrailingSharp(normalizedHost)
}
}

View File

@@ -0,0 +1,137 @@
/**
* 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
/**
* 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 = /\/v\d+(?:alpha|beta)?(?:\/|$)/i
/**
* Formats an API key string.
*
* @param {string} value - The API key string to format.
* @returns {string} The formatted API key string.
*/
export function formatApiKeys(value: string): string {
return value.replaceAll('', ',').replaceAll('\n', ',')
}
/**
* Determines whether a host or path string contains a version-like segment (e.g., /v1, /v2beta).
*
* @param host - The host or path string to check.
* @returns True if the path contains a version string, false otherwise.
*/
export function hasAPIVersion(host?: string): boolean {
if (!host) return false
try {
const url = new URL(host)
return VERSION_REGEX.test(url.pathname)
} catch {
// If the input cannot be parsed as a full URL, treat it as a path and test directly.
return VERSION_REGEX.test(host)
}
}
/**
* 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, '')
}
/**
* Removes the trailing slash from a URL string if it exists.
*
* @param url - The URL string to process
* @returns The URL string without a trailing slash
*
* @example
* ```ts
* withoutTrailingSlash('https://example.com/') // 'https://example.com'
* withoutTrailingSlash('https://example.com') // 'https://example.com'
* ```
*/
export function withoutTrailingSlash(url: string): string {
return url.replace(/\/$/, '')
}
/**
* Checks if a URL string ends with a trailing '#' character.
*
* @template T - The string type to preserve type safety
* @param {T} url - The URL string to check
* @returns {boolean} True if the URL ends with '#', false otherwise
*
* @example
* ```ts
* isWithTrailingSharp('https://example.com#') // true
* isWithTrailingSharp('https://example.com') // false
* ```
*/
export function isWithTrailingSharp<T extends string>(url: T): boolean {
return url.endsWith('#')
}
/**
* Removes the trailing '#' from a URL string if it exists.
*
* @template T - The string type to preserve type safety
* @param {T} url - The URL string to process
* @returns {T} The URL string without a trailing '#'
*
* @example
* ```ts
* withoutTrailingSharp('https://example.com#') // 'https://example.com'
* withoutTrailingSharp('https://example.com') // 'https://example.com'
* ```
*/
export function withoutTrailingSharp<T extends string>(url: T): T {
return url.replace(/#$/, '') as T
}

View File

@@ -1,5 +1,7 @@
import { parse as jsoncParse } from 'jsonc-parser'
export * from './api'
export const defaultAppHeaders = () => {
return {
'HTTP-Referer': 'https://cherry-ai.com',
@@ -7,31 +9,6 @@ export const defaultAppHeaders = () => {
}
}
/**
* Removes the trailing slash from a URL string if it exists.
*/
export function withoutTrailingSlash(url: string): string {
return url.replace(/\/$/, '')
}
/**
* Matches a version segment anywhere in a URL path (e.g., /v1, /v2beta, /v3alpha).
*/
const VERSION_REGEX = /\/v\d+(?:alpha|beta)?(?:\/|$)/i
/**
* Checks if a URL's path contains a version segment (e.g., /v1, /v2beta, /v3alpha).
* Unlike getTrailingApiVersion, this checks for versions anywhere in the path.
*/
export function hasAPIVersion(host: string): boolean {
try {
const url = new URL(host)
return VERSION_REGEX.test(url.pathname)
} catch {
return VERSION_REGEX.test(host)
}
}
// Following two function are not being used for now.
// I may use them in the future, so just keep them commented. - by eurfelux
@@ -63,59 +40,6 @@ export function hasAPIVersion(host: string): boolean {
// }
// }
/**
* 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
}
/**
* 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
/**
* 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, '')
}
export interface DataUrlParts {
/** The media type (e.g., 'image/png', 'text/plain') */
mediaType?: string

View File

@@ -0,0 +1,59 @@
import {
formatAzureOpenAIApiHost,
formatOllamaApiHost,
isAnthropicProvider,
isAzureOpenAIProvider,
isCherryAIProvider,
isGeminiProvider,
isOllamaProvider,
isPerplexityProvider,
isVertexProvider
} from '@shared/aiCore/provider/utils'
import { formatApiHost, isWithTrailingSharp } from '@shared/utils'
import type { Provider } from '@types'
import { SystemProviderIds } from '@types'
import { formatVertexApiHost } from './utils/api'
/**
* Format and normalize the API host URL for a provider.
* Handles provider-specific URL formatting rules (e.g., appending version paths, Azure formatting).
*
* @param provider - The provider whose API host is to be formatted.
* @returns A new provider instance with the formatted API host.
*/
export async function formatProviderApiHost(provider: Provider): Promise<Provider> {
// WARNING: if any changes are made here, please sync it to src/renderer/src/aiCore/provider/providerConfig.ts:formatProviderApiHost
// NOTE: It's async to support Vertex API host formatting
const formatted = { ...provider }
const appendApiVersion = !isWithTrailingSharp(provider.apiHost)
if (formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost, appendApiVersion)
}
if (isAnthropicProvider(provider)) {
const baseHost = formatted.anthropicApiHost || formatted.apiHost
// AI SDK needs /v1 in baseURL, Anthropic SDK will strip it in getSdkClient
formatted.apiHost = formatApiHost(baseHost, appendApiVersion)
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
}
} else if (formatted.id === SystemProviderIds.copilot || formatted.id === SystemProviderIds.github) {
formatted.apiHost = formatApiHost(formatted.apiHost, false)
} else if (isOllamaProvider(formatted)) {
formatted.apiHost = formatOllamaApiHost(formatted.apiHost)
} else if (isGeminiProvider(formatted)) {
formatted.apiHost = formatApiHost(formatted.apiHost, appendApiVersion, 'v1beta')
} else if (isAzureOpenAIProvider(formatted)) {
formatted.apiHost = formatAzureOpenAIApiHost(formatted.apiHost)
} else if (isVertexProvider(formatted)) {
formatted.apiHost = await formatVertexApiHost(formatted.apiHost)
} else if (isCherryAIProvider(formatted)) {
formatted.apiHost = formatApiHost(formatted.apiHost, false)
} else if (isPerplexityProvider(formatted)) {
formatted.apiHost = formatApiHost(formatted.apiHost, false)
} else {
formatted.apiHost = formatApiHost(formatted.apiHost, appendApiVersion)
}
return formatted
}

View File

@@ -0,0 +1,19 @@
import { reduxService } from '@main/services/ReduxService'
import { formatApiHost, withoutTrailingSlash } from '@shared/utils'
import { trim } from 'lodash'
// NOTE: Since #13194, it's re-written with reduxService
// See: renderer/src/utils/api.ts: formatVertexApiHost
export async function formatVertexApiHost(host: string): Promise<string> {
const { projectId: project, location } = (await reduxService.select('llm.settings.vertexai')) as {
projectId: string
location: string
}
const trimmedHost = withoutTrailingSlash(trim(host))
if (!trimmedHost || trimmedHost.endsWith('aiplatform.googleapis.com')) {
const fallbackHost =
location === 'global' ? 'https://aiplatform.googleapis.com' : `https://${location}-aiplatform.googleapis.com`
return `${formatApiHost(fallbackHost)}/projects/${project}/locations/${location}`
}
return formatApiHost(trimmedHost)
}

View File

@@ -1,3 +1,4 @@
import { formatProviderApiHost } from '@main/aiCore/provider/providerConfig'
import { CacheService } from '@main/services/CacheService'
import { loggerService } from '@main/services/LoggerService'
import { reduxService } from '@main/services/ReduxService'
@@ -32,15 +33,30 @@ export async function getAvailableProviders(): Promise<Provider[]> {
const supportedTypes: ProviderType[] = ['openai', 'anthropic', 'ollama', 'new-api']
const supportedProviders = providers.filter((p: Provider) => p.enabled && supportedTypes.includes(p.type))
// Cache the filtered results
CacheService.set(PROVIDERS_CACHE_KEY, supportedProviders, PROVIDERS_CACHE_TTL)
// Format provider apiHost according to their type
const results = await Promise.allSettled(supportedProviders.map((p: Provider) => formatProviderApiHost(p)))
const formattedProviders: Provider[] = []
for (let i = 0; i < results.length; i++) {
const result = results[i]
if (result.status === 'fulfilled') {
formattedProviders.push(result.value)
} else {
logger.warn('Failed to format provider API host', {
providerId: supportedProviders[i].id,
error: result.reason
})
}
}
logger.info('Providers filtered', {
supported: supportedProviders.length,
// Cache the formatted results
CacheService.set(PROVIDERS_CACHE_KEY, formattedProviders, PROVIDERS_CACHE_TTL)
logger.info('Providers filtered and formatted', {
supported: formattedProviders.length,
total: providers.length
})
return supportedProviders
return formattedProviders
} catch (error: any) {
logger.error('Failed to get providers from Redux store', { error })
return []

View File

@@ -72,6 +72,7 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
* @param provider - The provider whose API host is to be formatted.
* @returns A new provider instance with the formatted API host.
*/
// WARNING: if any changes are made here, please sync it to src/main/aiCore/provider/providerConfig.ts:formatProviderApiHost
export function formatProviderApiHost(provider: Provider): Provider {
const formatted = { ...provider }
const appendApiVersion = !isWithTrailingSharp(provider.apiHost)

View File

@@ -15,7 +15,7 @@ import { setIsBunInstalled } from '@renderer/store/mcp'
import type { EndpointType, Model } from '@renderer/types'
import type { TerminalConfig } from '@shared/config/constant'
import { codeTools, terminalApps } from '@shared/config/constant'
import { isSiliconAnthropicCompatibleModel } from '@shared/config/providers'
import { CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS, isSiliconAnthropicCompatibleModel } from '@shared/config/providers'
import { Alert, Button, Checkbox, Input, Select, Space, Tooltip } from 'antd'
import { Download, FolderOpen, Terminal, X } from 'lucide-react'
import type { FC } from 'react'
@@ -24,7 +24,6 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import {
CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS,
CLI_TOOL_PROVIDER_MAP,
CLI_TOOLS,
generateToolEnvironment,

View File

@@ -4,10 +4,11 @@ import {
isSupportedReasoningEffortModel,
isSupportedThinkingTokenClaudeModel
} from '@renderer/config/models/reasoning'
import { type EndpointType, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import { type EndpointType, type Model, type Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { getFancyProviderName, sanitizeProviderName } from '@renderer/utils/naming'
import { codeTools } from '@shared/config/constant'
import { CLAUDE_SUPPORTED_PROVIDERS } from '@shared/config/providers'
export interface LaunchValidationResult {
isValid: boolean
@@ -39,27 +40,7 @@ export const CLI_TOOLS = [
]
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = [
'deepseek',
'moonshot',
'zhipu',
'dashscope',
'modelscope',
'minimax',
'longcat',
SystemProviderIds.qiniu,
SystemProviderIds.silicon,
SystemProviderIds.mimo,
SystemProviderIds.openrouter
]
export const CLAUDE_SUPPORTED_PROVIDERS = [
'aihubmix',
'dmxapi',
'new-api',
'cherryin',
'302ai',
...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS
]
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin']
// Provider 过滤映射

View File

@@ -38,8 +38,8 @@ import type {
OpenAIReasoningSummary,
OpenAIVerbosity
} from '@renderer/types/aiCoreTypes'
import { uuid } from '@renderer/utils'
import { API_SERVER_DEFAULTS, UpgradeChannel } from '@shared/config/constant'
import { v4 as uuid } from 'uuid'
import type { RemoteSyncState } from './backup'

View File

@@ -1,22 +1,8 @@
import store from '@renderer/store'
import type { VertexProvider } from '@renderer/types'
import { getTrailingApiVersion, withoutTrailingApiVersion } from '@shared/utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
formatApiHost,
formatApiKeys,
formatAzureOpenAIApiHost,
formatOllamaApiHost,
formatVertexApiHost,
hasAPIVersion,
isWithTrailingSharp,
maskApiKey,
routeToEndpoint,
splitApiKeyString,
validateApiHost,
withoutTrailingSharp
} from '../api'
import { formatVertexApiHost, maskApiKey, routeToEndpoint, splitApiKeyString, validateApiHost } from '../api'
vi.mock('@renderer/store', () => {
const getState = vi.fn()
@@ -59,76 +45,6 @@ beforeEach(() => {
})
describe('api', () => {
describe('formatApiHost', () => {
it('returns empty string for falsy host', () => {
expect(formatApiHost('')).toBe('')
expect(formatApiHost(undefined)).toBe('')
})
it('appends api version when missing', () => {
expect(formatApiHost('https://api.example.com')).toBe('https://api.example.com/v1')
expect(formatApiHost('http://localhost:5173/')).toBe('http://localhost:5173/v1')
expect(formatApiHost(' https://api.openai.com ')).toBe('https://api.openai.com/v1')
})
it('keeps original host when api version already present', () => {
expect(formatApiHost('https://api.volces.com/api/v3')).toBe('https://api.volces.com/api/v3')
expect(formatApiHost('http://localhost:5173/v2beta')).toBe('http://localhost:5173/v2beta')
})
it('supports custom api version parameter', () => {
expect(formatApiHost('https://api.example.com', true, 'v2')).toBe('https://api.example.com/v2')
})
it('keeps host untouched when api version unsupported', () => {
expect(formatApiHost('https://api.example.com', false)).toBe('https://api.example.com')
})
it('removes trailing # and does not append api version when host ends with #', () => {
expect(formatApiHost('https://api.example.com#')).toBe('https://api.example.com')
expect(formatApiHost('http://localhost:5173/#')).toBe('http://localhost:5173/')
expect(formatApiHost(' https://api.openai.com/# ')).toBe('https://api.openai.com/')
})
it('handles trailing # with custom api version settings', () => {
expect(formatApiHost('https://api.example.com#', true, 'v2')).toBe('https://api.example.com')
expect(formatApiHost('https://api.example.com#', false, 'v2')).toBe('https://api.example.com')
})
it('handles host with both trailing # and existing api version', () => {
expect(formatApiHost('https://api.example.com/v2#')).toBe('https://api.example.com/v2')
expect(formatApiHost('https://api.example.com/v3beta#')).toBe('https://api.example.com/v3beta')
})
it('trims whitespace before processing trailing #', () => {
expect(formatApiHost(' https://api.example.com# ')).toBe('https://api.example.com')
expect(formatApiHost('\thttps://api.example.com#\n')).toBe('https://api.example.com')
})
})
describe('hasAPIVersion', () => {
it('detects numeric version suffix', () => {
expect(hasAPIVersion('https://api.example.com/v1')).toBe(true)
expect(hasAPIVersion('http://localhost:3000/v2beta')).toBe(true)
expect(hasAPIVersion('/v3alpha/resources')).toBe(true)
})
it('returns false when no version found', () => {
expect(hasAPIVersion('https://api.example.com')).toBe(false)
expect(hasAPIVersion('')).toBe(false)
expect(hasAPIVersion(undefined)).toBe(false)
})
it('return flase when starting without v character', () => {
expect(hasAPIVersion('https://api.example.com/a1v')).toBe(false)
expect(hasAPIVersion('/av1/users')).toBe(false)
})
it('return flase when starting with v- word', () => {
expect(hasAPIVersion('https://api.example.com/vendor')).toBe(false)
})
})
describe('maskApiKey', () => {
it('should return empty string when key is empty', () => {
expect(maskApiKey('')).toBe('')
@@ -284,27 +200,6 @@ describe('api', () => {
})
})
describe('formatApiKeys', () => {
it('normalizes chinese commas and new lines', () => {
expect(formatApiKeys('key1key2\nkey3')).toBe('key1,key2,key3')
})
it('returns empty string unchanged', () => {
expect(formatApiKeys('')).toBe('')
})
})
describe('formatAzureOpenAIApiHost', () => {
it('normalizes trailing segments and disables auto version append', () => {
expect(formatAzureOpenAIApiHost('https://example.openai.azure.com/')).toBe(
'https://example.openai.azure.com/openai'
)
expect(formatAzureOpenAIApiHost('https://example.openai.azure.com/openai/')).toBe(
'https://example.openai.azure.com/openai'
)
})
})
describe('formatVertexApiHost', () => {
it('builds default google endpoint when host absent', () => {
expect(formatVertexApiHost(createVertexProvider(''))).toBe(
@@ -341,310 +236,4 @@ describe('api', () => {
)
})
})
describe('formatOllamaApiHost', () => {
it('removes trailing slash and appends /api for basic hosts', () => {
expect(formatOllamaApiHost('https://api.ollama.com/')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('http://localhost:11434/')).toBe('http://localhost:11434/api')
})
it('appends /api when no suffix is present', () => {
expect(formatOllamaApiHost('https://api.ollama.com')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('http://localhost:11434')).toBe('http://localhost:11434/api')
})
it('removes /v1 suffix and appends /api', () => {
expect(formatOllamaApiHost('https://api.ollama.com/v1')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('http://localhost:11434/v1/')).toBe('http://localhost:11434/api')
})
it('removes /api suffix and keeps /api', () => {
expect(formatOllamaApiHost('https://api.ollama.com/api')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('http://localhost:11434/api/')).toBe('http://localhost:11434/api')
})
it('removes /chat suffix and appends /api', () => {
expect(formatOllamaApiHost('https://api.ollama.com/chat')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('http://localhost:11434/chat/')).toBe('http://localhost:11434/api')
})
it('handles multiple suffix combinations correctly', () => {
expect(formatOllamaApiHost('https://api.ollama.com/v1/chat')).toBe('https://api.ollama.com/v1/api')
expect(formatOllamaApiHost('https://api.ollama.com/chat/v1')).toBe('https://api.ollama.com/api')
expect(formatOllamaApiHost('https://api.ollama.com/api/chat')).toBe('https://api.ollama.com/api/api')
})
it('preserves complex paths while handling suffixes', () => {
expect(formatOllamaApiHost('https://api.ollama.com/custom/path')).toBe('https://api.ollama.com/custom/path/api')
expect(formatOllamaApiHost('https://api.ollama.com/custom/path/')).toBe('https://api.ollama.com/custom/path/api')
expect(formatOllamaApiHost('https://api.ollama.com/custom/path/v1')).toBe(
'https://api.ollama.com/custom/path/api'
)
})
it('handles edge cases with multiple slashes', () => {
expect(formatOllamaApiHost('https://api.ollama.com//')).toBe('https://api.ollama.com//api')
expect(formatOllamaApiHost('https://api.ollama.com///v1///')).toBe('https://api.ollama.com///v1///api')
})
it('handles localhost with different ports', () => {
expect(formatOllamaApiHost('http://localhost:3000')).toBe('http://localhost:3000/api')
expect(formatOllamaApiHost('http://127.0.0.1:11434/')).toBe('http://127.0.0.1:11434/api')
expect(formatOllamaApiHost('https://localhost:8080/v1')).toBe('https://localhost:8080/api')
})
it('handles IP addresses', () => {
expect(formatOllamaApiHost('http://192.168.1.100:11434')).toBe('http://192.168.1.100:11434/api')
expect(formatOllamaApiHost('https://10.0.0.1:8080/v1/')).toBe('https://10.0.0.1:8080/api')
})
it('handles empty strings and edge cases', () => {
expect(formatOllamaApiHost('')).toBe('/api')
expect(formatOllamaApiHost('/')).toBe('/api')
})
it('preserves protocol and handles mixed case', () => {
expect(formatOllamaApiHost('HTTPS://API.OLLAMA.COM')).toBe('HTTPS://API.OLLAMA.COM/api')
expect(formatOllamaApiHost('HTTP://localhost:11434/V1/')).toBe('HTTP://localhost:11434/V1/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()
})
it('returns undefined when URL ends with # regardless of version', () => {
expect(getTrailingApiVersion('https://api.example.com/v1#')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/v2beta#')).toBeUndefined()
expect(getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1#')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/service/v1#')).toBeUndefined()
})
it('handles URLs with # and trailing slash correctly', () => {
expect(getTrailingApiVersion('https://api.example.com/v1/#')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/v2beta/#')).toBeUndefined()
})
it('handles URLs with version followed by # and additional path', () => {
expect(getTrailingApiVersion('https://api.example.com/v1#endpoint')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/v2beta#chat/completions')).toBeUndefined()
})
it('handles complex URLs with multiple # characters', () => {
expect(getTrailingApiVersion('https://api.example.com/v1#path#')).toBeUndefined()
expect(getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/v2beta#')).toBeUndefined()
})
it('handles URLs ending with # when version is not at the end', () => {
expect(getTrailingApiVersion('https://api.example.com/v1/service#')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/v1/api/chat#')).toBeUndefined()
})
it('distinguishes between URLs with and without trailing #', () => {
// Without # - should extract version
expect(getTrailingApiVersion('https://api.example.com/v1')).toBe('v1')
expect(getTrailingApiVersion('https://api.example.com/v2beta')).toBe('v2beta')
// With # - should return undefined
expect(getTrailingApiVersion('https://api.example.com/v1#')).toBeUndefined()
expect(getTrailingApiVersion('https://api.example.com/v2beta#')).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('')
})
})
describe('isWithTrailingSharp', () => {
it('returns true when URL ends with #', () => {
expect(isWithTrailingSharp('https://api.example.com#')).toBe(true)
expect(isWithTrailingSharp('http://localhost:3000#')).toBe(true)
expect(isWithTrailingSharp('#')).toBe(true)
})
it('returns false when URL does not end with #', () => {
expect(isWithTrailingSharp('https://api.example.com')).toBe(false)
expect(isWithTrailingSharp('http://localhost:3000')).toBe(false)
expect(isWithTrailingSharp('')).toBe(false)
})
it('returns false when URL has # in the middle but not at the end', () => {
expect(isWithTrailingSharp('https://api.example.com#path')).toBe(false)
expect(isWithTrailingSharp('https://api.example.com#section/path')).toBe(false)
expect(isWithTrailingSharp('https://api.example.com#path#other')).toBe(false)
})
it('handles URLs with multiple # characters', () => {
expect(isWithTrailingSharp('https://api.example.com##')).toBe(true)
expect(isWithTrailingSharp('https://api.example.com#path#')).toBe(true)
expect(isWithTrailingSharp('https://api.example.com###')).toBe(true)
})
it('handles URLs with trailing whitespace after #', () => {
expect(isWithTrailingSharp('https://api.example.com# ')).toBe(false)
expect(isWithTrailingSharp('https://api.example.com#\t')).toBe(false)
expect(isWithTrailingSharp('https://api.example.com#\n')).toBe(false)
})
it('handles URLs with whitespace before trailing #', () => {
expect(isWithTrailingSharp(' https://api.example.com#')).toBe(true)
expect(isWithTrailingSharp('\thttps://localhost:3000#')).toBe(true)
})
it('preserves type safety with generic parameter', () => {
const url1: string = 'https://api.example.com#'
const url2 = 'https://example.com' as const
expect(isWithTrailingSharp(url1)).toBe(true)
expect(isWithTrailingSharp(url2)).toBe(false)
})
it('handles complex real-world URLs', () => {
expect(isWithTrailingSharp('https://open.cherryin.net/v1/chat/completions#')).toBe(true)
expect(isWithTrailingSharp('https://api.openai.com/v1/engines/gpt-4#')).toBe(true)
expect(isWithTrailingSharp('https://gateway.ai.cloudflare.com/v1/xxx/v1beta#')).toBe(true)
expect(isWithTrailingSharp('https://open.cherryin.net/v1/chat/completions')).toBe(false)
expect(isWithTrailingSharp('https://api.openai.com/v1/engines/gpt-4')).toBe(false)
expect(isWithTrailingSharp('https://gateway.ai.cloudflare.com/v1/xxx/v1beta')).toBe(false)
})
it('handles edge cases', () => {
expect(isWithTrailingSharp('#')).toBe(true)
expect(isWithTrailingSharp(' #')).toBe(true)
expect(isWithTrailingSharp('# ')).toBe(false)
expect(isWithTrailingSharp('path#')).toBe(true)
expect(isWithTrailingSharp('/path/with/trailing/#')).toBe(true)
expect(isWithTrailingSharp('/path/without/trailing/')).toBe(false)
})
})
describe('withoutTrailingSharp', () => {
it('removes trailing # from URL', () => {
expect(withoutTrailingSharp('https://api.example.com#')).toBe('https://api.example.com')
expect(withoutTrailingSharp('http://localhost:3000#')).toBe('http://localhost:3000')
})
it('returns URL unchanged when no trailing #', () => {
expect(withoutTrailingSharp('https://api.example.com')).toBe('https://api.example.com')
expect(withoutTrailingSharp('http://localhost:3000')).toBe('http://localhost:3000')
})
it('handles URLs with multiple # characters but only removes trailing one', () => {
expect(withoutTrailingSharp('https://api.example.com#path#')).toBe('https://api.example.com#path')
})
it('handles URLs with # in the middle (not trailing)', () => {
expect(withoutTrailingSharp('https://api.example.com#section/path')).toBe('https://api.example.com#section/path')
expect(withoutTrailingSharp('https://api.example.com/v1/chat/completions#')).toBe(
'https://api.example.com/v1/chat/completions'
)
})
it('handles empty string', () => {
expect(withoutTrailingSharp('')).toBe('')
})
it('handles single character #', () => {
expect(withoutTrailingSharp('#')).toBe('')
})
it('preserves whitespace around the URL (pure function)', () => {
expect(withoutTrailingSharp(' https://api.example.com# ')).toBe(' https://api.example.com# ')
expect(withoutTrailingSharp('\thttps://api.example.com#\n')).toBe('\thttps://api.example.com#\n')
})
it('only removes exact trailing # character', () => {
expect(withoutTrailingSharp('https://api.example.com# ')).toBe('https://api.example.com# ')
expect(withoutTrailingSharp(' https://api.example.com#')).toBe(' https://api.example.com')
expect(withoutTrailingSharp('https://api.example.com#\t')).toBe('https://api.example.com#\t')
})
it('handles URLs ending with multiple # characters', () => {
expect(withoutTrailingSharp('https://api.example.com##')).toBe('https://api.example.com#')
expect(withoutTrailingSharp('https://api.example.com###')).toBe('https://api.example.com##')
})
it('preserves URL with trailing # and other content', () => {
expect(withoutTrailingSharp('https://api.example.com/v1#')).toBe('https://api.example.com/v1')
expect(withoutTrailingSharp('https://api.example.com/v2beta#')).toBe('https://api.example.com/v2beta')
})
})
})

View File

@@ -1,155 +1,29 @@
import store from '@renderer/store'
import type { VertexProvider } from '@renderer/types'
import { formatApiHost, withoutTrailingSlash } from '@shared/utils'
import { trim } from 'lodash'
/**
* 格式化 API key 字符串。
*
* @param {string} value - 需要格式化的 API key 字符串。
* @returns {string} 格式化后的 API key 字符串。
*/
export function formatApiKeys(value: string): string {
return value.replaceAll('', ',').replaceAll('\n', ',')
}
// Re-export from shared, for backward compatibility
export {
formatApiHost,
formatApiKeys,
hasAPIVersion,
isWithTrailingSharp,
withoutTrailingSharp,
withoutTrailingSlash
} from '@shared/utils/api'
/**
* 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)?(?=\\/|$)'
/**
* 判断 host 的 path 中是否包含形如版本的字符串(例如 /v1、/v2beta 等),
*
* @param host - 要检查的 host 或 path 字符串
* @returns 如果 path 中包含版本字符串则返回 true否则 false
*/
export function hasAPIVersion(host?: string): boolean {
if (!host) return false
const regex = new RegExp(VERSION_REGEX_PATTERN, 'i')
try {
const url = new URL(host)
return regex.test(url.pathname)
} catch {
// 若无法作为完整 URL 解析,则当作路径直接检测
return regex.test(host)
}
}
/**
* Removes the trailing slash from a URL string if it exists.
*
* @template T - The string type to preserve type safety
* @param {T} url - The URL string to process
* @returns {T} The URL string without a trailing slash
*
* @example
* ```ts
* withoutTrailingSlash('https://example.com/') // 'https://example.com'
* withoutTrailingSlash('https://example.com') // 'https://example.com'
* ```
*/
export function withoutTrailingSlash<T extends string>(url: T): T {
return url.replace(/\/$/, '') as T
}
/**
* Checks if a URL string ends with a trailing '#' character.
*
* @template T - The string type to preserve type safety
* @param {T} url - The URL string to check
* @returns {boolean} True if the URL ends with '#', false otherwise
*
* @example
* ```ts
* isWithTrailingSharp('https://example.com#') // true
* isWithTrailingSharp('https://example.com') // false
* ```
*/
export function isWithTrailingSharp<T extends string>(url: T): boolean {
return url.endsWith('#')
}
/**
* Removes the trailing '#' from a URL string if it exists.
*
* @template T - The string type to preserve type safety
* @param {T} url - The URL string to process
* @returns {T} The URL string without a trailing '#'
*
* @example
* ```ts
* withoutTrailingSharp('https://example.com#') // 'https://example.com'
* withoutTrailingSharp('https://example.com') // 'https://example.com'
* ```
*/
export function withoutTrailingSharp<T extends string>(url: T): T {
return url.replace(/#$/, '') as 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 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.
* If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host with trailing '#' removed.
* Otherwise, returns the host with the API version appended.
*
* @example
* formatApiHost('https://api.example.com/') // Returns 'https://api.example.com/v1'
* 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, supportApiVersion: boolean = true, apiVersion: string = 'v1'): string {
const normalizedHost = withoutTrailingSlash(trim(host))
if (!normalizedHost) {
return ''
}
const shouldAppendApiVersion = !(normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost))
if (shouldAppendApiVersion) {
return `${normalizedHost}/${apiVersion}`
} else {
return withoutTrailingSharp(normalizedHost)
}
}
/**
* 格式化 Ollama 的 API 主机地址。
*/
export function formatOllamaApiHost(host: string): string {
const normalizedHost = withoutTrailingSlash(host)
?.replace(/\/v1$/, '')
?.replace(/\/api$/, '')
?.replace(/\/chat$/, '')
return formatApiHost(normalizedHost + '/api', false)
}
/**
* 格式化 Azure OpenAI 的 API 主机地址。
*/
export function formatAzureOpenAIApiHost(host: string): string {
const normalizedHost = withoutTrailingSlash(host)
?.replace(/\/v1$/, '')
.replace(/\/openai$/, '')
// NOTE: AISDK会添加上`v1`
return formatApiHost(normalizedHost + '/openai', false)
}
// Re-export from shared, for backward compatibility
export { formatAzureOpenAIApiHost, formatOllamaApiHost } from '@shared/aiCore/provider/utils'
// NOTE: Since #13194, it depends on the store state in renderer, so it cannot be moved to shared now.
export function formatVertexApiHost(provider: VertexProvider): string {
const { apiHost } = provider
const { projectId: project, location } = store.getState().llm.settings.vertexai
const trimmedHost = withoutTrailingSlash(trim(apiHost))
if (!trimmedHost || trimmedHost.endsWith('aiplatform.googleapis.com')) {
const host =
location == 'global' ? 'https://aiplatform.googleapis.com' : `https://${location}-aiplatform.googleapis.com`
location === 'global' ? 'https://aiplatform.googleapis.com' : `https://${location}-aiplatform.googleapis.com`
return `${formatApiHost(host)}/projects/${project}/locations/${location}`
}
return formatApiHost(trimmedHost)

View File

@@ -1,6 +1,7 @@
import { CLAUDE_SUPPORTED_PROVIDERS } from '@renderer/pages/code'
import type { AzureOpenAIProvider, ProviderType, VertexProvider } from '@renderer/types'
import type { AzureOpenAIProvider, ProviderType } from '@renderer/types'
import { isSystemProvider, type Provider, type SystemProviderId, SystemProviderIds } from '@renderer/types'
import { isAzureOpenAIProvider } from '@shared/aiCore/provider/utils'
import { CLAUDE_SUPPORTED_PROVIDERS } from '@shared/config/providers'
export const isAzureResponsesEndpoint = (provider: AzureOpenAIProvider) => {
return provider.apiVersion === 'preview' || provider.apiVersion === 'v1'
@@ -139,14 +140,6 @@ export const isNewApiProvider = (provider: Provider) => {
return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api'
}
export function isCherryAIProvider(provider: Provider): boolean {
return provider.id === 'cherryai'
}
export function isPerplexityProvider(provider: Provider): boolean {
return provider.id === 'perplexity'
}
/**
* 判断是否为 OpenAI 兼容的提供商
* @param {Provider} provider 提供商对象
@@ -156,38 +149,29 @@ export function isOpenAICompatibleProvider(provider: Provider): boolean {
return ['openai', 'new-api', 'mistral'].includes(provider.type)
}
export function isAzureOpenAIProvider(provider: Provider): provider is AzureOpenAIProvider {
return provider.type === 'azure-openai'
}
export function isOpenAIProvider(provider: Provider): boolean {
return provider.type === 'openai-response'
}
export function isVertexProvider(provider: Provider): provider is VertexProvider {
return provider.type === 'vertexai'
}
export function isAwsBedrockProvider(provider: Provider): boolean {
return provider.type === 'aws-bedrock'
}
export function isAnthropicProvider(provider: Provider): boolean {
return provider.type === 'anthropic'
}
export function isGeminiProvider(provider: Provider): boolean {
return provider.type === 'gemini'
}
// Re-export from shared, for backward compatibility
export {
isAnthropicProvider,
isAzureOpenAIProvider,
isCherryAIProvider,
isGeminiProvider,
isOllamaProvider,
isPerplexityProvider,
isVertexProvider
} from '@shared/aiCore/provider/utils'
export function isAIGatewayProvider(provider: Provider): boolean {
return provider.type === 'gateway'
}
export function isOllamaProvider(provider: Provider): boolean {
return provider.type === 'ollama'
}
const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot', 'perplexity'] as const satisfies SystemProviderId[]
export const isSupportAPIVersionProvider = (provider: Provider) => {

View File

@@ -1,3 +1,4 @@
import { resolve } from 'path'
import { defineConfig } from 'vitest/config'
import electronViteConfig from './electron.vite.config'
@@ -60,6 +61,11 @@ export default defineConfig({
// shared 包单元测试配置
{
extends: true,
resolve: {
alias: {
'@shared': resolve('packages/shared')
}
},
test: {
name: 'shared',
environment: 'node',