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:
digua committed 2026-05-14 22:09:12 +08:00
1 parent d87bdd5712
commit 9dfb2dfbf4
3 files changed
+211 -2

No files matched your search

+186
View File
@@ -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)
}
}
+17
View File
@@ -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 () => {
+8 -2
View File
@@ -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')