From ff64716c314597e0a3f4e9857a52adf0cb860f3b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 14 Aug 2026 15:04:59 -0300 Subject: [PATCH 1/5] feat(dashboard): opt-in CSP relaxation for VS Code Simple Browser embedding (#10273) (#10386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OmniRoute ships `frame-ancestors 'none'` + `X-Frame-Options: DENY` on every route, so the VS Code Simple Browser renders a blank tab — which is what the OmniCopilot extension's `dashboardOpen: "editor"` mode uses. Add the build-time opt-in `DASHBOARD_ALLOW_EMBED=vscode`. When set, the HTML pages are served with `frame-ancestors 'self' vscode-webview:` and without `X-Frame-Options` (XFO cannot express a custom scheme and would veto the relaxed CSP). Unset — the default — nothing changes. The API surface stays strictly unframable in both modes. Its exclusion list is derived from the `rewrites()` table plus `/api`, `/a2a`, `/healthz`, so a future root-level API alias is excluded automatically instead of silently becoming framable. The two generated `source` patterns are complementary by construction: every pathname matches exactly one, so there is no gap (a page with no security headers) and no order-dependent overlap. Closes #10273 Co-authored-by: Xiangzhe --- .env.example | 11 + .../features/10273-dashboard-embed-csp.md | 1 + docs/reference/ENVIRONMENT.md | 1 + next.config.mjs | 28 +- scripts/build/dashboardEmbed.mjs | 142 ++++++++ tests/unit/dashboard-embed-csp-10273.test.ts | 324 ++++++++++++++++++ 6 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 changelog.d/features/10273-dashboard-embed-csp.md create mode 100644 scripts/build/dashboardEmbed.mjs create mode 100644 tests/unit/dashboard-embed-csp-10273.test.ts diff --git a/.env.example b/.env.example index 8472bf8743..cf22f8da53 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,17 @@ PORT=20128 # stay consistent without relying on window.location.origin alone: # NEXT_PUBLIC_BASE_URL=https://host/omniroute +# Opt-in iframe embedding of the OmniRoute HTML pages (issue #10273). Off by default: +# every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`, which is why the +# VS Code Simple Browser (used by the OmniCopilot extension's "Open Dashboard → editor" +# mode) renders a blank tab. Set this to `vscode` to switch the HTML pages — dashboard, +# login, docs, landing — to `frame-ancestors 'self' vscode-webview:` and drop +# X-Frame-Options for them (XFO cannot express a custom scheme). The API surface +# (/api, /v1, /v1beta, /a2a, /healthz and the root-level aliases) keeps the strict +# headers regardless. Only `vscode` is recognised; `1`/`true` do NOT enable it. +# Used by: next.config.mjs via scripts/build/dashboardEmbed.mjs — build-time, rebuild after changing. +# DASHBOARD_ALLOW_EMBED=vscode + # Split-port mode: serve Dashboard and API on separate ports for network isolation. # Used by: src/lib/runtime/ports.ts — overrides PORT for each service. # API_PORT=20129 diff --git a/changelog.d/features/10273-dashboard-embed-csp.md b/changelog.d/features/10273-dashboard-embed-csp.md new file mode 100644 index 0000000000..8627e0ddbe --- /dev/null +++ b/changelog.d/features/10273-dashboard-embed-csp.md @@ -0,0 +1 @@ +- feat(dashboard): opt-in `DASHBOARD_ALLOW_EMBED=vscode` relaxes CSP `frame-ancestors` to `'self' vscode-webview:` and drops `X-Frame-Options` for HTML pages only, so the dashboard renders inside the VS Code Simple Browser (OmniCopilot). Default posture unchanged — API routes stay unframable (#10273) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ede96d13e5..908538f16d 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -123,6 +123,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | | `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs`, `scripts/docker/ensure-docker-base-path.mjs` | URL subpath for serving OmniRoute behind a reverse proxy (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. In Docker the value is baked during `docker build` (`ARG OMNIROUTE_BASE_PATH`); pre-built root images can apply a different runtime value once at container start before Next.js boots. Set `NEXT_PUBLIC_BASE_URL` to the public origin including the same subpath. | | `NEXT_PUBLIC_OMNIROUTE_BASE_PATH` | _(empty = root)_ | `src/shared/hooks/useDisplayBaseUrl.ts` | Browser-visible mirror of `OMNIROUTE_BASE_PATH`, inlined at build time so the dashboard endpoint display shows `https://host/omniroute/v1` instead of `https://host/v1`. Falls back to `OMNIROUTE_BASE_PATH` when unset. Rebuild after changing (Next `basePath` is build-time). | +| `DASHBOARD_ALLOW_EMBED` | _(unset = never framable)_ | `next.config.mjs`, `scripts/build/dashboardEmbed.mjs` | Opt-in iframe embedding of the HTML pages. Unset, every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`. Set to `vscode` to serve the pages (dashboard, login, docs, landing) with `frame-ancestors 'self' vscode-webview:` and no `X-Frame-Options`, so the VS Code Simple Browser can render them (OmniCopilot's `dashboardOpen: "editor"` mode). The API surface (`/api`, `/v1`, `/v1beta`, `/a2a`, `/healthz`, root-level aliases) keeps the strict headers either way. Only `vscode` is recognised — `1`/`true` do not enable it. Build-time: rebuild after changing. | | `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | | `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | | `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | diff --git a/next.config.mjs b/next.config.mjs index e1c2e7cc1a..34be60c02e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -4,6 +4,11 @@ import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs"; import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs"; +import { + buildSecurityHeaderRules, + nonPageRoutePrefixes, + resolveDashboardEmbedMode, +} from "./scripts/build/dashboardEmbed.mjs"; const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); const distDir = process.env.NEXT_DIST_DIR || ".build/next"; @@ -75,6 +80,11 @@ function isNextIntlExtractorDynamicImportWarning(warning) { // for security-sensitive environments. See docs/security/SOCKET_DEV_FINDINGS.md. const isMinimalBuild = process.env.OMNIROUTE_BUILD_PROFILE === "minimal"; +// #10273: `null` unless the operator opts in with DASHBOARD_ALLOW_EMBED=vscode. Read at build +// time like every other knob in this file (OMNIROUTE_BASE_PATH, OMNIROUTE_BUILD_PROFILE, …), +// so changing it requires a rebuild. See scripts/build/dashboardEmbed.mjs. +const dashboardEmbedMode = resolveDashboardEmbedMode(process.env); + const minimalBuildAliases = isMinimalBuild ? { "@/mitm/cert/install": "./src/mitm/cert/install.stub.ts", @@ -389,11 +399,21 @@ const nextConfig = { }, async headers() { + // #10273: opt-in embedding for the VS Code Simple Browser (OmniCopilot). Off by default — + // `securityHeaders` then applies to `/:path*` exactly as it always has. When the operator + // sets DASHBOARD_ALLOW_EMBED=vscode, buildSecurityHeaderRules() splits that catch-all into + // two complementary rules: the API surface keeps `frame-ancestors 'none'` + X-Frame-Options, + // the HTML pages get `frame-ancestors 'self' vscode-webview:` and no X-Frame-Options. + // The exclusion list is DERIVED from the rewrite table below (self-reference is safe — the + // config object is fully built by the time Next calls headers()), so a future root-level API + // alias is excluded automatically instead of silently becoming framable. + const embedRules = buildSecurityHeaderRules({ + mode: dashboardEmbedMode, + securityHeaders, + prefixes: dashboardEmbedMode ? nonPageRoutePrefixes(await nextConfig.rewrites()) : [], + }); return [ - { - source: "/:path*", - headers: securityHeaders, - }, + ...embedRules, // G-10: allow OmniRoute's own dashboard to embed the 9Router UI via our reverse proxy. // `frame-ancestors 'self'` overrides the global `frame-ancestors 'none'` only for this // path. The route is already LOCAL_ONLY (routeGuard.ts) so remote origins cannot reach it. diff --git a/scripts/build/dashboardEmbed.mjs b/scripts/build/dashboardEmbed.mjs new file mode 100644 index 0000000000..338cfeab8f --- /dev/null +++ b/scripts/build/dashboardEmbed.mjs @@ -0,0 +1,142 @@ +/** + * Opt-in iframe embedding for OmniRoute's HTML pages (#10273). + * + * OmniRoute ships `frame-ancestors 'none'` + `X-Frame-Options: DENY` on every route, which + * is the right default for a proxy that holds provider credentials. The OmniCopilot VS Code + * extension, however, renders the dashboard inside the built-in Simple Browser — an iframe + * whose ancestor is a `vscode-webview:` document — so the strict default paints a blank tab. + * + * Setting `DASHBOARD_ALLOW_EMBED=vscode` at build time swaps the page surface to + * `frame-ancestors 'self' vscode-webview:` and drops `X-Frame-Options` for those pages. + * XFO has no syntax for a custom scheme, and keeping `DENY` alongside a permissive + * `frame-ancestors` would still block the frame in engines that honour XFO first — dropping + * it is required, not cosmetic. (Modern engines ignore XFO entirely once `frame-ancestors` + * is present, so nothing is lost where CSP is supported.) + * + * The API surface is deliberately left out: `/api/*`, `/v1*`, `/a2a`, `/healthz` and every + * root-level rewrite alias keep the strict headers even in embed mode. Those are the + * Hard-Rule-15/17 process-spawning and proxy surfaces and never need framing. + * + * Build-time by design: Next.js resolves `headers()` when the config loads, matching the + * existing env-driven knobs in `next.config.mjs` (`OMNIROUTE_BASE_PATH`, + * `OMNIROUTE_BUILD_PROFILE`, …). Changing the value requires a rebuild. + */ + +export const DASHBOARD_EMBED_ENV = "DASHBOARD_ALLOW_EMBED"; + +/** Ancestor allow-list per supported embed mode. Adding a mode here is the only extension point. */ +export const EMBED_FRAME_ANCESTORS = Object.freeze({ + // `vscode-webview:` is the scheme VS Code assigns to webview/Simple Browser documents. + // `'self'` keeps OmniRoute's own same-origin frames (e.g. the G-10 9Router embed) working. + vscode: "'self' vscode-webview:", +}); + +/** The strict `frame-ancestors` token the CSP carries by default. */ +export const STRICT_FRAME_ANCESTORS = "frame-ancestors 'none'"; + +/** + * App-router surfaces that are not HTML pages and have no `rewrites()` alias to derive them + * from. Everything else in the exclusion list comes from the rewrite table, so a future API + * alias is excluded automatically instead of silently becoming framable. + */ +export const STATIC_NON_PAGE_PREFIXES = Object.freeze(["api", "a2a", "healthz"]); + +/** + * Resolve the opt-in embed mode from the environment. + * Unknown / truthy-looking values (`1`, `true`, `on`) intentionally do NOT enable embedding: + * the operator must name the ancestor family they are opening up. + * + * @param {Record} env + * @returns {"vscode" | null} + */ +export function resolveDashboardEmbedMode(env = process.env) { + const raw = env?.[DASHBOARD_EMBED_ENV]; + if (typeof raw !== "string") return null; + const normalized = raw.trim().toLowerCase(); + return Object.hasOwn(EMBED_FRAME_ANCESTORS, normalized) ? normalized : null; +} + +/** + * The first path segment of every route that must stay unframable, derived from the + * `rewrites()` table plus the static app-router API surfaces. + * + * @param {{ source: string }[]} rewriteRules + * @returns {string[]} sorted, de-duplicated prefixes + */ +export function nonPageRoutePrefixes(rewriteRules = []) { + const prefixes = new Set(STATIC_NON_PAGE_PREFIXES); + for (const { source } of rewriteRules) { + const first = source.replace(/^\//, "").split("/")[0]; + // Skip parameterised first segments (`/:path*`) — they would exclude the whole site. + if (first && !first.startsWith(":")) prefixes.add(first); + } + return [...prefixes].sort(); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Two complementary Next.js `source` patterns built from the same prefix list, so the union + * covers every pathname exactly once — no gap (a page with no security headers) and no + * overlap (an order-dependent merge). + * + * @param {string[]} prefixes + * @returns {{ nonPageSource: string, pageSource: string }} + */ +export function complementarySources(prefixes) { + const alternation = prefixes.map(escapeRegExp).join("|"); + const boundary = `(?:${alternation})(?:/|$)`; + return { + nonPageSource: `/((?=${boundary}).*)`, + pageSource: `/((?!${boundary}).*)`, + }; +} + +/** + * Swap only the `frame-ancestors` token of an existing CSP, leaving every other directive + * byte-identical. + * + * @param {string} contentSecurityPolicy + * @param {"vscode"} mode + */ +export function relaxFrameAncestors(contentSecurityPolicy, mode) { + return contentSecurityPolicy.replace( + STRICT_FRAME_ANCESTORS, + `frame-ancestors ${EMBED_FRAME_ANCESTORS[mode]}` + ); +} + +/** + * Build the `headers()` rules carrying OmniRoute's baseline security headers. + * + * With embedding off this returns the single catch-all rule the config has always had, so a + * default build is unchanged. With embedding on it returns two complementary rules: the API + * surface keeps the strict headers, the page surface gets the relaxed CSP and no XFO. + * + * @param {{ + * mode: "vscode" | null, + * securityHeaders: { key: string, value: string }[], + * prefixes?: string[], + * }} options + * @returns {{ source: string, headers: { key: string, value: string }[] }[]} + */ +export function buildSecurityHeaderRules({ mode, securityHeaders, prefixes = [] }) { + if (!mode) return [{ source: "/:path*", headers: securityHeaders }]; + + const { nonPageSource, pageSource } = complementarySources(prefixes); + const pageHeaders = securityHeaders + // X-Frame-Options cannot express `vscode-webview:` and would veto the relaxed CSP. + .filter((header) => header.key !== "X-Frame-Options") + .map((header) => + header.key === "Content-Security-Policy" + ? { key: header.key, value: relaxFrameAncestors(header.value, mode) } + : header + ); + + return [ + { source: nonPageSource, headers: securityHeaders }, + { source: pageSource, headers: pageHeaders }, + ]; +} diff --git a/tests/unit/dashboard-embed-csp-10273.test.ts b/tests/unit/dashboard-embed-csp-10273.test.ts new file mode 100644 index 0000000000..16951bcb5a --- /dev/null +++ b/tests/unit/dashboard-embed-csp-10273.test.ts @@ -0,0 +1,324 @@ +// Regression guard for #10273: opt-in CSP relaxation so OmniRoute's HTML pages can be +// embedded in the VS Code Simple Browser (the OmniCopilot extension's `dashboardOpen: +// "editor"` mode renders them inside a `vscode-webview:` iframe). +// +// The default posture is UNCHANGED and must stay that way: `frame-ancestors 'none'` + +// `X-Frame-Options: DENY` on every route. Only when the operator explicitly sets +// DASHBOARD_ALLOW_EMBED=vscode do the HTML pages switch to +// `frame-ancestors 'self' vscode-webview:` and drop `X-Frame-Options` (XFO has no syntax +// for a custom scheme, and CSP frame-ancestors supersedes it in modern engines). +// +// The API surface (`/api`, `/v1`, `/v1beta`, the root-level rewrite aliases, `/a2a`, +// `/healthz`) must keep the strict headers even in embed mode — those are the +// Hard-Rule-15/17 surfaces and never need framing. +// +// These tests assert EFFECTIVE headers, not config shape: `effectiveHeaders()` replays +// Next.js's own matching + last-wins merge (see +// node_modules/next/dist/server/lib/router-utils/resolve-routes.js, `resHeaders[key] = value`) +// so a rule that silently stops matching, or an ordering regression, fails here. + +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { getPathMatch } from "next/dist/shared/lib/router/utils/path-match.js"; + +import { + DASHBOARD_EMBED_ENV, + EMBED_FRAME_ANCESTORS, + resolveDashboardEmbedMode, + nonPageRoutePrefixes, + buildSecurityHeaderRules, +} from "../../scripts/build/dashboardEmbed.mjs"; + +const modulePath = path.join(process.cwd(), "next.config.mjs"); +const originalEmbed = process.env[DASHBOARD_EMBED_ENV]; + +interface HeaderEntry { + key: string; + value: string; +} +interface HeaderRule { + source: string; + headers: HeaderEntry[]; +} + +async function loadHeaders(label: string): Promise { + const mod = await import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}`); + return mod.default.headers(); +} + +/** Replay Next.js's header matching + last-wins merge for one pathname. */ +function effectiveHeaders(rules: HeaderRule[], pathname: string): Record { + const merged: Record = {}; + for (const rule of rules) { + if (getPathMatch(rule.source, { removeUnnamedParams: true })(pathname) === false) continue; + for (const { key, value } of rule.headers) merged[key] = value; + } + return merged; +} + +// Pages an operator expects to reach inside the VS Code Simple Browser. `/login` is on the +// list on purpose: the webview has its own cookie jar, so an embedded session ALWAYS starts +// unauthenticated and `/dashboard` redirects there (src/server/authz/pipeline.ts). +const PAGE_PATHS = [ + "/", + "/dashboard", + "/dashboard/providers", + "/dashboard/combos/editor", + "/login", + "/forgot-password", + "/docs/guides/i18n", + "/landing", + "/status", +]; + +// Never framable — the process-spawning / proxy surfaces plus every root-level API alias +// declared in next.config.mjs `rewrites()`. +const API_PATHS = [ + "/api", + "/api/v1/chat/completions", + "/api/services/ninerouter/start", + "/v1", + "/v1/models", + "/v1beta/models", + "/chat/completions", + "/responses", + "/responses/abc/cancel", + "/models", + "/codex/responses", + "/anthropic/v1/messages", + "/openai/v1/models", + "/metrics", + "/debug", + "/.env", + "/a2a", + "/healthz", +]; + +function restoreEnv(): void { + if (originalEmbed === undefined) delete process.env[DASHBOARD_EMBED_ENV]; + else process.env[DASHBOARD_EMBED_ENV] = originalEmbed; +} + +test.afterEach(restoreEnv); + +// ── the opt-in switch itself ─────────────────────────────────────────────── + +test("#10273 embed mode is OFF unless DASHBOARD_ALLOW_EMBED names a known mode", () => { + for (const raw of [undefined, "", " ", "0", "1", "true", "yes", "on", "browser", "vscode-web"]) { + assert.equal( + resolveDashboardEmbedMode(raw === undefined ? {} : { [DASHBOARD_EMBED_ENV]: raw }), + null, + `DASHBOARD_ALLOW_EMBED=${JSON.stringify(raw)} must not enable embedding` + ); + } +}); + +test("#10273 DASHBOARD_ALLOW_EMBED=vscode enables the vscode mode, case/space tolerant", () => { + for (const raw of ["vscode", "VSCode", " vscode ", "VSCODE"]) { + assert.equal(resolveDashboardEmbedMode({ [DASHBOARD_EMBED_ENV]: raw }), "vscode"); + } + assert.equal(EMBED_FRAME_ANCESTORS.vscode, "'self' vscode-webview:"); +}); + +// ── default posture must not move ────────────────────────────────────────── + +test("#10273 default build keeps frame-ancestors 'none' + X-Frame-Options: DENY everywhere", async () => { + delete process.env[DASHBOARD_EMBED_ENV]; + const rules = await loadHeaders("embed-off"); + + assert.equal( + rules[0].source, + "/:path*", + "the global rule must stay a plain catch-all by default" + ); + + for (const pathname of [...PAGE_PATHS, ...API_PATHS]) { + const headers = effectiveHeaders(rules, pathname); + assert.match( + headers["Content-Security-Policy"], + /frame-ancestors 'none'/, + `${pathname} must keep frame-ancestors 'none' when the opt-in is off` + ); + assert.equal( + headers["X-Frame-Options"], + "DENY", + `${pathname} must keep X-Frame-Options: DENY when the opt-in is off` + ); + } +}); + +test("#10273 default build emits no extra header rules (byte-identical to pre-feature config)", async () => { + delete process.env[DASHBOARD_EMBED_ENV]; + const rules = await loadHeaders("embed-off-shape"); + assert.deepEqual( + rules.map((rule) => rule.source), + ["/:path*", "/dashboard/providers/services/:name/embed/:path*"] + ); +}); + +// ── embed mode ───────────────────────────────────────────────────────────── + +test("#10273 embed mode lets vscode-webview: frame the HTML pages and drops X-Frame-Options", async () => { + process.env[DASHBOARD_EMBED_ENV] = "vscode"; + const rules = await loadHeaders("embed-on-pages"); + + for (const pathname of PAGE_PATHS) { + const headers = effectiveHeaders(rules, pathname); + assert.ok( + headers["Content-Security-Policy"], + `${pathname} must still receive a Content-Security-Policy` + ); + assert.match( + headers["Content-Security-Policy"], + /frame-ancestors 'self' vscode-webview:/, + `${pathname} must allow the vscode-webview: ancestor in embed mode` + ); + assert.equal( + headers["X-Frame-Options"], + undefined, + `${pathname} must NOT carry X-Frame-Options in embed mode (it would still block the iframe)` + ); + } +}); + +test("#10273 embed mode keeps the API surface strictly unframable", async () => { + process.env[DASHBOARD_EMBED_ENV] = "vscode"; + const rules = await loadHeaders("embed-on-api"); + + for (const pathname of API_PATHS) { + const headers = effectiveHeaders(rules, pathname); + assert.match( + headers["Content-Security-Policy"], + /frame-ancestors 'none'/, + `${pathname} is an API surface and must keep frame-ancestors 'none' even in embed mode` + ); + assert.equal( + headers["X-Frame-Options"], + "DENY", + `${pathname} is an API surface and must keep X-Frame-Options: DENY even in embed mode` + ); + assert.ok( + !headers["Content-Security-Policy"].includes("vscode-webview:"), + `${pathname} must never allow the vscode-webview: ancestor` + ); + } +}); + +test("#10273 embed mode relaxes ONLY frame-ancestors — every other directive/header survives", async () => { + process.env[DASHBOARD_EMBED_ENV] = "vscode"; + const relaxed = effectiveHeaders(await loadHeaders("embed-on-intact"), "/dashboard"); + + delete process.env[DASHBOARD_EMBED_ENV]; + const strict = effectiveHeaders(await loadHeaders("embed-off-intact"), "/dashboard"); + + assert.equal( + relaxed["Content-Security-Policy"], + strict["Content-Security-Policy"].replace( + "frame-ancestors 'none'", + `frame-ancestors ${EMBED_FRAME_ANCESTORS.vscode}` + ), + "embed mode must swap the frame-ancestors token and change nothing else in the CSP" + ); + + for (const key of [ + "X-Content-Type-Options", + "Referrer-Policy", + "Permissions-Policy", + "Strict-Transport-Security", + ]) { + assert.equal(relaxed[key], strict[key], `${key} must be identical in embed mode`); + } +}); + +test("#10273 embed mode preserves the G-10 9Router embed override (last rule still wins)", async () => { + process.env[DASHBOARD_EMBED_ENV] = "vscode"; + const rules = await loadHeaders("embed-on-g10"); + const headers = effectiveHeaders(rules, "/dashboard/providers/services/ninerouter/embed/ui"); + + assert.equal( + headers["Content-Security-Policy"], + "frame-ancestors 'self'", + "the G-10 same-origin override must keep the last word for the 9Router embed route" + ); +}); + +// ── the API prefix list must stay derived from the config, not hand-maintained ── + +test("#10273 every root-level rewrite alias is excluded from the embeddable page surface", async () => { + const modUrl = `${pathToFileURL(modulePath).href}?case=prefixes-${Date.now()}`; + const nextConfig = (await import(modUrl)).default; + const rewrites = await nextConfig.rewrites(); + const prefixes = new Set(nonPageRoutePrefixes(rewrites)); + + for (const { source } of rewrites) { + const first = source.replace(/^\//, "").split("/")[0]; + assert.ok( + prefixes.has(first), + `rewrite alias "${source}" must be excluded from the embeddable page surface — ` + + `it proxies an API route and must never be framable` + ); + } + // The app-router API surfaces that have no rewrite alias. + for (const literal of ["api", "a2a", "healthz"]) { + assert.ok(prefixes.has(literal), `"${literal}" must be excluded from the page surface`); + } +}); + +test("#10273 Next.js accepts the generated header sources in BOTH modes (startup guard)", async () => { + // `loadCustomRoutes` is the validation Next runs when the config loads: a `source` it + // rejects aborts the build. The embed-mode sources are regexes, and CI never builds with + // DASHBOARD_ALLOW_EMBED set — without this guard a malformed source would only blow up on + // the operator's machine, at build time, with the feature already shipped. + const require = createRequire(import.meta.url); + const loadCustomRoutes = require("next/dist/lib/load-custom-routes.js").default; + + for (const mode of [undefined, "vscode"]) { + if (mode) process.env[DASHBOARD_EMBED_ENV] = mode; + else delete process.env[DASHBOARD_EMBED_ENV]; + + const nextConfig = ( + await import(`${pathToFileURL(modulePath).href}?case=validate-${mode}-${Date.now()}`) + ).default; + const routes = await loadCustomRoutes({ + ...nextConfig, + trailingSlash: false, + skipTrailingSlashRedirect: false, + basePath: "", + i18n: undefined, + }); + + assert.equal( + routes.headers.length, + mode ? 3 : 2, + `mode=${mode ?? "off"} should produce ${mode ? 3 : 2} header routes` + ); + } +}); + +test("#10273 buildSecurityHeaderRules produces complementary sources with no gap", () => { + const securityHeaders = [ + { key: "Content-Security-Policy", value: "frame-ancestors 'none'; default-src 'self'" }, + { key: "X-Frame-Options", value: "DENY" }, + ]; + const rules = buildSecurityHeaderRules({ + mode: "vscode", + securityHeaders, + prefixes: ["api", "v1"], + }); + + // Every pathname must be covered by exactly one of the two rules — a gap would ship a + // page with NO security headers at all, an overlap would make the merge order-dependent. + for (const pathname of ["/", "/dashboard", "/login", "/api/v1/models", "/v1/models", "/apifoo"]) { + const matched = rules.filter( + (rule) => getPathMatch(rule.source, { removeUnnamedParams: true })(pathname) !== false + ); + assert.equal( + matched.length, + 1, + `${pathname} must match exactly one rule, got ${matched.length}` + ); + } +}); From 7837e469080b3355bbf30ee3f8e6b07c7f179a8d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 14 Aug 2026 15:15:12 -0300 Subject: [PATCH 2/5] feat(ocr): Vertex AI DeepSeek-OCR provider (#10398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sse): add Vertex AI DeepSeek OCR transformation to the registry Adds VERTEX_DEEPSEEK_TRANSFORMATION (request/response mapping for the Vertex AI DeepSeek OCR MaaS endpoint) and registers the "vertex-deepseek-ocr" provider in OCR_PROVIDERS, modeled on litellm's VertexAIDeepSeekOCRConfig. buildRequest treats the resolved baseUrl as the complete Vertex endpoint URL (project/location resolved upstream), matching the existing Mistral passthrough pattern. * feat(sse): resolve Vertex AI DeepSeek OCR auth and endpoint URL Adds resolveVertexOcrAccessToken (mints a Vertex OAuth access token from a Service Account JSON apiKey, reusing open-sse/executors/vertex.ts's existing JWT-bearer exchange — no new OAuth flow) and resolveVertexOcrBaseUrl (derives the project/location "openapi/chat/ completions" endpoint from providerSpecificData or the Service Account JSON's project_id). Both live in open-sse/handlers/ocr.ts, not the src/app/api/v1/ocr route, since routes may not import executor implementations directly (EXECUTOR_IMPORT_RESTRICTION in eslint.config.mjs) — the route re-exports/consumes them across that boundary. handleOcr now prefers credentials.accessToken over apiKey so the minted token (not the raw Service Account JSON) is sent upstream. * docs(api): document the vertex-deepseek-ocr /v1/ocr provider Adds the vertex-deepseek-ocr row to the /v1/ocr provider table and a short section on its Vertex AI auth/endpoint resolution, and lists the new provider/model id in openapi.yaml alongside mistral and azure-document-intelligence. * docs(skills): regenerate omni-inference skill for the Vertex OCR provider --------- Co-authored-by: Xiangzhe --- docs/openapi.yaml | 6 +- docs/reference/API_REFERENCE.md | 24 ++- open-sse/config/ocrRegistry.ts | 82 ++++++++++ open-sse/handlers/ocr.ts | 77 +++++++++- skills/omni-inference/SKILL.md | 2 +- src/app/api/v1/ocr/route.ts | 38 +++-- .../unit/ocr-registry-transformations.test.ts | 92 ++++++++++++ tests/unit/ocr-route-vertex.test.ts | 142 ++++++++++++++++++ 8 files changed, 444 insertions(+), 19 deletions(-) create mode 100644 tests/unit/ocr-route-vertex.test.ts diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 123db6af67..f4cfd804ca 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -6844,7 +6844,8 @@ paths: and response shape). Accepts a JSON body referencing a document/image and returns extracted text. `model` selects the provider via a `provider/model` prefix (e.g. `mistral/mistral-ocr-latest`, - `azure-document-intelligence/prebuilt-read`); a bare model id (e.g. + `azure-document-intelligence/prebuilt-read`, + `vertex-deepseek-ocr/deepseek-ocr-maas`); a bare model id (e.g. `mistral-ocr-latest`) resolves to its registered provider, and an omitted `model` defaults to Mistral. Azure Document Intelligence is asynchronous upstream — the handler polls the returned operation @@ -6865,7 +6866,8 @@ paths: description: >- `provider/model` id or bare model id. Registered ids: `mistral/mistral-ocr-latest`, - `azure-document-intelligence/prebuilt-read`. Defaults to + `azure-document-intelligence/prebuilt-read`, + `vertex-deepseek-ocr/deepseek-ocr-maas`. Defaults to `mistral-ocr-latest` when omitted. document: type: object diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index f1d4fce2e9..9ccca26093 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -220,12 +220,13 @@ Content-Type: application/json `mistral-ocr-latest`) resolves to its registered provider, and an omitted `model` defaults to Mistral (`mistral-ocr-latest`). Registered providers (`open-sse/config/ocrRegistry.ts`): -| Provider id | Model id | `model` value | Notes | -| ----------------------------- | -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `mistral` | `mistral-ocr-latest` | `mistral/mistral-ocr-latest` (or bare `mistral-ocr-latest`) | Synchronous — the response is returned directly from the single upstream call. | -| `azure-document-intelligence` | `prebuilt-read` | `azure-document-intelligence/prebuilt-read` | Asynchronous upstream (`analyze` + poll) — see below. | +| Provider id | Model id | `model` value | Notes | +| ----------------------------- | -------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `mistral` | `mistral-ocr-latest` | `mistral/mistral-ocr-latest` (or bare `mistral-ocr-latest`) | Synchronous — the response is returned directly from the single upstream call. | +| `azure-document-intelligence` | `prebuilt-read` | `azure-document-intelligence/prebuilt-read` | Asynchronous upstream (`analyze` + poll) — see below. | +| `vertex-deepseek-ocr` | `deepseek-ocr-maas` | `vertex-deepseek-ocr/deepseek-ocr-maas` | Synchronous, via Vertex AI's `openapi/chat/completions` partner endpoint — see below for auth/URL. | -Both providers respond in the same Mistral-shaped body: +All three providers respond in the same Mistral-shaped body: ```json { @@ -245,6 +246,19 @@ operation is still running after the attempt budget is exhausted. The final Azur normalized into the same `pages`/`markdown` shape used by Mistral before being returned to the caller, so client code does not need to special-case the provider. +### Vertex AI DeepSeek OCR auth and endpoint resolution + +`vertex-deepseek-ocr` reuses the same Vertex AI authentication OmniRoute already supports for +chat/image traffic (`open-sse/executors/vertex.ts`): the connection's API key is either a +Service Account JSON credential (exchanged for a short-lived OAuth access token via the JWT-bearer +flow) or an already-minted OAuth access token used as-is. The upstream endpoint URL is Vertex's +generic `openapi/chat/completions` partner endpoint, built from the connection's project and +region — an explicit `providerSpecificData.project`/`providerSpecificData.region` always wins; +otherwise the project is derived from the Service Account JSON's `project_id` and the region +defaults to `us-central1`. Both resolutions happen in `open-sse/handlers/ocr.ts` +(`resolveVertexOcrAccessToken`, `resolveVertexOcrBaseUrl`), consumed by +`src/app/api/v1/ocr/route.ts` before dispatching to `handleOcr`. + --- ## List Models diff --git a/open-sse/config/ocrRegistry.ts b/open-sse/config/ocrRegistry.ts index 4bcc141d4c..ccda80ddfc 100644 --- a/open-sse/config/ocrRegistry.ts +++ b/open-sse/config/ocrRegistry.ts @@ -104,6 +104,80 @@ export const AZURE_DI_TRANSFORMATION: OcrTransformation = { }, }; +/** + * Vertex AI DeepSeek OCR (deepseek-ai/deepseek-ocr-maas), served through Vertex's generic + * OpenAI-compatible partner endpoint ("openapi/chat/completions"). Modeled on litellm's + * VertexAIDeepSeekOCRConfig (litellm/llms/vertex_ai/ocr/deepseek_transformation.py): + * - request: OpenAI chat-completions shape, model prefixed with "deepseek-ai/", the OCR + * document sent as a single image_url content part (document_url documents are mapped to + * the same image_url shape — Vertex accepts both gs:// and https:// URLs there). + * - response: an OpenAI chat-completions body whose choices[0].message.content is either a + * JSON string already in the canonical {pages,model,usage_info} shape, or plain markdown + * text — both are normalized into OcrResponseShape. + * + * The full project/location endpoint URL is resolved into credentials.baseUrl upstream (see + * resolveOcrCredentials in src/app/api/v1/ocr/route.ts, the same pattern Azure DI uses for its + * resource endpoint) — buildRequest treats baseUrl as the complete URL, exactly like Mistral. + */ +function vertexDeepseekOcrContent(document: Record | undefined): { + type: string; + image_url: string; +} { + const url = String(document?.document_url ?? document?.image_url ?? ""); + return { type: "image_url", image_url: url }; +} + +export const VERTEX_DEEPSEEK_TRANSFORMATION: OcrTransformation = { + buildRequest({ baseUrl, token, body, modelId }) { + return { + url: baseUrl, + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + model: `deepseek-ai/${modelId}`, + messages: [ + { + role: "user", + content: [vertexDeepseekOcrContent(body.document as Record)], + }, + ], + }), + }, + }; + }, + parseResponse(raw) { + const r = raw as { + model?: string; + choices?: Array<{ message?: { content?: unknown } }>; + usage?: Record; + }; + const model = r.model ?? "deepseek-ocr-maas"; + const content = r.choices?.[0]?.message?.content; + + if (typeof content === "string") { + const trimmed = content.trim(); + if (trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed) as Partial; + if (Array.isArray(parsed.pages)) { + return { + pages: parsed.pages, + model: parsed.model ?? model, + usage_info: parsed.usage_info ?? r.usage, + }; + } + } catch { + // Not JSON after all — fall through and treat it as plain markdown. + } + } + return { pages: [{ index: 0, markdown: content }], model, usage_info: r.usage }; + } + + return { pages: [{ index: 0, markdown: "" }], model, usage_info: r.usage }; + }, +}; + export const OCR_PROVIDERS: Record = { mistral: { id: "mistral", @@ -120,6 +194,14 @@ export const OCR_PROVIDERS: Record = { models: [{ id: "prebuilt-read", name: "Azure Document Intelligence (Read)" }], transformation: AZURE_DI_TRANSFORMATION, }, + "vertex-deepseek-ocr": { + id: "vertex-deepseek-ocr", + baseUrl: "", + authType: "apikey", + authHeader: "bearer", + models: [{ id: "deepseek-ocr-maas", name: "DeepSeek OCR (Vertex AI MaaS)" }], + transformation: VERTEX_DEEPSEEK_TRANSFORMATION, + }, }; /** diff --git a/open-sse/handlers/ocr.ts b/open-sse/handlers/ocr.ts index 3edf0b5e61..565f05ce00 100644 --- a/open-sse/handlers/ocr.ts +++ b/open-sse/handlers/ocr.ts @@ -14,12 +14,83 @@ import { import { errorResponse } from "../utils/error.ts"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; +import { + getAccessToken, + looksLikeServiceAccountJson, + parseSAFromApiKey, +} from "../executors/vertex.ts"; const OCR_POLL_MAX_ATTEMPTS = 30; const OCR_POLL_INTERVAL_MS = 1000; const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +export const VERTEX_DEEPSEEK_OCR_PROVIDER_ID = "vertex-deepseek-ocr"; +const VERTEX_OCR_DEFAULT_REGION = "us-central1"; + +/** + * Resolve the Vertex AI project id backing a vertex-deepseek-ocr connection: an explicit + * providerSpecificData.project always wins; otherwise fall back to the project_id embedded in + * the Service Account JSON credential (the same source VertexExecutor.buildUrl uses for the + * chat/image pipeline — open-sse/executors/vertex.ts). Returns null when neither is available. + * Kept in this handler (rather than the route) because routes may not import executors + * directly (see EXECUTOR_IMPORT_RESTRICTION in eslint.config.mjs) — this stays behind the + * open-sse handler boundary and is re-exported for the route to call. + */ +function resolveVertexOcrProject(credentials: { + apiKey?: string; + providerSpecificData?: Record; +}): string | null { + const explicitProject = credentials.providerSpecificData?.project; + if (typeof explicitProject === "string" && explicitProject.trim()) return explicitProject; + if (credentials.apiKey && looksLikeServiceAccountJson(credentials.apiKey)) { + try { + const projectId = parseSAFromApiKey(credentials.apiKey).project_id; + return typeof projectId === "string" && projectId.trim() ? projectId : null; + } catch { + return null; + } + } + return null; +} + +/** + * Builds the full Vertex AI DeepSeek OCR endpoint URL (the generic Vertex + * "openapi/chat/completions" partner endpoint — see VERTEX_DEEPSEEK_TRANSFORMATION in + * open-sse/config/ocrRegistry.ts) from the resolved project + region, or null when the + * project cannot be resolved (handleOcr then surfaces the standard "No base URL configured" + * error, since OCR_PROVIDERS["vertex-deepseek-ocr"].baseUrl is intentionally empty). + */ +export function resolveVertexOcrBaseUrl(credentials: { + apiKey?: string; + providerSpecificData?: Record; +}): string | null { + const project = resolveVertexOcrProject(credentials); + if (!project) return null; + const region = credentials.providerSpecificData?.region; + const resolvedRegion = + typeof region === "string" && region.trim() ? region : VERTEX_OCR_DEFAULT_REGION; + return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${resolvedRegion}/endpoints/openapi/chat/completions`; +} + +/** + * Mint a short-lived Vertex AI OAuth access token for vertex-deepseek-ocr connections that + * authenticate with a Service Account JSON credential, reusing the exact JWT-bearer exchange + * the chat/image executor already uses (open-sse/executors/vertex.ts::getAccessToken) — no new + * OAuth flow. A raw (non-JSON) apiKey is treated as an already-minted OAuth access token and + * used as-is (matches the Vertex provider's "Service Account JSON or OAuth access_token" + * authHint), and an existing credentials.accessToken always wins. + */ +export async function resolveVertexOcrAccessToken< + T extends { apiKey?: string; accessToken?: string }, +>(providerId: string, credentials: T): Promise { + if (providerId !== VERTEX_DEEPSEEK_OCR_PROVIDER_ID) return credentials; + if (credentials.accessToken || !credentials.apiKey) return credentials; + if (!looksLikeServiceAccountJson(credentials.apiKey)) return credentials; + const accessToken = await getAccessToken(parseSAFromApiKey(credentials.apiKey)); + return { ...credentials, accessToken }; +} + /** * Handle OCR request * @@ -59,7 +130,11 @@ export async function handleOcr({ ); } - const token = credentials?.apiKey || credentials?.accessToken; + // accessToken wins when both are present: providers like vertex-deepseek-ocr resolve a + // short-lived OAuth token from a Service Account JSON apiKey (see resolveVertexOcrAccessToken + // in src/app/api/v1/ocr/route.ts) while keeping the original apiKey around for other + // resolution steps (e.g. deriving the project id) — the minted token must be the one sent. + const token = credentials?.accessToken || credentials?.apiKey; if (!token) { return errorResponse(401, `No credentials for OCR provider: ${providerId}`); } diff --git a/skills/omni-inference/SKILL.md b/skills/omni-inference/SKILL.md index bcc32df799..0a2930477d 100644 --- a/skills/omni-inference/SKILL.md +++ b/skills/omni-inference/SKILL.md @@ -304,7 +304,7 @@ curl -X POST https://localhost:20128/api/v1/management/proxy-subscriptions/{id}/ Document OCR -Multi-provider document OCR endpoint (Mistral OCR–compatible request and response shape). Accepts a JSON body referencing a document/image and returns extracted text. `model` selects the provider via a `provider/model` prefix (e.g. `mistral/mistral-ocr-latest`, `azure-document-intelligence/prebuilt-read`); a bare model id (e.g. `mistral-ocr-latest`) resolves to its registered provider, and an omitted `model` defaults to Mistral. Azure Document Intelligence is asynchronous upstream — the handler polls the returned operation until it succeeds or fails before responding, so this endpoint can take longer to return for that provider. Success responses carry the `X-OmniRoute-*` cost-telemetry headers. +Multi-provider document OCR endpoint (Mistral OCR–compatible request and response shape). Accepts a JSON body referencing a document/image and returns extracted text. `model` selects the provider via a `provider/model` prefix (e.g. `mistral/mistral-ocr-latest`, `azure-document-intelligence/prebuilt-read`, `vertex-deepseek-ocr/deepseek-ocr-maas`); a bare model id (e.g. `mistral-ocr-latest`) resolves to its registered provider, and an omitted `model` defaults to Mistral. Azure Document Intelligence is asynchronous upstream — the handler polls the returned operation until it succeeds or fails before responding, so this endpoint can take longer to return for that provider. Success responses carry the `X-OmniRoute-*` cost-telemetry headers. ```bash curl -X POST https://localhost:20128/api/v1/ocr \ diff --git a/src/app/api/v1/ocr/route.ts b/src/app/api/v1/ocr/route.ts index 6dde40c609..a4b25185f4 100644 --- a/src/app/api/v1/ocr/route.ts +++ b/src/app/api/v1/ocr/route.ts @@ -1,4 +1,9 @@ -import { handleOcr } from "@omniroute/open-sse/handlers/ocr.ts"; +import { + handleOcr, + resolveVertexOcrAccessToken, + resolveVertexOcrBaseUrl, + VERTEX_DEEPSEEK_OCR_PROVIDER_ID, +} from "@omniroute/open-sse/handlers/ocr.ts"; import { getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState, @@ -15,22 +20,34 @@ import { rateLimitedProviderResponse, } from "@/app/api/v1/_shared/rateLimit"; +export { resolveVertexOcrAccessToken }; + /** - * Custom-endpoint providers (e.g. azure-document-intelligence) store the - * connection's resource endpoint under providerSpecificData.baseUrl, not as - * a top-level credentials field — mirror the convention used across - * src/lib/providers/validation/* (see e.g. urlHelpers.ts). handleOcr reads - * credentials.baseUrl, so surface it here. An existing top-level baseUrl - * always wins (kept for tests/callers that pass it directly). + * Custom-endpoint providers (e.g. azure-document-intelligence, vertex-deepseek-ocr) store the + * connection's resource endpoint under providerSpecificData, not as a top-level credentials + * field — mirror the convention used across src/lib/providers/validation/* (see e.g. + * urlHelpers.ts). handleOcr reads credentials.baseUrl, so surface it here. An existing + * top-level baseUrl always wins (kept for tests/callers that pass it directly). The + * vertex-deepseek-ocr project/location resolution itself lives in the open-sse handler + * (resolveVertexOcrBaseUrl) — routes may not import executor implementations directly (see + * EXECUTOR_IMPORT_RESTRICTION in eslint.config.mjs). */ export function resolveOcrCredentials< - T extends { baseUrl?: string; providerSpecificData?: Record }, ->(credentials: T): T { + T extends { + baseUrl?: string; + apiKey?: string; + providerSpecificData?: Record; + }, +>(credentials: T, providerId?: string): T { if (credentials?.baseUrl) return credentials; const providerSpecificBaseUrl = credentials?.providerSpecificData?.baseUrl; if (typeof providerSpecificBaseUrl === "string" && providerSpecificBaseUrl.trim()) { return { ...credentials, baseUrl: providerSpecificBaseUrl }; } + if (providerId === VERTEX_DEEPSEEK_OCR_PROVIDER_ID) { + const vertexBaseUrl = resolveVertexOcrBaseUrl(credentials); + if (vertexBaseUrl) return { ...credentials, baseUrl: vertexBaseUrl }; + } return credentials; } @@ -85,7 +102,8 @@ async function postHandler(request, context) { return rateLimitedProviderResponse(resolvedProvider, credentials); } - const ocrCredentials = resolveOcrCredentials(credentials); + const tokenReadyCredentials = await resolveVertexOcrAccessToken(resolvedProvider, credentials); + const ocrCredentials = resolveOcrCredentials(tokenReadyCredentials, resolvedProvider); const response = await handleOcr({ body: { ...body, model }, credentials: ocrCredentials }); if (response?.ok) { diff --git a/tests/unit/ocr-registry-transformations.test.ts b/tests/unit/ocr-registry-transformations.test.ts index ce7454ac7d..b6b733bc22 100644 --- a/tests/unit/ocr-registry-transformations.test.ts +++ b/tests/unit/ocr-registry-transformations.test.ts @@ -4,6 +4,7 @@ import { OCR_PROVIDERS, getOcrTransformation, MISTRAL_PASSTHROUGH, + VERTEX_DEEPSEEK_TRANSFORMATION, } from "../../open-sse/config/ocrRegistry.ts"; test("mistral resolves the passthrough transformation by default", () => { @@ -72,3 +73,94 @@ test("azure DI maps base64/image_url documents to base64Source/urlSource", () => const sent = JSON.parse(String(init.body)); assert.equal(sent.base64Source, "AAAA"); }); + +// ── Vertex AI DeepSeek OCR ────────────────────────────────────────────────── +// URL/body/response shapes verified against the upstream reference +// (litellm/llms/vertex_ai/ocr/deepseek_transformation.py): the endpoint is the +// generic Vertex "openapi/chat/completions" partner endpoint, the model id is +// prefixed with "deepseek-ai/", and the OCR document is sent as an +// OpenAI-chat-shaped image_url content part. + +test("vertex-deepseek-ocr resolves its own transformation (not the passthrough)", () => { + const t = getOcrTransformation("vertex-deepseek-ocr"); + assert.equal(t, VERTEX_DEEPSEEK_TRANSFORMATION); +}); + +test("vertex-deepseek-ocr builds an OpenAI-chat-shaped request against the resolved endpoint", () => { + const t = getOcrTransformation("vertex-deepseek-ocr"); + const { url, init } = t.buildRequest({ + // resolveOcrCredentials (src/app/api/v1/ocr/route.ts) resolves the full + // project/location endpoint into credentials.baseUrl before this runs — + // buildRequest treats baseUrl as the complete URL, mirroring Mistral. + baseUrl: + "https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/endpoints/openapi/chat/completions", + token: "ya29.mock", + body: { document: { type: "image_url", image_url: "https://x/y.png" } }, + modelId: "deepseek-ocr-maas", + }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/endpoints/openapi/chat/completions" + ); + assert.equal(init.method, "POST"); + assert.equal((init.headers as Record).Authorization, "Bearer ya29.mock"); + const sent = JSON.parse(String(init.body)); + assert.equal(sent.model, "deepseek-ai/deepseek-ocr-maas"); + assert.deepEqual(sent.messages, [ + { role: "user", content: [{ type: "image_url", image_url: "https://x/y.png" }] }, + ]); +}); + +test("vertex-deepseek-ocr maps a document_url document to the same image_url content shape", () => { + const t = getOcrTransformation("vertex-deepseek-ocr"); + const { init } = t.buildRequest({ + baseUrl: + "https://aiplatform.googleapis.com/v1/projects/p/locations/us-central1/endpoints/openapi/chat/completions", + token: "t", + body: { document: { type: "document_url", document_url: "https://x/d.pdf" } }, + modelId: "deepseek-ocr-maas", + }); + const sent = JSON.parse(String(init.body)); + assert.deepEqual(sent.messages[0].content, [{ type: "image_url", image_url: "https://x/d.pdf" }]); +}); + +test("vertex-deepseek-ocr parseResponse extracts a JSON pages payload embedded in choices[0].message.content", () => { + const t = getOcrTransformation("vertex-deepseek-ocr"); + const raw = { + choices: [ + { + message: { + content: JSON.stringify({ + pages: [{ index: 0, markdown: "# hi" }], + model: "deepseek-ocr-maas", + usage_info: { pages_processed: 1 }, + }), + }, + }, + ], + }; + const parsed = t.parseResponse(raw); + assert.deepEqual(parsed.pages, [{ index: 0, markdown: "# hi" }]); + assert.equal(parsed.model, "deepseek-ocr-maas"); + assert.deepEqual(parsed.usage_info, { pages_processed: 1 }); +}); + +test("vertex-deepseek-ocr parseResponse wraps plain markdown content into a single page (Mistral shape)", () => { + const t = getOcrTransformation("vertex-deepseek-ocr"); + const raw = { + model: "deepseek-ocr-maas", + choices: [{ message: { content: "# just markdown, not JSON" } }], + usage: { total_tokens: 42 }, + }; + const parsed = t.parseResponse(raw); + assert.deepEqual(parsed.pages, [{ index: 0, markdown: "# just markdown, not JSON" }]); + assert.equal(parsed.model, "deepseek-ocr-maas"); + assert.deepEqual(parsed.usage_info, { total_tokens: 42 }); +}); + +test("vertex-deepseek-ocr parseResponse tolerates a missing/empty choices array", () => { + const t = getOcrTransformation("vertex-deepseek-ocr"); + const parsed = t.parseResponse({ model: "deepseek-ocr-maas", choices: [] }); + assert.deepEqual(parsed.pages, [{ index: 0, markdown: "" }]); + assert.equal(parsed.model, "deepseek-ocr-maas"); +}); diff --git a/tests/unit/ocr-route-vertex.test.ts b/tests/unit/ocr-route-vertex.test.ts new file mode 100644 index 0000000000..12828e7ea4 --- /dev/null +++ b/tests/unit/ocr-route-vertex.test.ts @@ -0,0 +1,142 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import { + resolveOcrCredentials, + resolveVertexOcrAccessToken, +} from "../../src/app/api/v1/ocr/route.ts"; + +// ── resolveOcrCredentials — vertex-deepseek-ocr project/location resolution ─ +// Mirrors the Azure DI pattern (providerSpecificData.baseUrl → top-level +// baseUrl) but synthesizes the full Vertex "openapi/chat/completions" +// endpoint URL from providerSpecificData.project/region, or (when project is +// not explicitly configured) from the Service Account JSON's project_id — +// the same source VertexExecutor.buildUrl uses (open-sse/executors/vertex.ts). + +test("resolveOcrCredentials builds the Vertex endpoint URL from explicit providerSpecificData.project/region", () => { + const credentials = { + apiKey: "ya29.raw-access-token", + providerSpecificData: { project: "proj-explicit", region: "europe-west4" }, + }; + const resolved = resolveOcrCredentials(credentials, "vertex-deepseek-ocr"); + assert.equal( + resolved.baseUrl, + "https://aiplatform.googleapis.com/v1/projects/proj-explicit/locations/europe-west4/endpoints/openapi/chat/completions" + ); +}); + +test("resolveOcrCredentials defaults the Vertex region to us-central1 when unset", () => { + const credentials = { apiKey: "ya29.tok", providerSpecificData: { project: "proj-1" } }; + const resolved = resolveOcrCredentials(credentials, "vertex-deepseek-ocr"); + assert.equal( + resolved.baseUrl, + "https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/endpoints/openapi/chat/completions" + ); +}); + +test("resolveOcrCredentials derives the Vertex project from a Service Account JSON apiKey when providerSpecificData.project is absent", () => { + const credentials = { + apiKey: JSON.stringify({ + project_id: "proj-from-sa", + client_email: "svc@x.iam", + private_key: "x", + }), + }; + const resolved = resolveOcrCredentials(credentials, "vertex-deepseek-ocr"); + assert.equal( + resolved.baseUrl, + "https://aiplatform.googleapis.com/v1/projects/proj-from-sa/locations/us-central1/endpoints/openapi/chat/completions" + ); +}); + +test("resolveOcrCredentials leaves baseUrl unset when the Vertex project cannot be resolved (raw token, no providerSpecificData.project)", () => { + const credentials = { apiKey: "ya29.raw-token-no-project" }; + const resolved = resolveOcrCredentials(credentials, "vertex-deepseek-ocr"); + assert.equal(resolved.baseUrl, undefined); +}); + +test("resolveOcrCredentials keeps an explicit top-level baseUrl untouched for vertex-deepseek-ocr", () => { + const credentials = { + apiKey: "ya29.tok", + baseUrl: "https://explicit.example.com", + providerSpecificData: { project: "ignored" }, + }; + const resolved = resolveOcrCredentials(credentials, "vertex-deepseek-ocr"); + assert.equal(resolved.baseUrl, "https://explicit.example.com"); +}); + +test("resolveOcrCredentials is unaffected for non-vertex providers (mistral, azure-document-intelligence unchanged)", () => { + const mistral = { apiKey: "sk-mistral" }; + assert.deepEqual(resolveOcrCredentials(mistral, "mistral"), mistral); + const azure = { + apiKey: "azkey", + providerSpecificData: { baseUrl: "https://r.cognitiveservices.azure.com" }, + }; + assert.equal( + resolveOcrCredentials(azure, "azure-document-intelligence").baseUrl, + "https://r.cognitiveservices.azure.com" + ); +}); + +// ── resolveVertexOcrAccessToken — mints a Vertex OAuth access token from a ─ +// Service Account JSON credential, reusing the exact same JWT-bearer flow +// the chat executor uses (open-sse/executors/vertex.ts::getAccessToken) — +// no new OAuth flow is implemented here. + +test("resolveVertexOcrAccessToken is a no-op for non-vertex providers", async () => { + const credentials = { apiKey: JSON.stringify({ client_email: "x", private_key: "y" }) }; + const resolved = await resolveVertexOcrAccessToken("mistral", credentials); + assert.equal(resolved, credentials); +}); + +test("resolveVertexOcrAccessToken is a no-op when an accessToken is already present", async () => { + const credentials = { apiKey: "sa-json-ignored", accessToken: "ya29.already-here" }; + const resolved = await resolveVertexOcrAccessToken("vertex-deepseek-ocr", credentials); + assert.equal(resolved, credentials); +}); + +test("resolveVertexOcrAccessToken is a no-op for a raw (non-JSON) access token apiKey — used as-is", async () => { + const credentials = { apiKey: "ya29.raw-preminted-token" }; + const resolved = await resolveVertexOcrAccessToken("vertex-deepseek-ocr", credentials); + assert.equal(resolved, credentials); +}); + +test("resolveVertexOcrAccessToken exchanges a Service Account JSON apiKey for a minted accessToken via the shared JWT-bearer flow", async () => { + const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const saJson = JSON.stringify({ + project_id: "proj-ocr", + private_key_id: "kid-ocr-1", + client_email: "svc-ocr-route-test@example.iam.gserviceaccount.com", + private_key: privateKey, + }); + + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string }> = []; + globalThis.fetch = async (url: string | URL | Request, options?: RequestInit) => { + calls.push({ url: String(url) }); + assert.match(String(url), /oauth2\.googleapis\.com\/token$/); + assert.match( + String(options?.body ?? ""), + /grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer/ + ); + return new Response(JSON.stringify({ access_token: "ya29.minted-for-ocr", expires_in: 3600 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const credentials = { apiKey: saJson }; + const resolved = await resolveVertexOcrAccessToken("vertex-deepseek-ocr", credentials); + assert.equal(resolved.accessToken, "ya29.minted-for-ocr"); + // apiKey is preserved (resolveOcrCredentials may still need it to derive the project). + assert.equal(resolved.apiKey, saJson); + assert.equal(calls.length, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); From abd4df63dc25479853d0b7410f59d4c1b5816ccc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 14 Aug 2026 15:40:33 -0300 Subject: [PATCH 3/5] fix(sse): surface Qwen/Alibaba personal Token Plan quota in dashboard and preflight (#10290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sse): surface Qwen/Alibaba personal Token Plan quota in dashboard and preflight The personal Token Plan (5-hour / 7-day sliding windows) has no official OpenAPI and the inference API key cannot read it. Add a cookie-authenticated fetcher for the console gateway shared by home.qwencloud.com and the Model Studio console (contract captured live from a logged-in session): - open-sse/services/qwenTokenPlanQuotaFetcher.ts: POST /data/api.json (IntlBroadScopeAspnGateway / sfm_bailian) for usage + quota-config + subscription; sec_token resolved best-effort from the dashboard HTML; per-window parse (fields are omitted while a window is Temporarily Removed); 60s usage cache, 1h tier cache. - usage/qwen-token-plan.ts leaf + registration in the usage dispatcher, USAGE_FETCHER_PROVIDERS, USAGE_SUPPORTED_PROVIDERS, PROVIDER_LIMITS_APIKEY_PROVIDERS and bespoke preflight/monitor windows. - Also adds bailian-coding-plan to USAGE_SUPPORTED_PROVIDERS / PROVIDER_LIMITS_APIKEY_PROVIDERS: the coding-plan fetcher existed but the dashboard filtered those connections out (UI gap). Refs #9603 (Problema 1 — quota missing; the 429 recovery half is a follow-up). * docs(env): document Qwen Token Plan quota env vars + regen omni-settings skill QWEN_CLOUD_COOKIE, QWEN_CLOUD_SEC_TOKEN, QWEN_TOKEN_PLAN_HOST and QWEN_TOKEN_PLAN_DASHBOARD_URL added to .env.example and docs/reference/ENVIRONMENT.md (check:env-doc-sync), with the generated omni-settings skill refreshed (check:agent-skills-sync). Refs #9603 * revert: keep hand-tuned omni-settings thinking-budget section The agent-skills-sync drift predates this PR (hand improvement from #10169 not yet synced into the generator source) — it fails on every open PR and belongs to a base-reds fix, not this branch. Regenerating here would erase the intentional content. * feat(dashboard): add the Qwen/Model Studio console cookie field to the connection modal The Token Plan quota fetcher is cookie-authenticated (the inference API key cannot read the console gateway), but no modal field existed to paste that cookie — so the quota was unconfigurable from the dashboard and the fetcher could only ever return its 'needs a cookie' message. Adds the field for qwen-cloud-token-plan and bailian-coding-plan alongside the existing ollama-cloud / alibaba console-cookie inputs (same password-input, blank-keeps-stored semantics), pre-fills it when editing a connection, and extends the providerSpecificData string/length validation to the two new keys. Tests: tests/unit/qwen-token-plan-cookie-field.test.ts (RED before, GREEN after) covers persistence + trimming, the blank-input no-overwrite rule and schema acceptance/rejection. Refs #9603 * docs(dashboard): correct the Qwen console cookie instructions The placeholder claimed the cookie looks like 'token=...'; the qwencloud portal actually issues 'login_qwencloud_ticket=...' alongside cna/cnaui/aui (mirroring login_aliyunid_ticket on the Alibaba console), so the hint pointed at the wrong value. Replaces the guesswork with the verified retrieval steps in all three places an operator can hit — the modal field hint, the fetcher's 'needs a cookie' message and .env.example/ENVIRONMENT.md: log in to home.qwencloud.com > Billing > Subscription, F12 > Network, reload, filter by api.json, click a request to cs-data.qwencloud.com and copy the WHOLE Cookie request header. Also documents that the value must go on one line (it contains '=' and ';') and that it dies with the browser session. Refs #9603 * fix(dashboard): tolerate partial form objects in the qwen cookie branch Adding bailian-coding-plan to QWEN_TOKEN_PLAN_PROVIDERS routed callers that previously matched NO branch in assignQuotaScrapingProviderData into the new one, which assumed the two new fields are always present. Older callers build a partial form object, so buildAddProviderSpecificData threw: TypeError: Cannot read properties of undefined (reading 'trim') (tests/unit/dashboard/agentrouter-connection-modal-fields.test.ts) Reads the new fields with optional chaining and adds a regression test that calls the helper with those keys deleted for both providers. Refs #9603 * refactor(dashboard): move quota-scraping form logic into a UI-free module tests/unit/qwen-token-plan-cookie-field.test.ts imported QuotaScrapingFields directly, which pulls `@/shared/components` and, through that barrel, untranspiled ESM (@lobehub/icons). The node:test runner cannot parse it and the whole test file died in CI with: SyntaxError: Unexpected token 'export' at @lobehub/icons/es/Ai21/components/Mono.js (It passed locally, so only the CI shard surfaced it.) Extracts the pure pieces — QWEN_TOKEN_PLAN_PROVIDERS, QuotaScrapingFieldValues, EMPTY_QUOTA_SCRAPING_FIELDS and assignQuotaScrapingProviderData — into quotaScrapingFieldValues.ts. The component imports them and re-exports the public names, so every existing importer keeps its current path. The unit test now targets the UI-free module. Refs #9603 * fix(providers): point bailian-coding-plan at the Token Plan endpoint and its console Two independent defects kept this provider unusable with a valid Alibaba Token Plan key (verified live 2026-08-14 with the owner's key and cookie): 1. Wrong inference host. The catalog entry is named "Alibaba Token Plan", links to token-plan-overview and its hint asks for a Token Plan key, but the registry pointed at coding-intl.dashscope.aliyuncs.com — the Coding Plan host, which rejects Token Plan keys with 401 invalid_api_key. The documented Anthropic base URL for Token Plan is token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic (https://www.alibabacloud.com/help/en/model-studio/more-tools). Against the new host the same key returns 200 for all six registry models and a real completion; auth stays on x-api-key. 2. Wrong console identity for quota. The personal Token Plan is sold through two consoles sharing one backend, and the gateway validates the session against the console declared in the request: an Alibaba console cookie (login_aliyunid_ticket) sent with the QwenCloud identity is refused with BailianGateway.Login.NotLogined. resolveConsoleSite() now picks host, cornerstoneParam.consoleSite/domain and Origin/Referer from the cookie's login ticket, falling back to the provider. With that switch the same cookie returns usage/subscription/quota-config. Also routes bailian-coding-plan quota through the Token Plan fetcher (the Coding Plan call returns "Bad Request" for these accounts), keeping the old fetcher as the fallback for real Coding Plan keys, and labels the plan by console ("Alibaba Token Plan (Pro)" vs "Qwen …"). Live validation: inference 200 (qwen3.7-plus answered "FUNCIONA"); quota 12,934/40,000 credits, 67.7% remaining, resets 2026-08-20. Refs #9603 --------- Co-authored-by: Xiangzhe --- .env.example | 16 + docs/reference/ENVIRONMENT.md | 4 + .../registry/bailian-coding-plan/index.ts | 7 +- .../services/qwenTokenPlanQuotaFetcher.ts | 437 ++++++++++++++++++ open-sse/services/usage.ts | 4 + open-sse/services/usage/bailian.ts | 17 +- open-sse/services/usage/qwen-token-plan.ts | 96 ++++ .../components/modals/EditConnectionModal.tsx | 2 + .../components/modals/QuotaScrapingFields.tsx | 91 ++-- .../modals/quotaScrapingFieldValues.ts | 64 +++ src/lib/usage/providerLimits.ts | 3 + src/shared/constants/providers.ts | 4 + src/shared/validation/providerSpecificData.ts | 2 + src/sse/handlers/chat.ts | 6 + .../unit/qwen-token-plan-console-site.test.ts | 120 +++++ .../unit/qwen-token-plan-cookie-field.test.ts | 101 ++++ .../qwen-token-plan-quota-fetcher.test.ts | 272 +++++++++++ 17 files changed, 1207 insertions(+), 39 deletions(-) create mode 100644 open-sse/services/qwenTokenPlanQuotaFetcher.ts create mode 100644 open-sse/services/usage/qwen-token-plan.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts create mode 100644 tests/unit/qwen-token-plan-console-site.test.ts create mode 100644 tests/unit/qwen-token-plan-cookie-field.test.ts create mode 100644 tests/unit/qwen-token-plan-quota-fetcher.test.ts diff --git a/.env.example b/.env.example index cf22f8da53..f984a0ea4d 100644 --- a/.env.example +++ b/.env.example @@ -2040,6 +2040,22 @@ APP_LOG_TO_FILE=true # ALIBABA_CODING_PLAN_HOST= # ALIBABA_CODING_PLAN_QUOTA_URL= +# ── Qwen Cloud / Model Studio personal Token Plan quota ── +# Cookie-authenticated console-gateway fetcher (issue #9603). Used by: +# open-sse/services/qwenTokenPlanQuotaFetcher.ts. Prefer the per-connection +# Dashboard fields (qwenCloudCookie / qwenCloudSecToken) — these env vars are +# global fallbacks. Cookie/sec_token are SENSITIVE session credentials. +# Getting the cookie: log in to home.qwencloud.com > Billing > Subscription, +# press F12 > Network, reload, filter by api.json, click any request to +# cs-data.qwencloud.com and copy the WHOLE Cookie value from Request Headers +# (it contains login_qwencloud_ticket). Paste it on ONE line — the value may +# contain '=' and ';'. It expires with the browser session; re-paste it when +# the dashboard reports an expired session. +# QWEN_CLOUD_COOKIE= +# QWEN_CLOUD_SEC_TOKEN= +# QWEN_TOKEN_PLAN_HOST= +# QWEN_TOKEN_PLAN_DASHBOARD_URL= + # ── Alibaba Model Studio free-tier quota sync ── # Console front-end path overrides for the free-tier quota fetcher. Used by: # open-sse/services/alibabaFreeTierQuotaFetcher.ts. When unset, the fetcher diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 908538f16d..fbaa8357b8 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1142,6 +1142,10 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | Redis connection string for the rate limiter backend. | | `ALIBABA_CODING_PLAN_HOST` | _(production host)_ | `open-sse/services/bailianQuotaFetcher.ts` | Override the host used to fetch Alibaba Bailian coding-plan quotas. | | `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | +| `QWEN_CLOUD_COOKIE` | _(unset)_ | `open-sse/services/qwenTokenPlanQuotaFetcher.ts` | Console session cookie for the Qwen Cloud / Model Studio personal Token Plan quota gateway (the inference API key cannot read it). Copy the whole `Cookie` request header — it contains `login_qwencloud_ticket` — from any `api.json` call to `cs-data.qwencloud.com` on home.qwencloud.com › Billing › Subscription (F12 › Network). Sensitive and session-scoped; prefer the per-connection `qwenCloudCookie` Dashboard field. | +| `QWEN_CLOUD_SEC_TOKEN` | _(unset)_ | `open-sse/services/qwenTokenPlanQuotaFetcher.ts` | Manual `sec_token` override for the Token Plan console gateway. Sensitive; when unset the fetcher resolves it from the dashboard HTML using the cookie. | +| `QWEN_TOKEN_PLAN_HOST` | `https://cs-data.qwencloud.com` | `open-sse/services/qwenTokenPlanQuotaFetcher.ts` | Gateway host override for the personal Token Plan quota fetcher (e.g. `bailian-singapore-cs.alibabacloud.com` for the Model Studio console). | +| `QWEN_TOKEN_PLAN_DASHBOARD_URL` | `https://home.qwencloud.com/` | `open-sse/services/qwenTokenPlanQuotaFetcher.ts` | Dashboard URL used to resolve `sec_token` from the logged-in HTML. | | `ALIBABA_FREE_TIER_VISION_FE_PATH` | `/costing-balance/free-quota-image-video` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier vision/media quota. | | `ALIBABA_FREE_TIER_MULTIMODAL_FE_PATH` | `/costing-balance/free-quota-multimodal` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier multimodal quota. | | `ALIBABA_FREE_TIER_AUDIO_FE_PATH` | `/costing-balance/free-quota-audio` | `open-sse/services/alibabaFreeTierQuotaFetcher.ts` | Console front-end path override for fetching Alibaba Model Studio free-tier audio quota. | diff --git a/open-sse/config/providers/registry/bailian-coding-plan/index.ts b/open-sse/config/providers/registry/bailian-coding-plan/index.ts index 735c563fa7..11af75db81 100644 --- a/open-sse/config/providers/registry/bailian-coding-plan/index.ts +++ b/open-sse/config/providers/registry/bailian-coding-plan/index.ts @@ -60,7 +60,12 @@ export const bailian_coding_planProvider: RegistryEntry = { alias: "bcp", format: "claude", executor: "default", - baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + // Token Plan endpoint (the catalog entry is "Alibaba Token Plan"). The former + // coding-intl.dashscope.aliyuncs.com host only accepts Coding Plan keys and rejects + // Token Plan keys with 401 invalid_api_key. Verified live 2026-08-14: this host + // returns 200 for every model below with the same key. + // Docs: https://www.alibabacloud.com/help/en/model-studio/more-tools + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", chatPath: "/messages", authType: "apikey", authHeader: "x-api-key", diff --git a/open-sse/services/qwenTokenPlanQuotaFetcher.ts b/open-sse/services/qwenTokenPlanQuotaFetcher.ts new file mode 100644 index 0000000000..929f950931 --- /dev/null +++ b/open-sse/services/qwenTokenPlanQuotaFetcher.ts @@ -0,0 +1,437 @@ +/** + * qwenTokenPlanQuotaFetcher.ts — Qwen Cloud / Alibaba Model Studio PERSONAL Token Plan + * quota fetcher (issue #9603, "quota is missing"). + * + * The personal Token Plan (5-hour / 7-day sliding windows) has NO official OpenAPI — + * the console gateway is the only quota surface, and the inference API key does NOT + * authenticate it. Both portals read the same backend: + * - home.qwencloud.com portal → https://cs-data.qwencloud.com (default) + * - Model Studio console (intl) → https://bailian-singapore-cs.alibabacloud.com + * + * Transport (captured live 2026-08-13 from a logged-in session): + * POST {host}/data/api.json?product=sfm_bailian&action=IntlBroadScopeAspnGateway + * &api=zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2F + * form body: product, action, sec_token, region, params = + * {"Api":"zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/","V":"1.0", + * "Data":{"commodityCode":"sfm_tokenplansolo_public_intl","cornerstoneParam":{...}}} + * Auth: browser session Cookie (providerSpecificData or QWEN_CLOUD_COOKIE env). + * sec_token: best-effort — resolved from the dashboard HTML (`SEC_TOKEN: "…"`) when + * not provided; some accounts reject requests without it + * (BailianGateway.Workspace.NotAuthorised). + * + * Windows: usage returns perPercentage (fraction used, 0..1) + + * perResetTime (epoch ms). Fields are OMITTED while a window is + * "Temporarily Removed" (observed for 5-hour), so every window is optional. + * + * Cache: usage 60s per connection; subscription/quota-config (slow-moving tier data) + * 1h per connection. Registration: registerQwenTokenPlanQuotaFetcher() at startup. + */ + +import { registerQuotaFetcher, registerQuotaWindows, type QuotaInfo } from "./quotaPreflight.ts"; +import { registerMonitorFetcher } from "./quotaMonitor.ts"; +import { throttleQuotaFetch } from "./quotaFetchThrottle.ts"; + +const DEFAULT_GATEWAY_HOST = "https://cs-data.qwencloud.com"; +const DEFAULT_DASHBOARD_URL = "https://home.qwencloud.com/"; + +/** + * The same personal Token Plan is sold through two consoles that share one backend. + * The gateway validates the browser session against the console identity sent in the + * request, so an Alibaba cookie paired with the QwenCloud identity is rejected with + * `BailianGateway.Login.NotLogined` (verified live 2026-08-14). + */ +export interface TokenPlanConsoleSite { + consoleSite: "QWENCLOUD" | "ALIYUN"; + domain: string; + gatewayHost: string; + dashboardUrl: string; + origin: string; +} + +const CONSOLE_SITES: Record<"qwencloud" | "aliyun", TokenPlanConsoleSite> = { + qwencloud: { + consoleSite: "QWENCLOUD", + domain: "home.qwencloud.com", + gatewayHost: DEFAULT_GATEWAY_HOST, + dashboardUrl: DEFAULT_DASHBOARD_URL, + origin: "https://home.qwencloud.com", + }, + aliyun: { + consoleSite: "ALIYUN", + domain: "modelstudio.console.alibabacloud.com", + gatewayHost: "https://bailian-singapore-cs.alibabacloud.com", + dashboardUrl: "https://modelstudio.console.alibabacloud.com/", + origin: "https://modelstudio.console.alibabacloud.com", + }, +}; + +/** Providers served by the Alibaba (Model Studio) console rather than QwenCloud. */ +const ALIYUN_CONSOLE_PROVIDERS = new Set(["bailian-coding-plan", "alibaba", "alibaba-cn"]); + +/** + * Pick the console identity for a cookie: the login ticket names its console + * (`login_aliyunid_ticket` vs `login_qwencloud_ticket`). Unmarked cookies fall back to + * the provider, then to QwenCloud. + */ +export function resolveConsoleSite( + cookie: string, + provider: string | undefined +): TokenPlanConsoleSite { + if (/login_aliyunid_ticket=/.test(cookie)) return CONSOLE_SITES.aliyun; + if (/login_qwencloud_ticket=/.test(cookie)) return CONSOLE_SITES.qwencloud; + if (provider && ALIYUN_CONSOLE_PROVIDERS.has(provider)) return CONSOLE_SITES.aliyun; + return CONSOLE_SITES.qwencloud; +} +const GATEWAY_REGION = "ap-southeast-1"; +const GATEWAY_PRODUCT = "sfm_bailian"; +const GATEWAY_ACTION = "IntlBroadScopeAspnGateway"; +const COMMODITY_CODE = "sfm_tokenplansolo_public_intl"; +const TOKEN_PLAN_API_PREFIX = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/"; + +const USAGE_CACHE_TTL_MS = 60_000; +const TIER_CACHE_TTL_MS = 60 * 60_000; + +// Window keys surfaced to the dashboard / quota-window registry +export const QWEN_TOKEN_PLAN_WINDOW_5H = "window_5h"; +export const QWEN_TOKEN_PLAN_WINDOW_WEEKLY = "window_weekly"; + +// usage payload field prefix → window key (fields: perPercentage / perResetTime) +const WINDOW_FIELD_MAP: Record = { + "5Hour": QWEN_TOKEN_PLAN_WINDOW_5H, + "1Week": QWEN_TOKEN_PLAN_WINDOW_WEEKLY, +}; + +export interface QwenTokenPlanQuota extends QuotaInfo { + windows: Record; + /** Which console served the quota — drives the plan label shown in the dashboard. */ + consoleSite: TokenPlanConsoleSite["consoleSite"]; + /** Subscription tier (e.g. "pro") or null when the subscription call failed. */ + specCode: string | null; + /** Credit limits of the active tier (from quota-config), when resolvable. */ + tierLimits: { fiveHour: number | null; weekly: number | null }; +} + +interface UsageCacheEntry { + quota: QwenTokenPlanQuota; + fetchedAt: number; +} + +interface TierCacheEntry { + specCode: string | null; + tierLimits: { fiveHour: number | null; weekly: number | null }; + fetchedAt: number; +} + +const usageCache = new Map(); +const tierCache = new Map(); +const secTokenCache = new Map(); + +const _cacheCleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of usageCache) { + if (now - entry.fetchedAt > USAGE_CACHE_TTL_MS * 5) usageCache.delete(key); + } + for (const [key, entry] of tierCache) { + if (now - entry.fetchedAt > TIER_CACHE_TTL_MS * 2) tierCache.delete(key); + } + for (const [key, entry] of secTokenCache) { + if (now - entry.fetchedAt > TIER_CACHE_TTL_MS * 2) secTokenCache.delete(key); + } +}, 5 * 60_000); + +if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) { + (_cacheCleanup as { unref?: () => void }).unref?.(); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function toRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function toNumberOrNull(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = parseFloat(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function toTrimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function getCookie(providerSpecificData: Record | undefined): string { + for (const key of ["qwenCloudCookie", "alibabaConsoleCookie", "cookie"]) { + const value = toTrimmedString(providerSpecificData?.[key]); + if (value) return value; + } + return process.env.QWEN_CLOUD_COOKIE?.trim() || ""; +} + +function getConfiguredSecToken(providerSpecificData: Record | undefined): string { + for (const key of ["qwenCloudSecToken", "alibabaConsoleSecToken"]) { + const value = toTrimmedString(providerSpecificData?.[key]); + if (value) return value; + } + return process.env.QWEN_CLOUD_SEC_TOKEN?.trim() || ""; +} + +function getGatewayHost(site: TokenPlanConsoleSite): string { + const configured = process.env.QWEN_TOKEN_PLAN_HOST?.trim(); + if (!configured) return site.gatewayHost; + return /^https?:\/\//i.test(configured) ? configured : `https://${configured}`; +} + +function getDashboardUrl(site: TokenPlanConsoleSite): string { + return process.env.QWEN_TOKEN_PLAN_DASHBOARD_URL?.trim() || site.dashboardUrl; +} + +/** Extract the console `SEC_TOKEN: "…"` embedded in the logged-in dashboard HTML. */ +export function extractQwenSecToken(html: string): string | null { + const match = /SEC_?TOKEN["']?\s*[:=]\s*["']([^"']+)["']/i.exec(html); + return match ? match[1] : null; +} + +async function resolveSecToken( + connectionId: string, + cookie: string, + site: TokenPlanConsoleSite +): Promise { + const cached = secTokenCache.get(connectionId); + if (cached && Date.now() - cached.fetchedAt < TIER_CACHE_TTL_MS) { + return cached.token; + } + + try { + const response = await fetch(getDashboardUrl(site), { + method: "GET", + headers: { + Cookie: cookie, + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36", + Accept: "text/html", + }, + redirect: "follow", + signal: AbortSignal.timeout(8_000), + }); + const html = await response.text(); + const token = extractQwenSecToken(html); + if (token) { + secTokenCache.set(connectionId, { token, fetchedAt: Date.now() }); + return token; + } + } catch { + // best-effort — some accounts work without sec_token + } + return ""; +} + +// ─── Gateway transport ─────────────────────────────────────────────────────── + +async function callGateway( + endpoint: string, + cookie: string, + secToken: string, + site: TokenPlanConsoleSite +): Promise { + const api = `${TOKEN_PLAN_API_PREFIX}${endpoint}`; + const url = `${getGatewayHost(site)}/data/api.json?product=${GATEWAY_PRODUCT}&action=${GATEWAY_ACTION}&api=${encodeURIComponent(api)}`; + + const params = JSON.stringify({ + Api: api, + V: "1.0", + Data: { + commodityCode: COMMODITY_CODE, + cornerstoneParam: { + console: "ONE_CONSOLE", + consoleSite: site.consoleSite, + domain: site.domain, + productCode: "p_efm", + protocol: "V2", + xsp_lang: "en-US", + }, + }, + }); + + const body = new URLSearchParams({ + product: GATEWAY_PRODUCT, + action: GATEWAY_ACTION, + sec_token: secToken, + region: GATEWAY_REGION, + params, + }); + + try { + // #6911: space concurrent upstream quota fetches (mirrors bailianQuotaFetcher.ts). + await throttleQuotaFetch(); + const response = await fetch(url, { + method: "POST", + headers: { + Cookie: cookie, + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Origin: site.origin, + Referer: `${site.origin}/`, + }, + body: body.toString(), + signal: AbortSignal.timeout(8_000), + }); + + const raw = await response.json(); + return parseGatewayEnvelope(raw); + } catch { + // Network error, timeout, non-JSON (login redirect page) — fail open + return null; + } +} + +/** Unwrap {code:"200", data:{DataV2:{data:{code:"SUCCESS", data:}}}} → payload. */ +function parseGatewayEnvelope(raw: unknown): unknown | null { + const obj = toRecord(raw); + if (obj["code"] !== "200" && obj["code"] !== 200) return null; + const inner = toRecord(toRecord(toRecord(obj["data"])["DataV2"])["data"]); + if (inner["code"] !== "SUCCESS" || inner["success"] !== true) return null; + return inner["data"] ?? null; +} + +// ─── Parsers ───────────────────────────────────────────────────────────────── + +function parseUsageWindows( + payload: unknown +): Record { + const obj = toRecord(payload); + const windows: Record = {}; + + for (const [fieldPrefix, windowKey] of Object.entries(WINDOW_FIELD_MAP)) { + const percent = toNumberOrNull(obj[`per${fieldPrefix}Percentage`]); + if (percent === null) continue; // window omitted (e.g. 5-hour "Temporarily Removed") + const resetMs = toNumberOrNull(obj[`per${fieldPrefix}ResetTime`]); + windows[windowKey] = { + percentUsed: percent, + resetAt: resetMs && resetMs > 0 ? new Date(resetMs).toISOString() : null, + }; + } + + return windows; +} + +async function resolveTierInfo( + connectionId: string, + cookie: string, + secToken: string, + site: TokenPlanConsoleSite +): Promise { + const cached = tierCache.get(connectionId); + if (cached && Date.now() - cached.fetchedAt < TIER_CACHE_TTL_MS) { + return cached; + } + + const [quotaConfig, subscription] = await Promise.all([ + callGateway("quota-config", cookie, secToken, site), + callGateway("subscription", cookie, secToken, site), + ]); + + const specCode = toTrimmedString(toRecord(subscription)["specCode"]) || null; + const tierRecord = specCode ? toRecord(toRecord(quotaConfig)[specCode]) : {}; + const entry: TierCacheEntry = { + specCode, + tierLimits: { + fiveHour: toNumberOrNull(tierRecord["five_hour"]), + weekly: toNumberOrNull(tierRecord["weekly"]), + }, + fetchedAt: Date.now(), + }; + + tierCache.set(connectionId, entry); + return entry; +} + +// ─── Core fetcher ──────────────────────────────────────────────────────────── + +/** + * Fetch the personal Token Plan quota for a qwen-cloud-token-plan connection. + * Returns percentUsed = max across the windows present in the usage response, + * or null when no cookie is configured / the console session expired. + */ +export async function fetchQwenTokenPlanQuota( + connectionId: string, + connection?: Record +): Promise { + const cached = usageCache.get(connectionId); + if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) { + return cached.quota; + } + + const providerSpecificData = + connection?.providerSpecificData && + typeof connection.providerSpecificData === "object" && + !Array.isArray(connection.providerSpecificData) + ? (connection.providerSpecificData as Record) + : undefined; + + const cookie = getCookie(providerSpecificData); + if (!cookie) return null; + + const site = resolveConsoleSite( + cookie, + typeof connection?.provider === "string" ? connection.provider : undefined + ); + + const secToken = + getConfiguredSecToken(providerSpecificData) || + (await resolveSecToken(connectionId, cookie, site)); + + const usagePayload = await callGateway("usage", cookie, secToken, site); + if (usagePayload === null) return null; + + const windows = parseUsageWindows(usagePayload); + const windowEntries = Object.values(windows); + if (windowEntries.length === 0) return null; + + const worst = windowEntries.reduce((max, w) => (w.percentUsed > max.percentUsed ? w : max)); + + const tier = await resolveTierInfo(connectionId, cookie, secToken, site); + const total = tier.tierLimits.weekly ?? 100; + + const quota: QwenTokenPlanQuota = { + used: Math.round(worst.percentUsed * total), + total, + percentUsed: worst.percentUsed, + resetAt: worst.resetAt, + windows, + consoleSite: site.consoleSite, + specCode: tier.specCode, + tierLimits: tier.tierLimits, + limitReached: worst.percentUsed >= 1, + }; + + usageCache.set(connectionId, { quota, fetchedAt: Date.now() }); + return quota; +} + +// ─── Invalidation ──────────────────────────────────────────────────────────── + +export function invalidateQwenTokenPlanQuotaCache(connectionId: string): void { + usageCache.delete(connectionId); + tierCache.delete(connectionId); + secTokenCache.delete(connectionId); +} + +// ─── Registration ──────────────────────────────────────────────────────────── + +/** + * Register the Qwen Token Plan quota fetcher with the preflight and monitor systems. + * Call once at server startup (src/sse/handlers/chat.ts), BEFORE registerGenericQuotaFetchers(). + */ +export function registerQwenTokenPlanQuotaFetcher(): void { + registerQuotaFetcher("qwen-cloud-token-plan", fetchQwenTokenPlanQuota); + registerMonitorFetcher("qwen-cloud-token-plan", fetchQwenTokenPlanQuota); + registerQuotaWindows("qwen-cloud-token-plan", [ + QWEN_TOKEN_PLAN_WINDOW_5H, + QWEN_TOKEN_PLAN_WINDOW_WEEKLY, + ]); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index e6979d172b..3634f7e3b5 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -69,6 +69,7 @@ import { getXaiOauthUsage } from "./usage/xaiOauth.ts"; import { getGrokCliUsage } from "./usage/grokCli.ts"; import { getFirecrawlUsage } from "./usage/firecrawl.ts"; import { getCommandCodeUsage } from "./usage/command-code.ts"; +import { getQwenTokenPlanUsage } from "./usage/qwen-token-plan.ts"; import { getConolUsage } from "./conolUsage.ts"; type JsonRecord = Record; @@ -111,6 +112,7 @@ export const USAGE_FETCHER_PROVIDERS = [ "minimax-cn", "crof", "bailian-coding-plan", + "qwen-cloud-token-plan", "nanogpt", "deepseek", "opencode", @@ -202,6 +204,8 @@ export async function getUsageForProvider( return await getCrofUsage(apiKey || ""); case "bailian-coding-plan": return await getBailianCodingPlanUsage(id || "", apiKey || "", providerSpecificData); + case "qwen-cloud-token-plan": + return await getQwenTokenPlanUsage(id || "", apiKey || "", providerSpecificData); case "nanogpt": return await getNanoGptUsage(apiKey || ""); case "deepseek": diff --git a/open-sse/services/usage/bailian.ts b/open-sse/services/usage/bailian.ts index 9830472234..eaef19b5b5 100644 --- a/open-sse/services/usage/bailian.ts +++ b/open-sse/services/usage/bailian.ts @@ -10,6 +10,7 @@ */ import { fetchBailianQuota, type BailianTripleWindowQuota } from "../bailianQuotaFetcher.ts"; +import { getQwenTokenPlanUsage } from "./qwen-token-plan.ts"; /** * Bailian (Alibaba Token Plan) Usage @@ -21,11 +22,25 @@ export async function getBailianCodingPlanUsage( providerSpecificData?: Record ) { try { + // The catalog entry is "Alibaba Token Plan" and now points at the Token Plan + // endpoint, so prefer the Token Plan quota (console cookie) when one is + // configured. The Coding Plan path below stays as the fallback for accounts + // that really do hold a Coding Plan key (#9603). + const tokenPlanUsage = await getQwenTokenPlanUsage( + connectionId, + apiKey, + providerSpecificData, + "bailian-coding-plan" + ); + if ("quotas" in tokenPlanUsage) return tokenPlanUsage; + const connection = { apiKey, providerSpecificData }; const quota = await fetchBailianQuota(connectionId, connection); if (!quota) { - return { message: "Alibaba Token Plan connected. Unable to fetch quota." }; + // Neither surface answered — surface the Token Plan guidance, which tells the + // operator how to supply the cookie the console gateway requires. + return tokenPlanUsage; } const bailianQuota = quota as BailianTripleWindowQuota; diff --git a/open-sse/services/usage/qwen-token-plan.ts b/open-sse/services/usage/qwen-token-plan.ts new file mode 100644 index 0000000000..d26c54886f --- /dev/null +++ b/open-sse/services/usage/qwen-token-plan.ts @@ -0,0 +1,96 @@ +/** + * usage/qwen-token-plan.ts — Qwen Cloud / Alibaba Model Studio personal Token Plan + * usage leaf (issue #9603). + * + * Delegates to qwenTokenPlanQuotaFetcher (cookie-authenticated console gateway) and + * shapes the 5-hour / weekly sliding windows into the standard usage response. The + * inference API key cannot read this quota — the connection needs a console session + * cookie in providerSpecificData (qwenCloudCookie / alibabaConsoleCookie / cookie) + * or the QWEN_CLOUD_COOKIE env var. + */ + +import { + fetchQwenTokenPlanQuota, + QWEN_TOKEN_PLAN_WINDOW_5H, + QWEN_TOKEN_PLAN_WINDOW_WEEKLY, + type QwenTokenPlanQuota, +} from "../qwenTokenPlanQuotaFetcher.ts"; +import type { UsageQuota } from "./quota.ts"; + +function windowToQuota( + window: { percentUsed: number; resetAt: string | null } | undefined, + totalCredits: number | null, + displayName: string +): UsageQuota | null { + if (!window) return null; + const total = totalCredits ?? 100; + const used = Math.round(window.percentUsed * total); + const remaining = Math.max(0, total - used); + return { + used, + total, + remaining, + remainingPercentage: Math.round((1 - window.percentUsed) * 1000) / 10, + resetAt: window.resetAt, + unlimited: false, + displayName, + }; +} + +/** + * Qwen Cloud personal Token Plan usage (5-hour + weekly sliding windows). + */ +export async function getQwenTokenPlanUsage( + connectionId: string, + apiKey: string, + providerSpecificData?: Record, + provider = "qwen-cloud-token-plan" +) { + try { + const quota = await fetchQwenTokenPlanQuota(connectionId, { + apiKey, + providerSpecificData, + provider, + }); + + if (!quota) { + return { + message: + "Qwen Token Plan connected. Quota needs a console session cookie — the inference " + + "API key cannot read it. Get it at home.qwencloud.com › Billing › Subscription " + + "(logged in): F12 › Network, reload, filter by api.json, click a request to " + + "cs-data.qwencloud.com and copy the whole Cookie value from Request Headers " + + "(it contains login_qwencloud_ticket). Paste it into the connection's " + + "'Qwen / Model Studio console cookie' field, or set QWEN_CLOUD_COOKIE. " + + "The cookie expires with the browser session — re-paste it when this message returns.", + }; + } + + const tokenPlanQuota = quota as QwenTokenPlanQuota; + const quotas: Record = {}; + + const fiveHour = windowToQuota( + tokenPlanQuota.windows[QWEN_TOKEN_PLAN_WINDOW_5H], + tokenPlanQuota.tierLimits.fiveHour, + "5-hour window" + ); + if (fiveHour) quotas.five_hour = fiveHour; + + const weekly = windowToQuota( + tokenPlanQuota.windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY], + tokenPlanQuota.tierLimits.weekly, + "Weekly window" + ); + if (weekly) quotas.weekly = weekly; + + const specCode = tokenPlanQuota.specCode; + const brand = tokenPlanQuota.consoleSite === "ALIYUN" ? "Alibaba" : "Qwen"; + const plan = specCode + ? `${brand} Token Plan (${specCode.charAt(0).toUpperCase()}${specCode.slice(1)})` + : `${brand} Token Plan`; + + return { plan, quotas }; + } catch (error) { + return { message: `Qwen Token Plan error: ${(error as Error).message}` }; + } +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index 17632c1b29..a698ee628f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -340,6 +340,8 @@ export default function EditConnectionModal({ opencodeGoAuthCookie: "", ollamaCloudUsageCookie: "", alibabaConsoleCookie: stringField(connection.providerSpecificData?.alibabaConsoleCookie), + qwenCloudCookie: stringField(connection.providerSpecificData?.qwenCloudCookie), + qwenCloudSecToken: stringField(connection.providerSpecificData?.qwenCloudSecToken), alibabaConsoleSecToken: stringField( connection.providerSpecificData?.alibabaConsoleSecToken ), diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx index 4f4ebe5d8f..923dda2fa7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx @@ -3,44 +3,16 @@ import { Input } from "@/shared/components"; import { providerText, type ProviderMessageTranslator } from "../../providerPageHelpers"; -export type QuotaScrapingFieldValues = { - opencodeGoWorkspaceId: string; - opencodeGoAuthCookie: string; - ollamaCloudUsageCookie: string; - alibabaConsoleCookie: string; - alibabaConsoleSecToken: string; -}; +import { + assignQuotaScrapingProviderData, + EMPTY_QUOTA_SCRAPING_FIELDS, + QWEN_TOKEN_PLAN_PROVIDERS, + type QuotaScrapingFieldValues, +} from "./quotaScrapingFieldValues"; -export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = { - opencodeGoWorkspaceId: "", - opencodeGoAuthCookie: "", - ollamaCloudUsageCookie: "", - alibabaConsoleCookie: "", - alibabaConsoleSecToken: "", -}; - -export function assignQuotaScrapingProviderData( - provider: string | undefined, - values: QuotaScrapingFieldValues, - target: Record -) { - if (provider === "opencode-go") { - target.opencodeGoWorkspaceId = values.opencodeGoWorkspaceId.trim() || undefined; - if (values.opencodeGoAuthCookie.trim()) { - target.opencodeGoAuthCookie = values.opencodeGoAuthCookie.trim(); - } - } else if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) { - target.ollamaCloudUsageCookie = values.ollamaCloudUsageCookie.trim(); - } else if ( - (provider === "alibaba" || provider === "alibaba-cn") && - values.alibabaConsoleCookie.trim() - ) { - target.alibabaConsoleCookie = values.alibabaConsoleCookie.trim(); - if (values.alibabaConsoleSecToken.trim()) { - target.alibabaConsoleSecToken = values.alibabaConsoleSecToken.trim(); - } - } -} +// Re-exported so existing importers (modals, tests) keep their current paths. +export { assignQuotaScrapingProviderData, EMPTY_QUOTA_SCRAPING_FIELDS }; +export type { QuotaScrapingFieldValues }; type QuotaScrapingFieldsProps = { provider?: string; @@ -170,5 +142,50 @@ export default function QuotaScrapingFields({ ); } + if (QWEN_TOKEN_PLAN_PROVIDERS.has(provider ?? "")) { + return ( +
+ onChange({ qwenCloudCookie: e.target.value })} + placeholder="cna=...; login_qwencloud_ticket=...; ..." + hint={providerText( + t, + "qwenCloudCookieHint", + (editMode ? "Leave blank to keep the stored cookie. " : "") + + "Required for Token Plan quota — the inference API key cannot read it. " + + "How to get it: open home.qwencloud.com › Billing › Subscription while logged in, " + + "press F12 › Network, reload the page, filter by api.json, click any request to " + + "cs-data.qwencloud.com, then under Request Headers copy the WHOLE Cookie value " + + "(it contains login_qwencloud_ticket). It expires with the browser session — " + + "re-paste it when the quota reports an expired session." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> + onChange({ qwenCloudSecToken: e.target.value })} + placeholder="GjRV..." + hint={providerText( + t, + "qwenCloudSecTokenHint", + "Optional — resolved automatically from the dashboard. Set it only if quota sync reports a permission error." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> +
+ ); + } + return null; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts new file mode 100644 index 0000000000..dcf9d3d3d2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts @@ -0,0 +1,64 @@ +/** + * quotaScrapingFieldValues.ts — form-state shape + persistence rules for the + * quota-scraping credential fields (cookies / workspace ids) rendered by + * QuotaScrapingFields.tsx. + * + * Kept in a UI-free module on purpose: importing the .tsx pulls in + * `@/shared/components`, whose barrel reaches untranspiled ESM deps + * (@lobehub/icons) that the node:test runner cannot parse. Unit tests import + * this file instead; the component re-exports it for existing callers. + */ + +/** Providers whose quota lives behind the Qwen/Model Studio console gateway (#9603). */ +export const QWEN_TOKEN_PLAN_PROVIDERS = new Set(["qwen-cloud-token-plan", "bailian-coding-plan"]); + +export type QuotaScrapingFieldValues = { + opencodeGoWorkspaceId: string; + opencodeGoAuthCookie: string; + ollamaCloudUsageCookie: string; + alibabaConsoleCookie: string; + alibabaConsoleSecToken: string; + qwenCloudCookie: string; + qwenCloudSecToken: string; +}; + +export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = { + opencodeGoWorkspaceId: "", + opencodeGoAuthCookie: "", + ollamaCloudUsageCookie: "", + alibabaConsoleCookie: "", + alibabaConsoleSecToken: "", + qwenCloudCookie: "", + qwenCloudSecToken: "", +}; + +export function assignQuotaScrapingProviderData( + provider: string | undefined, + values: QuotaScrapingFieldValues, + target: Record +) { + if (provider === "opencode-go") { + target.opencodeGoWorkspaceId = values.opencodeGoWorkspaceId.trim() || undefined; + if (values.opencodeGoAuthCookie.trim()) { + target.opencodeGoAuthCookie = values.opencodeGoAuthCookie.trim(); + } + } else if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) { + target.ollamaCloudUsageCookie = values.ollamaCloudUsageCookie.trim(); + } else if ( + (provider === "alibaba" || provider === "alibaba-cn") && + values.alibabaConsoleCookie.trim() + ) { + target.alibabaConsoleCookie = values.alibabaConsoleCookie.trim(); + if (values.alibabaConsoleSecToken.trim()) { + target.alibabaConsoleSecToken = values.alibabaConsoleSecToken.trim(); + } + } else if (QWEN_TOKEN_PLAN_PROVIDERS.has(provider ?? "") && values.qwenCloudCookie?.trim()) { + // Optional access: callers (AddApiKeyModal/EditConnectionModal form state, and + // existing tests) may pass a partial form object without the newer fields — + // bailian-coding-plan previously matched no branch here at all. + target.qwenCloudCookie = values.qwenCloudCookie.trim(); + if (values.qwenCloudSecToken?.trim()) { + target.qwenCloudSecToken = values.qwenCloudSecToken.trim(); + } + } +} diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 16f884f9de..3d89183e28 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -97,6 +97,9 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "command-code", "conol-web", "cnl", + // Alibaba Coding Plan (console API key) + Qwen personal Token Plan (console cookie) — #9603 + "bailian-coding-plan", + "qwen-cloud-token-plan", ]); const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70; const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 48841ddcae..143a11a45d 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -500,6 +500,10 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "command-code", "conol-web", "cnl", + // Alibaba Coding Plan triple-window quota (#9603 UI gap — fetcher existed, list entry missing) + "bailian-coding-plan", + // Qwen Cloud / Model Studio personal Token Plan (cookie-authenticated console gateway) + "qwen-cloud-token-plan", ]; // ── Zod validation at module load (Phase 7.2) ── diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 5580e39ed0..80168e51cf 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -328,6 +328,8 @@ export function validateProviderSpecificData( "usageCookie", "alibabaConsoleCookie", "alibabaConsoleSecToken", + "qwenCloudCookie", + "qwenCloudSecToken", ] as const) { const value = data[key]; if (value !== undefined && value !== null && typeof value !== "string") { diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index cd110ce263..9002225a1b 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -148,6 +148,7 @@ import { registerCodexQuotaFetcher, } from "@omniroute/open-sse/services/codexQuotaFetcher.ts"; import { registerBailianCodingPlanQuotaFetcher } from "@omniroute/open-sse/services/bailianQuotaFetcher.ts"; +import { registerQwenTokenPlanQuotaFetcher } from "@omniroute/open-sse/services/qwenTokenPlanQuotaFetcher.ts"; import { registerCrofUsageFetcher } from "@omniroute/open-sse/services/crofUsageFetcher.ts"; import { registerDeepseekQuotaFetcher } from "@omniroute/open-sse/services/deepseekQuotaFetcher.ts"; import { registerOpenrouterQuotaFetcher } from "@omniroute/open-sse/services/openrouterQuotaFetcher.ts"; @@ -171,6 +172,11 @@ registerCodexQuotaFetcher(); // can proactively switch accounts before quota is exhausted. registerBailianCodingPlanQuotaFetcher(); +// Register the Qwen Cloud / Model Studio personal Token Plan fetcher (#9603). +// Cookie-authenticated console gateway — 5-hour + weekly sliding windows. +// Runs before registerGenericQuotaFetchers so the bespoke fetcher wins. +registerQwenTokenPlanQuotaFetcher(); + // Register CrofAI usage fetcher (subscription requests + credits balance). // Surfaces usable_requests + credits in the monitor and only blocks (preflight // opt-in) when the active bucket reaches zero. diff --git a/tests/unit/qwen-token-plan-console-site.test.ts b/tests/unit/qwen-token-plan-console-site.test.ts new file mode 100644 index 0000000000..26b883aa9c --- /dev/null +++ b/tests/unit/qwen-token-plan-console-site.test.ts @@ -0,0 +1,120 @@ +/** + * qwen-token-plan-console-site.test.ts — the personal Token Plan is sold through TWO + * consoles that share one backend, and the gateway validates the session against the + * console declared in the request. Sending the Alibaba console cookie with the + * QwenCloud console identity returns: + * + * {"errorCode":"BailianGateway.Login.NotLogined"} + * + * Verified live (2026-08-14) against both consoles: switching only consoleSite/domain/ + * Origin/Referer (same cookie) turns that error into a real usage payload. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + resolveConsoleSite, + fetchQwenTokenPlanQuota, + invalidateQwenTokenPlanQuotaCache, +} from "../../open-sse/services/qwenTokenPlanQuotaFetcher.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("an Alibaba console cookie resolves to the Model Studio console", () => { + const site = resolveConsoleSite("cna=x; login_aliyunid_ticket=abc; aui=1", undefined); + assert.equal(site.consoleSite, "ALIYUN"); + assert.equal(site.domain, "modelstudio.console.alibabacloud.com"); + assert.ok(site.gatewayHost.includes("bailian-singapore-cs.alibabacloud.com")); + assert.ok(site.origin.includes("modelstudio.console.alibabacloud.com")); +}); + +test("a QwenCloud console cookie resolves to the QwenCloud console", () => { + const site = resolveConsoleSite("cna=x; login_qwencloud_ticket=abc", undefined); + assert.equal(site.consoleSite, "QWENCLOUD"); + assert.equal(site.domain, "home.qwencloud.com"); + assert.ok(site.gatewayHost.includes("cs-data.qwencloud.com")); +}); + +test("the provider decides when the cookie carries no console marker", () => { + assert.equal(resolveConsoleSite("session=opaque", "bailian-coding-plan").consoleSite, "ALIYUN"); + assert.equal( + resolveConsoleSite("session=opaque", "qwen-cloud-token-plan").consoleSite, + "QWENCLOUD" + ); + // Unknown provider + unmarked cookie keeps the QwenCloud default. + assert.equal(resolveConsoleSite("session=opaque", undefined).consoleSite, "QWENCLOUD"); +}); + +test("fetch sends the Alibaba console identity for an aliyun cookie", async () => { + const connectionId = `console-site-${Date.now()}`; + const calls: { url: string; init?: RequestInit }[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, init }); + const body = { + code: "200", + data: { + DataV2: { + data: { + code: "SUCCESS", + success: true, + data: url.includes("%2Fusage") + ? { per1WeekPercentage: 0.32, per1WeekResetTime: 1787254140000 } + : url.includes("%2Fsubscription") + ? { specCode: "pro" } + : { pro: { five_hour: 12000, weekly: 40000 } }, + }, + }, + success: true, + }, + httpStatusCode: "200", + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + const quota = await fetchQwenTokenPlanQuota(connectionId, { + provider: "bailian-coding-plan", + providerSpecificData: { + qwenCloudCookie: "cna=x; login_aliyunid_ticket=abc", + qwenCloudSecToken: "tok", + }, + }); + + assert.ok(quota, "expected quota"); + assert.equal(quota.percentUsed, 0.32); + + const usageCall = calls.find((c) => c.url.includes("%2Fusage")); + assert.ok(usageCall, "usage call missing"); + assert.ok( + usageCall.url.includes("bailian-singapore-cs.alibabacloud.com"), + `wrong gateway host: ${usageCall.url}` + ); + const headers = usageCall.init?.headers as Record; + assert.ok(String(headers.Referer).includes("modelstudio.console.alibabacloud.com")); + const params = JSON.parse( + new URLSearchParams(String(usageCall.init?.body)).get("params") ?? "{}" + ); + assert.equal(params.Data.cornerstoneParam.consoleSite, "ALIYUN"); + assert.equal(params.Data.cornerstoneParam.domain, "modelstudio.console.alibabacloud.com"); + + invalidateQwenTokenPlanQuotaCache(connectionId); +}); + +test("bailian-coding-plan points at the Token Plan endpoint, not the Coding Plan one", async () => { + const { bailian_coding_planProvider } = + await import("../../open-sse/config/providers/registry/bailian-coding-plan/index.ts"); + // The catalog entry is named "Alibaba Token Plan" and links to token-plan-overview; + // coding-intl.dashscope.aliyuncs.com only accepts Coding Plan keys (401 otherwise). + assert.equal( + bailian_coding_planProvider.baseUrl, + "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1" + ); +}); diff --git a/tests/unit/qwen-token-plan-cookie-field.test.ts b/tests/unit/qwen-token-plan-cookie-field.test.ts new file mode 100644 index 0000000000..1513f88fe3 --- /dev/null +++ b/tests/unit/qwen-token-plan-cookie-field.test.ts @@ -0,0 +1,101 @@ +/** + * qwen-token-plan-cookie-field.test.ts — the Qwen Token Plan quota fetcher is + * cookie-authenticated (the inference API key cannot read the console gateway), + * so the connection modal MUST expose a field to paste that cookie. Without it + * the quota is unconfigurable from the dashboard. + * + * Mirrors the existing ollama-cloud / alibaba console-cookie fields. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Imports the UI-free module on purpose: pulling the .tsx would drag in +// `@/shared/components` → untranspiled ESM (@lobehub/icons) that node:test +// cannot parse ("SyntaxError: Unexpected token 'export'"). +import { + EMPTY_QUOTA_SCRAPING_FIELDS, + assignQuotaScrapingProviderData, +} from "../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/quotaScrapingFieldValues.ts"; +const { updateProviderConnectionSchema } = await import("../../src/shared/validation/schemas.ts"); + +test("qwen-cloud-token-plan persists the console cookie and optional sec_token", () => { + const target: Record = {}; + + assignQuotaScrapingProviderData( + "qwen-cloud-token-plan", + { + ...EMPTY_QUOTA_SCRAPING_FIELDS, + qwenCloudCookie: " token=abc123; aux=1 ", + qwenCloudSecToken: " sec-tok ", + }, + target + ); + + assert.equal(target.qwenCloudCookie, "token=abc123; aux=1", "cookie must be stored trimmed"); + assert.equal(target.qwenCloudSecToken, "sec-tok", "sec_token must be stored trimmed"); +}); + +test("bailian-coding-plan reuses the same console cookie field", () => { + const target: Record = {}; + + assignQuotaScrapingProviderData( + "bailian-coding-plan", + { ...EMPTY_QUOTA_SCRAPING_FIELDS, qwenCloudCookie: "token=xyz" }, + target + ); + + assert.equal(target.qwenCloudCookie, "token=xyz"); +}); + +test("a blank cookie does not overwrite the stored one", () => { + const target: Record = {}; + + assignQuotaScrapingProviderData( + "qwen-cloud-token-plan", + { ...EMPTY_QUOTA_SCRAPING_FIELDS, qwenCloudCookie: " " }, + target + ); + + assert.equal( + Object.hasOwn(target, "qwenCloudCookie"), + false, + "blank input must leave the stored cookie untouched" + ); +}); + +test("a form object without the newer cookie fields does not throw", () => { + // Regression: adding bailian-coding-plan to the qwen branch made older callers + // (which build a partial form object) reach code that assumed the fields exist. + const target: Record = {}; + const partial = { ...EMPTY_QUOTA_SCRAPING_FIELDS } as Record; + delete partial.qwenCloudCookie; + delete partial.qwenCloudSecToken; + + for (const provider of ["bailian-coding-plan", "qwen-cloud-token-plan"]) { + assert.doesNotThrow(() => + assignQuotaScrapingProviderData( + provider, + partial as unknown as typeof EMPTY_QUOTA_SCRAPING_FIELDS, + target + ) + ); + } + assert.equal(Object.hasOwn(target, "qwenCloudCookie"), false); +}); + +test("providerSpecificData validation guards the qwen cookie fields", () => { + const ok = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { qwenCloudCookie: "token=abc", qwenCloudSecToken: "sec-tok" }, + }); + assert.equal(ok.success, true, JSON.stringify(ok.error?.issues)); + + const wrongType = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { qwenCloudCookie: 42 }, + }); + assert.equal(wrongType.success, false, "non-string cookie must be rejected"); + + const tooLong = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { qwenCloudCookie: "x".repeat(10_001) }, + }); + assert.equal(tooLong.success, false, "oversized cookie must be rejected"); +}); diff --git a/tests/unit/qwen-token-plan-quota-fetcher.test.ts b/tests/unit/qwen-token-plan-quota-fetcher.test.ts new file mode 100644 index 0000000000..718b4b1c51 --- /dev/null +++ b/tests/unit/qwen-token-plan-quota-fetcher.test.ts @@ -0,0 +1,272 @@ +/** + * qwen-token-plan-quota-fetcher.test.ts — Qwen Cloud / Alibaba Model Studio personal + * Token Plan quota fetcher (issue #9603, Problema 1: quota is missing). + * + * Fixtures captured live (2026-08-13) from home.qwencloud.com/billing/subscription/ + * token-plan-individual — console gateway POST cs-data.qwencloud.com/data/api.json + * (action=IntlBroadScopeAspnGateway, product=sfm_bailian), cookie-authenticated. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + QWEN_TOKEN_PLAN_WINDOW_5H, + QWEN_TOKEN_PLAN_WINDOW_WEEKLY, + extractQwenSecToken, + fetchQwenTokenPlanQuota, + invalidateQwenTokenPlanQuotaCache, + registerQwenTokenPlanQuotaFetcher, +} from "../../open-sse/services/qwenTokenPlanQuotaFetcher.ts"; + +const originalFetch = globalThis.fetch; + +const RESET_MS = 1786714740000; // 2026-08-14 10:39 (captured per1WeekResetTime) + +type FetchCall = { url: string; init: RequestInit | undefined }; + +function gatewayBody(payload: unknown, api: string): string { + return JSON.stringify({ + code: "200", + data: { + DataV2: { + ret: ["SUCCESS::ok"], + data: { msg: "Success.", code: "SUCCESS", data: payload, success: true }, + }, + success: true, + httpStatus: 200, + errorCode: "", + api, + errorMsg: "", + }, + httpStatusCode: "200", + successResponse: true, + }); +} + +const USAGE_PAYLOAD = { per1WeekResetTime: RESET_MS, per1WeekPercentage: 0.55 }; +const QUOTA_CONFIG_PAYLOAD = { + standard: { five_hour: 3000.0, weekly: 10000.0 }, + addon_quota: { extrabundle: 20000.0 }, + lite: { five_hour: 700.0, weekly: 2500.0 }, + pro: { five_hour: 12000.0, weekly: 40000.0 }, +}; +const SUBSCRIPTION_PAYLOAD = { + instanceCode: "sfm_tokenplansolo_public_intl-sg-test", + specCode: "pro", + remainingDays: 24, + startTime: 1786109803000, + endTime: 1788796800000, + autoRenewFlag: false, + status: "VALID", +}; + +function mockGateway( + calls: FetchCall[], + overrides?: { usagePayload?: unknown; dashboardHtml?: string } +): void { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, init }); + + if (!url.includes("/data/api.json")) { + // Dashboard HTML fetch (sec_token resolution) + return new Response(overrides?.dashboardHtml ?? "no token here", { + status: 200, + headers: { "content-type": "text/html" }, + }); + } + + const jsonHeaders = { "content-type": "application/json" }; + if (url.includes("%2Fusage")) { + const payload = + overrides && "usagePayload" in overrides ? overrides.usagePayload : USAGE_PAYLOAD; + return new Response(gatewayBody(payload, "usage"), { status: 200, headers: jsonHeaders }); + } + if (url.includes("%2Fquota-config")) { + return new Response(gatewayBody(QUOTA_CONFIG_PAYLOAD, "quota-config"), { + status: 200, + headers: jsonHeaders, + }); + } + if (url.includes("%2Fsubscription")) { + return new Response(gatewayBody(SUBSCRIPTION_PAYLOAD, "subscription"), { + status: 200, + headers: jsonHeaders, + }); + } + return new Response(JSON.stringify({ code: "404" }), { status: 404, headers: jsonHeaders }); + }) as typeof globalThis.fetch; +} + +test.beforeEach(() => { + delete process.env.QWEN_CLOUD_COOKIE; + delete process.env.QWEN_CLOUD_SEC_TOKEN; +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("fetchQwenTokenPlanQuota returns null without any cookie configured", async () => { + const calls: FetchCall[] = []; + mockGateway(calls); + + const quota = await fetchQwenTokenPlanQuota(`qwen-nocookie-${Date.now()}`, {}); + + assert.equal(quota, null); + assert.equal(calls.length, 0); +}); + +test("fetchQwenTokenPlanQuota parses the captured weekly-only usage response", async () => { + const connectionId = `qwen-weekly-${Date.now()}`; + const calls: FetchCall[] = []; + mockGateway(calls); + + const quota = await fetchQwenTokenPlanQuota(connectionId, { + providerSpecificData: { qwenCloudCookie: "token=abc123; aux=1", qwenCloudSecToken: "sec-tok" }, + }); + + assert.ok(quota, "expected quota, got null"); + assert.equal(quota.percentUsed, 0.55); + assert.equal(quota.resetAt, new Date(RESET_MS).toISOString()); + + const windows = ( + quota as { windows: Record } + ).windows; + assert.ok(windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY], "weekly window missing"); + assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY].percentUsed, 0.55); + assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_WEEKLY].resetAt, new Date(RESET_MS).toISOString()); + // 5-hour window "Temporarily Removed" → API omits per5Hour* fields → no window + assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_5H], undefined); + + // Tier totals resolved via subscription.specCode → quota-config.pro + assert.equal(quota.total, 40000); + assert.equal(quota.used, Math.round(0.55 * 40000)); + assert.equal((quota as { specCode: string | null }).specCode, "pro"); + + // Request contract (captured shape) + const usageCall = calls.find((c) => c.url.includes("%2Fusage")); + assert.ok(usageCall, "usage gateway call missing"); + assert.equal(usageCall.init?.method, "POST"); + const headers = usageCall.init?.headers as Record; + assert.ok(String(headers["Cookie"] ?? headers["cookie"]).includes("token=abc123")); + const body = String(usageCall.init?.body); + assert.ok(body.includes("product=sfm_bailian"), "body missing product"); + assert.ok(body.includes("action=IntlBroadScopeAspnGateway"), "body missing action"); + assert.ok(body.includes("region=ap-southeast-1"), "body missing region"); + assert.ok(body.includes("sec_token=sec-tok"), "body missing sec_token"); + const params = new URLSearchParams(body).get("params"); + assert.ok(params, "body missing params"); + const parsedParams = JSON.parse(params) as { + Api: string; + V: string; + Data: { commodityCode: string }; + }; + assert.equal(parsedParams.V, "1.0"); + assert.ok(parsedParams.Api.includes("/tokenplan/personal/api/v2/usage")); + assert.equal(parsedParams.Data.commodityCode, "sfm_tokenplansolo_public_intl"); + + invalidateQwenTokenPlanQuotaCache(connectionId); +}); + +test("fetchQwenTokenPlanQuota includes the 5-hour window when the API returns it", async () => { + const connectionId = `qwen-5h-${Date.now()}`; + const calls: FetchCall[] = []; + mockGateway(calls, { + usagePayload: { + per1WeekResetTime: RESET_MS, + per1WeekPercentage: 0.55, + per5HourResetTime: RESET_MS - 3_600_000, + per5HourPercentage: 0.7, + }, + }); + + const quota = await fetchQwenTokenPlanQuota(connectionId, { + providerSpecificData: { qwenCloudCookie: "token=abc", qwenCloudSecToken: "sec-tok" }, + }); + + assert.ok(quota, "expected quota, got null"); + const windows = ( + quota as { windows: Record } + ).windows; + assert.equal(windows[QWEN_TOKEN_PLAN_WINDOW_5H]?.percentUsed, 0.7); + // worst window wins + assert.equal(quota.percentUsed, 0.7); + assert.equal(quota.resetAt, new Date(RESET_MS - 3_600_000).toISOString()); + + invalidateQwenTokenPlanQuotaCache(connectionId); +}); + +test("fetchQwenTokenPlanQuota returns null when the console session expired", async () => { + const connectionId = `qwen-expired-${Date.now()}`; + globalThis.fetch = (async () => + new Response(JSON.stringify({ code: "ConsoleNeedLogin" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof globalThis.fetch; + + const quota = await fetchQwenTokenPlanQuota(connectionId, { + providerSpecificData: { qwenCloudCookie: "token=stale", qwenCloudSecToken: "sec-tok" }, + }); + + assert.equal(quota, null); +}); + +test("fetchQwenTokenPlanQuota resolves sec_token from the dashboard when absent", async () => { + const connectionId = `qwen-sectoken-${Date.now()}`; + const calls: FetchCall[] = []; + mockGateway(calls, { + dashboardHtml: + '', + }); + + const quota = await fetchQwenTokenPlanQuota(connectionId, { + providerSpecificData: { qwenCloudCookie: "token=abc" }, + }); + + assert.ok(quota, "expected quota, got null"); + const dashboardCall = calls.find((c) => !c.url.includes("/data/api.json")); + assert.ok(dashboardCall, "dashboard fetch for sec_token missing"); + const usageCall = calls.find((c) => c.url.includes("%2Fusage")); + assert.ok(String(usageCall?.init?.body).includes("sec_token=resolved-tok")); + + invalidateQwenTokenPlanQuotaCache(connectionId); +}); + +test("fetchQwenTokenPlanQuota serves the second call from cache", async () => { + const connectionId = `qwen-cache-${Date.now()}`; + const calls: FetchCall[] = []; + mockGateway(calls); + + const connection = { + providerSpecificData: { qwenCloudCookie: "token=abc", qwenCloudSecToken: "sec-tok" }, + }; + const first = await fetchQwenTokenPlanQuota(connectionId, connection); + assert.ok(first); + const callCountAfterFirst = calls.length; + + const second = await fetchQwenTokenPlanQuota(connectionId, connection); + assert.ok(second); + assert.equal(calls.length, callCountAfterFirst); + + invalidateQwenTokenPlanQuotaCache(connectionId); +}); + +test("extractQwenSecToken pulls SEC_TOKEN out of dashboard HTML", () => { + assert.equal(extractQwenSecToken('foo SEC_TOKEN: "abc-123", bar'), "abc-123"); + assert.equal(extractQwenSecToken("nothing"), null); +}); + +test("registerQwenTokenPlanQuotaFetcher registers without throwing", () => { + registerQwenTokenPlanQuotaFetcher(); +}); + +test("qwen-cloud-token-plan and bailian-coding-plan are wired into the usage/UI lists", async () => { + const { USAGE_FETCHER_PROVIDERS } = await import("../../open-sse/services/usage.ts"); + const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + + assert.ok((USAGE_FETCHER_PROVIDERS as readonly string[]).includes("qwen-cloud-token-plan")); + assert.ok((USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("qwen-cloud-token-plan")); + // #9603 UI gap: coding-plan connections were filtered out of /dashboard/quota + assert.ok((USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("bailian-coding-plan")); +}); From 8d1a59771a5f8156624bcac6393f927f7b7ece42 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 14 Aug 2026 16:52:00 -0300 Subject: [PATCH 4/5] fix(providers): refresh the translate-path golden for the bailian Token Plan endpoint (#10410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #10290 moved bailian-coding-plan from the Coding Plan host to the documented Token Plan one, but the provider/translate-path golden still pinned coding-intl.dashscope.aliyuncs.com, so tests/unit/provider-translate-path-golden.test.ts fails on the release tip. Regenerates the snapshot (UPDATE_GOLDEN=1) — the diff is exactly the two bailian-coding-plan URLs, every other provider byte-identical — and fixes the same stale host in the endpoint matrix of docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md. This golden covers every provider's resolved URL, which is why neither the focused tests nor typecheck caught the change: only the unit shard runs it. Refs #9603 Co-authored-by: Xiangzhe --- docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md | 2 +- tests/snapshots/provider/translate-path.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md b/docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md index 62c9058da1..3bb3a592d5 100644 --- a/docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md +++ b/docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md @@ -32,7 +32,7 @@ different endpoint families, so all four products remain separate provider IDs. | Provider family | `global-sg` | `china-beijing` | Wire format | | ----------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------- | ----------- | | `alibaba` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | -| `bailian-coding-plan` | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1` | `https://coding.dashscope.aliyuncs.com/apps/anthropic/v1` | Anthropic | +| `bailian-coding-plan` | `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1` | `https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic/v1` | Anthropic | | `qwen-cloud` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | | `qwen-cloud-token-plan` | `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | OpenAI | diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 7acc218d39..f2348b12d1 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -565,8 +565,8 @@ } }, "url": { - "nonStream": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", - "stream": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1" + "nonStream": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", + "stream": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1" } }, "baseten": { From 7bb3bc7e32f7dfcf033bebf901941f79445d387f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 14 Aug 2026 17:27:45 -0300 Subject: [PATCH 5/5] fix(ci): pin Build (advisory) to a hosted runner with memory provisioning (#10408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): pin Build (advisory) to a hosted runner with memory provisioning `Build (advisory)` has been reporting a permanent red on every PR while producing no usable signal at all. Measured over the last 25 quality.yml runs (2026-08-14): not one instance of the job reached a conclusion. Every sample was either queued on the self-hosted pool — 2 runners, omniroute-113-6/7, both permanently busy; one job sat queued for over 2 hours and was still unclaimed — or, when it did land on a runner, killed mid-build by this workflow's own cancel-in-progress concurrency. All 6 sampled "failures" are exit 143 / "The runner has received a shutdown signal" at ~3.5 min into `npm run build`. Zero OOM, zero build errors. The job was consuming a runner the real gates compete for while telling every PR author it was broken. Gap 19 deliberately left USE_VPS_RUNNER governing build-like jobs, on the premise that the build needs the .113's RAM. That premise no longer holds: `Fast Production Build` (build.yml) runs `build:release` — a superset of this job's `npm run build`, plus the CLI bundle — on plain ubuntu-latest and passed 24 of its last 25 runs in ~15 min. The difference is memory PROVISIONING, not the machine: a 10 GB swapfile plus a 12 GB V8 heap. Swap is the part that matters, because --max-old-space-size bounds only V8's JS heap and never Turbopack's native Rust allocation (#6409). Pins the job to ubuntu-latest and mirrors both settings from build.yml. USE_VPS_RUNNER keeps its other consumers (ci.yml Build, nightly-release-green, npm-publish), so the variable stays meaningful. Fork safety is strictly improved: no PR can reach the LAN runner through this job any more. check:workflows --ratchet: 186 zizmor findings, baseline 190, no regression. prettier + YAML parse: clean. * fix(ci): scope Build (advisory) to fork PRs Follow-up to the hosted-runner pin in this same PR, after measuring what the job is actually for. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]` and runs `build:release` — a superset of this job's `npm run build`, plus the CLI bundle. For an own-origin branch that push fires here, so the tree was being built twice per PR. A fork contributor pushes to THEIR repo, so build.yml never runs in this repo and this job is their only pre-merge build signal. That could have argued for deleting the job, except the traffic says otherwise: 72 of the last 100 PRs into release/** come from forks. The fork case is the majority, not the exception. So the job earns its place — it just should not duplicate build.yml for the own-origin 28%. Added the fork filter to the existing `if`. Also corrects the reliability claim in the previous commit message. Over a wider window the job is not literally never-green: across 2026-08-13/14 it reached `success` on roughly 10-15% of runs (13/138 on 08-14, 7/53 sampled on 08-13). Chronically unreliable, not permanently dead — the conclusion and the fix are unchanged. The #7307 guard in tests/unit/build/check-workflows.test.ts pinned the old self-hosted expression, so it is realigned here: it now asserts the hosted pin, the absence of self-hosted/USE_VPS_RUNNER in the job's DIRECTIVES (the comment legitimately explains why the pool was abandoned, so the scan strips comments), both memory settings, and the fork filter. Mutation-validated — restoring self-hosted, dropping the swapfile, or flipping the fork filter each turns it red. check-workflows.test.ts: 32 pass, 0 fail. check:workflows --ratchet: 186 findings, baseline 190, no regression. --------- Co-authored-by: Xiangzhe --- .github/workflows/quality.yml | 46 +++++++++++++++++-- .../fixes/build-advisory-hosted-runner.md | 1 + tests/unit/build/check-workflows.test.ts | 28 ++++++++++- 3 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/build-advisory-hosted-runner.md diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7163c9c22b..dd34601323 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -60,13 +60,49 @@ jobs: build: name: Build (advisory) needs: changes - if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} - # Dynamic runner — same fork-safe rule as ci.yml / fast-gates. - runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} + # FORK PRs ONLY. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]` + # and runs `build:release` — a superset of this job — so for an own-origin branch this job + # was building the same tree twice. A fork contributor pushes to THEIR repo, so that push + # never fires here, and this is the only pre-merge build signal they get. Measured + # 2026-08-14: 72 of the last 100 PRs into release/** came from forks, so the fork case is + # the majority of the traffic, not the exception — this job earns its place, it just should + # not duplicate build.yml for the own-origin 28%. + if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true' && github.event.pull_request.head.repo.full_name != github.repository) }} + # PINNED to hosted — this was the last job in THIS workflow still on the USE_VPS_RUNNER + # switch (ci.yml's Build, nightly-release-green and npm-publish keep it, so the variable + # stays meaningful), and with USE_VPS_RUNNER=true it produced NO signal at all here. + # Measured 2026-08-14 over the last 25 + # quality.yml runs: not one Build (advisory) reached a conclusion. Every sample was either + # queued on the self-hosted pool (2 runners, `omniroute-113-6/7`, both permanently busy — one + # job sat queued 2h+ and was still unclaimed) or, when it did land, killed mid-build by this + # workflow's own `cancel-in-progress` concurrency. 6/6 sampled "failures" are exit 143 / + # "The runner has received a shutdown signal" at ~3.5 min into `npm run build` — zero OOM, + # zero build errors. So the job burned a scarce runner that the gates actually need while + # reporting a permanent red on every PR. + # + # Gap 19 left USE_VPS_RUNNER governing build-like jobs on the premise that "the build needs + # the .113's RAM". That premise no longer holds: `Fast Production Build` (build.yml) runs + # `build:release` — a SUPERSET of this job's `npm run build`, plus the CLI bundle — on plain + # ubuntu-latest and passed 24/25 of its last runs in ~15 min. What it has and this job did + # not is memory PROVISIONING: a 10 GB swapfile plus a 12 GB V8 heap. That matters because + # --max-old-space-size only bounds V8's JS heap, never Turbopack's native (Rust) allocation + # (#6409) — swap is what absorbs the native peak. Both are mirrored below. + runs-on: ubuntu-latest # #7307: advisory for the first week of release-PR runs; remove # continue-on-error after the production-build signal is stable. continue-on-error: true steps: + # Mirrors build.yml: Turbopack's native peak is not bounded by --max-old-space-size, so + # the hosted runner needs swap headroom before the build starts. + - name: Expand virtual memory (10 GB swap) + run: | + sudo swapoff -a || true + sudo rm -f /mnt/swapfile /swapfile + sudo fallocate -l 10G /mnt/swapfile || sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=10240 + sudo chmod 600 /mnt/swapfile + sudo mkswap /mnt/swapfile + sudo swapon /mnt/swapfile + free -h - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false @@ -79,6 +115,10 @@ jobs: - run: npm run build env: OMNIROUTE_USE_TURBOPACK: "1" + # Same heap build.yml proves sufficient. build-next-isolated.mjs defaults to 8192 and + # honours OMNIROUTE_BUILD_MEMORY_MB; NODE_OPTIONS is set for parity with build.yml. + NODE_OPTIONS: "--max-old-space-size=12288" + OMNIROUTE_BUILD_MEMORY_MB: "12288" # No artifact upload here: the PR-to-release quality workflow has no # downstream package/e2e jobs that consume the Next.js build output. diff --git a/changelog.d/fixes/build-advisory-hosted-runner.md b/changelog.d/fixes/build-advisory-hosted-runner.md new file mode 100644 index 0000000000..4bc5ef5469 --- /dev/null +++ b/changelog.d/fixes/build-advisory-hosted-runner.md @@ -0,0 +1 @@ +- fix(ci): make `Build (advisory)` produce a signal again — pinned to a hosted runner with the swap/heap provisioning `Fast Production Build` proves sufficient, and scoped to fork PRs, which are the only ones `build.yml` cannot cover (72 of the last 100 PRs into `release/**`) diff --git a/tests/unit/build/check-workflows.test.ts b/tests/unit/build/check-workflows.test.ts index 40eb377fd3..3018b68600 100644 --- a/tests/unit/build/check-workflows.test.ts +++ b/tests/unit/build/check-workflows.test.ts @@ -329,11 +329,35 @@ test("#7307 quality.yml adds an advisory production build for release PR code ch assert.match(buildJob[0], /needs\.changes\.outputs\.code == 'true'/); assert.match(buildJob[0], /github\.event\.pull_request\.draft == false/); assert.match(buildJob[0], /startsWith\(github\.head_ref, 'mergify\/merge-queue\/'\)/); + // FORK PRs ONLY (2026-08-14). build.yml's `Fast Production Build` fires on + // `push: branches: ["**"]` and runs the superset `build:release`, so own-origin branches + // were building twice; a fork's push never reaches this repo, making this their only + // pre-merge build signal — and forks are 72 of the last 100 PRs into release/**. assert.match( buildJob[0], - /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/ + /github\.event\.pull_request\.head\.repo\.full_name != github\.repository/ ); - assert.match(buildJob[0], /fromJSON\('\["self-hosted","omni-release"\]'\) \|\| 'ubuntu-latest'/); + // Runner PINNED to hosted. The self-hosted pool is 2 permanently-busy runners, where this + // job either queued for hours or was killed by cancel-in-progress — ~10-15% of runs ever + // reached a conclusion across 2026-08-13/14. It must NOT go back on the USE_VPS_RUNNER + // switch (other workflows keep that variable). + assert.match(buildJob[0], /\n {4}runs-on: ubuntu-latest\n/); + // Check the DIRECTIVES, not the prose: the comment above legitimately explains why the + // self-hosted pool was abandoned, so a naive /self-hosted/ scan over the whole block would + // match its own rationale. + const buildDirectives = buildJob[0] + .split("\n") + .filter((line) => !/^\s*#/.test(line)) + .join("\n"); + assert.doesNotMatch(buildDirectives, /self-hosted/); + assert.doesNotMatch(buildDirectives, /USE_VPS_RUNNER/); + // Memory provisioning mirrored from build.yml: --max-old-space-size bounds only V8's heap, + // never Turbopack's native Rust allocation (#6409), so the swapfile is the load-bearing + // half. Dropping either one puts the hosted build back at risk of an OOM. + assert.match(buildJob[0], /fallocate -l 10G \/mnt\/swapfile/); + assert.match(buildJob[0], /swapon \/mnt\/swapfile/); + assert.match(buildJob[0], /NODE_OPTIONS: "--max-old-space-size=12288"/); + assert.match(buildJob[0], /OMNIROUTE_BUILD_MEMORY_MB: "12288"/); assert.match(buildJob[0], /continue-on-error: true/); assert.match(buildJob[0], /uses: actions\/checkout@[0-9a-f]{40} # v7/); assert.match(buildJob[0], /uses: actions\/setup-node@[0-9a-f]{40} # v7/);