Skip to content

Commit b34738b

Browse files
authored
fix: harden buildOpenAICompatibleProvider validation and config (#625)
1 parent ecbc009 commit b34738b

File tree

4 files changed

+413
-279
lines changed

4 files changed

+413
-279
lines changed

packages/stage-ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,14 @@
9090
"@xsai-transformers/shared": "^0.0.7",
9191
"@xsai/embed": "catalog:",
9292
"@xsai/generate-speech": "catalog:",
93+
"@xsai/generate-text": "catalog:",
9394
"@xsai/generate-transcription": "catalog:",
9495
"@xsai/model": "catalog:",
9596
"@xsai/shared": "catalog:",
9697
"@xsai/shared-chat": "catalog:",
9798
"@xsai/stream-text": "catalog:",
9899
"@xsai/tool": "catalog:",
100+
"@xsai/utils-chat": "catalog:",
99101
"animejs": "^4.2.1",
100102
"culori": "^4.0.2",
101103
"date-fns": "^4.1.0",

packages/stage-ui/src/stores/providers/openai-compatible-builder.ts

Lines changed: 100 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { ModelInfo, ProviderMetadata } from '../providers'
22

3+
import { generateText } from '@xsai/generate-text'
34
import { listModels } from '@xsai/model'
4-
5-
import { isUrl } from '../../utils/url'
5+
import { message } from '@xsai/utils-chat'
66

77
type ProviderCreator = (apiKey: string, baseUrl: string) => any
88

@@ -24,22 +24,47 @@ export function buildOpenAICompatibleProvider(
2424
additionalHeaders?: Record<string, string>
2525
},
2626
): ProviderMetadata {
27-
const { id, name, icon, description, nameKey, descriptionKey, category, tasks, defaultBaseUrl, creator, capabilities, validators, validation, additionalHeaders, ...rest } = options
27+
const {
28+
id,
29+
name,
30+
icon,
31+
description,
32+
nameKey,
33+
descriptionKey,
34+
category,
35+
tasks,
36+
defaultBaseUrl,
37+
creator,
38+
capabilities,
39+
validators,
40+
validation,
41+
additionalHeaders,
42+
...rest
43+
} = options
2844

2945
const finalCapabilities = capabilities || {
3046
listModels: async (config: Record<string, unknown>) => {
31-
const provider = await creator(
32-
(config.apiKey as string || '').trim(),
33-
(config.baseUrl as string || '').trim(),
34-
)
47+
// Safer casting of apiKey/baseUrl (prevents .trim() crash if not a string)
48+
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
49+
const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
3550

36-
if (!provider.model) {
51+
const provider = await creator(apiKey, baseUrl)
52+
// Check provider.model exists and is a function
53+
if (!provider || typeof provider.model !== 'function') {
3754
return []
3855
}
3956

40-
return (await listModels({
41-
...provider.model(),
42-
})).map((model: any) => {
57+
// Previously: fetch(`${baseUrl}models`)
58+
const models = await listModels({
59+
apiKey,
60+
baseURL: baseUrl,
61+
headers: {
62+
...additionalHeaders,
63+
Authorization: `Bearer ${apiKey}`,
64+
},
65+
})
66+
67+
return models.map((model: any) => {
4368
return {
4469
id: model.id,
4570
name: model.name || model.display_name || model.id,
@@ -55,96 +80,101 @@ export function buildOpenAICompatibleProvider(
5580
const finalValidators = validators || {
5681
validateProviderConfig: async (config: Record<string, unknown>) => {
5782
const errors: Error[] = []
83+
let baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
84+
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
5885

59-
if (!config.baseUrl) {
86+
if (!baseUrl) {
6087
errors.push(new Error('Base URL is required'))
6188
}
6289

63-
if (errors.length > 0) {
64-
return { errors, reason: errors.map(e => e.message).join(', '), valid: false }
90+
try {
91+
if (new URL(baseUrl).host.length === 0) {
92+
errors.push(new Error('Base URL is not absolute. Check your input.'))
93+
}
6594
}
66-
67-
if (!isUrl(config.baseUrl as string) || new URL(config.baseUrl as string).host.length === 0) {
68-
errors.push(new Error('Base URL is not absolute. Check your input.'))
95+
catch {
96+
errors.push(new Error('Base URL is invalid. It must be an absolute URL.'))
6997
}
7098

71-
if (!(config.baseUrl as string).endsWith('/')) {
72-
errors.push(new Error('Base URL must end with a trailing slash (/).'))
99+
// normalize trailing slash instead of rejecting
100+
if (baseUrl && !baseUrl.endsWith('/')) {
101+
baseUrl += '/'
73102
}
74103

75104
if (errors.length > 0) {
76-
return { errors, reason: errors.map(e => e.message).join(', '), valid: false }
105+
return {
106+
errors,
107+
reason: errors.map(e => e.message).join(', '),
108+
valid: false,
109+
}
77110
}
78111

79112
const validationChecks = validation || []
80-
let responseModelList = null
81-
let responseChat = null
82113

114+
// Health check = try generating text (was: fetch(`${baseUrl}chat/completions`))
83115
if (validationChecks.includes('health')) {
84116
try {
85-
responseChat = await fetch(`${config.baseUrl as string}chat/completions`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders }, method: 'POST', body: '{"model": "test"}' })
86-
responseModelList = await fetch(`${config.baseUrl as string}models`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders } })
87-
88-
// Also try transcription endpoints for speech recognition servers
89-
let responseTranscription = null
90-
try {
91-
// Sending empty FormData is fine; 400 still counts as a valid endpoint
92-
responseTranscription = await fetch(`${config.baseUrl as string}audio/transcriptions`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders }, method: 'POST', body: new FormData() })
93-
}
94-
catch {
95-
// Transcription endpoint might not exist, that's okay
96-
}
97-
98-
// Accept if any of the endpoints work (chat, models, or transcription)
99-
const validResponses = [responseChat, responseModelList, responseTranscription].filter(r => r && [200, 400, 401].includes(r.status))
100-
if (validResponses.length === 0) {
101-
errors.push(new Error(`Invalid Base URL, ${config.baseUrl} is not supported. Make sure your server supports OpenAI-compatible endpoints.`))
102-
}
117+
await generateText({
118+
apiKey,
119+
baseURL: baseUrl,
120+
headers: {
121+
...additionalHeaders,
122+
Authorization: `Bearer ${apiKey}`,
123+
},
124+
model: 'test',
125+
messages: message.messages(message.user('ping')),
126+
max_tokens: 1,
127+
})
103128
}
104129
catch (e) {
105-
errors.push(new Error(`Invalid Base URL, ${(e as Error).message}`))
130+
errors.push(new Error(`Health check failed: ${(e as Error).message}`))
106131
}
107132
}
108133

109-
if (errors.length > 0) {
110-
return { errors, reason: errors.map(e => e.message).join(', '), valid: false }
111-
}
112-
134+
// Model list validation (was: fetch(`${baseUrl}models`))
113135
if (validationChecks.includes('model_list')) {
114136
try {
115-
let response = responseModelList
116-
if (!response) {
117-
response = await fetch(`${config.baseUrl as string}models`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders } })
118-
}
119-
120-
if (!response.ok) {
121-
errors.push(new Error(`Invalid API Key`))
137+
const models = await listModels({
138+
apiKey,
139+
baseURL: baseUrl,
140+
headers: {
141+
...additionalHeaders,
142+
Authorization: `Bearer ${apiKey}`,
143+
},
144+
})
145+
if (!models || models.length === 0) {
146+
errors.push(new Error('Model list check failed: no models found'))
122147
}
123148
}
124149
catch (e) {
125150
errors.push(new Error(`Model list check failed: ${(e as Error).message}`))
126151
}
127152
}
128153

154+
// Chat completions validation = generateText again (was: fetch(`${baseUrl}chat/completions`))
129155
if (validationChecks.includes('chat_completions')) {
130156
try {
131-
let response = responseChat
132-
if (!response) {
133-
response = await fetch(`${config.baseUrl as string}chat/completions`, { headers: { Authorization: `Bearer ${config.apiKey}`, ...additionalHeaders }, method: 'POST', body: '{"model": "test"}' })
134-
}
135-
136-
if (!response.ok) {
137-
errors.push(new Error(`Invalid API Key`))
138-
}
157+
await generateText({
158+
apiKey,
159+
baseURL: baseUrl,
160+
headers: {
161+
...additionalHeaders,
162+
Authorization: `Bearer ${apiKey}`,
163+
},
164+
model: 'test',
165+
messages: message.messages(message.user('ping')),
166+
max_tokens: 1,
167+
})
139168
}
140169
catch (e) {
141-
errors.push(new Error(`Chat Completions check Failed: ${(e as Error).message}`))
170+
errors.push(new Error(`Chat completions check failed: ${(e as Error).message}`))
142171
}
143172
}
144173

145174
return {
146175
errors,
147-
reason: errors.map(e => e.message).join(', ') || '',
176+
// Consistent reason string (empty when no errors)
177+
reason: errors.length > 0 ? errors.map(e => e.message).join(', ') : '',
148178
valid: errors.length === 0,
149179
}
150180
},
@@ -162,7 +192,14 @@ export function buildOpenAICompatibleProvider(
162192
defaultOptions: () => ({
163193
baseUrl: defaultBaseUrl || '',
164194
}),
165-
createProvider: async config => creator((config.apiKey as string || '').trim(), (config.baseUrl as string || '').trim()),
195+
createProvider: async (config: { apiKey: string, baseUrl: string }) => {
196+
const apiKey = typeof config.apiKey === 'string' ? config.apiKey.trim() : ''
197+
let baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''
198+
if (baseUrl && !baseUrl.endsWith('/')) {
199+
baseUrl += '/'
200+
}
201+
return creator(apiKey, baseUrl)
202+
},
166203
capabilities: finalCapabilities,
167204
validators: finalValidators,
168205
...rest,

0 commit comments

Comments
 (0)