Skip to content
Closed
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
1 change: 1 addition & 0 deletions changelog.d/fixes/14148-opencode-refused-tool-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(opencode):** requests the upstream refuses because of the tools attached to them, such as the short title-generation call, are now replayed once in the other shape and the shape that worked is remembered for that prompt ([#14148](https://github.com/diegosouzapw/OmniRoute/pull/14148)) — thanks @maxmad64bis
1 change: 1 addition & 0 deletions changelog.d/fixes/14323-opencode-accounts-per-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(opencode):** Keep each request on its own member list so concurrent requests no longer replace each other's list ([#14323](https://github.com/diegosouzapw/OmniRoute/pull/14323)) — thanks @maxmad64bis
156 changes: 78 additions & 78 deletions open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ import {
forwardOpencodeClientHeaders,
resolveOpencodeCliDefaults,
} from "../utils/opencodeHeaders.ts";
import {
listForRequest,
releaseRequestList,
type ScopedAccount,
type ScopedAccountHealth,
} from "./opencodeAccountScope.ts";
import {
type AccountProxyConfig,
type RotatableAccount,
pickAccount as pickRotatableAccount,
maskAccountId,
isNetworkErrorRotatable,
Expand All @@ -37,14 +42,15 @@ import {
isOpencodeUserBlocked,
} from "./opencodeGeoBlock.ts";
import {
attemptFor,
isGatedFreeTierRequest,
isPremiumOpencodeModel,
noteFreeTierOutcome,
prepareFreeTierRequest,
rebuildJsonFromForcedStream,
surfaceFromBaseUrl,
type FreeTierContractAttempt,
} from "./opencodeFreeTierContract.ts";
import { withRequestShapeRetry } from "./opencodeRequestShape.ts";

// Re-exported: the free-model catalog moved to the contract module (it decides whether the
// contract applies), and existing importers keep resolving it from the executor.
Expand Down Expand Up @@ -85,11 +91,7 @@ const ZEN_BASE_URL = "https://opencode.ai/zen/v1";
*/
export type OpencodeAccountProxyConfig = AccountProxyConfig;

/** Runtime rotation/cooldown state for one "OpenCode Free" account. */
interface OpencodeAccountState extends RotatableAccount {
/** Account id (UI: providerSpecificData.fingerprints[i]); "" for the default direct account. */
fingerprint: string;
}
export { type ScopedAccount as OpencodeAccountState };

const EFFORT_LEVELS = ["none", "low", "high", "max"] as const;

Expand Down Expand Up @@ -275,24 +277,50 @@ export class OpencodeExecutor extends BaseExecutor {
}

_requestFormat: string | null = null;
private _contractAttempt: FreeTierContractAttempt | null = null;
/** Set in buildHeaders, which execute() runs before transformRequest. */
private _clientSession: string | undefined;
private _surface = () => surfaceFromBaseUrl(this.config?.baseUrl);

/**
* 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 },
];
// Not `private`: passed as the mutable rotation cursor to
// Not `private`: passed as the shared pick cursor to
// pickRotatableAccount(), which needs a plain `{ nextAccountIdx }` shape —
// TS's private-member nominal check rejects `this` there otherwise.
nextAccountIdx = 0;
/**
* Member health shared across requests on this alias, keyed by member id.
* Exposed for tests: they seed failure history here the same way they used
* to reach the old per-instance member list. Each request still walks its
* own member list (see `opencodeAccountScope.ts`); only health is shared.
* Writes flow through to the store, so `for (const m of exec.accounts)
* m.consecutiveFails = 2` seeds exactly like the old array did.
*/
accountHealth = new Map<string, ScopedAccountHealth>();
get accounts(): ScopedAccount[] {
const store = this.accountHealth;
if (store.size === 0) {
// Cold read parity with the historical default: a single direct member
// before any request ran. Read-only: the real sync stays the sole writer.
return [{ fingerprint: "", cooldownUntil: 0, consecutiveFails: 0, proxy: null }];
}
const live = [...store.entries()].map(([fingerprint]) => ({
fingerprint,
get cooldownUntil() {
return store.get(fingerprint)?.cooldownUntil ?? 0;
},
set cooldownUntil(value: number) {
const current = store.get(fingerprint) ?? { cooldownUntil: 0, consecutiveFails: 0 };
store.set(fingerprint, { ...current, cooldownUntil: value });
},
get consecutiveFails() {
return store.get(fingerprint)?.consecutiveFails ?? 0;
},
set consecutiveFails(value: number) {
const current = store.get(fingerprint) ?? { cooldownUntil: 0, consecutiveFails: 0 };
store.set(fingerprint, { ...current, consecutiveFails: value });
},
proxy: null as ScopedAccount["proxy"],
}));
return live;
}
// Sleep used by the opt-in transient failover pause (#13615). Not `private`:
// tests swap in a recording fake instead of waiting on real timers.
transientPauseSleep: (ms: number, signal?: AbortSignal | null) => Promise<boolean> =
Expand All @@ -302,48 +330,12 @@ export class OpencodeExecutor extends BaseExecutor {
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;
}

/** Round-robin pick, skipping non-candidates; falls back to the next index. */
/** Round-robin pick from this request's list, skipping members not ready. */
private pickAccountWith(
isReady: (account: OpencodeAccountState) => boolean
): OpencodeAccountState {
return pickRotatableAccount(this.accounts, this, isReady);
accounts: ScopedAccount[],
isReady: (account: ScopedAccount) => boolean
): ScopedAccount {
return pickRotatableAccount(accounts, this, isReady);
}

/**
Expand All @@ -360,12 +352,12 @@ export class OpencodeExecutor extends BaseExecutor {
input: ExecuteInput,
result: ExecutorExecuteResult
): ExecutorExecuteResult {
noteFreeTierOutcome(this._contractAttempt, "response" in result && !!result.response?.ok);
noteFreeTierOutcome(attemptFor(input.body), "response" in result && !!result.response?.ok);
if (input.stream) return result;
if (!("response" in result) || !result.response) return result;
// Non-null exactly when the contract applied: stands in for the old surface/model guard.
if (!this._contractAttempt) return result;
const model = this._contractAttempt.model;
const model = attemptFor(input.body)?.model;
if (!model) return result;
const response = rebuildJsonFromForcedStream(result.response, this._requestFormat, model);
return response === result.response ? result : { ...result, response };
}
Expand Down Expand Up @@ -483,6 +475,14 @@ export class OpencodeExecutor extends BaseExecutor {
}

async execute(input: ExecuteInput) {
try {
return await withRequestShapeRetry(input, (i) => this.executeOnce(i));
} finally {
releaseRequestList(input.body, this.accountHealth);
}
}

private async executeOnce(input: ExecuteInput) {
this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model);

// #8681: Gate premium opencode models behind a usable API key.
Expand Down Expand Up @@ -523,14 +523,16 @@ export class OpencodeExecutor extends BaseExecutor {
);
}

this.syncAccountsFromCredentials(input.credentials);
const accounts = listForRequest(input.body, this.accountHealth, input.credentials);
if (accounts.length === 1 && accounts[0]?.fingerprint === "") this.nextAccountIdx = 0;
else if (this.nextAccountIdx >= accounts.length) this.nextAccountIdx = 0;
const { log } = input;
// Request-scoped attribution prefix for rotation logs: message head,
// empty when absent (never n/a/none/fabricated). The existing motif
// stays byte-identical after the prefix.
const cid = input.correlationId ? `correlationId=${input.correlationId} ` : "";

const hasProxies = this.accounts.some((a) => a.proxy !== null);
const hasProxies = accounts.some((a) => a.proxy !== null);
// Opt-in Responses first-byte stall guard (#13484); a no-op when the window is 0.
const stallWindowMs = resolveResponsesStallWindowMs(input.stream, this._requestFormat);
const guardStall = <T>(r: T) => guardResponsesStall(r, stallWindowMs, input.signal);
Expand All @@ -539,7 +541,7 @@ export class OpencodeExecutor extends BaseExecutor {
// rejection (same predicate and logging as the rotation loop). Everything
// else passes untouched: this path deliberately preserves BaseExecutor's
// intra-URL 429 retries (no skipUpstreamRetry here).
if (this.accounts.length === 1 && !hasProxies) {
if (accounts.length === 1 && !hasProxies) {
// #11894: a connection-level proxy assignment (proxy_assignments) reaches
// the executor as the AMBIENT proxy context — the chat handler wraps
// execute() in runWithProxyContext(proxyInfo.proxy, ...) before we run.
Expand Down Expand Up @@ -597,7 +599,7 @@ export class OpencodeExecutor extends BaseExecutor {
// account (retry the same one), none for a multi-account fleet (rotation
// through the accounts is the retry). Avoids an unbounded loop on a
// persistently malformed upstream.
const emptyRejectionBudget = this.accounts.length === 1 ? 1 : 0;
const emptyRejectionBudget = accounts.length === 1 ? 1 : 0;
// Tried set: proxy keys already proven unusable for this request's
// model (geo-blocked, or transient 5xx). Request-local only — nothing
// persists past execute().
Expand All @@ -618,8 +620,8 @@ export class OpencodeExecutor extends BaseExecutor {
let transientStreak = 0;
let transientPausedMs = 0;

for (let attempt = 0; attempt < this.accounts.length + emptyRejectionBudget; attempt++) {
const isProxiedCandidate = (a: OpencodeAccountState): boolean => {
for (let attempt = 0; attempt < accounts.length + emptyRejectionBudget; attempt++) {
const isProxiedCandidate = (a: ScopedAccount): boolean => {
if (a.cooldownUntil > Date.now()) return false;
// Without any geo evidence this pass, every cooldown-ready account
// stays eligible (preserves the plain round-robin first pick).
Expand All @@ -628,21 +630,19 @@ export class OpencodeExecutor extends BaseExecutor {
const k = proxyKeyOf(a.proxy);
return k !== null && !geoTriedProxyKeys.has(k);
};
let account = this.pickAccountWith(isProxiedCandidate);
let account = this.pickAccountWith(accounts, isProxiedCandidate);
// Last resort: a single direct attempt (distinct egress that may
// succeed) once no proxied account is a candidate — never before.
if (!isProxiedCandidate(account) && !directTried && geoTriedProxyKeys.size > 0) {
const direct = this.accounts.find(
(a) => a.proxy === null && a.cooldownUntil <= Date.now()
);
const direct = accounts.find((a) => a.proxy === null && a.cooldownUntil <= Date.now());
if (direct) {
account = direct;
}
}
const lastStatus = lastResult !== null ? lastResult.response.status : null;
const lastWasGeo = lastStatus === 403 || lastStatus === 451;
const lastWasTransient = lastStatus !== null && lastStatus >= 500 && lastStatus < 600;
const isMonoRetryOwed = this.accounts.length === 1 && lastWasTransient;
const isMonoRetryOwed = accounts.length === 1 && lastWasTransient;
if (
!isMonoRetryOwed &&
lastResult !== null &&
Expand Down Expand Up @@ -689,7 +689,7 @@ export class OpencodeExecutor extends BaseExecutor {
// Token stays masked — never log the full account id.
log?.info?.(
"OPENCODE",
`${cid}dispatch via account ${masked} (idx ${attempt + 1}/${this.accounts.length})` +
`${cid}dispatch via account ${masked} (idx ${attempt + 1}/${accounts.length})` +
(account.proxy
? ` through proxy ${account.proxy.host}:${account.proxy.port}`
: " direct")
Expand Down Expand Up @@ -829,7 +829,7 @@ export class OpencodeExecutor extends BaseExecutor {
);
// Single account with a proxy: 0 retries (same egress = dead latency).
// (The fast path above already covers single-without-proxy; here length===1 WITH proxy.)
if (this.accounts.length === 1) return result;
if (accounts.length === 1) return result;
continue;
}
// Opt-in (#13498): an upstream user_blocked refusal (403 or 451, same
Expand All @@ -845,7 +845,7 @@ export class OpencodeExecutor extends BaseExecutor {
if (key !== null) geoTriedProxyKeys.add(key);
else directTried = true;
markCooldown(account);
const rotate = userBlockedRotations === 0 && this.accounts.length > 1;
const rotate = userBlockedRotations === 0 && accounts.length > 1;
log?.warn?.(
"OPENCODE",
`${cid}user_blocked ${status} on account ${masked} (proxy ${key ?? "direct"}), ${rotate ? "rotating to next account once…" : "returning the refusal"}`
Expand Down Expand Up @@ -1135,10 +1135,10 @@ export class OpencodeExecutor extends BaseExecutor {
this._surface(),
this.provider,
model,
this._clientSession
this._clientSession,
body
);
modifiedBody = prepared.body;
this._contractAttempt = prepared.attempt;
// 9router#1442: OpenCode upstreams (e.g. kimi-k2.6 via opencode-go) return
// 400 "Extra inputs are not permitted, field: 'client_metadata'" — an
// OpenAI-Codex/Claude-CLI passthrough field with no equivalent here. The
Expand Down
Loading
Loading