diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 1a0c181bb..a069637b9 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -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, } }, }, diff --git a/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts b/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts index 0d8ef4c91..229eefdfb 100644 --- a/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts +++ b/packages/stage-ui/src/stores/providers/openai-compatible-builder.ts @@ -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 & { id: string @@ -45,8 +72,13 @@ export function buildOpenAICompatibleProvider( const finalCapabilities = capabilities || { listModels: async (config: Record) => { // 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) => { 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[] = [] + 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,