mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Merge pull request #1186 from diegosouzapw/release/v3.6.4
chore(release): v3.6.4 — Auto-Combo Unification, LKGP Standalone, Combo Builder v2, Composite Tiers
This commit is contained in:
@@ -25,9 +25,8 @@ API_KEY_SECRET=
|
||||
# Initial admin login password — CHANGE THIS before first use!
|
||||
# Used by: bootstrap only — sets the initial dashboard password on first boot.
|
||||
# After first login you can change it from Dashboard → Settings → Security.
|
||||
# Default: 123456 (insecure, for local dev only)
|
||||
INITIAL_PASSWORD=123456
|
||||
|
||||
# Default: CHANGEME (insecure, for local dev only)
|
||||
INITIAL_PASSWORD=CHANGEME
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 2. STORAGE & DATABASE
|
||||
|
||||
@@ -28,6 +28,8 @@ scripts/
|
||||
.vscode/
|
||||
.agents/
|
||||
.env*
|
||||
app/.env
|
||||
app/.env*
|
||||
eslint.config.mjs
|
||||
prettier.config.mjs
|
||||
postcss.config.mjs
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -16,5 +16,6 @@
|
||||
"javascript:S3776": {
|
||||
"level": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
|
||||
84
CHANGELOG.md
84
CHANGELOG.md
@@ -4,6 +4,90 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.6.4] — 2026-04-12
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Combo Builder v2 (Wizard UI):** Completely redesigned the combo creation/editing interface as a multi-stage wizard with stages: Basics → Steps → Strategy → Review. The builder fetches provider, model, and connection metadata via a new `GET /api/combos/builder/options` endpoint, enabling precise provider/model/account selection with duplicate detection and automatic next-connection suggestion. Heavy UI components (`ModelSelectModal`, `ProxyConfigModal`, `ModelRoutingSection`) are now lazily loaded via `next/dynamic` for faster initial page render
|
||||
- **Combo Step Architecture (Schema v2):** Introduced a structured step model (`ComboModelStep`, `ComboRefStep`) replacing the legacy flat string/object combo entries. Steps carry explicit `id`, `kind`, `providerId`, `connectionId`, `weight`, and `label` fields, enabling pinned-account routing, cross-combo references, and per-step metrics. All combo CRUD operations normalize entries through the new `src/lib/combos/steps.ts` module. Zod schemas updated with `comboModelStepInputSchema` and `comboRefStepInputSchema` unions
|
||||
- **Composite Tiers System:** Added tiered model routing via `config.compositeTiers` — each tier maps a named stage to a specific combo step with optional fallback chains. Includes comprehensive validation (`src/lib/combos/compositeTiers.ts`) ensuring step existence, preventing circular fallback, and validating default tier references. Zod schema enforcement blocks composite tiers on global defaults (concrete combos only)
|
||||
- **Model Capabilities Registry:** Created `src/lib/modelCapabilities.ts` providing `getResolvedModelCapabilities()` — a unified resolver that merges static specs, provider registry data, and live-synced capabilities into a single `ResolvedModelCapabilities` object covering tool calling, reasoning, vision, context window, thinking budget, modalities, and model lifecycle metadata
|
||||
- **Observability Module:** Extracted health and telemetry payload construction into `src/lib/monitoring/observability.ts` with `buildHealthPayload()`, `buildTelemetryPayload()`, and `buildSessionsSummary()` builders. The health endpoint now returns session activity, quota monitor status, and per-provider breakdowns alongside existing system metrics
|
||||
- **Session & Quota Monitor Dashboard:** Added live Session Activity and Quota Monitors panels to the Health dashboard, showing active session counts, sticky-bound sessions, per-API-key breakdowns, and top session details alongside quota monitor alerting/exhausted/error status with per-provider drill-down
|
||||
- **Combo Health Per-Target Analytics:** The combo-health API now resolves per-target metrics using the new `resolveNestedComboTargets()` function, providing step-level success rates, latency, and historical usage breakdowns per execution key — enabling per-account, per-connection health visibility
|
||||
- **Auto-Combo → Combos Unification:** Merged the separate `/dashboard/auto-combo` page into the main `/dashboard/combos` page. Auto/LKGP combos are now managed alongside all other combos with a new strategy filter tabs system (All / Intelligent / Deterministic). The old auto-combo route redirects to `/dashboard/combos?filter=intelligent`. Removed the `auto-combo` sidebar entry, consolidating navigation into the single `Combos` item
|
||||
- **Intelligent Routing Panel (`IntelligentComboPanel`):** New inline panel (371 lines) within the combos page that shows real-time provider scores, 6-factor scoring breakdown (quota, health, cost, latency, task fitness, stability), mode pack selector, incident mode status, and excluded providers for `auto`/`lkgp` combos — replacing the former standalone auto-combo dashboard
|
||||
- **Builder Intelligent Step (`BuilderIntelligentStep`):** New conditional wizard step (280 lines) that appears in the Builder v2 flow only when `strategy=auto` or `strategy=lkgp` is selected. Exposes candidate pool selection, mode pack presets, router sub-strategy selector, exploration rate slider, budget cap, and collapsible advanced scoring weights configuration
|
||||
- **Intelligent Routing Module (`intelligentRouting.ts`):** Extracted strategy categorization and filtering logic into a dedicated shared module (210 lines) with `getStrategyCategory()`, `isIntelligentStrategy()`, `filterCombosByStrategyCategory()`, `normalizeIntelligentRoutingFilter()`, and `normalizeIntelligentRoutingConfig()` utility functions
|
||||
- **LKGP Standalone Strategy:** Implemented `lkgp` (Last Known Good Provider) as a fully functional standalone combo strategy. Previously, `lkgp` as a combo strategy silently fell through to `priority` ordering — the LKGP lookup only ran inside the `auto` engine. Now `strategy: "lkgp"` correctly queries the LKGP state, moves the last successful provider to the top of the target list, and saves the LKGP state after each successful request. Falls back to priority ordering when no LKGP state exists
|
||||
- **Unified Routing Rules & Model Aliases:** Consolidated the routing rules and model alias management controls into the Settings page, reducing fragmentation across the dashboard
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- **Middleware Lazy Loading:** Refactored `src/proxy.ts` to lazy-import `apiAuth`, `db/settings`, and `modelSyncScheduler` modules, reducing middleware cold-start overhead. Added inline `isPublicApiRoute()` to avoid loading the full auth module for public routes
|
||||
- **E2E Auth Bypass:** Added `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` environment flag to bypass authentication gates for dashboard and management API routes during Playwright E2E test runs
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **P2C Credential Selection:** Implemented Power-of-Two-Choices (P2C) connection scoring in `src/sse/services/auth.ts` with quota headroom awareness, error/recency penalties, and forced/excluded connection support. The new `getProviderCredentialsWithQuotaPreflight()` function integrates quota preflight checks directly into credential selection, eliminating the separate Codex-only preflight path
|
||||
- **Fixed-Account Combo Steps:** Combo steps with explicit `connectionId` now correctly bypass provider-level model cooldowns and circuit breakers, preventing a single account failure from blocking pinned-connection routing for the same model
|
||||
- **Combo Metrics Per-Target Tracking:** Extended `comboMetrics.ts` to track `byTarget` metrics keyed by execution path, recording per-step `provider`, `providerId`, `connectionId`, and `label` alongside existing per-model aggregates
|
||||
- **Call Logs Schema Expansion:** Added `requested_model`, `request_type`, `tokens_cache_read`, `tokens_cache_creation`, `tokens_reasoning`, `combo_step_id`, and `combo_execution_key` columns to `call_logs` with auto-migration. Added composite index `idx_cl_combo_target` for efficient per-target historical queries
|
||||
- **Quota Monitor Enrichment:** Expanded `quotaMonitor.ts` with full lifecycle state tracking (`status`, `startedAt`, `lastPolledAt`, `consecutiveFailures`, `totalPolls`, `totalAlerts`), ISO-formatted snapshots via `getQuotaMonitorSnapshots()`, and sorted summary via `getQuotaMonitorSummary()`
|
||||
- **Codex Quota Fetcher Hardening:** Improved `codexQuotaFetcher.ts` with safer connection registration and quota fetch error handling
|
||||
- **LKGP Save Refactored to Async/Await:** Replaced fire-and-forget `.then()` chain for LKGP persistence after successful combo routing with proper `async/await` + `try/catch`, preventing unhandled promise rejections and ensuring LKGP state is reliably saved before the response is returned
|
||||
- **Duplicate `auto` in Combo Strategy Schema:** Removed duplicate `"auto"` entry from `comboStrategySchema` (was listed on both line 104 and 108). Harmless to Zod runtime but cleaned up to avoid confusion. Schema now has exactly 13 unique strategy values
|
||||
- **Legacy Combo Refs Normalization:** Fixed combo step normalization to preserve legacy string combo references during CRUD operations, preventing data loss when editing combos created before the v2 step architecture
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **Auth Bypass on Backup Routes (Critical):** Added `isAuthenticated` guards to `/api/db-backups/exportAll` (full database export) and `/api/db-backups` (list, create, and restore backups) — both were previously accessible without authentication
|
||||
- **Auth Guard on Translator Save:** Added `isAuthenticated` guard to `/api/translator/save` for defense-in-depth consistency
|
||||
- **API Key Secret Hardening:** Removed the hardcoded `"omniroute-default-insecure-api-key-secret"` fallback from `apiKey.ts` — the function now fails fast if `API_KEY_SECRET` is unset, relying on the startup validator to auto-generate it
|
||||
- **NPM Tarball Leak Fix:** Added `app/.env*` to `.npmignore` to prevent the working `.env` file from being shipped inside the npm tarball distribution
|
||||
- **Electron Builder CVE Fix:** Bumped `electron-builder` to 26.8.1 to resolve `tar` CVEs in the desktop build pipeline
|
||||
|
||||
### 🔧 Maintenance & Infrastructure
|
||||
|
||||
- **DB Migration 021:** Added `combo_call_log_targets` migration for `combo_step_id` and `combo_execution_key` columns in call_logs
|
||||
- **Combo CRUD Normalization:** `db/combos.ts` now normalizes all stored combo entries through the step normalization pipeline on read, ensuring consistent step IDs and kind annotations regardless of when the combo was created
|
||||
- **Playwright Config:** Updated Playwright configuration and `run-next-playwright.mjs` script for improved E2E test orchestration
|
||||
- **Build Script:** Updated `build-next-isolated.mjs` with additional reliability improvements
|
||||
- **Auto-Combo UI Cleanup:** Deleted `AutoComboModal.tsx` (161 lines), replaced `auto-combo/page.tsx` (478→5 lines) with a server-side redirect to `/dashboard/combos?filter=intelligent`
|
||||
- **Sidebar Consolidation:** Removed `"auto-combo"` from `HIDEABLE_SIDEBAR_ITEM_IDS` and `PRIMARY_SIDEBAR_ITEMS` — `normalizeHiddenSidebarItems()` silently discards any stale `"auto-combo"` entries in user settings
|
||||
- **Schema Cleanup:** Removed obsolete `createAutoComboSchema` from `schemas.ts`. Exported `comboStrategySchema` for direct use in test and filter modules
|
||||
- **A2A Agent Card Update:** Renamed skill ID from `auto-combo` to `intelligent-routing` with updated description referencing the unified combos dashboard
|
||||
- **Builder Draft Refactor:** Extended `builderDraft.ts` with dynamic stage list generation via `getComboBuilderStages()` and `isIntelligentBuilderStrategy()`. Stage navigation (`getNextComboBuilderStage`, `getPreviousComboBuilderStage`, `canAccessComboBuilderStage`) now accepts options to conditionally include/skip the `intelligent` wizard step
|
||||
- **i18n Consolidation:** Removed the standalone `"autoCombo"` i18n block (22 keys) from all 30 language files. Migrated keys into the `"combos"` block with new additions for filter tabs, intelligent panel, and builder step labels
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- **16 New Test Suites:** Added comprehensive test coverage including:
|
||||
- `combo-builder-draft.test.mjs` (186 lines) — Builder draft step construction and validation
|
||||
- `combo-builder-options-route.test.mjs` (228 lines) — Builder options API endpoint
|
||||
- `combo-health-route.test.mjs` (266 lines) — Combo health analytics with per-target metrics
|
||||
- `combo-routes-composite-tiers.test.mjs` (157 lines) — Composite tiers API integration
|
||||
- `composite-tiers-validation.test.mjs` (131 lines) — Composite tier validation rules
|
||||
- `db-combos-crud.test.mjs` — Combo CRUD with step normalization
|
||||
- `db-core-init.test.mjs` (129 lines) — DB initialization and column migrations
|
||||
- `model-capabilities-registry.test.mjs` (105 lines) — Model capabilities resolution
|
||||
- `observability-payloads.test.mjs` (165 lines) — Health/telemetry payload construction
|
||||
- `openapi-spec-route.test.mjs` — OpenAPI spec generation
|
||||
- `proxy-e2e-mode.test.mjs` (74 lines) — E2E mode auth bypass
|
||||
- `quota-monitor.test.mjs` — Quota monitor lifecycle state
|
||||
- `run-next-playwright.test.mjs` (119 lines) — Playwright runner script
|
||||
- `sse-auth.test.mjs` (154 lines) — P2C credential selection and quota preflight
|
||||
- `telemetry-summary-route.test.mjs` (35 lines) — Telemetry summary endpoint
|
||||
- Plus updates to 12 existing test files for compatibility with new step architecture
|
||||
- **Auto-Combo Unification Tests:**
|
||||
- `autocombo-unification.test.mjs` (156 lines) — Strategy categorization, schema deduplication, sidebar cleanup, and routing strategies metadata validation
|
||||
- `combo-unification.spec.ts` (189 lines) — Playwright E2E tests for filter tabs, intelligent panel rendering, redirect from old route, sidebar entry removal, and Builder v2 intelligent step flow
|
||||
- 3 new LKGP standalone tests in `combo-routing-engine.test.mjs` — Validates LKGP provider prioritization, fallback to priority when no state exists, and LKGP state persistence after successful requests
|
||||
- Updated `combo-builder-draft.test.mjs` with intelligent stage navigation tests
|
||||
- Updated `sidebar-visibility.test.mjs` to reflect `auto-combo` removal
|
||||
|
||||
---
|
||||
|
||||
## [3.6.3] — 2026-04-11
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
20
README.md
20
README.md
@@ -244,6 +244,8 @@ Developers pay $20–200/month for Claude Pro, Codex Pro, or GitHub Copilot. Eve
|
||||
- **Provider Limits Tracking** — Cached quota snapshots refresh on a server-side schedule (default `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70`) with manual refresh available in the UI
|
||||
- **Multi-Account Support** — Multiple accounts per provider with auto round-robin — when one runs out, switches to the next
|
||||
- **Custom Combos** — Customizable fallback chains with 13 balancing strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, strict-random, auto, lkgp, context-optimized, **context-relay**)
|
||||
- **Structured Combo Builder** — Build combos step-by-step with explicit provider + model + account selection, including repeated providers and fixed-account targets
|
||||
- **Quota-Aware P2C** — Power-of-two account selection now factors quota headroom, backoff, recent errors, and consecutive use
|
||||
- **Codex Business Quotas** — Business/Team workspace quota monitoring directly in the dashboard
|
||||
|
||||
</details>
|
||||
@@ -2231,16 +2233,16 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
OmniRoute has **210+ features planned** across multiple development phases. Here are the key areas:
|
||||
OmniRoute has **218+ features planned** across multiple development phases. Here are the key areas:
|
||||
|
||||
| Category | Planned Features | Highlights |
|
||||
| ----------------------------- | ---------------- | -------------------------------------------------------------------------------------- |
|
||||
| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, P2C account selection |
|
||||
| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping |
|
||||
| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, cost tracking per model |
|
||||
| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing |
|
||||
| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API |
|
||||
| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode |
|
||||
| Category | Planned Features | Highlights |
|
||||
| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 🧠 **Routing & Intelligence** | 25+ | Lowest-latency routing, tag-based routing, quota preflight, quota-aware P2C, step-based combo routing |
|
||||
| 🔒 **Security & Compliance** | 20+ | SSRF hardening, credential cloaking, rate-limit per endpoint, management key scoping |
|
||||
| 📊 **Observability** | 15+ | OpenTelemetry integration, real-time quota monitoring, combo target health, cost tracking per model |
|
||||
| 🔄 **Provider Integrations** | 20+ | Dynamic model registry, provider cooldowns, multi-account Codex, Copilot quota parsing |
|
||||
| ⚡ **Performance** | 15+ | Dual cache layer, prompt cache, response cache, streaming keepalive, batch API |
|
||||
| 🌐 **Ecosystem** | 10+ | WebSocket API, config hot-reload, distributed config store, commercial mode |
|
||||
|
||||
### 🔜 Coming Soon
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ If you discover a security vulnerability in OmniRoute, please report it responsi
|
||||
|
||||
| Version | Support Status |
|
||||
| ------- | -------------- |
|
||||
| 3.4.x | ✅ Active |
|
||||
| 3.0.x | ✅ Security |
|
||||
| < 3.0.0 | ❌ Unsupported |
|
||||
| 3.6.x | ✅ Active |
|
||||
| 3.5.x | ✅ Security |
|
||||
| < 3.5.0 | ❌ Unsupported |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](ARCHITECTURE.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/ARCHITECTURE.md) | 🇪🇸 [Español](i18n/es/ARCHITECTURE.md) | 🇫🇷 [Français](i18n/fr/ARCHITECTURE.md) | 🇮🇹 [Italiano](i18n/it/ARCHITECTURE.md) | 🇷🇺 [Русский](i18n/ru/ARCHITECTURE.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/ARCHITECTURE.md) | 🇩🇪 [Deutsch](i18n/de/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](i18n/in/ARCHITECTURE.md) | 🇹🇭 [ไทย](i18n/th/ARCHITECTURE.md) | 🇺🇦 [Українська](i18n/uk-UA/ARCHITECTURE.md) | 🇸🇦 [العربية](i18n/ar/ARCHITECTURE.md) | 🇯🇵 [日本語](i18n/ja/ARCHITECTURE.md) | 🇻🇳 [Tiếng Việt](i18n/vi/ARCHITECTURE.md) | 🇧🇬 [Български](i18n/bg/ARCHITECTURE.md) | 🇩🇰 [Dansk](i18n/da/ARCHITECTURE.md) | 🇫🇮 [Suomi](i18n/fi/ARCHITECTURE.md) | 🇮🇱 [עברית](i18n/he/ARCHITECTURE.md) | 🇭🇺 [Magyar](i18n/hu/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/ARCHITECTURE.md) | 🇰🇷 [한국어](i18n/ko/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/ARCHITECTURE.md) | 🇳🇱 [Nederlands](i18n/nl/ARCHITECTURE.md) | 🇳🇴 [Norsk](i18n/no/ARCHITECTURE.md) | 🇵🇹 [Português (Portugal)](i18n/pt/ARCHITECTURE.md) | 🇷🇴 [Română](i18n/ro/ARCHITECTURE.md) | 🇵🇱 [Polski](i18n/pl/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](i18n/sk/ARCHITECTURE.md) | 🇸🇪 [Svenska](i18n/sv/ARCHITECTURE.md) | 🇵🇭 [Filipino](i18n/phi/ARCHITECTURE.md) | 🇨🇿 [Čeština](i18n/cs/ARCHITECTURE.md)
|
||||
|
||||
_Last updated: 2026-04-11_
|
||||
_Last updated: 2026-04-12_
|
||||
|
||||
## Executive Summary
|
||||
|
||||
@@ -14,7 +14,9 @@ Core capabilities:
|
||||
- OpenAI-compatible API surface for CLI/tools (100+ providers, 16 executors)
|
||||
- Request/response translation across provider formats
|
||||
- Model combo fallback (multi-model sequence)
|
||||
- Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers`
|
||||
- Account-level fallback (multi-account per provider)
|
||||
- Quota preflight and quota-aware P2C account selection in the main chat path
|
||||
- OAuth + API-key provider connection management (13 OAuth modules)
|
||||
- Embedding generation via `/v1/embeddings` (6 providers, 9 models)
|
||||
- Image generation via `/v1/images/generations` (10+ providers, 20+ models)
|
||||
@@ -45,6 +47,7 @@ Core capabilities:
|
||||
- Domain state persistence (SQLite write-through cache for fallbacks, budgets, lockouts, circuit breakers)
|
||||
- Policy engine for centralized request evaluation (lockout → budget → fallback)
|
||||
- Request telemetry with p50/p95/p99 latency aggregation
|
||||
- Combo target telemetry and historical combo target health via `combo_execution_key` / `combo_step_id`
|
||||
- Correlation ID (X-Request-Id) for end-to-end tracing
|
||||
- Compliance audit logging with opt-out per API key
|
||||
- Eval framework for LLM quality assurance
|
||||
@@ -89,15 +92,15 @@ Main pages under `src/app/(dashboard)/dashboard/`:
|
||||
- `/dashboard` — quick start + provider overview
|
||||
- `/dashboard/endpoint` — endpoint proxy + MCP + A2A + API endpoint tabs
|
||||
- `/dashboard/providers` — provider connections and credentials
|
||||
- `/dashboard/combos` — combo strategies, templates, model routing rules, manual persisted ordering
|
||||
- `/dashboard/combos` — combo strategies, templates, step-based builder, model routing rules, manual persisted ordering
|
||||
- `/dashboard/costs` — cost aggregation and pricing visibility
|
||||
- `/dashboard/analytics` — usage analytics and evaluations
|
||||
- `/dashboard/analytics` — usage analytics, evaluations, combo target health
|
||||
- `/dashboard/limits` — quota/rate controls
|
||||
- `/dashboard/cli-tools` — CLI onboarding, runtime detection, config generation
|
||||
- `/dashboard/agents` — detected ACP agents + custom agent registration
|
||||
- `/dashboard/media` — image/video/music playground
|
||||
- `/dashboard/search-tools` — search provider testing and history
|
||||
- `/dashboard/health` — uptime, circuit breakers, rate limits
|
||||
- `/dashboard/health` — uptime, circuit breakers, rate limits, quota-monitored sessions
|
||||
- `/dashboard/logs` — request/proxy/audit/console logs
|
||||
- `/dashboard/settings` — system settings tabs (general, routing, combo defaults, etc.)
|
||||
- `/dashboard/api-manager` — API key lifecycle and model permissions
|
||||
|
||||
@@ -18,6 +18,13 @@ Manage AI provider connections: OAuth providers (Claude Code, Codex, Gemini CLI)
|
||||
|
||||
Create model routing combos with 13 strategies: priority, weighted, round-robin, random, least-used, cost-optimized, strict-random, auto, fill-first, p2c, lkgp, context-optimized, and **context-relay**. Each combo chains multiple models with automatic fallback and includes quick templates and readiness checks.
|
||||
|
||||
Recent combo improvements:
|
||||
|
||||
- **Structured combo builder** — create each step by selecting provider, model, and exact account/connection
|
||||
- **Repeated provider support** — reuse the same provider many times in one combo as long as the `(provider, model, connection)` tuple is unique
|
||||
- **Combo target health** — analytics and health surfaces now distinguish individual combo targets/steps instead of collapsing everything into model strings
|
||||
- **Composite tier ordering** — `defaultTier -> fallbackTier` now influences runtime execution/fallback order for top-level combo steps
|
||||
|
||||

|
||||
|
||||
---
|
||||
@@ -32,7 +39,7 @@ Comprehensive usage analytics with token consumption, cost estimates, activity h
|
||||
|
||||
## 🏥 System Health
|
||||
|
||||
Real-time monitoring: uptime, memory, version, latency percentiles (p50/p95/p99), cache statistics, and provider circuit breaker states.
|
||||
Real-time monitoring: uptime, memory, version, latency percentiles (p50/p95/p99), cache statistics, provider circuit breaker states, active quota-monitored sessions, and combo target health.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.6.3
|
||||
version: 3.6.4
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.6.3",
|
||||
"version": "3.6.4",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^41.2.0",
|
||||
"electron-builder": "^25.1.8"
|
||||
"electron-builder": "^26.8.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "online.omniroute.desktop",
|
||||
|
||||
4
llm.txt
4
llm.txt
@@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
|
||||
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
|
||||
|
||||
**Current version:** 3.6.0
|
||||
**Current version:** 3.6.4
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
└── .env.example # Environment variable template
|
||||
```
|
||||
|
||||
## Key Features (v3.6.0)
|
||||
## Key Features (v3.6.4)
|
||||
|
||||
### Core Proxy
|
||||
- **60+ AI providers** with automatic format translation
|
||||
|
||||
@@ -67,10 +67,10 @@ const nextConfig = {
|
||||
//
|
||||
// We use two strategies:
|
||||
// 1. Exact-name externals for all known server-side packages.
|
||||
// 2. Hash-strip catch-all: any require('<name>-<16hexchars>' strips the
|
||||
// suffix and falls through to the real package name.
|
||||
// 2. Hash-strip catch-all: any require('<name>-<16hexchars>[/subpath]')
|
||||
// strips the hash suffix and falls through to the real package name.
|
||||
//
|
||||
const HASH_PATTERN = /^(.+)-[0-9a-f]{16}$/;
|
||||
const HASH_PATTERN = /^(.+)-[0-9a-f]{16}(\/.*)?$/;
|
||||
|
||||
const KNOWN_EXTERNALS = new Set([
|
||||
"better-sqlite3",
|
||||
@@ -102,13 +102,15 @@ const nextConfig = {
|
||||
if (KNOWN_EXTERNALS.has(request)) {
|
||||
return callback(null, `commonjs ${request}`);
|
||||
}
|
||||
// Case 2: Hash-suffixed name — strip hash, use base name
|
||||
// Case 2: Hash-suffixed name — strip hash, preserve subpath
|
||||
// e.g. "better-sqlite3-90e2652d1716b047" → "better-sqlite3"
|
||||
// "zod-dcb22c6336e0bc69" → "zod"
|
||||
// "zod-dcb22c6336e0bc69/v3" → "zod/v3"
|
||||
// "zod-dcb22c6336e0bc69/v4-mini" → "zod/v4-mini"
|
||||
const hashMatch = request?.match?.(HASH_PATTERN);
|
||||
if (hashMatch) {
|
||||
const baseName = hashMatch[1];
|
||||
return callback(null, `commonjs ${baseName}`);
|
||||
const resolved = hashMatch[2] ? `${hashMatch[1]}${hashMatch[2]}` : hashMatch[1];
|
||||
return callback(null, `commonjs ${resolved}`);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface RegistryModel {
|
||||
id: string;
|
||||
name: string;
|
||||
toolCalling?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
supportsVision?: boolean;
|
||||
targetFormat?: string;
|
||||
unsupportedParams?: readonly string[];
|
||||
/** Maximum context window in tokens */
|
||||
|
||||
@@ -206,9 +206,7 @@ export class BaseExecutor {
|
||||
headers["Authorization"] = `Bearer ${effectiveKey}`;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
headers["Accept"] = stream ? "text/event-stream" : "application/json";
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
if (stream) headers["Accept"] = "text/event-stream";
|
||||
headers["Accept"] = stream ? "text/event-stream" : "application/json";
|
||||
|
||||
// Qwen header cleanup: Remove X-Dashscope-* headers if using an API key (DashScope compatible mode).
|
||||
// If using OAuth (Qwen Code), we MUST keep them for portal.qwen.ai to accept the request.
|
||||
|
||||
@@ -479,6 +479,8 @@ export async function handleChatCore({
|
||||
comboName,
|
||||
comboStrategy = null,
|
||||
isCombo = false,
|
||||
comboStepId = null,
|
||||
comboExecutionKey = null,
|
||||
disableEmergencyFallback = false,
|
||||
}) {
|
||||
let { provider, model, extendedContext } = modelInfo;
|
||||
@@ -746,6 +748,8 @@ export async function handleChatCore({
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
comboName,
|
||||
comboStepId,
|
||||
comboExecutionKey,
|
||||
apiKeyId: apiKeyInfo?.id || null,
|
||||
apiKeyName: apiKeyInfo?.name || null,
|
||||
noLog: noLogEnabled,
|
||||
@@ -953,7 +957,10 @@ export async function handleChatCore({
|
||||
let translatedBody = body;
|
||||
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
|
||||
const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider);
|
||||
const upstreamStream = stream || isClaudeCodeCompatible;
|
||||
// Respect the client's explicit non-streaming intent for CC-compatible providers.
|
||||
// Most upstreams can answer JSON directly; the SSE->JSON fallback remains as a
|
||||
// compatibility path when an upstream still responds with event-stream.
|
||||
const upstreamStream = stream;
|
||||
let ccSessionId: string | null = null;
|
||||
|
||||
// Determine if we should preserve client-side cache_control headers
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
getComboModelProvider,
|
||||
getComboModelString,
|
||||
getComboStepTarget,
|
||||
} from "../../src/lib/combos/steps.ts";
|
||||
|
||||
import {
|
||||
MCP_TOOLS,
|
||||
@@ -105,11 +110,17 @@ function normalizeComboModels(
|
||||
rawModels: unknown
|
||||
): Array<{ provider: string; model: string; priority: number }> {
|
||||
return toArray(rawModels).map((rawModel, index) => {
|
||||
const model = toRecord(rawModel);
|
||||
const modelRecord = toRecord(rawModel);
|
||||
const modelString = getComboModelString(rawModel);
|
||||
const target = getComboStepTarget(rawModel);
|
||||
const provider =
|
||||
getComboModelProvider(rawModel) ||
|
||||
(modelString ? "unknown" : target ? "combo" : toString(modelRecord.provider, "unknown"));
|
||||
|
||||
return {
|
||||
provider: toString(model.provider, "unknown"),
|
||||
model: toString(model.model, "unknown"),
|
||||
priority: toNumber(model.priority, index + 1),
|
||||
provider,
|
||||
model: modelString || target || toString(modelRecord.model, "unknown"),
|
||||
priority: toNumber(modelRecord.priority, index + 1),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
import { logToolCall } from "../audit.ts";
|
||||
import { normalizeQuotaResponse } from "../../../src/shared/contracts/quota.ts";
|
||||
import { resolveOmniRouteBaseUrl } from "../../../src/shared/utils/resolveOmniRouteBaseUrl.ts";
|
||||
import {
|
||||
getComboModelProvider,
|
||||
getComboModelString,
|
||||
getComboStepTarget,
|
||||
} from "../../../src/lib/combos/steps.ts";
|
||||
|
||||
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
|
||||
const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || "";
|
||||
@@ -80,8 +85,8 @@ function getComboModels(combo: JsonRecord): ComboModel[] {
|
||||
const nestedModels = toArrayOfRecords(toRecord(combo.data).models);
|
||||
const sourceModels = directModels.length > 0 ? directModels : nestedModels;
|
||||
return sourceModels.map((model) => ({
|
||||
provider: toString(model.provider, "unknown"),
|
||||
model: toString(model.model, ""),
|
||||
provider: getComboModelProvider(model) || (getComboModelString(model) ? "unknown" : "combo"),
|
||||
model: getComboModelString(model) || getComboStepTarget(model) || "",
|
||||
inputCostPer1M: toNumber(model.inputCostPer1M, 3.0),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.6.3",
|
||||
"version": "3.6.4",
|
||||
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -76,6 +76,50 @@ export function registerCodexConnection(connectionId: string, meta: CodexConnect
|
||||
connectionRegistry.set(connectionId, meta);
|
||||
}
|
||||
|
||||
function getCodexConnectionMeta(
|
||||
connectionId: string,
|
||||
connection?: Record<string, unknown>
|
||||
): CodexConnectionMeta | null {
|
||||
if (connection && typeof connection === "object") {
|
||||
const providerSpecificData =
|
||||
connection.providerSpecificData &&
|
||||
typeof connection.providerSpecificData === "object" &&
|
||||
!Array.isArray(connection.providerSpecificData)
|
||||
? (connection.providerSpecificData as Record<string, unknown>)
|
||||
: {};
|
||||
const accessToken =
|
||||
typeof connection.accessToken === "string" && connection.accessToken.trim().length > 0
|
||||
? connection.accessToken
|
||||
: null;
|
||||
const workspaceId =
|
||||
typeof providerSpecificData.workspaceId === "string" &&
|
||||
providerSpecificData.workspaceId.trim().length > 0
|
||||
? providerSpecificData.workspaceId
|
||||
: undefined;
|
||||
|
||||
if (accessToken) {
|
||||
const meta = { accessToken, ...(workspaceId ? { workspaceId } : {}) };
|
||||
connectionRegistry.set(connectionId, meta);
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
|
||||
return connectionRegistry.get(connectionId) || null;
|
||||
}
|
||||
|
||||
function getDominantResetAt(quota: {
|
||||
window5h: { percentUsed: number; resetAt: string | null };
|
||||
window7d: { percentUsed: number; resetAt: string | null };
|
||||
}): string | null {
|
||||
if (quota.window7d.percentUsed > quota.window5h.percentUsed) {
|
||||
return quota.window7d.resetAt || quota.window5h.resetAt;
|
||||
}
|
||||
if (quota.window5h.percentUsed > quota.window7d.percentUsed) {
|
||||
return quota.window5h.resetAt || quota.window7d.resetAt;
|
||||
}
|
||||
return quota.window7d.resetAt || quota.window5h.resetAt;
|
||||
}
|
||||
|
||||
// ─── Core Fetcher ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -85,7 +129,10 @@ export function registerCodexConnection(connectionId: string, meta: CodexConnect
|
||||
* @param connectionId - Connection ID from the DB (used to look up credentials)
|
||||
* @returns QuotaInfo or null if fetch fails / no credentials
|
||||
*/
|
||||
export async function fetchCodexQuota(connectionId: string): Promise<QuotaInfo | null> {
|
||||
export async function fetchCodexQuota(
|
||||
connectionId: string,
|
||||
connection?: Record<string, unknown>
|
||||
): Promise<QuotaInfo | null> {
|
||||
// Check cache first
|
||||
const cached = quotaCache.get(connectionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
@@ -93,7 +140,7 @@ export async function fetchCodexQuota(connectionId: string): Promise<QuotaInfo |
|
||||
}
|
||||
|
||||
// Look up credentials
|
||||
const meta = connectionRegistry.get(connectionId);
|
||||
const meta = getCodexConnectionMeta(connectionId, connection);
|
||||
if (!meta?.accessToken) {
|
||||
// No credentials registered — skip preflight gracefully
|
||||
return null;
|
||||
@@ -206,6 +253,10 @@ function parseCodexUsageResponse(data: unknown): CodexDualWindowQuota | null {
|
||||
used: worstPercentUsed,
|
||||
total: 100,
|
||||
percentUsed: percentUsedNormalized,
|
||||
resetAt: getDominantResetAt({
|
||||
window5h: { percentUsed: usedPercent5h / 100, resetAt: resetAt5h },
|
||||
window7d: { percentUsed: usedPercent7d / 100, resetAt: resetAt7d },
|
||||
}),
|
||||
window5h: { percentUsed: usedPercent5h / 100, resetAt: resetAt5h },
|
||||
window7d: { percentUsed: usedPercent7d / 100, resetAt: resetAt7d },
|
||||
limitReached,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* In-memory combo metrics tracker
|
||||
* Tracks per-combo and per-model request counts, latency, success/failure rates
|
||||
* Provides API for reading metrics from the dashboard
|
||||
* Tracks per-combo, per-model, and per-target request counts, latency, success/failure rates.
|
||||
* Provides API for reading metrics from the dashboard.
|
||||
*/
|
||||
|
||||
interface ModelMetrics {
|
||||
@@ -13,6 +13,16 @@ interface ModelMetrics {
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
interface ComboTargetMetrics extends ModelMetrics {
|
||||
executionKey: string;
|
||||
stepId: string | null;
|
||||
model: string;
|
||||
provider: string | null;
|
||||
providerId: string | null;
|
||||
connectionId: string | null;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
interface ComboMetricsEntry {
|
||||
totalRequests: number;
|
||||
totalSuccesses: number;
|
||||
@@ -23,33 +33,140 @@ interface ComboMetricsEntry {
|
||||
lastUsedAt: string | null;
|
||||
intentCounts: Record<string, number>;
|
||||
byModel: Record<string, ModelMetrics>;
|
||||
byTarget: Record<string, ComboTargetMetrics>;
|
||||
}
|
||||
|
||||
interface ModelMetricsView extends ModelMetrics {
|
||||
avgLatencyMs: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
interface ComboTargetMetricsView extends ComboTargetMetrics {
|
||||
avgLatencyMs: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
interface ComboMetricsView extends ComboMetricsEntry {
|
||||
avgLatencyMs: number;
|
||||
successRate: number;
|
||||
fallbackRate: number;
|
||||
byModel: Record<
|
||||
string,
|
||||
ModelMetrics & {
|
||||
avgLatencyMs: number;
|
||||
successRate: number;
|
||||
}
|
||||
>;
|
||||
byModel: Record<string, ModelMetricsView>;
|
||||
byTarget: Record<string, ComboTargetMetricsView>;
|
||||
}
|
||||
|
||||
export interface ComboRequestTargetMeta {
|
||||
executionKey?: string | null;
|
||||
stepId?: string | null;
|
||||
provider?: string | null;
|
||||
providerId?: string | null;
|
||||
connectionId?: string | null;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function inferProvider(modelStr: string | null): string | null {
|
||||
const model = toNonEmptyString(modelStr);
|
||||
if (!model) return null;
|
||||
const [provider] = model.split("/");
|
||||
return toNonEmptyString(provider);
|
||||
}
|
||||
|
||||
function createModelMetrics(): ModelMetrics {
|
||||
return {
|
||||
requests: 0,
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
totalLatencyMs: 0,
|
||||
lastStatus: null,
|
||||
lastUsedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createComboEntry(strategy: string): ComboMetricsEntry {
|
||||
return {
|
||||
totalRequests: 0,
|
||||
totalSuccesses: 0,
|
||||
totalFailures: 0,
|
||||
totalFallbacks: 0,
|
||||
totalLatencyMs: 0,
|
||||
strategy,
|
||||
lastUsedAt: null,
|
||||
intentCounts: {},
|
||||
byModel: {},
|
||||
byTarget: {},
|
||||
};
|
||||
}
|
||||
|
||||
function applyMetricOutcome(
|
||||
metric: ModelMetrics,
|
||||
success: boolean,
|
||||
latencyMs: number,
|
||||
usedAt: string
|
||||
): void {
|
||||
metric.requests++;
|
||||
metric.totalLatencyMs += latencyMs;
|
||||
metric.lastUsedAt = usedAt;
|
||||
|
||||
if (success) {
|
||||
metric.successes++;
|
||||
metric.lastStatus = "ok";
|
||||
return;
|
||||
}
|
||||
|
||||
metric.failures++;
|
||||
metric.lastStatus = "error";
|
||||
}
|
||||
|
||||
function buildTargetMetric(
|
||||
modelStr: string,
|
||||
target: ComboRequestTargetMeta
|
||||
): ComboTargetMetrics | null {
|
||||
const executionKey = toNonEmptyString(target.executionKey) || toNonEmptyString(modelStr);
|
||||
const model = toNonEmptyString(modelStr);
|
||||
if (!executionKey || !model) return null;
|
||||
|
||||
return {
|
||||
executionKey,
|
||||
stepId: toNonEmptyString(target.stepId),
|
||||
model,
|
||||
provider: toNonEmptyString(target.provider) || inferProvider(model),
|
||||
providerId: toNonEmptyString(target.providerId),
|
||||
connectionId:
|
||||
target.connectionId === null ? null : (toNonEmptyString(target.connectionId) ?? null),
|
||||
label: target.label === null ? null : (toNonEmptyString(target.label) ?? null),
|
||||
...createModelMetrics(),
|
||||
};
|
||||
}
|
||||
|
||||
function toMetricView<T extends ModelMetrics>(
|
||||
metric: T
|
||||
): T & {
|
||||
avgLatencyMs: number;
|
||||
successRate: number;
|
||||
} {
|
||||
return {
|
||||
...metric,
|
||||
avgLatencyMs: metric.requests > 0 ? Math.round(metric.totalLatencyMs / metric.requests) : 0,
|
||||
successRate: metric.requests > 0 ? Math.round((metric.successes / metric.requests) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// In-memory store
|
||||
const metrics = new Map<string, ComboMetricsEntry>();
|
||||
|
||||
/**
|
||||
* Record a combo request result
|
||||
* Record a combo request result.
|
||||
* @param {string} comboName
|
||||
* @param {string} modelStr - The model that handled the request (or null if all failed)
|
||||
* @param {Object} options
|
||||
* @param {boolean} options.success
|
||||
* @param {number} options.latencyMs
|
||||
* @param {number} options.fallbackCount - How many fallbacks occurred
|
||||
* @param {string} [options.strategy] - "priority" or "weighted"
|
||||
* @param {string} [options.strategy] - Routing strategy name
|
||||
* @param {Object} [options.target] - Step/execution metadata for structured combos
|
||||
*/
|
||||
export function recordComboRequest(
|
||||
comboName: string,
|
||||
@@ -59,28 +176,27 @@ export function recordComboRequest(
|
||||
latencyMs,
|
||||
fallbackCount = 0,
|
||||
strategy = "priority",
|
||||
}: { success: boolean; latencyMs: number; fallbackCount?: number; strategy?: string }
|
||||
target,
|
||||
}: {
|
||||
success: boolean;
|
||||
latencyMs: number;
|
||||
fallbackCount?: number;
|
||||
strategy?: string;
|
||||
target?: ComboRequestTargetMeta | null;
|
||||
}
|
||||
): void {
|
||||
if (!metrics.has(comboName)) {
|
||||
metrics.set(comboName, {
|
||||
totalRequests: 0,
|
||||
totalSuccesses: 0,
|
||||
totalFailures: 0,
|
||||
totalFallbacks: 0,
|
||||
totalLatencyMs: 0,
|
||||
strategy,
|
||||
lastUsedAt: null,
|
||||
intentCounts: {},
|
||||
byModel: {},
|
||||
});
|
||||
metrics.set(comboName, createComboEntry(strategy));
|
||||
}
|
||||
|
||||
const combo = metrics.get(comboName);
|
||||
if (!combo) return;
|
||||
|
||||
const usedAt = new Date().toISOString();
|
||||
combo.totalRequests++;
|
||||
combo.totalLatencyMs += latencyMs;
|
||||
combo.totalFallbacks += fallbackCount;
|
||||
combo.lastUsedAt = new Date().toISOString();
|
||||
combo.lastUsedAt = usedAt;
|
||||
combo.strategy = strategy;
|
||||
|
||||
if (success) {
|
||||
@@ -89,35 +205,36 @@ export function recordComboRequest(
|
||||
combo.totalFailures++;
|
||||
}
|
||||
|
||||
// Per-model tracking
|
||||
if (modelStr) {
|
||||
if (!combo.byModel[modelStr]) {
|
||||
combo.byModel[modelStr] = {
|
||||
requests: 0,
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
totalLatencyMs: 0,
|
||||
lastStatus: null,
|
||||
lastUsedAt: null,
|
||||
};
|
||||
}
|
||||
const modelMetric = combo.byModel[modelStr];
|
||||
modelMetric.requests++;
|
||||
modelMetric.totalLatencyMs += latencyMs;
|
||||
modelMetric.lastUsedAt = new Date().toISOString();
|
||||
if (!modelStr) return;
|
||||
|
||||
if (success) {
|
||||
modelMetric.successes++;
|
||||
modelMetric.lastStatus = "ok";
|
||||
} else {
|
||||
modelMetric.failures++;
|
||||
modelMetric.lastStatus = "error";
|
||||
}
|
||||
if (!combo.byModel[modelStr]) {
|
||||
combo.byModel[modelStr] = createModelMetrics();
|
||||
}
|
||||
applyMetricOutcome(combo.byModel[modelStr], success, latencyMs, usedAt);
|
||||
|
||||
const targetMetric = buildTargetMetric(modelStr, target || {});
|
||||
if (!targetMetric) return;
|
||||
|
||||
if (!combo.byTarget[targetMetric.executionKey]) {
|
||||
combo.byTarget[targetMetric.executionKey] = targetMetric;
|
||||
}
|
||||
|
||||
const existingTargetMetric = combo.byTarget[targetMetric.executionKey];
|
||||
existingTargetMetric.stepId = targetMetric.stepId || existingTargetMetric.stepId;
|
||||
existingTargetMetric.provider = targetMetric.provider || existingTargetMetric.provider;
|
||||
existingTargetMetric.providerId = targetMetric.providerId || existingTargetMetric.providerId;
|
||||
existingTargetMetric.connectionId =
|
||||
target?.connectionId === null
|
||||
? null
|
||||
: (targetMetric.connectionId ?? existingTargetMetric.connectionId);
|
||||
existingTargetMetric.label =
|
||||
target?.label === null ? null : (targetMetric.label ?? existingTargetMetric.label);
|
||||
|
||||
applyMetricOutcome(existingTargetMetric, success, latencyMs, usedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metrics for a specific combo
|
||||
* Get metrics for a specific combo.
|
||||
* @param {string} comboName
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
@@ -135,20 +252,19 @@ export function getComboMetrics(comboName: string): ComboMetricsView | null {
|
||||
combo.totalRequests > 0 ? Math.round((combo.totalFallbacks / combo.totalRequests) * 100) : 0,
|
||||
intentCounts: { ...combo.intentCounts },
|
||||
byModel: Object.fromEntries(
|
||||
Object.entries(combo.byModel).map(([model, m]) => [
|
||||
model,
|
||||
{
|
||||
...m,
|
||||
avgLatencyMs: m.requests > 0 ? Math.round(m.totalLatencyMs / m.requests) : 0,
|
||||
successRate: m.requests > 0 ? Math.round((m.successes / m.requests) * 100) : 0,
|
||||
},
|
||||
Object.entries(combo.byModel).map(([model, metric]) => [model, toMetricView(metric)])
|
||||
),
|
||||
byTarget: Object.fromEntries(
|
||||
Object.entries(combo.byTarget).map(([executionKey, metric]) => [
|
||||
executionKey,
|
||||
toMetricView(metric),
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metrics for all combos
|
||||
* Get metrics for all combos.
|
||||
* @returns {Object} Map of comboName → metrics
|
||||
*/
|
||||
export function getAllComboMetrics(): Record<string, ComboMetricsView | null> {
|
||||
@@ -164,17 +280,7 @@ export function getAllComboMetrics(): Record<string, ComboMetricsView | null> {
|
||||
*/
|
||||
export function recordComboIntent(comboName: string, intent: string): void {
|
||||
if (!metrics.has(comboName)) {
|
||||
metrics.set(comboName, {
|
||||
totalRequests: 0,
|
||||
totalSuccesses: 0,
|
||||
totalFailures: 0,
|
||||
totalFallbacks: 0,
|
||||
totalLatencyMs: 0,
|
||||
strategy: "priority",
|
||||
lastUsedAt: null,
|
||||
intentCounts: {},
|
||||
byModel: {},
|
||||
});
|
||||
metrics.set(comboName, createComboEntry("priority"));
|
||||
}
|
||||
|
||||
const combo = metrics.get(comboName);
|
||||
@@ -184,14 +290,14 @@ export function recordComboIntent(comboName: string, intent: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset metrics for a specific combo
|
||||
* Reset metrics for a specific combo.
|
||||
*/
|
||||
export function resetComboMetrics(comboName: string): void {
|
||||
metrics.delete(comboName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all combo metrics
|
||||
* Reset all combo metrics.
|
||||
*/
|
||||
export function resetAllComboMetrics(): void {
|
||||
metrics.clear();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { REGISTRY } from "../config/providerRegistry.ts";
|
||||
import { getModelContextLimit } from "../../src/lib/modelsDevSync";
|
||||
import { getModelContextLimit } from "../../src/lib/modelCapabilities";
|
||||
|
||||
// Default token limits per provider (fallbacks when not in registry)
|
||||
const DEFAULT_LIMITS: Record<string, number> = {
|
||||
|
||||
@@ -71,6 +71,25 @@ function resolveProviderModelAlias(providerOrAlias, modelId) {
|
||||
return aliases?.[modelId] || modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider/model pair into canonical provider ID + provider-scoped model ID.
|
||||
* Keeps provider-specific legacy aliases out of downstream capability and budget lookups.
|
||||
*/
|
||||
export function resolveCanonicalProviderModel(providerOrAlias, modelId) {
|
||||
if (!modelId || typeof modelId !== "string") {
|
||||
return {
|
||||
provider: resolveProviderAlias(providerOrAlias),
|
||||
model: modelId || null,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = resolveProviderAlias(providerOrAlias);
|
||||
return {
|
||||
provider,
|
||||
model: resolveProviderModelAlias(provider, modelId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse model string: "alias/model" or "provider/model" or just alias
|
||||
* Supports [1m] suffix for extended 1M context window (e.g. "claude-sonnet-4-6[1m]")
|
||||
|
||||
@@ -1,92 +1,5 @@
|
||||
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
|
||||
import { parseModel } from "./model.ts";
|
||||
|
||||
// Conservative denylist fallback used when registry metadata is absent.
|
||||
// Keep small and explicit to avoid false negatives.
|
||||
const TOOL_CALLING_UNSUPPORTED_PATTERNS = ["gpt-oss-120b", "deepseek-reasoner"];
|
||||
|
||||
function getRegistryToolCallingFlag(providerIdOrAlias: string, modelId: string): boolean | null {
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[providerIdOrAlias] || providerIdOrAlias;
|
||||
const models = PROVIDER_MODELS[providerAlias];
|
||||
if (!Array.isArray(models)) return null;
|
||||
const found = models.find((m) => m?.id === modelId);
|
||||
if (!found) return null;
|
||||
return typeof found.toolCalling === "boolean" ? found.toolCalling : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a model should be considered safe for structured function/tool calling.
|
||||
*
|
||||
* Decision order:
|
||||
* 1) Provider registry metadata (toolCalling flag) when available.
|
||||
* 2) Conservative denylist fallback for known problematic model families.
|
||||
* 3) Default true.
|
||||
*/
|
||||
export function supportsToolCalling(modelStr: string): boolean {
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "";
|
||||
const model = parsed.model || modelStr;
|
||||
|
||||
if (provider) {
|
||||
const fromRegistry = getRegistryToolCallingFlag(provider, model);
|
||||
if (fromRegistry !== null) return fromRegistry;
|
||||
}
|
||||
|
||||
const normalized = String(modelStr || "").toLowerCase();
|
||||
if (!normalized) return false;
|
||||
|
||||
const blocked = TOOL_CALLING_UNSUPPORTED_PATTERNS.some((pattern) => {
|
||||
if (normalized === pattern) return true;
|
||||
if (normalized.endsWith(`/${pattern}`)) return true;
|
||||
return normalized.includes(pattern);
|
||||
});
|
||||
|
||||
return !blocked;
|
||||
}
|
||||
|
||||
// Models that do NOT support reasoning/thinking parameters.
|
||||
// AG (Antigravity) claude-sonnet-4-6 routes through a Google internal API
|
||||
// that returns 400 if thinking params are included.
|
||||
const REASONING_UNSUPPORTED_PATTERNS = [
|
||||
"antigravity/claude-sonnet-4-6",
|
||||
"antigravity/claude-sonnet-4-5",
|
||||
"antigravity/claude-sonnet-4",
|
||||
];
|
||||
|
||||
function getRegistryReasoningFlag(providerIdOrAlias: string, modelId: string): boolean | null {
|
||||
const providerAlias = PROVIDER_ID_TO_ALIAS[providerIdOrAlias] || providerIdOrAlias;
|
||||
const models = PROVIDER_MODELS[providerAlias];
|
||||
if (!Array.isArray(models)) return null;
|
||||
const found = models.find((m) => m?.id === modelId);
|
||||
if (!found) return null;
|
||||
return typeof found.supportsReasoning === "boolean" ? found.supportsReasoning : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a model supports reasoning/thinking parameters.
|
||||
*
|
||||
* Decision order:
|
||||
* 1) Provider registry metadata (supportsReasoning flag) when available.
|
||||
* 2) Explicit denylist for known unsupported models (e.g. AG Claude Sonnet).
|
||||
* 3) Default true (pass through — safe, provider will ignore if unsupported).
|
||||
*/
|
||||
export function supportsReasoning(modelStr: string): boolean {
|
||||
const parsed = parseModel(modelStr);
|
||||
const provider = parsed.provider || parsed.providerAlias || "";
|
||||
const model = parsed.model || modelStr;
|
||||
|
||||
if (provider) {
|
||||
const fromRegistry = getRegistryReasoningFlag(provider, model);
|
||||
if (fromRegistry !== null) return fromRegistry;
|
||||
}
|
||||
|
||||
const normalized = String(modelStr || "").toLowerCase();
|
||||
if (!normalized) return true;
|
||||
|
||||
const blocked = REASONING_UNSUPPORTED_PATTERNS.some(
|
||||
(pattern) =>
|
||||
normalized === pattern || normalized.endsWith(`/${pattern}`) || normalized.includes(pattern)
|
||||
);
|
||||
|
||||
return !blocked;
|
||||
}
|
||||
export {
|
||||
getResolvedModelCapabilities,
|
||||
supportsReasoning,
|
||||
supportsToolCalling,
|
||||
} from "../../src/lib/modelCapabilities.ts";
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* (commit 6cea566, Mar 8 2026).
|
||||
*/
|
||||
|
||||
import { getModelContextLimit } from "../../src/lib/modelsDevSync";
|
||||
import { getModelContextLimit } from "../../src/lib/modelCapabilities";
|
||||
import { parseModel } from "./model.ts";
|
||||
import { CONTEXT_OVERFLOW_REGEX } from "./errorClassifier.ts";
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { registerQuotaFetcher, type QuotaFetcher } from "./quotaPreflight.ts";
|
||||
import { getSessionInfo } from "./sessionManager.ts";
|
||||
|
||||
export { registerQuotaFetcher };
|
||||
export type { QuotaFetcher };
|
||||
@@ -24,6 +25,55 @@ interface MonitorState {
|
||||
stopped: boolean;
|
||||
provider: string;
|
||||
accountId: string;
|
||||
connectionSnapshot: Record<string, unknown> | null;
|
||||
sessionBound: boolean;
|
||||
status: "starting" | "idle" | "healthy" | "warning" | "exhausted" | "error";
|
||||
startedAt: number;
|
||||
lastPolledAt: number | null;
|
||||
lastSuccessAt: number | null;
|
||||
lastErrorAt: number | null;
|
||||
lastError: string | null;
|
||||
lastQuotaPercent: number | null;
|
||||
lastQuotaUsed: number | null;
|
||||
lastQuotaTotal: number | null;
|
||||
lastResetAt: string | null;
|
||||
lastAlertAt: number | null;
|
||||
nextPollDelayMs: number | null;
|
||||
nextPollAt: number | null;
|
||||
totalPolls: number;
|
||||
totalAlerts: number;
|
||||
consecutiveFailures: number;
|
||||
}
|
||||
|
||||
export interface QuotaMonitorSnapshot {
|
||||
sessionId: string;
|
||||
provider: string;
|
||||
accountId: string;
|
||||
status: "starting" | "idle" | "healthy" | "warning" | "exhausted" | "error";
|
||||
startedAt: string;
|
||||
lastPolledAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
lastErrorAt: string | null;
|
||||
lastError: string | null;
|
||||
lastQuotaPercent: number | null;
|
||||
lastQuotaUsed: number | null;
|
||||
lastQuotaTotal: number | null;
|
||||
lastResetAt: string | null;
|
||||
lastAlertAt: string | null;
|
||||
nextPollDelayMs: number | null;
|
||||
nextPollAt: string | null;
|
||||
totalPolls: number;
|
||||
totalAlerts: number;
|
||||
consecutiveFailures: number;
|
||||
}
|
||||
|
||||
export interface QuotaMonitorSummary {
|
||||
active: number;
|
||||
alerting: number;
|
||||
exhausted: number;
|
||||
errors: number;
|
||||
statusCounts: Record<QuotaMonitorSnapshot["status"], number>;
|
||||
byProvider: Record<string, number>;
|
||||
}
|
||||
|
||||
const activeMonitors = new Map<string, MonitorState>();
|
||||
@@ -45,47 +95,148 @@ function suppressedAlert(
|
||||
provider: string,
|
||||
accountId: string,
|
||||
percentUsed: number
|
||||
): void {
|
||||
): boolean {
|
||||
const key = `${sessionId}:${provider}:${accountId}`;
|
||||
const last = alertSuppression.get(key) ?? 0;
|
||||
if (Date.now() - last < ALERT_SUPPRESS_WINDOW_MS) return;
|
||||
if (Date.now() - last < ALERT_SUPPRESS_WINDOW_MS) return false;
|
||||
alertSuppression.set(key, Date.now());
|
||||
console.warn(
|
||||
`[QuotaMonitor] session=${sessionId} ${provider}/${accountId}: ${(percentUsed * 100).toFixed(1)}% quota used`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function toIsoTimestamp(value: number | null): string | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
function getMonitorStatus(percentUsed: number | null): MonitorState["status"] {
|
||||
if (!Number.isFinite(percentUsed)) return "idle";
|
||||
if ((percentUsed as number) >= EXHAUSTION_THRESHOLD) return "exhausted";
|
||||
if ((percentUsed as number) >= WARN_THRESHOLD) return "warning";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
function toPublicSnapshot(sessionId: string, state: MonitorState): QuotaMonitorSnapshot {
|
||||
return {
|
||||
sessionId,
|
||||
provider: state.provider,
|
||||
accountId: state.accountId,
|
||||
status: state.status,
|
||||
startedAt: new Date(state.startedAt).toISOString(),
|
||||
lastPolledAt: toIsoTimestamp(state.lastPolledAt),
|
||||
lastSuccessAt: toIsoTimestamp(state.lastSuccessAt),
|
||||
lastErrorAt: toIsoTimestamp(state.lastErrorAt),
|
||||
lastError: state.lastError,
|
||||
lastQuotaPercent: state.lastQuotaPercent,
|
||||
lastQuotaUsed: state.lastQuotaUsed,
|
||||
lastQuotaTotal: state.lastQuotaTotal,
|
||||
lastResetAt: state.lastResetAt,
|
||||
lastAlertAt: toIsoTimestamp(state.lastAlertAt),
|
||||
nextPollDelayMs: state.nextPollDelayMs,
|
||||
nextPollAt: toIsoTimestamp(state.nextPollAt),
|
||||
totalPolls: state.totalPolls,
|
||||
totalAlerts: state.totalAlerts,
|
||||
consecutiveFailures: state.consecutiveFailures,
|
||||
};
|
||||
}
|
||||
|
||||
function sortSnapshots(snapshots: QuotaMonitorSnapshot[]): QuotaMonitorSnapshot[] {
|
||||
const severityRank: Record<QuotaMonitorSnapshot["status"], number> = {
|
||||
exhausted: 5,
|
||||
warning: 4,
|
||||
error: 3,
|
||||
starting: 2,
|
||||
idle: 1,
|
||||
healthy: 0,
|
||||
};
|
||||
|
||||
return [...snapshots].sort((left, right) => {
|
||||
const severityDelta = severityRank[right.status] - severityRank[left.status];
|
||||
if (severityDelta !== 0) return severityDelta;
|
||||
const quotaDelta = (right.lastQuotaPercent ?? -1) - (left.lastQuotaPercent ?? -1);
|
||||
if (quotaDelta !== 0) return quotaDelta;
|
||||
return (
|
||||
(right.lastPolledAt ? Date.parse(right.lastPolledAt) : 0) -
|
||||
(left.lastPolledAt ? Date.parse(left.lastPolledAt) : 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleNextPoll(sessionId: string, intervalMs: number): void {
|
||||
const state = activeMonitors.get(sessionId);
|
||||
if (!state || state.stopped) return;
|
||||
state.nextPollDelayMs = intervalMs;
|
||||
state.nextPollAt = Date.now() + intervalMs;
|
||||
|
||||
const { provider, accountId } = state;
|
||||
const timer = setTimeout(async () => {
|
||||
const current = activeMonitors.get(sessionId);
|
||||
if (!current || current.stopped) return;
|
||||
if (current.sessionBound && !getSessionInfo(sessionId)) {
|
||||
stopQuotaMonitor(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetcher = quotaFetcherRegistry.get(provider);
|
||||
if (!fetcher) {
|
||||
current.status = current.lastQuotaPercent === null ? "idle" : current.status;
|
||||
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
const quota = await fetcher(accountId);
|
||||
const percentUsed = quota?.percentUsed ?? 0;
|
||||
current.lastPolledAt = Date.now();
|
||||
current.totalPolls += 1;
|
||||
const previousStatus = current.status;
|
||||
const quota = await fetcher(accountId, current.connectionSnapshot || undefined);
|
||||
const percentUsed =
|
||||
quota && typeof quota.percentUsed === "number" && Number.isFinite(quota.percentUsed)
|
||||
? quota.percentUsed
|
||||
: null;
|
||||
current.lastSuccessAt = Date.now();
|
||||
current.lastError = null;
|
||||
current.lastErrorAt = null;
|
||||
current.consecutiveFailures = 0;
|
||||
current.lastQuotaPercent = percentUsed;
|
||||
current.lastQuotaUsed =
|
||||
quota && typeof quota.used === "number" && Number.isFinite(quota.used) ? quota.used : null;
|
||||
current.lastQuotaTotal =
|
||||
quota && typeof quota.total === "number" && Number.isFinite(quota.total)
|
||||
? quota.total
|
||||
: null;
|
||||
current.lastResetAt =
|
||||
quota && typeof quota.resetAt === "string" && quota.resetAt.trim().length > 0
|
||||
? quota.resetAt
|
||||
: null;
|
||||
current.status = getMonitorStatus(percentUsed);
|
||||
|
||||
if (percentUsed >= EXHAUSTION_THRESHOLD) {
|
||||
suppressedAlert(sessionId, provider, accountId, percentUsed);
|
||||
console.info(
|
||||
`[QuotaMonitor] session=${sessionId}: marking ${accountId} for next-session cooldown`
|
||||
);
|
||||
if (percentUsed !== null && percentUsed >= EXHAUSTION_THRESHOLD) {
|
||||
const emittedAlert = suppressedAlert(sessionId, provider, accountId, percentUsed);
|
||||
if (emittedAlert) {
|
||||
current.lastAlertAt = Date.now();
|
||||
current.totalAlerts += 1;
|
||||
}
|
||||
if (emittedAlert || previousStatus !== "exhausted") {
|
||||
console.info(
|
||||
`[QuotaMonitor] session=${sessionId}: marking ${accountId} for next-session cooldown`
|
||||
);
|
||||
}
|
||||
scheduleNextPoll(sessionId, CRITICAL_INTERVAL_MS);
|
||||
} else if (percentUsed >= WARN_THRESHOLD) {
|
||||
suppressedAlert(sessionId, provider, accountId, percentUsed);
|
||||
} else if (percentUsed !== null && percentUsed >= WARN_THRESHOLD) {
|
||||
const emittedAlert = suppressedAlert(sessionId, provider, accountId, percentUsed);
|
||||
if (emittedAlert) {
|
||||
current.lastAlertAt = Date.now();
|
||||
current.totalAlerts += 1;
|
||||
}
|
||||
scheduleNextPoll(sessionId, CRITICAL_INTERVAL_MS);
|
||||
} else {
|
||||
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
current.lastErrorAt = Date.now();
|
||||
current.lastError = error instanceof Error ? error.message : String(error);
|
||||
current.consecutiveFailures += 1;
|
||||
current.status = "error";
|
||||
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
|
||||
}
|
||||
}, intervalMs);
|
||||
@@ -101,9 +252,40 @@ export function startQuotaMonitor(
|
||||
connection: Record<string, unknown>
|
||||
): void {
|
||||
if (!isQuotaMonitorEnabled(connection)) return;
|
||||
if (activeMonitors.has(sessionId)) return;
|
||||
const current = activeMonitors.get(sessionId);
|
||||
if (current && !current.stopped) {
|
||||
if (current.provider === provider && current.accountId === accountId) {
|
||||
current.connectionSnapshot = connection;
|
||||
current.sessionBound = current.sessionBound || getSessionInfo(sessionId) !== null;
|
||||
return;
|
||||
}
|
||||
stopQuotaMonitor(sessionId);
|
||||
}
|
||||
|
||||
activeMonitors.set(sessionId, { timer: null, stopped: false, provider, accountId });
|
||||
activeMonitors.set(sessionId, {
|
||||
timer: null,
|
||||
stopped: false,
|
||||
provider,
|
||||
accountId,
|
||||
connectionSnapshot: connection,
|
||||
sessionBound: getSessionInfo(sessionId) !== null,
|
||||
status: "starting",
|
||||
startedAt: Date.now(),
|
||||
lastPolledAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastErrorAt: null,
|
||||
lastError: null,
|
||||
lastQuotaPercent: null,
|
||||
lastQuotaUsed: null,
|
||||
lastQuotaTotal: null,
|
||||
lastResetAt: null,
|
||||
lastAlertAt: null,
|
||||
nextPollDelayMs: null,
|
||||
nextPollAt: null,
|
||||
totalPolls: 0,
|
||||
totalAlerts: 0,
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -124,3 +306,52 @@ export function stopQuotaMonitor(sessionId: string): void {
|
||||
export function getActiveMonitorCount(): number {
|
||||
return activeMonitors.size;
|
||||
}
|
||||
|
||||
export function getQuotaMonitorSnapshot(sessionId: string): QuotaMonitorSnapshot | null {
|
||||
const state = activeMonitors.get(sessionId);
|
||||
if (!state || state.stopped) return null;
|
||||
return toPublicSnapshot(sessionId, state);
|
||||
}
|
||||
|
||||
export function getQuotaMonitorSnapshots(): QuotaMonitorSnapshot[] {
|
||||
const snapshots: QuotaMonitorSnapshot[] = [];
|
||||
for (const [sessionId, state] of activeMonitors) {
|
||||
if (state.stopped) continue;
|
||||
snapshots.push(toPublicSnapshot(sessionId, state));
|
||||
}
|
||||
return sortSnapshots(snapshots);
|
||||
}
|
||||
|
||||
export function getQuotaMonitorSummary(): QuotaMonitorSummary {
|
||||
const snapshots = getQuotaMonitorSnapshots();
|
||||
const statusCounts: Record<QuotaMonitorSnapshot["status"], number> = {
|
||||
starting: 0,
|
||||
idle: 0,
|
||||
healthy: 0,
|
||||
warning: 0,
|
||||
exhausted: 0,
|
||||
error: 0,
|
||||
};
|
||||
const byProvider: Record<string, number> = {};
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
statusCounts[snapshot.status] += 1;
|
||||
byProvider[snapshot.provider] = (byProvider[snapshot.provider] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
active: snapshots.length,
|
||||
alerting: statusCounts.warning + statusCounts.exhausted,
|
||||
exhausted: statusCounts.exhausted,
|
||||
errors: statusCounts.error,
|
||||
statusCounts,
|
||||
byProvider,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearQuotaMonitors(): void {
|
||||
for (const sessionId of [...activeMonitors.keys()]) {
|
||||
stopQuotaMonitor(sessionId);
|
||||
}
|
||||
alertSuppression.clear();
|
||||
}
|
||||
|
||||
@@ -11,15 +11,20 @@ export interface PreflightQuotaResult {
|
||||
proceed: boolean;
|
||||
reason?: string;
|
||||
quotaPercent?: number;
|
||||
resetAt?: string | null;
|
||||
}
|
||||
|
||||
export interface QuotaInfo {
|
||||
used: number;
|
||||
total: number;
|
||||
percentUsed: number;
|
||||
resetAt?: string | null;
|
||||
}
|
||||
|
||||
export type QuotaFetcher = (connectionId: string) => Promise<QuotaInfo | null>;
|
||||
export type QuotaFetcher = (
|
||||
connectionId: string,
|
||||
connection?: Record<string, unknown>
|
||||
) => Promise<QuotaInfo | null>;
|
||||
|
||||
const EXHAUSTION_THRESHOLD = 0.95;
|
||||
const WARN_THRESHOLD = 0.8;
|
||||
@@ -51,7 +56,7 @@ export async function preflightQuota(
|
||||
|
||||
let quota: QuotaInfo | null = null;
|
||||
try {
|
||||
quota = await fetcher(connectionId);
|
||||
quota = await fetcher(connectionId, connection);
|
||||
} catch {
|
||||
return { proceed: true };
|
||||
}
|
||||
@@ -66,7 +71,12 @@ export async function preflightQuota(
|
||||
console.info(
|
||||
`[QuotaPreflight] ${provider}/${connectionId}: ${(percentUsed * 100).toFixed(1)}% used — switching`
|
||||
);
|
||||
return { proceed: false, reason: "quota_exhausted", quotaPercent: percentUsed };
|
||||
return {
|
||||
proceed: false,
|
||||
reason: "quota_exhausted",
|
||||
quotaPercent: percentUsed,
|
||||
resetAt: quota.resetAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (percentUsed >= WARN_THRESHOLD) {
|
||||
|
||||
@@ -13,8 +13,12 @@ export const ThinkingMode = {
|
||||
ADAPTIVE: "adaptive", // Scale based on request complexity
|
||||
};
|
||||
|
||||
import { capThinkingBudget, getDefaultThinkingBudget } from "@/shared/constants/modelSpecs";
|
||||
import { supportsReasoning } from "./modelCapabilities.ts";
|
||||
import {
|
||||
capThinkingBudget,
|
||||
getDefaultThinkingBudget,
|
||||
getResolvedModelCapabilities,
|
||||
supportsReasoning,
|
||||
} from "@/lib/modelCapabilities";
|
||||
|
||||
// Effort → budget token mapping
|
||||
export const EFFORT_BUDGETS = {
|
||||
@@ -289,6 +293,9 @@ function applyAdaptiveBudget(body, cfg) {
|
||||
*/
|
||||
export function hasThinkingCapableModel(body) {
|
||||
const model = body.model || "";
|
||||
const resolved = getResolvedModelCapabilities(model);
|
||||
if (resolved.supportsThinking === true) return true;
|
||||
if (resolved.supportsThinking === false) return false;
|
||||
return (
|
||||
model.includes("claude") ||
|
||||
model.includes("o1") ||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
capMaxOutputTokens,
|
||||
capThinkingBudget,
|
||||
getDefaultThinkingBudget,
|
||||
} from "../../../src/shared/constants/modelSpecs.ts";
|
||||
} from "../../../src/lib/modelCapabilities.ts";
|
||||
|
||||
import * as crypto from "crypto";
|
||||
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.6.3",
|
||||
"version": "3.6.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.6.3",
|
||||
"version": "3.6.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -20955,7 +20955,7 @@
|
||||
},
|
||||
"open-sse": {
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.6.3"
|
||||
"version": "3.6.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.6.3",
|
||||
"version": "3.6.4",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -4,6 +4,10 @@ const dashboardPort = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
|
||||
const dashboardBaseUrl = `http://localhost:${dashboardPort}`;
|
||||
const webServerReadyUrl = `${dashboardBaseUrl}/api/monitoring/health`;
|
||||
const playwrightServerMode = process.env.OMNIROUTE_PLAYWRIGHT_SERVER_MODE || "start";
|
||||
const playwrightWebServerTimeout = Number.parseInt(
|
||||
process.env.OMNIROUTE_PLAYWRIGHT_WEB_SERVER_TIMEOUT || "900000",
|
||||
10
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
@@ -33,6 +37,6 @@ export default defineConfig({
|
||||
command: `node scripts/run-next-playwright.mjs ${playwrightServerMode}`,
|
||||
url: webServerReadyUrl,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 300_000,
|
||||
timeout: Number.isFinite(playwrightWebServerTimeout) ? playwrightWebServerTimeout : 900_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ function runNextBuild() {
|
||||
const child = spawn(process.execPath, [nextBin, "build"], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
env: resolveNextBuildEnv(process.env),
|
||||
});
|
||||
|
||||
const forward = (signal) => {
|
||||
@@ -74,6 +74,13 @@ function runNextBuild() {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNextBuildEnv(baseEnv = process.env) {
|
||||
return {
|
||||
...baseEnv,
|
||||
NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0",
|
||||
};
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
let moved = false;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, renameSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
resolveRuntimePorts,
|
||||
sanitizeColorEnv,
|
||||
@@ -16,10 +17,19 @@ const cwd = process.cwd();
|
||||
const appDir = join(cwd, "app");
|
||||
const srcAppDir = join(cwd, "src", "app");
|
||||
const appPage = join(appDir, "page.tsx");
|
||||
const backupDir = join(cwd, "app.__qa_backup");
|
||||
const defaultBackupDir = join(cwd, "app.__qa_backup");
|
||||
const backupDir = resolvePlaywrightAppBackupDir({
|
||||
cwd,
|
||||
baseBackupExists: existsSync(defaultBackupDir),
|
||||
appDirExists: existsSync(appDir),
|
||||
});
|
||||
const usingAlternativeBackupDir = backupDir !== defaultBackupDir;
|
||||
const buildScript = join(cwd, "scripts", "build-next-isolated.mjs");
|
||||
const standaloneServer = join(cwd, testDistDir(), "standalone", "server.js");
|
||||
const buildIdFile = join(cwd, testDistDir(), "BUILD_ID");
|
||||
const rootStaticDir = join(cwd, testDistDir(), "static");
|
||||
const rootPublicDir = join(cwd, "public");
|
||||
const standaloneStaticDir = join(cwd, testDistDir(), "standalone", ".next", "static");
|
||||
const standalonePublicDir = join(cwd, testDistDir(), "standalone", "public");
|
||||
|
||||
let appDirMoved = false;
|
||||
|
||||
@@ -27,19 +37,90 @@ function testDistDir() {
|
||||
return process.env.NEXT_DIST_DIR || ".next";
|
||||
}
|
||||
|
||||
export function resolvePlaywrightAppBackupDir({
|
||||
cwd,
|
||||
baseBackupExists,
|
||||
appDirExists,
|
||||
pid = process.pid,
|
||||
now = Date.now(),
|
||||
}) {
|
||||
const baseBackupDir = join(cwd, "app.__qa_backup");
|
||||
if (!baseBackupExists || !appDirExists) {
|
||||
return baseBackupDir;
|
||||
}
|
||||
|
||||
return join(cwd, `app.__qa_backup.${pid}.${now}`);
|
||||
}
|
||||
|
||||
function shouldMoveAppDir() {
|
||||
return existsSync(appDir) && !existsSync(appPage) && existsSync(srcAppDir);
|
||||
}
|
||||
|
||||
export function directoryHasEntries(dirPath) {
|
||||
try {
|
||||
return readdirSync(dirPath).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function standaloneAssetsNeedSync({
|
||||
standaloneServerPath,
|
||||
rootStaticDirPath,
|
||||
standaloneStaticDirPath,
|
||||
}) {
|
||||
return (
|
||||
existsSync(standaloneServerPath) &&
|
||||
existsSync(rootStaticDirPath) &&
|
||||
!directoryHasEntries(standaloneStaticDirPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function syncStandaloneRuntimeAssets({
|
||||
standaloneServerPath,
|
||||
rootStaticDirPath,
|
||||
standaloneStaticDirPath,
|
||||
rootPublicDirPath,
|
||||
standalonePublicDirPath,
|
||||
log = console,
|
||||
}) {
|
||||
if (!existsSync(standaloneServerPath)) return false;
|
||||
|
||||
let changed = false;
|
||||
|
||||
if (existsSync(rootPublicDirPath) && !directoryHasEntries(standalonePublicDirPath)) {
|
||||
cpSync(rootPublicDirPath, standalonePublicDirPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (existsSync(rootStaticDirPath) && !directoryHasEntries(standaloneStaticDirPath)) {
|
||||
mkdirSync(dirname(standaloneStaticDirPath), {
|
||||
recursive: true,
|
||||
});
|
||||
cpSync(rootStaticDirPath, standaloneStaticDirPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
log.log("[Playwright WebServer] Rehydrated standalone static/public assets");
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function prepareAppDir() {
|
||||
if (!shouldMoveAppDir()) return;
|
||||
|
||||
if (existsSync(backupDir)) {
|
||||
if (usingAlternativeBackupDir) {
|
||||
console.warn(
|
||||
"[Playwright WebServer] app.__qa_backup already exists; leaving app/ in place. " +
|
||||
"If tests hit 404 on every route, clear app/ artifacts before running e2e."
|
||||
"[Playwright WebServer] Existing app.__qa_backup detected; using a per-run backup dir instead."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
renameSync(appDir, backupDir);
|
||||
@@ -55,14 +136,6 @@ function restoreAppDir() {
|
||||
console.log("[Playwright WebServer] Restored app/ directory");
|
||||
}
|
||||
|
||||
process.on("exit", restoreAppDir);
|
||||
process.on("uncaughtException", (error) => {
|
||||
restoreAppDir();
|
||||
throw error;
|
||||
});
|
||||
|
||||
prepareAppDir();
|
||||
|
||||
const bootstrapEnvVars = bootstrapEnv({ quiet: true });
|
||||
const runtimePorts = resolveRuntimePorts(bootstrapEnvVars);
|
||||
const testServerEnv = {
|
||||
@@ -73,8 +146,17 @@ const testServerEnv = {
|
||||
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK || "1",
|
||||
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "1",
|
||||
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "1",
|
||||
...(process.env.OMNIROUTE_USE_TURBOPACK
|
||||
? {
|
||||
OMNIROUTE_USE_TURBOPACK: process.env.OMNIROUTE_USE_TURBOPACK,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
export function shouldUseWebpackForPlaywrightDev({ mode, env }) {
|
||||
return mode === "dev" && env.OMNIROUTE_USE_TURBOPACK !== "1";
|
||||
}
|
||||
|
||||
function runChild(command, args, env) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
@@ -100,7 +182,7 @@ function runChild(command, args, env) {
|
||||
async function runBuildForStart() {
|
||||
if (mode !== "start") return;
|
||||
if (process.env.OMNIROUTE_PLAYWRIGHT_SKIP_BUILD === "1") return;
|
||||
if (existsSync(buildIdFile)) return;
|
||||
console.log("[Playwright WebServer] Building fresh standalone app for this run...");
|
||||
|
||||
const buildEnv = withRuntimePortEnv(testServerEnv, runtimePorts);
|
||||
const result = await runChild(process.execPath, [buildScript], buildEnv);
|
||||
@@ -115,18 +197,37 @@ async function runBuildForStart() {
|
||||
}
|
||||
}
|
||||
|
||||
await runBuildForStart();
|
||||
if (mode === "start") {
|
||||
if (existsSync(standaloneServer)) {
|
||||
spawnWithForwardedSignals(process.execPath, [standaloneServer], {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...withRuntimePortEnv(testServerEnv, runtimePorts),
|
||||
PORT: String(runtimePorts.dashboardPort),
|
||||
HOSTNAME: process.env.HOSTNAME || "127.0.0.1",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
export async function main() {
|
||||
process.on("exit", restoreAppDir);
|
||||
process.on("uncaughtException", (error) => {
|
||||
restoreAppDir();
|
||||
throw error;
|
||||
});
|
||||
|
||||
prepareAppDir();
|
||||
await runBuildForStart();
|
||||
|
||||
if (mode === "start") {
|
||||
if (existsSync(standaloneServer)) {
|
||||
syncStandaloneRuntimeAssets({
|
||||
standaloneServerPath: standaloneServer,
|
||||
rootStaticDirPath: rootStaticDir,
|
||||
standaloneStaticDirPath: standaloneStaticDir,
|
||||
rootPublicDirPath: rootPublicDir,
|
||||
standalonePublicDirPath: standalonePublicDir,
|
||||
});
|
||||
|
||||
spawnWithForwardedSignals(process.execPath, [standaloneServer], {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...withRuntimePortEnv(testServerEnv, runtimePorts),
|
||||
PORT: String(runtimePorts.dashboardPort),
|
||||
HOSTNAME: process.env.HOSTNAME || "127.0.0.1",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const args = [
|
||||
"./node_modules/next/dist/bin/next",
|
||||
"start",
|
||||
@@ -138,18 +239,26 @@ if (mode === "start") {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(testServerEnv, runtimePorts),
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
const args = [
|
||||
"./node_modules/next/dist/bin/next",
|
||||
mode,
|
||||
"--webpack",
|
||||
"--port",
|
||||
String(runtimePorts.dashboardPort),
|
||||
];
|
||||
|
||||
if (shouldUseWebpackForPlaywrightDev({ mode, env: testServerEnv })) {
|
||||
args.splice(2, 0, "--webpack");
|
||||
}
|
||||
|
||||
spawnWithForwardedSignals(process.execPath, args, {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(testServerEnv, runtimePorts),
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ function formatShare(value: number) {
|
||||
return formatPercent(value * 100, 1);
|
||||
}
|
||||
|
||||
function formatPercentOrDash(value: number | null, digits = 1) {
|
||||
return typeof value === "number" ? formatPercent(value, digits) : "n/a";
|
||||
}
|
||||
|
||||
function formatLatency(value: number) {
|
||||
return `${Math.round(value).toLocaleString()}ms`;
|
||||
}
|
||||
@@ -95,6 +99,7 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
),
|
||||
[combo.usageSkew.modelDistribution]
|
||||
);
|
||||
const targetHealth = combo.targetHealth || [];
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden p-0">
|
||||
@@ -251,6 +256,73 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{targetHealth.length > 0 ? (
|
||||
<div className="border-t border-black/5 px-6 py-5 dark:border-white/5">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">Execution targets</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
Step-level runtime metrics and quota visibility for structured combo targets.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{targetHealth.map((target) => (
|
||||
<div
|
||||
key={target.executionKey}
|
||||
className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-text-main">
|
||||
{target.label || target.model}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
{target.provider}
|
||||
{target.connectionId ? ` · ${target.connectionId.slice(0, 8)}` : ""}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-text-muted">{target.stepId}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{target.lastStatus ? (
|
||||
<Badge size="sm" variant={target.lastStatus === "ok" ? "success" : "error"}>
|
||||
{target.lastStatus}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge size="sm" variant="default">
|
||||
{target.requests} req
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<DistributionBar
|
||||
label="Success"
|
||||
value={Math.max(target.successRate, 0) / 100}
|
||||
meta={formatPercent(target.successRate, 0)}
|
||||
/>
|
||||
<DistributionBar
|
||||
label="Latency"
|
||||
value={target.avgLatencyMs > 0 ? 1 : 0}
|
||||
meta={formatLatency(target.avgLatencyMs)}
|
||||
/>
|
||||
<DistributionBar
|
||||
label="Quota"
|
||||
value={Math.max(target.quotaRemainingPct || 0, 0) / 100}
|
||||
meta={formatPercentOrDash(target.quotaRemainingPct)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-text-muted">
|
||||
<span>Quota scope: {target.quotaScope}</span>
|
||||
{target.quotaTrend ? <span>Trend: {target.quotaTrend}</span> : null}
|
||||
{target.quotaIsExhausted ? <span>Exhausted</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Input, Button } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AutoComboModal({ isOpen, onClose, onSave, combo, activeProviders = [] }) {
|
||||
const t = useTranslations("combos");
|
||||
const tc = useTranslations("common");
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
strategy: "auto",
|
||||
candidatePool: [],
|
||||
explorationRate: 0.05,
|
||||
modePack: "ship-fast",
|
||||
budgetCap: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (combo) {
|
||||
// eslint-disable-next-line
|
||||
setFormData({
|
||||
name: combo.name || "",
|
||||
strategy: combo.strategy || "auto",
|
||||
candidatePool: combo.config?.candidatePool || [],
|
||||
explorationRate: combo.config?.explorationRate ?? 0.05,
|
||||
modePack: combo.config?.modePack || "ship-fast",
|
||||
budgetCap: combo.config?.budgetCap || "",
|
||||
});
|
||||
} else {
|
||||
|
||||
setFormData({
|
||||
name: "",
|
||||
strategy: "auto",
|
||||
candidatePool: [],
|
||||
explorationRate: 0.05,
|
||||
modePack: "ship-fast",
|
||||
budgetCap: "",
|
||||
});
|
||||
}
|
||||
}, [combo, isOpen]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSave({
|
||||
name: formData.name,
|
||||
strategy: formData.strategy,
|
||||
config: {
|
||||
candidatePool: formData.candidatePool,
|
||||
explorationRate: Number(formData.explorationRate),
|
||||
modePack: formData.modePack,
|
||||
budgetCap: formData.budgetCap ? Number(formData.budgetCap) : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleProviderToggle = (providerId) => {
|
||||
setFormData((prev) => {
|
||||
const pool = prev.candidatePool.includes(providerId)
|
||||
? prev.candidatePool.filter((id) => id !== providerId)
|
||||
: [...prev.candidatePool, providerId];
|
||||
return { ...prev, candidatePool: pool };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={combo ? "Edit Auto-Combo" : "Create Auto-Combo"}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Combo Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
pattern="^[a-zA-Z0-9_\/\.\-]+$"
|
||||
disabled={!!combo} // Cannot change name if editing
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Strategy</label>
|
||||
<select
|
||||
className="w-full text-sm rounded-lg border border-border bg-surface px-3 py-2 text-text-main focus:border-primary focus:outline-none"
|
||||
value={formData.strategy}
|
||||
onChange={(e) => setFormData({ ...formData, strategy: e.target.value })}
|
||||
>
|
||||
<option value="auto">Smart Auto-Routing</option>
|
||||
<option value="lkgp">Last Known Good Provider (LKGP)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Candidate Pool</label>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
Select which providers this engine evaluates.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-2 border border-border rounded-lg">
|
||||
{activeProviders.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => handleProviderToggle(p.id)}
|
||||
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
|
||||
formData.candidatePool.includes(p.id)
|
||||
? "bg-primary border-primary text-white"
|
||||
: "bg-surface border-border text-text-main"
|
||||
}`}
|
||||
>
|
||||
{p.name || p.id}
|
||||
</button>
|
||||
))}
|
||||
{activeProviders.length === 0 && (
|
||||
<span className="text-xs text-text-muted">No active APIs found</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Exploration Rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={formData.explorationRate}
|
||||
onChange={(e) => setFormData({ ...formData, explorationRate: e.target.value })}
|
||||
/>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Mode Pack</label>
|
||||
<select
|
||||
className="w-full text-sm rounded-lg border border-border bg-surface px-3 py-2 text-text-main focus:border-primary focus:outline-none"
|
||||
value={formData.modePack}
|
||||
onChange={(e) => setFormData({ ...formData, modePack: e.target.value })}
|
||||
>
|
||||
<option value="ship-fast">Ship Fast</option>
|
||||
<option value="cost-saver">Cost Saver</option>
|
||||
<option value="quality-first">Quality First</option>
|
||||
<option value="offline-friendly">Offline Friendly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Budget Cap ($ USD / request limit)"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
placeholder="Optional"
|
||||
value={formData.budgetCap}
|
||||
onChange={(e) => setFormData({ ...formData, budgetCap: e.target.value })}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,477 +1,5 @@
|
||||
/**
|
||||
* Dashboard Auto-Combo Panel — /dashboard/auto-combo
|
||||
*
|
||||
* Shows provider scores, scoring factors, exclusions, mode packs, and routing history.
|
||||
*/
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import AutoComboModal from "./AutoComboModal";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
interface ProviderScore {
|
||||
provider: string;
|
||||
model: string;
|
||||
score: number;
|
||||
factors: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ExclusionEntry {
|
||||
provider: string;
|
||||
excludedAt: string;
|
||||
cooldownMs: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
type AutoComboRecord = {
|
||||
candidatePool?: unknown;
|
||||
weights?: unknown;
|
||||
};
|
||||
|
||||
type HealthRecord = {
|
||||
providerHealth?: Record<string, { state?: string; lastFailure?: string | null }>;
|
||||
circuitBreakers?: Array<{
|
||||
provider?: string;
|
||||
name?: string;
|
||||
state?: string;
|
||||
lastFailure?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default function AutoComboDashboard() {
|
||||
const [scores, setScores] = useState<ProviderScore[]>([]);
|
||||
const [exclusions, setExclusions] = useState<ExclusionEntry[]>([]);
|
||||
const [incidentMode, setIncidentMode] = useState(false);
|
||||
const [modePack, setModePack] = useState("ship-fast");
|
||||
|
||||
const notify = useNotificationStore();
|
||||
const [combos, setCombos] = useState<any[]>([]);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingCombo, setEditingCombo] = useState<any | null>(null);
|
||||
const [activeProviders, setActiveProviders] = useState<any[]>([]);
|
||||
|
||||
const fetchCombos = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/combos");
|
||||
if (res.ok) {
|
||||
const payload = await res.json();
|
||||
const allCombos = Array.isArray(payload?.combos) ? payload.combos : [];
|
||||
const auto = allCombos.filter((c: any) => c.strategy === "auto" || c.strategy === "lkgp");
|
||||
setCombos(auto);
|
||||
|
||||
// Refresh scores based on first auto combo found
|
||||
const firstCombo = auto[0] || null;
|
||||
const candidatePool = Array.isArray(firstCombo?.config?.candidatePool)
|
||||
? firstCombo.config.candidatePool
|
||||
: [];
|
||||
const rawWeights =
|
||||
firstCombo?.weights &&
|
||||
typeof firstCombo.weights === "object" &&
|
||||
!Array.isArray(firstCombo.weights)
|
||||
? (firstCombo.weights as Record<string, unknown>)
|
||||
: {};
|
||||
const factors = Object.fromEntries(
|
||||
Object.entries(rawWeights).map(([k, v]) => [k, typeof v === "number" ? v : 0])
|
||||
);
|
||||
const baseScore = candidatePool.length > 0 ? 1 / candidatePool.length : 0;
|
||||
setScores(
|
||||
candidatePool.map((provider) => ({
|
||||
provider,
|
||||
model: "auto",
|
||||
score: baseScore,
|
||||
factors,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setScores([]);
|
||||
}
|
||||
} catch {
|
||||
setScores([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchHealth = useCallback(async () => {
|
||||
try {
|
||||
const healthRes = await fetch("/api/monitoring/health");
|
||||
if (healthRes.ok) {
|
||||
const health = (await healthRes.json()) as HealthRecord;
|
||||
const providerHealth =
|
||||
health?.providerHealth && typeof health.providerHealth === "object"
|
||||
? health.providerHealth
|
||||
: {};
|
||||
const breakersFromProviderHealth = Object.entries(providerHealth).map(
|
||||
([provider, status]) => ({
|
||||
provider,
|
||||
state: status?.state || "CLOSED",
|
||||
lastFailure: status?.lastFailure || null,
|
||||
})
|
||||
);
|
||||
const breakersFromArray = Array.isArray(health?.circuitBreakers)
|
||||
? health.circuitBreakers
|
||||
: [];
|
||||
const breakers =
|
||||
breakersFromArray.length > 0
|
||||
? breakersFromArray.map((breaker) => ({
|
||||
provider: breaker.provider || breaker.name || "unknown",
|
||||
state: breaker.state || "CLOSED",
|
||||
lastFailure: breaker.lastFailure || null,
|
||||
}))
|
||||
: breakersFromProviderHealth;
|
||||
|
||||
const openBreakers = breakers.filter((breaker) => breaker.state === "OPEN");
|
||||
setIncidentMode(openBreakers.length / Math.max(breakers.length, 1) > 0.5);
|
||||
setExclusions(
|
||||
openBreakers.map((breaker) => ({
|
||||
provider: breaker.provider,
|
||||
excludedAt: breaker.lastFailure || new Date().toISOString(),
|
||||
cooldownMs: 5 * 60 * 1000,
|
||||
reason: "Circuit breaker OPEN",
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setIncidentMode(false);
|
||||
setExclusions([]);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
await Promise.all([fetchCombos(), fetchHealth()]);
|
||||
|
||||
// Fetch active providers for the Modal
|
||||
try {
|
||||
const pRes = await fetch("/api/providers");
|
||||
if (pRes.ok) {
|
||||
const pData = await pRes.json();
|
||||
setActiveProviders(
|
||||
(pData.connections || []).filter(
|
||||
(c: any) => c.testStatus === "active" || c.testStatus === "success"
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}, [fetchCombos, fetchHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(fetchData, 0);
|
||||
const interval = setInterval(fetchData, 30_000);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCreate = async (data: any) => {
|
||||
try {
|
||||
const res = await fetch("/api/combos", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchCombos();
|
||||
setShowCreateModal(false);
|
||||
notify.success("Auto-Combo created successfully");
|
||||
} else {
|
||||
const err = await res.json();
|
||||
notify.error(err.error?.message || err.error || "Failed to create combo");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Error creating combo");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: any) => {
|
||||
try {
|
||||
const res = await fetch(`/api/combos/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchCombos();
|
||||
setEditingCombo(null);
|
||||
notify.success("Auto-Combo updated");
|
||||
} else {
|
||||
const err = await res.json();
|
||||
notify.error("Failed to update: " + (err.error?.message || err.error));
|
||||
}
|
||||
} catch {
|
||||
notify.error("Error updating combo");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this auto-combo?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setCombos(combos.filter((c) => c.id !== id));
|
||||
notify.success("Auto-combo deleted");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Error deleting combo");
|
||||
}
|
||||
};
|
||||
|
||||
const FACTOR_LABELS: Record<string, string> = {
|
||||
quota: "📊 Quota",
|
||||
health: "💚 Health",
|
||||
costInv: "💰 Cost",
|
||||
latencyInv: "⚡ Latency",
|
||||
taskFit: "🎯 Task Fit",
|
||||
stability: "📈 Stability",
|
||||
tierPriority: "🏷️ Tier",
|
||||
};
|
||||
|
||||
const MODE_PACKS = [
|
||||
{ id: "ship-fast", label: "🚀 Ship Fast" },
|
||||
{ id: "cost-saver", label: "💰 Cost Saver" },
|
||||
{ id: "quality-first", label: "🎯 Quality First" },
|
||||
{ id: "offline-friendly", label: "📡 Offline Friendly" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">⚡ Auto-Combo Engine</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Smart routing automatically adapting to latency, health, and throughput
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="add" onClick={() => setShowCreateModal(true)}>
|
||||
Create Auto-Combo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ──── CRUD Auto Combos List ──── */}
|
||||
{combos.length > 0 && (
|
||||
<Card className="mb-2">
|
||||
<h2 className="text-lg font-semibold mb-4">Configured Auto-Combos</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{combos.map((combo) => (
|
||||
<div
|
||||
key={combo.id}
|
||||
className="p-4 border rounded-lg bg-surface flex justify-between items-center"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-main flex items-center gap-2">
|
||||
{combo.name}
|
||||
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-500">
|
||||
{combo.strategy}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Pool: {combo.config?.candidatePool?.length || "All"} APIs | Pack:{" "}
|
||||
{combo.config?.modePack || "fast"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditingCombo(combo)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDelete(combo.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Forms */}
|
||||
{showCreateModal && (
|
||||
<AutoComboModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSave={handleCreate}
|
||||
activeProviders={activeProviders}
|
||||
combo={null}
|
||||
/>
|
||||
)}
|
||||
{editingCombo && (
|
||||
<AutoComboModal
|
||||
isOpen={!!editingCombo}
|
||||
onClose={() => setEditingCombo(null)}
|
||||
onSave={(data: any) => handleUpdate(editingCombo.id, data)}
|
||||
activeProviders={activeProviders}
|
||||
combo={editingCombo}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold mb-4">Status Overview</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div
|
||||
className={`p-4 rounded-lg border flex items-center justify-between ${
|
||||
incidentMode
|
||||
? "bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-300"
|
||||
: "bg-green-500/10 border-green-500/30 text-green-700 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-[24px]">
|
||||
{incidentMode ? "warning" : "check_circle"}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-semibold">
|
||||
{incidentMode ? "Incident Mode" : "Normal Operation"}
|
||||
</h3>
|
||||
<p className="text-sm opacity-80">
|
||||
{incidentMode
|
||||
? "High circuit breaker trip rate detected"
|
||||
: "All providers reporting healthy metrics"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg border border-border/50 bg-surface/30 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-[24px] text-blue-500">tune</span>
|
||||
<div>
|
||||
<h3 className="font-semibold">Active Mode Pack</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{MODE_PACKS.find((m) => m.id === modePack)?.label || modePack}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold mb-4">Mode Pack</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{MODE_PACKS.map((mp) => (
|
||||
<button
|
||||
key={mp.id}
|
||||
onClick={() => setModePack(mp.id)}
|
||||
className={`flex flex-col items-start p-3 rounded-lg border transition-all ${
|
||||
modePack === mp.id
|
||||
? "border-blue-500/50 bg-blue-500/5 ring-1 ring-blue-500/20"
|
||||
: "border-border/50 hover:border-border hover:bg-surface/30"
|
||||
}`}
|
||||
>
|
||||
<span className={`font-medium ${modePack === mp.id ? "text-blue-500" : ""}`}>
|
||||
{mp.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
leaderboard
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Provider Scores</h3>
|
||||
</div>
|
||||
|
||||
{scores.length === 0 ? (
|
||||
<p className="text-sm text-text-muted py-4">
|
||||
No auto-combo configured... Create one to see live provider scores.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{scores.map((s) => (
|
||||
<div
|
||||
key={s.provider}
|
||||
className="p-3 bg-surface/30 rounded-lg border border-border/50"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium text-sm">
|
||||
{s.provider} / {s.model}
|
||||
</span>
|
||||
<span className="font-bold text-lg text-blue-500">
|
||||
{(s.score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Score Bar */}
|
||||
<div className="h-1.5 bg-border/50 rounded-full overflow-hidden mb-3">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all duration-1000"
|
||||
style={{ width: `${s.score * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Factor Breakdown */}
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-text-muted">
|
||||
{Object.entries(s.factors || {}).map(([key, val]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="px-2 py-0.5 rounded-full bg-black/5 dark:bg-white/5 border border-border/30"
|
||||
>
|
||||
{FACTOR_LABELS[key] || key}:{" "}
|
||||
<span className="font-medium text-text-main">
|
||||
{((val as number) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-red-500/10 text-red-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
block
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Excluded Providers</h3>
|
||||
</div>
|
||||
|
||||
{exclusions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[32px] mb-2 text-border">
|
||||
verified
|
||||
</span>
|
||||
<p className="text-sm">No providers currently excluded.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{exclusions.map((e) => (
|
||||
<div
|
||||
key={e.provider}
|
||||
className="p-3 bg-red-500/5 rounded-lg border border-red-500/20"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-red-600 dark:text-red-400">
|
||||
{e.provider}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-red-500/10 text-red-600 dark:text-red-400 font-medium">
|
||||
Cooldown: {Math.round(e.cooldownMs / 60000)}m
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1.5 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">info</span>
|
||||
{e.reason}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function AutoComboRedirectPage() {
|
||||
redirect("/dashboard/combos?filter=intelligent");
|
||||
}
|
||||
|
||||
280
src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx
Normal file
280
src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Card from "@/shared/components/Card";
|
||||
import {
|
||||
DEFAULT_INTELLIGENT_WEIGHTS,
|
||||
FACTOR_LABELS,
|
||||
MODE_PACK_OPTIONS,
|
||||
ROUTER_STRATEGY_OPTIONS,
|
||||
normalizeIntelligentRoutingConfig,
|
||||
} from "@/lib/combos/intelligentRouting";
|
||||
|
||||
function getI18nOrFallback(t: any, key: string, fallback: string) {
|
||||
if (typeof t?.has === "function" && t.has(key)) return t(key);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toProviderOptions(activeProviders: any[] = []) {
|
||||
const uniqueProviders = new Map<string, { id: string; label: string; connectionCount: number }>();
|
||||
|
||||
activeProviders.forEach((provider) => {
|
||||
const providerId =
|
||||
typeof provider?.provider === "string" && provider.provider.trim().length > 0
|
||||
? provider.provider
|
||||
: typeof provider?.id === "string" && provider.id.trim().length > 0
|
||||
? provider.id
|
||||
: null;
|
||||
|
||||
if (!providerId) return;
|
||||
|
||||
const currentEntry = uniqueProviders.get(providerId);
|
||||
const fallbackLabel =
|
||||
typeof provider?.name === "string" && provider.name.trim().length > 0
|
||||
? provider.name
|
||||
: providerId;
|
||||
|
||||
uniqueProviders.set(providerId, {
|
||||
id: providerId,
|
||||
label: currentEntry?.label || fallbackLabel,
|
||||
connectionCount: (currentEntry?.connectionCount || 0) + 1,
|
||||
});
|
||||
});
|
||||
|
||||
return [...uniqueProviders.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
export default function BuilderIntelligentStep({
|
||||
t,
|
||||
config,
|
||||
onChange,
|
||||
activeProviders,
|
||||
}: {
|
||||
t: any;
|
||||
config: Record<string, unknown>;
|
||||
onChange: (nextConfig: Record<string, unknown>) => void;
|
||||
activeProviders: any[];
|
||||
}) {
|
||||
const normalizedConfig = normalizeIntelligentRoutingConfig(config);
|
||||
const providerOptions = useMemo(() => toProviderOptions(activeProviders), [activeProviders]);
|
||||
|
||||
const updateConfig = (patch: Record<string, unknown>) => {
|
||||
onChange({
|
||||
...normalizedConfig,
|
||||
...patch,
|
||||
weights: {
|
||||
...normalizedConfig.weights,
|
||||
...(patch.weights || {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCandidateProvider = (providerId: string) => {
|
||||
const nextCandidatePool = normalizedConfig.candidatePool.includes(providerId)
|
||||
? normalizedConfig.candidatePool.filter((entry) => entry !== providerId)
|
||||
: [...normalizedConfig.candidatePool, providerId];
|
||||
|
||||
updateConfig({ candidatePool: nextCandidatePool });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card.Section>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{getI18nOrFallback(t, "builderIntelligentTitle", "Intelligent Routing Configuration")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"builderIntelligentDesc",
|
||||
"Configure the multi-factor scoring engine for this auto-routing combo."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||
<span className="material-symbols-outlined text-[12px]">auto_awesome</span>
|
||||
Intelligent
|
||||
</span>
|
||||
</div>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-text-main">
|
||||
{getI18nOrFallback(t, "candidatePoolLabel", "Candidate Pool")}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"candidatePoolHint",
|
||||
"Select which providers this engine should evaluate. Leave empty to use all active providers."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-text-muted">
|
||||
{normalizedConfig.candidatePool.length > 0
|
||||
? `${normalizedConfig.candidatePool.length} selected`
|
||||
: "All active providers"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{providerOptions.length === 0 && (
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{getI18nOrFallback(t, "candidatePoolEmpty", "No active providers available yet.")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{providerOptions.map((provider) => {
|
||||
const isSelected = normalizedConfig.candidatePool.includes(provider.id);
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => toggleCandidateProvider(provider.id)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-black/10 dark:border-white/10 text-text-main hover:border-primary/40 hover:bg-primary/5"
|
||||
}`}
|
||||
>
|
||||
{provider.label}
|
||||
<span className="ml-1 text-[10px] text-text-muted">
|
||||
{provider.connectionCount} acct
|
||||
{provider.connectionCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card.Section>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Card.Section>
|
||||
<label className="text-xs font-semibold text-text-main block mb-2">
|
||||
{getI18nOrFallback(t, "modePackLabel", "Mode Pack")}
|
||||
</label>
|
||||
<select
|
||||
value={normalizedConfig.modePack}
|
||||
onChange={(event) => updateConfig({ modePack: event.target.value })}
|
||||
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
|
||||
>
|
||||
{MODE_PACK_OPTIONS.map((modePack) => (
|
||||
<option key={modePack.id} value={modePack.id}>
|
||||
{modePack.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section>
|
||||
<label className="text-xs font-semibold text-text-main block mb-2">
|
||||
{getI18nOrFallback(t, "routerStrategyLabel", "Router Strategy")}
|
||||
</label>
|
||||
<select
|
||||
value={normalizedConfig.routerStrategy}
|
||||
onChange={(event) => updateConfig({ routerStrategy: event.target.value })}
|
||||
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
|
||||
>
|
||||
{ROUTER_STRATEGY_OPTIONS.map((strategy) => (
|
||||
<option key={strategy.id} value={strategy.id}>
|
||||
{strategy.id === "rules"
|
||||
? getI18nOrFallback(t, "strategyRules", strategy.label)
|
||||
: strategy.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Card.Section>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Card.Section>
|
||||
<label className="text-xs font-semibold text-text-main block">
|
||||
{getI18nOrFallback(t, "explorationRateLabel", "Exploration Rate")}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={normalizedConfig.explorationRate}
|
||||
onChange={(event) => updateConfig({ explorationRate: Number(event.target.value || 0) })}
|
||||
className="mt-3 w-full accent-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted mt-2">
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"explorationRateHint",
|
||||
"{percent}% of requests can explore non-optimal providers."
|
||||
).replace("{percent}", `${Math.round(normalizedConfig.explorationRate * 100)}`)}
|
||||
</p>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section>
|
||||
<label className="text-xs font-semibold text-text-main block mb-2">
|
||||
{getI18nOrFallback(t, "budgetCapLabel", "Budget Cap (USD / request)")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
value={normalizedConfig.budgetCap ?? ""}
|
||||
placeholder={getI18nOrFallback(t, "budgetCapPlaceholder", "No limit")}
|
||||
onChange={(event) =>
|
||||
updateConfig({
|
||||
budgetCap: event.target.value ? Number(event.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</Card.Section>
|
||||
</div>
|
||||
|
||||
<details className="rounded-lg border border-black/8 dark:border-white/8 bg-black/[0.02] dark:bg-white/[0.02] p-3">
|
||||
<summary className="cursor-pointer text-xs font-semibold text-text-main">
|
||||
{getI18nOrFallback(t, "advancedWeightsTitle", "Advanced: Scoring Weights")}
|
||||
</summary>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
{Object.entries(normalizedConfig.weights).map(([weightKey, weightValue]) => (
|
||||
<div
|
||||
key={weightKey}
|
||||
className="rounded-lg border border-black/6 dark:border-white/6 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-[11px] font-medium text-text-main">
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
`weight${weightKey[0].toUpperCase()}${weightKey.slice(1)}`,
|
||||
FACTOR_LABELS[weightKey as keyof typeof DEFAULT_INTELLIGENT_WEIGHTS]
|
||||
)}
|
||||
</label>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{Math.round(Number(weightValue) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={weightValue}
|
||||
onChange={(event) =>
|
||||
updateConfig({
|
||||
weights: {
|
||||
...normalizedConfig.weights,
|
||||
[weightKey]: Number(event.target.value || 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="mt-3 w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
371
src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx
Normal file
371
src/app/(dashboard)/dashboard/combos/IntelligentComboPanel.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Button from "@/shared/components/Button";
|
||||
import Card from "@/shared/components/Card";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import {
|
||||
FACTOR_LABELS,
|
||||
MODE_PACK_OPTIONS,
|
||||
buildIntelligentProviderScores,
|
||||
extractIntelligentHealthState,
|
||||
normalizeIntelligentRoutingConfig,
|
||||
} from "@/lib/combos/intelligentRouting";
|
||||
|
||||
function getI18nOrFallback(t: any, key: string, fallback: string) {
|
||||
if (typeof t?.has === "function" && t.has(key)) return t(key);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatProviderLabel(providerId: string, activeProviders: any[] = []) {
|
||||
const matchedConnection = activeProviders.find(
|
||||
(provider) =>
|
||||
provider?.provider === providerId ||
|
||||
provider?.id === providerId ||
|
||||
provider?.name === providerId
|
||||
);
|
||||
|
||||
if (matchedConnection?.name && matchedConnection.name !== providerId) {
|
||||
return `${matchedConnection.name} (${providerId})`;
|
||||
}
|
||||
|
||||
return providerId;
|
||||
}
|
||||
|
||||
export default function IntelligentComboPanel({
|
||||
t,
|
||||
combo,
|
||||
allCombos,
|
||||
activeProviders,
|
||||
onComboUpdated,
|
||||
}: {
|
||||
t: any;
|
||||
combo: any;
|
||||
allCombos: any[];
|
||||
activeProviders: any[];
|
||||
onComboUpdated?: (combo: any) => void;
|
||||
}) {
|
||||
const notify = useNotificationStore();
|
||||
const [incidentMode, setIncidentMode] = useState(false);
|
||||
const [exclusions, setExclusions] = useState<any[]>([]);
|
||||
const [savingModePack, setSavingModePack] = useState<string | null>(null);
|
||||
const normalizedConfig = useMemo(
|
||||
() => normalizeIntelligentRoutingConfig(combo?.config),
|
||||
[combo?.config]
|
||||
);
|
||||
const providerScores = useMemo(() => buildIntelligentProviderScores(combo), [combo]);
|
||||
|
||||
const fetchHealth = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/monitoring/health");
|
||||
if (!response.ok) {
|
||||
setIncidentMode(false);
|
||||
setExclusions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const health = await response.json();
|
||||
const nextState = extractIntelligentHealthState(health);
|
||||
setIncidentMode(nextState.incidentMode);
|
||||
setExclusions(nextState.exclusions);
|
||||
} catch {
|
||||
setIncidentMode(false);
|
||||
setExclusions([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(fetchHealth, 0);
|
||||
const intervalId = setInterval(fetchHealth, 30_000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [fetchHealth]);
|
||||
|
||||
const handleModePackChange = async (modePackId: string) => {
|
||||
if (!combo?.id || modePackId === normalizedConfig.modePack) return;
|
||||
setSavingModePack(modePackId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/combos/${combo.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
...(combo?.config || {}),
|
||||
modePack: modePackId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
errorBody?.error?.message || errorBody?.error || "Failed to update mode pack"
|
||||
);
|
||||
}
|
||||
|
||||
const updatedCombo = await response.json();
|
||||
onComboUpdated?.(updatedCombo);
|
||||
notify.success(
|
||||
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.").replace(
|
||||
"{pack}",
|
||||
modePackId
|
||||
)
|
||||
);
|
||||
} catch (error: any) {
|
||||
notify.error(error?.message || "Failed to update mode pack.");
|
||||
} finally {
|
||||
setSavingModePack(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="border-primary/10 bg-gradient-to-br from-primary/[0.04] via-transparent to-transparent"
|
||||
padding="sm"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[18px]">
|
||||
auto_awesome
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold text-text-main">
|
||||
{getI18nOrFallback(t, "intelligentPanelTitle", "Intelligent Routing Dashboard")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"intelligentPanelDesc",
|
||||
"Real-time scoring and health status for this auto-routing combo."
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-text-muted">
|
||||
<code className="rounded bg-black/5 dark:bg-white/5 px-2 py-1 text-text-main">
|
||||
{combo?.name}
|
||||
</code>
|
||||
<span>{allCombos.length} intelligent combo(s)</span>
|
||||
<span>
|
||||
{normalizedConfig.candidatePool.length || activeProviders.length} providers in scope
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium ${
|
||||
incidentMode
|
||||
? "bg-amber-500/15 text-amber-600 dark:text-amber-300"
|
||||
: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-300"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{incidentMode ? "warning" : "verified"}
|
||||
</span>
|
||||
{incidentMode
|
||||
? getI18nOrFallback(t, "incidentMode", "Incident Mode")
|
||||
: getI18nOrFallback(t, "normalOperation", "Normal Operation")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<Card.Section>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-main">
|
||||
{getI18nOrFallback(t, "statusOverview", "Status Overview")}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
{incidentMode
|
||||
? getI18nOrFallback(
|
||||
t,
|
||||
"highCircuitBreakerRate",
|
||||
"High circuit breaker trip rate detected."
|
||||
)
|
||||
: getI18nOrFallback(
|
||||
t,
|
||||
"allProvidersHealthy",
|
||||
"Providers are reporting healthy routing conditions."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-black/5 dark:bg-white/5 px-3 py-2 text-right">
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">Exclusions</p>
|
||||
<p className="text-lg font-semibold text-text-main">{exclusions.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-main">
|
||||
{getI18nOrFallback(t, "activeModePack", "Active Mode Pack")}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"modePackHint",
|
||||
"Switch presets to bias the routing engine without rebuilding the combo."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{savingModePack && (
|
||||
<span className="text-[11px] text-text-muted">Saving {savingModePack}…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||
{MODE_PACK_OPTIONS.map((modePack) => {
|
||||
const isActive = normalizedConfig.modePack === modePack.id;
|
||||
return (
|
||||
<Button
|
||||
key={modePack.id}
|
||||
variant={isActive ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => handleModePackChange(modePack.id)}
|
||||
disabled={Boolean(savingModePack)}
|
||||
className="!justify-start"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{modePack.emoji}</span>
|
||||
{modePack.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card.Section>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<Card.Section>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-main">
|
||||
{getI18nOrFallback(t, "providerScores", "Provider Scores")}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
{normalizedConfig.candidatePool.length > 0
|
||||
? `${normalizedConfig.candidatePool.length} providers currently ranked for this combo.`
|
||||
: getI18nOrFallback(
|
||||
t,
|
||||
"allProvidersEvaluated",
|
||||
"No candidate pool configured. All active providers are evaluated at runtime."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{providerScores.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-black/10 dark:border-white/10 p-3 text-[11px] text-text-muted">
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"allProvidersEvaluated",
|
||||
"No candidate pool configured. All active providers are evaluated at runtime."
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
providerScores.map((entry) => {
|
||||
const percentage = Math.round(entry.score * 100);
|
||||
return (
|
||||
<div
|
||||
key={entry.provider}
|
||||
className="rounded-lg border border-black/8 dark:border-white/8 bg-white/60 dark:bg-white/[0.03] p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-text-main">
|
||||
{formatProviderLabel(entry.provider, activeProviders)}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">{entry.model}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-primary/10 px-2 py-1 text-[11px] font-semibold text-primary">
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-2 rounded-full bg-black/8 dark:bg-white/8 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{Object.entries(entry.factors).map(([factorKey, factorValue]) => (
|
||||
<span
|
||||
key={`${entry.provider}-${factorKey}`}
|
||||
className="rounded-full bg-black/5 dark:bg-white/5 px-2 py-1 text-[10px] text-text-muted"
|
||||
>
|
||||
{FACTOR_LABELS[factorKey as keyof typeof FACTOR_LABELS]}{" "}
|
||||
{Math.round(Number(factorValue) * 100)}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-main">
|
||||
{getI18nOrFallback(t, "excludedProviders", "Excluded Providers")}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"excludedProvidersHint",
|
||||
"Providers with an OPEN circuit breaker are temporarily excluded from routing."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{exclusions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-emerald-500/20 bg-emerald-500/5 p-3 text-[11px] text-emerald-700 dark:text-emerald-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[14px]">verified</span>
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"noExcludedProviders",
|
||||
"No providers are currently excluded."
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
exclusions.map((exclusion) => (
|
||||
<div
|
||||
key={`${exclusion.provider}-${exclusion.excludedAt}`}
|
||||
className="rounded-lg border border-amber-500/20 bg-amber-500/10 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-text-main">{exclusion.provider}</p>
|
||||
<p className="text-[11px] text-text-muted mt-1">{exclusion.reason}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-amber-500/15 px-2 py-1 text-[10px] font-semibold text-amber-700 dark:text-amber-300">
|
||||
{getI18nOrFallback(t, "cooldownMinutes", "Cooldown: {minutes}m").replace(
|
||||
"{minutes}",
|
||||
`${Math.ceil(exclusion.cooldownMs / 60000)}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card.Section>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -137,7 +137,15 @@ export default function HealthPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const { system, providerHealth, providerSummary, rateLimitStatus, lockouts } = data;
|
||||
const {
|
||||
system,
|
||||
providerHealth,
|
||||
providerSummary,
|
||||
rateLimitStatus,
|
||||
lockouts,
|
||||
sessions,
|
||||
quotaMonitor,
|
||||
} = data;
|
||||
const cbEntries = Object.entries(providerHealth || {});
|
||||
const lockoutEntries = Object.entries(lockouts || {});
|
||||
|
||||
@@ -273,6 +281,138 @@ export default function HealthPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Session & Quota Observability */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">groups</span>
|
||||
Session Activity
|
||||
</h2>
|
||||
<span className="text-xs text-text-muted">{sessions?.activeCount ?? 0} active</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
|
||||
<div className="text-xs text-text-muted">Sticky-bound sessions</div>
|
||||
<div className="text-2xl font-semibold text-text-main mt-1">
|
||||
{sessions?.stickyBoundCount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
|
||||
<div className="text-xs text-text-muted">Sessions by API key</div>
|
||||
<div className="text-2xl font-semibold text-text-main mt-1">
|
||||
{Object.keys(sessions?.byApiKey || {}).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{sessions?.top?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{sessions.top.slice(0, 5).map((session: any) => (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="rounded-lg border border-border/30 bg-surface/20 p-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-xs text-text-main truncate">
|
||||
{session.sessionId}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
{session.requestCount} requests
|
||||
{session.connectionId ? ` • ${session.connectionId.slice(0, 8)}…` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-text-muted shrink-0">
|
||||
<div>{Math.round((session.idleMs || 0) / 1000)}s idle</div>
|
||||
<div>{Math.round((session.ageMs || 0) / 1000)}s age</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No active sessions tracked yet.</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">radar</span>
|
||||
Quota Monitors
|
||||
</h2>
|
||||
<span className="text-xs text-text-muted">{quotaMonitor?.active ?? 0} active</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
|
||||
<div className="text-xs text-text-muted">Alerting</div>
|
||||
<div className="text-2xl font-semibold text-amber-400 mt-1">
|
||||
{quotaMonitor?.alerting ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
|
||||
<div className="text-xs text-text-muted">Exhausted</div>
|
||||
<div className="text-2xl font-semibold text-red-400 mt-1">
|
||||
{quotaMonitor?.exhausted ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
|
||||
<div className="text-xs text-text-muted">Errors</div>
|
||||
<div className="text-2xl font-semibold text-orange-400 mt-1">
|
||||
{quotaMonitor?.errors ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
|
||||
<div className="text-xs text-text-muted">Providers</div>
|
||||
<div className="text-2xl font-semibold text-text-main mt-1">
|
||||
{Object.keys(quotaMonitor?.byProvider || {}).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{quotaMonitor?.monitors?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{quotaMonitor.monitors.slice(0, 5).map((monitor: any) => (
|
||||
<div
|
||||
key={`${monitor.sessionId}:${monitor.accountId}`}
|
||||
className="rounded-lg border border-border/30 bg-surface/20 p-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-text-main truncate">
|
||||
{monitor.provider} • {monitor.accountId.slice(0, 8)}…
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1 truncate">
|
||||
{monitor.sessionId} • {monitor.status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs shrink-0">
|
||||
<div
|
||||
className={
|
||||
monitor.status === "exhausted"
|
||||
? "text-red-400"
|
||||
: monitor.status === "warning"
|
||||
? "text-amber-400"
|
||||
: monitor.status === "error"
|
||||
? "text-orange-400"
|
||||
: "text-text-main"
|
||||
}
|
||||
>
|
||||
{typeof monitor.lastQuotaPercent === "number"
|
||||
? `${Math.round(monitor.lastQuotaPercent * 100)}%`
|
||||
: "—"}
|
||||
</div>
|
||||
<div className="text-text-muted">
|
||||
{monitor.nextPollDelayMs
|
||||
? `${Math.round(monitor.nextPollDelayMs / 1000)}s`
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No session quota monitors active.</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Graceful Degradation Status */}
|
||||
{degradation && degradation.features && degradation.features.length > 0 && (
|
||||
<Card className="p-5" role="region" aria-label="Graceful Degradation Status">
|
||||
|
||||
@@ -31,10 +31,15 @@ export default function ComboDefaultsTab() {
|
||||
handoffThreshold: 0.85,
|
||||
handoffModel: "",
|
||||
maxMessagesForSummary: 30,
|
||||
stickyRoundRobinLimit: 3,
|
||||
});
|
||||
const [providerOverrides, setProviderOverrides] = useState<any>({});
|
||||
const [newOverrideProvider, setNewOverrideProvider] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<{ type: "success" | "error" | ""; message: string }>({
|
||||
type: "",
|
||||
message: "",
|
||||
});
|
||||
const t = useTranslations("settings");
|
||||
const tc = useTranslations("common");
|
||||
const strategyOptions = ROUTING_STRATEGIES.map((strategy) => ({
|
||||
@@ -54,27 +59,75 @@ export default function ComboDefaultsTab() {
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/combo-defaults")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.comboDefaults) {
|
||||
setComboDefaults((prev) => ({ ...prev, ...data.comboDefaults }));
|
||||
}
|
||||
if (data.providerOverrides) setProviderOverrides(data.providerOverrides);
|
||||
Promise.all([
|
||||
fetch("/api/settings/combo-defaults").then((res) => res.json()),
|
||||
fetch("/api/settings").then((res) => res.json()),
|
||||
])
|
||||
.then(([comboData, settingsData]) => {
|
||||
setComboDefaults((prev) => ({
|
||||
...prev,
|
||||
...(comboData.comboDefaults || {}),
|
||||
strategy:
|
||||
settingsData.fallbackStrategy ?? comboData.comboDefaults?.strategy ?? prev.strategy,
|
||||
stickyRoundRobinLimit:
|
||||
settingsData.stickyRoundRobinLimit ??
|
||||
comboData.comboDefaults?.stickyRoundRobinLimit ??
|
||||
prev.stickyRoundRobinLimit,
|
||||
}));
|
||||
if (comboData.providerOverrides) setProviderOverrides(comboData.providerOverrides);
|
||||
})
|
||||
.catch((err) => console.error("Failed to fetch combo defaults:", err));
|
||||
}, []);
|
||||
|
||||
const showStatus = (type: "success" | "error", message: string) => {
|
||||
setStatus({ type, message });
|
||||
setTimeout(() => setStatus({ type: "", message: "" }), 2500);
|
||||
};
|
||||
|
||||
const syncGlobalRoutingSettings = async (patch: Record<string, unknown>) => {
|
||||
const keys = Object.keys(patch);
|
||||
if (keys.length === 0) return true;
|
||||
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to sync global routing settings");
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const saveComboDefaults = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch("/api/settings/combo-defaults", {
|
||||
const { stickyRoundRobinLimit, ...comboDefaultsPayload } = comboDefaults;
|
||||
const settingsPatch: Record<string, unknown> = {};
|
||||
if (comboDefaults.strategy) {
|
||||
settingsPatch.fallbackStrategy = comboDefaults.strategy;
|
||||
}
|
||||
if (comboDefaults.strategy === "round-robin" && stickyRoundRobinLimit !== undefined) {
|
||||
settingsPatch.stickyRoundRobinLimit = stickyRoundRobinLimit;
|
||||
}
|
||||
|
||||
const comboDefaultsRes = await fetch("/api/settings/combo-defaults", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ comboDefaults, providerOverrides }),
|
||||
body: JSON.stringify({ comboDefaults: comboDefaultsPayload, providerOverrides }),
|
||||
});
|
||||
|
||||
if (!comboDefaultsRes.ok) {
|
||||
throw new Error("Failed to save combo defaults");
|
||||
}
|
||||
|
||||
await syncGlobalRoutingSettings(settingsPatch);
|
||||
showStatus("success", t("savedSuccessfully"));
|
||||
} catch (err) {
|
||||
console.error("Failed to save combo defaults:", err);
|
||||
showStatus("error", t("errorOccurred"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -103,8 +156,38 @@ export default function ComboDefaultsTab() {
|
||||
tune
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{t("comboDefaultsTitle")}</h3>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{translateOrFallback(t, "comboDefaultsTitle", "Default Routing & Combo Settings")}
|
||||
</h3>
|
||||
<span className="text-xs text-text-muted ml-auto">{t("globalComboConfig")}</span>
|
||||
{status.message && (
|
||||
<span
|
||||
className={`text-xs font-medium ml-2 ${
|
||||
status.type === "success" ? "text-emerald-500" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{status.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4 rounded-lg border border-blue-500/20 bg-blue-500/5 p-3">
|
||||
<p className="text-xs font-medium text-blue-700 dark:text-blue-300">
|
||||
{translateOrFallback(t, "routingAdvancedGuideTitle", "Advanced routing guidance")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{translateOrFallback(
|
||||
t,
|
||||
"routingAdvancedGuideHint1",
|
||||
"Use Fill First for predictable priority, Round Robin for fairness, and P2C for latency resilience."
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{translateOrFallback(
|
||||
t,
|
||||
"routingAdvancedGuideHint2",
|
||||
"If providers vary in quality or cost, start with Cost Opt for background work and Least Used for balanced wear."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3">
|
||||
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
@@ -130,7 +213,15 @@ export default function ComboDefaultsTab() {
|
||||
key={s.value}
|
||||
role="tab"
|
||||
aria-selected={comboDefaults.strategy === s.value}
|
||||
onClick={() => setComboDefaults((prev) => ({ ...prev, strategy: s.value }))}
|
||||
onClick={async () => {
|
||||
setComboDefaults((prev) => ({ ...prev, strategy: s.value }));
|
||||
try {
|
||||
await syncGlobalRoutingSettings({ fallbackStrategy: s.value });
|
||||
} catch (error) {
|
||||
console.error("Failed to sync fallback strategy:", error);
|
||||
showStatus("error", t("errorOccurred"));
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded text-xs font-medium transition-all flex items-center justify-center gap-0.5",
|
||||
comboDefaults.strategy === s.value
|
||||
@@ -145,6 +236,35 @@ export default function ComboDefaultsTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{comboDefaults.strategy === "round-robin" && (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("stickyLimit")}</p>
|
||||
<p className="text-xs text-text-muted">{t("stickyLimitDesc")}</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={comboDefaults.stickyRoundRobinLimit || 3}
|
||||
onChange={async (e) => {
|
||||
const nextLimit = parseInt(e.target.value) || 3;
|
||||
setComboDefaults((prev) => ({
|
||||
...prev,
|
||||
stickyRoundRobinLimit: nextLimit,
|
||||
}));
|
||||
try {
|
||||
await syncGlobalRoutingSettings({ stickyRoundRobinLimit: nextLimit });
|
||||
} catch (error) {
|
||||
console.error("Failed to sync sticky round robin limit:", error);
|
||||
showStatus("error", t("errorOccurred"));
|
||||
}
|
||||
}}
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Numeric settings */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/50">
|
||||
{numericSettings.map(({ key, label, min, max, step }) => (
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Card, Input } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface WildcardAlias {
|
||||
pattern: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
type AliasMode = "exact" | "wildcard";
|
||||
|
||||
function translateOrFallback(
|
||||
t: ReturnType<typeof useTranslations>,
|
||||
key: string,
|
||||
fallback: string
|
||||
): string {
|
||||
return typeof t.has === "function" && t.has(key) ? t(key) : fallback;
|
||||
}
|
||||
|
||||
export default function ModelAliasesUnified() {
|
||||
const [wildcardAliases, setWildcardAliases] = useState<WildcardAlias[]>([]);
|
||||
const [builtInAliases, setBuiltInAliases] = useState<Record<string, string>>({});
|
||||
const [customAliases, setCustomAliases] = useState<Record<string, string>>({});
|
||||
const [aliasMode, setAliasMode] = useState<AliasMode>("exact");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<{ type: "success" | "error" | ""; message: string }>({
|
||||
type: "",
|
||||
message: "",
|
||||
});
|
||||
const [fromValue, setFromValue] = useState("");
|
||||
const [toValue, setToValue] = useState("");
|
||||
const t = useTranslations("settings");
|
||||
const builtInEntries = Object.entries(builtInAliases);
|
||||
const customEntries = Object.entries(customAliases);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAliases = async () => {
|
||||
try {
|
||||
const [settingsRes, aliasesRes] = await Promise.all([
|
||||
fetch("/api/settings"),
|
||||
fetch("/api/settings/model-aliases"),
|
||||
]);
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||
const aliasesData = aliasesRes.ok ? await aliasesRes.json() : {};
|
||||
setWildcardAliases(settingsData.wildcardAliases || []);
|
||||
setBuiltInAliases(aliasesData.builtIn || {});
|
||||
setCustomAliases(aliasesData.custom || {});
|
||||
} catch (error) {
|
||||
console.error("Failed to load model aliases:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAliases();
|
||||
}, []);
|
||||
|
||||
const showStatus = (type: "success" | "error", message: string) => {
|
||||
setStatus({ type, message });
|
||||
setTimeout(() => setStatus({ type: "", message: "" }), 2500);
|
||||
};
|
||||
|
||||
const addExactAlias = async () => {
|
||||
if (!fromValue.trim() || !toValue.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/model-aliases", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from: fromValue.trim(), to: toValue.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save exact alias");
|
||||
const data = await res.json();
|
||||
setCustomAliases(data.custom || {});
|
||||
setFromValue("");
|
||||
setToValue("");
|
||||
showStatus("success", translateOrFallback(t, "saved", "Saved"));
|
||||
} catch (error) {
|
||||
console.error("Failed to save exact alias:", error);
|
||||
showStatus("error", translateOrFallback(t, "errorOccurred", "An error occurred"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeExactAlias = async (from: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/model-aliases", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove exact alias");
|
||||
const data = await res.json();
|
||||
setCustomAliases(data.custom || {});
|
||||
showStatus("success", translateOrFallback(t, "saved", "Saved"));
|
||||
} catch (error) {
|
||||
console.error("Failed to remove exact alias:", error);
|
||||
showStatus("error", translateOrFallback(t, "errorOccurred", "An error occurred"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addWildcardAlias = async () => {
|
||||
if (!fromValue.trim() || !toValue.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = [...wildcardAliases, { pattern: fromValue.trim(), target: toValue.trim() }];
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wildcardAliases: updated }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save wildcard alias");
|
||||
setWildcardAliases(updated);
|
||||
setFromValue("");
|
||||
setToValue("");
|
||||
showStatus("success", translateOrFallback(t, "saved", "Saved"));
|
||||
} catch (error) {
|
||||
console.error("Failed to save wildcard alias:", error);
|
||||
showStatus("error", translateOrFallback(t, "errorOccurred", "An error occurred"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeWildcardAlias = async (index: number) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = wildcardAliases.filter((_, currentIndex) => currentIndex !== index);
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wildcardAliases: updated }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove wildcard alias");
|
||||
setWildcardAliases(updated);
|
||||
showStatus("success", translateOrFallback(t, "saved", "Saved"));
|
||||
} catch (error) {
|
||||
console.error("Failed to remove wildcard alias:", error);
|
||||
showStatus("error", translateOrFallback(t, "errorOccurred", "An error occurred"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (aliasMode === "wildcard") {
|
||||
await addWildcardAlias();
|
||||
return;
|
||||
}
|
||||
await addExactAlias();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
swap_horiz
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{translateOrFallback(t, "modelAliasesTitle", "Model Aliases")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{translateOrFallback(
|
||||
t,
|
||||
"modelAliasesDesc",
|
||||
"Remap model names using exact matches or wildcard patterns."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{status.message && (
|
||||
<span
|
||||
className={`text-xs font-medium flex items-center gap-1 ${
|
||||
status.type === "success" ? "text-emerald-500" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
{status.type === "success" ? "check_circle" : "error"}
|
||||
</span>
|
||||
{status.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg border border-border/30 bg-surface/20 p-4">
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{[
|
||||
{
|
||||
value: "exact",
|
||||
label: translateOrFallback(t, "exactMatchMode", "Exact Match"),
|
||||
},
|
||||
{
|
||||
value: "wildcard",
|
||||
label: translateOrFallback(t, "wildcardPatternMode", "Wildcard Pattern"),
|
||||
},
|
||||
].map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
onClick={() => setAliasMode(mode.value as AliasMode)}
|
||||
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
aliasMode === mode.value
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
{aliasMode === "exact"
|
||||
? translateOrFallback(
|
||||
t,
|
||||
"exactMatchModeDesc",
|
||||
"Use exact aliases for deprecated or renamed model IDs."
|
||||
)
|
||||
: translateOrFallback(
|
||||
t,
|
||||
"wildcardPatternModeDesc",
|
||||
"Use wildcard aliases with * and ? when a family of models should map to one target."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr_auto] gap-2 items-end">
|
||||
<Input
|
||||
label={aliasMode === "exact" ? t("deprecatedModelId") : t("pattern")}
|
||||
placeholder={
|
||||
aliasMode === "exact" ? t("deprecatedModelId") : t("aliasPatternPlaceholder")
|
||||
}
|
||||
value={fromValue}
|
||||
onChange={(e) => setFromValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void handleAdd()}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<div className="hidden md:flex items-center justify-center pb-2 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
arrow_forward
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
label={aliasMode === "exact" ? t("newModelId") : t("targetModel")}
|
||||
placeholder={aliasMode === "exact" ? t("newModelId") : t("aliasTargetPlaceholder")}
|
||||
value={toValue}
|
||||
onChange={(e) => setToValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void handleAdd()}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={loading || saving || !fromValue.trim() || !toValue.trim()}
|
||||
className="md:mb-[2px]"
|
||||
>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
||||
{translateOrFallback(t, "customAliases", "Custom Aliases")}
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/30 divide-y divide-border/20">
|
||||
{customEntries.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
{translateOrFallback(
|
||||
t,
|
||||
"noExactAliasesConfigured",
|
||||
"No exact-match aliases configured."
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
customEntries.map(([from, to]) => (
|
||||
<div key={from} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<code className="text-xs text-red-400/80 flex-1 truncate">{from}</code>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<code className="text-xs text-emerald-400/80 flex-1 truncate">{to}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void removeExactAlias(from)}
|
||||
disabled={saving}
|
||||
className="p-1 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
||||
{translateOrFallback(t, "wildcardRulesTitle", "Wildcard Rules")}
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/30 divide-y divide-border/20">
|
||||
{wildcardAliases.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
{translateOrFallback(
|
||||
t,
|
||||
"noWildcardAliasesConfigured",
|
||||
"No wildcard aliases configured."
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
wildcardAliases.map((alias, index) => (
|
||||
<div
|
||||
key={`${alias.pattern}-${alias.target}-${index}`}
|
||||
className="flex items-center gap-3 px-4 py-2.5"
|
||||
>
|
||||
<code className="text-xs text-purple-400 flex-1 truncate">{alias.pattern}</code>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<code className="text-xs text-emerald-400/80 flex-1 truncate">{alias.target}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void removeWildcardAlias(index)}
|
||||
disabled={saving}
|
||||
className="p-1 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="group">
|
||||
<summary className="text-xs font-medium text-text-muted uppercase tracking-wider cursor-pointer flex items-center gap-1 mb-2">
|
||||
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
|
||||
chevron_right
|
||||
</span>
|
||||
{translateOrFallback(t, "builtInAliases", "Built-in Aliases")} ({builtInEntries.length})
|
||||
</summary>
|
||||
<div className="rounded-lg border border-border/30 divide-y divide-border/20 max-h-60 overflow-y-auto">
|
||||
{builtInEntries.map(([from, to]) => (
|
||||
<div key={from} className="flex items-center gap-3 px-4 py-2 opacity-60">
|
||||
<code className="text-xs text-red-400/60 flex-1 truncate">{from}</code>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<code className="text-xs text-emerald-400/60 flex-1 truncate">{to}</code>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">lock</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Input, Button } from "@/shared/components";
|
||||
import FallbackChainsEditor from "./FallbackChainsEditor";
|
||||
import {
|
||||
ROUTING_STRATEGIES,
|
||||
SETTINGS_FALLBACK_STRATEGY_VALUES,
|
||||
} from "@/shared/constants/routingStrategies";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const STRATEGIES = ROUTING_STRATEGIES.filter((strategy) =>
|
||||
SETTINGS_FALLBACK_STRATEGY_VALUES.includes(strategy.value)
|
||||
).map((strategy) => ({
|
||||
value: strategy.value,
|
||||
labelKey: strategy.labelKey,
|
||||
descKey: strategy.settingsDescKey,
|
||||
icon: strategy.icon,
|
||||
}));
|
||||
import FallbackChainsEditor from "./FallbackChainsEditor";
|
||||
|
||||
export default function RoutingTab() {
|
||||
const [settings, setSettings] = useState<any>({
|
||||
fallbackStrategy: "fill-first",
|
||||
alwaysPreserveClientCache: "auto",
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [aliases, setAliases] = useState([]);
|
||||
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
|
||||
const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" });
|
||||
const [newPattern, setNewPattern] = useState("");
|
||||
const [newTarget, setNewTarget] = useState("");
|
||||
const t = useTranslations("settings");
|
||||
const strategyHintKeyByValue = STRATEGIES.reduce<Record<string, string>>((acc, strategy) => {
|
||||
acc[strategy.value] = strategy.descKey;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setSettings(data);
|
||||
setAliases(data.wildcardAliases || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
@@ -61,100 +39,8 @@ export default function RoutingTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const addAlias = async () => {
|
||||
if (!newPattern.trim() || !newTarget.trim()) return;
|
||||
const updated = [...aliases, { pattern: newPattern.trim(), target: newTarget.trim() }];
|
||||
await updateSetting({ wildcardAliases: updated });
|
||||
setAliases(updated);
|
||||
setNewPattern("");
|
||||
setNewTarget("");
|
||||
};
|
||||
|
||||
const removeAlias = async (idx) => {
|
||||
const updated = aliases.filter((_, i) => i !== idx);
|
||||
await updateSetting({ wildcardAliases: updated });
|
||||
setAliases(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Strategy Selection */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
route
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{t("routingStrategy")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-lg border border-blue-500/20 bg-blue-500/5 p-3">
|
||||
<p className="text-xs font-medium text-blue-700 dark:text-blue-300">
|
||||
{t("routingAdvancedGuideTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("routingAdvancedGuideHint1")}</p>
|
||||
<p className="text-xs text-text-muted">{t("routingAdvancedGuideHint2")}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-2 mb-4"
|
||||
style={{ gridAutoRows: "1fr" }}
|
||||
>
|
||||
{STRATEGIES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => updateSetting({ fallbackStrategy: s.value })}
|
||||
disabled={loading}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border text-center transition-all ${
|
||||
settings.fallbackStrategy === s.value
|
||||
? "border-blue-500/50 bg-blue-500/5 ring-1 ring-blue-500/20"
|
||||
: "border-border/50 hover:border-border hover:bg-surface/30"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[24px] ${
|
||||
settings.fallbackStrategy === s.value ? "text-blue-400" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{s.icon}
|
||||
</span>
|
||||
<div>
|
||||
<p
|
||||
className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}
|
||||
>
|
||||
{t(s.labelKey)}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t(s.descKey)}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{settings.fallbackStrategy === "round-robin" && (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("stickyLimit")}</p>
|
||||
<p className="text-xs text-text-muted">{t("stickyLimitDesc")}</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={settings.stickyRoundRobinLimit || 3}
|
||||
onChange={(e) => updateSetting({ stickyRoundRobinLimit: parseInt(e.target.value) })}
|
||||
disabled={loading}
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted italic pt-3 border-t border-border/30 mt-3">
|
||||
{t(strategyHintKeyByValue[settings.fallbackStrategy] || "fillFirstDesc")}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Adaptive Volume Routing */}
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex gap-3">
|
||||
@@ -188,7 +74,6 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* LKGP Toggle */}
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex gap-3">
|
||||
@@ -268,77 +153,8 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Wildcard Aliases */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
alt_route
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("modelAliases")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("modelAliasesDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aliases.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 mb-4">
|
||||
{aliases.map((a, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span className="font-mono text-purple-400 break-all">{a.pattern}</span>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">
|
||||
arrow_forward
|
||||
</span>
|
||||
<span className="font-mono text-text-main break-all">{a.target}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeAlias(i)}
|
||||
className="shrink-0 text-text-muted hover:text-red-400 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label={t("pattern")}
|
||||
placeholder={t("aliasPatternPlaceholder")}
|
||||
value={newPattern}
|
||||
onChange={(e) => setNewPattern(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label={t("targetModel")}
|
||||
placeholder={t("aliasTargetPlaceholder")}
|
||||
value={newTarget}
|
||||
onChange={(e) => setNewTarget(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={addAlias}
|
||||
className="mb-[2px] sm:w-auto w-full"
|
||||
>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Fallback Chains */}
|
||||
<FallbackChainsEditor />
|
||||
|
||||
{/* Client Cache Control */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">
|
||||
|
||||
@@ -14,13 +14,14 @@ import AppearanceTab from "./components/AppearanceTab";
|
||||
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
|
||||
import CodexServiceTierTab from "./components/CodexServiceTierTab";
|
||||
import SystemPromptTab from "./components/SystemPromptTab";
|
||||
import ModelAliasesTab from "./components/ModelAliasesTab";
|
||||
import ModelAliasesUnified from "./components/ModelAliasesUnified";
|
||||
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
|
||||
import CacheSettingsTab from "./components/CacheSettingsTab";
|
||||
import MemorySkillsTab from "./components/MemorySkillsTab";
|
||||
import ModelsDevSyncTab from "./components/ModelsDevSyncTab";
|
||||
import ResilienceTab from "./components/ResilienceTab";
|
||||
import CliproxyapiSettingsTab from "./components/CliproxyapiSettingsTab";
|
||||
import ModelRoutingSection from "@/shared/components/ModelRoutingSection";
|
||||
|
||||
const tabs = [
|
||||
{ id: "general", labelKey: "general", icon: "settings" },
|
||||
@@ -105,8 +106,9 @@ export default function SettingsPage() {
|
||||
{activeTab === "routing" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<RoutingTab />
|
||||
<ModelRoutingSection />
|
||||
<ComboDefaultsTab />
|
||||
<ModelAliasesTab />
|
||||
<ModelAliasesUnified />
|
||||
<BackgroundDegradationTab />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -51,6 +51,14 @@ export default function BudgetTelemetryCards() {
|
||||
<span className="text-text-muted">{t("totalRequests")}</span>
|
||||
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Active sessions</span>
|
||||
<span className="font-mono">{telemetry.sessions?.activeCount ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Quota alerts</span>
|
||||
<span className="font-mono">{telemetry.quotaMonitor?.alerting ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
|
||||
|
||||
@@ -63,17 +63,17 @@ export async function GET() {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "auto-combo",
|
||||
name: "Auto-Managed Model Combos",
|
||||
id: "intelligent-routing",
|
||||
name: "Intelligent Model Combos",
|
||||
description:
|
||||
"Self-healing model chains that dynamically adapt to provider " +
|
||||
"health and quota availability. Uses a scoring function based on " +
|
||||
"quota, health, cost, latency, task fitness, and stability.",
|
||||
tags: ["combo", "auto", "self-healing", "adaptive"],
|
||||
"Self-healing model chains with auto and LKGP routing. " +
|
||||
"Adapts to provider health, quota, latency, and cost using " +
|
||||
"the unified combos dashboard intelligent routing controls.",
|
||||
tags: ["combo", "intelligent-routing", "self-healing", "adaptive"],
|
||||
examples: [
|
||||
"Create an auto-managed combo for coding tasks",
|
||||
"Switch to cost-saver mode",
|
||||
"Show the Auto-Combo scoring breakdown",
|
||||
"Show the intelligent routing scoring breakdown",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getComboModelProvider } from "@/lib/combos/steps";
|
||||
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
|
||||
|
||||
const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl();
|
||||
@@ -18,7 +19,12 @@ export async function GET() {
|
||||
]);
|
||||
|
||||
const health = healthRes.status === "fulfilled" ? await healthRes.value.json() : {};
|
||||
const combos = combosRes.status === "fulfilled" ? await combosRes.value.json() : [];
|
||||
const combosPayload = combosRes.status === "fulfilled" ? await combosRes.value.json() : [];
|
||||
const combos = Array.isArray(combosPayload)
|
||||
? combosPayload
|
||||
: Array.isArray(combosPayload?.combos)
|
||||
? combosPayload.combos
|
||||
: [];
|
||||
|
||||
// Build provider scores from circuit breaker state
|
||||
const breakers: any[] = health?.circuitBreakers || [];
|
||||
@@ -29,8 +35,10 @@ export async function GET() {
|
||||
if (Array.isArray(combos)) {
|
||||
for (const combo of combos) {
|
||||
for (const model of combo.models || combo.data?.models || []) {
|
||||
allProviders.add(model.provider);
|
||||
providerScores.set(model.provider, (providerScores.get(model.provider) || 0) + 1);
|
||||
const provider = getComboModelProvider(model);
|
||||
if (!provider) continue;
|
||||
allProviders.add(provider);
|
||||
providerScores.set(provider, (providerScores.get(provider) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { validateCompositeTiersConfig } from "@/lib/combos/compositeTiers";
|
||||
import { normalizeComboModels } from "@/lib/combos/steps";
|
||||
import { validateComboDAG } from "@omniroute/open-sse/services/combo.ts";
|
||||
import { updateComboSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
@@ -53,7 +55,31 @@ export async function PUT(request, { params }) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const body = validation.data;
|
||||
const currentCombo = await getComboById(id);
|
||||
if (!currentCombo) {
|
||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||
}
|
||||
const allCombos = await getCombos();
|
||||
|
||||
const comboName = validation.data.name || currentCombo.name;
|
||||
const body = validation.data.models
|
||||
? {
|
||||
...validation.data,
|
||||
models: normalizeComboModels(validation.data.models, {
|
||||
comboName,
|
||||
allCombos,
|
||||
}),
|
||||
}
|
||||
: validation.data;
|
||||
const nextComboState = {
|
||||
...currentCombo,
|
||||
...body,
|
||||
name: comboName,
|
||||
};
|
||||
const compositeValidation = validateCompositeTiersConfig(nextComboState);
|
||||
if (!compositeValidation.success) {
|
||||
return NextResponse.json({ error: compositeValidation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if name already exists (exclude current combo)
|
||||
if (body.name) {
|
||||
@@ -65,10 +91,8 @@ export async function PUT(request, { params }) {
|
||||
|
||||
// Validate nested combo DAG (no circular references, max depth)
|
||||
if (body.models) {
|
||||
const allCombos = await getCombos();
|
||||
// Update the combo in the list temporarily for validation
|
||||
const updatedCombos = allCombos.map((c) => (c.id === id ? { ...c, ...body } : c));
|
||||
const comboName = body.name || (await getComboById(id))?.name;
|
||||
if (comboName) {
|
||||
try {
|
||||
validateComboDAG(comboName, updatedCombos);
|
||||
@@ -80,10 +104,6 @@ export async function PUT(request, { params }) {
|
||||
|
||||
const combo = await updateCombo(id, body);
|
||||
|
||||
if (!combo) {
|
||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
|
||||
12
src/app/api/combos/builder/options/route.ts
Normal file
12
src/app/api/combos/builder/options/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getComboBuilderOptions } from "@/lib/combos/builderOptions";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const options = await getComboBuilderOptions();
|
||||
return NextResponse.json(options);
|
||||
} catch (error) {
|
||||
console.log("Error fetching combo builder options:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch combo builder options" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
|
||||
import { getCombos, createCombo, getComboByName, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { validateCompositeTiersConfig } from "@/lib/combos/compositeTiers";
|
||||
import { normalizeComboModels } from "@/lib/combos/steps";
|
||||
import { validateComboDAG } from "@omniroute/open-sse/services/combo.ts";
|
||||
import { createComboSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
@@ -27,7 +29,20 @@ export async function POST(request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { name, models, strategy, config } = validation.data;
|
||||
const allCombos = await getCombos();
|
||||
const normalizedModels = normalizeComboModels(validation.data.models, {
|
||||
comboName: validation.data.name,
|
||||
allCombos,
|
||||
});
|
||||
const comboInput = {
|
||||
...validation.data,
|
||||
models: normalizedModels,
|
||||
};
|
||||
const { name, strategy, config } = comboInput;
|
||||
const compositeValidation = validateCompositeTiersConfig(comboInput);
|
||||
if (!compositeValidation.success) {
|
||||
return NextResponse.json({ error: compositeValidation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if name already exists
|
||||
const existing = await getComboByName(name);
|
||||
@@ -36,16 +51,20 @@ export async function POST(request) {
|
||||
}
|
||||
|
||||
// Validate nested combo DAG (no circular references, max depth)
|
||||
const allCombos = await getCombos();
|
||||
// Temporarily add the new combo to validate its graph
|
||||
const tempCombo = { name, models: models || [], strategy, config };
|
||||
const tempCombo = {
|
||||
...comboInput,
|
||||
name,
|
||||
strategy,
|
||||
config,
|
||||
};
|
||||
try {
|
||||
validateComboDAG(name, [...allCombos, tempCombo]);
|
||||
} catch (dagError) {
|
||||
return NextResponse.json({ error: dagError.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const combo = await createCombo({ name, models: models || [], strategy, config });
|
||||
const combo = await createCombo(comboInput);
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildComboTestRequestBody, extractComboTestResponseText } from "@/lib/combos/testHealth";
|
||||
import { getComboByName } from "@/lib/localDb";
|
||||
import { getComboByName, getCombos } from "@/lib/localDb";
|
||||
import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo.ts";
|
||||
import { testComboSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
async function testComboModel(modelStr, internalUrl) {
|
||||
function buildComboTestResult(target, partial = {}) {
|
||||
return {
|
||||
model: target.modelStr,
|
||||
provider: target.provider,
|
||||
stepId: target.stepId,
|
||||
executionKey: target.executionKey,
|
||||
connectionId: target.connectionId,
|
||||
label: target.label,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
async function testComboTarget(target, internalUrl) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
// Send a minimal but real chat request through the same internal
|
||||
// endpoint an external OpenAI-compatible client would use.
|
||||
const testBody = buildComboTestRequestBody(modelStr);
|
||||
const testBody = buildComboTestRequestBody(target.modelStr);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 20000);
|
||||
@@ -27,6 +40,7 @@ async function testComboModel(modelStr, internalUrl) {
|
||||
// Force a fresh execution path so combo tests cannot be satisfied by
|
||||
// OmniRoute's semantic cache or other request reuse layers.
|
||||
"X-OmniRoute-No-Cache": "true",
|
||||
...(target.connectionId ? { "X-OmniRoute-Connection": target.connectionId } : {}),
|
||||
"X-Request-Id": `combo-test-${randomUUID()}`,
|
||||
},
|
||||
body: JSON.stringify(testBody),
|
||||
@@ -48,16 +62,15 @@ async function testComboModel(modelStr, internalUrl) {
|
||||
|
||||
const responseText = extractComboTestResponseText(responseBody);
|
||||
if (!responseText) {
|
||||
return {
|
||||
model: modelStr,
|
||||
return buildComboTestResult(target, {
|
||||
status: "error",
|
||||
statusCode: res.status,
|
||||
error: "Provider returned HTTP 200 but no text content.",
|
||||
latencyMs,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { model: modelStr, status: "ok", latencyMs, responseText };
|
||||
return buildComboTestResult(target, { status: "ok", latencyMs, responseText });
|
||||
}
|
||||
|
||||
let errorMsg = "";
|
||||
@@ -68,21 +81,19 @@ async function testComboModel(modelStr, internalUrl) {
|
||||
errorMsg = res.statusText;
|
||||
}
|
||||
|
||||
return {
|
||||
model: modelStr,
|
||||
return buildComboTestResult(target, {
|
||||
status: "error",
|
||||
statusCode: res.status,
|
||||
error: errorMsg,
|
||||
latencyMs,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
const latencyMs = Date.now() - startTime;
|
||||
return {
|
||||
model: modelStr,
|
||||
return buildComboTestResult(target, {
|
||||
status: "error",
|
||||
error: error.name === "AbortError" ? "Timeout (20s)" : error.message,
|
||||
latencyMs,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,22 +130,35 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const models = (combo.models || []).map((m) => (typeof m === "string" ? m : m.model));
|
||||
const allCombos = await getCombos();
|
||||
const targets = resolveNestedComboTargets(combo, allCombos);
|
||||
|
||||
if (models.length === 0) {
|
||||
if (targets.length === 0) {
|
||||
return NextResponse.json({ error: "Combo has no models" }, { status: 400 });
|
||||
}
|
||||
|
||||
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
|
||||
const results = await Promise.all(
|
||||
models.map((modelStr) => testComboModel(modelStr, internalUrl))
|
||||
targets.map((target) => testComboTarget(target, internalUrl))
|
||||
);
|
||||
const resolvedBy = results.find((result) => result.status === "ok")?.model || null;
|
||||
const resolvedResult = results.find((result) => result.status === "ok") || null;
|
||||
const resolvedBy = resolvedResult?.model || null;
|
||||
|
||||
return NextResponse.json({
|
||||
comboName,
|
||||
strategy: combo.strategy || "priority",
|
||||
resolvedBy,
|
||||
resolvedByExecutionKey: resolvedResult?.executionKey || null,
|
||||
resolvedByTarget: resolvedResult
|
||||
? {
|
||||
model: resolvedResult.model,
|
||||
provider: resolvedResult.provider,
|
||||
stepId: resolvedResult.stepId,
|
||||
executionKey: resolvedResult.executionKey,
|
||||
connectionId: resolvedResult.connectionId,
|
||||
label: resolvedResult.label,
|
||||
}
|
||||
: null,
|
||||
results,
|
||||
testedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDbInstance, SQLITE_FILE } from "@/lib/db/core";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/db-backups/exportAll
|
||||
* Exports the entire database + settings as a ZIP archive
|
||||
* Security: Requires admin authentication.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!SQLITE_FILE) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { listDbBackups, restoreDbBackup, backupDbFile } from "@/lib/localDb";
|
||||
import { dbBackupRestoreSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
/**
|
||||
* PUT /api/db-backups — Trigger a manual backup snapshot.
|
||||
* Security: Requires admin authentication.
|
||||
*/
|
||||
export async function PUT() {
|
||||
export async function PUT(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = backupDbFile("manual");
|
||||
if (!result) {
|
||||
@@ -21,8 +27,13 @@ export async function PUT() {
|
||||
|
||||
/**
|
||||
* GET /api/db-backups — List available database backups.
|
||||
* Security: Requires admin authentication.
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const backups = await listDbBackups();
|
||||
return NextResponse.json({ backups });
|
||||
@@ -35,8 +46,13 @@ export async function GET() {
|
||||
/**
|
||||
* POST /api/db-backups — Restore a specific backup.
|
||||
* Body: { backupId: "db_2026-02-11T14-00-00-000Z_pre-write.json" }
|
||||
* Security: Requires admin authentication.
|
||||
*/
|
||||
export async function POST(request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections, getSettings } from "@/lib/localDb";
|
||||
import { buildHealthPayload } from "@/lib/monitoring/observability";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
@@ -15,67 +16,38 @@ export async function GET() {
|
||||
const { getAllRateLimitStatus } = await import("@omniroute/open-sse/services/rateLimitManager");
|
||||
const { getAllModelLockouts } = await import("@omniroute/open-sse/services/accountFallback");
|
||||
const { getInflightCount } = await import("@omniroute/open-sse/services/requestDedup.ts");
|
||||
const { getQuotaMonitorSummary, getQuotaMonitorSnapshots } =
|
||||
await import("@omniroute/open-sse/services/quotaMonitor.ts");
|
||||
const { getActiveSessions, getAllActiveSessionCountsByKey } =
|
||||
await import("@omniroute/open-sse/services/sessionManager.ts");
|
||||
|
||||
const settings = await getSettings();
|
||||
const connections = await getProviderConnections();
|
||||
const circuitBreakers = getAllCircuitBreakerStatuses();
|
||||
const rateLimitStatus = getAllRateLimitStatus();
|
||||
const lockouts = getAllModelLockouts();
|
||||
const quotaMonitorSummary = getQuotaMonitorSummary();
|
||||
const quotaMonitorMonitors = getQuotaMonitorSnapshots();
|
||||
const activeSessions = getActiveSessions();
|
||||
const activeSessionsByKey = getAllActiveSessionCountsByKey();
|
||||
const { getAllHealthStatuses } = await import("@/lib/localHealthCheck");
|
||||
|
||||
// System info
|
||||
const system = {
|
||||
version: APP_CONFIG.version,
|
||||
nodeVersion: process.version,
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: process.memoryUsage(),
|
||||
pid: process.pid,
|
||||
platform: process.platform,
|
||||
};
|
||||
|
||||
// Provider health summary (circuitBreakers is an Array of { name, state, ... })
|
||||
const providerHealth = {};
|
||||
for (const cb of circuitBreakers) {
|
||||
// Skip test circuit breakers (leftover from unit tests)
|
||||
if (cb.name.startsWith("test-") || cb.name.startsWith("test_")) continue;
|
||||
providerHealth[cb.name] = {
|
||||
state: cb.state,
|
||||
failures: cb.failureCount || 0,
|
||||
lastFailure: cb.lastFailureTime,
|
||||
};
|
||||
}
|
||||
|
||||
const configuredProviders = new Set(connections.map((c: any) => c.provider));
|
||||
const activeProviders = new Set(
|
||||
connections.filter((c: any) => c.isActive !== false).map((c: any) => c.provider)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
system,
|
||||
providerHealth,
|
||||
providerSummary: {
|
||||
catalogCount: Object.keys(AI_PROVIDERS).length,
|
||||
configuredCount: configuredProviders.size,
|
||||
activeCount: activeProviders.size,
|
||||
monitoredCount: Object.keys(providerHealth).length,
|
||||
},
|
||||
localProviders: getAllHealthStatuses(),
|
||||
const payload = buildHealthPayload({
|
||||
appVersion: APP_CONFIG.version,
|
||||
catalogCount: Object.keys(AI_PROVIDERS).length,
|
||||
settings,
|
||||
connections,
|
||||
circuitBreakers,
|
||||
rateLimitStatus,
|
||||
lockouts,
|
||||
dedup: {
|
||||
inflightRequests: getInflightCount(),
|
||||
},
|
||||
cryptography: {
|
||||
status:
|
||||
process.env.STORAGE_ENCRYPTION_KEY && process.env.STORAGE_ENCRYPTION_KEY.length >= 32
|
||||
? "healthy"
|
||||
: "missing_or_invalid",
|
||||
provider: "aes-256-gcm",
|
||||
},
|
||||
setupComplete: settings?.setupComplete || false,
|
||||
localProviders: getAllHealthStatuses(),
|
||||
inflightRequests: getInflightCount(),
|
||||
quotaMonitorSummary,
|
||||
quotaMonitorMonitors,
|
||||
activeSessions,
|
||||
activeSessionsByKey,
|
||||
});
|
||||
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
console.error("[API] GET /api/monitoring/health error:", error);
|
||||
return NextResponse.json({ status: "error", error: "Health check failed" }, { status: 500 });
|
||||
|
||||
@@ -9,17 +9,15 @@ import path from "path";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
let cachedSpec: { data: any; mtime: number } | null = null;
|
||||
const OPENAPI_SPEC_CANDIDATES = [
|
||||
path.join(process.cwd(), "docs", "openapi.yaml"),
|
||||
path.join(process.cwd(), "app", "docs", "openapi.yaml"),
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Try multiple locations for the spec file
|
||||
const candidates = [
|
||||
path.join(process.cwd(), "docs", "openapi.yaml"),
|
||||
path.join(process.cwd(), "app", "docs", "openapi.yaml"),
|
||||
];
|
||||
|
||||
let specPath = "";
|
||||
for (const p of candidates) {
|
||||
for (const p of OPENAPI_SPEC_CANDIDATES) {
|
||||
if (fs.existsSync(p)) {
|
||||
specPath = p;
|
||||
break;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildTelemetryPayload } from "@/lib/monitoring/observability";
|
||||
import { getTelemetrySummary } from "@/shared/utils/requestTelemetry";
|
||||
|
||||
export async function GET(request) {
|
||||
@@ -6,7 +7,14 @@ export async function GET(request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const windowMs = parseInt(searchParams.get("windowMs") || "300000", 10);
|
||||
const summary = getTelemetrySummary(windowMs);
|
||||
return NextResponse.json(summary);
|
||||
const { getQuotaMonitorSummary } = await import("@omniroute/open-sse/services/quotaMonitor.ts");
|
||||
const { getActiveSessions } = await import("@omniroute/open-sse/services/sessionManager.ts");
|
||||
const payload = buildTelemetryPayload({
|
||||
summary,
|
||||
quotaMonitorSummary: getQuotaMonitorSummary(),
|
||||
activeSessions: getActiveSessions(),
|
||||
});
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { translatorSaveSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
|
||||
@@ -3,6 +3,8 @@ import { z } from "zod";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
import { getComboById, getCombos } from "@/lib/db/combos";
|
||||
import { getQuotaSnapshots } from "@/lib/db/quotaSnapshots";
|
||||
import { getComboMetrics } from "@omniroute/open-sse/services/comboMetrics.ts";
|
||||
import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo.ts";
|
||||
import type {
|
||||
ComboHealthMetrics,
|
||||
ComboHealthResponse,
|
||||
@@ -10,13 +12,11 @@ import type {
|
||||
UtilizationTimeRange,
|
||||
} from "@/shared/types/utilization";
|
||||
|
||||
type ComboModelNode = string | { model?: string | null };
|
||||
|
||||
type ComboRecord = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
strategy?: string;
|
||||
models?: ComboModelNode[];
|
||||
models?: unknown[];
|
||||
};
|
||||
|
||||
type ModelUsageRow = {
|
||||
@@ -45,6 +45,40 @@ type ProviderHealth = {
|
||||
trend: "improving" | "stable" | "declining";
|
||||
};
|
||||
|
||||
type ResolvedComboTargetView = {
|
||||
stepId: string;
|
||||
executionKey: string;
|
||||
modelStr: string;
|
||||
provider: string;
|
||||
connectionId: string | null;
|
||||
label: string | null;
|
||||
};
|
||||
|
||||
type RuntimeTargetMetricView = {
|
||||
requests?: number;
|
||||
successRate?: number;
|
||||
avgLatencyMs?: number;
|
||||
lastStatus?: "ok" | "error" | null;
|
||||
lastUsedAt?: string | null;
|
||||
};
|
||||
|
||||
type HistoricalTargetUsageRow = {
|
||||
combo_execution_key: string | null;
|
||||
combo_step_id: string | null;
|
||||
status: number | null;
|
||||
duration: number | null;
|
||||
timestamp: string | null;
|
||||
};
|
||||
|
||||
type HistoricalTargetMetricView = {
|
||||
stepId: string | null;
|
||||
requests: number;
|
||||
successRate: number;
|
||||
avgLatencyMs: number;
|
||||
lastStatus: "ok" | "error" | null;
|
||||
lastUsedAt: string | null;
|
||||
};
|
||||
|
||||
const querySchema = z.object({
|
||||
range: z.enum(["1h", "24h", "7d", "30d"]),
|
||||
comboId: z
|
||||
@@ -84,25 +118,15 @@ function toSafeNumber(value: number | null | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function normalizeComboModels(models: ComboModelNode[] | undefined): string[] {
|
||||
if (!Array.isArray(models)) return [];
|
||||
|
||||
return models
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") return entry;
|
||||
if (entry && typeof entry === "object" && typeof entry.model === "string") {
|
||||
return entry.model;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter((entry): entry is string => entry.trim().length > 0);
|
||||
}
|
||||
|
||||
function extractProvider(model: string): string {
|
||||
const [provider] = model.split("/");
|
||||
return provider?.trim() || "unknown";
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function calculateGini(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
@@ -198,6 +222,50 @@ function buildProviderHealth(provider: string, snapshots: QuotaSnapshotRow[]): P
|
||||
};
|
||||
}
|
||||
|
||||
function buildConnectionHealth(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
snapshots: QuotaSnapshotRow[]
|
||||
): ProviderHealth | null {
|
||||
if (snapshots.length === 0) return null;
|
||||
|
||||
const ordered = [...snapshots].sort((left, right) => {
|
||||
const leftView = left as unknown as QuotaSnapshotView;
|
||||
const rightView = right as unknown as QuotaSnapshotView;
|
||||
return (leftView.createdAt || "").localeCompare(rightView.createdAt || "");
|
||||
});
|
||||
const firstSnapshot = ordered.find((entry) => {
|
||||
const snapshotView = entry as unknown as QuotaSnapshotView;
|
||||
return (
|
||||
snapshotView.remainingPercentage !== null && snapshotView.remainingPercentage !== undefined
|
||||
);
|
||||
});
|
||||
const lastSnapshot = [...ordered].reverse().find((entry) => {
|
||||
const snapshotView = entry as unknown as QuotaSnapshotView;
|
||||
return (
|
||||
snapshotView.remainingPercentage !== null && snapshotView.remainingPercentage !== undefined
|
||||
);
|
||||
});
|
||||
|
||||
const firstRemaining =
|
||||
(firstSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? 0;
|
||||
const lastRemaining =
|
||||
(lastSnapshot as unknown as QuotaSnapshotView | undefined)?.remainingPercentage ?? 0;
|
||||
const delta = lastRemaining - firstRemaining;
|
||||
|
||||
let trend: ProviderHealth["trend"] = "stable";
|
||||
if (delta >= 5) trend = "improving";
|
||||
if (delta <= -5) trend = "declining";
|
||||
|
||||
return {
|
||||
provider: `${provider}:${connectionId}`,
|
||||
remainingPct: roundNumber(lastRemaining),
|
||||
isExhausted:
|
||||
(ordered[ordered.length - 1] as unknown as QuotaSnapshotView | undefined)?.isExhausted === 1,
|
||||
trend,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUsageSkew(
|
||||
comboName: string,
|
||||
comboModels: string[],
|
||||
@@ -298,13 +366,168 @@ function buildQuotaHealth(providers: string[], since: string): ComboHealthMetric
|
||||
};
|
||||
}
|
||||
|
||||
function buildComboHealth(combo: ComboRecord, since: string): ComboHealthMetrics | null {
|
||||
function getHistoricalTargetMetrics(
|
||||
comboName: string,
|
||||
since: string
|
||||
): Map<string, HistoricalTargetMetricView> {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
combo_execution_key,
|
||||
combo_step_id,
|
||||
status,
|
||||
duration,
|
||||
timestamp
|
||||
FROM call_logs
|
||||
WHERE combo_name = ?
|
||||
AND timestamp >= ?
|
||||
AND COALESCE(NULLIF(combo_execution_key, ''), NULLIF(combo_step_id, '')) IS NOT NULL
|
||||
ORDER BY combo_execution_key ASC, combo_step_id ASC, timestamp DESC`
|
||||
)
|
||||
.all(comboName, since) as HistoricalTargetUsageRow[];
|
||||
|
||||
const metrics = new Map<
|
||||
string,
|
||||
{
|
||||
stepId: string | null;
|
||||
requests: number;
|
||||
successCount: number;
|
||||
totalLatencyMs: number;
|
||||
lastStatus: "ok" | "error" | null;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const executionKey =
|
||||
toNonEmptyString(row.combo_execution_key) || toNonEmptyString(row.combo_step_id);
|
||||
if (!executionKey) continue;
|
||||
|
||||
let metric = metrics.get(executionKey);
|
||||
if (!metric) {
|
||||
const statusCode = toSafeNumber(row.status);
|
||||
metric = {
|
||||
stepId: toNonEmptyString(row.combo_step_id),
|
||||
requests: 0,
|
||||
successCount: 0,
|
||||
totalLatencyMs: 0,
|
||||
lastStatus: statusCode > 0 ? (statusCode < 400 ? "ok" : "error") : null,
|
||||
lastUsedAt: toNonEmptyString(row.timestamp),
|
||||
};
|
||||
metrics.set(executionKey, metric);
|
||||
}
|
||||
|
||||
metric.requests += 1;
|
||||
metric.totalLatencyMs += toSafeNumber(row.duration);
|
||||
if (toSafeNumber(row.status) >= 200 && toSafeNumber(row.status) < 400) {
|
||||
metric.successCount += 1;
|
||||
}
|
||||
if (!metric.stepId) {
|
||||
metric.stepId = toNonEmptyString(row.combo_step_id);
|
||||
}
|
||||
}
|
||||
|
||||
return new Map(
|
||||
Array.from(metrics.entries()).map(([executionKey, metric]) => [
|
||||
executionKey,
|
||||
{
|
||||
stepId: metric.stepId,
|
||||
requests: metric.requests,
|
||||
successRate:
|
||||
metric.requests > 0 ? Math.round((metric.successCount / metric.requests) * 100) : 0,
|
||||
avgLatencyMs: metric.requests > 0 ? Math.round(metric.totalLatencyMs / metric.requests) : 0,
|
||||
lastStatus: metric.lastStatus,
|
||||
lastUsedAt: metric.lastUsedAt,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetHealth(
|
||||
comboName: string,
|
||||
targets: ResolvedComboTargetView[],
|
||||
since: string
|
||||
): NonNullable<ComboHealthMetrics["targetHealth"]> {
|
||||
const comboMetrics = getComboMetrics(comboName);
|
||||
const historicalMetrics = getHistoricalTargetMetrics(comboName, since);
|
||||
|
||||
return targets.map((target) => {
|
||||
const historicalMetric =
|
||||
historicalMetrics.get(target.executionKey) || historicalMetrics.get(target.stepId) || null;
|
||||
const runtimeMetric =
|
||||
historicalMetric === null
|
||||
? ((comboMetrics?.byTarget?.[target.executionKey] ||
|
||||
comboMetrics?.byTarget?.[target.stepId] ||
|
||||
null) as RuntimeTargetMetricView | null)
|
||||
: null;
|
||||
|
||||
let quotaRemainingPct: number | null = null;
|
||||
let quotaIsExhausted: boolean | null = null;
|
||||
let quotaTrend: "improving" | "stable" | "declining" | null = null;
|
||||
let quotaScope: "connection" | "provider" | "none" = "none";
|
||||
|
||||
if (target.connectionId) {
|
||||
const connectionHealth = buildConnectionHealth(
|
||||
target.provider,
|
||||
target.connectionId,
|
||||
getQuotaSnapshots({
|
||||
provider: target.provider,
|
||||
connectionId: target.connectionId,
|
||||
since,
|
||||
})
|
||||
);
|
||||
if (connectionHealth) {
|
||||
quotaRemainingPct = connectionHealth.remainingPct;
|
||||
quotaIsExhausted = connectionHealth.isExhausted;
|
||||
quotaTrend = connectionHealth.trend;
|
||||
quotaScope = "connection";
|
||||
}
|
||||
}
|
||||
|
||||
if (quotaScope === "none") {
|
||||
const providerSnapshots = getQuotaSnapshots({ provider: target.provider, since });
|
||||
const providerHealth = buildProviderHealth(target.provider, providerSnapshots);
|
||||
if (providerSnapshots.length > 0) {
|
||||
quotaRemainingPct = providerHealth.remainingPct;
|
||||
quotaIsExhausted = providerHealth.isExhausted;
|
||||
quotaTrend = providerHealth.trend;
|
||||
quotaScope = "provider";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
executionKey: target.executionKey,
|
||||
stepId: target.stepId,
|
||||
model: target.modelStr,
|
||||
provider: target.provider,
|
||||
connectionId: target.connectionId,
|
||||
label: target.label,
|
||||
requests: toSafeNumber(historicalMetric?.requests ?? runtimeMetric?.requests),
|
||||
successRate: toSafeNumber(historicalMetric?.successRate ?? runtimeMetric?.successRate),
|
||||
avgLatencyMs: toSafeNumber(historicalMetric?.avgLatencyMs ?? runtimeMetric?.avgLatencyMs),
|
||||
lastStatus: historicalMetric?.lastStatus ?? runtimeMetric?.lastStatus ?? null,
|
||||
lastUsedAt: historicalMetric?.lastUsedAt ?? runtimeMetric?.lastUsedAt ?? null,
|
||||
quotaRemainingPct,
|
||||
quotaIsExhausted,
|
||||
quotaTrend,
|
||||
quotaScope,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildComboHealth(
|
||||
combo: ComboRecord,
|
||||
since: string,
|
||||
allCombos: ComboRecord[]
|
||||
): ComboHealthMetrics | null {
|
||||
const comboId = typeof combo.id === "string" ? combo.id : "";
|
||||
const comboName = typeof combo.name === "string" ? combo.name : "";
|
||||
if (!comboId || !comboName) return null;
|
||||
|
||||
const models = normalizeComboModels(combo.models);
|
||||
const providers = Array.from(new Set(models.map(extractProvider)));
|
||||
const targets = resolveNestedComboTargets(combo, allCombos) as ResolvedComboTargetView[];
|
||||
const models = targets.map((target) => target.modelStr);
|
||||
const providers = Array.from(new Set(targets.map((target) => target.provider)));
|
||||
|
||||
return {
|
||||
comboId,
|
||||
@@ -314,6 +537,7 @@ function buildComboHealth(combo: ComboRecord, since: string): ComboHealthMetrics
|
||||
? combo.strategy
|
||||
: "priority",
|
||||
models,
|
||||
targetHealth: buildTargetHealth(comboName, targets, since),
|
||||
quotaHealth: buildQuotaHealth(providers, since),
|
||||
usageSkew: buildUsageSkew(comboName, models, since),
|
||||
performance: buildPerformance(comboName, since),
|
||||
@@ -340,21 +564,24 @@ export async function GET(request: Request) {
|
||||
const { range, comboId } = parsedQuery.data;
|
||||
const since = getRangeStartIso(range);
|
||||
|
||||
const allCombos = (await getCombos()) as ComboRecord[];
|
||||
let combos: ComboRecord[] = [];
|
||||
if (comboId) {
|
||||
const combo = (await getComboById(comboId)) as ComboRecord | null;
|
||||
const combo =
|
||||
allCombos.find((entry) => entry.id === comboId) ||
|
||||
((await getComboById(comboId)) as ComboRecord | null);
|
||||
if (!combo) {
|
||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||
}
|
||||
combos = [combo];
|
||||
} else {
|
||||
combos = (await getCombos()) as ComboRecord[];
|
||||
combos = allCombos;
|
||||
}
|
||||
|
||||
const response: ComboHealthResponse = {
|
||||
timeRange: range,
|
||||
combos: combos
|
||||
.map((combo) => buildComboHealth(combo, since))
|
||||
.map((combo) => buildComboHealth(combo, since, allCombos))
|
||||
.filter((combo): combo is ComboHealthMetrics => combo !== null),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { PROVIDER_MODELS } from "@/shared/constants/models";
|
||||
import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models";
|
||||
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
|
||||
import { getSyncedCapabilities } from "@/lib/modelsDevSync";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -21,18 +23,21 @@ export async function OPTIONS() {
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
getSyncedCapabilities();
|
||||
const models = [];
|
||||
|
||||
// Built-in models (hardcoded defaults)
|
||||
for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) {
|
||||
for (const model of providerModels) {
|
||||
const resolved = getResolvedModelCapabilities({ provider, model: model.id });
|
||||
models.push({
|
||||
name: `models/${provider}/${model.id}`,
|
||||
displayName: model.name || model.id,
|
||||
description: `${provider} model: ${model.name || model.id}`,
|
||||
supportedGenerationMethods: ["generateContent"],
|
||||
inputTokenLimit: 128000,
|
||||
outputTokenLimit: 8192,
|
||||
inputTokenLimit: resolved.maxInputTokens || resolved.contextWindow || 128000,
|
||||
outputTokenLimit: resolved.maxOutputTokens || 8192,
|
||||
...(resolved.supportsThinking === true ? { thinking: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -76,14 +81,26 @@ export async function GET() {
|
||||
continue;
|
||||
const m = model as Record<string, unknown>;
|
||||
if (m.isHidden === true) continue;
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: String(m.id),
|
||||
});
|
||||
models.push({
|
||||
name: `models/${providerId}/${m.id}`,
|
||||
displayName: m.name || m.id,
|
||||
...(typeof m.description === "string" ? { description: m.description } : {}),
|
||||
supportedGenerationMethods: ["generateContent"],
|
||||
inputTokenLimit: typeof m.inputTokenLimit === "number" ? m.inputTokenLimit : 128000,
|
||||
outputTokenLimit: typeof m.outputTokenLimit === "number" ? m.outputTokenLimit : 8192,
|
||||
...(m.supportsThinking === true ? { thinking: true } : {}),
|
||||
inputTokenLimit:
|
||||
typeof m.inputTokenLimit === "number"
|
||||
? m.inputTokenLimit
|
||||
: resolved.maxInputTokens || resolved.contextWindow || 128000,
|
||||
outputTokenLimit:
|
||||
typeof m.outputTokenLimit === "number"
|
||||
? m.outputTokenLimit
|
||||
: resolved.maxOutputTokens || 8192,
|
||||
...(m.supportsThinking === true || resolved.supportsThinking === true
|
||||
? { thinking: true }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
(e.g. Next.js route groups like "(dashboard)"). Explicit @source
|
||||
directives ensure all utility classes in route groups are included. */
|
||||
@source "../app/(dashboard)";
|
||||
@source "../../open-sse";
|
||||
@source not "../../*.sqlite*";
|
||||
@source not "../../.claude*";
|
||||
@source not "../../.claude-memory";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
/**
|
||||
* @typedef {import('./types.js').Combo} Combo
|
||||
*/
|
||||
import { getComboStepTarget, getComboStepWeight } from "@/lib/combos/steps";
|
||||
|
||||
/** @type {Map<string, number>} Persistent round-robin counters per combo */
|
||||
const roundRobinCounters = new Map();
|
||||
@@ -30,7 +31,12 @@ export function resolveComboModel(combo: any, context: any = {}) {
|
||||
}
|
||||
|
||||
// Normalize models to { model, weight } format
|
||||
const normalized = models.map((m) => (typeof m === "string" ? { model: m, weight: 1 } : m));
|
||||
const normalized = models
|
||||
.map((entry) => ({
|
||||
model: getComboStepTarget(entry) || "",
|
||||
weight: getComboStepWeight(entry) || 1,
|
||||
}))
|
||||
.filter((entry) => entry.model);
|
||||
|
||||
const strategy = combo.strategy || "priority";
|
||||
|
||||
@@ -93,6 +99,8 @@ export function resolveComboModel(combo: any, context: any = {}) {
|
||||
* @returns {string[]} Remaining models in order
|
||||
*/
|
||||
export function getComboFallbacks(combo, primaryIndex) {
|
||||
const models = (combo.models || []).map((m) => (typeof m === "string" ? m : m.model));
|
||||
const models = (combo.models || [])
|
||||
.map((entry) => getComboStepTarget(entry))
|
||||
.filter((entry): entry is string => !!entry);
|
||||
return [...models.slice(primaryIndex + 1), ...models.slice(0, primaryIndex)];
|
||||
}
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "لوحة القيادة",
|
||||
"providers": "المزوّدون",
|
||||
"combos": "المجموعات",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "الاستخدام",
|
||||
"analytics": "التحليلات",
|
||||
"costs": "التكاليف",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "التكاليف",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "المكالمات لكل حساب قبل التبديل",
|
||||
"modelAliases": "الأسماء المستعارة النموذجية",
|
||||
"modelAliasesTitle": "أسماء بديلة للنماذج",
|
||||
"modelAliasesDesc": "أنماط أحرف البدل لإعادة تعيين أسماء النماذج • استخدم * و؟",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "إضافة اسم بديل مخصص",
|
||||
"deprecatedModelId": "معرف النموذج المهمل",
|
||||
"newModelId": "معرف النموذج الجديد",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "مترجم",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "دردشة بسيطة",
|
||||
"streaming": "الجري",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Табло",
|
||||
"providers": "Доставчици",
|
||||
"combos": "Комбота",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Използване",
|
||||
"analytics": "Анализ",
|
||||
"costs": "Разходи",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Разходи",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Обаждания на акаунт преди превключване",
|
||||
"modelAliases": "Псевдоними на модела",
|
||||
"modelAliasesTitle": "Псевдоними на модели",
|
||||
"modelAliasesDesc": "Шаблони за заместващи символи за пренасочване на имена на модели • Използвайте * и ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Добави потребителски псевдоним",
|
||||
"deprecatedModelId": "Остарял ID на модел",
|
||||
"newModelId": "Нов ID на модел",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Преводач",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Обикновен чат",
|
||||
"streaming": "Поточно предаване",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Nástěnka",
|
||||
"providers": "Poskytovatelé",
|
||||
"combos": "Komba",
|
||||
"autoCombo": "Auto Kombo",
|
||||
"usage": "Použití",
|
||||
"analytics": "Analytika",
|
||||
"costs": "Náklady",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Kombo",
|
||||
"autoDesc": "Samoopravný inteligentní směrovací pool (Optimalizovaný výkon)",
|
||||
"lkgp": "Režim LKGP",
|
||||
"lkgpDesc": "Poslední známý dobrý poskytovatel (Predikovatelná odolnost)"
|
||||
"lkgpDesc": "Poslední známý dobrý poskytovatel (Predikovatelná odolnost)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Náklady",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Volání účtem před přepnutím",
|
||||
"modelAliases": "Aliasy modelů",
|
||||
"modelAliasesTitle": "Aliasy modelů",
|
||||
"modelAliasesDesc": "Vzory zástupných znaků pro přemapování názvů modelů • Použijte * a ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Přidat vlastní alias",
|
||||
"deprecatedModelId": "ID zastupovaného modelu",
|
||||
"newModelId": "Nové ID modelu",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Poslední známý dobrý poskytovatel (Predikovatelná odolnost)",
|
||||
"maintenance": "Údržba",
|
||||
"purgeExpiredLogs": "Vymazat expirované protokoly",
|
||||
"purgeLogsFailed": "Nepodařilo se vymazat protokoly"
|
||||
"purgeLogsFailed": "Nepodařilo se vymazat protokoly",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Překladatel",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Opravte chybu‚ příkaz nenalezen",
|
||||
"setupGuideCommandMissingDesc": "Ujistěte se, že CLI příkaz existuje v PATH, otevřete novou relaci terminálu a znovu spusťte tlačítko Obnovit."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Kombo Modul",
|
||||
"statusNormal": "Normální",
|
||||
"statusIncident": "Režim Incidentu",
|
||||
"modePack": "Balíček režimů",
|
||||
"providerScores": "Skóre poskytovatelů",
|
||||
"noAutoCombo": "Není nastaveno žádné automatické kombo.",
|
||||
"excludedProviders": "Vyloučení poskytovatelé",
|
||||
"noExclusions": "V současné době nejsou vyloučeni žádní poskytovatelé.",
|
||||
"factorQuota": "Kvóta",
|
||||
"factorHealth": "Zdraví",
|
||||
"factorCost": "Náklady",
|
||||
"factorLatency": "Latence",
|
||||
"factorTaskFit": "Přizpůsobení úkolu",
|
||||
"factorStability": "Stabilita",
|
||||
"factorTierPriority": "Priorita úrovně",
|
||||
"factorTierPriorityDesc": "Preferuje účty s vyššími kvótami (Ultra/Pro před Free)",
|
||||
"scoreFactorBreakdown": "Faktory bodování",
|
||||
"modePackShipFast": "Rychlá doprava",
|
||||
"modePackCostSaver": "Úspora nákladů",
|
||||
"modePackQualityFirst": "Kvalita na prvním místě",
|
||||
"modePackOfflineFriendly": "Offline přátelské"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Jednoduchý chat",
|
||||
"streaming": "Streamování",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Udbydere",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Brug",
|
||||
"analytics": "Analytics",
|
||||
"costs": "Omkostninger",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Omkostninger",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Opkald pr. konto før skift",
|
||||
"modelAliases": "Model aliaser",
|
||||
"modelAliasesTitle": "Model Aliaser",
|
||||
"modelAliasesDesc": "Wildcard-mønstre til omlægning af modelnavne • Brug * og ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Tilføj Tilpasset Alias",
|
||||
"deprecatedModelId": "Forældet model-ID",
|
||||
"newModelId": "Nyt model-ID",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Oversætter",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simpel chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Anbieter",
|
||||
"combos": "Kombinationen",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Nutzung",
|
||||
"analytics": "Analytik",
|
||||
"costs": "Kosten",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Kosten",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Anrufe pro Konto vor dem Wechsel",
|
||||
"modelAliases": "Modell-Aliase",
|
||||
"modelAliasesTitle": "Modell-Aliase",
|
||||
"modelAliasesDesc": "Platzhaltermuster zum Neuzuordnen von Modellnamen • Verwenden Sie * und ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Benutzerdefinierten Alias hinzufügen",
|
||||
"deprecatedModelId": "Veraltete Modell-ID",
|
||||
"newModelId": "Neue Modell-ID",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Übersetzer",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Einfacher Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Providers",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Usage",
|
||||
"analytics": "Analytics",
|
||||
"costs": "Costs",
|
||||
@@ -934,6 +933,12 @@
|
||||
"warningRoundRobinSingleModel": "Round-robin is most useful with at least 2 models.",
|
||||
"warningCostOptimizedPartialPricing": "Only {priced} of {total} models have pricing. Routing may be partially cost-aware.",
|
||||
"warningCostOptimizedNoPricing": "No pricing data found for this combo. Cost-optimized may route unexpectedly.",
|
||||
"filterAll": "All",
|
||||
"filterIntelligent": "Intelligent",
|
||||
"filterDeterministic": "Deterministic",
|
||||
"filterEmptyTitle": "No combos match this strategy filter.",
|
||||
"filterEmptyIntelligentDescription": "Create an auto or LKGP combo to populate the intelligent routing dashboard.",
|
||||
"filterEmptyDeterministicDescription": "Only auto and LKGP combos exist right now. Switch back to All or create a deterministic combo.",
|
||||
"readinessTitle": "Ready to save?",
|
||||
"readinessDescription": "Review the checklist before creating or updating this combo.",
|
||||
"readinessCheckName": "Combo name is valid",
|
||||
@@ -951,6 +956,44 @@
|
||||
"applyRecommendations": "Apply recommendations",
|
||||
"recommendationsUpdated": "Recommendations updated for {strategy}.",
|
||||
"recommendationsApplied": "Recommendations applied to this combo.",
|
||||
"intelligentPanelTitle": "Intelligent Routing Dashboard",
|
||||
"intelligentPanelDesc": "Real-time scoring and health status for this auto-routing combo.",
|
||||
"statusOverview": "Status Overview",
|
||||
"normalOperation": "Normal Operation",
|
||||
"allProvidersHealthy": "Providers are reporting healthy routing conditions.",
|
||||
"incidentMode": "Incident Mode",
|
||||
"highCircuitBreakerRate": "High circuit breaker trip rate detected.",
|
||||
"activeModePack": "Active Mode Pack",
|
||||
"modePackUpdated": "Mode pack updated to {pack}.",
|
||||
"modePackHint": "Switch presets to bias the routing engine without rebuilding the combo.",
|
||||
"providerScores": "Provider Scores",
|
||||
"allProvidersEvaluated": "No candidate pool configured. All active providers are evaluated at runtime.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"excludedProvidersHint": "Providers with an OPEN circuit breaker are temporarily excluded from routing.",
|
||||
"noExcludedProviders": "No providers are currently excluded.",
|
||||
"cooldownMinutes": "Cooldown: {minutes}m",
|
||||
"builderIntelligentTitle": "Intelligent Routing Configuration",
|
||||
"builderIntelligentDesc": "Configure the multi-factor scoring engine for this auto-routing combo.",
|
||||
"candidatePoolLabel": "Candidate Pool",
|
||||
"candidatePoolHint": "Select which providers this engine should evaluate. Leave empty to use all active providers.",
|
||||
"candidatePoolEmpty": "No active providers available yet.",
|
||||
"candidatePoolAllProviders": "All providers",
|
||||
"modePackLabel": "Mode Pack",
|
||||
"routerStrategyLabel": "Router Strategy",
|
||||
"strategyRules": "Rules (6-Factor Scoring)",
|
||||
"explorationRateLabel": "Exploration Rate",
|
||||
"explorationRateHint": "{percent}% of requests can explore non-optimal providers.",
|
||||
"budgetCapLabel": "Budget Cap (USD / request)",
|
||||
"budgetCapPlaceholder": "No limit",
|
||||
"advancedWeightsTitle": "Advanced: Scoring Weights",
|
||||
"weightQuota": "Quota",
|
||||
"weightHealth": "Health",
|
||||
"weightCostInv": "Cost",
|
||||
"weightLatencyInv": "Latency",
|
||||
"weightTaskFit": "Task Fit",
|
||||
"weightStability": "Stability",
|
||||
"weightTierPriority": "Tier",
|
||||
"reviewIntelligentTitle": "Intelligent Routing Config",
|
||||
"strategyRecommendations": {
|
||||
"priority": {
|
||||
"title": "Fail-safe baseline",
|
||||
@@ -1004,10 +1047,22 @@
|
||||
},
|
||||
"templateFreeStack": "Free Stack ($0)",
|
||||
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), Qoder (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding.",
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"auto": "Intelligent Auto",
|
||||
"autoDesc": "Self-healing smart routing pool with multi-factor scoring",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Prioritizes the last provider that successfully completed a request",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Costs",
|
||||
@@ -1779,9 +1834,6 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Health Check (min)",
|
||||
"healthCheckHint": "Proactive token refresh interval. 0 = disabled.",
|
||||
"filterModels": "Filter models...",
|
||||
"showModel": "Show model",
|
||||
"hideModel": "Hide model",
|
||||
"selectAllModels": "Select all",
|
||||
"deselectAllModels": "Deselect all",
|
||||
"modelsActiveCount": "{active}/{total} active",
|
||||
@@ -2061,7 +2113,7 @@
|
||||
"stickyLimitDesc": "Calls per account before switching",
|
||||
"modelAliases": "Model Aliases",
|
||||
"modelAliasesTitle": "Model Aliases",
|
||||
"modelAliasesDesc": "Wildcard patterns to remap model names • Use * and ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Add Custom Alias",
|
||||
"deprecatedModelId": "Deprecated model ID",
|
||||
"newModelId": "New model ID",
|
||||
@@ -2314,7 +2366,28 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Translator",
|
||||
@@ -3046,29 +3119,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Panel de control",
|
||||
"providers": "Proveedores",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Uso",
|
||||
"analytics": "Analítica",
|
||||
"costs": "Costos",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Costos",
|
||||
@@ -1948,7 +1959,7 @@
|
||||
"stickyLimitDesc": "Llamadas por cuenta antes de cambiar",
|
||||
"modelAliases": "Alias de modelo",
|
||||
"modelAliasesTitle": "Aliases de Modelo",
|
||||
"modelAliasesDesc": "Patrones comodín para reasignar nombres de modelos • Utilice * y ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Agregar Alias Personalizado",
|
||||
"deprecatedModelId": "ID del modelo obsoleto",
|
||||
"newModelId": "Nuevo ID del modelo",
|
||||
@@ -2237,7 +2248,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Traductor",
|
||||
@@ -2965,29 +2999,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Kojelauta",
|
||||
"providers": "Palveluntarjoajat",
|
||||
"combos": "Yhdistelmät",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Käyttö",
|
||||
"analytics": "Analytics",
|
||||
"costs": "Kustannukset",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Kustannukset",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Puhelut tilikohtaisesti ennen vaihtamista",
|
||||
"modelAliases": "Mallin aliakset",
|
||||
"modelAliasesTitle": "Mallialias",
|
||||
"modelAliasesDesc": "Jokerimerkkikuviot mallien nimien yhdistämiseen • Käytä * ja ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Lisää Mukautettu Alias",
|
||||
"deprecatedModelId": "Vanhentunut malli-ID",
|
||||
"newModelId": "Uusi malli-ID",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Kääntäjä",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Yksinkertainen chat",
|
||||
"streaming": "Suoratoisto",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Tableau de bord",
|
||||
"providers": "Fournisseurs",
|
||||
"combos": "Combinaisons",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Utilisation",
|
||||
"analytics": "Analyse",
|
||||
"costs": "Coûts",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Coûts",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Appels par compte avant de changer",
|
||||
"modelAliases": "Alias de modèle",
|
||||
"modelAliasesTitle": "Alias de Modèles",
|
||||
"modelAliasesDesc": "Modèles génériques pour remapper les noms de modèles • Utilisez * et ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Ajouter un Alias Personnalisé",
|
||||
"deprecatedModelId": "ID du modèle obsolète",
|
||||
"newModelId": "Nouvel ID de modèle",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Traducteur",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "לוח מחוונים",
|
||||
"providers": "ספקים",
|
||||
"combos": "שילובים",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "שימוש",
|
||||
"analytics": "אנליטיקס",
|
||||
"costs": "עלויות",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "עלויות",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "שיחות לכל חשבון לפני המעבר",
|
||||
"modelAliases": "כינויי דגם",
|
||||
"modelAliasesTitle": "כינויי מודלים",
|
||||
"modelAliasesDesc": "תבניות תווים כלליים למיפוי מחדש של שמות מודלים • השתמש ב-* וב-?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "הוסף כינוי מותאם אישית",
|
||||
"deprecatedModelId": "מזהה מודל מיושן",
|
||||
"newModelId": "מזהה מודל חדש",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "מתרגם",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "צ'אט פשוט",
|
||||
"streaming": "סטרימינג",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "डैशबोर्ड",
|
||||
"providers": "प्रदाता",
|
||||
"combos": "संयोजन",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "उपयोग",
|
||||
"analytics": "विश्लेषिकी",
|
||||
"costs": "लागत",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "लागत",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "स्विच करने से पहले प्रति खाता कॉल",
|
||||
"modelAliases": "मॉडल उपनाम",
|
||||
"modelAliasesTitle": "Model Alias",
|
||||
"modelAliasesDesc": "मॉडल नामों को रीमैप करने के लिए वाइल्डकार्ड पैटर्न • * और ? का उपयोग करें",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Kustom Alias Tambahkan",
|
||||
"deprecatedModelId": "ID model yang tidak digunakan lagi",
|
||||
"newModelId": "ID model baru",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "अनुवादक",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Irányítópult",
|
||||
"providers": "Szolgáltatók",
|
||||
"combos": "Kombók",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Használat",
|
||||
"analytics": "Analytics",
|
||||
"costs": "Költségek",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Költségek",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Hívások fiókonként váltás előtt",
|
||||
"modelAliases": "Modell álnevek",
|
||||
"modelAliasesTitle": "Modell aliasok",
|
||||
"modelAliasesDesc": "Helyettesítő karakter minták a modellnevek újratervezéséhez • Használja a * és a ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Egyéni Alias Hozzáadása",
|
||||
"deprecatedModelId": "Elavult modell ID",
|
||||
"newModelId": "Új modell ID",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Fordító",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Egyszerű csevegés",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Dasbor",
|
||||
"providers": "Penyedia",
|
||||
"combos": "kombo",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Penggunaan",
|
||||
"analytics": "Analisis",
|
||||
"costs": "Biaya",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Biaya",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Panggilan per akun sebelum beralih",
|
||||
"modelAliases": "Alias Model",
|
||||
"modelAliasesTitle": "Alias Model",
|
||||
"modelAliasesDesc": "Pola karakter pengganti untuk memetakan ulang nama model • Gunakan * dan ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Tambah Alias Kustom",
|
||||
"deprecatedModelId": "ID model yang sudah usang",
|
||||
"newModelId": "ID model baru",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Penerjemah",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Pannello di controllo",
|
||||
"providers": "Fornitori",
|
||||
"combos": "Combinazioni",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Utilizzo",
|
||||
"analytics": "Analitica",
|
||||
"costs": "Costi",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Costi",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Chiamate per account prima del cambio",
|
||||
"modelAliases": "Alias del modello",
|
||||
"modelAliasesTitle": "Alias dei Modelli",
|
||||
"modelAliasesDesc": "Modelli di caratteri jolly per rimappare i nomi dei modelli • Utilizzare * e ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Aggiungi Alias Personalizzato",
|
||||
"deprecatedModelId": "ID modello deprecato",
|
||||
"newModelId": "Nuovo ID modello",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Traduttore",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "ダッシュボード",
|
||||
"providers": "プロバイダー",
|
||||
"combos": "コンボ",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "使用法",
|
||||
"analytics": "分析",
|
||||
"costs": "コスト",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "コスト",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "切り替える前のアカウントごとの通話数",
|
||||
"modelAliases": "モデルのエイリアス",
|
||||
"modelAliasesTitle": "モデルエイリアス",
|
||||
"modelAliasesDesc": "モデル名を再マッピングするワイルドカード パターン • * と ? を使用します。",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "カスタムエイリアスを追加",
|
||||
"deprecatedModelId": "非推奨モデルID",
|
||||
"newModelId": "新しいモデルID",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "翻訳者",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "대시보드",
|
||||
"providers": "공급자",
|
||||
"combos": "콤보",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "사용법",
|
||||
"analytics": "분석",
|
||||
"costs": "비용",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "비용",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "전환 전 계정당 통화",
|
||||
"modelAliases": "모델 별칭",
|
||||
"modelAliasesTitle": "모델 별칭",
|
||||
"modelAliasesDesc": "모델 이름을 다시 매핑하는 와일드카드 패턴 • * 및 ?를 사용합니다.",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "사용자 지정 별칭 추가",
|
||||
"deprecatedModelId": "사용 중단된 모델 ID",
|
||||
"newModelId": "새 모델 ID",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "번역기",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Papan pemuka",
|
||||
"providers": "Pembekal",
|
||||
"combos": "Kombo",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Penggunaan",
|
||||
"analytics": "Analitis",
|
||||
"costs": "Kos",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Kos",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Panggilan setiap akaun sebelum bertukar",
|
||||
"modelAliases": "Alias Model",
|
||||
"modelAliasesTitle": "Alias Model",
|
||||
"modelAliasesDesc": "Corak kad liar untuk memetakan semula nama model • Gunakan * dan ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Tambah Alias Tersuai",
|
||||
"deprecatedModelId": "ID model yang ditamatkan",
|
||||
"newModelId": "ID model baharu",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Penterjemah",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Sembang Mudah",
|
||||
"streaming": "Penstriman",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Aanbieders",
|
||||
"combos": "Combo's",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Gebruik",
|
||||
"analytics": "Analyses",
|
||||
"costs": "Kosten",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Kosten",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Gesprekken per account voordat u overstapt",
|
||||
"modelAliases": "Modelaliassen",
|
||||
"modelAliasesTitle": "Model Aliases",
|
||||
"modelAliasesDesc": "Jokertekenpatronen om modelnamen opnieuw toe te wijzen • Gebruik * en ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Aangepast Alias Toevoegen",
|
||||
"deprecatedModelId": "Verouderd model-ID",
|
||||
"newModelId": "Nieuw model-ID",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Vertaler",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Eenvoudig chatten",
|
||||
"streaming": "Streamen",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Dashbord",
|
||||
"providers": "Leverandører",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Bruk",
|
||||
"analytics": "Analytics",
|
||||
"costs": "Kostnader",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Kostnader",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Anrop per konto før bytte",
|
||||
"modelAliases": "Modellaliaser",
|
||||
"modelAliasesTitle": "Modellaliaser",
|
||||
"modelAliasesDesc": "Jokertegnmønstre for å tilordne modellnavn på nytt • Bruk * og ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Legg til Tilpasset Alias",
|
||||
"deprecatedModelId": "Utdatert modell-ID",
|
||||
"newModelId": "Ny modell-ID",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Oversetter",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Enkel chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Mga provider",
|
||||
"combos": "Mga combo",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Paggamit",
|
||||
"analytics": "Analytics",
|
||||
"costs": "Mga gastos",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Mga gastos",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Mga tawag sa bawat account bago lumipat",
|
||||
"modelAliases": "Mga Alyas ng Modelo",
|
||||
"modelAliasesTitle": "Mga Alias ng Model",
|
||||
"modelAliasesDesc": "Mga pattern ng wildcard para i-remap ang mga pangalan ng modelo • Gamitin ang * at ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Magdagdag ng Custom na Alias",
|
||||
"deprecatedModelId": "Deprecated na Model ID",
|
||||
"newModelId": "Bagong Model ID",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Tagasalin",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simpleng Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Pulpit nawigacyjny",
|
||||
"providers": "Dostawcy",
|
||||
"combos": "Kombinacje",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Użycie",
|
||||
"analytics": "Analityka",
|
||||
"costs": "Koszty",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Koszty",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Połączenia na konto przed zmianą",
|
||||
"modelAliases": "Aliasy modeli",
|
||||
"modelAliasesTitle": "Aliasy modeli",
|
||||
"modelAliasesDesc": "Wzorce wieloznaczne do zmiany nazw modeli • Użyj * i ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Dodaj niestandardowy alias",
|
||||
"deprecatedModelId": "Przestarzały ID modelu",
|
||||
"newModelId": "Nowy ID modelu",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Tłumacz",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Painel",
|
||||
"providers": "Provedores",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Uso",
|
||||
"analytics": "Análises",
|
||||
"costs": "Custos",
|
||||
@@ -1007,7 +1006,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Pool de roteamento inteligente (Otimizado)",
|
||||
"lkgp": "Modo LKGP",
|
||||
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)"
|
||||
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Custos",
|
||||
@@ -1632,7 +1643,7 @@
|
||||
"adding": "Adicionando...",
|
||||
"importingModelsTitle": "Importando Modelos",
|
||||
"copyModel": "Copiar modelo",
|
||||
"filterModels": "Filtrar modelos…",
|
||||
"filterModels": "Filtrar modelos...",
|
||||
"modelsActive": "ativos",
|
||||
"showModel": "Mostrar modelo",
|
||||
"hideModel": "Ocultar modelo",
|
||||
@@ -1770,9 +1781,6 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Health Check (min)",
|
||||
"healthCheckHint": "Intervalo proativo de renovação de token. 0 = desativado.",
|
||||
"filterModels": "Filtrar modelos...",
|
||||
"showModel": "Mostrar modelo",
|
||||
"hideModel": "Ocultar modelo",
|
||||
"selectAllModels": "Selecionar todos",
|
||||
"deselectAllModels": "Desmarcar todos",
|
||||
"modelsActiveCount": "{active}/{total} ativos",
|
||||
@@ -2010,7 +2018,7 @@
|
||||
"stickyLimitDesc": "Chamadas por conta antes de trocar",
|
||||
"modelAliases": "Aliases de Modelo",
|
||||
"modelAliasesTitle": "Aliases de Modelo",
|
||||
"modelAliasesDesc": "Padrões coringa para remapear nomes de modelos • Use * e ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Adicionar Alias Personalizado",
|
||||
"deprecatedModelId": "ID do modelo depreciado",
|
||||
"newModelId": "Novo ID do modelo",
|
||||
@@ -2305,7 +2313,28 @@
|
||||
"clearLkgpCache": "Clear LKGP Cache",
|
||||
"lkgpCacheCleared": "LKGP cache cleared successfully",
|
||||
"lkgpCacheClearFailed": "Failed to clear LKGP cache",
|
||||
"maintenance": "Maintenance"
|
||||
"maintenance": "Maintenance",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Tradutor",
|
||||
@@ -3033,29 +3062,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Bate-papo simples",
|
||||
"streaming": "Transmissão",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Painel",
|
||||
"providers": "Provedores",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Uso",
|
||||
"analytics": "Análise",
|
||||
"costs": "Custos",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Pool de roteamento inteligente (Otimizado)",
|
||||
"lkgp": "Modo LKGP",
|
||||
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)"
|
||||
"lkgpDesc": "Último Provedor Bom Conhecido (Resiliência previsível)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Custos",
|
||||
@@ -1623,7 +1634,7 @@
|
||||
"adding": "Adicionando...",
|
||||
"importingModelsTitle": "Importando Modelos",
|
||||
"copyModel": "Copiar modelo",
|
||||
"filterModels": "Filtrar modelos…",
|
||||
"filterModels": "Filtrar modelos...",
|
||||
"modelsActive": "ativos",
|
||||
"showModel": "Mostrar modelo",
|
||||
"hideModel": "Ocultar modelo",
|
||||
@@ -1761,9 +1772,6 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Verificação de integridade (min)",
|
||||
"healthCheckHint": "Intervalo de atualização de token proativo. 0 = desabilitado.",
|
||||
"filterModels": "Filtrar modelos...",
|
||||
"showModel": "Mostrar modelo",
|
||||
"hideModel": "Ocultar modelo",
|
||||
"selectAllModels": "Selecionar todos",
|
||||
"deselectAllModels": "Desmarcar todos",
|
||||
"modelsActiveCount": "{active}/{total} ativos",
|
||||
@@ -2001,7 +2009,7 @@
|
||||
"stickyLimitDesc": "Chamadas por conta antes de mudar",
|
||||
"modelAliases": "Aliases de modelo",
|
||||
"modelAliasesTitle": "Aliases de Modelo",
|
||||
"modelAliasesDesc": "Padrões curinga para remapear nomes de modelos • Use * e ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Adicionar Alias Personalizado",
|
||||
"deprecatedModelId": "ID do modelo depreciado",
|
||||
"newModelId": "Novo ID do modelo",
|
||||
@@ -2290,7 +2298,30 @@
|
||||
"clearLkgpCache": "Clear LKGP Cache",
|
||||
"lkgpCacheCleared": "LKGP cache cleared successfully",
|
||||
"lkgpCacheClearFailed": "Failed to clear LKGP cache",
|
||||
"maintenance": "Maintenance"
|
||||
"maintenance": "Maintenance",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Tradutor",
|
||||
@@ -3018,29 +3049,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Simple Chat",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Tabloul de bord",
|
||||
"providers": "Furnizorii",
|
||||
"combos": "Combo-uri",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Utilizare",
|
||||
"analytics": "Analytics",
|
||||
"costs": "Costuri",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Costuri",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Apeluri pe cont înainte de a comuta",
|
||||
"modelAliases": "Aliasuri de model",
|
||||
"modelAliasesTitle": "Aliasuri de model",
|
||||
"modelAliasesDesc": "Modele wildcard pentru a remapa numele modelelor • Utilizați * și ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Adaugă Alias Personalizat",
|
||||
"deprecatedModelId": "ID model depreciat",
|
||||
"newModelId": "ID model nou",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Traducător",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Chat simplu",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Панель управления",
|
||||
"providers": "Провайдеры",
|
||||
"combos": "Комбо",
|
||||
"autoCombo": "Автокомбо",
|
||||
"usage": "Использование",
|
||||
"analytics": "Аналитика",
|
||||
"costs": "Затраты",
|
||||
@@ -1022,7 +1021,19 @@
|
||||
"auto": "Авто",
|
||||
"autoDesc": "Автоматическая стратегия - система сама выбирает подходящее поведение.",
|
||||
"lkgp": "LKGP",
|
||||
"lkgpDesc": "Маршрутизация по последней известной хорошей политике"
|
||||
"lkgpDesc": "Маршрутизация по последней известной хорошей политике",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Затраты",
|
||||
@@ -1986,7 +1997,7 @@
|
||||
"stickyLimitDesc": "Звонки на аккаунт до переключения",
|
||||
"modelAliases": "Псевдонимы моделей",
|
||||
"modelAliasesTitle": "Псевдонимы моделей",
|
||||
"modelAliasesDesc": "Шаблоны подстановочных знаков для переназначения названий моделей • Используйте * и ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Добавить пользовательский псевдоним",
|
||||
"deprecatedModelId": "Устаревший ID модели",
|
||||
"newModelId": "Новый ID модели",
|
||||
@@ -2261,7 +2272,30 @@
|
||||
"skillsComingSoon": "Маркетплейс навыков скоро появится.",
|
||||
"memorySkillsTitle": "Память и навыки",
|
||||
"memorySkillsDesc": "Постоянный контекст и возможности",
|
||||
"days": "Дни"
|
||||
"days": "Дни",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Переводчик",
|
||||
@@ -2989,29 +3023,6 @@
|
||||
"setupGuideCommandMissingTitle": "Исправить 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Убедитесь, что команда CLI есть в PATH, откройте новый терминал и снова нажмите «Обновить»."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Движок автокомбо",
|
||||
"statusNormal": "Нормальный",
|
||||
"statusIncident": "Режим инцидента",
|
||||
"modePack": "Пакет режимов",
|
||||
"providerScores": "Оценки провайдеров",
|
||||
"noAutoCombo": "Автокомбо не настроено.",
|
||||
"excludedProviders": "Исключённые провайдеры",
|
||||
"noExclusions": "Сейчас нет исключённых провайдеров.",
|
||||
"factorQuota": "Квота",
|
||||
"factorHealth": "Состояние",
|
||||
"factorCost": "Стоимость",
|
||||
"factorLatency": "Задержка",
|
||||
"factorTaskFit": "Соответствие задаче",
|
||||
"factorStability": "Стабильность",
|
||||
"factorTierPriority": "Приоритет тарифа",
|
||||
"factorTierPriorityDesc": "Предпочитает аккаунты с более высоким тарифом (Ultra/Pro выше Free)",
|
||||
"scoreFactorBreakdown": "Факторы оценки",
|
||||
"modePackShipFast": "Быстрое выполнение",
|
||||
"modePackCostSaver": "Экономный режим",
|
||||
"modePackQualityFirst": "Сначала качество",
|
||||
"modePackOfflineFriendly": "Подходит для офлайна"
|
||||
},
|
||||
"memory": {
|
||||
"title": "Память",
|
||||
"description": "Постоянная кросс-сессионная память для диалогов",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Poskytovatelia",
|
||||
"combos": "kombá",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Použitie",
|
||||
"analytics": "Analytics",
|
||||
"costs": "náklady",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "náklady",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Hovory na účet pred prepnutím",
|
||||
"modelAliases": "Aliasy modelov",
|
||||
"modelAliasesTitle": "Aliasy modelov",
|
||||
"modelAliasesDesc": "Vzory zástupných znakov na premapovanie názvov modelov • Použite * a ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Pridať vlastný alias",
|
||||
"deprecatedModelId": "Zastaraný ID modelu",
|
||||
"newModelId": "Nový ID modelu",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Prekladateľ",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Jednoduchý chat",
|
||||
"streaming": "Streamovanie",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Instrumentpanel",
|
||||
"providers": "Leverantörer",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Användning",
|
||||
"analytics": "Analytics",
|
||||
"costs": "Kostnader",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Kostnader",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Samtal per konto innan byte",
|
||||
"modelAliases": "Modellalias",
|
||||
"modelAliasesTitle": "Modellalias",
|
||||
"modelAliasesDesc": "Jokerteckenmönster för att mappa om modellnamn • Använd * och ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Lägg Till Anpassat Alias",
|
||||
"deprecatedModelId": "Föråldrat modell-ID",
|
||||
"newModelId": "Nytt modell-ID",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Översättare",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Enkel chatt",
|
||||
"streaming": "Streaming",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "แดชบอร์ด",
|
||||
"providers": "ผู้ให้บริการ",
|
||||
"combos": "คอมโบ",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "การใช้งาน",
|
||||
"analytics": "การวิเคราะห์",
|
||||
"costs": "ค่าใช้จ่าย",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "ค่าใช้จ่าย",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "โทรต่อบัญชีก่อนที่จะเปลี่ยน",
|
||||
"modelAliases": "นามแฝงของโมเดล",
|
||||
"modelAliasesTitle": "นามแฝงโมเดล",
|
||||
"modelAliasesDesc": "รูปแบบไวด์การ์ดเพื่อทำการแมปชื่อโมเดลใหม่ • ใช้ * และ ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "เพิ่มนามแฝงที่กำหนดเอง",
|
||||
"deprecatedModelId": "ID โมเดลที่เลิกใช้",
|
||||
"newModelId": "ID โมเดลใหม่",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "นักแปล",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "แชทง่ายๆ",
|
||||
"streaming": "สตรีมมิ่ง",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Kontrol Paneli",
|
||||
"providers": "Sağlayıcılar",
|
||||
"combos": "Kombinasyonlar",
|
||||
"autoCombo": "Otomatik Kombo",
|
||||
"usage": "Kullanım",
|
||||
"analytics": "Analizler",
|
||||
"costs": "Maliyetler",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Maliyetler",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Geçişten önce hesap başına çağrı sayısı",
|
||||
"modelAliases": "Model Takma Adları",
|
||||
"modelAliasesTitle": "Model Takma Adları",
|
||||
"modelAliasesDesc": "Model adlarını yeniden eşlemek için joker karakter desenleri • * ve ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Özel Takma Ad Ekle",
|
||||
"deprecatedModelId": "Kullanımdan kaldırılan model kimliği",
|
||||
"newModelId": "Yeni model kimliği",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Çeviri",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "'Komut bulunamadı' sorununu düzeltin",
|
||||
"setupGuideCommandMissingDesc": "PATH'de CLI komutunun mevcut olduğundan emin olun, yeni bir terminal oturumu açın ve Yenile'yi yeniden çalıştırın."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Otomatik Kombo Motoru",
|
||||
"statusNormal": "Normal Mod",
|
||||
"statusIncident": "Olay Modu",
|
||||
"modePack": "Mod Paketi",
|
||||
"providerScores": "Sağlayıcı Puanları",
|
||||
"noAutoCombo": "Otomatik kombo yapılandırılmamış.",
|
||||
"excludedProviders": "Hariç Tutulan Sağlayıcılar",
|
||||
"noExclusions": "Şu anda hariç tutulan hiçbir sağlayıcı yok.",
|
||||
"factorQuota": "Kota",
|
||||
"factorHealth": "Sağlık",
|
||||
"factorCost": "Maliyet",
|
||||
"factorLatency": "Gecikme",
|
||||
"factorTaskFit": "Görev Uyumu",
|
||||
"factorStability": "Kararlılık",
|
||||
"factorTierPriority": "Seviye Önceliği",
|
||||
"factorTierPriorityDesc": "Daha yüksek kota kademelerine sahip hesapları tercih eder (Ücretsiz yerine Ultra/Pro)",
|
||||
"scoreFactorBreakdown": "Puanlama Faktörleri",
|
||||
"modePackShipFast": "Hızlı Teslim",
|
||||
"modePackCostSaver": "Maliyet Tasarrufu",
|
||||
"modePackQualityFirst": "Kalite Öncelikli",
|
||||
"modePackOfflineFriendly": "Çevrimdışı Dostu"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Basit Sohbet",
|
||||
"streaming": "Akış",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Приладова панель",
|
||||
"providers": "Провайдери",
|
||||
"combos": "Комбо",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Використання",
|
||||
"analytics": "Аналітика",
|
||||
"costs": "Витрати",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Витрати",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Дзвінки на обліковий запис перед переходом",
|
||||
"modelAliases": "Псевдоніми моделі",
|
||||
"modelAliasesTitle": "Псевдоніми моделей",
|
||||
"modelAliasesDesc": "Шаблони шаблонів узагальнення для зміни назв моделей • Використовуйте * та ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Додати власний псевдонім",
|
||||
"deprecatedModelId": "Застарілий ID моделі",
|
||||
"newModelId": "Новий ID моделі",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Перекладач",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Простий чат",
|
||||
"streaming": "Потокове передавання",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "Trang tổng quan",
|
||||
"providers": "Nhà cung cấp",
|
||||
"combos": "Combo",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Cách sử dụng",
|
||||
"analytics": "Phân tích",
|
||||
"costs": "Chi phí",
|
||||
@@ -998,7 +997,19 @@
|
||||
"auto": "Auto Combo",
|
||||
"autoDesc": "Self-healing smart routing pool (Performance optimized)",
|
||||
"lkgp": "LKGP Mode",
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)"
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "Chi phí",
|
||||
@@ -1943,7 +1954,7 @@
|
||||
"stickyLimitDesc": "Cuộc gọi trên mỗi tài khoản trước khi chuyển đổi",
|
||||
"modelAliases": "Bí danh mẫu",
|
||||
"modelAliasesTitle": "Bí danh mô hình",
|
||||
"modelAliasesDesc": "Các mẫu ký tự đại diện để ánh xạ lại tên mẫu máy • Sử dụng * và ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "Thêm Bí Danh Tùy Chỉnh",
|
||||
"deprecatedModelId": "ID mô hình ngừng sử dụng",
|
||||
"newModelId": "ID mô hình mới",
|
||||
@@ -2232,7 +2243,30 @@
|
||||
"lkgpDesc": "Last Known Good Provider (Predictable resilience)",
|
||||
"maintenance": "Maintenance",
|
||||
"purgeExpiredLogs": "Purge Expired Logs",
|
||||
"purgeLogsFailed": "Failed to purge logs"
|
||||
"purgeLogsFailed": "Failed to purge logs",
|
||||
"contextRelay": "Context Relay",
|
||||
"contextRelayDesc": "Hands off context between providers with automatic summarization",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "Người phiên dịch",
|
||||
@@ -2960,29 +2994,6 @@
|
||||
"setupGuideCommandMissingTitle": "Fix 'command not found'",
|
||||
"setupGuideCommandMissingDesc": "Ensure the CLI command exists in PATH, open a new terminal session, and rerun Refresh."
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "Auto-Combo Engine",
|
||||
"statusNormal": "Normal",
|
||||
"statusIncident": "Incident Mode",
|
||||
"modePack": "Mode Pack",
|
||||
"providerScores": "Provider Scores",
|
||||
"noAutoCombo": "No auto-combo configured.",
|
||||
"excludedProviders": "Excluded Providers",
|
||||
"noExclusions": "No providers currently excluded.",
|
||||
"factorQuota": "Quota",
|
||||
"factorHealth": "Health",
|
||||
"factorCost": "Cost",
|
||||
"factorLatency": "Latency",
|
||||
"factorTaskFit": "Task Fit",
|
||||
"factorStability": "Stability",
|
||||
"factorTierPriority": "Tier Priority",
|
||||
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
|
||||
"scoreFactorBreakdown": "Scoring Factors",
|
||||
"modePackShipFast": "Ship Fast",
|
||||
"modePackCostSaver": "Cost Saver",
|
||||
"modePackQualityFirst": "Quality First",
|
||||
"modePackOfflineFriendly": "Offline Friendly"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "Trò chuyện đơn giản",
|
||||
"streaming": "Truyền phát",
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"dashboard": "仪表板",
|
||||
"providers": "提供商",
|
||||
"combos": "组合",
|
||||
"autoCombo": "自动组合",
|
||||
"usage": "用量",
|
||||
"analytics": "分析",
|
||||
"costs": "成本",
|
||||
@@ -1007,7 +1006,19 @@
|
||||
"auto": "自动组合",
|
||||
"autoDesc": "自愈型智能路由池(性能优化)",
|
||||
"lkgp": "LKGP 模式",
|
||||
"lkgpDesc": "最后已知良好提供商(可预测的弹性)"
|
||||
"lkgpDesc": "最后已知良好提供商(可预测的弹性)",
|
||||
"wizardGuideTitle": "Getting Started with Combos",
|
||||
"wizardGuideDesc": "Create model combos to route AI traffic intelligently",
|
||||
"wizardGuideHint": "or click + Create Combo above",
|
||||
"createFirstCombo": "Create Your First Combo",
|
||||
"wizardStep1Title": "Name Your Combo",
|
||||
"wizardStep1Desc": "Give your combo a unique name to identify it in routing rules",
|
||||
"wizardStep2Title": "Add Models",
|
||||
"wizardStep2Desc": "Select AI models and arrange their fallback priority order",
|
||||
"wizardStep3Title": "Choose Strategy",
|
||||
"wizardStep3Desc": "Pick how requests are distributed across your models - 13 strategies available",
|
||||
"wizardStep4Title": "Review & Save",
|
||||
"wizardStep4Desc": "Review your configuration and activate the combo"
|
||||
},
|
||||
"costs": {
|
||||
"title": "成本",
|
||||
@@ -2001,7 +2012,7 @@
|
||||
"stickyLimitDesc": "切换前每个账户连续处理的请求次数",
|
||||
"modelAliases": "模型别名",
|
||||
"modelAliasesTitle": "模型别名",
|
||||
"modelAliasesDesc": "重新映射模型名称的通配符模式 • 使用 * 和 ?",
|
||||
"modelAliasesDesc": "Remap model names using exact matches or wildcard patterns.",
|
||||
"addCustomAlias": "添加自定义别名",
|
||||
"deprecatedModelId": "已弃用的模型 ID",
|
||||
"newModelId": "新模型 ID",
|
||||
@@ -2254,7 +2265,28 @@
|
||||
"lkgpDesc": "最后已知良好提供商(可预测的弹性)",
|
||||
"maintenance": "维护",
|
||||
"purgeExpiredLogs": "清理过期日志",
|
||||
"purgeLogsFailed": "清理日志失败"
|
||||
"purgeLogsFailed": "清理日志失败",
|
||||
"contextOpt": "Context Optimized",
|
||||
"contextOptDesc": "Routes based on context window requirements and conversation length",
|
||||
"priorityDesc": "Sequential fallback - tries provider 1 first, then provider 2, and so on",
|
||||
"weightedDesc": "Distributes traffic by percentage weights across providers",
|
||||
"modelRoutingTitle": "Model Routing Rules",
|
||||
"modelRoutingDesc": "Automatically route models to specific combos using glob patterns",
|
||||
"addRule": "Add Rule",
|
||||
"routeToCombo": "Route to Combo",
|
||||
"selectCombo": "Select combo...",
|
||||
"priorityHint": "Higher = checked first. Use 10+ for specific patterns.",
|
||||
"patternHint": "Use * for any chars, ? for single char. Case-insensitive.",
|
||||
"noRoutingRules": "No routing rules configured. Requests use the global combo by default.",
|
||||
"routingRuleHint": "Add a rule like claude-opus* -> frontier-combo to automatically route requests.",
|
||||
"deleteRoutingRule": "Delete this model routing rule?",
|
||||
"exactMatchMode": "Exact Match",
|
||||
"wildcardPatternMode": "Wildcard Pattern",
|
||||
"exactMatchModeDesc": "Use exact aliases for deprecated or renamed model IDs.",
|
||||
"wildcardPatternModeDesc": "Use wildcard aliases with * and ? when a family of models should map to one target.",
|
||||
"noExactAliasesConfigured": "No exact-match aliases configured.",
|
||||
"wildcardRulesTitle": "Wildcard Rules",
|
||||
"noWildcardAliasesConfigured": "No wildcard aliases configured."
|
||||
},
|
||||
"translator": {
|
||||
"title": "翻译器",
|
||||
@@ -2986,29 +3018,6 @@
|
||||
"setupGuideCommandMissingTitle": "修复“command not found”",
|
||||
"setupGuideCommandMissingDesc": "请确认 CLI 命令已存在于 PATH 中,重新打开一个终端会话后再次点击“刷新”。"
|
||||
},
|
||||
"autoCombo": {
|
||||
"title": "自动组合引擎",
|
||||
"statusNormal": "正常",
|
||||
"statusIncident": "事件模式",
|
||||
"modePack": "模式包",
|
||||
"providerScores": "提供商得分",
|
||||
"noAutoCombo": "尚未配置自动组合。",
|
||||
"excludedProviders": "已排除的提供商",
|
||||
"noExclusions": "当前没有被排除的提供商。",
|
||||
"factorQuota": "配额",
|
||||
"factorHealth": "健康度",
|
||||
"factorCost": "成本",
|
||||
"factorLatency": "延迟",
|
||||
"factorTaskFit": "任务匹配度",
|
||||
"factorStability": "稳定性",
|
||||
"factorTierPriority": "套餐优先级",
|
||||
"factorTierPriorityDesc": "优先选择配额等级更高的账户(Ultra/Pro 高于 Free)",
|
||||
"scoreFactorBreakdown": "评分因子",
|
||||
"modePackShipFast": "快速交付",
|
||||
"modePackCostSaver": "节省成本",
|
||||
"modePackQualityFirst": "质量优先",
|
||||
"modePackOfflineFriendly": "离线友好"
|
||||
},
|
||||
"templateNames": {
|
||||
"simple-chat": "简单对话",
|
||||
"streaming": "流式响应",
|
||||
|
||||
186
src/lib/combos/builderDraft.ts
Normal file
186
src/lib/combos/builderDraft.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { ComboModelStep } from "@/lib/combos/steps";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export const COMBO_BUILDER_AUTO_CONNECTION = "__auto__";
|
||||
export const COMBO_BUILDER_STAGES = [
|
||||
"basics",
|
||||
"steps",
|
||||
"strategy",
|
||||
"intelligent",
|
||||
"review",
|
||||
] as const;
|
||||
|
||||
export type ComboBuilderStage = (typeof COMBO_BUILDER_STAGES)[number];
|
||||
export type ComboBuilderStageOptions = {
|
||||
strategy?: string | null;
|
||||
};
|
||||
|
||||
export function isIntelligentBuilderStrategy(strategy: unknown): boolean {
|
||||
return strategy === "auto" || strategy === "lkgp";
|
||||
}
|
||||
|
||||
export function getComboBuilderStages(options: ComboBuilderStageOptions = {}): ComboBuilderStage[] {
|
||||
if (isIntelligentBuilderStrategy(options.strategy)) {
|
||||
return [...COMBO_BUILDER_STAGES];
|
||||
}
|
||||
|
||||
return COMBO_BUILDER_STAGES.filter((stage) => stage !== "intelligent");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toTrimmedString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function parseQualifiedModel(
|
||||
value: unknown
|
||||
): { providerId: string; modelId: string } | null {
|
||||
const qualifiedModel = toTrimmedString(value);
|
||||
if (!qualifiedModel) return null;
|
||||
const firstSlashIndex = qualifiedModel.indexOf("/");
|
||||
if (firstSlashIndex <= 0 || firstSlashIndex >= qualifiedModel.length - 1) return null;
|
||||
return {
|
||||
providerId: qualifiedModel.slice(0, firstSlashIndex),
|
||||
modelId: qualifiedModel.slice(firstSlashIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function getComboDraftTarget(entry: unknown): string | null {
|
||||
if (typeof entry === "string") return toTrimmedString(entry);
|
||||
if (!isRecord(entry)) return null;
|
||||
if (entry.kind === "combo-ref") return toTrimmedString(entry.comboName);
|
||||
return toTrimmedString(entry.model);
|
||||
}
|
||||
|
||||
export function buildPrecisionComboModelStep({
|
||||
providerId,
|
||||
modelId,
|
||||
connectionId = null,
|
||||
connectionLabel,
|
||||
weight = 0,
|
||||
}: {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
connectionId?: string | null;
|
||||
connectionLabel?: string | null;
|
||||
weight?: number;
|
||||
}): ComboModelStep {
|
||||
const normalizedProviderId = toTrimmedString(providerId) || "provider";
|
||||
const normalizedModelId = toTrimmedString(modelId) || "model";
|
||||
const normalizedConnectionId = toTrimmedString(connectionId);
|
||||
const normalizedConnectionLabel = toTrimmedString(connectionLabel);
|
||||
|
||||
return {
|
||||
kind: "model",
|
||||
providerId: normalizedProviderId,
|
||||
model: `${normalizedProviderId}/${normalizedModelId}`,
|
||||
...(normalizedConnectionId ? { connectionId: normalizedConnectionId } : {}),
|
||||
...(normalizedConnectionLabel ? { label: normalizedConnectionLabel } : {}),
|
||||
weight: Number.isFinite(weight) ? Math.max(0, Math.min(100, Number(weight))) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getExactModelStepSignature(entry: unknown): string | null {
|
||||
if (!isRecord(entry) || entry.kind === "combo-ref") return null;
|
||||
const modelValue = toTrimmedString(entry.model);
|
||||
const parsed = parseQualifiedModel(modelValue);
|
||||
if (!parsed) return null;
|
||||
|
||||
const normalizedProviderId = toTrimmedString(entry.providerId) || parsed.providerId;
|
||||
const normalizedConnectionId =
|
||||
toTrimmedString(entry.connectionId) || COMBO_BUILDER_AUTO_CONNECTION;
|
||||
|
||||
return `model:${normalizedProviderId}:${parsed.modelId}:${normalizedConnectionId}`;
|
||||
}
|
||||
|
||||
export function hasExactModelStepDuplicate(entries: unknown[], candidate: unknown): boolean {
|
||||
const candidateSignature = getExactModelStepSignature(candidate);
|
||||
if (!candidateSignature) return false;
|
||||
|
||||
return entries.some((entry) => getExactModelStepSignature(entry) === candidateSignature);
|
||||
}
|
||||
|
||||
export function findNextSuggestedConnectionId(
|
||||
entries: unknown[],
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
connections: Array<{ id?: string | null }> = []
|
||||
): string {
|
||||
for (const connection of connections) {
|
||||
const connectionId = toTrimmedString(connection?.id);
|
||||
if (!connectionId) continue;
|
||||
|
||||
const step = buildPrecisionComboModelStep({
|
||||
providerId,
|
||||
modelId,
|
||||
connectionId,
|
||||
});
|
||||
if (!hasExactModelStepDuplicate(entries, step)) {
|
||||
return connectionId;
|
||||
}
|
||||
}
|
||||
|
||||
return COMBO_BUILDER_AUTO_CONNECTION;
|
||||
}
|
||||
|
||||
export function getComboBuilderStageChecks({
|
||||
name,
|
||||
nameError,
|
||||
modelsCount,
|
||||
hasInvalidWeightedTotal,
|
||||
hasCostOptimizedWithoutPricing,
|
||||
}: {
|
||||
name: string;
|
||||
nameError?: string | null;
|
||||
modelsCount: number;
|
||||
hasInvalidWeightedTotal?: boolean;
|
||||
hasCostOptimizedWithoutPricing?: boolean;
|
||||
}) {
|
||||
return {
|
||||
basics: Boolean(toTrimmedString(name)) && !toTrimmedString(nameError),
|
||||
steps: modelsCount > 0,
|
||||
strategy: !Boolean(hasInvalidWeightedTotal) && !Boolean(hasCostOptimizedWithoutPricing),
|
||||
review: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function canAccessComboBuilderStage(
|
||||
stage: ComboBuilderStage,
|
||||
checks: ReturnType<typeof getComboBuilderStageChecks>,
|
||||
options: ComboBuilderStageOptions = {}
|
||||
): boolean {
|
||||
const availableStages = getComboBuilderStages(options);
|
||||
if (!availableStages.includes(stage)) return false;
|
||||
if (stage === "basics") return true;
|
||||
if (stage === "steps") return checks.basics;
|
||||
if (stage === "strategy") return checks.basics && checks.steps;
|
||||
if (stage === "intelligent") return checks.basics && checks.steps && checks.strategy;
|
||||
if (stage === "review") return checks.basics && checks.steps;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getNextComboBuilderStage(
|
||||
stage: ComboBuilderStage,
|
||||
options: ComboBuilderStageOptions = {}
|
||||
): ComboBuilderStage {
|
||||
const stages = getComboBuilderStages(options);
|
||||
const stageIndex = stages.indexOf(stage);
|
||||
if (stageIndex === -1 || stageIndex >= stages.length - 1) {
|
||||
return "review";
|
||||
}
|
||||
return stages[stageIndex + 1];
|
||||
}
|
||||
|
||||
export function getPreviousComboBuilderStage(
|
||||
stage: ComboBuilderStage,
|
||||
options: ComboBuilderStageOptions = {}
|
||||
): ComboBuilderStage {
|
||||
const stages = getComboBuilderStages(options);
|
||||
const stageIndex = stages.indexOf(stage);
|
||||
if (stageIndex <= 0) return "basics";
|
||||
return stages[stageIndex - 1];
|
||||
}
|
||||
544
src/lib/combos/builderOptions.ts
Normal file
544
src/lib/combos/builderOptions.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
import {
|
||||
getAllCustomModels,
|
||||
getAllSyncedAvailableModels,
|
||||
getCombos,
|
||||
getModelIsHidden,
|
||||
getProviderConnections,
|
||||
getProviderNodes,
|
||||
} from "@/lib/localDb";
|
||||
import { getAccountDisplayName, getProviderDisplayName } from "@/lib/display/names";
|
||||
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
|
||||
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
|
||||
import { getSyncedCapabilities } from "@/lib/modelsDevSync";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
isAnthropicCompatibleProvider,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import type { RegistryModel } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type BuilderModelSource = "api-sync" | "system" | "custom" | "fallback";
|
||||
type BuilderConnectionStatus = "active" | "inactive" | "rate-limited" | "error";
|
||||
type ProviderVisual = { icon: string; color: string; source: "system" | "provider-node" };
|
||||
|
||||
type CustomModelLike = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
apiFormat?: string;
|
||||
supportedEndpoints?: string[];
|
||||
inputTokenLimit?: number;
|
||||
outputTokenLimit?: number;
|
||||
supportsThinking?: boolean;
|
||||
isHidden?: boolean;
|
||||
};
|
||||
|
||||
type SyncedModelLike = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
supportedEndpoints?: string[];
|
||||
inputTokenLimit?: number;
|
||||
outputTokenLimit?: number;
|
||||
description?: string;
|
||||
supportsThinking?: boolean;
|
||||
};
|
||||
|
||||
type ProviderConnectionLike = {
|
||||
id?: string;
|
||||
provider?: string;
|
||||
authType?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
priority?: number;
|
||||
isActive?: boolean;
|
||||
defaultModel?: string;
|
||||
rateLimitedUntil?: number | null;
|
||||
lastError?: string | null;
|
||||
lastTested?: string | null;
|
||||
updatedAt?: string | null;
|
||||
testStatus?: string | null;
|
||||
};
|
||||
|
||||
type ProviderNodeLike = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
export interface ComboBuilderModelOption {
|
||||
id: string;
|
||||
qualifiedModel: string;
|
||||
name: string;
|
||||
source: BuilderModelSource;
|
||||
sources: BuilderModelSource[];
|
||||
supportedEndpoints?: string[];
|
||||
apiFormat?: string;
|
||||
contextLength?: number;
|
||||
outputTokenLimit?: number;
|
||||
supportsThinking?: boolean;
|
||||
}
|
||||
|
||||
export interface ComboBuilderConnectionOption {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
status: BuilderConnectionStatus;
|
||||
priority: number;
|
||||
isActive: boolean;
|
||||
defaultModel?: string | null;
|
||||
rateLimitedUntil?: number | null;
|
||||
lastError?: string | null;
|
||||
lastTested?: string | null;
|
||||
}
|
||||
|
||||
export interface ComboBuilderProviderOption {
|
||||
providerId: string;
|
||||
providerType: string;
|
||||
displayName: string;
|
||||
alias: string;
|
||||
prefix?: string | null;
|
||||
icon: string;
|
||||
color: string;
|
||||
source: "system" | "provider-node";
|
||||
acceptsArbitraryModel: boolean;
|
||||
connectionCount: number;
|
||||
activeConnectionCount: number;
|
||||
modelCount: number;
|
||||
connections: ComboBuilderConnectionOption[];
|
||||
models: ComboBuilderModelOption[];
|
||||
}
|
||||
|
||||
export interface ComboBuilderComboRefOption {
|
||||
id: string;
|
||||
name: string;
|
||||
strategy: string;
|
||||
stepCount: number;
|
||||
version: number;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface ComboBuilderOptionsPayload {
|
||||
schemaVersion: number;
|
||||
generatedAt: string;
|
||||
providers: ComboBuilderProviderOption[];
|
||||
comboRefs: ComboBuilderComboRefOption[];
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const normalized = value
|
||||
.map((item) => toStringOrNull(item))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function isChatCapable(supportedEndpoints: string[] | undefined): boolean {
|
||||
if (!supportedEndpoints || supportedEndpoints.length === 0) return true;
|
||||
return supportedEndpoints.includes("chat");
|
||||
}
|
||||
|
||||
function getSourcePriority(source: BuilderModelSource): number {
|
||||
switch (source) {
|
||||
case "api-sync":
|
||||
return 0;
|
||||
case "system":
|
||||
return 1;
|
||||
case "custom":
|
||||
return 2;
|
||||
case "fallback":
|
||||
return 3;
|
||||
default:
|
||||
return 99;
|
||||
}
|
||||
}
|
||||
|
||||
function getCompatibleProviderVisual(providerNodeType: string | null): ProviderVisual {
|
||||
if (providerNodeType === "openai-compatible") {
|
||||
return { icon: "api", color: "#10A37F", source: "provider-node" };
|
||||
}
|
||||
if (providerNodeType === "anthropic-compatible") {
|
||||
return { icon: "api", color: "#D97757", source: "provider-node" };
|
||||
}
|
||||
if (providerNodeType === "anthropic-compatible-cc") {
|
||||
return { icon: "smart_toy", color: "#D97757", source: "provider-node" };
|
||||
}
|
||||
return { icon: "api", color: "#6B7280", source: "provider-node" };
|
||||
}
|
||||
|
||||
function getProviderVisual(
|
||||
providerId: string,
|
||||
providerNode: ProviderNodeLike | null
|
||||
): ProviderVisual & { alias: string; providerType: string } {
|
||||
const providerEntry = AI_PROVIDERS[providerId];
|
||||
if (providerEntry) {
|
||||
return {
|
||||
alias: providerEntry.alias || providerEntry.id,
|
||||
providerType: providerEntry.id,
|
||||
icon: providerEntry.icon || "hub",
|
||||
color: providerEntry.color || "#6B7280",
|
||||
source: "system",
|
||||
};
|
||||
}
|
||||
|
||||
const providerNodeType = toStringOrNull(providerNode?.type);
|
||||
const compatibleVisual = getCompatibleProviderVisual(providerNodeType);
|
||||
return {
|
||||
alias: toStringOrNull(providerNode?.prefix) || providerId,
|
||||
providerType: providerNodeType || providerId,
|
||||
...compatibleVisual,
|
||||
};
|
||||
}
|
||||
|
||||
function deriveConnectionStatus(connection: ProviderConnectionLike): BuilderConnectionStatus {
|
||||
if (connection.isActive === false) return "inactive";
|
||||
const rateLimitedUntil = toNumberOrNull(connection.rateLimitedUntil);
|
||||
if (typeof rateLimitedUntil === "number" && rateLimitedUntil > Date.now()) {
|
||||
return "rate-limited";
|
||||
}
|
||||
if (typeof connection.testStatus === "string" && /error|fail/i.test(connection.testStatus)) {
|
||||
return "error";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
function buildConnectionOption(
|
||||
connection: ProviderConnectionLike
|
||||
): ComboBuilderConnectionOption | null {
|
||||
const id = toStringOrNull(connection.id);
|
||||
if (!id) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
label: getAccountDisplayName(connection),
|
||||
type: toStringOrNull(connection.authType) || "unknown",
|
||||
status: deriveConnectionStatus(connection),
|
||||
priority: typeof connection.priority === "number" ? connection.priority : 0,
|
||||
isActive: connection.isActive !== false,
|
||||
defaultModel: toStringOrNull(connection.defaultModel),
|
||||
rateLimitedUntil: toNumberOrNull(connection.rateLimitedUntil),
|
||||
lastError: toStringOrNull(connection.lastError),
|
||||
lastTested: toStringOrNull(connection.lastTested),
|
||||
};
|
||||
}
|
||||
|
||||
function addModelOption(
|
||||
modelMap: Map<string, ComboBuilderModelOption>,
|
||||
providerId: string,
|
||||
input: {
|
||||
id: string | null;
|
||||
name?: string | null;
|
||||
source: BuilderModelSource;
|
||||
supportedEndpoints?: string[];
|
||||
apiFormat?: string | null;
|
||||
contextLength?: number | null;
|
||||
outputTokenLimit?: number | null;
|
||||
supportsThinking?: boolean;
|
||||
}
|
||||
) {
|
||||
const modelId = toStringOrNull(input.id);
|
||||
if (!modelId) return;
|
||||
if (getModelIsHidden(providerId, modelId)) return;
|
||||
if (!isChatCapable(input.supportedEndpoints)) return;
|
||||
|
||||
const nextSourcePriority = getSourcePriority(input.source);
|
||||
const existing = modelMap.get(modelId);
|
||||
if (!existing) {
|
||||
modelMap.set(modelId, {
|
||||
id: modelId,
|
||||
qualifiedModel: `${providerId}/${modelId}`,
|
||||
name: toStringOrNull(input.name) || modelId,
|
||||
source: input.source,
|
||||
sources: [input.source],
|
||||
...(input.supportedEndpoints && input.supportedEndpoints.length > 0
|
||||
? { supportedEndpoints: input.supportedEndpoints }
|
||||
: {}),
|
||||
...(toStringOrNull(input.apiFormat) ? { apiFormat: input.apiFormat || undefined } : {}),
|
||||
...(typeof input.contextLength === "number" ? { contextLength: input.contextLength } : {}),
|
||||
...(typeof input.outputTokenLimit === "number"
|
||||
? { outputTokenLimit: input.outputTokenLimit }
|
||||
: {}),
|
||||
...(typeof input.supportsThinking === "boolean"
|
||||
? { supportsThinking: input.supportsThinking }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPriority = getSourcePriority(existing.source);
|
||||
const mergedSources = new Set<BuilderModelSource>([...existing.sources, input.source]);
|
||||
|
||||
if (nextSourcePriority < existingPriority) {
|
||||
existing.source = input.source;
|
||||
}
|
||||
if (!existing.name || existing.name === existing.id) {
|
||||
existing.name = toStringOrNull(input.name) || existing.name;
|
||||
}
|
||||
if (!existing.supportedEndpoints && input.supportedEndpoints?.length) {
|
||||
existing.supportedEndpoints = input.supportedEndpoints;
|
||||
}
|
||||
if (!existing.apiFormat && toStringOrNull(input.apiFormat)) {
|
||||
existing.apiFormat = input.apiFormat || undefined;
|
||||
}
|
||||
if (existing.contextLength == null && typeof input.contextLength === "number") {
|
||||
existing.contextLength = input.contextLength;
|
||||
}
|
||||
if (existing.outputTokenLimit == null && typeof input.outputTokenLimit === "number") {
|
||||
existing.outputTokenLimit = input.outputTokenLimit;
|
||||
}
|
||||
if (existing.supportsThinking == null && typeof input.supportsThinking === "boolean") {
|
||||
existing.supportsThinking = input.supportsThinking;
|
||||
}
|
||||
existing.sources = Array.from(mergedSources).sort(
|
||||
(left, right) => getSourcePriority(left) - getSourcePriority(right)
|
||||
);
|
||||
}
|
||||
|
||||
function compareConnections(
|
||||
left: ComboBuilderConnectionOption,
|
||||
right: ComboBuilderConnectionOption
|
||||
): number {
|
||||
if (left.isActive !== right.isActive) return left.isActive ? -1 : 1;
|
||||
if (left.priority !== right.priority) return left.priority - right.priority;
|
||||
return left.label.localeCompare(right.label, undefined, { sensitivity: "base" });
|
||||
}
|
||||
|
||||
function compareModels(left: ComboBuilderModelOption, right: ComboBuilderModelOption): number {
|
||||
const sourceDelta = getSourcePriority(left.source) - getSourcePriority(right.source);
|
||||
if (sourceDelta !== 0) return sourceDelta;
|
||||
const nameDelta = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
|
||||
if (nameDelta !== 0) return nameDelta;
|
||||
return left.id.localeCompare(right.id, undefined, { sensitivity: "base" });
|
||||
}
|
||||
|
||||
function compareProviders(
|
||||
left: ComboBuilderProviderOption,
|
||||
right: ComboBuilderProviderOption
|
||||
): number {
|
||||
if (left.activeConnectionCount !== right.activeConnectionCount) {
|
||||
return right.activeConnectionCount - left.activeConnectionCount;
|
||||
}
|
||||
return left.displayName.localeCompare(right.displayName, undefined, { sensitivity: "base" });
|
||||
}
|
||||
|
||||
function normalizeCustomModels(raw: unknown): CustomModelLike[] {
|
||||
return Array.isArray(raw)
|
||||
? raw.filter(
|
||||
(model): model is CustomModelLike =>
|
||||
Boolean(model) && typeof model === "object" && !Array.isArray(model)
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeSyncedModels(raw: unknown): SyncedModelLike[] {
|
||||
return Array.isArray(raw)
|
||||
? raw.filter(
|
||||
(model): model is SyncedModelLike =>
|
||||
Boolean(model) && typeof model === "object" && !Array.isArray(model)
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPayload> {
|
||||
getSyncedCapabilities();
|
||||
const [connections, providerNodes, customModelsMap, syncedModelsMap, combos] = await Promise.all([
|
||||
getProviderConnections(),
|
||||
getProviderNodes(),
|
||||
getAllCustomModels(),
|
||||
getAllSyncedAvailableModels(),
|
||||
getCombos(),
|
||||
]);
|
||||
|
||||
const providerNodeMap = new Map<string, ProviderNodeLike>();
|
||||
for (const providerNode of providerNodes as ProviderNodeLike[]) {
|
||||
const providerId = toStringOrNull(providerNode.id);
|
||||
if (!providerId) continue;
|
||||
providerNodeMap.set(providerId, providerNode);
|
||||
}
|
||||
|
||||
const connectionsByProvider = new Map<string, ProviderConnectionLike[]>();
|
||||
for (const connection of connections as ProviderConnectionLike[]) {
|
||||
const providerId = toStringOrNull(connection.provider);
|
||||
if (!providerId) continue;
|
||||
const list = connectionsByProvider.get(providerId) || [];
|
||||
list.push(connection);
|
||||
connectionsByProvider.set(providerId, list);
|
||||
}
|
||||
|
||||
const providers: ComboBuilderProviderOption[] = [];
|
||||
|
||||
for (const [providerId, providerConnections] of connectionsByProvider) {
|
||||
const providerNode = providerNodeMap.get(providerId) || null;
|
||||
const providerVisual = getProviderVisual(providerId, providerNode);
|
||||
const modelMap = new Map<string, ComboBuilderModelOption>();
|
||||
const builtInModels = getModelsByProviderId(providerId);
|
||||
const syncedModels = normalizeSyncedModels(
|
||||
(syncedModelsMap as Record<string, unknown>)[providerId]
|
||||
);
|
||||
const customModels = normalizeCustomModels(
|
||||
(customModelsMap as Record<string, unknown>)[providerId]
|
||||
);
|
||||
const fallbackModels = getCompatibleFallbackModels(providerId, builtInModels);
|
||||
const acceptsArbitraryModel =
|
||||
Boolean((AI_PROVIDERS[providerId] as JsonRecord | undefined)?.passthroughModels) ||
|
||||
isOpenAICompatibleProvider(providerId) ||
|
||||
isAnthropicCompatibleProvider(providerId) ||
|
||||
isClaudeCodeCompatibleProvider(providerId);
|
||||
|
||||
for (const model of syncedModels) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "api-sync",
|
||||
supportedEndpoints: toStringArray(model.supportedEndpoints),
|
||||
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
|
||||
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
|
||||
supportsThinking:
|
||||
typeof model.supportsThinking === "boolean"
|
||||
? model.supportsThinking
|
||||
: (resolved.supportsThinking ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
for (const model of builtInModels as RegistryModel[]) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "system",
|
||||
contextLength: toNumberOrNull(model.contextLength) ?? resolved.contextWindow,
|
||||
outputTokenLimit: resolved.maxOutputTokens,
|
||||
supportsThinking: resolved.supportsThinking ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const model of customModels) {
|
||||
if (model.isHidden === true) continue;
|
||||
const source =
|
||||
toStringOrNull(model.source) === "api-sync" ? "api-sync" : ("custom" as BuilderModelSource);
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source,
|
||||
supportedEndpoints: toStringArray(model.supportedEndpoints),
|
||||
apiFormat: toStringOrNull(model.apiFormat),
|
||||
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
|
||||
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
|
||||
supportsThinking:
|
||||
typeof model.supportsThinking === "boolean"
|
||||
? model.supportsThinking
|
||||
: (resolved.supportsThinking ?? undefined),
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(fallbackModels)) {
|
||||
for (const model of fallbackModels) {
|
||||
const resolved = getResolvedModelCapabilities({
|
||||
provider: providerId,
|
||||
model: toStringOrNull(model.id),
|
||||
});
|
||||
addModelOption(modelMap, providerId, {
|
||||
id: toStringOrNull(model.id),
|
||||
name: toStringOrNull(model.name),
|
||||
source: "fallback",
|
||||
contextLength:
|
||||
typeof (model as { contextLength?: number }).contextLength === "number"
|
||||
? (model as { contextLength?: number }).contextLength || null
|
||||
: resolved.contextWindow,
|
||||
outputTokenLimit: resolved.maxOutputTokens,
|
||||
supportsThinking: resolved.supportsThinking ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedConnections = providerConnections
|
||||
.map((connection) => buildConnectionOption(connection))
|
||||
.filter((connection): connection is ComboBuilderConnectionOption => Boolean(connection))
|
||||
.sort(compareConnections);
|
||||
|
||||
const activeConnectionCount = normalizedConnections.filter(
|
||||
(connection) => connection.isActive
|
||||
).length;
|
||||
const displayName = (providerEntryName(providerId) ||
|
||||
getProviderDisplayName(providerId, providerNode) ||
|
||||
providerId) as string;
|
||||
|
||||
providers.push({
|
||||
providerId,
|
||||
providerType: providerVisual.providerType,
|
||||
displayName,
|
||||
alias: providerVisual.alias,
|
||||
prefix: toStringOrNull(providerNode?.prefix),
|
||||
icon: providerVisual.icon,
|
||||
color: providerVisual.color,
|
||||
source: providerVisual.source,
|
||||
acceptsArbitraryModel,
|
||||
connectionCount: normalizedConnections.length,
|
||||
activeConnectionCount,
|
||||
modelCount: modelMap.size,
|
||||
connections: normalizedConnections,
|
||||
models: Array.from(modelMap.values()).sort(compareModels),
|
||||
});
|
||||
}
|
||||
|
||||
const comboRefs = (combos as JsonRecord[])
|
||||
.filter((combo) => combo.isHidden !== true && combo.isActive !== false)
|
||||
.map((combo) => ({
|
||||
id: toStringOrNull(combo.id) || toStringOrNull(combo.name) || "combo",
|
||||
name: toStringOrNull(combo.name) || "combo",
|
||||
strategy: toStringOrNull(combo.strategy) || "priority",
|
||||
stepCount: Array.isArray(combo.models) ? combo.models.length : 0,
|
||||
version: typeof combo.version === "number" ? combo.version : 2,
|
||||
...(typeof combo.sortOrder === "number" ? { sortOrder: combo.sortOrder } : {}),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const leftSort =
|
||||
typeof left.sortOrder === "number" ? left.sortOrder : Number.MAX_SAFE_INTEGER;
|
||||
const rightSort =
|
||||
typeof right.sortOrder === "number" ? right.sortOrder : Number.MAX_SAFE_INTEGER;
|
||||
if (leftSort !== rightSort) return leftSort - rightSort;
|
||||
return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
generatedAt: new Date().toISOString(),
|
||||
providers: providers.sort(compareProviders),
|
||||
comboRefs,
|
||||
};
|
||||
}
|
||||
|
||||
function providerEntryName(providerId: string): string | null {
|
||||
const providerEntry = AI_PROVIDERS[providerId] as { name?: string } | undefined;
|
||||
return toStringOrNull(providerEntry?.name);
|
||||
}
|
||||
200
src/lib/combos/compositeTiers.ts
Normal file
200
src/lib/combos/compositeTiers.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { normalizeComboModels } from "@/lib/combos/steps";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type ValidationErrorDetail = {
|
||||
field: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type CompositeTierValidationFailure = {
|
||||
success: false;
|
||||
error: {
|
||||
message: string;
|
||||
details: ValidationErrorDetail[];
|
||||
};
|
||||
};
|
||||
|
||||
type CompositeTierValidationSuccess = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export type CompositeTierValidationResult =
|
||||
| CompositeTierValidationFailure
|
||||
| CompositeTierValidationSuccess;
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toTrimmedString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function createFailure(details: ValidationErrorDetail[]): CompositeTierValidationFailure {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: "Invalid composite tiers",
|
||||
details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCompositeTiersConfig(combo: {
|
||||
name?: unknown;
|
||||
models?: unknown;
|
||||
config?: unknown;
|
||||
}): CompositeTierValidationResult {
|
||||
if (!isRecord(combo.config)) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const compositeTiers = combo.config.compositeTiers;
|
||||
if (compositeTiers === undefined || compositeTiers === null) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (!isRecord(compositeTiers)) {
|
||||
return createFailure([
|
||||
{
|
||||
field: "config.compositeTiers",
|
||||
message: "compositeTiers must be an object",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const defaultTier = toTrimmedString(compositeTiers.defaultTier);
|
||||
const tiers = isRecord(compositeTiers.tiers) ? compositeTiers.tiers : null;
|
||||
const details: ValidationErrorDetail[] = [];
|
||||
|
||||
if (!defaultTier) {
|
||||
details.push({
|
||||
field: "config.compositeTiers.defaultTier",
|
||||
message: "defaultTier is required",
|
||||
});
|
||||
}
|
||||
|
||||
if (!tiers || Object.keys(tiers).length === 0) {
|
||||
details.push({
|
||||
field: "config.compositeTiers.tiers",
|
||||
message: "tiers must define at least one tier",
|
||||
});
|
||||
return createFailure(details);
|
||||
}
|
||||
|
||||
const normalizedSteps = normalizeComboModels(combo.models, {
|
||||
comboName: toTrimmedString(combo.name),
|
||||
});
|
||||
const stepIds = new Set(
|
||||
normalizedSteps
|
||||
.map((step) => toTrimmedString(step.id))
|
||||
.filter((value): value is string => !!value)
|
||||
);
|
||||
const tierEntries = new Map<string, { stepId: string; fallbackTier: string | null }>();
|
||||
const stepIdOwners = new Map<string, string>();
|
||||
|
||||
for (const [rawTierName, rawTierValue] of Object.entries(tiers)) {
|
||||
const tierName = toTrimmedString(rawTierName);
|
||||
const fieldBase = `config.compositeTiers.tiers.${rawTierName}`;
|
||||
|
||||
if (!tierName) {
|
||||
details.push({
|
||||
field: fieldBase,
|
||||
message: "tier name must be a non-empty string",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isRecord(rawTierValue)) {
|
||||
details.push({
|
||||
field: fieldBase,
|
||||
message: "tier config must be an object",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const stepId = toTrimmedString(rawTierValue.stepId);
|
||||
const fallbackTier = toTrimmedString(rawTierValue.fallbackTier);
|
||||
|
||||
if (!stepId) {
|
||||
details.push({
|
||||
field: `${fieldBase}.stepId`,
|
||||
message: "stepId is required",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!stepIds.has(stepId)) {
|
||||
details.push({
|
||||
field: `${fieldBase}.stepId`,
|
||||
message: `stepId "${stepId}" does not exist in combo.models`,
|
||||
});
|
||||
}
|
||||
|
||||
const previousTierForStep = stepIdOwners.get(stepId);
|
||||
if (previousTierForStep && previousTierForStep !== tierName) {
|
||||
details.push({
|
||||
field: `${fieldBase}.stepId`,
|
||||
message: `stepId "${stepId}" is already assigned to tier "${previousTierForStep}"`,
|
||||
});
|
||||
} else {
|
||||
stepIdOwners.set(stepId, tierName);
|
||||
}
|
||||
|
||||
if (fallbackTier && fallbackTier === tierName) {
|
||||
details.push({
|
||||
field: `${fieldBase}.fallbackTier`,
|
||||
message: "fallbackTier cannot reference the same tier",
|
||||
});
|
||||
}
|
||||
|
||||
tierEntries.set(tierName, {
|
||||
stepId,
|
||||
fallbackTier,
|
||||
});
|
||||
}
|
||||
|
||||
if (defaultTier && !tierEntries.has(defaultTier)) {
|
||||
details.push({
|
||||
field: "config.compositeTiers.defaultTier",
|
||||
message: `defaultTier "${defaultTier}" does not exist in tiers`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [tierName, entry] of tierEntries.entries()) {
|
||||
if (entry.fallbackTier && !tierEntries.has(entry.fallbackTier)) {
|
||||
details.push({
|
||||
field: `config.compositeTiers.tiers.${tierName}.fallbackTier`,
|
||||
message: `fallbackTier "${entry.fallbackTier}" does not exist in tiers`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const visitState = new Map<string, "visiting" | "visited">();
|
||||
|
||||
function visit(tierName: string, path: string[]) {
|
||||
const state = visitState.get(tierName);
|
||||
if (state === "visited") return;
|
||||
if (state === "visiting") {
|
||||
details.push({
|
||||
field: `config.compositeTiers.tiers.${tierName}.fallbackTier`,
|
||||
message: `fallbackTier cycle detected: ${[...path, tierName].join(" -> ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
visitState.set(tierName, "visiting");
|
||||
const fallbackTier = tierEntries.get(tierName)?.fallbackTier;
|
||||
if (fallbackTier && tierEntries.has(fallbackTier)) {
|
||||
visit(fallbackTier, [...path, tierName]);
|
||||
}
|
||||
visitState.set(tierName, "visited");
|
||||
}
|
||||
|
||||
for (const tierName of tierEntries.keys()) {
|
||||
visit(tierName, []);
|
||||
}
|
||||
|
||||
return details.length > 0 ? createFailure(details) : { success: true };
|
||||
}
|
||||
210
src/lib/combos/intelligentRouting.ts
Normal file
210
src/lib/combos/intelligentRouting.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export const INTELLIGENT_STRATEGIES = ["auto", "lkgp"] as const;
|
||||
export const INTELLIGENT_ROUTING_FILTERS = ["all", "intelligent", "deterministic"] as const;
|
||||
|
||||
export type IntelligentRoutingFilter = (typeof INTELLIGENT_ROUTING_FILTERS)[number];
|
||||
|
||||
export type IntelligentRoutingWeights = {
|
||||
quota: number;
|
||||
health: number;
|
||||
costInv: number;
|
||||
latencyInv: number;
|
||||
taskFit: number;
|
||||
stability: number;
|
||||
tierPriority: number;
|
||||
};
|
||||
|
||||
export type IntelligentRoutingConfig = {
|
||||
candidatePool: string[];
|
||||
explorationRate: number;
|
||||
modePack: string;
|
||||
budgetCap?: number;
|
||||
weights: IntelligentRoutingWeights;
|
||||
routerStrategy: string;
|
||||
};
|
||||
|
||||
export type IntelligentProviderScore = {
|
||||
provider: string;
|
||||
model: string;
|
||||
score: number;
|
||||
factors: IntelligentRoutingWeights;
|
||||
};
|
||||
|
||||
export type IntelligentExclusionEntry = {
|
||||
provider: string;
|
||||
excludedAt: string;
|
||||
cooldownMs: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_INTELLIGENT_WEIGHTS: IntelligentRoutingWeights = {
|
||||
quota: 0.2,
|
||||
health: 0.25,
|
||||
costInv: 0.2,
|
||||
latencyInv: 0.15,
|
||||
taskFit: 0.1,
|
||||
stability: 0.05,
|
||||
tierPriority: 0.05,
|
||||
};
|
||||
|
||||
export const MODE_PACK_OPTIONS = [
|
||||
{ id: "ship-fast", label: "Ship Fast", emoji: "rocket_launch" },
|
||||
{ id: "cost-saver", label: "Cost Saver", emoji: "savings" },
|
||||
{ id: "quality-first", label: "Quality First", emoji: "target" },
|
||||
{ id: "offline-friendly", label: "Offline Friendly", emoji: "cloud_off" },
|
||||
] as const;
|
||||
|
||||
export const ROUTER_STRATEGY_OPTIONS = [
|
||||
{ id: "rules", label: "Rules (6-Factor Scoring)" },
|
||||
{ id: "cost", label: "Cost Optimized" },
|
||||
{ id: "latency", label: "Latency Optimized" },
|
||||
{ id: "lkgp", label: "Last Known Good Provider" },
|
||||
] as const;
|
||||
|
||||
export const FACTOR_LABELS: Record<keyof IntelligentRoutingWeights, string> = {
|
||||
quota: "Quota",
|
||||
health: "Health",
|
||||
costInv: "Cost",
|
||||
latencyInv: "Latency",
|
||||
taskFit: "Task Fit",
|
||||
stability: "Stability",
|
||||
tierPriority: "Tier",
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
function toPositiveNumber(value: unknown): number | undefined {
|
||||
const numericValue = toFiniteNumber(value);
|
||||
return numericValue !== null && numericValue > 0 ? numericValue : undefined;
|
||||
}
|
||||
|
||||
export function isIntelligentStrategy(strategy: unknown): boolean {
|
||||
return typeof strategy === "string" && INTELLIGENT_STRATEGIES.includes(strategy as never);
|
||||
}
|
||||
|
||||
export function getStrategyCategory(strategy: unknown): "intelligent" | "deterministic" {
|
||||
return isIntelligentStrategy(strategy) ? "intelligent" : "deterministic";
|
||||
}
|
||||
|
||||
export function normalizeIntelligentRoutingFilter(value: unknown): IntelligentRoutingFilter {
|
||||
if (typeof value === "string" && INTELLIGENT_ROUTING_FILTERS.includes(value as never)) {
|
||||
return value as IntelligentRoutingFilter;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
export function filterCombosByStrategyCategory<T extends { strategy?: unknown }>(
|
||||
combos: T[],
|
||||
filter: IntelligentRoutingFilter
|
||||
): T[] {
|
||||
if (filter === "all") return combos;
|
||||
return combos.filter((combo) => getStrategyCategory(combo?.strategy) === filter);
|
||||
}
|
||||
|
||||
export function normalizeIntelligentRoutingConfig(config: unknown): IntelligentRoutingConfig {
|
||||
const configRecord = isRecord(config) ? config : {};
|
||||
const rawWeights = isRecord(configRecord.weights) ? configRecord.weights : {};
|
||||
|
||||
return {
|
||||
candidatePool: Array.isArray(configRecord.candidatePool)
|
||||
? configRecord.candidatePool.filter((value): value is string => typeof value === "string")
|
||||
: [],
|
||||
explorationRate: Math.min(1, Math.max(0, toFiniteNumber(configRecord.explorationRate) ?? 0.05)),
|
||||
modePack:
|
||||
typeof configRecord.modePack === "string" && configRecord.modePack.trim().length > 0
|
||||
? configRecord.modePack
|
||||
: "ship-fast",
|
||||
budgetCap: toPositiveNumber(configRecord.budgetCap),
|
||||
weights: {
|
||||
quota: toFiniteNumber(rawWeights.quota) ?? DEFAULT_INTELLIGENT_WEIGHTS.quota,
|
||||
health: toFiniteNumber(rawWeights.health) ?? DEFAULT_INTELLIGENT_WEIGHTS.health,
|
||||
costInv: toFiniteNumber(rawWeights.costInv) ?? DEFAULT_INTELLIGENT_WEIGHTS.costInv,
|
||||
latencyInv: toFiniteNumber(rawWeights.latencyInv) ?? DEFAULT_INTELLIGENT_WEIGHTS.latencyInv,
|
||||
taskFit: toFiniteNumber(rawWeights.taskFit) ?? DEFAULT_INTELLIGENT_WEIGHTS.taskFit,
|
||||
stability: toFiniteNumber(rawWeights.stability) ?? DEFAULT_INTELLIGENT_WEIGHTS.stability,
|
||||
tierPriority:
|
||||
toFiniteNumber(rawWeights.tierPriority) ?? DEFAULT_INTELLIGENT_WEIGHTS.tierPriority,
|
||||
},
|
||||
routerStrategy:
|
||||
typeof configRecord.routerStrategy === "string" &&
|
||||
configRecord.routerStrategy.trim().length > 0
|
||||
? configRecord.routerStrategy
|
||||
: "rules",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIntelligentProviderScores(combo: {
|
||||
config?: unknown;
|
||||
weights?: unknown;
|
||||
}): IntelligentProviderScore[] {
|
||||
const configRecord = normalizeIntelligentRoutingConfig(combo?.config);
|
||||
const comboWeights = isRecord(combo?.weights) ? combo.weights : combo?.config;
|
||||
const weights = normalizeIntelligentRoutingConfig({
|
||||
...(isRecord(comboWeights) ? comboWeights : {}),
|
||||
weights: isRecord(combo?.weights) ? combo.weights : configRecord.weights,
|
||||
}).weights;
|
||||
const pool = configRecord.candidatePool;
|
||||
const baseScore = pool.length > 0 ? 1 / pool.length : 0;
|
||||
|
||||
return pool.map((provider) => ({
|
||||
provider,
|
||||
model: "auto",
|
||||
score: baseScore,
|
||||
factors: weights,
|
||||
}));
|
||||
}
|
||||
|
||||
export function extractIntelligentHealthState(health: unknown): {
|
||||
incidentMode: boolean;
|
||||
exclusions: IntelligentExclusionEntry[];
|
||||
} {
|
||||
const healthRecord = isRecord(health) ? health : {};
|
||||
const providerHealth = isRecord(healthRecord.providerHealth) ? healthRecord.providerHealth : {};
|
||||
const providerBreakers = Object.entries(providerHealth).map(([provider, status]) => {
|
||||
const statusRecord = isRecord(status) ? status : {};
|
||||
return {
|
||||
provider,
|
||||
state: typeof statusRecord.state === "string" ? statusRecord.state : "CLOSED",
|
||||
lastFailure: typeof statusRecord.lastFailure === "string" ? statusRecord.lastFailure : null,
|
||||
};
|
||||
});
|
||||
const breakersFromArray = Array.isArray(healthRecord.circuitBreakers)
|
||||
? healthRecord.circuitBreakers
|
||||
.map((entry) => {
|
||||
const breaker = isRecord(entry) ? entry : {};
|
||||
const provider =
|
||||
typeof breaker.provider === "string"
|
||||
? breaker.provider
|
||||
: typeof breaker.name === "string"
|
||||
? breaker.name
|
||||
: "unknown";
|
||||
return {
|
||||
provider,
|
||||
state: typeof breaker.state === "string" ? breaker.state : "CLOSED",
|
||||
lastFailure: typeof breaker.lastFailure === "string" ? breaker.lastFailure : null,
|
||||
};
|
||||
})
|
||||
.filter((entry) => typeof entry.provider === "string")
|
||||
: [];
|
||||
|
||||
const breakers = breakersFromArray.length > 0 ? breakersFromArray : providerBreakers;
|
||||
const openBreakers = breakers.filter((breaker) => breaker.state === "OPEN");
|
||||
|
||||
return {
|
||||
incidentMode: openBreakers.length / Math.max(breakers.length, 1) > 0.5,
|
||||
exclusions: openBreakers.map((breaker) => ({
|
||||
provider: breaker.provider,
|
||||
excludedAt: breaker.lastFailure || new Date().toISOString(),
|
||||
cooldownMs: 5 * 60 * 1000,
|
||||
reason: "Circuit breaker OPEN",
|
||||
})),
|
||||
};
|
||||
}
|
||||
318
src/lib/combos/steps.ts
Normal file
318
src/lib/combos/steps.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export const COMBO_SCHEMA_VERSION = 2;
|
||||
|
||||
export interface ComboModelStep {
|
||||
id: string;
|
||||
kind: "model";
|
||||
model: string;
|
||||
providerId?: string | null;
|
||||
connectionId?: string | null;
|
||||
weight: number;
|
||||
label?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface ComboRefStep {
|
||||
id: string;
|
||||
kind: "combo-ref";
|
||||
comboName: string;
|
||||
weight: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type ComboStep = ComboModelStep | ComboRefStep;
|
||||
|
||||
type ComboCollectionLike =
|
||||
| Array<{ name?: unknown } | string>
|
||||
| { combos?: Array<{ name?: unknown }> }
|
||||
| Iterable<string>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
interface NormalizeComboStepOptions {
|
||||
comboName?: string | null;
|
||||
index?: number;
|
||||
allCombos?: ComboCollectionLike;
|
||||
}
|
||||
|
||||
interface NormalizeComboRecordOptions {
|
||||
allCombos?: ComboCollectionLike;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toTrimmedString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function toWeight(value: unknown): number {
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value.trim().length > 0
|
||||
? Number(value)
|
||||
: 0;
|
||||
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
return Math.max(0, Math.min(100, parsed));
|
||||
}
|
||||
|
||||
function collectComboNames(allCombos: ComboCollectionLike): Set<string> {
|
||||
const names = new Set<string>();
|
||||
if (!allCombos) return names;
|
||||
|
||||
if (
|
||||
!Array.isArray(allCombos) &&
|
||||
typeof (allCombos as { [Symbol.iterator]?: unknown })[Symbol.iterator] === "function" &&
|
||||
!(isRecord(allCombos) && Array.isArray(allCombos.combos))
|
||||
) {
|
||||
for (const value of allCombos as Iterable<string>) {
|
||||
const name = toTrimmedString(value);
|
||||
if (name) names.add(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
const combosFromRecord =
|
||||
isRecord(allCombos) && Array.isArray(allCombos.combos) ? allCombos.combos : [];
|
||||
const combos = Array.isArray(allCombos) ? allCombos : combosFromRecord;
|
||||
|
||||
for (const combo of combos) {
|
||||
const name = typeof combo === "string" ? toTrimmedString(combo) : toTrimmedString(combo?.name);
|
||||
if (name) names.add(name);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function parseProviderId(model: string): string | null {
|
||||
const trimmed = model.trim();
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex <= 0) return null;
|
||||
const providerId = trimmed.slice(0, slashIndex).trim();
|
||||
return providerId.length > 0 ? providerId : null;
|
||||
}
|
||||
|
||||
function toFullModelString(model: string, providerId?: string | null): string {
|
||||
const trimmedModel = model.trim();
|
||||
if (trimmedModel.includes("/")) return trimmedModel;
|
||||
const normalizedProviderId = toTrimmedString(providerId);
|
||||
return normalizedProviderId ? `${normalizedProviderId}/${trimmedModel}` : trimmedModel;
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug.length > 0 ? slug : "step";
|
||||
}
|
||||
|
||||
function buildStepId(
|
||||
kind: ComboStep["kind"],
|
||||
comboName: string | null,
|
||||
index: number,
|
||||
seed: string
|
||||
) {
|
||||
const parts = [
|
||||
slugify(comboName || "combo"),
|
||||
kind === "combo-ref" ? "ref" : "model",
|
||||
String(index + 1),
|
||||
slugify(seed),
|
||||
];
|
||||
return parts.join("-").slice(0, 200);
|
||||
}
|
||||
|
||||
function shouldTreatAsComboRef(
|
||||
target: string,
|
||||
providerId: string | null,
|
||||
options: NormalizeComboStepOptions
|
||||
): boolean {
|
||||
if (providerId) return false;
|
||||
const comboNames = collectComboNames(options.allCombos);
|
||||
return comboNames.has(target) || target === toTrimmedString(options.comboName);
|
||||
}
|
||||
|
||||
export function isComboRefStep(value: unknown): value is ComboRefStep {
|
||||
return isRecord(value) && value.kind === "combo-ref" && !!toTrimmedString(value.comboName);
|
||||
}
|
||||
|
||||
export function isComboModelStep(value: unknown): value is ComboModelStep {
|
||||
return isRecord(value) && value.kind === "model" && !!toTrimmedString(value.model);
|
||||
}
|
||||
|
||||
export function getComboStepWeight(value: unknown): number {
|
||||
if (typeof value === "string") return 0;
|
||||
if (!isRecord(value)) return 0;
|
||||
return toWeight(value.weight);
|
||||
}
|
||||
|
||||
export function getComboModelString(value: unknown): string | null {
|
||||
if (typeof value === "string") return toTrimmedString(value);
|
||||
if (!isRecord(value) || value.kind === "combo-ref") return null;
|
||||
|
||||
const rawModel = toTrimmedString(value.model);
|
||||
if (!rawModel) return null;
|
||||
|
||||
const providerId =
|
||||
toTrimmedString(value.providerId) ||
|
||||
toTrimmedString(value.provider) ||
|
||||
parseProviderId(rawModel);
|
||||
|
||||
return toFullModelString(rawModel, providerId);
|
||||
}
|
||||
|
||||
export function getComboModelProvider(value: unknown): string | null {
|
||||
if (typeof value === "string") return parseProviderId(value);
|
||||
if (!isRecord(value) || value.kind === "combo-ref") return null;
|
||||
|
||||
return (
|
||||
toTrimmedString(value.providerId) ||
|
||||
toTrimmedString(value.provider) ||
|
||||
parseProviderId(toTrimmedString(value.model) || "")
|
||||
);
|
||||
}
|
||||
|
||||
export function getComboStepTarget(
|
||||
value: unknown,
|
||||
options: NormalizeComboStepOptions = {}
|
||||
): string | null {
|
||||
if (typeof value === "string") {
|
||||
const target = toTrimmedString(value);
|
||||
if (!target) return null;
|
||||
return shouldTreatAsComboRef(target, null, options) ? target : target;
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.kind === "combo-ref") return toTrimmedString(value.comboName);
|
||||
|
||||
const rawModel = toTrimmedString(value.model);
|
||||
if (!rawModel) return null;
|
||||
const isExplicitModel = value.kind === "model";
|
||||
const providerId =
|
||||
toTrimmedString(value.providerId) ||
|
||||
toTrimmedString(value.provider) ||
|
||||
parseProviderId(rawModel);
|
||||
|
||||
if (!isExplicitModel && shouldTreatAsComboRef(rawModel, providerId, options)) {
|
||||
return rawModel;
|
||||
}
|
||||
|
||||
return toFullModelString(rawModel, providerId);
|
||||
}
|
||||
|
||||
export function normalizeComboStep(
|
||||
value: unknown,
|
||||
options: NormalizeComboStepOptions = {}
|
||||
): ComboStep | null {
|
||||
const comboName = toTrimmedString(options.comboName);
|
||||
const index = typeof options.index === "number" ? options.index : 0;
|
||||
|
||||
if (typeof value === "string") {
|
||||
const target = toTrimmedString(value);
|
||||
if (!target) return null;
|
||||
|
||||
if (shouldTreatAsComboRef(target, null, options)) {
|
||||
return {
|
||||
id: buildStepId("combo-ref", comboName, index, target),
|
||||
kind: "combo-ref",
|
||||
comboName: target,
|
||||
weight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const providerId = parseProviderId(target);
|
||||
return {
|
||||
id: buildStepId("model", comboName, index, target),
|
||||
kind: "model",
|
||||
model: target,
|
||||
...(providerId ? { providerId } : {}),
|
||||
weight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const explicitId = toTrimmedString(value.id);
|
||||
const weight = toWeight(value.weight);
|
||||
const label = toTrimmedString(value.label);
|
||||
|
||||
if (value.kind === "combo-ref") {
|
||||
const comboRefName = toTrimmedString(value.comboName);
|
||||
if (!comboRefName) return null;
|
||||
return {
|
||||
id: explicitId || buildStepId("combo-ref", comboName, index, comboRefName),
|
||||
kind: "combo-ref",
|
||||
comboName: comboRefName,
|
||||
weight,
|
||||
...(label ? { label } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const rawModel = toTrimmedString(value.model);
|
||||
if (!rawModel) return null;
|
||||
const isExplicitModel = value.kind === "model";
|
||||
|
||||
const providerId =
|
||||
toTrimmedString(value.providerId) ||
|
||||
toTrimmedString(value.provider) ||
|
||||
parseProviderId(rawModel);
|
||||
|
||||
if (!isExplicitModel && shouldTreatAsComboRef(rawModel, providerId, options)) {
|
||||
return {
|
||||
id: explicitId || buildStepId("combo-ref", comboName, index, rawModel),
|
||||
kind: "combo-ref",
|
||||
comboName: rawModel,
|
||||
weight,
|
||||
...(label ? { label } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const model = toFullModelString(rawModel, providerId);
|
||||
const connectionId =
|
||||
value.connectionId === null ? null : toTrimmedString(value.connectionId) || undefined;
|
||||
const tags = Array.isArray(value.tags)
|
||||
? value.tags.map((tag) => toTrimmedString(tag)).filter((tag): tag is string => !!tag)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id:
|
||||
explicitId ||
|
||||
buildStepId("model", comboName, index, connectionId ? `${model}:${connectionId}` : model),
|
||||
kind: "model",
|
||||
model,
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(connectionId !== undefined ? { connectionId } : {}),
|
||||
weight,
|
||||
...(label ? { label } : {}),
|
||||
...(tags && tags.length > 0 ? { tags } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeComboModels(
|
||||
models: unknown,
|
||||
options: Omit<NormalizeComboStepOptions, "index"> = {}
|
||||
): ComboStep[] {
|
||||
const list = Array.isArray(models) ? models : [];
|
||||
return list
|
||||
.map((value, index) => normalizeComboStep(value, { ...options, index }))
|
||||
.filter((value): value is ComboStep => value !== null);
|
||||
}
|
||||
|
||||
export function normalizeComboRecord<T extends JsonRecord>(
|
||||
combo: T,
|
||||
options: NormalizeComboRecordOptions = {}
|
||||
): T & { version: 2; models: ComboStep[] } {
|
||||
const comboName = toTrimmedString(combo.name);
|
||||
return {
|
||||
...combo,
|
||||
version: COMBO_SCHEMA_VERSION,
|
||||
models: normalizeComboModels(combo.models, {
|
||||
comboName,
|
||||
allCombos: options.allCombos,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user