mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-02 12:52:17 +03:00
Compare commits
1 Commits
fix/v3851-
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4fd593486 |
@@ -2398,9 +2398,6 @@ APP_LOG_TO_FILE=true
|
||||
# Bundled Codeium/language-server extension_version, distinct from Desktop.
|
||||
# Must use x.y.z format; invalid/unset values use the bundled default 1.48.2.
|
||||
# DEVIN_DESKTOP_EXTENSION_VERSION=1.48.2
|
||||
# Optional override for the Codeium seat-management API used by Devin CLI quota.
|
||||
# Used by: open-sse/services/usage/devinCli.ts. Default: https://server.codeium.com
|
||||
# DEVIN_SEAT_API_URL=https://server.codeium.com
|
||||
|
||||
# ── Command Code (custom CLI) callback ──
|
||||
# Local port used for OAuth-style callbacks from the Command Code CLI helper.
|
||||
|
||||
48
.github/actions/npm-ci-retry/action.yml
vendored
48
.github/actions/npm-ci-retry/action.yml
vendored
@@ -1,45 +1,9 @@
|
||||
name: npm ci with retry
|
||||
description: >-
|
||||
Install dependencies. Restores node_modules from the Actions cache when the exact
|
||||
lockfile / runner / Node version / postinstall inputs match; otherwise runs npm ci
|
||||
with retries for transient registry/network failures and saves the tree for the
|
||||
next run.
|
||||
inputs:
|
||||
cache:
|
||||
description: Set to "false" to skip the node_modules cache and always run npm ci.
|
||||
required: false
|
||||
default: "true"
|
||||
description: Run npm ci with retries for transient registry/network failures.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve Node version for the cache key
|
||||
id: node
|
||||
shell: bash
|
||||
run: echo "version=$(node --version)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# #8084 D3 (plan 3.8.51 task 5): every job used to pay ~80-90 s of `npm ci` even
|
||||
# with setup-node's npm tarball cache warm — 36 jobs per ci.yml run, ~55 min of
|
||||
# runner time per run just installing. A node_modules cache keyed on EVERYTHING
|
||||
# that shapes the tree lets a hit skip the install entirely.
|
||||
#
|
||||
# No restore-keys on purpose (same rule as the ESLint cache, #11600): a partial
|
||||
# tree from another lockfile / Node / postinstall script is exactly the kind of
|
||||
# silent drift a lockfile-pinned CI must never inherit. Exact key or a full npm ci.
|
||||
#
|
||||
# postinstall (scripts/build/postinstall.mjs + helpers) only mutates node_modules
|
||||
# on a plain install — its dist/ branch is gated on dist/ existing, which never
|
||||
# holds at install time in CI — so the cached tree already carries its effects.
|
||||
- name: Restore node_modules
|
||||
id: node-modules
|
||||
if: inputs.cache == 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.node.outputs.version }}-${{ hashFiles('package-lock.json', '.npmrc', 'scripts/build/postinstall.mjs', 'scripts/build/postinstallSupport.mjs', 'scripts/build/colocateOptionals.mjs', 'scripts/build/fixTlsClientNodeBinary.mjs', 'scripts/build/fixPlaywrightAndroid.mjs', 'scripts/build/native-binary-compat.mjs') }}
|
||||
|
||||
- name: npm ci (with retry)
|
||||
if: steps.node-modules.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
- shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -51,8 +15,7 @@ runs:
|
||||
echo "npm ci attempt $attempt/$max_attempts after transient failure"
|
||||
fi
|
||||
|
||||
# --no-audit: `audit:deps` is its own gate; the inline audit only adds latency.
|
||||
if npm ci --no-audit --no-fund; then
|
||||
if npm ci; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -64,8 +27,3 @@ runs:
|
||||
sleep "$delay_seconds"
|
||||
delay_seconds=$((delay_seconds * 2))
|
||||
done
|
||||
|
||||
- name: node_modules restored from cache
|
||||
if: steps.node-modules.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
run: echo "node_modules restored from cache (key hit) — npm ci skipped"
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -26,6 +26,6 @@ jobs:
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -177,13 +177,6 @@ npm run test:all
|
||||
# Single test file (Node.js native test runner — most tests use this)
|
||||
node --import tsx/esm --test tests/unit/your-file.test.ts
|
||||
|
||||
# Only the unit tests impacted by your change (same TIA selector as the CI gate, #8084)
|
||||
npm run test:scoped # changes in the last commit (or the working tree)
|
||||
npm run test:scoped:staged # staged changes only — pairs well with a pre-commit run
|
||||
npm run test:scoped:full # rebuild the import-graph map first (after adding/moving files)
|
||||
# Exit 1 + "run the full suite" means a hub file (tsconfig, package.json, …) or an
|
||||
# unmapped source changed — the selector fails safe, it never silently skips.
|
||||
|
||||
# Vitest (MCP server, autoCombo, cache)
|
||||
npm run test:vitest
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(ui):** enable React Compiler (`reactCompiler: true` + `babel-plugin-react-compiler`) for automatic memoization at build time ([#11783](https://github.com/diegosouzapw/OmniRoute/pull/11783)) — thanks @jonlwheat2-gif
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(sse):** treat `max` as a first-class reasoning-effort tier and clamp per model family (GLM 5.1+/DeepSeek V4+/Kimi K3+ keep native `max`; o1/MiniMax/Grok/Muse Spark clamp to their upstream ceiling) ([#11875](https://github.com/diegosouzapw/OmniRoute/pull/11875)) — thanks @Chewji9875
|
||||
@@ -1 +0,0 @@
|
||||
- Add a runtime feature flag to disable universal context handoffs globally without changing the default behavior.
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(providers):** the provider plugin manifest now also advertises a `usage-supported` capability for the 46 providers whose usage API is accepted by the server and Dashboard routes, so integrators can distinguish "the server will serve quota for this provider" from "a fetcher is wired" without reading TypeScript. Discovery only — no fetcher or quota change. `usage-fetch` resolves on id or alias (the usage dispatcher accepts both); `usage-supported` resolves on id alone, matching the runtime guard `USAGE_SUPPORTED_PROVIDERS.includes(providerId)`. `USAGE_SUPPORTED_PROVIDERS` moved to a zero-dependency leaf (`open-sse/services/usage/supportedProviders.ts`) and is re-exported from `providers.ts`, mirroring the `fetcherProviders` leaf from #11903 and keeping the manifest a light module. ([#12214](https://github.com/diegosouzapw/OmniRoute/pull/12214)) — thanks @maxmad64bis
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(usage):** Devin CLI agentic quota (Codeium seat-management GetUserStatus) and OpenRouter key limits plus account credits now surface in Provider Limits ([#12256](https://github.com/diegosouzapw/OmniRoute/pull/12256) — thanks @Neuron-Mr-White)
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(radar):** explain Community, single-use, contributor, supporter, recovery, abuse, offers, and privacy rules before either Radar activation action, and remove the superseded fixed-PR grant promise from every UI locale ([#12342](https://github.com/diegosouzapw/OmniRoute/pull/12342))
|
||||
@@ -1,5 +0,0 @@
|
||||
- **feat(dashboard):** the `/dashboard/orchestration` snapshot hook now subscribes to the
|
||||
`agents` WebSocket channel (`agent.task.updated`) instead of `requests` as its refetch
|
||||
trigger, and relaxes its background poll from 5s to 30s while that WS connection is up —
|
||||
falling back to the tighter 5s cadence, reprogrammed live on any connect/disconnect
|
||||
transition, whenever the socket is down.
|
||||
@@ -1,11 +0,0 @@
|
||||
- **feat(dashboard):** Orchestration canvas quick wins — search box plus state/source/provider
|
||||
filter chips with a one-click clear, and per-source collapse/expand, all reflected in the URL
|
||||
so a filtered/collapsed view is shareable and survives a refresh; the detail drawer gained a
|
||||
"copy trace JSON" action and hardened error/empty-state and accessibility handling; the
|
||||
Agents-tab edges now animate traveling particles along active (running) connections; and the
|
||||
canvas node/edge status colors moved off fixed hex values onto theme-aware `--orch-status-*`
|
||||
CSS custom properties, so they adapt correctly to light/dark mode.
|
||||
- **chore(dashboard):** Orchestration UI hardening pass and the missing component/model test
|
||||
coverage it called for — `OrchestratorNode`/`ActivityNode`/`OverflowNode` rendering, the
|
||||
`?node=`/overflow-click page routing, the Agents-tab orchestrator-click no-op and
|
||||
`showCompleted` toggle, and the overview kanban's done-column sort order (#12270, #12271).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Fixed the v3.8.50 Costs and Analytics dashboards so flat-rate Claude Code usage can be shown as an explicitly requested token-price estimate without changing default billed-cost semantics.
|
||||
- Fixed archived usage retention so each request is priced individually instead of pricing a day's summed tokens once, which understated archived cost whenever a day mixed cache-heavy and ordinary requests.
|
||||
- Fixed the Costs dashboard so it discloses when displayed figures include flat-rate token-price estimates instead of labelling them as billed spend, using the flag the analytics API already returns; the month-end projection and the CSV/JSON exports carry the same marker, and billed-cost mode is unchanged.
|
||||
@@ -1 +0,0 @@
|
||||
- **perf(compression):** OOM mitigations for large payload hashing, memoization, and token estimation ([#11844](https://github.com/diegosouzapw/OmniRoute/pull/11844) — thanks @AndrianBalanescu)
|
||||
@@ -1,5 +0,0 @@
|
||||
- **fix(memory):** self-hosted embedding endpoints now vectorize — the vector width is
|
||||
measured from the first embedding that comes back instead of being read from a registry
|
||||
that cannot describe them, so `vec_memories` is created and memories stop piling up
|
||||
unvectorized behind a green health check
|
||||
([#12180](https://github.com/diegosouzapw/OmniRoute/pull/12180)) — thanks @kanade-hoshino
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(memory):** Embedding Model Quick select, Embedding Source remote dropdown, and Rerank selector now list every configured provider with embedding/rerank support instead of only chat-catalog text matches plus OpenRouter live discovery; a generic OpenAI-compatible `/embeddings` + Cohere-compatible `/rerank` runtime fallback resolves any configured chat provider's embedding/rerank endpoint, so unlisted providers no longer fail with "Unknown embedding provider"; both memory selectors gained a free-text model override
|
||||
@@ -3,7 +3,6 @@
|
||||
"_justifications": {
|
||||
"@testing-library/dom": "Peer dep obrigatoria de @testing-library/react v16 (adicionada no PR #11224); Refs #9985.",
|
||||
"@testing-library/user-event": "Utilitario oficial do ecossistema testing-library para testes de UI (adicionada no PR #11224); Refs #9985.",
|
||||
"babel-plugin-react-compiler": "Official React Compiler Babel plugin (facebook/react, MIT). Required peer of Next.js 16 `reactCompiler: true`; Next declares it optional (`*`) and does not auto-install. Added by PR #11783 / issue #67.",
|
||||
"eslint-plugin-react-hooks": "React Hooks lint rules (set-state-in-effect, immutability, refs, purity) pinned at 7.0.1 by the release/v3.8.51 cycle; the 224 findings it raised are tracked in #11924. Refs #11924."
|
||||
},
|
||||
"allowed": [
|
||||
@@ -45,7 +44,6 @@
|
||||
"ajv",
|
||||
"ajv-formats",
|
||||
"axios",
|
||||
"babel-plugin-react-compiler",
|
||||
"bcryptjs",
|
||||
"better-sqlite3",
|
||||
"bottleneck",
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
---
|
||||
title: "Radar Free-Model Catalog"
|
||||
version: 3.8.51
|
||||
lastUpdated: 2026-09-01
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-13
|
||||
---
|
||||
|
||||
# Radar Free-Model Catalog
|
||||
|
||||
> **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/`
|
||||
> **Last updated:** 2026-09-01 — v3.8.51
|
||||
> **Hosted-service evidence boundary:** server-side rules described here were verified on
|
||||
> 2026-09-01 against the intentionally private Radar server at exact revision
|
||||
> `main@dce70f004364912f3f144cdb69f4cbcde16093ed`. That implementation is not distributed in
|
||||
> this OSS repository; hosted availability remains a separate operational state.
|
||||
> **Last updated:** 2026-08-13 — v3.8.50
|
||||
|
||||
Radar is an **optional add-on** that overlays a signed, freshly-curated free-model
|
||||
catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in
|
||||
@@ -28,7 +24,7 @@ is never mutated on disk — see
|
||||
|
||||
---
|
||||
|
||||
## Delivery status in v3.8.51
|
||||
## Delivery status in v3.8.50
|
||||
|
||||
The following status distinguishes what this OSS release implements from later Radar
|
||||
workstreams. It is a code-level status, not a promise that a particular hosted deployment
|
||||
@@ -113,17 +109,10 @@ When both are on, the sync path is:
|
||||
`Authorization: Bearer <supporter key>` header (see below). Servers default to the separately
|
||||
signed v1 transition artifact when the schema header is absent, so older installed clients keep
|
||||
receiving updates.
|
||||
2. This is a download-only application flow, but it is still an HTTPS request. The hosted
|
||||
infrastructure receives ordinary connection metadata such as the source IP. When a supporter
|
||||
key is configured, sync also sends that key in the Bearer header so the service can resolve the
|
||||
entitlement. At the exact private-server revision identified in the evidence boundary above,
|
||||
feed-request accounting uses key hashes, aggregate usage, and a daily rotating truncated HMAC
|
||||
of the IP for manual abuse review; those tables persist neither the key nor the IP in raw form.
|
||||
Infrastructure access logs and the encrypted delivery outbox are separate operational
|
||||
boundaries.
|
||||
3. OmniRoute never sends prompts, responses, conversations, provider credentials, model traffic,
|
||||
uptime, latency, or the local provider configuration to the Radar service.
|
||||
4. The response is verified, validated, and cached locally (see
|
||||
2. Nothing about the request, the operator, or their traffic is uploaded — it is a
|
||||
plain, unauthenticated-by-default GET. OmniRoute never posts usage data, provider
|
||||
configuration, or model traffic to the feed service.
|
||||
3. The response is verified, validated, and cached locally (see
|
||||
[Security model](#security-model)). Radar has exactly four server-side network paths:
|
||||
`syncRadar()` for the catalog, `syncRadarReferrals()` for referrals, and
|
||||
`syncRadarOffers()` / `syncRadarIntel()` for supporter-only offers and Intel.
|
||||
@@ -144,42 +133,6 @@ that lets the feed service decide which tier to serve (see
|
||||
|
||||
---
|
||||
|
||||
## Access and safety rules shown before opt-in
|
||||
|
||||
The inactive dashboard renders these rules from
|
||||
`src/app/(dashboard)/dashboard/radar/RadarAccessExplainer.tsx` **before** either activation action.
|
||||
The canonical access scale is:
|
||||
|
||||
| Level | Eligibility | Access | Repeat/expiration rule |
|
||||
| --------------------- | --------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Community | Anyone; no key | Complete catalog delayed by about 30 days | Always available; no issuance |
|
||||
| Star + follow | GitHub OAuth verifies both a star on the repository and a follow of the owner | One live catalog read, then Community | One issuance per login; never reissued |
|
||||
| Contributor Top 10 | Positions 1–10 in the latest complete weekly ranking | 365 live days | Claimed on demand; leaving the ranking does not shorten an awarded period |
|
||||
| Contributor Top 100 | Positions 11–100 in that ranking | 90 live days | Same on-demand/idempotent claim rule |
|
||||
| Supporter purchase | One-time 6-month, 1-year, or lifetime purchase | Live catalog, signed live offers, and Intel | No automatic renewal |
|
||||
| Donation/manual grant | Owner-reviewed donation or an owner grant for an explicit number of days/lifetime | Same live entitlement for the granted period | Audited, idempotent grant |
|
||||
|
||||
Merged PRs, commits, and changed lines are **ranking inputs only**. A login outside the Top 100 gets
|
||||
no contributor grant regardless of PR count. Finite purchases, donations, contributor periods, and
|
||||
manual grants accumulate from the current expiration; lifetime dominates. A rank change never
|
||||
retroactively revokes or shortens time already awarded.
|
||||
|
||||
The hosted license is personal and the user-facing rule is one active installation at a time. This
|
||||
release does **not** claim a hardware lock: the OSS sync does not fingerprint hardware or maintain a
|
||||
cryptographic device lease. At the verified private-server revision above, implemented enforcement
|
||||
is entitlement validation plus a manual-review signal when the same live key is seen from a fourth
|
||||
distinct IP within 24 hours. That signal never blocks or revokes a key automatically. Recovery
|
||||
revokes and replaces the lost key while preserving the existing expiration; it does not restart the
|
||||
purchased or granted period.
|
||||
|
||||
Live offers are manually curated and can change or expire. The opt-in screen also names the exact
|
||||
privacy boundary: signed catalog/referral metadata is downloaded; a valid key additionally unlocks
|
||||
signed offers and Intel; the Bearer key and normal connection metadata reach the hosted service;
|
||||
prompts, responses, conversations, provider credentials, model traffic, uptime, latency, and local
|
||||
provider configuration do not.
|
||||
|
||||
---
|
||||
|
||||
## Getting a supporter key
|
||||
|
||||
The activation screen (`/dashboard/radar`) links out to two flows for **obtaining** a
|
||||
@@ -189,12 +142,11 @@ destination pages, not in this repo (spec decision D14).
|
||||
|
||||
- **"I'm a contributor"** — opens `RADAR_CONTRIBUTOR_CLAIM_URL` (default
|
||||
`https://radar.omniroute.online/auth/github`), a GitHub OAuth claim flow hosted on
|
||||
the private Radar server. It checks the latest complete weekly ranking: Top 10 receives 365 days
|
||||
and positions 11–100 receive 90 days. Outside the Top 100, PR count never grants access; the flow
|
||||
instead checks the separate star + follow single-use level.
|
||||
the private radar server. It verifies the visitor's GitHub account and grants a
|
||||
supporter key to anyone with 5+ merged pull requests or a top-100 contributor spot
|
||||
on the repo.
|
||||
- **"Support the project"** — opens `RADAR_SUPPORTER_PLANS_URL` (default
|
||||
`https://radar.omniroute.online/planos`), the hosted page for the one-time 6-month, 1-year, and
|
||||
lifetime options. The OSS page still displays no monetary value.
|
||||
`https://radar.omniroute.online/planos`), the payment/plans page.
|
||||
|
||||
Both URLs are resolved server-side (`src/lib/radar/links.ts`, same env-override
|
||||
pattern as `RADAR_FEED_URL`) and relayed to the dashboard through the existing
|
||||
|
||||
@@ -6199,22 +6199,6 @@ paths:
|
||||
"200":
|
||||
description: Health status
|
||||
|
||||
/api/monitoring/compression:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get compression result-memo statistics
|
||||
description: >-
|
||||
In-process compression result-memo observability snapshot — size, capacity,
|
||||
lifetime hits/misses/hitRate plus 1m/5m/15m/1h windowed rates. Lightweight
|
||||
(no DB, no provider reads) companion to `GET /api/monitoring/health` intended
|
||||
for frequent polling. Sent with `Cache-Control: no-store, no-cache,
|
||||
must-revalidate`. Counters reset on process restart.
|
||||
responses:
|
||||
"200":
|
||||
description: Compression memo stats (`compression.memo` + `timestamp`)
|
||||
"503":
|
||||
description: Compression stats unavailable
|
||||
|
||||
/api/rate-limits:
|
||||
get:
|
||||
tags: [System]
|
||||
|
||||
@@ -428,7 +428,6 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
|
||||
| `DEVIN_BRIDGE_OPUS_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Opus default. |
|
||||
| `DEVIN_BRIDGE_HAIKU_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Haiku default. |
|
||||
| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. |
|
||||
| `DEVIN_SEAT_API_URL` | `https://server.codeium.com` | `open-sse/services/usage/devinCli.ts` | Optional override for the Codeium seat-management API used by Devin CLI quota (`GetUserStatus`). |
|
||||
| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. |
|
||||
| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). |
|
||||
| `ZCODE_BIN` | `zcode` | `open-sse/executors/zcode.ts` | Binary used for the local `zcode` provider's stdio client. Falls back to `zcode` on PATH. |
|
||||
|
||||
@@ -48,7 +48,7 @@ The manifest contains:
|
||||
- JSON-safe model metadata such as context length, vision/reasoning flags, and
|
||||
unsupported params
|
||||
- capability tags including `apikey`, `oauth`, `custom-executor`,
|
||||
`passthrough-models`, `responses`, `sidecar-candidate`, `usage-fetch`, and `usage-supported`
|
||||
`passthrough-models`, `responses`, `sidecar-candidate`, and `usage-fetch`
|
||||
|
||||
The manifest intentionally excludes:
|
||||
|
||||
@@ -74,7 +74,6 @@ re-reading the TypeScript sources.
|
||||
| `custom-executor` | Runs a non-default executor, so it stays on the TypeScript path. |
|
||||
| `sidecar-candidate` | Mirrors `sidecar.eligible` — safe to consider for sidecar import. |
|
||||
| `usage-fetch` | Has a wired usage or quota fetcher (`getUsageForProvider`). |
|
||||
| `usage-supported` | The usage API accepts this provider (`isSupportedUsageConnection`). |
|
||||
|
||||
`usage-fetch` is discovery only. It reports that OmniRoute knows how to read usage for the
|
||||
provider; it does not activate fetching, change quota semantics, or imply that the
|
||||
@@ -87,16 +86,6 @@ with aliases and is slightly longer than the number of tagged providers: entries
|
||||
not chat providers in the manifest registry (for example the `firecrawl` search provider
|
||||
and the `amazon-q` ACP provider) have no manifest entry to tag.
|
||||
|
||||
`usage-supported` answers whether the server and Dashboard usage routes accept a connection
|
||||
for the provider. It mirrors `isSupportedUsageConnection()` (`src/lib/usage/providerLimits.ts`)
|
||||
and `supportsProviderQuota()` (`src/shared/utils/providerQuotaVisibility.ts`), both gated by
|
||||
`USAGE_SUPPORTED_PROVIDERS` (`open-sse/services/usage/supportedProviders.ts`). Unlike
|
||||
`usage-fetch`, it is emitted on the provider id alone — the runtime guard does
|
||||
`USAGE_SUPPORTED_PROVIDERS.includes(providerId)` with no alias resolution, so the manifest
|
||||
keeps the same rule. The two tags have different perimeters: 3 providers carry only
|
||||
`usage-fetch` (`opencode`, `opencode-zen`, `xai`) and 1 carries only
|
||||
`usage-supported` (`xiaomi-mimo-token-plan`), so one does not imply the other.
|
||||
|
||||
## Sidecar Use
|
||||
|
||||
Sidecars should treat `sidecar.eligible` as a conservative candidate signal, not
|
||||
|
||||
@@ -223,9 +223,6 @@ const nextConfig = {
|
||||
...(isContributorBuild ? {} : { output: "standalone" }),
|
||||
compress: true,
|
||||
productionBrowserSourceMaps: false,
|
||||
// Issue #67: enable React Compiler — automates memoization, removes manual useCallback/useMemo debt.
|
||||
// See: https://next.dev/blog/react-compiler
|
||||
reactCompiler: true,
|
||||
// OmniRoute is a proxy for AI APIs — request bodies routinely include
|
||||
// multi-MB payloads (vision models, image edits, base64-encoded files,
|
||||
// long chat histories with embedded images). Next.js's Server Action
|
||||
@@ -333,13 +330,6 @@ const nextConfig = {
|
||||
// analysis can't follow _require.resolve("sql.js/package.json") and spams
|
||||
// build warnings. Externalizing silences them without changing behaviour.
|
||||
"sql.js",
|
||||
// tiktoken's node build reads tiktoken_bg.wasm via __dirname-relative
|
||||
// fs.readFileSync at import time. When bundled, the wasm asset is not
|
||||
// traced into the server chunk and page-data collection for any route
|
||||
// importing the vendored ChatGPT Web tokenizer fails with
|
||||
// "Missing tiktoken_bg.wasm". Externalizing keeps the require at runtime
|
||||
// where node_modules/tiktoken/tiktoken_bg.wasm resolves normally.
|
||||
"tiktoken",
|
||||
// sqlite-vec ships a native vec0.so loaded at runtime via createRequire().
|
||||
// Turbopack otherwise tries to bundle the .so and fails with "Unknown module
|
||||
// type"; externalizing it keeps the require at runtime (like better-sqlite3).
|
||||
|
||||
@@ -408,7 +408,6 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
const EMBEDDING_PROVIDER_ALIASES: Record<string, string> = {
|
||||
@@ -471,38 +470,6 @@ export function getEmbeddingProvider(providerId: string): EmbeddingProvider | nu
|
||||
return EMBEDDING_PROVIDERS[resolveEmbeddingProviderId(providerId)] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an OpenAI-compatible embeddings config for a chat provider that has NO
|
||||
* curated EMBEDDING_PROVIDERS entry. Works for any registry provider whose base
|
||||
* URL ends in /chat/completions by swapping that suffix for /embeddings (groq,
|
||||
* mistral, together, upstage, fireworks, nvidia, vercel-ai-gateway, ...).
|
||||
* Dynamic-URL providers (no usable static base) derive to
|
||||
* null — they need bespoke URL handling, not a bogus endpoint.
|
||||
*
|
||||
* This is a FALLBACK only: callers must check getEmbeddingProvider() first so
|
||||
* curated entries keep their specialized configuration.
|
||||
*/
|
||||
export function deriveEmbeddingProviderForChatProvider(
|
||||
providerId: string,
|
||||
chatEntry: { id?: string; baseUrl?: string | string[] } | null | undefined
|
||||
): EmbeddingProvider | null {
|
||||
if (!chatEntry) return null;
|
||||
const rawBase = Array.isArray(chatEntry.baseUrl)
|
||||
? chatEntry.baseUrl[0]
|
||||
: chatEntry.baseUrl;
|
||||
if (!rawBase || typeof rawBase !== "string") return null;
|
||||
// stripTrailingSlashes-equivalent without importing open-sse utils here:
|
||||
const base = rawBase.replace(/\/+$/, "");
|
||||
if (!base.endsWith("/chat/completions")) return null;
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl: `${base.slice(0, -"/chat/completions".length)}/embeddings`,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse embedding model string (format: "provider/model" or just "model")
|
||||
* Returns { provider, model }
|
||||
@@ -518,18 +485,6 @@ export function parseEmbeddingModel(
|
||||
const slashIdx = modelStr.indexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
const rawProvider = modelStr.slice(0, slashIdx);
|
||||
|
||||
// A configured provider_node whose prefix exactly equals the requested
|
||||
// provider segment always wins — even when that segment is also an alias
|
||||
// of a curated provider (a local node must not be hijacked by a registry
|
||||
// alias). Same exact-match precedence documented for
|
||||
// EMBEDDING_MODEL_ALIASES above.
|
||||
const dynamicExact =
|
||||
dynamicProviders && dynamicProviders.find((dp) => dp.id === rawProvider);
|
||||
if (dynamicExact) {
|
||||
return { provider: rawProvider, model: modelStr.slice(slashIdx + 1) };
|
||||
}
|
||||
|
||||
const resolvedProvider = resolveEmbeddingProviderId(rawProvider);
|
||||
|
||||
if (EMBEDDING_PROVIDERS[resolvedProvider]) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { RegistryEntry, RegistryModel } from "./providers/shared.ts";
|
||||
import { USAGE_FETCHER_PROVIDERS } from "../services/usage/fetcherProviders.ts";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "../services/usage/supportedProviders.ts";
|
||||
|
||||
export type ProviderPluginCapability =
|
||||
| "apikey"
|
||||
@@ -9,8 +8,7 @@ export type ProviderPluginCapability =
|
||||
| "passthrough-models"
|
||||
| "responses"
|
||||
| "sidecar-candidate"
|
||||
| "usage-fetch"
|
||||
| "usage-supported";
|
||||
| "usage-fetch";
|
||||
|
||||
export interface ProviderPluginModel {
|
||||
id: string;
|
||||
@@ -68,15 +66,6 @@ const SIDECAR_COMPATIBLE_EXECUTORS = new Set(["default"]);
|
||||
*/
|
||||
const USAGE_FETCHER_PROVIDER_SET = new Set<string>(USAGE_FETCHER_PROVIDERS);
|
||||
|
||||
/**
|
||||
* Providers whose usage API is accepted by dashboard/server routes (#10078).
|
||||
* Unlike USAGE_FETCHER_PROVIDERS this gate is checked with a plain
|
||||
* `USAGE_SUPPORTED_PROVIDERS.includes(providerId)` — no alias resolution —
|
||||
* so the manifest must emit on the identifier alone to stay faithful to the
|
||||
* runtime guard.
|
||||
*/
|
||||
const USAGE_SUPPORTED_PROVIDER_SET = new Set<string>(USAGE_SUPPORTED_PROVIDERS);
|
||||
|
||||
function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined)
|
||||
@@ -153,9 +142,6 @@ function capabilitiesFor(entry: RegistryEntry, eligible: boolean): ProviderPlugi
|
||||
) {
|
||||
capabilities.add("usage-fetch");
|
||||
}
|
||||
if (USAGE_SUPPORTED_PROVIDER_SET.has(entry.id)) {
|
||||
capabilities.add("usage-supported");
|
||||
}
|
||||
|
||||
return [...capabilities].sort();
|
||||
}
|
||||
|
||||
@@ -218,29 +218,3 @@ export function getAllRerankModels() {
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a Cohere-compatible rerank config for a chat provider that has NO
|
||||
* curated RERANK_PROVIDERS entry. Works for any registry provider whose base
|
||||
* URL ends in /chat/completions by swapping that suffix for /rerank (groq,
|
||||
* mistral, vercel-ai-gateway, ...). Dynamic-URL providers (no usable static
|
||||
* base, e.g. dynamic account-scoped hosts) derive to null — they need bespoke
|
||||
* URL handling.
|
||||
*
|
||||
* This is a FALLBACK only: callers must check getRerankProvider() first so
|
||||
* curated entries keep their specialized configuration and format adapters.
|
||||
*/
|
||||
export function deriveRerankProviderForChatProvider(providerId, chatEntry) {
|
||||
if (!chatEntry) return null;
|
||||
const rawBase = Array.isArray(chatEntry.baseUrl) ? chatEntry.baseUrl[0] : chatEntry.baseUrl;
|
||||
if (!rawBase || typeof rawBase !== "string") return null;
|
||||
const base = rawBase.replace(/\/+$/, "");
|
||||
if (!base.endsWith("/chat/completions")) return null;
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl: `${base.slice(0, -"/chat/completions".length)}/rerank`,
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,50 +50,6 @@ export const GITHUB_REASONING_EFFORT_OPT_IN_PATTERN = /claude[-_.]?(?:opus|sonne
|
||||
export const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i;
|
||||
const NVIDIA_GLM_52_PATTERN = /z-ai\/glm-5\.2\b/i;
|
||||
|
||||
/**
|
||||
* Model families whose top reasoning tier in their native API or upstream gateways
|
||||
* is `max` (rather than `xhigh`):
|
||||
* - GLM 5.1+ / 6.0+ (Z.AI / Zhipu GLM-5.1, GLM-5.2, GLM-5.3, GLM-5.3-flash, GLM-5.4, GLM-6...)
|
||||
* - DeepSeek V4+ (Flash, Pro, Flash-Vision, ...)
|
||||
* - Moonshot Kimi K3+ (Kimi K3, K4...)
|
||||
*/
|
||||
export const MAX_TIER_REASONING_MODEL_PATTERN =
|
||||
/(?:^|\/|\b)(?:glm-(?:5\.[1-9]|5\.\d+|[6-9]|\d{2,})|deepseek-v(?:[4-9]|\d{2,})|kimi-k(?:[3-9]|\d{2,}))/i;
|
||||
|
||||
export const O1_O3_REASONING_MODELS_PATTERN = /(?:^|\/|\b)(?:o1-mini|o1|o3-mini|o3-pro|o3)(?:$|-)/i;
|
||||
export const O1_PREVIEW_PATTERN = /(?:^|\/|\b)o1-preview(?:$|-)/i;
|
||||
export const MUSE_SPARK_PATTERN = /(?:^|\/|\b)muse-spark/i;
|
||||
export const MINIMAX_REASONING_PATTERN = /(?:^|\/|\b)minimax(?:-m3|-m2)/i;
|
||||
export const GROK_45_PATTERN = /(?:^|\/|\b)grok-4\.5/i;
|
||||
export const GROK_46_PATTERN = /(?:^|\/|\b)grok-4\.6/i;
|
||||
export const GLM_53_FAMILY_PATTERN = /(?:^|\/|\b)glm-5\.3(?:$|-)/i;
|
||||
export const GLM_52_FAMILY_PATTERN = /(?:^|\/|\b)glm-5\.2(?:$|-)/i;
|
||||
|
||||
export function isCommandCodeProvider(provider: string): boolean {
|
||||
return (
|
||||
provider === "command-code" ||
|
||||
provider === "cmd" ||
|
||||
provider === "command_code"
|
||||
);
|
||||
}
|
||||
|
||||
export function isOllamaCloudProvider(provider: string): boolean {
|
||||
return (
|
||||
provider === "ollama-cloud" ||
|
||||
provider === "ollamacloud" ||
|
||||
provider === "ollama_cloud"
|
||||
);
|
||||
}
|
||||
|
||||
export function isOpencodeGoProvider(provider: string): boolean {
|
||||
return (
|
||||
provider === "opencode-go" ||
|
||||
provider === "opencode-zen" ||
|
||||
provider === "opencode" ||
|
||||
provider === "opencode_go"
|
||||
);
|
||||
}
|
||||
|
||||
type ReasoningSanitizeLog = {
|
||||
info?: (tag: string, msg: string) => void;
|
||||
};
|
||||
@@ -198,21 +154,23 @@ export function supportsMaxEffortForProvider(provider: string, model: string): b
|
||||
const isClaude =
|
||||
(provider === PROVIDER_CLAUDE || isClaudeCodeCompatible(provider)) &&
|
||||
supportsClaudeMaxEffort(resolvedModelId);
|
||||
const isOpencodeGo = isOpencodeGoProvider(provider);
|
||||
const isOllamaCloud = isOllamaCloudProvider(provider);
|
||||
// opencode-go proxies DeepSeek with the native DeepSeek API contract, which
|
||||
// accepts {high, max} literally. Without this opt-in, max would be
|
||||
// normalized to xhigh (the OmniRoute-internal top tier) and rejected by the
|
||||
// upstream. Scoped to opencode-go deliberately: OpenRouter's DeepSeek path
|
||||
// (pi#4055) is the documented inverse and expects xhigh, not max.
|
||||
// Ollama Cloud also accepts literal max (for example GLM 5.2 supports
|
||||
// low|medium|high|max|none) and rejects xhigh; xhigh is mapped to max by the
|
||||
// provider guard in sanitizeReasoningEffortForProvider.
|
||||
const isOpencodeGoDeepSeek =
|
||||
(provider === "opencode-go" || provider === "opencode-zen") &&
|
||||
resolvedModelId.toLowerCase().includes("deepseek");
|
||||
const isOllamaCloud = provider === "ollama-cloud";
|
||||
const isMoonshotK3 = /^kimi-k3(?:$|-)/i.test(resolvedModelId);
|
||||
const isCommandCode = isCommandCodeProvider(provider);
|
||||
const isMaxTierModel =
|
||||
MAX_TIER_REASONING_MODEL_PATTERN.test(resolvedModelId) ||
|
||||
MAX_TIER_REASONING_MODEL_PATTERN.test(model);
|
||||
return (
|
||||
isClaude ||
|
||||
isOpencodeGo ||
|
||||
isOllamaCloud ||
|
||||
isMoonshotK3 ||
|
||||
isCommandCode ||
|
||||
isMaxTierModel
|
||||
);
|
||||
// Command Code's upstream API accepts the literal DeepSeek/OpenAI effort value
|
||||
// `max`; do not rewrite it to OmniRoute's internal `xhigh` spelling.
|
||||
const isCommandCode = provider === "command-code";
|
||||
return isClaude || isOpencodeGoDeepSeek || isOllamaCloud || isMoonshotK3 || isCommandCode;
|
||||
}
|
||||
|
||||
// ── Effort carrier helpers (#7044) ──────────────────────────────────────────
|
||||
@@ -309,15 +267,6 @@ export function sanitizeReasoningEffortForProvider(
|
||||
const effortStr = typeof c.effort === "string" ? c.effort.toLowerCase() : "";
|
||||
const modelStr = model || "";
|
||||
|
||||
// ── o1-preview: does not accept reasoning_effort parameter at all ─────────
|
||||
if (O1_PREVIEW_PATTERN.test(modelStr)) {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: removed unsupported reasoning_effort for o1-preview`
|
||||
);
|
||||
return stripEffortValue(b, c);
|
||||
}
|
||||
|
||||
const githubOptIn =
|
||||
provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr);
|
||||
const rejecting =
|
||||
@@ -331,136 +280,6 @@ export function sanitizeReasoningEffortForProvider(
|
||||
return stripEffortValue(b, c);
|
||||
}
|
||||
|
||||
// ── GLM-5.3 and GLM-5.3-FLASH specific rules ──────────────────────────────
|
||||
// Supported options: max (default & recommended), high, low.
|
||||
// none/minimal/low → low; medium/high → high; xhigh/max → max.
|
||||
// In addition, GLM-5.3+ forces thinking; thinking.type="disabled" is rejected upstream.
|
||||
if (GLM_53_FAMILY_PATTERN.test(modelStr)) {
|
||||
let mappedGlm53 = "max";
|
||||
if (effortStr === "none" || effortStr === "minimal" || effortStr === "low") {
|
||||
mappedGlm53 = "low";
|
||||
} else if (effortStr === "medium" || effortStr === "high") {
|
||||
mappedGlm53 = "high";
|
||||
} else if (effortStr === "xhigh" || effortStr === "max" || effortStr === "ultra") {
|
||||
mappedGlm53 = "max";
|
||||
}
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort ${effortStr} → ${mappedGlm53} (GLM-5.3 contract)`
|
||||
);
|
||||
let updated = writeEffortValue(b, mappedGlm53, c);
|
||||
const thinkingObj = updated.thinking;
|
||||
if (
|
||||
thinkingObj &&
|
||||
typeof thinkingObj === "object" &&
|
||||
!Array.isArray(thinkingObj) &&
|
||||
(thinkingObj as Record<string, unknown>).type === "disabled"
|
||||
) {
|
||||
updated = {
|
||||
...updated,
|
||||
thinking: {
|
||||
...(thinkingObj as Record<string, unknown>),
|
||||
type: "enabled",
|
||||
},
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── GLM-5.2 specific rules ────────────────────────────────────────────────
|
||||
// none/minimal stop thinking (none); low/medium → high; xhigh/max → max; high → high.
|
||||
if (GLM_52_FAMILY_PATTERN.test(modelStr)) {
|
||||
let mappedGlm52 = "max";
|
||||
if (effortStr === "none" || effortStr === "minimal") {
|
||||
mappedGlm52 = "none";
|
||||
} else if (effortStr === "low" || effortStr === "medium") {
|
||||
mappedGlm52 = "high";
|
||||
} else if (effortStr === "xhigh" || effortStr === "max" || effortStr === "ultra") {
|
||||
mappedGlm52 = "max";
|
||||
} else if (effortStr === "high") {
|
||||
mappedGlm52 = "high";
|
||||
}
|
||||
if (mappedGlm52 !== effortStr) {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort ${effortStr} → ${mappedGlm52} (GLM-5.2 contract)`
|
||||
);
|
||||
return writeEffortValue(b, mappedGlm52, c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── Muse Spark models (muse-spark-1.2, etc.) ─────────────────────────────
|
||||
// Accepts minimal|low|medium|high|xhigh. Rejects none (400) and max.
|
||||
// max/ultra → xhigh; none → minimal.
|
||||
if (MUSE_SPARK_PATTERN.test(modelStr)) {
|
||||
if (effortStr === "max" || effortStr === "ultra") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort ${effortStr} → xhigh (Muse Spark ceiling)`
|
||||
);
|
||||
return writeEffortValue(b, "xhigh", c);
|
||||
}
|
||||
if (effortStr === "none") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort none → minimal (Muse Spark floor)`
|
||||
);
|
||||
return writeEffortValue(b, "minimal", c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── OpenAI o1 / o3-mini models ───────────────────────────────────────────
|
||||
// Accepts only low|medium|high. Clamp xhigh/max/ultra → high.
|
||||
if (O1_O3_REASONING_MODELS_PATTERN.test(modelStr)) {
|
||||
if (effortStr === "xhigh" || effortStr === "max" || effortStr === "ultra") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort ${effortStr} → high (o1/o3-mini ceiling)`
|
||||
);
|
||||
return writeEffortValue(b, "high", c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── MiniMax models ───────────────────────────────────────────────────────
|
||||
// Accepts none|minimal|low|medium|high. Clamp xhigh/max/ultra → high.
|
||||
if (MINIMAX_REASONING_PATTERN.test(modelStr)) {
|
||||
if (effortStr === "xhigh" || effortStr === "max" || effortStr === "ultra") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort ${effortStr} → high (MiniMax ceiling)`
|
||||
);
|
||||
return writeEffortValue(b, "high", c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── xAI Grok models ──────────────────────────────────────────────────────
|
||||
// Grok 4.6 accepts low|medium|high|xhigh (clamp max/ultra → xhigh).
|
||||
// Grok 4.5 accepts low|medium|high (clamp xhigh/max/ultra → high).
|
||||
if (GROK_46_PATTERN.test(modelStr)) {
|
||||
if (effortStr === "max" || effortStr === "ultra") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort ${effortStr} → xhigh (Grok 4.6 ceiling)`
|
||||
);
|
||||
return writeEffortValue(b, "xhigh", c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
if (GROK_45_PATTERN.test(modelStr)) {
|
||||
if (effortStr === "xhigh" || effortStr === "max" || effortStr === "ultra") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort ${effortStr} → high (Grok 4.5 ceiling)`
|
||||
);
|
||||
return writeEffortValue(b, "high", c);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// `minimal` is a sub-`low` reasoning tier some catalogs advertise (e.g.
|
||||
// Muse Spark via models.dev) and the Codex provider accepts natively — but
|
||||
// Command Code rejects it outright:
|
||||
@@ -468,7 +287,7 @@ export function sanitizeReasoningEffortForProvider(
|
||||
// "low"|"medium"|"high"|"xhigh"|"max" at "params.reasoning_effort"
|
||||
// Map it to the closest supported value (`low`) for command-code only;
|
||||
// other providers (codex etc.) keep their native `minimal` handling.
|
||||
if (isCommandCodeProvider(provider) && effortStr === "minimal") {
|
||||
if (provider === "command-code" && effortStr === "minimal") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort minimal → low`
|
||||
@@ -476,23 +295,10 @@ export function sanitizeReasoningEffortForProvider(
|
||||
return writeEffortValue(b, "low", c);
|
||||
}
|
||||
|
||||
// Providers and model families whose top reasoning tier is `max` natively
|
||||
// (or whose gateways expect `max` rather than OmniRoute's internal `xhigh`):
|
||||
// - Command Code (`command-code` / `cmd`)
|
||||
// - Ollama Cloud (`ollama-cloud` / `ollamacloud`)
|
||||
// - OpenCode Go (`opencode-go` / `opencode-zen` / `opencode`)
|
||||
// - GLM 5.1+ / 6.0+ (Z.AI / Zhipu GLM-5.1, GLM-5.2, GLM-5.3, GLM-5.4...)
|
||||
// - DeepSeek V4+ (Flash, Pro, Vision, ...)
|
||||
// - Kimi K3+ (Moonshot AI K3, K4, ...)
|
||||
// OpenRouter (pi#4055) is excluded because OpenRouter's normalized API expects xhigh.
|
||||
const isMaxTierTarget =
|
||||
provider !== "openrouter" &&
|
||||
(isCommandCodeProvider(provider) ||
|
||||
isOllamaCloudProvider(provider) ||
|
||||
isOpencodeGoProvider(provider) ||
|
||||
MAX_TIER_REASONING_MODEL_PATTERN.test(modelStr));
|
||||
|
||||
if (isMaxTierTarget && effortStr === "xhigh") {
|
||||
// Command Code accepts the literal top-tier value `max`, while the shared
|
||||
// standardization stage may have already represented the client's `max` as
|
||||
// OmniRoute's internal `xhigh`. Convert it back before the upstream request.
|
||||
if (provider === "command-code" && effortStr === "xhigh") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: normalized reasoning_effort xhigh → max`
|
||||
@@ -500,6 +306,18 @@ export function sanitizeReasoningEffortForProvider(
|
||||
return writeEffortValue(b, "max", c);
|
||||
}
|
||||
|
||||
// Ollama Cloud accepts low|medium|high|max|none and rejects xhigh. Map
|
||||
// xhigh → max (its literal top tier) before the generic xhigh handling so
|
||||
// passthrough (unregistered) models are covered too — the registry opt-out
|
||||
// only covers known models.
|
||||
if (provider === "ollama-cloud" && effortStr === "xhigh") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort xhigh → max`
|
||||
);
|
||||
return writeEffortValue(b, "max", c);
|
||||
}
|
||||
|
||||
// Native DeepSeek (api.deepseek.com) — V4 Pro and Flash use the native
|
||||
// {low, high, max} vocabulary, while other model ids retain the {high, max}
|
||||
// floor. OmniRoute's internal top tier xhigh maps to DeepSeek's literal max,
|
||||
@@ -545,6 +363,14 @@ export function sanitizeReasoningEffortForProvider(
|
||||
// and the requested effort falls outside that vocabulary, remap to the
|
||||
// nearest declared tier: the smallest ranked value ≥ the request, else the
|
||||
// highest declared (a request above the ceiling lands on the ceiling).
|
||||
// Live case: opencode-go/ox-alpha-free (Console Go) only accepts
|
||||
// {low, high, max} — a client's reasoning_effort:"medium" reached the
|
||||
// upstream verbatim and 400'd every turn ("[1210] This model always engages
|
||||
// in thinking and cannot be disabled; please use low, high, or max"). The
|
||||
// learned-caps path can't help here (it only clamps down from xhigh/max,
|
||||
// and this error text isn't a parseable enum), so the declaration is the
|
||||
// only source of truth. Models without an explicit declaration keep
|
||||
// #8057's trust-the-upstream pass-through.
|
||||
const providerModelIdForClamp = modelStr.startsWith(`${provider}/`)
|
||||
? modelStr.slice(provider.length + 1)
|
||||
: modelStr;
|
||||
|
||||
@@ -109,7 +109,6 @@ function parseGlmEffortTier(model: string): GlmEffortTier | null {
|
||||
* https://docs.z.ai/guides/overview/concept-param
|
||||
*/
|
||||
const GLM_THINKING_MODEL_PATTERN = /^glm-5\.(?:[2-9]|\d{2,})/i;
|
||||
const GLM_53_OR_HIGHER_PATTERN = /^glm-5\.(?:[3-9]|\d{2,})/i;
|
||||
|
||||
function isGlmThinkingModel(model: string): boolean {
|
||||
return GLM_THINKING_MODEL_PATTERN.test(model);
|
||||
@@ -349,15 +348,6 @@ export class GlmExecutor extends DefaultExecutor {
|
||||
}
|
||||
|
||||
if (transport === "openai") {
|
||||
// GLM-5.3+ rejects thinking.type "disabled". Ensure thinking is enabled
|
||||
// when targeting GLM-5.3 or higher.
|
||||
if (record && GLM_53_OR_HIGHER_PATTERN.test(effectiveModel)) {
|
||||
const existingThinking = asRecord(record.thinking);
|
||||
if (existingThinking?.type === "disabled") {
|
||||
record.thinking = { ...existingThinking, type: "enabled" };
|
||||
}
|
||||
}
|
||||
|
||||
// GLM-5.3 effort tiers: inject the documented `reasoning_effort` param and
|
||||
// force thinking on — 5.3 rejects thinking.type "disabled", and an effort
|
||||
// tier without thinking would silently drop the selector upstream.
|
||||
|
||||
@@ -472,7 +472,6 @@ import {
|
||||
isRpmExhausted,
|
||||
} from "../services/geminiRateLimitTracker.ts";
|
||||
import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
|
||||
import { getProactiveCompressionRatio } from "@/lib/db/compression";
|
||||
|
||||
type ChatCoreExecutorResult = ReturnType<typeof normalizeExecutorResult> & {
|
||||
_executionCredentials?: Record<string, unknown>;
|
||||
@@ -1994,7 +1993,7 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
const COMPRESSION_THRESHOLD = getProactiveCompressionRatio();
|
||||
const COMPRESSION_THRESHOLD = 0.7;
|
||||
let reservedTokens = 0;
|
||||
if (Array.isArray(body.tools)) {
|
||||
reservedTokens = estimateTokens(body.tools);
|
||||
|
||||
@@ -201,7 +201,6 @@ export async function handleRerank({
|
||||
connectionId = null,
|
||||
apiKeyId = null,
|
||||
apiKeyName = null,
|
||||
resolvedProvider = null,
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
if (!model) return errorResponse(400, "model is required");
|
||||
@@ -211,8 +210,7 @@ export async function handleRerank({
|
||||
}
|
||||
|
||||
const { provider: providerId, model: modelId } = parseRerankModel(model);
|
||||
const providerConfig =
|
||||
resolvedProvider || (providerId ? getRerankProvider(providerId) : null);
|
||||
const providerConfig = providerId ? getRerankProvider(providerId) : null;
|
||||
|
||||
if (!providerConfig) {
|
||||
const availableProviders = Object.keys(RERANK_PROVIDERS).join(", ");
|
||||
@@ -221,13 +219,10 @@ export async function handleRerank({
|
||||
`No rerank provider found for model "${model}". Available: ${availableProviders}`
|
||||
);
|
||||
}
|
||||
// When a derived/generic provider is injected, its id is authoritative for
|
||||
// logging and cost attribution even though parseRerankModel returned null.
|
||||
const effectiveProviderId = providerConfig.id || providerId;
|
||||
|
||||
const token = credentials?.apiKey || credentials?.accessToken;
|
||||
if (!token) {
|
||||
return errorResponse(401, `No credentials for rerank provider: ${effectiveProviderId}`);
|
||||
return errorResponse(401, `No credentials for rerank provider: ${providerId}`);
|
||||
}
|
||||
|
||||
const requestBody = transformRequestForProvider(providerConfig, {
|
||||
@@ -280,8 +275,8 @@ export async function handleRerank({
|
||||
method: "POST",
|
||||
path: "/v1/rerank",
|
||||
status: res.status,
|
||||
model: `${effectiveProviderId}/${modelId}`,
|
||||
provider: effectiveProviderId,
|
||||
model: `${providerId}/${modelId}`,
|
||||
provider: providerId,
|
||||
connectionId: connectionId || undefined,
|
||||
duration: Date.now() - startTime,
|
||||
requestBody,
|
||||
@@ -301,14 +296,14 @@ export async function handleRerank({
|
||||
});
|
||||
|
||||
const searchUnits = Number(result?.meta?.billed_units?.search_units) || 0;
|
||||
const costUsd = await calculateModalCost("rerank", effectiveProviderId, modelId, { searchUnits });
|
||||
const costUsd = await calculateModalCost("rerank", providerId, modelId, { searchUnits });
|
||||
|
||||
saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/rerank",
|
||||
status: 200,
|
||||
model: `${effectiveProviderId}/${modelId}`,
|
||||
provider: effectiveProviderId,
|
||||
model: `${providerId}/${modelId}`,
|
||||
provider: providerId,
|
||||
connectionId: connectionId || undefined,
|
||||
duration: Date.now() - startTime,
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
@@ -320,7 +315,7 @@ export async function handleRerank({
|
||||
|
||||
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
|
||||
attachOmniRouteMetaHeaders(headers, {
|
||||
provider: effectiveProviderId,
|
||||
provider: providerId,
|
||||
model: modelId,
|
||||
costUsd,
|
||||
latencyMs: Date.now() - startTime,
|
||||
|
||||
@@ -11,11 +11,7 @@ import type {
|
||||
EngineValidationResult,
|
||||
} from "../types.ts";
|
||||
import { CODEX_RESPONSE_ITEM_META } from "../../bodyAdapter.ts";
|
||||
import {
|
||||
countTextTokens,
|
||||
MAX_EXACT_TOKEN_COUNT_CHARS,
|
||||
} from "../../../../../src/shared/utils/tiktokenCounter.ts";
|
||||
import { jsonLength, jsonLengthStrippingBase64DataUris } from "../../../../utils/jsonSize.ts";
|
||||
import { countTextTokens } from "../../../../../src/shared/utils/tiktokenCounter.ts";
|
||||
|
||||
const ENGINE_ID = "codex-responses";
|
||||
|
||||
@@ -23,23 +19,6 @@ function countCodexTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return countTextTokens(text, { provider: "codex" });
|
||||
}
|
||||
|
||||
/** Codex-context token count for a whole body, skipping JSON.stringify on oversized
|
||||
* bodies: countTextTokens falls back to a char heuristic above MAX_EXACT_TOKEN_COUNT_CHARS,
|
||||
* so materializing a multi-MB string for the count is a pure OOM-class transient (#7847). */
|
||||
function countCodexTokensForBody(body: unknown): number {
|
||||
if (body === null || body === undefined) return 0;
|
||||
if (typeof body === "string") return countCodexTokens(body);
|
||||
if (jsonLength(body) > MAX_EXACT_TOKEN_COUNT_CHARS) {
|
||||
// Oversized bodies skip countTextTokens (which falls back to a char heuristic above
|
||||
// MAX_EXACT_TOKEN_COUNT_CHARS) to avoid materializing a multi-MB string (#7847). But the
|
||||
// exact path it replaces also stripped base64 data URIs first; the heuristic must too,
|
||||
// otherwise embedded screenshots inflate the reported token count and distort
|
||||
// savingsPercent. (The compression DECISION is unaffected either way.)
|
||||
return Math.ceil(jsonLengthStrippingBase64DataUris(body) / 4);
|
||||
}
|
||||
return countCodexTokens(JSON.stringify(body));
|
||||
}
|
||||
const SUPPORTED_TYPES = new Set([
|
||||
"function_call_output",
|
||||
"local_shell_call_output",
|
||||
@@ -295,8 +274,8 @@ export const codexResponsesEngine: CompressionEngine = {
|
||||
if (!changed) return { body, compressed: false, stats: null };
|
||||
const nextBody = { ...body, messages };
|
||||
const stats = createCompressionStats(body, nextBody, "codex-responses", [ENGINE_ID]);
|
||||
const originalTokens = countCodexTokensForBody(body);
|
||||
const compressedTokens = countCodexTokensForBody(nextBody);
|
||||
const originalTokens = countCodexTokens(JSON.stringify(body));
|
||||
const compressedTokens = countCodexTokens(JSON.stringify(nextBody));
|
||||
stats.originalTokens = originalTokens;
|
||||
stats.compressedTokens = compressedTokens;
|
||||
stats.savingsPercent =
|
||||
|
||||
@@ -162,18 +162,17 @@ export function applyHardBudget(
|
||||
// Distribute the aggregate budget proportionally per message so the SUM stays
|
||||
// ≤ target (passing the full target to each message would let an N-message body
|
||||
// come back N× over budget).
|
||||
let changed = false;
|
||||
const newMessages = messages.map((m) => {
|
||||
if (typeof m.content !== "string") return m;
|
||||
const msgTokens = countTextTokens(m.content, tokenizerContext);
|
||||
const perMsgTarget =
|
||||
totalTokens > 0 ? Math.floor(effectiveTarget * (msgTokens / totalTokens)) : effectiveTarget;
|
||||
const out = compressText(m.content, perMsgTarget, tokenizerContext);
|
||||
if (out === m.content) return m;
|
||||
changed = true;
|
||||
return { ...m, content: out };
|
||||
return out === m.content ? m : { ...m, content: out };
|
||||
});
|
||||
|
||||
const changed = newMessages.some((m, i) => JSON.stringify(m) !== JSON.stringify(messages[i]));
|
||||
|
||||
// Measure the result to detect when preserve-guarded content makes the target
|
||||
// unreachable, so callers are not silently left over budget.
|
||||
const usedMessages = changed ? newMessages : messages;
|
||||
|
||||
@@ -90,8 +90,6 @@ export {
|
||||
applyStackedCompressionAsync,
|
||||
} from "./strategySelector.ts";
|
||||
|
||||
export { getMemoStats, clearMemoStore, makeMemoKey, isDeterministicMode } from "./resultMemo.ts";
|
||||
|
||||
export type {
|
||||
CompressionEngine,
|
||||
CompressionEngineApplyOptions,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { estimateCompressionTokens } from "./stats.ts";
|
||||
import type { CompressionResult, CompressionStats } from "./types.ts";
|
||||
import { jsonSha256 } from "../../utils/jsonHash.ts";
|
||||
|
||||
export interface LiveZoneOptions {
|
||||
principalId?: string;
|
||||
@@ -56,15 +57,8 @@ function serialize(value: unknown): string | null {
|
||||
}
|
||||
|
||||
function digest(value: unknown): string | null {
|
||||
// jsonSha256 computes sha256hex(JSON.stringify(value)) WITHOUT materializing the
|
||||
// multi-MB string, avoiding the #7847 OOM-class transient on large tool-message
|
||||
// items (e.g. base64 screenshots). Throws on non-serializable values, matching
|
||||
// the previous JSON.stringify behavior which the caller treats as a miss.
|
||||
try {
|
||||
return jsonSha256(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const serialized = serialize(value);
|
||||
return serialized === null ? null : createHash("sha256").update(serialized).digest("hex");
|
||||
}
|
||||
|
||||
function cloneItems(items: unknown[]): unknown[] | null {
|
||||
|
||||
@@ -1,52 +1,10 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { CompressionConfig, CompressionMode, CompressionResult } from "./types.ts";
|
||||
import { jsonSha256 } from "../../utils/jsonHash.ts";
|
||||
|
||||
export const MEMO_CAP = 5_000;
|
||||
|
||||
const memoMap = new Map<string, CompressionResult>();
|
||||
let lookupCountForTests = 0;
|
||||
let memoHits = 0;
|
||||
let memoMisses = 0;
|
||||
|
||||
// ── Windowed hit/miss ring buffer for time-bucketed stats ──────────────
|
||||
// Records each lookup outcome with a ms timestamp. getMemoStats scans the
|
||||
// ring to compute 1m/5m/15m/1h windows (like load average) so operators see
|
||||
// the *current* hit rate during a traffic spike, not a diluted all-time
|
||||
// average. Bounded memory: RING_CAP * ~9 bytes ≈ 90 KB, fixed-size array.
|
||||
const RING_CAP = 10_000;
|
||||
const ring: Array<{ ts: number; hit: boolean } | undefined> = new Array(RING_CAP);
|
||||
let ringHead = 0; // index of the NEXT write slot (wraps)
|
||||
let ringCount = 0; // entries written so far (clamped to RING_CAP)
|
||||
|
||||
function recordLookup(hit: boolean): void {
|
||||
ring[ringHead] = { ts: Date.now(), hit };
|
||||
ringHead = (ringHead + 1) % RING_CAP;
|
||||
if (ringCount < RING_CAP) ringCount++;
|
||||
}
|
||||
|
||||
/** Compute hits/misses/hitRate for lookups within the last `windowMs`. */
|
||||
function windowStats(windowMs: number): { hits: number; misses: number; hitRate: number } {
|
||||
const cutoff = Date.now() - windowMs;
|
||||
let hits = 0;
|
||||
let misses = 0;
|
||||
// Walk newest→oldest. The ring is time-ordered (oldest at head), so once
|
||||
// an entry is older than the cutoff every earlier one is too — early break.
|
||||
for (let k = 0; k < ringCount; k++) {
|
||||
const idx = (ringHead - 1 - k + RING_CAP) % RING_CAP;
|
||||
const e = ring[idx];
|
||||
if (!e) break;
|
||||
if (e.ts < cutoff) break;
|
||||
if (e.hit) hits++;
|
||||
else misses++;
|
||||
}
|
||||
const total = hits + misses;
|
||||
return {
|
||||
hits,
|
||||
misses,
|
||||
hitRate: total > 0 ? Math.round((hits / total) * 10000) / 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Opt-IN whitelist (NOT opt-out): cache only engines proven pure + STATELESS across
|
||||
// requests. Excluded on purpose: `ccr` and `session-dedup` write to the cross-request
|
||||
@@ -83,9 +41,7 @@ export function makeMemoKey(
|
||||
model?: string,
|
||||
supportsVision?: boolean | null
|
||||
): string {
|
||||
// Uses streaming jsonSha256 instead of sha256hex(JSON.stringify(body))
|
||||
// to avoid allocating multi-MB string transients on large agent payloads (#7847).
|
||||
const bodyHash = jsonSha256(body);
|
||||
const bodyHash = sha256hex(JSON.stringify(body));
|
||||
|
||||
// #8137: Only include model + supportsVision in the cache key when the compression
|
||||
// result actually depends on them. The `lite` engine strips data:image URLs only when
|
||||
@@ -141,74 +97,22 @@ function boundedSet(key: string, value: CompressionResult): void {
|
||||
export function memoLookup(key: string): CompressionResult | null {
|
||||
lookupCountForTests++;
|
||||
const hit = memoMap.get(key);
|
||||
if (!hit) {
|
||||
memoMisses++;
|
||||
recordLookup(false);
|
||||
return null;
|
||||
}
|
||||
memoHits++;
|
||||
recordLookup(true);
|
||||
if (!hit) return null;
|
||||
// Return a clone so downstream mutation cannot corrupt the cached value.
|
||||
const cloned = JSON.parse(JSON.stringify(hit)) as CompressionResult;
|
||||
if (cloned.stats) {
|
||||
cloned.stats.memoHit = true;
|
||||
}
|
||||
return cloned;
|
||||
return JSON.parse(JSON.stringify(hit)) as CompressionResult;
|
||||
}
|
||||
|
||||
export function memoStore(key: string, result: CompressionResult): CompressionResult {
|
||||
// Clone on STORE (memoLookup also clones on read) so the caller's live object — which
|
||||
// an async engine may still hold a sub-ref to — cannot later corrupt the cached entry.
|
||||
// Returns the stored clone so callers that need a fresh instance (the common
|
||||
// `memoStore(key, result); return memoLookup(key)!` idiom) can avoid a redundant
|
||||
// second multi-MB deep clone of the body on the way out.
|
||||
const stored = JSON.parse(JSON.stringify(result)) as CompressionResult;
|
||||
boundedSet(key, stored);
|
||||
return stored;
|
||||
export function memoStore(key: string, result: CompressionResult): void {
|
||||
// Clone on STORE too (memoLookup already clones on read). Storing the caller's live
|
||||
// object would let a later mutation of it (e.g. an async engine holding a sub-ref)
|
||||
// corrupt the cached entry. Both ends isolated ⇒ the cache is immutable once stored.
|
||||
boundedSet(key, JSON.parse(JSON.stringify(result)) as CompressionResult);
|
||||
}
|
||||
|
||||
/** Observability stats for the in-process result memo store.
|
||||
* `windows` gives time-bucketed hit/miss/rate (1m/5m/15m/1h) so operators
|
||||
* see the *current* behavior during a spike, not the diluted lifetime rate.
|
||||
* `hits`/`misses`/`hitRate` remain the lifetime cumulative counters. */
|
||||
export function getMemoStats(): {
|
||||
size: number;
|
||||
capacity: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
hitRate: number;
|
||||
windows: {
|
||||
"1m": { hits: number; misses: number; hitRate: number };
|
||||
"5m": { hits: number; misses: number; hitRate: number };
|
||||
"15m": { hits: number; misses: number; hitRate: number };
|
||||
"1h": { hits: number; misses: number; hitRate: number };
|
||||
};
|
||||
} {
|
||||
const total = memoHits + memoMisses;
|
||||
return {
|
||||
size: memoMap.size,
|
||||
capacity: MEMO_CAP,
|
||||
hits: memoHits,
|
||||
misses: memoMisses,
|
||||
hitRate: total > 0 ? Math.round((memoHits / total) * 10000) / 100 : 0,
|
||||
windows: {
|
||||
"1m": windowStats(60_000),
|
||||
"5m": windowStats(5 * 60_000),
|
||||
"15m": windowStats(15 * 60_000),
|
||||
"1h": windowStats(60 * 60_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** For tests only — clears the in-process memo store and resets counters. */
|
||||
/** For tests only — clears the in-process memo store. */
|
||||
export function clearMemoStore(): void {
|
||||
memoMap.clear();
|
||||
lookupCountForTests = 0;
|
||||
memoHits = 0;
|
||||
memoMisses = 0;
|
||||
for (let i = 0; i < RING_CAP; i++) ring[i] = undefined;
|
||||
ringHead = 0;
|
||||
ringCount = 0;
|
||||
}
|
||||
export const resultMemoForTests = {
|
||||
get lookupCount(): number {
|
||||
|
||||
@@ -11,22 +11,14 @@ import {
|
||||
countTextTokens,
|
||||
isCodexTokenizerContext,
|
||||
tokenizerContextFromBody,
|
||||
MAX_EXACT_TOKEN_COUNT_CHARS,
|
||||
} from "../../../src/shared/utils/tiktokenCounter.ts";
|
||||
import {
|
||||
anthropicImageTokens,
|
||||
ANTHROPIC_IMAGE_BLOCK_OVERHEAD_TOKENS,
|
||||
openAIVisionTokens,
|
||||
} from "omniglyph";
|
||||
import { isInlineBase64ImageBlock } from "../contextManager.ts";
|
||||
import {
|
||||
jsonLength,
|
||||
jsonLengthStrippingBase64DataUris,
|
||||
rawLengthStrippingBase64DataUris,
|
||||
} from "../../utils/jsonSize.ts";
|
||||
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
const DEFAULT_IMAGE_TOKEN_ESTIMATE = 1200;
|
||||
|
||||
/**
|
||||
* Anthropic image block shape this estimator recognizes:
|
||||
@@ -120,15 +112,11 @@ function decodePngDimensions(base64: string): { width: number; height: number }
|
||||
}
|
||||
}
|
||||
|
||||
/** Char-count fallback for one value (using jsonLength to avoid allocating multi-MB strings).
|
||||
* Base64 data URIs embedded in arbitrary strings (not just structured image blocks) are
|
||||
* stripped so a tool-output screenshot doesn't inflate the token estimate (#7847 drift). */
|
||||
/** Char-count fallback for one value (same accounting as the legacy estimator). */
|
||||
function charTokensOf(value: unknown): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
if (typeof value === "string") {
|
||||
return Math.ceil(rawLengthStrippingBase64DataUris(value) / CHARS_PER_TOKEN);
|
||||
}
|
||||
return Math.ceil(jsonLengthStrippingBase64DataUris(value) / CHARS_PER_TOKEN);
|
||||
const str = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return Math.ceil(str.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,42 +142,23 @@ function blankImageBlocksAndSumImageTokens(body: Record<string, unknown>): {
|
||||
return content.map((block) => {
|
||||
if (isAnthropicPngImageBlock(block)) {
|
||||
const dims = decodePngDimensions(block.source.data);
|
||||
if (!dims) {
|
||||
// Recognized image block that can't be decoded: use a bounded estimate rather
|
||||
// than char-counting the raw base64, which would inflate the token estimate
|
||||
// multi-MB (the #7847 OOM/drift class).
|
||||
imageTokens += DEFAULT_IMAGE_TOKEN_ESTIMATE;
|
||||
return { ...block, source: { ...block.source, data: "" } };
|
||||
}
|
||||
if (!dims) return block; // fall back to char-counting this block as-is
|
||||
imageTokens += anthropicImageTokens(dims.width, dims.height, "standard");
|
||||
imageTokens += ANTHROPIC_IMAGE_BLOCK_OVERHEAD_TOKENS;
|
||||
return { ...block, source: { ...block.source, data: "" } };
|
||||
}
|
||||
if (isOpenAIChatPngImagePart(block)) {
|
||||
const dims = pngDimensionsFromDataUrl(block.image_url.url);
|
||||
if (!dims) {
|
||||
imageTokens += DEFAULT_IMAGE_TOKEN_ESTIMATE;
|
||||
return { ...block, image_url: { ...block.image_url, url: "" } };
|
||||
}
|
||||
if (!dims) return block;
|
||||
imageTokens += openAIVisionTokens(model, dims.width, dims.height);
|
||||
return { ...block, image_url: { ...block.image_url, url: "" } };
|
||||
}
|
||||
if (isOpenAIResponsesPngImagePart(block)) {
|
||||
const dims = pngDimensionsFromDataUrl(block.image_url);
|
||||
if (!dims) {
|
||||
imageTokens += DEFAULT_IMAGE_TOKEN_ESTIMATE;
|
||||
return { ...block, image_url: "" };
|
||||
}
|
||||
if (!dims) return block;
|
||||
imageTokens += openAIVisionTokens(model, dims.width, dims.height);
|
||||
return { ...block, image_url: "" };
|
||||
}
|
||||
if (isInlineBase64ImageBlock(block as Record<string, unknown>)) {
|
||||
// Inline-base64 image content-block shape (AI SDK / Gemini / flat) not
|
||||
// covered by the PNG decoders above. Keep the estimate bounded so a
|
||||
// multi-MB screenshot doesn't inflate the token count (#7847 drift).
|
||||
imageTokens += DEFAULT_IMAGE_TOKEN_ESTIMATE;
|
||||
return { ...block, image: "" };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
};
|
||||
@@ -232,19 +201,15 @@ export function estimateCompressionTokens(text: string | object | null | undefin
|
||||
text as Record<string, unknown>
|
||||
);
|
||||
if (imageTokens === 0) {
|
||||
// countTextTokens falls back to a char heuristic above MAX_EXACT_TOKEN_COUNT_CHARS,
|
||||
// so materializing JSON.stringify(text) for a large body would only allocate a
|
||||
// multi-MB transient that's immediately discarded (#7847 OOM class). Measure the
|
||||
// serialized length via jsonLength instead and skip the allocation when oversized.
|
||||
if (useExactTokenizer && jsonLength(text) <= MAX_EXACT_TOKEN_COUNT_CHARS) {
|
||||
return countTextTokens(JSON.stringify(text), tokenizerContext);
|
||||
}
|
||||
return charTokensOf(text);
|
||||
// Keep the legacy character estimate for generic payloads. Codex payloads use
|
||||
// the model-appropriate tokenizer so their compression stats match hard budgets.
|
||||
return useExactTokenizer
|
||||
? countTextTokens(JSON.stringify(text), tokenizerContext)
|
||||
: charTokensOf(text);
|
||||
}
|
||||
if (useExactTokenizer && jsonLength(clone) <= MAX_EXACT_TOKEN_COUNT_CHARS) {
|
||||
return countTextTokens(JSON.stringify(clone), tokenizerContext) + imageTokens;
|
||||
}
|
||||
return charTokensOf(clone) + imageTokens;
|
||||
return useExactTokenizer
|
||||
? countTextTokens(JSON.stringify(clone), tokenizerContext) + imageTokens
|
||||
: charTokensOf(clone) + imageTokens;
|
||||
} catch {
|
||||
// Non-serializable/unexpected shape → fall back to the legacy char-count,
|
||||
// never throw out of an estimator.
|
||||
|
||||
@@ -331,10 +331,6 @@ function runCompression(
|
||||
...options,
|
||||
config: { ...options.config, memoizeCompressionResults: false },
|
||||
});
|
||||
// memoStore clones internally, so the cache entry stays isolated from the caller's
|
||||
// live object. Return the caller's own `result` (upstream #11727 semantics): handing
|
||||
// back the stored clone would let the caller's later mutations corrupt the cache —
|
||||
// the exact bug the result-memo mutation-isolation test guards.
|
||||
memoStore(key, result);
|
||||
return result;
|
||||
}
|
||||
@@ -568,8 +564,6 @@ async function runCompressionAsync(
|
||||
...options,
|
||||
config: { ...options.config, memoizeCompressionResults: false },
|
||||
});
|
||||
// Same contract as the sync path: store the internal clone; return the caller's own
|
||||
// object so later caller mutations cannot corrupt the cache (#11727 semantics).
|
||||
memoStore(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -326,8 +326,6 @@ export interface CompressionStats {
|
||||
validationWarnings?: string[];
|
||||
validationErrors?: string[];
|
||||
fallbackApplied?: boolean;
|
||||
/** #7847 observability: true when this result was served from the result memo cache. */
|
||||
memoHit?: boolean;
|
||||
/**
|
||||
* Contabilidade física do OmniGlyph, normalizada pelo próprio pacote
|
||||
* (`normalizeAccounting`). Só número e enum — ver `omniglyphTelemetry.ts`
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "../../src/lib/db/contextHandoffs.ts";
|
||||
import { estimateTokens } from "./contextManager.ts";
|
||||
import { stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts";
|
||||
import { isFeatureFlagEnabled } from "../../src/shared/utils/featureFlags.ts";
|
||||
|
||||
export const HANDOFF_WARNING_THRESHOLD = 0.85;
|
||||
export const HANDOFF_EXHAUSTION_THRESHOLD = 0.95;
|
||||
@@ -140,9 +139,7 @@ export function resolveUniversalHandoffConfig(
|
||||
triggerRaw === "always" || triggerRaw === "on-error" ? triggerRaw : "on-switch";
|
||||
|
||||
return {
|
||||
enabled:
|
||||
isFeatureFlagEnabled("UNIVERSAL_CONTEXT_HANDOFF_ENABLED") &&
|
||||
getBool("enabled", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.enabled),
|
||||
enabled: getBool("enabled", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.enabled),
|
||||
trigger,
|
||||
providerAllowlist: getStringArray(
|
||||
"providerAllowlist",
|
||||
|
||||
@@ -363,17 +363,6 @@ export function classifyProviderError(
|
||||
if (recoverableProject403) {
|
||||
return PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR;
|
||||
}
|
||||
// Kiro IDC missing profileArn — AWS returns 403 "User is not authorized to make this call"
|
||||
// when the request is sent without a profileArn or to the wrong Q Developer region.
|
||||
// This is a recoverable configuration issue, not a ban: the account still works in Kiro IDE.
|
||||
// Do NOT classify as FORBIDDEN (which bans permanently). Treat as PROJECT_ROUTE_ERROR
|
||||
// so the connection stays active and can be retried after profile discovery (#10725).
|
||||
const isKiroProfile403 =
|
||||
(p === "kiro" || p === "amazon-q") &&
|
||||
bodyStr.includes("User is not authorized to make this call");
|
||||
if (isKiroProfile403) {
|
||||
return PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR;
|
||||
}
|
||||
// A Cloudflare Sentinel/Turnstile 403 is a TERMINAL block for browser-session
|
||||
// providers: the user's IP/session needs a browser Turnstile challenge, and
|
||||
// retrying the same connection will keep 403ing. Classify as FORBIDDEN so
|
||||
|
||||
@@ -21,15 +21,7 @@ type JsonRecord = Record<string, unknown>;
|
||||
* Related: services/mimoThinking.ts uses the same pattern for Xiaomi MiMo.
|
||||
*/
|
||||
|
||||
const OPENCODE_GO_PROVIDERS = new Set([
|
||||
"ollama-cloud",
|
||||
"ollamacloud",
|
||||
"ollama_cloud",
|
||||
"opencode-go",
|
||||
"opencode_go",
|
||||
"opencode",
|
||||
"opencode-zen",
|
||||
]);
|
||||
const OPENCODE_GO_PROVIDERS = new Set(["ollama-cloud", "opencode-go", "opencode", "opencode-zen"]);
|
||||
|
||||
/** True when the provider is backed by the opencode-go backend. */
|
||||
export function isOpencodeGoProvider(provider: string): boolean {
|
||||
|
||||
@@ -17,13 +17,8 @@
|
||||
* -> { data: { total_credits, total_usage } }
|
||||
* Account-level totals; upstream caches this endpoint for ~60s already.
|
||||
*
|
||||
* We fetch both and merge into one QuotaInfo. OpenRouter is credit-based, not
|
||||
* subscription-based: the /credits balance (`total_credits - total_usage`, the
|
||||
* documented "get remaining credits" signal) is authoritative and stands on
|
||||
* its own — a /key failure (rate limit, transient error, unexpected shape)
|
||||
* degrades to a credits-only quota instead of discarding the balance.
|
||||
* Only a double auth-rejection (401/403 on both) means the token is invalid.
|
||||
* Graceful "unknown" on any fetch failure — quota tracking must
|
||||
* We fetch both (credits is a cheap second call, same auth) and merge into one
|
||||
* QuotaInfo. Graceful "unknown" on any fetch failure — quota tracking must
|
||||
* never block routing (mirrors deepseekQuotaFetcher.ts / bailianQuotaFetcher.ts).
|
||||
*
|
||||
* Cache: in-memory TTL (45s, inside the 30-60s window OpenRouter's own docs
|
||||
@@ -209,38 +204,6 @@ function buildQuotaFromParts(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits-only quota — built when `/key` is unavailable but `/credits`
|
||||
* succeeded. OpenRouter is credit-based, not subscription-based: the account
|
||||
* balance (`total_credits - total_usage`, the documented "get remaining
|
||||
* credits" signal) stands on its own without any key-level cap data.
|
||||
*/
|
||||
function buildCreditsOnlyQuota(credits: OpenrouterCreditsFields): OpenrouterQuota {
|
||||
const creditBalance =
|
||||
credits.totalCredits !== null && credits.totalUsage !== null
|
||||
? credits.totalCredits - credits.totalUsage
|
||||
: null;
|
||||
return {
|
||||
used: 0,
|
||||
total: 100,
|
||||
percentUsed: 0,
|
||||
resetAt: null,
|
||||
limitReached: false,
|
||||
limit: null,
|
||||
limitRemaining: null,
|
||||
isFreeTier: false,
|
||||
usage: 0,
|
||||
usageDaily: 0,
|
||||
usageWeekly: 0,
|
||||
usageMonthly: 0,
|
||||
byokUsage: null,
|
||||
includeByokInLimit: false,
|
||||
totalCredits: credits.totalCredits,
|
||||
totalUsage: credits.totalUsage,
|
||||
creditBalance,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Free-Window Preflight (#6842) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -302,36 +265,6 @@ async function fetchJson(
|
||||
}
|
||||
}
|
||||
|
||||
type EndpointResult = { status: number; data: unknown } | null;
|
||||
|
||||
function isAuthRejected(result: EndpointResult): boolean {
|
||||
return !result || result.status === 401 || result.status === 403;
|
||||
}
|
||||
|
||||
function rememberQuota(connectionId: string, quota: OpenrouterQuota): OpenrouterQuota {
|
||||
quotaCache.set(connectionId, { quota, fetchedAt: Date.now() });
|
||||
return quota;
|
||||
}
|
||||
|
||||
function mergeOpenrouterResults(
|
||||
keyResult: EndpointResult,
|
||||
creditsResult: EndpointResult
|
||||
): OpenrouterQuota | null {
|
||||
const keyFields =
|
||||
keyResult && keyResult.status === 200 ? parseOpenrouterKeyResponse(keyResult.data) : null;
|
||||
const creditsFields =
|
||||
creditsResult && creditsResult.status === 200
|
||||
? parseOpenrouterCreditsResponse(creditsResult.data)
|
||||
: { totalCredits: null, totalUsage: null };
|
||||
if (keyFields) return buildQuotaFromParts(keyFields, creditsFields);
|
||||
// /key unavailable (rate-limited, transient failure, or unexpected shape).
|
||||
// OpenRouter is credit-based: the /credits balance stands on its own.
|
||||
if (creditsFields.totalCredits !== null || creditsFields.totalUsage !== null) {
|
||||
return buildCreditsOnlyQuota(creditsFields);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current quota for an OpenRouter connection.
|
||||
* Returns quota info based on the /key + /credits API responses.
|
||||
@@ -358,24 +291,29 @@ export async function fetchOpenrouterQuota(
|
||||
try {
|
||||
await throttleQuotaFetch();
|
||||
|
||||
const keyResult = await fetchJson(
|
||||
`${OPENROUTER_CONFIG.baseUrl}${OPENROUTER_CONFIG.keyPath}`,
|
||||
apiKey
|
||||
);
|
||||
const creditsResult = await fetchJson(
|
||||
`${OPENROUTER_CONFIG.baseUrl}${OPENROUTER_CONFIG.creditsPath}`,
|
||||
apiKey
|
||||
);
|
||||
const keyUrl = `${OPENROUTER_CONFIG.baseUrl}${OPENROUTER_CONFIG.keyPath}`;
|
||||
const keyResult = await fetchJson(keyUrl, apiKey);
|
||||
|
||||
// Both endpoints auth-rejected: the token itself is invalid — fail open.
|
||||
// A single-endpoint rejection must NOT discard the other endpoint's data.
|
||||
if (isAuthRejected(keyResult) && isAuthRejected(creditsResult)) {
|
||||
// 401/403 on the key endpoint: token invalid — remove from cache, fail open.
|
||||
if (!keyResult || keyResult.status === 401 || keyResult.status === 403) {
|
||||
quotaCache.delete(connectionId);
|
||||
return null;
|
||||
}
|
||||
if (keyResult.status !== 200) return null;
|
||||
|
||||
const quota = mergeOpenrouterResults(keyResult, creditsResult);
|
||||
return quota ? rememberQuota(connectionId, quota) : null;
|
||||
const keyFields = parseOpenrouterKeyResponse(keyResult.data);
|
||||
if (!keyFields) return null;
|
||||
|
||||
const creditsUrl = `${OPENROUTER_CONFIG.baseUrl}${OPENROUTER_CONFIG.creditsPath}`;
|
||||
const creditsResult = await fetchJson(creditsUrl, apiKey);
|
||||
const creditsFields =
|
||||
creditsResult && creditsResult.status === 200
|
||||
? parseOpenrouterCreditsResponse(creditsResult.data)
|
||||
: { totalCredits: null, totalUsage: null };
|
||||
|
||||
const quota = buildQuotaFromParts(keyFields, creditsFields);
|
||||
quotaCache.set(connectionId, { quota, fetchedAt: Date.now() });
|
||||
return quota;
|
||||
} catch {
|
||||
// Network error, timeout, etc. — fail open (graceful "unknown").
|
||||
return null;
|
||||
|
||||
@@ -20,14 +20,6 @@ interface SystemPromptConfig {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSystemMessage(value: unknown): value is Record<string, unknown> {
|
||||
return isRecord(value) && (value.role === "system" || value.role === "developer");
|
||||
}
|
||||
|
||||
// Typed accessor for globalThis storage — avoids `as any` casts (#2470)
|
||||
const _store = globalThis as unknown as Record<string, SystemPromptConfig | undefined>;
|
||||
|
||||
@@ -82,50 +74,45 @@ export function getSystemPromptConfig() {
|
||||
* suffixPrompt is appended after existing system content.
|
||||
* This ensures: prefix → agent instructions → suffix (#2468).
|
||||
*
|
||||
* @param body - Request body
|
||||
* @returns Modified body
|
||||
* @param {object} body - Request body
|
||||
* @returns {object} Modified body
|
||||
*/
|
||||
export function injectSystemPrompt<T>(body: T): T {
|
||||
export function injectSystemPrompt(body) {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.enabled) return body;
|
||||
const prefix = cfg.prefixPrompt || "";
|
||||
const suffix = cfg.suffixPrompt || "";
|
||||
if (!prefix && !suffix) return body;
|
||||
if (!isRecord(body)) return body;
|
||||
if (!body || typeof body !== "object") return body;
|
||||
if (body._skipSystemPrompt) return body;
|
||||
|
||||
const result: Record<string, unknown> = { ...body };
|
||||
const result = { ...body };
|
||||
|
||||
// OpenAI/Claude format (messages[])
|
||||
if (result.messages && Array.isArray(result.messages)) {
|
||||
const messages: unknown[] = result.messages;
|
||||
const sysIdx = messages.findIndex(isSystemMessage);
|
||||
const nextMessages = [...messages];
|
||||
const sysIdx = result.messages.findIndex((m) => m.role === "system" || m.role === "developer");
|
||||
result.messages = [...result.messages];
|
||||
if (sysIdx >= 0) {
|
||||
const existingMessage = nextMessages[sysIdx];
|
||||
if (isRecord(existingMessage)) {
|
||||
const msg = { ...existingMessage };
|
||||
if (Array.isArray(msg.content)) {
|
||||
const content: unknown[] = [...msg.content];
|
||||
if (prefix) content.unshift({ type: "text", text: prefix });
|
||||
if (suffix) content.push({ type: "text", text: suffix });
|
||||
msg.content = content;
|
||||
} else {
|
||||
let content = String(msg.content || "");
|
||||
if (prefix) content = prefix + "\n\n" + content;
|
||||
if (suffix) content = content + "\n\n" + suffix;
|
||||
msg.content = content;
|
||||
}
|
||||
nextMessages[sysIdx] = msg;
|
||||
const msg = { ...result.messages[sysIdx] };
|
||||
if (Array.isArray(msg.content)) {
|
||||
const content = [...msg.content];
|
||||
if (prefix) content.unshift({ type: "text", text: prefix });
|
||||
if (suffix) content.push({ type: "text", text: suffix });
|
||||
msg.content = content;
|
||||
} else {
|
||||
let content = msg.content || "";
|
||||
if (prefix) content = prefix + "\n\n" + content;
|
||||
if (suffix) content = content + "\n\n" + suffix;
|
||||
msg.content = content;
|
||||
}
|
||||
result.messages[sysIdx] = msg;
|
||||
} else {
|
||||
// No existing system message — combine both into one
|
||||
const combined = [prefix, suffix].filter(Boolean).join("\n\n");
|
||||
if (combined) {
|
||||
nextMessages.unshift({ role: "system", content: combined });
|
||||
result.messages = [{ role: "system", content: combined }, ...result.messages];
|
||||
}
|
||||
}
|
||||
result.messages = nextMessages;
|
||||
}
|
||||
|
||||
// Claude format (system field)
|
||||
@@ -136,14 +123,14 @@ export function injectSystemPrompt<T>(body: T): T {
|
||||
if (suffix) sys = sys + "\n\n" + suffix;
|
||||
result.system = sys;
|
||||
} else if (Array.isArray(result.system)) {
|
||||
let arr: unknown[] = [...result.system];
|
||||
let arr = [...result.system];
|
||||
if (prefix) arr = [{ type: "text", text: prefix }, ...arr];
|
||||
if (suffix) arr = [...arr, { type: "text", text: suffix }];
|
||||
result.system = arr;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.assign({}, body, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,10 +36,6 @@ import {
|
||||
getResolvedModelCapabilities,
|
||||
supportsReasoning,
|
||||
} from "@/lib/modelCapabilities";
|
||||
import {
|
||||
jsonLengthStrippingBase64DataUris,
|
||||
rawLengthStrippingBase64DataUris,
|
||||
} from "../utils/jsonSize.ts";
|
||||
|
||||
// Effort → budget token mapping
|
||||
export const EFFORT_BUDGETS: Record<string, number> = {
|
||||
@@ -354,8 +350,7 @@ function applyAdaptiveBudget(body: unknown, cfg: Partial<ThinkingBudgetConfig>)
|
||||
const tools = Array.isArray(bodyRecord.tools) ? bodyRecord.tools : [];
|
||||
const toolCount = tools.length;
|
||||
|
||||
// Get last user message length. Strip base64 data URIs so an inline image in the prompt
|
||||
// doesn't inflate lastMsgLength and silently bump the complexity multiplier.
|
||||
// Get last user message length
|
||||
let lastMsgLength = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
@@ -363,8 +358,8 @@ function applyAdaptiveBudget(body: unknown, cfg: Partial<ThinkingBudgetConfig>)
|
||||
if (msgRecord.role === "user") {
|
||||
lastMsgLength =
|
||||
typeof msgRecord.content === "string"
|
||||
? rawLengthStrippingBase64DataUris(msgRecord.content)
|
||||
: jsonLengthStrippingBase64DataUris(msgRecord.content || "");
|
||||
? msgRecord.content.length
|
||||
: JSON.stringify(msgRecord.content || "").length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ import { getQoderUsage, parseQoderUserStatusUsage } from "./usage/qoder.ts";
|
||||
export { parseQoderUserStatusUsage } from "./usage/qoder.ts";
|
||||
import { getOpencodeUsage } from "./usage/opencode.ts";
|
||||
import { getDeepseekUsage } from "./usage/deepseek.ts";
|
||||
import { getDevinCliUsage } from "./usage/devinCli.ts";
|
||||
import { getBailianCodingPlanUsage } from "./usage/bailian.ts";
|
||||
import { getVertexUsage } from "./usage/vertex.ts";
|
||||
import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.ts";
|
||||
@@ -209,9 +208,6 @@ export async function getUsageForProvider(
|
||||
return await getAgentrouterUsage(id, connection);
|
||||
case "kilocode":
|
||||
return await getKilocodeUsage(id, connection);
|
||||
case "devin-cli":
|
||||
// Devin CLI tokens live in `accessToken` (oauth import) or `apiKey`.
|
||||
return await getDevinCliUsage(apiKey || accessToken);
|
||||
default:
|
||||
return { message: `Usage API not implemented for ${provider}` };
|
||||
}
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* usage/devinCli.ts — Devin CLI (devin-cli / devin-cli-agentic) usage fetcher.
|
||||
*
|
||||
* Devin exposes no REST usage endpoint; the official CLI reads account quota from
|
||||
* the Codeium seat-management Connect API:
|
||||
*
|
||||
* POST {api}/exa.seat_management_pb.SeatManagementService/GetUserStatus
|
||||
* Content-Type: application/proto
|
||||
* Connect-Protocol-Version: 1
|
||||
* Authorization: Basic <token>-<token> (raw, non-base64 — Codeium convention)
|
||||
*
|
||||
* Request body (protobuf):
|
||||
* GetUserStatusRequest { 1: Metadata { 1: ide_name, 2: extension_version,
|
||||
* 3: api_key, 4: locale, 5: platform } }
|
||||
*
|
||||
* Response (protobuf) — the fields surfaced here, read off the live wire format:
|
||||
* GetUserStatusResponse { 1: user_status { 13: plan_status {
|
||||
* 1: plan_info { 2: plan_name } → "Pro" | "Teams" | …
|
||||
* 14: daily_quota_remaining_percent → 0..100
|
||||
* 15: weekly_quota_remaining_percent → 0..100
|
||||
* 17: daily_quota_reset_at_unix → epoch seconds
|
||||
* 18: weekly_quota_reset_at_unix → epoch seconds
|
||||
* } } }
|
||||
*
|
||||
* Surfaces `daily` / `weekly` percent-based quotas (used/total expressed in
|
||||
* percent, matching the percent-quota style used by the Claude family leaves)
|
||||
* for Provider Limits and genericQuotaFetcher preflight. Graceful `{ message }`
|
||||
* on any failure — quota tracking must never block routing.
|
||||
*/
|
||||
|
||||
import { parseResetTime, type UsageQuota } from "./quota.ts";
|
||||
|
||||
const SEAT_MANAGEMENT_API_BASE =
|
||||
process.env.DEVIN_SEAT_API_URL?.trim() || "https://server.codeium.com";
|
||||
const GET_USER_STATUS_PATH = "/exa.seat_management_pb.SeatManagementService/GetUserStatus";
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const CONNECT_PROTOCOL_VERSION = "1";
|
||||
|
||||
// ─── Minimal protobuf wire helpers ───────────────────────────────────────────
|
||||
|
||||
function encodeVarint(value: number): number[] {
|
||||
const bytes: number[] = [];
|
||||
let v = value;
|
||||
while (v > 0x7f) {
|
||||
bytes.push((v & 0x7f) | 0x80);
|
||||
v = Math.floor(v / 128);
|
||||
}
|
||||
bytes.push(v);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function encodeStringField(field: number, text: string): number[] {
|
||||
const bytes = Array.from(new TextEncoder().encode(text));
|
||||
return [(field << 3) | 2, ...encodeVarint(bytes.length), ...bytes];
|
||||
}
|
||||
|
||||
function buildGetUserStatusRequest(token: string): Uint8Array {
|
||||
const metadata = [
|
||||
...encodeStringField(1, "chisel"), // ide_name
|
||||
...encodeStringField(2, "0.0.0-dev"), // extension_version
|
||||
...encodeStringField(3, token), // api_key
|
||||
...encodeStringField(4, "en"), // locale
|
||||
...encodeStringField(5, "linux"), // platform
|
||||
...encodeStringField(7, "0.0.0-dev"), // ide_version — required by the endpoint
|
||||
];
|
||||
return new Uint8Array([
|
||||
...encodeVarint((1 << 3) | 2),
|
||||
...encodeVarint(metadata.length),
|
||||
...metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
interface ProtoField {
|
||||
field: number;
|
||||
varint: number | null;
|
||||
bytes: Uint8Array | null;
|
||||
}
|
||||
|
||||
function readVarint(buf: Uint8Array, start: number): { value: number; next: number } | null {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
let i = start;
|
||||
for (;;) {
|
||||
if (i >= buf.length) return null;
|
||||
const byte = buf[i++];
|
||||
result += (byte & 0x7f) * Math.pow(2, shift);
|
||||
if ((byte & 0x80) === 0) return { value: result, next: i };
|
||||
shift += 7;
|
||||
if (shift > 63) return null;
|
||||
}
|
||||
}
|
||||
|
||||
function advancePastFixed(buf: Uint8Array, i: number, size: number): number | null {
|
||||
return i + size > buf.length ? null : i + size;
|
||||
}
|
||||
|
||||
/** Decode one protobuf field; `{ field: null }` skips fixed64/fixed32 payloads. */
|
||||
function decodeOneField(
|
||||
buf: Uint8Array,
|
||||
start: number
|
||||
): { field: ProtoField | null; next: number } | null {
|
||||
const tag = readVarint(buf, start);
|
||||
if (!tag) return null;
|
||||
const field = tag.value >>> 3;
|
||||
const wire = tag.value & 7;
|
||||
if (wire === 0) {
|
||||
const v = readVarint(buf, tag.next);
|
||||
if (!v) return null;
|
||||
return { field: { field, varint: v.value, bytes: null }, next: v.next };
|
||||
}
|
||||
if (wire === 2) {
|
||||
const len = readVarint(buf, tag.next);
|
||||
if (!len || len.value > buf.length - len.next) return null;
|
||||
return {
|
||||
field: { field, varint: null, bytes: buf.subarray(len.next, len.next + len.value) },
|
||||
next: len.next + len.value,
|
||||
};
|
||||
}
|
||||
if (wire === 1) {
|
||||
const next = advancePastFixed(buf, tag.next, 8);
|
||||
return next === null ? null : { field: null, next };
|
||||
}
|
||||
if (wire === 5) {
|
||||
const next = advancePastFixed(buf, tag.next, 4);
|
||||
return next === null ? null : { field: null, next };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Walk one protobuf message into (field, value) triples; null on malformed input. */
|
||||
export function decodeProtoFields(buf: Uint8Array): ProtoField[] | null {
|
||||
const out: ProtoField[] = [];
|
||||
let i = 0;
|
||||
while (i < buf.length) {
|
||||
const step = decodeOneField(buf, i);
|
||||
if (!step) return null;
|
||||
if (step.field) out.push(step.field);
|
||||
i = step.next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fieldBytes(fields: ProtoField[] | null, field: number): Uint8Array | null {
|
||||
return fields?.find((f) => f.field === field && f.bytes !== null)?.bytes ?? null;
|
||||
}
|
||||
|
||||
function fieldVarint(fields: ProtoField[] | null, field: number): number | null {
|
||||
const hit = fields?.find((f) => f.field === field && f.varint !== null);
|
||||
return hit ? (hit.varint as number) : null;
|
||||
}
|
||||
|
||||
function fieldString(fields: ProtoField[] | null, field: number): string | null {
|
||||
const hit = fields?.find((f) => f.field === field && f.bytes !== null);
|
||||
if (!hit?.bytes) return null;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(hit.bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Response parsing ────────────────────────────────────────────────────────
|
||||
|
||||
export interface DevinQuotaSnapshot {
|
||||
plan: string | null;
|
||||
dailyRemainingPercent: number | null;
|
||||
weeklyRemainingPercent: number | null;
|
||||
dailyResetAtUnix: number | null;
|
||||
weeklyResetAtUnix: number | null;
|
||||
}
|
||||
|
||||
/** Parse a GetUserStatus protobuf response into the quota snapshot. */
|
||||
export function parseDevinUserStatus(buf: Uint8Array): DevinQuotaSnapshot | null {
|
||||
const userStatus = fieldBytes(decodeProtoFields(buf), 1);
|
||||
if (!userStatus) return null;
|
||||
|
||||
const planStatus = fieldBytes(decodeProtoFields(userStatus), 13);
|
||||
if (!planStatus) return null;
|
||||
|
||||
const status = decodeProtoFields(planStatus);
|
||||
if (!status) return null;
|
||||
|
||||
const planInfoBytes = fieldBytes(status, 1);
|
||||
const planName = planInfoBytes ? fieldString(decodeProtoFields(planInfoBytes), 2) : null;
|
||||
|
||||
return {
|
||||
plan: planName,
|
||||
dailyRemainingPercent: fieldVarint(status, 14),
|
||||
weeklyRemainingPercent: fieldVarint(status, 15),
|
||||
dailyResetAtUnix: fieldVarint(status, 17),
|
||||
weeklyResetAtUnix: fieldVarint(status, 18),
|
||||
};
|
||||
}
|
||||
|
||||
function percentQuota(
|
||||
remainingPercent: number,
|
||||
resetAtUnix: number | null,
|
||||
displayName: string
|
||||
): UsageQuota {
|
||||
const clamped = Math.min(Math.max(remainingPercent, 0), 100);
|
||||
return {
|
||||
used: 100 - clamped,
|
||||
total: 100,
|
||||
remaining: clamped,
|
||||
remainingPercentage: clamped,
|
||||
resetAt: parseResetTime(resetAtUnix),
|
||||
unlimited: false,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Fetcher ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getDevinCliUsage(token: string | null | undefined) {
|
||||
if (!token?.trim()) {
|
||||
return { message: "Devin token not available. Import a Devin token to view usage." };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${SEAT_MANAGEMENT_API_BASE}${GET_USER_STATUS_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/proto",
|
||||
"Connect-Protocol-Version": CONNECT_PROTOCOL_VERSION,
|
||||
Authorization: `Basic ${token}-${token}`,
|
||||
},
|
||||
body: new Uint8Array(buildGetUserStatusRequest(token.trim())),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
return { message: `Devin usage error: ${(error as Error).message}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { message: `Devin GetUserStatus failed (${response.status})` };
|
||||
}
|
||||
|
||||
const snapshot = parseDevinUserStatus(new Uint8Array(await response.arrayBuffer()));
|
||||
if (!snapshot) {
|
||||
return { message: "Devin quota response could not be parsed." };
|
||||
}
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
if (snapshot.dailyRemainingPercent !== null) {
|
||||
quotas.daily = percentQuota(
|
||||
snapshot.dailyRemainingPercent,
|
||||
snapshot.dailyResetAtUnix,
|
||||
"Daily Agentic Quota"
|
||||
);
|
||||
}
|
||||
if (snapshot.weeklyRemainingPercent !== null) {
|
||||
quotas.weekly = percentQuota(
|
||||
snapshot.weeklyRemainingPercent,
|
||||
snapshot.weeklyResetAtUnix,
|
||||
"Weekly Agentic Quota"
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length === 0) {
|
||||
return { message: "Devin quota fields not present in GetUserStatus response." };
|
||||
}
|
||||
|
||||
return { plan: snapshot.plan ?? "Devin", quotas };
|
||||
}
|
||||
@@ -82,8 +82,6 @@ export const USAGE_FETCHER_PROVIDERS = [
|
||||
// AgentRouter (New-API) console balance (GET /api/user/self)
|
||||
"agentrouter",
|
||||
"kilocode",
|
||||
// Devin CLI agentic quota (Codeium seat-management GetUserStatus, protobuf)
|
||||
"devin-cli",
|
||||
] as const;
|
||||
|
||||
export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number];
|
||||
|
||||
@@ -70,9 +70,9 @@ export async function getOpenrouterUsage(
|
||||
|
||||
if (!quota) {
|
||||
return {
|
||||
plan: "OpenRouter (credits endpoint unreachable)",
|
||||
plan: "OpenRouter (usage endpoint unreachable)",
|
||||
quotas,
|
||||
message: "OpenRouter connected. /key and /credits both unreachable — no balance data.",
|
||||
message: "OpenRouter connected. Balance/credit-cap data temporarily unavailable.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* usage/supportedProviders.ts — registration list of providers whose usage/quota
|
||||
* API is accepted by the dashboard and server routes.
|
||||
*
|
||||
* Extracted from `src/shared/constants/providers.ts` so that light consumers —
|
||||
* the provider-plugin manifest (`config/providerPluginManifest.ts`) above all —
|
||||
* can read the list without pulling the ~12-module provider registry, and
|
||||
* without an open-sse module reaching across the workspace boundary into
|
||||
* `src/` (the open-sse typecheck gate forbids open-sse → src imports). Same
|
||||
* pattern as `fetcherProviders.ts` (#11903): pure data — no imports, no module
|
||||
* state — so it cannot introduce a cycle. `src/shared/constants/providers.ts`
|
||||
* re-exports the value, so every existing `@/shared/constants/providers`
|
||||
* import path keeps working unchanged.
|
||||
*
|
||||
* Typed `readonly string[]` (not `as const`): the dashboard/server gates call
|
||||
* `USAGE_SUPPORTED_PROVIDERS.includes(providerId)` with a plain `string`, which
|
||||
* a literal-tuple type would reject (TS2345).
|
||||
*/
|
||||
|
||||
// Providers that support usage/quota API
|
||||
export const USAGE_SUPPORTED_PROVIDERS: readonly string[] = [
|
||||
"antigravity",
|
||||
"agy",
|
||||
"kiro",
|
||||
"amazon-q",
|
||||
"github",
|
||||
"codex",
|
||||
"claude",
|
||||
"cursor",
|
||||
"qoder",
|
||||
"kimi-coding",
|
||||
"kimi-coding-apikey",
|
||||
"glm",
|
||||
"glm-cn",
|
||||
"zai",
|
||||
"glmt",
|
||||
"opencode-go",
|
||||
"ollama-cloud",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
"nanogpt",
|
||||
"deepseek",
|
||||
"xiaomi-mimo",
|
||||
"xiaomi-mimo-token-plan",
|
||||
"vertex",
|
||||
"vertex-partner",
|
||||
"codebuddy-cn",
|
||||
// PromptQL playground credits (getCreditSummary → USD micros)
|
||||
"promptql",
|
||||
"pql",
|
||||
// Adobe Firefly web (cookie/JWT as apikey) — GET firefly.adobe.io/v1/credits/balance
|
||||
"adobe-firefly",
|
||||
"firefly",
|
||||
"hyperagent",
|
||||
"ha",
|
||||
// xAI OAuth (Grok) weekly quota (id + public alias, same pattern as ha/agy)
|
||||
"xai-oauth",
|
||||
"xao",
|
||||
// Grok Build subscription, billing credits, and auto top-up status
|
||||
"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",
|
||||
"cnl",
|
||||
// Alibaba Coding Plan triple-window quota (#9603 UI gap — fetcher existed, list entry missing)
|
||||
"bailian-coding-plan",
|
||||
// Qwen Cloud / Model Studio personal Token Plan (cookie-authenticated console gateway)
|
||||
"qwen-cloud-token-plan",
|
||||
// AgentRouter (New-API) console balance quota (consoleApiKey + newApiUserId)
|
||||
"agentrouter",
|
||||
// Kilo Code personal USD balance (GET /api/profile/balance, existing OAuth token)
|
||||
"kilocode",
|
||||
// OpenRouter key limits + account credits (GET /api/v1/key + /api/v1/credits)
|
||||
"openrouter",
|
||||
// Devin CLI agentic quota (Codeium seat-management GetUserStatus, protobuf)
|
||||
"devin-cli",
|
||||
];
|
||||
@@ -1,221 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
/**
|
||||
* Streaming JSON hash — computes `sha256hex(JSON.stringify(value))` WITHOUT
|
||||
* materializing the JSON string (#7847 OOM class). Several hot-path call sites
|
||||
* stringify a multi-megabyte request body just to hash it (compression memo
|
||||
* keys, cache keys). On a ~5 MiB agent body (with base64 screenshots) that
|
||||
* allocates a full ~5 MiB string, read once for a hash, then discarded.
|
||||
*
|
||||
* `jsonSha256()` walks the value and feeds the same bytes `JSON.stringify`
|
||||
* would emit directly into a `crypto.createHash("sha256")` stream, so peak
|
||||
* allocation stays bounded to a small rolling buffer.
|
||||
*
|
||||
* Semantics mirror `JSON.stringify` exactly:
|
||||
* - key order = `Object.keys()` order (insertion order)
|
||||
* - `undefined`/function/symbol object values drop the whole entry
|
||||
* - `undefined`/function/symbol array items render as `null`
|
||||
* - non-finite numbers render as `null`
|
||||
* - `BigInt` throws (matches JSON.stringify)
|
||||
* - Date / toJSON / non-plain containers fall back to `JSON.stringify` for
|
||||
* that subtree only (kept rare so big arrays stay on the fast path).
|
||||
*
|
||||
* Deterministic across calls: identical logical bodies always produce the
|
||||
* identical digest, so callers can replace `sha256hex(JSON.stringify(body))`
|
||||
* with `jsonSha256(body)` without changing cache/memo semantics.
|
||||
*/
|
||||
export function jsonSha256(value: unknown): string {
|
||||
const hash = crypto.createHash("sha256");
|
||||
writeValue(hash, value, new Set<object>());
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function isOmitted(value: unknown): boolean {
|
||||
return value === undefined || typeof value === "function" || typeof value === "symbol";
|
||||
}
|
||||
|
||||
function isPlainContainer(value: object): boolean {
|
||||
if (Array.isArray(value)) return true;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
function writeValue(
|
||||
hash: ReturnType<typeof crypto.createHash>,
|
||||
value: unknown,
|
||||
seen: Set<object>
|
||||
): void {
|
||||
if (writePrimitive(hash, value)) return;
|
||||
|
||||
const obj = value as object;
|
||||
// Date, Map, boxed primitives, class instances with toJSON — fall back to
|
||||
// JSON.stringify for THIS SUBTREE only, keeping multi-MB arrays on the
|
||||
// streaming path. JSON.stringify(Date) emits a quoted ISO string, so push
|
||||
// exactly the string form JSON.stringify would have produced.
|
||||
if (writeToJSONFallback(hash, obj)) return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
if (seen.has(obj)) {
|
||||
throw new TypeError("Converting circular structure to JSON");
|
||||
}
|
||||
seen.add(obj);
|
||||
try {
|
||||
writeArray(hash, obj, seen);
|
||||
} finally {
|
||||
seen.delete(obj);
|
||||
}
|
||||
} else {
|
||||
writePlainObject(hash, obj, seen);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* toJSON / non-plain-container fallback: serializes the subtree with
|
||||
* JSON.stringify, exactly as JSON.stringify would have (undefined → the bare
|
||||
* token, e.g. an object-valued key being dropped later is not possible here
|
||||
* — writeValue callers already filter omissions). Returns true when handled.
|
||||
*/
|
||||
function writeToJSONFallback(
|
||||
hash: ReturnType<typeof crypto.createHash>,
|
||||
obj: object
|
||||
): boolean {
|
||||
const hasToJSON = typeof (obj as { toJSON?: unknown }).toJSON === "function";
|
||||
if (hasToJSON || !isPlainContainer(obj)) {
|
||||
const encoded = JSON.stringify(obj);
|
||||
hash.update(encoded === undefined ? "undefined" : encoded);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Writes JSON primitives and omissions. Returns true when `value` is fully handled. */
|
||||
function writePrimitive(hash: ReturnType<typeof crypto.createHash>, value: unknown): boolean {
|
||||
if (value === null) {
|
||||
hash.update("null");
|
||||
return true;
|
||||
}
|
||||
const type = typeof value;
|
||||
if (type === "string") {
|
||||
writeEncodedString(hash, value as string);
|
||||
return true;
|
||||
}
|
||||
if (type === "boolean") {
|
||||
hash.update(value ? "true" : "false");
|
||||
return true;
|
||||
}
|
||||
if (type === "number") {
|
||||
// Non-finite numbers serialize as null (matches JSON.stringify).
|
||||
hash.update(Number.isFinite(value as number) ? String(value) : "null");
|
||||
return true;
|
||||
}
|
||||
if (type === "bigint") {
|
||||
// Matches JSON.stringify, which throws rather than guessing an encoding.
|
||||
throw new TypeError("Do not know how to serialize a BigInt");
|
||||
}
|
||||
if (isOmitted(value) || type !== "object") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function writeArray(
|
||||
hash: ReturnType<typeof crypto.createHash>,
|
||||
obj: unknown[],
|
||||
seen: Set<object>
|
||||
): void {
|
||||
hash.update("[");
|
||||
for (let i = 0; i < obj.length; i++) {
|
||||
if (i > 0) hash.update(",");
|
||||
const item = obj[i];
|
||||
if (isOmitted(item)) {
|
||||
hash.update("null"); // array items render as null
|
||||
} else {
|
||||
writeValue(hash, item, seen);
|
||||
}
|
||||
}
|
||||
hash.update("]");
|
||||
}
|
||||
|
||||
function writePlainObject(
|
||||
hash: ReturnType<typeof crypto.createHash>,
|
||||
obj: object,
|
||||
seen: Set<object>
|
||||
): void {
|
||||
if (seen.has(obj)) {
|
||||
throw new TypeError("Converting circular structure to JSON");
|
||||
}
|
||||
seen.add(obj);
|
||||
try {
|
||||
hash.update("{");
|
||||
let first = true;
|
||||
for (const key of Object.keys(obj)) {
|
||||
const item = (obj as Record<string, unknown>)[key];
|
||||
if (isOmitted(item)) continue; // entry disappears entirely
|
||||
if (!first) hash.update(",");
|
||||
first = false;
|
||||
writeEncodedString(hash, key);
|
||||
hash.update(":");
|
||||
writeValue(hash, item, seen);
|
||||
}
|
||||
hash.update("}");
|
||||
} finally {
|
||||
seen.delete(obj);
|
||||
}
|
||||
}
|
||||
|
||||
// Static escapes for fast paths: quote, backslash, and the short control
|
||||
// escapes JSON.stringify emits. Lookup avoids the escape ladder entirely.
|
||||
const SINGLE_ESCAPES = new Map<number, string>([
|
||||
[0x22, '\\"'],
|
||||
[0x5c, "\\\\"],
|
||||
[0x08, "\\b"],
|
||||
[0x09, "\\t"],
|
||||
[0x0a, "\\n"],
|
||||
[0x0c, "\\f"],
|
||||
[0x0d, "\\r"],
|
||||
]);
|
||||
|
||||
/** Writes one (possibly surrogate-paired) code unit's escaped form. */
|
||||
function appendEscapedChar(out: string[], value: string, i: number, code: number): number {
|
||||
const single = SINGLE_ESCAPES.get(code);
|
||||
if (single !== undefined) {
|
||||
out.push(single);
|
||||
return i;
|
||||
}
|
||||
if (code < 0x20) {
|
||||
out.push("\\u" + code.toString(16).padStart(4, "0"));
|
||||
return i;
|
||||
}
|
||||
if (code >= 0xd800 && code <= 0xdfff) {
|
||||
const next = i + 1 < value.length ? value.charCodeAt(i + 1) : NaN;
|
||||
const isHigh = code >= 0xd800 && code <= 0xdbff;
|
||||
if (isHigh && next >= 0xdc00 && next <= 0xdfff) {
|
||||
out.push(value[i] + value[i + 1]);
|
||||
return i + 1;
|
||||
}
|
||||
out.push("\\u" + code.toString(16).padStart(4, "0"));
|
||||
return i;
|
||||
}
|
||||
out.push(value[i]);
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Writes a JSON-escaped, double-quoted string, flushing in ~8 KiB chunks. */
|
||||
function writeEncodedString(hash: ReturnType<typeof crypto.createHash>, value: string): void {
|
||||
const out: string[] = [];
|
||||
let buffered = 0;
|
||||
let i = 0;
|
||||
out.push('"');
|
||||
while (i < value.length) {
|
||||
const next = appendEscapedChar(out, value, i, value.charCodeAt(i));
|
||||
buffered += next - i + 1;
|
||||
i = next + 1;
|
||||
if (buffered > 8192) {
|
||||
hash.update(out.join(""));
|
||||
out.length = 0;
|
||||
buffered = 0;
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
hash.update(out.join(""));
|
||||
}
|
||||
@@ -18,14 +18,11 @@
|
||||
* message history back onto the allocating path.
|
||||
*/
|
||||
|
||||
const BASE64_DATA_URI_RE = /data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/gi;
|
||||
|
||||
/** Length of a JSON-encoded string, including the surrounding quotes. */
|
||||
function encodedStringLength(value: string, stripBase64 = false): number {
|
||||
const target = stripBase64 ? value.replace(BASE64_DATA_URI_RE, "") : value;
|
||||
function encodedStringLength(value: string): number {
|
||||
let len = 2; // the quotes
|
||||
for (let i = 0; i < target.length; i++) {
|
||||
const code = target.charCodeAt(i);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code === 0x22 || code === 0x5c) {
|
||||
len += 2; // \" and \\
|
||||
} else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) {
|
||||
@@ -36,7 +33,7 @@ function encodedStringLength(value: string, stripBase64 = false): number {
|
||||
// Surrogates: a well-formed pair serializes as its two code units (2 chars); a LONE
|
||||
// surrogate is escaped as \uXXXX since ES2019 well-formed JSON.stringify.
|
||||
const isHigh = code <= 0xdbff;
|
||||
const next = isHigh ? target.charCodeAt(i + 1) : NaN;
|
||||
const next = isHigh ? value.charCodeAt(i + 1) : NaN;
|
||||
const paired = isHigh && next >= 0xdc00 && next <= 0xdfff;
|
||||
if (paired) {
|
||||
len += 2;
|
||||
@@ -69,34 +66,14 @@ function isPlainContainer(value: object): boolean {
|
||||
* Throws on circular structures and BigInt, exactly as JSON.stringify does.
|
||||
*/
|
||||
export function jsonLength(value: unknown): number {
|
||||
return lengthOf(value, new Set<object>(), false);
|
||||
return lengthOf(value, new Set<object>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `jsonLength`, but strips `data:image/*;base64,...` data URIs from strings
|
||||
* before counting, matching `countTextTokens(JSON.stringify(body))` semantics for
|
||||
* token heuristics without materializing the multi-megabyte string (#7847).
|
||||
*/
|
||||
export function jsonLengthStrippingBase64DataUris(value: unknown): number {
|
||||
return lengthOf(value, new Set<object>(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw length of a string with `data:image/*;base64,...` data URIs removed. Unlike
|
||||
* `jsonLengthStrippingBase64DataUris`, this returns the plain code-unit count with NO
|
||||
* JSON-encoding overhead (no surrounding quotes/escaping). Use it where a threshold was
|
||||
* previously fed by `string.length` (e.g. thinking-budget complexity) but the value may
|
||||
* embed a base64 image.
|
||||
*/
|
||||
export function rawLengthStrippingBase64DataUris(value: string): number {
|
||||
return value.replace(BASE64_DATA_URI_RE, "").length;
|
||||
}
|
||||
|
||||
function lengthOf(value: unknown, seen: Set<object>, stripBase64: boolean): number {
|
||||
function lengthOf(value: unknown, seen: Set<object>): number {
|
||||
if (value === null) return 4; // "null"
|
||||
const type = typeof value;
|
||||
|
||||
if (type === "string") return encodedStringLength(value as string, stripBase64);
|
||||
if (type === "string") return encodedStringLength(value as string);
|
||||
if (type === "boolean") return value ? 4 : 5;
|
||||
if (type === "number") {
|
||||
// Non-finite numbers serialize as null.
|
||||
@@ -115,8 +92,7 @@ function lengthOf(value: unknown, seen: Set<object>, stripBase64: boolean): numb
|
||||
// Map, boxed primitives. Scoped to this subtree so the big arrays stay on the fast path.
|
||||
if (!isPlainContainer(obj) || typeof (obj as { toJSON?: unknown }).toJSON === "function") {
|
||||
const encoded = JSON.stringify(obj);
|
||||
if (encoded === undefined) return 0;
|
||||
return stripBase64 ? encoded.replace(BASE64_DATA_URI_RE, "").length : encoded.length;
|
||||
return encoded === undefined ? 0 : encoded.length;
|
||||
}
|
||||
|
||||
if (seen.has(obj)) {
|
||||
@@ -130,7 +106,7 @@ function lengthOf(value: unknown, seen: Set<object>, stripBase64: boolean): numb
|
||||
if (i > 0) len += 1; // comma
|
||||
const item = obj[i];
|
||||
// Omitted values render as null inside arrays rather than disappearing.
|
||||
len += isOmitted(item) ? 4 : lengthOf(item, seen, stripBase64);
|
||||
len += isOmitted(item) ? 4 : lengthOf(item, seen);
|
||||
}
|
||||
return len;
|
||||
}
|
||||
@@ -142,7 +118,7 @@ function lengthOf(value: unknown, seen: Set<object>, stripBase64: boolean): numb
|
||||
if (isOmitted(item)) continue; // the whole entry disappears
|
||||
if (!first) len += 1; // comma
|
||||
first = false;
|
||||
len += encodedStringLength(key, false) + 1 + lengthOf(item, seen, stripBase64); // "key":value
|
||||
len += encodedStringLength(key) + 1 + lengthOf(item, seen); // "key":value
|
||||
}
|
||||
return len;
|
||||
} finally {
|
||||
|
||||
@@ -771,7 +771,6 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const passthroughResponsesOutputItems: unknown[] = [];
|
||||
const passthroughResponsesPendingFunctionCalls = new Map<string, JsonRecord>();
|
||||
let passthroughResponsesId: string | null = null;
|
||||
let passthroughLastChatId: string | null = null;
|
||||
let passthroughResponsesCurrentFunctionCallKey: string | null = null;
|
||||
const passthroughResponsesReasoningSummarySeen = new Set<string>();
|
||||
// #6199 — commentary-phase items announced via `response.output_item.added` are
|
||||
@@ -1956,16 +1955,6 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
|
||||
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
|
||||
|
||||
// Remember the upstream's chat-completion id so synthetic chunks
|
||||
// emitted at flush (e.g. the estimated usage-only chunk) carry the
|
||||
// stream's real string id instead of null on the chat path
|
||||
// (passthroughResponsesId is only ever set on the Responses path).
|
||||
if (typeof parsed.id === "string" && parsed.id) {
|
||||
passthroughLastChatId = parsed.id;
|
||||
} else if (typeof parsed.id === "number") {
|
||||
passthroughLastChatId = String(parsed.id);
|
||||
}
|
||||
|
||||
if (isFinishChunk) {
|
||||
passthroughSawFinishReason = true;
|
||||
}
|
||||
@@ -1984,21 +1973,28 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
parsed.choices[0].finish_reason !== "tool_calls"
|
||||
) {
|
||||
parsed.choices[0].finish_reason = "tool_calls";
|
||||
// If we modify it, we must output the modified object. This used to
|
||||
// piggyback on the estimated-usage rewrite below; with the estimate
|
||||
// moved to flush() (#12151 follow-up) the rewrite must happen here.
|
||||
// injectedUsage doubles as the "output already rewritten" latch —
|
||||
// without it the raw line overwrites this rewrite further down.
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
injectedUsage = true;
|
||||
// If we modify it, we must output the modified object
|
||||
if (!injectedUsage && hasValidUsage(parsed.usage)) {
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
injectedUsage = true;
|
||||
}
|
||||
}
|
||||
// #12151 follow-up: do NOT inject estimated usage into the finish chunk.
|
||||
// A genuine OpenAI upstream sends its usage in a trailing empty-choices
|
||||
// chunk AFTER the finish; estimating here marked passthroughForwardedUsage
|
||||
// and made the real trailing block get dropped in favor of the estimate
|
||||
// (billing regression pinned by tests/unit/stream-utils.test.ts). The
|
||||
// estimate is now emitted in flush(), only when the upstream stayed silent.
|
||||
if (isFinishChunk && hasValidUsage(usage) && !passthroughForwardedUsage) {
|
||||
if (
|
||||
isFinishChunk &&
|
||||
!passthroughForwardedUsage &&
|
||||
!hasValidUsage(parsed.usage) &&
|
||||
!hasValidUsage(usage) &&
|
||||
totalContentLength > 0
|
||||
) {
|
||||
const estimated = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI);
|
||||
if (hasValidUsage(estimated)) {
|
||||
parsed.usage = filterUsageForFormat(estimated, sourceFormat || FORMATS.OPENAI);
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
usage = estimated;
|
||||
passthroughForwardedUsage = true;
|
||||
injectedUsage = true;
|
||||
}
|
||||
} else if (isFinishChunk && hasValidUsage(usage) && !passthroughForwardedUsage) {
|
||||
const buffered = addBufferToUsage(usage);
|
||||
parsed.usage = filterUsageForFormat(buffered, sourceFormat || FORMATS.OPENAI);
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
@@ -2514,30 +2510,6 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
forward(controller, encoder.encode(finishOutput));
|
||||
clientPayloadCollector.push(syntheticFinishChunk);
|
||||
}
|
||||
// #12151: upstream never reported usage — emit the estimate as a
|
||||
// canonical OpenAI trailing usage-only chunk (empty choices) before
|
||||
// [DONE], so metered clients still see token counts. When the
|
||||
// upstream DID send usage (trailing or in-band), it was forwarded
|
||||
// already and passthroughForwardedUsage guards this off.
|
||||
if (
|
||||
shouldEmitDoneTerminator &&
|
||||
!passthroughForwardedUsage &&
|
||||
hasValidUsage(usage)
|
||||
) {
|
||||
const usageOnlyChunk = {
|
||||
id: passthroughLastChatId ?? passthroughResponsesId ?? `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [],
|
||||
usage: filterUsageForFormat(usage, sourceFormat || FORMATS.OPENAI),
|
||||
};
|
||||
const usageOutput = `data: ${JSON.stringify(usageOnlyChunk)}\n\n`;
|
||||
reqLogger?.appendConvertedChunk?.(usageOutput);
|
||||
forward(controller, encoder.encode(usageOutput));
|
||||
clientPayloadCollector.push(usageOnlyChunk);
|
||||
passthroughForwardedUsage = true;
|
||||
}
|
||||
await emitFinalSseMetadata(controller, usage);
|
||||
doneSent = true;
|
||||
if (shouldEmitDoneTerminator) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cloneLogPayload } from "@/lib/logPayloads";
|
||||
import { toNumber } from "@/shared/utils/numeric";
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
import { jsonLength } from "./jsonSize.ts";
|
||||
|
||||
type StructuredSSEEvent = {
|
||||
index: number;
|
||||
@@ -915,7 +914,7 @@ export function createStructuredSSECollector(options: CollectorOptions = {}) {
|
||||
event.event = eventName;
|
||||
}
|
||||
|
||||
const serializedSize = jsonLength(event);
|
||||
const serializedSize = JSON.stringify(event).length;
|
||||
if (events.length >= maxEvents || usedBytes + serializedSize > maxBytes) {
|
||||
droppedEvents += 1;
|
||||
return;
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -122,7 +122,6 @@
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"bun": "1.4.0",
|
||||
"c8": "^12.0.0",
|
||||
"concurrently": "^10.0.5",
|
||||
@@ -16174,16 +16173,6 @@
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-react-compiler": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-walk": {
|
||||
"version": "3.0.0-canary-5",
|
||||
"resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz",
|
||||
|
||||
@@ -126,7 +126,6 @@
|
||||
"test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:scoped": "bash scripts/quality/test-scoped.sh",
|
||||
"test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged",
|
||||
"test:scoped:full": "bash scripts/quality/test-scoped.sh --full",
|
||||
"test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"",
|
||||
"test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=1/2 \"tests/unit/serial/**/*.test.ts\"",
|
||||
"test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,translator,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"",
|
||||
@@ -386,7 +385,6 @@
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"bun": "1.4.0",
|
||||
"c8": "^12.0.0",
|
||||
"concurrently": "^10.0.5",
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* scripts/ad-hoc/backfill-servicekinds.mjs
|
||||
*
|
||||
* PR A (gate hardening, #10513): make `serviceKinds` REQUIRED on every provider
|
||||
* in the catalog, backfilling the ~320 entries that never declared it.
|
||||
*
|
||||
* Design (pacocartones #10267): serviceKinds distinguishes a canonical provider
|
||||
* that legitimately has no REGISTRY entry (search/audio/media/local/cloud-agent)
|
||||
* from a half-removed provider whose catalog entry outlived its registry entry.
|
||||
* Making the field mandatory turns "canonical provider with no REGISTRY entry"
|
||||
* into a checkable invariant for `provider:remove --dry-run`.
|
||||
*
|
||||
* Rule:
|
||||
* - LLM chat providers -> ["llm"]
|
||||
* - Search providers -> ["webSearch"] (+["webFetch"] where known)
|
||||
* - Pure-media providers -> [] (kinds derived from media registries)
|
||||
* - Cloud agents / system / proxy-> [] (no direct chat registry path)
|
||||
*
|
||||
* Media kinds are NOT declared here — open-sse/config/mediaServiceKinds.ts
|
||||
* derives them from the audio/video/music/image/embedding/ocr registries, so
|
||||
* declaring them would duplicate (and drift from) that source of truth.
|
||||
*
|
||||
* USAGE: node --import tsx/esm scripts/ad-hoc/backfill-servicekinds.mjs
|
||||
* Idempotent: only inserts where serviceKinds is absent.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
// ── Section membership from the REAL catalog modules ────────────────────────
|
||||
import { SEARCH_PROVIDERS } from "../../src/shared/constants/providers/search.ts";
|
||||
import { AUDIO_ONLY_PROVIDERS } from "../../src/shared/constants/providers/audio.ts";
|
||||
import { CLOUD_AGENT_PROVIDERS } from "../../src/shared/constants/providers/cloud-agent.ts";
|
||||
import { SYSTEM_PROVIDERS } from "../../src/shared/constants/providers/system.ts";
|
||||
import { UPSTREAM_PROXY_PROVIDERS } from "../../src/shared/constants/providers/upstream-proxy.ts";
|
||||
import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers/oauth.ts";
|
||||
import { WEB_COOKIE_PROVIDERS } from "../../src/shared/constants/providers/web-cookie.ts";
|
||||
import { LOCAL_PROVIDERS } from "../../src/shared/constants/providers/local.ts";
|
||||
import { APIKEY_PROVIDERS_GATEWAYS } from "../../src/shared/constants/providers/apikey/gateways.ts";
|
||||
import { APIKEY_PROVIDERS_FRONTIER } from "../../src/shared/constants/providers/apikey/frontier-labs.ts";
|
||||
import { APIKEY_PROVIDERS_INFERENCE } from "../../src/shared/constants/providers/apikey/inference-hosts.ts";
|
||||
import { APIKEY_PROVIDERS_ENTERPRISE } from "../../src/shared/constants/providers/apikey/enterprise-cloud.ts";
|
||||
import { APIKEY_PROVIDERS_REGIONAL } from "../../src/shared/constants/providers/apikey/regional.ts";
|
||||
import { APIKEY_PROVIDERS_SPECIALTY } from "../../src/shared/constants/providers/apikey/specialty-media.ts";
|
||||
|
||||
const SEARCH_IDS = new Set(Object.keys(SEARCH_PROVIDERS));
|
||||
const AUDIO_IDS = new Set(Object.keys(AUDIO_ONLY_PROVIDERS));
|
||||
const CLOUD_AGENT_IDS = new Set(Object.keys(CLOUD_AGENT_PROVIDERS));
|
||||
const SYSTEM_IDS = new Set(Object.keys(SYSTEM_PROVIDERS));
|
||||
const UPSTREAM_PROXY_IDS = new Set(Object.keys(UPSTREAM_PROXY_PROVIDERS));
|
||||
|
||||
// Search providers that ALSO fetch pages (declared webFetch today).
|
||||
const SEARCH_WEBFETCH = new Set(["exa-search", "tavily-search", "firecrawl"]);
|
||||
|
||||
// Pure-media / no-direct-chat providers -> [] (kinds come from registries).
|
||||
// web-cookie image/video generators + local image runtimes + specialty-media
|
||||
// image/embedding/music/video set members that have no chat facade.
|
||||
const NO_LLM = new Set([
|
||||
// web-cookie image/video generators
|
||||
"microsoft-designer-web",
|
||||
"adobe-firefly",
|
||||
// local image runtimes
|
||||
"sdwebui",
|
||||
"comfyui",
|
||||
// specialty-media pure media (image/embedding/music/video, no chat facade)
|
||||
"runwayml",
|
||||
"ideogram",
|
||||
"freepik",
|
||||
// freepik foi renomeado para magnific na migration 160 — ambos os ids
|
||||
// permanecem aqui para que uma re-execução não volte a marcá-lo como llm.
|
||||
"magnific",
|
||||
"suno",
|
||||
"udio",
|
||||
"voyage-ai",
|
||||
"jina-ai",
|
||||
"fal-ai",
|
||||
"stability-ai",
|
||||
"black-forest-labs",
|
||||
"recraft",
|
||||
"topaz",
|
||||
"segmind",
|
||||
"nomic",
|
||||
"mixedbread",
|
||||
"leonardo",
|
||||
"haiper",
|
||||
"kie",
|
||||
"deepai",
|
||||
]);
|
||||
|
||||
/** Compute declared serviceKinds for a provider id (media kinds NOT included). */
|
||||
export function computeDeclaredServiceKinds(providerId) {
|
||||
if (SEARCH_IDS.has(providerId)) {
|
||||
return SEARCH_WEBFETCH.has(providerId) ? ["webSearch", "webFetch"] : ["webSearch"];
|
||||
}
|
||||
if (NO_LLM.has(providerId)) return [];
|
||||
if (AUDIO_IDS.has(providerId)) return [];
|
||||
if (CLOUD_AGENT_IDS.has(providerId)) return [];
|
||||
if (SYSTEM_IDS.has(providerId)) return [];
|
||||
if (UPSTREAM_PROXY_IDS.has(providerId)) return [];
|
||||
return ["llm"];
|
||||
}
|
||||
|
||||
/** Insert `serviceKinds` after the `id:` line of a provider entry, if absent. */
|
||||
function insertIntoFile(filePath, providerId, kinds) {
|
||||
const abs = path.join(ROOT, filePath);
|
||||
const src = readFileSync(abs, "utf8");
|
||||
|
||||
const escaped = providerId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// Multi-line entry ` \"provider-id\": {\n ... },` — full block capture. The
|
||||
// whole-block capture makes the idempotency check see serviceKinds wherever it
|
||||
// sits (before OR after the id line) without a file-global `includes` that
|
||||
// would short-circuit every later entry after the first insert.
|
||||
const entryRe = new RegExp(`^( {2})"?${escaped}"?(: \\{)([\\s\\S]*?)^( {2})},$`, "m");
|
||||
const match = entryRe.exec(src);
|
||||
if (!match) {
|
||||
console.error(` ✗ could not locate entry for ${providerId} in ${filePath}`);
|
||||
return false;
|
||||
}
|
||||
// Per-entry idempotency: refuse when THIS entry already declares serviceKinds.
|
||||
const block = match[0];
|
||||
if (/serviceKinds\s*:/.test(block)) return null;
|
||||
// Insert after the `id: \"provider-id\",` line (4-space indent inside the block).
|
||||
const idLineRe = new RegExp(`( {4}id: \"${escaped}\",)`);
|
||||
const idMatch = idLineRe.exec(block);
|
||||
if (!idMatch) {
|
||||
console.error(` ✗ entry for ${providerId} in ${filePath} has no id line`);
|
||||
return false;
|
||||
}
|
||||
const idLineEnd = match.index + idMatch.index + idMatch[1].length;
|
||||
const insert = `\n serviceKinds: ${JSON.stringify(kinds)},`;
|
||||
writeFileSync(abs, src.slice(0, idLineEnd) + insert + src.slice(idLineEnd));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Files to process, derived from the section modules themselves ───────────
|
||||
const FILES = [
|
||||
["src/shared/constants/providers/oauth.ts", OAUTH_PROVIDERS],
|
||||
["src/shared/constants/providers/web-cookie.ts", WEB_COOKIE_PROVIDERS],
|
||||
["src/shared/constants/providers/local.ts", LOCAL_PROVIDERS],
|
||||
["src/shared/constants/providers/search.ts", SEARCH_PROVIDERS],
|
||||
["src/shared/constants/providers/audio.ts", AUDIO_ONLY_PROVIDERS],
|
||||
["src/shared/constants/providers/upstream-proxy.ts", UPSTREAM_PROXY_PROVIDERS],
|
||||
["src/shared/constants/providers/cloud-agent.ts", CLOUD_AGENT_PROVIDERS],
|
||||
["src/shared/constants/providers/system.ts", SYSTEM_PROVIDERS],
|
||||
["src/shared/constants/providers/apikey/gateways.ts", APIKEY_PROVIDERS_GATEWAYS],
|
||||
["src/shared/constants/providers/apikey/frontier-labs.ts", APIKEY_PROVIDERS_FRONTIER],
|
||||
["src/shared/constants/providers/apikey/inference-hosts.ts", APIKEY_PROVIDERS_INFERENCE],
|
||||
["src/shared/constants/providers/apikey/enterprise-cloud.ts", APIKEY_PROVIDERS_ENTERPRISE],
|
||||
["src/shared/constants/providers/apikey/regional.ts", APIKEY_PROVIDERS_REGIONAL],
|
||||
["src/shared/constants/providers/apikey/specialty-media.ts", APIKEY_PROVIDERS_SPECIALTY],
|
||||
];
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
for (const [file, sectionMap] of FILES) {
|
||||
for (const id of Object.keys(sectionMap)) {
|
||||
if (sectionMap[id]?.serviceKinds !== undefined) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const kinds = computeDeclaredServiceKinds(id);
|
||||
const result = insertIntoFile(file, id, kinds);
|
||||
if (result === true) inserted += 1;
|
||||
else if (result === false) failed += 1;
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[backfill] inserted=${inserted} skipped(already-declared)=${skipped} failed=${failed}`
|
||||
);
|
||||
@@ -7,13 +7,6 @@
|
||||
// Catraca: exceções pré-existentes ficam em KNOWN_REGISTRY_ONLY; só NOVOS órfãos falham.
|
||||
// Stale-enforcement (6A.3): entrada em KNOWN_REGISTRY_ONLY que não suprime nenhum órfão
|
||||
// real → gate falha com instrução de remoção (evita furo de regressão silencioso).
|
||||
//
|
||||
// Reverse walk (#10513): providers.ts → REGISTRY. Um provider canônico cujo
|
||||
// serviceKinds inclui "llm" DEVE ter entrada no REGISTRY — a não ser que esteja em
|
||||
// KNOWN_CATALOG_ONLY (providers que roteiam via baseUrl de conexão / executor
|
||||
// especializado sem entrada de registry). Isso torna provider:remove --dry-run
|
||||
// verificável: um provider removido do REGISTRY mas esquecido em providers.ts
|
||||
// aparece como órfão reverso e o gate falha.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { AI_PROVIDERS, getProviderById } from "@/shared/constants/providers.ts";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
@@ -23,46 +16,6 @@ import { assertNoStale } from "./lib/allowlist.mjs";
|
||||
// justificativa. Remover daqui ao registrar o provider em providers.ts.
|
||||
export const KNOWN_REGISTRY_ONLY: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* Providers canônicos com serviceKinds llm que LEGITIMAMENTE não têm entrada no
|
||||
* REGISTRY. Cada um roteia via baseUrl de conexão (providerSpecificData.baseUrl) ou
|
||||
* executor especializado, então a ausência de registro não é órfão.
|
||||
*/
|
||||
export const KNOWN_CATALOG_ONLY: Record<string, string> = {
|
||||
"amazon-q": "OAuth/IDE provider roteado via KiroExecutor sem entrada de registry.",
|
||||
zed: "OAuth/IDE provider (Zed) roteado via executor especializado; sem entrada de registry.",
|
||||
piapi: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
getgoapi: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
laozhang: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
thebai: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
fenayai: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
empower: "Gateway OpenAI-compatible roteado via baseUrl de conexão.",
|
||||
"arcee-ai": "API-key provider roteado via baseUrl de conexão.",
|
||||
"azure-openai": "Azure OpenAI roteado via AzureOpenAIExecutor + baseUrl de conexão.",
|
||||
"azure-ai": "Azure AI Foundry roteado via AzureAiExecutor + baseUrl de conexão.",
|
||||
watsonx: "Enterprise provider roteado via baseUrl de conexão.",
|
||||
oci: "OCI Generative AI roteado via baseUrl de conexão.",
|
||||
sap: "SAP AI Core roteado via baseUrl de conexão.",
|
||||
datarobot: "Enterprise provider roteado via baseUrl de conexão.",
|
||||
clarifai: "Clarifai PAT roteado via baseUrl de conexão.",
|
||||
"360ai": "Regional provider roteado via baseUrl de conexão.",
|
||||
gitlab: "GitLab (non-Duo) roteado via executor especializado + baseUrl de conexão.",
|
||||
"poe-web": "Web/cookie provider roteado via executor especializado (PoeWebExecutor).",
|
||||
"venice-web": "Web/cookie provider roteado via executor especializado (VeniceWeb).",
|
||||
"v0-vercel-web": "Web/cookie provider roteado via executor especializado (V0VercelWeb).",
|
||||
"gemini-business": "Enterprise Gemini roteado via executor especializado + baseUrl de conexão.",
|
||||
"ollama-local": "Local provider (Ollama) roteado via baseUrl de conexão; sem registry.",
|
||||
"lm-studio": "Local provider (LM Studio) roteado via baseUrl de conexão.",
|
||||
vllm: "Local provider (vLLM) roteado via baseUrl de conexão.",
|
||||
lemonade: "Local provider roteado via baseUrl de conexão.",
|
||||
llamafile: "Local provider roteado via baseUrl de conexão.",
|
||||
"llama-cpp": "Local provider roteado via baseUrl de conexão.",
|
||||
triton: "Local provider (Triton) roteado via baseUrl de conexão.",
|
||||
"docker-model-runner": "Local provider roteado via baseUrl de conexão.",
|
||||
xinference: "Local provider (XInference) roteado via baseUrl de conexão.",
|
||||
oobabooga: "Local provider (Oobabooga) roteado via baseUrl de conexão.",
|
||||
};
|
||||
|
||||
/** Ids do REGISTRY que não são providers canônicos e não estão na allowlist. */
|
||||
export function findOrphanRegistryIds(
|
||||
registryIds: string[],
|
||||
@@ -72,24 +25,6 @@ export function findOrphanRegistryIds(
|
||||
return registryIds.filter((id) => !isKnownProvider(id) && !(id in allowlist));
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers canônicos com serviceKinds llm sem entrada no REGISTRY e fora da
|
||||
* allowlist — metade de um provider:remove (registro apagado, catálogo esquecido).
|
||||
*/
|
||||
export function findCatalogOnlyLlmProviders(
|
||||
canonicalProviders: Record<string, { serviceKinds?: string[] }>,
|
||||
registryIds: string[],
|
||||
allowlist: Record<string, string>
|
||||
): string[] {
|
||||
const registry = new Set(registryIds);
|
||||
return Object.entries(canonicalProviders)
|
||||
.filter(([id, p]) => {
|
||||
if (registry.has(id) || id in allowlist) return false;
|
||||
return Array.isArray(p.serviceKinds) && p.serviceKinds.includes("llm");
|
||||
})
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const canonical = new Set(Object.keys(AI_PROVIDERS));
|
||||
const isKnown = (id: string) => canonical.has(id) || Boolean(getProviderById(id));
|
||||
@@ -107,24 +42,9 @@ function main(): void {
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
// Reverse walk: llm-kind canonical provider sem REGISTRY = órfão reverso.
|
||||
const catalogOnlyLlm = findCatalogOnlyLlmProviders(
|
||||
AI_PROVIDERS as Record<string, { serviceKinds?: string[] }>,
|
||||
Object.keys(REGISTRY),
|
||||
KNOWN_CATALOG_ONLY
|
||||
);
|
||||
if (catalogOnlyLlm.length) {
|
||||
console.error(
|
||||
`[provider-consistency] ${catalogOnlyLlm.length} provider(s) canônico(s) llm sem entrada no REGISTRY:\n` +
|
||||
catalogOnlyLlm.map((id) => ` ✗ ${id}`).join("\n") +
|
||||
`\n → registre o provider em open-sse/config/providers/registry/<id>/ ou adicione a KNOWN_CATALOG_ONLY (scripts/check/check-provider-consistency.ts) com justificativa — órfão reverso de um provider:remove incompleto?`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (!process.exitCode) {
|
||||
console.log(
|
||||
`[provider-consistency] OK — ${Object.keys(REGISTRY).length} entradas REGISTRY, ${canonical.size} providers canônicos, ${Object.keys(KNOWN_REGISTRY_ONLY).length} exceção(ões) registry-only, ${Object.keys(KNOWN_CATALOG_ONLY).length} catalog-only`
|
||||
`[provider-consistency] OK — ${Object.keys(REGISTRY).length} entradas REGISTRY, ${canonical.size} providers canônicos, ${Object.keys(KNOWN_REGISTRY_ONLY).length} exceção(ões) conhecida(s)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,18 +39,7 @@ export function selectImpacted({ changed, map }) {
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
// `--stdin`: read the changed-file list from stdin (one path per line) instead of
|
||||
// diffing git. Used by scripts/quality/test-scoped.sh so `--staged` selects from the
|
||||
// index — the git-diff path here only knows about commits, never the working tree.
|
||||
export function changedFilesFromStdin(text) {
|
||||
return String(text || "")
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function changedFiles() {
|
||||
if (process.argv.includes("--stdin")) return changedFilesFromStdin(fs.readFileSync(0, "utf8"));
|
||||
const baseRef = process.env.GITHUB_BASE_REF;
|
||||
const baseTarget = process.env.GITHUB_BASE_SHA || (baseRef ? `origin/${baseRef}` : "HEAD~1");
|
||||
const stdout = execFileSync(
|
||||
|
||||
@@ -2,46 +2,25 @@
|
||||
# test-scoped — run only unit tests impacted by your changes.
|
||||
#
|
||||
# Usage:
|
||||
# npm run test:scoped # tests for changes vs HEAD~1 (working tree if no commit)
|
||||
# npm run test:scoped:staged # tests for staged changes only
|
||||
# npm run test:scoped:full # rebuild the import-graph impact map first, then select
|
||||
# npm run test:scoped # tests for changes vs HEAD~1
|
||||
# npm run test:scoped -- --staged # tests for staged changes only
|
||||
#
|
||||
# This is the local DX companion to the CI TIA gate (#8084 D1). It uses the SAME
|
||||
# selector as CI (scripts/quality/select-impacted-tests.mjs) against the import-graph
|
||||
# impact map (config/quality/test-impact-map.json, gitignored):
|
||||
# This is the local DX companion to the CI TIA gate (#8084 D1). The CI version
|
||||
# builds a full import-graph impact map; for local dev we use a fast heuristic:
|
||||
# - Changed test files → run those directly
|
||||
# - Changed source files → run every unit test whose import graph reaches them
|
||||
# - Hub files (tsconfig, package.json, …) or unmapped sources → full suite (fail-safe)
|
||||
# - Changed source files → run tests that share the file's directory/name prefix
|
||||
# - Hub files (tsconfig, package.json, etc.) → suggest full suite
|
||||
#
|
||||
# The map is a snapshot of the import graph: rebuild it (`--full`) after adding tests,
|
||||
# moving files, or pulling a big base update — a stale map falls back to __RUN_ALL__
|
||||
# for unknown sources, never to a silent skip.
|
||||
#
|
||||
# Loader parity with `npm run test:unit` / CI (#6787): tests/unit/dashboard/** runs
|
||||
# under `--import tsx` (CJS transform — required for ESM-only deep imports such as
|
||||
# @lobehub/icons/es/*), tests/unit/serial/** at --test-concurrency=1, everything else
|
||||
# under `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard
|
||||
# test the map selects ("Unexpected token 'export'").
|
||||
# For the full TIA (import-graph based), use: npm run test:scoped:full
|
||||
# (requires a pre-built impact map via: node scripts/quality/build-test-impact-map.mjs)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
MAP_FILE="$REPO_ROOT/config/quality/test-impact-map.json"
|
||||
|
||||
STAGED=false
|
||||
FULL=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--staged) STAGED=true ;;
|
||||
--full) FULL=true ;;
|
||||
-h|--help) sed -n '2,25p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
||||
*) echo "[test:scoped] unknown argument: $arg (use --staged, --full)"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── 1. Determine changed files ───────────────────────────────────────────────
|
||||
if [ "$STAGED" = true ]; then
|
||||
if [[ "${1:-}" == "--staged" ]]; then
|
||||
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR --cached)
|
||||
else
|
||||
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR HEAD~1...HEAD 2>/dev/null || \
|
||||
@@ -53,51 +32,80 @@ if [ -z "$CHANGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 2. Impact map (build on --full or when missing) ──────────────────────────
|
||||
if [ "$FULL" = true ] || [ ! -f "$MAP_FILE" ]; then
|
||||
echo "[test:scoped] Building the import-graph impact map (config/quality/test-impact-map.json)…"
|
||||
(cd "$REPO_ROOT" && node scripts/quality/build-test-impact-map.mjs)
|
||||
fi
|
||||
# ── 2. Classify changes ──────────────────────────────────────────────────────
|
||||
HUB_RE="(setupPolyfill|tsconfig|package\\.json|package-lock\\.json|\\.env|vitest\\.config|stryker\\.conf)"
|
||||
TEST_FILES=()
|
||||
SRC_FILES=()
|
||||
HIT_HUB=false
|
||||
|
||||
# ── 3. Select impacted tests (same selector as the CI TIA gate) ──────────────
|
||||
SEL=$(printf '%s\n' "$CHANGED" | node "$REPO_ROOT/scripts/quality/select-impacted-tests.mjs" --stdin)
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
if echo "$f" | grep -qE "$HUB_RE"; then
|
||||
HIT_HUB=true
|
||||
elif echo "$f" | grep -qE '^tests/unit/.*\.test\.(ts|mjs)$'; then
|
||||
TEST_FILES+=("$f")
|
||||
elif echo "$f" | grep -qE '^(src|open-sse)/'; then
|
||||
SRC_FILES+=("$f")
|
||||
fi
|
||||
done <<< "$CHANGED"
|
||||
|
||||
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
||||
echo "[test:scoped] Hub file or unmapped source changed — run the full suite: npm run test:unit"
|
||||
echo "[test:scoped] (if you just added a source file, rebuild the map: npm run test:scoped:full)"
|
||||
# ── 3. Hub file changed → full suite ─────────────────────────────────────────
|
||||
if [ "$HIT_HUB" = true ]; then
|
||||
echo "[test:scoped] Hub file changed — run full suite: npm run test:unit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t RUN_TESTS < <(printf '%s\n' "$SEL" | grep -v '^$' | sort -u)
|
||||
# ── 4. Collect tests to run ──────────────────────────────────────────────────
|
||||
RUN_TESTS=()
|
||||
|
||||
if [ ${#RUN_TESTS[@]} -eq 0 ]; then
|
||||
echo "[test:scoped] No impacted unit tests — the change does not reach any node:test file."
|
||||
# Direct test file changes always run
|
||||
for tf in "${TEST_FILES[@]}"; do
|
||||
RUN_TESTS+=("$tf")
|
||||
done
|
||||
|
||||
# For source files, try the impact map first; fall back to heuristic
|
||||
MAP_FILE="$REPO_ROOT/config/quality/test-impact-map.json"
|
||||
if [ ${#SRC_FILES[@]} -gt 0 ] && [ -f "$MAP_FILE" ]; then
|
||||
# Use the TIA selection with the impact map
|
||||
SEL=$(printf '%s\n' "${SRC_FILES[@]}" | node "$REPO_ROOT/scripts/quality/select-impacted-tests.mjs" 2>/dev/null || echo "__RUN_ALL__")
|
||||
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
||||
echo "[test:scoped] Unmapped source change — run full suite: npm run test:unit"
|
||||
exit 1
|
||||
fi
|
||||
while IFS= read -r t; do
|
||||
[ -n "$t" ] && RUN_TESTS+=("$t")
|
||||
done <<< "$SEL"
|
||||
elif [ ${#SRC_FILES[@]} -gt 0 ]; then
|
||||
# No impact map — heuristic: suggest building it
|
||||
echo "[test:scoped] No impact map found. Build it with: node scripts/quality/build-test-impact-map.mjs"
|
||||
echo "[test:scoped] Or run the full suite: npm run test:unit"
|
||||
echo ""
|
||||
echo "[test:scoped] Changed source files:"
|
||||
printf ' %s\n' "${SRC_FILES[@]}"
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
echo "[test:scoped] Running changed test files only..."
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deduplicate
|
||||
IFS=$'\n' SORTED=($(printf '%s\n' "${RUN_TESTS[@]}" | sort -u)); unset IFS
|
||||
|
||||
if [ ${#SORTED[@]} -eq 0 ]; then
|
||||
echo "[test:scoped] No impacted tests — source changes don't map to any unit test."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[test:scoped] Running ${#RUN_TESTS[@]} impacted test(s)..."
|
||||
|
||||
# ── 4. Split by loader (mirror package.json test:unit / quality.yml TIA step) ──
|
||||
DASH=(); SERIAL=(); REST=()
|
||||
for f in "${RUN_TESTS[@]}"; do
|
||||
case "$f" in
|
||||
tests/unit/dashboard/*) DASH+=("$f") ;;
|
||||
tests/unit/serial/*) SERIAL+=("$f") ;;
|
||||
*) REST+=("$f") ;;
|
||||
esac
|
||||
done
|
||||
echo "[test:scoped] Running ${#SORTED[@]} impacted test(s)..."
|
||||
|
||||
# ── 5. Run selected tests ────────────────────────────────────────────────────
|
||||
cd "$REPO_ROOT"
|
||||
NODE_COMMON=(--max-old-space-size=8192 --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit)
|
||||
export DISABLE_SQLITE_AUTO_BACKUP=true
|
||||
RC=0
|
||||
if [ ${#REST[@]} -gt 0 ]; then
|
||||
node --import tsx/esm "${NODE_COMMON[@]}" --test-concurrency=4 "${REST[@]}" || RC=$?
|
||||
fi
|
||||
if [ ${#DASH[@]} -gt 0 ]; then
|
||||
node --import tsx "${NODE_COMMON[@]}" --test-concurrency=4 "${DASH[@]}" || RC=$?
|
||||
fi
|
||||
if [ ${#SERIAL[@]} -gt 0 ]; then
|
||||
node --import tsx/esm "${NODE_COMMON[@]}" --test-concurrency=1 "${SERIAL[@]}" || RC=$?
|
||||
fi
|
||||
exit $RC
|
||||
exec cross-env \
|
||||
DISABLE_SQLITE_AUTO_BACKUP=true \
|
||||
node --max-old-space-size=8192 \
|
||||
--import tsx/esm \
|
||||
--import ./open-sse/utils/setupPolyfill.ts \
|
||||
--import ./tests/_setup/isolateDataDir.ts \
|
||||
--test --test-force-exit --test-concurrency=4 \
|
||||
"${SORTED[@]}"
|
||||
|
||||
@@ -25,17 +25,6 @@ curl https://localhost:20128/api/monitoring/health \
|
||||
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
|
||||
```
|
||||
|
||||
### GET /api/monitoring/compression
|
||||
|
||||
Get compression result-memo statistics
|
||||
|
||||
In-process compression result-memo observability snapshot — size, capacity, lifetime hits/misses/hitRate plus 1m/5m/15m/1h windowed rates. Lightweight (no DB, no provider reads) companion to `GET /api/monitoring/health` intended for frequent polling. Sent with `Cache-Control: no-store, no-cache, must-revalidate`. Counters reset on process restart.
|
||||
|
||||
```bash
|
||||
curl https://localhost:20128/api/monitoring/compression \
|
||||
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
|
||||
```
|
||||
|
||||
### GET /api/provider-metrics
|
||||
|
||||
GET provider metrics
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useSyncExternalStore } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
|
||||
// The URL in README.md's Open Source Friends section. This used to go through
|
||||
// our own link.omniroute.online shortener for click metrics, but that domain no
|
||||
// longer resolves (every slug 404s) after the move to omniskill.online, so the
|
||||
// CTA points straight at the destination again.
|
||||
const CHEAPER_INFERENCE_URL = "https://cheaperinference.com/?utm_source=omniroute";
|
||||
// Branded short link through our own link.omniroute.online shortener, so the
|
||||
// click lands in our Kutt metrics. Points at cheaperinference.com?utm_source=omniroute
|
||||
// (the URL in README.md's Open Source Friends section). Keep in sync with the
|
||||
// `cheaper` slug on the shortener.
|
||||
const CHEAPER_INFERENCE_URL = "https://link.omniroute.online/cheaper";
|
||||
|
||||
// Cheaper Inference brand green (#31f889). White text on it fails contrast, so
|
||||
// the CTA pairs it with the dark ink from the provider's color token (colors.ts:
|
||||
|
||||
@@ -6,10 +6,9 @@ import { useTranslations } from "next-intl";
|
||||
// Marketplace listing is the primary CTA; Open VSX (Cursor/Windsurf/VSCodium/etc.)
|
||||
// is called out via secondaryNote instead of a second button, to keep this banner
|
||||
// the same size as KimiSponsorBanner.
|
||||
// This used to go through our own link.omniroute.online shortener for click
|
||||
// metrics, but that domain no longer resolves after the move to omniskill.online.
|
||||
const MARKETPLACE_URL =
|
||||
"https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot";
|
||||
// Branded short link through our own link.omniroute.online shortener (the `vsx`
|
||||
// slug), so the click lands in our Kutt metrics.
|
||||
const MARKETPLACE_URL = "https://link.omniroute.online/vsx";
|
||||
|
||||
const DISMISS_STORAGE_KEY = "omniroute-vscode-copilot-banner-dismissed-v1";
|
||||
// Same-tab signal for the dismiss button, since writing localStorage doesn't
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, memo, Suspense } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -741,7 +741,7 @@ function formatComboEntryDisplay(
|
||||
return `${providerLabel}/${modelLabel}`;
|
||||
}
|
||||
|
||||
function CombosPageContent() {
|
||||
export default function CombosPage() {
|
||||
const t = useTranslations("combos");
|
||||
const tc = useTranslations("common");
|
||||
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
|
||||
@@ -1373,14 +1373,6 @@ function CombosPageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function CombosPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CombosPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const COMBO_WIZARD_STEPS = [
|
||||
{
|
||||
step: 1,
|
||||
|
||||
@@ -122,10 +122,6 @@ interface UsageAnalyticsPayload {
|
||||
weeklyPattern: Array<{ day: string; avgTokens: number; totalTokens: number }>;
|
||||
activityMap: Record<string, number>;
|
||||
presetSummaries?: Record<string, { totalCost: number }>;
|
||||
// The API reports whether the returned cost figures include token-price
|
||||
// equivalents for flat-rate subscriptions (route.ts). Billed-cost mode omits
|
||||
// it, so treat anything but an explicit `true` as billed money.
|
||||
includesFlatRateEstimates?: boolean;
|
||||
}
|
||||
|
||||
const RANGE_OPTIONS: Array<{ value: CostRange; labelKey: string }> = [
|
||||
@@ -212,30 +208,16 @@ function csvCell(value: string | number): string {
|
||||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
// The exports are consumed outside the app, where no i18n runtime is available
|
||||
// and the header/summary keys are already English literals, so the estimate
|
||||
// disclosure ships as an English marker alongside them.
|
||||
const FLAT_RATE_ESTIMATE_CSV_NOTE =
|
||||
"Includes token-price estimates for flat-rate subscriptions; not billed cost.";
|
||||
|
||||
function generateCSV(analytics: UsageAnalyticsPayload, locale: string): string {
|
||||
const currencyFormatter = createCurrencyFormatter(locale);
|
||||
const lines: string[] = [];
|
||||
// Only an explicit `true` means estimate mode; omitted/false/malformed stays
|
||||
// billed-cost, which is what the API itself does with the query parameter.
|
||||
const includesEstimates = analytics.includesFlatRateEstimates === true;
|
||||
|
||||
lines.push("# OmniRoute Cost Report");
|
||||
lines.push(`# Generated: ${new Date().toISOString()}`);
|
||||
if (includesEstimates) {
|
||||
lines.push(`# ${FLAT_RATE_ESTIMATE_CSV_NOTE}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("## Summary");
|
||||
lines.push("Metric,Value");
|
||||
lines.push(
|
||||
`${csvCell(includesEstimates ? "Total Cost (includes flat-rate estimates)" : "Total Cost")},${csvCell(currencyFormatter.format(analytics.summary.totalCost))}`
|
||||
);
|
||||
lines.push(`Total Cost,${csvCell(currencyFormatter.format(analytics.summary.totalCost))}`);
|
||||
lines.push(`Total Requests,${analytics.summary.totalRequests}`);
|
||||
lines.push(`Unique Models,${analytics.summary.uniqueModels}`);
|
||||
lines.push(`Unique Accounts,${analytics.summary.uniqueAccounts}`);
|
||||
@@ -293,7 +275,6 @@ function generateJSON(analytics: UsageAnalyticsPayload): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
generatedAt: new Date().toISOString(),
|
||||
includesFlatRateEstimates: analytics.includesFlatRateEstimates === true,
|
||||
summary: analytics.summary,
|
||||
dailyTrend: analytics.dailyTrend,
|
||||
weeklyPattern: analytics.weeklyPattern,
|
||||
@@ -363,7 +344,6 @@ export default function CostOverviewTab() {
|
||||
const params = new URLSearchParams({
|
||||
range,
|
||||
presets: "1d,7d,30d",
|
||||
includeFlatRateEstimates: "true",
|
||||
});
|
||||
if (apiKeyFilter) params.set("apiKeyIds", apiKeyFilter);
|
||||
const response = await fetch(`/api/usage/analytics?${params.toString()}`);
|
||||
@@ -417,12 +397,6 @@ export default function CostOverviewTab() {
|
||||
streak: 0,
|
||||
};
|
||||
const hasCostData = summary.totalCost > 0;
|
||||
// The API opts this page into token-price equivalents for flat-rate
|
||||
// subscriptions (includeFlatRateEstimates=true above) and reports back whether
|
||||
// the figures actually carry them. Only an explicit `true` switches the page
|
||||
// to estimate wording — omitted, false, malformed or unknown values keep the
|
||||
// billed-cost presentation, matching the API's own default.
|
||||
const includesFlatRateEstimates = analytics?.includesFlatRateEstimates === true;
|
||||
|
||||
const providersByCost = [...(analytics?.byProvider || [])]
|
||||
.filter((provider) => (hasCostData ? provider.cost > 0 : provider.requests > 0))
|
||||
@@ -596,13 +570,6 @@ export default function CostOverviewTab() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{includesFlatRateEstimates && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/20 bg-amber-500/5 px-4 py-3">
|
||||
<span className="material-symbols-outlined text-amber-400 text-base leading-5">info</span>
|
||||
<p className="text-xs text-amber-300/90">{t("flatRateEstimateNotice")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedApiKeyId && (
|
||||
<ApiKeyUsageLimitCard
|
||||
payload={apiKeyUsageLimits}
|
||||
@@ -771,9 +738,6 @@ export default function CostOverviewTab() {
|
||||
<span>/</span>
|
||||
<span>{t("daysRemaining", { days: daysRemainingInMonth })}</span>
|
||||
</div>
|
||||
{includesFlatRateEstimates && (
|
||||
<p className="mt-2 text-xs text-amber-300/90">{t("flatRateEstimateForecast")}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
|
||||
@@ -89,46 +89,22 @@ export default function EmbeddingSourceSelector({ settings, providers, onSave, s
|
||||
{t("embedding.noRemoteProviders")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={
|
||||
remoteProviders.some((p) => p.models.some((m) => m.id === currentProviderModel))
|
||||
? currentProviderModel
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="embedding-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("embedding.selectProviderModel")}</option>
|
||||
{remoteProviders.map((p) =>
|
||||
p.models.length > 0 ? (
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.dimensions ? `${m.dimensions}d` : "?"})
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<optgroup key={p.provider} label={p.provider}>
|
||||
<option value="">{`— ${p.provider} (no curated models)`}</option>
|
||||
</optgroup>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
{/* Free-text override: the runtime accepts any configured provider's
|
||||
OpenAI-compatible model id, including ones without a curated
|
||||
registry entry (e.g. groq/, mistral/, cf/...). */}
|
||||
<input
|
||||
type="text"
|
||||
value={currentProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder="provider/model — e.g. mistral/mistral-embed"
|
||||
data-testid="embedding-provider-model-input"
|
||||
className="w-full mt-2 px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
</>
|
||||
<select
|
||||
value={currentProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="embedding-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("embedding.selectProviderModel")}</option>
|
||||
{remoteProviders.map((p) =>
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({m.dimensions ? `${m.dimensions}d` : "?"})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
<CustomEmbeddingEndpointFields settings={settings} onSave={onSave} saving={saving} />
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,11 @@ export default function RerankConfigCard({ settings, providers, onSave, saving }
|
||||
}}
|
||||
disabled={saving || (!rerankEnabled && !hasProvider)}
|
||||
aria-disabled={saving || (!rerankEnabled && !hasProvider)}
|
||||
title={!rerankEnabled && !hasProvider ? t("rerank.noProviderWithKey") : undefined}
|
||||
title={
|
||||
!rerankEnabled && !hasProvider
|
||||
? t("rerank.noProviderWithKey")
|
||||
: undefined
|
||||
}
|
||||
role="switch"
|
||||
aria-checked={rerankEnabled}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
@@ -79,45 +83,22 @@ export default function RerankConfigCard({ settings, providers, onSave, saving }
|
||||
{t("rerank.noProviderWithKey")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={
|
||||
rerankProviders.some((p) => p.models.some((m) => m.id === rerankProviderModel))
|
||||
? rerankProviderModel
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="rerank-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("rerank.selectProviderModel")}</option>
|
||||
{rerankProviders.map((p) =>
|
||||
p.models.length > 0 ? (
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<optgroup key={p.provider} label={p.provider}>
|
||||
<option value="">{`— ${p.provider} (no curated models)`}</option>
|
||||
</optgroup>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
{/* Free-text override: any configured provider's Cohere-compatible
|
||||
model id is accepted by the runtime even without a curated entry. */}
|
||||
<input
|
||||
type="text"
|
||||
value={rerankProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
placeholder="provider/model — e.g. groq/my-reranker"
|
||||
data-testid="rerank-provider-model-input"
|
||||
className="w-full mt-2 px-3 py-2 rounded-lg bg-background border border-border text-sm font-mono focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
</>
|
||||
<select
|
||||
value={rerankProviderModel}
|
||||
onChange={(e) => handleProviderModelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
data-testid="rerank-provider-model-select"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">{t("rerank.selectProviderModel")}</option>
|
||||
{rerankProviders.map((p) =>
|
||||
p.models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
)),
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -16,7 +16,6 @@ export default function EngineTab() {
|
||||
const { status, isLoading: statusLoading } = useEngineStatus();
|
||||
const { settings, save: saveSettings, isLoading: settingsLoading } = useMemorySettings();
|
||||
const [providers, setProviders] = useState<EmbeddingProviderListing[]>([]);
|
||||
const [rerankProviders, setRerankProviders] = useState<EmbeddingProviderListing[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reindexing, setReindexing] = useState(false);
|
||||
const [reindexMsg, setReindexMsg] = useState("");
|
||||
@@ -33,14 +32,6 @@ export default function EngineTab() {
|
||||
if (!cancelled && data?.providers) setProviders(data.providers);
|
||||
})
|
||||
.catch(() => {});
|
||||
// Rerank has its own curated registry — the embedding listing does not
|
||||
// include rerank-only providers (cohere rerank SKUs, siliconflow, ...).
|
||||
fetch("/api/memory/rerank-providers")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (!cancelled && data?.providers) setRerankProviders(data.providers);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -148,7 +139,7 @@ export default function EngineTab() {
|
||||
</h3>
|
||||
<RerankConfigCard
|
||||
settings={settings}
|
||||
providers={rerankProviders}
|
||||
providers={providers}
|
||||
onSave={handleSaveSettings}
|
||||
saving={saving}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLiveComboStatus } from "@/hooks/useLiveDashboard";
|
||||
@@ -9,49 +9,12 @@ import { AgentsTab } from "./tabs/AgentsTab";
|
||||
import { RoutingTab } from "./tabs/RoutingTab";
|
||||
import { OverviewTab } from "./tabs/OverviewTab";
|
||||
import { OrchestrationDrawer } from "./drawer/OrchestrationDrawer";
|
||||
import { OrchestrationToolbar } from "./OrchestrationToolbar";
|
||||
import { collectProviderKeys, filterSnapshot } from "./model/filterSnapshot";
|
||||
import type { OrchFilter } from "./model/filterSnapshot";
|
||||
import { ORCH_STATES } from "./model/orchestrationTypes";
|
||||
import type { OrchSource, OrchState } from "./model/orchestrationTypes";
|
||||
|
||||
const TABS = ["agents", "routing", "overview"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
const VALID_STATES: ReadonlySet<OrchState> = new Set(ORCH_STATES);
|
||||
const VALID_SOURCES: ReadonlySet<OrchSource> = new Set(["cloud-agent", "a2a", "conductor"]);
|
||||
|
||||
/** CSV → Set, dropping empty/invalid entries (`valid` omitted accepts any non-empty token). */
|
||||
function parseCsvSet<T extends string>(raw: string | null, valid?: ReadonlySet<T>): Set<T> {
|
||||
const out = new Set<T>();
|
||||
if (!raw) return out;
|
||||
for (const v of raw.split(",")) {
|
||||
if (!v) continue;
|
||||
if (!valid || valid.has(v as T)) out.add(v as T);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Toggle `value` in `current`, returning the next CSV (or `null` to drop the param). */
|
||||
function toggleCsv<T extends string>(current: ReadonlySet<T>, value: T): string | null {
|
||||
const next = new Set(current);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next.size > 0 ? [...next].sort().join(",") : null;
|
||||
}
|
||||
|
||||
const TAB_KEY: Record<Tab, string> = {
|
||||
agents: "tabAgents",
|
||||
routing: "tabRouting",
|
||||
overview: "tabOverview",
|
||||
};
|
||||
|
||||
/**
|
||||
* The page's entire URL state (tab / selected node / filters / collapsed groups) plus the
|
||||
* writer that patches it back into the query string. Pure derivation over
|
||||
* `useSearchParams` — no state of its own, so the URL stays the single source of truth.
|
||||
*/
|
||||
function useOrchUrlState() {
|
||||
export default function OrchestrationPageClient() {
|
||||
const t = useTranslations("orchestration");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useSearchParams();
|
||||
@@ -59,11 +22,7 @@ function useOrchUrlState() {
|
||||
const tab: Tab = (TABS as readonly string[]).includes(params.get("tab") ?? "")
|
||||
? (params.get("tab") as Tab)
|
||||
: "agents";
|
||||
const qParam = params.get("q") ?? "";
|
||||
const stateParam = params.get("state");
|
||||
const sourceParam = params.get("source");
|
||||
const providerParam = params.get("provider");
|
||||
const collapsedParam = params.get("collapsed");
|
||||
const nodeId = params.get("node");
|
||||
|
||||
const setParams = useCallback(
|
||||
(patch: Record<string, string | null>) => {
|
||||
@@ -74,108 +33,69 @@ function useOrchUrlState() {
|
||||
[params, pathname, router]
|
||||
);
|
||||
|
||||
const filter: OrchFilter = useMemo(
|
||||
() => ({
|
||||
q: qParam,
|
||||
states: parseCsvSet(stateParam, VALID_STATES),
|
||||
sources: parseCsvSet(sourceParam, VALID_SOURCES),
|
||||
providers: parseCsvSet<string>(providerParam),
|
||||
}),
|
||||
[qParam, stateParam, sourceParam, providerParam]
|
||||
);
|
||||
const collapsed = useMemo(() => parseCsvSet(collapsedParam, VALID_SOURCES), [collapsedParam]);
|
||||
|
||||
return { tab, nodeId: params.get("node"), filter, collapsed, setParams };
|
||||
}
|
||||
|
||||
/** The tab strip. Presentation only — selecting a tab writes it back to the URL. */
|
||||
function TabList({
|
||||
tab,
|
||||
t,
|
||||
onSelect,
|
||||
}: {
|
||||
tab: Tab;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
onSelect: (tab: Tab) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="tablist" className="flex gap-1 border-b border-border">
|
||||
{TABS.map((tb) => (
|
||||
<button
|
||||
key={tb}
|
||||
role="tab"
|
||||
aria-selected={tab === tb}
|
||||
className={`px-3 py-1.5 text-sm rounded-t ${tab === tb ? "border border-b-0 border-border bg-surface font-medium" : "text-muted"}`}
|
||||
onClick={() => onSelect(tb)}
|
||||
>
|
||||
{t(TAB_KEY[tb])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrchestrationPageClient() {
|
||||
const t = useTranslations("orchestration");
|
||||
const { tab, nodeId, filter, collapsed, setParams } = useOrchUrlState();
|
||||
|
||||
const { snapshot, showCompleted, setShowCompleted, refetch } = useOrchestrationSnapshot();
|
||||
const { comboEvents, activeCombos, isConnected } = useLiveComboStatus();
|
||||
const { providerHealth, connectionHealth } = useProviderBreakerHealth();
|
||||
|
||||
const filtered = useMemo(() => filterSnapshot(snapshot, filter), [snapshot, filter]);
|
||||
const providerKeys = useMemo(() => collectProviderKeys(snapshot), [snapshot]);
|
||||
|
||||
const onToggleCollapse = useCallback(
|
||||
(s: OrchSource) => setParams({ collapsed: toggleCsv(collapsed, s) }),
|
||||
[collapsed, setParams]
|
||||
);
|
||||
const closeDrawer = useCallback(() => setParams({ node: null }), [setParams]);
|
||||
|
||||
const selectedNode = nodeId ? (snapshot.nodes.find((n) => n.id === nodeId) ?? null) : null;
|
||||
const onNodeClick = (id: string) =>
|
||||
id.startsWith("overflow:")
|
||||
? setParams({ tab: "overview", node: null })
|
||||
: setParams({ node: id });
|
||||
|
||||
const TAB_KEY: Record<Tab, string> = {
|
||||
agents: "tabAgents",
|
||||
routing: "tabRouting",
|
||||
overview: "tabOverview",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100dvh-6rem)] min-h-[480px] p-4 gap-3">
|
||||
<TabList tab={tab} t={t} onSelect={(tb) => setParams({ tab: tb })} />
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2">
|
||||
{(tab === "agents" || tab === "overview") && (
|
||||
<OrchestrationToolbar filter={filter} providerKeys={providerKeys} setParams={setParams} />
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{tab === "agents" && (
|
||||
<AgentsTab
|
||||
snapshot={filtered}
|
||||
onNodeClick={onNodeClick}
|
||||
showCompleted={showCompleted}
|
||||
onToggleCompleted={setShowCompleted}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
/>
|
||||
)}
|
||||
{tab === "routing" && (
|
||||
<RoutingTab
|
||||
comboEvents={comboEvents}
|
||||
combos={[...activeCombos]}
|
||||
isConnected={isConnected}
|
||||
providerHealth={providerHealth}
|
||||
connectionHealth={connectionHealth}
|
||||
/>
|
||||
)}
|
||||
{tab === "overview" && (
|
||||
<OverviewTab
|
||||
snapshot={filtered}
|
||||
comboEvents={comboEvents}
|
||||
onCardClick={(id) => setParams({ node: id })}
|
||||
onSeeInGraph={(id) => setParams({ tab: "agents", node: id })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div role="tablist" className="flex gap-1 border-b border-border">
|
||||
{TABS.map((tb) => (
|
||||
<button
|
||||
key={tb}
|
||||
role="tab"
|
||||
aria-selected={tab === tb}
|
||||
className={`px-3 py-1.5 text-sm rounded-t ${tab === tb ? "border border-b-0 border-border bg-surface font-medium" : "text-muted"}`}
|
||||
onClick={() => setParams({ tab: tb })}
|
||||
>
|
||||
{t(TAB_KEY[tb])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<OrchestrationDrawer node={selectedNode} onClose={closeDrawer} onActionDone={refetch} />
|
||||
<div className="flex-1 min-h-0">
|
||||
{tab === "agents" && (
|
||||
<AgentsTab
|
||||
snapshot={snapshot}
|
||||
onNodeClick={onNodeClick}
|
||||
showCompleted={showCompleted}
|
||||
onToggleCompleted={setShowCompleted}
|
||||
/>
|
||||
)}
|
||||
{tab === "routing" && (
|
||||
<RoutingTab
|
||||
comboEvents={comboEvents}
|
||||
combos={[...activeCombos]}
|
||||
isConnected={isConnected}
|
||||
providerHealth={providerHealth}
|
||||
connectionHealth={connectionHealth}
|
||||
/>
|
||||
)}
|
||||
{tab === "overview" && (
|
||||
<OverviewTab
|
||||
snapshot={snapshot}
|
||||
comboEvents={comboEvents}
|
||||
onCardClick={(id) => setParams({ node: id })}
|
||||
onSeeInGraph={(id) => setParams({ tab: "agents", node: id })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<OrchestrationDrawer
|
||||
node={selectedNode}
|
||||
onClose={() => setParams({ node: null })}
|
||||
onActionDone={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"use client";
|
||||
/**
|
||||
* Search input + filter chips for the Agents/Overview tabs — pure presentation over the URL
|
||||
* params owned by OrchestrationPageClient (`q`/`state`/`source`/`provider`). No filtering logic
|
||||
* lives here; it renders `filter` (an `OrchFilter` already parsed from the URL) and calls
|
||||
* `setParams` to mutate it. Spec: task-a6-brief.md (2.3+2.4).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { isEmptyFilter } from "./model/filterSnapshot";
|
||||
import type { OrchFilter } from "./model/filterSnapshot";
|
||||
import { ORCH_STATES } from "./model/orchestrationTypes";
|
||||
import type { OrchSource, OrchState } from "./model/orchestrationTypes";
|
||||
|
||||
const SOURCES = ["cloud-agent", "a2a", "conductor"] as const satisfies readonly OrchSource[];
|
||||
|
||||
const STATE_KEY: Record<OrchState, string> = {
|
||||
queued: "stateQueued",
|
||||
running: "stateRunning",
|
||||
waiting_approval: "stateWaitingApproval",
|
||||
succeeded: "stateSucceeded",
|
||||
failed: "stateFailed",
|
||||
cancelled: "stateCancelled",
|
||||
};
|
||||
const SOURCE_KEY: Record<(typeof SOURCES)[number], string> = {
|
||||
"cloud-agent": "sourceCloudAgent",
|
||||
a2a: "sourceA2A",
|
||||
conductor: "sourceConductor",
|
||||
};
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Toggle `value` in `current`, returning the next CSV (or `null` to drop the param). */
|
||||
function toggleCsv<T extends string>(current: ReadonlySet<T>, value: T): string | null {
|
||||
const next = new Set(current);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next.size > 0 ? [...next].sort().join(",") : null;
|
||||
}
|
||||
|
||||
const chipClass = (active: boolean) =>
|
||||
`text-[10px] px-2 py-0.5 rounded-full border whitespace-nowrap ${
|
||||
active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted"
|
||||
}`;
|
||||
|
||||
/**
|
||||
* One labeled row of toggle chips (states / sources / providers). Pure presentation:
|
||||
* `active` drives the pressed style + `aria-pressed`, `onToggle` writes the URL param
|
||||
* upstream. Extracted so the toolbar itself stays under the max-lines ratchet.
|
||||
*/
|
||||
function ChipGroup<T extends string>({
|
||||
label,
|
||||
values,
|
||||
active,
|
||||
renderLabel,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
values: readonly T[];
|
||||
active: ReadonlySet<T>;
|
||||
renderLabel: (value: T) => string;
|
||||
onToggle: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-[10px] text-muted">{label}</span>
|
||||
{values.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={chipClass(active.has(v))}
|
||||
aria-pressed={active.has(v)}
|
||||
onClick={() => onToggle(v)}
|
||||
>
|
||||
{renderLabel(v)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrchestrationToolbar({
|
||||
filter,
|
||||
providerKeys,
|
||||
setParams,
|
||||
}: {
|
||||
filter: OrchFilter;
|
||||
providerKeys: string[];
|
||||
setParams: (patch: Record<string, string | null>) => void;
|
||||
}) {
|
||||
const t = useTranslations("orchestration");
|
||||
const [text, setText] = useState(filter.q);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (v: string) => {
|
||||
setText(v);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setParams({ q: v || null }), SEARCH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setText("");
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setParams({ q: null, state: null, source: null, provider: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-surface px-3 py-2">
|
||||
<input
|
||||
type="search"
|
||||
value={text}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="text-xs px-2 py-1 rounded border border-border bg-transparent min-w-[160px]"
|
||||
/>
|
||||
<ChipGroup
|
||||
label={t("filterStates")}
|
||||
values={ORCH_STATES}
|
||||
active={filter.states}
|
||||
renderLabel={(s) => t(STATE_KEY[s])}
|
||||
onToggle={(s) => setParams({ state: toggleCsv(filter.states, s) })}
|
||||
/>
|
||||
<ChipGroup
|
||||
label={t("filterSources")}
|
||||
values={SOURCES}
|
||||
active={filter.sources}
|
||||
renderLabel={(s) => t(SOURCE_KEY[s])}
|
||||
onToggle={(s) => setParams({ source: toggleCsv(filter.sources, s) })}
|
||||
/>
|
||||
{providerKeys.length > 0 && (
|
||||
<ChipGroup
|
||||
label={t("filterProviders")}
|
||||
values={providerKeys}
|
||||
active={filter.providers}
|
||||
renderLabel={(p) => p}
|
||||
onToggle={(p) => setParams({ provider: toggleCsv(filter.providers, p) })}
|
||||
/>
|
||||
)}
|
||||
{!isEmptyFilter(filter) && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] underline text-muted ml-auto"
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t("clearFilters")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,12 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { StatusDot } from "@/shared/components/flow/StatusDot";
|
||||
import { orchStateColor, type OrchNode, type OrchState } from "../model/orchestrationTypes";
|
||||
import { useDrawerDetail } from "./useDrawerDetail";
|
||||
import type { DrawerError } from "./useDrawerDetail";
|
||||
import type { CloudAgentTask } from "@/lib/cloudAgent/types";
|
||||
import type { A2ATask } from "@/lib/a2a/taskManager";
|
||||
|
||||
const TOAST_MS = 2500;
|
||||
|
||||
/** Timeline normalized by source — the same data the Timeline component displays. */
|
||||
function normalizedTimeline(node: OrchNode, detail: unknown): unknown {
|
||||
if (node.source === "cloud-agent") return (detail as CloudAgentTask | null)?.activities ?? [];
|
||||
if (node.source === "a2a") return (detail as A2ATask | null)?.events ?? [];
|
||||
return null; // conductor/overflow: the raw payload already is the trace
|
||||
}
|
||||
|
||||
/** Builds the copy-to-clipboard JSON payload for the drawer's "copy trace" action. */
|
||||
export function buildTraceJson(node: OrchNode, detail: unknown): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
node: { id: node.id, source: node.source, state: node.state, label: node.label },
|
||||
timeline: normalizedTimeline(node, detail),
|
||||
raw: detail ?? node.raw ?? null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>;
|
||||
|
||||
const STATE_KEY: Record<OrchState, string> = {
|
||||
@@ -95,30 +72,18 @@ function Timeline({ node, detail }: { node: OrchNode; detail: unknown }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Header row: status dot, label/source/state, copy-trace + close buttons. */
|
||||
/** Header row: status dot, label/source/state, close button. */
|
||||
function DrawerHeader({
|
||||
node,
|
||||
detail,
|
||||
state,
|
||||
t,
|
||||
onClose,
|
||||
onToast,
|
||||
}: {
|
||||
node: OrchNode;
|
||||
detail: unknown;
|
||||
state: OrchState;
|
||||
t: Translate;
|
||||
onClose: () => void;
|
||||
onToast: (text: string) => void;
|
||||
}) {
|
||||
const copyTrace = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildTraceJson(node, detail));
|
||||
onToast(t("actionDone"));
|
||||
} catch {
|
||||
onToast(t("actionFailed", { error: "clipboard" }));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<StatusDot
|
||||
@@ -132,78 +97,13 @@ function DrawerHeader({
|
||||
{node.source} · {t(STATE_KEY[state])}
|
||||
</div>
|
||||
</div>
|
||||
<button className="ml-auto text-muted" onClick={copyTrace} aria-label={t("copyTrace")}>
|
||||
⧉
|
||||
</button>
|
||||
<button className="text-muted" onClick={onClose} aria-label={t("drawerClose")}>
|
||||
<button className="ml-auto text-muted" onClick={onClose} aria-label="close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows the loaded detail payload to the typed shape of the node's source — the
|
||||
* non-matching one is always `null`, so each section can read its own shape safely.
|
||||
*/
|
||||
function narrowDetail(
|
||||
node: OrchNode,
|
||||
detail: unknown
|
||||
): { ca: CloudAgentTask | null; a2a: A2ATask | null } {
|
||||
return {
|
||||
ca: node.source === "cloud-agent" ? (detail as CloudAgentTask | null) : null,
|
||||
a2a: node.source === "a2a" ? (detail as A2ATask | null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Objective section: the agent prompt / first A2A message, falling back to the node labels. */
|
||||
function DrawerObjective({
|
||||
node,
|
||||
ca,
|
||||
a2a,
|
||||
t,
|
||||
}: {
|
||||
node: OrchNode;
|
||||
ca: CloudAgentTask | null;
|
||||
a2a: A2ATask | null;
|
||||
t: Translate;
|
||||
}) {
|
||||
return (
|
||||
<Section title={t("drawerObjective")}>
|
||||
<p className="text-xs break-words">
|
||||
{ca?.prompt ?? a2a?.input?.messages[0]?.content ?? node.sublabel ?? node.label}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Transient banners above the sections: toast, load/action error, loading placeholder. */
|
||||
function DrawerBanners({
|
||||
toast,
|
||||
error,
|
||||
errorKind,
|
||||
isLoading,
|
||||
t,
|
||||
}: {
|
||||
toast: string | null;
|
||||
error: string | null;
|
||||
errorKind: DrawerError["kind"] | null;
|
||||
isLoading: boolean;
|
||||
t: Translate;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{toast && <div className="text-xs text-success mb-3">{toast}</div>}
|
||||
{error && (
|
||||
<div className="text-xs text-error mb-3">
|
||||
{t(errorKind === "detail" ? "detailFailed" : "actionFailed", { error })}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <div className="text-xs text-muted mb-3">…</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cost/duration metrics section — omitted entirely when neither value is present. */
|
||||
function DrawerMetrics({
|
||||
node,
|
||||
@@ -260,46 +160,37 @@ function DrawerResult({
|
||||
function DrawerActions({
|
||||
canApprove,
|
||||
canCancel,
|
||||
busy,
|
||||
approve,
|
||||
cancel,
|
||||
onActionDone,
|
||||
onToast,
|
||||
t,
|
||||
}: {
|
||||
canApprove: boolean;
|
||||
canCancel: boolean;
|
||||
busy: boolean;
|
||||
approve: () => Promise<boolean>;
|
||||
cancel: () => Promise<boolean>;
|
||||
onActionDone: () => void;
|
||||
onToast: (text: string) => void;
|
||||
t: Translate;
|
||||
}) {
|
||||
if (!canApprove && !canCancel) return null;
|
||||
const run = async (fn: () => Promise<boolean>) => {
|
||||
if (await fn()) {
|
||||
onActionDone();
|
||||
onToast(t("actionDone"));
|
||||
}
|
||||
if (await fn()) onActionDone();
|
||||
};
|
||||
return (
|
||||
<Section title={t("drawerActions")}>
|
||||
<div className="flex gap-2">
|
||||
{canApprove && (
|
||||
<button
|
||||
className="text-xs rounded border border-success px-2 py-1 disabled:opacity-50"
|
||||
className="text-xs rounded border border-success px-2 py-1"
|
||||
onClick={() => run(approve)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("actionApprove")}
|
||||
</button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button
|
||||
className="text-xs rounded border border-error px-2 py-1 disabled:opacity-50"
|
||||
className="text-xs rounded border border-error px-2 py-1"
|
||||
onClick={() => run(cancel)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("actionCancel")}
|
||||
</button>
|
||||
@@ -309,42 +200,14 @@ function DrawerActions({
|
||||
);
|
||||
}
|
||||
|
||||
/** Closes the drawer on Escape while `node` is set. Rebinds by id, not by object
|
||||
* identity, so a fresh `node` reference for the same task (e.g. a refetch) does not
|
||||
* tear down and re-add the listener. */
|
||||
/** Closes the drawer on Escape while `node` is set. */
|
||||
function useCloseOnEscape(node: OrchNode | null, onClose: () => void) {
|
||||
const nodeId = node?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!nodeId) return;
|
||||
if (!node) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [nodeId, onClose]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local, self-clearing toast state. `showToast` starts the timer synchronously in the
|
||||
* same handler that sets the message (button onClick / async action callback) — never
|
||||
* inside an effect body — so the only thing the unmount effect does is clear a pending
|
||||
* timer, with no setState call of its own (keeps `react-hooks/set-state-in-effect` clean).
|
||||
*/
|
||||
function useDrawerToast() {
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const showToast = (text: string) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setToast(text);
|
||||
timerRef.current = setTimeout(() => setToast(null), TOAST_MS);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { toast, showToast };
|
||||
}, [node, onClose]);
|
||||
}
|
||||
|
||||
export function OrchestrationDrawer({
|
||||
@@ -357,14 +220,14 @@ export function OrchestrationDrawer({
|
||||
onActionDone: () => void;
|
||||
}) {
|
||||
const t = useTranslations("orchestration");
|
||||
const { detail, isLoading, busy, error, errorKind, canApprove, canCancel, approve, cancel } =
|
||||
const { detail, isLoading, error, canApprove, canCancel, approve, cancel } =
|
||||
useDrawerDetail(node);
|
||||
useCloseOnEscape(node, onClose);
|
||||
const { toast, showToast } = useDrawerToast();
|
||||
|
||||
if (!node) return null;
|
||||
const state = node.state ?? "queued";
|
||||
const { ca, a2a } = narrowDetail(node, detail);
|
||||
const ca = node.source === "cloud-agent" ? (detail as CloudAgentTask | null) : null;
|
||||
const a2a = node.source === "a2a" ? (detail as A2ATask | null) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -374,24 +237,16 @@ export function OrchestrationDrawer({
|
||||
role="dialog"
|
||||
aria-label={node.label}
|
||||
>
|
||||
<DrawerHeader
|
||||
node={node}
|
||||
detail={detail}
|
||||
state={state}
|
||||
t={t}
|
||||
onClose={onClose}
|
||||
onToast={showToast}
|
||||
/>
|
||||
<DrawerHeader node={node} state={state} t={t} onClose={onClose} />
|
||||
|
||||
<DrawerBanners
|
||||
toast={toast}
|
||||
error={error}
|
||||
errorKind={errorKind}
|
||||
isLoading={isLoading}
|
||||
t={t}
|
||||
/>
|
||||
{error && <div className="text-xs text-error mb-3">{t("actionFailed", { error })}</div>}
|
||||
{isLoading && <div className="text-xs text-muted mb-3">…</div>}
|
||||
|
||||
<DrawerObjective node={node} ca={ca} a2a={a2a} t={t} />
|
||||
<Section title={t("drawerObjective")}>
|
||||
<p className="text-xs break-words">
|
||||
{ca?.prompt ?? a2a?.input?.messages[0]?.content ?? node.sublabel ?? node.label}
|
||||
</p>
|
||||
</Section>
|
||||
<Section title={t("drawerTimeline")}>
|
||||
<Timeline node={node} detail={detail} />
|
||||
</Section>
|
||||
@@ -400,11 +255,9 @@ export function OrchestrationDrawer({
|
||||
<DrawerActions
|
||||
canApprove={canApprove}
|
||||
canCancel={canCancel}
|
||||
busy={busy}
|
||||
approve={approve}
|
||||
cancel={cancel}
|
||||
onActionDone={onActionDone}
|
||||
onToast={showToast}
|
||||
t={t}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
@@ -81,12 +81,6 @@ function deriveActionAvailability(route: SourceRoute | null, node: OrchNode | nu
|
||||
return { canApprove, canCancel };
|
||||
}
|
||||
|
||||
/** Origin-tagged detail error, so the drawer can pick `detailFailed` vs `actionFailed` honestly. */
|
||||
export interface DrawerError {
|
||||
kind: "detail" | "action";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets `detail`/`error`/`isLoading` during render when the selected node
|
||||
* identity changes — React's documented "adjust state when a prop changes"
|
||||
@@ -96,7 +90,7 @@ function useSyncedNodeIdentity(
|
||||
node: OrchNode | null,
|
||||
route: SourceRoute | null,
|
||||
setDetail: (d: unknown | null) => void,
|
||||
setError: (e: DrawerError | null) => void,
|
||||
setError: (e: string | null) => void,
|
||||
setIsLoading: (b: boolean) => void
|
||||
) {
|
||||
const [syncedId, setSyncedId] = useState<string | undefined>(undefined);
|
||||
@@ -120,7 +114,7 @@ function useFetchDetail(
|
||||
node: OrchNode | null,
|
||||
route: SourceRoute | null,
|
||||
setDetail: (d: unknown | null) => void,
|
||||
setDetailError: (text: string) => void,
|
||||
setError: (e: string | null) => void,
|
||||
setIsLoading: (b: boolean) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
@@ -130,7 +124,7 @@ function useFetchDetail(
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
|
||||
.then((body) => setDetail(unwrapDetailBody(node.id, body)))
|
||||
.catch((err) => {
|
||||
if (!controller.signal.aborted) setDetailError(toSafeErrorText(err));
|
||||
if (!controller.signal.aborted) setError(toSafeErrorText(err));
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
return () => controller.abort();
|
||||
@@ -140,7 +134,7 @@ function useFetchDetail(
|
||||
|
||||
async function performAction(
|
||||
req: { url: string; init: RequestInit } | null,
|
||||
setActionError: (text: string) => void
|
||||
setError: (e: string | null) => void
|
||||
): Promise<boolean> {
|
||||
if (!req) return false;
|
||||
try {
|
||||
@@ -148,7 +142,7 @@ async function performAction(
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setActionError(toSafeErrorText(err));
|
||||
setError(toSafeErrorText(err));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -156,37 +150,21 @@ async function performAction(
|
||||
export function useDrawerDetail(node: OrchNode | null) {
|
||||
const [detail, setDetail] = useState<unknown | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setErrorState] = useState<DrawerError | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const route = node ? routeFor(node) : null;
|
||||
|
||||
const setDetailError = (text: string) => setErrorState({ kind: "detail", text });
|
||||
const setActionError = (text: string) => setErrorState({ kind: "action", text });
|
||||
|
||||
useSyncedNodeIdentity(node, route, setDetail, setErrorState, setIsLoading);
|
||||
useFetchDetail(node, route, setDetail, setDetailError, setIsLoading);
|
||||
useSyncedNodeIdentity(node, route, setDetail, setError, setIsLoading);
|
||||
useFetchDetail(node, route, setDetail, setError, setIsLoading);
|
||||
|
||||
const { canApprove, canCancel } = deriveActionAvailability(route, node);
|
||||
|
||||
const runAction = async (req: { url: string; init: RequestInit } | null): Promise<boolean> => {
|
||||
if (busy) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
return await performAction(req, setActionError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
detail,
|
||||
isLoading,
|
||||
busy,
|
||||
error: error?.text ?? null,
|
||||
errorKind: error?.kind ?? null,
|
||||
error,
|
||||
canApprove,
|
||||
canCancel,
|
||||
approve: () => runAction(route?.approveReq ?? null),
|
||||
cancel: () => runAction(route?.cancelReq ?? null),
|
||||
approve: () => performAction(route?.approveReq ?? null, setError),
|
||||
cancel: () => performAction(route?.cancelReq ?? null, setError),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Custom particle-stream edge for the Orchestration Canvas — replaces xyflow's built-in
|
||||
* `animated: true` marching-ants (perf cost at scale, see
|
||||
* .agents/skills/flow-studio/references/animated-edges.md §1) with the §3 "particle stream"
|
||||
* recipe: staggered SMIL `<ellipse>` shapes traveling the edge's own bezier path. Two hard
|
||||
* rules from that recipe (both real defects, kept verbatim): the opacity gate (`opacity="0"`
|
||||
* + a paired `<set>`) prevents a parked particle flashing at the SVG origin before its
|
||||
* `begin` fires, and clock offsets must be plain seconds — `begin="id.begin"` syncbase
|
||||
* references silently never fire once mounted inside React.
|
||||
*/
|
||||
"use client";
|
||||
import { memo } from "react";
|
||||
import { BaseEdge, getBezierPath, type EdgeProps } from "@xyflow/react";
|
||||
import { orchStateColor, type OrchState } from "../model/orchestrationTypes";
|
||||
|
||||
interface StatusEdgeData {
|
||||
state?: OrchState;
|
||||
active?: boolean;
|
||||
mirror?: boolean;
|
||||
}
|
||||
|
||||
/** Mesma precedência do edgeStyle da v1: failed > active > succeeded > idle. */
|
||||
function strokeFor(d: StatusEdgeData): { stroke: string; strokeWidth: number; opacity: number } {
|
||||
if (d.state === "failed")
|
||||
return { stroke: orchStateColor("failed"), strokeWidth: 2, opacity: 0.85 };
|
||||
if (d.active) return { stroke: orchStateColor("succeeded"), strokeWidth: 2.5, opacity: 1 };
|
||||
if (d.state === "succeeded")
|
||||
return { stroke: orchStateColor("succeeded"), strokeWidth: 1.5, opacity: 0.4 };
|
||||
return { stroke: "var(--color-text-muted)", strokeWidth: 1, opacity: 0.3 };
|
||||
}
|
||||
|
||||
const PARTICLES = 3;
|
||||
const DUR = 2.4;
|
||||
|
||||
function StatusEdgeImpl(props: EdgeProps) {
|
||||
const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition } = props;
|
||||
const data = (props.data ?? {}) as StatusEdgeData;
|
||||
const [path] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
const s = strokeFor(data);
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={path}
|
||||
style={{ ...s, ...(data.mirror ? { strokeDasharray: "6 4" } : {}) }}
|
||||
/>
|
||||
{data.active &&
|
||||
Array.from({ length: PARTICLES }, (_, i) => (
|
||||
<ellipse
|
||||
key={i}
|
||||
className="orch-edge-particle"
|
||||
rx="3.4"
|
||||
ry="2.2"
|
||||
fill={s.stroke}
|
||||
opacity="0"
|
||||
>
|
||||
<animateMotion
|
||||
dur={`${DUR}s`}
|
||||
begin={`${(i * DUR) / PARTICLES}s`}
|
||||
repeatCount="indefinite"
|
||||
path={path}
|
||||
rotate="auto"
|
||||
calcMode="spline"
|
||||
keyPoints="0;1"
|
||||
keyTimes="0;1"
|
||||
keySplines="0.4 0 0.6 1"
|
||||
/>
|
||||
<set attributeName="opacity" to="1" begin={`${(i * DUR) / PARTICLES}s`} fill="freeze" />
|
||||
</ellipse>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const StatusEdge = memo(StatusEdgeImpl);
|
||||
StatusEdge.displayName = "StatusEdge";
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
/**
|
||||
* Polls the 3 agent sources (allSettled), listens to the `agents` WS channel as a refetch
|
||||
* trigger, and relaxes the poll interval from 5s to 30s while that WS connection is up (the
|
||||
* channel event still forces an immediate debounced refetch either way).
|
||||
*/
|
||||
/** Polls the 3 agent sources (allSettled), listens to the `requests` WS channel as a refetch trigger. */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLiveDashboard } from "@/hooks/useLiveDashboard";
|
||||
import type { CloudAgentTask } from "@/lib/cloudAgent/types";
|
||||
@@ -16,7 +12,6 @@ import { mergeSnapshot } from "../model/mergeSnapshot";
|
||||
import type { OrchSnapshot, SourceStatus } from "../model/orchestrationTypes";
|
||||
|
||||
export const POLL_MS = 5_000;
|
||||
export const POLL_MS_WS_CONNECTED = 30_000;
|
||||
export const WS_REFETCH_DEBOUNCE_MS = 1_000;
|
||||
|
||||
interface Raw {
|
||||
@@ -36,29 +31,6 @@ async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cheap structural fingerprint of the fields that actually affect rendering.
|
||||
* `polledAt`/`raw` change on every 5s poll even when nothing meaningful moved,
|
||||
* which would otherwise re-mint every node/edge array each tick and defeat the
|
||||
* `React.memo` on the canvas node components. Exported for the test.
|
||||
*/
|
||||
export function snapshotContentKey(s: OrchSnapshot): string {
|
||||
return JSON.stringify([
|
||||
s.nodes.map((n) => [
|
||||
n.id,
|
||||
n.state,
|
||||
n.updatedAt,
|
||||
n.label,
|
||||
n.sublabel,
|
||||
n.cost,
|
||||
n.counts,
|
||||
n.sourceIssue,
|
||||
]),
|
||||
s.edges.map((e) => [e.id, e.active]),
|
||||
s.sources,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Builds the 3-source status list from a `Promise.allSettled` triple. */
|
||||
function buildSourceStatuses(
|
||||
ca: PromiseSettledResult<{ data: CloudAgentTask[] }>,
|
||||
@@ -140,7 +112,9 @@ export function useOrchestrationSnapshot() {
|
||||
|
||||
pollRef.current = () => void poll();
|
||||
void poll();
|
||||
const id = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
controller.abort();
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
@@ -153,10 +127,10 @@ export function useOrchestrationSnapshot() {
|
||||
pollRef.current();
|
||||
}, []);
|
||||
|
||||
const { connection } = useLiveDashboard({
|
||||
channels: ["agents"],
|
||||
useLiveDashboard({
|
||||
channels: ["requests"],
|
||||
onEvent: (payload) => {
|
||||
if (payload.channel !== "agents") return;
|
||||
if (payload.channel !== "requests") return;
|
||||
if (debounceRef.current) return; // debounce burst → one refetch
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
@@ -165,17 +139,6 @@ export function useOrchestrationSnapshot() {
|
||||
},
|
||||
});
|
||||
|
||||
// Adaptive poll interval, separated from the mount effect above: a connected `agents` WS
|
||||
// already pushes refetches on change, so the background poll only needs to be a slow safety
|
||||
// net (30s) — it falls back to the tighter 5s cadence while the WS is down. Declared AFTER
|
||||
// the mount effect so `pollRef.current` is already populated (its initial value is a safe
|
||||
// no-op) by the time this effect's first tick can fire.
|
||||
const wsConnected = connection.isConnected;
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => pollRef.current(), wsConnected ? POLL_MS_WS_CONNECTED : POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [wsConnected]);
|
||||
|
||||
const snapshot: OrchSnapshot = useMemo(
|
||||
() =>
|
||||
mergeSnapshot(
|
||||
@@ -190,19 +153,5 @@ export function useOrchestrationSnapshot() {
|
||||
[raw, statuses, showCompleted, polledAt]
|
||||
);
|
||||
|
||||
// `polledAt` advances every poll tick and re-mints every node/edge array in
|
||||
// `snapshot` above even when nothing meaningful changed, which would defeat
|
||||
// the canvas node components' `React.memo`. Render-time-sync idiom (same
|
||||
// pattern as `useSyncedNodeIdentity` in `useDrawerDetail.ts`): only adopt the
|
||||
// freshly computed snapshot when its content key actually differs, so
|
||||
// `stableSnapshot` keeps referential identity across no-op ticks.
|
||||
const [syncedKey, setSyncedKey] = useState("");
|
||||
const [stableSnapshot, setStableSnapshot] = useState<OrchSnapshot>(snapshot);
|
||||
const key = snapshotContentKey(snapshot);
|
||||
if (key !== syncedKey) {
|
||||
setSyncedKey(key);
|
||||
setStableSnapshot(snapshot);
|
||||
}
|
||||
|
||||
return { snapshot: stableSnapshot, isLoading, showCompleted, setShowCompleted, refetch };
|
||||
return { snapshot, isLoading, showCompleted, setShowCompleted, refetch };
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Pure client-side filter over an OrchSnapshot — full-text search + state/source/provider chips.
|
||||
* No React, no side effects. Spec: _tasks/superpowers/specs/2026-08-30-orchestration-canvas-design.md §1/2.4
|
||||
*
|
||||
* Only `work` nodes are tested against the filter dimensions. `activity` nodes always follow
|
||||
* their parent work node (id = `${workId}:activity`) — they survive iff the parent does.
|
||||
* `orchestrator` / `source` / `overflow` nodes are always kept. SourceNode `counts` (and
|
||||
* overflow `droppedByState`) are NOT recalculated here — they keep showing the TRUE totals
|
||||
* even while the filter hides nodes from the canvas; only visibility is affected.
|
||||
*/
|
||||
import type { OrchNode, OrchSnapshot, OrchSource, OrchState } from "./orchestrationTypes";
|
||||
|
||||
export interface OrchFilter {
|
||||
q: string;
|
||||
states: ReadonlySet<OrchState>;
|
||||
sources: ReadonlySet<OrchSource>;
|
||||
providers: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export const EMPTY_FILTER: OrchFilter = {
|
||||
q: "",
|
||||
states: new Set(),
|
||||
sources: new Set(),
|
||||
providers: new Set(),
|
||||
};
|
||||
|
||||
export function isEmptyFilter(f: OrchFilter): boolean {
|
||||
return f.q === "" && f.states.size === 0 && f.sources.size === 0 && f.providers.size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider identity of a work node, or `null` when its source has no provider concept
|
||||
* (a2a, routing) or the raw payload doesn't carry one.
|
||||
*/
|
||||
export function nodeProviderKey(node: OrchNode): string | null {
|
||||
if (node.source === "cloud-agent") {
|
||||
return (node.raw as { providerId?: string } | undefined)?.providerId ?? null;
|
||||
}
|
||||
if (node.source === "conductor") {
|
||||
return (node.raw as { runner?: string | null } | undefined)?.runner ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Distinct non-null provider keys among the snapshot's work nodes, sorted. */
|
||||
export function collectProviderKeys(snap: OrchSnapshot): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const n of snap.nodes) {
|
||||
if (n.kind !== "work") continue;
|
||||
const key = nodeProviderKey(n);
|
||||
if (key !== null) keys.add(key);
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
function matchesWork(node: OrchNode, f: OrchFilter): boolean {
|
||||
if (f.q) {
|
||||
const haystack = `${node.label} ${node.sublabel ?? ""} ${node.id}`.toLowerCase();
|
||||
if (!haystack.includes(f.q.toLowerCase())) return false;
|
||||
}
|
||||
if (f.states.size > 0 && (!node.state || !f.states.has(node.state))) return false;
|
||||
if (f.sources.size > 0 && (!node.source || !f.sources.has(node.source))) return false;
|
||||
if (f.providers.size > 0) {
|
||||
const key = nodeProviderKey(node);
|
||||
if (key === null || !f.providers.has(key)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const ACTIVITY_SUFFIX = ":activity";
|
||||
|
||||
function activityParentId(id: string): string {
|
||||
return id.endsWith(ACTIVITY_SUFFIX) ? id.slice(0, -ACTIVITY_SUFFIX.length) : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a snapshot down to the nodes/edges matching `f` (all non-empty dimensions AND
|
||||
* together). Returns `snap` itself (same reference) when `f` is empty, so callers can memoize
|
||||
* on the previous result instead of re-rendering on every keystroke of a cleared search box.
|
||||
*/
|
||||
export function filterSnapshot(snap: OrchSnapshot, f: OrchFilter): OrchSnapshot {
|
||||
if (isEmptyFilter(f)) return snap;
|
||||
|
||||
const workSurvivors = new Set<string>();
|
||||
for (const n of snap.nodes) {
|
||||
if (n.kind === "work" && matchesWork(n, f)) workSurvivors.add(n.id);
|
||||
}
|
||||
|
||||
const nodes = snap.nodes.filter((n) => {
|
||||
if (n.kind === "work") return workSurvivors.has(n.id);
|
||||
if (n.kind === "activity") return workSurvivors.has(activityParentId(n.id));
|
||||
return true; // orchestrator, source, overflow always survive
|
||||
});
|
||||
const survivingIds = new Set(nodes.map((n) => n.id));
|
||||
const edges = snap.edges.filter((e) => survivingIds.has(e.from) && survivingIds.has(e.to));
|
||||
|
||||
return { ...snap, nodes, edges };
|
||||
}
|
||||
@@ -112,9 +112,7 @@ function overflowNodeForSource(
|
||||
// Additive: lets overviewProjection fold true per-state totals into its
|
||||
// counters even though these nodes no longer render on the canvas
|
||||
// (operator ruling — spec governs, counters must show TRUE totals).
|
||||
// Spread into a fresh object — sharing the `counts` reference is a mutation
|
||||
// footgun (a caller mutating either field silently corrupts the other).
|
||||
droppedByState: { ...counts },
|
||||
droppedByState: counts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,8 +177,6 @@ function buildRootAndSourceEdges(
|
||||
source: s.source,
|
||||
label: s.source,
|
||||
sublabel: s.offline ? "offline" : "error",
|
||||
sourceIssue: s.offline ? "offline" : "error",
|
||||
staleSince: s.staleSince,
|
||||
});
|
||||
sourceIds.add(`source:${s.source}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** OrchSnapshot → @xyflow nodes/edges with a deterministic shallow 3-layer layout. Pure. */
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { OrchNodeKind, OrchSnapshot, OrchSource } from "./orchestrationTypes";
|
||||
import { edgeStyle } from "@/shared/components/flow/edgeStyles";
|
||||
import type { OrchNodeKind, OrchSnapshot } from "./orchestrationTypes";
|
||||
|
||||
const LAYER_Y: Record<OrchNodeKind, number> = {
|
||||
orchestrator: 0,
|
||||
@@ -11,36 +12,13 @@ const LAYER_Y: Record<OrchNodeKind, number> = {
|
||||
};
|
||||
const X_GAP = 260;
|
||||
|
||||
export interface OrchestrationToFlowOptions {
|
||||
collapsed?: ReadonlySet<OrchSource>;
|
||||
}
|
||||
|
||||
export function orchestrationToFlow(
|
||||
snap: OrchSnapshot,
|
||||
opts?: OrchestrationToFlowOptions
|
||||
): {
|
||||
export function orchestrationToFlow(snap: OrchSnapshot): {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
fitKey: string;
|
||||
} {
|
||||
const collapsed = opts?.collapsed;
|
||||
const hasCollapsed = !!collapsed && collapsed.size > 0;
|
||||
|
||||
// Drop work/activity/overflow nodes whose source is collapsed BEFORE layout, so the
|
||||
// remaining nodes recenter into their layer instead of leaving gaps.
|
||||
const visibleNodes = hasCollapsed
|
||||
? snap.nodes.filter((n) => {
|
||||
if (n.kind !== "work" && n.kind !== "activity" && n.kind !== "overflow") return true;
|
||||
return !(n.source && collapsed!.has(n.source));
|
||||
})
|
||||
: snap.nodes;
|
||||
const visibleIds = hasCollapsed ? new Set(visibleNodes.map((n) => n.id)) : null;
|
||||
const visibleEdges = visibleIds
|
||||
? snap.edges.filter((e) => visibleIds.has(e.from) && visibleIds.has(e.to))
|
||||
: snap.edges;
|
||||
|
||||
const byLayer = new Map<number, string[]>();
|
||||
for (const n of [...visibleNodes].sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
for (const n of [...snap.nodes].sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
const y = LAYER_Y[n.kind];
|
||||
const ids = byLayer.get(y) ?? [];
|
||||
ids.push(n.id);
|
||||
@@ -52,34 +30,29 @@ export function orchestrationToFlow(
|
||||
ids.forEach((id, i) => pos.set(id, { x: i * X_GAP - width / 2, y }));
|
||||
}
|
||||
|
||||
const stateOf = new Map(visibleNodes.map((n) => [n.id, n.state]));
|
||||
const nodes: Node[] = visibleNodes.map((n) => {
|
||||
const isCollapsedSource = n.kind === "source" && !!n.source && !!collapsed?.has(n.source);
|
||||
const stateOf = new Map(snap.nodes.map((n) => [n.id, n.state]));
|
||||
const nodes: Node[] = snap.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.kind,
|
||||
position: pos.get(n.id)!,
|
||||
data: n as unknown as Record<string, unknown>,
|
||||
}));
|
||||
const edges: Edge[] = snap.edges.map((e) => {
|
||||
const target = stateOf.get(e.to);
|
||||
const style = edgeStyle(e.active, false, target === "failed", target === "succeeded");
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.kind,
|
||||
position: pos.get(n.id)!,
|
||||
data: (isCollapsedSource ? { ...n, collapsed: true } : n) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
id: e.id,
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
animated: e.active,
|
||||
style: e.kind === "mirror" ? { ...style, strokeDasharray: "6 4" } : style,
|
||||
};
|
||||
});
|
||||
const edges: Edge[] = visibleEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
type: "status",
|
||||
data: { state: stateOf.get(e.to), active: e.active, mirror: e.kind === "mirror" },
|
||||
}));
|
||||
|
||||
const workIdsKey = visibleNodes
|
||||
const fitKey = snap.nodes
|
||||
.filter((n) => n.kind === "work")
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join("|");
|
||||
const fitKey = hasCollapsed
|
||||
? `${workIdsKey}::collapsed=${[...collapsed!].sort().join(",")}`
|
||||
: workIdsKey;
|
||||
return { nodes, edges, fitKey };
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
* Pure domain vocabulary for the Orchestration Canvas — no React, no side effects.
|
||||
* Spec: _tasks/superpowers/specs/2026-08-30-orchestration-canvas-design.md
|
||||
*/
|
||||
import { STATUS_HEX } from "@/shared/constants/statusColors";
|
||||
|
||||
export type OrchState =
|
||||
"queued" | "running" | "waiting_approval" | "succeeded" | "failed" | "cancelled";
|
||||
export type OrchSource = "cloud-agent" | "a2a" | "conductor" | "routing";
|
||||
export type OrchNodeKind = "orchestrator" | "source" | "work" | "activity" | "overflow";
|
||||
// SourceNode only: why a source placeholder was materialized — replaces the
|
||||
// magic-string comparison against `sublabel` ("error"/"offline") with a typed union.
|
||||
export type SourceIssue = "error" | "offline";
|
||||
|
||||
export interface OrchNode {
|
||||
id: string; // `${source}:${sourceId}` for work nodes
|
||||
@@ -30,16 +28,6 @@ export interface OrchNode {
|
||||
droppedByState?: Partial<Record<OrchState, number>>;
|
||||
mirrorOf?: string;
|
||||
raw?: unknown;
|
||||
// SourceNode only: set to `true` by orchestrationToFlow's `opts.collapsed` when this
|
||||
// source is currently collapsed by the operator. Never set on any other node kind.
|
||||
collapsed?: boolean;
|
||||
// SourceNode only: set by mergeSnapshot's buildRootAndSourceEdges placeholder for a
|
||||
// failed/offline source. `sublabel` still carries the same value for display compat.
|
||||
sourceIssue?: SourceIssue;
|
||||
// SourceNode only: ISO timestamp mirrored from the originating SourceStatus.staleSince
|
||||
// (set only for `sourceIssue === "error"` placeholders — buildSourceStatuses never sets
|
||||
// it for the `offline` case). Feeds SourceNode's `sourceStale` ICU message.
|
||||
staleSince?: string;
|
||||
}
|
||||
|
||||
export interface OrchEdge {
|
||||
@@ -74,25 +62,17 @@ export const ORCH_STATES = [
|
||||
"cancelled",
|
||||
] as const satisfies readonly OrchState[];
|
||||
|
||||
// Theme-aware CSS custom properties (light values in `:root`, dark values in `.dark`
|
||||
// of src/app/globals.css) — replaces the previous fixed STATUS_HEX lookup so the
|
||||
// canvas status colors adapt to the active theme instead of always rendering dark-mode hex.
|
||||
const STATE_VAR: Record<OrchState, string> = {
|
||||
queued: "var(--orch-status-muted)",
|
||||
running: "var(--orch-status-warning)",
|
||||
waiting_approval: "var(--orch-status-approval)",
|
||||
succeeded: "var(--orch-status-success)",
|
||||
failed: "var(--orch-status-error)",
|
||||
cancelled: "var(--orch-status-muted)",
|
||||
const STATE_HEX: Record<OrchState, string> = {
|
||||
queued: STATUS_HEX.muted,
|
||||
running: STATUS_HEX.warning,
|
||||
waiting_approval: STATUS_HEX.approval,
|
||||
succeeded: STATUS_HEX.success,
|
||||
failed: STATUS_HEX.error,
|
||||
cancelled: STATUS_HEX.muted,
|
||||
};
|
||||
|
||||
export function orchStateColor(state: OrchState): string {
|
||||
return STATE_VAR[state];
|
||||
}
|
||||
|
||||
/** Fundo de badge com alpha — hex+"20" não funciona com var(); color-mix sim. */
|
||||
export function orchStateBadgeBg(state: OrchState): string {
|
||||
return `color-mix(in srgb, ${STATE_VAR[state]} 13%, transparent)`;
|
||||
return STATE_HEX[state];
|
||||
}
|
||||
|
||||
export const STALE_COMPLETED_MS = 600_000; // completed >10 min ago drop out of the live view
|
||||
|
||||
@@ -18,4 +18,3 @@ function ActivityNodeImpl({ data }: { data: OrchNode }) {
|
||||
}
|
||||
|
||||
export const ActivityNode = memo(ActivityNodeImpl);
|
||||
ActivityNode.displayName = "ActivityNode";
|
||||
|
||||
@@ -17,4 +17,3 @@ function OrchestratorNodeImpl({ data }: { data: OrchNode }) {
|
||||
}
|
||||
|
||||
export const OrchestratorNode = memo(OrchestratorNodeImpl);
|
||||
OrchestratorNode.displayName = "OrchestratorNode";
|
||||
|
||||
@@ -27,4 +27,3 @@ function OverflowNodeImpl({ data }: { data: OrchNode }) {
|
||||
}
|
||||
|
||||
export const OverflowNode = memo(OverflowNodeImpl);
|
||||
OverflowNode.displayName = "OverflowNode";
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
import { memo } from "react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ORCH_STATES,
|
||||
orchStateColor,
|
||||
orchStateBadgeBg,
|
||||
type OrchNode,
|
||||
} from "../model/orchestrationTypes";
|
||||
import { ORCH_STATES, orchStateColor, type OrchNode } from "../model/orchestrationTypes";
|
||||
const HANDLE = "!bg-transparent !border-0 !w-0 !h-0";
|
||||
const LABEL_KEY: Record<string, string> = {
|
||||
"cloud-agent": "sourceCloudAgent",
|
||||
@@ -17,29 +12,18 @@ const LABEL_KEY: Record<string, string> = {
|
||||
|
||||
function SourceNodeImpl({ data }: { data: OrchNode }) {
|
||||
const t = useTranslations("orchestration");
|
||||
const stale = data.sourceIssue === "error"; // set by mergeSnapshot for failed sources
|
||||
const collapsed = !!data.collapsed; // set by orchestrationToFlow's opts.collapsed
|
||||
const stale = data.sublabel === "error"; // set by mergeSnapshot for failed sources
|
||||
const label = data.source && LABEL_KEY[data.source] ? t(LABEL_KEY[data.source]) : data.label;
|
||||
// Formatting a prop, not sampling the clock during render (react-hooks/purity) —
|
||||
// `data.staleSince` is a snapshot value set once by mergeSnapshot, not `Date.now()`.
|
||||
const since =
|
||||
data.staleSince && Number.isFinite(Date.parse(data.staleSince))
|
||||
? new Date(data.staleSince).toLocaleTimeString()
|
||||
: "—";
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border bg-surface px-3 py-2 min-w-[150px] ${stale ? "opacity-70 border-warning" : "border-border"}`}
|
||||
aria-label={label}
|
||||
aria-expanded={!collapsed}
|
||||
title={t(collapsed ? "sourceExpand" : "sourceCollapse")}
|
||||
>
|
||||
<div className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<span aria-hidden>{collapsed ? "▸" : "▾"}</span>
|
||||
{stale && <span aria-hidden>⚠</span>}
|
||||
{label}
|
||||
</div>
|
||||
{stale && <div className="text-[10px] text-warning">{t("sourceStale", { since })}</div>}
|
||||
{data.sourceIssue === "offline" && (
|
||||
{data.sublabel === "offline" && (
|
||||
<div className="text-[10px] text-muted">{t("sourceOffline")}</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
@@ -47,7 +31,7 @@ function SourceNodeImpl({ data }: { data: OrchNode }) {
|
||||
<span
|
||||
key={s}
|
||||
className="text-[9px] px-1.5 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: orchStateBadgeBg(s), color: orchStateColor(s) }}
|
||||
style={{ backgroundColor: `${orchStateColor(s)}20`, color: orchStateColor(s) }}
|
||||
>
|
||||
{data.counts?.[s]}
|
||||
</span>
|
||||
@@ -60,4 +44,3 @@ function SourceNodeImpl({ data }: { data: OrchNode }) {
|
||||
}
|
||||
|
||||
export const SourceNode = memo(SourceNodeImpl);
|
||||
SourceNode.displayName = "SourceNode";
|
||||
|
||||
@@ -43,4 +43,3 @@ function WorkNodeImpl({ data }: { data: OrchNode }) {
|
||||
}
|
||||
|
||||
export const WorkNode = memo(WorkNodeImpl);
|
||||
WorkNode.displayName = "WorkNode";
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { Suspense } from "react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Metadata } from "next";
|
||||
import OrchestrationPageClient from "./OrchestrationPageClient";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("orchestration");
|
||||
return { title: t("title"), description: t("description") };
|
||||
}
|
||||
|
||||
export const metadata: Metadata = { title: "Orchestration — OmniRoute" };
|
||||
export default function OrchestrationPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<OrchestrationPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
return <OrchestrationPageClient />;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import type { NodeTypes, EdgeTypes, NodeMouseHandler } from "@xyflow/react";
|
||||
import type { NodeTypes, NodeMouseHandler } from "@xyflow/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { FlowCanvas } from "@/shared/components/flow/FlowCanvas";
|
||||
import { orchestrationToFlow } from "../model/orchestrationToFlow";
|
||||
import type { OrchNode, OrchSnapshot, OrchSource } from "../model/orchestrationTypes";
|
||||
import type { OrchSnapshot } from "../model/orchestrationTypes";
|
||||
import { OrchestratorNode } from "../nodes/OrchestratorNode";
|
||||
import { SourceNode } from "../nodes/SourceNode";
|
||||
import { WorkNode } from "../nodes/WorkNode";
|
||||
import { ActivityNode } from "../nodes/ActivityNode";
|
||||
import { OverflowNode } from "../nodes/OverflowNode";
|
||||
import { StatusEdge } from "../edges/StatusEdge";
|
||||
|
||||
const NODE_TYPES: NodeTypes = {
|
||||
orchestrator: OrchestratorNode as never,
|
||||
@@ -20,40 +19,22 @@ const NODE_TYPES: NodeTypes = {
|
||||
activity: ActivityNode as never,
|
||||
overflow: OverflowNode as never,
|
||||
};
|
||||
const EDGE_TYPES: EdgeTypes = { status: StatusEdge as never };
|
||||
|
||||
// Stable empty-set reference — avoids re-minting a Set every render when the caller
|
||||
// doesn't pass `collapsed` (e.g. pre-A6 callers/tests), so orchestrationToFlow's memo
|
||||
// doesn't invalidate on every render.
|
||||
const EMPTY_COLLAPSED: ReadonlySet<OrchSource> = new Set();
|
||||
|
||||
export function AgentsTab({
|
||||
snapshot,
|
||||
onNodeClick,
|
||||
showCompleted,
|
||||
onToggleCompleted,
|
||||
collapsed = EMPTY_COLLAPSED,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
snapshot: OrchSnapshot;
|
||||
onNodeClick: (orchNodeId: string) => void;
|
||||
showCompleted: boolean;
|
||||
onToggleCompleted: (v: boolean) => void;
|
||||
collapsed?: ReadonlySet<OrchSource>;
|
||||
onToggleCollapse?: (s: OrchSource) => void;
|
||||
}) {
|
||||
const t = useTranslations("orchestration");
|
||||
const { nodes, edges, fitKey } = useMemo(
|
||||
() => orchestrationToFlow(snapshot, { collapsed }),
|
||||
[snapshot, collapsed]
|
||||
);
|
||||
const { nodes, edges, fitKey } = useMemo(() => orchestrationToFlow(snapshot), [snapshot]);
|
||||
const hasWork = snapshot.nodes.some((n) => n.kind === "work");
|
||||
const handleClick: NodeMouseHandler = (_e, node) => {
|
||||
if (node.type === "source") {
|
||||
const source = (node.data as unknown as OrchNode).source;
|
||||
if (source) onToggleCollapse?.(source);
|
||||
return;
|
||||
}
|
||||
if (node.type === "work" || node.type === "activity" || node.type === "overflow")
|
||||
onNodeClick(node.id);
|
||||
};
|
||||
@@ -90,7 +71,6 @@ export function AgentsTab({
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
fitKey={fitKey}
|
||||
onNodeClick={handleClick}
|
||||
className="h-full"
|
||||
|
||||
@@ -22,7 +22,6 @@ const STATE_KEY: Record<OrchState, string> = {
|
||||
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
if (!Number.isFinite(ms)) return "—";
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ type AdaptaTutorialModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// The Adapta CTA href used to go through our own link.omniroute.online shortener
|
||||
// for click metrics, but that domain no longer resolves after the move to
|
||||
// omniskill.online, so href and visible text are the real destination again.
|
||||
// The Adapta CTA href points at https://link.omniroute.online/adapta (our own
|
||||
// shortener, the `adapta` slug) so the click lands in our Kutt metrics. The visible
|
||||
// link text intentionally stays the real domain (agent.adapta.one/agentic-chat) so
|
||||
// users still see where they are going.
|
||||
export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProps) {
|
||||
const t = useTranslations("providers.adaptaTutorial");
|
||||
|
||||
@@ -32,7 +33,7 @@ export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProp
|
||||
<p className="text-text-muted mt-0.5">
|
||||
{t("step1DescPrefix")}{" "}
|
||||
<a
|
||||
href="https://agent.adapta.one/agentic-chat"
|
||||
href="https://link.omniroute.online/adapta"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, Suspense } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Card, CardSkeleton, Badge, Button, CollapsibleSection } from "@/shared/components";
|
||||
import {
|
||||
AGGREGATOR_PROVIDER_IDS,
|
||||
@@ -211,7 +211,7 @@ async function loadOauthEnvRepairStatus(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
function ProvidersPageContent() {
|
||||
export default function ProvidersPage() {
|
||||
const router = useRouter();
|
||||
const [connections, setConnections] = useState<any[]>([]);
|
||||
const [providerNodes, setProviderNodes] = useState<any[]>([]);
|
||||
@@ -1895,14 +1895,6 @@ function ProvidersPageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ProvidersPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Provider Test Results View (mirrors combo TestResultsView) ──────────────
|
||||
|
||||
function ProviderTestResultsView({ results }: { results: ProviderBatchTestResults }) {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
function RuleItem({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<li className="flex items-start gap-2">
|
||||
<span aria-hidden="true" className="mt-0.5 shrink-0 text-green-400">
|
||||
✓
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** D32 — normative access, safety, and privacy rules shown before opt-in. */
|
||||
export function RadarAccessExplainer() {
|
||||
const t = useTranslations("radarPage");
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="radar-access-scale-title"
|
||||
className="w-full rounded-xl border border-border bg-violet-500/5 p-4 text-left sm:p-5"
|
||||
>
|
||||
<h3 id="radar-access-scale-title" className="text-base font-semibold text-text-main">
|
||||
{t("accessScaleTitle")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-text-muted">{t("accessScaleIntro")}</p>
|
||||
|
||||
<ul className="mt-4 grid gap-3 text-sm text-text-muted md:grid-cols-2">
|
||||
<RuleItem>{t("accessCommunityRule")}</RuleItem>
|
||||
<RuleItem>{t("accessSingleUseRule")}</RuleItem>
|
||||
<RuleItem>{t("accessContributorRule")}</RuleItem>
|
||||
<RuleItem>{t("accessSupporterRule")}</RuleItem>
|
||||
</ul>
|
||||
|
||||
<div className="mt-5 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
|
||||
<section aria-labelledby="radar-access-use-title">
|
||||
<h4 id="radar-access-use-title" className="text-sm font-semibold text-text-main">
|
||||
{t("accessUseTitle")}
|
||||
</h4>
|
||||
<ul className="mt-2 flex flex-col gap-2 text-sm text-text-muted">
|
||||
<RuleItem>{t("accessInstallationRule")}</RuleItem>
|
||||
<RuleItem>{t("accessAbuseRule")}</RuleItem>
|
||||
<RuleItem>{t("accessOffersRule")}</RuleItem>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="radar-privacy-title">
|
||||
<h4 id="radar-privacy-title" className="text-sm font-semibold text-text-main">
|
||||
{t("privacyTitle")}
|
||||
</h4>
|
||||
<ul className="mt-2 flex flex-col gap-2 text-sm text-text-muted">
|
||||
<RuleItem>{t("privacyDownloadsRule")}</RuleItem>
|
||||
<RuleItem>{t("privacySendsRule")}</RuleItem>
|
||||
<RuleItem>{t("privacyNeverRule")}</RuleItem>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { notFound } from "next/navigation";
|
||||
import { Card } from "@/shared/components";
|
||||
import { shouldAutoSyncOnOpen } from "@/lib/radar/autoSync";
|
||||
import { isValidSupporterKeyFormat } from "@/lib/radar/supporterKey";
|
||||
import { RadarAccessExplainer } from "./RadarAccessExplainer";
|
||||
import { RadarCatalogTable, type RadarMergedEntry } from "./RadarCatalogTable";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -391,45 +390,24 @@ export default function RadarPage() {
|
||||
{/* Opt-in pending */}
|
||||
{pageState === "optin_pending" && (
|
||||
<Card>
|
||||
<div className="mx-auto flex max-w-5xl flex-col items-center gap-6 py-8 text-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="material-symbols-outlined text-4xl text-violet-400"
|
||||
>
|
||||
radar
|
||||
</span>
|
||||
<div className="flex flex-col items-center gap-6 py-8 text-center max-w-lg mx-auto">
|
||||
<div className="text-4xl">📡</div>
|
||||
<h2 className="text-xl font-semibold">{t("activateTitle")}</h2>
|
||||
<p className="max-w-2xl text-text-muted">{t("activateDescription")}</p>
|
||||
|
||||
<RadarAccessExplainer />
|
||||
|
||||
{/* F4/T7 — ways to obtain a supporter key. These conditions
|
||||
intentionally precede both activation actions (D32). */}
|
||||
{contributorClaimUrl && supporterPlansUrl && (
|
||||
<div className="flex w-full flex-col gap-3 rounded-xl border border-border p-4">
|
||||
<p className="text-sm font-medium">{t("claimSectionTitle")}</p>
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row">
|
||||
<a
|
||||
href={contributorClaimUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 rounded-lg border border-violet-500 px-4 py-2 text-center text-sm font-medium text-violet-400 transition-colors hover:bg-violet-500/10"
|
||||
>
|
||||
{t("contributorButton")}
|
||||
</a>
|
||||
<a
|
||||
href={supporterPlansUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 rounded-lg border border-violet-500 px-4 py-2 text-center text-sm font-medium text-violet-400 transition-colors hover:bg-violet-500/10"
|
||||
>
|
||||
{t("supporterButton")}
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-left text-xs text-text-muted">{t("contributorHint")}</p>
|
||||
<p className="text-left text-xs text-text-muted">{t("supporterHint")}</p>
|
||||
<p className="text-text-muted">{t("activateDescription")}</p>
|
||||
<div className="flex flex-col gap-2 text-sm text-text-muted text-left w-full">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-green-400 mt-0.5">✓</span>
|
||||
<span>{t("privacyNoUpload")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-green-400 mt-0.5">✓</span>
|
||||
<span>{t("privacyOnlySigned")}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-green-400 mt-0.5">✓</span>
|
||||
<span>{t("privacyLocalOnly")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paste-key activation — primary path: pasting an already-obtained
|
||||
supporter key both sets it AND opts in (unlocks this screen).
|
||||
@@ -482,6 +460,35 @@ export default function RadarPage() {
|
||||
>
|
||||
{activating ? t("activating") : t("activateButton")}
|
||||
</button>
|
||||
|
||||
{/* F4/T7 — "get a supporter key" outbound links. Both open in a
|
||||
new tab; neither one carries a price/value (D14 — the
|
||||
only place pricing lives is the destination page). */}
|
||||
{contributorClaimUrl && supporterPlansUrl && (
|
||||
<div className="w-full pt-6 mt-2 border-t border-border flex flex-col gap-3">
|
||||
<p className="text-sm font-medium">{t("claimSectionTitle")}</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full">
|
||||
<a
|
||||
href={contributorClaimUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-center rounded-lg border border-violet-500 text-violet-400 hover:bg-violet-500/10 transition-colors"
|
||||
>
|
||||
{t("contributorButton")}
|
||||
</a>
|
||||
<a
|
||||
href={supporterPlansUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-center rounded-lg border border-violet-500 text-violet-400 hover:bg-violet-500/10 transition-colors"
|
||||
>
|
||||
{t("supporterButton")}
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted text-left">{t("contributorHint")}</p>
|
||||
<p className="text-xs text-text-muted text-left">{t("supporterHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
@@ -48,7 +48,7 @@ function resolveText(text: RadarLocalizedText, locale: string): string {
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RadarSetupPageContent() {
|
||||
export default function RadarSetupPage() {
|
||||
const t = useTranslations("radarSetupPage");
|
||||
const locale = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -290,11 +290,3 @@ function RadarSetupPageContent() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RadarSetupPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<RadarSetupPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -307,27 +307,6 @@ function parseAgentrouter(data: any) {
|
||||
return quotaEntries(data).map(([quotaKey, quota]) => parseAgentrouterQuota(quotaKey, quota));
|
||||
}
|
||||
|
||||
// OpenRouter is credit-based, not subscription-based: the `credits` quota entry
|
||||
// (open-sse/services/usage/openrouter.ts) carries the account balance in
|
||||
// `remaining` + `currency: "USD"` with `unlimited: true` / total 0. The generic
|
||||
// path (normalizeQuotaEntry via parseGeneric) drops `currency` and never sets
|
||||
// `isCredits`/`creditCount`, so the row rendered as a meaningless "100% left"
|
||||
// instead of the dollar balance. Route it through buildCreditsQuota() (same
|
||||
// shape DeepSeek/AgentRouter credits rows use) so the credit count renders as
|
||||
// USD. Free-tier request windows keep the generic percentage treatment.
|
||||
function parseOpenrouterQuota(quotaKey: string, quota: any) {
|
||||
if (quotaKey !== "credits") return normalizeQuotaEntry(quotaKey, quota);
|
||||
const remaining = Math.max(0, Number(quota?.remaining ?? 0));
|
||||
const currency = quota?.currency || "USD";
|
||||
const remainingPercentage =
|
||||
safePercentage(quota?.remainingPercentage) ?? (remaining > 0 ? 100 : 0);
|
||||
return buildCreditsQuota("credits", remaining, remainingPercentage, { currency });
|
||||
}
|
||||
|
||||
function parseOpenrouter(data: any) {
|
||||
return quotaEntries(data).map(([quotaKey, quota]) => parseOpenrouterQuota(quotaKey, quota));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kilo Code quota parser. Personal balance keeps the credits-style USD row; the four raw Kilo Pass
|
||||
* quota keys (kiloPassBase/kiloPassBonus/kiloPassUsage/kiloPassRemaining) are collapsed into one
|
||||
@@ -443,7 +422,6 @@ function parseProviderQuotas(providerId: string, data: any) {
|
||||
if (providerId === "deepseek") return parseDeepseek(data);
|
||||
if (providerId === "kilocode") return parseKilocode(data);
|
||||
if (providerId === "agentrouter") return parseAgentrouter(data);
|
||||
if (providerId === "openrouter") return parseOpenrouter(data);
|
||||
return parseGeneric(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { RERANK_PROVIDERS } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import { getProviderCredentials } from "@/sse/services/auth";
|
||||
import {
|
||||
buildRerankProviderListing,
|
||||
mergeRerankProviderListings,
|
||||
} from "@/lib/memory/embedding/rerankListings";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
* GET /api/memory/rerank-providers
|
||||
*
|
||||
* Lists rerank providers with hasKey state for the memory Rerank selector:
|
||||
* curated RERANK_PROVIDERS entries first, then local provider_nodes.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const curated = [];
|
||||
for (const [providerId, config] of Object.entries(RERANK_PROVIDERS)) {
|
||||
let hasKey = false;
|
||||
try {
|
||||
const creds = await getProviderCredentials(providerId);
|
||||
hasKey = !!(
|
||||
creds &&
|
||||
!("allRateLimited" in creds && (creds as { allRateLimited?: boolean }).allRateLimited) &&
|
||||
((creds as { apiKey?: string | null }).apiKey ||
|
||||
(creds as { accessToken?: string | null }).accessToken)
|
||||
);
|
||||
} catch {
|
||||
hasKey = false;
|
||||
}
|
||||
curated.push(buildRerankProviderListing(providerId, config, hasKey));
|
||||
}
|
||||
|
||||
// Local rerank-capable provider_nodes appended after curated entries.
|
||||
const extra = [];
|
||||
try {
|
||||
const { getCachedProviderNodes } = await import("@/lib/localDb");
|
||||
const nodes = await getCachedProviderNodes();
|
||||
for (const n of Array.isArray(nodes) ? nodes : []) {
|
||||
const apiType = (n as { apiType?: string }).apiType || "";
|
||||
if (!["chat", "responses", "rerank"].includes(apiType)) continue;
|
||||
const prefix = (n as { prefix?: string }).prefix;
|
||||
const baseUrl = (n as { baseUrl?: string }).baseUrl;
|
||||
if (!prefix || !baseUrl) continue;
|
||||
extra.push({ provider: prefix, hasKey: true, models: [] });
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
return NextResponse.json({ providers: mergeRerankProviderListings(curated, extra) });
|
||||
} catch (err: unknown) {
|
||||
const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
return NextResponse.json({ error: { message } }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import pino from "pino";
|
||||
|
||||
const logger = pino({ name: "monitoring-compression-api" });
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/compression — Compression result-memo observability snapshot
|
||||
*
|
||||
* Exposes the in-process compression result-memo stats (size, capacity, hits,
|
||||
* misses, hitRate) so the cache-hit efficiency of the memoized compression
|
||||
* path can be tracked over HTTP. This is the observability companion to the
|
||||
* #7847 OOM mitigations: a low memo hit rate on deterministic (lite/standard/
|
||||
* rtk) modes signals repeated full-pipeline re-runs that the cache was meant
|
||||
* to eliminate.
|
||||
*
|
||||
* Lightweight (no DB, no provider reads) and intentionally distinct from the
|
||||
* heavier /api/monitoring/health snapshot so it can be polled more frequently.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { getMemoStats } = await import("@omniroute/open-sse/services/compression/index.ts");
|
||||
return NextResponse.json(
|
||||
{
|
||||
compression: {
|
||||
memo: getMemoStats(),
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "GET /api/monitoring/compression failed");
|
||||
return NextResponse.json(
|
||||
{ status: "error", error: "compression_stats_unavailable" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
EMBEDDING_PROVIDERS,
|
||||
type EmbeddingProvider,
|
||||
} from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
|
||||
export type EmbeddingModelOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build quick-select options from the curated embedding registry — one option
|
||||
* per registered model, provider-prefixed so the value matches what users type
|
||||
* elsewhere (e.g. "mistral/mistral-embed"). Providers without curated
|
||||
* models (dynamic-only, like lmstudio) contribute nothing.
|
||||
*/
|
||||
export function buildRegistryEmbeddingOptions(): EmbeddingModelOption[] {
|
||||
const options: EmbeddingModelOption[] = [];
|
||||
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS) as Array<
|
||||
[string, EmbeddingProvider]
|
||||
>) {
|
||||
for (const model of config.models) {
|
||||
const value = `${providerId}/${model.id}`;
|
||||
const dims = typeof model.dimensions === "number" ? `${model.dimensions}d` : "?";
|
||||
options.push({ value, label: `${value} - ${model.name} (${dims})` });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge heuristic/live-discovered options with registry options: dedupe by
|
||||
* `value` (first occurrence wins, so chat-catalog and OpenRouter-live labels
|
||||
* keep priority), then sort by value for stable dropdown ordering.
|
||||
*/
|
||||
export function mergeEmbeddingOptions(
|
||||
existing: EmbeddingModelOption[],
|
||||
registry: EmbeddingModelOption[]
|
||||
): EmbeddingModelOption[] {
|
||||
const seen = new Set(existing.map((o) => o.value));
|
||||
const merged = [...existing];
|
||||
for (const opt of registry) {
|
||||
if (!seen.has(opt.value)) {
|
||||
seen.add(opt.value);
|
||||
merged.push(opt);
|
||||
}
|
||||
}
|
||||
return merged.sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
@@ -4,10 +4,6 @@ import { getProviderConnections } from "@/lib/db/providers";
|
||||
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
||||
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import {
|
||||
buildRegistryEmbeddingOptions,
|
||||
mergeEmbeddingOptions,
|
||||
} from "./catalog";
|
||||
|
||||
type EmbeddingModelOption = {
|
||||
value: string;
|
||||
@@ -92,22 +88,8 @@ export async function GET(request: NextRequest) {
|
||||
// Best effort only: keep endpoint fast and resilient.
|
||||
}
|
||||
|
||||
// Ensure the default always exists as a safe fallback.
|
||||
if (!options.some((o) => o.value === "openai/text-embedding-3-small")) {
|
||||
options.unshift({
|
||||
value: "openai/text-embedding-3-small",
|
||||
label: "openai/text-embedding-3-small - OpenAI Text Embedding 3 Small",
|
||||
});
|
||||
}
|
||||
|
||||
// Merge curated registry models (EMBEDDING_PROVIDERS — cohere, voyage,
|
||||
// jina, ...) so the Quick select lists real
|
||||
// embedding providers instead of only chat-catalog text matches and
|
||||
// OpenRouter live discovery. Registry options dedupe against the above;
|
||||
// mergeEmbeddingOptions returns value-sorted options for stable UI order.
|
||||
const withRegistry = mergeEmbeddingOptions(options, buildRegistryEmbeddingOptions());
|
||||
|
||||
return NextResponse.json({ models: withRegistry });
|
||||
options.sort((a, b) => a.value.localeCompare(b.value));
|
||||
return NextResponse.json({ models: options });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
return NextResponse.json({ error: { message }, models: [] }, { status: 500 });
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from "@/lib/db/usageAnalytics";
|
||||
import { getFallbackStats, getErrorTypeBreakdown } from "@/lib/db/callLogStats";
|
||||
import { buildByProviderRows } from "@/lib/usage/providerDisplayNames";
|
||||
import { isFlatRateProvider } from "@/lib/usage/flatRateProviders";
|
||||
import { toNumber } from "@/shared/utils/numeric";
|
||||
|
||||
function getRangeStartIso(range: string): string | null {
|
||||
@@ -242,23 +241,12 @@ function computeUsageRowCost(
|
||||
pricingByProvider: PricingByProvider,
|
||||
providerAliasMap: Record<string, string>,
|
||||
normalizeModelName: (model: string) => string,
|
||||
computeCostFromPricing: ComputeCostFromPricing,
|
||||
flatRateAsZero = true
|
||||
computeCostFromPricing: ComputeCostFromPricing
|
||||
): number {
|
||||
const provider = toStringValue(row.provider);
|
||||
const model = toStringValue(row.model);
|
||||
if (!provider || !model) return 0;
|
||||
const serviceTier = normalizeServiceTier(row.serviceTier ?? row.service_tier);
|
||||
const isAggregated = toNumber(row.isAggregated ?? row.is_aggregated) > 0;
|
||||
const storedCost = toNumber(row.storedCost ?? row.stored_cost);
|
||||
|
||||
if (isAggregated) {
|
||||
if (flatRateAsZero && isFlatRateProvider(provider)) return 0;
|
||||
// New rollups preserve the exact API-equivalent value calculated before
|
||||
// cache/reasoning token dimensions are discarded. Legacy zero-cost rows
|
||||
// fall through to the best available input/output-token estimate.
|
||||
if (storedCost > 0) return storedCost;
|
||||
}
|
||||
|
||||
const pricing = resolveModelPricing(
|
||||
pricingByProvider,
|
||||
@@ -282,7 +270,7 @@ function computeUsageRowCost(
|
||||
provider,
|
||||
model,
|
||||
serviceTier,
|
||||
flatRateAsZero,
|
||||
flatRateAsZero: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -292,24 +280,14 @@ function computeUsageRowStandardCost(
|
||||
pricingByProvider: PricingByProvider,
|
||||
providerAliasMap: Record<string, string>,
|
||||
normalizeModelName: (model: string) => string,
|
||||
computeCostFromPricing: ComputeCostFromPricing,
|
||||
flatRateAsZero = true
|
||||
computeCostFromPricing: ComputeCostFromPricing
|
||||
): number {
|
||||
return computeUsageRowCost(
|
||||
{
|
||||
...row,
|
||||
serviceTier: "standard",
|
||||
service_tier: "standard",
|
||||
storedCost: 0,
|
||||
stored_cost: 0,
|
||||
isAggregated: 0,
|
||||
is_aggregated: 0,
|
||||
},
|
||||
{ ...row, serviceTier: "standard", service_tier: "standard" },
|
||||
pricingByProvider,
|
||||
providerAliasMap,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing,
|
||||
flatRateAsZero
|
||||
computeCostFromPricing
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,10 +336,6 @@ export async function GET(request: Request) {
|
||||
const endDate = searchParams.get("endDate") || undefined;
|
||||
const apiKeyIdsParam = searchParams.get("apiKeyIds") || "";
|
||||
const apiKeyIds = apiKeyIdsParam ? apiKeyIdsParam.split(",").filter(Boolean) : [];
|
||||
// Flat-rate subscriptions are $0 in billed-cost analytics by default. The
|
||||
// dedicated costs page opts into their token-price equivalent so subscription
|
||||
// consumption can be compared with metered providers without changing budgets.
|
||||
const includeFlatRateEstimates = searchParams.get("includeFlatRateEstimates") === "true";
|
||||
|
||||
const sinceIso = startDate || getRangeStartIso(range);
|
||||
const untilIso = endDate || null;
|
||||
@@ -579,8 +553,7 @@ export async function GET(request: Request) {
|
||||
pricingByProvider,
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing,
|
||||
!includeFlatRateEstimates
|
||||
computeCostFromPricing
|
||||
);
|
||||
dailyCostByDate.set(date, (dailyCostByDate.get(date) || 0) + cost);
|
||||
|
||||
@@ -618,8 +591,7 @@ export async function GET(request: Request) {
|
||||
pricingByProvider,
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing,
|
||||
!includeFlatRateEstimates
|
||||
computeCostFromPricing
|
||||
);
|
||||
// Keyed by model name alone (not provider) — the table renders one row per
|
||||
// model, so the same model served via multiple provider connections/accounts
|
||||
@@ -690,8 +662,7 @@ export async function GET(request: Request) {
|
||||
pricingByProvider,
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing,
|
||||
!includeFlatRateEstimates
|
||||
computeCostFromPricing
|
||||
);
|
||||
providerCostByProvider.set(provider, (providerCostByProvider.get(provider) || 0) + cost);
|
||||
}
|
||||
@@ -706,8 +677,7 @@ export async function GET(request: Request) {
|
||||
pricingByProvider,
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing,
|
||||
!includeFlatRateEstimates
|
||||
computeCostFromPricing
|
||||
);
|
||||
accountCostByAccount.set(accountKey, (accountCostByAccount.get(accountKey) || 0) + cost);
|
||||
}
|
||||
@@ -769,8 +739,7 @@ export async function GET(request: Request) {
|
||||
pricingByProvider,
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing,
|
||||
!includeFlatRateEstimates
|
||||
computeCostFromPricing
|
||||
);
|
||||
apiKeyMap.set(key, existing);
|
||||
}
|
||||
@@ -814,8 +783,7 @@ export async function GET(request: Request) {
|
||||
pricingByProvider,
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing,
|
||||
!includeFlatRateEstimates
|
||||
computeCostFromPricing
|
||||
);
|
||||
existing.cost += actualCost;
|
||||
if (serviceTier === "flex") {
|
||||
@@ -824,8 +792,7 @@ export async function GET(request: Request) {
|
||||
pricingByProvider,
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing,
|
||||
!includeFlatRateEstimates
|
||||
computeCostFromPricing
|
||||
);
|
||||
existing.savings += Math.max(0, standardCost - actualCost);
|
||||
existing.usageSavingsTokens += computeUsageSavingsTokens(
|
||||
@@ -906,7 +873,6 @@ export async function GET(request: Request) {
|
||||
modelNames,
|
||||
errorBreakdown,
|
||||
range,
|
||||
includesFlatRateEstimates: includeFlatRateEstimates,
|
||||
} as any;
|
||||
|
||||
if (presetsParam) {
|
||||
@@ -943,8 +909,7 @@ export async function GET(request: Request) {
|
||||
pricingByProvider,
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing,
|
||||
!includeFlatRateEstimates
|
||||
computeCostFromPricing
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import { saveCallLog } from "@/lib/usageDb";
|
||||
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import { CORS_HEADERS } from "@omniroute/open-sse/utils/cors.ts";
|
||||
import { deriveRerankProviderForChatProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -108,36 +107,14 @@ async function postHandler(request, context) {
|
||||
// Try cloud registry first
|
||||
const { provider, model: modelId } = parseRerankModel(body.model);
|
||||
|
||||
// Generic fallback: a configured OpenAI-compatible chat provider with no
|
||||
// curated rerank entry (groq, mistral, ...) still exposes a Cohere-compatible
|
||||
// <base>/rerank endpoint. Only used when the prefix matches a chat provider
|
||||
// that can actually derive an endpoint — otherwise fall through to local nodes.
|
||||
let derivedProvider: ReturnType<typeof deriveRerankProviderForChatProvider> = null;
|
||||
if (!provider) {
|
||||
const prefix = body.model.split("/")[0];
|
||||
if (prefix && prefix !== body.model) {
|
||||
try {
|
||||
const { REGISTRY } = await import("@omniroute/open-sse/config/providerRegistry.ts");
|
||||
const chatEntry = (REGISTRY as Record<string, { baseUrl?: string } | undefined>)[prefix];
|
||||
derivedProvider = deriveRerankProviderForChatProvider(prefix, chatEntry);
|
||||
} catch {
|
||||
derivedProvider = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (provider || derivedProvider) {
|
||||
// Cloud provider matched (or a generic Cohere-compatible endpoint was derived)
|
||||
const effectiveProviderId = provider || derivedProvider!.id;
|
||||
const credentials = await getProviderCredentialsWithQuotaPreflight(effectiveProviderId);
|
||||
if (provider) {
|
||||
// Cloud provider matched
|
||||
const credentials = await getProviderCredentialsWithQuotaPreflight(provider);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`No credentials for provider: ${effectiveProviderId}`
|
||||
);
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||
}
|
||||
if (isAllRateLimitedCredentials(credentials)) {
|
||||
return rateLimitedProviderResponse(effectiveProviderId, credentials);
|
||||
return rateLimitedProviderResponse(provider, credentials);
|
||||
}
|
||||
|
||||
const response = await handleRerank({
|
||||
@@ -147,7 +124,6 @@ async function postHandler(request, context) {
|
||||
top_n: body.top_n,
|
||||
return_documents: body.return_documents,
|
||||
credentials,
|
||||
resolvedProvider: derivedProvider || null,
|
||||
connectionId: (credentials as { connectionId?: string } | null)?.connectionId || null,
|
||||
apiKeyId: policy.apiKeyInfo?.id || null,
|
||||
apiKeyName: policy.apiKeyInfo?.name || null,
|
||||
|
||||
@@ -48,13 +48,6 @@
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
|
||||
/* Orchestration status tokens — light values (AA sobre fundo claro) */
|
||||
--orch-status-success: #15803d;
|
||||
--orch-status-warning: #b45309;
|
||||
--orch-status-error: #b91c1c;
|
||||
--orch-status-muted: #6b7280;
|
||||
--orch-status-approval: #6d28d9;
|
||||
|
||||
/* Traffic lights */
|
||||
--color-traffic-red: #ff5f56;
|
||||
--color-traffic-yellow: #ffbd2e;
|
||||
@@ -137,13 +130,6 @@
|
||||
--color-text-primary: #e6e6ef;
|
||||
--color-text-muted: #a1a1aa;
|
||||
|
||||
/* Orchestration status tokens — dark values (previous STATUS_HEX constants) */
|
||||
--orch-status-success: #22c55e;
|
||||
--orch-status-warning: #f59e0b;
|
||||
--orch-status-error: #ef4444;
|
||||
--orch-status-muted: #6b7280;
|
||||
--orch-status-approval: #8b5cf6;
|
||||
|
||||
/* Fumadocs theme mapping */
|
||||
--color-fd-background: #0b0e14;
|
||||
--color-fd-foreground: #e6e6ef;
|
||||
@@ -840,7 +826,4 @@ html[dir="rtl"] :where(.material-symbols-outlined, .traffic-lights, .react-flow)
|
||||
animation: none;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
.orchestration-canvas .orch-edge-particle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,7 +987,6 @@
|
||||
"disabled": "معطل",
|
||||
"featureFlagOmnirouteEmergencyFallbackDescription": "توجيه الطلبات التي استنفدت الميزانية إلى موفر/نموذج الاحتياط المجاني للطوارئ.",
|
||||
"featureFlagArenaEloSyncEnabledDescription": "تمكين المزامنة الدورية لتصنيف ELO للوحة صدارة Arena AI لتصنيفات ذكاء النماذج.",
|
||||
"featureFlagUniversalContextHandoffEnabledDescription": "__MISSING__:Generate and inject conversation summaries when combo routing switches models. Disable to treat model switches independently and prevent background handoff requests for all existing and future combos.",
|
||||
"featureFlagExposeCcDiscoveryAliasesDescription": "اعلن عن معرفات مرآة claude/<provider>/<model> على /v1/models حتى تظهر قائمة اكتشاف نماذج بوابة Claude Code نماذج غير Claude. تحذير: يؤدي إلى تكرار إدخالات الكتالوج لجميع العملاء عند تفعيله عالميًا.",
|
||||
"featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.",
|
||||
"featureFlagChatVirtualLanesEnabledDescription": "فعّل مسارات القبول الافتراضية التكيفية لكل مستأجر (tenant) لتوزيع المزودين (#9654): لم يعد انفجار حركة أحد المستأجرين يسبب خطأ 503 لمستأجر آخر. متغير البيئة OMNIROUTE_CHAT_VIRTUAL_LANES له الأولوية على هذا الإعداد في لوحة التحكم؛ تصبح التغييرات سارية بعد إعادة تشغيل الخادم.",
|
||||
@@ -3791,8 +3790,6 @@
|
||||
"rangeAll": "كل الوقت",
|
||||
"spend30d": "أنفق30 د",
|
||||
"activeModels": "نماذج نشطة",
|
||||
"flatRateEstimateNotice": "__MISSING__:Includes token-price estimates for flat-rate subscriptions, not billed cost.",
|
||||
"flatRateEstimateForecast": "__MISSING__:Projection includes flat-rate estimates.",
|
||||
"selectedWindow": "النافذة المحددة",
|
||||
"activeProviders": "مقدمو الخدمات النشطون",
|
||||
"overviewTitle": "عنوان نظرة عامة",
|
||||
@@ -13359,8 +13356,11 @@
|
||||
"tierLive": "مباشر (في الوقت الحقيقي)",
|
||||
"tierCommunity": "مجتمع (تأخير 30 يومًا)",
|
||||
"activateTitle": "تفعيل الرادار",
|
||||
"activateDescription": "__MISSING__:Compare Community and live access, understand what your installation exchanges, and then choose how to activate Radar.",
|
||||
"activateButton": "__MISSING__:Activate Community",
|
||||
"activateDescription": "يعزز الرادار كتالوج النموذج المجاني بذكاء مستمد من المجتمع - تحديث توافر النماذج، الحصص، وأدلة الإعداد.",
|
||||
"privacyNoUpload": "لا يوجد شيء تم تحميله - تظل بياناتك محلية",
|
||||
"privacyOnlySigned": "يتم تنزيل بيانات التعريف الموقعة تشفيرياً فقط",
|
||||
"privacyLocalOnly": "تتم جميع المعالجة على مثيل OmniRoute الخاص بك",
|
||||
"activateButton": "تفعيل",
|
||||
"activating": "جارٍ التفعيل...",
|
||||
"keySectionTitle": "Already have a supporter key?",
|
||||
"keyInvalidFormatError": "Invalid key format. It must start with \"omr_\" followed by 40 hex characters.",
|
||||
@@ -13368,9 +13368,9 @@
|
||||
"changeKeyButton": "Change key",
|
||||
"claimSectionTitle": "Don't have a supporter key yet?",
|
||||
"contributorButton": "I'm a contributor",
|
||||
"contributorHint": "__MISSING__:GitHub OAuth checks the current weekly ranking: Top 10 get 365 days and ranks 11–100 get 90 days. PR count alone never grants access outside the Top 100.",
|
||||
"contributorHint": "Verify your GitHub account — 5+ merged pull requests or a top-100 contributor spot unlocks a free supporter key.",
|
||||
"supporterButton": "Support the project",
|
||||
"supporterHint": "__MISSING__:One-time 6-month, 1-year, and lifetime options have no automatic renewal; eligible periods accumulate and lifetime prevails.",
|
||||
"supporterHint": "Support OmniRoute to get a supporter key with the live catalog and extra perks.",
|
||||
"colProvider": "المزود",
|
||||
"colModel": "النموذج",
|
||||
"colQuota": "الحصة",
|
||||
@@ -13410,21 +13410,7 @@
|
||||
"localStateSaveFailed": "Failed to save local Radar settings",
|
||||
"guidedCombos": "Guided combos",
|
||||
"offers": "Offers",
|
||||
"intel": "Intel",
|
||||
"accessScaleTitle": "__MISSING__:Understand Radar access before activating",
|
||||
"accessScaleIntro": "__MISSING__:Radar stays network-inert until you opt in. Choose Community access or activate a personal supporter key after reviewing these rules.",
|
||||
"accessCommunityRule": "__MISSING__:Community — no key required: the complete catalog with a 30-day delay.",
|
||||
"accessSingleUseRule": "__MISSING__:Star + follow — after GitHub OAuth verifies both, your GitHub account receives one live catalog read; then access returns to Community. This one-time access cannot be reissued for that account.",
|
||||
"accessContributorRule": "__MISSING__:Contributors — merged PRs, commits, and lines only order the weekly ranking. Top 10 receive 365 days; ranks 11–100 receive 90 days; contributors outside the Top 100 receive no grant regardless of PR count. Leaving the ranking never shortens time already granted.",
|
||||
"accessSupporterRule": "__MISSING__:Supporters — one-time purchases grant 6 months, 1 year, or lifetime with no automatic renewal. Purchases, donations, contributor periods, and manual grants accumulate; lifetime always prevails.",
|
||||
"accessUseTitle": "__MISSING__:Personal use, recovery, and review",
|
||||
"accessInstallationRule": "__MISSING__:Personal license — use the key on one active installation at a time. OmniRoute does not fingerprint hardware. Recovery revokes and replaces a lost key without resetting its expiration.",
|
||||
"accessAbuseRule": "__MISSING__:Abuse review — the 4th distinct IP in 24 hours creates a manual review flag only; it never blocks or revokes a key automatically.",
|
||||
"accessOffersRule": "__MISSING__:Live offers are manually curated and may change or expire.",
|
||||
"privacyTitle": "__MISSING__:What Radar exchanges",
|
||||
"privacyDownloadsRule": "__MISSING__:Downloads — your installation receives cryptographically signed catalog and referral metadata; a valid live key also unlocks signed offers and Intel.",
|
||||
"privacySendsRule": "__MISSING__:Sends — server-side sync sends the supporter key as a Bearer token, and the HTTPS infrastructure receives the connection IP. Feed-request accounting stores neither value raw: it keeps key hashes, aggregate usage, and a daily rotating truncated IP HMAC for abuse review.",
|
||||
"privacyNeverRule": "__MISSING__:Never collected — prompts, responses, conversations, provider credentials, model traffic, uptime, latency, and local provider configuration are never sent to Radar."
|
||||
"intel": "Intel"
|
||||
},
|
||||
"radarCombosPage": {
|
||||
"title": "Radar guided combos",
|
||||
@@ -13949,18 +13935,11 @@
|
||||
"tabRouting": "التوجيه",
|
||||
"tabOverview": "نظرة عامة",
|
||||
"showCompleted": "إظهار المكتمل",
|
||||
"searchPlaceholder": "البحث في المهام…",
|
||||
"filterStates": "الحالات",
|
||||
"filterSources": "المصادر",
|
||||
"filterProviders": "المزوّدون",
|
||||
"clearFilters": "مسح عوامل التصفية",
|
||||
"sourceCloudAgent": "وكيل سحابي",
|
||||
"sourceA2A": "A2A",
|
||||
"sourceConductor": "موصل",
|
||||
"sourceStale": "قديم منذ {since}",
|
||||
"sourceOffline": "غير متصل",
|
||||
"sourceCollapse": "طيّ",
|
||||
"sourceExpand": "توسيع",
|
||||
"stateQueued": "في الانتظار",
|
||||
"stateRunning": "قيد التشغيل",
|
||||
"stateWaitingApproval": "بانتظار الموافقة",
|
||||
@@ -13977,14 +13956,11 @@
|
||||
"drawerMetrics": "المقاييس",
|
||||
"drawerResult": "النتيجة",
|
||||
"drawerActions": "الإجراءات",
|
||||
"drawerClose": "إغلاق",
|
||||
"copyTrace": "نسخ أثر التتبع (JSON)",
|
||||
"actionApprove": "الموافقة على الخطة",
|
||||
"actionCancel": "إلغاء",
|
||||
"actionSeeInGraph": "عرض في الرسم البياني",
|
||||
"actionDone": "تم تطبيق الإجراء",
|
||||
"actionFailed": "فشل الإجراء: {error}",
|
||||
"detailFailed": "فشل تحميل التفاصيل: {error}",
|
||||
"mirroredInA2A": "معكوس في A2A"
|
||||
},
|
||||
"cliproxyProviderExposure": {
|
||||
|
||||
@@ -987,7 +987,6 @@
|
||||
"disabled": "Deaktiv",
|
||||
"featureFlagOmnirouteEmergencyFallbackDescription": "Büdcəsi tükənmiş sorğuları təcili pulsuz ehtiyat təminatçıya/modelə yönləndirin.",
|
||||
"featureFlagArenaEloSyncEnabledDescription": "Model intellekti reytinqləri üçün dövri Arena AI liderlər cədvəli ELO sinxronizasiyasını aktivləşdirin.",
|
||||
"featureFlagUniversalContextHandoffEnabledDescription": "__MISSING__:Generate and inject conversation summaries when combo routing switches models. Disable to treat model switches independently and prevent background handoff requests for all existing and future combos.",
|
||||
"featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models üzərində claude/<provider>/<model> güzgü id-lərini reklam edin ki, Claude Code keçid modeli kəşfiyyatında qeyri-Claude modelləri siyahıya alsın. Diqqət: qlobal olaraq aktivləşdirildikdə bütün müştərilər üçün kataloq girişlərini ikiqat artırır.",
|
||||
"featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.",
|
||||
"featureFlagChatVirtualLanesEnabledDescription": "Təchizatçı göndərişi üçün hər bir icarəçi (tenant) üzrə adaptiv virtual qəbul zolaqlarını aktivləşdirin (#9654): bir icarəçinin ani yükü artıq digərinə 503 qaytarmır. OMNIROUTE_CHAT_VIRTUAL_LANES mühit dəyişəni bu idarəetmə paneli ayarından üstündür; dəyişikliklər server yenidən işə salındıqda qüvvəyə minir.",
|
||||
@@ -3791,8 +3790,6 @@
|
||||
"rangeAll": "All Time",
|
||||
"spend30d": "Spend 30D",
|
||||
"activeModels": "Active Models",
|
||||
"flatRateEstimateNotice": "__MISSING__:Includes token-price estimates for flat-rate subscriptions, not billed cost.",
|
||||
"flatRateEstimateForecast": "__MISSING__:Projection includes flat-rate estimates.",
|
||||
"selectedWindow": "Selected Window",
|
||||
"activeProviders": "Active Providers",
|
||||
"overviewTitle": "Cost Overview",
|
||||
@@ -13359,8 +13356,11 @@
|
||||
"tierLive": "Canlı (real vaxt)",
|
||||
"tierCommunity": "İcma (30 günlük gecikmə)",
|
||||
"activateTitle": "Radarı aktivləşdir",
|
||||
"activateDescription": "__MISSING__:Compare Community and live access, understand what your installation exchanges, and then choose how to activate Radar.",
|
||||
"activateButton": "__MISSING__:Activate Community",
|
||||
"activateDescription": "Radar pulsuz model kataloqunu icma mənbəli intellektlə zənginləşdirir — yenilənmiş model mövcudluğu, kvotalar və quraşdırma bələdçiləri.",
|
||||
"privacyNoUpload": "Heç nə yüklənməyib — məlumatlarınız yerli qalır",
|
||||
"privacyOnlySigned": "Yalnız kriptografik imzalanmış metadata yüklənir",
|
||||
"privacyLocalOnly": "Bütün emal sizin OmniRoute instansiyanızda baş verir",
|
||||
"activateButton": "Aktivləşdir",
|
||||
"activating": "Aktivləşdirilir...",
|
||||
"keySectionTitle": "Already have a supporter key?",
|
||||
"keyInvalidFormatError": "Invalid key format. It must start with \"omr_\" followed by 40 hex characters.",
|
||||
@@ -13368,9 +13368,9 @@
|
||||
"changeKeyButton": "Change key",
|
||||
"claimSectionTitle": "Don't have a supporter key yet?",
|
||||
"contributorButton": "I'm a contributor",
|
||||
"contributorHint": "__MISSING__:GitHub OAuth checks the current weekly ranking: Top 10 get 365 days and ranks 11–100 get 90 days. PR count alone never grants access outside the Top 100.",
|
||||
"contributorHint": "Verify your GitHub account — 5+ merged pull requests or a top-100 contributor spot unlocks a free supporter key.",
|
||||
"supporterButton": "Support the project",
|
||||
"supporterHint": "__MISSING__:One-time 6-month, 1-year, and lifetime options have no automatic renewal; eligible periods accumulate and lifetime prevails.",
|
||||
"supporterHint": "Support OmniRoute to get a supporter key with the live catalog and extra perks.",
|
||||
"colProvider": "Təchizatçı",
|
||||
"colModel": "Model",
|
||||
"colQuota": "Kvota",
|
||||
@@ -13410,21 +13410,7 @@
|
||||
"localStateSaveFailed": "Failed to save local Radar settings",
|
||||
"guidedCombos": "Guided combos",
|
||||
"offers": "Offers",
|
||||
"intel": "Intel",
|
||||
"accessScaleTitle": "__MISSING__:Understand Radar access before activating",
|
||||
"accessScaleIntro": "__MISSING__:Radar stays network-inert until you opt in. Choose Community access or activate a personal supporter key after reviewing these rules.",
|
||||
"accessCommunityRule": "__MISSING__:Community — no key required: the complete catalog with a 30-day delay.",
|
||||
"accessSingleUseRule": "__MISSING__:Star + follow — after GitHub OAuth verifies both, your GitHub account receives one live catalog read; then access returns to Community. This one-time access cannot be reissued for that account.",
|
||||
"accessContributorRule": "__MISSING__:Contributors — merged PRs, commits, and lines only order the weekly ranking. Top 10 receive 365 days; ranks 11–100 receive 90 days; contributors outside the Top 100 receive no grant regardless of PR count. Leaving the ranking never shortens time already granted.",
|
||||
"accessSupporterRule": "__MISSING__:Supporters — one-time purchases grant 6 months, 1 year, or lifetime with no automatic renewal. Purchases, donations, contributor periods, and manual grants accumulate; lifetime always prevails.",
|
||||
"accessUseTitle": "__MISSING__:Personal use, recovery, and review",
|
||||
"accessInstallationRule": "__MISSING__:Personal license — use the key on one active installation at a time. OmniRoute does not fingerprint hardware. Recovery revokes and replaces a lost key without resetting its expiration.",
|
||||
"accessAbuseRule": "__MISSING__:Abuse review — the 4th distinct IP in 24 hours creates a manual review flag only; it never blocks or revokes a key automatically.",
|
||||
"accessOffersRule": "__MISSING__:Live offers are manually curated and may change or expire.",
|
||||
"privacyTitle": "__MISSING__:What Radar exchanges",
|
||||
"privacyDownloadsRule": "__MISSING__:Downloads — your installation receives cryptographically signed catalog and referral metadata; a valid live key also unlocks signed offers and Intel.",
|
||||
"privacySendsRule": "__MISSING__:Sends — server-side sync sends the supporter key as a Bearer token, and the HTTPS infrastructure receives the connection IP. Feed-request accounting stores neither value raw: it keeps key hashes, aggregate usage, and a daily rotating truncated IP HMAC for abuse review.",
|
||||
"privacyNeverRule": "__MISSING__:Never collected — prompts, responses, conversations, provider credentials, model traffic, uptime, latency, and local provider configuration are never sent to Radar."
|
||||
"intel": "Intel"
|
||||
},
|
||||
"radarCombosPage": {
|
||||
"title": "Radar guided combos",
|
||||
@@ -13949,18 +13935,11 @@
|
||||
"tabRouting": "Marşrutlaşdırma",
|
||||
"tabOverview": "İcmal",
|
||||
"showCompleted": "Tamamlananları göstər",
|
||||
"searchPlaceholder": "Tapşırıqları axtar…",
|
||||
"filterStates": "Statuslar",
|
||||
"filterSources": "Mənbələr",
|
||||
"filterProviders": "Provayderlər",
|
||||
"clearFilters": "Filtrləri təmizlə",
|
||||
"sourceCloudAgent": "Bulud agenti",
|
||||
"sourceA2A": "A2A",
|
||||
"sourceConductor": "İdarəçi",
|
||||
"sourceStale": "{since} tarixindən köhnəlib",
|
||||
"sourceOffline": "Oflayn",
|
||||
"sourceCollapse": "Yığ",
|
||||
"sourceExpand": "Aç",
|
||||
"stateQueued": "Növbədə",
|
||||
"stateRunning": "İşləyir",
|
||||
"stateWaitingApproval": "Təsdiq gözləyir",
|
||||
@@ -13977,14 +13956,11 @@
|
||||
"drawerMetrics": "Metriklər",
|
||||
"drawerResult": "Nəticə",
|
||||
"drawerActions": "Əməliyyatlar",
|
||||
"drawerClose": "Bağla",
|
||||
"copyTrace": "İzləmə JSON-unu kopyala",
|
||||
"actionApprove": "Planı təsdiqlə",
|
||||
"actionCancel": "Ləğv et",
|
||||
"actionSeeInGraph": "Qrafikdə bax",
|
||||
"actionDone": "Əməliyyat tətbiq edildi",
|
||||
"actionFailed": "Əməliyyat uğursuz oldu: {error}",
|
||||
"detailFailed": "Detallar yüklənmədi: {error}",
|
||||
"mirroredInA2A": "A2A-da əks olunub"
|
||||
},
|
||||
"cliproxyProviderExposure": {
|
||||
|
||||
@@ -987,7 +987,6 @@
|
||||
"disabled": "Деактивирано",
|
||||
"featureFlagOmnirouteEmergencyFallbackDescription": "Маршрутизиране на заявки с изчерпан бюджет към аварийния безплатен резервен доставчик/модел.",
|
||||
"featureFlagArenaEloSyncEnabledDescription": "Активиране на периодична синхронизация на ELO от класацията на Arena AI за класиране на интелигентността на моделите.",
|
||||
"featureFlagUniversalContextHandoffEnabledDescription": "__MISSING__:Generate and inject conversation summaries when combo routing switches models. Disable to treat model switches independently and prevent background handoff requests for all existing and future combos.",
|
||||
"featureFlagExposeCcDiscoveryAliasesDescription": "Рекламирайте claude/<provider>/<model> mirror идентификатори на /v1/models, така че списъкът с модели на Claude Code gateway да включва неклаудови модели. Внимание: удвоява записите в каталога за всички клиенти, когато е активирано глобално.",
|
||||
"featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.",
|
||||
"featureFlagChatVirtualLanesEnabledDescription": "Активирайте адаптивни виртуални ленти за допускане за всеки наемател (tenant) при изпращане към доставчици (#9654): скокът в натоварването на един наемател вече не връща 503 на друг. Променливата на средата OMNIROUTE_CHAT_VIRTUAL_LANES има предимство пред тази настройка в таблото; промените влизат в сила след рестартиране на сървъра.",
|
||||
@@ -3791,8 +3790,6 @@
|
||||
"rangeAll": "Всички времена",
|
||||
"spend30d": "Spend30D",
|
||||
"activeModels": "Active Models",
|
||||
"flatRateEstimateNotice": "__MISSING__:Includes token-price estimates for flat-rate subscriptions, not billed cost.",
|
||||
"flatRateEstimateForecast": "__MISSING__:Projection includes flat-rate estimates.",
|
||||
"selectedWindow": "Selected Window",
|
||||
"activeProviders": "Active Providers",
|
||||
"overviewTitle": "Overview Title",
|
||||
@@ -13360,6 +13357,9 @@
|
||||
"tierCommunity": "Общност (30-дневно закъснение)",
|
||||
"activateTitle": "Активирайте радара",
|
||||
"activateDescription": "Радарът обогатява безплатния модел каталог с информация от общността — актуализирана наличност на модели, квоти и ръководства за настройка.",
|
||||
"privacyNoUpload": "Нищо не е качено — вашите данни остават локални",
|
||||
"privacyOnlySigned": "Изтеглят се само криптографски подписани метаданни",
|
||||
"privacyLocalOnly": "Всички обработки се извършват на вашия OmniRoute инстанс",
|
||||
"activateButton": "Активирайте",
|
||||
"activating": "Активиране...",
|
||||
"keySectionTitle": "Already have a supporter key?",
|
||||
@@ -13368,7 +13368,7 @@
|
||||
"changeKeyButton": "Change key",
|
||||
"claimSectionTitle": "Don't have a supporter key yet?",
|
||||
"contributorButton": "I'm a contributor",
|
||||
"contributorHint": "__MISSING__:GitHub OAuth checks the current weekly ranking: Top 10 get 365 days and ranks 11–100 get 90 days. PR count alone never grants access outside the Top 100.",
|
||||
"contributorHint": "Verify your GitHub account — 5+ merged pull requests or a top-100 contributor spot unlocks a free supporter key.",
|
||||
"supporterButton": "Support the project",
|
||||
"supporterHint": "Support OmniRoute to get a supporter key with the live catalog and extra perks.",
|
||||
"colProvider": "Доставчик",
|
||||
@@ -13410,21 +13410,7 @@
|
||||
"localStateSaveFailed": "Failed to save local Radar settings",
|
||||
"guidedCombos": "Guided combos",
|
||||
"offers": "Offers",
|
||||
"intel": "Intel",
|
||||
"accessScaleTitle": "__MISSING__:Understand Radar access before activating",
|
||||
"accessScaleIntro": "__MISSING__:Radar stays network-inert until you opt in. Choose Community access or activate a personal supporter key after reviewing these rules.",
|
||||
"accessCommunityRule": "__MISSING__:Community — no key required: the complete catalog with a 30-day delay.",
|
||||
"accessSingleUseRule": "__MISSING__:Star + follow — after GitHub OAuth verifies both, your GitHub account receives one live catalog read; then access returns to Community. This one-time access cannot be reissued for that account.",
|
||||
"accessContributorRule": "__MISSING__:Contributors — merged PRs, commits, and lines only order the weekly ranking. Top 10 receive 365 days; ranks 11–100 receive 90 days; contributors outside the Top 100 receive no grant regardless of PR count. Leaving the ranking never shortens time already granted.",
|
||||
"accessSupporterRule": "__MISSING__:Supporters — one-time purchases grant 6 months, 1 year, or lifetime with no automatic renewal. Purchases, donations, contributor periods, and manual grants accumulate; lifetime always prevails.",
|
||||
"accessUseTitle": "__MISSING__:Personal use, recovery, and review",
|
||||
"accessInstallationRule": "__MISSING__:Personal license — use the key on one active installation at a time. OmniRoute does not fingerprint hardware. Recovery revokes and replaces a lost key without resetting its expiration.",
|
||||
"accessAbuseRule": "__MISSING__:Abuse review — the 4th distinct IP in 24 hours creates a manual review flag only; it never blocks or revokes a key automatically.",
|
||||
"accessOffersRule": "__MISSING__:Live offers are manually curated and may change or expire.",
|
||||
"privacyTitle": "__MISSING__:What Radar exchanges",
|
||||
"privacyDownloadsRule": "__MISSING__:Downloads — your installation receives cryptographically signed catalog and referral metadata; a valid live key also unlocks signed offers and Intel.",
|
||||
"privacySendsRule": "__MISSING__:Sends — server-side sync sends the supporter key as a Bearer token, and the HTTPS infrastructure receives the connection IP. Feed-request accounting stores neither value raw: it keeps key hashes, aggregate usage, and a daily rotating truncated IP HMAC for abuse review.",
|
||||
"privacyNeverRule": "__MISSING__:Never collected — prompts, responses, conversations, provider credentials, model traffic, uptime, latency, and local provider configuration are never sent to Radar."
|
||||
"intel": "Intel"
|
||||
},
|
||||
"radarCombosPage": {
|
||||
"title": "Radar guided combos",
|
||||
@@ -13949,18 +13935,11 @@
|
||||
"tabRouting": "Маршрутизиране",
|
||||
"tabOverview": "Общ преглед",
|
||||
"showCompleted": "Показване на завършените",
|
||||
"searchPlaceholder": "Търсене на задачи…",
|
||||
"filterStates": "Състояния",
|
||||
"filterSources": "Източници",
|
||||
"filterProviders": "Доставчици",
|
||||
"clearFilters": "Изчистване на филтрите",
|
||||
"sourceCloudAgent": "Облачен агент",
|
||||
"sourceA2A": "A2A",
|
||||
"sourceConductor": "Кондуктор",
|
||||
"sourceStale": "Остаряло от {since}",
|
||||
"sourceOffline": "Офлайн",
|
||||
"sourceCollapse": "Свиване",
|
||||
"sourceExpand": "Разгъване",
|
||||
"stateQueued": "На опашка",
|
||||
"stateRunning": "Изпълнява се",
|
||||
"stateWaitingApproval": "Изчаква одобрение",
|
||||
@@ -13977,14 +13956,11 @@
|
||||
"drawerMetrics": "Метрики",
|
||||
"drawerResult": "Резултат",
|
||||
"drawerActions": "Действия",
|
||||
"drawerClose": "Затваряне",
|
||||
"copyTrace": "Копиране на трасето (JSON)",
|
||||
"actionApprove": "Одобри плана",
|
||||
"actionCancel": "Отказ",
|
||||
"actionSeeInGraph": "Виж в графа",
|
||||
"actionDone": "Действието е приложено",
|
||||
"actionFailed": "Действието се провали: {error}",
|
||||
"detailFailed": "Неуспешно зареждане на детайлите: {error}",
|
||||
"mirroredInA2A": "Отразено в A2A"
|
||||
},
|
||||
"cliproxyProviderExposure": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user