feat: OpenAI 兼容 API 地址自动补全 /v1 并支持实时预览

This commit is contained in:
digua
2026-04-28 20:10:26 +08:00
committed by digua
parent 57455e3120
commit 97de1e9439
7 changed files with 118 additions and 4 deletions
+29 -1
View File
@@ -432,6 +432,28 @@ function normalizeAnthropicBaseUrl(url: string): string {
return url.replace(/\/v1\/?$/, '')
}
/**
* 规范化 OpenAI Compatible baseUrl
* 用户经常忘记在域名后加 /v1,OpenAI SDK 不会自动补全。
* 如果 URL 没有以 /v1 结尾且路径部分为空或仅有 /,自动补上。
* 已有具体路径(如 /api/v1、/proxy)的不做修改。
*/
function normalizeOpenAICompatibleBaseUrl(url: string): string {
if (!url) return url
const trimmed = url.replace(/\/+$/, '')
if (trimmed.endsWith('/v1')) return trimmed
try {
const parsed = new URL(trimmed)
// 仅当路径为空或 "/" 时补全 /v1,避免破坏已有的自定义路径
if (parsed.pathname === '/' || parsed.pathname === '') {
return trimmed + '/v1'
}
} catch {
// URL 解析失败,不做处理
}
return trimmed
}
export function buildPiModel(config: AIServiceConfig): PiModel<PiApi> {
const providerDef = getBuiltinProviderById(config.provider)
const providerInfo = getProviderInfo(config.provider)
@@ -477,12 +499,18 @@ export function buildPiModel(config: AIServiceConfig): PiModel<PiApi> {
}
}
// openai-compatible + openai-completions:自动补全 /v1(用户经常忘记)
const resolvedBaseUrl =
config.provider === 'openai-compatible' && apiFormat === 'openai-completions'
? normalizeOpenAICompatibleBaseUrl(baseUrl)
: baseUrl
return {
id: modelId,
name: modelId,
api: apiFormat,
provider: config.provider,
baseUrl,
baseUrl: resolvedBaseUrl,
headers: config.provider === 'openai-compatible' ? buildChatLabUserAgentHeaders() : undefined,
reasoning: config.isReasoningModel ?? false,
input: ['text'],
@@ -46,6 +46,7 @@ const {
canSave,
apiFormatItems,
modalTitle,
resolvedApiUrl,
selectProvider,
onConnectionModeChange,
openAddModelDialog,
@@ -176,6 +177,18 @@ function closeModal() {
<p class="mt-1 text-[10px] text-gray-400">
{{ t('settings.aiConfig.modal.apiEndpointOverrideHint') }}
</p>
<p
v-if="resolvedApiUrl"
class="mt-1.5 flex items-center gap-1 text-[11px] text-gray-400 dark:text-gray-500"
>
<UIcon name="i-heroicons-link" class="h-3 w-3 shrink-0" />
<span>{{ t('settings.aiConfig.modal.resolvedUrl') }}</span>
<code
class="ml-0.5 break-all rounded bg-gray-100 px-1 py-0.5 font-mono text-[10px] text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
{{ resolvedApiUrl }}
</code>
</p>
</div>
<!-- 模型选择 -->
@@ -226,11 +239,23 @@ function closeModal() {
{{ t('settings.aiConfig.modal.apiEndpoint') }}
</label>
<div class="flex gap-2">
<UInput v-model="formData.baseUrl" placeholder="http://localhost:11434/v1" class="flex-1" />
<UInput v-model="formData.baseUrl" placeholder="http://localhost:11434" class="flex-1" />
<UButton :loading="isValidating" :disabled="!formData.baseUrl" variant="soft" @click="validateKey">
{{ t('settings.aiConfig.modal.validate') }}
</UButton>
</div>
<p
v-if="resolvedApiUrl"
class="mt-1.5 flex items-center gap-1 text-[11px] text-gray-400 dark:text-gray-500"
>
<UIcon name="i-heroicons-link" class="h-3 w-3 shrink-0" />
<span>{{ t('settings.aiConfig.modal.resolvedUrl') }}</span>
<code
class="ml-0.5 break-all rounded bg-gray-100 px-1 py-0.5 font-mono text-[10px] text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
{{ resolvedApiUrl }}
</code>
</p>
</div>
<!-- 模型选择 -->
@@ -312,8 +337,20 @@ function closeModal() {
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('settings.aiConfig.modal.apiEndpoint') }}
</label>
<UInput v-model="formData.baseUrl" class="w-full" placeholder="https://api.example.com/v1" />
<UInput v-model="formData.baseUrl" class="w-full" placeholder="https://api.example.com" />
<p class="mt-1 text-xs text-gray-500">{{ t('settings.aiConfig.modal.apiEndpointHint') }}</p>
<p
v-if="resolvedApiUrl"
class="mt-1.5 flex items-center gap-1 text-[11px] text-gray-400 dark:text-gray-500"
>
<UIcon name="i-heroicons-link" class="h-3 w-3 shrink-0" />
<span>{{ t('settings.aiConfig.modal.resolvedUrl') }}</span>
<code
class="ml-0.5 break-all rounded bg-gray-100 px-1 py-0.5 font-mono text-[10px] text-gray-500 dark:bg-gray-800 dark:text-gray-400"
>
{{ resolvedApiUrl }}
</code>
</p>
</div>
<!-- 模型选择 -->
@@ -179,6 +179,50 @@ export function useAIConfigForm(props: {
props.mode.value === 'add' ? t('settings.aiConfig.modal.addConfig') : t('settings.aiConfig.modal.editConfig')
)
const BUILTIN_PROVIDER_API: Record<string, string> = {
gemini: 'google-generative-ai',
anthropic: 'anthropic-messages',
}
const API_PATH_SUFFIX: Record<string, string> = {
'openai-completions': '/chat/completions',
'openai-responses': '/responses',
'anthropic-messages': '/v1/messages',
}
const resolvedApiUrl = computed(() => {
const rawInput = formData.value.baseUrl?.trim()
const raw = rawInput || (isPresetMode.value ? currentProviderDef.value?.defaultBaseUrl : '')
if (!raw) return ''
const effectiveApiFormat = isPresetMode.value
? BUILTIN_PROVIDER_API[formData.value.provider] || API_FORMAT_DEFAULT
: formData.value.apiFormat || API_FORMAT_DEFAULT
const trimmed = raw.replace(/\/+$/, '')
let baseUrl: string
if (effectiveApiFormat === 'anthropic-messages') {
baseUrl = trimmed.replace(/\/v1\/?$/, '')
} else if (effectiveApiFormat === 'openai-completions' || effectiveApiFormat === 'openai-responses') {
try {
const parsed = new URL(trimmed)
if (!trimmed.endsWith('/v1') && (parsed.pathname === '/' || parsed.pathname === '')) {
baseUrl = trimmed + '/v1'
} else {
baseUrl = trimmed
}
} catch {
baseUrl = trimmed
}
} else {
baseUrl = trimmed
}
const suffix = API_PATH_SUFFIX[effectiveApiFormat]
return suffix ? baseUrl + suffix : baseUrl
})
// ============ 表单操作 ============
function resetForm() {
@@ -253,7 +297,7 @@ export function useAIConfigForm(props: {
if (mode === 'local') {
formData.value.provider = 'openai-compatible'
formData.value.baseUrl = 'http://localhost:11434/v1'
formData.value.baseUrl = 'http://localhost:11434'
formData.value.model = compatModels.value[0]?.id || ''
formData.value.apiKey = ''
} else if (mode === 'openai-compat') {
@@ -575,6 +619,7 @@ export function useAIConfigForm(props: {
canSave,
apiFormatItems,
modalTitle,
resolvedApiUrl,
// 方法
selectProvider,
+1
View File
@@ -127,6 +127,7 @@
"modelNameHintLocal": "Enter the locally deployed model name",
"apiEndpoint": "API URL",
"apiEndpointHint": "OpenAI-compatible API endpoint",
"resolvedUrl": "Resolved URL:",
"apiEndpointOverrideHint": "Default URL is prefilled. Replace if needed",
"disableThinking": "Disable Thinking Mode",
"disableThinkingDesc": "For models like Qwen3, DeepSeek-R1. Uses standard tool calling format when disabled.",
+1
View File
@@ -127,6 +127,7 @@
"modelNameHintLocal": "ローカルにデプロイしたモデル名を入力",
"apiEndpoint": "API URL",
"apiEndpointHint": "OpenAI 互換形式の API エンドポイント",
"resolvedUrl": "実際のリクエストURL:",
"apiEndpointOverrideHint": "デフォルトURLは入力済みです。必要に応じて変更してください",
"disableThinking": "思考モードをオフ",
"disableThinkingDesc": "Qwen3 や DeepSeek-R1 などのモデル向け。オフにすると標準のツール実行形式を使います",
+1
View File
@@ -127,6 +127,7 @@
"modelNameHintLocal": "输入本地部署的模型名称",
"apiEndpoint": "API 地址",
"apiEndpointHint": "兼容 OpenAI 格式的 API 端点",
"resolvedUrl": "实际请求地址:",
"apiEndpointOverrideHint": "默认地址已填入,可按需改为完整 URL",
"disableThinking": "禁用思考模式",
"disableThinkingDesc": "针对 Qwen3、DeepSeek-R1 等模型,禁用后使用标准工具调用格式",
+1
View File
@@ -127,6 +127,7 @@
"modelNameHintLocal": "輸入本機部署的模型名稱",
"apiEndpoint": "API 位址",
"apiEndpointHint": "相容 OpenAI 格式的 API 端點",
"resolvedUrl": "實際請求地址:",
"apiEndpointOverrideHint": "預設地址已填入,可按需改為完整 URL",
"disableThinking": "關閉思考模式",
"disableThinkingDesc": "針對 Qwen3、DeepSeek-R1 等模型,停用後使用標準工具呼叫格式",