diff --git a/.env.example b/.env.example index 71f7a728f9..40ce624b7c 100644 --- a/.env.example +++ b/.env.example @@ -2403,8 +2403,21 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # Path to a file containing the internal service token (overrides the inline var). # OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE= -# ── OpenRouter provider stats (catalog enrichment) ──────────────────────────── -# On by default; set to "false" to skip fetching OpenRouter per-provider stats. -# OPENROUTER_PROVIDER_STATS_ENABLED=true -# Cache TTL for the fetched stats, in milliseconds. -# OPENROUTER_PROVIDER_STATS_TTL_MS=3600000 +# ═══════════════════════════════════════════════════════════════════════════════ +# 26. RADAR FEED (SELF-HOSTING) +# ═══════════════════════════════════════════════════════════════════════════════ +# Optional add-on (feature flag RADAR_ENABLED, default off — see feature flag +# settings, not an env var) that overlays a signed, freshly-curated free-model +# catalog on top of the release baseline. Both variables below are optional and +# only needed to point the client at a self-hosted/forked feed instead of the +# default OmniRoute Radar feed. Used by: src/lib/radar/sync.ts, +# src/lib/radar/pinnedKeys.ts. + +# Base URL of the Radar feed service. Overrides the built-in default so forks +# and self-hosters can point at their own signed feed. +# RADAR_FEED_URL=https://radar.omniroute.online + +# Ed25519 public key (base64-DER SPKI or PEM) used to verify the feed +# signature, replacing the pinned default key. Required when self-hosting a +# feed signed with a different key pair. +# RADAR_FEED_PUBKEY= diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index f54adb8d50..5823bc06bc 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -330,7 +330,10 @@ function trimLeadingDashes(value: string): string { * sees a consistent identifier. */ export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Required< - Pick + Pick< + OmniRoutePluginOptions, + "providerId" | "displayName" | "modelCacheTtl" | "autoSyncIntervalMs" + > > & { /** * #6859: the UNPREFIXED provider id ("omniroute", "omniroute-preprod", …). @@ -621,7 +624,7 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook */ export function invalidateOmniRouteFetchCache( cache: OmniRouteFetchCache, - baseURL?: string, + baseURL?: string ): number { if (!baseURL) { const n = cache.size; @@ -645,7 +648,7 @@ export function invalidateOmniRouteFetchCache( */ export async function resolveOmniRouteRuntimeAuth( resolved: ResolvedOmniRoutePluginOptions, - readAuthJson?: OmniRouteReadAuthJson, + readAuthJson?: OmniRouteReadAuthJson ): Promise<{ apiKey: string; baseURL: string; managementReadToken: string } | null> { const reader = readAuthJson ?? defaultReadAuthJson; let authJson: AuthJsonShape | undefined | null; @@ -672,7 +675,7 @@ export async function resolveOmniRouteRuntimeAuth( e && (e as { type?: unknown }).type === "api" && typeof (e as { key?: unknown }).key === "string" && - ((e as { key: string }).key).length > 0 + (e as { key: string }).key.length > 0 ) { entry = e as AuthJsonApiEntry; break; @@ -737,7 +740,7 @@ export async function forceSyncOmniRouteModels(args: { const auth = await resolveOmniRouteRuntimeAuth( resolved, - args.readAuthJson ?? defaultReadAuthJson, + args.readAuthJson ?? defaultReadAuthJson ); if (!auth) { return { @@ -795,7 +798,7 @@ export async function forceSyncOmniRouteModels(args: { rawCompressionCombos = await compressionMetaFetcher( auth.baseURL, auth.managementReadToken, - 10_000, + 10_000 ); } catch { rawCompressionCombos = []; @@ -820,10 +823,7 @@ export async function forceSyncOmniRouteModels(args: { rawConnections, expiresAt: t + resolved.modelCacheTtl, }; - const cacheKey = modelsCacheKey( - auth.baseURL, - `${auth.apiKey}\0${auth.managementReadToken}`, - ); + const cacheKey = modelsCacheKey(auth.baseURL, `${auth.apiKey}\0${auth.managementReadToken}`); cache.set(cacheKey, entry); if (wantDiskCache) { @@ -831,7 +831,7 @@ export async function forceSyncOmniRouteModels(args: { const fingerprint = diskSnapshotIdentityFingerprint( auth.baseURL, auth.apiKey, - auth.managementReadToken, + auth.managementReadToken ); const { expiresAt: _expiresAt, ...diskEntry } = entry; await defaultDiskSnapshotWriter(resolved.providerId, diskEntry, fingerprint); @@ -843,7 +843,7 @@ export async function forceSyncOmniRouteModels(args: { console.warn( `[omniroute-plugin] force sync ok providerId=${resolved.providerId} ` + `models=${rawModels.length} combos=${rawCombos.length} ` + - `clearedMemory=${clearedMemory + clearedAll} disk=${clearedDisk}`, + `clearedMemory=${clearedMemory + clearedAll} disk=${clearedDisk}` ); return { @@ -944,7 +944,7 @@ export function startOmniRouteAutoSync(args: { const result = await forceSyncOmniRouteModels({ resolved, cache }); if (!result.ok) { console.warn( - `[omniroute-plugin] auto-sync failed providerId=${resolved.providerId}: ${result.error}`, + `[omniroute-plugin] auto-sync failed providerId=${resolved.providerId}: ${result.error}` ); return; } @@ -955,7 +955,7 @@ export function startOmniRouteAutoSync(args: { if (result.count !== lastCount) { console.warn( `[omniroute-plugin] auto-sync catalog size changed ${lastCount} → ${result.count} ` + - `(providerId=${resolved.providerId})`, + `(providerId=${resolved.providerId})` ); lastCount = result.count; } @@ -976,7 +976,7 @@ export function startOmniRouteAutoSync(args: { } console.warn( - `[omniroute-plugin] auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`, + `[omniroute-plugin] auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}` ); return () => { @@ -1032,7 +1032,13 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => { const cfg = input as Config & { command?: Record< string, - { template: string; description?: string; agent?: string; model?: string; subtask?: boolean } + { + template: string; + description?: string; + agent?: string; + model?: string; + subtask?: boolean; + } >; }; if (!cfg.command) cfg.command = {}; @@ -4466,7 +4472,8 @@ export function buildStaticProviderEntry( // (`opencode-omniroute/opencode-omniroute/`), and `parseModel()` // resolves credentials for the nonexistent provider `opencode-omniroute` // instead of `omniroute`. See #7976. - models[buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId).split("/").pop()!] = entry; + models[buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId).split("/").pop()!] = + entry; // Make this combo's resolved entry available to parent combos // that reference it via combo-ref. Use the friendly name since diff --git a/AGENTS.md b/AGENTS.md index faaa956fdb..b5689f8e3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -399,6 +399,7 @@ For any non-trivial change, read the matching deep-dive first: | Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` | | Reasoning replay | `docs/routing/REASONING_REPLAY.md` | | Skills framework | `docs/frameworks/SKILLS.md` | +| Radar (free-model catalog overlay) | `docs/frameworks/RADAR.md` | | Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` | | Cloud agents | `docs/frameworks/CLOUD_AGENT.md` | | Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` | @@ -427,7 +428,7 @@ For any non-trivial change, read the matching deep-dive first: | What | Command | | ----------------------- | --------------------------------------------------------------------------- | | Unit tests | `npm run test:unit` | -| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` | +| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` | | Vitest (MCP, autoCombo) | `npm run test:vitest` | | E2E (Playwright) | `npm run test:e2e` | | Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` | diff --git a/bin/cli/commands/doctor.mjs b/bin/cli/commands/doctor.mjs index f61f9d60c0..bf7ba10b59 100644 --- a/bin/cli/commands/doctor.mjs +++ b/bin/cli/commands/doctor.mjs @@ -299,7 +299,15 @@ async function checkNativeBinary(rootDir) { "Release", "better_sqlite3.node" ), - path.join(rootDir, "dist", "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"), + path.join( + rootDir, + "dist", + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" + ), path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"), ]; const binaryPath = candidates.find((candidate) => fs.existsSync(candidate)); @@ -396,7 +404,10 @@ async function checkServerLiveness(options = {}) { // First attempt: configured health endpoint (may require auth token). const primary = await probeUrl(url); if (primary.ok) { - return ok("Server liveness", "Server health endpoint is reachable", { url, status: primary.status }); + return ok("Server liveness", "Server health endpoint is reachable", { + url, + status: primary.status, + }); } // #6162: /api/health and /api/health/degradation require a management token. @@ -427,7 +438,12 @@ async function checkServerLiveness(options = {}) { return ok( "Server liveness", `Server reachable (health endpoint returned ${primary.status}, likely requires MANAGEMENT_TOKEN)`, - { primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status } + { + primaryUrl: url, + primaryStatus: primary.status, + fallbackUrl, + fallbackStatus: fallback.status, + } ); } @@ -440,8 +456,7 @@ async function checkServerLiveness(options = {}) { export async function collectDoctorChecks(context = {}, options = {}) { const rootDir = - context.rootDir || - path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); const dataDir = resolveDataDir(); const dbPath = resolveStoragePath(dataDir); diff --git a/bin/cli/commands/oauth.mjs b/bin/cli/commands/oauth.mjs index d1d2fd3b40..5c1a6ec4a9 100644 --- a/bin/cli/commands/oauth.mjs +++ b/bin/cli/commands/oauth.mjs @@ -159,9 +159,7 @@ async function runBrowserFlow(def, opts) { } const result = await exchangeRes.json(); const conn = result.connection ?? {}; - process.stdout.write( - `Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n` - ); + process.stdout.write(`Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n`); } async function safeErrorBody(res) { diff --git a/bin/cli/commands/setup-claude.mjs b/bin/cli/commands/setup-claude.mjs index 6ccc668257..600a33d8bb 100644 --- a/bin/cli/commands/setup-claude.mjs +++ b/bin/cli/commands/setup-claude.mjs @@ -160,8 +160,7 @@ export async function runSetupClaudeCommand(opts = {}) { let detail = `HTTP ${res.status}`; try { const errorBody = await res.json(); - const serverMsg = - errorBody?.error?.message || errorBody?.error || errorBody?.message || ""; + const serverMsg = errorBody?.error?.message || errorBody?.error || errorBody?.message || ""; if (serverMsg) detail += ` — ${serverMsg}`; } catch {} throw new Error(detail); diff --git a/bin/cli/commands/update.mjs b/bin/cli/commands/update.mjs index 75f829107d..afdaff68e4 100644 --- a/bin/cli/commands/update.mjs +++ b/bin/cli/commands/update.mjs @@ -188,16 +188,18 @@ export async function runUpdateCommand(opts = {}) { const afterVersion = await getCurrentVersion(); if (afterVersion && compareVersions(afterVersion, latest) < 0) { printError( - `Global install updated to ${latest}, but the running binary still reports ${afterVersion}.`, + `Global install updated to ${latest}, but the running binary still reports ${afterVersion}.` ); console.log( - " A local `node_modules/omniroute` is likely shadowing the global install on PATH.", + " A local `node_modules/omniroute` is likely shadowing the global install on PATH." ); console.log(" Diagnose with:"); console.log(" which -a omniroute"); console.log(" command -v omniroute"); console.log(" npm prefix -g"); - console.log(" Then remove the shadowing local copy (e.g. `npm uninstall omniroute` from its directory)"); + console.log( + " Then remove the shadowing local copy (e.g. `npm uninstall omniroute` from its directory)" + ); console.log(" or reorder PATH so the global bin comes first."); return 1; } diff --git a/bin/cli/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs index bd053384f9..b632e689a2 100644 --- a/bin/cli/runtime/nativeDeps.mjs +++ b/bin/cli/runtime/nativeDeps.mjs @@ -94,10 +94,12 @@ export function isBetterSqliteBinaryValid() { const magic = buf.toString("hex"); const os = platform(); let formatOk; - if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF + if (os === "linux") + formatOk = magic.startsWith("7f454c46"); // ELF else if (os === "darwin") formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O - else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ + else if (os === "win32") + formatOk = magic.startsWith("4d5a"); // PE/MZ else formatOk = true; if (!formatOk) return false; // File-format magic bytes alone do not guarantee the binary was built for the Node ABI diff --git a/docs/README.md b/docs/README.md index 9cb3895425..96834709ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -105,6 +105,7 @@ Pluggable subsystems exposed to clients, agents, and operators. - [PLUGINS.md](frameworks/PLUGINS.md) — CLI plugin system overview. - [PLUGIN_SDK.md](frameworks/PLUGIN_SDK.md) — plugin SDK reference. - [PLUGIN_MARKETPLACE.md](frameworks/PLUGIN_MARKETPLACE.md) — plugin marketplace. +- [RADAR.md](frameworks/RADAR.md) — Radar free-model catalog overlay (optional, off by default). ## routing/ diff --git a/docs/architecture/REPOSITORY_MAP.md b/docs/architecture/REPOSITORY_MAP.md index 2224cb9e6b..20c31c4829 100644 --- a/docs/architecture/REPOSITORY_MAP.md +++ b/docs/architecture/REPOSITORY_MAP.md @@ -184,6 +184,7 @@ src/ | `config/` | Runtime config helpers | | `db/` | 95+ domain DB modules + 110+ migrations (always go through here for SQLite) | | `quota/` | Quota Sharing Engine: `dimensions.ts` (types/Zod), `types.ts` (QuotaStore interface), `sqliteQuotaStore.ts`, `redisQuotaStore.ts`, `storeFactory.ts`, `fairShare.ts`, `burnRate.ts`, `planResolver.ts`, `planRegistry.ts`, `saturationSignals.ts`, `enforce.ts`, `spendRecorder.ts` — see `docs/routing/QUOTA_SHARE.md` | +| `radar/` | Radar free-model catalog client: `feedSchema.ts`, `pinnedKeys.ts`, `verify.ts`, `sync.ts`, `applyFeed.ts`, `index.ts` (`getRadarCatalog()`) — see `docs/frameworks/RADAR.md` | | `display/` | UI formatting helpers (cost, latency, etc.) | | `embeddings/` | Embeddings service helpers | | `env/` | Env variable parsing + validation | @@ -410,6 +411,7 @@ open-sse/ | `AGENT_PROTOCOLS_GUIDE.md` | Unified guide: A2A vs ACP vs Cloud Agents | | `CLOUD_AGENT.md` | Codex Cloud / Devin / Jules orchestration | | `SKILLS.md` | Skills framework (built-in + marketplace + SkillsSH + sandbox) | +| `RADAR.md` | Radar free-model catalog overlay (`RADAR_ENABLED`, off by default) | | `MEMORY.md` | Memory system (SQLite FTS5 + Qdrant) | | `EVALS.md` | Eval framework (suites, runs, rubrics) | | `GUARDRAILS.md` | PII masker, prompt injection, vision bridge | diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md new file mode 100644 index 0000000000..e3b608651a --- /dev/null +++ b/docs/frameworks/RADAR.md @@ -0,0 +1,236 @@ +--- +title: "Radar Free-Model Catalog" +version: 3.8.50 +lastUpdated: 2026-08-05 +--- + +# Radar Free-Model Catalog + +> **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/` +> **Last updated:** 2026-08-05 — 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 +`open-sse/config/freeModelCatalog.ts`). It exists because the free-tier landscape moves +faster than release cadence — providers add, shrink, or discontinue free quotas between +releases, and the baseline catalog can only be refreshed when a new version ships. + +**Nothing that is free today stops being free.** Radar never removes or paywalls a +baseline entry; it only refreshes limits/status fields at read time and can layer in +newly-discovered free models between releases. The baseline catalog itself is never +mutated on disk — see [Read-time overlay merge rules](#read-time-overlay-merge-rules) +below. + +--- + +## Flag: `RADAR_ENABLED` (default off) + +Radar is gated end-to-end by the `RADAR_ENABLED` feature flag +(`src/shared/constants/featureFlagDefinitions.ts`, category `policies`, +`defaultValue: "false"`). + +**When the flag is off, the surface does not exist:** + +- `GET /api/radar/catalog`, `POST /api/radar/sync`, `POST /api/radar/settings` all + return `404` before touching any Radar module. +- The dashboard screens (`/dashboard/radar`, `/dashboard/radar/setup`) render + `notFound()`. +- `getRadarCatalog()` (`src/lib/radar/index.ts`) returns the untouched baseline — + same entry count, same values, every entry tagged `origin: "baseline"` — and never + reads the feed cache. +- No network call is ever made; `syncRadar()` (`src/lib/radar/sync.ts`) returns + `{ status: "disabled" }` at step 1 without touching `fetch`. + +This is a strict superset gate: flipping the flag on unlocks the _screens_, nothing +more. It does not upload data, does not start a background sync, and does not change +routing or model selection — see the separate opt-in below. + +--- + +## Data sync is a SEPARATE opt-in — the privacy promise + +Turning `RADAR_ENABLED` on only unlocks the UI. Syncing the feed requires a second, +independent opt-in stored in `radar_settings.opt_in` (`src/lib/db/radar.ts`, +migration `136_radar_cache_settings.sql`). `syncRadar()` checks the flag _and_ the +opt-in before making any network call: + +``` +Flag off → { status: "disabled" } — no network call +Opt-in false → { status: "opt_out" } — no network call +``` + +When both are on, the sync path is: + +1. `GET /v1/catalog/latest` with an optional `Authorization: Bearer +` header (see below). +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)). Nothing else touches the network for Radar. + +The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`) +that lets the feed service decide which tier to serve (see +[Tiers](#tiers-community-and-live)). It is: + +- Stored **encrypted at rest** with the same AES-256-GCM `encrypt()`/`decrypt()` + helpers (`src/lib/db/encryption.ts`) used for provider credentials. +- Set via `POST /api/radar/settings` (`{ supporterKey: "omr_" + 40 hex chars }`) and + **never echoed back** — the response returns a masked form (`omr_****abcd`). +- Sent to the feed service as a Bearer token on the sync GET — nothing else about the + key ever leaves the client. + +--- + +## Security model + +### Ed25519 signature over exact bytes + +The feed payload is signed with Ed25519. `verifyFeedBytes()` +(`src/lib/radar/verify.ts`) verifies the signature over the **exact response bytes** +received over the wire — the payload is never re-serialized before verification, so a +byte-for-byte re-encoding cannot silently invalidate or bypass the signature check. +Verification failure (`invalid_signature`) aborts the sync before the payload is ever +parsed or cached. + +### Pinned public key + rotation + +The verifying public key is pinned in `src/lib/radar/pinnedKeys.ts` +(`PINNED_FEED_PUBLIC_KEYS`), an array so a new key can be prepended ahead of a +rotation while old cached feeds signed with a previous key remain valid until +re-synced. + +### Fork-friendly env overrides + +Two env vars let forks and self-hosters point the client at their own feed instead of +the default OmniRoute service — see +[How to self-host a feed](#how-to-self-host-a-feed) below: + +| Var | Purpose | +| ------------------- | ------------------------------------------------------------------------------------------------------------ | +| `RADAR_FEED_URL` | Overrides the feed base URL (default `https://radar.omniroute.online`). | +| `RADAR_FEED_PUBKEY` | Overrides the pinned public key (base64-DER SPKI or PEM), replacing the built-in array with this single key. | + +### Version floor + +`syncRadar()` rejects a downloaded feed whose `version` is not strictly newer than the +currently cached version (`compareVersions()`, dotted `YYYY.MM.DD.n` comparison) — +`{ status: "stale" }`. This prevents a compromised or misconfigured feed endpoint from +rolling a client back to an older, differently-signed payload. + +### Schema validation + +The downloaded bytes are parsed and validated against `RadarFeedSchema` +(`src/lib/radar/feedSchema.ts`, a Zod schema) **after** signature verification. A +schema mismatch returns `{ status: "invalid_schema" }` and the cache is left +untouched. The cached payload is defensively re-validated again on every read +(`getRadarCatalog()`) — a corrupted or hand-edited cache row falls back to the +baseline rather than being served. + +--- + +## Tiers: `community` and `live` + +The feed schema carries a `tier: "community" | "live"` field, decided **server-side** +by the feed service based on the request (presence and validity of the supporter key) +— the client never decides its own tier. + +- **`community`** — the free catalog delayed by roughly 30 days behind the freshest + data. This is what an unauthenticated or invalid-key request receives. +- **`live`** — the freshest catalog, served to requests carrying a valid supporter + key. + +**An invalid or expired supporter key degrades to `community` — it is never an +error.** The sync path only distinguishes signature/schema/version failures (all +recoverable, all non-fatal to the cached state) from a successful `{ status: +"updated", version, tier }`. There is no tier-specific error path a client needs to +handle. + +--- + +## Read-time overlay merge rules + +`applyFeed()` (`src/lib/radar/applyFeed.ts`) merges the cached feed **over** the +static baseline at **read time**, inside `getRadarCatalog()`. The baseline array +(`FREE_MODEL_BUDGETS`) is never mutated — a `MergedEntry[]` is computed fresh on every +call. + +Four rules, in order of precedence: + +1. **Feed never overwrites a local override.** Per-field: if the operator has + customized a field on an entry (`localOverrides` map, keyed `provider:modelId`), + the feed's value for that specific field is skipped — the operator's value wins. +2. **`enabled: false` disables the entry, with provenance.** A feed entry that turns + an entry off sets `enabled: false` and `disabledBy: "radar"` on the merged result, + so the UI can explain _why_ an entry went from available to disabled. +3. **A user-added entry not present in the feed survives untouched.** Entries that + only exist in the baseline (or were added locally) and have no corresponding feed + entry pass through unchanged. +4. **A tombstoned entry is never resurrected.** If the operator explicitly deleted an + entry (`tombstones` set), the feed re-adding that `provider:modelId` in a later + version does not bring it back. + +### Provenance markers + +Every merged entry carries an `origin` field the UI renders as a badge: + +- `"baseline"` — untouched from the static release catalog. +- `"radar"` — one or more fields were refreshed by the feed. +- `"local"` — the operator has at least one local override on this entry (local + overrides always win over the feed per rule 1, regardless of what the feed says). + +--- + +## Local surfaces — never a feed proxy + +Three local routes back the UI, all under `src/app/api/radar/`: + +| Route | Method | Purpose | +| --------------------- | ------ | ---------------------------------------------------------------------- | +| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. | +| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. | +| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. | + +**Hard rule: these routes never proxy the feed service.** The browser only ever talks +to the local OmniRoute server; `syncRadar()` is the single module in the whole client +that touches the network for Radar (`src/lib/radar/sync.ts`), and it always runs +server-side, never client-side. This keeps the feed URL and any supporter key +out of client-facing network traffic entirely. + +All three routes return `404` when `RADAR_ENABLED` is off (see +[Flag](#flag-radar_enabled-default-off) above), and route error responses through +`buildErrorBody()`/`sanitizeErrorMessage()` per the repo-wide error-sanitization rule +(`docs/security/ERROR_SANITIZATION.md`). + +--- + +## How to self-host a feed + +A fork or self-hoster that wants full control over the catalog can run their own feed +service without touching client code: + +1. Serve a `GET /v1/catalog/latest` endpoint returning a JSON body that satisfies + `RadarFeedSchema` (`src/lib/radar/feedSchema.ts`) — top-level `feed: +"omniroute-radar"`, `schemaVersion: 1`, `version`, `tier`, `providers`, `models`, + `quirks`, and `totals`. +2. Sign the exact response bytes with an Ed25519 key pair and return the base64 + signature in the `x-omniroute-feed-signature` response header. +3. Set `RADAR_FEED_URL` to the new base URL and `RADAR_FEED_PUBKEY` to the matching + public key (base64-DER SPKI or PEM) — see the + [env var reference](../reference/ENVIRONMENT.md#27-radar-feed-self-hosting). +4. Enable `RADAR_ENABLED` and opt in via `POST /api/radar/settings` + (`{ optIn: true }`). + +No other code changes are required — `verifyFeedBytes()` picks up the override +automatically (`getFeedPublicKeys()` in `src/lib/radar/pinnedKeys.ts`), and version +comparison, schema validation, and the merge rules apply identically to a self-hosted +feed. + +--- + +## Related docs + +- [`docs/security/ERROR_SANITIZATION.md`](../security/ERROR_SANITIZATION.md) — the + error-response pattern the three `/api/radar/*` routes follow. +- [`docs/reference/ENVIRONMENT.md`](../reference/ENVIRONMENT.md#27-radar-feed-self-hosting) + — `RADAR_FEED_URL` / `RADAR_FEED_PUBKEY` reference. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 7a425f9aa9..52b379ec42 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -43,6 +43,7 @@ lastUpdated: 2026-06-28 - [22. Debugging](#22-debugging) - [23. GitHub Integration](#23-github-integration) - [24. Skills Sandbox (v3.8.0+)](#24-skills-sandbox-v380) +- [27. Radar Feed (Self-Hosting)](#27-radar-feed-self-hosting) - [Deployment Scenarios](#deployment-scenarios) - [Audit: Removed / Dead Variables](#audit-removed--dead-variables) @@ -1246,6 +1247,22 @@ that should be able to run the docs translator. --- +## 27. Radar Feed (Self-Hosting) + +Optional add-on gated by the RADAR_ENABLED feature flag (default off — a feature +flag toggled via Settings/DB, not an env var; see +[docs/frameworks/RADAR.md](../frameworks/RADAR.md#flag-radar_enabled-default-off)). +Both variables below are optional overrides used only to point the client at a +self-hosted or forked feed instead of the default OmniRoute Radar feed. See +[docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full module doc. + +| Variable | Default | Source File | Description | +| -------------------- | ------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ | +| `RADAR_FEED_URL` | `https://radar.omniroute.dev` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. | +| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. | + +--- + ## Audit: Removed / Dead Variables The following variables appeared in previous versions of `.env.example` but have **no runtime references** in the current codebase. They have been removed: diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index a4312778d9..b71f717b97 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -324,7 +324,11 @@ export function stripVersionedToolModelPrefix(tools: unknown): void { for (const t of tools as Array>) { if (typeof t.model !== "string") continue; const model = t.model; - if (typeof t.type === "string" && /^[a-z][a-z0-9_]*_\d{8}$/.test(t.type) && model.includes("/")) { + if ( + typeof t.type === "string" && + /^[a-z][a-z0-9_]*_\d{8}$/.test(t.type) && + model.includes("/") + ) { t.model = model.split("/").pop(); } else { const prefix = CLAUDE_TOOL_MODEL_PREFIXES.find((candidate) => model.startsWith(candidate)); @@ -1571,7 +1575,8 @@ export class BaseExecutor { if (/content[_-]blocked/i.test(wafErrText)) { retryAttemptsByUrl[urlIndex] = (retryAttemptsByUrl[urlIndex] ?? 0) + 1; const wafAttempt = retryAttemptsByUrl[urlIndex]; - const wafBackoff = BaseExecutor.WAF_RETRY_CONFIG.delayMs * + const wafBackoff = + BaseExecutor.WAF_RETRY_CONFIG.delayMs * Math.pow(BaseExecutor.WAF_RETRY_CONFIG.backoffMultiplier, wafAttempt - 1); log?.debug?.( "WAF_RETRY", diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index 68cc708e4a..78ee1c8b0f 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -378,8 +378,7 @@ export function selectBetaFlags( // Code sends effort on every request and never sends ATU, so treating effort as // a proxy for ATU force-injects the heavy-agent pair the client never negotiated — // the same class of mutation #3415 closed. Opaque clients keep the full set. - const allowHeavy = - clientBetaSet === null || clientBetaSet.has("advanced-tool-use-2025-11-20"); + const allowHeavy = clientBetaSet === null || clientBetaSet.has("advanced-tool-use-2025-11-20"); const hasSystem = !!b.system && (typeof b.system === "string" || (Array.isArray(b.system) && b.system.length > 0)); diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index d75f3a72b8..49eaefc3c8 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -713,8 +713,7 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { // part. Place deltas at summary[summary_index] (growing the array) so // segments are preserved for later "\n\n" joining on the non-stream path, // instead of overwriting summary[0] regardless of index. - const summaryIndex = - typeof evt.summary_index === "number" ? evt.summary_index : 0; + const summaryIndex = typeof evt.summary_index === "number" ? evt.summary_index : 0; const part = summary[summaryIndex] && typeof summary[summaryIndex] === "object" ? { ...toRecord(summary[summaryIndex]) } @@ -733,8 +732,7 @@ export function parseSSEToResponsesOutput(rawSSE, fallbackModel) { ); const summary = Array.isArray(reasoningItem.summary) ? reasoningItem.summary : []; // #9500 — respect summary_index on the terminal done event too. - const summaryIndex = - typeof evt.summary_index === "number" ? evt.summary_index : 0; + const summaryIndex = typeof evt.summary_index === "number" ? evt.summary_index : 0; const part = summary[summaryIndex] && typeof summary[summaryIndex] === "object" ? { ...toRecord(summary[summaryIndex]) } diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 89807dfeca..6696de2119 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -161,9 +161,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { } parts.push({ - ...(embeddedThoughtSignature - ? { thoughtSignature: embeddedThoughtSignature } - : {}), + ...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}), functionCall: { ...(stripFunctionCallId ? {} : { id: block.id }), name: sanitizeToolName(block.name), diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 61d4f4283a..5bc9482508 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -108,14 +108,14 @@ export function geminiToClaudeResponse(chunk, state) { } const fc = part.functionCall; const rawToolName = fc.name; - const restoredToolName = normalizeToolName(state.toolNameMap?.get(rawToolName) || rawToolName); + const restoredToolName = normalizeToolName( + state.toolNameMap?.get(rawToolName) || rawToolName + ); const idx = state.contentBlockIndex++; const toolId = fc.id || `toolu_${Date.now()}_${idx}`; const signatureForToolCall = - (typeof hasThoughtSig === "string" && hasThoughtSig.length > 0 - ? hasThoughtSig - : null) || + (typeof hasThoughtSig === "string" && hasThoughtSig.length > 0 ? hasThoughtSig : null) || (typeof state.pendingThoughtSignature === "string" && state.pendingThoughtSignature.length > 0 ? state.pendingThoughtSignature diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index c2f81e035c..086c938fcf 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -687,7 +687,8 @@ export function createSSEStream(options: StreamOptions = {}) { // Responses API, Anthropic SSE, and Antigravity/cloudcode terminate on // their own protocol events (response.completed / message_stop / last // response candidate respectively). - const shouldEmitDoneTerminator = !clientExpectsResponsesStream && !clientExpectsClaudeStream && !clientExpectsAntigravityStream; + const shouldEmitDoneTerminator = + !clientExpectsResponsesStream && !clientExpectsClaudeStream && !clientExpectsAntigravityStream; let buffer = ""; let usage: UsageTokenRecord | null = null; diff --git a/scripts/build/postinstall.mjs b/scripts/build/postinstall.mjs index a2fa8acca4..9570691b46 100644 --- a/scripts/build/postinstall.mjs +++ b/scripts/build/postinstall.mjs @@ -24,7 +24,15 @@ * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/7802 */ -import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -78,7 +86,8 @@ function patchNodeGypCommonGypi() { const variablesMatch = content.match(/('variables'\s*:\s*\{)/); if (variablesMatch) { const insertPos = content.indexOf(variablesMatch[0]) + variablesMatch[0].length; - content = content.slice(0, insertPos) + "\n 'android_ndk_path%': ''," + content.slice(insertPos); + content = + content.slice(0, insertPos) + "\n 'android_ndk_path%': ''," + content.slice(insertPos); writeFileSync(commonGypi, content, "utf8"); console.log(` ✅ Patched common.gypi for Android at ${commonGypi}`); } diff --git a/scripts/query_providers.js b/scripts/query_providers.js index cfad330655..993bf33487 100644 --- a/scripts/query_providers.js +++ b/scripts/query_providers.js @@ -1,12 +1,16 @@ -const Database = require('better-sqlite3'); -const path = require('path'); -const dbPath = path.resolve(process.env.USERPROFILE, '.omniroute', 'storage.sqlite'); +const Database = require("better-sqlite3"); +const path = require("path"); +const dbPath = path.resolve(process.env.USERPROFILE, ".omniroute", "storage.sqlite"); try { const db = new Database(dbPath, { readonly: true }); - const rows = db.prepare(`SELECT id, provider, name, auth_type, is_active, api_key IS NOT NULL as has_api_key, access_token IS NOT NULL as has_access_token, refresh_token IS NOT NULL as has_refresh, last_error, test_status, provider_specific_data, updated_at FROM provider_connections WHERE provider LIKE '%anthropic%' OR provider='anthropic' OR provider LIKE '%claude%' OR provider='claude'`).all(); + const rows = db + .prepare( + `SELECT id, provider, name, auth_type, is_active, api_key IS NOT NULL as has_api_key, access_token IS NOT NULL as has_access_token, refresh_token IS NOT NULL as has_refresh, last_error, test_status, provider_specific_data, updated_at FROM provider_connections WHERE provider LIKE '%anthropic%' OR provider='anthropic' OR provider LIKE '%claude%' OR provider='claude'` + ) + .all(); console.log(JSON.stringify(rows, null, 2)); db.close(); } catch (err) { - console.error('ERROR', err && err.message); + console.error("ERROR", err && err.message); process.exit(2); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts index 97b9974532..7e5354bc6d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts @@ -24,7 +24,11 @@ type UseApiKeySaveParams = { setShowImportModal: (open: boolean) => void; setShowAddApiKeyModal: (open: boolean) => void; setSiliconFlowInitialBaseUrl: (url: string | undefined) => void; - notify: { success: (msg: string) => void; error: (msg: string) => void; info?: (msg: string) => void }; + notify: { + success: (msg: string) => void; + error: (msg: string) => void; + info?: (msg: string) => void; + }; t: ProviderMessageTranslator; }; diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx index 4faea53e21..f8da4b9f9f 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx @@ -290,9 +290,12 @@ const ProviderCard = forwardRef(function ) : null; const openRouterTooltipBits: string[] = []; - if (openRouterStat?.headquarters) openRouterTooltipBits.push(`HQ: ${openRouterStat.headquarters}`); + if (openRouterStat?.headquarters) + openRouterTooltipBits.push(`HQ: ${openRouterStat.headquarters}`); if (openRouterStat?.dataPolicy?.training === false) { - openRouterTooltipBits.push(providerText(t, "openRouterNoTraining", "Does not train on prompts")); + openRouterTooltipBits.push( + providerText(t, "openRouterNoTraining", "Does not train on prompts") + ); } if (openRouterStat?.dataPolicy?.retainsPrompts === false) { openRouterTooltipBits.push(providerText(t, "openRouterNoRetention", "Does not retain prompts")); diff --git a/src/app/(dashboard)/dashboard/providers/context/openRouterProviderStatsContext.tsx b/src/app/(dashboard)/dashboard/providers/context/openRouterProviderStatsContext.tsx index 83487c514c..d2959ad798 100644 --- a/src/app/(dashboard)/dashboard/providers/context/openRouterProviderStatsContext.tsx +++ b/src/app/(dashboard)/dashboard/providers/context/openRouterProviderStatsContext.tsx @@ -10,8 +10,7 @@ import type { OpenRouterProviderStatsEntry } from "../providerPageUtils"; * for providers OpenRouter doesn't know about simply get `undefined`. */ const EMPTY_STATS_MAP: ReadonlyMap = new Map(); -const Context = - createContext>(EMPTY_STATS_MAP); +const Context = createContext>(EMPTY_STATS_MAP); export function OpenRouterProviderStatsProvider({ entries, diff --git a/src/app/(dashboard)/dashboard/providers/hooks/useProviderUrlFilters.ts b/src/app/(dashboard)/dashboard/providers/hooks/useProviderUrlFilters.ts index 02bd84d49c..e9ce89754e 100644 --- a/src/app/(dashboard)/dashboard/providers/hooks/useProviderUrlFilters.ts +++ b/src/app/(dashboard)/dashboard/providers/hooks/useProviderUrlFilters.ts @@ -2,10 +2,7 @@ import { useEffect, useState } from "react"; import type { ReadonlyURLSearchParams } from "next/navigation"; -import { - readProviderFiltersFromUrl, - syncProviderFiltersToUrl, -} from "../providerPageUtils"; +import { readProviderFiltersFromUrl, syncProviderFiltersToUrl } from "../providerPageUtils"; import { readProviderDisplayModePreference, type ProviderDisplayMode, diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index c5f537a61e..a0984d1c9a 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -818,472 +818,195 @@ export default function ProvidersPage() { return ( -
- {showFirstProviderHint && ( - -
-
- dns -
-

- {t("addFirstProvider") || "Add your first provider"} -

-

- {t("addFirstProviderDesc") || - "Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."} -

-
- - - help - {t("learnMore") || "Learn more"} - -
-
-
- )} - - { - setShowFreeOnly(freeOnly); - setActiveCategory(freeOnly ? null : category); - }} - onDisplayModeChange={setProviderDisplayMode} - onNewProvider={() => router.push("/dashboard/providers/new")} - onImportFromFile={() => setShowImportFromFileModal(true)} - searchQuery={searchQuery} - setModelSearchQuery={setModelSearchQuery} - setSearchQuery={setSearchQuery} - showFreeOnly={showFreeOnly} - summaryStats={summaryStats} - t={t} - tc={tc} - testingMode={testingMode} - /> - - {/* Expiration Banner */} - {expirations?.summary && - (expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && ( -
0 - ? "bg-red-500/10 border-red-500/20" - : "bg-amber-500/10 border-amber-500/20" - }`} - > - 0 ? "text-red-500" : "text-amber-500" - }`} - > - {expirations.summary.expired > 0 ? "error" : "warning"} - -
-

0 ? "text-red-500" : "text-amber-500"}`} - > - {expirations.summary.expired > 0 - ? t("expirationBannerExpired", { count: expirations.summary.expired }) - : t("expirationBannerExpiringSoon", { - count: expirations.summary.expiringSoon, - })} -

-

- {expirations.summary.expired > 0 - ? t("expirationBannerExpiredDesc") - : t("expirationBannerExpiringSoonDesc")} +

+ {showFirstProviderHint && ( + +
+
+ dns +
+

+ {t("addFirstProvider") || "Add your first provider"} +

+

+ {t("addFirstProviderDesc") || + "Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."}

+
+ + + help + {t("learnMore") || "Learn more"} + +
-
+ )} - {isCompactProviderDisplay ? ( - compactProviderEntries.length > 0 ? ( -
- {compactProviderEntries.map((entry) => ( - - handleToggleProvider(entry.providerId, entry.toggleAuthType, active) - } - /> - ))} -
+ { + setShowFreeOnly(freeOnly); + setActiveCategory(freeOnly ? null : category); + }} + onDisplayModeChange={setProviderDisplayMode} + onNewProvider={() => router.push("/dashboard/providers/new")} + onImportFromFile={() => setShowImportFromFileModal(true)} + searchQuery={searchQuery} + setModelSearchQuery={setModelSearchQuery} + setSearchQuery={setSearchQuery} + showFreeOnly={showFreeOnly} + summaryStats={summaryStats} + t={t} + tc={tc} + testingMode={testingMode} + /> + + {/* Expiration Banner */} + {expirations?.summary && + (expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && ( +
0 + ? "bg-red-500/10 border-red-500/20" + : "bg-amber-500/10 border-amber-500/20" + }`} + > + 0 ? "text-red-500" : "text-amber-500" + }`} + > + {expirations.summary.expired > 0 ? "error" : "warning"} + +
+

0 ? "text-red-500" : "text-amber-500"}`} + > + {expirations.summary.expired > 0 + ? t("expirationBannerExpired", { count: expirations.summary.expired }) + : t("expirationBannerExpiringSoon", { + count: expirations.summary.expiringSoon, + })} +

+

+ {expirations.summary.expired > 0 + ? t("expirationBannerExpiredDesc") + : t("expirationBannerExpiringSoonDesc")} +

+
+
+ )} + + {isCompactProviderDisplay ? ( + compactProviderEntries.length > 0 ? ( +
+ {compactProviderEntries.map((entry) => ( + + handleToggleProvider(entry.providerId, entry.toggleAuthType, active) + } + /> + ))} +
+ ) : ( +
+ search_off + {providerText(t, "noProvidersMatch", "No providers match your search.")} +
+ ) ) : ( -
- search_off - {providerText(t, "noProvidersMatch", "No providers match your search.")} -
- ) - ) : ( - <> - {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */} - {showSection("compatible") && ( -
-
-

- {t("compatibleProviders")}{" "} - - -

-
- {(compatibleProviders.length > 0 || - anthropicCompatibleProviders.length > 0 || - ccCompatibleProviders.length > 0) && ( - - )} - {ccCompatibleProviderEnabled && ( - - )} - - -
-
-

{t("compatibleProvidersDesc")}

- {compatibleProviders.length === 0 && - anthropicCompatibleProviders.length === 0 && - ccCompatibleProviders.length === 0 ? ( -
- extension - {t("noCompatibleYet")} -
- ) : ( -
- {compatibleProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - - handleToggleProvider(providerId, toggleAuthType, active) - } - /> - ) - )} -
- )} -
- )} - - {/* OAuth Providers (including providers that expose free tiers via OAuth) */} - {showSection("oauth") && ( -
-
-

- {t("oauthProviders")}{" "} - - !IDE_PROVIDER_IDS.has(e.providerId)) - )} - /> -

-
- {oauthEnvRepairStatus?.available && oauthEnvRepairStatus.missingCount > 0 && ( - - )} - -
-
-

{t("oauthProvidersDesc")}

-
- {oauthProviderEntries - .filter((e) => !IDE_PROVIDER_IDS.has(e.providerId)) - .map(({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - - handleToggleProvider(providerId, toggleAuthType, active) - } + className="size-2.5 rounded-full bg-orange-500" + title={t("compatibleLabel")} /> - ))} -
-
- )} - - {/* IDE Providers (Cursor, Zed, Trae) — editors with built-in AI subscription */} - {showSection("ide") && ( -
-
-

- {t("ideProviders") || "IDE Providers"}{" "} - - -

- -
-

- {t("ideProvidersDesc") || - "Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."} -

- {ideProviderEntries.length === 0 ? ( -
- {t("noIdeProviders") || "No IDE providers match the current filters."} -
- ) : ( -
- {ideProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - - handleToggleProvider(providerId, toggleAuthType, active) - } - /> - ) - )} -
- )} -
- )} - - {/* Web / Cookie Providers */} - {showSection("web") && webCookieProviderEntries.length > 0 && ( -
-
-

- {t("webCookieProviders")}{" "} - - -

- -
-

{t("webCookieProvidersDesc")}

-
- {webCookieProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} - - {/* Free Tier Providers */} - {showSection("free") && freeSectionEntries.length > 0 && ( -
-
-
-

- {t("freeTierProviders")} - - +

-

{t("freeAggregated")}

+
+ {(compatibleProviders.length > 0 || + anthropicCompatibleProviders.length > 0 || + ccCompatibleProviders.length > 0) && ( + + )} + {ccCompatibleProviderEnabled && ( + + )} + + +
- -
-
- {freeSectionEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - - handleToggleProvider(providerId, toggleAuthType, active) - } - /> - ) - )} -
-
- )} - - {/* API Key Providers — fixed list */} - {showSection("apikey") && ( -
-
-

- {t("apiKeyProviders")}{" "} - - -

- -
-

{t("apiKeyProvidersDesc")}

- {llmProviderEntries.length > 0 && ( -
-

- {t("llmProviders")} -

+

{t("compatibleProvidersDesc")}

+ {compatibleProviders.length === 0 && + anthropicCompatibleProviders.length === 0 && + ccCompatibleProviders.length === 0 ? ( +
+ extension + {t("noCompatibleYet")} +
+ ) : (
- {llmProviderEntries.map( + {compatibleProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( -
- )} -
- )} - - {/* No Auth Providers */} - {showSection("noauth") && - !showFreeOnly && - (noAuthEntriesAll.length > 0 || blockedNoAuthEntries.length > 0) && ( - notify.error(msg)} - testingMode={testingMode} - onBatchTest={handleBatchTest} - onToggleProvider={handleToggleProvider} - /> + )} +
)} - {/* Upstream Proxy Providers */} - {showSection("proxy") && upstreamProxyEntries.length > 0 && ( -
-
-

- {t("upstreamProxyProviders")}{" "} - - -

- + )} + +
+
+

{t("oauthProvidersDesc")}

+
+ {oauthProviderEntries + .filter((e) => !IDE_PROVIDER_IDS.has(e.providerId)) + .map(({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ))} +
+
+ )} + + {/* IDE Providers (Cursor, Zed, Trae) — editors with built-in AI subscription */} + {showSection("ide") && ( +
+
+

+ {t("ideProviders") || "IDE Providers"}{" "} + + +

+ -
-

{t("upstreamProxyProvidersDesc")}

-
- {upstreamProxyEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} - - {/* Web Fetch Providers */} - {showSection("webfetch") && webFetchEntries.length > 0 && ( -
-
-

- {t("webFetchProvidersHeading")}{" "} - - -

-
-

{t("webFetchProvidersDesc")}

-
- {webFetchEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - - handleToggleProvider(providerId, toggleAuthType, active) - } - /> - ) + + play_arrow + + {testingMode === "ide" ? t("testing") : t("testAll")} + +
+

+ {t("ideProvidersDesc") || + "Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."} +

+ {ideProviderEntries.length === 0 ? ( +
+ {t("noIdeProviders") || "No IDE providers match the current filters."} +
+ ) : ( +
+ {ideProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
)}
-
- )} + )} - {/* Aggregators Gateways */} - {showSection("apikey") && aggregatorProviderEntries.length > 0 && ( -
-
-

- {t("aggregatorsGateways")}{" "} - - -

-
-

{t("aggregatorsGatewaysDesc")}

-
- {aggregatorProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - - handleToggleProvider(providerId, toggleAuthType, active) - } + {/* Web / Cookie Providers */} + {showSection("web") && webCookieProviderEntries.length > 0 && ( +
+
+

+ {t("webCookieProviders")}{" "} + - ) - )} -

-
- )} - - {/* Enterprise & Cloud */} - {showSection("apikey") && enterpriseProviderEntries.length > 0 && ( -
-
-

- {t("enterpriseCloud")}{" "} - - -

-
-

{t("enterpriseCloudDesc")}

-
- {enterpriseProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - - handleToggleProvider(providerId, toggleAuthType, active) - } - /> - ) - )} -
-
- )} - - {/* Cloud Agent Providers */} - {showSection("cloud") && cloudAgentProviderEntries.length > 0 && ( -
-
-

- {t("cloudAgentProviders")}{" "} - - -

- + + play_arrow + + {testingMode === "web-cookie" ? t("testing") : t("testAll")} + +
+

{t("webCookieProvidersDesc")}

+
+ {webCookieProviderEntries.map( + ({ providerId, provider, stats, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
-

{t("cloudAgentProvidersDesc")}

-
- {cloudAgentProviderEntries.map( - ({ providerId, provider, stats, toggleAuthType }) => ( - - handleToggleProvider(providerId, toggleAuthType, active) - } - /> - ) - )} -
-
- )} + )} - {/* Local / Self-Hosted Providers */} - {showSection("local") && localProviderEntries.length > 0 && ( -
-
-

- {t("localProviders")}{" "} - - -

- + + play_arrow + + {testingMode === "free" ? t("testing") : t("testAll")} + +
+
+ {freeSectionEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
-

{t("localProvidersDesc")}

-
- {localProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} + )} - {/* Search Providers */} - {showSection("search") && searchProviderEntries.length > 0 && ( -
-
-

- {t("searchProvidersHeading")}{" "} - - -

- -
-

{t("searchProvidersDesc")}

-
- {searchProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} - - {/* Embeddings & Rerank */} - {showSection("apikey") && embeddingRerankProviderEntries.length > 0 && ( -
-
-

- {t("embeddingRerankProviders")}{" "} - - -

-
-

{t("embeddingRerankProvidersDesc")}

-
- {embeddingRerankProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - - handleToggleProvider(providerId, toggleAuthType, active) - } - /> - ) + + play_arrow + + {testingMode === "apikey" ? t("testing") : t("testAll")} + +
+

{t("apiKeyProvidersDesc")}

+ {llmProviderEntries.length > 0 && ( +
+

+ {t("llmProviders")} +

+
+ {llmProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
)}
-
- )} + )} - {/* Image Providers */} - {showSection("apikey") && imageProviderEntries.length > 0 && ( -
-
-

- {t("imageProviders")}{" "} - - -

-
-

{t("imageProvidersDesc")}

-
- {imageProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - - handleToggleProvider(providerId, toggleAuthType, active) - } + {/* No Auth Providers */} + {showSection("noauth") && + !showFreeOnly && + (noAuthEntriesAll.length > 0 || blockedNoAuthEntries.length > 0) && ( + notify.error(msg)} + testingMode={testingMode} + onBatchTest={handleBatchTest} + onToggleProvider={handleToggleProvider} + /> + )} + + {/* Upstream Proxy Providers */} + {showSection("proxy") && upstreamProxyEntries.length > 0 && ( +
+
+

+ {t("upstreamProxyProviders")}{" "} + - ) - )} -

-
- )} - - {/* Audio Only Providers */} - {showSection("audio") && audioProviderEntries.length > 0 && ( -
-
-

- {t("audioProvidersHeading")}{" "} - - -

- -
-

{t("audioProvidersDesc")}

-
- {audioProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} - - {/* Video Generation */} - {showSection("apikey") && videoProviderEntries.length > 0 && ( -
-
-

- {t("videoProviders")}{" "} - - -

-
-

{t("videoProvidersDesc")}

-
- {videoProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + play_arrow + + {testingMode === "upstream-proxy" ? t("testing") : t("testAll")} + +
+

{t("upstreamProxyProvidersDesc")}

+
+ {upstreamProxyEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( handleToggleProvider(providerId, toggleAuthType, active) } /> - ) - )} + ))} +
-
- )} - - )} + )} + + {/* Web Fetch Providers */} + {showSection("webfetch") && webFetchEntries.length > 0 && ( +
+
+

+ {t("webFetchProvidersHeading")}{" "} + + +

+
+

{t("webFetchProvidersDesc")}

+
+ {webFetchEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} + + {/* Aggregators Gateways */} + {showSection("apikey") && aggregatorProviderEntries.length > 0 && ( +
+
+

+ {t("aggregatorsGateways")}{" "} + + +

+
+

{t("aggregatorsGatewaysDesc")}

+
+ {aggregatorProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} + + {/* Enterprise & Cloud */} + {showSection("apikey") && enterpriseProviderEntries.length > 0 && ( +
+
+

+ {t("enterpriseCloud")}{" "} + + +

+
+

{t("enterpriseCloudDesc")}

+
+ {enterpriseProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} + + {/* Cloud Agent Providers */} + {showSection("cloud") && cloudAgentProviderEntries.length > 0 && ( +
+
+

+ {t("cloudAgentProviders")}{" "} + + +

+ +
+

{t("cloudAgentProvidersDesc")}

+
+ {cloudAgentProviderEntries.map( + ({ providerId, provider, stats, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} + + {/* Local / Self-Hosted Providers */} + {showSection("local") && localProviderEntries.length > 0 && ( +
+
+

+ {t("localProviders")}{" "} + + +

+ +
+

{t("localProvidersDesc")}

+
+ {localProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ))} +
+
+ )} + + {/* Search Providers */} + {showSection("search") && searchProviderEntries.length > 0 && ( +
+
+

+ {t("searchProvidersHeading")}{" "} + + +

+ +
+

{t("searchProvidersDesc")}

+
+ {searchProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ))} +
+
+ )} + + {/* Embeddings & Rerank */} + {showSection("apikey") && embeddingRerankProviderEntries.length > 0 && ( +
+
+

+ {t("embeddingRerankProviders")}{" "} + + +

+
+

{t("embeddingRerankProvidersDesc")}

+
+ {embeddingRerankProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} + + {/* Image Providers */} + {showSection("apikey") && imageProviderEntries.length > 0 && ( +
+
+

+ {t("imageProviders")}{" "} + + +

+
+

{t("imageProvidersDesc")}

+
+ {imageProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} + + {/* Audio Only Providers */} + {showSection("audio") && audioProviderEntries.length > 0 && ( +
+
+

+ {t("audioProvidersHeading")}{" "} + + +

+ +
+

{t("audioProvidersDesc")}

+
+ {audioProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ))} +
+
+ )} + + {/* Video Generation */} + {showSection("apikey") && videoProviderEntries.length > 0 && ( +
+
+

+ {t("videoProviders")}{" "} + + +

+
+

{t("videoProvidersDesc")}

+
+ {videoProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} + + )} - setShowAddCompatibleModal(false)} - onCreated={(node) => { - setProviderNodes((prev) => upsertProviderNodeById(prev, node)); - setShowAddCompatibleModal(false); - router.push(`/dashboard/providers/${node.id}`); - }} - /> - setShowAddAnthropicCompatibleModal(false)} - onCreated={(node) => { - setProviderNodes((prev) => upsertProviderNodeById(prev, node)); - setShowAddAnthropicCompatibleModal(false); - router.push(`/dashboard/providers/${node.id}`); - }} - /> - {ccCompatibleProviderEnabled && ( setShowAddCcCompatibleModal(false)} + isOpen={showAddCompatibleModal} + mode="openai" + onClose={() => setShowAddCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => upsertProviderNodeById(prev, node)); - setShowAddCcCompatibleModal(false); + setShowAddCompatibleModal(false); router.push(`/dashboard/providers/${node.id}`); }} /> - )} - setShowImportFromFileModal(false)} - onImported={async () => setConnections((await loadProviderPageData()).connections)} - /> - {/* Test Results Modal */} - {testResults && ( -
setTestResults(null)} - > -
+ setShowAddAnthropicCompatibleModal(false)} + onCreated={(node) => { + setProviderNodes((prev) => upsertProviderNodeById(prev, node)); + setShowAddAnthropicCompatibleModal(false); + router.push(`/dashboard/providers/${node.id}`); + }} + /> + {ccCompatibleProviderEnabled && ( + setShowAddCcCompatibleModal(false)} + onCreated={(node) => { + setProviderNodes((prev) => upsertProviderNodeById(prev, node)); + setShowAddCcCompatibleModal(false); + router.push(`/dashboard/providers/${node.id}`); + }} + /> + )} + setShowImportFromFileModal(false)} + onImported={async () => setConnections((await loadProviderPageData()).connections)} + /> + {/* Test Results Modal */} + {testResults && (
e.stopPropagation()} + className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" + onClick={() => setTestResults(null)} > -
-

{t("testResults")}

- -
-
- +
+
e.stopPropagation()} + > +
+

{t("testResults")}

+ +
+
+ +
-
- )} -
+ )} +
); } diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index b48c434e60..be0bf0f974 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -627,6 +627,8 @@ export async function loadProviderPageData( ? settingsData.blockedProviders : null, settings: settingsData ?? null, - openRouterProviderStats: Array.isArray(openRouterStatsData?.data) ? openRouterStatsData.data : [], + openRouterProviderStats: Array.isArray(openRouterStatsData?.data) + ? openRouterStatsData.data + : [], }; } diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx new file mode 100644 index 0000000000..7cacdf64ed --- /dev/null +++ b/src/app/(dashboard)/dashboard/radar/page.tsx @@ -0,0 +1,411 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { Card } from "@/shared/components"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface RadarMeta { + version: string; + tier: string; + fetchedAt: string; +} + +interface RadarMergedEntry { + provider: string; + modelId: string; + displayName: string; + monthlyTokens: number; + creditTokens: number; + freeType: string; + poolKey: string | null; + tos: string; + trainsOnPrompts?: boolean; + enabled?: boolean; + origin: "baseline" | "radar" | "local"; + disabledBy?: "radar"; + // Extended feed fields (present when origin=radar) + contextWindow?: number | null; + capabilities?: { tools: boolean; vision: boolean; thinking: boolean }; + budget?: { kind: string; tokensPerMonth?: number; poolId?: string }; + limits?: { rpm: number | null; rpd: number | null; tpm: number | null; tpd: number | null }; + setup?: { keyUrl: string | null; steps: string[] } | null; +} + +type PageState = "flag_off" | "optin_pending" | "empty" | "populated"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Determine the page state from the fetch result. */ +export function resolveRadarPageState( + flagOn: boolean, + optedIn: boolean, + hasEntries: boolean, +): PageState { + if (!flagOn) return "flag_off"; + if (!optedIn) return "optin_pending"; + if (!hasEntries) return "empty"; + return "populated"; +} + +/** Relative time string (e.g., "3h ago", "2d ago"). */ +function relativeTime(isoDate: string): string { + const now = Date.now(); + const then = new Date(isoDate).getTime(); + const diffMs = now - then; + if (diffMs < 0) return "just now"; + const mins = Math.floor(diffMs / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +/** Format token count as human-readable. */ +function formatTokens(n: number): string { + if (n === 0) return "rate-only"; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return String(n); +} + +/** Budget display string. */ +function budgetLabel(entry: RadarMergedEntry): string { + if (entry.budget?.kind === "shared_pool") { + return `shared (${formatTokens(entry.budget.tokensPerMonth ?? entry.monthlyTokens)}/mo)`; + } + if (entry.budget?.kind === "rate_only" || entry.monthlyTokens === 0) return "rate-only"; + return `${formatTokens(entry.monthlyTokens)}/mo`; +} + +// --------------------------------------------------------------------------- +// Page Component +// --------------------------------------------------------------------------- + +export default function RadarPage() { + const t = useTranslations("radarPage"); + const [entries, setEntries] = useState([]); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [optIn, setOptIn] = useState(null); + const [activating, setActivating] = useState(false); + const [syncing, setSyncing] = useState(false); + + // Fetch catalog + const fetchCatalog = useCallback(async () => { + setLoading(true); + setError(""); + try { + const res = await fetch("/api/radar/catalog"); + if (res.status === 404) { + // Flag off — treat as not found + setOptIn(false); + setEntries([]); + setMeta(null); + setLoading(false); + return; + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setEntries(data.entries || []); + setMeta(data.meta || null); + } catch (err) { + setError(err instanceof Error ? err.message : t("errorLoading")); + } finally { + setLoading(false); + } + }, [t]); + + // Fetch settings to determine opt-in state + const fetchSettings = useCallback(async () => { + try { + // We don't have a GET /api/radar/settings — infer from catalog response: + // If catalog returns meta=null and entries are baseline-only, user hasn't opted in. + // A 404 means flag is off. + const res = await fetch("/api/radar/catalog"); + if (res.status === 404) { + setOptIn(false); + return; + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setEntries(data.entries || []); + setMeta(data.meta || null); + // If meta is null, the user hasn't synced yet (or hasn't opted in). + // We need to check opt-in state. Since there's no GET endpoint for settings, + // we infer: if flag is on and we got baseline, user may or may not be opted in. + // The activation flow handles this — we show the activation screen if meta is null. + setOptIn(null); // unknown — will determine from user action + } catch { + setOptIn(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + // Sync (defined before handleActivate which depends on it) + const handleSync = useCallback(async () => { + setSyncing(true); + setError(""); + try { + const res = await fetch("/api/radar/sync", { method: "POST" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (data.status === "updated" || data.status === "stale") { + await fetchCatalog(); + } else if (data.status === "error") { + setError(data.reason || t("syncFailed")); + } else if (data.status === "disabled") { + setError(t("flagDisabled")); + } else if (data.status === "opt_out") { + setOptIn(false); + } + } catch (err) { + setError(err instanceof Error ? err.message : t("syncFailed")); + } finally { + setSyncing(false); + } + }, [t, fetchCatalog]); + + // Activate opt-in + const handleActivate = useCallback(async () => { + setActivating(true); + try { + const res = await fetch("/api/radar/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ optIn: true }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setOptIn(true); + // After activation, trigger a sync + await handleSync(); + } catch (err) { + setError(err instanceof Error ? err.message : t("activationFailed")); + } finally { + setActivating(false); + } + }, [t, handleSync]); + + // Determine effective state + const flagOn = optIn !== false || entries.length > 0 || meta !== null; + const pageState = resolveRadarPageState( + optIn !== false, // if we got a 404, optIn=false => flag off + optIn === true, + entries.length > 0 && meta !== null, + ); + + // Flag off — render not-found + if (pageState === "flag_off" && !loading) { + notFound(); + } + + return ( +
+ {/* Header */} +
+
+

{t("title")}

+

{t("subtitle")}

+
+ {pageState === "populated" && ( + + )} +
+ + {/* Feed freshness header */} + {meta && ( +
+ + {t("feedVersion")}: {meta.version} + + + {t("feedTier")}:{" "} + + {meta.tier === "live" ? t("tierLive") : t("tierCommunity")} + + + + {t("feedFetched")}: {relativeTime(meta.fetchedAt)} + +
+ )} + + {error && ( +
{error}
+ )} + + {loading ? ( +
+
{t("loading")}
+
+ ) : ( + <> + {/* Opt-in pending */} + {pageState === "optin_pending" && ( + +
+
📡
+

{t("activateTitle")}

+

{t("activateDescription")}

+
+
+ + {t("privacyNoUpload")} +
+
+ + {t("privacyOnlySigned")} +
+
+ + {t("privacyLocalOnly")} +
+
+ +
+
+ )} + + {/* Empty cache — opted in but no data yet */} + {pageState === "empty" && ( + +
+

{t("emptyState")}

+ +
+
+ )} + + {/* Populated catalog table */} + {pageState === "populated" && ( + +
+ + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + + ))} + +
{t("colProvider")}{t("colModel")}{t("colQuota")}{t("colContext")}{t("colCapabilities")}{t("colTos")}
+
+ {entry.provider} + {entry.origin === "radar" && ( + + {t("newBadge")} + + )} + {entry.setup?.keyUrl && ( + + ⚙ + + )} +
+ {entry.enabled === false && entry.disabledBy === "radar" && ( +

{t("disabledByFeed")}

+ )} +
+ {entry.displayName} + {budgetLabel(entry)} + {entry.contextWindow + ? `${(entry.contextWindow / 1000).toFixed(0)}K` + : "—"} + +
+ {entry.capabilities?.tools && ( + + {t("capTools")} + + )} + {entry.capabilities?.vision && ( + + {t("capVision")} + + )} + {entry.capabilities?.thinking && ( + + {t("capThinking")} + + )} +
+
+ + {entry.tos} + +
+
+
+ )} + + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/radar/setup/page.tsx b/src/app/(dashboard)/dashboard/radar/setup/page.tsx new file mode 100644 index 0000000000..1bbbe6e65b --- /dev/null +++ b/src/app/(dashboard)/dashboard/radar/setup/page.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Card } from "@/shared/components"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Localized text: either a plain string or an {en, pt?} object. + * The renderer resolves the best locale with EN fallback (D25 compat). + */ +type LocalizedText = string | { en: string; pt?: string }; + +interface SetupInfo { + keyUrl: string | null; + steps: LocalizedText[]; +} + +interface ProviderSetupData { + provider: string; + setup: SetupInfo | null; + configured: boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Resolve a LocalizedText to a display string. */ +function resolveText(text: LocalizedText, locale: string): string { + if (typeof text === "string") return text; + if (locale === "pt" && text.pt) return text.pt; + return text.en; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function RadarSetupPage() { + const t = useTranslations("radarSetupPage"); + const searchParams = useSearchParams(); + const provider = searchParams.get("provider"); + const locale = "en"; // Could be derived from next-intl locale later + + const [setupData, setSetupData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + + // Fetch catalog to find the provider's setup data + useEffect(() => { + if (!provider) { + setLoading(false); + return; + } + + async function load() { + try { + const res = await fetch("/api/radar/catalog"); + if (res.status === 404) { + setError(t("flagDisabled")); + setLoading(false); + return; + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + + // Find ALL entries for this provider and extract setup from the first one that has it + const providerEntries = data.entries.filter( + (e: { provider: string }) => e.provider === provider, + ); + + if (providerEntries.length === 0) { + setError(t("providerNotFound", { provider })); + setLoading(false); + return; + } + + // Find setup info from feed entries (they carry the setup field) + const entryWithSetup = providerEntries.find( + (e: { setup?: SetupInfo | null }) => e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl), + ); + + // Check if provider is configured (has connections) + // We infer this from whether the provider exists in the catalog at all + // The actual connection check would need a separate API — for now we show + // the guide regardless + setSetupData({ + provider, + setup: entryWithSetup?.setup ?? null, + configured: false, // Will be enriched when connection-status API is available + }); + } catch (err) { + setError(err instanceof Error ? err.message : t("loadFailed")); + } finally { + setLoading(false); + } + } + + load(); + }, [provider, t]); + + // Test connection — uses the EXISTING connection-test endpoint + const handleTestConnection = useCallback(async () => { + if (!provider) return; + setTesting(true); + setTestResult(null); + try { + // The existing test endpoint is POST /api/providers/[id]/test + // We need the connection ID — for now we use the provider ID as a proxy. + // In a full implementation, the setup page would list connections for + // this provider and test each one. Here we test the first connection. + const res = await fetch(`/api/providers/${encodeURIComponent(provider)}/test`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + if (res.ok) { + setTestResult({ ok: true, message: t("testSuccess") }); + } else { + const data = await res.json().catch(() => null); + setTestResult({ + ok: false, + message: data?.error?.message || t("testFailed"), + }); + } + } catch { + setTestResult({ ok: false, message: t("testFailed") }); + } finally { + setTesting(false); + } + }, [provider, t]); + + if (!provider) { + return ( +
+

{t("title")}

+ +
{t("noProvider")}
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + ← {t("backToCatalog")} + +
+
+

{t("setupTitle", { provider })}

+

{t("setupSubtitle")}

+
+ + {error && ( +
{error}
+ )} + + {loading ? ( +
+
{t("loading")}
+
+ ) : setupData ? ( + <> + {/* Configured indicator */} + {setupData.configured && ( + +
+ + {t("providerConfigured")} +
+
+ )} + + {/* Key URL */} + {setupData.setup?.keyUrl && ( + +
+

{t("getApiKey")}

+ + {setupData.setup.keyUrl} + +
+
+ )} + + {/* Steps */} + {setupData.setup && setupData.setup.steps.length > 0 && ( + +
+

{t("setupSteps")}

+
    + {setupData.setup.steps.map((step, idx) => ( +
  1. + + {idx + 1} + + + {resolveText(step, locale)} + +
  2. + ))} +
+
+
+ )} + + {/* No guide available */} + {(!setupData.setup || setupData.setup.steps.length === 0) && !setupData.setup?.keyUrl && ( + +
+

{t("noGuide")}

+ + {t("visitDocs")} + +
+
+ )} + + {/* Test connection */} + +
+

{t("testConnection")}

+

{t("testDescription")}

+
+ + {testResult && ( + + {testResult.message} + + )} +
+
+
+ + {/* Add connection link */} + +
+

{t("addConnection")}

+

{t("addConnectionDescription")}

+ + {t("addConnectionLink")} + +
+
+ + ) : null} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx index 8a5fdb1a62..0a5c178a84 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx @@ -94,9 +94,7 @@ export default function AgentBridgePageClient({ const res = await fetch("/api/tools/agent-bridge/server", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - sudoPassword ? { action, sudoPassword } : { action } - ), + body: JSON.stringify(sudoPassword ? { action, sudoPassword } : { action }), }); const payload = (await res.json().catch(() => ({}))) as { error?: { message?: string }; @@ -138,37 +136,43 @@ export default function AgentBridgePageClient({ // ── Upstream CA ─────────────────────────────────────────────────────────── - const handleUpstreamCaSave = useCallback(async (path: string) => { - setActionError(null); - try { - const res = await fetch("/api/tools/agent-bridge/upstream-ca", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - await refresh(); - } catch (err) { - setActionError(err instanceof Error ? err.message : t("unknownError")); - } - }, [refresh, t]); + const handleUpstreamCaSave = useCallback( + async (path: string) => { + setActionError(null); + try { + const res = await fetch("/api/tools/agent-bridge/upstream-ca", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await refresh(); + } catch (err) { + setActionError(err instanceof Error ? err.message : t("unknownError")); + } + }, + [refresh, t] + ); // ── Bypass list ─────────────────────────────────────────────────────────── - const handleBypassSave = useCallback(async (patterns: string[]) => { - setActionError(null); - try { - const res = await fetch("/api/tools/agent-bridge/bypass", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ patterns }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - await refresh(); - } catch (err) { - setActionError(err instanceof Error ? err.message : t("unknownError")); - } - }, [refresh, t]); + const handleBypassSave = useCallback( + async (patterns: string[]) => { + setActionError(null); + try { + const res = await fetch("/api/tools/agent-bridge/bypass", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ patterns }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + await refresh(); + } catch (err) { + setActionError(err instanceof Error ? err.message : t("unknownError")); + } + }, + [refresh, t] + ); // ── DNS toggle ──────────────────────────────────────────────────────────── @@ -180,9 +184,7 @@ export default function AgentBridgePageClient({ const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/dns`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - password ? { enabled, sudoPassword: password } : { enabled } - ), + body: JSON.stringify(password ? { enabled, sudoPassword: password } : { enabled }), }); if (!res.ok) { const payload = (await res.json().catch(() => ({}))) as { diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx index 4e54226fc7..fb5eab24c5 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx @@ -21,7 +21,6 @@ function hasAcceptedRisk(agentId: string): boolean { } } - interface AgentCardProps { target: MitmTargetView; agentState: AgentStateEntry | undefined; diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx index fa4365b385..15fdafee40 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx @@ -4,7 +4,11 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import { AgentCard } from "./AgentCard"; import type { MitmTargetView } from "@/mitm/types"; -import type { AgentStateEntry, AgentMappingsMap, AgentBridgeServerState } from "../AgentBridgePageClient"; +import type { + AgentStateEntry, + AgentMappingsMap, + AgentBridgeServerState, +} from "../AgentBridgePageClient"; import type { MappingRow } from "./ModelMappingTable"; interface AgentListProps { diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelMappingTable.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelMappingTable.tsx index ac67e9a7b2..d9d9759522 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelMappingTable.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/ModelMappingTable.tsx @@ -55,7 +55,8 @@ export function ModelMappingTable({ agentId, mappings, onSave }: ModelMappingTab {rows.length === 0 ? (

- {t("noMappingsDesc") || "No model mappings configured yet. Add mappings to route agent requests through OmniRoute."} + {t("noMappingsDesc") || + "No model mappings configured yet. Add mappings to route agent requests through OmniRoute."}