mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
Normalize endpoint API root handling
This commit is contained in:
@@ -1050,7 +1050,7 @@ import { log } from '@/utils/logger'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import EndpointConditionEditor from './EndpointConditionEditor.vue'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
import { getDefaultEndpointPath, normalizeEndpointApiFormat } from './endpoint-default-paths'
|
||||
import { getDefaultEndpointBaseUrl, getDefaultEndpointPath, normalizeEndpointApiFormat } from './endpoint-default-paths'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import {
|
||||
createEndpoint,
|
||||
@@ -1886,6 +1886,15 @@ function getEndpointDefaultPath(endpoint: ProviderEndpoint): string {
|
||||
return getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url)
|
||||
}
|
||||
|
||||
function getNewEndpointBaseUrl(): string {
|
||||
const typedBaseUrl = newEndpoint.value.base_url.trim()
|
||||
if (typedBaseUrl) return typedBaseUrl
|
||||
return getDefaultEndpointBaseUrl({
|
||||
apiFormat: newEndpoint.value.api_format,
|
||||
baseUrl: props.provider?.website || '',
|
||||
})
|
||||
}
|
||||
|
||||
function getDisplayedPath(endpoint: ProviderEndpoint): string {
|
||||
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
|
||||
}
|
||||
@@ -3117,8 +3126,8 @@ function getResponseHeaderValidationErrorForEndpoint(endpointId: string): string
|
||||
|
||||
// 新端点选择的格式的默认路径
|
||||
const newEndpointDefaultPath = computed(() => {
|
||||
// 使用填写的 base_url 或 provider 的 website 来判断是否是 Codex 端点
|
||||
const baseUrl = newEndpoint.value.base_url || props.provider?.website || ''
|
||||
// 使用填写的 base_url;留空时使用按格式规范化后的 provider website。
|
||||
const baseUrl = getNewEndpointBaseUrl()
|
||||
return getDefaultPath(newEndpoint.value.api_format, baseUrl)
|
||||
})
|
||||
|
||||
@@ -3362,8 +3371,8 @@ async function handleCycleUpstreamStream(endpoint: ProviderEndpoint) {
|
||||
async function handleAddEndpoint() {
|
||||
if (!props.provider || !newEndpoint.value.api_format) return
|
||||
|
||||
// 如果没有输入 base_url,使用提供商的 website 作为默认值
|
||||
const baseUrl = newEndpoint.value.base_url || props.provider.website
|
||||
// 如果没有输入 base_url,使用按格式规范化后的提供商 website 作为默认值。
|
||||
const baseUrl = getNewEndpointBaseUrl()
|
||||
if (!baseUrl) {
|
||||
showError('请输入 Base URL')
|
||||
return
|
||||
|
||||
+124
-10
@@ -1,29 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getDefaultEndpointPath } from '../endpoint-default-paths'
|
||||
import { getDefaultEndpointBaseUrl, getDefaultEndpointPath } from '../endpoint-default-paths'
|
||||
|
||||
const apiFormats = [
|
||||
{ value: 'openai:chat', default_path: '/v1/chat/completions' },
|
||||
{ value: 'gemini:generate_content', default_path: '/v1beta/models/{model}:{action}' },
|
||||
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:{action}' },
|
||||
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:embedContent' },
|
||||
{ value: 'gemini:video', default_path: '/v1beta/models/{model}:predictLongRunning' },
|
||||
{ value: 'openai:responses', default_path: '/v1/responses' },
|
||||
{ value: 'openai:embedding', default_path: '/v1/embeddings' },
|
||||
{ value: 'openai:rerank', default_path: '/v1/rerank' },
|
||||
{ value: 'openai:image', default_path: '/v1/images/generations' },
|
||||
{ value: 'openai:video', default_path: '/v1/videos' },
|
||||
{ value: 'jina:embedding', default_path: '/v1/embeddings' },
|
||||
{ value: 'jina:rerank', default_path: '/v1/rerank' },
|
||||
{ value: 'claude:messages', default_path: '/v1/messages' },
|
||||
]
|
||||
|
||||
describe('endpoint default paths', () => {
|
||||
it('uses Gemini Developer API paths for custom Gemini endpoints', () => {
|
||||
it('uses Gemini Developer API resource paths for custom Gemini endpoints', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'gemini:generate_content',
|
||||
providerType: 'custom',
|
||||
apiFormats,
|
||||
})).toBe('/v1beta/models/{model}:{action}')
|
||||
})).toBe('/models/{model}:{action}')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'gemini:embedding',
|
||||
providerType: 'custom',
|
||||
apiFormats,
|
||||
})).toBe('/v1beta/models/{model}:{action}')
|
||||
})).toBe('/models/{model}:embedContent')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'gemini:video',
|
||||
providerType: 'custom',
|
||||
apiFormats,
|
||||
})).toBe('/models/{model}:predictLongRunning')
|
||||
})
|
||||
|
||||
it('uses Vertex AI project/location paths for Vertex provider Gemini endpoints', () => {
|
||||
@@ -56,7 +68,7 @@ describe('endpoint default paths', () => {
|
||||
})).toBe('/responses')
|
||||
})
|
||||
|
||||
it('drops /v1 from OpenAI-compatible defaults when base URL includes a path', () => {
|
||||
it('drops /v1 from API-root defaults because base URL is the API root', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:chat',
|
||||
providerType: 'custom',
|
||||
@@ -71,6 +83,41 @@ describe('endpoint default paths', () => {
|
||||
apiFormats,
|
||||
})).toBe('/embeddings')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:rerank',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api?tenant=demo',
|
||||
apiFormats,
|
||||
})).toBe('/rerank')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:image',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api',
|
||||
apiFormats,
|
||||
})).toBe('/images/generations')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:video',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api',
|
||||
apiFormats,
|
||||
})).toBe('/videos')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'jina:embedding',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://api.jina.ai/v1',
|
||||
apiFormats,
|
||||
})).toBe('/embeddings')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'jina:rerank',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://api.jina.ai/v1',
|
||||
apiFormats,
|
||||
})).toBe('/rerank')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:chat',
|
||||
providerType: 'custom',
|
||||
@@ -83,7 +130,7 @@ describe('endpoint default paths', () => {
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com',
|
||||
apiFormats,
|
||||
})).toBe('/v1/chat/completions')
|
||||
})).toBe('/chat/completions')
|
||||
})
|
||||
|
||||
it('drops /v1 from OpenAI-compatible defaults when base URL already includes a known API root', () => {
|
||||
@@ -102,7 +149,7 @@ describe('endpoint default paths', () => {
|
||||
})).toBe('/responses')
|
||||
})
|
||||
|
||||
it('keeps /v1 for Claude Messages defaults unless base URL already ends with v1', () => {
|
||||
it('drops /v1 from Claude Messages defaults because base URL is the API root', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'claude:messages',
|
||||
providerType: 'custom',
|
||||
@@ -115,13 +162,80 @@ describe('endpoint default paths', () => {
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api',
|
||||
apiFormats,
|
||||
})).toBe('/v1/messages')
|
||||
})).toBe('/messages')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'claude:messages',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/anthropic',
|
||||
apiFormats,
|
||||
})).toBe('/v1/messages')
|
||||
})).toBe('/messages')
|
||||
})
|
||||
|
||||
it('defaults API-root base URLs to the format version when using a provider website', () => {
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:chat',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
})).toBe('https://api.openai.com/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:responses',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode',
|
||||
})).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'claude:messages',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
})).toBe('https://api.anthropic.com/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:embedding',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
})).toBe('https://api.openai.com/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:image',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
})).toBe('https://api.openai.com/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:video',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
})).toBe('https://api.openai.com/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'jina:embedding',
|
||||
baseUrl: 'https://api.jina.ai',
|
||||
})).toBe('https://api.jina.ai/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'gemini:generate_content',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
})).toBe('https://generativelanguage.googleapis.com/v1beta')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'gemini:embedding',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
})).toBe('https://generativelanguage.googleapis.com/v1beta')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'gemini:video',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
})).toBe('https://generativelanguage.googleapis.com/v1beta')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:chat',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
})).toBe('https://open.bigmodel.cn/api/coding/paas/v4')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:chat',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
})).toBe('https://generativelanguage.googleapis.com/v1beta/openai')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:chat',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
})).toBe('https://api.deepseek.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,11 +42,21 @@ function baseUrlEndsWithV1Root(baseUrl?: string | null): boolean {
|
||||
return parseBaseUrlParts(baseUrl)?.path.endsWith('/v1') ?? false
|
||||
}
|
||||
|
||||
function baseUrlHasVersionedApiRoot(baseUrl?: string | null): boolean {
|
||||
const path = parseBaseUrlParts(baseUrl)?.path || ''
|
||||
return /\/v\d+(?:beta\d*)?(?:\/|$)/i.test(path)
|
||||
}
|
||||
|
||||
function isBigModelCodingApiRoot(baseUrl?: string | null): boolean {
|
||||
const parts = parseBaseUrlParts(baseUrl)
|
||||
return parts?.host === 'open.bigmodel.cn' && parts.path === '/api/coding/paas/v4'
|
||||
}
|
||||
|
||||
function isDeepSeekApiRoot(baseUrl?: string | null): boolean {
|
||||
const parts = parseBaseUrlParts(baseUrl)
|
||||
return parts?.host === 'api.deepseek.com'
|
||||
}
|
||||
|
||||
function isGoogleOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
|
||||
const parts = parseBaseUrlParts(baseUrl)
|
||||
return parts?.host === 'generativelanguage.googleapis.com'
|
||||
@@ -68,20 +78,84 @@ function openAiCompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
|
||||
|| isVertexOpenAiCompatApiRoot(baseUrl)
|
||||
}
|
||||
|
||||
function v1CompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
|
||||
return baseUrlEndsWithV1Root(baseUrl)
|
||||
}
|
||||
|
||||
function stripV1PrefixForApiRoot(path: string): string {
|
||||
return path.replace(/^\/v1(?=\/)/i, '')
|
||||
function stripVersionPrefixForApiRoot(path: string): string {
|
||||
return path.replace(/^\/v\d+(?:beta\d*)?(?=\/)/i, '')
|
||||
}
|
||||
|
||||
function isOpenAiCompatibleFormat(apiFormat: string): boolean {
|
||||
return apiFormat.startsWith('openai:') || apiFormat.startsWith('jina:')
|
||||
}
|
||||
|
||||
function isClaudeCompatibleFormat(apiFormat: string): boolean {
|
||||
return apiFormat === 'claude:messages'
|
||||
function usesVersionedApiRootByDefault(apiFormat: string): boolean {
|
||||
return apiFormat === 'openai:chat'
|
||||
|| apiFormat === 'openai:responses'
|
||||
|| apiFormat === 'openai:responses:compact'
|
||||
|| apiFormat === 'openai:embedding'
|
||||
|| apiFormat === 'openai:rerank'
|
||||
|| apiFormat === 'openai:image'
|
||||
|| apiFormat === 'openai:video'
|
||||
|| apiFormat === 'jina:embedding'
|
||||
|| apiFormat === 'jina:rerank'
|
||||
|| apiFormat === 'claude:messages'
|
||||
|| apiFormat === 'gemini:generate_content'
|
||||
|| apiFormat === 'gemini:embedding'
|
||||
|| apiFormat === 'gemini:video'
|
||||
}
|
||||
|
||||
function versionedApiRootSuffix(apiFormat: string): '/v1' | '/v1beta' {
|
||||
if (
|
||||
apiFormat === 'gemini:generate_content'
|
||||
|| apiFormat === 'gemini:embedding'
|
||||
|| apiFormat === 'gemini:video'
|
||||
) {
|
||||
return '/v1beta'
|
||||
}
|
||||
return '/v1'
|
||||
}
|
||||
|
||||
function skipsVersionedApiRootDefault(apiFormat: string, baseUrl: string): boolean {
|
||||
if (
|
||||
apiFormat === 'gemini:generate_content'
|
||||
|| apiFormat === 'gemini:embedding'
|
||||
|| apiFormat === 'gemini:video'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return isDeepSeekApiRoot(baseUrl)
|
||||
|| isBigModelCodingApiRoot(baseUrl)
|
||||
|| isGoogleOpenAiCompatApiRoot(baseUrl)
|
||||
|| isVertexOpenAiCompatApiRoot(baseUrl)
|
||||
}
|
||||
|
||||
function appendVersionedApiRoot(baseUrl: string, suffix: '/v1' | '/v1beta'): string {
|
||||
const raw = baseUrl.trim()
|
||||
if (!raw) return ''
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
parsed.pathname = `${parsed.pathname.replace(/\/+$/, '')}${suffix}`
|
||||
return parsed.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
const [base, query] = raw.split('?', 2)
|
||||
const normalizedBase = base.replace(/\/+$/, '')
|
||||
return query === undefined ? `${normalizedBase}${suffix}` : `${normalizedBase}${suffix}?${query}`
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultEndpointBaseUrl(params: {
|
||||
apiFormat: string
|
||||
baseUrl?: string | null
|
||||
}): string {
|
||||
const normalizedApiFormat = normalizeEndpointApiFormat(params.apiFormat)
|
||||
const rawBaseUrl = (params.baseUrl || '').trim()
|
||||
if (!rawBaseUrl) return ''
|
||||
if (
|
||||
usesVersionedApiRootByDefault(normalizedApiFormat)
|
||||
&& !baseUrlHasVersionedApiRoot(rawBaseUrl)
|
||||
&& !skipsVersionedApiRootDefault(normalizedApiFormat, rawBaseUrl)
|
||||
) {
|
||||
return appendVersionedApiRoot(rawBaseUrl, versionedApiRootSuffix(normalizedApiFormat))
|
||||
}
|
||||
return rawBaseUrl
|
||||
}
|
||||
|
||||
export function getDefaultEndpointPath(params: {
|
||||
@@ -117,11 +191,11 @@ export function getDefaultEndpointPath(params: {
|
||||
if (normalizedApiFormat === 'openai:responses' && isCodex) {
|
||||
return '/responses'
|
||||
}
|
||||
if (openAiCompatibleBaseIncludesApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
|
||||
return stripV1PrefixForApiRoot(defaultPath)
|
||||
if (usesVersionedApiRootByDefault(normalizedApiFormat)) {
|
||||
return stripVersionPrefixForApiRoot(defaultPath)
|
||||
}
|
||||
if (v1CompatibleBaseIncludesApiRoot(params.baseUrl) && isClaudeCompatibleFormat(normalizedApiFormat)) {
|
||||
return stripV1PrefixForApiRoot(defaultPath)
|
||||
if (openAiCompatibleBaseIncludesApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
|
||||
return stripVersionPrefixForApiRoot(defaultPath)
|
||||
}
|
||||
return defaultPath
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user