@@ -21,6 +21,8 @@ const {
2121 detectLineEnding,
2222 normalizeLineEnding,
2323 isValidProviderName,
24+ escapeTomlBasicString,
25+ buildModelProviderTableHeader,
2426 buildModelsCandidates,
2527 isValidHttpUrl,
2628 normalizeBaseUrl,
@@ -227,7 +229,11 @@ function readConfig() {
227229 }
228230 try {
229231 const content = fs . readFileSync ( CONFIG_FILE , 'utf-8' ) ;
230- return toml . parse ( content ) ;
232+ const parsed = toml . parse ( content ) ;
233+ if ( isPlainObject ( parsed ) && isPlainObject ( parsed . model_providers ) ) {
234+ parsed . model_providers = normalizeLegacyModelProviders ( parsed . model_providers ) ;
235+ }
236+ return parsed ;
231237 } catch ( e ) {
232238 throw new Error ( `配置文件解析失败: ${ e . message } ` ) ;
233239 }
@@ -283,6 +289,132 @@ function isPlainObject(value) {
283289 return ! ! value && typeof value === 'object' && ! Array . isArray ( value ) ;
284290}
285291
292+ const PROVIDER_CONFIG_KEYS = new Set ( [
293+ 'name' ,
294+ 'base_url' ,
295+ 'wire_api' ,
296+ 'requires_openai_auth' ,
297+ 'preferred_auth_method' ,
298+ 'request_max_retries' ,
299+ 'stream_max_retries' ,
300+ 'stream_idle_timeout_ms'
301+ ] ) ;
302+
303+ function looksLikeProviderConfig ( value ) {
304+ if ( ! isPlainObject ( value ) ) return false ;
305+ return Object . keys ( value ) . some ( ( key ) => PROVIDER_CONFIG_KEYS . has ( key ) ) ;
306+ }
307+
308+ function collectNestedProviderConfigs ( node , pathPrefix , collector ) {
309+ if ( ! isPlainObject ( node ) ) return ;
310+ if ( looksLikeProviderConfig ( node ) ) {
311+ collector . push ( [ pathPrefix , node ] ) ;
312+ return ;
313+ }
314+ for ( const [ childKey , childValue ] of Object . entries ( node ) ) {
315+ if ( ! isPlainObject ( childValue ) ) continue ;
316+ collectNestedProviderConfigs ( childValue , `${ pathPrefix } .${ childKey } ` , collector ) ;
317+ }
318+ }
319+
320+ function normalizeLegacyModelProviders ( modelProviders ) {
321+ if ( ! isPlainObject ( modelProviders ) ) {
322+ return modelProviders ;
323+ }
324+
325+ let changed = false ;
326+ const normalized = { } ;
327+ const addRecovered = ( name , provider ) => {
328+ if ( ! name || ! isPlainObject ( provider ) ) return ;
329+ if ( Object . prototype . hasOwnProperty . call ( modelProviders , name ) ) return ;
330+ if ( Object . prototype . hasOwnProperty . call ( normalized , name ) ) return ;
331+ normalized [ name ] = provider ;
332+ changed = true ;
333+ } ;
334+
335+ for ( const [ name , provider ] of Object . entries ( modelProviders ) ) {
336+ normalized [ name ] = provider ;
337+ if ( ! isPlainObject ( provider ) ) continue ;
338+
339+ if ( looksLikeProviderConfig ( provider ) ) {
340+ for ( const [ childKey , childValue ] of Object . entries ( provider ) ) {
341+ if ( ! isPlainObject ( childValue ) ) continue ;
342+ const recovered = [ ] ;
343+ collectNestedProviderConfigs ( childValue , `${ name } .${ childKey } ` , recovered ) ;
344+ for ( const [ recoveredName , recoveredProvider ] of recovered ) {
345+ addRecovered ( recoveredName , recoveredProvider ) ;
346+ }
347+ }
348+ continue ;
349+ }
350+
351+ const recovered = [ ] ;
352+ collectNestedProviderConfigs ( provider , name , recovered ) ;
353+ if ( recovered . length > 0 ) {
354+ delete normalized [ name ] ;
355+ changed = true ;
356+ for ( const [ recoveredName , recoveredProvider ] of recovered ) {
357+ addRecovered ( recoveredName , recoveredProvider ) ;
358+ }
359+ }
360+ }
361+
362+ return changed ? normalized : modelProviders ;
363+ }
364+
365+ function escapeRegex ( value ) {
366+ return String ( value || '' ) . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
367+ }
368+
369+ function findProviderSectionRanges ( content , providerName ) {
370+ const text = typeof content === 'string' ? content : '' ;
371+ const name = typeof providerName === 'string' ? providerName . trim ( ) : '' ;
372+ if ( ! text || ! name ) return [ ] ;
373+
374+ const safeName = escapeRegex ( name ) ;
375+ const headerPatterns = [
376+ { priority : 0 , regex : new RegExp ( `^\\s*model_providers\\s*\\.\\s*"${ safeName } "\\s*$` ) } ,
377+ { priority : 1 , regex : new RegExp ( `^\\s*model_providers\\s*\\.\\s*'${ safeName } '\\s*$` ) } ,
378+ { priority : 2 , regex : new RegExp ( `^\\s*model_providers\\s*\\.\\s*${ safeName } \\s*$` ) }
379+ ] ;
380+
381+ const allHeaders = [ ] ;
382+ const targetPriorityByStart = new Map ( ) ;
383+ const sectionLineRegex = / ^ [ \t ] * \[ (? ! \[ ) ( [ ^ \] \n ] + ) \] [ \t ] * (?: # .* ) ? $ / gm;
384+ let match ;
385+ while ( ( match = sectionLineRegex . exec ( text ) ) !== null ) {
386+ const start = match . index ;
387+ allHeaders . push ( start ) ;
388+ const headerExpr = String ( match [ 1 ] || '' ) . trim ( ) ;
389+ for ( const pattern of headerPatterns ) {
390+ if ( pattern . regex . test ( headerExpr ) ) {
391+ const prev = targetPriorityByStart . get ( start ) ;
392+ if ( prev === undefined || pattern . priority < prev ) {
393+ targetPriorityByStart . set ( start , pattern . priority ) ;
394+ }
395+ break ;
396+ }
397+ }
398+ }
399+
400+ if ( targetPriorityByStart . size === 0 ) {
401+ return [ ] ;
402+ }
403+
404+ const ranges = [ ] ;
405+ for ( let i = 0 ; i < allHeaders . length ; i ++ ) {
406+ const start = allHeaders [ i ] ;
407+ if ( ! targetPriorityByStart . has ( start ) ) continue ;
408+ const end = i + 1 < allHeaders . length ? allHeaders [ i + 1 ] : text . length ;
409+ ranges . push ( {
410+ start,
411+ end,
412+ priority : targetPriorityByStart . get ( start )
413+ } ) ;
414+ }
415+ return ranges ;
416+ }
417+
286418function normalizeAuthProfileName ( value ) {
287419 const raw = typeof value === 'string' ? value . trim ( ) : '' ;
288420 if ( ! raw ) return '' ;
@@ -1662,6 +1794,9 @@ function addProviderToConfig(params = {}) {
16621794
16631795 if ( ! name ) return { error : '名称不能为空' } ;
16641796 if ( ! url ) return { error : 'URL 不能为空' } ;
1797+ if ( ! isValidProviderName ( name ) ) {
1798+ return { error : '名称仅支持字母/数字/._-' } ;
1799+ }
16651800 if ( isReservedProviderNameForCreation ( name ) ) {
16661801 return { error : 'local provider 为系统保留名称,不可新增' } ;
16671802 }
@@ -1693,24 +1828,19 @@ function addProviderToConfig(params = {}) {
16931828 return { error : `config.toml 解析失败: ${ e . message } ` } ;
16941829 }
16951830
1696- if ( ! parsed . model_providers || typeof parsed . model_providers !== 'object' ) {
1697- parsed . model_providers = { } ;
1698- }
1699-
1700- if ( parsed . model_providers [ name ] ) {
1831+ const normalizedProviders = isPlainObject ( parsed . model_providers )
1832+ ? normalizeLegacyModelProviders ( parsed . model_providers )
1833+ : { } ;
1834+ if ( normalizedProviders && normalizedProviders [ name ] ) {
17011835 return { error : '提供商已存在' } ;
17021836 }
17031837
1704- const escapeTomlString = ( value ) => String ( value || '' )
1705- . replace ( / \\ / g, '\\\\' )
1706- . replace ( / " / g, '\\"' ) ;
1707-
17081838 const lineEnding = content . includes ( '\r\n' ) ? '\r\n' : '\n' ;
1709- const safeName = escapeTomlString ( name ) ;
1710- const safeUrl = escapeTomlString ( url ) ;
1711- const safeKey = escapeTomlString ( key ) ;
1839+ const safeName = escapeTomlBasicString ( name ) ;
1840+ const safeUrl = escapeTomlBasicString ( url ) ;
1841+ const safeKey = escapeTomlBasicString ( key ) ;
17121842 const block = [
1713- `[model_providers. ${ safeName } ]` ,
1843+ buildModelProviderTableHeader ( name ) ,
17141844 `name = "${ safeName } "` ,
17151845 `base_url = "${ safeUrl } "` ,
17161846 `wire_api = "responses"` ,
@@ -1810,8 +1940,6 @@ function performProviderDeletion(name, options = {}) {
18101940 const content = fs . readFileSync ( CONFIG_FILE , 'utf-8' ) ;
18111941 const lineEnding = content . includes ( '\r\n' ) ? '\r\n' : '\n' ;
18121942 const hasBom = content . charCodeAt ( 0 ) === 0xFEFF ;
1813- const safeName = name . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
1814- const sectionRegex = new RegExp ( `\\[\\s*model_providers\\s*\\.\\s*(?:"${ safeName } "|'${ safeName } '|${ safeName } )\\s*\\]` ) ;
18151943
18161944 const remainingProviders = Object . keys ( config . model_providers || { } ) . filter ( item => item !== name ) ;
18171945 if ( remainingProviders . length === 0 ) {
@@ -1850,17 +1978,14 @@ function performProviderDeletion(name, options = {}) {
18501978 } ;
18511979
18521980 let updatedContent = null ;
1853- const match = content . match ( sectionRegex ) ;
1854- if ( match ) {
1855- const startIdx = match . index ;
1856- const rest = content . slice ( startIdx + match [ 0 ] . length ) ;
1857- const nextIdx = rest . indexOf ( '[' ) ;
1858- const endIdx = nextIdx === - 1 ? content . length : ( startIdx + match [ 0 ] . length + nextIdx ) ;
1859-
1860- const removedContent = ( content . slice ( 0 , startIdx ) + content . slice ( endIdx ) )
1861- . replace ( / \n { 3 , } / g, lineEnding + lineEnding ) ;
1862-
1863- updatedContent = removedContent ;
1981+ const ranges = findProviderSectionRanges ( content , name ) ;
1982+ if ( ranges . length > 0 ) {
1983+ const sorted = ranges . sort ( ( a , b ) => b . start - a . start ) ;
1984+ let removedContent = content ;
1985+ for ( const range of sorted ) {
1986+ removedContent = removedContent . slice ( 0 , range . start ) + removedContent . slice ( range . end ) ;
1987+ }
1988+ updatedContent = removedContent . replace ( / \n { 3 , } / g, lineEnding + lineEnding ) ;
18641989 }
18651990
18661991 if ( updatedContent ) {
@@ -5034,6 +5159,10 @@ function cmdAdd(name, baseUrl, apiKey, silent = false) {
50345159 }
50355160 throw new Error ( '名称和URL必填' ) ;
50365161 }
5162+ if ( ! isValidProviderName ( providerName ) ) {
5163+ if ( ! silent ) console . error ( '错误: 名称仅支持字母/数字/._-' ) ;
5164+ throw new Error ( '名称仅支持字母/数字/._-' ) ;
5165+ }
50375166 if ( isReservedProviderNameForCreation ( providerName ) ) {
50385167 if ( ! silent ) console . error ( '错误: local provider 为系统保留名称,不可新增' ) ;
50395168 throw new Error ( 'local provider 为系统保留名称,不可新增' ) ;
@@ -5045,13 +5174,16 @@ function cmdAdd(name, baseUrl, apiKey, silent = false) {
50455174 throw new Error ( '提供商已存在' ) ;
50465175 }
50475176
5177+ const safeName = escapeTomlBasicString ( providerName ) ;
5178+ const safeBaseUrl = escapeTomlBasicString ( providerBaseUrl ) ;
5179+ const safeApiKey = escapeTomlBasicString ( apiKey || '' ) ;
50485180 const newBlock = `
5049- [model_providers. ${ providerName } ]
5050- name = "${ providerName } "
5051- base_url = "${ providerBaseUrl } "
5181+ ${ buildModelProviderTableHeader ( providerName ) }
5182+ name = "${ safeName } "
5183+ base_url = "${ safeBaseUrl } "
50525184wire_api = "responses"
50535185requires_openai_auth = false
5054- preferred_auth_method = "${ apiKey || '' } "
5186+ preferred_auth_method = "${ safeApiKey } "
50555187request_max_retries = 4
50565188stream_max_retries = 10
50575189stream_idle_timeout_ms = 300000
@@ -5111,41 +5243,32 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
51115243 }
51125244
51135245 const content = fs . readFileSync ( CONFIG_FILE , 'utf-8' ) ;
5114- const safeName = name . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
5115- const sectionRegex = new RegExp ( `\\[\\s*model_providers\\s*\\.\\s*${ safeName } \\s*\\]` ) ;
5116- const match = content . match ( sectionRegex ) ;
5117- if ( ! match ) {
5246+ const ranges = findProviderSectionRanges ( content , name ) ;
5247+ if ( ranges . length === 0 ) {
51185248 if ( ! silent ) console . error ( '错误: 无法找到提供商配置块' ) ;
51195249 throw new Error ( '无法找到提供商配置块' ) ;
51205250 }
51215251
5122- const startIdx = match . index ;
5123- const rest = content . slice ( startIdx + match [ 0 ] . length ) ;
5124- const nextIdx = rest . indexOf ( '[' ) ;
5125- const endIdx = nextIdx === - 1 ? content . length : ( startIdx + match [ 0 ] . length + nextIdx ) ;
5126-
5127- // 提取该提供商的配置块
5128- const providerBlock = content . slice ( startIdx , endIdx ) ;
5129-
5130- // 替换 base_url
5131- let updatedBlock = providerBlock ;
5132- if ( baseUrl ) {
5133- updatedBlock = updatedBlock . replace (
5134- / ^ ( b a s e _ u r l \s * = \s * ) ( [ " ' ] ) .* ?\2/ m,
5135- `$1$2${ baseUrl } $2`
5136- ) ;
5137- }
5138-
5139- // 替换 preferred_auth_method (API Key)
5140- if ( apiKey !== undefined ) {
5141- updatedBlock = updatedBlock . replace (
5142- / ^ ( p r e f e r r e d _ a u t h _ m e t h o d \s * = \s * ) ( [ " ' ] ) .* ?\2/ m,
5143- `$1$2${ apiKey } $2`
5144- ) ;
5252+ let newContent = content ;
5253+ const sorted = ranges . sort ( ( a , b ) => b . start - a . start ) ;
5254+ for ( const range of sorted ) {
5255+ const providerBlock = newContent . slice ( range . start , range . end ) ;
5256+ let updatedBlock = providerBlock ;
5257+ if ( baseUrl ) {
5258+ updatedBlock = updatedBlock . replace (
5259+ / ^ ( b a s e _ u r l \s * = \s * ) ( [ " ' ] ) .* ?\2/ m,
5260+ `$1$2${ baseUrl } $2`
5261+ ) ;
5262+ }
5263+ if ( apiKey !== undefined ) {
5264+ updatedBlock = updatedBlock . replace (
5265+ / ^ ( p r e f e r r e d _ a u t h _ m e t h o d \s * = \s * ) ( [ " ' ] ) .* ?\2/ m,
5266+ `$1$2${ apiKey } $2`
5267+ ) ;
5268+ }
5269+ newContent = newContent . slice ( 0 , range . start ) + updatedBlock + newContent . slice ( range . end ) ;
51455270 }
51465271
5147- // 组合新的内容
5148- const newContent = content . slice ( 0 , startIdx ) + updatedBlock + content . slice ( endIdx ) ;
51495272 writeConfig ( newContent . trim ( ) ) ;
51505273
51515274 // 如果更新了 API Key 且该提供商是当前激活的,同步更新 auth.json
0 commit comments