perf(provider): optimize validation and checks if missing API key (#719)

This commit is contained in:
Iro
2025-11-05 00:27:12 +08:00
committed by GitHub
parent 9dff810373
commit 5d2b7cd2a6
2 changed files with 133 additions and 78 deletions
+4 -2
View File
@@ -765,6 +765,7 @@ export const useProvidersStore = defineStore('providers', () => {
validators: {
validateProviderConfig: (config) => {
const errors = [
!config.apiKey && new Error('API Key is required'),
!config.baseUrl && new Error('Base URL is required. Default to https://api.openai.com/v1/ for official OpenAI API.'),
].filter(Boolean)
@@ -776,7 +777,7 @@ export const useProvidersStore = defineStore('providers', () => {
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
valid: !!config.baseUrl,
valid: !!config.apiKey && !!config.baseUrl,
}
},
},
@@ -812,6 +813,7 @@ export const useProvidersStore = defineStore('providers', () => {
validators: {
validateProviderConfig: (config) => {
const errors = [
!config.apiKey && new Error('API Key is required'),
!config.baseUrl && new Error('Base URL is required. Default to https://api.openai.com/v1/ for official OpenAI API.'),
].filter(Boolean)
@@ -823,7 +825,7 @@ export const useProvidersStore = defineStore('providers', () => {
return {
errors,
reason: errors.filter(e => e).map(e => String(e)).join(', ') || '',
valid: !!config.baseUrl,
valid: !!config.apiKey && !!config.baseUrl,
}
},
},
@@ -6,6 +6,33 @@ import { message } from '@xsai/utils-chat'
type ProviderCreator = (apiKey: string, baseUrl: string) => any
// Lightweight normalization utilities and conditional logging
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function normalizeBaseUrl(value: unknown): string {
let base = normalizeString(value)
if (base && !base.endsWith('/'))
base += '/'
return base
}
function shouldLog(): boolean {
try {
// Opt-in via localStorage to minimize I/O in production
return typeof localStorage !== 'undefined' && localStorage.getItem('airi:debug') === '1'
}
catch {
return false
}
}
function logWarn(...args: unknown[]) {
if (shouldLog())
console.warn(...args)
}
export function buildOpenAICompatibleProvider(
options: Partial<ProviderMetadata> & {
id: string
@@ -45,8 +72,13 @@ export function buildOpenAICompatibleProvider(
const finalCapabilities = capabilities || {
listModels: async (config: Record<string, unknown>) => {
// Safer casting of apiKey/baseUrl (prevents .trim() crash if not a string)
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
const apiKey = normalizeString(config.apiKey)
const baseUrl = normalizeBaseUrl(config.baseUrl)
// If not configured yet, avoid remote calls and return empty
if (!apiKey || !baseUrl) {
return []
}
const provider = await creator(apiKey, baseUrl)
// Check provider.model exists and is a function
@@ -77,8 +109,12 @@ export function buildOpenAICompatibleProvider(
const finalValidators = validators || {
validateProviderConfig: async (config: Record<string, unknown>) => {
const errors: Error[] = []
let baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
let baseUrl = normalizeString(config.baseUrl)
const apiKey = normalizeString(config.apiKey)
if (!apiKey) {
errors.push(new Error('API Key is required'))
}
if (!baseUrl) {
errors.push(new Error('Base URL is required'))
@@ -94,9 +130,7 @@ export function buildOpenAICompatibleProvider(
}
// normalize trailing slash instead of rejecting
if (baseUrl && !baseUrl.endsWith('/')) {
baseUrl += '/'
}
baseUrl = normalizeBaseUrl(baseUrl)
if (errors.length > 0) {
return {
@@ -107,82 +141,104 @@ export function buildOpenAICompatibleProvider(
}
const validationChecks = validation || []
// Auto-detect first available model for validation
let model = 'test' // fallback to `test` if fails
try {
const models = await listModels({
apiKey,
baseURL: baseUrl,
headers: additionalHeaders,
})
.then(models => models.filter(model =>
[
// exclude embedding models
'embed',
// exclude tts models, specifically for OpenAI
'tts',
// bypass gemini pro quota
// TODO: more elegant solution
'models/gemini-2.5-pro',
].every(str => !model.id.includes(str)),
))
if (models.length > 0)
model = models[0].id
}
catch (e) {
console.warn(`Model auto-detection failed: ${(e as Error).message}`)
}
// Health check = try generating text (was: fetch(`${baseUrl}chat/completions`))
if (validationChecks.includes('health')) {
try {
await generateText({
apiKey,
baseURL: baseUrl,
headers: additionalHeaders,
model,
messages: message.messages(message.user('ping')),
max_tokens: 1,
})
}
catch (e) {
errors.push(new Error(`Health check failed: ${(e as Error).message}`))
}
}
// Model list validation (was: fetch(`${baseUrl}models`))
if (validationChecks.includes('model_list')) {
const hasApiKey = Boolean(apiKey)
// Prepare model auto-detection promise for checks that need it
const modelPromise = (async () => {
let detected = 'test'
if (!hasApiKey)
return detected
try {
const models = await listModels({
apiKey,
baseURL: baseUrl,
headers: additionalHeaders,
})
if (!models || models.length === 0) {
errors.push(new Error('Model list check failed: no models found'))
}
.then(models => models.filter(model =>
[
'embed',
'tts',
'models/gemini-2.5-pro',
].every(str => !model.id.includes(str)),
))
if (models.length > 0)
detected = models[0].id
}
catch (e) {
errors.push(new Error(`Model list check failed: ${(e as Error).message}`))
logWarn(`Model auto-detection failed: ${(e as Error).message}`)
}
return detected
})()
// Health check = try generating text (was: fetch(`${baseUrl}chat/completions`))
const asyncChecks: Promise<Error | null>[] = []
if (validationChecks.includes('health') && hasApiKey) {
asyncChecks.push((async () => {
try {
const model = await modelPromise
await generateText({
apiKey,
baseURL: baseUrl,
headers: additionalHeaders,
model,
messages: message.messages(message.user('ping')),
max_tokens: 1,
})
return null
}
catch (e) {
return new Error(`Health check failed: ${(e as Error).message}`)
}
})())
}
// Model list validation (was: fetch(`${baseUrl}models`))
if (validationChecks.includes('model_list') && hasApiKey) {
asyncChecks.push((async () => {
try {
const models = await listModels({
apiKey,
baseURL: baseUrl,
headers: additionalHeaders,
})
if (!models || models.length === 0) {
return new Error('Model list check failed: no models found')
}
return null
}
catch (e) {
return new Error(`Model list check failed: ${(e as Error).message}`)
}
})())
}
// Chat completions validation = generateText again (was: fetch(`${baseUrl}chat/completions`))
if (validationChecks.includes('chat_completions')) {
try {
await generateText({
apiKey,
baseURL: baseUrl,
headers: additionalHeaders,
model,
messages: message.messages(message.user('ping')),
max_tokens: 1,
})
}
catch (e) {
errors.push(new Error(`Chat completions check failed: ${(e as Error).message}`))
if (validationChecks.includes('chat_completions') && hasApiKey) {
asyncChecks.push((async () => {
try {
const model = await modelPromise
await generateText({
apiKey,
baseURL: baseUrl,
headers: additionalHeaders,
model,
messages: message.messages(message.user('ping')),
max_tokens: 1,
})
return null
}
catch (e) {
return new Error(`Chat completions check failed: ${(e as Error).message}`)
}
})())
}
if (asyncChecks.length > 0) {
const results = await Promise.allSettled(asyncChecks)
for (const r of results) {
if (r.status === 'fulfilled' && r.value)
errors.push(r.value)
else if (r.status === 'rejected')
errors.push(new Error(String(r.reason)))
}
}
@@ -208,11 +264,8 @@ export function buildOpenAICompatibleProvider(
baseUrl: defaultBaseUrl || '',
}),
createProvider: async (config: { apiKey: string, baseUrl: string }) => {
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
let baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
if (baseUrl && !baseUrl.endsWith('/')) {
baseUrl += '/'
}
const apiKey = normalizeString(config.apiKey)
const baseUrl = normalizeBaseUrl(config.baseUrl)
return creator(apiKey, baseUrl)
},
capabilities: finalCapabilities,