Skip to content

Commit e65caf5

Browse files
authored
refactor(openai-bridge): split runtime retry layers (#212)
* feat(providers): configure codex bridge retries * refactor(openai-bridge): split runtime retry layers * fix(web-ui): isolate prompts markdown tabs * fix(web-ui): reset prompts scroll on tab switch * chore(release): bump version to v0.1.0
1 parent 042f455 commit e65caf5

23 files changed

Lines changed: 2264 additions & 1869 deletions

cli.js

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2561,6 +2561,28 @@ function buildClaudeSettingsDiff(params = {}) {
25612561
};
25622562
}
25632563

2564+
2565+
function normalizeOpenaiBridgeMaxRetries(value, fallback = 2) {
2566+
const raw = Number(value);
2567+
const fallbackRaw = Number(fallback);
2568+
const base = Number.isFinite(raw) ? raw : (Number.isFinite(fallbackRaw) ? fallbackRaw : 2);
2569+
return Math.min(10, Math.max(2, Math.floor(base)));
2570+
}
2571+
2572+
function resolveProviderOpenaiBridgeMaxRetries(provider) {
2573+
if (!provider || typeof provider !== 'object') return 2;
2574+
if (provider.codexmate_bridge_max_retries !== undefined) {
2575+
return normalizeOpenaiBridgeMaxRetries(provider.codexmate_bridge_max_retries);
2576+
}
2577+
if (provider.openai_bridge_max_retries !== undefined) {
2578+
return normalizeOpenaiBridgeMaxRetries(provider.openai_bridge_max_retries);
2579+
}
2580+
if (provider.max_retries !== undefined) {
2581+
return normalizeOpenaiBridgeMaxRetries(provider.max_retries);
2582+
}
2583+
return 2;
2584+
}
2585+
25642586
function addProviderToConfig(params = {}) {
25652587
const name = typeof params.name === 'string' ? params.name.trim() : '';
25662588
const url = typeof params.url === 'string' ? params.url.trim() : '';
@@ -2575,6 +2597,8 @@ function addProviderToConfig(params = {}) {
25752597
? params.model.trim()
25762598
: fallbackModel;
25772599
const useTransform = !!params.useTransform;
2600+
const hasOpenaiBridgeMaxRetries = params.openaiBridgeMaxRetries !== undefined || params.maxRetries !== undefined;
2601+
const openaiBridgeMaxRetries = normalizeOpenaiBridgeMaxRetries(params.openaiBridgeMaxRetries ?? params.maxRetries);
25782602
const allowManaged = !!params.allowManaged;
25792603
const normalizedUrl = normalizeBaseUrl(url);
25802604

@@ -2634,7 +2658,7 @@ function addProviderToConfig(params = {}) {
26342658
const requiresOpenaiAuth = useTransform;
26352659

26362660
if (useTransform) {
2637-
const saveRes = upsertOpenaiBridgeProvider(OPENAI_BRIDGE_SETTINGS_FILE, name, normalizedUrl, key);
2661+
const saveRes = upsertOpenaiBridgeProvider(OPENAI_BRIDGE_SETTINGS_FILE, name, normalizedUrl, key, undefined, { maxRetries: openaiBridgeMaxRetries });
26382662
if (saveRes && saveRes.error) {
26392663
return { error: String(saveRes.error) };
26402664
}
@@ -2646,6 +2670,7 @@ function addProviderToConfig(params = {}) {
26462670
).toString().replace(/\/+$/g, '');
26472671
authKeyForConfig = 'codexmate';
26482672
extraLines.push(`codexmate_bridge = "openai"`);
2673+
extraLines.push(`codexmate_bridge_max_retries = ${openaiBridgeMaxRetries}`);
26492674
}
26502675

26512676
const safeUrl = escapeTomlBasicString(baseUrlForConfig);
@@ -2689,6 +2714,8 @@ function updateProviderInConfig(params = {}) {
26892714
? String(params.key).trim()
26902715
: undefined;
26912716
const useTransform = !!params.useTransform;
2717+
const hasOpenaiBridgeMaxRetries = params.openaiBridgeMaxRetries !== undefined || params.maxRetries !== undefined;
2718+
const openaiBridgeMaxRetries = normalizeOpenaiBridgeMaxRetries(params.openaiBridgeMaxRetries ?? params.maxRetries);
26922719
const allowManaged = !!params.allowManaged;
26932720

26942721
if (!name) return { error: '名称不能为空' };
@@ -2703,7 +2730,7 @@ function updateProviderInConfig(params = {}) {
27032730
}
27042731

27052732
try {
2706-
cmdUpdate(name, url || undefined, key, true, { allowManaged, useTransform });
2733+
cmdUpdate(name, url || undefined, key, true, { allowManaged, useTransform, ...(hasOpenaiBridgeMaxRetries ? { openaiBridgeMaxRetries } : {}) });
27072734
return { success: true };
27082735
} catch (e) {
27092736
return { error: e.message || '更新失败' };
@@ -9846,6 +9873,8 @@ function cmdDelete(name, silent = false) {
98469873
function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
98479874
const allowManaged = !!(options && options.allowManaged);
98489875
const forceUseTransform = !!(options && options.useTransform);
9876+
const hasOpenaiBridgeMaxRetries = options && Object.prototype.hasOwnProperty.call(options, 'openaiBridgeMaxRetries');
9877+
const openaiBridgeMaxRetries = normalizeOpenaiBridgeMaxRetries(options && options.openaiBridgeMaxRetries);
98499878
const normalizedBaseUrl = baseUrl === undefined ? undefined : normalizeBaseUrl(baseUrl);
98509879
if (!name) {
98519880
if (!silent) console.error('错误: 提供商名称必填');
@@ -10005,6 +10034,32 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
1000510034
return next;
1000610035
};
1000710036

10037+
const replaceTomlNumberField = (block, fieldName, rawValue) => {
10038+
const numberValue = String(Math.floor(Number(rawValue)));
10039+
const escapedFieldName = escapeRegex(fieldName);
10040+
const multilineRanges = collectTomlMultilineStringRanges(block);
10041+
const withCommentRegex = new RegExp(`^(\\s*${escapedFieldName}\\s*=\\s*)([-+]?\\d+(?:\\.\\d+)?)(\\s+#.*)?$`, 'mg');
10042+
let replaced = false;
10043+
let next = block.replace(withCommentRegex, (full, prefix, _value, suffix = '', offset) => {
10044+
if (replaced || isIndexInRanges(offset, multilineRanges)) {
10045+
return full;
10046+
}
10047+
replaced = true;
10048+
return `${prefix}${numberValue}${suffix}`;
10049+
});
10050+
if (!replaced) {
10051+
const keyIndentMatch = block.match(/^(\s*)[A-Za-z0-9_.-]+\s*=/m);
10052+
const indent = keyIndentMatch ? keyIndentMatch[1] : '';
10053+
const lineEnding = block.includes('\r\n') ? '\r\n' : '\n';
10054+
const tailMatch = block.match(/(\s*)$/);
10055+
const tail = tailMatch ? tailMatch[1] : '';
10056+
const body = block.slice(0, block.length - tail.length);
10057+
const separator = body.endsWith('\n') || body.endsWith('\r') ? '' : lineEnding;
10058+
next = `${body}${separator}${indent}${fieldName} = ${numberValue}${tail}`;
10059+
}
10060+
return next;
10061+
};
10062+
1000810063
const replaceTomlBooleanField = (block, fieldName, rawValue) => {
1000910064
const boolValue = rawValue ? 'true' : 'false';
1001010065
const escapedFieldName = escapeRegex(fieldName);
@@ -10061,7 +10116,9 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
1006110116
: existingApiKey;
1006210117

1006310118
if (upstreamBaseUrl) {
10064-
const saveRes = upsertOpenaiBridgeProvider(OPENAI_BRIDGE_SETTINGS_FILE, name, upstreamBaseUrl, upstreamApiKey);
10119+
const saveRes = upsertOpenaiBridgeProvider(OPENAI_BRIDGE_SETTINGS_FILE, name, upstreamBaseUrl, upstreamApiKey, undefined, {
10120+
maxRetries: hasOpenaiBridgeMaxRetries ? openaiBridgeMaxRetries : resolveProviderOpenaiBridgeMaxRetries(providerConfig)
10121+
});
1006510122
if (saveRes && saveRes.error) {
1006610123
throw new Error(String(saveRes.error));
1006710124
}
@@ -10077,6 +10134,9 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
1007710134
updatedBlock = replaceTomlBooleanField(updatedBlock, 'requires_openai_auth', true);
1007810135
updatedBlock = replaceTomlStringField(updatedBlock, 'preferred_auth_method', 'codexmate');
1007910136
updatedBlock = replaceTomlStringField(updatedBlock, 'codexmate_bridge', 'openai');
10137+
if (hasOpenaiBridgeMaxRetries) {
10138+
updatedBlock = replaceTomlNumberField(updatedBlock, 'codexmate_bridge_max_retries', openaiBridgeMaxRetries);
10139+
}
1008010140
} else {
1008110141
if (normalizedBaseUrl) {
1008210142
updatedBlock = replaceTomlStringField(updatedBlock, 'base_url', normalizedBaseUrl);
@@ -12819,7 +12879,10 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
1281912879
break;
1282012880
}
1282112881
// 不返回 apiKey(敏感信息),仅返回用户填过的上游 URL
12822-
result = { baseUrl: upstream.baseUrl, hasApiKey: !!(upstream.apiKey) };
12882+
const config = readConfig();
12883+
const provider = config.model_providers && config.model_providers[name];
12884+
const providerMaxRetries = resolveProviderOpenaiBridgeMaxRetries(provider);
12885+
result = { baseUrl: upstream.baseUrl, hasApiKey: !!(upstream.apiKey), maxRetries: providerMaxRetries };
1282312886
break;
1282412887
}
1282512888
case 'list-sessions':
@@ -15059,11 +15122,13 @@ function buildMcpProviderListPayload() {
1505915122
upstreamUrl = upstream.baseUrl.trim();
1506015123
}
1506115124
}
15125+
const openaiBridgeMaxRetries = resolveProviderOpenaiBridgeMaxRetries(p);
1506215126
return {
1506315127
name,
1506415128
url: p.base_url || '',
1506515129
upstreamUrl,
1506615130
codexmate_bridge: bridge,
15131+
openaiBridgeMaxRetries,
1506715132
key: maskKey(p.preferred_auth_method || ''),
1506815133
hasKey: !!(p.preferred_auth_method && p.preferred_auth_method.trim()),
1506915134
models: Array.isArray(p.models)

cli/openai-bridge-retry.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const DEFAULT_BRIDGE_MAX_RETRIES = 2;
2+
const MIN_BRIDGE_MAX_RETRIES = 2;
3+
const MAX_BRIDGE_MAX_RETRIES = 10;
4+
const BASE_TRANSIENT_RETRY_DELAY_MS = 200;
5+
const MAX_TRANSIENT_RETRY_DELAY_MS = 5000;
6+
7+
function normalizeBridgeMaxRetries(value, fallback = DEFAULT_BRIDGE_MAX_RETRIES) {
8+
const raw = Number(value);
9+
const fallbackRaw = Number(fallback);
10+
const base = Number.isFinite(raw) ? raw : (Number.isFinite(fallbackRaw) ? fallbackRaw : DEFAULT_BRIDGE_MAX_RETRIES);
11+
return Math.min(MAX_BRIDGE_MAX_RETRIES, Math.max(MIN_BRIDGE_MAX_RETRIES, Math.floor(base)));
12+
}
13+
14+
function isTransientNetworkError(error) {
15+
const text = String(error || '').trim();
16+
if (!text) return false;
17+
if (/socket hang up/i.test(text)) return true;
18+
if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
19+
if (/EAI_AGAIN/i.test(text)) return true;
20+
if (/UND_ERR_SOCKET/i.test(text)) return true;
21+
if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
22+
return false;
23+
}
24+
25+
function getTransientRetryDelayMs(attempt) {
26+
const index = Math.max(0, Number(attempt) - 1);
27+
return Math.min(MAX_TRANSIENT_RETRY_DELAY_MS, BASE_TRANSIENT_RETRY_DELAY_MS * Math.pow(3, index));
28+
}
29+
30+
async function retryTransientRequest(executor, options = {}) {
31+
const maxRetries = normalizeBridgeMaxRetries(options && options.maxRetries);
32+
let lastResult = null;
33+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
34+
if (attempt > 0) {
35+
const delay = getTransientRetryDelayMs(attempt);
36+
// eslint-disable-next-line no-await-in-loop
37+
await new Promise((r) => {
38+
const t = setTimeout(r, delay);
39+
if (typeof t.unref === 'function') t.unref();
40+
});
41+
}
42+
// eslint-disable-next-line no-await-in-loop
43+
const result = await executor(attempt);
44+
lastResult = result;
45+
if (!result) return result;
46+
if (result.ok) return result;
47+
if (result.retry) return result;
48+
if (result.status && result.status > 0) return result;
49+
if (!isTransientNetworkError(result.error)) return result;
50+
}
51+
return lastResult;
52+
}
53+
54+
module.exports = {
55+
DEFAULT_BRIDGE_MAX_RETRIES,
56+
MIN_BRIDGE_MAX_RETRIES,
57+
MAX_BRIDGE_MAX_RETRIES,
58+
normalizeBridgeMaxRetries,
59+
isTransientNetworkError,
60+
getTransientRetryDelayMs,
61+
retryTransientRequest
62+
};

0 commit comments

Comments
 (0)