Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ab398be
feat(local-ai): isolate local gateway console
awsl233777 Jul 4, 2026
7d9b380
fix(local-ai): tighten provider setup controls
awsl233777 Jul 4, 2026
47af59d
style(local-ai): merge gateway summary cards
awsl233777 Jul 4, 2026
ffe1677
fix(local-ai): support editing providers
awsl233777 Jul 4, 2026
914421a
fix(local-ai): show client base url
awsl233777 Jul 4, 2026
4209db3
style(local-ai): improve text contrast
awsl233777 Jul 4, 2026
710a4d3
feat(local-ai): refine local service gateway
awsl233777 Jul 5, 2026
ee6b13e
fix(local-ai): reject stale loopback upstreams
awsl233777 Jul 5, 2026
32158d5
fix(local-ai): reject html upstream chat responses
awsl233777 Jul 5, 2026
4dc5796
fix(local-ai): report upstream TLS mismatches
awsl233777 Jul 5, 2026
0c13b7a
fix(local-ai): fall back from html upstream routes
awsl233777 Jul 5, 2026
8d2d41c
fix(local-ai): retry unversioned streaming routes
awsl233777 Jul 5, 2026
46927fa
fix(proxy): block loopback https default port
awsl233777 Jul 5, 2026
33bcf90
fix(local-ai): sanitize upstream html errors
awsl233777 Jul 5, 2026
3b119a5
test(local-ai): assert upstream auth on fallback
awsl233777 Jul 5, 2026
fae24dc
feat(local-ai): copy response details
awsl233777 Jul 5, 2026
c804104
fix(local-ai): place response copy beside title
awsl233777 Jul 5, 2026
0917133
fix(local-ai): prefer provider auth for upstream
awsl233777 Jul 5, 2026
1c895ed
feat(local-ai): copy upstream curl details
awsl233777 Jul 5, 2026
89c71ca
fix(local-ai): expose upstream auth request details
awsl233777 Jul 5, 2026
d382639
fix(local-ai): keep upstream curl credentials visible
awsl233777 Jul 5, 2026
6fa93b3
fix(local-ai): protect provider secrets in bridge UI
awsl233777 Jul 5, 2026
4fd79e7
fix(local-ai): show in-flight request logs
awsl233777 Jul 5, 2026
e06de53
fix(local-ai): pin request detail and reject stale loopback upstreams
awsl233777 Jul 5, 2026
2720f06
fix(local-ai): fail over from html upstream providers
awsl233777 Jul 5, 2026
c88f8a0
fix(local-ai): try compatible chat upstream routes
awsl233777 Jul 5, 2026
15efb09
fix(local-ai): send user agent to upstreams
awsl233777 Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
799 changes: 729 additions & 70 deletions cli.js

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions cli/builtin-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,40 @@ function createBuiltinProxyRuntimeController(deps = {}) {
return false;
}

function isLoopbackHostname(hostname) {
const value = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
return value === 'localhost' || value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
}

function describeLoopbackHttpsDefaultPortTarget(targetUrl) {
try {
const parsed = new URL(targetUrl);
if (parsed.protocol !== 'https:' || !isLoopbackHostname(parsed.hostname)) return '';
if (parsed.port && parsed.port !== '443') return '';
parsed.username = '';
parsed.password = '';
return `Refusing loopback HTTPS default-port upstream (${parsed.toString()}); use the real upstream URL, or http://127.0.0.1:<port>/v1 for a local HTTP upstream.`;
} catch (_) {
return '';
}
}

function isLikelyHtmlBody(bodyText) {
const text = String(bodyText || '').trimStart();
return /^<!doctype\s+html\b/i.test(text) || /^<html\b/i.test(text);
}

function isHtmlUpstreamResult(result) {
const headers = result && result.headers && typeof result.headers === 'object' ? result.headers : {};
const contentType = String(headers['content-type'] || headers['Content-Type'] || '').toLowerCase();
return contentType.includes('text/html') || isLikelyHtmlBody(result && result.bodyText);
}

function sendHtmlUpstreamDiagnostic(res) {
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'Upstream returned HTML instead of OpenAI-compatible JSON; check the provider base URL/path because it may point at an admin dashboard or browser challenge page.' }));
}

const TRANSIENT_RETRY_DELAYS_MS = [200, 600, 1200];

async function retryTransientRequest(executor) {
Expand Down Expand Up @@ -203,6 +237,8 @@ function createBuiltinProxyRuntimeController(deps = {}) {

function proxyRequestJson(targetUrl, options = {}) {
const parsed = new URL(targetUrl);
const loopbackIssue = describeLoopbackHttpsDefaultPortTarget(targetUrl);
if (loopbackIssue) return Promise.resolve({ ok: false, error: loopbackIssue });
const transport = parsed.protocol === 'https:' ? https : http;
const bodyText = options.body ? JSON.stringify(options.body) : '';
const headers = {
Expand Down Expand Up @@ -1508,6 +1544,8 @@ function createBuiltinProxyRuntimeController(deps = {}) {

function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
const parsed = new URL(targetUrl);
const loopbackIssue = describeLoopbackHttpsDefaultPortTarget(targetUrl);
if (loopbackIssue) return Promise.resolve({ ok: false, error: loopbackIssue });
const transport = parsed.protocol === 'https:' ? https : http;
const bodyText = options.body ? JSON.stringify(options.body) : '';
const headers = {
Expand Down Expand Up @@ -1678,6 +1716,8 @@ function createBuiltinProxyRuntimeController(deps = {}) {

function streamResponsesSse(targetUrl, options = {}) {
const parsed = new URL(targetUrl);
const loopbackIssue = describeLoopbackHttpsDefaultPortTarget(targetUrl);
if (loopbackIssue) return Promise.resolve({ ok: false, error: loopbackIssue });
const transport = parsed.protocol === 'https:' ? https : http;
const bodyText = options.body ? JSON.stringify(options.body) : '';
const headers = {
Expand Down Expand Up @@ -2204,6 +2244,10 @@ function createBuiltinProxyRuntimeController(deps = {}) {
: false;
if (!canFallbackToChat) {
if (!res.headersSent) {
if (isHtmlUpstreamResult(streamedResponses)) {
sendHtmlUpstreamDiagnostic(res);
return;
}
Comment on lines +2247 to +2250

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve upstream headers before checking streaming HTML errors.

streamResponsesSse and streamChatCompletionsAsResponsesSse return error results without headers on HTTP >= 400, so these new checks miss Content-Type: text/html unless the body starts with an HTML tag. Include headers: upstreamRes.headers || {} in those failure/retry results before relying on isHtmlUpstreamResult.

Proposed fix
- upstreamRes.on('end', () => finish({ ok: false, status, bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : '' }));
+ upstreamRes.on('end', () => finish({
+     ok: false,
+     status,
+     headers: upstreamRes.headers || {},
+     bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
+ }));

Apply the same shape in both streaming helpers’ non-OK result paths.

Also applies to: 2274-2277

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/builtin-proxy.js` around lines 2247 - 2250, The streaming error results
from streamResponsesSse and streamChatCompletionsAsResponsesSse are missing
upstream headers on HTTP >= 400, so isHtmlUpstreamResult cannot detect text/html
responses reliably. Update both helpers’ non-OK failure/retry return paths to
include headers: upstreamRes.headers || {} in the result shape, and keep the
existing checks in builtin-proxy.js so the HTML diagnostic can trigger from
Content-Type even when the body is not HTML-tag-prefixed.

const status = streamedResponses.status && streamedResponses.status >= 400 ? streamedResponses.status : 502;
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(streamedResponses.bodyText || JSON.stringify({ error: streamedResponses.error || 'proxy request failed' }));
Expand All @@ -2227,6 +2271,10 @@ function createBuiltinProxyRuntimeController(deps = {}) {
});
if (!streamed.ok) {
if (!res.headersSent) {
if (isHtmlUpstreamResult(streamed)) {
sendHtmlUpstreamDiagnostic(res);
return;
}
res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'proxy request failed' }));
} else if (!res.writableEnded) {
Expand Down Expand Up @@ -2272,6 +2320,10 @@ function createBuiltinProxyRuntimeController(deps = {}) {

if (upstreamResponses.ok && upstreamResponses.status >= 400) {
if (!shouldFallbackFromUpstreamResponses(upstreamResponses.status, upstreamResponses.bodyText)) {
if (isHtmlUpstreamResult(upstreamResponses)) {
sendHtmlUpstreamDiagnostic(res);
return;
}
res.writeHead(upstreamResponses.status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(upstreamResponses.bodyText || JSON.stringify({ error: 'Upstream error' }));
return;
Expand Down Expand Up @@ -2302,6 +2354,10 @@ function createBuiltinProxyRuntimeController(deps = {}) {
}

if (upstreamChat.status >= 400) {
if (isHtmlUpstreamResult(upstreamChat)) {
sendHtmlUpstreamDiagnostic(res);
return;
}
res.writeHead(upstreamChat.status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(upstreamChat.bodyText || JSON.stringify({ error: 'Upstream error' }));
return;
Expand Down Expand Up @@ -2373,6 +2429,17 @@ function createBuiltinProxyRuntimeController(deps = {}) {
requestHeaders['x-forwarded-for'] = req.socket.remoteAddress;
}

const loopbackIssue = describeLoopbackHttpsDefaultPortTarget(targetUrl.toString());
if (loopbackIssue) {
const body = JSON.stringify({ error: loopbackIssue });
res.writeHead(502, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body, 'utf-8')
});
res.end(body, 'utf-8');
return;
}

const transport = targetUrl.protocol === 'https:' ? https : http;
const upstreamReq = transport.request({
protocol: targetUrl.protocol,
Expand Down
Loading
Loading