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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(build):** `next build --turbopack` also failed with `the chunking context does not support external modules (request: node:fs)` on every dashboard page after #13264 made the browser-reachable provider registry entry `codebuddy-cn` import `src/lib/oauth/constants/oauth.ts` (which pulls in `open-sse/utils/cursorAgentCliVersion.ts` → `node:fs`) — `CODEBUDDY_CN_USER_AGENT` now lives in a dependency-free `open-sse/config/providers/registry/codebuddy-cn/userAgent.ts`, re-exported from the OAuth constants so all three consumers still share one string ([#13704](https://github.com/diegosouzapw/OmniRoute/pull/13704)) — thanks @seanford
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(build):** the production build (`next build --turbopack`, and therefore every Docker image build) failed with 168 `Module not found: Can't resolve 'child_process'` errors after #13283 made the `"use client"` combo control center import `open-sse/services/model.ts`, whose lazy DB imports pulled the whole server graph into the browser bundle — `resolveProviderAlias()` now lives in a pure `open-sse/services/providerAlias.ts` (re-exported from `model.ts`) and the control center imports that ([#13704](https://github.com/diegosouzapw/OmniRoute/pull/13704)) — thanks @seanford
2 changes: 1 addition & 1 deletion open-sse/config/providers/registry/codebuddy-cn/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CODEBUDDY_CN_USER_AGENT } from "@/lib/oauth/constants/oauth";
import { CODEBUDDY_CN_USER_AGENT } from "./userAgent.ts";
import type { RegistryEntry } from "../../shared.ts";

/**
Expand Down
18 changes: 18 additions & 0 deletions open-sse/config/providers/registry/codebuddy-cn/userAgent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* CODEBUDDY_CN_USER_AGENT is the single source of truth for the CLI/CodeBuddy
* version string. It MUST stay identical across OAuth
* (src/lib/oauth/constants/oauth.ts), chat completions (./index.ts) and
* usage/quota (open-sse/services/usage/codebuddy-cn.ts) — a mismatched version
* string across a single account's auth vs. chat calls is exactly the kind of
* internally-inconsistent client fingerprint Tencent's WAF flags as anomalous
* (#12702).
*
* It lives in its own dependency-free module because the provider registry is
* reachable from the browser bundle (dashboard model pickers import
* `open-sse/config/providerModels.ts`), while `src/lib/oauth/constants/oauth.ts`
* pulls in `open-sse/utils/cursorAgentCliVersion.ts` and therefore `node:fs`.
* Importing the OAuth constants module from the registry (#13264) made
* `next build --turbopack` fail with "the chunking context does not support
* external modules (request: node:fs)" on every dashboard page.
*/
export const CODEBUDDY_CN_USER_AGENT = "CLI/2.108.1 CodeBuddy/2.108.1";
62 changes: 6 additions & 56 deletions open-sse/services/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
import { ALIAS_TO_PROVIDER_ID, resolveProviderAlias } from "./providerAlias.ts";

export { ALIAS_TO_PROVIDER_ID, resolveProviderAlias };
import { resolveWildcardAlias } from "./wildcardRouter.ts";
import { getRegisteredProviderEffortBaseModelId } from "../utils/registeredEffortVariants.ts";

Expand Down Expand Up @@ -27,37 +30,9 @@ export function stripContextWindowSuffix(
return modelStr.replace(CONTEXT_WINDOW_SUFFIX_RE, "").trimEnd();
}

// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
// This prevents the two maps from drifting out of sync
const ALIAS_TO_PROVIDER_ID: Record<string, string> = {};
for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (ALIAS_TO_PROVIDER_ID[alias]) {
console.log(
`[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".`
);
}
ALIAS_TO_PROVIDER_ID[alias] = id;
}
// Manual alias overrides — maps slug-style prefixes to canonical provider IDs.
// These live outside the registry because they represent multiple providers
// or backward-compatible slug changes, not a single provider's display name.
// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier)
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
// xiaomi/ is the user-visible prefix for MiMo models; register it so
// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead
// of falling through to the identity fallback ("xiaomi").
ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo";
// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider.
// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing
// prefix is "llamacpp". Register it so parseModel("llamacpp/<model>") resolves
// provider = "llama-cpp" instead of the identity fallback ("llamacpp").
ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp";
// agy/ is the short alias for antigravity provider.
ALIAS_TO_PROVIDER_ID["agy"] = "antigravity";
// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider.
// The canonical provider ID is "amazon-q". Register it so parseModel("aq/<model>")
// resolves provider = "amazon-q" instead of falling through to the identity fallback.
ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q";
// ALIAS_TO_PROVIDER_ID and resolveProviderAlias() live in ./providerAlias.ts so
// browser-bundled callers can use them without pulling in this module's lazy DB
// imports; both are re-exported below for existing server-side importers.

// Provider-scoped legacy model aliases. Used to normalize provider/model inputs
// and keep backward compatibility when upstream IDs change.
Expand Down Expand Up @@ -180,31 +155,6 @@ interface ProviderConnectionLike {
is_active?: unknown;
}

/**
* Resolve provider alias to provider ID
*/
export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null {
if (typeof aliasOrId !== "string") return null;
// Follow the alias chain transitively so intermediate alias-only hops resolve
// to the final target, but STOP as soon as a hop lands on a registered
// provider id (#2901): "oc" must resolve to the no-auth "opencode" provider,
// NOT continue through the manual "opencode" → "opencode-zen" slug override —
// that override is for user-typed `opencode/` prefixes only. Without this
// boundary the no-auth provider becomes unreachable by any prefix.
// Guarded against infinite loops with both a depth limit and a seen-set.
let current = aliasOrId;
const seen = new Set<string>();
for (let i = 0; i < 10; i++) {
const next = ALIAS_TO_PROVIDER_ID[current];
if (!next || next === current) return current;
if (next in PROVIDER_ID_TO_ALIAS) return next;
if (seen.has(next)) return next;
seen.add(next);
current = next;
}
return current;
}

/**
* #474 — Resolve a bare model name to the selected connection's `defaultModel`.
*
Expand Down
74 changes: 74 additions & 0 deletions open-sse/services/providerAlias.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Provider alias → canonical provider id resolution.
*
* Split out of `./model.ts` so that browser-bundled code can use it. `model.ts`
* lazily imports the DB layer (`@/lib/db/readCache`, `@/lib/db/models`, …) for
* catalog lookups; Turbopack follows those `await import()` edges, so any
* `"use client"` component that reaches `model.ts` — even for a pure helper —
* drags the whole server graph (down to the Playwright-backed executors) into
* the client bundle and the production build fails with a wall of
* "Module not found: Can't resolve 'child_process'" errors (#13283 introduced
* exactly that edge via `src/lib/combos/controlCenter.ts`).
*
* This module depends only on the static provider registry and is safe to
* import from client components. `model.ts` re-exports everything here, so
* existing server-side importers are unchanged.
*/
import { PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";

// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
// This prevents the two maps from drifting out of sync
export const ALIAS_TO_PROVIDER_ID: Record<string, string> = {};
for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (ALIAS_TO_PROVIDER_ID[alias]) {
console.log(
`[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".`
);
}
ALIAS_TO_PROVIDER_ID[alias] = id;
}
// Manual alias overrides — maps slug-style prefixes to canonical provider IDs.
// These live outside the registry because they represent multiple providers
// or backward-compatible slug changes, not a single provider's display name.
// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier)
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
// xiaomi/ is the user-visible prefix for MiMo models; register it so
// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead
// of falling through to the identity fallback ("xiaomi").
ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo";
// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider.
// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing
// prefix is "llamacpp". Register it so parseModel("llamacpp/<model>") resolves
// provider = "llama-cpp" instead of the identity fallback ("llamacpp").
ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp";
// agy/ is the short alias for antigravity provider.
ALIAS_TO_PROVIDER_ID["agy"] = "antigravity";
// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider.
// The canonical provider ID is "amazon-q". Register it so parseModel("aq/<model>")
// resolves provider = "amazon-q" instead of falling through to the identity fallback.
ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q";

/**
* Resolve provider alias to provider ID
*/
export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null {
if (typeof aliasOrId !== "string") return null;
// Follow the alias chain transitively so intermediate alias-only hops resolve
// to the final target, but STOP as soon as a hop lands on a registered
// provider id (#2901): "oc" must resolve to the no-auth "opencode" provider,
// NOT continue through the manual "opencode" → "opencode-zen" slug override —
// that override is for user-typed `opencode/` prefixes only. Without this
// boundary the no-auth provider becomes unreachable by any prefix.
// Guarded against infinite loops with both a depth limit and a seen-set.
let current = aliasOrId;
const seen = new Set<string>();
for (let i = 0; i < 10; i++) {
const next = ALIAS_TO_PROVIDER_ID[current];
if (!next || next === current) return current;
if (next in PROVIDER_ID_TO_ALIAS) return next;
if (seen.has(next)) return next;
seen.add(next);
current = next;
}
return current;
}
2 changes: 1 addition & 1 deletion src/lib/combos/controlCenter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +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";
import { resolveProviderAlias } from "../../../open-sse/services/providerAlias.ts";

type JsonRecord = Record<string, unknown>;

Expand Down
11 changes: 5 additions & 6 deletions src/lib/oauth/constants/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "@omniroute/open-sse/config/grokBuild.ts";
import { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds.ts";
import { CURSOR_AGENT_CLI_VERSION } from "@omniroute/open-sse/utils/cursorAgentCliVersion.ts";
import { CODEBUDDY_CN_USER_AGENT } from "@omniroute/open-sse/config/providers/registry/codebuddy-cn/userAgent.ts";
import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitlab";

/**
Expand Down Expand Up @@ -108,12 +109,10 @@ export const QODER_CONFIG = {
// No client_id/secret — the upstream CLI ships none.
//
// CODEBUDDY_CN_USER_AGENT is the single source of truth for the CLI/CodeBuddy version
// string. It MUST stay identical across OAuth (this file), chat completions
// (open-sse/config/providers/registry/codebuddy-cn/index.ts) and usage/quota
// (open-sse/services/usage/codebuddy-cn.ts) — a mismatched version string across a
// single account's auth vs. chat calls is exactly the kind of internally-inconsistent
// client fingerprint Tencent's WAF flags as anomalous (#12702).
export const CODEBUDDY_CN_USER_AGENT = "CLI/2.108.1 CodeBuddy/2.108.1";
// string shared by OAuth (this file), chat completions and usage/quota (#12702). It
// is defined in the (browser-safe) provider registry and re-exported here so this
// module's node:fs dependencies never reach the client bundle.
export { CODEBUDDY_CN_USER_AGENT };

export const CODEBUDDY_CN_CONFIG = {
baseUrl: "https://copilot.tencent.com",
Expand Down
98 changes: 90 additions & 8 deletions tests/unit/client-bundle-no-server-only-10692.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,19 @@ import { fileURLToPath } from "node:url";
* - **`import type` is not an edge.** TypeScript erases it before the bundler sees it. A scan
* that counts type imports reports 26 phantom leaks against 2 real ones here — a guard that
* cries wolf gets switched off.
* - **Dynamic `import()` is not followed.** It does not actually break a bundle edge (that was
* tried for #10692 and failed), but it does move the module into a chunk the browser only
* fetches on demand, which is a legitimate boundary for a lazily-used server path.
* - **Dynamic `import()` IS followed.** It does not break a bundle edge (that was tried for
* #10692 and failed): the bundler still has to build the lazy chunk for the browser, so a
* Node builtin behind it fails the build exactly like a static one. #13283 proved it — a
* `"use client"` page reached `open-sse/services/model.ts`, whose `await import("@/lib/db/…")`
* dragged the DB layer and the Playwright executors into the client graph and the Docker build
* died with 168 `Module not found: Can't resolve 'child_process'`.
*
* Server-only modules are recognised two ways: the explicit `SERVER_ONLY` list below, and —
* generically — any first-party module that imports a Node builtin the browser bundle cannot
* polyfill (`NON_POLYFILLABLE_BUILTINS`). The second rule is what catches the next occurrence
* of this pattern in PR CI instead of in the next real Docker build (#13264 was
* `codebuddy-cn/index.ts → src/lib/oauth/constants/oauth.ts → cursorAgentCliVersion.ts →
* node:fs`, and nothing on the hand-written list covered it).
*/
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");

Expand All @@ -39,6 +49,33 @@ const SERVER_ONLY = new Set([
"open-sse/utils/tlsClient.ts",
]);

/**
* Node builtins that no browser bundle can polyfill. `path`, `crypto`, `buffer`, `util`,
* `stream`, `os` and friends get browser shims and are deliberately NOT listed; a module that
* imports one of these, by bare name or with the `node:` prefix, is server-only.
*/
const NON_POLYFILLABLE_BUILTINS = new Set([
"fs",
"fs/promises",
"net",
"tls",
"child_process",
"async_hooks",
"worker_threads",
"cluster",
"dgram",
"dns",
"http2",
"readline",
"repl",
"v8",
"vm",
]);

function isNonPolyfillableBuiltin(specifier: string): boolean {
return NON_POLYFILLABLE_BUILTINS.has(specifier.replace(/^node:/, ""));
}

/**
* Non-`"use client"` entry points that still end up in a client bundle because client
* components import them. Kept explicit so the original #10692 chain stays pinned even if the
Expand Down Expand Up @@ -97,10 +134,16 @@ function isTypeOnlyClause(clause: string): boolean {
return bindings.length > 0 && bindings.every((binding) => /^type\s/.test(binding));
}

/** Value-carrying static specifiers only. */
/**
* Value-carrying specifiers: static imports/re-exports, side-effect imports, and dynamic
* `import("…")` with a literal specifier (see the header for why the last one counts).
*/
function staticSpecifiers(source: string): string[] {
const withoutDynamic = source.replace(/\bimport\s*\(/g, "__dynamic_import__(");
const out: string[] = [];
for (const match of source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)) {
out.push(match[1]);
}
for (const pattern of [
/(?:^|\n)\s*import\s+([^;'"]*)from\s*["']([^"']+)["']/g,
/(?:^|\n)\s*export\s+([^;'"]*)from\s*["']([^"']+)["']/g,
Expand All @@ -118,20 +161,35 @@ function staticSpecifiers(source: string): string[] {
}

const specifierCache = new Map<string, string[]>();
const builtinCache = new Map<string, string | null>();
function edgesOf(file: string): string[] {
const cached = specifierCache.get(file);
if (cached) return cached;
const absolute = path.join(REPO_ROOT, file);
let edges: string[] = [];
let builtin: string | null = null;
if (fs.existsSync(absolute)) {
edges = staticSpecifiers(fs.readFileSync(absolute, "utf8"))
const specifiers = staticSpecifiers(fs.readFileSync(absolute, "utf8"));
builtin = specifiers.find(isNonPolyfillableBuiltin) ?? null;
edges = specifiers
.map((specifier) => resolveSpecifier(file, specifier))
.filter((resolved): resolved is string => resolved !== null);
}
specifierCache.set(file, edges);
builtinCache.set(file, builtin);
return edges;
}

/** The non-polyfillable builtin `file` imports directly, if any. */
function builtinOf(file: string): string | null {
if (!builtinCache.has(file)) edgesOf(file);
return builtinCache.get(file) ?? null;
}

function isServerOnly(file: string): boolean {
return SERVER_ONLY.has(file) || builtinOf(file) !== null;
}

/** BFS over static imports; returns the first path reaching a server-only module. */
function findServerOnlyPath(entry: string): string[] | null {
const seen = new Set<string>([entry]);
Expand All @@ -140,7 +198,10 @@ function findServerOnlyPath(entry: string): string[] | null {
const trail = queue.shift()!;
for (const resolved of edgesOf(trail[trail.length - 1])) {
if (seen.has(resolved)) continue;
if (SERVER_ONLY.has(resolved)) return [...trail, resolved];
if (isServerOnly(resolved)) {
const builtin = builtinOf(resolved);
return [...trail, builtin ? `${resolved} (imports ${builtin})` : resolved];
}
seen.add(resolved);
queue.push([...trail, resolved]);
}
Expand All @@ -163,7 +224,9 @@ function walk(dir: string, acc: string[] = []): string[] {

function clientEntryPoints(): string[] {
return walk(path.join(REPO_ROOT, "src")).filter((file) =>
/^\s*["']use client["']/m.test(fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200))
/^\s*["']use client["']/m.test(
fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200)
)
);
}

Expand All @@ -175,11 +238,30 @@ test("no client entry point statically reaches server-only code", () => {
.map((entry) => ({ entry, trail: findServerOnlyPath(entry) }))
.filter((row): row is { entry: string; trail: string[] } => row.trail !== null);

// One bad edge is usually reachable from hundreds of entry points; report each distinct
// offending edge (the importer → server-only module pair) once, with one example chain and
// a count.
const distinct = new Map<string, { trail: string[]; entries: number }>();
for (const { trail } of offenders) {
const key = trail.slice(-2).join("→");
const seen = distinct.get(key);
if (seen) seen.entries += 1;
else distinct.set(key, { trail, entries: 1 });
}

assert.deepEqual(
offenders.map((o) => o.entry),
[],
"A client bundle would have to include server-only modules:\n" +
offenders.map((o) => ` ${o.trail.join("\n → ")}`).join("\n\n") +
[...distinct.values()]
.map(
({ trail, entries }) =>
` ${trail.join("\n → ")}` +
(entries > 1
? `\n (and ${entries - 1} more client entry points reach the same chain)`
: "")
)
.join("\n\n") +
"\nBreak the chain — or, when the binding is only a type, mark it `import type` so it " +
"carries no runtime edge."
);
Expand Down