From a5db197663824c5575e2e8e3602b889bec610d2c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 11 Sep 2026 22:05:50 -0300 Subject: [PATCH] fix(routing): stop round-robin combo opencode targets from collapsing onto opencode-zen (#11912) (#13283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit. Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them. - ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on #13243) - `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK - complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline - 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs - `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for #13278 and #13276, annotated and landed on #13243 ⚠️ base-red inherited: #12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch. --- ...11912-roundrobin-opencode-zen-collision.md | 1 + open-sse/services/combo/comboStructure.ts | 10 ++- .../services/combo/opencodeTargetAlias.ts | 43 ++++++++++++ src/lib/combos/controlCenter.ts | 12 +++- ...11912-opencode-roundrobin-collapse.test.ts | 70 +++++++++++++++++++ 5 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/11912-roundrobin-opencode-zen-collision.md create mode 100644 open-sse/services/combo/opencodeTargetAlias.ts create mode 100644 tests/unit/issue-11912-opencode-roundrobin-collapse.test.ts diff --git a/changelog.d/fixes/11912-roundrobin-opencode-zen-collision.md b/changelog.d/fixes/11912-roundrobin-opencode-zen-collision.md new file mode 100644 index 0000000000..beb24e4a89 --- /dev/null +++ b/changelog.d/fixes/11912-roundrobin-opencode-zen-collision.md @@ -0,0 +1 @@ +- fix(routing): stop a round-robin combo's "opencode" targets from collapsing onto the opencode-zen connection (#11912) diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 7d92d7fb45..a12aeb1528 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -26,6 +26,7 @@ import { containsMediaKind } from "../../utils/mediaParts.ts"; import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; import { parseModel, stripContextWindowSuffix } from "../model.ts"; import { dedupeTargetsByExecutionKey, isRecord } from "./comboData.ts"; +import { resolveComboTargetModelStr } from "./opencodeTargetAlias.ts"; import { isComboModelVisible } from "./comboVisibility.ts"; import { getTargetProvider, MAX_COMBO_DEPTH } from "./comboPredicates.ts"; import { evaluateContextLimit } from "./contextOverrideGate.ts"; @@ -122,8 +123,13 @@ function normalizeRuntimeStep( }; } - const modelStr = getComboModelString(step); - if (!modelStr) return null; + const declaredModelStr = getComboModelString(step); + if (!declaredModelStr) return null; + // #11912: rewrite an ambiguous "opencode/" target to the "oc/" alias + // so it stays distinct from an explicit "opencode-zen/" sibling + // instead of both collapsing onto the same provider — see + // opencodeTargetAlias.ts for the full rationale. + const modelStr = resolveComboTargetModelStr(declaredModelStr); const connectionId = toTrimmedString(step.connectionId); const allowedConnectionIds = implicitPinAllowlist(connectionId, step.allowedConnectionIds); diff --git a/open-sse/services/combo/opencodeTargetAlias.ts b/open-sse/services/combo/opencodeTargetAlias.ts new file mode 100644 index 0000000000..b3cfa5e26e --- /dev/null +++ b/open-sse/services/combo/opencodeTargetAlias.ts @@ -0,0 +1,43 @@ +/** + * Issue #11912 — a combo step declared with the raw "opencode/" prefix + * is ambiguous: open-sse/services/model.ts's manual ALIAS_TO_PROVIDER_ID + * override canonicalizes ANY "opencode/" string to provider + * "opencode-zen" (the api-key gateway) before dispatch. A round-robin combo + * mixing declared "opencode/" targets (intended as the free/dynamic + * no-auth pool) with an explicit "opencode-zen/" target therefore + * collapses every rotation slot onto the SAME provider + connection identity + * — every request executes against the single opencode-zen connection + * instead of rotating across the free pool, and the account eventually + * 429s. + * + * The combo BUILDER already avoids this for freshly-generated model strings + * by emitting the "oc/" alias for the no-auth provider (#2901, + * src/lib/combos/builderOptions.ts's rewriteQualifiedModelPrefix). This + * mirrors that same substitution at combo TARGET RESOLUTION time so a step + * saved — or hand-typed — with the raw "opencode/" prefix still reaches the + * true no-auth provider and stays a distinct rotation identity from an + * explicit "opencode-zen/" target. + * + * Deliberately scoped to combo target resolution only — this never touches + * open-sse/services/model.ts's general alias-resolution path, so a raw + * client request to "opencode/" outside a combo keeps routing to + * opencode-zen unchanged (#2798/#3870), and the #7993 sibling credential + * lookup (tests/unit/opencode-autocombo-search-pair.test.ts) is unaffected. + */ + +const AMBIGUOUS_OPENCODE_PREFIX = "opencode"; +const OPENCODE_NOAUTH_ALIAS = "oc"; + +/** + * Rewrite a combo-declared model string's "opencode/" prefix to the "oc/" + * no-auth alias. Every other prefix (including "opencode-zen/" and + * "opencode-go/") passes through untouched. + */ +export function resolveComboTargetModelStr(modelStr: string): string { + if (typeof modelStr !== "string" || modelStr.length === 0) return modelStr; + const slashIndex = modelStr.indexOf("/"); + if (slashIndex <= 0) return modelStr; + const prefix = modelStr.slice(0, slashIndex); + if (prefix !== AMBIGUOUS_OPENCODE_PREFIX) return modelStr; + return `${OPENCODE_NOAUTH_ALIAS}${modelStr.slice(slashIndex)}`; +} diff --git a/src/lib/combos/controlCenter.ts b/src/lib/combos/controlCenter.ts index d606d2ec93..92a87b2738 100644 --- a/src/lib/combos/controlCenter.ts +++ b/src/lib/combos/controlCenter.ts @@ -1,4 +1,6 @@ import { normalizeComboModels, type ComboStep } from "./steps"; +import { resolveComboTargetModelStr } from "../../../open-sse/services/combo/opencodeTargetAlias.ts"; +import { resolveProviderAlias } from "../../../open-sse/services/model.ts"; type JsonRecord = Record; @@ -108,9 +110,15 @@ function toString(value: unknown): string | null { function providerFromModel(model: string | null | undefined): string | null { if (!model) return null; - const slashIndex = model.indexOf("/"); + // #11912: resolve through the same "opencode" -> "oc" combo-target alias + // treatment (and then the general alias table) that target resolution + // applies before dispatch, so this label matches what actually executed + // upstream instead of a raw, un-aliased prefix slice. + const normalized = resolveComboTargetModelStr(model); + const slashIndex = normalized.indexOf("/"); if (slashIndex <= 0) return null; - return model.slice(0, slashIndex); + const prefix = normalized.slice(0, slashIndex); + return resolveProviderAlias(prefix) || prefix; } function normalizeSuccessRate(value: unknown): number { diff --git a/tests/unit/issue-11912-opencode-roundrobin-collapse.test.ts b/tests/unit/issue-11912-opencode-roundrobin-collapse.test.ts new file mode 100644 index 0000000000..7522e9d9a6 --- /dev/null +++ b/tests/unit/issue-11912-opencode-roundrobin-collapse.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveComboTargets } from "../../open-sse/services/combo/comboStructure.ts"; +import { resolveComboTargetModelStr } from "../../open-sse/services/combo/opencodeTargetAlias.ts"; +import { parseModel } from "../../open-sse/services/model.ts"; + +// Issue #11912: a round-robin combo built from several "opencode" (free / +// dynamic no-auth) targets plus one "opencode-zen" (authenticated api-key) +// target routed 100% of upstream traffic to the opencode-zen connection. +// +// Root cause: open-sse/services/model.ts's manual ALIAS_TO_PROVIDER_ID +// override canonicalizes ANY "opencode/" string to provider +// "opencode-zen" before dispatch, so every declared "opencode/" +// combo target and the explicit "opencode-zen/" target resolved to +// the identical provider identity — round-robin's "7 targets" were never 7 +// distinct upstream accounts. +// +// Fix: combo target resolution (comboStructure.ts's normalizeRuntimeStep) +// now rewrites an ambiguous "opencode/" combo target to the "oc/" +// no-auth alias, mirroring the combo builder's existing #2901 guard, before +// the model string reaches dispatch — so it resolves to the true no-auth +// "opencode" provider and stays distinct from an "opencode-zen/" +// sibling target. + +test("issue #11912: round-robin combo keeps opencode and opencode-zen targets on distinct providers", () => { + const targets = resolveComboTargets( + { + name: "opencode-round-robin", + strategy: "round-robin", + models: [ + { kind: "model", model: "opencode/mimo-v2.5-free" }, + { kind: "model", model: "opencode/mimo-v2.5-free" }, + { kind: "model", model: "opencode-zen/mimo-v2.5-free" }, + ], + }, + null + ); + + assert.equal(targets.length, 3); + const [dynamicA, dynamicB, authenticated] = targets; + + assert.notEqual( + dynamicA.provider, + authenticated.provider, + `combo target "opencode/" resolved to provider "${dynamicA.provider}" — it collapsed ` + + `onto the same identity as the explicit "opencode-zen/" target instead of routing ` + + `to the free/dynamic no-auth pool` + ); + assert.equal(dynamicA.provider, dynamicB.provider); + assert.equal(authenticated.provider, "opencode-zen"); + + // The rewritten model string must still resolve to the genuine no-auth + // provider identity when it later reaches dispatch (parseModel is exactly + // what open-sse/services/combo/roundRobinCombo.ts and + // resolveModelOrError() call on the resolved target's modelStr). + assert.equal(parseModel(dynamicA.modelStr).provider, "opencode"); + assert.equal(parseModel(authenticated.modelStr).provider, "opencode-zen"); +}); + +test("resolveComboTargetModelStr rewrites the ambiguous opencode/ prefix to oc/", () => { + assert.equal(resolveComboTargetModelStr("opencode/mimo-v2.5-free"), "oc/mimo-v2.5-free"); + // Siblings and the explicit api-key gateway must pass through untouched. + assert.equal(resolveComboTargetModelStr("opencode-zen/mimo-v2.5-free"), "opencode-zen/mimo-v2.5-free"); + assert.equal(resolveComboTargetModelStr("opencode-go/mimo-v2.5-free"), "opencode-go/mimo-v2.5-free"); + assert.equal(resolveComboTargetModelStr("oc/mimo-v2.5-free"), "oc/mimo-v2.5-free"); + // Non-slashed / non-opencode strings are untouched. + assert.equal(resolveComboTargetModelStr("bare-model"), "bare-model"); + assert.equal(resolveComboTargetModelStr("anthropic/claude"), "anthropic/claude"); +});