From d47052603175048010d06b01771ae1d4c68531eb Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Sat, 18 Jul 2026 20:13:13 +0200 Subject: [PATCH] Honor provider proxies for The Old LLM Vercel blocks (#7380) * fix(theoldllm): honor provider proxy for Vercel blocks * fix(theoldllm): fail closed when assigned proxy is unavailable * refactor(theoldllm): extract proxy guards into dedicated module --- open-sse/executors/theoldllm.ts | 218 +++++++++++++++----- src/lib/db/proxies.ts | 64 +----- src/lib/db/proxies/guards.ts | 81 ++++++++ tests/theoldllm-stress.test.ts | 72 +++++-- tests/unit/theoldllm-provider-proxy.test.ts | 60 ++++++ 5 files changed, 363 insertions(+), 132 deletions(-) create mode 100644 src/lib/db/proxies/guards.ts create mode 100644 tests/unit/theoldllm-provider-proxy.test.ts diff --git a/open-sse/executors/theoldllm.ts b/open-sse/executors/theoldllm.ts index 64e985b96c..8acc471b3a 100644 --- a/open-sse/executors/theoldllm.ts +++ b/open-sse/executors/theoldllm.ts @@ -108,6 +108,23 @@ export function mapModel(model: string): string { const TOKEN_SEED = "oldllm-client-2026"; const UA_PREFIX = CHROME_UA.slice(0, 20); // "Mozilla/5.0 (Windows" +type TheOldLlmProxy = { + type?: string; + host: string; + port: number; + username?: string | null; + password?: string | null; +} | null; + +interface TheOldLlmFetchDependencies { + resolveProxy: () => Promise; + runWithProxy: (proxy: TheOldLlmProxy, request: () => Promise) => Promise; + fetch: typeof fetch; + hasBlockingProxyAssignment?: () => boolean; +} + +class TheOldLlmProxyUnavailableError extends Error {} + export function generateRequestToken(): string { const n = Date.now(); const e = `${n}-${TOKEN_SEED}-${UA_PREFIX}`; @@ -127,6 +144,44 @@ export const tokenCache: { value: string; expiresAt: number } = { value: "", exp // ── Direct Node.js fetch ────────────────────────────────────────────────── +export async function fetchTheOldLlmWithProviderProxy( + reqBody: Record, + signal: AbortSignal, + dependencies?: TheOldLlmFetchDependencies +): Promise { + let deps = dependencies; + if (!deps) { + const [ + { resolveProxyForProvider, hasBlockingProxyAssignmentForProvider }, + { runWithProxyContext }, + ] = await Promise.all([import("../../src/lib/db/proxies"), import("../utils/proxyFetch.ts")]); + deps = { + resolveProxy: () => resolveProxyForProvider("theoldllm"), + runWithProxy: runWithProxyContext, + fetch: globalThis.fetch, + hasBlockingProxyAssignment: () => hasBlockingProxyAssignmentForProvider("theoldllm"), + }; + } + + const proxy = await deps.resolveProxy(); + if (!proxy && deps.hasBlockingProxyAssignment?.()) { + throw new TheOldLlmProxyUnavailableError("No active proxy is available for The Old LLM"); + } + return deps.runWithProxy(proxy, () => + deps.fetch(API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Client-Version": "3.8.4", + "X-Request-Token": generateRequestToken(), + "User-Agent": CHROME_UA, + }, + body: JSON.stringify(reqBody), + signal, + }) + ); +} + async function directFetch( reqBody: Record, signal?: AbortSignal | null @@ -137,23 +192,26 @@ async function directFetch( signal?.addEventListener("abort", onSignal!, { once: true }); try { - return await fetch(API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Client-Version": "3.8.4", - "X-Request-Token": generateRequestToken(), - "User-Agent": CHROME_UA, - }, - body: JSON.stringify(reqBody), - signal: controller.signal, - }); + // No-auth providers do not have a connection row, so chatCore cannot apply + // a connection-scoped proxy context for them. Resolve the provider/global + // assignment explicitly; otherwise The Old LLM always leaks out through the + // VPS address and Vercel's bot protection denies every model. + return await fetchTheOldLlmWithProviderProxy(reqBody, controller.signal); } finally { clearTimeout(timer); if (onSignal) signal?.removeEventListener("abort", onSignal); } } +export function isVercelMitigationResponse(response: Response, body: string): boolean { + const mitigation = response.headers.get("x-vercel-mitigated")?.toLowerCase(); + if (mitigation === "deny" || mitigation === "challenge") return true; + return ( + (response.status === 403 || response.status === 429) && + /vercel security checkpoint|"message"\s*:\s*"forbidden"/i.test(body) + ); +} + function isTokenRejected(status: number, body: string): boolean { if (status === 401 || status === 403) return true; try { @@ -211,6 +269,45 @@ function buildErrorResponse(status: number, body: string): string { }); } +function buildVercelMitigationError(): string { + return JSON.stringify({ + error: { + message: + "The Old LLM is blocked by Vercel for this server egress IP. Configure a residential provider or global proxy for 'theoldllm' and retry.", + type: "upstream_access_denied", + code: "THEOLDLLM_VERCEL_MITIGATED", + }, + }); +} + +function buildProxyUnavailableError(): string { + return JSON.stringify({ + error: { + message: + "The Old LLM proxy assignment has no active proxies. Configure or enable a proxy and retry.", + type: "proxy_unavailable", + code: "THEOLDLLM_PROXY_UNAVAILABLE", + }, + }); +} + +async function fetchUpstreamWithRetry( + reqBody: Record, + signal: AbortSignal | null | undefined, + log: ExecuteInput["log"] +): Promise<{ response: Response; body: string; vercelMitigated: boolean }> { + let response = await directFetch(reqBody, signal); + let body = await response.text(); + let vercelMitigated = isVercelMitigationResponse(response, body); + if (!vercelMitigated && isTokenRejected(response.status, body)) { + log?.warn?.("THEOLDLLM", `Token rejected (${response.status}), retrying with fresh token…`); + response = await directFetch(reqBody, signal); + body = await response.text(); + vercelMitigated = isVercelMitigationResponse(response, body); + } + return { response, body, vercelMitigated }; +} + // ── Executor ────────────────────────────────────────────────────────────── export class TheOldLlmExecutor extends BaseExecutor { @@ -237,27 +334,37 @@ export class TheOldLlmExecutor extends BaseExecutor { return body; } + private executionResult(input: ExecuteInput, response: Response, body: unknown) { + return { + response, + url: API_URL, + headers: this.buildHeaders(input.credentials), + transformedBody: body, + }; + } + async testConnection( _credentials: ProviderCredentials, _signal?: AbortSignal | null, log?: ExecuteInput["log"] ): Promise { try { - const resp = await fetch(API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Client-Version": "3.8.4", - "X-Request-Token": generateRequestToken(), - "User-Agent": CHROME_UA, - }, - body: JSON.stringify({ + const resp = await directFetch( + { model: "GPT_5_4", messages: [{ role: "user", content: "ping" }], stream: false, - }), - signal: _signal ?? undefined, - }); + }, + _signal + ); + const body = await resp.text(); + if (!resp.ok && isVercelMitigationResponse(resp, body)) { + log?.warn?.( + "THEOLDLLM", + "Vercel blocked this egress IP; configure a residential provider proxy" + ); + return false; + } return resp.status === 200; } catch { log?.warn?.("THEOLDLLM", "testConnection network error"); @@ -297,56 +404,55 @@ export class TheOldLlmExecutor extends BaseExecutor { stream: true, }; - let upstream = await directFetch(reqBody, signal); - let finalBody = await upstream.text(); - - if (isTokenRejected(upstream.status, finalBody)) { - log?.warn?.("THEOLDLLM", `Token rejected (${upstream.status}), retrying with fresh token…`); - upstream = await directFetch(reqBody, signal); - finalBody = await upstream.text(); - } + const { + response: upstream, + body: finalBody, + vercelMitigated, + } = await fetchUpstreamWithRetry(reqBody, signal, log); if (upstream.status === 200 && finalBody) { const payload = stream ? finalBody : buildChatCompletion(parseSseContent(finalBody), model); - return { - response: new Response(encoder.encode(payload), { + return this.executionResult( + input, + new Response(encoder.encode(payload), { status: 200, headers: { "Content-Type": stream ? "text/event-stream" : "application/json", "Cache-Control": "no-cache", }, }), - url: API_URL, - headers: this.buildHeaders(input.credentials), - transformedBody: body, - }; + body + ); } - return { - response: new Response(encoder.encode(buildErrorResponse(upstream.status, finalBody)), { + const errorPayload = vercelMitigated + ? buildVercelMitigationError() + : buildErrorResponse(upstream.status, finalBody); + return this.executionResult( + input, + new Response(encoder.encode(errorPayload), { status: upstream.status, headers: { "Content-Type": "application/json" }, }), - url: API_URL, - headers: this.buildHeaders(input.credentials), - transformedBody: body, - }; + body + ); } catch (err) { + const proxyUnavailable = err instanceof TheOldLlmProxyUnavailableError; const msg = err instanceof Error ? err.message : String(err); log?.error?.("THEOLDLLM", `Executor error: ${msg}`); - return { - response: new Response( - encoder.encode( - JSON.stringify({ - error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" }, - }) - ), - { status: 502, headers: { "Content-Type": "application/json" } } - ), - url: API_URL, - headers: this.buildHeaders(input.credentials), - transformedBody: body, - }; + const errorPayload = proxyUnavailable + ? buildProxyUnavailableError() + : JSON.stringify({ + error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" }, + }); + return this.executionResult( + input, + new Response(encoder.encode(errorPayload), { + status: proxyUnavailable ? 503 : 502, + headers: { "Content-Type": "application/json" }, + }), + body + ); } } } diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 1f05128045..a32b6b59d6 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -31,6 +31,11 @@ import { coerceProxyPayload, redactProxySecrets, } from "./proxies/mappers"; +import { isGlobalProxyEnabled, PROXY_ALIVE_PREDICATE } from "./proxies/guards"; +export { + hasBlockingProxyAssignment, + hasBlockingProxyAssignmentForProvider, +} from "./proxies/guards"; export { extractRelayAuth, redactProxySecrets } from "./proxies/mappers"; let proxyRegistryGeneration = 0; @@ -822,9 +827,6 @@ export async function deleteProxyById(id: string, options?: { force?: boolean }) // usable so a working proxy is never stranded; only known-dead states are // excluded so a dead proxy stops being handed out (every request would // otherwise pay the timeout or leak out the host IP). -const PROXY_ALIVE_PREDICATE = - "(p.status IS NULL OR LOWER(p.status) NOT IN ('inactive','error','disabled','dead','down'))"; - // Resolve one scope's alive pool to a single proxy via its rotation strategy. // Returns the standard registry resolution shape, or null when the pool is empty // or every member is dead (preserving the #6246 fail-closed contract — a dead @@ -908,59 +910,6 @@ export async function resolveProxyForScopeFromRegistry(scope: string, scopeId?: } } -/** - * #6246 fail-closed guard. Returns true when a connection would egress DIRECTLY - * ONLY because its ASSIGNED proxy (account/provider/global scope) is dead/inactive - * — i.e. the request must be BLOCKED, not silently sent on the real IP. - * - * Callers use this after `resolveProxyForConnection` returns a direct result: if - * the operator assigned a proxy but every assigned proxy is dead, leaking the IP - * is worse than failing the request. An explicit "proxy off" (global or per - * connection) is a deliberate direct choice and is NOT treated as a leak. Read-only - * and best-effort: any DB error fails OPEN (returns false) so a guard never breaks - * the request path. - */ -export function hasBlockingProxyAssignment(connectionId: string): boolean { - try { - const db = getDbInstance(); - - // Explicit global "proxy off" → direct is intended, never a leak. - const globalRow = db - .prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'proxyEnabled'") - .get() as { value?: string } | undefined; - if (globalRow?.value) { - try { - if (JSON.parse(globalRow.value) === false) return false; - } catch { - /* malformed → treat as enabled */ - } - } - - // Explicit per-connection "proxy off" → direct is intended. - const conn = db - .prepare("SELECT provider, proxy_enabled FROM provider_connections WHERE id = ?") - .get(connectionId) as { provider?: string | null; proxy_enabled?: number } | undefined; - if (conn && conn.proxy_enabled === 0) return false; - const provider = conn?.provider ?? null; - - // A proxy is assigned to this connection at some scope, but every assigned - // proxy is dead (the alive filter would have resolved a live one already). - const dead = db - .prepare( - `SELECT 1 FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id - WHERE ((a.scope = 'account' AND a.scope_id = ?) - OR (a.scope = 'provider' AND a.scope_id = ?) - OR (a.scope = 'global')) - AND NOT ${PROXY_ALIVE_PREDICATE} - LIMIT 1` - ) - .get(connectionId, provider); - return !!dead; - } catch { - return false; - } -} - export async function migrateLegacyProxyConfigToRegistry(options?: { force?: boolean }) { const force = options?.force === true; const db = getDbInstance(); @@ -1127,6 +1076,9 @@ export async function bulkAssignProxyToScope( */ export async function resolveProxyForProvider(providerId: string) { try { + const db = getDbInstance(); + if (!isGlobalProxyEnabled(db)) return null; + // Resolve by specificity across both storage backends. The GUI Custom tab // still writes provider/global proxies to the legacy config, while Saved // Proxy uses the registry. A registry-global fallback must not shadow a diff --git a/src/lib/db/proxies/guards.ts b/src/lib/db/proxies/guards.ts new file mode 100644 index 0000000000..d0fd7c5069 --- /dev/null +++ b/src/lib/db/proxies/guards.ts @@ -0,0 +1,81 @@ +import { getDbInstance } from "../core"; + +export const PROXY_ALIVE_PREDICATE = + "(p.status IS NULL OR LOWER(p.status) NOT IN ('inactive','error','disabled','dead','down'))"; + +export function isGlobalProxyEnabled(db: ReturnType): boolean { + try { + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'proxyEnabled'") + .get() as { value?: string } | undefined; + if (!row?.value) return true; + try { + return JSON.parse(row.value) !== false; + } catch { + return true; + } + } catch { + return true; + } +} + +/** + * #6246 fail-closed guard for a connection with an assigned dead proxy pool. + * Explicitly disabling proxying globally or for the connection allows direct egress. + */ +export function hasBlockingProxyAssignment(connectionId: string): boolean { + try { + const db = getDbInstance(); + if (!isGlobalProxyEnabled(db)) return false; + + const conn = db + .prepare("SELECT provider, proxy_enabled FROM provider_connections WHERE id = ?") + .get(connectionId) as { provider?: string | null; proxy_enabled?: number } | undefined; + if (conn && conn.proxy_enabled === 0) return false; + const provider = conn?.provider ?? null; + const dead = db + .prepare( + `SELECT 1 FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id + WHERE ((a.scope = 'account' AND a.scope_id = ?) + OR (a.scope = 'provider' AND a.scope_id = ?) + OR (a.scope = 'global')) + AND NOT ${PROXY_ALIVE_PREDICATE} + LIMIT 1` + ) + .get(connectionId, provider); + return !!dead; + } catch { + return false; + } +} + +/** + * #7380 fail-closed guard for providers without a connection row. Returns true + * when a provider/global proxy assignment exists but all assigned proxies are known dead. + */ +export function hasBlockingProxyAssignmentForProvider(providerId: string): boolean { + try { + const db = getDbInstance(); + if (!isGlobalProxyEnabled(db)) return false; + + const assignments = db + .prepare( + `SELECT + EXISTS( + SELECT 1 FROM proxy_assignments a + WHERE ((a.scope = 'provider' AND a.scope_id = ?) + OR a.scope = 'global') + ) AS assigned, + EXISTS( + SELECT 1 FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id + WHERE ((a.scope = 'provider' AND a.scope_id = ?) + OR a.scope = 'global') + AND ${PROXY_ALIVE_PREDICATE} + ) AS alive` + ) + .get(providerId, providerId) as { assigned?: number; alive?: number } | undefined; + return assignments?.assigned === 1 && assignments.alive === 0; + } catch { + return false; + } +} diff --git a/tests/theoldllm-stress.test.ts b/tests/theoldllm-stress.test.ts index 2125edff5a..e4b6ab59c6 100644 --- a/tests/theoldllm-stress.test.ts +++ b/tests/theoldllm-stress.test.ts @@ -40,32 +40,30 @@ describe("TheOldLlmExecutor", () => { assert.strictEqual(headers["Content-Type"], "application/json"); assert.ok( headers["User-Agent"].includes("Chrome/"), - `expected Chrome UA, got ${headers["User-Agent"]}`, + `expected Chrome UA, got ${headers["User-Agent"]}` ); assert.ok( headers["User-Agent"].includes("Mozilla/5.0"), - `expected Mozilla UA, got ${headers["User-Agent"]}`, + `expected Mozilla UA, got ${headers["User-Agent"]}` ); }); it("maps model aliases to upstream slugs", () => { const cases: Record = { "gpt-5.4": "GPT_5_4", - "GPT_5_3": "GPT_5_3", - "gpt_5_2": "GPT_5_2", + GPT_5_3: "GPT_5_3", + gpt_5_2: "GPT_5_2", "gpt-4o": "GPT_4O", "claude-4.6-opus": "CLAUDE_4_6_OPUS", "claude sonnet 4": "CLAUDE_4_6_SONNET", - "claude_haiku_3_5": "CLAUDE_4_5_HAIKU", + claude_haiku_3_5: "CLAUDE_4_5_HAIKU", "weird-model": "GPT_5_4", }; - const transformRequest = (executor as any).transformRequest.bind( - executor, - ) as ( + const transformRequest = (executor as any).transformRequest.bind(executor) as ( model: string, body: Record, - stream: boolean, + stream: boolean ) => Record; for (const [model, expected] of Object.entries(cases)) { @@ -73,7 +71,7 @@ describe("TheOldLlmExecutor", () => { assert.strictEqual( updated.model, expected, - `model ${model} mapped to ${expected}, got ${updated.model}`, + `model ${model} mapped to ${expected}, got ${updated.model}` ); } }); @@ -89,7 +87,7 @@ describe("TheOldLlmExecutor", () => { error: () => {}, debug: () => {}, }), - true, + true ); globalThis.fetch = async () => makeResponse(401) as any; @@ -100,7 +98,7 @@ describe("TheOldLlmExecutor", () => { error: () => {}, debug: () => {}, }), - false, + false ); } finally { globalThis.fetch = originalFetch; @@ -112,15 +110,10 @@ describe("TheOldLlmExecutor", () => { warmTokenCache(); try { let calls = 0; - const responses = [ - () => makeResponse(401, MOCK_ERR), - () => makeResponse(200, MOCK_SSE), - ]; + const responses = [() => makeResponse(401, MOCK_ERR), () => makeResponse(200, MOCK_SSE)]; globalThis.fetch = async () => - responses[ - calls++ < responses.length ? calls - 1 : responses.length - 1 - ]() as any; + responses[calls++ < responses.length ? calls - 1 : responses.length - 1]() as any; const result = await executor.execute({ model: "gpt-5.4", @@ -144,6 +137,45 @@ describe("TheOldLlmExecutor", () => { } }); + it("does not retry a Vercel egress denial as a stale request token", async () => { + const originalFetch = globalThis.fetch; + try { + let calls = 0; + globalThis.fetch = async () => { + calls++; + return new Response( + JSON.stringify({ error: { code: "403", message: "Forbidden", id: "fra1::test" } }), + { + status: 403, + headers: { + "content-type": "application/json", + "x-vercel-mitigated": "deny", + }, + } + ); + }; + + const result = await executor.execute({ + model: "gpt-5.4", + body: { messages: [{ role: "user", content: "ping" }] }, + stream: true, + signal: null, + credentials: {}, + log: { debug() {}, info() {}, warn() {}, error() {} }, + }); + + assert.strictEqual(calls, 1); + assert.strictEqual(result.response.status, 403); + const json = (await result.response.json()) as { + error?: { code?: string; message?: string }; + }; + assert.strictEqual(json.error?.code, "THEOLDLLM_VERCEL_MITIGATED"); + assert.match(json.error?.message || "", /residential.*proxy/i); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("lets cancellation abort before upstream work", async () => { const controller = new AbortController(); controller.abort(new Error("cancelled")); @@ -202,7 +234,7 @@ describe("TheOldLlmExecutor", () => { warn: () => {}, error: () => {}, }, - }), + }) ); await Promise.all(requests); diff --git a/tests/unit/theoldllm-provider-proxy.test.ts b/tests/unit/theoldllm-provider-proxy.test.ts new file mode 100644 index 0000000000..f9092268b1 --- /dev/null +++ b/tests/unit/theoldllm-provider-proxy.test.ts @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { fetchTheOldLlmWithProviderProxy } from "../../open-sse/executors/theoldllm.ts"; + +test("theoldllm dispatches through its provider proxy assignment", async () => { + const assignedProxy = { + type: "http", + host: "residential.example", + port: 8080, + username: "user", + password: "secret", + }; + let observedProxy: unknown = null; + let fetchCalls = 0; + + const response = await fetchTheOldLlmWithProviderProxy( + { model: "GPT_5_4", messages: [], stream: true }, + new AbortController().signal, + { + resolveProxy: async () => assignedProxy, + runWithProxy: async (proxy, request) => { + observedProxy = proxy; + return request(); + }, + fetch: (async () => { + fetchCalls++; + return new Response("ok", { status: 200 }); + }) as typeof fetch, + } + ); + + assert.equal(response.status, 200); + assert.equal(fetchCalls, 1); + assert.deepEqual(observedProxy, assignedProxy); +}); + +test("theoldllm fails closed when an assigned proxy pool has no active proxy", async () => { + let fetchCalls = 0; + + await assert.rejects( + () => + fetchTheOldLlmWithProviderProxy( + { model: "GPT_5_4", messages: [], stream: true }, + new AbortController().signal, + { + resolveProxy: async () => null, + hasBlockingProxyAssignment: () => true, + runWithProxy: async (_proxy, request) => request(), + fetch: (async () => { + fetchCalls++; + return new Response("unexpected", { status: 200 }); + }) as typeof fetch, + } + ), + /No active proxy is available/ + ); + + assert.equal(fetchCalls, 0, "a dead assigned proxy pool must never fall back to direct egress"); +});