diff --git a/CHANGELOG.md b/CHANGELOG.md index 195467d2ab..b1d61fef70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ _In development β€” bullets added per PR; finalized at release._ ### πŸ”§ Bug Fixes - **fix(dashboard):** show custom provider given-name instead of internal id across dashboard pages β€” cache, combo health, compression analytics, cost overview, health/autopilot, provider stats, route explainability, provider utilization, runtime. Adds shared `resolveProviderName` resolver and `useProviderNodeMap` hook. (#4603) -- **fix(compression):** stop RTK over-truncating file-read tool results β€” a tool returning a file's contents (e.g. a ~147-line code/prose file via a Read tool) is no longer head/tail-truncated by the generic-output fallback filter or the line/char hard-cap, which were silently dropping the middle. RTK now treats content with no detected command, an `unknown` type, and no error markers as a document read and skips those truncation paths; genuine repetitive command output (npm install, make, docker logs) is unaffected. (#4559) +- **fix(sse):** honor per-account proxies and fingerprint rotation in the OpenCode (Free) executor. The UI exposes multi-account + per-account proxy controls, but the executor was a plain pass-through β€” requests always egressed direct and never rotated. The executor now reads `providerSpecificData.accountProxies`, dispatches each request through the selected account's proxy via `runWithProxyContext`, and rotates to the next account (with exponential cooldown) on a 429. The synthetic no-auth credentials are also hydrated with the connection's `fingerprints`/`accountProxies` so the config reaches the executor (also fixes the same gap for MiMoCode). (#4954) --- diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 50af955e4c..e9acfc765b 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -227,7 +227,7 @@ "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, "src/sse/handlers/chat.ts": 1525, - "src/sse/services/auth.ts": 2289 + "src/sse/services/auth.ts": 2328 }, "testCap": 800, "testFrozen": { diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 28666efb76..ce68ef694c 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -11,18 +11,177 @@ import { injectReasoningContentForThinkingModel, isThinkingMessageModel, } from "../utils/reasoningContentInjector.ts"; +import { runWithProxyContext } from "../utils/proxyFetch.ts"; + +/** + * Per-account proxy configuration, persisted by NoAuthAccountCard under + * `providerSpecificData.accountProxies` (keyed by the account id, which the UI + * stores in `providerSpecificData.fingerprints`). Same shape mimocode uses. + */ +export interface OpencodeAccountProxyConfig { + fingerprint: string; + proxy: { + type: string; + host: string; + port: number; + username?: string; + password?: string; + } | null; +} + +/** Runtime rotation/cooldown state for one "OpenCode Free" account. */ +interface OpencodeAccountState { + /** Account id (UI: providerSpecificData.fingerprints[i]); "" for the default direct account. */ + fingerprint: string; + cooldownUntil: number; + consecutiveFails: number; + /** Resolved proxy config for this account (null = direct egress). */ + proxy: OpencodeAccountProxyConfig["proxy"]; +} + +const OPENCODE_COOLDOWN_BASE_MS = 5_000; +const OPENCODE_COOLDOWN_MAX_MS = 60_000; export class OpencodeExecutor extends BaseExecutor { _requestFormat: string | null = null; + /** + * Per-account rotation state, rebuilt from credentials on each request. The + * default entry (fingerprint "") represents the single anonymous account with + * no configured proxy β€” preserves the historical direct pass-through when the + * user has not configured any per-account proxy. + */ + private accounts: OpencodeAccountState[] = [ + { fingerprint: "", cooldownUntil: 0, consecutiveFails: 0, proxy: null }, + ]; + private nextAccountIdx = 0; + constructor(provider: string) { super(provider, PROVIDERS[provider] || PROVIDERS.openai); } + /** + * Rebuild `accounts` from `providerSpecificData.fingerprints` + + * `providerSpecificData.accountProxies`. Each configured account id becomes a + * rotation slot carrying its own proxy. When the user configured no accounts + * at all, the single default direct account is kept (backward compatible). + */ + private syncAccountsFromCredentials(credentials: ProviderCredentials): void { + const psd = credentials?.providerSpecificData; + const fingerprints = Array.isArray(psd?.fingerprints) + ? (psd!.fingerprints as unknown[]).filter((f): f is string => typeof f === "string") + : []; + + const accountProxies = psd?.accountProxies as OpencodeAccountProxyConfig[] | undefined; + const proxyMap = Array.isArray(accountProxies) + ? new Map(accountProxies.map((ap) => [ap.fingerprint, ap.proxy ?? null] as const)) + : null; + + if (fingerprints.length === 0) { + // No configured accounts β€” keep a single direct account. + this.accounts = [{ fingerprint: "", cooldownUntil: 0, consecutiveFails: 0, proxy: null }]; + this.nextAccountIdx = 0; + return; + } + + const previous = new Map(this.accounts.map((a) => [a.fingerprint, a] as const)); + this.accounts = fingerprints.map((fp) => { + const prior = previous.get(fp); + return { + fingerprint: fp, + cooldownUntil: prior?.cooldownUntil ?? 0, + consecutiveFails: prior?.consecutiveFails ?? 0, + proxy: proxyMap ? (proxyMap.get(fp) ?? null) : null, + }; + }); + if (this.nextAccountIdx >= this.accounts.length) this.nextAccountIdx = 0; + } + + private isAccountReady(account: OpencodeAccountState): boolean { + return account.cooldownUntil <= Date.now(); + } + + /** Round-robin pick, skipping accounts in cooldown; falls back to the next index. */ + private pickAccount(): OpencodeAccountState { + for (let i = 0; i < this.accounts.length; i++) { + const idx = (this.nextAccountIdx + i) % this.accounts.length; + const acct = this.accounts[idx]; + if (this.isAccountReady(acct)) { + this.nextAccountIdx = (idx + 1) % this.accounts.length; + return acct; + } + } + const fallbackIdx = this.nextAccountIdx % this.accounts.length; + this.nextAccountIdx = (this.nextAccountIdx + 1) % this.accounts.length; + return this.accounts[fallbackIdx]; + } + + private markCooldown(account: OpencodeAccountState): void { + account.consecutiveFails++; + const backoff = Math.min( + OPENCODE_COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1), + OPENCODE_COOLDOWN_MAX_MS + ); + account.cooldownUntil = Date.now() + backoff + Math.random() * 1000; + } + + private markSuccess(account: OpencodeAccountState): void { + account.consecutiveFails = 0; + } + + /** Mask an account id for logs (UI calls it a fingerprint). */ + private static maskAccountId(fingerprint: string): string { + if (!fingerprint) return "direct"; + return `${fingerprint.slice(0, 8)}…`; + } + async execute(input: ExecuteInput) { this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai"; try { - return await super.execute(input); + this.syncAccountsFromCredentials(input.credentials); + + const hasProxies = this.accounts.some((a) => a.proxy !== null); + // Fast path: no multi-account proxy wiring configured β†’ original behavior. + if (this.accounts.length === 1 && !hasProxies) { + return await super.execute(input); + } + + const { log } = input; + let lastResult: Awaited> | null = null; + + for (let attempt = 0; attempt < this.accounts.length; attempt++) { + const account = this.pickAccount(); + const masked = OpencodeExecutor.maskAccountId(account.fingerprint); + log?.debug?.( + "OPENCODE", + `dispatch via account ${masked} (idx ${attempt + 1}/${this.accounts.length})` + + (account.proxy ? ` through proxy ${account.proxy.host}:${account.proxy.port}` : " direct") + ); + + // Pin egress to this account's proxy for the whole BaseExecutor dispatch + // (incl. its intra-URL 429 retries). skipUpstreamRetry lets THIS loop own + // the cross-account 429 fallback instead of BaseExecutor's same-key retry. + const result = await runWithProxyContext(account.proxy, () => + super.execute({ ...input, skipUpstreamRetry: true }) + ); + lastResult = result; + + const status = result.response.status; + if (status === 429) { + this.markCooldown(account); + log?.warn?.( + "OPENCODE", + `Rate limited (429) on account ${masked}, rotating to next…` + ); + continue; + } + + this.markSuccess(account); + return result; + } + + // All accounts returned 429 (or errored) β€” surface the last response. + return lastResult ?? (await super.execute(input)); } finally { this._requestFormat = null; } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index ea23f0c95d..f11681a23b 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -781,14 +781,14 @@ type AnonymousFallbackProviderDefinition = { noAuth?: boolean; }; -function buildSyntheticNoAuthCredentials(): { +function buildSyntheticNoAuthCredentials(providerSpecificData: JsonRecord = {}): { apiKey: null; accessToken: null; refreshToken: null; expiresAt: null; projectId: null; copilotToken: null; - providerSpecificData: Record; + providerSpecificData: JsonRecord; connectionId: typeof SYNTHETIC_NOAUTH_CONNECTION_ID; testStatus: "active"; lastError: null; @@ -809,7 +809,7 @@ function buildSyntheticNoAuthCredentials(): { expiresAt: null, projectId: null, copilotToken: null, - providerSpecificData: {}, + providerSpecificData, connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID, testStatus: "active", lastError: null, @@ -821,6 +821,39 @@ function buildSyntheticNoAuthCredentials(): { }; } +/** + * #4954 β€” A no-auth provider ("OpenCode Free", MiMoCode, …) has no DB-backed + * credential, but its NoAuthAccountCard DOES persist a real connection row whose + * `providerSpecificData` carries the per-account proxy/rotation config + * (`fingerprints` + `accountProxies`). The synthetic credentials returned above + * default to an empty `providerSpecificData`, so without hydration the executor + * never sees those proxies and every request egresses direct. Pull just the + * rotation-relevant fields off the active connection so the executor can honor + * them. Best-effort: any read failure falls back to empty (historical behavior). + */ +async function loadNoAuthProviderSpecificData(providerId: string): Promise { + try { + const connectionsRaw = await getProviderConnections({ provider: providerId }); + const connections = (Array.isArray(connectionsRaw) ? connectionsRaw : []).map( + toProviderConnection + ); + const hydrated: JsonRecord = {}; + for (const conn of connections) { + const psd = conn.providerSpecificData; + if (!psd || typeof psd !== "object") continue; + if (Array.isArray(psd.fingerprints) && !Array.isArray(hydrated.fingerprints)) { + hydrated.fingerprints = psd.fingerprints; + } + if (Array.isArray(psd.accountProxies) && !Array.isArray(hydrated.accountProxies)) { + hydrated.accountProxies = psd.accountProxies; + } + } + return hydrated; + } catch { + return {}; + } +} + function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean { const providerDef = getProviderById(providerId) as | AnonymousFallbackProviderDefinition @@ -838,10 +871,16 @@ function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean { ); } -function maybeSyntheticNoAuthFallback(providerId: string, excludedConnectionIds: Set) { +async function maybeSyntheticNoAuthFallback( + providerId: string, + excludedConnectionIds: Set +) { if (!providerCanUseSyntheticNoAuthFallback(providerId)) return null; if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) return null; - return buildSyntheticNoAuthCredentials(); + // #4954: hydrate per-account proxy/rotation config off the connection row so + // no-auth executors (opencode, mimocode) actually honor configured proxies. + const providerSpecificData = await loadNoAuthProviderSpecificData(providerId); + return buildSyntheticNoAuthCredentials(providerSpecificData); } function normalizeExcludedConnectionIds( @@ -1021,7 +1060,7 @@ export async function getProviderCredentials( excludeConnectionId, options.excludeConnectionIds ); - return maybeSyntheticNoAuthFallback(resolvedId, excludedForNoAuth); + return await maybeSyntheticNoAuthFallback(resolvedId, excludedForNoAuth); } const allowSuppressedConnections = options.allowSuppressedConnections === true; @@ -1106,7 +1145,7 @@ export async function getProviderCredentials( // the dashboard sees a misleading "bad_request" code. const terminalConnections = allConnections.filter(isTerminalConnectionStatus); if (terminalConnections.length === allConnections.length) { - const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); + const syntheticFallback = await maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); if (syntheticFallback) return syntheticFallback; const statusCounts = new Map(); @@ -1123,7 +1162,7 @@ export async function getProviderCredentials( }; } } - const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); + const syntheticFallback = await maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); if (syntheticFallback) return syntheticFallback; log.warn("AUTH", `No credentials for ${provider}`); return null; @@ -1294,7 +1333,7 @@ export async function getProviderCredentials( cooldownModel: allBlockedByModelCooldown ? requestedModel : null, }; } - const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); + const syntheticFallback = await maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); if (syntheticFallback) return syntheticFallback; log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`); return null; diff --git a/tests/unit/opencode-proxy-rotation-4954.test.ts b/tests/unit/opencode-proxy-rotation-4954.test.ts new file mode 100644 index 0000000000..25a1c49119 --- /dev/null +++ b/tests/unit/opencode-proxy-rotation-4954.test.ts @@ -0,0 +1,168 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; + +/** + * #4954 β€” "OpenCode Free" exposes per-account proxy + multi-account rotation in + * the UI (NoAuthAccountCard persists providerSpecificData.fingerprints + + * providerSpecificData.accountProxies), but the executor ignored them entirely: + * every request egressed direct and never rotated. These tests pin the wiring: + * + * 1. A request for an account that has a configured proxy must egress THROUGH + * that proxy (resolveProxyForRequest reports source "context", not "direct"). + * 2. On a 429 the executor must rotate to the NEXT account (and its proxy). + * + * The dispatch layer is mocked by stubbing globalThis.fetch β€” exactly what the + * proxy context wraps β€” and we observe the proxy that resolveProxyForRequest sees + * for the in-flight request, mirroring the mimocode proxy integration test. Two + * throwaway local TCP listeners stand in for the proxies so runWithProxyContext's + * fast-fail reachability probe passes without a live SOCKS/HTTP proxy. + */ + +const log = { debug() {}, info() {}, warn() {}, error() {} }; + +const ACCOUNT_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ACCOUNT_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +// Two local listeners so the proxy fast-fail reachability check succeeds. The +// proxy host/port are observable in the egress context β€” that is what asserts the +// per-account proxy is honored (was always "direct" before #4954). +let serverA: net.Server; +let serverB: net.Server; +let portA = 0; +let portB = 0; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as net.AddressInfo).port); + }); + }); +} + +before(async () => { + serverA = net.createServer((s) => s.destroy()); + serverB = net.createServer((s) => s.destroy()); + portA = await listen(serverA); + portB = await listen(serverB); +}); + +after(() => { + serverA?.close(); + serverB?.close(); +}); + +function credentialsWithProxies() { + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints: [ACCOUNT_A, ACCOUNT_B], + accountProxies: [ + { fingerprint: ACCOUNT_A, proxy: { type: "http", host: "127.0.0.1", port: portA } }, + { fingerprint: ACCOUNT_B, proxy: { type: "http", host: "127.0.0.1", port: portB } }, + ], + }, + } as any; +} + +describe("OpencodeExecutor per-account proxy + rotation (#4954)", () => { + let originalFetch: typeof globalThis.fetch; + let observed: Array<{ source: string; host: string | null; port: string | null }>; + + beforeEach(() => { + originalFetch = globalThis.fetch; + observed = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + /** Record the proxy context resolved for each dispatch, then return `status`. */ + function installFetchStub(statuses: number[]) { + let call = 0; + globalThis.fetch = (async (input: any) => { + const url = typeof input === "string" ? input : input?.url || String(input); + const resolved = resolveProxyForRequest(url); + let host: string | null = null; + let port: string | null = null; + try { + if (resolved.proxyUrl) { + const u = new URL(resolved.proxyUrl); + host = u.hostname; + port = u.port; + } + } catch { + host = resolved.proxyUrl; + } + observed.push({ source: resolved.source, host, port }); + const status = statuses[Math.min(call, statuses.length - 1)]; + call++; + return new Response(JSON.stringify({ ok: status === 200 }), { + status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + } + + it("dispatches through the selected account's proxy (not direct)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetchStub([200]); + + const result = await exec.execute({ + model: "grok-code", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsWithProxies(), + log, + }); + + assert.strictEqual((result as any).response.status, 200); + assert.ok(observed.length >= 1, "at least one dispatch happened"); + const first = observed[0]; + assert.strictEqual( + first.source, + "context", + `expected proxy-context egress, got source="${first.source}" (was always "direct" before #4954)` + ); + assert.strictEqual(first.host, "127.0.0.1", "egress must use a configured proxy host"); + assert.ok( + first.port === String(portA) || first.port === String(portB), + `expected one of the configured proxy ports, got "${first.port}"` + ); + }); + + it("rotates to the next account (and its proxy) on a 429", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + // first account β†’ 429, second account β†’ 200 + installFetchStub([429, 200]); + + const result = await exec.execute({ + model: "grok-code", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsWithProxies(), + log, + }); + + assert.strictEqual((result as any).response.status, 200, "final response should succeed"); + assert.ok(observed.length >= 2, "should have retried on a second account after 429"); + const ports = observed.map((p) => p.port); + assert.ok(ports.includes(String(portA)), "first attempt should use account A's proxy"); + assert.ok(ports.includes(String(portB)), "rotated attempt should use account B's proxy"); + assert.notStrictEqual( + observed[0].port, + observed[1].port, + "rotation must switch to a different account/proxy" + ); + for (const p of observed) { + assert.strictEqual(p.source, "context", "every dispatch must egress through a proxy context"); + } + }); +});