Compare commits

..

41 Commits

Author SHA1 Message Date
Xiangzhe
aa597ab0c1 fix(security): match cookie domains by suffix, not substring
CodeQL js/incomplete-url-substring-sanitization, alerts #860 and #861:
volcengineConsoleAutoLogin accepted any cookie whose `domain` merely *contained*
"volcengine.com".

That check is an authorization decision, not a string test. The console
auto-login harvests `digest`, `AccountID`, `csrfToken` and `userInfo` out of the
Playwright context and persists them as the operator's Volcengine credentials,
so a cookie set by `volcengine.com.attacker.tld` — or `notvolcengine.com` — was
captured and stored as a provider connection.

Add `matchesCookieDomain()` (open-sse/utils/cookieDomain.ts): exact host or
dot-boundary suffix, leading dots and case normalized on both sides, failing
closed on an empty expected domain. Same shape as the existing
`isAdobeCookieDomain` in adobeFireflyBrowserLogin.ts, which already got this
right.

While sweeping the class, inAppLoginService's cookie capture had the identical
weakness — `c.domain.includes(domain.replace(/^\./, ""))` — with the identical
consequence: a look-alike host's cookie stored as the operator's credential.
CodeQL did not flag it because the expected domain comes from
TOKEN_EXTRACTION_CONFIGS rather than a literal. Both callsites now share the
helper.

tests/unit/volcengine-cookie-domain-suffix.test.ts — 5 tests, red before the
fix, covering the real domains, seven look-alikes, empty/missing input, and the
config-supplied path.
2026-08-24 15:57:16 -03:00
Diego Rodrigues de Sa e Souza
8bbe92c692 fix(docker): size the Next build worker pool for a 16 GB runner (#11419)
Every "Publish to Docker Hub" run has failed since 2026-08-22 23:14 UTC — 96 of
the last 100. The builder stage dies with:

  ERROR: failed to solve: ResourceExhausted: process "/bin/sh -c ... npm run
  build ..." did not complete successfully: cannot allocate memory

That is the kernel, not V8. The log puts it precisely: the compile phase always
finishes ("✓ Compiled successfully in 4.2min") and the build is killed right
after "Collecting page data using 7 workers".

Each page-data worker is its own process and inherits NODE_OPTIONS, so the
--max-old-space-size ceiling is per PROCESS, not per build. CIRCLE_NODE_TOTAL=8
means 7 workers, and 7 of them alongside the parent no longer fit the 16 GB /
4 vCPU GitHub-hosted runners the pipeline builds on. It was intermittent for a
while before going 100%, which is what a threshold crossed by ordinary codebase
growth looks like — 7 was also oversubscribing a 4 vCPU runner.

Lower the pool to 3 (2 workers) and make it a build arg, so a big builder can
raise it back with `--build-arg OMNIROUTE_BUILD_WORKERS=8`.

tests/unit/docker-build-memory-budget.test.ts pins the budget: it reads the two
ARG defaults out of the Dockerfile and fails if `parent heap + workers × peak`
outgrows the runner, or if the pool oversubscribes its CPUs. Red on the base
(3/3), green here (3/3). The per-worker peak it budgets with is documented as an
inference from this failure, not a measurement.

DOCKER_GUIDE's build-arg table was stale (it still listed the pre-#10060 4096 MB
default); updated and given the new knob plus the symptom to recognize.
CIRCLE_NODE_TOTAL and OMNIROUTE_BUILD_WORKERS are allowlisted in the
fabricated-docs gate with the reason: neither is read via process.env here — one
is a Dockerfile ARG, the other is read by Next itself.

Note: the real proof is the next publish run. This failure mode only reproduces
on a memory-constrained host, so it cannot be reproduced by the unit suite; the
test guards the arithmetic, not the outcome.

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-24 15:47:47 -03:00
Diego Rodrigues de Sa e Souza
bbc7bf4351 fix(authz): match exact public routes exactly, not as prefixes (#11417)
`isPublicApiRoute()` matched every entry of PUBLIC_API_ROUTE_PREFIXES with
`startsWith()`, but 11 of the 15 entries name ONE route, not a subtree. As a
prefix each also marked every adjacent path sharing its leading characters as
PUBLIC, which skips the MANAGEMENT auth gate.

That is reachable today: Next resolves `/api/usage/om-usage<anything>` to the
dynamic route `/api/usage/[connectionId]`, and that handler carries no auth of
its own — it relies entirely on being classified MANAGEMENT. An unauthenticated
caller therefore reaches `fetchAndPersistProviderLimits()`, which is an
existence oracle over connection ids (409/404/400/200) and, for a connection id
actually starting with `om-usage`, discloses live quota JSON and can drive an
OAuth token refresh (a write side effect) with no credentials.

Split the allowlist by shape:

- PUBLIC_API_ROUTE_PREFIXES keeps only genuine subtrees, every entry ending in
  "/" (asserted by a unit test, so the class cannot come back silently).
- PUBLIC_API_ROUTES_EXACT holds the single routes, matched exactly in both
  spellings.
- The three read-only "prefixes" were single routes too and move to
  PUBLIC_READONLY_CORS_API_ROUTES, matched exactly. classify.ts now asks
  `isPublicReadonlyCorsRoute()` instead of scanning the raw list, so the CORS
  origin relaxation pipeline.ts keys on cannot be inherited by a sibling either
  (`/api/monitoring/health-detail` was taking it).
- `/api/health` deliberately stays in its own set so it keeps classifying as
  `public_prefix`; folding it into the read-only set would widen CORS on it.

dashboardCsrf.ts had a second copy of the prefix scan; it now shares
`isPublicApiRoute()` so the client CSRF exemption and the server classification
cannot disagree. Side effect in the safe direction: the three LOCAL_ONLY oauth
auto-import routes were CSRF-exempt on the client while the server already
required the token — the client now attaches it.

Reported by @ntdat812 (GHSA-74g9-q8f6-793h), with the shape of the fix and the
two gotchas above called out in the report.

Closes GHSA-74g9-q8f6-793h

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
Co-authored-by: Nguyen Thanh Dat <ntdat812.dev@gmail.com>
2026-08-24 15:47:34 -03:00
Jacob Stoner
56d64e29a4 fix(dashboard): expose custom mode-pack option (#11407)
Validado em lote combinado (batch-0824f, junto de #11399/#11400/#11402) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK, 56/56 testes focados passando incluindo os deste PR (tests/unit/autocombo-unification.test.ts).

Baixo risco: expõe a opção "custom" já suportada em runtime (`getModePack("custom") === undefined`, cai de volta para os pesos explícitos dos sliders) no seletor compartilhado de mode-pack da UI. Obrigado pela contribuição!
2026-08-24 14:16:37 -03:00
Jacob Stoner
7b36e45df8 fix(dashboard): normalize explicit auto weights (#11402)
Validado em lote combinado (batch-0824f, junto de #11399/#11400/#11407) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK, 56/56 testes focados passando incluindo os deste PR (tests/unit/combo-scoring-inspector.test.ts).

Baixo risco: normaliza pesos parciais/não-unitários no inspector de diagnóstico (`comboScoringInspector.ts`) reutilizando o normalizador já existente do motor real de scoring, mantendo diagnósticos consistentes com o runtime. Obrigado pela contribuição!
2026-08-24 14:16:20 -03:00
Jacob Stoner
ddee064f1b fix(sse): preserve auto scoring order (#11400)
Validado em lote combinado (batch-0824f, junto de #11399/#11402/#11407) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK, 56/56 testes focados passando incluindo os deste PR (tests/unit/combo-task-aware.test.ts).

Remove `auto` da lista de estratégias task-routing genéricas — coerente com o #11399, que também protege a ordem já computada pelo `auto` contra reordenação por outro pós-processamento. Obrigado pela contribuição!
2026-08-24 14:16:04 -03:00
Jacob Stoner
b1fdfd5ea4 fix(sse): preserve auto-selected first target (#11399)
Validado em lote combinado (batch-0824f, junto de #11400/#11402/#11407) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK (abaixo do baseline), 56/56 testes focados passando incluindo os deste PR (tests/unit/8370-priority-affinity-reorder.test.ts).

Aditivo e coerente: protege a ordem já decidida pelo `auto` contra reordenação pelo pós-processamento de prompt-cache-affinity — mesma linha do #11400. Obrigado pela contribuição!
2026-08-24 14:15:52 -03:00
Markus Hartung
71eeaf293c fix(combo): reconcile #11360 retry-loop persisted-cooldown recheck return shape
The retry-loop recheck returned a non-conforming {ok:false, reason} object
that breaks typecheck against the established {ok, response?} contract used
everywhere else in this function. Aligns with the pre-dispatch skip pattern
(return null after fallbackCount++), matching the PR's own intent: skip this
target and move to the next, not error the whole attempt.

This is a live fix — the broken shape reached origin/release/v3.8.50 via
#11360's own squash-merge and was breaking typecheck:core until now.
2026-08-24 12:32:09 -03:00
Markus Hartung
406f4524ff chore(quality): rebaseline file-size for #11355/#11344/#11381/#11362/#11382/#11383 growth
These entries were already validated in an earlier merge-batch worktree but
never reached origin (worktree discarded before pushing). Re-adding them
here since #11355's test/route.ts growth (1215->1237) is now live on
origin/release/v3.8.50 and fails the frozen cap otherwise.
2026-08-24 12:24:40 -03:00
Markus Hartung
dfc5b5eec4 perf(providers): lazy validate provider schema on demand to reduce startup heap 2026-08-24 12:23:50 -03:00
ggdayup
0a53c8a2ce test(providers): update reserved-prefix count fixture to 391 after upstream merge
Upstream 65e81158a added new providers to the registry; the reserved set
is a full REGISTRY walk, so the pinned count moves 329 -> 391. The
tracked-artifacts pre-commit gate fails on this branch because the same
upstream commit force-tracked two docs/superpowers/ files that its own
.gitignore excludes — an inherited upstream issue unrelated to this fix,
so hooks are skipped for this fixture-only commit with operator approval.
2026-08-24 12:22:38 -03:00
ggdayup
93da24cd79 fix(providers): reject reserved provider prefixes on compatible-node create/update
A compatible node created with prefix "tokenrouter" was silently
unreachable: the runtime model resolver (src/sse/services/model.ts)
skips compatible-node lookup for built-in registry ids/aliases, so
"tokenrouter/qwen/..." routed to the built-in tokenrouter provider and
failed with "No active credentials for provider: tokenrouter" even
though the node itself worked when addressed by its internal id.

Reject reserved prefixes at the write path instead:

- new shared module src/shared/constants/reservedProviderPrefixes.ts
  (REGISTRY ids + aliases, case-sensitive, built lazily) — single
  source of truth consumed by both the runtime guard and the
  validation schemas so they can never drift apart
- createProviderNodeSchema / updateProviderNodeSchema now reject
  reserved prefixes with a clear message naming the colliding prefix
- src/sse/services/model.ts consumes the shared module; runtime
  behavior is byte-for-byte unchanged (verified e2e)

Set semantics mirror the old inline guard exactly: manual alias ids
outside REGISTRY (xiaomi/llamacpp/aq) do not intercept nodes at
runtime and stay allowed; mixed-case input (TokenRouter) does not
collide with the exact-match runtime lookup either.
2026-08-24 12:22:38 -03:00
杨思源
815c7c2864 fix(volcengine): exempt volcengine-console from the -web naming convention
Upstream added a lint test requiring every web-cookie provider ID to end
with -web. volcengine-console extracts a console session (not a chat-web
credential), so it is exempted explicitly.
2026-08-24 12:22:00 -03:00
deploy
8f15b79a84 feat(volcengine): phone/SMS auto-login for console with MFA + identity selection
- Session-based headless login service (volcengineConsoleAutoLogin)
- API: POST /connect {phone} + /code /status /cancel /resend /identity sub-routes
- Dashboard modal: phone → SMS code → MFA step-up → identity selection
- Falls back to the legacy headful manual flow on risk-control/TOTP-binding
- Route guard: connect subtree stays LOCAL_ONLY + spawn-capable
2026-08-24 12:22:00 -03:00
yangsiyuan.rengar
07a378c86c feat(volcengine): switch Agent Plan discovery to ListAgentPlanLatestModel 2026-08-24 12:21:59 -03:00
yangsiyuan.rengar
34150506f2 fix(volcengine): retain API-callable Agent Plan models 2026-08-24 12:21:59 -03:00
yangsiyuan.rengar
76ac1c8b7e feat(volcengine): live model discovery for Ark plan providers
Replace the static curated model lists for volcengine-agent-plan and
volcengine-coding-plan with live discovery from the console APIs
(GetAgentPlanModelMappingMeta / ListArkCodeLatestModel), authenticated by
the console cookie+csrf already captured at plan binding time.

- Add volcenginePlanModelDiscovery.ts: fetch + parse + capability enrichment
  (family->contextLength/vision/reasoning map, conservative default fallback).
  Console calls go through a dynamic undici import to bypass OmniRoute's
  global fetch patch (built for LLM provider traffic, reroutes console hits).
  Coding plan's ListArkCodeLatestModel needs {AccountId:<number>} extracted
  from the console cookie; agent plan's GetAgentPlanModelMappingMeta filters
  PlatformAllowStatus===true && Type==='llm'.
- Remove both plan ids from CURATED_MODEL_ONLY_PROVIDERS so synced models
  merge into /v1/models and the dashboard Sync Models button works.
- sync-models route: short-circuit to console discovery for plan providers
  (the chat API has no /models endpoint); persist via
  replaceSyncedAvailableModelsForConnection.
- volcenginePlanBinding: set autoSync:true on new plan connections so the
  24h modelSyncScheduler refreshes them automatically.
- volcPlanAutoSyncBackfill: idempotent boot-time backfill so pre-existing
  plan connections also enter the scheduler.

Verified end-to-end on local OmniRoute build against live Volcano console:
agent plan synced 7 LLMs, coding plan synced 11 models, /v1/models exposes
all of them (incl. new glm-5-3-260801 / deepseek-v4-flash-260801).
2026-08-24 12:21:59 -03:00
yangsiyuan.rengar
d732cf615d feat(volcengine): add Ark plan providers 2026-08-24 12:21:59 -03:00
Yao Lu
f58e8bef6f fix(opencode): close Muse Responses streams at completion 2026-08-24 12:21:51 -03:00
Nicolas Duran Garces
243445f210 docs(changelog): record Codex tool call fix 2026-08-24 12:21:44 -03:00
Nicolas Duran Garces
13e29f2f39 fix(translator): preserve Claude tool call state 2026-08-24 12:21:44 -03:00
Zius
2544ee9498 feat: enable Linux PATH inheritance for autostart & extend loginShellPath to Linux (#11372)
Merged via consolidated batch validation. Fixes autostart on Linux failing to inherit the user's shell PATH (CLI-dependent features like Kiro's Google OAuth broke). Resolved a conflict against a batch sibling in bin/cli/commands/doctor.mjs (kept the more complete prebuilds-aware candidate list) and setup-claude.mjs (formatting only). Own test (login-shell-path-3321.test.ts, 10/10) passes + typecheck:core clean. Thanks!
2026-08-24 12:20:02 -03:00
Prabhudutt Dash
440113c8e8 fix(dashboard): align sync interval slider ticks via magnetic checkpoints (#11394)
Merged via consolidated batch validation. Model Database sync-interval slider used two incompatible coordinate systems (evenly spaced labels vs a linear 1-168h scale); moves the slider to checkpoint-space so the thumb and labels agree. Own test passes.
2026-08-24 12:13:45 -03:00
Bob.Hou
3c2906a80e fix(sse): kill entire process tree on Linux for adobe firefly sign-in to prevent orphan browser instances (#11387)
Merged via consolidated batch validation. Fixes orphaned browser processes on Linux for Adobe Firefly sign-in: spawns Chrome as a process-group leader (detached:true) and kills -pid instead of the single PID, with self-termination guards. Own test passes.
2026-08-24 12:13:40 -03:00
Bob.Hou
095f424658 fix(sse): spare live user message across all aggressive compression sub-paths (#11386)
Merged via consolidated batch validation. Aggressive compression could collapse the live user's active prompt into a [COMPRESSED:summary] marker; now spares the last user message across all sub-paths (applyAging, fallback summarizer, caveman/lite). Own test passes.
2026-08-24 12:13:36 -03:00
Mr White
20de0d9c79 fix(usage): parse CREDIT_LIMIT rows from z.ai coding-plan quota API (#11378)
Merged via consolidated batch validation. Z.ai's quota API now returns CREDIT_LIMIT rows for GLM Coding Plan subscription keys instead of TOKENS_LIMIT, breaking the dashboard quota card. Own test passes.
2026-08-24 12:13:19 -03:00
Nguyen Thanh Dat
9f30b76057 fix(live-ws): resolve the public socket URL at runtime (#11377)
Merged via consolidated batch validation. Fixes live-ws public socket URL resolution for prebuilt Docker/npm images, where NEXT_PUBLIC_* is inlined at build time and can never carry an operator's runtime value. Own test passes.
2026-08-24 12:13:15 -03:00
Nguyen Thanh Dat
019ad33a61 fix(auth): keep the real upstream reason in lastError (#11376)
Merged via consolidated batch validation. markAccountUnavailable collapsed every non-string upstream error reason to a generic 'Provider error' literal, hiding the actual upstream detail operators need in lastError. Own test passes.
2026-08-24 12:13:11 -03:00
Nguyen Thanh Dat
dfc9257b07 fix(cli): spawn npm the way Windows needs in omniroute update (#11374)
Merged via consolidated batch validation. Fixes omniroute update on Windows (npm.cmd cannot be execFile'd without a shell on Node >=24, nodejs/node#52554). Extracts a shared bin/cli/npm-exec.mjs (also handles Bun, windowsHide) mirroring the existing server-side pattern in src/lib/services/installers/utils.ts. Own tests pass. Note: #11336 fixed the same underlying bug (#11335) with a narrower inline change; closed as duplicate crediting this more complete fix.
2026-08-24 12:13:06 -03:00
MSiva
37e71915db fix(translator): preserve functionCall id in Gemini to OpenAI request translation (#11365)
Merged via consolidated batch validation. Fixes geminiToOpenAIRequest discarding functionCall.id in favor of a random generated id, causing multi-turn tool-call id mismatches against OpenAI-compatible upstreams. Own test passes.
2026-08-24 12:13:02 -03:00
Nguyễn Viết Tuấn
077bc1a8a2 fix(compression): use pathToFileURL for workerUrl to prevent bundler resolution failure (#11364)
Merged via consolidated batch validation. Fixes Webpack/Turbopack production build failure (Module not found: compressionWorker.js) by using pathToFileURL(join(...)) instead of new URL(..., import.meta.url), which static bundler scanning misidentifies as an asset import.
2026-08-24 12:12:43 -03:00
sprintberlin
378eff0f75 fix(combo): pre-skip targets with persisted connection cooldown and re-check on retry (#11360)
Merged via consolidated batch validation, with one fix applied during batch validation: the retry-loop persisted-cooldown recheck returned a non-conforming {ok:false, reason} shape that failed typecheck against the established {ok, response?} contract — aligned it with the pre-dispatch skip pattern (return null after fallbackCount++), matching this PR's own intent (skip the target, don't error the whole attempt). Pre-skips combo targets with a persisted connection cooldown and re-checks fresh before transient retries. Own regression suite (13/13, including the fixed retry-recheck path) passes.
2026-08-24 12:12:38 -03:00
Rouzbeh†
6de542b9b6 fix(providers): mark Antigravity connects with no Cloud Code projectId as degraded (#11284) (#11358)
Merged via consolidated batch validation. Production evidence (VPS docker instance): Antigravity OAuth connects ending without a Cloud Code projectId were persisted as silently active while every model call failed; now persisted as degraded. Own tests pass.
2026-08-24 12:12:33 -03:00
sprintberlin
315b0a94e1 fix(resilience): preserve active cooldowns during recovery and probes (#11355)
Merged via consolidated batch validation (fix applied for a cross-PR interaction with #11360, both boarded in the same batch — see combo.ts reconciliation commit). Startup crash recovery cleared every non-terminal transient cooldown unconditionally, erasing legitimate multi-day weekly quota cooldowns on restart. Now only clears expired/unparseable ones. Own repro tests pass.
2026-08-24 12:12:30 -03:00
sprintberlin
e1c2b347f9 fix(quota): parse absolute ISO datetime reset timestamps in weekly quota fallback (#11353)
Merged via consolidated batch validation. Fixes GLM/Z.AI weekly quota fallback: parseDayGranularityResetMs only recognized 'reset in N days', dropping the real multi-day cooldown when upstream returns a full absolute ISO datetime. Own repro test passes.
2026-08-24 12:12:26 -03:00
Paco Cartones
f88aa48847 test(db): make exclusive-connection-lease uniqueness test self-contained (#11341)
Merged via consolidated batch validation. Test-only fix: exclusive-connection-lease uniqueness test implicitly depended on lease state from an earlier test in the same file (shared DB instance, reset only in test.after) — now self-contained. No production change.
2026-08-24 12:12:04 -03:00
Paco Cartones
8301984734 fix(i18n): complete zh-CN/zh-TW CLI locales and guard their parity (#11339)
Merged via consolidated batch validation. Completes 45 missing zh-CN/zh-TW CLI locale keys and adds a parity guard so future gaps fail CI. Own tests pass.
2026-08-24 12:12:00 -03:00
Paco Cartones
028f1b91e4 fix(release): count sweep-stale matches by their real category in the summary (#11338)
Merged via consolidated batch validation. Fixes sweep-stale-fragments.mjs miscounting: classifyFragments never actually produces matchedBy==="ref" (only "pr-number"/"text"), so the pr-number bucket was permanently 0 in the release captain's report. Own test passes.
2026-08-24 12:11:56 -03:00
stanley
2af1326adf fix(catalog): add Stealth Ox Alpha (stealth/ox-alpha) to the openrouter free roster (#11337)
Merged via consolidated batch validation. Data fix so stealth/ox-alpha becomes visible in /v1/models under hidePaidModels (synced-provider-row filter drops pricing metadata before isFreeModel; adds :free suffix handling). Own test passes.
2026-08-24 12:11:52 -03:00
Paco Cartones
644dd32d3f fix(cli): resolve tray runtime import to a file:// URL so --tray works on Windows (#11332)
Merged via consolidated batch validation. Fixes omniroute server --tray on Windows: absolute paths passed to dynamic import() are parsed as URLs, and a Windows drive letter (C:) isn't a supported URL scheme. Resolves via pathToFileURL. Own regression test passes.
2026-08-24 12:11:49 -03:00
Diego Rodrigues de Sa e Souza
9df3f8923d fix(build): stop bundling the better-sqlite3 stub at runtime (#11343) (#11391)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824e`, 27-PR batch). Critical fix: next.config.mjs unconditionally aliased better-sqlite3 to its build-time stub, but Turbopack's resolveAlias applies at RUNTIME too — every request on any build from the release/v3.8.50 tip answered HTTP 500 because the real driver was never loaded. Gates green; own tests pass.
2026-08-24 12:11:36 -03:00
130 changed files with 8409 additions and 409 deletions

View File

@@ -180,6 +180,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
### 🐛 Bug Fixes
- **fix(build):** every route no longer answers HTTP 500 on artifacts built from the release tip ([#11343](https://github.com/diegosouzapw/OmniRoute/issues/11343)) — `next.config.mjs` aliased `better-sqlite3` to its build-time stub **unconditionally**, on the premise that `serverExternalPackages` still won at runtime. It does not: a Turbopack `resolveAlias` rewrites the request *before* the externals check, so the request stopped matching the `better-sqlite3` external entry and the stub was baked into the shipped bundle. The sync driver then failed with `r(...) is not a constructor`, fell through `node:sqlite` and sql.js, and the instrumentation hook aborted at boot. Same failure shape as [#6344](https://github.com/diegosouzapw/OmniRoute/issues/6344), so it gets the same treatment: the alias is opt-in via `OMNIROUTE_BETTER_SQLITE3_STUB=1` through the shared `scripts/build/better-sqlite3-stub-flag.mjs` helper — set it only on a build host that actually hits the SIGABRT build-worker teardown ([#10060](https://github.com/diegosouzapw/OmniRoute/issues/10060)); default builds externalize the real native addon. Regression guards: `tests/unit/better-sqlite3-stub-alias-11343.test.mjs` (5) and the env matrix in `tests/unit/next-config.test.ts`.
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
- **cli**: route provider test commands through configured connection test endpoints (#10570)

View File

@@ -169,7 +169,8 @@ export async function runSetupClaudeCommand(opts = {}) {
let detail = `HTTP ${res.status}`;
try {
const errorBody = await res.json();
const serverMsg = errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
const serverMsg =
errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
if (serverMsg) detail += `${serverMsg}`;
} catch {}
throw new Error(detail);

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { t } from "../i18n.mjs";
import { npmBin, npmExecOptions } from "../npm-exec.mjs";
const execFileAsync = promisify(execFile);
@@ -31,9 +32,13 @@ export async function getCurrentVersion() {
// they were already on the latest version (#4376). `execFn` is injectable for tests.
export async function getLatestVersion(execFn = execFileAsync) {
try {
const { stdout } = await execFn("npm", ["view", "omniroute", "version", "--prefer-online"], {
timeout: 15000,
});
// argv is all literals, so enabling the shell on win32 cannot splice a
// runtime value into the command line (Hard Rule #13).
const { stdout } = await execFn(
npmBin(),
["view", "omniroute", "version", "--prefer-online"],
npmExecOptions(process.platform, { timeoutMs: 15000 })
);
return stdout.trim();
} catch {
return null;
@@ -114,9 +119,11 @@ export async function runUpdateCommand(opts = {}) {
if (showChangelog) {
try {
const { stdout } = await execFileAsync("npm", ["view", "omniroute", "changelog"], {
timeout: 10000,
});
const { stdout } = await execFileAsync(
npmBin(),
["view", "omniroute", "changelog"],
npmExecOptions(process.platform, { timeoutMs: 15000 })
);
if (stdout.trim()) {
console.log(stdout.trim());
} else {

View File

@@ -38,7 +38,8 @@
"testFailed": "提供者测试失败:{error}",
"loginEnabled": "登录:已启用(密码已更新)",
"loginDisabled": "登录:已禁用",
"providerInfo": "提供者:{info}"
"providerInfo": "提供者:{info}",
"opencode": "安装并配置随附的 @omniroute/opencode-plugin 以用于 OpenCode"
},
"doctor": {
"title": "OmniRoute 诊断",
@@ -252,7 +253,9 @@
"no_recovery": "禁用崩溃自动重启(调试模式)",
"max_restarts": "30 秒内的最大崩溃重启次数默认2",
"tray": "显示系统托盘图标(仅桌面,选择加入)",
"no_tray": "禁用系统托盘图标"
"no_tray": "禁用系统托盘图标",
"tls_cert": "用于提供 HTTPS 服务的 TLS 证书PEM路径也可用 OMNIROUTE_TLS_CERT",
"tls_key": "用于提供 HTTPS 服务的 TLS 私钥PEM路径也可用 OMNIROUTE_TLS_KEY"
},
"backup": {
"title": "备份",
@@ -1258,5 +1261,69 @@
"search": "搜索 npm 注册表中的可用插件",
"update": "更新已安装的插件",
"scaffold": "搭建新的插件模板"
},
"authExport": {
"description": "导出已解密的提供者凭据(仅限本地,明文输出)",
"idOpt": "仅导出与此 id/名称/提供者匹配的连接",
"formatOpt": "输出格式json 或 env",
"outOpt": "将输出写入文件而非标准输出(以 0600 权限写入)",
"forceOpt": "确认你了解此操作会打印/写入明文密钥",
"warning": "⚠ 此操作会打印/写入已解密的明文 API 密钥和 OAuth 令牌。请确保你的屏幕、shell 历史记录以及任何输出文件保持私密。",
"confirmHeading": "⚠ 警告:此操作会以明文导出已解密的提供者凭据",
"confirmBody": "此命令会为所选连接解密并打印/写入 apiKey、accessToken、refreshToken 和\nidToken。请将输出视为机密。",
"confirmFooter": "如需确认,请运行:\n omniroute auth export --force",
"missingKey": "导出凭据需要 STORAGE_ENCRYPTION_KEY。",
"notFound": "未找到连接:{id}",
"invalidFormat": "无效格式:{format}。请使用 json 或 env。"
},
"radar": {
"description": "检查并同步本地 Radar 目录订阅源",
"status": "显示本地 Radar 设置和订阅源缓存状态",
"sync": "通过本地服务器同步目录、推荐、优惠和 Intel"
},
"launch": {
"description": "启动指向 OmniRoute 的 Claude Code本地或远程使用 --profile",
"token": "Claude 客户端应发送的令牌ANTHROPIC_AUTH_TOKEN",
"notRunning": "无法在 {port} 访问 OmniRoute。请使用 “omniroute serve” 启动它。",
"notFound": "在 PATH 中未找到 “claude” CLI。"
},
"run": {
"description": "通过 OmniRoute 启动受支持的 CLI 目标"
},
"setupClaude": {
"description": "从 OmniRoute 模型目录生成 ~/.claude/profiles 的 Claude Code 配置文件"
},
"connect": {
"description": "连接到远程 OmniRoute 服务器并进入远程模式"
},
"tokens": {
"description": "管理限定范围的 CLI 访问令牌(远程模式)"
},
"configure": {
"description": "从活动服务器选择提供者+模型并配置受支持的本地 CLI"
},
"launchCodex": {
"description": "启动指向 OmniRoute 的 Codex CLI本地或远程 VPS"
},
"setupCodex": {
"description": "从 OmniRoute 实时模型目录生成 ~/.codex 配置文件"
},
"packs": {
"description": "管理可选的运行时包ML / 浏览器自动化)",
"listDescription": "列出可选包及其安装状态",
"installDescription": "将可选包安装到 DATA_DIR",
"verifyDescription": "根据随附的校验和索引验证已安装的包",
"removeDescription": "移除已安装的可选包",
"sourceOpt": "存放包负载和包索引的目录",
"warnNoIndex": "未找到 optional-packs.index.json —— 此检出无法进行安装/验证(桌面捆绑包会附带它)",
"errUnknown": "未知的包:{name}",
"errNoIndex": "未找到包索引;请通过 --source <dir> 传入存放包负载的目录(桌面捆绑包会将其附带在应用旁)",
"installed": "包 “{name}” 已安装并在 {dir} 验证通过",
"restartHint": "请重启 OmniRoute 服务器(或桌面应用),以便运行时加载该包",
"removed": "包 “{name}” 已移除",
"notInstalled": "包 “{name}” 未安装",
"verifyOk": "所有已安装的包均已验证通过",
"verifyFailed": "{count} 个包验证失败",
"noneInstalled": "未安装可选包"
}
}

View File

@@ -38,7 +38,8 @@
"testFailed": "提供者測試失敗:{error}",
"loginEnabled": "登入:已啟用(密碼已更新)",
"loginDisabled": "登入:已停用",
"providerInfo": "提供者:{info}"
"providerInfo": "提供者:{info}",
"opencode": "安裝並配置隨附的 @omniroute/opencode-plugin 以用於 OpenCode"
},
"doctor": {
"title": "OmniRoute 診斷",
@@ -252,7 +253,9 @@
"no_recovery": "停用崩潰自動重啟(除錯模式)",
"max_restarts": "30 秒內的最大崩潰重啟次數預設2",
"tray": "顯示系統托盤圖示(僅桌面,選擇加入)",
"no_tray": "停用系統托盤圖示"
"no_tray": "停用系統托盤圖示",
"tls_cert": "用於提供 HTTPS 服務的 TLS 憑證PEM路徑也可用 OMNIROUTE_TLS_CERT",
"tls_key": "用於提供 HTTPS 服務的 TLS 私鑰PEM路徑也可用 OMNIROUTE_TLS_KEY"
},
"backup": {
"title": "備份",
@@ -1258,5 +1261,69 @@
"search": "搜尋 npm 登錄檔中的可用外掛",
"update": "更新已安裝的外掛",
"scaffold": "搭建新的外掛模板"
},
"authExport": {
"description": "匯出已解密的提供者憑據(僅限本機,明文輸出)",
"idOpt": "僅匯出與此 id/名稱/提供者相符的連線",
"formatOpt": "輸出格式json 或 env",
"outOpt": "將輸出寫入檔案而非標準輸出(以 0600 權限寫入)",
"forceOpt": "確認你了解此操作會列印/寫入明文密鑰",
"warning": "⚠ 此操作會列印/寫入已解密的明文 API 金鑰和 OAuth 令牌。請確保你的螢幕、shell 歷史記錄以及任何輸出檔案保持私密。",
"confirmHeading": "⚠ 警告:此操作會以明文匯出已解密的提供者憑據",
"confirmBody": "此命令會為所選連線解密並列印/寫入 apiKey、accessToken、refreshToken 和\nidToken。請將輸出視為機密。",
"confirmFooter": "如需確認,請執行:\n omniroute auth export --force",
"missingKey": "匯出憑據需要 STORAGE_ENCRYPTION_KEY。",
"notFound": "找不到連線:{id}",
"invalidFormat": "無效格式:{format}。請使用 json 或 env。"
},
"radar": {
"description": "檢查並同步本機 Radar 目錄訂閱來源",
"status": "顯示本機 Radar 設定和訂閱來源快取狀態",
"sync": "透過本機伺服器同步目錄、推薦、優惠和 Intel"
},
"launch": {
"description": "啟動指向 OmniRoute 的 Claude Code本機或遠端使用 --profile",
"token": "Claude 用戶端應傳送的令牌ANTHROPIC_AUTH_TOKEN",
"notRunning": "無法在 {port} 存取 OmniRoute。請使用「omniroute serve」啟動它。",
"notFound": "在 PATH 中找不到「claude」CLI。"
},
"run": {
"description": "透過 OmniRoute 啟動受支援的 CLI 目標"
},
"setupClaude": {
"description": "從 OmniRoute 模型目錄產生 ~/.claude/profiles 的 Claude Code 配置檔"
},
"connect": {
"description": "連線到遠端 OmniRoute 伺服器並進入遠端模式"
},
"tokens": {
"description": "管理限定範圍的 CLI 存取令牌(遠端模式)"
},
"configure": {
"description": "從使用中的伺服器選擇提供者+模型並配置受支援的本機 CLI"
},
"launchCodex": {
"description": "啟動指向 OmniRoute 的 Codex CLI本機或遠端 VPS"
},
"setupCodex": {
"description": "從 OmniRoute 即時模型目錄產生 ~/.codex 配置檔"
},
"packs": {
"description": "管理可選的執行階段套件ML / 瀏覽器自動化)",
"listDescription": "列出可選套件及其安裝狀態",
"installDescription": "將可選套件安裝到 DATA_DIR",
"verifyDescription": "根據隨附的總和檢查碼索引驗證已安裝的套件",
"removeDescription": "移除已安裝的可選套件",
"sourceOpt": "存放套件負載和套件索引的目錄",
"warnNoIndex": "找不到 optional-packs.index.json —— 此檢出無法進行安裝/驗證(桌面套件會隨附它)",
"errUnknown": "未知的套件:{name}",
"errNoIndex": "找不到套件索引;請透過 --source <dir> 傳入存放套件負載的目錄(桌面套件會將其隨附在應用程式旁)",
"installed": "套件「{name}」已安裝並在 {dir} 驗證通過",
"restartHint": "請重新啟動 OmniRoute 伺服器(或桌面應用程式),以便執行階段載入該套件",
"removed": "套件「{name}」已移除",
"notInstalled": "套件「{name}」未安裝",
"verifyOk": "所有已安裝的套件均已驗證通過",
"verifyFailed": "{count} 個套件驗證失敗",
"noneInstalled": "未安裝可選套件"
}
}

34
bin/cli/npm-exec.mjs Normal file
View File

@@ -0,0 +1,34 @@
// Spawning npm from the CLI, on every platform.
//
// On Windows npm is `npm.cmd`, a batch wrapper. Node ≥ 24 refuses to spawn a
// `.cmd` without a shell (nodejs/node#52554), and a bare `npm` can additionally
// resolve to an extensionless shim that `CreateProcess` cannot execute — so the
// call fails with `EINVAL` or `ENOENT` while npm works fine in the same terminal.
// `src/lib/services/installers/utils.ts` already solves this for the server; this
// is the same rule for the `bin/cli` entry points, which cannot import TypeScript.
//
// SECURITY (Hard Rule #13): enabling the shell means the SHELL splits the command
// line, not `execFile`. Every argv element passed alongside these options must be
// a literal — never a runtime value — or it must be validated first. Callers that
// need to pass a user-supplied name have to guard it themselves.
/** The npm binary to spawn on this platform. */
export function npmBin(platform = process.platform) {
const isBun = Boolean(process.versions.bun);
if (platform === "win32") return isBun ? "bun.exe" : "npm.cmd";
return isBun ? "bun" : "npm";
}
/**
* `execFile` / `spawnSync` options for an npm call.
*
* @param {NodeJS.Platform} platform
* @param {{ timeoutMs?: number, stdio?: string }} [options]
*/
export function npmExecOptions(platform = process.platform, options = {}) {
const base = {};
if (options.timeoutMs !== undefined) base.timeout = options.timeoutMs;
if (options.stdio !== undefined) base.stdio = options.stdio;
if (platform !== "win32") return { ...base, shell: false };
return { ...base, shell: true, windowsHide: true };
}

View File

@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime");
// systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime,
@@ -16,6 +17,16 @@ export const SYSTRAY_PACKAGE = "systray2";
export const SYSTRAY_VERSION = "2.1.4";
const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`;
// Dynamic `import()` resolves its specifier as a URL, not a filesystem path.
// On Windows the lazily-installed systray2 lives at an absolute path whose
// leading drive letter the ESM loader parses as an unsupported URL scheme
// (e.g. `c:`) and rejects. Build a file:// URL so the tray import works on
// Windows too. Same defect fixed for the CLI db-fallback imports in #11238,
// missed at this call site.
export function systrayModuleSpecifier(runtimeDir: string): string {
return pathToFileURL(join(runtimeDir, "node_modules", SYSTRAY_PACKAGE)).href;
}
export function resolveSystrayBinName(platform: NodeJS.Platform): string | null {
if (platform === "win32") return "tray_windows_release.exe";
if (platform === "darwin") return "tray_darwin_release";
@@ -60,8 +71,7 @@ export async function loadSystray(): Promise<(new (...args: unknown[]) => unknow
// drop the +x bit on extraction (observed on macOS).
chmodSystrayBinAt(RUNTIME_DIR, process.platform);
try {
const modPath = join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE);
const mod = await import(modPath);
const mod = await import(systrayModuleSpecifier(RUNTIME_DIR));
return (mod.default ?? mod.SysTray ?? mod) as (new (...args: unknown[]) => unknown) | null;
} catch (err) {
console.warn(`[omniroute] tray runtime import failed: ${(err as Error).message}`);

View File

@@ -114,10 +114,13 @@ function writeLinuxSystemdUnit(cliPath) {
const unitDir = dirname(linuxSystemdUnitPath());
mkdirSync(unitDir, { recursive: true });
const envFile = join(userHomeDir(), ".omniroute", ".env");
const nodeBinDir = dirname(process.execPath);
const userLocalBin = join(userHomeDir(), ".local", "bin");
const pathEnv = `${nodeBinDir}:${userLocalBin}:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin`;
const lines = [
"[Unit]",
"Description=OmniRoute AI proxy router",
"After=network-online.target",
"After=network-online.target graphical-session.target",
"Wants=network-online.target",
"",
"[Service]",
@@ -134,6 +137,7 @@ function writeLinuxSystemdUnit(cliPath) {
`ExecStart=${buildServeExecLine(cliPath, { tray: false })}`,
"Restart=on-failure",
"RestartSec=5",
`Environment="PATH=${pathEnv}"`,
];
if (existsSync(envFile)) lines.push(`EnvironmentFile=-${envFile}`);
lines.push("", "[Install]", "WantedBy=default.target", "");

View File

@@ -0,0 +1 @@
- **fix(providers):** Antigravity OAuth marks connects with no Cloud Code projectId as degraded instead of a false "Connected"; BYOP detection at connect time, auto-disable of confirmed-missing accounts, and selection-side rotation ([#11284](https://github.com/diegosouzapw/OmniRoute/issues/11284))

View File

@@ -0,0 +1 @@
- **fix(translator):** Codex Responses tool calls translated for Claude clients no longer emit a duplicate `tool_use` block with the same ID and an empty name, preventing Claude Code from terminating with `No such tool available` ([#11347](https://github.com/diegosouzapw/OmniRoute/pull/11347))

View File

@@ -0,0 +1 @@
- **fix(dashboard):** Model Database sync interval slider ticks now match the thumb position — checkpoint-space slider with magnetic snap on release ([#11394](https://github.com/diegosouzapw/OmniRoute/pull/11394)) — thanks @An0nym0us92

View File

@@ -0,0 +1 @@
- **fix(cli):** `omniroute update` now finds npm on Windows. It called `execFile("npm", …)` with no shell, and on Node ≥ 24 a `.cmd` wrapper cannot be spawned that way (nodejs/node#52554) — while a bare `npm` can also resolve to an extensionless shim `CreateProcess` refuses. The result was `✖ Could not check latest version. Is npm available?` in a terminal where `npm view omniroute version` worked fine, so the updater was unusable on Windows even though nothing was wrong with the install. This is the same class as #5379/#5542, which fixed the server-side calls; the CLI entry points were missed because they are plain `.mjs` and cannot import the TypeScript helper. `bin/cli/npm-exec.mjs` now states the same rule for them: `npm.cmd` plus a shell on win32, no shell anywhere else. Both npm lookups in `update.mjs` (version and changelog) pass a literal argv array, so enabling the shell cannot splice a runtime value into the command line — a test asserts that and fails if a future edit interpolates one. (#11335)

View File

@@ -0,0 +1 @@
- **fix(compression):** use `pathToFileURL` in `compressionWorkerPool` so bundlers (Webpack / Turbopack) do not attempt static asset resolution of missing `compressionWorker.js` during build

View File

@@ -0,0 +1 @@
- **fix(usage):** z.ai/GLM coding-plan subscription keys now render their quota cards again, with absolute credits. Z.ai's `/api/monitor/usage/quota/limit` switched these keys from `TOKENS_LIMIT` to `CREDIT_LIMIT` rows (same `unit`/`number` semantics: unit=3/number=5 → 5-hour window, unit=6/number=1 → weekly), and the parser only matched `TOKENS_LIMIT`/`TIME_LIMIT`, so both rows were dropped and the subscription card rendered empty. `CREDIT_LIMIT` is now accepted alongside `TOKENS_LIMIT`, and when the row carries absolute credit fields (`usage`/`currentValue`/`remaining`) they are preferred over the percent-only scale, so the card shows `3341 / 28000` like z.ai's own dashboard instead of `11 / 100`

View File

@@ -0,0 +1 @@
- **fix(auth):** a connection's `lastError` now names the real upstream failure instead of the bare string `Provider error`. `markAccountUnavailable` kept the reason only when it was already a string, so every other shape collapsed to that literal — and the shape that matters most is not a string: a failed `fetch` arrives as `TypeError: fetch failed` with the actionable part on `error.cause.code`, which means a wrong port, a firewall, a DNS failure and a blocked proxy all looked identical in the dashboard and in the console line. `describeUpstreamFailure` (in `src/shared/utils/upstreamError.ts`, reusing the `extractErrorMessage` that already parsed provider bodies) reads Error messages and appends the transport code when the message does not already carry it, reads the usual provider JSON shapes (`error.message`, `message`, string `error`, `detail`, `errors[]`), and falls back to the code alone before giving up. It never serializes the error object wholesale, so a request body or header attached to an error cannot leak into the stored reason — pinned by a test.

View File

@@ -0,0 +1 @@
- **fix(live-ws):** the Live dashboard socket can now be pointed at a reverse proxy without rebuilding the image. `NEXT_PUBLIC_*` is inlined at BUILD time, so a prebuilt Docker or npm image never carries an operator's `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` — which is exactly why the browser discovers the socket through `/api/v1/ws?handshake=1` instead. The server side of that handshake, however, read only the `NEXT_PUBLIC_`-prefixed name, so it had nothing to echo: behind Traefik the dashboard kept dialling `wss://<host>:20132/live-ws` and sat on "Live disabled — WebSocket disconnected. Showing last known state." `LIVE_WS_PUBLIC_URL` is now read at runtime alongside the existing `LIVE_WS_HOST` / `LIVE_WS_PORT`, and the prefixed name stays supported as the fallback, so deployments that already set it are unaffected. Only `ws://` and `wss://` values are accepted, matching the guard the client already applies. (#11331)

View File

@@ -227,7 +227,9 @@
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1604,
"tests/unit/usage-service-hardening.test.ts": 1928,
"tests/unit/vscode-token-routes.test.ts": 1633,
"tests/unit/executor-antigravity.test.ts": 1427
"tests/unit/executor-antigravity.test.ts": 1427,
"tests/unit/guardrails/videoBridgeResultCache.test.ts": 1040,
"_rebaseline_2026_08_24_video_bridge_fu01_fu03_fu04_result_cache_tests": "PRs #11362 (FU-01 cache hardening) + #11382 (FU-03 visual dedup policy identity) + #11383 (FU-04 focused analysis mode) own test growth: videoBridgeResultCache.test.ts <1000->1040, +40 (sum of three stacked PRs boarded together in the same merge-batch, each adding its own cache-identity assertions on the shared result-cache seam). Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive)."
},
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
"_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.",
@@ -308,7 +310,7 @@
"_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)",
"frozen": {
"_rebaseline_2026_08_20_10878_10799_provider_health_probes": "PRs #10878 (unsupported OpenAI-like validation probes stay neutral) + #10799 (preserve credential health on inconclusive NVIDIA-timeout/Antigravity-400 probes) own growth: src/app/api/providers/[id]/test/route.ts 946->1025 (+79, sum of both boarded together). Both add narrowly-scoped classification branches at the existing test-route dispatch chokepoint (unsupported-capability skip, credential-inconclusive detection) rather than new files, mirroring the prior 2026_06_27_5193 rebaseline of the same file. Covered by tests/unit/provider-validation-unsupported-neutral.test.ts + tests/unit/provider-health-inconclusive-probes.test.ts.",
"src/app/api/providers/[id]/test/route.ts": 1215,
"src/app/api/providers/[id]/test/route.ts": 1237,
"_rebaseline_2026_08_23_11141_oauth_400_recovery": "PR #11141 (HouMinXi) own growth: test/route.ts 1025->1215 (+190, the reactive-400 recovery path — a fully rebuilt probe for refresh+retry on refreshable non-rotating connections, with inconclusive-status preservation and rotating-provider exclusion; all growth is the new probe builder + guards at the existing test-route dispatch, extraction would split the retry flow mid-logic). Covered by tests/unit/oauth-400-recovery.test.ts (8, bug-injection proof). Owner pre-authorized baseline bumps 2026-08-22.",
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
@@ -433,7 +435,8 @@
"src/shared/components/analytics/charts.tsx": 1346,
"src/shared/services/cliRuntime.ts": 1459,
"src/sse/handlers/chat.ts": 2493,
"src/sse/services/auth.ts": 3344,
"src/sse/services/auth.ts": 3346,
"_rebaseline_2026_08_24_lasterror_provider_error_detail": "PR (ntdat812) own growth: src/sse/services/auth.ts 3344->3346 (+2). One line is the import of describeUpstreamFailure from @/shared/utils/upstreamError, which replaces the string-only collapse `typeof errorText === \"string\" ? errorText.slice(0, 100) : \"Provider error\"` at the single markAccountUnavailable chokepoint (net 0 lines there) — the logic itself lives in upstreamError.ts, next to the extractErrorMessage it reuses, so nothing else moved into this file. The second line is the repo's own lint-staged prettier pass splitting a pre-existing two-statements-on-one-line at getProviderCredentials (`invalidateManagedLease(...); log.warn(...)`); it re-applies on any commit that touches this file, so it is not separable from the change. Covered by tests/unit/provider-error-detail-lastError.test.ts.",
"_rebaseline_2026_08_23_11186_synced_inventory_routing": "PR #11186 (pacocartones) own growth: src/sse/services/auth.ts 3260->3337 (+77, loadAdvertisedModelsForSelfHostedConnections + the modelNotAdvertised candidate-filter predicate — pins chat routing to the connection whose synced inventory actually advertises the model, fixing spurious model-not-found on multi-host self-hosted setups; at the existing credential-selection chokepoint, not extractable without splitting the selection flow). Covered by tests/unit/chat-routing-synced-inventory-11089.test.ts. Owner pre-authorized baseline bumps 2026-08-22.",
"tests/unit/account-fallback-service.test.ts": 2044,
"tests/unit/provider-validation-specialty.test.ts": 3880,
@@ -472,7 +475,10 @@
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).",
"_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts.",
"_rebaseline_2026_08_23_tip_drift_post_batch0823": "Tip drift after the 2026-08-23 merge wave: chatBodyAdmission.ts 1009->1118 (+109, gate count incl. +1) and auth.ts 3337->3344 (+7), both grown by merges already on origin/release/v3.8.50 (verified identical on the pristine tip) — not by the codex-appserver-hardening PR that carries this bump. Owner pre-authorized baseline bumps 2026-08-22."
"_rebaseline_2026_08_23_tip_drift_post_batch0823": "Tip drift after the 2026-08-23 merge wave: chatBodyAdmission.ts 1009->1118 (+109, gate count incl. +1) and auth.ts 3337->3344 (+7), both grown by merges already on origin/release/v3.8.50 (verified identical on the pristine tip) — not by the codex-appserver-hardening PR that carries this bump. Owner pre-authorized baseline bumps 2026-08-22.",
"_rebaseline_2026_08_24_11355_cooldown_recovery_guards": "PR #11355 own growth: test/route.ts 1215->1237, +22 (startup crash-recovery guard: clearStaleCrashCooldowns() now parses the persisted rate_limited_until deadline and skips clearing rows still genuinely in the future, instead of clearing every non-terminal cooldown unconditionally). Cohesive fix at the existing test-route dispatch chokepoint alongside the #11141 probe builder. Covered by tests/unit/startup-stale-cooldown-recovery.test.ts + tests/unit/repro-zai-cooldown-cleared-by-connection-test.test.ts.",
"src/lib/guardrails/videoBridgeRuntime.ts": 1009,
"_rebaseline_2026_08_24_video_bridge_fu02_fu07_sampler": "PRs #11344 (FU-02 one-frame scene-aware determinism) + #11381 (FU-07 opt-in segment_aware structural sampling) own growth: videoBridgeRuntime.ts <1000->1009, +9 (sum of both boarded together in the same merge-batch). #11344 adds the deterministic one-frame midpoint fallback + policyEffective=uniform report at the existing scene_aware seam; #11381 adds the bounded local-only FFmpeg structural pre-analysis pass (scene/freeze/blur/exposure/SI-TI) and its budget-reallocation logic. Covered by tests/unit/guardrails/videoBridgeSampler.test.ts, tests/unit/guardrails/videoBridgeFu07StructuralSampling.test.ts, tests/integration/video-bridge-sampler-ffmpeg.test.ts. Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive)."
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",

View File

@@ -108,24 +108,48 @@ A successful policy returns `AuthSubject` with `kind ∈ { client_api_key, dashb
`src/shared/constants/publicApiRoutes.ts` is the explicit allowlist:
The list is split by **shape**, and the split is load-bearing (GHSA-74g9-q8f6-793h): a prefix is
matched with `startsWith()`, so it also matches every adjacent path sharing its leading characters.
`/api/usage/om-usage` as a prefix marked `/api/usage/om-usage<anything>` PUBLIC, and Next resolves
that to `/api/usage/[connectionId]` — a handler with no auth of its own.
```ts
// Genuine subtrees. Every entry MUST end in "/" (asserted by a unit test).
PUBLIC_API_ROUTE_PREFIXES = [
"/api/auth/oidc/",
"/api/v1/", // treated as CLIENT_API in classify, not as "no-auth public"
"/api/oauth/",
"/api/codex/connect/",
"/api/telegram/",
"/api/cursor-cli/",
];
// Single routes, matched EXACTLY (with or without a trailing slash).
PUBLIC_API_ROUTES_EXACT = new Set([
"/api/auth/login",
"/api/auth/logout",
"/api/auth/status",
"/api/init",
"/api/v1/", // treated as CLIENT_API in classify, not as "no-auth public"
"/api/cloud/",
"/api/sync/bundle",
"/api/oauth/",
"/api/cli/connect",
"/api/usage/om-usage",
"/api/skills/collect/chaos",
]);
// Read-only single routes that also take the CORS origin relaxation.
PUBLIC_READONLY_CORS_API_ROUTES = [
"/api/health/ping",
"/api/monitoring/health",
"/api/settings/require-login",
];
PUBLIC_READONLY_API_ROUTE_PREFIXES = ["/api/monitoring/health", "/api/settings/require-login"];
// Read-only single route WITHOUT the CORS relaxation.
PUBLIC_READONLY_API_ROUTES_EXACT = new Set(["/api/health"]);
PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
```
Read-only prefixes are public **only** for safe methods. Note: `classifyRoute()` excludes `/api/v1/*` and `/api/v1beta/*` from the PUBLIC fall-through — those are always `CLIENT_API` so the Bearer-key policy still applies.
Read-only routes are public **only** for safe methods. Note: `classifyRoute()` excludes `/api/v1/*` and `/api/v1beta/*` from the PUBLIC fall-through — those are always `CLIENT_API` so the Bearer-key policy still applies.
## Adding a New Route
@@ -168,7 +192,7 @@ export async function POST(request: Request) {
### Pattern 3 — Adding to the public allowlist
Add the prefix to `PUBLIC_API_ROUTE_PREFIXES` (or `PUBLIC_READONLY_API_ROUTE_PREFIXES` for GET-only). Update unit tests at `tests/unit/public-api-routes.test.ts` and `tests/unit/authz/classify.test.ts`.
Pick the set by shape, not by convenience. One route goes in `PUBLIC_API_ROUTES_EXACT` (or `PUBLIC_READONLY_CORS_API_ROUTES` for GET-only); only a genuine subtree goes in `PUBLIC_API_ROUTE_PREFIXES`, and it **must end in `/`**. Putting a single route in the prefix list also publishes every adjacent path that shares its leading characters — including dynamic-segment siblings added later (GHSA-74g9-q8f6-793h). Update unit tests at `tests/unit/public-api-routes.test.ts`, `tests/unit/authz/public-route-exact-match.test.ts` and `tests/unit/authz/classify.test.ts`.
## Scopes

View File

@@ -2,6 +2,7 @@ import createNextIntlPlugin from "next-intl/plugin";
import { createMDX } from "fumadocs-mdx/next";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { betterSqlite3AliasFor } from "./scripts/build/better-sqlite3-stub-flag.mjs";
import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs";
import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs";
import {
@@ -138,10 +139,14 @@ const nextConfig = {
// the stub to every npm/Electron/VPS artifact and broke Agent Bridge
// start for all non-Docker users (#6344). See scripts/build/mitm-stub-flag.mjs.
...mitmManagerAliasFor(process.env),
// Build-time stub so the bundler never traces the native better-sqlite3
// addon into a build worker (SIGABRT at worker teardown). Runtime still
// uses the real package via serverExternalPackages. (#10060)
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
// better-sqlite3 → build-time stub ONLY where the build worker actually
// aborts while tracing the native addon (SIGABRT at worker teardown,
// #10060); opt in with OMNIROUTE_BETTER_SQLITE3_STUB=1. The alias used to
// be unconditional on the premise that serverExternalPackages still won
// at runtime — it does not: resolveAlias rewrites the request before the
// externals check, so the stub was bundled and EVERY route answered 500
// (#11343). See scripts/build/better-sqlite3-stub-flag.mjs.
...betterSqlite3AliasFor(process.env),
...minimalBuildAliases,
},
// src/lib/agentSkills/generator.ts builds its fs base path from a runtime

View File

@@ -16,7 +16,7 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
* rewrites file timestamps on every deploy, which would report a months-old
* catalog as "updated today". Bump this whenever the entries below change.
*/
export const FREE_CATALOG_CURATED_AT = "2026-08-18";
export const FREE_CATALOG_CURATED_AT = "2026-08-20";
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "chatgpt-web", modelId: "gpt-5.6-luna-free", displayName: "GPT-5.6 Luna (Free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" },
@@ -318,6 +318,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "opencode-zen", modelId: "opencode/north-mini-code-free", displayName: "North Mini Code (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "opencode-zen-free", tos: "caution" },
{ provider: "opencode-zen", modelId: "opencode/nemotron-3-ultra-free", displayName: "Nemotron 3 Ultra (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "opencode-zen-free", tos: "caution" },
{ provider: "openrouter", modelId: "auto", displayName: "Auto (Best Available)", monthlyTokens: 1200000, creditTokens: 0, freeType: "recurring-daily", poolKey: "openrouter-free", tos: "caution" },
{ provider: "openrouter", modelId: "stealth/ox-alpha", displayName: "Stealth Ox Alpha (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "openrouter-free", tos: "caution" },
{ provider: "pollinations", modelId: "openai", displayName: "OpenAI (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },
{ provider: "pollinations", modelId: "openai-fast", displayName: "OpenAI Fast (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },
{ provider: "pollinations", modelId: "openai-large", displayName: "OpenAI Large (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },

View File

@@ -70,6 +70,8 @@ import { togetherProvider } from "./registry/together/index.ts";
import { cohereProvider } from "./registry/cohere/index.ts";
import { cursorProvider, cursor_apiProvider } from "./registry/cursor/index.ts";
import { volcengineProvider } from "./registry/volcengine/index.ts";
import { volcengine_agent_planProvider } from "./registry/volcengine/agent-plan/index.ts";
import { volcengine_coding_planProvider } from "./registry/volcengine/coding-plan/index.ts";
import { freetheaiProvider } from "./registry/freetheai/index.ts";
import { g4f_groqProvider } from "./registry/g4f-groq/index.ts";
import { g4f_geminiProvider } from "./registry/g4f-gemini/index.ts";
@@ -337,6 +339,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
cursor: cursorProvider,
"cursor-api": cursor_apiProvider,
volcengine: volcengineProvider,
"volcengine-agent-plan": volcengine_agent_planProvider,
"volcengine-coding-plan": volcengine_coding_planProvider,
freetheai: freetheaiProvider,
"g4f-groq": g4f_groqProvider,
"g4f-gemini": g4f_geminiProvider,

View File

@@ -0,0 +1,115 @@
import type { RegistryEntry, RegistryModel } from "../../../shared.ts";
/**
* Volcano Ark Agent Plan models.
*
* The Agent Plan subscription (console.volcengine.com/ark/subscription/agent-plan)
* is served by the Plan API endpoint — `/api/plan/v3` — which differs from both the
* standard pay-per-use API (`/api/v3`) and the Coding Plan API (`/api/coding/v3`).
* The Plan API has NO `/models` listing endpoint (returns 404); key validation falls
* back to a chat probe against the first model. Model IDs below verified live against
* /api/plan/v3/chat/completions (all return 200).
*/
export const VOLCENGINE_AGENT_PLAN_MODELS: RegistryModel[] = [
{
id: "doubao-seed-evolving",
name: "Doubao Seed Evolving (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "doubao-seed-2-1-turbo-260628",
name: "Doubao Seed 2.1 Turbo (Agent Plan)",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "doubao-seed-2-0-lite-260215",
name: "Doubao Seed 2.0 Lite (Agent Plan)",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "doubao-seed-2-0-mini-260215",
name: "Doubao Seed 2.0 Mini (Agent Plan)",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "deepseek-v4-flash-ga-260731",
name: "DeepSeek V4 Flash GA (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "kimi-k3",
name: "Kimi K3 (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "glm-5-2-260617",
name: "GLM 5.2 (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "minimax-m3",
name: "MiniMax M3 (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "deepseek-v4-pro-260425",
name: "DeepSeek V4 Pro (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "minimax-m2.7",
name: "MiniMax M2.7 (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "kimi-k2.6",
name: "Kimi K2.6 (Agent Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
];
export const volcengine_agent_planProvider: RegistryEntry = {
id: "volcengine-agent-plan",
alias: "veap",
format: "openai",
executor: "default",
baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: VOLCENGINE_AGENT_PLAN_MODELS,
};

View File

@@ -0,0 +1,92 @@
import type { RegistryEntry, RegistryModel } from "../../../shared.ts";
/**
* Volcano Ark Coding Plan models.
*
* The Coding Plan subscription (console.volcengine.com/ark/subscription/coding-plan)
* is served by a DEDICATED endpoint — `/api/coding/v3` — which differs from both the
* standard pay-per-use API (`/api/v3`) and the Agent Plan API (`/api/plan/v3`). Using
* the wrong base URL returns HTTP 401 "The API key or AK/SK ... is missing or invalid"
* even with a valid Coding Plan key. Model IDs below verified live against
* /api/coding/v3/chat/completions (all return 200).
*/
export const VOLCENGINE_CODING_PLAN_MODELS: RegistryModel[] = [
{
id: "doubao-seed-2-1-turbo",
name: "Doubao Seed 2.1 Turbo (Coding Plan)",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "doubao-seed-2.0-lite",
name: "Doubao Seed 2.0 Lite (Coding Plan)",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "glm-5.2",
name: "GLM 5.2 (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
id: "minimax-m3",
name: "MiniMax M3 (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "minimax-m2.7",
name: "MiniMax M2.7 (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
{
id: "kimi-k2.6",
name: "Kimi K2.6 (Coding Plan)",
contextLength: 1048576,
toolCalling: true,
supportsReasoning: true,
},
];
export const volcengine_coding_planProvider: RegistryEntry = {
id: "volcengine-coding-plan",
alias: "vecp",
format: "openai",
executor: "default",
baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: VOLCENGINE_CODING_PLAN_MODELS,
modelsUrl: "/models",
};

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import {
BaseExecutor,
type ExecuteInput,
@@ -10,7 +11,7 @@ import {
injectReasoningContentForThinkingModel,
isThinkingMessageModel,
} from "../utils/reasoningContentInjector.ts";
import { runWithProxyContext } from "../utils/proxyFetch.ts";
import { runWithDirectFetchContext, runWithProxyContext } from "../utils/proxyFetch.ts";
import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
import {
type AccountProxyConfig,
@@ -245,6 +246,17 @@ export function createMuseSparkStreamFinishNormalizer(
};
}
function isResponsesTerminalLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return false;
try {
const payload = JSON.parse(trimmed.slice(5).trim()) as Record<string, unknown>;
return payload.type === "response.completed";
} catch {
return false;
}
}
export class OpencodeExecutor extends BaseExecutor {
/** Delegates to `isPremiumOpencodeModel`. Exported for testability. */
static isPremiumModel(model: string, provider: string): boolean {
@@ -384,24 +396,51 @@ export class OpencodeExecutor extends BaseExecutor {
const encoder = new TextEncoder();
let buffer = "";
const reader = response.body.getReader();
let closed = false;
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
async start(controller) {
try {
const { done, value } = await reader.read();
if (done) {
if (buffer.length > 0) controller.enqueue(encoder.encode(normalizer(buffer)));
controller.close();
return;
while (!closed) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
if (buffer.length > 0 && !closed) {
controller.enqueue(encoder.encode(normalizer(buffer)));
}
if (!closed) {
closed = true;
controller.close();
}
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const normalized = normalizer(line);
controller.enqueue(encoder.encode(normalized + "\n"));
if (isResponsesTerminalLine(line)) {
// OpenCode Zen sends a ping after response.completed and may keep
// the HTTP connection alive. The Responses terminal event is
// authoritative; do not let those post-completion pings hold Chat
// Completions open.
closed = true;
void reader.cancel().catch(() => undefined);
controller.close();
return;
}
}
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) controller.enqueue(encoder.encode(normalizer(line) + "\n"));
} catch (err) {
controller.error(err);
if (!closed) {
closed = true;
controller.error(err);
}
}
},
cancel(reason) {
closed = true;
reader.cancel(reason).catch(() => undefined);
},
});
@@ -450,7 +489,10 @@ export class OpencodeExecutor extends BaseExecutor {
// 200s ("Provider returned empty content"). Raise tiny budgets to the
// floor before dispatch (see MUSE_SPARK_MIN_OUTPUT_TOKENS).
if (input.body && typeof input.body === "object" && !Array.isArray(input.body)) {
applyMuseSparkMinOutputTokens(String(input.model ?? ""), input.body as Record<string, unknown>);
applyMuseSparkMinOutputTokens(
String(input.model ?? ""),
input.body as Record<string, unknown>
);
}
this.syncAccountsFromCredentials(input.credentials);
@@ -463,7 +505,9 @@ export class OpencodeExecutor extends BaseExecutor {
// else passes untouched: this path deliberately preserves BaseExecutor's
// intra-URL 429 retries (no skipUpstreamRetry here).
if (this.accounts.length === 1 && !hasProxies) {
const single = (await super.execute(input)) as HttpExecuteResult;
const single = (await runWithDirectFetchContext(() =>
super.execute(input)
)) as HttpExecuteResult;
if (single.response.status === 400) {
let bodyText: string | null = null;
try {
@@ -630,10 +674,7 @@ export class OpencodeExecutor extends BaseExecutor {
}
// All accounts returned 429 (or errored) — surface the last response.
return this.normalizeMuseSparkResponse(
input,
lastResult ?? (await super.execute(input))
);
return this.normalizeMuseSparkResponse(input, lastResult ?? (await super.execute(input)));
} finally {
this._requestFormat = null;
}
@@ -735,6 +776,18 @@ export class OpencodeExecutor extends BaseExecutor {
});
}
// Muse's Responses endpoint rejects the short conversation fingerprint used
// by the Chat endpoint in practice. Keep the workaround scoped to Muse.
if (
this._requestFormat === "openai-responses" &&
model.startsWith("muse-spark") &&
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
headers["x-opencode-session"] || ""
)
) {
headers["x-opencode-session"] = randomUUID();
}
void model;
return headers;

View File

@@ -1008,27 +1008,63 @@ async function captureViaCdp(opts: {
}
}
function killProcessTree(child: ChildProcess | null): void {
/**
* Terminate a spawned browser process and all of its descendants.
*
* Windows uses `taskkill /pid <pid> /T /F` to walk the process tree and terminate descendants.
* Linux/POSIX sends SIGTERM/SIGKILL to the process group (`-pid`) when detached/group leader,
* falling back to direct child kill if the process group is unavailable.
*/
export function killProcessTree(
child:
| ChildProcess
| { pid?: number; kill?: (signal?: NodeJS.Signals | number | string) => boolean | void }
| null
| undefined,
options?: {
platform?: string;
processKill?: (pid: number, signal?: NodeJS.Signals | string) => void;
spawnFn?: typeof spawn;
}
): void {
if (!child?.pid) return;
const pid = child.pid;
// Never taskkill our own Node/pkg process or its parent (would kill the backend mid-login).
if (pid === process.pid || (typeof process.ppid === "number" && pid === process.ppid)) {
return;
}
const platform = options?.platform || process.platform;
const processKill = options?.processKill || process.kill.bind(process);
const spawnFn = options?.spawnFn || spawn;
try {
if (process.platform === "win32") {
if (platform === "win32") {
// /T kills only this PID's descendants — not system Chrome profiles we did not spawn.
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
const killer = spawnFn("taskkill", ["/pid", String(pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
detached: true,
});
killer.unref?.();
killer?.unref?.();
} else {
child.kill("SIGTERM");
let killedGroup = false;
try {
processKill(-pid, "SIGTERM");
killedGroup = true;
} catch {
try {
child.kill?.("SIGTERM");
} catch {
/* ignore */
}
}
setTimeout(() => {
try {
child.kill("SIGKILL");
if (killedGroup) {
processKill(-pid, "SIGKILL");
} else {
child.kill?.("SIGKILL");
}
} catch {
/* ignore */
}
@@ -1036,7 +1072,7 @@ function killProcessTree(child: ChildProcess | null): void {
}
} catch {
try {
child.kill();
child.kill?.();
} catch {
/* ignore */
}
@@ -1175,12 +1211,15 @@ async function runAdobeFireflyCdpBrowser(opts: {
// detach so a long Forter wait does not pin the Node process refcount.
// Host job SILENT_BREAKAWAY_OK still prevents Chrome from joining the backend job
// (that was killing/wedging VibeProxyServices on Sign in with browser).
// On POSIX: detached creates a new process group leader so killProcessTree(-pid)
// can terminate Chrome and all its child processes (zygote/renderer/GPU).
const isDetached = process.platform !== "win32" || !opts.interactive;
child = spawn(browserPath, args, {
stdio: "ignore",
// Interactive sign-in: show Chrome. Background warm: hide spawn console/window
// host; headless flags already suppress the browser UI.
windowsHide: !opts.interactive,
detached: !opts.interactive,
detached: isDetached,
});
if (!opts.interactive) {
try {

View File

@@ -52,8 +52,23 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
const projectId = (psd as Record<string, unknown>).projectId;
return typeof projectId === "string" && projectId.trim().length > 0;
};
const withStoredProject = connections.filter(hasStoredProject);
return withStoredProject.length > 0 ? withStoredProject : connections;
// #11284: rows whose missing Cloud Code project was CONFIRMED at request
// time (errorCode="missing_project_id") are dead weight — drop them when a
// healthier sibling exists. When every row is confirmed missing, keep the
// pool so the typed 422 (not an empty-selection 404) explains what to fix.
const hasHealthySibling = (connection: T): boolean =>
connections.some(
(other) => other !== connection && other.errorCode !== "missing_project_id"
);
const candidates = connections.filter(
(connection) =>
connection.errorCode !== "missing_project_id" ||
!hasHealthySibling(connection) ||
!hasStoredProject(connection)
);
const withStoredProject = candidates.filter(hasStoredProject);
if (withStoredProject.length > 0) return withStoredProject;
return candidates.length > 0 ? candidates : connections;
}
export async function persistDiscoveredAntigravityProjectId(

View File

@@ -64,6 +64,11 @@ export function persistDiscoveredAntigravityProjectId(
errorCode: null,
lastError: null,
lastErrorType: null,
// #11284: a discovered project proves the account is usable again —
// re-enable it (markAntigravityMissingCloudCodeProject may have disabled
// it after a confirmed-missing 422).
isActive: true,
testStatus: "active",
providerSpecificData,
})
.catch(() => {})
@@ -77,7 +82,14 @@ export function markAntigravityMissingCloudCodeProject(
): void {
if (!connectionId) return;
// #11284: a CONFIRMED missing Cloud Code project is not transient — disable
// the row so selection rotates to healthy siblings instead of re-dispatching
// into the same 422 every request. "unavailable" is deliberately NOT a
// terminal status: persistDiscoveredAntigravityProjectId() re-enables the
// account the moment a project shows up at request time.
void updateProviderConnection(connectionId, {
isActive: false,
testStatus: "unavailable",
errorCode: "missing_project_id",
lastError:
"Missing Google projectId for Antigravity account. Reconnect OAuth after completing Gemini Code Assist onboarding.",

View File

@@ -210,12 +210,16 @@ import {
normalizeConnectionStatus,
hasFutureRateLimitUntil,
getConnectionStatusQuotaCutoffReason,
getPersistedConnectionCooldownSkipReason,
resolvePersistedConnectionCooldownSkipReason,
isContextOverflow400,
isParamValidation400,
isModelScoped400,
} from "./combo/comboPredicates.ts";
export {
getConnectionStatusQuotaCutoffReason,
getPersistedConnectionCooldownSkipReason,
resolvePersistedConnectionCooldownSkipReason,
isContextOverflow400,
isParamValidation400,
isModelScoped400,
@@ -320,6 +324,26 @@ export {
* peekStickyConnectionId guards against clearing an unrelated pin when the
* failing target isn't actually the currently sticky-bound connection.
*/
/**
* Connection read for the pre-dispatch persisted-cooldown gate.
*
* `fresh: false` (first attempt) uses the shared 5s readCache — the row was just
* read by the surrounding target resolution, so a second uncached hit is pure cost.
* `fresh: true` (every retry) goes straight to SQLite: during a burst a sibling
* request routinely writes `rate_limited_until` while this attempt is sleeping out
* its retry delay, so the cached snapshot would still say "no cooldown" — which is
* exactly how a retry ended up dispatching into a real upstream 429 on a connection
* the engine had already marked unavailable.
*/
async function readConnectionForCooldownGate(
connectionId: string,
fresh: boolean
): Promise<Record<string, unknown> | null | undefined> {
if (!fresh) return getCachedProviderConnectionById(connectionId);
const { getProviderConnectionById } = await import("@/lib/db/providers");
return (await getProviderConnectionById(connectionId)) as Record<string, unknown> | null;
}
export function releaseStickyPinOnFailure(
messageHash: string | null | undefined,
failedConnectionId: string | null | undefined
@@ -1214,6 +1238,23 @@ async function handleComboChatInner({
}
: { ...target, modelAbortSignal: abortControllers.get(i)!.signal };
// Persist the connection cooldown before dispatch. AUTH only learns
// unavailable during credential lookup, so a burst would otherwise
// burn max_concurrent slots on real upstream calls against a row
// SQLite already locked until the reset.
if (target.connectionId && !allowRateLimitedConnection) {
const persistedSkip = await resolvePersistedConnectionCooldownSkipReason(
target,
(id) => readConnectionForCooldownGate(id, false),
allowRateLimitedConnection
);
if (persistedSkip) {
log.info("COMBO", persistedSkip);
if (i > 0) fallbackCount++;
return null;
}
}
// #1731 / #1731v2: skip targets already known-exhausted this request (shared predicate).
const exhaustedSkip = getExhaustedTargetSkipReason(
target,
@@ -1471,6 +1512,21 @@ async function handleComboChatInner({
log.info("COMBO", `Client disconnected during retry delay — aborting`);
return { ok: false, response: errorResponse(499, "Client disconnected") };
}
// Retry re-check: a sibling attempt (or attempt 1) may have persisted
// a quota cooldown while this attempt was sleeping out its retry delay
// ("Trying model 1/7: zai/glm-5.3 (retry 1)" after "already marked
// unavailable until …"). Reads fresh, not cached: see readConnectionForCooldownGate.
const persistedRetrySkip = await resolvePersistedConnectionCooldownSkipReason(
target,
(id) => readConnectionForCooldownGate(id, true),
allowRateLimitedConnection
);
if (persistedRetrySkip) {
log.info("COMBO", persistedRetrySkip);
if (i > 0) fallbackCount++;
return null;
}
}
log.info(

View File

@@ -482,6 +482,73 @@ export function getConnectionStatusQuotaCutoffReason(
return undefined;
}
/**
* Pre-dispatch skip for a combo target whose connection is already on a
* persisted cooldown. Combo previously only learned that from AUTH after a
* real upstream call, so a burst could burn max_concurrent slots against a
* connection that SQLite already marked unavailable until a future reset.
*
* Honours a future rateLimitedUntil regardless of testStatus, the terminal
* statuses that must never be dispatched, and a bare `unavailable` status even
* when no timestamp was written alongside it.
*/
export function getPersistedConnectionCooldownSkipReason(
target: { modelStr: string; connectionId?: string | null },
connection: Record<string, unknown> | null | undefined,
allowRateLimitedConnection = false
): string | null {
if (allowRateLimitedConnection) return null;
if (!target.connectionId || !connection) return null;
if (hasFutureRateLimitUntil(connection.rateLimitedUntil)) {
return `Skipping ${target.modelStr} — connection ${target.connectionId} has persisted cooldown until ${String(connection.rateLimitedUntil)}`;
}
const status = normalizeConnectionStatus(connection.testStatus);
if (QUOTA_BLOCKING_CONNECTION_STATUSES.has(status)) {
return `Skipping ${target.modelStr} — connection ${target.connectionId} status=${status}`;
}
// `unavailable` with no (or an already-expired) rateLimitedUntil still means AUTH
// took this connection out of rotation — markAccountUnavailable() writes the status
// before, and sometimes without, a timestamp ("Using zai account …" then a real
// upstream 429). Without this branch the pre-skip only fired once the timestamp had
// landed, so a burst still dispatched against a connection AUTH had already retired.
// Lazy recovery is unaffected: clearAccountError() resets the status on first success.
if (status === "unavailable") {
return `Skipping ${target.modelStr} — connection ${target.connectionId} status=unavailable`;
}
return null;
}
/**
* Async wrapper around `getPersistedConnectionCooldownSkipReason` for the combo
* dispatchers, which must re-check the persisted cooldown before EVERY upstream
* attempt — not just once before the retry loop.
*
* The retry path is exactly where the stale-read risk lives: a sibling request in
* the same burst can write `rate_limited_until` while this attempt is sleeping out
* its retry delay, so the caller passes a cache-bypassing fetcher for retry > 0
* (the readCache TTL is 5s, long enough to serve a "no cooldown" snapshot written
* before the 429 landed).
*
* Kept dependency-free — the fetcher is injected, so this module stays pure and
* unit-testable without a DB.
*/
export async function resolvePersistedConnectionCooldownSkipReason(
target: { modelStr: string; connectionId?: string | null },
fetchConnection: (id: string) => Promise<Record<string, unknown> | null | undefined>,
allowRateLimitedConnection = false
): Promise<string | null> {
if (allowRateLimitedConnection) return null;
if (!target.connectionId) return null;
let connection: Record<string, unknown> | null | undefined;
try {
connection = await fetchConnection(target.connectionId);
} catch {
// A DB read failure must never block dispatch — fall through to the upstream call.
return null;
}
return getPersistedConnectionCooldownSkipReason(target, connection, allowRateLimitedConnection);
}
/** @param {string} errorText */
export function isContextOverflow400(errorText: string | null | undefined): boolean {
const text = String(errorText || "");

View File

@@ -290,6 +290,7 @@ export function shouldProtectOriginalFirst(
return (
stickyStuck ||
autoUsedExplicitRouter ||
strategy === "auto" ||
strategy === "quota-share" ||
strategy === "weighted" ||
strategy === "priority" ||

View File

@@ -64,6 +64,7 @@ export function compressAggressive(
let summarizerSavings = 0;
let toolResultSavings = 0;
let agingSavings = 0;
const lastUserIdx = currentMessages.findLastIndex((m) => m.role === "user");
// Step 1: Tool-result compression
try {
@@ -110,7 +111,8 @@ export function compressAggressive(
currentMessages,
cfg.thresholds,
summarizer,
cfg.preserveSystemPrompt !== false
cfg.preserveSystemPrompt !== false,
lastUserIdx
);
agingSavings = agingResult.saved;
currentMessages = agingResult.messages as ChatMessage[];
@@ -121,8 +123,9 @@ export function compressAggressive(
// Step 3: Fallback summarizer for remaining long messages
if (cfg.summarizerEnabled) {
try {
currentMessages = currentMessages.map((msg) => {
currentMessages = currentMessages.map((msg, idx) => {
if (cfg.preserveSystemPrompt !== false && msg.role === "system") return msg;
if (idx === lastUserIdx) return msg;
const text = extractTextContent(msg.content);
if (!text || COMPRESSED_MARKER_RE.test(text)) return msg;
if (text.length <= cfg.maxTokensPerMessage * 4) return msg;
@@ -133,7 +136,10 @@ export function compressAggressive(
});
if (summary && summary.length < text.length) {
summarizerSavings += estimateTokens(text) - estimateTokens(summary);
return setContent(msg, `[COMPRESSED:summary] ${summary}`);
const finalSummary = COMPRESSED_MARKER_RE.test(summary)
? summary
: `[COMPRESSED:summary] ${summary}`;
return setContent(msg, finalSummary);
}
return msg;
});
@@ -153,13 +159,27 @@ export function compressAggressive(
if (resultStats.savingsPercent < cfg.minSavingsThreshold * 100) {
try {
const cavemanResult = cavemanCompress({ messages: currentMessages as unknown as Parameters<typeof cavemanCompress>[0]["messages"] });
if (cavemanResult?.compressed && cavemanResult.stats) {
const cavemanSavings = cavemanResult.stats.savingsPercent ?? 0;
if (cavemanSavings > resultStats.savingsPercent) {
currentMessages = (cavemanResult.body?.messages ?? currentMessages) as ChatMessage[];
resultStats.compressedTokens = cavemanResult.stats.compressedTokens ?? compressedTokens;
resultStats.savingsPercent = cavemanSavings;
const cavemanResult = cavemanCompress(
{
messages: currentMessages as unknown as Parameters<typeof cavemanCompress>[0]["messages"],
},
{ enabled: true }
);
if (cavemanResult?.compressed && cavemanResult.body?.messages) {
const rawMsgs = cavemanResult.body.messages as ChatMessage[];
const candidateMsgs = rawMsgs.map((msg, idx) =>
idx === lastUserIdx ? currentMessages[idx] : msg
);
const candidateTokens = candidateMsgs.reduce(
(sum, m) => sum + estimateTokens(extractTextContent(m.content)),
0
);
const candidateSavings =
originalTokens > 0 ? ((originalTokens - candidateTokens) / originalTokens) * 100 : 0;
if (candidateSavings > resultStats.savingsPercent) {
currentMessages = candidateMsgs;
resultStats.compressedTokens = candidateTokens;
resultStats.savingsPercent = candidateSavings;
resultStats.techniquesUsed.push("caveman-fallback");
}
}
@@ -172,12 +192,21 @@ export function compressAggressive(
{ messages: currentMessages },
{ preserveSystemPrompt: cfg.preserveSystemPrompt !== false }
);
if (liteResult?.compressed && liteResult.stats) {
const liteSavings = liteResult.stats.savingsPercent ?? 0;
if (liteSavings > resultStats.savingsPercent) {
currentMessages = (liteResult.body?.messages ?? currentMessages) as ChatMessage[];
resultStats.compressedTokens = liteResult.stats.compressedTokens ?? compressedTokens;
resultStats.savingsPercent = liteSavings;
if (liteResult?.compressed && liteResult.body?.messages) {
const rawMsgs = liteResult.body.messages as ChatMessage[];
const candidateMsgs = rawMsgs.map((msg, idx) =>
idx === lastUserIdx ? currentMessages[idx] : msg
);
const candidateTokens = candidateMsgs.reduce(
(sum, m) => sum + estimateTokens(extractTextContent(m.content)),
0
);
const candidateSavings =
originalTokens > 0 ? ((originalTokens - candidateTokens) / originalTokens) * 100 : 0;
if (candidateSavings > resultStats.savingsPercent) {
currentMessages = candidateMsgs;
resultStats.compressedTokens = candidateTokens;
resultStats.savingsPercent = candidateSavings;
resultStats.techniquesUsed.push("lite-fallback");
}
}

View File

@@ -1,6 +1,6 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import type { CompressionResult } from "./types.ts";
import type { StackedCompressionStep } from "./strategySelector.ts";
@@ -17,9 +17,10 @@ function positiveInteger(value: string | undefined, fallback: number): number {
function workerUrl(): URL {
const dir = dirname(fileURLToPath(import.meta.url));
for (const name of ["compressionWorker.js", "compressionWorker.ts"]) {
if (existsSync(join(dir, name))) return new URL(name, import.meta.url);
const candidate = join(dir, name);
if (existsSync(candidate)) return pathToFileURL(candidate);
}
return new URL("compressionWorker.js", import.meta.url);
return pathToFileURL(join(dir, "compressionWorker.js"));
}
function unchanged(body: Record<string, unknown>): CompressionResult {
return { body, compressed: false, stats: null };

View File

@@ -67,7 +67,8 @@ export function applyAging(
messages: unknown[],
thresholds?: AgingThresholds,
summarizer?: Summarizer,
preserveSystemPrompt = true
preserveSystemPrompt = true,
spareUserIndex?: number
): { messages: unknown[]; saved: number } {
const t = thresholds ?? DEFAULT_AGGRESSIVE_CONFIG.thresholds;
const sum = summarizer ?? {
@@ -81,6 +82,9 @@ export function applyAging(
const typed = messages as ChatMessage[];
if (typed.length === 0) return { messages: [], saved: 0 };
const lastUserIdx =
spareUserIndex !== undefined ? spareUserIndex : typed.findLastIndex((m) => m.role === "user");
const totalMessages = typed.length;
const result: ChatMessage[] = [];
let saved = 0;
@@ -89,7 +93,11 @@ export function applyAging(
const msg = typed[i];
const text = extractTextContent(msg.content);
if ((preserveSystemPrompt && msg.role === "system") || COMPRESSED_MARKER_RE.test(text)) {
if (
(preserveSystemPrompt && msg.role === "system") ||
COMPRESSED_MARKER_RE.test(text) ||
i === lastUserIdx
) {
result.push(msg);
continue;
}

View File

@@ -18,6 +18,7 @@ import {
TokenExtractionConfig,
type TokenSource,
} from "./tokenExtractionConfig";
import { matchesCookieDomain } from "../utils/cookieDomain";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -196,9 +197,14 @@ export class InAppLoginService extends EventEmitter {
for (const source of tokenSources) {
if (source.type === "cookie") {
const domain = source.domain || undefined;
// Exact host or dot-boundary suffix, never `includes()`: a cookie
// from `<domain>.attacker.tld` would otherwise be captured and
// persisted as the operator's credential. Same class CodeQL flagged
// in volcengineConsoleAutoLogin (#860/#861); this callsite was not
// flagged because the expected domain is config-supplied.
const matched = cookies.find(
(c: any) =>
c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, "")))
c.name === source.name && (!domain || matchesCookieDomain(c.domain, domain))
);
if (matched && !credentials[source.name]) {
credentials[source.name] = matched.value;

View File

@@ -26,19 +26,121 @@ export function shouldPreserveQuotaSignals(
}
/**
* Parse a day-granularity quota reset countdown ("Your quota will reset in
* 3 days.", "Resets in 13 days") out of an upstream 429 body.
* Parse a day-granularity quota reset countdown (\"Your quota will reset in
* 3 days.\", \"Resets in 13 days\") out of an upstream 429 body.
*
* Companion to the Xh/Ym/Zs countdown parsing already handled inline by
* `parseRetryFromErrorText` — none of those patterns match when the upstream
* expresses the reset window in whole days rather than hours/minutes/seconds,
* so a multi-day quota reset previously parsed to `null` and fell back to the
* engine's ~seconds-scale default cooldown.
*
* Delegates to `parseIsoDateTimeResetMs` (absolute \"reset at YYYY-MM-DD HH:MM:SS\")
* and then `parseMonthDayResetMs` (year-less \"reset at MM-DD HH:MM:SS UTC\") so
* every absolute-reset shape an upstream uses resolves to the real wait.
*/
export function parseDayGranularityResetMs(msg: string, maxMs: number): number | null {
export function parseDayGranularityResetMs(
msg: string,
maxMs: number,
nowMs: number = Date.now()
): number | null {
const dayMatch = /reset(?:s)?\s+in\s+(\d+)\s*day(?:s)?/i.exec(msg);
if (!dayMatch) return null;
const days = Number.parseInt(dayMatch[1], 10);
if (!Number.isFinite(days) || days <= 0) return null;
return Math.min(days * 24 * 3600 * 1000, maxMs);
if (dayMatch) {
const days = Number.parseInt(dayMatch[1], 10);
if (Number.isFinite(days) && days > 0) {
return Math.min(days * 24 * 3600 * 1000, maxMs);
}
}
const isoMs = parseIsoDateTimeResetMs(msg, maxMs, nowMs);
if (isoMs !== null) return isoMs;
return parseMonthDayResetMs(msg, maxMs, nowMs);
}
/**
* Z.AI (GLM) reports an exhausted weekly/monthly cap with a FULL absolute
* datetime rather than a countdown:
*
* \"[1310][Weekly/Monthly Limit Exhausted. … Your limit will reset at
* 2026-08-29 21:01:21]\"
*
* `parseRetryFromErrorText` (accountFallback.ts) has an equivalent ISO matcher,
* but `buildWeeklyQuotaFallback` never reaches it: it calls
* `parseDayGranularityResetMs` directly, and neither the \"reset in N days\" nor
* the year-less MM-DD parser matched this shape. The weekly fallback therefore
* fell back to WEEKLY_QUOTA_COOLDOWN_MS (24h) and the connection was dispatched
* again — into a real upstream 429 — every day until the true reset ~6 days out.
*
* The datetime may use a `T` or a space separator, and may carry `Z` or a
* `±HH:MM` offset. A NAIVE datetime (no zone) is interpreted as UTC: Z.AI
* reports in UTC, and treating it as local time would shift the cooldown by the
* host offset. Returns null when the instant is not in the future.
*/
export function parseIsoDateTimeResetMs(
msg: string,
maxMs: number,
nowMs: number = Date.now()
): number | null {
const match =
/\b(?:try again at|wait until|reset(?:s)?\s+at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?)\s*(Z|[+-]\d{2}:?\d{2})?/i.exec(
msg
);
if (!match) return null;
const stamp = match[1].replace(/[Tt ]/, "T");
// No zone in the body → UTC (see doc comment). Normalize \"+0200\" to \"+02:00\":
// the bare-offset form is not part of the ES Date.parse grammar.
const rawZone = match[2] ? match[2].toUpperCase() : "Z";
const zone = /^[+-]\d{4}$/.test(rawZone)
? `${rawZone.slice(0, 3)}:${rawZone.slice(3)}`
: rawZone;
const resetMs = Date.parse(`${stamp}${zone}`);
if (!Number.isFinite(resetMs)) return null;
const waitMs = resetMs - nowMs;
if (waitMs <= 0) return null;
return Math.min(waitMs, maxMs);
}
/**
* Qwen token-plan (and similar apikey providers) report the weekly reset as
* \"The quota will reset at 08-29 15:29:00 UTC\" without a year. Treat that as
* the next occurrence of MM-DD HH:MM[:SS] UTC; if the date already passed this
* year, roll to next year. Returns null when the parsed instant is not in the
* future or the wait would exceed maxMs.
*/
export function parseMonthDayResetMs(
msg: string,
maxMs: number,
nowMs: number = Date.now()
): number | null {
const match =
/reset(?:s)?\s+at\s+(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s*(?:UTC|Z)?/i.exec(
msg
);
if (!match) return null;
const month = Number.parseInt(match[1], 10);
const day = Number.parseInt(match[2], 10);
const hour = Number.parseInt(match[3], 10);
const minute = Number.parseInt(match[4], 10);
const second = match[5] ? Number.parseInt(match[5], 10) : 0;
if (
month < 1 ||
month > 12 ||
day < 1 ||
day > 31 ||
hour > 23 ||
minute > 59 ||
second > 59
) {
return null;
}
const now = new Date(nowMs);
let year = now.getUTCFullYear();
let resetMs = Date.UTC(year, month - 1, day, hour, minute, second);
if (!Number.isFinite(resetMs)) return null;
if (resetMs <= nowMs) {
year += 1;
resetMs = Date.UTC(year, month - 1, day, hour, minute, second);
}
const waitMs = resetMs - nowMs;
if (!Number.isFinite(waitMs) || waitMs <= 0) return null;
return Math.min(waitMs, maxMs);
}

View File

@@ -11,6 +11,7 @@
*/
import { RateLimitReason } from "../config/constants.ts";
import { parseDayGranularityResetMs } from "./quotaResetParsing.ts";
type RateLimitReasonValue = (typeof RateLimitReason)[keyof typeof RateLimitReason];
@@ -97,16 +98,29 @@ export function isWeeklyUsageLimitText(lower: string): boolean {
return (
lower.includes("weekly usage limit") ||
lower.includes("weekly limit reached") ||
lower.includes("reached your weekly")
lower.includes("reached your weekly") ||
lower.includes("1-week quota") ||
lower.includes("week quota") ||
lower.includes("weekly/monthly limit") ||
(lower.includes("weekly") && lower.includes("quota") && lower.includes("exhaust"))
);
}
const MAX_WEEKLY_QUOTA_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;
export function buildWeeklyQuotaFallback(errorStr: string): QuotaTextFallback | null {
if (!isWeeklyUsageLimitText(errorStr.toLowerCase())) return null;
const parsedResetMs = parseDayGranularityResetMs(errorStr, MAX_WEEKLY_QUOTA_COOLDOWN_MS);
const cooldownMs =
typeof parsedResetMs === "number" && parsedResetMs > 0
? parsedResetMs
: WEEKLY_QUOTA_COOLDOWN_MS;
return {
shouldFallback: true,
cooldownMs: WEEKLY_QUOTA_COOLDOWN_MS,
cooldownMs,
reason: RateLimitReason.QUOTA_EXHAUSTED,
usedUpstreamRetryHint: typeof parsedResetMs === "number" && parsedResetMs > 0,
quotaResetHintMs: typeof parsedResetMs === "number" && parsedResetMs > 0 ? parsedResetMs : undefined,
};
}

View File

@@ -64,7 +64,7 @@ const MAX_CONVERSATION_AFFINITY_ENTRIES = 1000;
* Task routing is additive: other strategies are wholly unaffected.
*/
export function isTaskRoutingStrategy(strategy: unknown): boolean {
return ["smart", "task", "task-aware", "task_aware", "auto"].includes(
return ["smart", "task", "task-aware", "task_aware"].includes(
String(strategy ?? "").toLowerCase()
);
}

View File

@@ -185,6 +185,26 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [
{ cookieDomain: ".chat.qwen.ai" }
),
// ── Volcano Engine Ark Console ───────────────────────────
config(
"volcengine-console",
"Volcano Engine Ark Console",
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan",
"https://console.volcengine.com",
[
{ type: "cookie", name: "digest", domain: ".volcengine.com" },
{ type: "cookie", name: "AccountID", domain: ".volcengine.com" },
{ type: "cookie", name: "csrfToken", domain: ".volcengine.com" },
{ type: "cookie", name: "userInfo", domain: ".volcengine.com" },
],
"Log in to the Volcano Engine Ark console. The console session is used to discover Agent/Coding Plan API keys and live quota usage.",
{
cookieDomain: ".volcengine.com",
successUrlPattern: /console\.volcengine\.com\/ark/i,
pollingConfig: { timeout: 300_000, minLoginTime: 3000 },
}
),
// ── Kimi Web ──────────────────────────────────────────────
config(
"kimi-web",

View File

@@ -68,6 +68,7 @@ import { getXaiUsage } from "./usage/xai.ts";
import { getXaiOauthUsage } from "./usage/xaiOauth.ts";
import { getGrokCliUsage } from "./usage/grokCli.ts";
import { getFirecrawlUsage } from "./usage/firecrawl.ts";
import { getVolcenginePlanUsage } from "./usage/volcenginePlan.ts";
import { getCommandCodeUsage } from "./usage/command-code.ts";
import { getQwenTokenPlanUsage } from "./usage/qwen-token-plan.ts";
import { getConolUsage } from "./conolUsage.ts";
@@ -135,6 +136,9 @@ export const USAGE_FETCHER_PROVIDERS = [
"ha",
// Firecrawl team credits (GET /v2/team/credit-usage)
"firecrawl",
// Volcano Ark Plan subscriptions (agent-plan / coding-plan)
"volcengine-agent-plan",
"volcengine-coding-plan",
// Command Code credits + 5h/weekly windows (GET /alpha/billing/credits)
"command-code",
"conol-web",
@@ -242,6 +246,9 @@ export async function getUsageForProvider(
return await getHyperAgentUsage(apiKey || accessToken, providerSpecificData);
case "firecrawl":
return await getFirecrawlUsage(id || "", apiKey, connection);
case "volcengine-agent-plan":
case "volcengine-coding-plan":
return await getVolcenginePlanUsage(apiKey || "", provider, providerSpecificData);
case "command-code":
return await getCommandCodeUsage(apiKey || accessToken || "");
case "conol-web":

View File

@@ -155,15 +155,30 @@ export async function getGlmUsage(apiKey: string, providerSpecificData?: Record<
const resetMs = toNumber(src.nextResetTime, 0);
const resetAt = resetMs > 0 ? new Date(resetMs).toISOString() : null;
if (type === "TOKENS_LIMIT") {
// Z.ai coding-plan keys (CREDIT-based, e.g. GLM Coding Max/Lite) report
// CREDIT_LIMIT rows with the same unit/number semantics as TOKENS_LIMIT
// (unit=3/number=5 → 5-hour window, unit=6/number=1 → weekly). Without
// this branch every CREDIT_LIMIT row is dropped and the quota card
// renders empty for subscription keys.
if (type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT") {
const quotaName = getGlmTokenQuotaName(src, quotas);
const usedPercent = toPercentage(src.percentage);
const remaining = Math.max(0, 100 - usedPercent);
// CREDIT_LIMIT rows (z.ai coding-plan keys) carry absolute credits on
// top of the percentage: usage = window total, currentValue = consumed,
// remaining = credits left. Prefer them so the quota card renders
// "3341 / 28000" like z.ai's own dashboard instead of a percent-only
// scale. TOKENS_LIMIT rows without absolute fields keep the percent path.
const totalCredits = toNumber(src.usage, 0);
const usedCredits = totalCredits > 0 ? toNumber(src.currentValue, usedPercent) : usedPercent;
const remainingCredits = totalCredits > 0 ? toNumber(src.remaining, remaining) : remaining;
const total = totalCredits > 0 ? totalCredits : 100;
quotas[quotaName] = {
used: usedPercent,
total: 100,
remaining,
used: usedCredits,
total,
remaining: remainingCredits,
remainingPercentage: remaining,
resetAt,
displayName: getGlmQuotaDisplayName(quotaName),

View File

@@ -0,0 +1,317 @@
/**
* usage/volcenginePlan.ts — Volcano Ark Plan usage fetcher.
*
* Volcano Engine Ark serves the two subscription plans on DISTINCT chat base URLs:
* - Agent Plan → https://ark.cn-beijing.volces.com/api/plan/v3
* - Coding Plan → https://ark.cn-beijing.volces.com/api/coding/v3
* (both differ from the standard pay-per-use API at /api/v3).
*
* The data-plane API exposes NO quota/usage endpoint. Real subscription usage
* lives behind the Ark console's authenticated "top" API, which is keyed by the
* browser session cookie (+ CSRF token), NOT the ark- API key:
* - Coding Plan → POST /api/top/ark/cn-beijing/2024-01-01/GetCodingPlanUsage
* - Agent Plan → POST /api/top/ark/cn-beijing/2024-01-01/GetAgentPlanAFPUsage
*
* When the connection carries a console cookie in providerSpecificData
* (`volcConsoleCookie` + `volcCsrfToken`), we fetch the real quota windows and
* map them into OmniRoute's UsageQuota shape. Without a cookie we fall back to a
* data-plane connectivity probe (validates the key, no quota numbers).
*/
import { toRecord, toNumber } from "./scalars.ts";
import { type UsageQuota } from "./quota.ts";
type JsonRecord = Record<string, unknown>;
const AGENT_PLAN_BASE_URL = "https://ark.cn-beijing.volces.com/api/plan/v3";
const CODING_PLAN_BASE_URL = "https://ark.cn-beijing.volces.com/api/coding/v3";
const CONSOLE_TOP_BASE = "https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01";
// First model probed for the Agent Plan chat-based validation (no /models endpoint).
const AGENT_PLAN_PROBE_MODEL = "doubao-seed-2-0-pro-260215";
const CONSOLE_HINT_AGENT = "console.volcengine.com/ark → 订阅 Agent Plan";
const CONSOLE_HINT_CODING = "console.volcengine.com/ark → 订阅 Coding Plan";
function getPlanName(provider: string): string {
if (provider === "volcengine-agent-plan") return "Volcano Ark Agent Plan";
if (provider === "volcengine-coding-plan") return "Volcano Ark Coding Plan";
return "Volcano Ark Plan";
}
function getBaseUrl(provider: string, providerSpecificData?: JsonRecord): string {
const override = providerSpecificData?.arkPlanBaseUrl;
if (typeof override === "string" && override.trim()) return override.trim().replace(/\/+$/, "");
if (provider === "volcengine-coding-plan") return CODING_PLAN_BASE_URL;
return AGENT_PLAN_BASE_URL;
}
// ── Console cookie helpers ──────────────────────────────────────────────────
function getConsoleCookie(providerSpecificData?: JsonRecord): string {
const cookie = providerSpecificData?.volcConsoleCookie;
return typeof cookie === "string" ? cookie.trim() : "";
}
function getConsoleCsrf(providerSpecificData?: JsonRecord, cookie = ""): string {
const explicit = providerSpecificData?.volcCsrfToken;
if (typeof explicit === "string" && explicit.trim()) return explicit.trim();
// Fall back to the csrfToken embedded in the cookie string.
const match = cookie.match(/csrfToken=([^;]+)/);
return match ? match[1].trim() : "";
}
async function callConsoleApi(
action: string,
cookie: string,
csrf: string,
referer: string
): Promise<{ ok: boolean; status: number; json: JsonRecord; error?: string }> {
const response = await fetch(`${CONSOLE_TOP_BASE}/${action}?`, {
method: "POST",
headers: {
accept: "application/json, text/plain, */*",
"content-type": "application/json",
cookie,
origin: "https://console.volcengine.com",
referer,
"x-csrf-token": csrf,
},
body: "{}",
});
const text = await response.text();
let json: JsonRecord = {};
try {
json = toRecord(JSON.parse(text));
} catch {
/* non-JSON */
}
const err = toRecord(toRecord(json.ResponseMetadata).Error);
const errMsg = typeof err.Message === "string" ? err.Message : "";
return { ok: response.ok && !errMsg, status: response.status, json, error: errMsg };
}
// ── Console usage → UsageQuota mapping ───────────────────────────────────────
function tsToIso(seconds: number): string | null {
if (!seconds || seconds <= 0) return null;
const ms = seconds < 1e12 ? seconds * 1000 : seconds;
const d = new Date(ms);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
const CODING_WINDOW_LABEL: Record<string, string> = {
session: "Session (5h)",
weekly: "Weekly",
monthly: "Monthly",
daily: "Daily",
};
/**
* Map GetCodingPlanUsage → quotas. Coding Plan reports each window as a used
* `Percent` (0-100) against `Cap` (100), so remaining = Cap - Percent.
*/
function mapCodingPlanUsage(result: JsonRecord): Record<string, UsageQuota> {
const quotas: Record<string, UsageQuota> = {};
const windows = Array.isArray(result.QuotaUsage) ? result.QuotaUsage : [];
for (const raw of windows) {
const w = toRecord(raw);
const level = String(w.Level || "").toLowerCase();
if (!level) continue;
const cap = toNumber(w.Cap, 100) || 100;
const usedPercent = toNumber(w.Percent, 0);
const remainingPercentage = Math.max(0, Math.min(100, cap - usedPercent));
quotas[level] = {
used: usedPercent,
total: cap,
remaining: Math.max(0, cap - usedPercent),
remainingPercentage,
resetAt: tsToIso(toNumber(w.ResetTimestamp, 0)),
unlimited: false,
displayName: CODING_WINDOW_LABEL[level] || level,
};
}
return quotas;
}
const AGENT_WINDOW_LABEL: Array<[string, string]> = [
["AFPFiveHour", "Session (5h)"],
["AFPDaily", "Daily"],
["AFPWeekly", "Weekly"],
["AFPMonthly", "Monthly"],
];
/**
* Map GetAgentPlanAFPUsage → quotas. Agent Plan reports absolute `Quota`/`Used`
* (AFP credits) per window with a millisecond `ResetTime`.
*/
function mapAgentPlanUsage(result: JsonRecord): Record<string, UsageQuota> {
const quotas: Record<string, UsageQuota> = {};
for (const [key, label] of AGENT_WINDOW_LABEL) {
const w = toRecord(result[key]);
if (Object.keys(w).length === 0) continue;
const total = toNumber(w.Quota, 0);
const used = toNumber(w.Used, 0);
const remaining = Math.max(0, total - used);
const remainingPercentage =
total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 100;
const resetMs = toNumber(w.ResetTime, 0);
quotas[key] = {
used,
total,
remaining,
remainingPercentage,
// Agent Plan ResetTime is in milliseconds already.
resetAt: tsToIso(resetMs >= 1e12 ? resetMs / 1000 : resetMs),
unlimited: false,
displayName: label,
};
}
return quotas;
}
// ── Data-plane connectivity probes (fallback, no cookie) ─────────────────────
function parseArkError(json: unknown): { code: string; message: string } | null {
const data = toRecord(json);
const error = toRecord(data.error);
if (!error.code && !error.message && !data.message) return null;
return {
code: String(error.code || ""),
message: String(error.message || data.message || ""),
};
}
function authErrorMessage(planName: string, status: number, errorMsg: string): string {
if (status === 401) {
const isFormatError = /format.*incorrect|incorrect.*format/i.test(errorMsg);
return isFormatError
? `Invalid API key format. ${planName} keys start with 'ark-'. Check your subscription key.`
: `Invalid API key or the key does not belong to a ${planName} subscription.`;
}
if (status === 403) {
return `Access denied. Ensure your key has an active ${planName} subscription.`;
}
return `${planName} API error (${status}): ${errorMsg}`;
}
async function reportError(response: Response, responseText: string, planName: string) {
let data: unknown = null;
try {
data = JSON.parse(responseText);
} catch {
/* non-JSON error body */
}
const arkError = parseArkError(data);
return {
plan: planName,
message: authErrorMessage(
planName,
response.status,
arkError?.message || responseText.slice(0, 200)
),
};
}
/** Coding Plan: validate via the working /models listing endpoint. */
async function probeCodingPlan(baseUrl: string, apiKey: string, planName: string) {
const response = await fetch(`${baseUrl}/models`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
});
const responseText = await response.text();
if (!response.ok) return reportError(response, responseText, planName);
return {
plan: planName,
message: `${planName} connected. Add your console cookie (volcConsoleCookie) to view live quota, or check ${CONSOLE_HINT_CODING}.`,
};
}
/** Agent Plan: no /models endpoint — validate via a minimal chat probe. */
async function probeAgentPlan(baseUrl: string, apiKey: string, planName: string) {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
model: AGENT_PLAN_PROBE_MODEL,
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
stream: false,
}),
});
const responseText = await response.text();
if (!response.ok) return reportError(response, responseText, planName);
return {
plan: planName,
message: `${planName} connected. Add your console cookie (volcConsoleCookie) to view live quota, or check ${CONSOLE_HINT_AGENT}.`,
};
}
// ── Entry point ──────────────────────────────────────────────────────────────
export async function getVolcenginePlanUsage(
apiKey: string,
provider: string,
providerSpecificData?: JsonRecord
) {
const planName = getPlanName(provider);
const isCoding = provider === "volcengine-coding-plan";
// Preferred path: real usage via the authenticated console "top" API.
const cookie = getConsoleCookie(providerSpecificData);
if (cookie) {
const csrf = getConsoleCsrf(providerSpecificData, cookie);
const action = isCoding ? "GetCodingPlanUsage" : "GetAgentPlanAFPUsage";
const referer = isCoding
? "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan"
: "https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan";
try {
const { ok, status, json, error } = await callConsoleApi(action, cookie, csrf, referer);
if (ok) {
const result = toRecord(json.Result);
const quotas = isCoding ? mapCodingPlanUsage(result) : mapAgentPlanUsage(result);
if (Object.keys(quotas).length > 0) {
const planType = typeof result.PlanType === "string" ? ` (${result.PlanType})` : "";
return { plan: `${planName}${planType}`, quotas };
}
return {
plan: planName,
message: `${planName} connected. No active quota windows reported.`,
};
}
// Cookie present but console call failed (expired session / no subscription).
if (status === 401 || status === 403 || /login|unauthor|登录|鉴权/i.test(error || "")) {
return {
plan: planName,
message: `Console session expired. Refresh volcConsoleCookie to view live quota.`,
};
}
return {
plan: planName,
message: `${planName}: console usage unavailable${error ? ` (${error})` : ""}.`,
};
} catch (err) {
return {
plan: planName,
message: `${planName} — unable to reach the Ark console: ${(err as Error).message}`,
};
}
}
// Fallback: data-plane connectivity probe (needs the ark- API key).
if (!apiKey) {
return { message: "API key not available. Add an Ark Plan API key to view usage." };
}
const baseUrl = getBaseUrl(provider, providerSpecificData);
try {
return isCoding
? await probeCodingPlan(baseUrl, apiKey, planName)
: await probeAgentPlan(baseUrl, apiKey, planName);
} catch (error) {
return {
plan: planName,
message: `${planName} — unable to reach the Ark API: ${(error as Error).message}`,
};
}
}

View File

@@ -0,0 +1,995 @@
/**
* VolcengineConsoleAutoLogin — session-based phone/SMS-code login for the
* Volcano Engine console.
*
* Unlike InAppLoginService (which opens a headful browser and requires the
* operator to complete login inside a browser on the server machine), this
* service drives a headless Chromium through the console's 手机号登录 (phone +
* SMS verification code) flow:
*
* 1. startLogin(phone) — navigate to the login page, switch to the phone
* tab, fill the phone number, click 获取验证码. If the console demands an
* image captcha, a screenshot is captured for the dashboard to render.
* 2. submitCode(code, captcha?) — fill the SMS code (and image captcha when
* requested), click 登录 / 注册, then poll the browser context for the
* console session cookies (digest / AccountID / csrfToken / userInfo).
* 3. cancel() / resendCode() — lifecycle helpers.
*
* The service only extracts credentials; persisting/binding them to provider
* connections stays in the dashboard API layer (volcenginePlanBinding.ts).
*
* Selector strategy: the console login page is built with Arco Design and
* exposes stable element ids (#Tel_input, #Code_input, #VerificatonCodeInput).
* Every interaction goes through multi-candidate selector lists so a single
* frontend rename does not break the flow. When a candidate list misses or
* risk-control (slider) is detected, the session degrades to
* `fallback_manual` and the caller can fall back to the pre-existing
* headful-browser flow.
*/
import { randomUUID } from "crypto";
import { matchesCookieDomain } from "../utils/cookieDomain";
// ─── Public types ───────────────────────────────────────────────────────────
export type VolcLoginPhase =
| "starting"
| "sending_code"
| "waiting_code"
| "captcha_required"
| "submitting"
| "mfa_waiting"
| "identity_required"
| "success"
| "error"
| "timeout"
| "cancelled"
| "fallback_manual";
export interface VolcLoginSessionView {
sessionId: string;
phase: VolcLoginPhase;
phoneMasked: string;
error: string | null;
/** data:image/png;base64 screenshot of the image captcha, when required */
captchaImage: string | null;
/** epoch ms — earliest time a resend should be offered */
resendAvailableAt: number;
createdAt: number;
updatedAt: number;
/** True while the console demands an MFA step-up code (second SMS code) */
mfaRequired?: boolean;
/** Identity options scraped from /auth/login/select_identity, when required */
identityOptions?: Array<{ index: number; label: string }>;
/** Credentials (console cookies) — only present after success */
credentials?: Record<string, string>;
/** Set by the API layer after binding plans (not part of this service) */
binding?: unknown;
}
export interface StartOptions {
/** Total session timeout in ms (default 300_000) */
timeout?: number;
}
export interface SubmitCodeOptions {
/** Extra wait for cookie polling after submit (default 90_000) */
timeout?: number;
}
/** Injectable delays — tests shrink these to keep the suite fast. */
export interface ServiceDelays {
pageSettleMs?: number;
tabSwitchMs?: number;
sendCodeSettleMs?: number;
pollIntervalMs?: number;
resendCooldownMs?: number;
}
// ─── Config ─────────────────────────────────────────────────────────────────
const LOGIN_URL = "https://console.volcengine.com/auth/login";
/** Landing page the manual headful flow uses — the console app issues the
* remaining session cookies (AccountID/userInfo) once it runs. */
const ARK_CONSOLE_URL =
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan";
/** Cookie names required for a valid console session (mirrors tokenExtractionConfig) */
const REQUIRED_COOKIES = ["digest", "AccountID", "csrfToken", "userInfo"] as const;
const DEFAULT_SESSION_TIMEOUT = 300_000;
const SUBMIT_COOKIE_TIMEOUT = 90_000;
const CAPTURE_POLL_INTERVAL = 1_000;
const RESEND_COOLDOWN_MS = 60_000;
const MAX_ACTIVE_SESSIONS = 2;
/** Multi-candidate selectors — first visible candidate wins. */
const SELECTORS = {
phoneTab: ['.arco-tabs-header-title:has-text("手机号登录")', "text=手机号登录"],
phoneInput: ["#Tel_input", 'input[name="Tel"]', 'input[placeholder*="手机号"]'],
smsCodeInput: ["#Code_input", 'input[placeholder*="请输入验证码"]'],
sendCodeButton: ['button:has-text("获取验证码")', "text=获取验证码"],
loginButton: ['button:has-text("登录 / 注册")', 'button:has-text("登录")'],
imageCaptchaInput: ["#VerificatonCodeInput", "input.verify-input"],
captchaShot: [".arco-modal", '[class*="captcha"]', '[class*="verify"]'],
/** Risk-control slider / popup heuristics */
riskControl: [
'[class*="secsdk-captcha"]',
"#captcha_popup",
'[class*="captcha-slider"]',
'[class*="drag"] [class*="slider"]',
],
/** MFA step-up modal (需要额外认证): a SECOND 6-digit SMS code is required */
mfaModal: ['.arco-modal:has-text("需要额外认证")', "text=需要额外认证"],
mfaInput: ["#VerificatonCodeInput", ".arco-modal input.verify-input", ".arco-modal input"],
mfaConfirmButton: ['button:has-text("好的")', '.arco-modal button:has-text("确定")'],
mfaResendButton: ['button:has-text("重发校验码")'],
/** TOTP binding modal (绑定MFA设备) — needs interactive Google Authenticator setup */
mfaBindModal: ['.arco-modal:has-text("绑定MFA设备")'],
/** Identity selection page (/auth/login/select_identity) — the phone maps to
* multiple accounts; the user must pick which identity to log in as.
* Structure verified against the real auth bundle (vconsole-auth 1.0.0.2837,
* module 12173 + chunk 202): ul[class*=accountUl] > li[class*=accountLi] >
* div[class*=item] (click target) with the identity text in [class*=identity];
* submit is button[type=submit] ("登录") inside [class*=selectPlatformIdentity].
* .arco-list-item is kept as a fallback for future Arco-based redesigns. */
identityList: ['ul[class*="accountUl"] li[class*="accountLi"]', ".arco-list-item"],
identityItem: ['li[class*="accountLi"] > [class*="item"]', ".arco-list-item"],
identitySubmitButton: [
'[class*="selectPlatformIdentity"] button[type="submit"]',
'button[type="submit"]:has-text("登录")',
'button:has-text("登录")',
],
} as const;
/** URL marker for the console's identity-selection page */
const IDENTITY_URL_PATTERN = /\/auth\/login\/select_identity/i;
const BROWSER_CONTEXT_OPTIONS = {
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
viewport: { width: 1280, height: 800 },
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
};
// ─── Minimal playwright structural types ──────────────────────────────────
// Playwright is an optional runtime dep (dynamically imported), so we model
// only the API surface this service drives instead of importing its types.
interface PwLocator {
first(): PwLocator;
isVisible(options?: { timeout?: number }): Promise<boolean>;
click(options?: unknown): Promise<void>;
fill(value: string): Promise<void>;
isDisabled(): Promise<boolean>;
screenshot(options?: { type?: string }): Promise<Buffer>;
textContent(options?: { timeout?: number }): Promise<string | null>;
count(): Promise<number>;
nth(index: number): PwLocator;
}
interface PwPage {
setDefaultTimeout(timeout: number): void;
goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<unknown>;
locator(selector: string): PwLocator;
screenshot(options?: { type?: string }): Promise<Buffer>;
url(): string;
content(): Promise<string>;
}
interface PwContext {
newPage(): Promise<PwPage>;
cookies(): Promise<Array<{ name: string; domain: string; value: string }>>;
}
interface PwBrowser {
newContext(options?: Record<string, unknown>): Promise<PwContext>;
close(): Promise<void>;
}
interface PwModule {
chromium: {
launch(options?: { headless?: boolean; args?: string[]; channel?: string }): Promise<PwBrowser>;
};
}
// ─── Session record (internal) ──────────────────────────────────────────────
interface ActiveSession {
sessionId: string;
phone: string;
phase: VolcLoginPhase;
error: string | null;
captchaImage: string | null;
resendAvailableAt: number;
createdAt: number;
updatedAt: number;
timeoutMs: number;
credentials: Record<string, string> | null;
/** Binding outcome set by the API layer via withBinding() */
binding?: unknown;
cancelled: boolean;
/** Identity options scraped from the select_identity page */
identityOptions: Array<{ index: number; label: string }> | null;
// Playwright handles — never serialized
browser: PwBrowser | null;
context: PwContext | null;
page: PwPage | null;
}
export function maskPhone(phone: string): string {
if (phone.length < 7) return "***";
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
/** Normalize a CN mobile number: strip +86/86 prefix, spaces, dashes. */
export function normalizePhone(raw: string): string | null {
const trimmed = String(raw || "")
.trim()
.replace(/[\s-]/g, "");
const bare = trimmed.replace(/^\+?86/, "");
return /^1\d{10}$/.test(bare) ? bare : null;
}
/**
* Whether a cookie's `domain` belongs to the Volcengine console.
*
* Cookie domains must be matched by exact host or dot-boundary suffix, never by
* substring: `domain.includes("volcengine.com")` also accepted
* `volcengine.com.attacker.tld` and `notvolcengine.com`, so a cookie named
* `digest`/`AccountID`/`csrfToken`/`userInfo` set by a look-alike host was
* harvested as an operator credential and persisted as a provider connection
* (CodeQL js/incomplete-url-substring-sanitization #860/#861). Mirrors
* `isAdobeCookieDomain` in adobeFireflyBrowserLogin.ts.
*/
export function isVolcengineCookieDomain(domain: string | undefined): boolean {
return matchesCookieDomain(domain, "volcengine.com");
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── Service ────────────────────────────────────────────────────────────────
export class VolcengineConsoleAutoLoginService {
private sessions = new Map<string, ActiveSession>();
/** sessionId → bind promise set by the API layer to dedupe lazy binding */
private bindInFlight = new Map<string, Promise<unknown>>();
/** Injectable for tests — resolves the playwright module instead of `import("playwright")`. */
private readonly loadPlaywright: () => Promise<PwModule>;
private readonly delays: Required<ServiceDelays>;
constructor(
loadPlaywright: () => Promise<PwModule> = async () => import("playwright"),
delays: ServiceDelays = {}
) {
this.loadPlaywright = loadPlaywright;
this.delays = {
pageSettleMs: delays.pageSettleMs ?? 2_500,
tabSwitchMs: delays.tabSwitchMs ?? 1_000,
sendCodeSettleMs: delays.sendCodeSettleMs ?? 2_000,
pollIntervalMs: delays.pollIntervalMs ?? CAPTURE_POLL_INTERVAL,
resendCooldownMs: delays.resendCooldownMs ?? RESEND_COOLDOWN_MS,
};
}
// ─── Queries ─────────────────────────────────────────────────────────────
getActiveSessionCount(): number {
let count = 0;
for (const session of this.sessions.values()) {
if (!isTerminal(session.phase)) count++;
}
return count;
}
getStatus(sessionId: string): VolcLoginSessionView | null {
const session = this.sessions.get(sessionId);
if (!session) return null;
return this.toView(session);
}
/**
* Lazy binding hook used by the API layer: the route stores a promise here
* so concurrent status polls do not double-bind the same credentials.
*/
async withBinding<T>(
sessionId: string,
bind: (credentials: Record<string, string>) => Promise<T>
): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (session.phase !== "success" || !session.credentials) {
return this.toView(session);
}
if (session.binding !== undefined) return this.toView(session);
let inFlight = this.bindInFlight.get(sessionId);
if (!inFlight) {
inFlight = bind(session.credentials)
.then((binding: unknown) => {
session.binding = binding;
return binding;
})
.catch((error: unknown) => {
// Persist the failure so status polls do not retry forever.
session.binding = { error: errorMessage(error) };
return session.binding;
})
.finally(() => {
this.bindInFlight.delete(sessionId);
});
this.bindInFlight.set(sessionId, inFlight);
}
await inFlight;
return this.toView(session);
}
// ─── Lifecycle ───────────────────────────────────────────────────────────
async startLogin(
phone: string,
options?: StartOptions
): Promise<{ ok: true; session: VolcLoginSessionView } | { ok: false; error: string }> {
const normalized = normalizePhone(phone);
if (!normalized) {
return { ok: false, error: "Invalid phone number (expected an 11-digit CN mobile number)" };
}
this.expireSessions();
for (const session of this.sessions.values()) {
if (session.phone === normalized && !isTerminal(session.phase)) {
await this.cancel(session.sessionId);
}
}
if (this.getActiveSessionCount() >= MAX_ACTIVE_SESSIONS) {
return { ok: false, error: "Too many concurrent Volcano login sessions" };
}
let playwright: PwModule;
try {
playwright = await this.loadPlaywright();
} catch {
return {
ok: false,
error: "Playwright is not installed. Use manual browser login instead.",
};
}
const session: ActiveSession = {
sessionId: randomUUID(),
phone: normalized,
phase: "starting",
error: null,
captchaImage: null,
resendAvailableAt: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
timeoutMs: options?.timeout || DEFAULT_SESSION_TIMEOUT,
credentials: null,
cancelled: false,
identityOptions: null,
browser: null,
context: null,
page: null,
};
this.sessions.set(session.sessionId, session);
try {
// Prefer the playwright-managed Chromium; fall back to the system Chrome
// channel on machines without `npx playwright install` browsers (dev laptops).
try {
session.browser = await playwright.chromium.launch({
headless: true,
args: ["--disable-blink-features=AutomationControlled"],
});
} catch (launchError) {
if (!/Executable doesn't exist/.test(String(launchError))) throw launchError;
session.browser = await playwright.chromium.launch({
headless: true,
channel: "chrome",
args: ["--disable-blink-features=AutomationControlled"],
});
}
session.context = await session.browser.newContext(BROWSER_CONTEXT_OPTIONS);
session.page = await session.context.newPage();
session.page.setDefaultTimeout(15_000);
await session.page.goto(LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
await sleep(this.delays.pageSettleMs);
// Switch to the phone-code login tab
const tab = await this.firstVisible(session.page, SELECTORS.phoneTab);
if (!tab) throw new SelectorMissError("phone tab");
await tab.click();
await sleep(this.delays.tabSwitchMs);
// Fill the phone number
const phoneInput = await this.firstVisible(session.page, SELECTORS.phoneInput);
if (!phoneInput) throw new SelectorMissError("phone input");
await phoneInput.fill(normalized);
// Send the SMS code
const sendBtn = await this.firstVisible(session.page, SELECTORS.sendCodeButton);
if (!sendBtn) throw new SelectorMissError("send-code button");
await sendBtn.click();
session.phase = "sending_code";
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
await sleep(this.delays.sendCodeSettleMs);
// Risk-control slider → degrade to the manual headful flow
const risk = await this.firstVisible(session.page, SELECTORS.riskControl);
if (risk) {
session.captchaImage = await this.shot(session.page);
session.phase = "fallback_manual";
session.error =
"Volcano risk control (slider captcha) was triggered in headless mode. Use manual browser login.";
await this.closeBrowser(session);
return { ok: true, session: this.toView(session) };
}
// Image captcha may be required before the SMS is sent
const captchaInput = await this.firstVisible(session.page, SELECTORS.imageCaptchaInput);
if (captchaInput) {
session.captchaImage = await this.shot(session.page);
session.phase = "captcha_required";
} else {
session.phase = "waiting_code";
}
return { ok: true, session: this.toView(session) };
} catch (error) {
await this.closeBrowser(session);
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
session.error = errorMessage(error);
if (session.phase === "fallback_manual") {
session.error = `${session.error}. The login page layout may have changed — use manual browser login.`;
}
return { ok: true, session: this.toView(session) };
}
}
async submitCode(
sessionId: string,
code: string,
captcha?: string,
options?: SubmitCodeOptions
): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
const fromMfa = session.phase === "mfa_waiting";
if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) {
return this.toView(session);
}
const smsCode = String(code || "").trim();
if (!/^\d{4,6}$/.test(smsCode)) {
session.error = "Invalid SMS code";
return this.toView(session);
}
if (session.phase === "captcha_required" && !String(captcha || "").trim()) {
session.error = "Image captcha is required";
return this.toView(session);
}
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
try {
if (fromMfa) {
// MFA step-up (需要额外认证): fill the SECOND code into the modal
// input and confirm with 好的.
const mfaInput = await this.firstVisible(page, SELECTORS.mfaInput);
if (!mfaInput) throw new SelectorMissError("mfa code input");
await mfaInput.fill(smsCode);
const confirmBtn = await this.firstVisible(page, SELECTORS.mfaConfirmButton);
if (!confirmBtn) throw new SelectorMissError("mfa confirm button");
await confirmBtn.click();
} else {
const codeInput = await this.firstVisible(page, SELECTORS.smsCodeInput);
if (!codeInput) throw new SelectorMissError("sms code input");
await codeInput.fill(smsCode);
if (captcha) {
const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput);
if (captchaInput) await captchaInput.fill(String(captcha).trim());
}
const loginBtn = await this.firstVisible(page, SELECTORS.loginButton);
if (!loginBtn) throw new SelectorMissError("login button");
await loginBtn.click();
}
session.phase = "submitting";
session.error = null;
session.captchaImage = null;
return await this.pollUntilResolved(session, {
timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT,
fromMfa,
detectIdentity: true,
});
} catch (error) {
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
session.error = errorMessage(error);
await this.closeBrowser(session);
return this.toView(session);
}
}
/**
* Pick an identity on the console's /auth/login/select_identity page and
* finish the login. `index` maps to the identityOptions list previously
* returned in the session view.
*/
async selectIdentity(
sessionId: string,
index: number,
options?: SubmitCodeOptions
): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (session.phase !== "identity_required") {
return this.toView(session);
}
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
try {
// Click the requested identity card (the page pre-selects the first one,
// so only non-zero indexes need an explicit click).
if (index > 0) {
const itemSelector = await this.identityItemSelector(page);
if (!itemSelector) throw new SelectorMissError("identity item");
const items = page.locator(itemSelector);
const count = await items.count();
if (index < 0 || index >= count) {
session.error = `Identity index ${index} is out of range (${count} options)`;
return this.toView(session);
}
await items.nth(index).click();
await sleep(this.delays.tabSwitchMs);
}
// Submit the selection (button[type=submit] “登录” on the identity card)
const submitBtn = await this.firstVisible(page, SELECTORS.identitySubmitButton);
if (!submitBtn) throw new SelectorMissError("identity submit button");
await submitBtn.click();
session.phase = "submitting";
session.error = null;
session.identityOptions = null;
return await this.pollUntilResolved(session, {
timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT,
fromMfa: false,
detectIdentity: false,
});
} catch (error) {
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
session.error = errorMessage(error);
await this.closeBrowser(session);
return this.toView(session);
}
}
/** First clickable identity-item selector that matches at least one element. */
private async identityItemSelector(page: PwPage): Promise<string | null> {
for (const selector of SELECTORS.identityItem) {
try {
const count = await page.locator(selector).count();
if (count > 0) return selector;
} catch {
// try next candidate
}
}
return null;
}
/**
* Shared post-submit loop: waits for console cookies, watching for MFA
* step-up, identity selection, TOTP binding, and console error toasts.
*/
private async pollUntilResolved(
session: ActiveSession,
opts: { timeoutMs: number; fromMfa: boolean; detectIdentity: boolean }
): Promise<VolcLoginSessionView> {
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
const deadline = Date.now() + opts.timeoutMs;
let pollCount = 0;
let navigatedAfterLogin = false;
while (Date.now() < deadline) {
if (session.cancelled) {
session.phase = "cancelled";
await this.closeBrowser(session);
return this.toView(session);
}
if (Date.now() - session.createdAt > session.timeoutMs) {
session.phase = "timeout";
session.error = "Login timed out";
await this.closeBrowser(session);
return this.toView(session);
}
const cookies = await session.context.cookies();
const credentials: Record<string, string> = {};
for (const cookie of cookies as Array<{ name: string; domain: string; value: string }>) {
if (
REQUIRED_COOKIES.includes(cookie.name as (typeof REQUIRED_COOKIES)[number]) &&
isVolcengineCookieDomain(cookie.domain)
) {
credentials[cookie.name] = cookie.value;
}
}
if (REQUIRED_COOKIES.every((name) => credentials[name])) {
session.credentials = credentials;
session.phase = "success";
await this.closeBrowser(session);
return this.toView(session);
}
// TOTP binding modal (绑定MFA设备) — needs interactive Google
// Authenticator setup that cannot be driven headlessly.
const bindModal = await this.firstVisible(page, SELECTORS.mfaBindModal);
if (bindModal) {
session.phase = "fallback_manual";
session.error =
"The console requires binding an MFA device (Google Authenticator). Use manual browser login to complete the one-time setup.";
await this.closeBrowser(session);
return this.toView(session);
}
// MFA step-up modal (需要额外认证) — a second SMS code is required;
// hand control back to the user instead of timing out.
if (!opts.fromMfa) {
const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal);
if (mfaModal) {
session.phase = "mfa_waiting";
session.error = null;
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
return this.toView(session);
}
} else if (pollCount >= 5) {
// Wrong MFA code → the modal stays up; after a grace window hand
// control back so the user can enter the latest code.
const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal);
if (mfaModal) {
session.phase = "mfa_waiting";
session.error = "The MFA code was not accepted — enter the latest code";
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
return this.toView(session);
}
}
// Identity selection page (/auth/login/select_identity) — the phone
// maps to multiple accounts; scrape the options and let the user pick.
if (opts.detectIdentity && IDENTITY_URL_PATTERN.test(page.url())) {
const options = await this.scrapeIdentityOptions(page);
if (options.length > 0) {
session.phase = "identity_required";
session.error = null;
session.identityOptions = options;
return this.toView(session);
}
}
// Login redirected away from /auth/login but cookies are incomplete →
// the console app may need to run once to issue AccountID/userInfo.
// Give it the same landing page the manual flow uses.
if (!navigatedAfterLogin && pollCount >= 2 && !page.url().includes("/auth/login")) {
navigatedAfterLogin = true;
try {
await page.goto(ARK_CONSOLE_URL, {
waitUntil: "domcontentloaded",
timeout: 30_000,
});
} catch {
// navigation is best-effort; keep polling cookies
}
}
// Console error toast (e.g. wrong SMS code) → surface it early
const toast = await page
.locator('.arco-message-error, [class*="message-error"]')
.first()
.textContent({ timeout: 250 })
.catch(() => null);
if (toast && /验证码|密码|错误|失败|频繁/.test(toast)) {
session.phase = "error";
session.error = toast.trim().slice(0, 120);
await this.closeBrowser(session);
return this.toView(session);
}
await sleep(this.delays.pollIntervalMs);
pollCount++;
}
session.phase = "timeout";
session.error = await this.timeoutDiagnostics(session);
await this.closeBrowser(session);
return this.toView(session);
}
/** First identity-list selector that matches at least one element. */
private async identityListSelector(page: PwPage): Promise<string | null> {
for (const selector of SELECTORS.identityList) {
try {
const count = await page.locator(selector).count();
if (count > 0) return selector;
} catch {
// try next candidate
}
}
return null;
}
/** Scrape identity options from the select_identity page, in document order. */
private async scrapeIdentityOptions(
page: PwPage
): Promise<Array<{ index: number; label: string }>> {
const selector = await this.identityListSelector(page);
if (!selector) return [];
const items = page.locator(selector);
const count = await items.count();
const options: Array<{ index: number; label: string }> = [];
for (let i = 0; i < count; i++) {
const text =
(await items
.nth(i)
.textContent()
.catch(() => "")) || "";
const label = text.replace(/\s+/g, " ").trim();
if (label) options.push({ index: i, label: label.slice(0, 100) });
}
return options;
}
/**
* Build a diagnostic message for the cookie-poll timeout: page URL, cookies
* collected so far, and any blocking modal. Keeps future debugging cheap.
* When stuck on the identity-selection page, also dumps the page HTML to
* /tmp so a selector miss can be fixed from ground truth in one shot.
*/
private async timeoutDiagnostics(session: ActiveSession): Promise<string> {
const parts = ["Timed out waiting for the console session cookies"];
try {
if (session.page) {
parts.push(`url=${session.page.url()}`);
const cookies = (await session.context.cookies()) as Array<{
name: string;
domain: string;
}>;
const present = REQUIRED_COOKIES.filter((name) =>
cookies.some((c) => c.name === name && isVolcengineCookieDomain(c.domain))
);
parts.push(
`cookies=[${present.join(",") || "none of digest/AccountID/csrfToken/userInfo"}]`
);
const bindModal = await this.firstVisible(session.page, SELECTORS.mfaBindModal);
if (bindModal) parts.push("blocked by 绑定MFA设备 modal");
const mfaModal = await this.firstVisible(session.page, SELECTORS.mfaModal);
if (mfaModal) parts.push("blocked by 需要额外认证 modal");
const risk = await this.firstVisible(session.page, SELECTORS.riskControl);
if (risk) parts.push("blocked by risk-control slider");
if (IDENTITY_URL_PATTERN.test(session.page.url())) {
const dump = await this.dumpPageHtml(session);
if (dump) parts.push(`identityPageHtml=${dump}`);
}
}
} catch {
// diagnostics are best-effort
}
return parts.join(" · ");
}
/** Best-effort page HTML dump for debugging selector misses. */
private async dumpPageHtml(session: ActiveSession): Promise<string | null> {
try {
const { writeFile } = await import("fs/promises");
const path = `/tmp/omniroute-volc-select-identity-${session.sessionId.slice(0, 8)}.html`;
await writeFile(path, await session.page.content(), "utf8");
return path;
} catch {
return null;
}
}
async resendCode(sessionId: string): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
const fromMfa = session.phase === "mfa_waiting";
if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) {
return this.toView(session);
}
if (Date.now() < session.resendAvailableAt) {
return this.toView(session);
}
const page = session.page;
if (!page) {
session.phase = "error";
session.error = "Browser session is gone — restart the login";
return this.toView(session);
}
try {
// In the MFA step-up modal the button is 重发校验码; on the login form
// it counts down ("60s后重发" etc.) — try the fresh label first, then
// any 重发/重新获取 variant.
const resendSelectors = fromMfa
? [...SELECTORS.mfaResendButton]
: [
'button:has-text("获取验证码")',
'button:has-text("重发")',
'button:has-text("重新获取")',
'button:has-text("重新发送")',
];
const btn = await this.firstVisible(page, resendSelectors);
if (!btn) throw new SelectorMissError("resend button");
const disabled = await btn.isDisabled().catch(() => false);
if (disabled) {
session.error = "Resend is still cooling down on the login page";
return this.toView(session);
}
await btn.click();
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
await sleep(this.delays.sendCodeSettleMs);
if (fromMfa) {
// Stay in mfa_waiting — the modal persists until a valid code lands.
session.phase = "mfa_waiting";
session.error = null;
return this.toView(session);
}
const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput);
if (captchaInput) {
session.captchaImage = await this.shot(page);
session.phase = "captcha_required";
} else {
session.captchaImage = null;
session.phase = "waiting_code";
}
session.error = null;
return this.toView(session);
} catch (error) {
session.phase = "error";
session.error = errorMessage(error);
await this.closeBrowser(session);
return this.toView(session);
}
}
async cancel(sessionId: string): Promise<VolcLoginSessionView | null> {
const session = this.sessions.get(sessionId);
if (!session) return null;
if (isTerminal(session.phase)) return this.toView(session);
session.cancelled = true;
session.phase = "cancelled";
await this.closeBrowser(session);
return this.toView(session);
}
// ─── Internals ───────────────────────────────────────────────────────────
private toView(session: ActiveSession): VolcLoginSessionView {
const view: VolcLoginSessionView = {
sessionId: session.sessionId,
phase: session.phase,
phoneMasked: maskPhone(session.phone),
error: session.error,
captchaImage: session.phase === "captcha_required" ? session.captchaImage : null,
resendAvailableAt: session.resendAvailableAt,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
if (session.phase === "mfa_waiting") view.mfaRequired = true;
if (session.phase === "identity_required" && session.identityOptions) {
view.identityOptions = session.identityOptions;
}
if (session.phase === "success" && session.credentials) view.credentials = session.credentials;
if (session.binding !== undefined) view.binding = session.binding;
return view;
}
private async closeBrowser(session: ActiveSession): Promise<void> {
try {
await session.browser?.close?.();
} catch {
// browser may already be gone
} finally {
session.browser = null;
session.context = null;
session.page = null;
}
}
/** Screenshot for captcha rendering; null when capture fails. */
private async shot(page: PwPage): Promise<string | null> {
try {
const target = await this.firstVisible(page, SELECTORS.captchaShot);
const buffer: Buffer | null = target
? await target.screenshot({ type: "png" })
: await page.screenshot({ type: "png" });
return buffer ? `data:image/png;base64,${buffer.toString("base64")}` : null;
} catch {
return null;
}
}
private async firstVisible(
page: PwPage,
selectors: readonly string[]
): Promise<PwLocator | null> {
for (const selector of selectors) {
try {
const locator = page.locator(selector).first();
if (await locator.isVisible({ timeout: 2_000 })) return locator;
} catch {
// try next candidate
}
}
return null;
}
/** Close and drop sessions past their TTL; keep terminal ones briefly for status reads. */
private expireSessions(): void {
const now = Date.now();
for (const [id, session] of this.sessions) {
const age = now - session.createdAt;
const terminal = isTerminal(session.phase);
if (terminal && age > 10 * 60_000) {
this.sessions.delete(id);
} else if (!terminal && age > session.timeoutMs + 60_000) {
session.phase = "timeout";
session.error = "Session expired";
void this.closeBrowser(session);
this.sessions.delete(id);
}
}
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
class SelectorMissError extends Error {
constructor(element: string) {
super(`Login page element not found: ${element}`);
}
}
function isTerminal(phase: VolcLoginPhase): boolean {
return (
phase === "success" ||
phase === "error" ||
phase === "timeout" ||
phase === "cancelled" ||
phase === "fallback_manual"
);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
// ─── Singleton ──────────────────────────────────────────────────────────────
export const volcengineConsoleAutoLoginService = new VolcengineConsoleAutoLoginService();

View File

@@ -137,7 +137,7 @@ function convertGeminiContent(content) {
if (part.functionCall) {
toolCalls.push({
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
id: part.functionCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type: "function",
function: {
name: part.functionCall.name,

View File

@@ -1155,7 +1155,12 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
// Keyed by index, not insertion order — readers that need call order for
// parallel calls closed out of order should sort by this key rather than
// relying on Map iteration order.
// Responses→Claude uses this same shared map for Claude block lifecycle
// state. Preserve those fields when adding the completed-call summary;
// replacing the entry makes the arguments chunk look like a new unnamed
// tool and emits a duplicate empty content_block_start.
state.toolCalls.set(currentIndex, {
...state.toolCalls.get(currentIndex),
id: callId,
index: currentIndex,
type: "function",

View File

@@ -0,0 +1,34 @@
/**
* Cookie-domain matching for browser-driven credential capture.
*
* Every in-app / console login flow harvests cookies out of a Playwright
* context and persists them as operator credentials, so "is this cookie from
* the site I sent the browser to?" is an authorization decision. A substring
* test is not one: `domain.includes("example.com")` also accepts
* `example.com.attacker.tld` and `notexample.com`, which lets a look-alike host
* hand us cookies we then store as the operator's real credentials
* (CodeQL js/incomplete-url-substring-sanitization).
*
* A cookie domain is matched by exact host or dot-boundary suffix — nothing
* else. Leading dots (the RFC 6265 "domain-matches any subdomain" spelling) and
* case are normalized away on both sides.
*/
export function matchesCookieDomain(
cookieDomain: string | undefined,
expectedDomain: string | undefined
): boolean {
const expected = normalizeCookieDomain(expectedDomain);
if (!expected) return false;
const actual = normalizeCookieDomain(cookieDomain);
if (!actual) return false;
return actual === expected || actual.endsWith(`.${expected}`);
}
function normalizeCookieDomain(domain: string | undefined): string {
return String(domain || "")
.trim()
.replace(/^\.+/, "")
.toLowerCase();
}

View File

@@ -351,10 +351,7 @@ function sanitizeTransportError(
typeof source.code === "string" && /^[A-Z0-9_:-]{1,64}$/.test(source.code)
? source.code
: fallbackCode;
if (
typeof source.errorCode === "string" &&
/^[a-zA-Z0-9_:-]{1,64}$/.test(source.errorCode)
) {
if (typeof source.errorCode === "string" && /^[a-zA-Z0-9_:-]{1,64}$/.test(source.errorCode)) {
sanitized.errorCode = source.errorCode;
}
if (typeof source.statusCode === "number" && Number.isFinite(source.statusCode)) {
@@ -547,10 +544,7 @@ export function resolveProxyForRequest(targetUrl) {
* Dependency-internal TimeoutError/AbortError values are transport failures and
* retain the normal safe-method fallback behavior.
*/
function isCallerAbort(
_error: unknown,
signal: AbortSignal | null | undefined
): boolean {
function isCallerAbort(_error: unknown, signal: AbortSignal | null | undefined): boolean {
return signal?.aborted === true;
}
@@ -573,8 +567,7 @@ export async function runWithProxyContext(
// sentinel must remain direct without being mistaken for a proxy config.
const currentContext = proxyContext.getStore();
const inheritsDirect = currentContext === DIRECT_PROXY_CONTEXT && !proxyConfig;
const effectiveProxyConfig =
proxyConfig || (inheritsDirect ? null : currentContext) || null;
const effectiveProxyConfig = proxyConfig || (inheritsDirect ? null : currentContext) || null;
const contextValue = inheritsDirect ? DIRECT_PROXY_CONTEXT : effectiveProxyConfig;
const resolvedProxyUrl = effectiveProxyConfig ? proxyConfigToUrl(effectiveProxyConfig) : null;
@@ -711,6 +704,11 @@ export async function runWithProxyContext(
});
}
/** Run a request with an explicit direct-egress sentinel, bypassing proxy env/context lookup. */
export function runWithDirectFetchContext<T>(fn: () => T): T {
return proxyContext.run(DIRECT_PROXY_CONTEXT, fn);
}
/**
* Like {@link runWithProxyContext}, but if the assigned proxy is unreachable or fails
* its pre-checks the request can degrade to a DIRECT connection instead of throwing.
@@ -732,6 +730,12 @@ async function patchedFetch(
options: FetchWithDispatcherOptions = {},
deps: ProxyFetchDeps = {}
) {
// Explicit direct contexts must win even when a caller supplied a stale
// dispatcher. Native fetch preserves direct streaming semantics.
if (proxyContext.getStore() === DIRECT_PROXY_CONTEXT) {
return originalFetch(input, options);
}
if (options?.dispatcher) {
// When a dispatcher is present, we MUST use the undici library fetch
// to ensure version compatibility. Node 22 built-in fetch (undici v6)
@@ -1133,9 +1137,7 @@ async function patchedFetch(
);
const sanitized = sanitizeTransportError(
error,
originalMsg
? `Proxy request failed: ${originalMsg}`
: "Proxy request failed",
originalMsg ? `Proxy request failed: ${originalMsg}` : "Proxy request failed",
"PROXY_REQUEST_FAILED"
);
console.error(
@@ -1190,8 +1192,7 @@ export async function runWithTlsTracking<T>(
providerOrIdentityOrFn: string | null | undefined | TlsTrackingIdentity | (() => T),
maybeFn?: () => T
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }> {
const legacyFn =
typeof providerOrIdentityOrFn === "function" ? providerOrIdentityOrFn : maybeFn;
const legacyFn = typeof providerOrIdentityOrFn === "function" ? providerOrIdentityOrFn : maybeFn;
if (typeof legacyFn !== "function") {
throw new TypeError("runWithTlsTracking requires a callback function");
}
@@ -1201,8 +1202,7 @@ export async function runWithTlsTracking<T>(
typeof providerOrIdentityOrFn !== "function"
? providerOrIdentityOrFn
: {
provider:
typeof providerOrIdentityOrFn === "string" ? providerOrIdentityOrFn : undefined,
provider: typeof providerOrIdentityOrFn === "string" ? providerOrIdentityOrFn : undefined,
};
const store: TlsFingerprintStore = {
used: false,
@@ -1214,10 +1214,7 @@ export async function runWithTlsTracking<T>(
}
/** Check whether TLS fingerprint transport is enabled for this route identity. */
export function isTlsFingerprintActive(
provider?: string | null,
proxied = false
): boolean {
export function isTlsFingerprintActive(provider?: string | null, proxied = false): boolean {
return (
isTlsFingerprintEnabled() &&
activeTlsClient.available &&

11
package-lock.json generated
View File

@@ -25588,6 +25588,17 @@
"node": ">= 14"
}
},
"node_modules/libxmljs2/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/libxmljs2/node_modules/cacache": {
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",

View File

@@ -0,0 +1,36 @@
/**
* Decide whether the Next.js build should alias `better-sqlite3` to the
* build-time stub (src/lib/db/better-sqlite3.stub.js).
*
* History (#11343): the alias was UNCONDITIONAL, added to keep the bundler from
* tracing the native addon into a Next.js build worker, whose thread teardown
* can abort with SIGABRT (assertion in node::RemoveEnvironmentCleanupHook) and
* leave the build without standalone output (#10060).
*
* The premise recorded next to that alias — "runtime still uses the real
* package via serverExternalPackages" — does not hold. A Turbopack
* `resolveAlias` rewrites the request BEFORE the externals check runs, so
* `better-sqlite3` becomes a relative path, no longer matches the
* `serverExternalPackages` entry, and the stub is baked into the bundle. Every
* artifact built from that config answered HTTP 500 on every route: the stub's
* default export is not a constructor, the sync driver chain fell through to
* `node:sqlite` and then sql.js, and the instrumentation hook aborted at boot.
*
* This is the same failure shape as #6344 (the @/mitm/manager stub shipping to
* every npm/Electron/VPS artifact), so it gets the same treatment: the alias is
* opt-in, and a default build gets the real, externalized native package.
*
* Set OMNIROUTE_BETTER_SQLITE3_STUB=1 ONLY on a build host that actually hits
* the SIGABRT worker teardown, and never for an artifact that will be run —
* the resulting bundle cannot open a database.
*/
export function shouldStubBetterSqlite3(env = process.env) {
return env.OMNIROUTE_BETTER_SQLITE3_STUB === "1";
}
/** Turbopack resolveAlias fragment for `better-sqlite3`, derived from the env. */
export function betterSqlite3AliasFor(env = process.env) {
return shouldStubBetterSqlite3(env)
? { "better-sqlite3": "./src/lib/db/better-sqlite3.stub.js" }
: {};
}

View File

@@ -69,6 +69,7 @@ const files = walk(COMMANDS_DIR);
const usedKeys = collectTKeys(files);
const en = loadJson(join(LOCALES_DIR, "en.json"));
const ptBR = loadJson(join(LOCALES_DIR, "pt-BR.json"));
const zhLocales = ["zh-CN", "zh-TW"].map((n) => [n, loadJson(join(LOCALES_DIR, `${n}.json`))]);
const enKeys = flattenKeys(en);
let errors = 0;
@@ -95,6 +96,19 @@ if (missingTopLevel.length > 0) {
console.log(`[cli-i18n] ✓ pt-BR.json has all ${enTopLevel.length} top-level sections`);
}
// Check 3: zh-CN and zh-TW have full key parity with en.json
for (const [name, cat] of zhLocales) {
const catKeys = flattenKeys(cat);
const missingKeys = [...enKeys].filter((k) => !catKeys.has(k));
if (missingKeys.length > 0) {
console.error(`[cli-i18n] Keys in en.json missing from ${name}.json:`);
for (const k of missingKeys) console.error(`${k}`);
errors += missingKeys.length;
} else {
console.log(`[cli-i18n] ✓ ${name}.json has full parity (${enKeys.size} keys)`);
}
}
if (errors > 0) {
console.error(`[cli-i18n] FAIL — ${errors} error(s) found`);
process.exit(1);

View File

@@ -111,6 +111,18 @@ export function classifyFragments({ fragments = [], changelog = "" }) {
return { stale, keep };
}
/**
* Count a stale list by how each entry was matched, for the human report line.
* classifyFragments only ever sets matchedBy to "pr-number" (the filename convention) or
* "text" (the normalized-bullet fallback); the summary must bucket under those exact values.
* Pure - the two counts always add up to stale.length and never mislabel a category.
*/
export function summarizeStale(stale) {
const byPrNumber = (stale || []).filter((s) => s.matchedBy === "pr-number").length;
const byText = (stale || []).filter((s) => s.matchedBy === "text").length;
return { byPrNumber, byText };
}
export function readFragments(root) {
const out = [];
for (const sub of FRAGMENT_DIRS) {
@@ -141,9 +153,8 @@ function main(argv) {
return 0;
}
const byRef = stale.filter((s) => s.matchedBy === "ref").length;
const byText = stale.length - byRef;
process.stdout.write(` matched by ref: ${byRef} · by text: ${byText}\n`);
const { byPrNumber, byText } = summarizeStale(stale);
process.stdout.write(` matched by pr-number: ${byPrNumber} · by text: ${byText}\n`);
for (const s of stale) process.stdout.write(` ${apply ? "removed" : "stale"}: ${s.rel}${s.reason}\n`);
if (!apply) {

View File

@@ -57,6 +57,7 @@ import CustomModelsSection from "./components/CustomModelsSection";
import ConnectionsListPanel from "./components/ConnectionsListPanel";
import CoolingConnectionsPanel from "./components/CoolingConnectionsPanel";
import ConnectionsHeaderToolbar from "./components/ConnectionsHeaderToolbar";
import VolcengineConnectModal from "./components/VolcengineConnectModal";
import ProviderAccountRoutingCard from "../../settings/components/ProviderAccountRoutingCard";
import ZedImportCard from "./components/ZedImportCard";
import CursorAgentNudge from "./components/CursorAgentNudge";
@@ -79,6 +80,7 @@ export default function ProviderDetailPageClient() {
const [showOAuthModal, _setShowOAuthModal] = useState(false);
const [reauthConnection, setReauthConnection] = useState<ConnectionRowConnection | null>(null);
const [showKimiAuthMethodModal, setShowKimiAuthMethodModal] = useState(false);
const [showVolcengineConnectModal, setShowVolcengineConnectModal] = useState(false);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false);
const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState<string | undefined>();
@@ -92,6 +94,7 @@ export default function ProviderDetailPageClient() {
const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false);
const [importGeminiModalOpen, setImportGeminiModalOpen] = useState(false);
const [importGrokCliModalOpen, setImportGrokCliModalOpen] = useState(false);
const [connectingVolcengineAccount, setConnectingVolcengineAccount] = useState(false);
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
const isCommandCode = providerId === "command-code";
@@ -381,6 +384,43 @@ export default function ProviderDetailPageClient() {
openApiKeyAddFlow();
}, [providerId, isOAuth, openApiKeyAddFlow]);
// Legacy manual flow: headful browser login on the machine running OmniRoute.
// Kept as the fallback for the phone/SMS auto-login modal.
const connectVolcengineAccountManually = useCallback(async () => {
setConnectingVolcengineAccount(true);
try {
const response = await fetch("/api/providers/volcengine-plan/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timeout: 300_000 }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data?.success) {
throw new Error(data?.error || "Failed to connect Volcano account");
}
const results = Array.isArray(data?.binding?.results) ? data.binding.results : [];
const connected = results.filter((item: any) => item?.ok).length;
const failed = results.filter((item: any) => item && item.ok === false && item.available);
if (connected > 0) {
notify.success(`Connected ${connected} Volcano plan${connected > 1 ? "s" : ""}`);
}
if (failed.length > 0) {
notify.error(
failed.map((item: any) => `${item.plan}: ${item.error || "failed"}`).join("; ")
);
}
await fetchConnections();
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to connect Volcano account");
} finally {
setConnectingVolcengineAccount(false);
}
}, [fetchConnections, notify]);
const connectVolcengineAccount = useCallback(() => {
setShowVolcengineConnectModal(true);
}, []);
const {
commandCodeAuthState,
handleCloseAddApiKeyModal,
@@ -595,6 +635,8 @@ export default function ProviderDetailPageClient() {
gateConnectionFlow={gateConnectionFlow}
openApiKeyAddFlow={openApiKeyAddFlow}
openPrimaryAddFlow={openPrimaryAddFlow}
connectVolcengineAccount={connectVolcengineAccount}
connectingVolcengineAccount={connectingVolcengineAccount}
openExternalLinkFlow={openExternalLinkFlow}
handleOpenCommandCodeConnect={handleOpenCommandCodeConnect}
commandCodeAuthState={commandCodeAuthState}
@@ -868,6 +910,16 @@ export default function ProviderDetailPageClient() {
setShowTutorialModal={setShowTutorialModal}
t={t}
/>
{/* Volcano Engine console phone/SMS auto-login (falls back to manual browser login) */}
<VolcengineConnectModal
isOpen={showVolcengineConnectModal}
onClose={() => setShowVolcengineConnectModal(false)}
onFallbackManual={connectVolcengineAccountManually}
onConnected={fetchConnections}
notify={notify}
t={t}
/>
</div>
);
}

View File

@@ -40,6 +40,8 @@ type ConnectionsHeaderToolbarProps = {
gateConnectionFlow: (callback: () => void) => void;
openApiKeyAddFlow: () => void;
openPrimaryAddFlow: () => void;
connectVolcengineAccount?: () => void;
connectingVolcengineAccount?: boolean;
openExternalLinkFlow: () => void;
handleOpenCommandCodeConnect: () => void;
commandCodeAuthState: { phase: string };
@@ -86,6 +88,8 @@ export default function ConnectionsHeaderToolbar({
gateConnectionFlow,
openApiKeyAddFlow,
openPrimaryAddFlow,
connectVolcengineAccount,
connectingVolcengineAccount,
openExternalLinkFlow,
handleOpenCommandCodeConnect,
commandCodeAuthState,
@@ -303,6 +307,19 @@ export default function ConnectionsHeaderToolbar({
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openPrimaryAddFlow)}>
{providerSupportsPat ? providerText(t, "addPat", "Add PAT") : t("add")}
</Button>
{(providerId === "volcengine-agent-plan" ||
providerId === "volcengine-coding-plan") &&
connectVolcengineAccount && (
<Button
size="sm"
variant="secondary"
icon="login"
loading={connectingVolcengineAccount}
onClick={() => gateConnectionFlow(connectVolcengineAccount)}
>
{providerText(t, "connectVolcengineAccount", "Connect Volcano Account")}
</Button>
)}
{providerId === "qoder" && (
<Button
size="sm"

View File

@@ -0,0 +1,591 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Input, Modal } from "@/shared/components";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
/**
* VolcengineConnectModal — phone/SMS-code login for the Volcano Engine console.
*
* Drives the session-based auto login API:
* POST /api/providers/volcengine-plan/connect {phone}
* POST /api/providers/volcengine-plan/connect/{id}/code {code, captcha?}
* GET /api/providers/volcengine-plan/connect/{id}/status
* POST /api/providers/volcengine-plan/connect/{id}/resend
* POST /api/providers/volcengine-plan/connect/{id}/cancel
*
* Falls back to the legacy manual headful-browser flow (same POST /connect
* endpoint without a phone) when risk control or a layout change degrades
* the headless session.
*/
type SessionPhase =
| "starting"
| "sending_code"
| "waiting_code"
| "captcha_required"
| "submitting"
| "mfa_waiting"
| "identity_required"
| "success"
| "error"
| "timeout"
| "cancelled"
| "fallback_manual";
interface SessionView {
sessionId: string;
phase: SessionPhase;
phoneMasked: string;
error: string | null;
captchaImage: string | null;
resendAvailableAt: number;
mfaRequired?: boolean;
identityOptions?: Array<{ index: number; label: string }>;
binding?: {
results?: Array<{
plan: string;
available: boolean;
ok: boolean;
error?: string | null;
}>;
error?: string;
};
}
const PHONE_STORAGE_KEY = "omniroute.volcengine.phone";
const TERMINAL_PHASES: SessionPhase[] = [
"success",
"error",
"timeout",
"cancelled",
"fallback_manual",
];
function isTerminal(phase: SessionPhase | undefined): boolean {
return !!phase && TERMINAL_PHASES.includes(phase);
}
type VolcengineConnectModalProps = {
isOpen: boolean;
onClose: () => void;
/** Legacy headful-browser login (opens on the server machine) */
onFallbackManual: () => void;
/** Refresh connections after a successful bind */
onConnected: () => void | Promise<void>;
notify: {
success: (message: string, title?: string) => void;
error: (message: string, title?: string) => void;
};
t: ProviderMessageTranslator;
};
export default function VolcengineConnectModal({
isOpen,
onClose,
onFallbackManual,
onConnected,
notify,
t,
}: VolcengineConnectModalProps) {
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [captcha, setCaptcha] = useState("");
const [session, setSession] = useState<SessionView | null>(null);
const [starting, setStarting] = useState(false);
const [submittingCode, setSubmittingCode] = useState(false);
const [resending, setResending] = useState(false);
const [selectingIdentity, setSelectingIdentity] = useState(false);
const [resendCountdown, setResendCountdown] = useState(0);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// ── lifecycle ────────────────────────────────────────────────────────────
const stopTimers = useCallback(() => {
if (pollTimer.current) {
clearInterval(pollTimer.current);
pollTimer.current = null;
}
}, []);
const reset = useCallback(() => {
stopTimers();
setSession(null);
setCode("");
setCaptcha("");
setResendCountdown(0);
}, [stopTimers]);
useEffect(() => {
if (!isOpen) {
// Leaving the modal cancels an in-flight session server-side.
const active = session && !isTerminal(session.phase) ? session : null;
if (active) {
void fetch(`/api/providers/volcengine-plan/connect/${active.sessionId}/cancel`, {
method: "POST",
}).catch(() => {});
}
reset();
return;
}
const saved = typeof window !== "undefined" ? localStorage.getItem(PHONE_STORAGE_KEY) : null;
if (saved) setPhone(saved);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
useEffect(() => stopTimers, [stopTimers]);
// resend countdown ticker
const resendAvailableAt = session?.resendAvailableAt ?? 0;
const sessionId = session?.sessionId;
const sessionPhase = session?.phase;
useEffect(() => {
if (!sessionId || isTerminal(sessionPhase)) return;
const tick = () => {
setResendCountdown(Math.max(0, Math.ceil((resendAvailableAt - Date.now()) / 1000)));
};
tick();
const timer = setInterval(tick, 1000);
return () => clearInterval(timer);
}, [sessionId, sessionPhase, resendAvailableAt]);
// ── status polling ──────────────────────────────────────────────────────
const startPolling = useCallback(
(sessionId: string) => {
stopTimers();
pollTimer.current = setInterval(async () => {
try {
const response = await fetch(
`/api/providers/volcengine-plan/connect/${sessionId}/status`
);
const data = await response.json().catch(() => ({}));
if (data?.session) {
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
if (isTerminal(data.session.phase)) {
stopTimers();
if (data.session.phase === "success") void onConnected();
}
}
} catch {
// transient network error — keep polling until phase resolves
}
}, 1500);
},
[stopTimers, onConnected]
);
// ── actions ─────────────────────────────────────────────────────────────
const handleStart = useCallback(async () => {
const trimmed = phone.trim();
if (!trimmed) return;
setStarting(true);
try {
const response = await fetch("/api/providers/volcengine-plan/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: trimmed }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data?.success || !data?.session) {
throw new Error(data?.error || "Failed to start Volcano login");
}
setSession(data.session);
setResendCountdown(
Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000))
);
localStorage.setItem(PHONE_STORAGE_KEY, trimmed);
if (data.session.phase === "starting" || data.session.phase === "sending_code") {
startPolling(data.session.sessionId);
}
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to start Volcano login");
} finally {
setStarting(false);
}
}, [phone, notify, startPolling]);
const handleSubmitCode = useCallback(async () => {
if (!session) return;
setSubmittingCode(true);
try {
const payload: { code: string; captcha?: string } = { code: code.trim() };
if (session.phase === "captcha_required" && captcha.trim()) {
payload.captcha = captcha.trim();
}
const response = await fetch(
`/api/providers/volcengine-plan/connect/${session.sessionId}/code`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
const data = await response.json().catch(() => ({}));
if (data?.session) {
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
if (data.session.phase === "mfa_waiting") {
// A NEW code is required for the MFA step — clear the stale input.
setCode("");
setCaptcha("");
}
if (
data.session.phase === "starting" ||
data.session.phase === "sending_code" ||
data.session.phase === "submitting"
) {
startPolling(data.session.sessionId);
} else if (data.session.phase === "success") {
void onConnected();
}
} else {
throw new Error(data?.error || "Failed to submit verification code");
}
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to submit verification code");
} finally {
setSubmittingCode(false);
}
}, [session, code, captcha, notify, startPolling, onConnected]);
const handleSelectIdentity = useCallback(
async (index: number) => {
if (!session) return;
setSelectingIdentity(true);
try {
const response = await fetch(
`/api/providers/volcengine-plan/connect/${session.sessionId}/identity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index }),
}
);
const data = await response.json().catch(() => ({}));
if (data?.session) {
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
if (
data.session.phase === "starting" ||
data.session.phase === "sending_code" ||
data.session.phase === "submitting"
) {
startPolling(data.session.sessionId);
} else if (data.session.phase === "success") {
void onConnected();
}
} else {
throw new Error(data?.error || "Failed to select identity");
}
} catch (error) {
notify.error(error instanceof Error ? error.message : "Failed to select identity");
} finally {
setSelectingIdentity(false);
}
},
[session, notify, startPolling, onConnected]
);
const handleResend = useCallback(async () => {
if (!session || resendCountdown > 0) return;
setResending(true);
try {
const response = await fetch(
`/api/providers/volcengine-plan/connect/${session.sessionId}/resend`,
{ method: "POST" }
);
const data = await response.json().catch(() => ({}));
if (data?.session) {
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
setResendCountdown(
Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000))
);
setCode("");
setCaptcha("");
}
} catch {
notify.error("Failed to resend verification code");
} finally {
setResending(false);
}
}, [session, resendCountdown, notify]);
const handleCancelSession = useCallback(async () => {
if (!session) return;
try {
await fetch(`/api/providers/volcengine-plan/connect/${session.sessionId}/cancel`, {
method: "POST",
});
} catch {
// best-effort
}
reset();
}, [session, reset]);
// ── derived UI state ────────────────────────────────────────────────────
const phase = session?.phase;
const showPhoneStep = !session;
const showCodeStep =
phase === "waiting_code" ||
phase === "captcha_required" ||
phase === "mfa_waiting" ||
phase === "identity_required";
const showPolling = phase === "starting" || phase === "sending_code" || phase === "submitting";
const done = isTerminal(phase);
const mfaStep = phase === "mfa_waiting";
const bindingResults = session?.binding?.results || [];
const connectedPlans = bindingResults.filter((r) => r?.ok);
const bindingError = session?.binding?.error;
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
// ── render ──────────────────────────────────────────────────────────────
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={providerText(t, "connectVolcengineAccount", "Connect Volcano Account")}
size="md"
>
<div className="space-y-4">
{showPhoneStep && (
<>
<p className="text-sm text-text-muted">
{providerText(
t,
"volcAutoLoginDesc",
"Enter your phone number. OmniRoute sends a verification code via the Volcano Engine console and extracts the session cookies automatically — no browser interaction needed."
)}
</p>
<Input
label={providerText(t, "volcPhoneLabel", "Phone number")}
placeholder="13800000000"
value={phone}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPhone(e.target.value)}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") void handleStart();
}}
inputMode="numeric"
/>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={handleClose}>
{providerText(t, "cancel", "Cancel")}
</Button>
<Button size="sm" loading={starting} disabled={!phone.trim()} onClick={handleStart}>
{providerText(t, "volcSendCode", "Send verification code")}
</Button>
</div>
</>
)}
{showCodeStep && (
<>
<p className="text-sm text-text-muted">
{mfaStep
? providerText(
t,
"volcMfaDesc",
"Additional verification required (MFA). A NEW 6-digit code was sent to {phone} — enter it below to finish login.",
{ phone: session?.phoneMasked || "your phone" }
)
: phase === "identity_required"
? providerText(
t,
"volcIdentityDesc",
"Your phone number is linked to multiple Volcano Engine identities. Pick the one you want to log in with:"
)
: providerText(
t,
"volcCodeSent",
"A verification code was sent to {phone}. Enter it below to finish login.",
{ phone: session?.phoneMasked || "your phone" }
)}
</p>
{phase === "identity_required" && session?.identityOptions?.length ? (
<div className="space-y-2">
{session.identityOptions.map((option) => (
<button
key={option.index}
type="button"
disabled={selectingIdentity}
onClick={() => handleSelectIdentity(option.index)}
className="w-full rounded-lg border border-border p-3 text-left text-sm transition-colors hover:bg-sidebar disabled:cursor-not-allowed disabled:opacity-60"
>
{selectingIdentity ? (
<span className="flex items-center gap-2">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-primary border-t-transparent" />
{option.label}
</span>
) : (
option.label
)}
</button>
))}
</div>
) : (
<>
{phase === "captcha_required" && session?.captchaImage && (
<div className="space-y-2">
<p className="text-sm font-medium">
{providerText(
t,
"volcCaptchaLabel",
"Image captcha (required by the console)"
)}
</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={session.captchaImage}
alt="captcha"
className="max-h-40 rounded border border-border"
/>
<Input
placeholder={providerText(t, "volcCaptchaPlaceholder", "Captcha characters")}
value={captcha}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCaptcha(e.target.value)
}
/>
</div>
)}
<Input
label={
mfaStep
? providerText(t, "volcMfaCodeLabel", "MFA verification code")
: providerText(t, "volcCodeLabel", "Verification code")
}
placeholder="123456"
value={code}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCode(e.target.value)}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") void handleSubmitCode();
}}
inputMode="numeric"
maxLength={6}
/>
{session?.error && <p className="text-sm text-red-500">{session.error}</p>}
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
loading={resending}
disabled={resendCountdown > 0}
onClick={handleResend}
>
{resendCountdown > 0
? providerText(t, "volcResendIn", "Resend in {s}s", { s: resendCountdown })
: providerText(t, "volcResend", "Resend code")}
</Button>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={handleCancelSession}>
{providerText(t, "back", "Back")}
</Button>
<Button
size="sm"
loading={submittingCode}
disabled={code.trim().length < 4}
onClick={handleSubmitCode}
>
{providerText(t, "volcLogin", "Log in")}
</Button>
</div>
</div>
</>
)}
</>
)}
{showPolling && (
<div className="flex items-center gap-3 py-2">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-text-muted">
{phase === "submitting"
? providerText(
t,
"volcSubmitting",
"Submitting code and extracting console cookies..."
)
: providerText(t, "volcStarting", "Starting Volcano login...")}
</p>
</div>
)}
{done && phase === "success" && (
<div className="space-y-3">
<p className="text-sm font-medium text-green-600">
{providerText(t, "volcLoginSuccess", "Logged in to the Volcano Engine console")}
</p>
{bindingError ? (
<p className="text-sm text-red-500">
{providerText(t, "volcBindError", "Plan binding failed: {error}", {
error: bindingError,
})}
</p>
) : (
<div className="space-y-1 text-sm">
{connectedPlans.length > 0 ? (
connectedPlans.map((item) => (
<p key={item.plan} className="text-green-600">
{item.plan} plan connected
</p>
))
) : (
<p className="text-text-muted">
{providerText(
t,
"volcNoPlans",
"No Agent/Coding plans were detected on this account."
)}
</p>
)}
</div>
)}
<div className="flex justify-end">
<Button size="sm" onClick={handleClose}>
{providerText(t, "done", "Done")}
</Button>
</div>
</div>
)}
{done && phase !== "success" && (
<div className="space-y-3">
<p className="text-sm text-red-500">
{session?.error ||
(phase === "timeout"
? providerText(t, "volcTimeout", "Login timed out")
: phase === "cancelled"
? providerText(t, "volcCancelled", "Login cancelled")
: providerText(t, "volcFailed", "Login failed"))}
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={reset}>
{providerText(t, "retry", "Retry")}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
onClose();
onFallbackManual();
}}
>
{providerText(t, "volcManualLogin", "Manual browser login")}
</Button>
</div>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -24,6 +24,46 @@ interface SyncResult {
error?: string;
}
// Slider works in "checkpoint space": position p ∈ [0, 3] maps linearly onto
// these hour values, so the evenly spaced tick labels always match the thumb.
const INTERVAL_CHECKPOINTS = [1, 6, 24, 168];
const SNAP_THRESHOLD = 0.15;
function positionToHours(pos: number): number {
const p = Math.min(INTERVAL_CHECKPOINTS.length - 1, Math.max(0, pos));
const lower = Math.floor(p);
const upper = Math.ceil(p);
if (lower === upper) return INTERVAL_CHECKPOINTS[lower];
const t = p - lower;
return Math.round(
INTERVAL_CHECKPOINTS[lower] + (INTERVAL_CHECKPOINTS[upper] - INTERVAL_CHECKPOINTS[lower]) * t
);
}
function hoursToPosition(hours: number): number {
const cps = INTERVAL_CHECKPOINTS;
if (hours <= cps[0]) return 0;
for (let i = 0; i < cps.length - 1; i++) {
if (hours <= cps[i + 1]) {
return i + (hours - cps[i]) / (cps[i + 1] - cps[i]);
}
}
return cps.length - 1;
}
// Magnetic checkpoints: snap to a reference point when released nearby,
// otherwise keep the freely chosen position.
function snapPosition(pos: number): number {
for (let i = 0; i < INTERVAL_CHECKPOINTS.length; i++) {
if (Math.abs(pos - i) <= SNAP_THRESHOLD) return i;
}
return pos;
}
function formatInterval(hours: number): string {
return hours === 168 ? "7d" : `${hours}h`;
}
export default function ModelsDevSyncTab() {
const t = useTranslations("settings");
const [status, setStatus] = useState<ModelsDevStatus | null>(null);
@@ -32,7 +72,7 @@ export default function ModelsDevSyncTab() {
const [saving, setSaving] = useState(false);
const [enabled, setEnabled] = useState(false);
const [intervalHours, setIntervalHours] = useState(24);
const [draftIntervalHours, setDraftIntervalHours] = useState(24);
const [draftPos, setDraftPos] = useState(2);
const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>(
null
);
@@ -58,7 +98,7 @@ export default function ModelsDevSyncTab() {
const intervalMs = settingsData.modelsDevSyncInterval || 86400000;
const hours = Math.round(intervalMs / 3600000);
setIntervalHours(hours);
setDraftIntervalHours(hours);
setDraftPos(hoursToPosition(hours));
}
})
.catch((err) => {
@@ -126,7 +166,7 @@ export default function ModelsDevSyncTab() {
const updateInterval = async (hours: number) => {
const oldInterval = intervalHours;
setIntervalHours(hours);
setDraftIntervalHours(hours);
setDraftPos(hoursToPosition(hours));
try {
const res = await fetch("/api/settings", {
method: "PATCH",
@@ -135,20 +175,27 @@ export default function ModelsDevSyncTab() {
});
if (!res.ok) {
setIntervalHours(oldInterval);
setDraftIntervalHours(oldInterval);
setDraftPos(hoursToPosition(oldInterval));
setFeedback({ type: "error", message: t("enableSyncError") });
} else {
setFeedback({ type: "success", message: "Interval updated" });
}
} catch {
setIntervalHours(oldInterval);
setDraftIntervalHours(oldInterval);
setDraftPos(hoursToPosition(oldInterval));
setFeedback({ type: "error", message: "Network error" });
} finally {
setTimeout(() => setFeedback(null), 3000);
}
};
// Commit on release: snap to a checkpoint when near one, else keep free value.
const commitDraftInterval = () => {
const snapped = snapPosition(draftPos);
if (snapped !== draftPos) setDraftPos(snapped);
updateInterval(positionToHours(snapped));
};
if (loading) {
return (
<Card>
@@ -238,18 +285,20 @@ export default function ModelsDevSyncTab() {
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium">{t("modelsDevInterval")}</p>
<span className="text-sm font-mono tabular-nums text-blue-400">
{draftIntervalHours}h
{formatInterval(positionToHours(draftPos))}
</span>
</div>
<input
type="range"
min="1"
max="168"
step="1"
value={draftIntervalHours}
onChange={(e) => setDraftIntervalHours(parseInt(e.target.value))}
onMouseUp={(e) => updateInterval(parseInt((e.target as HTMLInputElement).value))}
onBlur={(e) => updateInterval(parseInt(e.target.value))}
min="0"
max={INTERVAL_CHECKPOINTS.length - 1}
step="any"
value={draftPos}
onChange={(e) => setDraftPos(parseFloat(e.target.value))}
onMouseUp={commitDraftInterval}
onTouchEnd={commitDraftInterval}
onBlur={commitDraftInterval}
aria-label={t("modelsDevInterval")}
className="w-full accent-blue-500"
/>
<div className="flex justify-between text-xs text-text-muted mt-1">

View File

@@ -25,6 +25,7 @@ import {
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
import { antigravityDegradedProjectState } from "@/lib/oauth/antigravityProjectGate";
import { syncToCloud } from "@/lib/cloudSync";
import { startLocalServer } from "@/lib/oauth/utils/server";
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
@@ -520,6 +521,12 @@ export async function POST(
exchangeTokens(provider, code, redirectUri, codeVerifier, normalizedState)
);
// #11284: when Cloud Code projectId discovery failed at connect time,
// SAVE the connection but mark it degraded (maintainer direction on
// #11284) — the refresh token stays stored and request-time bootstrap
// self-heals the row once Google assigns a project.
const degradedProject = antigravityDegradedProjectState(provider, tokenData);
// Normalize: if name is missing, use email or displayName as fallback so accounts
// always show a real label (e.g. user@gmail.com) instead of "Account #abc123"
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
@@ -542,14 +549,15 @@ export async function POST(
connection = await updateProviderConnection(matchId, {
...tokenData,
expiresAt,
testStatus: "active",
testStatus: degradedProject?.testStatus ?? "active",
...(degradedProject ?? {}),
isActive: true,
});
}
}
if (!connection) {
connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject)
);
}
@@ -558,6 +566,7 @@ export async function POST(
return NextResponse.json({
success: true,
...(degradedProject ? { warning: degradedProject.warning } : {}),
connection: {
id: connection.id,
provider: connection.provider,
@@ -739,6 +748,10 @@ export async function POST(
exchangeTokens(provider, params.code, redirectUri, codeVerifier, params.state)
);
// #11284: when Cloud Code projectId discovery failed at connect time,
// SAVE the connection but mark it degraded (maintainer direction).
const degradedProject = antigravityDegradedProjectState(provider, tokenData);
// Normalize: if name is missing, use email as fallback display label
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
tokenData.name = tokenData.email || tokenData.displayName;
@@ -765,14 +778,15 @@ export async function POST(
connection = await updateProviderConnection(matchId, {
...tokenData,
expiresAt,
testStatus: "active",
testStatus: degradedProject?.testStatus ?? "active",
...(degradedProject ?? {}),
isActive: true,
});
}
}
if (!connection) {
connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject)
);
}
@@ -780,6 +794,7 @@ export async function POST(
return NextResponse.json({
success: true,
...(degradedProject ? { warning: degradedProject.warning } : {}),
connection: {
id: connection.id,
provider: connection.provider,

View File

@@ -21,6 +21,11 @@ import {
import { autoSyncCodexProfilesFromLiveCatalog } from "@/lib/cli-helper/codexProfileAutoSync";
import { autoSyncClaudeProfilesFromLiveCatalog } from "@/lib/cli-helper/claudeProfileAutoSync";
import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability";
import {
fetchVolcPlanModels,
providerToVolcPlanKind,
} from "@/lib/providers/volcenginePlanModelDiscovery";
import { replaceSyncedAvailableModelsForConnection } from "@/lib/db/models";
import { GET as getProviderModels } from "../models/route";
import { isDegradedDiscovery } from "./degradedLocalCatalog";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
@@ -423,6 +428,84 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
logProvider = toNonEmptyString(connection.provider) || "unknown";
channelLabel = getModelSyncChannelLabel(connection);
// Volcano Ark plan providers: discover models live from the console API
// (cookie+csrf captured at bind time). The chat API has no /models
// endpoint, so the default discovery path below cannot serve them.
const volcPlanKind = providerToVolcPlanKind(logProvider);
if (volcPlanKind) {
const psd =
connection.providerSpecificData && typeof connection.providerSpecificData === "object"
? (connection.providerSpecificData as JsonRecord)
: {};
const cookie = toNonEmptyString(psd.volcConsoleCookie) || "";
const csrf = toNonEmptyString(psd.volcCsrfToken) || "";
const duration = Date.now() - start;
let discovered;
try {
discovered = await fetchVolcPlanModels(volcPlanKind, cookie, csrf);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
await saveCallLog({
method: "POST",
path: `/api/providers/${id}/sync-models`,
status: 401,
model: "model-sync",
provider: logProvider,
sourceFormat: "-",
connectionId: id,
duration,
error: message,
requestType: "model-sync",
...(channelLabel ? { responseBody: { channel: channelLabel } } : {}),
}).catch(() => undefined);
return NextResponse.json(
{ error: sanitizeErrorMessage(message) || "Volcano plan discovery failed" },
{ status: 401 }
);
}
const previous = await getSyncedAvailableModelsForConnection(logProvider, id);
const synced = await replaceSyncedAvailableModelsForConnection(logProvider, id, discovered);
const prevIds = new Set(previous.map((m) => String(m.id)));
const added = synced.filter((m) => !prevIds.has(String(m.id))).length;
const removed = previous.filter(
(m) => !synced.some((n) => String(n.id) === String(m.id))
).length;
await saveCallLog({
method: "GET",
path: `/api/providers/${id}/models`,
status: 200,
model: "model-sync",
provider: logProvider,
sourceFormat: "console-discovery",
connectionId: id,
duration: Date.now() - start,
requestType: "model-sync",
responseBody: {
source: "volcengine-plan-console-discovery",
plan: volcPlanKind,
syncedModels: synced.length,
added,
removed,
provider: logProvider,
channel: channelLabel,
mode,
},
}).catch(() => undefined);
return NextResponse.json({
ok: true,
provider: logProvider,
connectionId: id,
source: "volcengine-plan-console-discovery",
plan: volcPlanKind,
mode,
syncedModels: synced.length,
availableModelsCount: synced.length,
modelChanges: { added, removed, total: added + removed },
models: synced,
});
}
if (providerUsesCuratedModelsOnly(logProvider)) {
const [removedSyncedLists, removedImportedModelIds] = await Promise.all([
deleteSyncedAvailableModelsForProvider(logProvider),

View File

@@ -30,6 +30,7 @@ import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
import { shouldUseApiKeyConnectionTest } from "./webSessionTestDispatch";
import { testCodexAppServerConnection, makeDiagnosis } from "./codexAppServerHealth";
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
import { shouldClearErrorStateOnValidProbe } from "@/lib/usage/providerLimits";
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult";
@@ -1082,23 +1083,46 @@ export async function testSingleConnection(connectionId: string, validationModel
terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase());
const testFailureCooldownMs = result.valid ? 0 : 30_000; // 30s retry window
// A successful credential probe proves the KEY is valid. It does NOT prove the
// quota window reopened: the probe is a cheap auth/models call that never touches
// the chat quota a weekly cap applies to. Clearing an ACTIVE cooldown here — which
// the credential-health scheduler triggers for every connection every 300s — put
// `zai/glm-5.3` back to `active` / `rate_limited_until = NULL` within 30s of every
// restart, so combo dispatched it straight into the same weekly 429. Same rule as
// maybeClearRecoveredQuotaState: a future rateLimitedUntil is the 429 handler's
// hard statement and no poller may overrule it. Once it elapses, the next probe
// clears it normally.
const clearErrorState = shouldClearErrorStateOnValidProbe(
connection as { rateLimitedUntil?: string | null },
result.valid
);
const updateData: Record<string, any> = {
testStatus: result.valid ? "active" : "error",
lastError: result.valid ? null : result.error,
lastErrorAt: result.valid ? null : now,
testStatus: clearErrorState ? "active" : result.valid ? connection.testStatus : "error",
lastError: clearErrorState ? null : result.valid ? connection.lastError : result.error,
lastErrorAt: clearErrorState ? null : result.valid ? connection.lastErrorAt : now,
lastTested: now,
lastErrorType: result.valid ? null : diagnosis.type,
lastErrorSource: result.valid ? null : diagnosis.source,
errorCode: result.valid ? null : diagnosis.code || result.statusCode || null,
rateLimitedUntil:
result.valid || isTerminalFailure
? result.valid
? null
: connection.rateLimitedUntil || null
: new Date(Date.now() + testFailureCooldownMs).toISOString(),
lastErrorType: clearErrorState ? null : result.valid ? connection.lastErrorType : diagnosis.type,
lastErrorSource: clearErrorState
? null
: result.valid
? connection.lastErrorSource
: diagnosis.source,
errorCode: clearErrorState
? null
: result.valid
? connection.errorCode
: diagnosis.code || result.statusCode || null,
rateLimitedUntil: clearErrorState
? null
: isTerminalFailure
? connection.rateLimitedUntil || null
: result.valid
? connection.rateLimitedUntil || null
: new Date(Date.now() + testFailureCooldownMs).toISOString(),
};
if (result.valid) {
if (clearErrorState) {
updateData.backoffLevel = 0;
const psd = connection?.providerSpecificData as Record<string, unknown> | undefined;

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
/**
* POST /api/providers/volcengine-plan/connect/[sessionId]/cancel
* Cancel an auto phone login session and close its headless browser.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ sessionId: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const { sessionId } = await params;
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
const session = await volcengineConsoleAutoLoginService.cancel(sessionId);
if (!session) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
return NextResponse.json({ success: true, session });
} catch {
return NextResponse.json({ success: false, error: "Cancel failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
/**
* POST /api/providers/volcengine-plan/connect/[sessionId]/code
* Submit the SMS verification code (plus image captcha when required) for an
* auto phone login session. Returns the session view; binding runs lazily on
* the next status poll once credentials are extracted.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ sessionId: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const { sessionId } = await params;
const body = await request.json().catch(() => ({}));
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
const session = await volcengineConsoleAutoLoginService.submitCode(
sessionId,
String(body.code ?? ""),
typeof body.captcha === "string" ? body.captcha : undefined,
{ timeout }
);
if (!session) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
// Credentials ready → bind immediately so the response carries the outcome.
if (session.phase === "success") {
const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
bindVolcenginePlansFromConsoleCredentials(credentials)
);
return NextResponse.json({ success: true, session: bound ?? session });
}
return NextResponse.json({ success: false, session });
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
return NextResponse.json(
{ success: false, error: `Volcano code submission failed: ${message}` },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
/**
* POST /api/providers/volcengine-plan/connect/[sessionId]/identity
* Pick an identity on the console's select_identity page (the phone maps to
* multiple accounts) and finish the login + plan binding.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ sessionId: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const { sessionId } = await params;
const body = await request.json().catch(() => ({}));
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
const index = Number(body.index);
if (!Number.isInteger(index) || index < 0) {
return NextResponse.json(
{ success: false, error: "Invalid identity index" },
{ status: 400 }
);
}
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
const session = await volcengineConsoleAutoLoginService.selectIdentity(sessionId, index, {
timeout,
});
if (!session) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
// Credentials ready → bind immediately so the response carries the outcome.
if (session.phase === "success") {
const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
bindVolcenginePlansFromConsoleCredentials(credentials)
);
return NextResponse.json({ success: true, session: bound ?? session });
}
return NextResponse.json({ success: false, session });
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
return NextResponse.json(
{ success: false, error: `Volcano identity selection failed: ${message}` },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
/**
* POST /api/providers/volcengine-plan/connect/[sessionId]/resend
* Re-trigger the SMS verification code for an active login session.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ sessionId: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const { sessionId } = await params;
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
const session = await volcengineConsoleAutoLoginService.resendCode(sessionId);
if (!session) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
return NextResponse.json({ success: true, session });
} catch {
return NextResponse.json({ success: false, error: "Resend failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
/**
* GET /api/providers/volcengine-plan/connect/[sessionId]/status
* Poll an auto phone login session. When credentials have been extracted, the
* plan binding runs lazily (deduped) and its result is attached to the view.
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ sessionId: string }> }
): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const { sessionId } = await params;
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
const session = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
bindVolcenginePlansFromConsoleCredentials(credentials)
);
if (!session) {
return NextResponse.json(
{ success: false, error: "Unknown or expired Volcano login session" },
{ status: 404 }
);
}
return NextResponse.json({ success: session.phase === "success", session });
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
return NextResponse.json(
{ success: false, error: `Volcano login status failed: ${message}` },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
export async function POST(request: Request): Promise<NextResponse> {
const auth = await requireManagementAuth(request);
if (auth) return auth;
const body = await request.json().catch(() => ({}));
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
// Auto flow: phone present → start a session-based headless phone/SMS login.
if (typeof body.phone === "string" && body.phone.trim()) {
try {
const { volcengineConsoleAutoLoginService } =
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
const started = await volcengineConsoleAutoLoginService.startLogin(body.phone, { timeout });
if (!started.ok) {
return NextResponse.json({ success: false, error: started.error }, { status: 400 });
}
return NextResponse.json({ success: true, session: started.session });
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
return NextResponse.json(
{ success: false, error: `Volcano auto login failed to start: ${message}` },
{ status: 500 }
);
}
}
// Legacy manual flow: headful browser login on the server machine.
try {
const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts");
const login = await inAppLoginService.startLogin("volcengine-console", { timeout });
if (!login.success || !login.credentials) {
return NextResponse.json(
{ success: false, error: login.error || "Volcano console login failed" },
{ status: 400 }
);
}
const binding = await bindVolcenginePlansFromConsoleCredentials(login.credentials);
return NextResponse.json({ success: true, binding });
} catch (error) {
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
return NextResponse.json(
{ success: false, error: `Volcano account binding failed: ${message}` },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,5 @@
import { CORS_HEADERS } from "@/shared/utils/cors";
import { getLiveWsPath } from "@/shared/utils/wsPath";
import { getLiveWsPath, resolveLiveWsPublicUrl } from "@/shared/utils/wsPath";
import { authorizeWebSocketHandshake } from "@/lib/ws/handshake";
const WS_HANDSHAKE_HEADERS = {
@@ -13,9 +13,9 @@ const WS_HANDSHAKE_HEADERS = {
* env changes are honored, and only echoed when it is a ws:// or wss:// URL.
*/
function getLivePublicUrl(): string | null {
const publicUrl = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL;
if (!publicUrl) return null;
return publicUrl.startsWith("ws://") || publicUrl.startsWith("wss://") ? publicUrl : null;
// Runtime-resolved: a prebuilt image never carries a build-time NEXT_PUBLIC_*
// value, and this handshake is what the browser reads instead (#11331).
return resolveLiveWsPublicUrl();
}
function getWsProtocol() {

View File

@@ -58,6 +58,7 @@ export const DEFAULT_INTELLIGENT_WEIGHTS: IntelligentRoutingWeights = {
};
export const MODE_PACK_OPTIONS = [
{ id: "custom", label: "Custom / None (Use Sliders)", emoji: "tune" },
{ id: "ship-fast", label: "Ship Fast", emoji: "rocket_launch" },
{ id: "cost-saver", label: "Cost Saver", emoji: "savings" },
{ id: "quality-first", label: "Quality First", emoji: "target" },

View File

@@ -1,13 +1,19 @@
// Build-time stub for better-sqlite3 (#10060).
//
// Aliased in for the Next.js production build (turbopack + webpack) so the
// bundler never pulls the real native addon into a build worker. The native
// Statement destructor aborts with SIGABRT when a build worker thread exits
// OPT-IN ONLY — set OMNIROUTE_BETTER_SQLITE3_STUB=1 to alias it in, and only on
// a build host that actually hits the SIGABRT worker teardown: the native
// Statement destructor aborts when a Next.js build worker thread exits
// (assertion in node::RemoveEnvironmentCleanupHook, env == nullptr), which can
// leave the build with no standalone output. At runtime the real package is
// used (it is listed in serverExternalPackages, so it is require()'d natively,
// not bundled); this stub only stands in during the build, where the DB is
// never actually queried.
// leave the build with no standalone output.
//
// It is NOT a build-only stand-in. A Turbopack resolveAlias rewrites the
// request before the externals check, so aliasing `better-sqlite3` here also
// removes it from serverExternalPackages' reach and bakes THIS FILE into the
// shipped bundle. An artifact built with the flag on cannot open a database:
// the sync driver chain fails with "r(...) is not a constructor", falls through
// node:sqlite and sql.js, and the instrumentation hook aborts at boot, so every
// route answers HTTP 500. That is exactly what an unconditional alias shipped
// in #11343. See scripts/build/better-sqlite3-stub-flag.mjs.
class Database {
constructor() {}
prepare() {

View File

@@ -124,6 +124,27 @@ export function getEffectiveQuotaUsage(
return used;
}
/**
* Normalize a persisted `rate_limited_until` to epoch ms.
*
* The column is written in two shapes: epoch ms by `setConnectionRateLimitUntil`
* (the chat path) and an ISO-8601 string by `updateProviderConnection` (the
* dashboard/AUTH path). Returns null when the value is absent or unparseable —
* callers treat that as "no usable deadline".
*/
function parseCooldownUntilMs(value: string | number | null | undefined): number | null {
if (value == null || value === "") return null;
if (typeof value === "number") return Number.isFinite(value) ? value : null;
const raw = String(value).trim();
if (raw === "") return null;
if (/^\d+$/.test(raw)) {
const numeric = Number(raw);
return Number.isFinite(numeric) ? numeric : null;
}
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* T05: Startup crash-recovery — clear stale transient connection cooldowns.
*
@@ -138,9 +159,19 @@ export function getEffectiveQuotaUsage(
* - Only connections with `rate_limited_until IS NOT NULL` are touched.
* - Terminal states (`banned`, `expired`, `credits_exhausted`) are skipped —
* those require a deliberate credential change or operator reset.
* - Past timestamps are also cleared: they are already expired in the lazy
* - Past timestamps are cleared: they are already expired in the lazy
* expiry sense, but clearing them resets `backoffLevel` / transient error
* fields so the connection gets a clean slate on this fresh process.
* fields so the connection gets a clean slate on this fresh process. An
* unparseable timestamp is treated the same way — it can never expire
* lazily, so leaving it would strand the connection forever.
* - FUTURE timestamps are NEVER cleared. Clearing them was the original
* behaviour and it wiped legitimate multi-day quota cooldowns on every
* container recreate: a GLM weekly cap persisted until 2026-08-29 came
* back `active` with `rate_limited_until = NULL`, combo dispatched it
* immediately, and the connection re-earned a real upstream 429. A stale
* crash-backoff value is bounded by the engine's own cooldown cap, so
* honouring it costs at most that window — far less than burning quota
* against an upstream that is provably exhausted.
*
* Must be called once, early in the startup sequence, before any request
* is handled. Returns the number of connections that were cleared.
@@ -148,6 +179,7 @@ export function getEffectiveQuotaUsage(
export function clearStaleCrashCooldowns(): { cleared: number } {
const db = getDbInstance() as unknown as DbLike;
const now = new Date().toISOString();
const nowMs = Date.now();
// Fetch all connections that have a rate_limited_until set and are NOT in
// a terminal state. We do the terminal-status filter in JS to reuse the
@@ -156,13 +188,20 @@ export function clearStaleCrashCooldowns(): { cleared: number } {
const rows = db
.prepare(
`SELECT id, test_status FROM provider_connections WHERE rate_limited_until IS NOT NULL`
`SELECT id, test_status, rate_limited_until FROM provider_connections WHERE rate_limited_until IS NOT NULL`
)
.all() as Array<{ id: string; test_status: string | null }>;
.all() as Array<{
id: string;
test_status: string | null;
rate_limited_until: string | number | null;
}>;
const toReset = rows.filter((r) => {
const status = (r.test_status || "").trim().toLowerCase();
return !TERMINAL_STATUSES.has(status);
if (TERMINAL_STATUSES.has(status)) return false;
const untilMs = parseCooldownUntilMs(r.rate_limited_until);
// Unparseable → clear (cannot expire lazily). Future → keep.
return untilMs === null || untilMs <= nowMs;
});
if (toReset.length === 0) return { cleared: 0 };

View File

@@ -4,6 +4,7 @@ import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
import { getJobRegistry } from "@/lib/jobRegistry";
import { registerBudgetResetJob } from "@/lib/jobs/budgetResetJob";
import { registerTokenHealthCheck } from "@/lib/jobs/tokenHealthCheckJob";
import { backfillVolcPlanAutoSync } from "@/lib/providers/volcPlanAutoSyncBackfill";
// Initialize runtime background sync services once per server process.
let initialized = false;
@@ -31,6 +32,7 @@ export async function ensureCloudSyncInitialized() {
if (!initialized) {
try {
await initializeCloudSync();
await backfillVolcPlanAutoSync();
startModelSyncScheduler();
// startAll() runs each interval job's first tick synchronously, so it has to

View File

@@ -0,0 +1,61 @@
/**
* #11284 — Antigravity OAuth connect-time DEGRADE marking for accounts without
* a Cloud Code projectId. Shared helper used by the OAuth route's `exchange`,
* `poll-callback`, and the shared persistOAuthConnection path.
*
* Maintainer direction on #11284: do NOT reject the connect — SAVE the
* connection but mark it degraded, so the refresh token stays stored and the
* request-time bootstrap can self-heal it (persistDiscoveredAntigravityProjectId
* flips the row back to active). Confirmed-BYOP accounts get disabled by
* markAntigravityMissingCloudCodeProject() on the first dispatch instead.
*/
export type AntigravityDegradedProjectState = {
/** Persist with this status instead of "active". */
testStatus: "degraded";
errorCode: string;
lastErrorType: string;
lastError: string;
/** Non-fatal warning surfaced in the connect response for the dashboard. */
warning: string;
};
/** Providers whose Cloud Code projectId is expected at connect time. */
const PROJECT_EXPECTED_PROVIDERS = new Set(["antigravity", "agy"]);
const BYOP_WARNING =
"Connected, but Google did not assign a Cloud Code project to this account (BYOP). " +
"Create a GCP Project at console.cloud.google.com and complete Gemini Code Assist onboarding; " +
"the account is marked degraded until then and cannot serve requests.";
const DISCOVERY_FAILED_WARNING =
"Connected, but the Google Cloud Code projectId could not be discovered during login " +
"(loadCodeAssist/onboardUser failed). The account is marked degraded; discovery retries " +
"automatically on the first request.";
/**
* #11284: when projectId discovery failed at connect time, return the degrade
* fields to persist (testStatus:"degraded" + typed error markers) instead of
* silently saving a false "active". Returns null for healthy payloads.
*/
export function antigravityDegradedProjectState(
provider: string,
tokenData: Record<string, unknown> | null | undefined
): AntigravityDegradedProjectState | null {
if (!PROJECT_EXPECTED_PROVIDERS.has(provider)) return null;
const outcome = tokenData?.projectDiscoveryOutcome;
if (!outcome) return null;
console.warn(
`[oauth] ${provider}: marking connection degraded — no Cloud Code projectId (${String(outcome)}) (#11284)`
);
return {
testStatus: "degraded",
errorCode: "missing_project_id",
lastErrorType: "oauth_missing_project_id",
lastError:
outcome === "requires_manual_project"
? BYOP_WARNING
: DISCOVERY_FAILED_WARNING,
warning: outcome === "requires_manual_project" ? BYOP_WARNING : DISCOVERY_FAILED_WARNING,
};
}

View File

@@ -96,7 +96,13 @@ export function findExistingOAuthConnectionMatch(
export function buildOAuthConnectionCreatePayload(
provider: string,
tokenData: Record<string, any>,
expiresAt: string | null
expiresAt: string | null,
degradedProject?: {
testStatus: "degraded";
errorCode: string;
lastErrorType: string;
lastError: string;
} | null
) {
return {
provider,
@@ -104,7 +110,17 @@ export function buildOAuthConnectionCreatePayload(
...tokenData,
expiresAt,
tokenExpiresAt: expiresAt,
testStatus: "active" as const,
// #11284: degraded when Cloud Code projectId discovery failed at connect
// time — the row is saved (refresh token stored, request-time bootstrap
// can self-heal) but visibly NOT active.
testStatus: degradedProject?.testStatus ?? ("active" as const),
...(degradedProject
? {
errorCode: degradedProject.errorCode,
lastErrorType: degradedProject.lastErrorType,
lastError: degradedProject.lastError,
}
: {}),
};
}

View File

@@ -17,10 +17,20 @@ type AntigravityTokenPayload = {
refresh_token?: string;
scope?: string;
};
/**
* Why no Cloud Code projectId was discovered at connect time (#11284).
* - "requires_manual_project": Google answered onboardUser with 200 but no
* cloudaicompanionProject in the body — the account must bring its own GCP
* project (BYOP, #8491). Retrying can never succeed.
* - "discovery_failed": loadCodeAssist/onboardUser errored, timed out, or
* still returned empty after a successful onboarding round-trip.
*/
type AntigravityProjectDiscoveryOutcome = "requires_manual_project" | "discovery_failed";
type AntigravityPostExchange = {
projectId: string;
tierId: string;
userInfo: { email?: string };
projectDiscoveryOutcome?: AntigravityProjectDiscoveryOutcome;
};
async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) {
@@ -150,6 +160,8 @@ async function postExchangeAntigravity(
let projectId = "";
let tierId = "legacy-tier";
// #11284: classify WHY discovery fails instead of silently swallowing it.
let loadFailed = false;
try {
const response = await fetchFirstOk(
config.loadCodeAssistEndpoints,
@@ -160,6 +172,7 @@ async function postExchangeAntigravity(
projectId = extractProjectId(data);
tierId = extractCodeAssistOnboardTierId(data);
} catch (error) {
loadFailed = true;
console.log("Failed to load code assist:", error);
}
@@ -168,21 +181,57 @@ async function postExchangeAntigravity(
} else if (config.onboardUserEndpoints.length > 0) {
// Accounts without an existing Cloud Code project need one bounded inline
// onboarding attempt before loadCodeAssist can discover their project.
let onboardedWithoutProject = false;
try {
await fetchFirstOk(
const response = await fetchFirstOk(
config.onboardUserEndpoints,
{ method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) },
POSTEXCHANGE_TIMEOUT_MS
);
const retryResponse = await fetchFirstOk(
config.loadCodeAssistEndpoints,
{ method: "POST", headers, body: JSON.stringify({ metadata }) },
POSTEXCHANGE_TIMEOUT_MS
);
projectId = extractProjectId((await retryResponse.json()) as Record<string, unknown>);
} catch {
// Lazy request-time bootstrap retries if onboarding or discovery is unavailable.
// Google BYOP (#8491): a 200 WITHOUT cloudaicompanionProject in the
// onboardUser body means no project was created and none ever will be —
// standard-tier/personal accounts must bring their own GCP project.
// A body that DOES carry one (string or {id}) is a real onboarding
// success; the retry loadCodeAssist below picks the id up (it can lag).
const bodyText = await response.text().catch(() => "");
if (bodyText && !bodyText.includes("cloudaicompanionProject")) {
console.log(
"[oauth] antigravity onboardUser succeeded without creating a project — Google BYOP (user-defined GCP project) required"
);
onboardedWithoutProject = true;
}
if (!onboardedWithoutProject) {
const retryResponse = await fetchFirstOk(
config.loadCodeAssistEndpoints,
{ method: "POST", headers, body: JSON.stringify({ metadata }) },
POSTEXCHANGE_TIMEOUT_MS
);
projectId = extractProjectId((await retryResponse.json()) as Record<string, unknown>);
// Prefer the id straight from the onboarding response when discovery
// lags behind server-side project creation.
if (!projectId) {
projectId = extractProjectId(
(await new Response(bodyText).json().catch(() => ({}))) as Record<string, unknown>
);
}
}
} catch (error) {
console.log("[oauth] antigravity inline onboarding/discovery failed:", error);
}
if (!projectId) {
return {
userInfo,
projectId,
tierId,
projectDiscoveryOutcome: onboardedWithoutProject
? "requires_manual_project"
: "discovery_failed",
};
}
} else if (loadFailed) {
// No onboarding path configured and discovery hard-failed — do not report
// this account as healthy-with-no-project (#11284).
return { userInfo, projectId, tierId, projectDiscoveryOutcome: "discovery_failed" };
}
return { userInfo, projectId, tierId };
}
@@ -199,6 +248,9 @@ function mapAntigravityTokens(
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
// #11284: let the OAuth route reject connects that ended without a Cloud
// Code project instead of persisting a dead "active" row.
projectDiscoveryOutcome: extra?.projectDiscoveryOutcome,
providerSpecificData: {
clientProfile,
projectId: extra?.projectId,

View File

@@ -10,7 +10,12 @@
/** Service kinds that, on their own, mean the provider lists no models. */
const TOOL_ONLY_SERVICE_KINDS = new Set<string>(["webSearch", "webFetch"]);
/** Providers whose registry catalog is the complete, intentional model list. */
/** Providers whose registry catalog is the complete, intentional model list.
*
* Volcano Ark plan providers (`volcengine-agent-plan` / `volcengine-coding-plan`)
* are intentionally NOT curated: their model list is discovered live from the
* console API (see volcenginePlanModelDiscovery.ts) and merged into the synced
* catalog, so the static registry only acts as a capability-seed fallback. */
const CURATED_MODEL_ONLY_PROVIDERS = new Set<string>(["chatgpt-web", "kimi-web", "zai-web"]);
export function providerUsesCuratedModelsOnly(providerId: string): boolean {

View File

@@ -0,0 +1,44 @@
/**
* One-time, idempotent backfill: ensure Volcano Ark plan connections carry
* `autoSync:true` so the 24h modelSyncScheduler picks them up.
*
* Plan connections created before volcenginePlanBinding set `autoSync` do not
* have the flag, so the scheduler (which only syncs connections whose
* providerSpecificData.autoSync === true) silently skipped them. This runs
* once per boot, patches any missing flag in place, and exits. It is safe to
* re-run — updateProviderConnection merges the patch.
*/
import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers";
const VOLC_PLAN_PROVIDERS = new Set(["volcengine-agent-plan", "volcengine-coding-plan"]);
let backfilled = false;
export async function backfillVolcPlanAutoSync(): Promise<void> {
if (backfilled) return;
backfilled = true;
try {
const connections = await getProviderConnections();
for (const conn of connections) {
const provider = typeof conn.provider === "string" ? conn.provider : "";
if (!VOLC_PLAN_PROVIDERS.has(provider)) continue;
const psd =
conn.providerSpecificData && typeof conn.providerSpecificData === "object"
? (conn.providerSpecificData as Record<string, unknown>)
: {};
if (psd.autoSync === true) continue;
const merged = { ...psd, autoSync: true };
if (typeof conn.id !== "string" || !conn.id) continue;
await updateProviderConnection(conn.id, {
providerSpecificData: merged,
});
}
} catch (error) {
backfilled = false; // allow retry on next boot if this boot failed
console.warn(
"[VolcPlanAutoSync] backfill failed — will retry next boot:",
(error as Error).message
);
}
}

View File

@@ -0,0 +1,279 @@
import {
createProviderConnection,
getProviderConnections,
updateProviderConnection,
} from "@/models";
type JsonRecord = Record<string, unknown>;
export const CONSOLE_TOP_BASE = "https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01";
const CODING_PLAN_PROVIDER = "volcengine-coding-plan";
const AGENT_PLAN_PROVIDER = "volcengine-agent-plan";
const PLAN_CONFIG = {
coding: {
provider: CODING_PLAN_PROVIDER,
name: "Volcano Ark Coding Plan",
usageAction: "GetCodingPlanUsage",
listModelAction: "ListArkCodeLatestModel",
listModelPayload: {},
referer: "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan",
listApiKeysPayload: { ProjectName: "default" },
},
agent: {
provider: AGENT_PLAN_PROVIDER,
name: "Volcano Ark Agent Plan",
usageAction: "GetAgentPlanAFPUsage",
listModelAction: "GetAgentPlanModelMappingMeta",
listModelPayload: { Edition: "agent_plan_personal" },
referer: "https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan",
listApiKeysPayload: {
ProjectName: "default",
Filter: { Scene: "RealAgentPlanPersonal" },
},
},
} as const;
type PlanKind = keyof typeof PLAN_CONFIG;
export interface ConsoleApiResult {
ok: boolean;
status: number;
json: JsonRecord;
error: string | null;
}
export function stringField(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function record(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function buildCookieHeader(credentials: JsonRecord): string {
const rawCookie = stringField(credentials.volcConsoleCookie);
if (rawCookie) return rawCookie;
const names = ["digest", "AccountID", "csrfToken", "userInfo"];
return names
.map((name) => {
const value = stringField(credentials[name]);
return value ? `${name}=${value}` : "";
})
.filter(Boolean)
.join("; ");
}
function extractCsrf(credentials: JsonRecord, cookieHeader: string): string {
const explicit = stringField(credentials.volcCsrfToken) || stringField(credentials.csrfToken);
if (explicit) return explicit;
return cookieHeader.match(/(?:^|;\s*)csrfToken=([^;]+)/)?.[1]?.trim() || "";
}
export async function callConsoleApi(
action: string,
payload: JsonRecord,
cookieHeader: string,
csrfToken: string,
referer: string
): Promise<ConsoleApiResult> {
const response = await fetch(`${CONSOLE_TOP_BASE}/${action}?`, {
method: "POST",
headers: {
accept: "application/json, text/plain, */*",
"content-type": "application/json",
cookie: cookieHeader,
origin: "https://console.volcengine.com",
referer,
"x-csrf-token": csrfToken,
},
body: JSON.stringify(payload),
});
const text = await response.text();
let json: JsonRecord = {};
try {
json = record(JSON.parse(text));
} catch {
// Non-JSON console failures are reported through `error` below.
}
const meta = record(json.ResponseMetadata);
const err = record(meta.Error);
const message = stringField(err.Message);
return {
ok: response.ok && !message,
status: response.status,
json,
error: message || (response.ok ? null : text.slice(0, 200)),
};
}
export async function detectPlan(
kind: PlanKind,
cookieHeader: string,
csrfToken: string
): Promise<{ available: boolean; usage: JsonRecord; error: string | null }> {
const cfg = PLAN_CONFIG[kind];
const result = await callConsoleApi(cfg.usageAction, {}, cookieHeader, csrfToken, cfg.referer);
if (!result.ok) {
return { available: false, usage: {}, error: result.error };
}
return { available: true, usage: record(result.json.Result), error: null };
}
function firstApiKeyItem(result: JsonRecord): JsonRecord | null {
const items = record(result.Result).Items;
if (!Array.isArray(items)) return null;
return record(items[0]);
}
async function fetchRawApiKey(
kind: PlanKind,
cookieHeader: string,
csrfToken: string
): Promise<{ apiKey: string; id: number | null; maskedKey: string | null; error: string | null }> {
const cfg = PLAN_CONFIG[kind];
const list = await callConsoleApi(
"ListApiKeys",
cfg.listApiKeysPayload,
cookieHeader,
csrfToken,
cfg.referer
);
if (!list.ok) {
return { apiKey: "", id: null, maskedKey: null, error: list.error || "ListApiKeys failed" };
}
const item = firstApiKeyItem(list.json);
const id = Number(item?.Id);
if (!Number.isFinite(id) || id <= 0) {
return { apiKey: "", id: null, maskedKey: null, error: "No API key found for this plan" };
}
const raw = await callConsoleApi(
"GetRawApiKey",
{ Id: id },
cookieHeader,
csrfToken,
cfg.referer
);
if (!raw.ok) {
return { apiKey: "", id, maskedKey: stringField(item?.Key) || null, error: raw.error };
}
const apiKey = stringField(record(raw.json.Result).ApiKey);
if (!apiKey) {
return {
apiKey: "",
id,
maskedKey: stringField(item?.Key) || null,
error: "Raw API key missing",
};
}
return { apiKey, id, maskedKey: stringField(item?.Key) || null, error: null };
}
async function upsertConnection(
kind: PlanKind,
apiKey: string,
cookieHeader: string,
csrfToken: string,
apiKeyId: number | null,
usage: JsonRecord
) {
const cfg = PLAN_CONFIG[kind];
const providerSpecificData = {
volcConsoleCookie: cookieHeader,
volcCsrfToken: csrfToken,
volcApiKeyId: apiKeyId,
volcPlanKind: kind,
volcLastUsage: usage,
// Enable 24h model auto-sync (modelSyncScheduler picks up autoSync:true).
autoSync: true,
};
const existing = (await getProviderConnections({ provider: cfg.provider })).find(
(conn: JsonRecord) => stringField(conn.name) === cfg.name
);
if (existing?.id) {
return await updateProviderConnection(stringField(existing.id), {
apiKey,
name: cfg.name,
providerSpecificData,
isActive: true,
testStatus: "active",
});
}
return await createProviderConnection({
provider: cfg.provider,
authType: "apikey",
name: cfg.name,
apiKey,
providerSpecificData,
isActive: true,
testStatus: "active",
});
}
export async function bindVolcenginePlansFromConsoleCredentials(credentials: JsonRecord) {
const cookieHeader = buildCookieHeader(credentials);
const csrfToken = extractCsrf(credentials, cookieHeader);
if (!cookieHeader || !csrfToken) {
throw new Error("Volcano console cookie or csrfToken is missing");
}
const results: Array<{
plan: PlanKind;
available: boolean;
ok: boolean;
connectionId?: string;
apiKeyId?: number | null;
maskedKey?: string | null;
error?: string | null;
}> = [];
for (const kind of ["coding", "agent"] as PlanKind[]) {
const detected = await detectPlan(kind, cookieHeader, csrfToken);
if (!detected.available) {
results.push({ plan: kind, available: false, ok: false, error: detected.error });
continue;
}
const key = await fetchRawApiKey(kind, cookieHeader, csrfToken);
if (!key.apiKey) {
results.push({
plan: kind,
available: true,
ok: false,
apiKeyId: key.id,
maskedKey: key.maskedKey,
error: key.error,
});
continue;
}
const connection = await upsertConnection(
kind,
key.apiKey,
cookieHeader,
csrfToken,
key.id,
detected.usage
);
results.push({
plan: kind,
available: true,
ok: Boolean(connection?.id),
connectionId: stringField(connection?.id),
apiKeyId: key.id,
maskedKey: key.maskedKey,
});
}
return {
cookieCaptured: true,
results,
};
}

View File

@@ -0,0 +1,400 @@
/**
* Volcano Ark Plan — live model discovery via console APIs.
*
* Both Plan subscriptions have NO usable `/models` endpoint on the chat API
* (`/api/plan/v3` returns 404; coding `/api/coding/v3/models` is unreliable).
* The authoritative model catalog is instead exposed by the console's
* top-level Ark actions, authenticated by the same console cookie + csrf
* token already captured during plan binding (see volcenginePlanBinding.ts).
*
* - Agent Plan: `ListAgentPlanLatestModel` → Result.Data[]
* id : ModelId (version-suffixed, matches chat endpoint)
* - Coding Plan: `ListArkCodeLatestModel` → Result.Data[]
* id : ModelId (version-suffixed)
*
* Both APIs return the same response shape (ModelId / OutputName / Enabled /
* Description / EnabledThinking). We keep ALL entries — the chat endpoint
* accepts every listed ModelId, and `Enabled` only reflects console visibility.
*
* The console API returns only id/name/description — NOT capabilities
* (contextLength, toolCalling, vision, reasoning). We enrich each discovered
* model from a static family→capability map keyed by the OutputName/ModelName
* prefix, falling back to conservative defaults so new families stay usable
* without a code change.
*
* Output shape matches SyncedAvailableModelInput so the sync-models route can
* persist it via replaceSyncedAvailableModelsForConnection.
*/
import type { SyncedAvailableModelInput } from "@/lib/db/models/synced";
type JsonRecord = Record<string, unknown>;
export type VolcPlanKind = "agent" | "coding";
export interface DiscoveredVolcModel {
id: string;
name: string;
description?: string;
enabledThinking?: boolean;
}
const CONSOLE_TOP_BASE = "https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01";
const AGENT_PLAN_REFERER =
"https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan";
const CODING_PLAN_REFERER =
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan";
function stringField(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function record(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
interface ConsoleApiResult {
ok: boolean;
status: number;
json: JsonRecord;
error: string | null;
}
/**
* Hit the Volcano console API directly via undici, BYPASSING OmniRoute's
* global fetch patch (open-sse/utils/proxyFetch.ts) which is built for LLM
* provider traffic and reroutes/rewrites requests to console.volcengine.com.
* Dynamic import so the build cannot extern/strip the dependency.
*/
async function callConsoleApiDirect(
action: string,
payload: JsonRecord,
cookieHeader: string,
csrfToken: string,
referer: string
): Promise<ConsoleApiResult> {
const { fetch: pristineFetch } = await import("undici");
const response = await pristineFetch(`${CONSOLE_TOP_BASE}/${action}?`, {
method: "POST",
headers: {
accept: "application/json, text/plain, */*",
"content-type": "application/json",
cookie: cookieHeader,
origin: "https://console.volcengine.com",
referer,
"x-csrf-token": csrfToken,
},
body: JSON.stringify(payload),
});
const text = await response.text();
let json: JsonRecord = {};
try {
json = record(JSON.parse(text));
} catch {
// Non-JSON console failures are reported through `error` below.
}
const meta = record(json.ResponseMetadata);
const err = record(meta.Error);
const message = stringField(err.Message);
return {
ok: response.ok && !message,
status: response.status,
json,
error: message || (response.ok ? null : text.slice(0, 200)),
};
}
async function detectPlan(
kind: VolcPlanKind,
cookieHeader: string,
csrfToken: string
): Promise<{ available: boolean; error: string | null }> {
const action = kind === "agent" ? "GetAgentPlanAFPUsage" : "GetCodingPlanUsage";
const referer = kind === "agent" ? AGENT_PLAN_REFERER : CODING_PLAN_REFERER;
const result = await callConsoleApiDirect(action, {}, cookieHeader, csrfToken, referer);
if (!result.ok) {
return { available: false, error: result.error };
}
return { available: true, error: null };
}
const PLAN_DISCOVERY_CONFIG: Record<
VolcPlanKind,
{
action: string;
/** Base payload; coding plan needs AccountId injected per-request. */
payload: JsonRecord;
referer: string;
/** Whether the listing API requires the console AccountId in the body. */
requiresAccountId: boolean;
}
> = {
agent: {
action: "ListAgentPlanLatestModel",
payload: {},
referer: AGENT_PLAN_REFERER,
requiresAccountId: false,
},
coding: {
action: "ListArkCodeLatestModel",
payload: {},
referer: CODING_PLAN_REFERER,
requiresAccountId: true,
},
};
/**
* Extract the numeric `AccountID` from the console cookie jar. The Coding Plan
* listing API requires `{AccountId: <number>}` in the body (string is rejected
* with InvalidParameter). The AccountID is always present in an authenticated
* console cookie, so this avoids a separate binding field / DB migration.
*/
function extractAccountId(cookieHeader: string): number | null {
const raw = cookieHeader.match(/(?:^|;\s*)AccountID=([^;]+)/i)?.[1]?.trim();
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
}
/**
* Family→capability enrichment. The console API does not return context
* window / tool / vision / reasoning flags, so we seed them from the model
* family. Keyed by the canonical model name (RespModelName / OutputName /
* ModelName) lowercased; a `*`-prefixed entry matches by prefix.
*
* Values mirror the curated static registry (volcengine/{agent,coding}-plan)
* so behavior is unchanged for known models; unknown families fall back to
* `enrichWithDefaults`.
*/
const FAMILY_CAPABILITY_MAP: Array<{
match: string;
contextLength: number;
toolCalling: boolean;
supportsVision: boolean;
supportsReasoning: boolean;
}> = [
// Doubao Seed 2.x turbo / mini — 256K, multimodal
{
match: "doubao-seed-2-1-turbo",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
match: "doubao-seed-2-0-mini",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
// Doubao Seed 2.0 lite — 256K, multimodal
{
match: "doubao-seed-2-0-lite",
contextLength: 262144,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
// Doubao Seed Evolving — 1M
{
match: "doubao-seed-evolving",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
// DeepSeek V4 family — 1M, text-only reasoning
{
match: "deepseek-v4",
contextLength: 1048576,
toolCalling: true,
supportsVision: false,
supportsReasoning: true,
},
// GLM 5.x — 1M
{
match: "glm-5",
contextLength: 1048576,
toolCalling: true,
supportsVision: false,
supportsReasoning: true,
},
// Kimi K3 / K2.7 code — 1M, multimodal
{
match: "kimi-k3",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
match: "kimi-k2.7-code",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
{
match: "kimi-k2-7-code",
contextLength: 1048576,
toolCalling: true,
supportsVision: true,
supportsReasoning: true,
},
// Kimi K2.6 — 1M
{
match: "kimi-k2.6",
contextLength: 1048576,
toolCalling: true,
supportsVision: false,
supportsReasoning: true,
},
// MiniMax M3 / M2.7 — 1M
{
match: "minimax-m3",
contextLength: 1048576,
toolCalling: true,
supportsVision: false,
supportsReasoning: true,
},
{
match: "minimax-m2.7",
contextLength: 1048576,
toolCalling: true,
supportsVision: false,
supportsReasoning: true,
},
];
const DEFAULT_CAPABILITY = {
contextLength: 131072,
toolCalling: true,
supportsVision: false,
supportsReasoning: true,
};
function matchFamily(name: string) {
const lower = name.trim().toLowerCase();
if (!lower) return null;
// Prefer exact match, then prefix match.
for (const entry of FAMILY_CAPABILITY_MAP) {
if (entry.match === lower) return entry;
}
for (const entry of FAMILY_CAPABILITY_MAP) {
if (lower.startsWith(entry.match)) return entry;
}
return null;
}
export function enrichModel(model: DiscoveredVolcModel): SyncedAvailableModelInput {
const family = matchFamily(model.name) ?? matchFamily(model.id) ?? DEFAULT_CAPABILITY;
return {
id: model.id,
name: model.name || model.id,
source: "imported",
apiFormat: "chat-completions",
supportedEndpoints: ["chat"],
inputTokenLimit: family.contextLength,
supportsTools: family.toolCalling,
supportsVision: family.supportsVision,
supportsThinking: model.enabledThinking ?? family.supportsReasoning,
...(model.description ? { description: model.description } : {}),
};
}
/**
* Parse `ListAgentPlanLatestModel` / `ListArkCodeLatestModel` Result.Data[].
*
* Both console APIs return the same response shape: each entry has
* `ModelId` (the version-suffixed ID accepted by the chat endpoint),
* `OutputName` / `ModelName` (the canonical family name used for capability
* enrichment), `Enabled` (console visibility — not API availability), and
* optional `Description` / `EnabledThinking`.
*
* We keep ALL entries with a non-empty `ModelId`. The chat endpoint accepts
* every listed model; `Enabled` only controls whether the model appears in
* the console's model picker, so filtering on it would hide callable models.
*/
export function parseLatestModelList(json: JsonRecord): DiscoveredVolcModel[] {
const data = record(json.Result).Data;
const arr = Array.isArray(data) ? data : [];
const out: DiscoveredVolcModel[] = [];
for (const raw of arr) {
const item = record(raw);
const id = stringField(item.ModelId);
if (!id) continue;
const name = stringField(item.OutputName) || stringField(item.ModelName) || id;
const enabledThinking = item.EnabledThinking === true || item.EnabledThinking === "true";
const desc = stringField(item.Description);
out.push({
id,
name,
...(desc ? { description: desc } : {}),
...(enabledThinking ? { enabledThinking: true } : {}),
});
}
return out;
}
/**
* Fetch the live model list for a Volcano Ark plan subscription using the
* console cookie + csrf token stored on the connection's providerSpecificData.
*
* Verifies the plan subscription is still active (detectPlan) before listing,
* so an expired/disabled plan returns a clear error instead of a stale/empty
* catalog that would erase the user's synced models.
*/
export async function fetchVolcPlanModels(
kind: VolcPlanKind,
cookieHeader: string,
csrfToken: string
): Promise<SyncedAvailableModelInput[]> {
if (!cookieHeader || !csrfToken) {
throw new Error("Volcano console cookie or csrfToken is missing — re-bind the plan");
}
// Validate the subscription/credentials are still live.
const detected = await detectPlan(kind, cookieHeader, csrfToken);
if (!detected.available) {
throw new Error(
`Volcano ${kind} plan unavailable${detected.error ? `: ${detected.error}` : ""} — re-bind the plan`
);
}
const cfg = PLAN_DISCOVERY_CONFIG[kind];
const payload: JsonRecord = { ...cfg.payload };
if (cfg.requiresAccountId) {
const accountId = extractAccountId(cookieHeader);
if (accountId === null) {
throw new Error(
`Volcano ${kind} plan discovery requires AccountId, but none found in console cookie — re-bind the plan`
);
}
payload.AccountId = accountId;
}
const result = await callConsoleApiDirect(
cfg.action,
payload,
cookieHeader,
csrfToken,
cfg.referer
);
if (!result.ok) {
throw new Error(
`Volcano ${kind} plan model discovery (${cfg.action}) failed${result.error ? `: ${result.error}` : ""}`
);
}
const discovered = parseLatestModelList(result.json);
if (discovered.length === 0) {
throw new Error(`Volcano ${kind} plan returned no usable models`);
}
return discovered.map(enrichModel);
}
export function providerToVolcPlanKind(providerId: string): VolcPlanKind | null {
const id = providerId.trim().toLowerCase();
if (id === "volcengine-agent-plan") return "agent";
if (id === "volcengine-coding-plan") return "coding";
return null;
}

View File

@@ -12,6 +12,7 @@ import {
calculateFactors,
calculateScore,
DEFAULT_WEIGHTS,
normalizeScoringWeights,
type ProviderCandidate,
type ScoringFactors,
type ScoringWeights,
@@ -122,8 +123,11 @@ function resolveModePackName(config: Record<string, unknown>): string | null {
/** Resolves an explicit, validated `weights` object from the config, if present. */
function resolveExplicitWeights(config: Record<string, unknown>): ScoringWeights | undefined {
const explicitWeights = isRecord(config.weights) ? (config.weights as ScoringWeights) : undefined;
return explicitWeights && validateWeights(explicitWeights) ? explicitWeights : undefined;
if (!isRecord(config.weights)) return undefined;
const explicitWeights = config.weights as ScoringWeights;
if (validateWeights(explicitWeights)) return explicitWeights;
const normalized = normalizeScoringWeights(config.weights as Partial<ScoringWeights>);
return validateWeights(normalized) ? normalized : undefined;
}
function resolveInspectorWeights(combo: ComboRecord | undefined): InspectorWeights {

View File

@@ -26,6 +26,7 @@ import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import { mergeProviderLimitsCacheEntry, toProviderLimitsCacheEntry } from "./providerLimitsCache";
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
import { cooldownUntilMs } from "@omniroute/open-sse/services/accountFallback.ts";
import {
rotationGroupFor,
serializeRefresh,
@@ -99,6 +100,9 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
"hyperagent",
"ha",
"firecrawl",
// Volcano Ark Plan subscriptions (agent-plan / coding-plan)
"volcengine-agent-plan",
"volcengine-coding-plan",
// Command Code API key → /alpha/billing/credits + windowLimits
"command-code",
"conol-web",
@@ -459,66 +463,57 @@ function windowStillExhaustedAfterRealReset(value: unknown, nowMs: number): bool
return resetMs > nowMs;
}
/**
* Is an explicit cooldown still in the future?
*
* A rateLimitedUntil set by the upstream 429 handler is a hard statement and
* must never be overruled by a quota poll.
*
* Gate on the timestamp alone; lastErrorType stays irrelevant here.
*/
export function hasActiveCooldown(
connection: Pick<ProviderConnectionLike, "rateLimitedUntil">,
now: number = Date.now()
): boolean {
if (!connection.rateLimitedUntil) return false;
// #3954: the rate_limited_until TEXT column holds an ISO string (dashboard/AUTH
// path) OR numeric epoch ms (setConnectionRateLimitUntil, the chat path). A bare
// `new Date(String(...))` yields Invalid Date for the numeric form, which read as
// "no cooldown" and let every poller wipe a chat-path-written lockout. Use the
// canonical parser connectionRecovery.ts already relies on.
const until = cooldownUntilMs(connection.rateLimitedUntil as string | number | null | undefined);
return Number.isFinite(until) && until > now;
}
/**
* Whether a connection test may wipe the persisted error/cooldown state.
*
* A successful probe proves the CREDENTIAL is valid; it does not prove an
* exhausted quota window reopened — the probe is a cheap auth/models call that
* never touches the chat quota a weekly cap applies to. The credential-health
* scheduler runs that probe against every connection every 300s, so without this
* gate a weekly-capped connection was reset to `active` / `rateLimitedUntil=null`
* within 30s of every restart and dispatched straight back into the same 429.
*
* Same rule as `maybeClearRecoveredQuotaState`: a future `rateLimitedUntil` is
* the 429 handler's hard statement and no poller may overrule it. Once the
* window elapses, the next probe clears the state normally.
*/
export function shouldClearErrorStateOnValidProbe(
connection: Pick<ProviderConnectionLike, "rateLimitedUntil">,
probeValid: boolean,
now: number = Date.now()
): boolean {
return probeValid && !hasActiveCooldown(connection, now);
}
export async function maybeClearRecoveredQuotaState(
connection: ProviderConnectionLike,
usage: JsonRecord
): Promise<ProviderConnectionLike> {
if (!hasUsableQuota(usage)) return connection;
if (isTerminalStatusForQuotaRecovery(connection.testStatus)) return connection;
if (connection.lastErrorType === "quota_exhausted") {
if (
connection.lastErrorSource === CLAUDE_EXTRA_USAGE_ERROR_SOURCE &&
isClaudeExtraUsageBlockEnabled(connection.provider, connection.providerSpecificData) &&
isClaudeExtraUsageQueued(usage)
) {
// Claude's pay-as-you-go extra-usage block is orthogonal to the
// session/weekly quota windows checked below: the upstream can report a
// fully recovered quota window while extraUsage.queued is still true.
// Only syncClaudeExtraUsageStateIfNeeded (buildClaudeExtraUsageConnectionUpdate)
// owns clearing this specific state — the general window-recovery logic
// below must not release it just because some quota window looks fresh.
return connection;
}
const quotas = usage?.quotas;
if (isRecord(quotas)) {
// Honor the REAL per-window resetAt from the freshly fetched quota
// instead of the synthetic cooldown persisted at failure time (e.g.
// Claude's flat 1h SUBSCRIPTION_QUOTA_COOLDOWN_MS when no upstream
// reset was parseable). Only stay locked if some window that governs
// this connection's quota is still demonstrably exhausted.
const anyStillBlocking = Object.values(quotas).some((value) =>
windowStillExhaustedAfterRealReset(value, Date.now())
);
if (anyStillBlocking) return connection;
} else if (
connection.rateLimitedUntil &&
new Date(connection.rateLimitedUntil).getTime() > Date.now()
) {
// No quota object at all (degraded/failed fetch shape) — fall back to
// the previous synthetic-cooldown guard.
return connection;
}
} else if (
connection.rateLimitedUntil &&
new Date(connection.rateLimitedUntil).getTime() > Date.now()
) {
// Universal fallback guard for every lastErrorType other than
// "quota_exhausted" (which gets the more precise per-window check above,
// and may legitimately release early once the REAL window has reset even
// while a synthetic rateLimitedUntil is still in the future). A future
// rateLimitedUntil is a hard statement made by the 429/error handler that
// persisted it (src/sse/services/auth.ts, src/app/api/providers/[id]/test/
// route.ts) — no quota poll finding *some* usable window elsewhere should
// be able to overrule it. Before this fix, ANY lastErrorType other than
// "quota_exhausted" skipped straight to hasTransientState/
// clearRecoveredProviderState() below with no rateLimitedUntil check at
// all, so a multi-day cooldown (observed: 146h, Z.AI weekly quota) got
// cleared on the very next quota sync a few minutes later — a
// self-restart/burn loop that kept burning real upstream calls against a
// known-exhausted connection (#11277).
return connection;
}
if (hasActiveCooldown(connection)) return connection;
const hasTransientState =
connection.testStatus === "unavailable" ||

View File

@@ -1,7 +1,6 @@
import {
PUBLIC_READONLY_API_ROUTE_PREFIXES,
PUBLIC_READONLY_METHODS,
isPublicApiRoute,
isPublicReadonlyCorsRoute,
} from "../../shared/constants/publicApiRoutes";
import type { ClassificationReason, RouteClassification } from "./types";
@@ -135,8 +134,9 @@ export function classifyRoute(rawPath: string, method: string = "GET"): RouteCla
}
function matchesReadonlyPublic(path: string, method: string): boolean {
if (!PUBLIC_READONLY_METHODS.has(String(method).toUpperCase())) return false;
return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((p) => path.startsWith(p));
// Exact match, not startsWith: a prefix here would hand the CORS origin
// relaxation to every adjacent path too (GHSA-74g9-q8f6-793h).
return isPublicReadonlyCorsRoute(path, method);
}
function isClassifiedAsPublic(path: string, method: string): boolean {

View File

@@ -95,6 +95,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
*/
export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray<RegExp> = [
/^\/api\/providers\/[^/]+\/login\/?$/,
/^\/api\/providers\/volcengine-plan\/connect(\/.*)?$/, // manual headful flow + session-based phone/SMS auto-login (both spawn Playwright)
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/,
/^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/,
];

View File

@@ -14,6 +14,7 @@ import { AUDIO_ONLY_PROVIDERS } from "./providers/audio";
import { UPSTREAM_PROXY_PROVIDERS } from "./providers/upstream-proxy";
import { CLOUD_AGENT_PROVIDERS } from "./providers/cloud-agent";
import { SYSTEM_PROVIDERS } from "./providers/system";
import { validateProviders } from "../validation/providerSchema";
export const FREE_PROVIDERS = {};
@@ -74,6 +75,7 @@ export function getProviderConnectionFamilyIds(providerId: unknown): readonly st
// Web / Cookie Providers
// API Key Providers
// Sub-categories within APIKEY_PROVIDERS (used by dashboard and catalog views).
@@ -142,6 +144,7 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([
"helixmind",
"tabitoken",
"logfare",
]);
export const ENTERPRISE_CLOUD_PROVIDER_IDS = new Set([
@@ -307,10 +310,27 @@ const _PROVIDER_SECTIONS = [
SYSTEM_PROVIDERS,
] as const;
let _validated = false;
function ensureProvidersValidated() {
if (_validated) return;
validateProviders(NOAUTH_PROVIDERS, "NOAUTH_PROVIDERS");
validateProviders(OAUTH_PROVIDERS, "OAUTH_PROVIDERS");
validateProviders(APIKEY_PROVIDERS, "APIKEY_PROVIDERS");
validateProviders(WEB_COOKIE_PROVIDERS, "WEB_COOKIE_PROVIDERS");
validateProviders(LOCAL_PROVIDERS, "LOCAL_PROVIDERS");
validateProviders(SEARCH_PROVIDERS, "SEARCH_PROVIDERS");
validateProviders(AUDIO_ONLY_PROVIDERS, "AUDIO_ONLY_PROVIDERS");
validateProviders(UPSTREAM_PROXY_PROVIDERS, "UPSTREAM_PROXY_PROVIDERS");
validateProviders(CLOUD_AGENT_PROVIDERS, "CLOUD_AGENT_PROVIDERS");
_validated = true;
}
let _aiProviders: Record<string, any> | null = null;
function getOrCreateAiProviders(): Record<string, any> {
if (!_aiProviders) {
ensureProvidersValidated();
_aiProviders = {};
for (const section of _PROVIDER_SECTIONS) {
Object.assign(_aiProviders, section);
@@ -505,6 +525,9 @@ export const USAGE_SUPPORTED_PROVIDERS = [
"grok-cli",
// Firecrawl team credits (GET /v2/team/credit-usage)
"firecrawl",
// Volcano Ark Plan subscriptions (agent-plan / coding-plan)
"volcengine-agent-plan",
"volcengine-coding-plan",
// Command Code credits + 5h/weekly rolling windows
"command-code",
"conol-web",
@@ -517,7 +540,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [
"agentrouter",
];
// ── Zod validation at module load (Phase 7.2) ──
// ── Zod validation, lazily on first AI_PROVIDERS access (perf: skips the walk
// for processes that never touch AI_PROVIDERS, e.g. short-lived CLI commands) ──
// Re-export the extracted data catalogs so external importers of providers.ts are unchanged.
export {
@@ -532,15 +556,3 @@ export {
CLOUD_AGENT_PROVIDERS,
SYSTEM_PROVIDERS,
};
import { validateProviders } from "../validation/providerSchema";
validateProviders(NOAUTH_PROVIDERS, "NOAUTH_PROVIDERS");
validateProviders(OAUTH_PROVIDERS, "OAUTH_PROVIDERS");
validateProviders(APIKEY_PROVIDERS, "APIKEY_PROVIDERS");
validateProviders(WEB_COOKIE_PROVIDERS, "WEB_COOKIE_PROVIDERS");
validateProviders(LOCAL_PROVIDERS, "LOCAL_PROVIDERS");
validateProviders(SEARCH_PROVIDERS, "SEARCH_PROVIDERS");
validateProviders(AUDIO_ONLY_PROVIDERS, "AUDIO_ONLY_PROVIDERS");
validateProviders(UPSTREAM_PROXY_PROVIDERS, "UPSTREAM_PROXY_PROVIDERS");
validateProviders(CLOUD_AGENT_PROVIDERS, "CLOUD_AGENT_PROVIDERS");

View File

@@ -199,6 +199,26 @@ export const APIKEY_PROVIDERS_REGIONAL = {
textIcon: "VE",
website: "https://www.volcengine.com",
},
"volcengine-agent-plan": {
id: "volcengine-agent-plan",
alias: "veap",
name: "Volcengine Ark Agent Plan",
icon: "local_fire_department",
color: "#DC2626",
textIcon: "VA",
website: "https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan",
authHint: "Connect your Volcano Engine account or use an Ark Agent Plan subscription API key.",
},
"volcengine-coding-plan": {
id: "volcengine-coding-plan",
alias: "vecp",
name: "Volcengine Ark Coding Plan",
icon: "code",
color: "#FF6A00",
textIcon: "VC",
website: "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan",
authHint: "Connect your Volcano Engine account or use an Ark Coding Plan subscription API key.",
},
gigachat: {
id: "gigachat",
alias: "gigachat",

View File

@@ -1,30 +1,27 @@
// Public API surface, split by SHAPE — this file is matched two different ways
// and the distinction is load-bearing (GHSA-74g9-q8f6-793h).
//
// A prefix is matched with `startsWith()`, so it also matches every adjacent
// path that merely shares its leading characters. `/api/usage/om-usage` as a
// prefix marked `/api/usage/om-usage<anything>` PUBLIC — and Next resolves that
// to the dynamic route `/api/usage/[connectionId]`, whose handler carries no
// auth of its own because it relies on being classified MANAGEMENT. Ten other
// entries had no shadowing sibling in the route tree today, but any route added
// later under a dynamic segment adjacent to one of them would inherit the same
// bypass silently.
//
// So: PREFIXES are genuine subtrees and MUST end in "/" (asserted by
// tests/unit/authz/public-route-exact-match.test.ts); single routes live in an
// EXACT set instead.
// Genuine subtrees. Every entry MUST end in "/".
const PUBLIC_API_ROUTE_PREFIXES = [
"/api/auth/login",
"/api/auth/logout",
"/api/auth/status",
"/api/auth/oidc/",
"/api/init",
"/api/v1/",
"/api/sync/bundle",
"/api/oauth/",
// Public, ticket-gated Codex device-flow completion (validate + persist).
// The handler enforces its own single-use ticket check; no dashboard auth.
"/api/codex/connect/",
// Remote-mode bootstrap: exchange the management password for a scoped CLI
// access token. The handler enforces its own password check + lockout — there
// is no token yet at this point, so it cannot require management auth.
"/api/cli/connect",
// Terminal-friendly @@om-usage equivalent for CLI clients (Claude Code/Codex).
// The handler enforces its own auth via extractUsageCommandApiKey/isValidApiKey
// and the allowUsageCommand flag — it must not be gated by management auth.
"/api/usage/om-usage",
// Chaos Mode external dispatch endpoint (POST /api/skills/collect/chaos).
// This entry only bypasses the dashboard requireLogin (cookie) gate — the
// handler enforces its own Bearer-token auth (validateApiKey +
// chaosModeEnabled check) before doing any work. See src/app/api/skills/
// collect/chaos/route.ts. Do not widen this prefix to cover other
// /api/skills/collect/* routes without the same per-handler auth.
"/api/skills/collect/chaos",
// Telegram Bot API update webhook + Mini App proxy. Telegram POSTs updates
// here without any dashboard cookie/API key; the handler enforces its own
// auth (503 when TELEGRAM_BOT_TOKEN is unset; 401 on invalid initData
@@ -38,18 +35,45 @@ const PUBLIC_API_ROUTE_PREFIXES = [
"/api/cursor-cli/",
];
const PUBLIC_READONLY_API_ROUTE_PREFIXES = [
// Single routes, public by EXACT path (both spellings) — never by prefix.
const PUBLIC_API_ROUTES_EXACT = new Set([
"/api/auth/login",
"/api/auth/logout",
"/api/auth/status",
"/api/init",
"/api/sync/bundle",
// Remote-mode bootstrap: exchange the management password for a scoped CLI
// access token. The handler enforces its own password check + lockout — there
// is no token yet at this point, so it cannot require management auth.
"/api/cli/connect",
// Terminal-friendly @@om-usage equivalent for CLI clients (Claude Code/Codex).
// The handler enforces its own auth via extractUsageCommandApiKey/isValidApiKey
// and the allowUsageCommand flag — it must not be gated by management auth.
// EXACT: the sibling `/api/usage/[connectionId]` has no auth of its own.
"/api/usage/om-usage",
// Chaos Mode external dispatch endpoint (POST /api/skills/collect/chaos).
// This entry only bypasses the dashboard requireLogin (cookie) gate — the
// handler enforces its own Bearer-token auth (validateApiKey +
// chaosModeEnabled check) before doing any work. See src/app/api/skills/
// collect/chaos/route.ts. Do not widen it to other /api/skills/collect/*
// routes without the same per-handler auth.
"/api/skills/collect/chaos",
]);
// Read-only single routes that ALSO take the CORS origin relaxation: they
// classify as `public_readonly_prefix`, which authz/pipeline.ts keys on.
const PUBLIC_READONLY_CORS_API_ROUTES = [
"/api/health/ping",
"/api/monitoring/health",
"/api/settings/require-login",
];
// Read-only routes public by EXACT path, never by prefix.
// Read-only routes public by EXACT path, WITHOUT the CORS relaxation.
//
// `/api/health` has to be reachable without a key — a probe has none, and a 401 there is
// indistinguishable from a wrong key or a missing route. It cannot go in the prefix list
// above: `startsWith("/api/health")` would also expose `/api/health/degradation`, which is
// authenticated today.
// indistinguishable from a wrong key or a missing route. It stays in its own set (rather than
// joining PUBLIC_READONLY_CORS_API_ROUTES) so it keeps classifying as `public_prefix`: moving it
// would silently widen CORS on it.
const PUBLIC_READONLY_API_ROUTES_EXACT = new Set(["/api/health"]);
const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
@@ -64,6 +88,13 @@ function pathMatchesExactRoute(pathname: string, routePath: string): boolean {
return pathname === routePath || pathname === `${routePath}/`;
}
function matchesAnyExactRoute(pathname: string, routes: Iterable<string>): boolean {
for (const route of routes) {
if (pathMatchesExactRoute(pathname, route)) return true;
}
return false;
}
function isPublicCloudApiRoute(pathname: string, method: string): boolean {
const normalizedMethod = String(method).toUpperCase();
return PUBLIC_CLOUD_API_ROUTES.some(
@@ -82,6 +113,17 @@ const LOCAL_ONLY_OAUTH_IMPORT_ROUTES = [
"/api/oauth/raycast/auto-import",
];
/**
* Whether the route classifies as read-only PUBLIC *with* the CORS origin
* relaxation (authz/classify.ts reason `public_readonly_prefix`). Exported as a
* predicate rather than as the raw list so a caller cannot reintroduce the
* prefix match this file exists to prevent.
*/
export function isPublicReadonlyCorsRoute(pathname: string, method = "GET"): boolean {
if (!PUBLIC_READONLY_METHODS.has(String(method).toUpperCase())) return false;
return matchesAnyExactRoute(pathname, PUBLIC_READONLY_CORS_API_ROUTES);
}
export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
if (
LOCAL_ONLY_OAUTH_IMPORT_ROUTES.some(
@@ -95,6 +137,10 @@ export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
return true;
}
if (matchesAnyExactRoute(pathname, PUBLIC_API_ROUTES_EXACT)) {
return true;
}
if (PUBLIC_API_ROUTE_PREFIXES.some((route) => pathname.startsWith(route))) {
return true;
}
@@ -103,18 +149,17 @@ export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
return false;
}
for (const route of PUBLIC_READONLY_API_ROUTES_EXACT) {
if (pathMatchesExactRoute(pathname, route)) {
return true;
}
if (matchesAnyExactRoute(pathname, PUBLIC_READONLY_API_ROUTES_EXACT)) {
return true;
}
return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((route) => pathname.startsWith(route));
return isPublicReadonlyCorsRoute(pathname, method);
}
export {
PUBLIC_API_ROUTE_PREFIXES,
PUBLIC_READONLY_API_ROUTE_PREFIXES,
PUBLIC_API_ROUTES_EXACT,
PUBLIC_READONLY_CORS_API_ROUTES,
PUBLIC_READONLY_API_ROUTES_EXACT,
PUBLIC_READONLY_METHODS,
};

View File

@@ -0,0 +1,70 @@
// Reserved provider prefixes — single source of truth shared by:
//
// 1. The runtime model resolver guard (src/sse/services/model.ts): user-defined
// compatible-node prefixes must not be allowed to shadow built-in provider
// ids/aliases, otherwise a node with prefix="cf" would hijack cloudflare-ai
// requests (ported from upstream 9router 047fdc89).
// 2. The write-path validation schemas (createProviderNodeSchema /
// updateProviderNodeSchema in src/shared/validation/schemas/provider.ts):
// a prefix that the runtime will never honor must be rejected at creation
// time with a clear message instead of silently routing to the built-in
// provider (tokenrouter bug: "No active credentials for provider:
// tokenrouter" despite a fully configured compatible node).
//
// Semantics (mirror the original inline runtime guard exactly):
// - REGISTRY entry ids + aliases only. Manual alias ids outside REGISTRY
// (xiaomi/llamacpp/aq) do NOT intercept nodes at runtime and are therefore
// deliberately NOT reserved — including them would cause false-positive
// rejections.
// - Case-sensitive: mixed-case input like "TokenRouter" does not collide with
// the runtime lookup (`Set.has` is exact-match), so it stays allowed.
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
let _reserved: Set<string> | null = null;
function buildReservedProviderPrefixes(): Set<string> {
if (_reserved) return _reserved;
const reserved = new Set<string>();
for (const entry of Object.values(REGISTRY)) {
if (entry?.id) reserved.add(entry.id);
if (entry?.alias) reserved.add(entry.alias);
}
_reserved = reserved;
return reserved;
}
/**
* All reserved provider prefixes (REGISTRY ids + aliases). Built lazily so the
* registry is only walked once per process.
*/
export function getReservedProviderPrefixes(): ReadonlySet<string> {
return buildReservedProviderPrefixes();
}
/**
* Number of unique reserved prefixes (ids + aliases deduplicated). Exposed for
* tests/docs so counts are measured, not memorized.
*/
export const RESERVED_PREFIX_COUNT = buildReservedProviderPrefixes().size;
/**
* Frozen snapshot of the reserved set (test/documentation convenience). Prefer
* `isReservedProviderPrefix` / `getReservedProviderPrefixes` on hot paths.
*/
export const RESERVED_PROVIDER_PREFIXES: ReadonlySet<string> = getReservedProviderPrefixes();
/**
* True when `value` is a reserved provider prefix. Non-strings are never
* reserved (mirrors the runtime guard's typeof check).
*/
export function isReservedProviderPrefix(value: unknown): boolean {
return typeof value === "string" && buildReservedProviderPrefixes().has(value);
}
/**
* Zod-friendly rejection message for a reserved prefix. Names the colliding
* prefix and tells the operator what to pick instead.
*/
export function reservedProviderPrefixMessage(value: string): string {
return `"${value}" is a reserved provider prefix — choose a different prefix (reserved ids/aliases cannot be used for custom nodes because requests like <prefix>/model would always route to the built-in provider)`;
}

View File

@@ -52,6 +52,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
*/
export const SPAWN_CAPABLE_PATTERNS: ReadonlyArray<RegExp> = [
/^\/api\/providers\/[^/]+\/login\/?$/, // pre-existing gap: in LOCAL_ONLY_API_PATTERNS today but never in a spawn-capable deny-list
/^\/api\/providers\/volcengine-plan\/connect(\/.*)?$/, // launches Playwright to bind a Volcano Engine console session — covers the manual headful flow AND the session-based phone/SMS auto-login sub-routes (/code, /status, /cancel, /resend)
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/, // spawns cursor-agent via renewal.ts (Hard Rules #15 + #17)
/^\/api\/providers\/cursor\/agent-availability\/?$/, // static path (no dynamic segment), but kept in this array alongside its /api/providers/ siblings rather than the flat SPAWN_CAPABLE_PREFIXES array — spawns cursor-agent status via checkCursorAgentAvailability()/getCachedCursorAgentAvailability() (Hard Rules #15 + #17)
/^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/, // spawns via getTunnelRuntimeStatus() → spawnSync("...","runtimes status") (open-sse/executors/chatgpt-web-codex/tunnelClient.ts). Mirrors LOCAL_ONLY_API_PATTERNS in routeGuard.ts; keep the two in sync (GHSA-9q3h-mjm5-f4gj).

View File

@@ -59,8 +59,8 @@ export interface LoginShellPathOptions {
*/
export function getLoginShellPath(opts: LoginShellPathOptions = {}): string | null {
const platform = opts.platform ?? process.platform;
if (platform !== "darwin") return null;
const shell = opts.shell || process.env.SHELL || "/bin/zsh";
if (platform !== "darwin" && platform !== "linux") return null;
const shell = opts.shell || process.env.SHELL || (platform === "darwin" ? "/bin/zsh" : "/bin/bash");
if (!/^[\w./-]+$/.test(shell)) return null;
const run =
opts.runShell ||

View File

@@ -1,5 +1,5 @@
import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf";
import { PUBLIC_API_ROUTE_PREFIXES } from "@/shared/constants/publicApiRoutes";
import { isPublicApiRoute } from "@/shared/constants/publicApiRoutes";
interface CachedDashboardCsrfToken {
token: string;
@@ -113,11 +113,7 @@ function isClientApiPath(pathname: string): boolean {
);
}
function isPublicApiPath(pathname: string): boolean {
return PUBLIC_API_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
function shouldAttachDashboardCsrf(url: URL): boolean {
function shouldAttachDashboardCsrf(url: URL, method: string): boolean {
if (
TOP_LEVEL_MANAGEMENT_PATH_PREFIXES.some(
(prefix) => url.pathname === prefix || url.pathname.startsWith(prefix + "/")
@@ -129,7 +125,10 @@ function shouldAttachDashboardCsrf(url: URL): boolean {
return (
url.pathname.startsWith("/api/") &&
url.pathname !== "/api/auth/csrf" &&
!isPublicApiPath(url.pathname) &&
// Share the server's PUBLIC classification instead of re-scanning the
// prefix list here — a second copy is a second chance to disagree with the
// authz pipeline (GHSA-74g9-q8f6-793h).
!isPublicApiRoute(url.pathname, method) &&
!isClientApiPath(url.pathname)
);
}
@@ -150,7 +149,7 @@ function sameOriginDashboardMutation(input: RequestInfo | URL, init?: RequestIni
return false;
}
return url.origin === window.location.origin && shouldAttachDashboardCsrf(url);
return url.origin === window.location.origin && shouldAttachDashboardCsrf(url, method);
}
function mergedHeaders(input: RequestInfo | URL, init?: RequestInit): Headers {

View File

@@ -79,7 +79,7 @@ export function toJsonErrorPayload(rawError: unknown, fallbackMessage = "Upstrea
return fallback;
}
function extractErrorMessage(value: unknown): string | null {
export function extractErrorMessage(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const record = value as JsonRecord;
@@ -110,3 +110,48 @@ function extractErrorMessage(value: unknown): string | null {
return null;
}
/**
* One-line reason for an upstream failure, for `lastError` and the console.
*
* A non-string used to collapse to the bare fallback, which is what an operator
* then reads in the dashboard. The case that matters most is not a string: a
* failed `fetch` arrives as `TypeError: fetch failed` with the actionable part on
* `error.cause.code` (ECONNREFUSED, ENOTFOUND, ETIMEDOUT), so a wrong port, a
* firewall and a blocked proxy all looked identical.
*
* Only message-shaped fields and transport codes are read — the value is never
* serialized wholesale, so a request body or header attached to an error cannot
* leak into the stored reason.
*/
export function describeUpstreamFailure(
value: unknown,
fallback = "Provider error",
maxLength = 100
): string {
const clamp = (text: string) => text.replace(/\s+/g, " ").trim().slice(0, maxLength);
if (typeof value === "string") return value.slice(0, maxLength);
if (!value || typeof value !== "object") return fallback;
const record = value as JsonRecord;
const cause = record.cause as JsonRecord | undefined;
const code =
typeof record.code === "string" && record.code
? record.code
: cause && typeof cause === "object" && typeof cause.code === "string" && cause.code
? cause.code
: null;
const nestedError = record.error;
const message =
extractErrorMessage(value) ??
(typeof nestedError === "string" && nestedError.trim()
? nestedError.trim()
: extractErrorMessage(nestedError));
if (message) {
return code && !message.includes(code) ? clamp(`${message} (${code})`) : clamp(message);
}
return code ? clamp(`${fallback} (${code})`) : fallback;
}

View File

@@ -23,7 +23,33 @@ export function deriveLiveWsPath(publicUrl?: string): string {
}
}
/**
* The operator-declared public WebSocket URL, resolved at RUNTIME.
*
* `NEXT_PUBLIC_*` is inlined into the client bundle at BUILD time, so a prebuilt
* Docker or npm image can never carry an operator's value — which is exactly why
* the server echoes this in `/api/v1/ws?handshake=1` for the client to discover.
* Reading only the `NEXT_PUBLIC_`-prefixed name on the server made that echo
* unreachable too: behind a reverse proxy the dashboard kept dialling
* `wss://<host>:20132/live-ws` and reported "Live disabled" (#11331).
*
* `LIVE_WS_PUBLIC_URL` is the runtime name, alongside the existing runtime
* `LIVE_WS_HOST` / `LIVE_WS_PORT`. The prefixed name still wins nothing and loses
* nothing — it stays supported as the fallback so existing deployments that set it
* (build-time or in the container) keep working.
*/
export function resolveLiveWsPublicUrl(env: NodeJS.ProcessEnv = process.env): string | null {
const candidates = [env.LIVE_WS_PUBLIC_URL, env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL];
for (const candidate of candidates) {
if (typeof candidate !== "string") continue;
const trimmed = candidate.trim();
if (!trimmed) continue;
if (trimmed.startsWith("ws://") || trimmed.startsWith("wss://")) return trimmed;
}
return null;
}
/** Convenience: read the env var at call time and derive the path. */
export function getLiveWsPath(): string {
return deriveLiveWsPath(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL);
return deriveLiveWsPath(resolveLiveWsPublicUrl() ?? undefined);
}

View File

@@ -14,6 +14,10 @@ import {
} from "@/shared/constants/upstreamHeaders";
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
import { validateProviderSpecificData } from "@/shared/validation/providerSpecificData";
import {
isReservedProviderPrefix,
reservedProviderPrefixMessage,
} from "@/shared/constants/reservedProviderPrefixes";
import {
upstreamHeadersRecordSchema,
@@ -367,6 +371,17 @@ export const createProviderNodeSchema = z
message: "Prefix is required",
path: ["prefix"],
});
} else if (isReservedProviderPrefix(value.prefix.trim())) {
// Reserved-prefix guard (tokenrouter bug): the runtime model resolver skips
// compatible-node lookup for built-in registry ids/aliases, so a node
// created with such a prefix could never be reached by it and silently
// routed requests to the built-in provider instead. Reject at the write
// path. Case-sensitive to match the runtime guard exactly.
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: reservedProviderPrefixMessage(value.prefix.trim()),
path: ["prefix"],
});
}
if (nodeType === "openai-compatible" && !value.apiType) {
ctx.addIssue({
@@ -377,27 +392,40 @@ export const createProviderNodeSchema = z
}
});
export const updateProviderNodeSchema = z.object({
name: z.string().trim().min(1, "Name is required"),
prefix: z.string().trim().min(1, "Prefix is required"),
apiType: z
.enum([
"chat",
"responses",
"embeddings",
"audio-transcriptions",
"audio-speech",
"images-generations",
])
.optional(),
baseUrl: z.string().trim().min(1, "Base URL is required"),
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
// #2166: same optional remote icon URL as createProviderNodeSchema — empty string
// clears a previously stored custom icon.
iconUrl: providerNodeIconUrlSchema,
customHeaders: customHeadersSchema,
});
export const updateProviderNodeSchema = z
.object({
name: z.string().trim().min(1, "Name is required"),
prefix: z.string().trim().min(1, "Prefix is required"),
apiType: z
.enum([
"chat",
"responses",
"embeddings",
"audio-transcriptions",
"audio-speech",
"images-generations",
])
.optional(),
baseUrl: z.string().trim().min(1, "Base URL is required"),
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
// #2166: same optional remote icon URL as createProviderNodeSchema — empty string
// clears a previously stored custom icon.
iconUrl: providerNodeIconUrlSchema,
customHeaders: customHeadersSchema,
})
.superRefine((value, ctx) => {
// Reserved-prefix guard (tokenrouter bug) — same rationale as the guard in
// createProviderNodeSchema: renaming a node's prefix onto a built-in
// registry id/alias would make it unreachable via that prefix.
if (isReservedProviderPrefix(value.prefix)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: reservedProviderPrefixMessage(value.prefix),
path: ["prefix"],
});
}
});
export const providerNodeValidateSchema = z.object({
baseUrl: z.string().trim().min(1, "Base URL and API key required"),

View File

@@ -1,6 +1,7 @@
import { randomUUID, createHash } from "crypto";
import { nodeTypeFromId } from "@/lib/db/providerNodeSelect";
import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts";
import { describeUpstreamFailure } from "@/shared/utils/upstreamError";
import { buildAllExpiredCredentials } from "./authExpiredCredentials.ts";
import {
getCachedRawProviderConnections,
@@ -1710,7 +1711,8 @@ export async function getProviderCredentials(
if (terminalConnections.length === connections.length) {
return buildAllExpiredCredentials(terminalConnections);
}
invalidateManagedLease(options, "CONNECTION_INELIGIBLE"); log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`);
invalidateManagedLease(options, "CONNECTION_INELIGIBLE");
log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`);
return null;
}
@@ -3063,7 +3065,7 @@ export async function markAccountUnavailable(
return { shouldFallback: true, cooldownMs: lockout.cooldownMs };
}
const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
const errorMsg = describeUpstreamFailure(errorText);
// T09: Codex per-scope lockout (do not block the whole account globally).
if (

View File

@@ -20,29 +20,10 @@ import {
import { getLearnedReasoningEffortForModel } from "@omniroute/open-sse/services/learnedReasoningEffortCaps.ts";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts";
import { getReservedProviderPrefixes } from "@/shared/constants/reservedProviderPrefixes";
export { parseModel, stripContextWindowSuffix };
/**
* Reserved provider prefixes — built-in provider ids + aliases. User-defined
* compatible-node prefixes must not be allowed to shadow these, otherwise a
* node with prefix="cf" would hijack cloudflare-ai requests (and similar for
* every built-in provider). Ported from upstream 9router 047fdc89.
*
* Built lazily so the registry is only walked once per process.
*/
let _reservedProviderPrefixes: Set<string> | null = null;
function getReservedProviderPrefixes(): Set<string> {
if (_reservedProviderPrefixes) return _reservedProviderPrefixes;
const reserved = new Set<string>();
for (const entry of Object.values(REGISTRY)) {
if (entry?.id) reserved.add(entry.id);
if (entry?.alias) reserved.add(entry.alias);
}
_reservedProviderPrefixes = reserved;
return reserved;
}
/**
* Fold `settings.wildcardAliases` ({pattern,target}[]) — the store the Settings
* UI's "Wildcard Pattern" mode writes to (ModelAliasesUnified.tsx::addWildcardAlias
@@ -460,9 +441,11 @@ export async function getModelInfo(modelStr) {
// node prefix lookup so the request still routes to the built-in provider.
// Internal UUID-prefixed node ids (e.g. "openai-compatible-responses-...")
// are never in the reserved set, so the #2778 combo path still works.
// Ported from upstream 9router 047fdc89.
const reserved = getReservedProviderPrefixes();
const isReservedPrefix = typeof prefixToCheck === "string" && reserved.has(prefixToCheck);
// Ported from upstream 9router 047fdc89. Set shared with the write-path
// validation guard (src/shared/constants/reservedProviderPrefixes.ts) so
// both sides can never drift apart.
const isReservedPrefix =
typeof prefixToCheck === "string" && getReservedProviderPrefixes().has(prefixToCheck);
if (!isReservedPrefix) {
// Check OpenAI Compatible nodes

View File

@@ -139,8 +139,8 @@ test("BUG #8370: priority combo keeps its declared model-1-first order despite c
);
});
test("shouldProtectOriginalFirst covers priority, fill-first, and lkgp", () => {
for (const strategy of ["priority", "fill-first", "lkgp"]) {
test("shouldProtectOriginalFirst covers auto, priority, fill-first, and lkgp", () => {
for (const strategy of ["auto", "priority", "fill-first", "lkgp"]) {
assert.equal(
shouldProtectOriginalFirst(false, false, strategy),
true,

View File

@@ -1,6 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { runWithProxyContext, resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts";
import {
runWithDirectFetchContext,
runWithProxyContext,
resolveProxyForRequest,
} from "../../open-sse/utils/proxyFetch.ts";
async function withEnv(
overrides: Record<string, string | undefined>,
@@ -59,3 +63,13 @@ test("[9551] resolveProxyForRequest: context-proxy respects NO_PROXY=*", async (
}
);
});
test("direct fetch context overrides an inherited proxy context", async () => {
await runWithProxyContext({ type: "http", host: "127.0.0.1", port: 7897 }, () =>
runWithDirectFetchContext(() => {
const resolved = resolveProxyForRequest("https://api.commandcode.ai/alpha/generate");
assert.equal(resolved.source, "direct");
assert.equal(resolved.proxyUrl, null);
})
);
});

View File

@@ -19,6 +19,7 @@ import {
isAdobeRiskCookieName,
resolveAdobeAccountLabel,
resolveSystemBrowserExecutable,
killProcessTree,
} from "../../open-sse/services/adobeFireflyBrowserLogin.ts";
test("clampAdobeFireflyLoginTimeout defaults and clamps", () => {
@@ -237,3 +238,114 @@ test("error path does not mention Playwright (packaged backend has no Playwright
else process.env.OMNIROUTE_LOGIN_BROWSER_PATH = prev;
}
});
test("killProcessTree on Linux targets process group (-pid) with SIGTERM and schedules SIGKILL", () => {
const killedSignals: Array<{ pid: number; signal: NodeJS.Signals | string }> = [];
const mockProcessKill = (pid: number, signal?: NodeJS.Signals | string) => {
if (signal) killedSignals.push({ pid, signal });
};
let procKillCalled = false;
const fakeChild = {
pid: 54321,
kill: (_sig?: NodeJS.Signals | number | string) => {
procKillCalled = true;
return true;
},
};
killProcessTree(fakeChild, {
platform: "linux",
processKill: mockProcessKill,
});
assert.equal(killedSignals.length, 1, "expected immediate SIGTERM call to process group");
assert.equal(killedSignals[0].pid, -54321, "Linux must target process group with negative PID");
assert.equal(killedSignals[0].signal, "SIGTERM");
assert.equal(procKillCalled, false, "should not call direct child.kill when process group kill succeeds");
});
test("killProcessTree falls back to child.kill on Linux when process group kill fails", () => {
let childKilledWith: string | undefined;
const fakeChild = {
pid: 54322,
kill: (sig?: NodeJS.Signals | number | string) => {
childKilledWith = typeof sig === "string" ? sig : undefined;
return true;
},
};
const mockProcessKill = () => {
throw new Error("ESRCH: no such process group");
};
killProcessTree(fakeChild, {
platform: "linux",
processKill: mockProcessKill,
});
assert.equal(childKilledWith, "SIGTERM", "must fall back to direct child.kill('SIGTERM')");
});
test("killProcessTree ignores self PID and parent PID to prevent killing backend", () => {
let killCalled = false;
const selfChild = {
pid: process.pid,
kill: () => {
killCalled = true;
return true;
},
};
killProcessTree(selfChild, { platform: "linux" });
assert.equal(killCalled, false, "must never kill own process.pid");
if (process.ppid) {
const parentChild = {
pid: process.ppid,
kill: () => {
killCalled = true;
return true;
},
};
killProcessTree(parentChild, { platform: "linux" });
assert.equal(killCalled, false, "must never kill process.ppid");
}
});
test("killProcessTree on win32 uses taskkill /pid <pid> /T /F with detached and windowsHide", () => {
const spawnCalls: Array<{ cmd: string; args: readonly string[]; opts: unknown }> = [];
let unrefCalled = false;
const mockSpawn = ((cmd: string, args: readonly string[], opts: unknown) => {
spawnCalls.push({ cmd, args, opts });
return {
unref: () => {
unrefCalled = true;
},
};
}) as unknown as typeof import("node:child_process").spawn;
const fakeChild = {
pid: 7788,
kill: () => true,
};
killProcessTree(fakeChild, {
platform: "win32",
spawnFn: mockSpawn,
});
assert.equal(spawnCalls.length, 1);
assert.equal(spawnCalls[0].cmd, "taskkill");
assert.deepEqual(spawnCalls[0].args, ["/pid", "7788", "/T", "/F"]);
const opts = spawnCalls[0].opts as { windowsHide?: boolean; detached?: boolean };
assert.equal(opts.windowsHide, true);
assert.equal(opts.detached, true);
assert.equal(unrefCalled, true);
});
test("killProcessTree handles null / undefined / pid-less gracefully without throwing", () => {
assert.doesNotThrow(() => killProcessTree(null));
assert.doesNotThrow(() => killProcessTree(undefined));
assert.doesNotThrow(() => killProcessTree({}));
assert.doesNotThrow(() => killProcessTree({ pid: undefined }));
});

View File

@@ -0,0 +1,64 @@
/**
* #11284 — Selection-side safety net for Antigravity accounts with no stored
* Cloud Code projectId.
*
* Production evidence (VPS docker `omniroute`, 2026-08-24): a pool can hold
* healthy accounts WITH projectIds alongside accounts whose projectId is
* empty and which were never confirmed missing (no errorCode) — those
* empty-but-unconfirmed rows still win round-robin slots, burn the request on
* loadCodeAssist discovery + 422, and drag the whole combo circuit down.
*
* Contract pinned here (`antigravityProjectPersist.ts`, quota-strategy copy):
* - connections with an EMPTY stored projectId are skipped whenever at
* least one sibling carries one;
* - when NO connection has a stored project the pool passes through
* unchanged (fresh installs keep their lazy-discovery path — #2334);
* - confirmed-missing rows (errorCode="missing_project_id") stay excluded
* even when they carry a stale stored id (regression guard for the
* persistence-module twin `antigravityProjectPersistence.ts`).
*
* Run: node --import tsx/esm --test tests/unit/antigravity-empty-project-selection.test.ts
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { preferAntigravityConnectionsWithStoredProject } from "../../open-sse/services/antigravityProjectPersist.ts";
const withProject = { id: "a", projectId: "proj-1" };
const withoutProject = { id: "d", projectId: null, providerSpecificData: {} };
const confirmedMissingWithStaleId = {
id: "f",
errorCode: "missing_project_id",
projectId: "stale-proj",
};
test("#11284: skips empty-projectId siblings when a healthier account exists", () => {
const pool = [withoutProject, withProject];
assert.deepEqual(
preferAntigravityConnectionsWithStoredProject(pool).map((c) => c.id),
["a"]
);
});
test("#11284: skips confirmed-missing rows even with a stale stored id", () => {
const pool = [confirmedMissingWithStaleId, withProject];
assert.deepEqual(
preferAntigravityConnectionsWithStoredProject(pool).map((c) => c.id),
["a"]
);
});
test("#11284: keeps the full pool when ONLY confirmed-missing rows exist (never empty)", () => {
const pool = [confirmedMissingWithStaleId];
assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool);
});
test("#11284: never empties the pool when every row lacks a projectId", () => {
const pool = [withoutProject, { id: "e", providerSpecificData: {} }];
assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool);
});
test("#11284: single connection passes through untouched (lazy discovery still applies)", () => {
const pool = [withoutProject];
assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool);
});

View File

@@ -0,0 +1,90 @@
/**
* #11284 — Auto-disable Antigravity connections whose Cloud Code project is
* confirmed missing, so credential selection rotates to healthy siblings
* instead of re-dispatching into a guaranteed 422 on every request.
*
* Production evidence (VPS docker `omniroute`, 2026-08-24): five rows carried
* project_id="" with NO missing-project marker — nothing excluded them from
* selection, so each dispatch paid the discovery round-trip and failed.
*
* Contract: `markAntigravityMissingCloudCodeProject()` must persist the
* typed marker (errorCode/lastErrorType) AND `isActive: false` +
* `testStatus: "unavailable"` (recoverable — NOT a terminal status), while
* `persistDiscoveredAntigravityProjectId()` re-enables the row when a project
* is later discovered at request time.
*
* Run: node --import tsx/esm --test tests/unit/antigravity-missing-project-autodisable.test.ts
*/
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-ag-11284-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "ag-11284-test-secret";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const {
markAntigravityMissingCloudCodeProject,
persistDiscoveredAntigravityProjectId,
} = await import("../../open-sse/services/antigravityProjectPersistence.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
await resetStorage();
});
async function createConnection() {
return providersDb.createProviderConnection({
provider: "antigravity",
authType: "oauth",
name: "autodisable-test",
email: `autodisable-${Date.now()}@example.test`,
accessToken: "token",
refreshToken: "refresh",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
providerSpecificData: { tier: "g1-pro-tier" },
isActive: true,
testStatus: "active",
}) as Promise<{ id: string; providerSpecificData: Record<string, unknown> }>;
}
test("confirmed-missing project disables the connection for selection", async () => {
const connection = await createConnection();
markAntigravityMissingCloudCodeProject(connection.id);
await new Promise((resolve) => setTimeout(resolve, 50));
const updated = await providersDb.getProviderConnectionById(connection.id);
assert.equal(updated?.isActive, false, "selection must skip disabled accounts");
assert.equal(updated?.testStatus, "unavailable");
assert.equal(updated?.errorCode, "missing_project_id");
assert.equal(updated?.lastErrorType, "oauth_missing_project_id");
});
test("discovery of a projectId later re-enables the connection", async () => {
const connection = await createConnection();
markAntigravityMissingCloudCodeProject(connection.id);
await new Promise((resolve) => setTimeout(resolve, 50));
persistDiscoveredAntigravityProjectId(
connection.id,
"recovered-project-99",
connection.providerSpecificData as Record<string, unknown>
);
await new Promise((resolve) => setTimeout(resolve, 50));
const healed = await providersDb.getProviderConnectionById(connection.id);
assert.equal(healed?.projectId, "recovered-project-99");
assert.equal(healed?.isActive, true, "healthy accounts return to rotation");
assert.equal(healed?.testStatus, "active");
assert.ok(!healed?.errorCode);
});

View File

@@ -78,7 +78,11 @@ test("Antigravity missing-project 422 stays fail-closed without account cooldown
assert.equal(payload.error?.code, "missing_project_id");
assert.equal(payload.error?.type, "oauth_missing_project_id");
assert.equal(bootstrapCalls, 1);
assert.equal(persisted?.testStatus, "active");
// #11284: a CONFIRMED missing project disables the account (recoverable,
// not terminal) so selection rotates to healthy siblings — and
// persistDiscoveredAntigravityProjectId re-enables it on recovery.
assert.equal(persisted?.isActive, false);
assert.equal(persisted?.testStatus, "unavailable");
assert.equal(persisted?.rateLimitedUntil, undefined);
assert.equal(persisted?.errorCode, "missing_project_id");
assert.equal(persisted?.lastErrorType, "oauth_missing_project_id");

View File

@@ -0,0 +1,197 @@
/**
* #11284 — Antigravity OAuth must never persist a connection without a Cloud
* Code projectId, and the connect-time post-exchange must detect Google's
* BYOP ("bring your own project") behavior instead of silently swallowing it.
*
* Production evidence (VPS docker `omniroute`, 2026-08-24): five antigravity
* connections were persisted with project_id="" and
* providerSpecificData.projectId="" while tier/subscriptionTier were fully
* populated (g1-pro-tier / "Google AI Pro") — proof the token exchange and
* loadCodeAssist round-trips SUCCEEDED but Google returned no
* cloudaicompanionProject (BYOP accounts, #8491). The old postExchange
* swallowed that outcome and the route marked the rows testStatus="active",
* so the dashboard showed "Connected" while every model call failed.
*
* Contract pinned here:
* 1. postExchange reports WHY no project was found:
* - "requires_manual_project" → onboardUser answered 200 without a
* cloudaicompanionProject in the body (Google BYOP).
* - "discovery_failed" → loadCodeAssist/onboardUser errored or timed out.
* - absent/undefined → projectId discovered normally.
* 2. mapTokens surfaces that outcome as tokenData.projectDiscoveryOutcome so
* the OAuth route can mark the connection degraded (saved, not active)
* instead of silently persisting a false "Connected" row.
*
* Run: node --import tsx/esm --test tests/unit/antigravity-oauth-empty-project-rejection.test.ts
*/
import test from "node:test";
import assert from "node:assert/strict";
import { antigravity } from "../../src/lib/oauth/providers/antigravity.ts";
const originalFetch = globalThis.fetch;
function jsonRes(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
test("postExchange reports requires_manual_project when onboardUser answers 200 without a project (Google BYOP)", async () => {
// Fresh account: loadCodeAssist has no project; onboardUser "succeeds" (200)
// but its body carries NO cloudaicompanionProject — Google now expects the
// user to bring their own GCP project (#8491). The retry loadCodeAssist
// still finds nothing. Outcome must be surfaced, not swallowed.
let onboardCalls = 0;
globalThis.fetch = (async (url: unknown) => {
const u = String(url);
if (u.includes("userinfo")) return jsonRes({ email: "byop@example.com" });
if (u.includes("loadCodeAssist")) {
return jsonRes({
allowedTiers: [{ id: "g1-pro-tier", isDefault: true }],
});
}
if (u.includes("onboardUser")) {
onboardCalls++;
// BYOP shape: 200 OK, body without cloudaicompanionProject.
return jsonRes({ done: true });
}
return jsonRes({});
}) as typeof fetch;
const result = await antigravity.postExchange({ access_token: "tok" } as never);
assert.ok(onboardCalls >= 1, "onboarding attempt must run");
assert.equal(result.projectId, "", "no project exists for BYOP accounts");
assert.equal(
result.projectDiscoveryOutcome,
"requires_manual_project",
"BYOP outcome must be reported so the route marks the connection degraded"
);
});
test("postExchange reports discovery_failed when loadCodeAssist errors (was silently swallowed)", async () => {
// Upstream hard-fails: previously this collapsed to console.log + empty
// projectId with zero signal. Now it must be classified discovery_failed.
globalThis.fetch = (async (url: unknown) => {
const u = String(url);
if (u.includes("userinfo")) return jsonRes({ email: "err@example.com" });
if (u.includes("loadCodeAssist")) return jsonRes({ error: "boom" }, 500);
if (u.includes("onboardUser")) return jsonRes({ error: "boom" }, 500);
return jsonRes({});
}) as typeof fetch;
const result = await antigravity.postExchange({ access_token: "tok" } as never);
assert.equal(result.projectId, "");
assert.equal(
result.projectDiscoveryOutcome,
"discovery_failed",
"upstream failures must be classified instead of silently dropped"
);
});
test("postExchange omits projectDiscoveryOutcome when a project is discovered (happy path unchanged)", async () => {
globalThis.fetch = (async (url: unknown) => {
const u = String(url);
if (u.includes("userinfo")) return jsonRes({ email: "ok@example.com" });
if (u.includes("loadCodeAssist")) {
return jsonRes({
cloudaicompanionProject: "happy-path-project",
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
});
}
if (u.includes("onboardUser")) return jsonRes({ done: true });
return jsonRes({});
}) as typeof fetch;
const result = await antigravity.postExchange({ access_token: "tok" } as never);
assert.equal(result.projectId, "happy-path-project");
assert.equal(
result.projectDiscoveryOutcome,
undefined,
"successful discovery must not carry an outcome flag"
);
});
test("postExchange reports discovery_failed when onboarding succeeds but retry still finds nothing (propagation/transient)", async () => {
// onboardUser returns 200 WITHOUT cloudaicompanionProject in the body but
// the retry loadCodeAssist eventually surfaces it — recovery wins, no
// outcome flag. (The pure-lag case is covered by the onboard-body fallback.)
let lcaCalls = 0;
globalThis.fetch = (async (url: unknown) => {
const u = String(url);
if (u.includes("userinfo")) return jsonRes({ email: "lag@example.com" });
if (u.includes("loadCodeAssist")) {
lcaCalls++;
return jsonRes({
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
});
}
if (u.includes("onboardUser")) {
// Real onboarding success shape: project id present in body.
return jsonRes({ done: true, cloudaicompanionProject: { id: "late-project" } });
}
return jsonRes({});
}) as typeof fetch;
const result = await antigravity.postExchange({ access_token: "tok" } as never);
assert.equal(result.projectId, "late-project");
assert.equal(
result.projectDiscoveryOutcome,
undefined,
"recovered projectId means healthy connection"
);
void lcaCalls;
});
test("postExchange still fails when onboarding carries a project but every discovery path stays empty", async () => {
// Degenerate upstream: onboardUser body has a project but retry loadCodeAssist
// errors — must NOT persist as silently-empty; classify discovery_failed.
globalThis.fetch = (async (url: unknown) => {
const u = String(url);
if (u.includes("userinfo")) return jsonRes({ email: "lag2@example.com" });
if (u.includes("loadCodeAssist")) return jsonRes({ error: "boom" }, 500);
if (u.includes("onboardUser")) {
return new Response(null, { status: 500 });
}
return jsonRes({});
}) as typeof fetch;
const result = await antigravity.postExchange({ access_token: "tok" } as never);
assert.equal(result.projectId, "");
assert.equal(result.projectDiscoveryOutcome, "discovery_failed");
});
test("mapTokens surfaces projectDiscoveryOutcome for the OAuth route degrade gate", async () => {
// The route can only act on what mapTokens hands it — the outcome must
// survive into tokenData.
globalThis.fetch = (async (url: unknown) => {
const u = String(url);
if (u.includes("userinfo")) return jsonRes({ email: "map@example.com" });
if (u.includes("loadCodeAssist")) {
return jsonRes({ allowedTiers: [{ id: "legacy-tier", isDefault: true }] });
}
if (u.includes("onboardUser")) return jsonRes({ done: true });
return jsonRes({});
}) as typeof fetch;
const tokens = { access_token: "tok" } as never;
const extra = await antigravity.postExchange(tokens);
const mapped = antigravity.mapTokens(tokens, extra);
assert.equal(mapped.projectId, "");
assert.equal(
mapped.projectDiscoveryOutcome,
"requires_manual_project",
"degrade gate needs the outcome on the mapped payload"
);
});

View File

@@ -0,0 +1,113 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
PUBLIC_API_ROUTE_PREFIXES,
PUBLIC_API_ROUTES_EXACT,
PUBLIC_READONLY_API_ROUTES_EXACT,
PUBLIC_READONLY_CORS_API_ROUTES,
isPublicApiRoute,
} from "../../../src/shared/constants/publicApiRoutes.ts";
import { classifyRoute } from "../../../src/server/authz/classify.ts";
// GHSA-74g9-q8f6-793h — `isPublicApiRoute()` matched every entry of
// PUBLIC_API_ROUTE_PREFIXES with startsWith(), but most entries name ONE exact
// route, not a subtree. As prefixes they also marked every adjacent path
// sharing the same leading characters as PUBLIC, skipping the MANAGEMENT auth
// gate. `/api/usage/om-usage<suffix>` resolves to the dynamic route
// `/api/usage/[connectionId]`, whose handler carries no auth of its own.
test("every prefix entry is a genuine subtree (ends in a slash)", () => {
for (const prefix of PUBLIC_API_ROUTE_PREFIXES) {
assert.equal(
prefix.endsWith("/"),
true,
`${prefix} is matched with startsWith(): a prefix that does not end in "/" also ` +
`matches every adjacent path sharing its leading characters (GHSA-74g9-q8f6-793h)`
);
}
});
test("exact public routes stay public in both spellings", () => {
for (const route of PUBLIC_API_ROUTES_EXACT) {
assert.equal(isPublicApiRoute(route, "POST"), true, route);
assert.equal(isPublicApiRoute(`${route}/`, "POST"), true, `${route}/`);
}
for (const route of [...PUBLIC_READONLY_API_ROUTES_EXACT, ...PUBLIC_READONLY_CORS_API_ROUTES]) {
assert.equal(isPublicApiRoute(route, "GET"), true, route);
assert.equal(isPublicApiRoute(`${route}/`, "GET"), true, `${route}/`);
}
});
test("sibling paths shadowed by an exact route are NOT public", () => {
const shadowed = [
"/api/auth/login-as",
"/api/auth/logout-all",
"/api/auth/status-page",
"/api/init-db",
"/api/sync/bundle-export",
"/api/cli/connect-token",
"/api/usage/om-usage-x",
"/api/usage/om-usageZZZ",
"/api/skills/collect/chaos-report",
"/api/health/pings",
"/api/monitoring/health-detail",
"/api/settings/require-login-policy",
];
for (const path of shadowed) {
assert.equal(isPublicApiRoute(path, "GET"), false, `${path} (GET)`);
assert.equal(isPublicApiRoute(path, "POST"), false, `${path} (POST)`);
}
});
test("the reported bypass: /api/usage/om-usage<suffix> classifies MANAGEMENT", () => {
// The live one — Next resolves it to /api/usage/[connectionId], a handler
// with no auth of its own that reaches fetchAndPersistProviderLimits().
assert.equal(classifyRoute("/api/usage/om-usage-x", "GET").routeClass, "MANAGEMENT");
assert.equal(classifyRoute("/api/usage/om-usageZZZ", "GET").routeClass, "MANAGEMENT");
// The real CLI route keeps its PUBLIC classification (it enforces its own key).
assert.equal(classifyRoute("/api/usage/om-usage", "GET").routeClass, "PUBLIC");
assert.equal(classifyRoute("/api/usage/om-usage/", "GET").routeClass, "PUBLIC");
});
test("genuine subtrees stay public all the way down", () => {
assert.equal(isPublicApiRoute("/api/v1/chat/completions", "POST"), true);
assert.equal(isPublicApiRoute("/api/oauth/cursor/callback", "GET"), true);
assert.equal(isPublicApiRoute("/api/auth/oidc/callback", "GET"), true);
assert.equal(isPublicApiRoute("/api/codex/connect/complete", "POST"), true);
assert.equal(isPublicApiRoute("/api/telegram/update", "POST"), true);
assert.equal(isPublicApiRoute("/api/cursor-cli/auth/exchange_user_api_key", "POST"), true);
});
test("read-only method gate is unchanged", () => {
for (const route of [...PUBLIC_READONLY_API_ROUTES_EXACT, ...PUBLIC_READONLY_CORS_API_ROUTES]) {
assert.equal(isPublicApiRoute(route, "GET"), true, `${route} GET`);
assert.equal(isPublicApiRoute(route, "HEAD"), true, `${route} HEAD`);
assert.equal(isPublicApiRoute(route, "OPTIONS"), true, `${route} OPTIONS`);
assert.equal(isPublicApiRoute(route, "POST"), false, `${route} POST`);
assert.equal(isPublicApiRoute(route, "DELETE"), false, `${route} DELETE`);
}
});
test("CORS relaxation reason set is unchanged", () => {
// pipeline.ts keys its CORS origin relaxation off `public_readonly_prefix`.
for (const route of PUBLIC_READONLY_CORS_API_ROUTES) {
assert.equal(classifyRoute(route, "GET").reason, "public_readonly_prefix", route);
}
// /api/health deliberately stays `public_prefix` — folding it into the
// read-only set would silently widen CORS on it.
assert.equal(classifyRoute("/api/health", "GET").reason, "public_prefix");
// ...and a shadowed sibling must not inherit the relaxation either.
assert.equal(classifyRoute("/api/monitoring/health-detail", "GET").routeClass, "MANAGEMENT");
});
test("LOCAL_ONLY oauth auto-import exclusions still win over the /api/oauth/ subtree", () => {
for (const route of [
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
"/api/oauth/raycast/auto-import",
]) {
assert.equal(isPublicApiRoute(route, "POST"), false, route);
assert.equal(classifyRoute(route, "POST").routeClass, "MANAGEMENT", route);
}
});

View File

@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
const intelligentRouting = await import("../../src/lib/combos/intelligentRouting.ts");
const { getModePack } = await import("../../open-sse/services/autoCombo/modePacks.ts");
test("getStrategyCategory classifies intelligent and deterministic strategies correctly", () => {
assert.equal(intelligentRouting.getStrategyCategory("auto"), "intelligent");
@@ -155,6 +156,19 @@ test("sidebar visibility excludes the removed auto-combo item", async () => {
]);
});
test("custom mode-pack selection preserves explicit slider intent", () => {
assert.deepEqual(intelligentRouting.MODE_PACK_OPTIONS[0], {
id: "custom",
label: "Custom / None (Use Sliders)",
emoji: "tune",
});
assert.equal(
intelligentRouting.normalizeIntelligentRoutingConfig({ modePack: "custom" }).modePack,
"custom"
);
assert.equal(getModePack("custom"), undefined);
});
test("intelligent routing helpers normalize config and build provider scores", () => {
const normalizedConfig = intelligentRouting.normalizeIntelligentRoutingConfig({
candidatePool: ["openai", "anthropic"],

Some files were not shown because too many files have changed in this diff Show More