perf(provider): optimize validation and checks if missing API key (#719)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user