fix(routing): stop round-robin combo opencode targets from collapsing onto opencode-zen (#11912) - #13283
Conversation
… onto opencode-zen (#11912) open-sse/services/model.ts canonicalizes any "opencode/<model>" combo target string to provider "opencode-zen" (the api-key gateway). A round-robin combo mixing declared "opencode/<model>" targets (the free/dynamic no-auth pool) with an explicit "opencode-zen/<model>" target therefore collapsed every rotation slot onto the same provider + connection identity, so all traffic executed against the single opencode-zen connection instead of rotating across the free pool. Mirror the combo builder's existing #2901 guard at combo target resolution time (comboStructure.ts's normalizeRuntimeStep): rewrite an ambiguous "opencode/<model>" combo target to the "oc/" no-auth alias before it reaches dispatch, so it stays a distinct rotation identity. Also resolve controlCenter.ts's Resolved-Runtime-Targets label through the same alias-normalization path so the dashboard label matches what actually executes upstream.
|
Heads-up: applied on top of
import { resolveComboTargetModelStr } from "../../../open-sse/services/combo/opencodeTargetAlias.ts";
import { resolveProviderAlias } from "../../../open-sse/services/model.ts";pull the server-side open-sse graph into the browser bundle. The build then fails with: Same chain also produces The alias resolution needs to stay server-side. Two options: normalize the label in the API route that serves the control-center data instead of in the shared module, or move the two helpers into a small dependency-free module that does not transitively import Two side notes:
|
… into the client bundle src/lib/combos/controlCenter.ts is imported by the "use client" ComboControlCenterClient.tsx, so everything it imports statically ends up in the browser bundle. diegosouzapw#13283 added resolveProviderAlias from open-sse/services/model.ts to that file; model.ts reaches @/lib/db/readCache, which fans out through settings.ts -> runtimeSettings.ts -> tokenHealthCheck.ts -> webCookie.ts -> zai-web.ts -> cursorImages.ts -> sharp -> detect-libc. Webpack then fails the client compile with "Module not found: Can't resolve 'child_process'", breaking docker-publish.yml and nightly-compat.yml, which both build with OMNIROUTE_USE_TURBOPACK=0. Move ALIAS_TO_PROVIDER_ID and resolveProviderAlias into open-sse/services/providerAlias.ts, which imports only the provider catalogue -- already part of the client graph. model.ts re-exports the function, so every existing caller is unchanged.
Fixes the two client-bundle leaks introduced by the upstream/release/v3.8.51 sync (upstream diegosouzapw#13283 and diegosouzapw#13264), which fail the webpack production build that oci-build.yml and upstream's own docker-publish.yml both use.
a5db197 (upstream diegosouzapw#11912/diegosouzapw#13283) made src/lib/combos/controlCenter.ts import open-sse/services/model.ts. That file transitively reaches open-sse/utils/cursorImages.ts, which dynamic-imports sharp. sharp pulls in detect-libc → child_process, which webpack refuses to bundle for the client chunk (ComboControlCenterClient). Externalizing sharp keeps the runtime require() intact (server bundle) while telling webpack to skip client-side bundling of the package. Mine-side follow-up to the 44-ahead-113-behind merge. No upstream code touched; only next.config.mjs serverExternalPackages list.
a5db197 (upstream diegosouzapw#11912/diegosouzapw#13283) added 'import { resolveProviderAlias } from open-sse/services/model.ts' to src/lib/combos/controlCenter.ts. That file is imported by ComboControlCenterClient.tsx (a 'use client' file). Through the import graph webpack reaches open-sse/utils/ cursorImages.ts, which dynamic-imports sharp. sharp transitively pulls in detect-libc, which statically requires child_process; webpack refuses to bundle that for the client. sharp is server-only in this codebase (5 server-side importers, none client-side), so externalizing it from the client chunk is safe and consistent with the 21-entry serverExternalPackages list. Mine-side follow-up after 44-ahead-113-behind merge of upstream release/v3.8.51. Reviewed by Sub Agent 2 (w3B:p6, rev 32+, fact-sheet all-green, confidence high).
a5db197 (upstream diegosouzapw#11912/diegosouzapw#13283) made src/lib/combos/controlCenter.ts import open-sse/services/model.ts. controlCenter.ts is reachable from src/app/(dashboard)/dashboard/combos/ComboControlCenterClient.tsx (a 'use client' file). Through model.ts → providerModels.ts → providerRegistry.ts → providers/index.ts → ... webpack follows the import graph all the way to: - open-sse/services/qoderCli.ts (import { spawn } from 'child_process') - open-sse/executors/codex.ts (uses node:module) - open-sse/config/credentialLoader.ts (uses fs) - node_modules/detect-libc/lib/{detect-libc,filesystem}.js (child_process, fs) All five chains fail webpack's client-bundle compilation with 'Can't resolve child_process/fs/module'. This commit removes the resolveProviderAlias import + call from controlCenter.ts. resolveProviderAlias lives in open-sse/services/model.ts and was added by the same upstream commit (diegosouzapw#11912). The function was a display-only label refinement: 'step.providerId || providerFromModel(step.model)' at line 176 already prefers the combo's stored providerId, and the providerFromModel call site falls back to the raw prefix when providerId is absent. Dropping the fallback alias chain here has no behavioural effect on the rendered UI; the primary upstream fix (resolveComboTargetModelStr turning 'opencode/<model>' into 'oc/<model>' per diegosouzapw#11912) is preserved. Reviewed by Sub Agent 2 (w3B:p6) per user directive. Mine-side follow-up after 44-ahead-113-behind merge of upstream release/v3.8.51.
… client bundle controlCenter.ts (imported by the 'use client' ComboControlCenterClient) imported resolveProviderAlias from open-sse/services/model.ts, dragging DB/playwright/sharp into the browser bundle and failing the Turbopack build with 168 'Can't resolve' errors (regression from diegosouzapw#13283). Move the alias map and resolver into the pure open-sse/services/providerAlias.ts; model.ts re-exports it.
…os client bundle builds `src/lib/combos/controlCenter.ts` is imported by the "use client" ComboControlCenterClient.tsx. Since diegosouzapw#13283 it imported `resolveProviderAlias` from `open-sse/services/model.ts`, whose dynamic `import("@/lib/db/...")` calls drag ioredis/better-sqlite3 into the browser graph and `next build` fails with "Module not found: Can't resolve 'tls'" (docker-publish red on release/v3.8.51). Extract `ALIAS_TO_PROVIDER_ID` + the manual overrides + `resolveProviderAlias` into `open-sse/services/providerAlias.ts` (depends only on providerModels). `model.ts` imports and re-exports it, so every existing caller is unchanged; `controlCenter.ts` now imports the light module. Regression test asserts the light module and controlCenter stay free of db/model imports.
…ervice diegosouzapw#13283 made src/lib/combos/controlCenter.ts — imported by the "use client" ComboControlCenterClient page — import resolveProviderAlias() from open-sse/services/model.ts. That module lazily imports the DB layer (@/lib/db/readCache, @/lib/db/models, …); Turbopack follows those edges into the client bundle, drags the executors (down to the Playwright-backed zai-web browser automation) along, and `next build --turbopack` fails with 168 "Module not found: Can't resolve 'child_process' / 'async_hooks'" errors. Every Docker image build since the 2026-09-11 batch fails the same way. Move ALIAS_TO_PROVIDER_ID and resolveProviderAlias() into a pure open-sse/services/providerAlias.ts that depends only on the static provider registry, re-export both from model.ts so the existing server-side importers are untouched, and point controlCenter.ts at the pure module. Verified with an import-graph walk over src/ + open-sse/: at the last commit that published an image no "use client"/edge entry reached a playwright importer, at the branch tip exactly one did (this chain), and none do after this change.
…nts module diegosouzapw#13264 made open-sse/config/providers/registry/codebuddy-cn/index.ts import CODEBUDDY_CN_USER_AGENT from src/lib/oauth/constants/oauth.ts. The provider registry is browser-reachable (dashboard model pickers → src/shared/constants/models.ts → open-sse/config/providerModels.ts → providerRegistry → every registry entry), and oauth.ts imports open-sse/utils/cursorAgentCliVersion.ts, which imports node:fs / node:os / node:path. With the Playwright edge from diegosouzapw#13283 fixed, `next build --turbopack` then fails one step later on every dashboard page: TurbopackInternalError: Failed to write app endpoint /(dashboard)/dashboard/page Caused by: the chunking context (unknown) does not support external modules (request: node:fs) Define the constant in a dependency-free open-sse/config/providers/registry/codebuddy-cn/userAgent.ts, import it there from the registry entry, and have oauth.ts import + re-export it so the OAuth config, chat headers and usage/quota client keep sharing the one string that diegosouzapw#12702 requires. Verified with an import-graph walk (client/edge entries → files importing a Node built-in): the set of reachable chains is now identical to the last release-branch commit that published a Docker image.
…nts module diegosouzapw#13264 made open-sse/config/providers/registry/codebuddy-cn/index.ts import CODEBUDDY_CN_USER_AGENT from src/lib/oauth/constants/oauth.ts. The provider registry is browser-reachable (dashboard model pickers → src/shared/constants/models.ts → open-sse/config/providerModels.ts → providerRegistry → every registry entry), and oauth.ts imports open-sse/utils/cursorAgentCliVersion.ts, which imports node:fs / node:os / node:path. With the Playwright edge from diegosouzapw#13283 fixed, `next build --turbopack` then fails one step later on every dashboard page: TurbopackInternalError: Failed to write app endpoint /(dashboard)/dashboard/page Caused by: the chunking context (unknown) does not support external modules (request: node:fs) Define the constant in a dependency-free open-sse/config/providers/registry/codebuddy-cn/userAgent.ts, import it there from the registry entry, and have oauth.ts import + re-export it so the OAuth config, chat headers and usage/quota client keep sharing the one string that diegosouzapw#12702 requires. Verified with an import-graph walk (client/edge entries → files importing a Node built-in): the set of reachable chains is now identical to the last release-branch commit that published a Docker image.
…ervice diegosouzapw#13283 made src/lib/combos/controlCenter.ts — imported by the "use client" ComboControlCenterClient page — import resolveProviderAlias() from open-sse/services/model.ts. That module lazily imports the DB layer (@/lib/db/readCache, @/lib/db/models, …); Turbopack follows those edges into the client bundle, drags the executors (down to the Playwright-backed zai-web browser automation) along, and `next build --turbopack` fails with 168 "Module not found: Can't resolve 'child_process' / 'async_hooks'" errors. Every Docker image build since the 2026-09-11 batch fails the same way. Move ALIAS_TO_PROVIDER_ID and resolveProviderAlias() into a pure open-sse/services/providerAlias.ts that depends only on the static provider registry, re-export both from model.ts so the existing server-side importers are untouched, and point controlCenter.ts at the pure module. Verified with an import-graph walk over src/ + open-sse/: at the last commit that published an image no "use client"/edge entry reached a playwright importer, at the branch tip exactly one did (this chain), and none do after this change.
…nts module diegosouzapw#13264 made open-sse/config/providers/registry/codebuddy-cn/index.ts import CODEBUDDY_CN_USER_AGENT from src/lib/oauth/constants/oauth.ts. The provider registry is browser-reachable (dashboard model pickers → src/shared/constants/models.ts → open-sse/config/providerModels.ts → providerRegistry → every registry entry), and oauth.ts imports open-sse/utils/cursorAgentCliVersion.ts, which imports node:fs / node:os / node:path. With the Playwright edge from diegosouzapw#13283 fixed, `next build --turbopack` then fails one step later on every dashboard page: TurbopackInternalError: Failed to write app endpoint /(dashboard)/dashboard/page Caused by: the chunking context (unknown) does not support external modules (request: node:fs) Define the constant in a dependency-free open-sse/config/providers/registry/codebuddy-cn/userAgent.ts, import it there from the registry entry, and have oauth.ts import + re-export it so the OAuth config, chat headers and usage/quota client keep sharing the one string that diegosouzapw#12702 requires. Verified with an import-graph walk (client/edge entries → files importing a Node built-in): the set of reachable chains is now identical to the last release-branch commit that published a Docker image.
…nerically The guard test from diegosouzapw#10692 only knew a hand-written list of server-only modules and ignored dynamic `import()`, so neither of the two regressions fixed in this PR (diegosouzapw#13264: registry entry → oauth constants → node:fs; diegosouzapw#13283: combo control center → model.ts → lazy DB import → child_process) could fail PR CI — they only surfaced in the next Docker build. - Treat any first-party module that imports a non-polyfillable Node builtin (fs, net, tls, child_process, async_hooks, worker_threads, …) as server-only, in addition to the explicit list. Polyfilled builtins (path, crypto, buffer, …) are deliberately not listed. - Follow dynamic `import("…")` edges: the bundler still has to build the lazy chunk, so a builtin behind one fails the build exactly like a static import. - Report each offending edge once with an example chain and a count instead of one chain per client entry point. On release/v3.8.51 without the two fixes the test now fails with exactly the two chains above; with them it passes.
…nerically The guard test from diegosouzapw#10692 only knew a hand-written list of server-only modules and ignored dynamic `import()`, so neither of the two regressions fixed in this PR (diegosouzapw#13264: registry entry → oauth constants → node:fs; diegosouzapw#13283: combo control center → model.ts → lazy DB import → child_process) could fail PR CI — they only surfaced in the next Docker build. - Treat any first-party module that imports a non-polyfillable Node builtin (fs, net, tls, child_process, async_hooks, worker_threads, …) as server-only, in addition to the explicit list. Polyfilled builtins (path, crypto, buffer, …) are deliberately not listed. - Follow dynamic `import("…")` edges: the bundler still has to build the lazy chunk, so a builtin behind one fails the build exactly like a static import. - Report each offending edge once with an example chain and a count instead of one chain per client entry point. On release/v3.8.51 without the two fixes the test now fails with exactly the two chains above; with them it passes.
… onto opencode-zen (diegosouzapw#11912) (diegosouzapw#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 diegosouzapw#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 diegosouzapw#13278 and diegosouzapw#13276, annotated and landed on diegosouzapw#13243⚠️ base-red inherited: diegosouzapw#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.
… onto opencode-zen (diegosouzapw#11912) (diegosouzapw#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 diegosouzapw#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 diegosouzapw#13278 and diegosouzapw#13276, annotated and landed on diegosouzapw#13243⚠️ base-red inherited: diegosouzapw#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.
Closes #11912
Root cause
open-sse/services/model.ts's manualALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"override canonicalizes ANY"opencode/<model>"string to provideropencode-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 collapsed every rotation slot onto the same provider + connection identity — every request executed against the singleopencode-zenconnection instead of rotating across the free pool, matching the reporter's upstream-log screenshots (100% of traffic landing onopencode-zen, eventually 429ing). The same collapse explained the reported dashboard metrics desync:src/lib/combos/controlCenter.ts'sproviderFromModel()labeled targets from a raw, un-aliased string slice, so the "Resolved Runtime Targets" panel and the actual dispatched identity disagreed.Fix
Scoped to combo target resolution only —
open-sse/services/model.ts's general alias-resolution path is untouched, so a raw client request to"opencode/<model>"outside a combo keeps routing toopencode-zenunchanged (#2798/#3870), and the #7993 sibling credential lookup (tests/unit/opencode-autocombo-search-pair.test.ts) is unaffected.open-sse/services/combo/opencodeTargetAlias.ts:resolveComboTargetModelStr()rewrites an ambiguous"opencode/<model>"combo target to the"oc/"no-auth alias, mirroring the combo builder's existing [BUG] OpenCode Free combo entries use opencode/ prefix instead of oc/ #2901 guard (src/lib/combos/builderOptions.ts).open-sse/services/combo/comboStructure.ts'snormalizeRuntimeStep()(the single shared resolution point for every combo strategy) applies that rewrite before the model string reaches dispatch, so"opencode/<model>"and"opencode-zen/<model>"targets stay distinct rotation identities.src/lib/combos/controlCenter.ts'sproviderFromModel()resolves labels through the same alias-normalization path (plus the existingresolveProviderAlias), so the dashboard label matches what actually executes upstream.Regression test
tests/unit/issue-11912-opencode-roundrobin-collapse.test.tsorigin/release/v3.8.51,comboStructure.tsunfixed):AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + 'opencode-zen' - 'opencode'Gates run
node scripts/check/check-file-size.mjs— no✗for touched filesnode scripts/check/check-complexity.mjs— OK (2798 violations vs baseline 3218)node scripts/check/check-cognitive-complexity.mjs— OK (1265 violations vs baseline 1437)npm run typecheck:core— exit 0npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files>— exit 0node --import tsx/esm --test tests/unit/issue-11912-opencode-roundrobin-collapse.test.ts— 2/2 passtests/unit/combo/round-robin-combo.test.ts,tests/unit/combo-pin-implicit-allowlist.test.ts,tests/unit/combo-routing-engine.test.ts,tests/unit/combo-scoring-inspector.test.ts,tests/unit/opencode-autocombo-search-pair.test.ts,tests/unit/refactor-buildHeaders-opencode.test.ts,tests/unit/model-alias-seed-fallback.test.ts,tests/unit/combo-control-center.test.ts,tests/unit/fusion-vision-panel-3378.test.tsnode scripts/check/check-changelog-integrity.mjs— OK