Skip to content

Commit 0782fd6

Browse files
committed
fix: harden provider section matching to avoid config corruption
1 parent 5877e9e commit 0782fd6

3 files changed

Lines changed: 426 additions & 61 deletions

File tree

cli.js

Lines changed: 183 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
286418
function 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}"
50525184
wire_api = "responses"
50535185
requires_openai_auth = false
5054-
preferred_auth_method = "${apiKey || ''}"
5186+
preferred_auth_method = "${safeApiKey}"
50555187
request_max_retries = 4
50565188
stream_max_retries = 10
50575189
stream_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-
/^(base_url\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-
/^(preferred_auth_method\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+
/^(base_url\s*=\s*)(["']).*?\2/m,
5260+
`$1$2${baseUrl}$2`
5261+
);
5262+
}
5263+
if (apiKey !== undefined) {
5264+
updatedBlock = updatedBlock.replace(
5265+
/^(preferred_auth_method\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

lib/cli-utils.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ function isValidProviderName(name) {
7171
return typeof name === 'string' && /^[a-zA-Z0-9._-]+$/.test(name.trim());
7272
}
7373

74+
function escapeTomlBasicString(value) {
75+
return String(value || '')
76+
.replace(/\\/g, '\\\\')
77+
.replace(/"/g, '\\"');
78+
}
79+
80+
function buildModelProviderTableHeader(providerName) {
81+
const raw = typeof providerName === 'string' ? providerName.trim() : '';
82+
if (/^[a-zA-Z0-9_-]+$/.test(raw)) {
83+
return `[model_providers.${raw}]`;
84+
}
85+
return `[model_providers."${escapeTomlBasicString(raw)}"]`;
86+
}
87+
7488
function buildModelsCandidates(baseUrl) {
7589
const trimmed = typeof baseUrl === 'string' ? baseUrl.trim() : '';
7690
if (!trimmed) return [];
@@ -132,6 +146,8 @@ module.exports = {
132146
detectLineEnding,
133147
normalizeLineEnding,
134148
isValidProviderName,
149+
escapeTomlBasicString,
150+
buildModelProviderTableHeader,
135151
buildModelsCandidates,
136152
isValidHttpUrl,
137153
normalizeBaseUrl,

0 commit comments

Comments
 (0)