mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(server,web): implement validateApiKey and fetchRemoteModels for web mode
- Create remote-api.ts with platform-agnostic validateApiKey (completeSimple test call) and fetchRemoteModels (OpenAI/Gemini model listing endpoints) - Add POST /_web/ai/llm/validate-key and POST /_web/ai/llm/remote-models HTTP endpoints - Update web shim to call new endpoints instead of returning stubs
This commit is contained in:
1 parent
d87bdd5712
commit
9dfb2dfbf4
3 files changed
+211
-2
No files matched your search
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 远程 LLM API 操作(平台无关)
|
||||
*
|
||||
* - validateApiKey: 通过 completeSimple 发一条测试请求验证 key 有效性
|
||||
* - fetchRemoteModels: 调用 provider API 获取可用模型列表
|
||||
*/
|
||||
|
||||
import { completeSimple } from '@mariozechner/pi-ai'
|
||||
import { BUILTIN_PROVIDERS } from '@openchatlab/core'
|
||||
import { buildPiModel, type AIServiceConfig } from './llm-config'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface RemoteModel {
|
||||
id: string
|
||||
name: string
|
||||
ownedBy?: string
|
||||
contextWindow?: number
|
||||
}
|
||||
|
||||
export interface FetchRemoteModelsResult {
|
||||
success: boolean
|
||||
models?: RemoteModel[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ==================== fetchRemoteModels ====================
|
||||
|
||||
/**
|
||||
* 根据 API 格式调用 provider 的模型列表端点:
|
||||
* - openai-completions / openai-responses → {baseUrl}/models
|
||||
* - google-generative-ai → {baseUrl}/v1beta/models?key={apiKey}
|
||||
* - anthropic-messages → 不支持
|
||||
*/
|
||||
export async function fetchRemoteModels(
|
||||
provider: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
apiFormat?: string
|
||||
): Promise<FetchRemoteModelsResult> {
|
||||
const effectiveApiFormat = apiFormat || 'openai-completions'
|
||||
|
||||
if (effectiveApiFormat === 'anthropic-messages') {
|
||||
return { success: false, error: 'Anthropic does not support model listing via API' }
|
||||
}
|
||||
|
||||
const providerDef = BUILTIN_PROVIDERS.find((p) => p.id === provider)
|
||||
const rawBaseUrl = baseUrl || providerDef?.defaultBaseUrl || ''
|
||||
if (!rawBaseUrl) {
|
||||
return { success: false, error: 'No base URL provided' }
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
const timeout = setTimeout(() => abortController.abort(), 15000)
|
||||
|
||||
try {
|
||||
let url: string
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (effectiveApiFormat === 'google-generative-ai') {
|
||||
const trimmed = rawBaseUrl.replace(/\/+$/, '').replace(/\/v1(beta)?$/, '')
|
||||
url = `${trimmed}/v1beta/models?key=${apiKey}`
|
||||
} else {
|
||||
let resolved = rawBaseUrl.replace(/\/+$/, '')
|
||||
try {
|
||||
const parsed = new URL(resolved)
|
||||
if (!resolved.endsWith('/v1') && (parsed.pathname === '/' || parsed.pathname === '')) {
|
||||
resolved = resolved + '/v1'
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
url = `${resolved}/models`
|
||||
headers['Authorization'] = `Bearer ${apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
return { success: false, error: `HTTP ${response.status}: ${body.slice(0, 200)}` }
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
|
||||
let models: RemoteModel[]
|
||||
|
||||
if (effectiveApiFormat === 'google-generative-ai') {
|
||||
const geminiModels = (json.models || []) as Array<{
|
||||
name?: string
|
||||
displayName?: string
|
||||
inputTokenLimit?: number
|
||||
}>
|
||||
models = geminiModels.map((m) => {
|
||||
const id = (m.name || '').replace(/^models\//, '')
|
||||
return {
|
||||
id,
|
||||
name: m.displayName || id,
|
||||
ownedBy: 'google',
|
||||
contextWindow: m.inputTokenLimit || undefined,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const data = (json.data || []) as Array<{
|
||||
id?: string
|
||||
owned_by?: string
|
||||
context_length?: number
|
||||
}>
|
||||
models = data
|
||||
.filter((m) => m.id)
|
||||
.map((m) => ({
|
||||
id: m.id!,
|
||||
name: m.id!,
|
||||
ownedBy: m.owned_by,
|
||||
contextWindow: m.context_length || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
return { success: true, models }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.includes('aborted') || message.includes('AbortError')) {
|
||||
return { success: false, error: 'Request timed out (15s)' }
|
||||
}
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== validateApiKey ====================
|
||||
|
||||
export async function validateApiKey(
|
||||
provider: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
model?: string,
|
||||
apiFormat?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const providerDef = BUILTIN_PROVIDERS.find((p) => p.id === provider)
|
||||
const defaultModel = providerDef?.modelIds?.[0]
|
||||
|
||||
const config: AIServiceConfig = {
|
||||
id: 'validate-temp',
|
||||
name: 'validate-temp',
|
||||
provider,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model: model || defaultModel,
|
||||
apiFormat,
|
||||
}
|
||||
const piModel = buildPiModel(config)
|
||||
|
||||
const abortController = new AbortController()
|
||||
const timeout = setTimeout(() => abortController.abort(), 15000)
|
||||
|
||||
try {
|
||||
const result = await completeSimple(
|
||||
piModel,
|
||||
{
|
||||
messages: [{ role: 'user', content: 'Hi', timestamp: Date.now() }] as any,
|
||||
},
|
||||
{
|
||||
apiKey,
|
||||
maxTokens: 1,
|
||||
signal: abortController.signal,
|
||||
}
|
||||
)
|
||||
if (result.stopReason === 'error' || result.stopReason === 'aborted') {
|
||||
return { success: false, error: result.errorMessage || 'Connection failed' }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.includes('aborted') || message.includes('AbortError')) {
|
||||
return { success: false, error: 'Request timed out (15s)' }
|
||||
}
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
updateCustomModel,
|
||||
deleteCustomModel,
|
||||
} from '../../ai/custom-store'
|
||||
import { validateApiKey, fetchRemoteModels } from '../../ai/remote-api'
|
||||
|
||||
function getAiDir(dbManager: DatabaseManager): string {
|
||||
const pathProvider = (dbManager as any)['pathProvider']
|
||||
@@ -375,6 +376,22 @@ export function registerAiRoutes(
|
||||
return deleteCustomModel(aiDataDir, request.params.providerId, request.params.modelId)
|
||||
})
|
||||
|
||||
// ==================== Remote API (Validate Key & Fetch Models) ====================
|
||||
|
||||
server.post<{
|
||||
Body: { provider: string; apiKey: string; baseUrl?: string; model?: string; apiFormat?: string }
|
||||
}>('/_web/ai/llm/validate-key', async (request) => {
|
||||
const { provider, apiKey, baseUrl, model, apiFormat } = request.body
|
||||
return validateApiKey(provider, apiKey, baseUrl, model, apiFormat)
|
||||
})
|
||||
|
||||
server.post<{
|
||||
Body: { provider: string; apiKey: string; baseUrl?: string; apiFormat?: string }
|
||||
}>('/_web/ai/llm/remote-models', async (request) => {
|
||||
const { provider, apiKey, baseUrl, apiFormat } = request.body
|
||||
return fetchRemoteModels(provider, apiKey, baseUrl, apiFormat)
|
||||
})
|
||||
|
||||
// ==================== Tool Catalog ====================
|
||||
|
||||
server.get('/_web/ai/tools/catalog', async () => {
|
||||
|
||||
@@ -213,8 +213,14 @@ async function installAiApiShims(): Promise<void> {
|
||||
_cachedLlmData = null
|
||||
return httpPut(`/ai/llm/configs/${id}`, updates)
|
||||
},
|
||||
validateApiKey: () => Promise.resolve(WEB_STUB),
|
||||
fetchRemoteModels: () => Promise.resolve({ success: false, error: 'Not available in web mode', models: [] }),
|
||||
validateApiKey: async (provider: string, apiKey: string, baseUrl?: string, model?: string) => {
|
||||
const { post: httpPost } = await import('./utils/http')
|
||||
return httpPost('/ai/llm/validate-key', { provider, apiKey, baseUrl, model })
|
||||
},
|
||||
fetchRemoteModels: async (provider: string, apiKey: string, baseUrl?: string, apiFormat?: string) => {
|
||||
const { post: httpPost } = await import('./utils/http')
|
||||
return httpPost('/ai/llm/remote-models', { provider, apiKey, baseUrl, apiFormat })
|
||||
},
|
||||
chatStream: () => Promise.resolve({ success: false, error: 'Not available in web mode' }),
|
||||
addCustomModel: async (input: Record<string, unknown>) => {
|
||||
const { post: httpPost } = await import('./utils/http')
|
||||
|
||||
Reference in new issue
Block a user