From e7f6b1d130eefd044e8b16329f1c3e1bbe5c4086 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Thu, 6 Aug 2026 05:58:58 -0300 Subject: [PATCH] feat(radar): flag-gated signed free-model catalog overlay (#9515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): add RADAR_ENABLED flag (default off) * feat(db): radar feed cache + settings with encrypted supporter key * feat(radar): signed feed sync with pinned key and version floor - feedSchema.ts: Zod v4 schema mirroring the server feed format (discriminated union on budget.kind, enum constraints, etc.) - pinnedKeys.ts: Ed25519 SPKI-DER pinned key + env override for forks - verify.ts: signature verification over exact wire bytes, never throws - sync.ts: full download/verify/validate/cache pipeline with injectable deps, feature-flag gate, opt-in gate, version floor (numeric compare), and sanitized error reasons (no stack traces) - 40 tests covering: contract hash, key handling, sig verification, schema validation, version compare, all sync paths (disabled, opt_out, invalid_signature, invalid_schema, stale, updated, error), auth header injection, and cache-untouched assertions for every failure mode * feat(radar): read-time overlay merge rules over the free catalog Pure function applyFeed() merges the cached Radar feed over the static baseline catalog at read time, honoring 4 rules: 1. Feed never overwrites a local override field. 2. enabled:false disables the entry with disabledBy:"radar" provenance. 3. User-added entry NOT in the feed survives untouched. 4. User deletion tombstone prevents feed resurrection. getRadarCatalog() accessor in index.ts: flag off / no cache / corrupt payload all fall back to baseline. Valid cache applies the overlay and returns feed metadata (version, tier, fetchedAt). TDD: 19 tests (4 rules + dedup + origin + accessor flag/cache/corrupt/ valid/bad-feed + baselineToMergedEntries converter). * feat(dashboard): radar catalog and guided setup screens - API routes: GET /api/radar/catalog, POST /api/radar/sync, POST /api/radar/settings - All gated on RADAR_ENABLED flag (404 when off) - Error responses via buildErrorBody(), never raw stack/message - Settings never echoes clear supporter key (masked omr_****) - Sync delegates to syncRadar() server-side, never proxies feed URL - Dashboard pages: - /dashboard/radar: 4 states (flag off, opt-in pending, empty, populated) - /dashboard/radar/setup?provider=X: guided setup with steps, key URL, test connection - Uses existing Card component and next-intl patterns - Sidebar: radar entry in costs group with icon - i18n: pt-BR and en keys for radarPage and radarSetupPage namespaces - Tests: - radar-api-routes.test.ts: 11 tests (flag-off 404, flag-on shape, error sanitization) - radar-page-state.test.ts: 5 tests (pure state logic) - All 90 radar tests pass (including prior 74) * docs(radar): module doc and flag-off inertia test Add docs/frameworks/RADAR.md covering the flag gate, the separate data-sync opt-in and privacy promise, the Ed25519 signature/pinned-key security model, tiers, the read-time overlay merge rules, and the self-hosting env vars — plus index entries in CLAUDE.md/AGENTS.md/docs/README.md/REPOSITORY_MAP.md. Document RADAR_FEED_URL and RADAR_FEED_PUBKEY in .env.example and docs/reference/ENVIRONMENT.md to satisfy check:env-doc-sync, which was failing on this branch since the sync.ts commit added the reads. Add tests/unit/radar-inertia.test.ts as the single canonical place asserting the "RADAR_ENABLED off => zero behavioral delta" claim end to end: the three /api/radar/* routes 404, the flag resolves to the definition default with no override, getRadarCatalog() returns exactly the baseline without touching the cache, and computeFreeModelTotals() keeps its pinned values with the Radar module imported alongside it. * fix(db): renumber radar migration to 135 after collision with 134 The base branch introduced 134_proxy_logs_egress_ip while this branch carried 134_radar_cache_settings; the migration runner rejects duplicate numeric prefixes. This migration has never been applied to a real database (the PR is unmerged), so no retroactive isSchemaAlreadyApplied guard is needed. * i18n(radar): translate radar catalog and setup strings to all locales The UI-coverage ratchet measures (present - placeholder) / total_en, so the __MISSING__ sentinels that i18n:sync-ui writes do not count as covered — only real translations restore the metric. Scoped to this PR's namespaces (radarPage, radarSetupPage, sidebar.radar*) instead of a bulk sync, which would have pulled ~978 unrelated pending keys into this diff. Placeholders and code identifiers verified preserved across all 1682 strings. * fix(radar): trust the served-tier header instead of the signed body field The signed feed body always carries tier:"live" by design (one signed artifact per version — rewriting the field server-side per request would break the exact-bytes Ed25519 signature). The server now returns the tier ACTUALLY served via the x-omniroute-feed-tier response header, so free users on a delayed community snapshot no longer see "Ao vivo (tempo real)" in the UI. sync.ts now reads and validates that header (falling back to the body's tier only when the header is absent or holds an unrecognized value) and stores the served tier in the cache; index.ts already surfaces cache.tier to the UI unchanged. * test(combo): shorten an assert message that exceeded the line limit The assertion added by #9507 was 104 chars, so prettier reformatted it into five lines on the next commit that touched the file, pushing it past its frozen size (3449) and failing check:file-size. The message is shortened (the issue reference stays in the comment directly above); the assertion itself is unchanged, and the file is back to 3448 lines and prettier-clean. * i18n(radar): use the canonical zh-TW glossary terms The machine translation produced retired renderings the glossary gate blocks: 供應商 for provider (canonical 提供者) and 文檔 for documentation (canonical 文件). Fixed across the 11 affected radar strings; tests/unit/i18n-glossary-consistency-check.test.ts is back to 17/17. * fix(radar): point the default feed URL at the domain that exists radar.omniroute.dev was a placeholder for a domain that was never registered, so an out-of-the-box sync would fail DNS resolution for every user. The live feed is served from radar.omniroute.online (the subdomain the design always specified), now behind Cloudflare TLS. Forks still override it via RADAR_FEED_URL. --------- Co-authored-by: diegosouzapw --- .env.example | 23 +- @omniroute/opencode-plugin/src/index.ts | 41 +- AGENTS.md | 3 +- bin/cli/commands/doctor.mjs | 25 +- bin/cli/commands/oauth.mjs | 4 +- bin/cli/commands/setup-claude.mjs | 3 +- bin/cli/commands/update.mjs | 8 +- bin/cli/runtime/nativeDeps.mjs | 6 +- docs/README.md | 1 + docs/architecture/REPOSITORY_MAP.md | 2 + docs/frameworks/RADAR.md | 236 ++ docs/reference/ENVIRONMENT.md | 17 + open-sse/executors/base.ts | 9 +- open-sse/executors/claudeIdentity.ts | 3 +- open-sse/handlers/sseParser.ts | 6 +- .../translator/request/claude-to-gemini.ts | 4 +- .../translator/response/gemini-to-claude.ts | 8 +- open-sse/utils/stream.ts | 3 +- scripts/build/postinstall.mjs | 13 +- scripts/query_providers.js | 14 +- .../providers/[id]/hooks/useApiKeySave.ts | 6 +- .../providers/components/ProviderCard.tsx | 7 +- .../openRouterProviderStatsContext.tsx | 3 +- .../providers/hooks/useProviderUrlFilters.ts | 5 +- .../(dashboard)/dashboard/providers/page.tsx | 1892 +++++++++-------- .../dashboard/providers/providerPageUtils.ts | 4 +- src/app/(dashboard)/dashboard/radar/page.tsx | 411 ++++ .../dashboard/radar/setup/page.tsx | 284 +++ .../agent-bridge/AgentBridgePageClient.tsx | 70 +- .../agent-bridge/components/AgentCard.tsx | 1 - .../agent-bridge/components/AgentList.tsx | 6 +- .../components/ModelMappingTable.tsx | 3 +- .../steps/integrations/TelegramConfigForm.tsx | 7 +- src/app/api/radar/catalog/route.ts | 45 + src/app/api/radar/settings/route.ts | 106 + src/app/api/radar/sync/route.ts | 63 + .../agents/[id]/mappings/route.ts | 6 +- src/app/api/v1/models/catalogResponse.ts | 3 +- src/i18n/messages/ar.json | 68 +- src/i18n/messages/az.json | 68 +- src/i18n/messages/bg.json | 68 +- src/i18n/messages/bn.json | 68 +- src/i18n/messages/cs.json | 68 +- src/i18n/messages/da.json | 68 +- src/i18n/messages/de.json | 68 +- src/i18n/messages/en.json | 66 +- src/i18n/messages/es.json | 68 +- src/i18n/messages/fa.json | 68 +- src/i18n/messages/fi.json | 68 +- src/i18n/messages/fr.json | 68 +- src/i18n/messages/gu.json | 68 +- src/i18n/messages/he.json | 68 +- src/i18n/messages/hi.json | 68 +- src/i18n/messages/hu.json | 68 +- src/i18n/messages/id.json | 68 +- src/i18n/messages/in.json | 68 +- src/i18n/messages/it.json | 68 +- src/i18n/messages/ja.json | 68 +- src/i18n/messages/ko.json | 68 +- src/i18n/messages/mr.json | 68 +- src/i18n/messages/ms.json | 68 +- src/i18n/messages/nl.json | 68 +- src/i18n/messages/no.json | 68 +- src/i18n/messages/phi.json | 68 +- src/i18n/messages/pl.json | 68 +- src/i18n/messages/pt-BR.json | 66 +- src/i18n/messages/pt.json | 68 +- src/i18n/messages/ro.json | 68 +- src/i18n/messages/ru.json | 68 +- src/i18n/messages/sk.json | 68 +- src/i18n/messages/sv.json | 68 +- src/i18n/messages/sw.json | 68 +- src/i18n/messages/ta.json | 68 +- src/i18n/messages/te.json | 68 +- src/i18n/messages/th.json | 68 +- src/i18n/messages/tr.json | 68 +- src/i18n/messages/uk-UA.json | 68 +- src/i18n/messages/ur.json | 68 +- src/i18n/messages/vi.json | 68 +- src/i18n/messages/zh-CN.json | 68 +- src/i18n/messages/zh-TW.json | 68 +- src/instrumentation-node.ts | 5 +- src/lib/catalog/openrouterProviderStats.ts | 13 +- src/lib/db/agentBridgeMappings.ts | 7 +- .../migrations/136_radar_cache_settings.sql | 30 + src/lib/db/radar.ts | 126 ++ src/lib/db/registeredKeys.ts | 9 +- src/lib/localDb.ts | 9 + src/lib/modelMetadataRegistry.ts | 8 +- src/lib/providers/validation/webProvidersB.ts | 24 +- src/lib/radar/applyFeed.ts | 303 +++ src/lib/radar/feedSchema.ts | 174 ++ src/lib/radar/index.ts | 134 ++ src/lib/radar/pinnedKeys.ts | 63 + src/lib/radar/sync.ts | 252 +++ src/lib/radar/verify.ts | 47 + src/mitm/manager.ts | 7 +- src/server/authz/policies/management.ts | 11 +- src/shared/components/Breadcrumbs.tsx | 2 + src/shared/components/ModelSelectModal.tsx | 14 +- .../components/modelSelectModalHelpers.ts | 4 +- .../constants/featureFlagDefinitions.ts | 14 +- src/shared/constants/providers/web-cookie.ts | 2 +- .../constants/sidebarVisibility/sections.ts | 7 + .../constants/sidebarVisibility/types.ts | 1 + src/shared/providers/webSessionCredentials.ts | 3 +- tests/fixtures/radar-feed-canonical.json | 136 ++ tests/integration/agent-bridge-routes.test.ts | 90 +- .../9474-claude-code-oauth-mismap.test.ts | 4 +- .../agent-bridge-detected-models-8656.test.ts | 19 +- .../agent-bridge-dns-per-agent-8466.test.ts | 8 +- .../agent-bridge-mappings-sync-8656.test.ts | 53 +- .../build/mitm-server-bundle-contents.test.ts | 9 +- tests/unit/catalog-order-helper.test.ts | 5 +- .../unit/claude-atu-effort-leak-9505.test.ts | 17 +- tests/unit/claude-oauth-tool-cloak.test.ts | 118 +- .../cli-stop-supervisor-respawn-9455.test.ts | 5 +- .../cli-update-shadow-install-9475.test.ts | 6 +- .../launch-codex-windows-spawn-args.test.ts | 2 +- .../cli/launch-windows-spawn-args.test.ts | 8 +- tests/unit/combo-routing-engine.test.ts | 2 +- .../ccr-eviction-scope-9146.test.ts | 5 +- tests/unit/db-adapters/driverFactory.test.ts | 4 +- tests/unit/db-registeredKeys-crud.test.ts | 10 +- .../unit/muse-spark-cookie-copy-5449.test.ts | 5 +- .../muse-spark-ws-auth-token-9502.test.ts | 5 +- tests/unit/openrouter-provider-stats.test.ts | 48 +- .../unit/poe-api-executor-regression.test.ts | 75 +- .../provider-models-management-route.test.ts | 15 +- tests/unit/radar-api-routes.test.ts | 282 +++ tests/unit/radar-apply-feed.test.ts | 689 ++++++ tests/unit/radar-db.test.ts | 220 ++ tests/unit/radar-flag-default.test.ts | 60 + tests/unit/radar-inertia.test.ts | 227 ++ tests/unit/radar-page-state.test.ts | 70 + tests/unit/radar-sync.test.ts | 811 +++++++ .../unit/reasoning-token-buffer-9507.test.ts | 5 +- .../repro-9500-reasoning-separator.test.ts | 69 +- ...quire-management-auth-access-token.test.ts | 4 +- ...l-select-modal-hidden-models-7156.test.tsx | 94 +- .../webhook-edit-wizard-regression.test.ts | 5 +- 141 files changed, 9240 insertions(+), 1485 deletions(-) create mode 100644 docs/frameworks/RADAR.md create mode 100644 src/app/(dashboard)/dashboard/radar/page.tsx create mode 100644 src/app/(dashboard)/dashboard/radar/setup/page.tsx create mode 100644 src/app/api/radar/catalog/route.ts create mode 100644 src/app/api/radar/settings/route.ts create mode 100644 src/app/api/radar/sync/route.ts create mode 100644 src/lib/db/migrations/136_radar_cache_settings.sql create mode 100644 src/lib/db/radar.ts create mode 100644 src/lib/radar/applyFeed.ts create mode 100644 src/lib/radar/feedSchema.ts create mode 100644 src/lib/radar/index.ts create mode 100644 src/lib/radar/pinnedKeys.ts create mode 100644 src/lib/radar/sync.ts create mode 100644 src/lib/radar/verify.ts create mode 100644 tests/fixtures/radar-feed-canonical.json create mode 100644 tests/unit/radar-api-routes.test.ts create mode 100644 tests/unit/radar-apply-feed.test.ts create mode 100644 tests/unit/radar-db.test.ts create mode 100644 tests/unit/radar-flag-default.test.ts create mode 100644 tests/unit/radar-inertia.test.ts create mode 100644 tests/unit/radar-page-state.test.ts create mode 100644 tests/unit/radar-sync.test.ts 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."}