diff --git a/.env.example b/.env.example index 8472bf8743..f984a0ea4d 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 @@ -2029,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/.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/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/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/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/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/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/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ede96d13e5..fbaa8357b8 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. | @@ -1141,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/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/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/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/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/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/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/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/(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/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/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/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": { 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/); 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}` + ); + } +}); 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; + } +}); 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")); +});