fix(provider): support unversioned API roots in model fetch

This commit is contained in:
zhefox
2026-05-26 15:39:56 +08:00
parent c4927162b7
commit d28a389a93
11 changed files with 525 additions and 41 deletions
@@ -153,7 +153,7 @@
<Label class="text-xs text-muted-foreground">自定义路径</Label>
<Input
:model-value="getDisplayedPath(endpoint)"
:placeholder="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'"
:placeholder="getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url) || '留空使用默认'"
:disabled="isFixedProvider"
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
/>
@@ -212,7 +212,7 @@ const emit = defineEmits<{
saved: []
}>()
const { success, error: showError } = useToast()
const { success, error: showError, warning: showWarning } = useToast()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
const isOpen = computed(() => props.open)
@@ -297,7 +297,7 @@ async function fetchUpstreamModels() {
hasQueried.value = true
// 如果有部分失败,显示警告提示
if (result.error) {
showError(`部分格式获取失败: ${result.error}`, '警告')
showWarning(result.error, '部分格式获取失败')
}
} else if (result.error) {
errorMessage.value = result.error
@@ -395,7 +395,7 @@ const emit = defineEmits<{
saved: []
}>()
const { success, error: showError } = useToast()
const { success, error: showError, warning: showWarning } = useToast()
const { confirmWarning } = useConfirm()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
@@ -691,6 +691,9 @@ async function fetchUpstreamModels(forceRefresh = false) {
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
const upstreamIds = new Set(result.models.map((m: UpstreamModel) => m.id))
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
if (result.error) {
showWarning(result.error, '部分格式获取失败')
}
} else if (result.error) {
showError(result.error, '获取上游模型失败')
}
@@ -322,7 +322,7 @@ const emit = defineEmits<{
'saved': []
}>()
const { error: showError, success: showSuccess } = useToast()
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
type EndpointOption = {
@@ -565,7 +565,11 @@ async function fetchUpstreamModels() {
allCustomNames.value = Array.from(mergedCustom).filter(name => !upstreamIds.has(name))
}
if (result.error) {
showError(result.error, '获取上游模型失败')
if (result.models.length > 0) {
showWarning(result.error, '部分格式获取失败')
} else {
showError(result.error, '获取上游模型失败')
}
}
} catch (err: unknown) {
showError(parseApiError(err, '获取上游模型列表失败'), '错误')
@@ -3,9 +3,11 @@ import { describe, expect, it } from 'vitest'
import { 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: 'openai:responses', default_path: '/v1/responses' },
{ value: 'openai:embedding', default_path: '/v1/embeddings' },
]
describe('endpoint default paths', () => {
@@ -44,4 +46,20 @@ describe('endpoint default paths', () => {
apiFormats,
})).toBe('/responses')
})
it('drops /v1 from OpenAI-compatible defaults when base URL ends with /api', () => {
expect(getDefaultEndpointPath({
apiFormat: 'openai:chat',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/api',
apiFormats,
})).toBe('/chat/completions')
expect(getDefaultEndpointPath({
apiFormat: 'openai:embedding',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/api?tenant=demo',
apiFormats,
})).toBe('/embeddings')
})
})
@@ -15,6 +15,25 @@ function isCodexUrl(baseUrl: string): boolean {
return url.includes('/backend-api/codex') || url.endsWith('/codex')
}
function baseUrlEndsWithApiRoot(baseUrl?: string | null): boolean {
const raw = (baseUrl || '').trim()
if (!raw) return false
try {
const parsed = new URL(raw)
return parsed.pathname.replace(/\/+$/, '').toLowerCase().endsWith('/api')
} catch {
return raw.split('?')[0].replace(/\/+$/, '').toLowerCase().endsWith('/api')
}
}
function stripV1PrefixForApiRoot(path: string): string {
return path.replace(/^\/v1(?=\/)/i, '')
}
function isOpenAiCompatibleFormat(apiFormat: string): boolean {
return apiFormat.startsWith('openai:') || apiFormat.startsWith('jina:')
}
export function getDefaultEndpointPath(params: {
apiFormat: string
providerType?: string | null
@@ -43,5 +62,8 @@ export function getDefaultEndpointPath(params: {
if (normalizedApiFormat === 'openai:responses' && isCodex) {
return '/responses'
}
if (baseUrlEndsWithApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
return stripV1PrefixForApiRoot(defaultPath)
}
return defaultPath
}