mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 03:32:21 +03:00
Compare commits
1 Commits
fix/datadi
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b93babb2 |
16
.env.example
16
.env.example
@@ -2040,22 +2040,6 @@ 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
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -26,6 +26,6 @@ jobs:
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
46
.github/workflows/quality.yml
vendored
46
.github/workflows/quality.yml
vendored
@@ -60,49 +60,13 @@ jobs:
|
||||
build:
|
||||
name: Build (advisory)
|
||||
needs: changes
|
||||
# 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
|
||||
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' }}
|
||||
# #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
|
||||
@@ -115,10 +79,6 @@ 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 +0,0 @@
|
||||
- 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/**`)
|
||||
@@ -330,75 +330,32 @@ excludeMarkers, defaultRetryAfterMs}`), matched via `applyStatusRestatement()`.
|
||||
|
||||
Permanent errors (agentrouter's `无权访问模型` — no access to this model) are
|
||||
NEVER restated: `excludeMarkers` vetoes the rule even when `textMarkers` hit,
|
||||
so the error keeps its original status and nothing retries it forever. The
|
||||
matching provider classification rule
|
||||
(`agentrouter-model-access-denied` in `open-sse/config/providerErrorRules.ts`:
|
||||
`reason: "auth_error"`, `scope: "model"`, a `6h` declared base cooldown) is
|
||||
consulted by `checkFallbackError` (`open-sse/services/accountFallback.ts`)
|
||||
*before* the generic apikey-category `FORBIDDEN` early-return, gated on
|
||||
`honorsRuleLockScope(provider)` (#10334 — currently agentrouter-exclusive via
|
||||
the `HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist in
|
||||
`providerErrorRules.ts`). The rule's declared 6h cooldown flows through as
|
||||
`fallbackResult.baseCooldownMs`, but it still feeds the pre-existing
|
||||
per-model-quota lockout path (`lockModelIfPerModelQuota()` /
|
||||
`recordModelLockoutFailure()`, unchanged by #10334 except for the cooldown
|
||||
source): it is clamped down to the operator's `mlSettings.maxCooldownMs`
|
||||
(default `1_800_000ms` / 30min), like every other model lockout, and the
|
||||
*persisted lockout reason* stays the pre-existing hardcoded `"forbidden"`,
|
||||
not the rule's `"auth_error"` — only the cooldown duration is honored
|
||||
end-to-end, not the reason string. The connection itself stays active;
|
||||
sibling models on the same connection are unaffected.
|
||||
so the error keeps its original status and nothing retries it forever. A
|
||||
separate provider classification rule
|
||||
(`agentrouter-model-access-denied` in `open-sse/config/providerErrorRules.ts`)
|
||||
declares an `auth_error`/scope-`model` match for this text, but it does not
|
||||
fire on the live production path today: the rule only matches `status ===
|
||||
403`, and `checkFallbackError`'s apikey-category `FORBIDDEN` branch
|
||||
(`open-sse/services/accountFallback.ts`) returns early for a plain 403
|
||||
*before* the provider-rule lookup ever runs. In practice a `无权访问模型` 403
|
||||
is handled the same way as the base apikey-provider 403 path (see Connection
|
||||
Cooldown, §2), not as a 6h model lockout. The rule still exists as a
|
||||
declarative classification consumable by future callers of `classifyError`
|
||||
with context — wiring it into the production `checkFallbackError` path is
|
||||
tracked as a follow-up, not yet done.
|
||||
|
||||
Restated quota errors (`额度不足`) reach a provider rule in production
|
||||
(`agentrouter-user-quota-exhausted`: `reason: "quota_exhausted"`, `scope:
|
||||
"connection"`, no declared cooldown of its own — the persistence layer's
|
||||
scaled backoff default applies). Since #10334, `scope` on
|
||||
`ProviderErrorRuleMatch` IS consumed end-to-end, but **only** for providers in
|
||||
the `HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist (`providerErrorRules.ts` —
|
||||
today only `"agentrouter"`, gated via `honorsRuleLockScope()`). For every
|
||||
other provider `scope` remains informational, exactly as before #10334.
|
||||
`checkFallbackError` surfaces the matched rule's scope as
|
||||
`fallbackResult.ruleScope`; `isAgentrouterConnectionQuotaScope()`
|
||||
(`src/sse/services/auth.ts`) is the shared guard that confirms a
|
||||
`ruleScope` is genuinely safe to honor as a connection-wide, self-recovering
|
||||
signal (scope `"connection"`, reason `quota_exhausted`, never `permanent`,
|
||||
never `creditsExhausted` — a defense against a future rule pairing scope
|
||||
`"connection"` with a permanent account state). Two consumers call it:
|
||||
|
||||
- **Persistence** (`markAccountUnavailable()`, `src/sse/services/auth.ts`):
|
||||
instead of falling into the passthrough-provider **per-model** lockout
|
||||
branch (agentrouter is `passthroughModels: true` → `hasPerModelQuota()`
|
||||
returns `true`), it applies a **temporary connection cooldown** —
|
||||
`testStatus: "unavailable"` + `rateLimitedUntil`, never a terminal status
|
||||
(`credits_exhausted`/`banned`/`expired`) — so the connection self-recovers
|
||||
once the cooldown lapses instead of requiring a manual credential reset.
|
||||
Skipped for connections with `disableCooling: true` (#2997): that opt-out
|
||||
falls through to the per-model lockout instead (a documented trade-off —
|
||||
see the code comment above the branch).
|
||||
- **Same-request combo routing** (`applyComboTargetExhaustion()`,
|
||||
`open-sse/services/combo/targetExhaustion.ts`): the same guard marks the
|
||||
connection into the in-memory `exhaustedConnections` set, keyed
|
||||
`${provider}:${connectionId}`. This only skips a remaining SAME-REQUEST
|
||||
target that *itself already carries that exact `connectionId`* on its own
|
||||
target object (`getExhaustedTargetSkipReason()`,
|
||||
`open-sse/services/combo/comboPredicates.ts`, `if (provider &&
|
||||
connectionId)` before the `exhaustedConnections` lookup) — a plain
|
||||
model-list combo, where sibling targets carry no pinned `connectionId` of
|
||||
their own and one is only resolved per-dispatch from the response's
|
||||
`X-OmniRoute-Selected-Connection-Id` header, never hits that key match. For
|
||||
that common case, the real protection against a remaining leg reusing the
|
||||
just-exhausted account is NOT this Set — it is the persistence layer above
|
||||
(the connection's `rateLimitedUntil` is now in the future) combined with
|
||||
this same guard suppressing `transientRateLimitedProviders` for the
|
||||
failure (see "Two-stage design" and the code comment on the
|
||||
`isAgentrouterConnectionQuotaScope` branch in `targetExhaustion.ts`): with
|
||||
that Set left unmarked, `combo.ts`'s `allowRateLimitedConnection` force-allow
|
||||
(`open-sse/services/combo.ts:1005-1013`, `:2734-2738`) does NOT kick in for
|
||||
the provider's remaining legs, so credential selection's `rateLimitedUntil`
|
||||
filter (`src/sse/services/auth.ts:1238`) is honored normally and a
|
||||
remaining leg either picks a different, still-eligible agentrouter
|
||||
connection or fails with no credentials available — it does not force its
|
||||
way back onto the connection this branch just cooled down.
|
||||
Restated quota errors (`额度不足`) do reach a provider rule in production
|
||||
(`agentrouter-user-quota-exhausted`, scope `"connection"`), but `scope` on
|
||||
`ProviderErrorRuleMatch` is currently informational — the persistence path
|
||||
(`checkFallbackError` → `combo.ts`) only consumes `reason` and `cooldownMs`,
|
||||
never `scope`. What actually happens for agentrouter (`passthroughModels:
|
||||
true` → `hasPerModelQuota()` returns `true`) is a **per-model** lockout via
|
||||
`recordModelLockoutFailure()`: the connection itself is never cooled down for
|
||||
this error (`combo.ts` skips `recordProviderCooldown` for 429 when
|
||||
`hasPerModelQuota` is true), so other models on the same account keep being
|
||||
tried — each one burns one call and its own lockout before combo routing
|
||||
moves on. Honoring `scope` end-to-end (so a `"connection"` match actually
|
||||
locks the connection) is tracked as a follow-up.
|
||||
|
||||
### Two-stage design: status restatement, then classification
|
||||
|
||||
@@ -423,15 +380,6 @@ allowlisted providers, the structured error otherwise. Adding a provider to
|
||||
that the default path for every provider not on the list stays
|
||||
byte-for-byte unchanged.
|
||||
|
||||
A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in
|
||||
from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as
|
||||
`fallbackResult.ruleScope`, and downstream consumers only honor it as
|
||||
anything other than an informational label, for providers in the
|
||||
`HONORS_RULE_LOCK_SCOPE_PROVIDERS` allowlist in the same file (`gated via
|
||||
honorsRuleLockScope()` — today only `"agentrouter"`). See "Restated quota
|
||||
errors" above for what a `scope: "connection"` match actually does once a
|
||||
provider is on that allowlist.
|
||||
|
||||
### Adding a new quota-misstating gateway
|
||||
|
||||
1. Register one rule array in `statusRestatementRegistry`
|
||||
@@ -447,15 +395,7 @@ provider is on that allowlist.
|
||||
`checkFallbackError` only ever hands the rule the structured
|
||||
`{code, type}` error and a body-text rule will never match live traffic.
|
||||
Rules that match purely on `status`/`headers` (like Opencode's or
|
||||
Minimax's) do not need this opt-in. Separately, if the rule declares
|
||||
`scope: "connection"` and the intent is an actual connection-wide cooldown
|
||||
plus same-request combo skip (not just an informational label), add the
|
||||
provider id to `HONORS_RULE_LOCK_SCOPE_PROVIDERS` in the same file — this
|
||||
is what gates `isAgentrouterConnectionQuotaScope()`-style consumption in
|
||||
`markAccountUnavailable()` (`src/sse/services/auth.ts`) and
|
||||
`applyComboTargetExhaustion()`
|
||||
(`open-sse/services/combo/targetExhaustion.ts`); without it, `scope`
|
||||
still flows through `fallbackResult.ruleScope` but nothing acts on it.
|
||||
Minimax's) do not need this opt-in.
|
||||
3. Add unit tests mirroring `tests/unit/upstream-status-restatement.test.ts`
|
||||
and `tests/unit/agentrouter-error-rules.test.ts` (including the
|
||||
not-permanent / not-creditsExhausted guards, and — if the provider needs
|
||||
|
||||
@@ -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://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1` | `https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic/v1` | Anthropic |
|
||||
| `bailian-coding-plan` | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1` | `https://coding.dashscope.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 |
|
||||
|
||||
|
||||
@@ -1142,10 +1142,6 @@ 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. |
|
||||
|
||||
@@ -30,15 +30,13 @@ export type ProviderErrorRule = {
|
||||
export type ProviderErrorRuleMatch = {
|
||||
reason: ConfiguredErrorReason;
|
||||
/**
|
||||
* Intended lock scope. #10334: this field is CONSUMED end-to-end only for
|
||||
* providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS` (agentrouter-exclusive
|
||||
* today, gated by `honorsRuleLockScope()`) — for those, `checkFallbackError`
|
||||
* surfaces it as `ruleScope` on its return value for the persistence layer
|
||||
* to honor instead of re-deriving scope from `hasPerModelQuota()`. For
|
||||
* every other provider it remains INFORMATIONAL: `getProviderErrorRuleMatch`
|
||||
* callers still read only `reason`/`cooldownMs`, and the actual lock scope
|
||||
* is decided independently by each call site. Widening the allowlist is
|
||||
* tracked as a follow-up — see `docs/architecture/RESILIENCE_GUIDE.md` §7.
|
||||
* Intended lock scope. NOTE: this field is currently INFORMATIONAL — no
|
||||
* consumer of `getProviderErrorRuleMatch` (checkFallbackError, combo.ts)
|
||||
* reads `scope` today; only `reason` and `cooldownMs` are consulted. The
|
||||
* actual lock scope applied at runtime is decided independently by each
|
||||
* call site (e.g. `hasPerModelQuota()` deciding model- vs connection-level
|
||||
* lockout). Honoring this field end-to-end is tracked as a follow-up —
|
||||
* see `docs/architecture/RESILIENCE_GUIDE.md` §7.
|
||||
*/
|
||||
scope: "model" | "provider" | "connection";
|
||||
/** Optional explicit cooldown; falls back to the existing per-reason defaults. */
|
||||
@@ -190,29 +188,31 @@ function buildOpenrouterRules(): ProviderErrorRule[] {
|
||||
// agentrouter.org misstates temporary quota exhaustion as 403/400 with a
|
||||
// Chinese body. upstreamStatusRestatement.ts rewrites the status to 429
|
||||
// BEFORE classification, so rules here accept both the raw 403/400 and the
|
||||
// restated 429 (text is the real discriminator either way). Both the raw 403
|
||||
// path AND the restated 429 path reach these rules in production:
|
||||
// checkFallbackError's `honorsRuleLockScope("agentrouter")` pre-check
|
||||
// (#10334) consults these rules BEFORE the generic apikey-category FORBIDDEN
|
||||
// branch, and the restated 429 reaches them via the existing provider-rule
|
||||
// lookup in the configured-rule branch. Both paths use resolveRuleMatchBody,
|
||||
// the only mechanism in checkFallbackError that hands agentrouter's rules the
|
||||
// full error text instead of just {code, type}.
|
||||
// restated 429 (text is the real discriminator either way). In production,
|
||||
// the raw 403 path is what actually matters here: checkFallbackError's
|
||||
// apikey-category FORBIDDEN branch (~line 1699) returns EARLY for a plain
|
||||
// 403, before these rules are ever consulted — these rules fire on the
|
||||
// RESTATED 429 (chatCore's upstreamStatusRestatement hook runs first) via
|
||||
// resolveRuleMatchBody, which is the only path in checkFallbackError that
|
||||
// hands these rules the full error text instead of just {code, type}.
|
||||
// - "额度不足": account-wide temporary quota → quota_exhausted, scope
|
||||
// "connection" (mirror of the Opencode account-wide rationale above).
|
||||
// `scope` on ProviderErrorRuleMatch is CONSUMED for agentrouter (#10334,
|
||||
// exclusive allowlist via `honorsRuleLockScope`): checkFallbackError
|
||||
// surfaces it as `ruleScope` on its return value. Whether the persistence
|
||||
// layer (markAccountUnavailable / combo target exhaustion) actually
|
||||
// switches from `hasPerModelQuota()`-derived scope to honoring `ruleScope`
|
||||
// is Tasks 2/3 of #10334 — this task only surfaces the field.
|
||||
// NOTE: `scope` on ProviderErrorRuleMatch is currently informational —
|
||||
// checkFallbackError/combo.ts only consume `reason` and `cooldownMs`, not
|
||||
// `scope`. For agentrouter specifically (passthroughModels: true →
|
||||
// hasPerModelQuota() is true), this quota_exhausted match actually
|
||||
// resolves to a PER-MODEL lockout (recordModelLockoutFailure), not a
|
||||
// connection-wide lock — other models on the same account keep being
|
||||
// tried by combo routing (each burning one call) until they lock out
|
||||
// individually. Honoring `scope` end-to-end is tracked as a follow-up.
|
||||
// - "无权访问模型": declares auth_error/scope "model" (intent: lock only the
|
||||
// model so the connection keeps serving the rest — Model Lockout tier).
|
||||
// This rule now fires on the production 403 path (#10334): the
|
||||
// `honorsRuleLockScope` pre-check matches it and returns its declared
|
||||
// reason/cooldown/scope before the generic apikey-FORBIDDEN early-return
|
||||
// ever runs. A live `无权访问模型` 403 therefore no longer falls through to
|
||||
// the base apikey-provider 403 handling.
|
||||
// This rule does NOT fire on the production path today: it only matches
|
||||
// `status === 403`, but checkFallbackError's apikey FORBIDDEN branch
|
||||
// returns early for a plain 403 before this rule is ever consulted (see
|
||||
// the note above). A live `无权访问模型` 403 is handled like the base
|
||||
// apikey-provider 403 today. Wiring this rule into that path is tracked
|
||||
// as a follow-up.
|
||||
function buildAgentrouterRules(): ProviderErrorRule[] {
|
||||
const AGENTROUTER_ERROR_STATUSES = new Set([400, 403, 429]);
|
||||
return [
|
||||
@@ -231,15 +231,8 @@ function buildAgentrouterRules(): ProviderErrorRule[] {
|
||||
if (status !== 403) return null;
|
||||
const text = JSON.stringify(body ?? "").toLowerCase();
|
||||
if (!text.includes("无权访问模型")) return null;
|
||||
// Declares a 6h cooldown, but the effective cooldown is NOT 6h: the
|
||||
// model-lockout persistence layer (recordModelLockoutFailure, called from
|
||||
// markAccountUnavailable) clamps every base cooldown — this one included —
|
||||
// to the configured model-lockout maxCooldownMs, which defaults to
|
||||
// 1_800_000ms / 30min (src/lib/resilience/modelLockoutSettings.ts,
|
||||
// DEFAULT_MODEL_LOCKOUT_SETTINGS.maxCooldownMs). So in practice this is
|
||||
// "locked for ~30min by default (up to 6h if an operator raises the model-
|
||||
// lockout cap in settings)", not "until the operator fixes the key's model
|
||||
// grants" — it is a recoverable window, not a real fix-driven unlock.
|
||||
// 6h: effectively "until the operator fixes the key's model grants",
|
||||
// without being an unrecoverable terminal state.
|
||||
return { reason: "auth_error", scope: "model", cooldownMs: 6 * 60 * 60 * 1000 };
|
||||
},
|
||||
},
|
||||
@@ -262,21 +255,6 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
|
||||
["agentrouter", buildAgentrouterRules()],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Providers whose ProviderErrorRuleMatch.scope is actually CONSUMED at the
|
||||
* persistence layer (markAccountUnavailable / combo target exhaustion) to pick
|
||||
* connection-vs-model lock scope. EXCLUSIVE allowlist by owner decision
|
||||
* (2026-08-14, issue #10334) — deliberately SEPARATE from
|
||||
* FULL_TEXT_RULE_PROVIDERS: that set controls what body a rule matches against
|
||||
* (input), this one controls whether the matched scope changes caller behavior
|
||||
* (output). A provider could need one without the other.
|
||||
*/
|
||||
const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter"]);
|
||||
|
||||
export function honorsRuleLockScope(provider: string | null | undefined): boolean {
|
||||
return !!provider && HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(provider.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers whose rules match on the FULL upstream error text.
|
||||
* checkFallbackError's rule lookup normally passes only the structured
|
||||
|
||||
@@ -60,12 +60,7 @@ export const bailian_coding_planProvider: RegistryEntry = {
|
||||
alias: "bcp",
|
||||
format: "claude",
|
||||
executor: "default",
|
||||
// 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",
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
|
||||
chatPath: "/messages",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
|
||||
@@ -15,11 +15,7 @@ import {
|
||||
serviceSupervisorCooldown,
|
||||
isNimFunctionDegraded,
|
||||
} from "../config/errorConfig.ts";
|
||||
import {
|
||||
getProviderErrorRuleMatch,
|
||||
resolveRuleMatchBody,
|
||||
honorsRuleLockScope,
|
||||
} from "../config/providerErrorRules.ts";
|
||||
import { getProviderErrorRuleMatch, resolveRuleMatchBody } from "../config/providerErrorRules.ts";
|
||||
import * as rot from "./rotationConfig.ts";
|
||||
import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts";
|
||||
import {
|
||||
@@ -1462,11 +1458,6 @@ export function checkFallbackError(
|
||||
/** #6061: the provider-configured cooldown (ms) before backoff scaling, surfaced so the
|
||||
* caller can persist an explicit reset window instead of the engine's scaled cooldown. */
|
||||
configuredCooldownMs?: number;
|
||||
/** #10334 — the matched ProviderErrorRule's declared lock scope, surfaced so the
|
||||
* persistence layer can honor it instead of re-deriving scope from
|
||||
* hasPerModelQuota(). Populated ONLY when honorsRuleLockScope(provider) is true;
|
||||
* always undefined for every other provider, so existing consumers are unaffected. */
|
||||
ruleScope?: "model" | "provider" | "connection";
|
||||
} {
|
||||
// #10360: an executor-result contract violation is OUR bug, not the provider's.
|
||||
// Retrying reproduces it verbatim, and cooling the connection down (or tripping
|
||||
@@ -1721,36 +1712,6 @@ export function checkFallbackError(
|
||||
return { shouldFallback: false, cooldownMs: 0, reason: RateLimitReason.UNKNOWN };
|
||||
}
|
||||
|
||||
// #10334 — agentrouter EXCLUSIVE: consult the provider rules BEFORE the
|
||||
// apikey-FORBIDDEN early-return below, so a recognized 403 body (e.g.
|
||||
// "无权访问模型") carries the rule's declared reason/cooldown/scope instead of
|
||||
// the generic short auth cooldown. Gated on honorsRuleLockScope — for any
|
||||
// other provider this block is a no-op and the early-return stays identical.
|
||||
if (status === HTTP_STATUS.FORBIDDEN && provider && honorsRuleLockScope(provider)) {
|
||||
const forbiddenMatch = getProviderErrorRuleMatch(
|
||||
provider,
|
||||
status,
|
||||
headers,
|
||||
resolveRuleMatchBody(provider, structuredError ?? null, errorStr)
|
||||
);
|
||||
if (forbiddenMatch) {
|
||||
const scaled = getScaledBaseCooldown(
|
||||
forbiddenMatch.reason as RateLimitReasonValue,
|
||||
backoffLevel
|
||||
);
|
||||
const ruleCooldownMs = forbiddenMatch.cooldownMs;
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: ruleCooldownMs ?? scaled.cooldownMs,
|
||||
baseCooldownMs: ruleCooldownMs ?? scaled.baseCooldownMs,
|
||||
configuredCooldownMs: ruleCooldownMs,
|
||||
newBackoffLevel: ruleCooldownMs !== undefined ? 0 : scaled.newBackoffLevel,
|
||||
reason: forbiddenMatch.reason,
|
||||
ruleScope: forbiddenMatch.scope,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
status === HTTP_STATUS.FORBIDDEN &&
|
||||
provider &&
|
||||
@@ -1803,8 +1764,6 @@ export function checkFallbackError(
|
||||
providerMatch?.cooldownMs !== undefined && providerMatch.cooldownMs > 0
|
||||
? providerMatch.cooldownMs
|
||||
: undefined;
|
||||
const ruleScope =
|
||||
providerMatch && honorsRuleLockScope(provider) ? providerMatch.scope : undefined;
|
||||
const fallback = buildRetryableFallback(reason);
|
||||
if (providerCooldownMs !== undefined) {
|
||||
return {
|
||||
@@ -1812,10 +1771,9 @@ export function checkFallbackError(
|
||||
cooldownMs: providerCooldownMs,
|
||||
baseCooldownMs: providerCooldownMs,
|
||||
configuredCooldownMs: providerCooldownMs,
|
||||
ruleScope,
|
||||
};
|
||||
}
|
||||
return { ...fallback, ruleScope };
|
||||
return fallback;
|
||||
}
|
||||
// #6842: non-backoff configured rules (e.g. status_402) previously never
|
||||
// consulted providerRuleRegistry, so a provider-specific rule (like
|
||||
@@ -1831,15 +1789,12 @@ export function checkFallbackError(
|
||||
)
|
||||
: null;
|
||||
const cooldownMs = providerMatch?.cooldownMs ?? configuredRule.cooldownMs ?? 0;
|
||||
const ruleScope =
|
||||
providerMatch && honorsRuleLockScope(provider) ? providerMatch.scope : undefined;
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs,
|
||||
baseCooldownMs: cooldownMs,
|
||||
configuredCooldownMs: cooldownMs,
|
||||
reason: providerMatch?.reason ?? configuredRule.reason ?? RateLimitReason.UNKNOWN,
|
||||
ruleScope,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,6 @@ import {
|
||||
import { RateLimitReason } from "../../config/constants.ts";
|
||||
import { isProviderCircuitOpenResult, isRequestScopedUpstreamFailure } from "./comboPredicates.ts";
|
||||
import { isCloudflareFingerprintRejection } from "../errorClassifier.ts";
|
||||
// #10334 — agentrouter-exclusive predicate shared with the persistence layer
|
||||
// (markAccountUnavailable) so the same-request combo skip and the persisted
|
||||
// connection cooldown agree on exactly which fallbackResult shapes qualify.
|
||||
import { isAgentrouterConnectionQuotaScope } from "@/sse/services/auth";
|
||||
import type { ComboLogger, ResolvedComboTarget } from "./types.ts";
|
||||
|
||||
// Connection-level failure statuses: the provider connection itself is likely bad (upstream
|
||||
@@ -64,13 +60,7 @@ export type ComboExhaustionSets = {
|
||||
|
||||
export type ApplyComboTargetExhaustionOptions = {
|
||||
result: { status: number; headers?: Headers | null };
|
||||
fallbackResult: Parameters<typeof isProviderExhaustedReason>[0] & {
|
||||
/** #10334 — agentrouter-exclusive; see isAgentrouterConnectionQuotaScope
|
||||
* (src/sse/services/auth.ts). Populated only for providers in
|
||||
* HONORS_RULE_LOCK_SCOPE_PROVIDERS (today: agentrouter only). */
|
||||
ruleScope?: "model" | "provider" | "connection";
|
||||
permanent?: boolean;
|
||||
};
|
||||
fallbackResult: Parameters<typeof isProviderExhaustedReason>[0];
|
||||
errorText: string;
|
||||
rawModel: string;
|
||||
isTokenLimitBreach: boolean;
|
||||
@@ -96,56 +86,6 @@ export function applyComboTargetExhaustion(
|
||||
const { result, sets, log, tag, errorText, structuredError } = opts;
|
||||
const provider = target.provider;
|
||||
|
||||
// #10334: agentrouter-exclusive account-wide quota exhaustion ("额度不足")
|
||||
// must skip remaining SAME-CONNECTION targets within THIS request too, not
|
||||
// just via the persisted cooldown markAccountUnavailable applies for
|
||||
// whichever leg runs next. agentrouter is a passthroughModels provider
|
||||
// (hasPerModelQuota() === true), so without this branch the classification
|
||||
// below would fall straight through isProviderQuotaExhausted's
|
||||
// !hasPerModelQuota() guard, and — for the restated-429 case —
|
||||
// markConnectionLevelExhaustion's connection-level guard (429 is not in
|
||||
// CONNECTION_LEVEL_ERROR_STATUSES), marking nothing: combo would keep
|
||||
// burning one upstream call per remaining model of the same exhausted
|
||||
// account. isAgentrouterConnectionQuotaScope is the same guard
|
||||
// markAccountUnavailable uses, so both consumers agree on exactly which
|
||||
// fallbackResult shapes qualify (never a permanent/credits-exhausted
|
||||
// result, even one carrying ruleScope "connection").
|
||||
//
|
||||
// Runs BEFORE the auth-level (401/403) branch below. This is deliberate,
|
||||
// not incidental: the "额度不足" rule matches statuses {400, 403, 429}
|
||||
// (buildAgentrouterRules, providerErrorRules.ts), and Task 1's FORBIDDEN
|
||||
// pre-check (accountFallback.ts ~1729-1751) surfaces `ruleScope:
|
||||
// "connection"` for a RAW 403 carrying that body too — so this branch can
|
||||
// also fire on a 403, not just the restated 429. That is safe: for a 403
|
||||
// this branch and markAuthLevelExhaustion below write the SAME set with
|
||||
// the SAME `${provider}:${connId}` key and both return `true` — they are
|
||||
// set-equivalent for agentrouter on that status. The Cloudflare-1010 and
|
||||
// Alibaba free-tier EXEMPTIONS further down in the 401/403 branch cannot
|
||||
// apply here regardless of ordering: 1010 is a CDN fingerprint rejection
|
||||
// agentrouter's own text never carries, and the Alibaba exemption is
|
||||
// gated on isAlibabaModelStudioProvider(provider), which agentrouter is
|
||||
// not.
|
||||
//
|
||||
// Unlike the connection-level/auth-level branches, this path deliberately
|
||||
// does NOT fall through to markTransientOrConnectionLevel, so
|
||||
// sets.transientRateLimitedProviders is NEVER populated for this failure.
|
||||
// That is required, not just incidental: combo.ts (both dispatchers, see
|
||||
// the `allowRateLimitedConnection` reads keyed off
|
||||
// transientRateLimitedProviders) uses that set to force-allow reusing a
|
||||
// rate-limited CONNECTION for the provider's remaining legs — i.e. it
|
||||
// bypasses the very `rateLimitedUntil` filter this branch (and Task 2's
|
||||
// markAccountUnavailable) just set. Marking it here would silently
|
||||
// re-open the account this branch just cooled down. One secondary
|
||||
// consequence: a SIBLING agentrouter connection that is merely
|
||||
// rate-limited (not the one this branch exhausted) will also no longer be
|
||||
// force-allowed for a later leg on the same provider — a remaining leg
|
||||
// can now resolve to "no credentials available" instead of retrying a
|
||||
// rate-limited sibling account, which is the intended, safer outcome.
|
||||
if (isAgentrouterConnectionQuotaScope(provider, opts.fallbackResult)) {
|
||||
markAgentrouterConnectionQuotaExhaustion(target, { sets, log, tag });
|
||||
return true;
|
||||
}
|
||||
|
||||
// #8133/#8137: auth-level failures (401/403) mean that connection's credentials are bad.
|
||||
// Split out to keep applyComboTargetExhaustion under the complexity ceiling.
|
||||
// Cloudflare 1010 (a 403 carrying error_code 1010 / browser_signature_banned) is NOT an
|
||||
@@ -319,35 +259,6 @@ function markAuthLevelExhaustion(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #10334: agentrouter-exclusive connection-scope account quota exhaustion. Mirrors
|
||||
* markAuthLevelExhaustion's connectionId-present/absent split — when the target carries a
|
||||
* connectionId, only that connection's account is exhausted (sibling agentrouter connections
|
||||
* for the same user may still have quota); fall back to whole-provider exhaustion only when no
|
||||
* connectionId is available.
|
||||
*/
|
||||
function markAgentrouterConnectionQuotaExhaustion(
|
||||
target: ResolvedComboTarget,
|
||||
opts: Pick<ApplyComboTargetExhaustionOptions, "sets" | "log" | "tag">
|
||||
): void {
|
||||
const { sets, log, tag } = opts;
|
||||
const provider = target.provider;
|
||||
const connId = target.connectionId ?? undefined;
|
||||
if (connId) {
|
||||
sets.exhaustedConnections.add(`${provider}:${connId}`);
|
||||
log.info(
|
||||
tag,
|
||||
`Provider ${provider} connection ${connId} account quota exhausted (rule scope=connection) — marking for skip on remaining targets (#10334)`
|
||||
);
|
||||
} else {
|
||||
sets.exhaustedProviders.add(provider as string);
|
||||
log.info(
|
||||
tag,
|
||||
`Provider ${provider} account quota exhausted (rule scope=connection, no connectionId) — marking for skip on remaining targets (#10334)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #1731v2: connection-level errors (408/5xx, excluding the OmniRoute circuit-open signal) suggest
|
||||
* the provider connection itself is bad → skip remaining same-connection (or same-provider, when
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
/**
|
||||
* 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,7 +69,6 @@ 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>;
|
||||
@@ -112,7 +111,6 @@ export const USAGE_FETCHER_PROVIDERS = [
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
"bailian-coding-plan",
|
||||
"qwen-cloud-token-plan",
|
||||
"nanogpt",
|
||||
"deepseek",
|
||||
"opencode",
|
||||
@@ -204,8 +202,6 @@ 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,7 +10,6 @@
|
||||
*/
|
||||
|
||||
import { fetchBailianQuota, type BailianTripleWindowQuota } from "../bailianQuotaFetcher.ts";
|
||||
import { getQwenTokenPlanUsage } from "./qwen-token-plan.ts";
|
||||
|
||||
/**
|
||||
* Bailian (Alibaba Token Plan) Usage
|
||||
@@ -22,25 +21,11 @@ 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) {
|
||||
// Neither surface answered — surface the Token Plan guidance, which tells the
|
||||
// operator how to supply the cookie the console gateway requires.
|
||||
return tokenPlanUsage;
|
||||
return { message: "Alibaba Token Plan connected. Unable to fetch quota." };
|
||||
}
|
||||
|
||||
const bailianQuota = quota as BailianTripleWindowQuota;
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* 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}` };
|
||||
}
|
||||
}
|
||||
@@ -340,8 +340,6 @@ 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,16 +3,44 @@
|
||||
import { Input } from "@/shared/components";
|
||||
import { providerText, type ProviderMessageTranslator } from "../../providerPageHelpers";
|
||||
|
||||
import {
|
||||
assignQuotaScrapingProviderData,
|
||||
EMPTY_QUOTA_SCRAPING_FIELDS,
|
||||
QWEN_TOKEN_PLAN_PROVIDERS,
|
||||
type QuotaScrapingFieldValues,
|
||||
} from "./quotaScrapingFieldValues";
|
||||
export type QuotaScrapingFieldValues = {
|
||||
opencodeGoWorkspaceId: string;
|
||||
opencodeGoAuthCookie: string;
|
||||
ollamaCloudUsageCookie: string;
|
||||
alibabaConsoleCookie: string;
|
||||
alibabaConsoleSecToken: string;
|
||||
};
|
||||
|
||||
// Re-exported so existing importers (modals, tests) keep their current paths.
|
||||
export { assignQuotaScrapingProviderData, EMPTY_QUOTA_SCRAPING_FIELDS };
|
||||
export type { 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type QuotaScrapingFieldsProps = {
|
||||
provider?: string;
|
||||
@@ -142,50 +170,5 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,54 +83,12 @@ export function resolveDataDir({ isCloud = false }: { isCloud?: boolean } = {}):
|
||||
* Use this only at the single startup site that owns directory creation
|
||||
* (currently `db/core.ts`); everywhere else keep using the pure resolver.
|
||||
*/
|
||||
/**
|
||||
* #10428: true when this process looks like a test run rather than a server start.
|
||||
*
|
||||
* `NODE_TEST_CONTEXT` is set by `node --test` in every spawned test process, `VITEST` by
|
||||
* vitest, and `NODE_ENV=test` by the npm scripts — between them they cover both runners
|
||||
* plus the AGENTS.md single-file command, which does NOT load
|
||||
* `tests/_setup/isolateDataDir.ts`.
|
||||
*/
|
||||
function isTestContext(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === "test" ||
|
||||
!!process.env.VITEST ||
|
||||
!!process.env.NODE_TEST_CONTEXT ||
|
||||
process.execArgv.includes("--test") ||
|
||||
process.argv.includes("--test")
|
||||
);
|
||||
}
|
||||
|
||||
/** Process-wide redirect target, so repeated calls share one DB instead of one per call. */
|
||||
let testContextDataDir: string | null = null;
|
||||
|
||||
export function resolveWritableDataDir({ isCloud = false }: { isCloud?: boolean } = {}): string {
|
||||
const resolved = resolveDataDir({ isCloud });
|
||||
|
||||
// Cloud/serverless never owns a writable home dir; leave its sentinel alone.
|
||||
if (isCloud) return resolved;
|
||||
|
||||
// #10428: a test/ad-hoc run that never chose a DATA_DIR would otherwise open the
|
||||
// OPERATOR'S REAL database (~/.omniroute/storage.sqlite — live provider credentials).
|
||||
// Redirect to a throwaway dir instead of throwing: the documented single-file command
|
||||
// (`node --import tsx/esm --test tests/unit/x.test.ts`) does not load the isolation
|
||||
// setup, and a hard failure there would only teach people to disable the guard.
|
||||
// `OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1` opts back in, so the intent is recorded.
|
||||
if (
|
||||
!process.env.DATA_DIR &&
|
||||
isTestContext() &&
|
||||
process.env.OMNIROUTE_ALLOW_DEFAULT_DATA_DIR !== "1"
|
||||
) {
|
||||
if (!testContextDataDir) {
|
||||
testContextDataDir = fs.mkdtempSync(path.join(os.tmpdir(), `${APP_NAME}-testctx-`));
|
||||
console.warn(
|
||||
`[DATA_DIR] test context without DATA_DIR → using '${testContextDataDir}' instead of ` +
|
||||
`'${resolved}'. Set DATA_DIR explicitly (or load tests/_setup/isolateDataDir.ts) to silence this.`
|
||||
);
|
||||
}
|
||||
return testContextDataDir;
|
||||
}
|
||||
|
||||
// No explicit override → already the default user dir; nothing to fall back to.
|
||||
const configured = normalizeConfiguredPath(process.env.DATA_DIR);
|
||||
if (!configured) return resolved;
|
||||
|
||||
@@ -97,9 +97,6 @@ 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,10 +500,6 @@ 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,8 +328,6 @@ export function validateProviderSpecificData(
|
||||
"usageCookie",
|
||||
"alibabaConsoleCookie",
|
||||
"alibabaConsoleSecToken",
|
||||
"qwenCloudCookie",
|
||||
"qwenCloudSecToken",
|
||||
] as const) {
|
||||
const value = data[key];
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
|
||||
@@ -148,7 +148,6 @@ 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";
|
||||
@@ -172,11 +171,6 @@ 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.
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
} from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { honorsRuleLockScope } from "@omniroute/open-sse/config/providerErrorRules.ts";
|
||||
import {
|
||||
preflightQuota,
|
||||
isQuotaPreflightEnabled,
|
||||
@@ -1982,46 +1981,6 @@ export async function getProviderCredentialsWithQuotaPreflight(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #10334 — Guard for the agentrouter-exclusive "connection scope" quota
|
||||
* cooldown branch in markAccountUnavailable. The "never terminal" invariant of
|
||||
* that branch is NOT structurally guaranteed by `ruleScope === "connection"`
|
||||
* alone — it also depends on the provider rule table only ever pairing scope
|
||||
* "connection" with a genuinely transient reason. Today
|
||||
* (`buildAgentrouterRules()` in providerErrorRules.ts) that is true: the only
|
||||
* rule declaring scope "connection" is the quota-exhausted one. But a FUTURE
|
||||
* agentrouter rule for a permanent account state (e.g. "账号已封禁") — or a 402
|
||||
* added to `AGENTROUTER_ERROR_STATUSES` with scope "connection", a natural-
|
||||
* looking choice for an account ban — would otherwise be silently downgraded
|
||||
* to a transient cooldown here instead of going through
|
||||
* resolveTerminalConnectionStatus()/auto-disable below. Require the
|
||||
* reason/permanent/creditsExhausted signals checkFallbackError already
|
||||
* computes to explicitly confirm "this is quota, not a permanent state"
|
||||
* before taking the early return.
|
||||
*
|
||||
* Exported (not just inlined) so a synthetic permanent/credits-exhausted
|
||||
* `fallbackResult` can be tested directly — no rule in the table produces
|
||||
* that combination today, so this predicate is the only way to pin the guard
|
||||
* without editing the (production) rule table just for a test.
|
||||
*/
|
||||
export function isAgentrouterConnectionQuotaScope(
|
||||
provider: string | null | undefined,
|
||||
fallbackResult: {
|
||||
ruleScope?: "model" | "provider" | "connection";
|
||||
reason?: string;
|
||||
permanent?: boolean;
|
||||
creditsExhausted?: boolean;
|
||||
}
|
||||
): boolean {
|
||||
return (
|
||||
honorsRuleLockScope(provider) &&
|
||||
fallbackResult.ruleScope === "connection" &&
|
||||
fallbackResult.reason === RateLimitReason.QUOTA_EXHAUSTED &&
|
||||
!fallbackResult.permanent &&
|
||||
!fallbackResult.creditsExhausted
|
||||
);
|
||||
}
|
||||
|
||||
/** Persist exponential-backoff state for an unavailable provider connection. */
|
||||
export async function markAccountUnavailable(
|
||||
connectionId: string,
|
||||
@@ -2142,53 +2101,6 @@ export async function markAccountUnavailable(
|
||||
const disableCooling = connProviderSpecificData.disableCooling === true;
|
||||
|
||||
const isPerModelQuotaProvider = hasPerModelQuota(provider, model, connectionPassthroughModels);
|
||||
|
||||
// #10334 — agentrouter EXCLUSIVE: the matched provider rule declared scope
|
||||
// "connection" for account-wide quota exhaustion ("额度不足"). agentrouter is
|
||||
// a passthroughModels provider (isPerModelQuotaProvider === true), so without
|
||||
// this branch the next `if` would treat it like any other passthrough 429 and
|
||||
// lock a SINGLE model — leaving combo routing to burn one upstream call per
|
||||
// remaining model of the same exhausted account. Must run BEFORE that block.
|
||||
// Deliberately ignores persistUnavailableState/isCombo: for combo the caller
|
||||
// downgrades persistUnavailableState to false, and the generic path further
|
||||
// below would then lock per MODEL instead of cooling the connection — exactly
|
||||
// what this scope must override. NEVER sets a terminal status: this is a
|
||||
// renewing quota window, not "credits_exhausted"/"banned"/"expired".
|
||||
//
|
||||
// The "never terminal" invariant above is NOT structurally guaranteed by
|
||||
// ruleScope === "connection" alone — see isAgentrouterConnectionQuotaScope's
|
||||
// doc comment for why (a future permanent-state rule could pair scope
|
||||
// "connection" with a non-quota reason). That predicate is the actual guard.
|
||||
const ruleScopeIsConnection = isAgentrouterConnectionQuotaScope(provider, fallbackResult);
|
||||
// #2997's disableCooling opt-out is respected here (`!disableCooling` below):
|
||||
// a connection with disableCooling=true skips this branch entirely and falls
|
||||
// into the per-model-quota block further down, which locks the model for up
|
||||
// to ~30min (mlSettings.maxCooldownMs) instead of cooling the connection for
|
||||
// the rule's shorter transient window. That is a deliberate, if counter-
|
||||
// intuitive, consequence of #2997's scope (opt-out was designed only for the
|
||||
// CONNECTION-level cooldown, never extended to model lockout) — "opting out
|
||||
// of cooldown" ends up producing a LONGER effective block for this one rule.
|
||||
// Not addressed here; flagged for a future #2997 follow-up if it proves to be
|
||||
// a real operator complaint.
|
||||
if (ruleScopeIsConnection && provider && !disableCooling) {
|
||||
const connectionCooldownMs =
|
||||
fallbackResult.cooldownMs > 0 ? fallbackResult.cooldownMs : COOLDOWN_MS.rateLimit;
|
||||
await updateProviderConnection(connectionId, {
|
||||
lastErrorType: fallbackResult.reason || RateLimitReason.QUOTA_EXHAUSTED,
|
||||
lastError: `Account quota exhausted (${provider})`,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
errorCode: status,
|
||||
backoffLevel: fallbackResult.newBackoffLevel ?? backoffLevel,
|
||||
rateLimitedUntil: getUnavailableUntil(connectionCooldownMs),
|
||||
testStatus: "unavailable",
|
||||
});
|
||||
log.info(
|
||||
"AUTH",
|
||||
`Connection-scoped cooldown for ${provider}:${connectionId.slice(0, 8)} — ${status} ${fallbackResult.reason} ${Math.ceil(connectionCooldownMs / 1000)}s (rule scope=connection, overrides per-model lockout)`
|
||||
);
|
||||
return { shouldFallback: true, cooldownMs: connectionCooldownMs };
|
||||
}
|
||||
|
||||
const isNvidiaModelGone = provider === "nvidia" && status === 410;
|
||||
const modelLockoutOptions = { maxCooldownMs: effectiveProviderProfile?.maxCooldownMs };
|
||||
if (
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
"tests/unit/adaptive-admission-runtime.test.ts",
|
||||
"tests/unit/adobe-firefly.test.ts",
|
||||
"tests/unit/agentrouter-error-rules.test.ts",
|
||||
"tests/unit/agentrouter-lock-scope-10334.test.ts",
|
||||
"tests/unit/alibaba-free-tier-exhaustion.test.ts",
|
||||
"tests/unit/anthropic-thinking-signature-recovery.test.ts",
|
||||
"tests/unit/antigravity-429-quota-tdd.test.ts",
|
||||
|
||||
@@ -565,8 +565,8 @@
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"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"
|
||||
"nonStream": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
|
||||
"stream": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1"
|
||||
}
|
||||
},
|
||||
"baseten": {
|
||||
|
||||
@@ -11,17 +11,18 @@ import assert from "node:assert/strict";
|
||||
* Status matching accepts both the raw upstream 403 AND the restated 429
|
||||
* (upstreamStatusRestatement.ts rewrites 403→429 before classification).
|
||||
*
|
||||
* #10334 — `ProviderErrorRuleMatch.scope` is now CONSUMED for agentrouter:
|
||||
* `checkFallbackError` surfaces it as `ruleScope` on its return value (see
|
||||
* A11/A12 below), and a raw 403 is no longer an early-return dead end for
|
||||
* this provider — `honorsRuleLockScope("agentrouter")` gates a dedicated
|
||||
* pre-check that consults the provider rules BEFORE the generic apikey
|
||||
* FORBIDDEN branch (see A7/A12). This is an EXCLUSIVE allowlist
|
||||
* (`honorsRuleLockScope`, A14): every other provider's `scope` stays
|
||||
* declared-but-unconsumed exactly as before (A13). See
|
||||
* `docs/architecture/RESILIENCE_GUIDE.md` §7 for the full writeup — Tasks 2/3
|
||||
* of #10334 wire the surfaced `ruleScope` into the persistence layer
|
||||
* (markAccountUnavailable / combo target exhaustion).
|
||||
* IMPORTANT — `scope` above is what the rule DECLARES, not what production
|
||||
* enforces: `ProviderErrorRuleMatch.scope` is not consumed by
|
||||
* checkFallbackError/combo.ts today (only `reason`/`cooldownMs` are). For
|
||||
* agentrouter (passthroughModels: true → hasPerModelQuota() true), the
|
||||
* quota_exhausted match actually resolves to a PER-MODEL lockout in
|
||||
* production, not a connection-wide lock — other models on the same account
|
||||
* keep being tried by combo routing until they lock out individually. And
|
||||
* the "无权访问模型" rule never reaches production traffic at all today: it
|
||||
* only matches raw `status === 403`, but checkFallbackError's apikey
|
||||
* FORBIDDEN branch returns early for a plain 403 before any provider rule is
|
||||
* consulted (see A7). See `docs/architecture/RESILIENCE_GUIDE.md` §7 for the
|
||||
* full writeup and the tracked follow-up to honor `scope`.
|
||||
*/
|
||||
|
||||
const { providerRuleRegistry, getProviderErrorRuleMatch } = await import(
|
||||
@@ -52,7 +53,7 @@ test("A3: quota body also matches the raw (pre-restatement) 403", () => {
|
||||
assert.equal(match.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("A4: 无权访问模型 → auth_error scope model, at the RULE layer (getProviderErrorRuleMatch directly) — since #10334 this rule DOES receive production traffic for agentrouter via the honorsRuleLockScope pre-check in checkFallbackError (see A12)", () => {
|
||||
test("A4: 无权访问模型 → auth_error scope model, at the RULE layer only (getProviderErrorRuleMatch directly) — this rule never receives production traffic (see A7): checkFallbackError's apikey FORBIDDEN branch returns early for a plain 403 before reaching this rule", () => {
|
||||
const match = getProviderErrorRuleMatch("agentrouter", 403, {}, {
|
||||
error: { message: "无权访问模型 claude-sonnet-4" },
|
||||
});
|
||||
@@ -85,15 +86,14 @@ test("A6: guard — restated quota error is retryable, never terminal, and now a
|
||||
});
|
||||
|
||||
test("A7: guard — raw 403 quota (hook bypassed) is still not account-deactivation", () => {
|
||||
// Since #10334, a raw (pre-restatement) 403 for agentrouter DOES reach the
|
||||
// provider rules: checkFallbackError's honorsRuleLockScope pre-check runs
|
||||
// BEFORE the generic apikey-category FORBIDDEN branch and matches the
|
||||
// "额度不足" rule here (reason quota_exhausted, scope connection — see A11).
|
||||
// In the real pipeline, chatCore's upstreamStatusRestatement hook (Task 2)
|
||||
// still converts 403→429 before checkFallbackError sees it, so this raw-403
|
||||
// path is what a hook-bypassed request hits — and it must still not be
|
||||
// misclassified as permanent account deactivation, regardless of which
|
||||
// branch (pre-check or the old apikey-FORBIDDEN fallback) ultimately fires.
|
||||
// A raw (pre-restatement) 403 never actually reaches the agentrouter provider
|
||||
// rules in production: checkFallbackError's apikey-category FORBIDDEN branch
|
||||
// (status === 403 && getProviderCategory(provider) === "apikey") returns
|
||||
// EARLY via resolveApiKeyForbiddenFallback before the provider-rule lookup
|
||||
// is ever consulted. In the real pipeline, chatCore's upstreamStatusRestatement
|
||||
// hook (Task 2) already converts 403→429 before checkFallbackError ever sees
|
||||
// it, so this early-return path is what a hook-bypassed raw 403 hits — and it
|
||||
// must still not be misclassified as permanent account deactivation.
|
||||
const result = checkFallbackError(403, "用户额度不足", 0, null, "agentrouter", null);
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.ok(!result.permanent);
|
||||
@@ -139,39 +139,3 @@ test("A10: other providers' checkFallbackError behavior is unchanged (exclusivit
|
||||
assert.equal(result.reason, "rate_limit_exceeded");
|
||||
assert.equal(result.cooldownMs, 3000);
|
||||
});
|
||||
|
||||
test("A11: checkFallbackError surfaces ruleScope=connection for agentrouter quota", () => {
|
||||
const result = checkFallbackError(429, "用户额度不足", 0, null, "agentrouter", null);
|
||||
assert.equal(result.ruleScope, "connection");
|
||||
assert.equal(result.reason, "quota_exhausted");
|
||||
assert.ok(!result.permanent);
|
||||
});
|
||||
|
||||
test("A12: checkFallbackError 403 无权访问模型 carries the rule's scope + cooldown", () => {
|
||||
const result = checkFallbackError(403, "无权访问模型 claude-opus-5", 0, null, "agentrouter", null);
|
||||
assert.equal(result.ruleScope, "model");
|
||||
assert.equal(result.reason, "auth_error");
|
||||
assert.equal(result.baseCooldownMs, 6 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("A13: exclusivity — ruleScope stays undefined for other providers", () => {
|
||||
const opencode = checkFallbackError(
|
||||
429,
|
||||
'{"error":{"message":"organization_quota_exceeded"}}',
|
||||
0,
|
||||
null,
|
||||
"opencode",
|
||||
null
|
||||
);
|
||||
assert.equal(opencode.ruleScope, undefined);
|
||||
const openrouter = checkFallbackError(402, "credits exhausted", 0, null, "openrouter", null);
|
||||
assert.equal(openrouter.ruleScope, undefined);
|
||||
});
|
||||
|
||||
test("A14: honorsRuleLockScope allowlist is agentrouter-only", async () => {
|
||||
const { honorsRuleLockScope } = await import("../../open-sse/config/providerErrorRules.ts");
|
||||
assert.equal(honorsRuleLockScope("agentrouter"), true);
|
||||
assert.equal(honorsRuleLockScope("AgentRouter"), true);
|
||||
assert.equal(honorsRuleLockScope("opencode"), false);
|
||||
assert.equal(honorsRuleLockScope(null), false);
|
||||
});
|
||||
|
||||
@@ -1,638 +0,0 @@
|
||||
// #10334 — agentrouter EXCLUSIVE: markAccountUnavailable must honor the
|
||||
// provider rule's declared lock scope instead of always deriving it from
|
||||
// hasPerModelQuota(). agentrouter is a passthroughModels provider, so a
|
||||
// naive account-wide quota exhaustion ("额度不足") would otherwise be treated
|
||||
// as a per-model 429 and lock only ONE model, leaving combo routing to burn
|
||||
// one upstream call per remaining model of the same exhausted account. This
|
||||
// suite pins the connection-scoped cooldown behavior AND its invariants:
|
||||
// never a terminal status, must also win when the caller is combo (isCombo),
|
||||
// must not lock the model, and must be EXCLUSIVE to agentrouter — every other
|
||||
// passthroughModels/compatible provider keeps today's per-model lockout.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-agentrouter-lock-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const auth = await import("../../src/sse/services/auth.ts");
|
||||
const accountFallback = await import("../../open-sse/services/accountFallback.ts");
|
||||
const { applyComboTargetExhaustion } = await import(
|
||||
"../../open-sse/services/combo/targetExhaustion.ts"
|
||||
);
|
||||
const { classifyProviderError } = await import("../../open-sse/services/errorClassifier.ts");
|
||||
|
||||
const QUOTA_EXHAUSTED_429 = '{"error":{"message":"账户额度不足,请充值后重试"}}';
|
||||
const MODEL_ACCESS_DENIED_403 = '{"error":{"message":"无权访问模型 claude-opus-5"}}';
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function seedConnection(
|
||||
provider: string,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): Promise<string> {
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
apiKey: `${provider}-key`,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
...overrides,
|
||||
});
|
||||
return (conn as Record<string, unknown>).id as string;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("agentrouter 429 account quota exhausted -> connection cooldown, never terminal", async () => {
|
||||
await resetStorage();
|
||||
const connId = await seedConnection("agentrouter");
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connId,
|
||||
429,
|
||||
QUOTA_EXHAUSTED_429,
|
||||
"agentrouter",
|
||||
"claude-opus-5"
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.ok(result.cooldownMs > 0, "connection cooldown must be positive");
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connId);
|
||||
assert.equal(after.testStatus, "unavailable");
|
||||
assert.notEqual(after.testStatus, "credits_exhausted");
|
||||
assert.ok(after.rateLimitedUntil, "connection must carry a rateLimitedUntil");
|
||||
assert.ok(
|
||||
new Date(String(after.rateLimitedUntil)).getTime() > Date.now(),
|
||||
"rateLimitedUntil must be in the future"
|
||||
);
|
||||
});
|
||||
|
||||
test("agentrouter 429 quota exhausted with isCombo: true still cools the connection (not a model lock)", async () => {
|
||||
await resetStorage();
|
||||
const connId = await seedConnection("agentrouter");
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connId,
|
||||
429,
|
||||
QUOTA_EXHAUSTED_429,
|
||||
"agentrouter",
|
||||
"claude-opus-5",
|
||||
null,
|
||||
{ isCombo: true, persistUnavailableState: false }
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.ok(result.cooldownMs > 0);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connId);
|
||||
assert.equal(after.testStatus, "unavailable");
|
||||
assert.notEqual(after.testStatus, "credits_exhausted");
|
||||
assert.ok(after.rateLimitedUntil, "connection must be cooled down even for combo callers");
|
||||
});
|
||||
|
||||
test("agentrouter quota cooldown does NOT lock the model", async () => {
|
||||
await resetStorage();
|
||||
const connId = await seedConnection("agentrouter");
|
||||
|
||||
await auth.markAccountUnavailable(
|
||||
connId,
|
||||
429,
|
||||
QUOTA_EXHAUSTED_429,
|
||||
"agentrouter",
|
||||
"claude-opus-5"
|
||||
);
|
||||
|
||||
const lockout = accountFallback.getModelLockoutInfo("agentrouter", connId, "claude-opus-5");
|
||||
assert.equal(lockout, null, "connection-scoped quota must not also record a model lockout");
|
||||
});
|
||||
|
||||
test("agentrouter 403 model-access-denied -> model lockout, connection stays active", async () => {
|
||||
await resetStorage();
|
||||
const connId = await seedConnection("agentrouter");
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connId,
|
||||
403,
|
||||
MODEL_ACCESS_DENIED_403,
|
||||
"agentrouter",
|
||||
"claude-opus-5"
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connId);
|
||||
assert.equal(after.testStatus, "active");
|
||||
assert.ok(!after.rateLimitedUntil, "connection must not be rate-limited by a model-scoped rule");
|
||||
|
||||
// #3027's existing per-model-quota-provider branch handles this 403 (it is
|
||||
// unmodified by #10334 except that it now reads the rule's declared
|
||||
// cooldown via fallbackResult.baseCooldownMs) — the recorded reason stays
|
||||
// the pre-existing hardcoded "forbidden", not the rule's "auth_error".
|
||||
const lockout = accountFallback.getModelLockoutInfo("agentrouter", connId, "claude-opus-5");
|
||||
assert.equal(lockout?.reason, "forbidden");
|
||||
// The 6h base cooldown declared by the "agentrouter-model-access-denied"
|
||||
// rule (open-sse/config/providerErrorRules.ts) must flow through as
|
||||
// fallbackResult.baseCooldownMs instead of the generic
|
||||
// COOLDOWN_MS.serviceUnavailable (2s) default — it then gets clamped down
|
||||
// to the model-lockout maxCooldownMs setting (default 1_800_000ms / 30min)
|
||||
// by recordModelLockoutFailure, same as every other model lockout. What
|
||||
// this pins is that the rule's cooldown was consulted at all: a plain 2s
|
||||
// default would be immediately visible as a tiny remainingMs, not ~max.
|
||||
assert.ok(
|
||||
lockout && lockout.remainingMs > 1_700_000,
|
||||
`expected the rule cooldown to be clamped to ~maxCooldownMs (1_800_000ms), got ${lockout?.remainingMs}ms`
|
||||
);
|
||||
});
|
||||
|
||||
test("exclusivity: ollama-cloud with an equivalent account-wide-looking 429 keeps today's per-model lockout, no connection cooldown", async () => {
|
||||
await resetStorage();
|
||||
const connId = await seedConnection("ollama-cloud");
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connId,
|
||||
429,
|
||||
QUOTA_EXHAUSTED_429,
|
||||
"ollama-cloud",
|
||||
"claude-opus-5"
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connId);
|
||||
// ollama-cloud is NOT in the honorsRuleLockScope allowlist: today's
|
||||
// per-model-quota behavior for a 429 must be unchanged — connection stays
|
||||
// active, no rateLimitedUntil.
|
||||
assert.equal(after.testStatus, "active");
|
||||
assert.ok(!after.rateLimitedUntil, "non-agentrouter providers must not gain connection cooldown");
|
||||
|
||||
// Positive assertion, not just the negative: the model lockout must have
|
||||
// actually been recorded. Without this, a future refactor that stops
|
||||
// locking anything for these providers would pass this test silently.
|
||||
const lockout = accountFallback.getModelLockoutInfo("ollama-cloud", connId, "claude-opus-5");
|
||||
assert.ok(lockout, "expected the pre-existing per-model lockout to be recorded");
|
||||
});
|
||||
|
||||
test("exclusivity: vertex with an equivalent account-wide-looking 429 keeps today's per-model lockout, no connection cooldown", async () => {
|
||||
await resetStorage();
|
||||
const connId = await seedConnection("vertex");
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connId,
|
||||
429,
|
||||
QUOTA_EXHAUSTED_429,
|
||||
"vertex",
|
||||
"claude-opus-5"
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connId);
|
||||
assert.equal(after.testStatus, "active");
|
||||
assert.ok(!after.rateLimitedUntil, "non-agentrouter providers must not gain connection cooldown");
|
||||
|
||||
// Positive assertion, not just the negative — see the ollama-cloud case above.
|
||||
const lockout = accountFallback.getModelLockoutInfo("vertex", connId, "claude-opus-5");
|
||||
assert.ok(lockout, "expected the pre-existing per-model lockout to be recorded");
|
||||
});
|
||||
|
||||
// ─── Fix round 1 (#10334 review) ───────────────────────────────────────────
|
||||
|
||||
// Important finding: the "never terminal" invariant is not structurally
|
||||
// guaranteed by `ruleScope === "connection"` alone — it depends on the
|
||||
// provider rule table only ever pairing scope "connection" with a genuinely
|
||||
// transient reason. isAgentrouterConnectionQuotaScope() is the actual guard;
|
||||
// pin its predicate directly with synthetic fallbackResult shapes, since no
|
||||
// rule in the current table produces a permanent/credits-exhausted result
|
||||
// with scope "connection" (exercising it end-to-end would require editing
|
||||
// the production rule table just for a test).
|
||||
test("isAgentrouterConnectionQuotaScope: rejects a permanent rule result even with scope connection", () => {
|
||||
const permanentConnectionScopeResult = {
|
||||
ruleScope: "connection" as const,
|
||||
reason: "auth_error",
|
||||
permanent: true,
|
||||
};
|
||||
assert.equal(
|
||||
auth.isAgentrouterConnectionQuotaScope("agentrouter", permanentConnectionScopeResult),
|
||||
false,
|
||||
"a future permanent-state rule with scope connection must NOT take the transient-cooldown branch"
|
||||
);
|
||||
});
|
||||
|
||||
test("isAgentrouterConnectionQuotaScope: rejects a credits-exhausted rule result even with scope connection", () => {
|
||||
const creditsExhaustedConnectionScopeResult = {
|
||||
ruleScope: "connection" as const,
|
||||
reason: "quota_exhausted",
|
||||
creditsExhausted: true,
|
||||
};
|
||||
assert.equal(
|
||||
auth.isAgentrouterConnectionQuotaScope("agentrouter", creditsExhaustedConnectionScopeResult),
|
||||
false,
|
||||
"a future credits-exhausted rule with scope connection must NOT take the transient-cooldown branch"
|
||||
);
|
||||
});
|
||||
|
||||
test("isAgentrouterConnectionQuotaScope: accepts the real quota-exhausted/connection shape", () => {
|
||||
const quotaConnectionScopeResult = {
|
||||
ruleScope: "connection" as const,
|
||||
reason: "quota_exhausted",
|
||||
};
|
||||
assert.equal(
|
||||
auth.isAgentrouterConnectionQuotaScope("agentrouter", quotaConnectionScopeResult),
|
||||
true,
|
||||
"today's only connection-scope rule result (quota_exhausted, no permanent/creditsExhausted) must pass"
|
||||
);
|
||||
});
|
||||
|
||||
test("isAgentrouterConnectionQuotaScope: rejects non-agentrouter providers regardless of shape", () => {
|
||||
const quotaConnectionScopeResult = {
|
||||
ruleScope: "connection" as const,
|
||||
reason: "quota_exhausted",
|
||||
};
|
||||
assert.equal(
|
||||
auth.isAgentrouterConnectionQuotaScope("ollama-cloud", quotaConnectionScopeResult),
|
||||
false,
|
||||
"honorsRuleLockScope must still gate every provider outside the agentrouter allowlist"
|
||||
);
|
||||
});
|
||||
|
||||
// Minor finding: guard the branch's POSITION in markAccountUnavailable. If a
|
||||
// future refactor moved the branch above the terminal-status guard (~line
|
||||
// 2023) or the anti-thundering-herd guard (~line 2038), a credits_exhausted
|
||||
// connection would be silently overwritten, or a live cooldown would be
|
||||
// shortened — and the 6 tests above would stay green because none of them
|
||||
// seed a connection with pre-existing terminal/cooldown state.
|
||||
test("position guard: a connection already credits_exhausted stays terminal through an agentrouter quota 429", async () => {
|
||||
await resetStorage();
|
||||
const connId = await seedConnection("agentrouter", { testStatus: "credits_exhausted" });
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connId,
|
||||
429,
|
||||
QUOTA_EXHAUSTED_429,
|
||||
"agentrouter",
|
||||
"claude-opus-5"
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.cooldownMs, 0, "terminal-status short-circuit returns cooldownMs 0");
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connId);
|
||||
assert.equal(
|
||||
after.testStatus,
|
||||
"credits_exhausted",
|
||||
"the connection-scope branch must never overwrite a pre-existing terminal status"
|
||||
);
|
||||
});
|
||||
|
||||
test("position guard: an existing live cooldown is not shortened by the connection-scope branch", async () => {
|
||||
await resetStorage();
|
||||
const futureCooldown = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
||||
const connId = await seedConnection("agentrouter", {
|
||||
testStatus: "unavailable",
|
||||
rateLimitedUntil: futureCooldown,
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connId,
|
||||
429,
|
||||
QUOTA_EXHAUSTED_429,
|
||||
"agentrouter",
|
||||
"claude-opus-5"
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connId);
|
||||
assert.equal(
|
||||
after.rateLimitedUntil,
|
||||
futureCooldown,
|
||||
"the anti-thundering-herd guard must win: an existing live cooldown must not be reset/shortened"
|
||||
);
|
||||
});
|
||||
|
||||
// Minor finding: disableCooling=true skips the connection-scope branch (the
|
||||
// `!disableCooling` condition), so the #10334 bug survives for connections
|
||||
// with that opt-out — they fall into the ~30min per-model lockout instead of
|
||||
// the shorter connection cooldown. Documented in the block comment above the
|
||||
// branch; pin the behavior so a future change to the guard is deliberate.
|
||||
test("disableCooling=true skips the connection-scope branch and falls back to per-model lockout", async () => {
|
||||
await resetStorage();
|
||||
const connId = await seedConnection("agentrouter", {
|
||||
providerSpecificData: { disableCooling: true },
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
connId,
|
||||
429,
|
||||
QUOTA_EXHAUSTED_429,
|
||||
"agentrouter",
|
||||
"claude-opus-5"
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(connId);
|
||||
// Connection is NOT cooled down — disableCooling's documented CONNECTION-
|
||||
// level opt-out (#2997) is honored.
|
||||
assert.equal(after.testStatus, "active");
|
||||
assert.ok(!after.rateLimitedUntil, "disableCooling must keep the connection selectable");
|
||||
|
||||
// But the model IS locked out instead (the #10334 bug's exact symptom for
|
||||
// disableCooling connections — a deliberate, documented trade-off).
|
||||
const lockout = accountFallback.getModelLockoutInfo("agentrouter", connId, "claude-opus-5");
|
||||
assert.ok(
|
||||
lockout,
|
||||
"expected a per-model lockout when disableCooling bypasses the connection branch"
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Task 3 (#10334): combo skips the exhausted agentrouter connection
|
||||
// WITHIN THE SAME REQUEST ──────────────────────────────────────────────────
|
||||
// The tests above pin markAccountUnavailable's PERSISTED connection cooldown
|
||||
// — that only protects the NEXT request. applyComboTargetExhaustion (the
|
||||
// #1731/#1731v2 shared classifier both combo dispatchers call after every
|
||||
// target's upstream error — open-sse/services/combo/targetExhaustion.ts) is
|
||||
// what decides whether remaining targets of the CURRENT request are skipped.
|
||||
// Without a matching gate there, a combo with 5 legs on the same exhausted
|
||||
// agentrouter account would still burn all 5 upstream calls before the
|
||||
// persisted cooldown from the tests above ever kicks in.
|
||||
|
||||
function comboSets() {
|
||||
return {
|
||||
exhaustedProviders: new Set<string>(),
|
||||
exhaustedConnections: new Set<string>(),
|
||||
transientRateLimitedProviders: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function comboTarget(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
kind: "model",
|
||||
executionKey: "ek",
|
||||
modelStr: "agentrouter/claude-opus-5",
|
||||
provider: "agentrouter",
|
||||
providerId: null,
|
||||
connectionId: "conn-agentrouter-1",
|
||||
...overrides,
|
||||
} as Parameters<typeof applyComboTargetExhaustion>[0];
|
||||
}
|
||||
|
||||
const comboLog = { info() {}, warn() {}, error() {}, debug() {} };
|
||||
|
||||
const comboBaseOpts = {
|
||||
errorText: QUOTA_EXHAUSTED_429,
|
||||
rawModel: "claude-opus-5",
|
||||
isTokenLimitBreach: false,
|
||||
allAccountsRateLimited: false,
|
||||
requestScopedFailure: false,
|
||||
log: comboLog,
|
||||
tag: "COMBO",
|
||||
exhaustedLogLevel: "info" as const,
|
||||
};
|
||||
|
||||
// The real shape checkFallbackError surfaces for agentrouter's restated 429
|
||||
// (open-sse/config/providerErrorRules.ts's "agentrouter-user-quota-exhausted"
|
||||
// rule: reason "quota_exhausted", scope "connection") — same shape pinned by
|
||||
// isAgentrouterConnectionQuotaScope's own tests above.
|
||||
const CONNECTION_SCOPE_FALLBACK_RESULT = {
|
||||
ruleScope: "connection" as const,
|
||||
reason: "quota_exhausted",
|
||||
};
|
||||
|
||||
test("combo in-request skip: agentrouter connection-scope quota marks exhaustedConnections (#10334)", () => {
|
||||
const sets = comboSets();
|
||||
const exhausted = applyComboTargetExhaustion(comboTarget(), {
|
||||
...comboBaseOpts,
|
||||
result: { status: 429 },
|
||||
fallbackResult: CONNECTION_SCOPE_FALLBACK_RESULT,
|
||||
sets,
|
||||
});
|
||||
assert.equal(
|
||||
exhausted,
|
||||
true,
|
||||
"combo must treat this like an exhausted target — no same-target retry"
|
||||
);
|
||||
assert.ok(
|
||||
sets.exhaustedConnections.has("agentrouter:conn-agentrouter-1"),
|
||||
"the exhausted account's connection must be marked so remaining same-connection targets are skipped this request"
|
||||
);
|
||||
assert.equal(
|
||||
sets.exhaustedProviders.size,
|
||||
0,
|
||||
"must NOT exhaust the whole provider — sibling agentrouter connections keep their own quota"
|
||||
);
|
||||
// Important finding (review round 1): unlike markConnectionLevelExhaustion's
|
||||
// path, this branch must NEVER populate transientRateLimitedProviders. That
|
||||
// set drives combo.ts's `allowRateLimitedConnection` force-allow
|
||||
// (open-sse/services/combo.ts:1005-1013 and :2734-2738), which bypasses the
|
||||
// `rateLimitedUntil` filter in credential selection (src/sse/services/auth.ts:1238)
|
||||
// for the provider's remaining legs this request. Marking it here would
|
||||
// silently re-open the very connection Task 2's markAccountUnavailable (and
|
||||
// this branch) just cooled down.
|
||||
assert.equal(
|
||||
sets.transientRateLimitedProviders.size,
|
||||
0,
|
||||
"must NOT mark transientRateLimitedProviders — that would force-allow reusing the connection this branch just exhausted"
|
||||
);
|
||||
});
|
||||
|
||||
test("combo in-request skip: no connectionId falls back to whole-provider exhaustion", () => {
|
||||
const sets = comboSets();
|
||||
const exhausted = applyComboTargetExhaustion(comboTarget({ connectionId: null }), {
|
||||
...comboBaseOpts,
|
||||
result: { status: 429 },
|
||||
fallbackResult: CONNECTION_SCOPE_FALLBACK_RESULT,
|
||||
sets,
|
||||
});
|
||||
assert.equal(exhausted, true);
|
||||
assert.ok(
|
||||
sets.exhaustedProviders.has("agentrouter"),
|
||||
"no connectionId to scope to — must fall back to whole-provider, mirroring markAuthLevelExhaustion"
|
||||
);
|
||||
assert.equal(sets.exhaustedConnections.size, 0);
|
||||
});
|
||||
|
||||
test("exclusivity: an equivalent connection-scope-shaped result for ollama-cloud marks nothing (#10334 is agentrouter-only)", () => {
|
||||
const sets = comboSets();
|
||||
// Synthetic: production never actually produces ruleScope for a
|
||||
// non-allowlisted provider (honorsRuleLockScope gates it upstream inside
|
||||
// checkFallbackError) — feeding it here directly proves
|
||||
// applyComboTargetExhaustion ALSO re-checks the provider via
|
||||
// isAgentrouterConnectionQuotaScope rather than trusting whatever shape
|
||||
// it is handed.
|
||||
const exhausted = applyComboTargetExhaustion(
|
||||
comboTarget({ provider: "ollama-cloud", connectionId: "conn-ollama-1" }),
|
||||
{
|
||||
...comboBaseOpts,
|
||||
result: { status: 429 },
|
||||
fallbackResult: CONNECTION_SCOPE_FALLBACK_RESULT,
|
||||
sets,
|
||||
}
|
||||
);
|
||||
assert.equal(
|
||||
exhausted,
|
||||
false,
|
||||
"ollama-cloud must fall through to today's per-model-quota behavior unchanged"
|
||||
);
|
||||
assert.equal(sets.exhaustedConnections.size, 0);
|
||||
assert.equal(sets.exhaustedProviders.size, 0);
|
||||
});
|
||||
|
||||
test("exclusivity: vertex with the same synthetic connection-scope result marks nothing", () => {
|
||||
const sets = comboSets();
|
||||
const exhausted = applyComboTargetExhaustion(
|
||||
comboTarget({ provider: "vertex", connectionId: "conn-vertex-1" }),
|
||||
{
|
||||
...comboBaseOpts,
|
||||
result: { status: 429 },
|
||||
fallbackResult: CONNECTION_SCOPE_FALLBACK_RESULT,
|
||||
sets,
|
||||
}
|
||||
);
|
||||
assert.equal(exhausted, false);
|
||||
assert.equal(sets.exhaustedConnections.size, 0);
|
||||
assert.equal(sets.exhaustedProviders.size, 0);
|
||||
});
|
||||
|
||||
test("guard: a permanent agentrouter fallbackResult with scope connection does NOT mark the connection exhausted here either", () => {
|
||||
const sets = comboSets();
|
||||
const exhausted = applyComboTargetExhaustion(comboTarget(), {
|
||||
...comboBaseOpts,
|
||||
result: { status: 429 },
|
||||
fallbackResult: { ruleScope: "connection" as const, reason: "auth_error", permanent: true },
|
||||
sets,
|
||||
});
|
||||
assert.equal(exhausted, false);
|
||||
assert.equal(sets.exhaustedConnections.has("agentrouter:conn-agentrouter-1"), false);
|
||||
assert.equal(sets.exhaustedProviders.size, 0);
|
||||
});
|
||||
|
||||
test("guard: a credits-exhausted agentrouter fallbackResult with scope connection does NOT mark the connection exhausted here either", () => {
|
||||
const sets = comboSets();
|
||||
const exhausted = applyComboTargetExhaustion(comboTarget(), {
|
||||
...comboBaseOpts,
|
||||
result: { status: 429 },
|
||||
fallbackResult: {
|
||||
ruleScope: "connection" as const,
|
||||
reason: "quota_exhausted",
|
||||
creditsExhausted: true,
|
||||
},
|
||||
sets,
|
||||
});
|
||||
assert.equal(exhausted, false);
|
||||
assert.equal(sets.exhaustedConnections.has("agentrouter:conn-agentrouter-1"), false);
|
||||
assert.equal(sets.exhaustedProviders.size, 0);
|
||||
});
|
||||
|
||||
// Minor finding (review round 1): the connection-scope branch is NOT
|
||||
// 429-only. The "额度不足" rule (buildAgentrouterRules, providerErrorRules.ts)
|
||||
// matches statuses {400, 403, 429}, and Task 1's FORBIDDEN pre-check
|
||||
// (accountFallback.ts ~1729-1751, gated on honorsRuleLockScope) surfaces
|
||||
// `ruleScope: "connection"` for a RAW 403 carrying that body too — before the
|
||||
// generic apikey FORBIDDEN early-return, and before markAuthLevelExhaustion
|
||||
// below ever sees it. Pin that a raw 403 with this shape takes the SAME
|
||||
// connection-scope branch (not markAuthLevelExhaustion) and lands in the SAME
|
||||
// set with the SAME key — the two paths are set-equivalent for agentrouter on
|
||||
// this status, so this is not a behavior change, just documenting which
|
||||
// branch actually runs.
|
||||
//
|
||||
// Fix round 2 finding: the Set-content assertions alone (exhausted===true,
|
||||
// the connection key present, the other two sets empty) do NOT discriminate
|
||||
// which branch ran — markAuthLevelExhaustion (the 401/403 branch below)
|
||||
// produces the byte-identical Set effects for a 403 with a connectionId (same
|
||||
// key, same untouched sibling sets, same `true` return), so deleting the new
|
||||
// branch entirely would leave this test green. Use a log spy — the one real
|
||||
// observable difference between the two paths — to prove the NEW branch
|
||||
// actually fired: its message is tagged `#10334` / "account quota exhausted"
|
||||
// (markAgentrouterConnectionQuotaExhaustion), never `#8133` / "auth failure"
|
||||
// (markAuthLevelExhaustion).
|
||||
function makeLogSpy() {
|
||||
const calls: { level: string; tag: string; message: string }[] = [];
|
||||
const record = (level: string) => (tag: string, message: string) => {
|
||||
calls.push({ level, tag, message });
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
log: {
|
||||
info: record("info"),
|
||||
warn: record("warn"),
|
||||
error: record("error"),
|
||||
debug: record("debug"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("combo in-request skip: a RAW 403 with connection-scope quota also takes this branch (not markAuthLevelExhaustion)", () => {
|
||||
const sets = comboSets();
|
||||
const spy = makeLogSpy();
|
||||
const exhausted = applyComboTargetExhaustion(comboTarget(), {
|
||||
...comboBaseOpts,
|
||||
result: { status: 403 },
|
||||
fallbackResult: CONNECTION_SCOPE_FALLBACK_RESULT,
|
||||
sets,
|
||||
log: spy.log,
|
||||
});
|
||||
assert.equal(exhausted, true);
|
||||
assert.ok(
|
||||
sets.exhaustedConnections.has("agentrouter:conn-agentrouter-1"),
|
||||
"a raw 403 carrying ruleScope=connection must exhaust the connection just like the restated-429 case"
|
||||
);
|
||||
assert.equal(sets.exhaustedProviders.size, 0);
|
||||
assert.equal(
|
||||
sets.transientRateLimitedProviders.size,
|
||||
0,
|
||||
"same suppression as the 429 case — must not force-allow reusing this connection"
|
||||
);
|
||||
// The discriminant: prove the NEW (#10334) branch emitted the log, not
|
||||
// markAuthLevelExhaustion's (#8133) — the Set assertions above cannot tell
|
||||
// the two apart on their own.
|
||||
assert.equal(spy.calls.length, 1, "exactly one log call expected for this failure");
|
||||
assert.match(
|
||||
spy.calls[0].message,
|
||||
/#10334/,
|
||||
"must be markAgentrouterConnectionQuotaExhaustion's log line, not markAuthLevelExhaustion's"
|
||||
);
|
||||
assert.ok(
|
||||
/account quota exhausted/.test(spy.calls[0].message),
|
||||
"must carry the new branch's wording, not markAuthLevelExhaustion's 'auth failure'"
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
spy.calls[0].message,
|
||||
/#8133/,
|
||||
"must NOT be markAuthLevelExhaustion's log line"
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Invariant sentinel ─────────────────────────────────────────────────
|
||||
// classifyProviderError (open-sse/services/errorClassifier.ts) must NEVER
|
||||
// classify agentrouter's restated 429 body ("用户额度不足") as quota_exhausted.
|
||||
// If it ever does, open-sse/handlers/chatCore.ts's providerFailure handling
|
||||
// (~line 3835-3856) can reach the terminal `else` branch
|
||||
// (`testStatus: "credits_exhausted"`) for agentrouter whenever
|
||||
// lockModelIfPerModelQuota does not itself claim the failure — turning a
|
||||
// transient, self-recovering account-quota window into a connection that
|
||||
// requires a manual operator reset. agentrouter is an apikey-category
|
||||
// provider (not oauth), so shouldPreserveQuotaSignalsFor429 in
|
||||
// errorClassifier.ts returns false for it and the 429 branch falls through
|
||||
// to RATE_LIMITED instead — pin that this stays true.
|
||||
test("sentinel: classifyProviderError never returns quota_exhausted for agentrouter's restated 429 body", () => {
|
||||
const classification = classifyProviderError(429, "用户额度不足", "agentrouter");
|
||||
assert.notEqual(
|
||||
classification,
|
||||
"quota_exhausted",
|
||||
"a quota_exhausted classification here would route agentrouter's transient account quota into chatCore's terminal credits_exhausted branch (~chatCore.ts:3849)"
|
||||
);
|
||||
});
|
||||
@@ -329,35 +329,11 @@ 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/
|
||||
);
|
||||
// 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], /fromJSON\('\["self-hosted","omni-release"\]'\) \|\| 'ubuntu-latest'/);
|
||||
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/);
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
/**
|
||||
* #10428 — a script or test that opens the DB without setting DATA_DIR resolves to the
|
||||
* operator's REAL database (`~/.omniroute/storage.sqlite`, credentials included).
|
||||
* `tests/_setup/isolateDataDir.ts` protects the npm test scripts, but it is opt-in per
|
||||
* invocation: the AGENTS.md-documented single-file command
|
||||
* (`node --import tsx/esm --test tests/unit/x.test.ts`) does NOT load it, and neither does
|
||||
* an ad-hoc `node --import tsx probe.ts`.
|
||||
*
|
||||
* The guard therefore lives at the one place that actually opens the DB
|
||||
* (`resolveWritableDataDir`, consumed only by `src/lib/db/core.ts`): in a test context
|
||||
* pointing at the default user data dir, it redirects to a throwaway temp dir instead of
|
||||
* touching the real one. Redirecting rather than throwing keeps the documented
|
||||
* single-file command working — a hard failure there would just teach people to unset the
|
||||
* guard.
|
||||
*/
|
||||
|
||||
const { resolveWritableDataDir, getDefaultDataDir } = await import("../../src/lib/dataPaths.ts");
|
||||
|
||||
function withEnv(overrides: Record<string, string | undefined>, run: () => void) {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
saved[key] = process.env[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("G1: a test context with no DATA_DIR never resolves to the operator's real data dir", () => {
|
||||
withEnv({ DATA_DIR: undefined, NODE_ENV: "test", OMNIROUTE_ALLOW_DEFAULT_DATA_DIR: undefined }, () => {
|
||||
const resolved = resolveWritableDataDir();
|
||||
assert.notEqual(
|
||||
resolved,
|
||||
getDefaultDataDir(),
|
||||
"a test run must never be handed the operator's real DATA_DIR"
|
||||
);
|
||||
assert.ok(
|
||||
resolved.startsWith(os.tmpdir()),
|
||||
`expected a throwaway temp dir, got ${resolved}`
|
||||
);
|
||||
assert.ok(fs.existsSync(resolved), "the redirected dir must exist and be usable");
|
||||
});
|
||||
});
|
||||
|
||||
test("G2: an explicit DATA_DIR still wins inside a test context", () => {
|
||||
const explicit = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-explicit-"));
|
||||
withEnv({ DATA_DIR: explicit, NODE_ENV: "test" }, () => {
|
||||
assert.equal(resolveWritableDataDir(), explicit);
|
||||
});
|
||||
fs.rmSync(explicit, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("G3: the escape hatch restores the old behavior for deliberate runs", () => {
|
||||
withEnv(
|
||||
{ DATA_DIR: undefined, NODE_ENV: "test", OMNIROUTE_ALLOW_DEFAULT_DATA_DIR: "1" },
|
||||
() => {
|
||||
assert.equal(
|
||||
resolveWritableDataDir(),
|
||||
getDefaultDataDir(),
|
||||
"an explicit opt-in must still reach the real dir, so the intent is recorded"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("G4: a normal server run (no test markers) is untouched", () => {
|
||||
withEnv(
|
||||
{
|
||||
DATA_DIR: undefined,
|
||||
NODE_ENV: "production",
|
||||
VITEST: undefined,
|
||||
NODE_TEST_CONTEXT: undefined,
|
||||
OMNIROUTE_ALLOW_DEFAULT_DATA_DIR: undefined,
|
||||
},
|
||||
() => {
|
||||
assert.equal(
|
||||
resolveWritableDataDir(),
|
||||
getDefaultDataDir(),
|
||||
"the server must keep resolving to the real data dir"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("G5: node:test subprocesses are detected through NODE_TEST_CONTEXT too", () => {
|
||||
withEnv(
|
||||
{
|
||||
DATA_DIR: undefined,
|
||||
NODE_ENV: undefined,
|
||||
NODE_TEST_CONTEXT: "child-v8",
|
||||
OMNIROUTE_ALLOW_DEFAULT_DATA_DIR: undefined,
|
||||
},
|
||||
() => {
|
||||
const resolved = resolveWritableDataDir();
|
||||
assert.notEqual(resolved, getDefaultDataDir());
|
||||
assert.ok(resolved.startsWith(os.tmpdir()));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("G6: the redirect is stable within a process (same dir on repeated calls)", () => {
|
||||
withEnv({ DATA_DIR: undefined, NODE_ENV: "test" }, () => {
|
||||
const first = resolveWritableDataDir();
|
||||
const second = resolveWritableDataDir();
|
||||
assert.equal(first, second, "a per-call temp dir would split the DB across handles");
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* 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"
|
||||
);
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
/**
|
||||
* 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