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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(routing): stop a round-robin combo's "opencode" targets from collapsing onto the opencode-zen connection (#11912)
10 changes: 8 additions & 2 deletions open-sse/services/combo/comboStructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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/<model>" target to the "oc/" alias
// so it stays distinct from an explicit "opencode-zen/<model>" 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);
Expand Down
43 changes: 43 additions & 0 deletions open-sse/services/combo/opencodeTargetAlias.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Issue #11912 — a combo step declared with the raw "opencode/<model>" prefix
* is ambiguous: open-sse/services/model.ts's manual ALIAS_TO_PROVIDER_ID
* override canonicalizes ANY "opencode/<model>" string to provider
* "opencode-zen" (the api-key gateway) before dispatch. A round-robin combo
* mixing declared "opencode/<model>" targets (intended as the free/dynamic
* no-auth pool) with an explicit "opencode-zen/<model>" 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/<model>" 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/<model>" 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)}`;
}
12 changes: 10 additions & 2 deletions src/lib/combos/controlCenter.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

Expand Down Expand Up @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/issue-11912-opencode-roundrobin-collapse.test.ts
Original file line number Diff line number Diff line change
@@ -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/<model>" string to provider
// "opencode-zen" before dispatch, so every declared "opencode/<model>"
// combo target and the explicit "opencode-zen/<model>" 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/<model>" 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/<model>"
// 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/<model>" resolved to provider "${dynamicA.provider}" — it collapsed ` +
`onto the same identity as the explicit "opencode-zen/<model>" 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");
});
Loading