Skip to content

Commit d54b004

Browse files
committed
feat(bridge): add local bridge with provider pool
1 parent 2cf219c commit d54b004

11 files changed

Lines changed: 580 additions & 43 deletions

cli.js

Lines changed: 145 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ const {
107107
readOpenaiBridgeSettings,
108108
resolveOpenaiBridgeUpstream
109109
} = require('./cli/openai-bridge');
110+
const {
111+
createLocalBridgeHttpHandler
112+
} = require('./cli/local-bridge');
110113
const {
111114
createOpenclawConfigController
112115
} = require('./cli/openclaw-config');
@@ -188,6 +191,7 @@ const INIT_MARK_FILE = path.join(CONFIG_DIR, 'codexmate-init.json');
188191
const BUILTIN_PROXY_SETTINGS_FILE = path.join(CONFIG_DIR, 'codexmate-proxy.json');
189192
const BUILTIN_CLAUDE_PROXY_SETTINGS_FILE = path.join(CONFIG_DIR, 'codexmate-claude-proxy.json');
190193
const OPENAI_BRIDGE_SETTINGS_FILE = path.join(CONFIG_DIR, 'codexmate-openai-bridge.json');
194+
const LOCAL_BRIDGE_SETTINGS_FILE = path.join(CONFIG_DIR, 'codexmate-local-bridge.json');
191195
const CODEX_SESSIONS_DIR = path.join(CONFIG_DIR, 'sessions');
192196
const SESSION_TRASH_DIR = path.join(CONFIG_DIR, 'codexmate-session-trash');
193197
const SESSION_TRASH_FILES_DIR = path.join(SESSION_TRASH_DIR, 'files');
@@ -268,6 +272,7 @@ const DEFAULT_EXTRACT_SUFFIXES = Object.freeze(['.json']);
268272
const g_taskRunControllers = new Map();
269273
let g_taskQueueProcessor = null;
270274
const BUILTIN_PROXY_PROVIDER_NAME = 'codexmate-proxy';
275+
const BUILTIN_LOCAL_PROVIDER_NAME = 'local';
271276
const DEFAULT_BUILTIN_PROXY_SETTINGS = Object.freeze({
272277
enabled: false,
273278
host: '127.0.0.1',
@@ -330,6 +335,16 @@ const openaiBridgeHandler = createOpenaiBridgeHttpHandler({
330335
httpsAgent: HTTPS_KEEP_ALIVE_AGENT
331336
});
332337

338+
const localBridgeHandler = createLocalBridgeHttpHandler({
339+
readConfigFn: readConfig,
340+
openaiBridgeFile: OPENAI_BRIDGE_SETTINGS_FILE,
341+
localBridgeSettingsFile: LOCAL_BRIDGE_SETTINGS_FILE,
342+
expectedToken: typeof process.env.CODEXMATE_HTTP_TOKEN === 'string' ? process.env.CODEXMATE_HTTP_TOKEN.trim() : '',
343+
maxBodySize: MAX_API_BODY_SIZE,
344+
httpAgent: HTTP_KEEP_ALIVE_AGENT,
345+
httpsAgent: HTTPS_KEEP_ALIVE_AGENT
346+
});
347+
333348
function resolveWebPort() {
334349
const raw = process.env.CODEXMATE_PORT;
335350
if (!raw) return DEFAULT_WEB_PORT;
@@ -589,16 +604,17 @@ model_auto_compact_token_limit = ${DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT}
589604
disable_response_storage = true
590605
approval_policy = "never"
591606
sandbox_mode = "danger-full-access"
592-
model_provider = "maxx"
607+
model_provider = "local"
593608
personality = "pragmatic"
594609
web_search = "live"
595610

596-
[model_providers.maxx]
597-
name = "maxx"
598-
base_url = "https://maxx-direct.cloverstd.com"
611+
[model_providers.local]
612+
name = "local"
613+
base_url = "http://127.0.0.1:3737/bridge/local/v1"
599614
wire_api = "responses"
600-
requires_openai_auth = false
601-
preferred_auth_method = "sk-"
615+
requires_openai_auth = true
616+
preferred_auth_method = "codexmate"
617+
codexmate_bridge = "local"
602618
request_max_retries = 4
603619
stream_max_retries = 10
604620
stream_idle_timeout_ms = 300000
@@ -620,12 +636,16 @@ function isBuiltinProxyProvider(providerName) {
620636
return typeof providerName === 'string' && providerName.trim().toLowerCase() === BUILTIN_PROXY_PROVIDER_NAME.toLowerCase();
621637
}
622638

639+
function isLocalProvider(providerName) {
640+
return typeof providerName === 'string' && providerName.trim().toLowerCase() === BUILTIN_LOCAL_PROVIDER_NAME.toLowerCase();
641+
}
642+
623643
function isReservedProviderNameForCreation(providerName) {
624-
return false;
644+
return isLocalProvider(providerName);
625645
}
626646

627647
function isBuiltinManagedProvider(providerName) {
628-
return isBuiltinProxyProvider(providerName);
648+
return isBuiltinProxyProvider(providerName) || isLocalProvider(providerName);
629649
}
630650

631651
function isNonDeletableProvider(providerName) {
@@ -1661,6 +1681,7 @@ const {
16611681
DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT,
16621682
CODEXMATE_MANAGED_MARKER,
16631683
BUILTIN_PROXY_PROVIDER_NAME,
1684+
BUILTIN_LOCAL_PROVIDER_NAME,
16641685
EMPTY_CONFIG_FALLBACK_TEMPLATE
16651686
});
16661687

@@ -2059,7 +2080,7 @@ function addProviderToConfig(params = {}) {
20592080
return { error: '提供商名称不可用' };
20602081
}
20612082
if (isBuiltinProxyProvider(name) && !allowManaged) {
2062-
return { error: 'codexmate-proxy 为保留名称,不可手动添加' };
2083+
return { error: `${"codexmate-proxy"} 为保留名称,不可手动添加` }; // keep literal for codexmate-proxy
20632084
}
20642085

20652086
ensureConfigDir();
@@ -2159,7 +2180,7 @@ function updateProviderInConfig(params = {}) {
21592180
return { error: 'URL 仅支持 http/https' };
21602181
}
21612182
if (isNonEditableProvider(name) && !allowManaged) {
2162-
return { error: 'codexmate-proxy 为保留名称,不可编辑' };
2183+
return { error: `${name} 为保留名称,不可编辑` };
21632184
}
21642185

21652186
try {
@@ -2174,7 +2195,7 @@ function deleteProviderFromConfig(params = {}) {
21742195
const name = typeof params.name === 'string' ? params.name.trim() : '';
21752196
if (!name) return { error: '名称不能为空' };
21762197
if (isNonDeletableProvider(name)) {
2177-
return { error: 'codexmate-proxy 为保留名称,不可删除' };
2198+
return { error: `${name} 为保留名称,不可删除` };
21782199
}
21792200
if (!fs.existsSync(CONFIG_FILE)) {
21802201
return { error: 'config.toml 不存在' };
@@ -2202,7 +2223,7 @@ function deleteProviderFromConfig(params = {}) {
22022223
function performProviderDeletion(name, options = {}) {
22032224
const silent = !!options.silent;
22042225
if (isNonDeletableProvider(name)) {
2205-
const msg = 'codexmate-proxy 为保留名称,不可删除';
2226+
const msg = `${name} 为保留名称,不可删除`;
22062227
if (!silent) console.error('错误:', msg);
22072228
return { error: msg };
22082229
}
@@ -5423,6 +5444,100 @@ async function ensureBuiltinProxyForCodexDefault(params = {}) {
54235444
return { error: '该功能已移除' };
54245445
}
54255446

5447+
function readLocalBridgeSettings() {
5448+
const defaults = { enabled: false, lastActiveProvider: '', lastModel: '', excludedProviders: [] };
5449+
try {
5450+
if (!fs.existsSync(LOCAL_BRIDGE_SETTINGS_FILE)) return defaults;
5451+
const raw = JSON.parse(fs.readFileSync(LOCAL_BRIDGE_SETTINGS_FILE, 'utf-8'));
5452+
return {
5453+
enabled: !!raw.enabled,
5454+
lastActiveProvider: typeof raw.lastActiveProvider === 'string' ? raw.lastActiveProvider.trim() : '',
5455+
lastModel: typeof raw.lastModel === 'string' ? raw.lastModel.trim() : '',
5456+
excludedProviders: Array.isArray(raw.excludedProviders) ? raw.excludedProviders.filter(p => typeof p === 'string') : []
5457+
};
5458+
} catch (e) {
5459+
return defaults;
5460+
}
5461+
}
5462+
5463+
function writeLocalBridgeSettings(settings) {
5464+
fs.writeFileSync(LOCAL_BRIDGE_SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf-8');
5465+
}
5466+
5467+
function toggleLocalBridgeProvider(params = {}) {
5468+
const enable = !!params.enable;
5469+
const settings = readLocalBridgeSettings();
5470+
try {
5471+
const config = readConfig();
5472+
const currentProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : '';
5473+
const currentModel = typeof config.model === 'string' ? config.model.trim() : '';
5474+
5475+
if (enable) {
5476+
if (currentProvider === 'local') return { success: true, enabled: true, notice: '已启用 local 转换' };
5477+
settings.lastActiveProvider = currentProvider;
5478+
settings.lastModel = currentModel;
5479+
settings.enabled = true;
5480+
writeLocalBridgeSettings(settings);
5481+
let content = fs.readFileSync(CONFIG_FILE, 'utf-8');
5482+
content = content.replace(/^(model_provider\s*=\s*)(["']).*?(["'])/m, `$1$2local$3`);
5483+
writeConfig(content);
5484+
return { success: true, enabled: true, previousProvider: currentProvider };
5485+
} else {
5486+
if (currentProvider !== 'local') {
5487+
settings.enabled = false;
5488+
writeLocalBridgeSettings(settings);
5489+
return { success: true, enabled: false, notice: 'local 转换未启用' };
5490+
}
5491+
const restoreProvider = settings.lastActiveProvider || '';
5492+
if (!restoreProvider) {
5493+
settings.enabled = false;
5494+
writeLocalBridgeSettings(settings);
5495+
return { success: true, enabled: false, notice: '已关闭 local 转换(无历史 provider 可恢复)' };
5496+
}
5497+
let content = fs.readFileSync(CONFIG_FILE, 'utf-8');
5498+
content = content.replace(/^(model_provider\s*=\s*)(["']).*?(["'])/m, `$1$2${restoreProvider}$3`);
5499+
if (settings.lastModel) {
5500+
content = content.replace(/^(model\s*=\s*)(["']).*?(["'])/m, `$1$2${settings.lastModel}$3`);
5501+
}
5502+
writeConfig(content);
5503+
settings.enabled = false;
5504+
writeLocalBridgeSettings(settings);
5505+
return { success: true, enabled: false, restoredProvider: restoreProvider, restoredModel: settings.lastModel };
5506+
}
5507+
} catch (e) {
5508+
return { error: e && e.message ? e.message : '操作失败' };
5509+
}
5510+
}
5511+
5512+
function getLocalBridgeStatus() {
5513+
const settings = readLocalBridgeSettings();
5514+
let currentProvider = '';
5515+
try {
5516+
const config = readConfig();
5517+
currentProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : '';
5518+
} catch (e) { /* ignore */ }
5519+
return {
5520+
enabled: settings.enabled,
5521+
active: currentProvider === 'local',
5522+
excludedProviders: settings.excludedProviders,
5523+
lastActiveProvider: settings.lastActiveProvider,
5524+
lastModel: settings.lastModel
5525+
};
5526+
}
5527+
5528+
function setLocalBridgeExcludedProviders(params = {}) {
5529+
const names = Array.isArray(params.names) ? params.names.filter(n => typeof n === 'string' && n.trim()) : [];
5530+
const settings = readLocalBridgeSettings();
5531+
settings.excludedProviders = names;
5532+
writeLocalBridgeSettings(settings);
5533+
return { success: true, excludedProviders: names };
5534+
}
5535+
5536+
function getLocalBridgeExcludedProviders() {
5537+
const settings = readLocalBridgeSettings();
5538+
return { excludedProviders: settings.excludedProviders };
5539+
}
5540+
54265541
function removeClaudeSessionIndexEntry(indexPath, sessionFilePath, sessionId) {
54275542
if (!indexPath || !fs.existsSync(indexPath)) {
54285543
return { removed: false, entry: null };
@@ -8132,8 +8247,8 @@ function cmdAdd(name, baseUrl, apiKey, silent = false, options = {}) {
81328247
throw new Error('提供商名称不可用');
81338248
}
81348249
if (isBuiltinProxyProvider(providerName)) {
8135-
if (!silent) console.error('错误: codexmate-proxy 为保留名称,不可手动添加');
8136-
throw new Error('codexmate-proxy 为保留名称,不可手动添加');
8250+
if (!silent) console.error(`错误: ${providerName} 为保留名称,不可手动添加`);
8251+
throw new Error(`${providerName} 为保留名称,不可手动添加`);
81378252
}
81388253
if (!isValidHttpUrl(providerBaseUrl)) {
81398254
if (!silent) console.error('错误: URL 仅支持 http/https');
@@ -8229,7 +8344,7 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
82298344
throw new Error('提供商名称必填');
82308345
}
82318346
if (isNonEditableProvider(name) && !allowManaged) {
8232-
const msg = 'codexmate-proxy 为保留名称,不可编辑';
8347+
const msg = `${name} 为保留名称,不可编辑`;
82338348
if (!silent) console.error(`错误: ${msg}`);
82348349
throw new Error(msg);
82358350
}
@@ -9961,6 +10076,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
996110076
});
996210077
res.end(body, 'utf-8');
996310078
};
10079+
if (typeof localBridgeHandler === 'function' && localBridgeHandler(req, res)) {
10080+
return;
10081+
}
996410082
if (typeof openaiBridgeHandler === 'function' && openaiBridgeHandler(req, res)) {
996510083
return;
996610084
}
@@ -10497,6 +10615,18 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
1049710615
case 'proxy-apply-provider':
1049810616
result = applyBuiltinProxyProvider(params || {});
1049910617
break;
10618+
case 'local-bridge-toggle':
10619+
result = toggleLocalBridgeProvider(params || {});
10620+
break;
10621+
case 'local-bridge-status':
10622+
result = getLocalBridgeStatus();
10623+
break;
10624+
case 'local-bridge-set-excluded':
10625+
result = setLocalBridgeExcludedProviders(params || {});
10626+
break;
10627+
case 'local-bridge-get-excluded':
10628+
result = getLocalBridgeExcludedProviders();
10629+
break;
1050010630
case 'workflow-list':
1050110631
result = listWorkflowDefinitions();
1050210632
break;

cli/config-bootstrap.js

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ function createConfigBootstrapController(deps = {}) {
3030
DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT,
3131
CODEXMATE_MANAGED_MARKER,
3232
BUILTIN_PROXY_PROVIDER_NAME,
33+
BUILTIN_LOCAL_PROVIDER_NAME,
3334
EMPTY_CONFIG_FALLBACK_TEMPLATE
3435
} = deps;
3536

@@ -58,6 +59,7 @@ function createConfigBootstrapController(deps = {}) {
5859
if (!Array.isArray(DEFAULT_MODELS)) throw new Error('createConfigBootstrapController 缺少 DEFAULT_MODELS');
5960
if (!CODEXMATE_MANAGED_MARKER) throw new Error('createConfigBootstrapController 缺少 CODEXMATE_MANAGED_MARKER');
6061
if (!BUILTIN_PROXY_PROVIDER_NAME) throw new Error('createConfigBootstrapController 缺少 BUILTIN_PROXY_PROVIDER_NAME');
62+
if (!BUILTIN_LOCAL_PROVIDER_NAME) throw new Error('createConfigBootstrapController 缺少 BUILTIN_LOCAL_PROVIDER_NAME');
6163
if (typeof EMPTY_CONFIG_FALLBACK_TEMPLATE !== 'string') throw new Error('createConfigBootstrapController 缺少 EMPTY_CONFIG_FALLBACK_TEMPLATE');
6264

6365
let initNotice = '';
@@ -118,17 +120,18 @@ function createConfigBootstrapController(deps = {}) {
118120
return `${CODEXMATE_MANAGED_MARKER}
119121
# codexmate-initialized-at: ${initializedAt}
120122
121-
model_provider = "openai"
123+
model_provider = "local"
122124
model = "${defaultModel}"
123125
model_context_window = ${DEFAULT_MODEL_CONTEXT_WINDOW}
124126
model_auto_compact_token_limit = ${DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT}
125127
126-
[model_providers.openai]
127-
name = "openai"
128-
base_url = "https://api.openai.com/v1"
128+
[model_providers.local]
129+
name = "local"
130+
base_url = "http://127.0.0.1:3737/bridge/local/v1"
129131
wire_api = "responses"
130-
requires_openai_auth = false
131-
preferred_auth_method = ""
132+
requires_openai_auth = true
133+
preferred_auth_method = "codexmate"
134+
codexmate_bridge = "local"
132135
request_max_retries = 4
133136
stream_max_retries = 10
134137
stream_idle_timeout_ms = 300000
@@ -145,9 +148,8 @@ stream_idle_timeout_ms = 300000
145148
const currentProvider = typeof safeConfig.model_provider === 'string' ? safeConfig.model_provider.trim() : '';
146149
const hasRemovedBuiltin = !!(providers && providers[BUILTIN_PROXY_PROVIDER_NAME]);
147150
const currentIsRemovedBuiltin = currentProvider === BUILTIN_PROXY_PROVIDER_NAME;
148-
const currentIsRemovedVirtualLocal = currentProvider === 'local' && !(providers && isPlainObject(providers.local));
149151

150-
if (!hasRemovedBuiltin && !currentIsRemovedBuiltin && !currentIsRemovedVirtualLocal) {
152+
if (!hasRemovedBuiltin && !currentIsRemovedBuiltin) {
151153
return safeConfig;
152154
}
153155

@@ -163,11 +165,26 @@ stream_idle_timeout_ms = 300000
163165
return {
164166
...safeConfig,
165167
model_providers: nextProviders,
166-
model_provider: (currentIsRemovedBuiltin || currentIsRemovedVirtualLocal) ? fallbackProvider : safeConfig.model_provider,
167-
model: (currentIsRemovedBuiltin || currentIsRemovedVirtualLocal) ? fallbackModel : safeConfig.model
168+
model_provider: currentIsRemovedBuiltin ? fallbackProvider : safeConfig.model_provider,
169+
model: currentIsRemovedBuiltin ? fallbackModel : safeConfig.model
168170
};
169171
}
170172

173+
function ensureLocalProviderSection() {
174+
if (!fs.existsSync(CONFIG_FILE)) return;
175+
let content;
176+
try {
177+
content = fs.readFileSync(CONFIG_FILE, 'utf-8');
178+
} catch (e) {
179+
return;
180+
}
181+
// Check if [model_providers.local] section already exists
182+
if (/\[model_providers\.local\]/.test(content)) return;
183+
184+
const localSection = `\n[model_providers.local]\nname = "local"\nbase_url = "http://127.0.0.1:3737/bridge/local/v1"\nwire_api = "responses"\nrequires_openai_auth = true\npreferred_auth_method = "codexmate"\ncodexmate_bridge = "local"\nrequest_max_retries = 4\nstream_max_retries = 10\nstream_idle_timeout_ms = 300000\n`;
185+
fs.appendFileSync(CONFIG_FILE, localSection, 'utf-8');
186+
}
187+
171188
function readConfigOrVirtualDefault() {
172189
if (fs.existsSync(CONFIG_FILE)) {
173190
try {
@@ -260,7 +277,7 @@ stream_idle_timeout_ms = 300000
260277
ensureConfigDir();
261278

262279
const initializedAt = new Date().toISOString();
263-
const defaultProvider = 'openai';
280+
const defaultProvider = 'local';
264281
const defaultModel = DEFAULT_MODELS[0] || 'gpt-4';
265282
const forceResetExistingConfig = process.env.CODEXMATE_FORCE_RESET_EXISTING_CONFIG === '1';
266283
const mark = readJsonFile(INIT_MARK_FILE, null);
@@ -273,6 +290,7 @@ stream_idle_timeout_ms = 300000
273290
initNotice = '检测到配置缺失,已自动重建默认配置。';
274291
return { notice: initNotice };
275292
}
293+
ensureLocalProviderSection();
276294
ensureSupportFiles(defaultProvider, defaultModel);
277295
return { notice: '' };
278296
}
@@ -338,7 +356,7 @@ stream_idle_timeout_ms = 300000
338356
function resetConfigToDefault() {
339357
ensureConfigDir();
340358
const initializedAt = new Date().toISOString();
341-
const defaultProvider = 'openai';
359+
const defaultProvider = 'local';
342360
const defaultModel = DEFAULT_MODELS[0] || 'gpt-4';
343361

344362
let backupFile = '';

0 commit comments

Comments
 (0)