mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 03:32:21 +03:00
Merge remote-tracking branch 'origin/release/v3.8.50' into feat/agentrouter-lock-scope
This commit is contained in:
27
.env.example
27
.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
|
||||
|
||||
46
.github/workflows/quality.yml
vendored
46
.github/workflows/quality.yml
vendored
@@ -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.
|
||||
|
||||
|
||||
1
changelog.d/features/10273-dashboard-embed-csp.md
Normal file
1
changelog.d/features/10273-dashboard-embed-csp.md
Normal file
@@ -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)
|
||||
1
changelog.d/fixes/build-advisory-hosted-runner.md
Normal file
1
changelog.d/fixes/build-advisory-hosted-runner.md
Normal file
@@ -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/**`)
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>)],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
parseResponse(raw) {
|
||||
const r = raw as {
|
||||
model?: string;
|
||||
choices?: Array<{ message?: { content?: unknown } }>;
|
||||
usage?: Record<string, unknown>;
|
||||
};
|
||||
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<OcrResponseShape>;
|
||||
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<string, OcrProvider> = {
|
||||
mistral: {
|
||||
id: "mistral",
|
||||
@@ -120,6 +194,14 @@ export const OCR_PROVIDERS: Record<string, OcrProvider> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, unknown>;
|
||||
}): 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, unknown>;
|
||||
}): 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<T> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
437
open-sse/services/qwenTokenPlanQuotaFetcher.ts
Normal file
437
open-sse/services/qwenTokenPlanQuotaFetcher.ts
Normal file
@@ -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<endpoint>
|
||||
* form body: product, action, sec_token, region, params =
|
||||
* {"Api":"zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/<endpoint>","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 per<Window>Percentage (fraction used, 0..1) +
|
||||
* per<Window>ResetTime (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: per<prefix>Percentage / per<prefix>ResetTime)
|
||||
const WINDOW_FIELD_MAP: Record<string, string> = {
|
||||
"5Hour": QWEN_TOKEN_PLAN_WINDOW_5H,
|
||||
"1Week": QWEN_TOKEN_PLAN_WINDOW_WEEKLY,
|
||||
};
|
||||
|
||||
export interface QwenTokenPlanQuota extends QuotaInfo {
|
||||
windows: Record<string, { percentUsed: number; resetAt: string | null }>;
|
||||
/** 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<string, UsageCacheEntry>();
|
||||
const tierCache = new Map<string, TierCacheEntry>();
|
||||
const secTokenCache = new Map<string, { token: string; fetchedAt: number }>();
|
||||
|
||||
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<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
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<string, unknown> | 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<string, unknown> | 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<string> {
|
||||
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<unknown | null> {
|
||||
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>}}}} → 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<string, { percentUsed: number; resetAt: string | null }> {
|
||||
const obj = toRecord(payload);
|
||||
const windows: Record<string, { percentUsed: number; resetAt: string | null }> = {};
|
||||
|
||||
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<TierCacheEntry> {
|
||||
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<string, unknown>
|
||||
): Promise<QuotaInfo | null> {
|
||||
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<string, unknown>)
|
||||
: 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,
|
||||
]);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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":
|
||||
|
||||
@@ -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<string, unknown>
|
||||
) {
|
||||
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;
|
||||
|
||||
96
open-sse/services/usage/qwen-token-plan.ts
Normal file
96
open-sse/services/usage/qwen-token-plan.ts
Normal file
@@ -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<string, unknown>,
|
||||
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<string, UsageQuota> = {};
|
||||
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
142
scripts/build/dashboardEmbed.mjs
Normal file
142
scripts/build/dashboardEmbed.mjs
Normal file
@@ -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<string, string | undefined>} 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 },
|
||||
];
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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<string, unknown>
|
||||
) {
|
||||
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 (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/50 bg-surface/20 p-4">
|
||||
<Input
|
||||
label={providerText(t, "qwenCloudCookieLabel", "Qwen / Model Studio console cookie")}
|
||||
name="qwenCloudCookie"
|
||||
type="password"
|
||||
value={values.qwenCloudCookie}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Input
|
||||
label={providerText(t, "qwenCloudSecTokenLabel", "Qwen console sec_token (optional)")}
|
||||
name="qwenCloudSecToken"
|
||||
type="password"
|
||||
value={values.qwenCloudSecToken}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> },
|
||||
>(credentials: T): T {
|
||||
T extends {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
},
|
||||
>(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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
324
tests/unit/dashboard-embed-csp-10273.test.ts
Normal file
324
tests/unit/dashboard-embed-csp-10273.test.ts
Normal file
@@ -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<HeaderRule[]> {
|
||||
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<string, string> {
|
||||
const merged: Record<string, string> = {};
|
||||
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}`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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<string, string>).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");
|
||||
});
|
||||
|
||||
142
tests/unit/ocr-route-vertex.test.ts
Normal file
142
tests/unit/ocr-route-vertex.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
120
tests/unit/qwen-token-plan-console-site.test.ts
Normal file
120
tests/unit/qwen-token-plan-console-site.test.ts
Normal file
@@ -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<string, string>;
|
||||
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"
|
||||
);
|
||||
});
|
||||
101
tests/unit/qwen-token-plan-cookie-field.test.ts
Normal file
101
tests/unit/qwen-token-plan-cookie-field.test.ts
Normal file
@@ -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<string, unknown> = {};
|
||||
|
||||
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<string, unknown> = {};
|
||||
|
||||
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<string, unknown> = {};
|
||||
|
||||
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<string, unknown> = {};
|
||||
const partial = { ...EMPTY_QUOTA_SCRAPING_FIELDS } as Record<string, string>;
|
||||
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");
|
||||
});
|
||||
272
tests/unit/qwen-token-plan-quota-fetcher.test.ts
Normal file
272
tests/unit/qwen-token-plan-quota-fetcher.test.ts
Normal file
@@ -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 ?? "<html>no token here</html>", {
|
||||
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<string, { percentUsed: number; resetAt: string | null }> }
|
||||
).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<string, string>;
|
||||
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<string, { percentUsed: number; resetAt: string | null }> }
|
||||
).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:
|
||||
'<script>window.X = { IS_CERTIFIED: "true", SEC_TOKEN: "resolved-tok" };</script>',
|
||||
});
|
||||
|
||||
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("<html>nothing</html>"), 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"));
|
||||
});
|
||||
Reference in New Issue
Block a user