From 77edb687325601d669de3652c65be9aeac250bc5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 10 Aug 2026 20:11:20 -0300 Subject: [PATCH] feat(radar): add guided combos and MCP catalog (#9836) * feat(radar): build guided combo suggestions * feat(radar): add guided combos page * feat(mcp): expose Radar catalog tool * docs(radar): document guided combos and MCP * refactor(mcp): modularize Radar catalog tool * docs(changelog): record Radar guided combos --------- Co-authored-by: diegosouzapw --- AGENTS.md | 28 +-- README.md | 8 +- .../features/9836-radar-guided-combos.md | 1 + docs/architecture/QUALITY_GATES.md | 8 +- docs/frameworks/MCP-SERVER.md | 25 ++- docs/frameworks/RADAR.md | 26 ++- open-sse/mcp-server/README.md | 27 ++- .../__tests__/essentialTools.test.ts | 8 +- .../__tests__/radarCatalogTool.test.ts | 151 +++++++++++++ open-sse/mcp-server/radarCatalog.ts | 170 +++++++++++++++ open-sse/mcp-server/schemas/index.ts | 2 + open-sse/mcp-server/schemas/radarCatalog.ts | 65 ++++++ open-sse/mcp-server/schemas/tools.ts | 10 +- open-sse/mcp-server/server.ts | 9 +- open-sse/mcp-server/toolResult.ts | 4 + scripts/check/check-docs-counts-sync.mjs | 5 +- .../dashboard/radar/combos/page.tsx | 201 ++++++++++++++++++ src/app/(dashboard)/dashboard/radar/page.tsx | 29 ++- src/i18n/messages/ar.json | 20 +- src/i18n/messages/az.json | 20 +- src/i18n/messages/bg.json | 20 +- src/i18n/messages/bn.json | 20 +- src/i18n/messages/cs.json | 20 +- src/i18n/messages/da.json | 20 +- src/i18n/messages/de.json | 20 +- src/i18n/messages/en.json | 20 +- src/i18n/messages/es.json | 20 +- src/i18n/messages/fa.json | 20 +- src/i18n/messages/fi.json | 20 +- src/i18n/messages/fr.json | 20 +- src/i18n/messages/gu.json | 20 +- src/i18n/messages/he.json | 20 +- src/i18n/messages/hi.json | 20 +- src/i18n/messages/hu.json | 20 +- src/i18n/messages/id.json | 20 +- src/i18n/messages/in.json | 20 +- src/i18n/messages/it.json | 20 +- src/i18n/messages/ja.json | 20 +- src/i18n/messages/ko.json | 20 +- src/i18n/messages/mr.json | 20 +- src/i18n/messages/ms.json | 20 +- src/i18n/messages/nl.json | 20 +- src/i18n/messages/no.json | 20 +- src/i18n/messages/phi.json | 20 +- src/i18n/messages/pl.json | 20 +- src/i18n/messages/pt-BR.json | 20 +- src/i18n/messages/pt.json | 20 +- src/i18n/messages/ro.json | 20 +- src/i18n/messages/ru.json | 20 +- src/i18n/messages/sk.json | 20 +- src/i18n/messages/sv.json | 20 +- src/i18n/messages/sw.json | 20 +- src/i18n/messages/ta.json | 20 +- src/i18n/messages/te.json | 20 +- src/i18n/messages/th.json | 20 +- src/i18n/messages/tr.json | 20 +- src/i18n/messages/uk-UA.json | 20 +- src/i18n/messages/ur.json | 20 +- src/i18n/messages/vi.json | 20 +- src/i18n/messages/zh-CN.json | 20 +- src/i18n/messages/zh-TW.json | 20 +- src/lib/radar/applyFeed.ts | 7 + src/lib/radar/comboSuggestions.ts | 164 ++++++++++++++ src/shared/constants/mcpScopes.ts | 2 + tests/unit/check-docs-counts-sync.test.ts | 10 +- tests/unit/radar-apply-feed.test.ts | 34 +++ tests/unit/radar-combo-suggestions.test.ts | 134 ++++++++++++ tests/unit/radar-combos-page.test.ts | 90 ++++++++ 68 files changed, 1956 insertions(+), 122 deletions(-) create mode 100644 changelog.d/features/9836-radar-guided-combos.md create mode 100644 open-sse/mcp-server/__tests__/radarCatalogTool.test.ts create mode 100644 open-sse/mcp-server/radarCatalog.ts create mode 100644 open-sse/mcp-server/schemas/radarCatalog.ts create mode 100644 open-sse/mcp-server/toolResult.ts create mode 100644 src/app/(dashboard)/dashboard/radar/combos/page.tsx create mode 100644 src/lib/radar/comboSuggestions.ts create mode 100644 tests/unit/radar-combo-suggestions.test.ts create mode 100644 tests/unit/radar-combos-page.test.ts diff --git a/AGENTS.md b/AGENTS.md index f88c9056a5..feda2fb478 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,20 +48,20 @@ Repository map and Reference Documentation sections below. **OmniRoute** — unified AI proxy/router. One endpoint, 291 LLM providers, auto-fallback. -| Layer | Location | Purpose | -| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| API Routes | `src/app/api/v1/` | Next.js App Router — entry points | -| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) | -| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch | -| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | -| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | -| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (130 migrations) | -| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | -| MCP Server | `open-sse/mcp-server/` | 105 tools (42 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes | -| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | -| Skills | `src/lib/skills/` | Extensible skill framework | -| Memory | `src/lib/memory/` | Persistent conversational memory | +| Layer | Location | Purpose | +| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| API Routes | `src/app/api/v1/` | Next.js App Router — entry points | +| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) | +| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch | +| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | +| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | +| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | +| Database | `src/lib/db/` | SQLite domain modules (130 migrations) | +| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | +| MCP Server | `open-sse/mcp-server/` | 109 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | +| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | +| Skills | `src/lib/skills/` | Extensible skill framework | +| Memory | `src/lib/memory/` | Persistent conversational memory | Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point). diff --git a/README.md b/README.md index 352619a7f0..5edc2d69c7 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint. 291 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 291 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 105 tools, A2A, memory, guardrails, evals — 25,000+ tests). +The Promise — One endpoint. 291 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 291 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests).

@@ -439,7 +439,7 @@ All **19** strategies — mix & match per combo step: -What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 291 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 105 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs. +What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 291 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -723,7 +723,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo - + @@ -1110,7 +1110,7 @@ same process on one port, so there is no separate CLI-only package today. - + diff --git a/changelog.d/features/9836-radar-guided-combos.md b/changelog.d/features/9836-radar-guided-combos.md new file mode 100644 index 0000000000..c813289b35 --- /dev/null +++ b/changelog.d/features/9836-radar-guided-combos.md @@ -0,0 +1 @@ +- **feat(radar):** add curated-family combo suggestions, a guided combo page, and the read-only Radar MCP catalog tool ([#9836](https://github.com/diegosouzapw/OmniRoute/pull/9836)) diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index 06c55e99cf..b7e279525f 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -186,10 +186,10 @@ Runs on pull requests only. Runs after `build`. Blocks merge on failure. -| Suite | Validates | Blocking | -| ---------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- | -| `test:vitest` | MCP server (94 tools), autoCombo, cache — vitest runner | Yes | -| `test:vitest:ui` | UI component tests — vitest runner | **Advisory** (`continue-on-error: true`) — failing until Fase 6A UI triage | +| Suite | Validates | Blocking | +| ---------------- | -------------------------------------------------------- | -------------------------------------------------------------------------- | +| `test:vitest` | MCP server (109 tools), autoCombo, cache — vitest runner | Yes | +| `test:vitest:ui` | UI component tests — vitest runner | **Advisory** (`continue-on-error: true`) — failing until Fase 6A UI triage | ### Nightly workflows (scheduled, advisory) diff --git a/docs/frameworks/MCP-SERVER.md b/docs/frameworks/MCP-SERVER.md index 710315cf49..7a8e40db39 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -1,14 +1,14 @@ --- title: "OmniRoute MCP Server Documentation" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.50 +lastUpdated: 2026-08-08 --- # OmniRoute MCP Server Documentation -> Model Context Protocol server with 105 tools across routing, cache, compression, memory, skills, proxy, pool, and context source operations. +> Model Context Protocol server with 109 tools across routing, cache, compression, memory, skills, proxy, pool, Radar, and context source operations. > -> Source of truth: `open-sse/mcp-server/server.ts` computes **104 unique tools** with `countUniqueMcpTools()`: 42 canonical definitions (including the six CCR lifecycle tools and the agent-skills trio), plus memory (3), skills (4), GitHub skills (3), pool (6), gamification (8), plugins (8), Notion (6), Obsidian (22), and two RTK-only compression tools. +> Source of truth: `open-sse/mcp-server/server.ts` computes **109 unique tools** with `countUniqueMcpTools()`: 44 canonical definitions (including the six CCR lifecycle tools, the agent-skills trio, and `omniroute_radar_catalog`), plus memory (3), skills (4), GitHub skills (3), pool (6), gamification (8), plugins (8), Notion (6), Obsidian (22), local corpus (3), and two RTK-only compression tools. ## Installation @@ -64,7 +64,7 @@ Cursor, Cline, and compatible MCP client setup. --- -## Essential Tools (8) — Phase 1 +## Essential Tools (13) — Phase 1 | Tool | Scopes | Description | | :------------------------------ | :-------------------- | :------------------------------------------------------------ | @@ -72,16 +72,15 @@ Cursor, Cline, and compatible MCP client setup. | `omniroute_list_combos` | `read:combos` | All configured combos with strategies (optional metrics) | | `omniroute_get_combo_metrics` | `read:combos` | Performance metrics for a specific combo | | `omniroute_switch_combo` | `write:combos` | Activate or deactivate a combo | +| `omniroute_create_combo` | `write:combos` | Create a validated combo through the existing combo API | | `omniroute_check_quota` | `read:quota` | Quota used/total, percent remaining, reset time, token health | | `omniroute_route_request` | `execute:completions` | Send a chat completion through OmniRoute routing | | `omniroute_cost_report` | `read:usage` | Cost report by period (session/day/week/month) | | `omniroute_list_models_catalog` | `read:models` | Full model catalog with capabilities, status, pricing | - -## Phase 1 — Search - -| Tool | Scopes | Description | -| :--------------------- | :--------------- | :--------------------------------------------------------------------------------------------------------------------------------- | -| `omniroute_web_search` | `execute:search` | Web search through OmniRoute search gateway (Serper/Brave/Perplexity/Exa/Tavily/Google PSE/Linkup/SearchAPI/SearXNG) with failover | +| `omniroute_radar_catalog` | `read:radar` | Local signed Radar catalog; optional provider/family filters | +| `omniroute_tool_search` | `read:tools` | Discover tools from the registered MCP catalog | +| `omniroute_web_search` | `execute:search` | Web search through the configured search providers | +| `omniroute_web_fetch` | `execute:search` | Fetch web content through the configured fetch providers | ## Advanced Tools (11) — Phase 2 @@ -227,7 +226,7 @@ See [AGENT-SKILLS.md](./AGENT-SKILLS.md) for the full catalog and how external a ## Related Frameworks (v3.8.0) -The MCP tool inventory above (104 unique tools, computed by `countUniqueMcpTools()`) is intentionally +The MCP tool inventory above (109 unique tools, computed by `countUniqueMcpTools()`) is intentionally scoped to runtime routing/cache/compression/memory/skills/proxy/context-source operations. Two adjacent frameworks ship alongside the MCP server in v3.8.0 and are documented separately: @@ -369,7 +368,7 @@ MCP tool, prompt, and resource registries can compress descriptions at registrat Description compression shrinks each tool's metadata; **tool-cardinality reduction** goes one step further by reducing _how many_ tools are announced at all. Advertising fewer tools in the `tools/list` manifest cuts the per-request token cost the client's model pays for the tool catalog ("layer 5" compression). The implementation is a pure, stateless filter in `open-sse/mcp-server/toolCardinality.ts` (`reduceToolManifest`), wired into the registration loop in `createMcpServer()` (`open-sse/mcp-server/server.ts`). -**Opt-in, off by default.** The filter only runs when at least one of two environment variables is set; with neither set, all 105 tools are announced unchanged. +**Opt-in, off by default.** The filter only runs when at least one of two environment variables is set; with neither set, all 109 tools are announced unchanged. | Variable | Mode | | :--------------- | :-------------------------------------------------------------------------------------- | diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index 1d1d1015d7..1e36a4e228 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -290,6 +290,26 @@ The dashboard exposes four local actions: A feed `enabled: false` remains the safety exception: it wins over a stale local `enabled: true`, keeps the merged entry disabled, and records `disabledBy: "radar"`. +### Guided combos and MCP access + +Confirmed `familyId` values survive the read-time overlay and drive the pure +`buildRadarComboSuggestions()` module (`src/lib/radar/comboSuggestions.ts`). A family is suggested +only when at least two distinct providers have active connections and expose the exact curated model +ID. Disabled models, inactive providers, missing model IDs, singleton families, and ambiguous +alias/prefix matches fail closed. Suggestions use the existing `priority` strategy, ordering the +largest recurring monthly budget first; the UI creates them only through `POST /api/combos`. + +The guided UI lives at `/dashboard/radar/combos`. It reads only the local +`GET /api/radar/catalog` and `GET /api/combos/builder/options` endpoints. It never triggers Radar sync, +reads provider credentials, or writes directly to the combo database. + +MCP clients can read the same local projection with `omniroute_radar_catalog` (`read:radar`). The +optional `provider`, `familyId`, and `enabledOnly` filters are evaluated after one local +`GET /api/radar/catalog` read. Its closed output includes catalog metadata plus provider/model, +display name, `familyId`, quota, capabilities, enabled state, origin, and `disabledBy`; setup URLs, +steps, connections, e-mail addresses, keys, and referral data are never returned. This tool is +read-only and never invokes `/api/radar/sync`. + ### Provenance markers Every merged entry carries an `origin` field the UI renders as a badge: @@ -303,7 +323,7 @@ Every merged entry carries an `origin` field the UI renders as a badge: ## Local surfaces — never a feed proxy -Six local endpoints back the UI, all under `src/app/api/radar/`: +The local Radar route families below back the UI under `src/app/api/radar/`: | Route | Method | Purpose | | ------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------- | @@ -323,14 +343,14 @@ to the local OmniRoute server. The two modules that touch the Radar service are always run server-side, never client-side. This keeps the feed URL and any supporter key out of client-facing network traffic entirely. -All six endpoints return `404` when `RADAR_ENABLED` is off (see +All Radar endpoints 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`). ### Authentication -All six endpoints require authentication via `isAuthenticated()` +All Radar endpoints require authentication via `isAuthenticated()` (`src/shared/utils/apiAuth.ts`) — a dashboard session cookie or a management-scoped API key, the same gate that protects the rest of `/api/settings/*`. The flag-off `404` check always runs **before** the auth check, so an install with `RADAR_ENABLED` diff --git a/open-sse/mcp-server/README.md b/open-sse/mcp-server/README.md index 1ef5f8aa5f..cf3d5ce748 100644 --- a/open-sse/mcp-server/README.md +++ b/open-sse/mcp-server/README.md @@ -1,6 +1,6 @@ # OmniRoute MCP Server -> **Model Context Protocol server** that exposes OmniRoute's gateway intelligence as **104 tools** for AI agents. +> **Model Context Protocol server** that exposes OmniRoute's gateway intelligence as **109 tools** for AI agents. > > **Source of truth for the full tool catalog and REST surface:** [`docs/frameworks/MCP-SERVER.md`](../../docs/frameworks/MCP-SERVER.md). This README focuses on architecture, configuration, and integration examples; the catalog below is a summary subset. @@ -20,7 +20,7 @@ The MCP Server allows any AI agent (Claude Desktop, Cursor, VS Code Copilot, cus ┌──────────────────────────────────────────────────────────────────┐ │ OmniRoute MCP Server │ │ ┌──────────────┐ ┌─────────────────┐ ┌────────────────────┐ │ -│ │ Scope │ │ 104 MCP Tools │ │ Audit Logger │ │ +│ │ Scope │ │ 109 MCP Tools │ │ Audit Logger │ │ │ │ Enforcement │──│ (core + memory │──│ (SHA-256/SQLite) │ │ │ │ │ │ + skills + …) │ │ │ │ │ └──────────────┘ └────────┬────────┘ └────────────────────┘ │ @@ -120,18 +120,23 @@ omniroute --mcp ## Tool Reference -### Phase 1: Essential Tools (8) +### Phase 1: Essential Tools (13) | # | Tool | Scopes | Description | | --- | ------------------------------- | --------------------- | -------------------------------------------------------------------------- | -| 1 | `omniroute_get_health` | `read:health` | Gateway health, uptime, memory, circuit breakers, rate limits, cache stats | -| 2 | `omniroute_list_combos` | `read:combos` | List all combos (model chains) with strategies and optional metrics | -| 3 | `omniroute_get_combo_metrics` | `read:combos` | Performance metrics for a specific combo | -| 4 | `omniroute_switch_combo` | `write:combos` | Activate or deactivate a combo for routing | -| 5 | `omniroute_check_quota` | `read:quota` | Remaining API quota per provider with token health status | -| 6 | `omniroute_route_request` | `execute:completions` | Send a chat completion through intelligent routing | -| 7 | `omniroute_cost_report` | `read:usage` | Cost report by period (session/day/week/month) with per-provider breakdown | -| 8 | `omniroute_list_models_catalog` | `read:models` | List all available models across providers with capabilities and pricing | +| 1 | `omniroute_tool_search` | `read:tools` | Discover tools from the registered MCP catalog | +| 2 | `omniroute_get_health` | `read:health` | Gateway health, uptime, memory, circuit breakers, rate limits, cache stats | +| 3 | `omniroute_list_combos` | `read:combos` | List all combos (model chains) with strategies and optional metrics | +| 4 | `omniroute_get_combo_metrics` | `read:combos` | Performance metrics for a specific combo | +| 5 | `omniroute_switch_combo` | `write:combos` | Activate or deactivate a combo for routing | +| 6 | `omniroute_create_combo` | `write:combos` | Create a validated combo through the existing combo API | +| 7 | `omniroute_check_quota` | `read:quota` | Remaining API quota per provider with token health status | +| 8 | `omniroute_route_request` | `execute:completions` | Send a chat completion through intelligent routing | +| 9 | `omniroute_cost_report` | `read:usage` | Cost report by period (session/day/week/month) with per-provider breakdown | +| 10 | `omniroute_list_models_catalog` | `read:models` | List all available models across providers with capabilities and pricing | +| 11 | `omniroute_radar_catalog` | `read:radar` | Read the local signed Radar catalog with provider/family filters | +| 12 | `omniroute_web_search` | `execute:search` | Search the web through configured search providers | +| 13 | `omniroute_web_fetch` | `execute:search` | Fetch web content through configured fetch providers | ### Phase 2: Advanced Tools (8) diff --git a/open-sse/mcp-server/__tests__/essentialTools.test.ts b/open-sse/mcp-server/__tests__/essentialTools.test.ts index 88353eed92..c611d5f3c3 100644 --- a/open-sse/mcp-server/__tests__/essentialTools.test.ts +++ b/open-sse/mcp-server/__tests__/essentialTools.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for MCP Essential Tools (Phase 1) * - * Tests all 10 essential tool handlers via the tool handler functions. + * Tests the essential tool handlers via the tool handler functions. * The omniroute_web_search tests use InMemoryTransport + Client to exercise * the actual registered handler (not mockFetch directly). */ @@ -22,10 +22,10 @@ describe("MCP Essential Tools", () => { }); describe("Tool schema validation", () => { - it("should have exactly 12 essential tools (includes web_search + web_fetch + tool_search)", () => { - // 11 -> 12: #8925 shipped omniroute_create_combo as a phase-1 tool. + it("should have exactly 13 essential tools (including Radar catalog)", () => { + // 12 -> 13: F3 shipped omniroute_radar_catalog as a phase-1 read-only tool. const schemas = MCP_ESSENTIAL_TOOLS; - expect(schemas).toHaveLength(12); + expect(schemas).toHaveLength(13); }); it("all tools should have omniroute_ prefix", () => { diff --git a/open-sse/mcp-server/__tests__/radarCatalogTool.test.ts b/open-sse/mcp-server/__tests__/radarCatalogTool.test.ts new file mode 100644 index 0000000000..d30a59be7e --- /dev/null +++ b/open-sse/mcp-server/__tests__/radarCatalogTool.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { MCP_SCOPE_LIST, MCP_TOOL_SCOPES } from "../../../src/shared/constants/mcpScopes.ts"; +import { evaluateToolScopes } from "../scopeEnforcement.ts"; +import { getMcpRadarCatalog } from "../radarCatalog.ts"; +import { MCP_ESSENTIAL_TOOLS, MCP_TOOL_MAP } from "../schemas/tools.ts"; +import { createMcpServer } from "../server.ts"; + +vi.mock("../audit.ts", () => ({ + logToolCall: vi.fn().mockResolvedValue(undefined), +})); + +const catalog = { + entries: [ + { + provider: "groq", + modelId: "llama", + displayName: "Llama on Groq", + familyId: "llama-family", + monthlyTokens: 200, + creditTokens: 0, + freeType: "recurring-daily", + poolKey: null, + tos: "ok", + enabled: true, + origin: "radar", + capabilities: { tools: true, vision: false, thinking: false }, + limits: { rpm: 30, rpd: null, tpm: null, tpd: null }, + setup: { keyUrl: "https://secret.example/key", steps: ["do not expose"] }, + }, + { + provider: "cerebras", + modelId: "llama", + displayName: "Llama on Cerebras", + familyId: "llama-family", + monthlyTokens: 300, + creditTokens: 0, + freeType: "recurring-daily", + poolKey: null, + tos: "ok", + enabled: false, + disabledBy: "radar", + origin: "radar", + capabilities: { tools: true, vision: false, thinking: true }, + limits: { rpm: null, rpd: 100, tpm: null, tpd: null }, + }, + ], + meta: { version: "2026.08.08.1", tier: "community", fetchedAt: "2026-08-08T20:00:00Z" }, +}; + +describe("omniroute_radar_catalog", () => { + it("is a phase-1 read-only registry tool with the dedicated Radar scope", () => { + const definition = MCP_TOOL_MAP.omniroute_radar_catalog; + expect(definition).toBeDefined(); + expect(definition.phase).toBe(1); + expect(definition.scopes).toEqual(["read:radar"]); + expect(definition.auditLevel).toBe("none"); + expect(definition.sourceEndpoints).toEqual(["/api/radar/catalog"]); + expect(MCP_ESSENTIAL_TOOLS).toContain(definition); + expect(MCP_SCOPE_LIST).toContain("read:radar"); + expect(MCP_TOOL_SCOPES.omniroute_radar_catalog).toEqual(["read:radar"]); + }); + + it("reads only the local catalog and returns a closed filtered projection", async () => { + const fetchJson = vi.fn().mockResolvedValue(catalog); + const result = await getMcpRadarCatalog( + { provider: "groq", familyId: "llama-family", enabledOnly: true }, + { fetchJson } + ); + + expect(fetchJson).toHaveBeenCalledOnce(); + expect(fetchJson).toHaveBeenCalledWith("/api/radar/catalog"); + expect(result.models).toHaveLength(1); + expect(result.models[0]).toEqual({ + provider: "groq", + modelId: "llama", + displayName: "Llama on Groq", + familyId: "llama-family", + quota: { + monthlyTokens: 200, + creditTokens: 0, + freeType: "recurring-daily", + limits: { rpm: 30, rpd: null, tpm: null, tpd: null }, + }, + capabilities: { tools: true, vision: false, thinking: false }, + enabled: true, + origin: "radar", + disabledBy: null, + }); + expect(JSON.stringify(result)).not.toContain("secret.example"); + expect(JSON.stringify(result)).not.toContain("setup"); + }); + + it("defaults enabledOnly to true and includes disabled models only when explicitly requested", async () => { + const fetchJson = vi.fn().mockResolvedValue(catalog); + expect((await getMcpRadarCatalog({}, { fetchJson })).models).toHaveLength(1); + expect((await getMcpRadarCatalog({ enabledOnly: false }, { fetchJson })).models).toHaveLength( + 2 + ); + }); + + it("allows read:radar and read:* but denies a missing scope when enforcement is active", () => { + expect(evaluateToolScopes("omniroute_radar_catalog", ["read:radar"], true).allowed).toBe(true); + expect(evaluateToolScopes("omniroute_radar_catalog", ["read:*"], true).allowed).toBe(true); + expect(evaluateToolScopes("omniroute_radar_catalog", [], true)).toMatchObject({ + allowed: false, + reason: "missing_scopes", + missing: ["read:radar"], + }); + }); +}); + +describe("omniroute_radar_catalog MCP dispatch", () => { + const mockFetch = vi.fn(); + let client: Client; + + beforeEach(async () => { + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + client = new Client({ name: "radar-catalog-test", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + vi.unstubAllGlobals(); + }); + + it("registers and dispatches a real read without sync or write", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => catalog }); + + const listed = await client.listTools(); + expect(listed.tools.some((tool) => tool.name === "omniroute_radar_catalog")).toBe(true); + + const result = await client.callTool({ + name: "omniroute_radar_catalog", + arguments: { enabledOnly: false }, + }); + expect(result.isError).toBeFalsy(); + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch.mock.calls[0][0]).toContain("/api/radar/catalog"); + expect(mockFetch.mock.calls[0][1]).not.toMatchObject({ method: "POST" }); + const body = JSON.parse((result.content[0] as { text: string }).text); + expect(body.models).toHaveLength(2); + }); +}); diff --git a/open-sse/mcp-server/radarCatalog.ts b/open-sse/mcp-server/radarCatalog.ts new file mode 100644 index 0000000000..e17ce724bb --- /dev/null +++ b/open-sse/mcp-server/radarCatalog.ts @@ -0,0 +1,170 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { logToolCall } from "./audit.ts"; +import { radarCatalogInput, radarCatalogOutput } from "./schemas/radarCatalog.ts"; +import type { McpToolExtraLike } from "./scopeEnforcement.ts"; +import type { TextToolResult } from "./toolResult.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +type JsonRecord = Record; + +type ScopeEnforcer = ( + toolName: string, + handler: (args: unknown, extra?: McpToolExtraLike) => Promise, + toolScopes?: readonly string[] +) => (args: unknown, extra?: McpToolExtraLike) => Promise; + +export interface McpRadarCatalogArgs { + provider?: string; + familyId?: string; + enabledOnly?: boolean; +} + +interface McpRadarCatalogDeps { + fetchJson?: (path: string) => Promise; +} + +function record(value: unknown): JsonRecord { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : {}; +} + +function text(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function number(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; +} + +function nullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + +function normalizeMeta( + value: unknown +): { version: string; tier: string; fetchedAt: string } | null { + const meta = record(value); + if ( + typeof meta.version !== "string" || + typeof meta.tier !== "string" || + typeof meta.fetchedAt !== "string" + ) { + return null; + } + return { version: meta.version, tier: meta.tier, fetchedAt: meta.fetchedAt }; +} + +function normalizeEntry(value: unknown) { + const entry = record(value); + const provider = text(entry.provider).trim(); + const modelId = text(entry.modelId).trim(); + if (!provider || !modelId) return null; + + const capabilities = record(entry.capabilities); + const limits = record(entry.limits); + const origin = + entry.origin === "radar" || entry.origin === "local" ? entry.origin : ("baseline" as const); + return { + provider, + modelId, + displayName: text(entry.displayName, modelId), + familyId: typeof entry.familyId === "string" ? entry.familyId : null, + quota: { + monthlyTokens: number(entry.monthlyTokens), + creditTokens: number(entry.creditTokens), + freeType: text(entry.freeType, "unknown"), + limits: + Object.keys(limits).length > 0 + ? { + rpm: nullableNumber(limits.rpm), + rpd: nullableNumber(limits.rpd), + tpm: nullableNumber(limits.tpm), + tpd: nullableNumber(limits.tpd), + } + : null, + }, + capabilities: + Object.keys(capabilities).length > 0 + ? { + tools: capabilities.tools === true, + vision: capabilities.vision === true, + thinking: capabilities.thinking === true, + } + : null, + enabled: entry.enabled !== false, + origin, + disabledBy: entry.disabledBy === "radar" ? ("radar" as const) : null, + }; +} + +function compareEntries( + left: NonNullable>, + right: NonNullable> +): number { + return left.provider.localeCompare(right.provider) || left.modelId.localeCompare(right.modelId); +} + +/** Read and project the local Radar catalog without exposing setup or secret-bearing state. */ +export async function getMcpRadarCatalog( + args: McpRadarCatalogArgs, + deps: McpRadarCatalogDeps = {} +) { + const fetchJson = + deps.fetchJson ?? + ((path: string) => import("./server.ts").then((module) => module.omniRouteFetch(path))); + const raw = record(await fetchJson("/api/radar/catalog")); + const providerFilter = args.provider?.trim().toLowerCase(); + const familyFilter = args.familyId?.trim().toLowerCase(); + const enabledOnly = args.enabledOnly !== false; + const entries = Array.isArray(raw.entries) ? raw.entries : []; + const models = entries + .map(normalizeEntry) + .filter((entry): entry is NonNullable => entry !== null) + .filter((entry) => !enabledOnly || entry.enabled) + .filter((entry) => !providerFilter || entry.provider.toLowerCase() === providerFilter) + .filter((entry) => !familyFilter || entry.familyId?.toLowerCase() === familyFilter) + .sort(compareEntries); + + return { meta: normalizeMeta(raw.meta), models }; +} + +async function handleRadarCatalog(args: { + provider?: string; + familyId?: string; + enabledOnly: boolean; +}): Promise { + const start = Date.now(); + try { + const result = radarCatalogOutput.parse(await getMcpRadarCatalog(args)); + await logToolCall( + "omniroute_radar_catalog", + args, + { modelCount: result.models.length }, + Date.now() - start, + true + ); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + const message = sanitizeErrorMessage(error) || "Failed to read Radar catalog"; + await logToolCall("omniroute_radar_catalog", args, null, Date.now() - start, false, message); + return { content: [{ type: "text", text: `Error: ${message}` }], isError: true }; + } +} + +export function registerRadarCatalogTool( + server: McpServer, + withScopeEnforcement: ScopeEnforcer +): void { + server.registerTool( + "omniroute_radar_catalog", + { + description: "Reads the local signed Radar catalog with optional provider and family filters", + inputSchema: radarCatalogInput, + }, + withScopeEnforcement("omniroute_radar_catalog", (args) => + handleRadarCatalog(radarCatalogInput.parse(args)) + ) + ); +} diff --git a/open-sse/mcp-server/schemas/index.ts b/open-sse/mcp-server/schemas/index.ts index fe9df69ff2..1c3cbdc029 100644 --- a/open-sse/mcp-server/schemas/index.ts +++ b/open-sse/mcp-server/schemas/index.ts @@ -91,6 +91,8 @@ export { ccrStatsTool, } from "./tools.ts"; +export { radarCatalogInput, radarCatalogOutput, radarCatalogTool } from "./radarCatalog.ts"; + // A2A schemas export { AgentCardSchema, diff --git a/open-sse/mcp-server/schemas/radarCatalog.ts b/open-sse/mcp-server/schemas/radarCatalog.ts new file mode 100644 index 0000000000..1bfb4040e2 --- /dev/null +++ b/open-sse/mcp-server/schemas/radarCatalog.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import type { McpToolDefinition } from "./toolDefinition.ts"; + +export const radarCatalogInput = z.object({ + provider: z.string().trim().min(1).max(100).optional().describe("Filter by provider id"), + familyId: z.string().trim().min(1).max(120).optional().describe("Filter by curated family id"), + enabledOnly: z.boolean().default(true).describe("Exclude models disabled by the Radar feed"), +}); + +const radarLimitOutput = z.object({ + rpm: z.number().nullable(), + rpd: z.number().nullable(), + tpm: z.number().nullable(), + tpd: z.number().nullable(), +}); + +export const radarCatalogOutput = z.object({ + meta: z + .object({ + version: z.string(), + tier: z.string(), + fetchedAt: z.string(), + }) + .nullable(), + models: z.array( + z.object({ + provider: z.string(), + modelId: z.string(), + displayName: z.string(), + familyId: z.string().nullable(), + quota: z.object({ + monthlyTokens: z.number(), + creditTokens: z.number(), + freeType: z.string(), + limits: radarLimitOutput.nullable(), + }), + capabilities: z + .object({ + tools: z.boolean(), + vision: z.boolean(), + thinking: z.boolean(), + }) + .nullable(), + enabled: z.boolean(), + origin: z.enum(["baseline", "radar", "local"]), + disabledBy: z.literal("radar").nullable(), + }) + ), +}); + +export const radarCatalogTool: McpToolDefinition< + typeof radarCatalogInput, + typeof radarCatalogOutput +> = { + name: "omniroute_radar_catalog", + description: + "Reads the local signed Radar catalog with optional provider and curated-family filters. Never syncs or writes data.", + inputSchema: radarCatalogInput, + outputSchema: radarCatalogOutput, + scopes: ["read:radar"], + auditLevel: "none", + phase: 1, + sourceEndpoints: ["/api/radar/catalog"], +}; diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 4db6a850dc..437a49f56c 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -1,5 +1,5 @@ /** - * MCP Tool Schemas — Contracts for all 23 core and advanced OmniRoute MCP tools. + * MCP Tool Schemas — Contracts for the canonical OmniRoute MCP tools. * * Defines input/output Zod schemas, descriptions, scopes, and audit levels * for both essential (Phase 1) and advanced (Phase 2) MCP tools. @@ -13,11 +13,11 @@ import { z } from "zod"; import { toolSearchTool } from "./toolSearch.ts"; import { pickFastestModelTool } from "./pickFastestModel.ts"; import { CCR_MCP_TOOLS } from "./ccrTools.ts"; +import { radarCatalogTool } from "./radarCatalog.ts"; import { AUTO_ROUTING_STRATEGY_VALUES, ROUTING_STRATEGY_VALUES, } from "../../../src/shared/constants/routingStrategies.ts"; - // ============ Shared Types ============ // AuditLevel + McpToolDefinition live in the leaf ./toolDefinition.ts so that // toolSearch.ts can import the type without forming a tools.ts ↔ toolSearch.ts cycle. @@ -26,8 +26,7 @@ export type { AuditLevel, McpToolDefinition } from "./toolDefinition.ts"; import type { McpToolDefinition } from "./toolDefinition.ts"; export { pickFastestModelInput, pickFastestModelOutput } from "./pickFastestModel.ts"; export * from "./ccrTools.ts"; - -// ============ Phase 1: Essential Tools (8) ============ +// ============ Phase 1: Essential Tools ============ // --- Tool 1: omniroute_get_health --- export const getHealthInput = z.object({}).describe("No parameters required"); @@ -432,7 +431,7 @@ export const listModelsCatalogTool: McpToolDefinition< sourceEndpoints: ["/api/models/catalog", "/v1/models"], }; -// --- Tool 9: omniroute_web_search --- +// --- Tool 10: omniroute_web_search --- export const webSearchInput = z.object({ query: z .string() @@ -1512,6 +1511,7 @@ export const MCP_TOOLS = [ routeRequestTool, costReportTool, listModelsCatalogTool, + radarCatalogTool, webSearchTool, webFetchTool, simulateRouteTool, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index d61b654f3c..811d87dce0 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -93,6 +93,8 @@ import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { getMcpModelsCatalog } from "./catalog.ts"; +import { registerRadarCatalogTool } from "./radarCatalog.ts"; +import type { TextToolResult } from "./toolResult.ts"; export { getMcpModelsCatalog } from "./catalog.ts"; const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); @@ -146,11 +148,6 @@ function readMcpAccessibilityConfig(): McpAccessibilityConfig { } } -type TextToolResult = { - content: Array<{ type: "text"; text: string }>; - isError?: boolean; -}; - function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -815,6 +812,8 @@ export function createMcpServer(): McpServer { ) ); + registerRadarCatalogTool(server, withScopeEnforcement); + server.registerTool( "omniroute_simulate_route", { diff --git a/open-sse/mcp-server/toolResult.ts b/open-sse/mcp-server/toolResult.ts new file mode 100644 index 0000000000..ea8535f545 --- /dev/null +++ b/open-sse/mcp-server/toolResult.ts @@ -0,0 +1,4 @@ +export type TextToolResult = { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +}; diff --git a/scripts/check/check-docs-counts-sync.mjs b/scripts/check/check-docs-counts-sync.mjs index c0e4cf83f6..01ad1aeef1 100644 --- a/scripts/check/check-docs-counts-sync.mjs +++ b/scripts/check/check-docs-counts-sync.mjs @@ -147,9 +147,10 @@ function readCodeFacts() { 'import {pluginTools} from "./open-sse/mcp-server/tools/pluginTools.ts";', 'import {notionTools} from "./open-sse/mcp-server/tools/notionTools.ts";', 'import {obsidianTools} from "./open-sse/mcp-server/tools/obsidianTools.ts";', + 'import {localCorpusTools} from "./open-sse/mcp-server/tools/localCorpusTools.ts";', 'import {compressionTools} from "./open-sse/mcp-server/tools/compressionTools.ts";', "const cols={MCP_TOOLS,memoryTools,skillTools,agentSkillTools,githubSkillTools,poolTools,", - "gamificationTools,pluginTools,notionTools,obsidianTools,compressionTools};", + "gamificationTools,pluginTools,notionTools,obsidianTools,localCorpusTools,compressionTools};", "const sc=new Set();", "for(const col of Object.values(cols))for(const t of Object.values(col))", "for(const x of (t?.scopes||[]))sc.add(x);", @@ -313,7 +314,7 @@ export function buildChecks() { // total ("33 tools (25 CLI Code's …)") are not the MCP aggregate // per-module rows read "… tool definitions (N tools" / "… management tools // (N tools" — the word tool(s)/definitions sits right before the paren. The - // aggregate ("MCP Server (104 tools", "all 104 tools") never does. + // aggregate ("MCP Server (109 tools", "all 109 tools") never does. skipBefore: /(tools?|definitions?)\s*\(\s*$/i, skipAfter: /^\s*\(\d+ CLI/, }, diff --git a/src/app/(dashboard)/dashboard/radar/combos/page.tsx b/src/app/(dashboard)/dashboard/radar/combos/page.tsx new file mode 100644 index 0000000000..04cb3f3e98 --- /dev/null +++ b/src/app/(dashboard)/dashboard/radar/combos/page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import type { ComboBuilderOptionsPayload } from "@/lib/combos/builderOptions"; +import type { MergedEntry } from "@/lib/radar/applyFeed"; +import { + buildRadarComboSuggestions, + type RadarComboSuggestion, +} from "@/lib/radar/comboSuggestions"; +import { Card } from "@/shared/components"; + +interface RadarCatalogPayload { + entries?: MergedEntry[]; + meta?: unknown; +} + +function comboNames(payload: ComboBuilderOptionsPayload): Set { + return new Set(payload.comboRefs.map((combo) => combo.name)); +} + +export default function RadarCombosPage() { + const t = useTranslations("radarCombosPage"); + const [entries, setEntries] = useState([]); + const [providers, setProviders] = useState([]); + const [existingNames, setExistingNames] = useState>(new Set()); + const [createdNames, setCreatedNames] = useState>(new Set()); + const [hasCatalog, setHasCatalog] = useState(false); + const [flagOff, setFlagOff] = useState(false); + const [loading, setLoading] = useState(true); + const [creatingName, setCreatingName] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + async function load() { + try { + const [catalogResponse, optionsResponse] = await Promise.all([ + fetch("/api/radar/catalog"), + fetch("/api/combos/builder/options"), + ]); + if (catalogResponse.status === 404) { + setFlagOff(true); + return; + } + if (!catalogResponse.ok || !optionsResponse.ok) throw new Error("load_failed"); + + const catalog = (await catalogResponse.json()) as RadarCatalogPayload; + const options = (await optionsResponse.json()) as ComboBuilderOptionsPayload; + if (!Array.isArray(catalog.entries) || !Array.isArray(options.providers)) { + throw new Error("invalid_shape"); + } + + setEntries(catalog.entries); + setProviders(options.providers); + setExistingNames(comboNames(options)); + setHasCatalog(catalog.meta != null); + } catch { + setError(t("loadFailed")); + } finally { + setLoading(false); + } + } + + void load(); + }, [t]); + + const suggestions = useMemo( + () => buildRadarComboSuggestions({ entries, providers, existingComboNames: existingNames }), + [entries, providers, existingNames] + ); + + const refreshExistingName = useCallback(async (name: string): Promise => { + try { + const response = await fetch("/api/combos/builder/options"); + if (!response.ok) return false; + const options = (await response.json()) as ComboBuilderOptionsPayload; + if (!Array.isArray(options.comboRefs)) return false; + const names = comboNames(options); + if (![...names].some((candidate) => candidate.toLowerCase() === name.toLowerCase())) { + return false; + } + setExistingNames(names); + return true; + } catch { + return false; + } + }, []); + + const createSuggestion = useCallback( + async (suggestion: RadarComboSuggestion) => { + setCreatingName(suggestion.name); + setError(""); + try { + const response = await fetch("/api/combos", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(suggestion.payload), + }); + if (!response.ok) { + if (response.status === 400 && (await refreshExistingName(suggestion.name))) return; + throw new Error("create_failed"); + } + setExistingNames((current) => new Set([...current, suggestion.name])); + setCreatedNames((current) => new Set([...current, suggestion.name])); + } catch { + setError(t("createFailed")); + } finally { + setCreatingName(null); + } + }, + [refreshExistingName, t] + ); + + if (flagOff) notFound(); + + return ( +
+
+ + ← {t("backToRadar")} + +
+

{t("title")}

+

{t("subtitle")}

+
+
+ + {error &&
{error}
} + + {loading ? ( +
+ {t("loading")} +
+ ) : !hasCatalog ? ( + +

{t("catalogRequired")}

+
+ ) : suggestions.length === 0 ? ( + +

{t("noSuggestions")}

+
+ ) : ( +
+ {suggestions.map((suggestion) => { + const creating = creatingName === suggestion.name; + const created = createdNames.has(suggestion.name); + return ( + +
+
+ + {t("familyLabel")} + +

{suggestion.familyId}

+

{t("strategyReason")}

+
+ +
+ {t("modelsLabel")} + {suggestion.models.map((model) => ( +
+ {model.providerName} + + {model.qualifiedModel} + +
+ ))} +
+ + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx index 9153ba254d..75803dc17a 100644 --- a/src/app/(dashboard)/dashboard/radar/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; import { notFound } from "next/navigation"; import { Card } from "@/shared/components"; import { shouldAutoSyncOnOpen } from "@/lib/radar/autoSync"; @@ -315,15 +316,25 @@ export default function RadarPage() {

{t("title")}

{t("subtitle")}

- {pageState === "populated" && ( - - )} +
+ {(pageState === "empty" || pageState === "populated") && ( + + {t("guidedCombos")} + + )} + {pageState === "populated" && ( + + )} +
{/* Feed freshness header */} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 7c84e6c5a7..9a35e2f896 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "إعداد المزود", @@ -12280,5 +12281,22 @@ "addConnection": "إضافة اتصال", "addConnectionDescription": "لا تملك اتصالاً بعد؟ أضف واحدًا في لوحة معلومات المزودين.", "addConnectionLink": "انتقل إلى المزودين →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 3709ce24b5..4b087ef24f 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Təchizatçı Quraşdırması", @@ -12280,5 +12281,22 @@ "addConnection": "Bağlantı əlavə et", "addConnectionDescription": "Hələ bağlantınız yoxdur? Təchizatçılar panelində birini əlavə edin.", "addConnectionLink": "Təchizatçılara keç →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 83d2ba777d..84a44a67c8 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Настройка на доставчика", @@ -12280,5 +12281,22 @@ "addConnection": "Добавете връзка", "addConnectionDescription": "Все още нямате връзка? Добавете една в таблото на доставчиците.", "addConnectionLink": "Отидете на доставчиците →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 6ac5db1b9b..5d9d195ed4 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "প্রদানকারী সেটআপ", @@ -12280,5 +12281,22 @@ "addConnection": "সংযোগ যোগ করুন", "addConnectionDescription": "এখনও একটি সংযোগ নেই? প্রদানকারীদের ড্যাশবোর্ডে একটি যোগ করুন।", "addConnectionLink": "প্রদানকারীদের কাছে যান →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 5a6c8ef797..09ae432596 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Nastavení poskytovatele", @@ -12280,5 +12281,22 @@ "addConnection": "Přidat připojení", "addConnectionDescription": "Ještě nemáte připojení? Přidejte jedno v řídicím panelu poskytovatelů.", "addConnectionLink": "Přejít na poskytovatele →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 00d2771893..a8aa197cc8 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Udbyder Opsætning", @@ -12280,5 +12281,22 @@ "addConnection": "Tilføj forbindelse", "addConnectionDescription": "Har du ikke en forbindelse endnu? Tilføj en i udbyderdashboardet.", "addConnectionLink": "Gå til udbydere →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 088ee4a22e..40a1e5e313 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Anbieter-Einrichtung", @@ -12280,5 +12281,22 @@ "addConnection": "Verbindung hinzufügen", "addConnectionDescription": "Haben Sie noch keine Verbindung? Fügen Sie eine im Anbieter-Dashboard hinzu.", "addConnectionLink": "Zu den Anbietern →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ac89199d31..2ff4fdcbe1 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -12305,7 +12305,25 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." }, "radarSetupPage": { "title": "Provider Setup", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index a4375b1aea..bd2d6b454e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Configuración del proveedor", @@ -12280,5 +12281,22 @@ "addConnection": "Agregar conexión", "addConnectionDescription": "¿Aún no tienes una conexión? Agrega una en el panel de proveedores.", "addConnectionLink": "Ir a proveedores →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index fea6f33e10..51f69ddae5 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "راه‌اندازی تأمین‌کننده", @@ -12280,5 +12281,22 @@ "addConnection": "افزودن اتصال", "addConnectionDescription": "هنوز اتصالی ندارید؟ یکی را در داشبورد تأمین‌کنندگان اضافه کنید.", "addConnectionLink": "به تأمین‌کنندگان بروید →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 4b598dd04a..2473d88fb2 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Palveluntarjoajan asennus", @@ -12280,5 +12281,22 @@ "addConnection": "Lisää yhteys", "addConnectionDescription": "Eikö sinulla vielä ole yhteyttä? Lisää yksi palveluntarjoajien hallintapaneelissa.", "addConnectionLink": "Siirry palveluntarjoajiin →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 36859e21c4..9c44606d57 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -12279,7 +12279,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Configuration du fournisseur", @@ -12305,5 +12306,22 @@ "addConnection": "Ajouter une connexion", "addConnectionDescription": "Vous n'avez pas encore de connexion ? Ajoutez-en une dans le tableau de bord des fournisseurs.", "addConnectionLink": "Aller aux fournisseurs →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 64e7a6a1ab..12546d9c29 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "પ્રદાતા સેટઅપ", @@ -12280,5 +12281,22 @@ "addConnection": "કનેક્શન ઉમેરો", "addConnectionDescription": "હજુ સુધી કનેક્શન નથી? પ્રદાતા ડેશબોર્ડમાં એક ઉમેરો.", "addConnectionLink": "પ્રદાતાઓ પર જાઓ →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index d4fc2301cc..8d14ed37f2 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "הגדרת ספק", @@ -12280,5 +12281,22 @@ "addConnection": "הוסף חיבור", "addConnectionDescription": "עדיין אין לך חיבור? הוסף אחד בלוח הבקרה של הספקים.", "addConnectionLink": "עבור לספקים →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 01164266f4..07e921ca43 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "प्रदाता सेटअप", @@ -12280,5 +12281,22 @@ "addConnection": "कनेक्शन जोड़ें", "addConnectionDescription": "क्या आपके पास अभी तक कोई कनेक्शन नहीं है? प्रदाता डैशबोर्ड में एक जोड़ें।", "addConnectionLink": "प्रदाताओं पर जाएं →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 206543bb1e..7080943ab7 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Szolgáltató beállítása", @@ -12280,5 +12281,22 @@ "addConnection": "Kapcsolat hozzáadása", "addConnectionDescription": "Még nincs kapcsolatod? Adj hozzá egyet a szolgáltatók irányítópultján.", "addConnectionLink": "Menj a szolgáltatókhoz →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 73fd1574ad..4416521072 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Pengaturan Penyedia", @@ -12280,5 +12281,22 @@ "addConnection": "Tambah koneksi", "addConnectionDescription": "Belum memiliki koneksi? Tambahkan satu di dasbor penyedia.", "addConnectionLink": "Pergi ke penyedia →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index 1db67ada06..3c451f32b6 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "प्रदाता सेटअप", @@ -12280,5 +12281,22 @@ "addConnection": "कनेक्शन जोड़ें", "addConnectionDescription": "क्या आपके पास अभी तक कोई कनेक्शन नहीं है? प्रदाता डैशबोर्ड में एक जोड़ें।", "addConnectionLink": "प्रदाताओं पर जाएं →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 98f5d1eb0f..1ea2bfe569 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Configurazione del fornitore", @@ -12280,5 +12281,22 @@ "addConnection": "Aggiungi connessione", "addConnectionDescription": "Non hai ancora una connessione? Aggiungine una nel dashboard dei fornitori.", "addConnectionLink": "Vai ai fornitori →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 9cd199e37b..0563a26e74 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "プロバイダーセットアップ", @@ -12280,5 +12281,22 @@ "addConnection": "接続を追加", "addConnectionDescription": "まだ接続がありませんか?プロバイダーダッシュボードで追加してください。", "addConnectionLink": "プロバイダーに移動 →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 9203076caf..e6e81411c5 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "제공자 설정", @@ -12280,5 +12281,22 @@ "addConnection": "연결 추가", "addConnectionDescription": "아직 연결이 없으신가요? 제공자 대시보드에서 추가하세요.", "addConnectionLink": "제공자로 이동 →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 82f8074832..c48bbef677 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "प्रदाता सेटअप", @@ -12280,5 +12281,22 @@ "addConnection": "कनेक्शन जोडा", "addConnectionDescription": "अद्याप कनेक्शन नाही? प्रदाता डॅशबोर्डमध्ये एक जोडा.", "addConnectionLink": "प्रदात्यांकडे जा →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index ea1572e6e6..89e6af1a8e 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Penyediaan Penyedia", @@ -12280,5 +12281,22 @@ "addConnection": "Tambah sambungan", "addConnectionDescription": "Belum mempunyai sambungan? Tambah satu di papan pemuka penyedia.", "addConnectionLink": "Pergi ke penyedia →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 3d01da6c9b..338422573a 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Leverancier Configuratie", @@ -12280,5 +12281,22 @@ "addConnection": "Voeg verbinding toe", "addConnectionDescription": "Nog geen verbinding? Voeg er een toe in het leveranciersdashboard.", "addConnectionLink": "Ga naar leveranciers →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 57a3975e33..2acf920bab 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Leverandøroppsett", @@ -12280,5 +12281,22 @@ "addConnection": "Legg til tilkobling", "addConnectionDescription": "Har du ikke en tilkobling ennå? Legg til en i leverandørdashbordet.", "addConnectionLink": "Gå til leverandører →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 7886a6bc26..a7bcc09ea9 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Provider Setup", @@ -12280,5 +12281,22 @@ "addConnection": "Add connection", "addConnectionDescription": "Don't have a connection yet? Add one in the providers dashboard.", "addConnectionLink": "Go to providers →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 56ce285697..9470587d36 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -12276,7 +12276,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Konfiguracja dostawcy", @@ -12302,5 +12303,22 @@ "addConnection": "Dodaj połączenie", "addConnectionDescription": "Nie masz jeszcze połączenia? Dodaj je w panelu dostawców.", "addConnectionLink": "Przejdź do dostawców →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index f3a97861ae..b5c5efa60b 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -12305,7 +12305,25 @@ "hiddenModelsTitle": "Modelos ocultos", "modelDisplayName": "Nome local do modelo", "modelEnabled": "Ativado localmente", - "localStateSaveFailed": "Falha ao salvar as configurações locais do Radar" + "localStateSaveFailed": "Falha ao salvar as configurações locais do Radar", + "guidedCombos": "Combos guiados" + }, + "radarCombosPage": { + "title": "Combos guiados pelo Radar", + "subtitle": "Crie fallbacks prioritários com modelos gratuitos equivalentes curados pelo Radar.", + "backToRadar": "Voltar ao Radar", + "loading": "Carregando sugestões de combos...", + "familyLabel": "Família de modelos", + "modelsLabel": "Ordem de prioridade", + "strategyReason": "A estratégia de prioridade usa primeiro a maior franquia gratuita recorrente e depois tenta outros provedores ativos.", + "generateButton": "Gerar combo", + "generating": "Gerando...", + "alreadyCreated": "Já criado", + "created": "Combo criado", + "noSuggestions": "Nenhuma família elegível tem conexões ativas com pelo menos dois provedores no momento.", + "catalogRequired": "Sincronize primeiro o catálogo Radar para carregar as famílias de modelos curadas.", + "loadFailed": "Falha ao carregar as sugestões de combos do Radar.", + "createFailed": "Falha ao criar o combo. Revise as conexões dos provedores e tente novamente." }, "radarSetupPage": { "title": "Configuração do Provedor", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index f8bbdefccb..a177bce7ba 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Configuração do Fornecedor", @@ -12280,5 +12281,22 @@ "addConnection": "Adicionar conexão", "addConnectionDescription": "Não tem uma conexão ainda? Adicione uma no painel de fornecedores.", "addConnectionLink": "Ir para fornecedores →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 5433dc2119..eecd50ccef 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Configurare Furnizor", @@ -12280,5 +12281,22 @@ "addConnection": "Adaugă conexiune", "addConnectionDescription": "Nu ai o conexiune încă? Adaugă una în tabloul de bord al furnizorilor.", "addConnectionLink": "Mergi la furnizori →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 3136d2d8a1..74c2abecf3 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -12348,7 +12348,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Настройка провайдера", @@ -12374,5 +12375,22 @@ "addConnection": "Добавить соединение", "addConnectionDescription": "У вас еще нет соединения? Добавьте его на панели провайдеров.", "addConnectionLink": "Перейти к провайдерам →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 23bd038967..ad61e1ec54 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Nastavenie poskytovateľa", @@ -12280,5 +12281,22 @@ "addConnection": "Pridať pripojenie", "addConnectionDescription": "Ešte nemáte pripojenie? Pridajte jedno v ovládacom paneli poskytovateľov.", "addConnectionLink": "Prejdite na poskytovateľov →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 80676e0dad..99dc26c545 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Leverantörsinstallation", @@ -12280,5 +12281,22 @@ "addConnection": "Lägg till anslutning", "addConnectionDescription": "Har du ingen anslutning ännu? Lägg till en i leverantörernas instrumentpanel.", "addConnectionLink": "Gå till leverantörer →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 203f4ac7ce..0f60456caa 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Leverantörsinstallation", @@ -12280,5 +12281,22 @@ "addConnection": "Lägg till anslutning", "addConnectionDescription": "Har du ingen anslutning ännu? Lägg till en i leverantörernas instrumentpanel.", "addConnectionLink": "Gå till leverantörer →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index ff0dcbdd27..b08a960d33 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "வழங்குநர் அமைப்பு", @@ -12280,5 +12281,22 @@ "addConnection": "இணைப்பைச் சேர்க்கவும்", "addConnectionDescription": "இன்னும் ஒரு இணைப்பு இல்லை? வழங்குநர் டாஷ்போர்டில் ஒன்றைச் சேர்க்கவும்.", "addConnectionLink": "வழங்குநர்களுக்கு செல்லவும் →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 1ac839a4c6..4eebf6458d 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "ప్రొవైడర్ సెటప్", @@ -12280,5 +12281,22 @@ "addConnection": "కనెక్షన్ జోడించండి", "addConnectionDescription": "ఇంకా కనెక్షన్ లేదు? ప్రొవైడర్ డాష్‌బోర్డులో ఒకటి జోడించండి.", "addConnectionLink": "ప్రొవైడర్లకు వెళ్లండి →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index f448f0afa9..d017df86f3 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "การตั้งค่าผู้ให้บริการ", @@ -12280,5 +12281,22 @@ "addConnection": "เพิ่มการเชื่อมต่อ", "addConnectionDescription": "ยังไม่มีการเชื่อมต่อ? เพิ่มหนึ่งในแดชบอร์ดผู้ให้บริการ", "addConnectionLink": "ไปที่ผู้ให้บริการ →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index d5d361bcf1..4c7196c193 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Sağlayıcı Kurulumu", @@ -12280,5 +12281,22 @@ "addConnection": "Bağlantı ekle", "addConnectionDescription": "Henüz bir bağlantınız yok mu? Sağlayıcılar panosunda bir tane ekleyin.", "addConnectionLink": "Sağlayıcılara git →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 09be04e006..86fa1c32c8 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Налаштування постачальника", @@ -12280,5 +12281,22 @@ "addConnection": "Додати з'єднання", "addConnectionDescription": "Ще немає з'єднання? Додайте його на панелі постачальників.", "addConnectionLink": "Перейти до постачальників →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 1b3afb4c38..c9e05f15ec 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "فراہم کنندہ سیٹ اپ", @@ -12280,5 +12281,22 @@ "addConnection": "کنکشن شامل کریں", "addConnectionDescription": "کیا آپ کے پاس ابھی تک کوئی کنکشن نہیں ہے؟ فراہم کنندہ کے ڈیش بورڈ میں ایک شامل کریں۔", "addConnectionLink": "فراہم کنندگان پر جائیں →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 5cc7d91c18..c15c386a48 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -12305,7 +12305,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Thiết lập nhà cung cấp", @@ -12331,5 +12332,22 @@ "addConnection": "Thêm kết nối", "addConnectionDescription": "Chưa có kết nối? Thêm một cái trong bảng điều khiển nhà cung cấp.", "addConnectionLink": "Đi đến các nhà cung cấp →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 6035a1094d..843ea63891 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "提供者设置", @@ -12280,5 +12281,22 @@ "addConnection": "添加连接", "addConnectionDescription": "还没有连接?在提供者仪表板中添加一个。", "addConnectionLink": "前往提供者 →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index c45103f2fa..02a8793b5c 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -12254,7 +12254,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "提供者設置", @@ -12280,5 +12281,22 @@ "addConnection": "添加連接", "addConnectionDescription": "還沒有連接?在提供者儀表板中添加一個。", "addConnectionLink": "前往提供者 →" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/lib/radar/applyFeed.ts b/src/lib/radar/applyFeed.ts index 55ba532099..a8e9f07ce7 100644 --- a/src/lib/radar/applyFeed.ts +++ b/src/lib/radar/applyFeed.ts @@ -27,6 +27,8 @@ export interface MergedEntry { provider: string; modelId: string; displayName: string; + /** Curated cross-provider model family used for Radar combo suggestions. */ + familyId?: string | null; monthlyTokens: number; creditTokens: number; freeType: @@ -259,6 +261,9 @@ function mergeOne( if (!overriddenKeys.has("displayName")) { result.displayName = feed.displayName; } + if (!overriddenKeys.has("familyId")) { + result.familyId = feed.familyId; + } if (!overriddenKeys.has("monthlyTokens")) { result.monthlyTokens = feedBudgetToMonthlyTokens(feed.budget); } @@ -293,6 +298,7 @@ function mergeOne( // Apply local overrides (rule 1: they win) if (overrides) { if (overrides.displayName !== undefined) result.displayName = overrides.displayName; + if (overrides.familyId !== undefined) result.familyId = overrides.familyId; if (overrides.monthlyTokens !== undefined) result.monthlyTokens = overrides.monthlyTokens; if (overrides.creditTokens !== undefined) result.creditTokens = overrides.creditTokens; if (overrides.freeType !== undefined) result.freeType = overrides.freeType; @@ -330,6 +336,7 @@ function feedModelToMerged( provider: feed.provider, modelId: feed.modelId, displayName: overrides?.displayName ?? feed.displayName, + familyId: overrides?.familyId ?? feed.familyId, monthlyTokens: overrides?.monthlyTokens ?? feedBudgetToMonthlyTokens(feed.budget), creditTokens: overrides?.creditTokens ?? 0, freeType: overrides?.freeType ?? feed.freeType, diff --git a/src/lib/radar/comboSuggestions.ts b/src/lib/radar/comboSuggestions.ts new file mode 100644 index 0000000000..f3fbdf7547 --- /dev/null +++ b/src/lib/radar/comboSuggestions.ts @@ -0,0 +1,164 @@ +import type { ComboBuilderProviderOption } from "../combos/builderOptions"; + +import type { MergedEntry } from "./applyFeed"; + +export interface RadarComboSuggestionModel { + providerId: string; + providerName: string; + modelId: string; + qualifiedModel: string; + displayName: string; + monthlyTokens: number; +} + +export interface RadarComboSuggestionPayload { + name: string; + strategy: "priority"; + models: Array<{ + kind: "model"; + providerId: string; + model: string; + weight: 0; + }>; +} + +export interface RadarComboSuggestion { + familyId: string; + name: string; + alreadyExists: boolean; + models: RadarComboSuggestionModel[]; + payload: RadarComboSuggestionPayload; +} + +export interface BuildRadarComboSuggestionsInput { + entries: readonly MergedEntry[]; + providers: readonly ComboBuilderProviderOption[]; + existingComboNames: Iterable; +} + +function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function normalizedIdentity(value: string | null | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +function resolveProvider( + providerIdentity: string, + providers: readonly ComboBuilderProviderOption[] +): ComboBuilderProviderOption | null { + const identity = normalizedIdentity(providerIdentity); + if (!identity) return null; + + const active = providers.filter((provider) => provider.activeConnectionCount > 0); + const selectors: Array<(provider: ComboBuilderProviderOption) => string | null | undefined> = [ + (provider) => provider.providerId, + (provider) => provider.alias, + (provider) => provider.prefix, + ]; + + for (const select of selectors) { + const matches = active.filter((provider) => normalizedIdentity(select(provider)) === identity); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) return null; + } + + return null; +} + +function stableHash(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function comboNameForFamily(familyId: string): string { + const safeFamily = + familyId + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") || "family"; + const fullName = `radar-${safeFamily}`; + if (fullName.length <= 100) return fullName; + + const hash = stableHash(familyId); + const availableFamilyLength = 100 - "radar-".length - 1 - hash.length; + return `radar-${safeFamily.slice(0, availableFamilyLength)}-${hash}`; +} + +function compareModels(left: RadarComboSuggestionModel, right: RadarComboSuggestionModel): number { + return ( + right.monthlyTokens - left.monthlyTokens || + compareText(left.providerId, right.providerId) || + compareText(left.modelId, right.modelId) + ); +} + +/** Build pure, deterministic combo proposals from curated Radar families and live provider options. */ +export function buildRadarComboSuggestions( + input: BuildRadarComboSuggestionsInput +): RadarComboSuggestion[] { + const families = new Map>(); + + for (const entry of input.entries) { + const familyId = entry.familyId?.trim(); + if (!familyId || entry.enabled === false) continue; + + const provider = resolveProvider(entry.provider, input.providers); + if (!provider) continue; + const model = provider.models.find((candidate) => candidate.id === entry.modelId); + if (!model) continue; + + const candidate: RadarComboSuggestionModel = { + providerId: provider.providerId, + providerName: provider.displayName, + modelId: entry.modelId, + qualifiedModel: model.qualifiedModel, + displayName: entry.displayName, + monthlyTokens: entry.monthlyTokens, + }; + const byProvider = families.get(familyId) ?? new Map(); + const current = byProvider.get(provider.providerId); + if (!current || compareModels(candidate, current) < 0) { + byProvider.set(provider.providerId, candidate); + } + families.set(familyId, byProvider); + } + + const existingNames = new Set( + [...input.existingComboNames].map((name) => normalizedIdentity(name)).filter(Boolean) + ); + const suggestions: RadarComboSuggestion[] = []; + + for (const familyId of [...families.keys()].sort(compareText)) { + const models = [...(families.get(familyId)?.values() ?? [])].sort(compareModels); + if (models.length < 2) continue; + + const name = comboNameForFamily(familyId); + suggestions.push({ + familyId, + name, + alreadyExists: existingNames.has(normalizedIdentity(name)), + models, + payload: { + name, + strategy: "priority", + models: models.map((model) => ({ + kind: "model", + providerId: model.providerId, + model: model.qualifiedModel, + weight: 0, + })), + }, + }); + } + + return suggestions; +} diff --git a/src/shared/constants/mcpScopes.ts b/src/shared/constants/mcpScopes.ts index d5babbb63c..c03e4e5797 100644 --- a/src/shared/constants/mcpScopes.ts +++ b/src/shared/constants/mcpScopes.ts @@ -15,6 +15,7 @@ export const MCP_SCOPE_LIST = [ "read:quota", "read:usage", "read:models", + "read:radar", "execute:completions", "execute:search", "write:budget", @@ -44,6 +45,7 @@ export const MCP_TOOL_SCOPES: Record = { omniroute_web_fetch: ["execute:search"], omniroute_cost_report: ["read:usage"], omniroute_list_models_catalog: ["read:models"], + omniroute_radar_catalog: ["read:radar"], // Phase 2: Advanced Tools omniroute_simulate_route: ["read:health", "read:combos"], diff --git a/tests/unit/check-docs-counts-sync.test.ts b/tests/unit/check-docs-counts-sync.test.ts index 27deae3057..7f7aaaaffd 100644 --- a/tests/unit/check-docs-counts-sync.test.ts +++ b/tests/unit/check-docs-counts-sync.test.ts @@ -148,7 +148,7 @@ test("free-tier gate passes when a file carries no headline at all", () => { // --- Generic numeric-claim gate (engines / MCP tools / scopes / CLI) -------- // Extends the same drift guard to the counts that silently drifted in v3.8.49: -// 11→12 engines, 94→104 MCP tools, 30→31 scopes, 26→33 CLI tools. +// 11→12 engines, 94→109 MCP tools, 30→33 scopes, 26→33 CLI tools. import { makeNumberClaimValidator } from "../../scripts/check/check-docs-counts-sync.mjs"; const makeValidator = makeNumberClaimValidator as ( @@ -157,19 +157,19 @@ const makeValidator = makeNumberClaimValidator as ( ) => (content: string) => { ok: boolean; detail: string }; test("MCP-tools gate accepts the aggregate and rejects a stale one", () => { - const v = makeValidator(104, { + const v = makeValidator(109, { what: "MCP tools", pattern: /(\d+) tools/gi, skipBefore: /(tools?|definitions?)\s*\(\s*$/i, skipAfter: /^\s*\(\d+ CLI/, }); - assert.equal(v("MCP Server (104 tools)").ok, true); - assert.equal(v("with 104 tools total").ok, true); + assert.equal(v("MCP Server (109 tools)").ok, true); + assert.equal(v("with 109 tools total").ok, true); assert.equal(v("MCP Server (94 tools)").ok, false); }); test("MCP-tools gate ignores per-module counts and the CLI catalog total", () => { - const v = makeValidator(104, { + const v = makeValidator(109, { what: "MCP tools", pattern: /(\d+) tools/gi, skipBefore: /(tools?|definitions?)\s*\(\s*$/i, diff --git a/tests/unit/radar-apply-feed.test.ts b/tests/unit/radar-apply-feed.test.ts index 868d0cd76a..cd1a3fc012 100644 --- a/tests/unit/radar-apply-feed.test.ts +++ b/tests/unit/radar-apply-feed.test.ts @@ -746,6 +746,40 @@ test("FIX2 feedModelToMerged path: contextWindow/capabilities/limits/setup survi }); }); +test("F3 mergeOne path: familyId survives the feed merge over a baseline entry", () => { + const result = applyFeed({ + baseline: makeBaseline(), + feed: [ + makeFeedModel({ + provider: "groq", + modelId: "llama-3.3-70b-versatile", + familyId: "llama-3.3-70b", + }), + ], + localOverrides: new Map(), + tombstones: new Set(), + }); + + assert.equal(result.find((entry) => entry.provider === "groq")?.familyId, "llama-3.3-70b"); +}); + +test("F3 feedModelToMerged path: familyId survives for a feed-only entry", () => { + const result = applyFeed({ + baseline: [], + feed: [ + makeFeedModel({ + provider: "new-provider", + modelId: "shared-model", + familyId: "shared-family", + }), + ], + localOverrides: new Map(), + tombstones: new Set(), + }); + + assert.equal(result[0]?.familyId, "shared-family"); +}); + // =========================================================================== // Feed `enabled:false` is the safety exception to local override precedence: // a model confirmed dead upstream must not be resurrected locally. diff --git a/tests/unit/radar-combo-suggestions.test.ts b/tests/unit/radar-combo-suggestions.test.ts new file mode 100644 index 0000000000..9a83b3555b --- /dev/null +++ b/tests/unit/radar-combo-suggestions.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ComboBuilderProviderOption } from "../../src/lib/combos/builderOptions.ts"; +import type { MergedEntry } from "../../src/lib/radar/applyFeed.ts"; +import { buildRadarComboSuggestions } from "../../src/lib/radar/comboSuggestions.ts"; + +function entry( + overrides: Partial & Pick +): MergedEntry { + return { + provider: overrides.provider, + modelId: overrides.modelId, + displayName: overrides.displayName ?? overrides.modelId, + monthlyTokens: overrides.monthlyTokens ?? 100, + creditTokens: 0, + freeType: "recurring-daily", + poolKey: null, + tos: "ok", + enabled: true, + origin: "radar", + familyId: "shared-family", + ...overrides, + }; +} + +function provider( + providerId: string, + modelId: string, + overrides: Partial = {} +): ComboBuilderProviderOption { + return { + providerId, + providerType: providerId, + displayName: providerId.toUpperCase(), + alias: providerId, + icon: "api", + color: "#000000", + source: "system", + acceptsArbitraryModel: false, + connectionCount: 1, + activeConnectionCount: 1, + modelCount: 1, + connections: [], + models: [ + { + id: modelId, + qualifiedModel: `${providerId}/${modelId}`, + name: modelId, + source: "system", + sources: ["system"], + }, + ], + ...overrides, + }; +} + +test("two active providers in one family create one deterministic priority suggestion", () => { + const suggestions = buildRadarComboSuggestions({ + entries: [ + entry({ provider: "groq", modelId: "llama", monthlyTokens: 200 }), + entry({ provider: "cerebras", modelId: "llama", monthlyTokens: 300 }), + ], + providers: [provider("groq", "llama"), provider("cerebras", "llama")], + existingComboNames: [], + }); + + assert.equal(suggestions.length, 1); + assert.equal(suggestions[0].familyId, "shared-family"); + assert.equal(suggestions[0].name, "radar-shared-family"); + assert.equal(suggestions[0].alreadyExists, false); + assert.deepEqual(suggestions[0].payload, { + name: "radar-shared-family", + strategy: "priority", + models: [ + { kind: "model", providerId: "cerebras", model: "cerebras/llama", weight: 0 }, + { kind: "model", providerId: "groq", model: "groq/llama", weight: 0 }, + ], + }); +}); + +test("ineligible entries fail closed while alias and prefix match exact provider models", () => { + const suggestions = buildRadarComboSuggestions({ + entries: [ + entry({ provider: "gq", modelId: "llama", monthlyTokens: 500 }), + entry({ provider: "cb", modelId: "llama", monthlyTokens: 400 }), + entry({ provider: "inactive", modelId: "llama", monthlyTokens: 900 }), + entry({ provider: "disabled", modelId: "llama", enabled: false }), + entry({ provider: "missing-model", modelId: "other" }), + entry({ provider: "singleton", modelId: "solo", familyId: "solo-family" }), + ], + providers: [ + provider("groq", "llama", { alias: "gq" }), + provider("cerebras", "llama", { prefix: "cb" }), + provider("inactive", "llama", { activeConnectionCount: 0 }), + provider("disabled", "llama"), + provider("missing-model", "llama"), + provider("singleton", "solo"), + ], + existingComboNames: new Set(["RADAR-SHARED-FAMILY"]), + }); + + assert.equal(suggestions.length, 1); + assert.equal(suggestions[0].alreadyExists, true); + assert.deepEqual( + suggestions[0].models.map((model) => model.providerId), + ["groq", "cerebras"] + ); +}); + +test("ambiguous provider aliases, duplicate providers, empty families and unsafe names are closed", () => { + const longFamily = `Family / ${"x".repeat(120)}`; + const suggestions = buildRadarComboSuggestions({ + entries: [ + entry({ provider: "ambiguous", modelId: "m", familyId: "ambiguous-family" }), + entry({ provider: "one", modelId: "m", familyId: longFamily, monthlyTokens: 200 }), + entry({ provider: "two", modelId: "m", familyId: longFamily, monthlyTokens: 100 }), + entry({ provider: "one", modelId: "m", familyId: longFamily, monthlyTokens: 50 }), + entry({ provider: "one", modelId: "blank", familyId: " " }), + ], + providers: [ + provider("ambiguous-a", "m", { alias: "ambiguous" }), + provider("ambiguous-b", "m", { alias: "ambiguous" }), + provider("one", "m"), + provider("two", "m"), + ], + existingComboNames: [], + }); + + assert.equal(suggestions.length, 1); + assert.ok(suggestions[0].name.length <= 100); + assert.match(suggestions[0].name, /^[a-zA-Z0-9_/.\-\[\] ]+$/); + assert.equal(new Set(suggestions[0].models.map((model) => model.providerId)).size, 2); +}); diff --git a/tests/unit/radar-combos-page.test.ts b/tests/unit/radar-combos-page.test.ts new file mode 100644 index 0000000000..de41e5d86a --- /dev/null +++ b/tests/unit/radar-combos-page.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const pagePath = path.join(process.cwd(), "src/app/(dashboard)/dashboard/radar/combos/page.tsx"); +const radarPagePath = path.join(process.cwd(), "src/app/(dashboard)/dashboard/radar/page.tsx"); + +function pageSource(): string { + return fs.existsSync(pagePath) ? fs.readFileSync(pagePath, "utf8") : ""; +} + +test("Radar exposes the guided combos page from its catalog", () => { + assert.ok(fs.existsSync(pagePath), "missing /dashboard/radar/combos page"); + assert.match(fs.readFileSync(radarPagePath, "utf8"), /href="\/dashboard\/radar\/combos"/); +}); + +test("guided combos reuse only the local catalog, builder options, and combo writer", () => { + const source = pageSource(); + assert.match(source, /fetch\("\/api\/radar\/catalog"\)/); + assert.match(source, /fetch\("\/api\/combos\/builder\/options"\)/); + assert.match(source, /fetch\("\/api\/combos",\s*\{/); + assert.match(source, /method:\s*"POST"/); + assert.doesNotMatch(source, /\/api\/radar\/sync/); + assert.doesNotMatch(source, /localDb|getDbInstance|createCombo\(/); +}); + +test("guided combos render family, provider models, strategy reason and created state", () => { + const source = pageSource(); + assert.match(source, /buildRadarComboSuggestions/); + for (const key of [ + "familyLabel", + "modelsLabel", + "strategyReason", + "generateButton", + "alreadyCreated", + "noSuggestions", + "catalogRequired", + "loadFailed", + "createFailed", + ]) { + assert.match(source, new RegExp(`t\\("${key}"`), `missing UI key ${key}`); + } +}); + +test("every locale carries the Radar combos namespace and English/pt-BR have real copy", () => { + const messagesDir = path.join(process.cwd(), "src/i18n/messages"); + const files = fs.readdirSync(messagesDir).filter((file) => file.endsWith(".json")); + const requiredKeys = [ + "title", + "subtitle", + "backToRadar", + "loading", + "familyLabel", + "modelsLabel", + "strategyReason", + "generateButton", + "generating", + "alreadyCreated", + "created", + "noSuggestions", + "catalogRequired", + "loadFailed", + "createFailed", + ]; + + for (const file of files) { + const messages = JSON.parse(fs.readFileSync(path.join(messagesDir, file), "utf8")) as { + radarCombosPage?: Record; + }; + for (const key of requiredKeys) { + const value = messages.radarCombosPage?.[key]; + assert.equal(typeof value, "string", `${file}: missing radarCombosPage.${key}`); + assert.ok((value as string).trim().length > 0, `${file}: empty radarCombosPage.${key}`); + } + } + + for (const locale of ["en", "pt-BR"]) { + const messages = JSON.parse( + fs.readFileSync(path.join(messagesDir, `${locale}.json`), "utf8") + ) as { radarCombosPage: Record }; + for (const key of requiredKeys) { + assert.doesNotMatch( + messages.radarCombosPage[key], + /^__MISSING__:/, + `${locale}: placeholder at radarCombosPage.${key}` + ); + } + } +});
InterfaceEndpoint / commandUse it for
🧰 MCP (stdio)omniroute --mcpPlug into Claude Desktop, Cursor, any MCP client
🌊 MCP (HTTP)/api/mcp/streamRemote MCP — 105 tools, 31 scopes, full audit trail
🌊 MCP (HTTP)/api/mcp/streamRemote MCP — 109 tools, 33 scopes, full audit trail
📡 MCP (SSE)/api/mcp/sseStreaming MCP transport
🤝 A2A/.well-known/agent.jsonAgent-to-agent, JSON-RPC 2.0 + SSE, 6 skills
🌐 REST API/v1/*OpenAI-compatible — chat, embeddings, images, audio, OCR
DocumentDescription
API ReferenceAll endpoints with examples
OpenAPI SpecOpenAPI 3.0 specification
MCP Server104 MCP tools, IDE configs, Python/TS/Go clients
MCP Server109 MCP tools, IDE configs, Python/TS/Go clients
MCP Server GuideMCP installation, transports, and tool reference
A2A ServerJSON-RPC 2.0 protocol, skills, streaming, task mgmt
A2A Server GuideA2A agent card, tasks, skills, and streaming