11import type { ModelInfo , ProviderMetadata } from '../providers'
22
3+ import { generateText } from '@xsai/generate-text'
34import { listModels } from '@xsai/model'
4-
5- import { isUrl } from '../../utils/url'
5+ import { message } from '@xsai/utils-chat'
66
77type 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