Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
2 changes: 1 addition & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
161 changes: 160 additions & 1 deletion open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
57 changes: 48 additions & 9 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -809,7 +809,7 @@ function buildSyntheticNoAuthCredentials(): {
expiresAt: null,
projectId: null,
copilotToken: null,
providerSpecificData: {},
providerSpecificData,
connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID,
testStatus: "active",
lastError: null,
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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>();
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading