Skip to content

Commit ef09f77

Browse files
committed
feat(local-ai): isolate local gateway console
1 parent a088933 commit ef09f77

32 files changed

Lines changed: 5117 additions & 5283 deletions

cli.js

Lines changed: 575 additions & 68 deletions
Large diffs are not rendered by default.

cli/local-bridge.js

Lines changed: 112 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ function buildUpstreamPool(readConfigFn, openaiBridgeFile, excludedProviders) {
103103
const baseUrl = typeof p.base_url === 'string' ? p.base_url.trim() : '';
104104
if (!isValidHttpUrl(normalizeBaseUrl(baseUrl))) continue;
105105
const authMethod = typeof p.preferred_auth_method === 'string' ? p.preferred_auth_method.trim() : '';
106-
pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), authMethod, requiresOpenaiAuth: !!p.requires_openai_auth });
106+
const models = Array.isArray(p.models) ? p.models.filter(item => typeof item === 'string' && item.trim()).map(item => item.trim()) : [];
107+
const model = typeof p.selected_model === 'string' && p.selected_model.trim() ? p.selected_model.trim() : (models[0] || '');
108+
const temperature = typeof p.temperature === 'number' && Number.isFinite(p.temperature) ? p.temperature : null;
109+
pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), authMethod, requiresOpenaiAuth: !!p.requires_openai_auth, model, temperature });
107110
}
108111
if (pool.length === 0) return { error: '请先添加上游 provider' };
109112
return { pool };
@@ -151,8 +154,11 @@ function resolveUpstreamAuth(entry, openaiBridgeFile, reqAuthToken) {
151154
if (upstream && upstream.apiKey) {
152155
return upstream.apiKey.startsWith('Bearer ') ? upstream.apiKey : `Bearer ${upstream.apiKey}`;
153156
}
157+
return '';
154158
}
155-
return '';
159+
const providerToken = typeof entry.authMethod === 'string' ? entry.authMethod.trim() : '';
160+
if (!providerToken) return '';
161+
return /^Bearer\s+/i.test(providerToken) ? providerToken : `Bearer ${providerToken}`;
156162
}
157163

158164
function createLocalBridgeHttpHandler(options = {}) {
@@ -186,6 +192,20 @@ function createLocalBridgeHttpHandler(options = {}) {
186192
return { entry: pool[0], idx: 0 };
187193
}
188194

195+
function pickUpstreamExcluding(pool, excludedNames) {
196+
const now = Date.now();
197+
const excluded = excludedNames instanceof Set ? excludedNames : new Set();
198+
for (let i = 0; i < pool.length; i++) {
199+
const idx = rrIndex++ % pool.length;
200+
const entry = pool[idx];
201+
if (excluded.has(entry.name)) continue;
202+
const st = circuitState.get(entry.name);
203+
if (st && st.openUntil > now) continue;
204+
return { entry, idx };
205+
}
206+
return null;
207+
}
208+
189209
function recordFailure(name) {
190210
let st = circuitState.get(name);
191211
if (!st) { st = { failures: 0, openUntil: 0 }; circuitState.set(name, st); }
@@ -722,25 +742,98 @@ function createLocalBridgeHttpHandler(options = {}) {
722742
return;
723743
}
724744

725-
// passthrough for other v1/* paths
726-
const upstreamUrl = joinApiUrl(upstreamBase, normalizedSuffix);
727-
const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
728-
method: req.method || 'GET',
729-
body: null,
730-
headers: { ...(authHeader ? { Authorization: authHeader } : {}) },
731-
maxBytes: maxUpstreamBytes,
732-
httpAgent,
733-
httpsAgent
734-
}));
735-
if (!upstreamResult.ok) {
736-
recordFailure(entry.name);
737-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
738-
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));
745+
// OpenAI-compatible passthrough for /bridge/local/v1/* paths.
746+
// Automatic policy: retry transient errors, fail over to the next provider,
747+
// and let the circuit breaker freeze repeatedly failing providers.
748+
const method = (req.method || 'GET').toUpperCase();
749+
let passthroughBody = null;
750+
if (method !== 'GET' && method !== 'HEAD') {
751+
const bodyResult = await readRequestBody(req, maxBodySize);
752+
if (bodyResult.error) {
753+
res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
754+
res.end(JSON.stringify({ error: bodyResult.error }));
755+
return;
756+
}
757+
if (String(bodyResult.body || '').trim()) {
758+
const parsedBody = parseJsonOrError(bodyResult.body);
759+
if (parsedBody.error) {
760+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
761+
res.end(JSON.stringify({ error: `Invalid JSON: ${parsedBody.error}` }));
762+
return;
763+
}
764+
passthroughBody = parsedBody.value;
765+
}
766+
}
767+
768+
const attemptedProviders = [];
769+
const attemptedProviderNames = new Set();
770+
let lastResult = null;
771+
let lastEntry = null;
772+
const shouldFailover = (result) => {
773+
if (!result || !result.ok) return true;
774+
const status = Number(result.status || 0);
775+
return status === 401 || status === 403 || status === 429 || status >= 500;
776+
};
777+
778+
for (let attempt = 0; attempt < pool.length; attempt += 1) {
779+
const selected = attempt === 0 ? { entry, idx } : pickUpstreamExcluding(pool, attemptedProviderNames);
780+
if (!selected || !selected.entry) break;
781+
const attemptEntry = selected.entry;
782+
const attemptAuthHeader = resolveUpstreamAuth(attemptEntry, openaiBridgeFile, token);
783+
const attemptBase = attemptEntry.baseUrl.replace(/\/+$/, '');
784+
const upstreamUrl = joinApiUrl(attemptBase, normalizedSuffix);
785+
let attemptBody = passthroughBody;
786+
if (normalizedSuffix === 'chat/completions' && attemptBody && typeof attemptBody === 'object' && !Array.isArray(attemptBody)) {
787+
attemptBody = { ...attemptBody };
788+
if (!attemptBody.model && attemptEntry.model) {
789+
attemptBody.model = attemptEntry.model;
790+
}
791+
if (attemptBody.temperature === undefined && attemptEntry.temperature !== null && attemptEntry.temperature !== undefined) {
792+
attemptBody.temperature = attemptEntry.temperature;
793+
}
794+
}
795+
attemptedProviders.push(attemptEntry.name);
796+
attemptedProviderNames.add(attemptEntry.name);
797+
const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
798+
method,
799+
body: attemptBody,
800+
headers: {
801+
...(attemptAuthHeader ? { Authorization: attemptAuthHeader } : {}),
802+
...(attemptBody !== null ? { 'Content-Type': 'application/json' } : {})
803+
},
804+
maxBytes: maxUpstreamBytes,
805+
httpAgent,
806+
httpsAgent
807+
}));
808+
lastResult = upstreamResult;
809+
lastEntry = attemptEntry;
810+
if (!shouldFailover(upstreamResult)) {
811+
recordSuccess(attemptEntry.name);
812+
res.writeHead(upstreamResult.status, {
813+
'Content-Type': 'application/json; charset=utf-8',
814+
'X-Codexmate-Upstream-Provider': attemptEntry.name,
815+
'X-Codexmate-Attempted-Providers': attemptedProviders.join(',')
816+
});
817+
res.end(upstreamResult.bodyText);
818+
return;
819+
}
820+
recordFailure(attemptEntry.name);
821+
}
822+
823+
if (!lastResult || !lastResult.ok) {
824+
res.writeHead(502, {
825+
'Content-Type': 'application/json; charset=utf-8',
826+
'X-Codexmate-Attempted-Providers': attemptedProviders.join(',')
827+
});
828+
res.end(JSON.stringify({ error: `Upstream request failed: ${lastResult && lastResult.error ? lastResult.error : 'unknown error'}` }));
739829
return;
740830
}
741-
recordSuccess(entry.name);
742-
res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
743-
res.end(upstreamResult.bodyText);
831+
res.writeHead(lastResult.status || 502, {
832+
'Content-Type': 'application/json; charset=utf-8',
833+
'X-Codexmate-Upstream-Provider': lastEntry ? lastEntry.name : '',
834+
'X-Codexmate-Attempted-Providers': attemptedProviders.join(',')
835+
});
836+
res.end(lastResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
744837
} catch (e) {
745838
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
746839
res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' }));

0 commit comments

Comments
 (0)