fix: harden buildOpenAICompatibleProvider validation and config (#625)

This commit is contained in:
Iro
2025-10-07 15:07:55 +08:00
committed by GitHub
parent ecbc00972a
commit b34738b1ac
4 changed files with 414 additions and 280 deletions
+2
View File
@@ -90,12 +90,14 @@
"@xsai-transformers/shared": "^0.0.7",
"@xsai/embed": "catalog:",
"@xsai/generate-speech": "catalog:",
"@xsai/generate-text": "catalog:",
"@xsai/generate-transcription": "catalog:",
"@xsai/model": "catalog:",
"@xsai/shared": "catalog:",
"@xsai/shared-chat": "catalog:",
"@xsai/stream-text": "catalog:",
"@xsai/tool": "catalog:",
"@xsai/utils-chat": "catalog:",
"animejs": "^4.2.1",
"culori": "^4.0.2",
"date-fns": "^4.1.0",
@@ -1,8 +1,8 @@
import type { ModelInfo, ProviderMetadata } from '../providers'
import { generateText } from '@xsai/generate-text'
import { listModels } from '@xsai/model'
import { isUrl } from '../../utils/url'
import { message } from '@xsai/utils-chat'
type ProviderCreator = (apiKey: string, baseUrl: string) => any
@@ -24,22 +24,47 @@ export function buildOpenAICompatibleProvider(
additionalHeaders?: Record<string, string>
},
): ProviderMetadata {
const { id, name, icon, description, nameKey, descriptionKey, category, tasks, defaultBaseUrl, creator, capabilities, validators, validation, additionalHeaders, ...rest } = options
const {
id,
name,
icon,
description,
nameKey,
descriptionKey,
category,
tasks,
defaultBaseUrl,
creator,
capabilities,
validators,
validation,
additionalHeaders,
...rest
} = options
const finalCapabilities = capabilities || {
listModels: async (config: Record<string, unknown>) => {
const provider = await creator(
(config.apiKey as string || '').trim(),
(config.baseUrl as string || '').trim(),
)
// 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() : ''
if (!provider.model) {
const provider = await creator(apiKey, baseUrl)
// Check provider.model exists and is a function
if (!provider || typeof provider.model !== 'function') {
return []
}
return (await listModels({
...provider.model(),
})).map((model: any) => {
// Previously: fetch(`${baseUrl}models`)
const models = await listModels({
apiKey,
baseURL: baseUrl,
headers: {
...additionalHeaders,
Authorization: `Bearer ${apiKey}`,
},
})
return models.map((model: any) => {
return {
id: model.id,
name: model.name || model.display_name || model.id,
@@ -55,70 +80,70 @@ 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() : ''
if (!config.baseUrl) {
if (!baseUrl) {
errors.push(new Error('Base URL is required'))
}
if (errors.length > 0) {
return { errors, reason: errors.map(e => e.message).join(', '), valid: false }
try {
if (new URL(baseUrl).host.length === 0) {
errors.push(new Error('Base URL is not absolute. Check your input.'))
}
}
catch {
errors.push(new Error('Base URL is invalid. It must be an absolute URL.'))
}
if (!isUrl(config.baseUrl as string) || new URL(config.baseUrl as string).host.length === 0) {
errors.push(new Error('Base URL is not absolute. Check your input.'))
}
if (!(config.baseUrl as string).endsWith('/')) {
errors.push(new Error('Base URL must end with a trailing slash (/).'))
// normalize trailing slash instead of rejecting
if (baseUrl && !baseUrl.endsWith('/')) {
baseUrl += '/'
}
if (errors.length > 0) {
return { errors, reason: errors.map(e => e.message).join(', '), valid: false }
return {
errors,
reason: errors.map(e => e.message).join(', '),
valid: false,
}
}
const validationChecks = validation || []
let responseModelList = null
let responseChat = null
// Health check = try generating text (was: fetch(`${baseUrl}chat/completions`))
if (validationChecks.includes('health')) {
try {
responseChat = await fetch(`${config.baseUrl as string}chat/completions`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders }, method: 'POST', body: '{"model": "test"}' })
responseModelList = await fetch(`${config.baseUrl as string}models`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders } })
// Also try transcription endpoints for speech recognition servers
let responseTranscription = null
try {
// Sending empty FormData is fine; 400 still counts as a valid endpoint
responseTranscription = await fetch(`${config.baseUrl as string}audio/transcriptions`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders }, method: 'POST', body: new FormData() })
}
catch {
// Transcription endpoint might not exist, that's okay
}
// Accept if any of the endpoints work (chat, models, or transcription)
const validResponses = [responseChat, responseModelList, responseTranscription].filter(r => r && [200, 400, 401].includes(r.status))
if (validResponses.length === 0) {
errors.push(new Error(`Invalid Base URL, ${config.baseUrl} is not supported. Make sure your server supports OpenAI-compatible endpoints.`))
}
await generateText({
apiKey,
baseURL: baseUrl,
headers: {
...additionalHeaders,
Authorization: `Bearer ${apiKey}`,
},
model: 'test',
messages: message.messages(message.user('ping')),
max_tokens: 1,
})
}
catch (e) {
errors.push(new Error(`Invalid Base URL, ${(e as Error).message}`))
errors.push(new Error(`Health check failed: ${(e as Error).message}`))
}
}
if (errors.length > 0) {
return { errors, reason: errors.map(e => e.message).join(', '), valid: false }
}
// Model list validation (was: fetch(`${baseUrl}models`))
if (validationChecks.includes('model_list')) {
try {
let response = responseModelList
if (!response) {
response = await fetch(`${config.baseUrl as string}models`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders } })
}
if (!response.ok) {
errors.push(new Error(`Invalid API Key`))
const models = await listModels({
apiKey,
baseURL: baseUrl,
headers: {
...additionalHeaders,
Authorization: `Bearer ${apiKey}`,
},
})
if (!models || models.length === 0) {
errors.push(new Error('Model list check failed: no models found'))
}
}
catch (e) {
@@ -126,25 +151,30 @@ export function buildOpenAICompatibleProvider(
}
}
// Chat completions validation = generateText again (was: fetch(`${baseUrl}chat/completions`))
if (validationChecks.includes('chat_completions')) {
try {
let response = responseChat
if (!response) {
response = await fetch(`${config.baseUrl as string}chat/completions`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders }, method: 'POST', body: '{"model": "test"}' })
}
if (!response.ok) {
errors.push(new Error(`Invalid API Key`))
}
await generateText({
apiKey,
baseURL: baseUrl,
headers: {
...additionalHeaders,
Authorization: `Bearer ${apiKey}`,
},
model: 'test',
messages: message.messages(message.user('ping')),
max_tokens: 1,
})
}
catch (e) {
errors.push(new Error(`Chat Completions check Failed: ${(e as Error).message}`))
errors.push(new Error(`Chat completions check failed: ${(e as Error).message}`))
}
}
return {
errors,
reason: errors.map(e => e.message).join(', ') || '',
// Consistent reason string (empty when no errors)
reason: errors.length > 0 ? errors.map(e => e.message).join(', ') : '',
valid: errors.length === 0,
}
},
@@ -162,7 +192,14 @@ export function buildOpenAICompatibleProvider(
defaultOptions: () => ({
baseUrl: defaultBaseUrl || '',
}),
createProvider: async config => creator((config.apiKey as string || '').trim(), (config.baseUrl as string || '').trim()),
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 += '/'
}
return creator(apiKey, baseUrl)
},
capabilities: finalCapabilities,
validators: finalValidators,
...rest,
+310 -215
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -23,7 +23,7 @@ catalog:
'@xsai/shared-chat': ^0.4.0-beta.4
'@xsai/stream-text': ^0.4.0-beta.4
'@xsai/tool': ^0.4.0-beta.4
'@xsai/utils-chat': ^0.4.0-beta.4
'@xsai/utils-chat': 0.4.0-beta.4
xsschema: ^0.4.0-beta.4
catalogs: