优化 API 配置:一个弹窗完成接口、Key 与模型选择
- 去掉预设供应商,直接选择接口类型并填写 Base URL、API Key - 可在保存前从接口拉取模型,搜索并勾选多个;也可手动输入模型名 - 适配中转站:漏写 /v1(Gemini 为 /v1beta)时自动补全,同时发送 Bearer 与官方鉴权头 - 保存时服务端同步连接下的模型列表;编辑时不填 Key 也可拉取 - 单模型测试与参数设置;手机端布局优化 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,21 +2,32 @@ import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
name: z.string().optional(),
|
||||
type: z.enum(['openai-compatible', 'anthropic', 'google']).optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
// 不传或传空字符串表示保留原有密钥
|
||||
apiKey: z.string().optional()
|
||||
apiKey: z.string().optional(),
|
||||
// 传入时把该连接下的模型同步为这个列表
|
||||
models: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = param(event)
|
||||
const body = await readJson(event, schema)
|
||||
const patch: Partial<typeof tables.providers.$inferInsert> = {}
|
||||
if (body.name !== undefined) patch.name = body.name
|
||||
if (body.type !== undefined) patch.type = body.type
|
||||
if (body.baseUrl !== undefined) patch.baseUrl = body.baseUrl.trim()
|
||||
const db = useDb()
|
||||
const current = db.select().from(tables.providers).where(eq(tables.providers.id, id)).get()
|
||||
if (!current) throw createError({ statusCode: 404, message: 'API 连接不存在' })
|
||||
|
||||
const type = body.type ?? current.type
|
||||
const baseUrl = body.baseUrl !== undefined ? body.baseUrl.trim() : current.baseUrl
|
||||
const patch: Partial<typeof tables.providers.$inferInsert> = { type, baseUrl }
|
||||
if (body.name !== undefined) patch.name = body.name.trim() || defaultProviderName(type, baseUrl)
|
||||
if (body.apiKey) patch.apiKeyEnc = encryptSecret(body.apiKey.trim())
|
||||
useDb().update(tables.providers).set(patch).where(eq(tables.providers.id, id)).run()
|
||||
return { ok: true }
|
||||
|
||||
let result = { added: 0, removed: 0 }
|
||||
db.transaction(() => {
|
||||
db.update(tables.providers).set(patch).where(eq(tables.providers.id, id)).run()
|
||||
if (body.models) result = syncProviderModels(id, body.models)
|
||||
})
|
||||
return { ok: true, ...result }
|
||||
})
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/** 拉取提供方的可用模型列表 */
|
||||
export default defineEventHandler(async (event) => {
|
||||
const p = useDb().select().from(tables.providers).where(eq(tables.providers.id, param(event))).get()
|
||||
if (!p) throw createError({ statusCode: 404, message: '提供方不存在' })
|
||||
try {
|
||||
return await listRemoteModels(p.type, p.baseUrl, decryptSecret(p.apiKeyEnc))
|
||||
}
|
||||
catch (err) {
|
||||
throw createError({ statusCode: 502, message: `获取模型列表失败:${describeError(err)}` })
|
||||
}
|
||||
})
|
||||
@@ -1,20 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1),
|
||||
name: z.string().default(''),
|
||||
type: z.enum(['openai-compatible', 'anthropic', 'google']),
|
||||
baseUrl: z.string().default(''),
|
||||
apiKey: z.string().default('')
|
||||
apiKey: z.string().default(''),
|
||||
models: z.array(z.string()).default([])
|
||||
})
|
||||
|
||||
/** 新建 API 连接,并一次性添加所选模型 */
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readJson(event, schema)
|
||||
const row = useDb().insert(tables.providers).values({
|
||||
id: newId(),
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
baseUrl: body.baseUrl.trim(),
|
||||
apiKeyEnc: encryptSecret(body.apiKey.trim())
|
||||
}).returning().get()
|
||||
return { id: row.id }
|
||||
const id = newId()
|
||||
useDb().transaction(() => {
|
||||
useDb().insert(tables.providers).values({
|
||||
id,
|
||||
name: body.name.trim() || defaultProviderName(body.type, body.baseUrl),
|
||||
type: body.type,
|
||||
baseUrl: body.baseUrl.trim(),
|
||||
apiKeyEnc: encryptSecret(body.apiKey.trim())
|
||||
}).run()
|
||||
syncProviderModels(id, body.models)
|
||||
})
|
||||
return { id }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
|
||||
const schema = z.object({
|
||||
type: z.enum(['openai-compatible', 'anthropic', 'google']),
|
||||
baseUrl: z.string().default(''),
|
||||
apiKey: z.string().default(''),
|
||||
/** 编辑已有连接且未重新填写 Key 时,使用已保存的 Key */
|
||||
providerId: z.string().optional()
|
||||
})
|
||||
|
||||
/** 用(可能尚未保存的)连接配置拉取模型列表 */
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readJson(event, schema)
|
||||
let apiKey = body.apiKey.trim()
|
||||
if (!apiKey && body.providerId) {
|
||||
const p = useDb().select().from(tables.providers).where(eq(tables.providers.id, body.providerId)).get()
|
||||
if (p) apiKey = decryptSecret(p.apiKeyEnc)
|
||||
}
|
||||
if (!apiKey) throw createError({ statusCode: 400, message: '请先填写 API Key' })
|
||||
try {
|
||||
return await listRemoteModels(body.type, body.baseUrl, apiKey)
|
||||
}
|
||||
catch (err) {
|
||||
throw createError({ statusCode: 502, message: `拉取模型列表失败:${describeError(err)}` })
|
||||
}
|
||||
})
|
||||
+50
-22
@@ -59,29 +59,57 @@ export function modelSettings(row: ModelRow) {
|
||||
return s
|
||||
}
|
||||
|
||||
/** 从提供方拉取可用模型列表 */
|
||||
export async function listRemoteModels(type: ProviderType, baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
const base = trimUrl(baseUrl) || DEFAULT_BASE_URL[type]
|
||||
if (type === 'anthropic') {
|
||||
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
|
||||
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
||||
query: { limit: 1000 }
|
||||
})
|
||||
return res.data.map(m => m.id)
|
||||
}
|
||||
if (type === 'google') {
|
||||
const res = await $fetch<{ models: { name: string, supportedGenerationMethods?: string[] }[] }>(`${base}/models`, {
|
||||
headers: { 'x-goog-api-key': apiKey },
|
||||
query: { pageSize: 1000 }
|
||||
})
|
||||
return res.models
|
||||
.filter(m => !m.supportedGenerationMethods || m.supportedGenerationMethods.includes('generateContent'))
|
||||
.map(m => m.name.replace(/^models\//, ''))
|
||||
}
|
||||
const res = await $fetch<{ data: { id: string }[] }>(`${base}/models`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
/** 各接口类型的版本路径:中转站地址经常漏填,拉取失败时自动补上再试 */
|
||||
const VERSION_SUFFIX: Record<ProviderType, string> = {
|
||||
'openai-compatible': '/v1',
|
||||
'anthropic': '/v1',
|
||||
'google': '/v1beta'
|
||||
}
|
||||
|
||||
type ModelListResponse = {
|
||||
data?: { id?: string, name?: string }[]
|
||||
models?: { name?: string, id?: string, supportedGenerationMethods?: string[] }[]
|
||||
}
|
||||
|
||||
async function fetchModelIds(type: ProviderType, base: string, apiKey: string): Promise<string[]> {
|
||||
// 同时带上官方鉴权头与 Bearer,兼容各种中转站
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` }
|
||||
if (type === 'anthropic') Object.assign(headers, { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' })
|
||||
if (type === 'google') headers['x-goog-api-key'] = apiKey
|
||||
const res = await $fetch<ModelListResponse>(`${base}/models`, {
|
||||
headers,
|
||||
query: type === 'google' ? { pageSize: 1000 } : type === 'anthropic' ? { limit: 1000 } : undefined,
|
||||
timeout: 20_000
|
||||
})
|
||||
return res.data.map(m => m.id).sort()
|
||||
const ids = [
|
||||
...(res.data ?? []).map(m => m.id || m.name || ''),
|
||||
...(res.models ?? [])
|
||||
.filter(m => !m.supportedGenerationMethods || m.supportedGenerationMethods.includes('generateContent'))
|
||||
.map(m => (m.name || m.id || '').replace(/^models\//, ''))
|
||||
].filter(Boolean)
|
||||
if (!Array.isArray(res.data) && !Array.isArray(res.models)) throw new Error('接口返回的不是模型列表,请检查 Base URL')
|
||||
return [...new Set(ids)].sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
/**
|
||||
* 从接口拉取可用模型列表。
|
||||
* 返回实际可用的 baseUrl:用户填的地址缺少 /v1 等版本路径时,会自动补全并返回补全后的地址。
|
||||
*/
|
||||
export async function listRemoteModels(type: ProviderType, baseUrl: string, apiKey: string): Promise<{ models: string[], baseUrl: string }> {
|
||||
const base = trimUrl(baseUrl) || DEFAULT_BASE_URL[type]
|
||||
try {
|
||||
return { models: await fetchModelIds(type, base, apiKey), baseUrl: trimUrl(baseUrl) }
|
||||
}
|
||||
catch (err) {
|
||||
const suffix = VERSION_SUFFIX[type]
|
||||
if (base.endsWith(suffix) || /\/v\d+(beta)?$/.test(base)) throw err
|
||||
try {
|
||||
return { models: await fetchModelIds(type, base + suffix, apiKey), baseUrl: base + suffix }
|
||||
}
|
||||
catch {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 AI SDK / HTTP 错误整理成用户能看懂的信息 */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type { ProviderType } from '#shared/types'
|
||||
|
||||
const TYPE_NAME: Record<ProviderType, string> = {
|
||||
'openai-compatible': 'OpenAI 兼容',
|
||||
'anthropic': 'Anthropic',
|
||||
'google': 'Gemini'
|
||||
}
|
||||
|
||||
/** 未填写名称时,用 Base URL 的域名作为连接名称 */
|
||||
export function defaultProviderName(type: ProviderType, baseUrl: string) {
|
||||
try {
|
||||
if (baseUrl.trim()) return new URL(baseUrl.trim()).host
|
||||
}
|
||||
catch {}
|
||||
return TYPE_NAME[type]
|
||||
}
|
||||
|
||||
export function normalizeModelIds(ids: string[]) {
|
||||
return [...new Set(ids.map(s => s.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
/**
|
||||
* 把连接下的模型同步为给定列表:新增缺少的,删除不在列表中的。
|
||||
* 被删除模型的 AI 成员会因外键 ON DELETE SET NULL 变为“未指定模型”。
|
||||
*/
|
||||
export function syncProviderModels(providerId: string, modelIds: string[]) {
|
||||
const db = useDb()
|
||||
const wanted = normalizeModelIds(modelIds)
|
||||
const existing = db.select().from(tables.models).where(eq(tables.models.providerId, providerId)).all()
|
||||
const toDelete = existing.filter(m => !wanted.includes(m.modelId)).map(m => m.id)
|
||||
const toAdd = wanted.filter(id => !existing.some(m => m.modelId === id))
|
||||
if (toDelete.length) {
|
||||
db.delete(tables.models).where(and(eq(tables.models.providerId, providerId), inArray(tables.models.id, toDelete))).run()
|
||||
}
|
||||
if (toAdd.length) {
|
||||
db.insert(tables.models).values(toAdd.map(modelId => ({ id: newId(), providerId, label: modelId, modelId }))).run()
|
||||
}
|
||||
return { added: toAdd.length, removed: toDelete.length }
|
||||
}
|
||||
Reference in New Issue
Block a user