fix(provider): refine endpoint default paths for openai and claude roots

This commit is contained in:
zhefox
2026-05-26 16:50:36 +08:00
parent 23a36e37bb
commit aa409a8a9c
7 changed files with 226 additions and 33 deletions
@@ -153,10 +153,17 @@
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
:model-value="getDisplayedPath(endpoint)"
:placeholder="getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url) || '留空使用默认'"
:placeholder="getEndpointDefaultPath(endpoint) || '留空使用默认'"
:disabled="isFixedProvider"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
/>
<p
v-if="getEndpointDefaultPath(endpoint)"
class="text-[10px] text-muted-foreground truncate"
:title="getEndpointDefaultPath(endpoint)"
>
当前默认路径:{{ getEndpointDefaultPath(endpoint) }}
</p>
</div>
</div>
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
@@ -963,6 +970,13 @@
size="sm"
:placeholder="newEndpointDefaultPath || '留空使用默认'"
/>
<p
v-if="newEndpointDefaultPath"
class="text-[10px] text-muted-foreground truncate"
:title="newEndpointDefaultPath"
>
当前默认路径:{{ newEndpointDefaultPath }}
</p>
</div>
</div>
</div>
@@ -1859,6 +1873,10 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
})
}
function getEndpointDefaultPath(endpoint: ProviderEndpoint): string {
return getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url)
}
function getDisplayedPath(endpoint: ProviderEndpoint): string {
if (isFixedProvider.value) {
return getDefaultPath(endpoint.api_format, endpoint.base_url)
@@ -8,6 +8,7 @@ const apiFormats = [
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:{action}' },
{ value: 'openai:responses', default_path: '/v1/responses' },
{ value: 'openai:embedding', default_path: '/v1/embeddings' },
{ value: 'claude:messages', default_path: '/v1/messages' },
]
describe('endpoint default paths', () => {
@@ -47,7 +48,7 @@ describe('endpoint default paths', () => {
})).toBe('/responses')
})
it('drops /v1 from OpenAI-compatible defaults when base URL ends with /api', () => {
it('drops /v1 from OpenAI-compatible defaults when base URL includes a path', () => {
expect(getDefaultEndpointPath({
apiFormat: 'openai:chat',
providerType: 'custom',
@@ -61,5 +62,58 @@ describe('endpoint default paths', () => {
baseUrl: 'https://proxy.example.com/api?tenant=demo',
apiFormats,
})).toBe('/embeddings')
expect(getDefaultEndpointPath({
apiFormat: 'openai:chat',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/openai',
apiFormats,
})).toBe('/chat/completions')
expect(getDefaultEndpointPath({
apiFormat: 'openai:chat',
providerType: 'custom',
baseUrl: 'https://proxy.example.com',
apiFormats,
})).toBe('/v1/chat/completions')
})
it('drops /v1 from OpenAI-compatible defaults when base URL already includes a known API root', () => {
expect(getDefaultEndpointPath({
apiFormat: 'openai:chat',
providerType: 'custom',
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
apiFormats,
})).toBe('/chat/completions')
expect(getDefaultEndpointPath({
apiFormat: 'openai:responses',
providerType: 'custom',
baseUrl: 'https://api.openai.example/v1',
apiFormats,
})).toBe('/responses')
})
it('drops /v1 from Claude Messages defaults for v1 and unversioned api roots', () => {
expect(getDefaultEndpointPath({
apiFormat: 'claude:messages',
providerType: 'custom',
baseUrl: 'https://api.anthropic.example/v1',
apiFormats,
})).toBe('/messages')
expect(getDefaultEndpointPath({
apiFormat: 'claude:messages',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/api',
apiFormats,
})).toBe('/messages')
expect(getDefaultEndpointPath({
apiFormat: 'claude:messages',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/anthropic',
apiFormats,
})).toBe('/messages')
})
})
@@ -15,17 +15,63 @@ function isCodexUrl(baseUrl: string): boolean {
return url.includes('/backend-api/codex') || url.endsWith('/codex')
}
function baseUrlEndsWithApiRoot(baseUrl?: string | null): boolean {
function parseBaseUrlParts(baseUrl?: string | null): { host: string; path: string } | null {
const raw = (baseUrl || '').trim()
if (!raw) return false
if (!raw) return null
try {
const parsed = new URL(raw)
return parsed.pathname.replace(/\/+$/, '').toLowerCase().endsWith('/api')
return {
host: parsed.hostname.toLowerCase(),
path: parsed.pathname.replace(/\/+$/, '').toLowerCase(),
}
} catch {
return raw.split('?')[0].replace(/\/+$/, '').toLowerCase().endsWith('/api')
const pathStart = raw.indexOf('/')
return {
host: '',
path: pathStart >= 0 ? raw.slice(pathStart).split('?')[0].replace(/\/+$/, '').toLowerCase() : '',
}
}
}
function baseUrlHasPathApiRoot(baseUrl?: string | null): boolean {
const path = parseBaseUrlParts(baseUrl)?.path
return !!path && path !== '/'
}
function baseUrlEndsWithV1Root(baseUrl?: string | null): boolean {
return parseBaseUrlParts(baseUrl)?.path.endsWith('/v1') ?? false
}
function isBigModelCodingApiRoot(baseUrl?: string | null): boolean {
const parts = parseBaseUrlParts(baseUrl)
return parts?.host === 'open.bigmodel.cn' && parts.path === '/api/coding/paas/v4'
}
function isGoogleOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
const parts = parseBaseUrlParts(baseUrl)
return parts?.host === 'generativelanguage.googleapis.com'
&& (parts.path === '/v1beta/openai' || parts.path === '/v1/openai')
}
function isVertexOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
const parts = parseBaseUrlParts(baseUrl)
return !!parts
&& (parts.host === 'aiplatform.googleapis.com' || parts.host.endsWith('.aiplatform.googleapis.com') || parts.host.endsWith('-aiplatform.googleapis.com'))
&& parts.path.endsWith('/endpoints/openapi')
}
function openAiCompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
return baseUrlEndsWithV1Root(baseUrl)
|| baseUrlHasPathApiRoot(baseUrl)
|| isBigModelCodingApiRoot(baseUrl)
|| isGoogleOpenAiCompatApiRoot(baseUrl)
|| isVertexOpenAiCompatApiRoot(baseUrl)
}
function v1CompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
return baseUrlEndsWithV1Root(baseUrl) || baseUrlHasPathApiRoot(baseUrl)
}
function stripV1PrefixForApiRoot(path: string): string {
return path.replace(/^\/v1(?=\/)/i, '')
}
@@ -34,6 +80,10 @@ function isOpenAiCompatibleFormat(apiFormat: string): boolean {
return apiFormat.startsWith('openai:') || apiFormat.startsWith('jina:')
}
function isClaudeCompatibleFormat(apiFormat: string): boolean {
return apiFormat === 'claude:messages'
}
export function getDefaultEndpointPath(params: {
apiFormat: string
providerType?: string | null
@@ -62,7 +112,10 @@ export function getDefaultEndpointPath(params: {
if (normalizedApiFormat === 'openai:responses' && isCodex) {
return '/responses'
}
if (baseUrlEndsWithApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
if (openAiCompatibleBaseIncludesApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
return stripV1PrefixForApiRoot(defaultPath)
}
if (v1CompatibleBaseIncludesApiRoot(params.baseUrl) && isClaudeCompatibleFormat(normalizedApiFormat)) {
return stripV1PrefixForApiRoot(defaultPath)
}
return defaultPath