mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
fix(routing): stop round-robin combo opencode targets from collapsing onto opencode-zen (#11912) (#13283)
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.
This commit is contained in:
committed by
GitHub
parent
660137b3ce
commit
a5db197663
@@ -0,0 +1 @@
|
||||
- fix(routing): stop a round-robin combo's "opencode" targets from collapsing onto the opencode-zen connection (#11912)
|
||||
@@ -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/<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);
|
||||
|
||||
43
open-sse/services/combo/opencodeTargetAlias.ts
Normal file
43
open-sse/services/combo/opencodeTargetAlias.ts
Normal file
@@ -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)}`;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
70
tests/unit/issue-11912-opencode-roundrobin-collapse.test.ts
Normal file
70
tests/unit/issue-11912-opencode-roundrobin-collapse.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user