fix(sse): honor per-account proxies and fingerprint rotation in opencode executor (#4954) (#4989)

* fix(sse): honor per-account proxies and fingerprint rotation in opencode executor (#4954)

* chore(quality): rebaseline auth.ts file-size for #4954 (+39: synthetic no-auth providerSpecificData hydration of fingerprints/accountProxies; irreducible credential-path wiring, covered by opencode-proxy-rotation-4954.test.ts + 159 auth/noauth regression)

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-25 03:27:55 -03:00
committed by GitHub
parent bf6c8ca3fd
commit 083d4b0044
5 changed files with 378 additions and 12 deletions

View File

@@ -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)
---

View File

@@ -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": {

View File

@@ -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<ReturnType<BaseExecutor["execute"]>> | 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;
}

View File

@@ -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<string, never>;
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<JsonRecord> {
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<string>) {
async function maybeSyntheticNoAuthFallback(
providerId: string,
excludedConnectionIds: Set<string>
) {
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<string, number>();
@@ -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;

View File

@@ -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<number> {
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");
}
});
});