Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da14390fe0 | |||
| 11c0cff4ef | |||
| e322376996 | |||
| 4fbe45f30a | |||
| 2cd0f60c3c | |||
| 1b354be827 | |||
| 7db280ee64 | |||
| 192c06cadf | |||
| ad7e7abda0 | |||
| 02ccb35e80 | |||
| a8a29e17c5 | |||
| 75a6d850fc | |||
| b0f5f92f1a | |||
| eaddb6f0fa | |||
| 5cff98ea75 | |||
| 76127415a4 | |||
| 56936fe0e3 | |||
| dfbbbeb1b4 | |||
| 7f3ffd935e | |||
| 29cf462d8f | |||
| 5e1693e1f7 |
+23
-1
@@ -142,10 +142,32 @@ GITHUB_USER_AGENT=GitHubCopilotChat/0.26.7
|
||||
ANTIGRAVITY_USER_AGENT=antigravity/1.104.0 darwin/arm64
|
||||
KIRO_USER_AGENT=AWS-SDK-JS/3.0.0 kiro-ide/1.0.0
|
||||
IFLOW_USER_AGENT=iFlow-Cli
|
||||
QWEN_USER_AGENT=google-api-nodejs-client/9.15.1
|
||||
QWEN_USER_AGENT=QwenCode/0.12.3 (linux; x64)
|
||||
CURSOR_USER_AGENT=connect-es/1.6.1
|
||||
GEMINI_CLI_USER_AGENT=google-api-nodejs-client/9.15.1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# CLI Fingerprint Compatibility (optional — match native CLI binary signatures)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# When enabled, OmniRoute reorders HTTP headers and JSON body fields to match
|
||||
# the exact signature of official CLI tools, reducing account flagging risk.
|
||||
# Your proxy IP is preserved — you get both stealth AND IP masking.
|
||||
#
|
||||
# Enable per-provider:
|
||||
# CLI_COMPAT_CODEX=1
|
||||
# CLI_COMPAT_CLAUDE=1
|
||||
# CLI_COMPAT_GITHUB=1
|
||||
# CLI_COMPAT_ANTIGRAVITY=1
|
||||
# CLI_COMPAT_KIRO=1
|
||||
# CLI_COMPAT_CURSOR=1
|
||||
# CLI_COMPAT_KIMI_CODING=1
|
||||
# CLI_COMPAT_KILOCODE=1
|
||||
# CLI_COMPAT_CLINE=1
|
||||
# CLI_COMPAT_QWEN=1
|
||||
#
|
||||
# Or enable for all providers at once:
|
||||
# CLI_COMPAT_ALL=1
|
||||
|
||||
# API Key Providers (Phase 1 + Phase 4)
|
||||
# Add via Dashboard → Providers → Add API Key, or set here
|
||||
# DEEPSEEK_API_KEY=
|
||||
|
||||
@@ -18,10 +18,10 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU (for multi-arch builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
|
||||
+92
-1
@@ -2,6 +2,98 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.5.0] - 2026-03-14
|
||||
|
||||
> Major release: strict-random routing strategy, API key access controls, connection groups, external pricing sync, and critical bug fixes for thinking models, combo testing, and tool name validation.
|
||||
|
||||
### ✨ New Features (PRs #363 & #365)
|
||||
|
||||
- **Strict-Random Routing Strategy**: Fisher-Yates shuffle deck with anti-repeat guarantee and mutex serialization for concurrent requests. Independent decks per combo and per provider.
|
||||
- **API Key Access Controls**: `allowedConnections` (restrict which connections a key can use), `is_active` (enable/disable key with 403), `accessSchedule` (time-based access control), `autoResolve` toggle, rename keys via PATCH.
|
||||
- **Connection Groups**: Group provider connections by environment. Accordion view in Limits page with localStorage persistence and smart auto-switch.
|
||||
- **External Pricing Sync (LiteLLM)**: 3-tier pricing resolution (user overrides → synced → defaults). Opt-in via `PRICING_SYNC_ENABLED=true`. MCP tool `omniroute_sync_pricing`. 23 new tests.
|
||||
- **i18n**: 30 languages updated with strict-random strategy, API key management strings. pt-BR fully translated.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix #355**: Stream idle timeout increased from 60s to 300s — prevents aborting extended-thinking models (claude-opus-4-6, o3, etc.) during long reasoning phases. Configurable via `STREAM_IDLE_TIMEOUT_MS`.
|
||||
- **fix #350**: Combo test now bypasses `REQUIRE_API_KEY=true` using internal header, and uses OpenAI-compatible format universally. Timeout extended from 15s to 20s.
|
||||
- **fix #346**: Tools with empty `function.name` (forwarded by Claude Code) are now filtered before upstream providers receive them, preventing "Invalid input[N].name: empty string" errors.
|
||||
|
||||
### 🗑️ Closed Issues
|
||||
|
||||
- **#341**: Debug section removed — replacement is `/dashboard/logs` and `/dashboard/health`.
|
||||
|
||||
> API Key Round-Robin support for multi-key provider setups, and confirmation of wildcard routing and quota window rolling already in place.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **API Key Round-Robin (T07)**: Provider connections can now hold multiple API keys (Edit Connection → Extra API Keys). Requests rotate round-robin between primary + extra keys via `providerSpecificData.extraApiKeys[]`. Keys are held in-memory indexed per connection — no DB schema changes required.
|
||||
|
||||
### 📝 Already Implemented (confirmed in audit)
|
||||
|
||||
- **Wildcard Model Routing (T13)**: `wildcardRouter.ts` with glob-style wildcard matching (`gpt*`, `claude-?-sonnet`, etc.) is already integrated into `model.ts` with specificity ranking.
|
||||
- **Quota Window Rolling (T08)**: `accountFallback.ts:isModelLocked()` already auto-advances the window — if `Date.now() > entry.until`, lock is deleted immediately (no stale blocking).
|
||||
|
||||
> UI polish, routing strategy additions, and graceful error handling for usage limits.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Fill-First & P2C Routing Strategies**: Added `fill-first` (drain quota before moving on) and `p2c` (Power-of-Two-Choices low-latency selection) to combo strategy picker, with full guidance panels and color-coded badges.
|
||||
- **Free Stack Preset Models**: Creating a combo with the Free Stack template now auto-fills 7 best-in-class free provider models (Gemini CLI, Kiro, iFlow×2, Qwen, NVIDIA NIM, Groq). Users just activate the providers and get a $0/month combo out-of-the-box.
|
||||
- **Wider Combo Modal**: Create/Edit combo modal now uses `max-w-4xl` for comfortable editing of large combos.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Limits page HTTP 500 for Codex & GitHub**: `getCodexUsage()` and `getGitHubUsage()` now return a user-friendly message when the provider returns 401/403 (expired token), instead of throwing and causing a 500 error on the Limits page.
|
||||
- **MaintenanceBanner false-positive**: Banner no longer shows "Server is unreachable" spuriously on page load. Fixed by calling `checkHealth()` immediately on mount and removing stale `show`-state closure.
|
||||
- **Provider icon tooltips**: Edit (pencil) and delete icon buttons in the provider connection row now have native HTML tooltips — all 6 action icons are now self-documented.
|
||||
|
||||
> Multiple improvements from community issue analysis, new provider support, bug fixes for token tracking, model routing, and streaming reliability.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Task-Aware Smart Routing (T05)**: Automatic model selection based on request content type — coding → deepseek-chat, analysis → gemini-2.5-pro, vision → gpt-4o, summarization → gemini-2.5-flash. Configurable via Settings. New `GET/PUT/POST /api/settings/task-routing` API.
|
||||
- **HuggingFace Provider**: Added HuggingFace Router as an OpenAI-compatible provider with Llama 3.1 70B/8B, Qwen 2.5 72B, Mistral 7B, Phi-3.5 Mini.
|
||||
- **Vertex AI Provider**: Added Vertex AI (Google Cloud) provider with Gemini 2.5 Pro/Flash, Gemma 2 27B, Claude via Vertex.
|
||||
- **Playground File Uploads**: Audio upload for transcription, image upload for vision models (auto-detect by model name), inline image rendering for image generation results.
|
||||
- **Model Select Visual Feedback**: Already-added models in combo picker now show ✓ green badge — prevents duplicate confusion.
|
||||
- **Qwen Compatibility (PR #352)**: Updated User-Agent and CLI fingerprint settings for Qwen provider compatibility.
|
||||
- **Round-Robin State Management (PR #349)**: Enhanced round-robin logic to handle excluded accounts and maintain rotation state correctly.
|
||||
- **Clipboard UX (PR #360)**: Hardened clipboard operations with fallback for non-secure contexts; Claude tool normalization improvements.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Fix #302 — OpenAI SDK stream=False drops tool_calls**: T01 Accept header negotiation no longer forces streaming when `body.stream` is explicitly `false`. Was causing tool_calls to be silently dropped when using the OpenAI Python SDK in non-streaming mode.
|
||||
- **Fix #73 — Claude Haiku routed to OpenAI without provider prefix**: `claude-*` models sent without a provider prefix now correctly route to the `antigravity` (Anthropic) provider. Added `gemini-*`/`gemma-*` → `gemini` heuristic as well.
|
||||
- **Fix #74 — Token counts always 0 for Antigravity/Claude streaming**: The `message_start` SSE event which carries `input_tokens` was not being parsed by `extractUsage()`, causing all input token counts to drop. Input/output token tracking now works correctly for streaming responses.
|
||||
- **Fix #180 — Model import duplicates with no feedback**: `ModelSelectModal` now shows ✓ green highlight for models already in the combo, making it obvious they're already added.
|
||||
- **Media page generation errors**: Image results now render as `<img>` tags instead of raw JSON. Transcription results shown as readable text. Credential errors show an amber banner instead of silent failure.
|
||||
- **Token refresh button on provider page**: Manual token refresh UI added for OAuth providers.
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- **Provider Registry**: HuggingFace and Vertex AI added to `providerRegistry.ts` and `providers.ts` (frontend).
|
||||
- **Read Cache**: New `src/lib/db/readCache.ts` for efficient DB read caching.
|
||||
- **Quota Cache**: Improved quota cache with TTL-based eviction.
|
||||
|
||||
### 📦 Dependencies
|
||||
|
||||
- `dompurify` → 3.3.3 (PR #347)
|
||||
- `undici` → 7.24.2 (PR #348, #361)
|
||||
- `docker/setup-qemu-action` → v4 (PR #342)
|
||||
- `docker/setup-buildx-action` → v4 (PR #343)
|
||||
|
||||
### 📁 New Files
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------------------------- | --------------------------------------- |
|
||||
| `open-sse/services/taskAwareRouter.ts` | Task-aware routing logic (7 task types) |
|
||||
| `src/app/api/settings/task-routing/route.ts` | Task routing config API |
|
||||
| `src/app/api/providers/[id]/refresh/route.ts` | Manual OAuth token refresh |
|
||||
| `src/lib/db/readCache.ts` | Efficient DB read cache |
|
||||
| `src/shared/utils/clipboard.ts` | Hardened clipboard with fallback |
|
||||
|
||||
## [2.4.1] - 2026-03-13
|
||||
|
||||
### 🐛 Fix
|
||||
@@ -40,7 +132,6 @@
|
||||
|
||||
## [2.3.14] - 2026-03-13
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **iFlow OAuth (#339)**: Restored the valid default `clientSecret` — was previously an empty string, causing "Bad client credentials" on every connect attempt. The public credential is now the default fallback (overridable via `IFLOW_OAUTH_CLIENT_SECRET` env var).
|
||||
|
||||
@@ -706,19 +706,18 @@ Outcome: deep fallback depth for deadline-critical workloads
|
||||
|
||||
> Setup AI coding in minutes at **$0/month**. Connect these free accounts and use the built-in **Free Stack** combo.
|
||||
|
||||
| Step | Action | Providers Unlocked |
|
||||
|---|---|---|
|
||||
| 1 | Connect **Kiro** (AWS Builder ID OAuth) | Claude Sonnet 4.5, Haiku 4.5 — **unlimited** |
|
||||
| 2 | Connect **iFlow** (Google OAuth) | kimi-k2-thinking, qwen3-coder-plus, deepseek-r1... — **unlimited** |
|
||||
| 3 | Connect **Qwen** (Device Code) | qwen3-coder-plus, qwen3-coder-flash... — **unlimited** |
|
||||
| 4 | Connect **Gemini CLI** (Google OAuth) | gemini-3-flash, gemini-2.5-pro — **180K/mo free** |
|
||||
| 5 | `/dashboard/combos` → **Free Stack ($0)** template | Round-robin all free providers automatically |
|
||||
| Step | Action | Providers Unlocked |
|
||||
| ---- | -------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| 1 | Connect **Kiro** (AWS Builder ID OAuth) | Claude Sonnet 4.5, Haiku 4.5 — **unlimited** |
|
||||
| 2 | Connect **iFlow** (Google OAuth) | kimi-k2-thinking, qwen3-coder-plus, deepseek-r1... — **unlimited** |
|
||||
| 3 | Connect **Qwen** (Device Code) | qwen3-coder-plus, qwen3-coder-flash... — **unlimited** |
|
||||
| 4 | Connect **Gemini CLI** (Google OAuth) | gemini-3-flash, gemini-2.5-pro — **180K/mo free** |
|
||||
| 5 | `/dashboard/combos` → **Free Stack ($0)** template | Round-robin all free providers automatically |
|
||||
|
||||
**Point any IDE/CLI to:** `http://localhost:20128/v1` · API Key: `any-string` · Done.
|
||||
|
||||
> **Optional extra coverage (also free):** Groq API key (30 RPM free), NVIDIA NIM (40 RPM free, 70+ models), Cerebras (1M tok/day).
|
||||
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
### 1) Install and run
|
||||
@@ -899,25 +898,25 @@ When minimized, OmniRoute lives in your system tray with quick actions:
|
||||
|
||||
## 💰 Pricing at a Glance
|
||||
|
||||
| Tier | Provider | Cost | Quota Reset | Best For |
|
||||
| ------------------- | ----------------- | ----------------------- | ---------------- | -------------------- |
|
||||
| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed |
|
||||
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
|
||||
| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! |
|
||||
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
|
||||
| **🔑 API KEY** | NVIDIA NIM | **FREE** (dev forever) | ~40 RPM | 70+ open models |
|
||||
| | Cerebras | **FREE** (1M tok/day) | 60K TPM / 30 RPM | World's fastest |
|
||||
| | Groq | **FREE** (30 RPM) | 14.4K RPD | Ultra-fast Llama/Gemma |
|
||||
| | DeepSeek | Pay-per-use | None | Best price/quality |
|
||||
| | xAI (Grok) | Pay-per-use | None | Grok models |
|
||||
| | Mistral | Free trial + paid | Rate limited | European AI |
|
||||
| | OpenRouter | Pay-per-use | None | 100+ models aggr. |
|
||||
| **💰 CHEAP** | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
|
||||
| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option |
|
||||
| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost |
|
||||
| **🆓 FREE** | iFlow | **$0** | Unlimited | 5 models unlimited |
|
||||
| | Qwen | **$0** | Unlimited | 4 models unlimited |
|
||||
| | Kiro | **$0** | Unlimited | Claude (AWS Builder ID) |
|
||||
| Tier | Provider | Cost | Quota Reset | Best For |
|
||||
| ------------------- | ----------------- | ---------------------- | ---------------- | ----------------------- |
|
||||
| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed |
|
||||
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
|
||||
| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! |
|
||||
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
|
||||
| **🔑 API KEY** | NVIDIA NIM | **FREE** (dev forever) | ~40 RPM | 70+ open models |
|
||||
| | Cerebras | **FREE** (1M tok/day) | 60K TPM / 30 RPM | World's fastest |
|
||||
| | Groq | **FREE** (30 RPM) | 14.4K RPD | Ultra-fast Llama/Gemma |
|
||||
| | DeepSeek | Pay-per-use | None | Best price/quality |
|
||||
| | xAI (Grok) | Pay-per-use | None | Grok models |
|
||||
| | Mistral | Free trial + paid | Rate limited | European AI |
|
||||
| | OpenRouter | Pay-per-use | None | 100+ models aggr. |
|
||||
| **💰 CHEAP** | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
|
||||
| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option |
|
||||
| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost |
|
||||
| **🆓 FREE** | iFlow | **$0** | Unlimited | 5 models unlimited |
|
||||
| | Qwen | **$0** | Unlimited | 4 models unlimited |
|
||||
| | Kiro | **$0** | Unlimited | Claude (AWS Builder ID) |
|
||||
|
||||
**💡 $0 Combo Stack:** Gemini CLI (180K/mo) → iFlow (unlimited: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1) → Kiro (Claude for free) → Qwen (4 models, unlimited) — **Zero cost, never stops coding.** When Gemini quota runs out, OmniRoute auto-falls back to iFlow or Kiro with zero config.
|
||||
|
||||
@@ -931,63 +930,64 @@ When minimized, OmniRoute lives in your system tray with quick actions:
|
||||
|
||||
### 🔵 CLAUDE MODELS (via Kiro — AWS Builder ID)
|
||||
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
|---|---|---|---|
|
||||
| `claude-sonnet-4.5` | `kr/` | **Unlimited** | No reported daily cap |
|
||||
| `claude-haiku-4.5` | `kr/` | **Unlimited** | No reported daily cap |
|
||||
| `claude-opus-4.6` | `kr/` | **Unlimited** | Latest Opus via Kiro |
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
| ------------------- | ------ | ------------- | --------------------- |
|
||||
| `claude-sonnet-4.5` | `kr/` | **Unlimited** | No reported daily cap |
|
||||
| `claude-haiku-4.5` | `kr/` | **Unlimited** | No reported daily cap |
|
||||
| `claude-opus-4.6` | `kr/` | **Unlimited** | Latest Opus via Kiro |
|
||||
|
||||
### 🟢 IFLOW MODELS (Free OAuth — No Credit Card)
|
||||
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
|---|---|---|---|
|
||||
| `kimi-k2-thinking` | `if/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-plus` | `if/` | **Unlimited** | No reported cap |
|
||||
| `deepseek-r1` | `if/` | **Unlimited** | No reported cap |
|
||||
| `minimax-m2.1` | `if/` | **Unlimited** | No reported cap |
|
||||
| `kimi-k2` | `if/` | **Unlimited** | No reported cap |
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
| ------------------ | ------ | ------------- | --------------- |
|
||||
| `kimi-k2-thinking` | `if/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-plus` | `if/` | **Unlimited** | No reported cap |
|
||||
| `deepseek-r1` | `if/` | **Unlimited** | No reported cap |
|
||||
| `minimax-m2.1` | `if/` | **Unlimited** | No reported cap |
|
||||
| `kimi-k2` | `if/` | **Unlimited** | No reported cap |
|
||||
|
||||
### 🟡 QWEN MODELS (Device Code Auth)
|
||||
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
|---|---|---|---|
|
||||
| `qwen3-coder-plus` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-flash` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-next` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `vision-model` | `qw/` | **Unlimited** | Multimodal (images) |
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
| ------------------- | ------ | ------------- | ------------------- |
|
||||
| `qwen3-coder-plus` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-flash` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `qwen3-coder-next` | `qw/` | **Unlimited** | No reported cap |
|
||||
| `vision-model` | `qw/` | **Unlimited** | Multimodal (images) |
|
||||
|
||||
### 🟣 GEMINI CLI (Google OAuth)
|
||||
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
|---|---|---|---|
|
||||
| `gemini-3-flash-preview` | `gc/` | **180K tok/month** + 1K/day | Monthly reset |
|
||||
| `gemini-2.5-pro` | `gc/` | 180K/month (shared pool) | High quality |
|
||||
| Model | Prefix | Limit | Rate Limit |
|
||||
| ------------------------ | ------ | --------------------------- | ------------- |
|
||||
| `gemini-3-flash-preview` | `gc/` | **180K tok/month** + 1K/day | Monthly reset |
|
||||
| `gemini-2.5-pro` | `gc/` | 180K/month (shared pool) | High quality |
|
||||
|
||||
### ⚫ NVIDIA NIM (Free API Key — build.nvidia.com)
|
||||
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
| ---------- | ------------ | ----------- | ------------------------------------------------------ |
|
||||
| Free (Dev) | No token cap | **~40 RPM** | 70+ models; transitioning to pure rate limits mid-2025 |
|
||||
|
||||
Popular free models: `moonshotai/kimi-k2.5` (Kimi K2.5), `z-ai/glm4.7` (GLM 4.7), `deepseek-ai/deepseek-v3.2` (DeepSeek V3.2), `nvidia/llama-3.3-70b-instruct`, `deepseek/deepseek-r1`
|
||||
|
||||
### ⚪ CEREBRAS (Free API Key — inference.cerebras.ai)
|
||||
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
| ---- | ----------------- | ---------------- | ------------------------------------------- |
|
||||
| Free | **1M tokens/day** | 60K TPM / 30 RPM | World's fastest LLM inference; resets daily |
|
||||
|
||||
Available free: `llama-3.3-70b`, `llama-3.1-8b`, `deepseek-r1-distill-llama-70b`
|
||||
|
||||
### 🔴 GROQ (Free API Key — console.groq.com)
|
||||
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Tier | Daily Limit | Rate Limit | Notes |
|
||||
| ---- | ------------- | ---------------- | ----------------------------------------- |
|
||||
| Free | **14.4K RPD** | 30 RPM per model | No credit card; 429 on limit, not charged |
|
||||
|
||||
Available free: `llama-3.3-70b-versatile`, `gemma2-9b-it`, `mixtral-8x7b`, `whisper-large-v3`
|
||||
|
||||
> **💡 The Ultimate Free Stack:**
|
||||
>
|
||||
> ```
|
||||
> Kiro (Claude, unlimited)
|
||||
> → iFlow (5 models, unlimited)
|
||||
@@ -997,18 +997,18 @@ Available free: `llama-3.3-70b-versatile`, `gemma2-9b-it`, `mixtral-8x7b`, `whis
|
||||
> → Groq (14.4K req/day)
|
||||
> → NVIDIA NIM (40 RPM, 70+ models)
|
||||
> ```
|
||||
>
|
||||
> Configure this as an OmniRoute combo and you'll never pay for AI again.
|
||||
|
||||
|
||||
## 🎙️ Free Transcription Combo
|
||||
|
||||
> Transcribe any audio/video for **$0** — Deepgram leads with $200 free, AssemblyAI $50 fallback, Groq Whisper as unlimited emergency backup.
|
||||
|
||||
| Provider | Free Credits | Best Model | Rate Limit |
|
||||
|---|---|---|---|
|
||||
| 🟢 **Deepgram** | **$200 free** (signup) | `nova-3` — best accuracy, 30+ languages | No RPM limit on free credits |
|
||||
| 🔵 **AssemblyAI** | **$50 free** (signup) | `universal-3-pro` — chapters, sentiment, PII | No RPM limit on free credits |
|
||||
| 🔴 **Groq** | **Free forever** | `whisper-large-v3` — OpenAI Whisper | 30 RPM (rate limited) |
|
||||
| Provider | Free Credits | Best Model | Rate Limit |
|
||||
| ----------------- | ---------------------- | -------------------------------------------- | ---------------------------- |
|
||||
| 🟢 **Deepgram** | **$200 free** (signup) | `nova-3` — best accuracy, 30+ languages | No RPM limit on free credits |
|
||||
| 🔵 **AssemblyAI** | **$50 free** (signup) | `universal-3-pro` — chapters, sentiment, PII | No RPM limit on free credits |
|
||||
| 🔴 **Groq** | **Free forever** | `whisper-large-v3` — OpenAI Whisper | 30 RPM (rate limited) |
|
||||
|
||||
**Suggested combo in `/dashboard/combos`:**
|
||||
|
||||
@@ -1023,7 +1023,6 @@ Nodes:
|
||||
|
||||
Then in `/dashboard/media` → **Transcription** tab: upload any audio or video file → select your combo endpoint → get transcription in supported formats.
|
||||
|
||||
|
||||
## 💡 Key Features
|
||||
|
||||
OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
|
||||
@@ -1058,20 +1057,21 @@ OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
|
||||
|
||||
### 🧠 Routing & Intelligence
|
||||
|
||||
| Feature | What It Does |
|
||||
| ---------------------------------- | --------------------------------------------------------------------- |
|
||||
| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free |
|
||||
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider |
|
||||
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Responses with schema-safe conversions |
|
||||
| 👥 **Multi-Account Support** | Multiple accounts per provider with intelligent selection |
|
||||
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry |
|
||||
| 🎨 **Custom Combos** | 6 balancing strategies + fallback chain control |
|
||||
| 🌐 **Wildcard Router** | `provider/*` dynamic routing |
|
||||
| 🧠 **Thinking Budget Controls** | Passthrough, auto, custom, and adaptive reasoning limits |
|
||||
| 🔀 **Model Aliases** | Built-in + custom model aliasing and migration safety |
|
||||
| ⚡ **Background Degradation** | Route low-priority background tasks to cheaper models |
|
||||
| 💬 **System Prompt Injection** | Global behavior controls applied consistently |
|
||||
| 📄 **Responses API Compatibility** | Full `/v1/responses` support for Codex and advanced agentic workflows |
|
||||
| Feature | What It Does |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free |
|
||||
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider |
|
||||
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Responses with schema-safe conversions |
|
||||
| 👥 **Multi-Account Support** | Multiple accounts per provider with intelligent selection |
|
||||
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry |
|
||||
| 🎨 **Custom Combos** | 6 balancing strategies + fallback chain control |
|
||||
| 🌐 **Wildcard Router** | `provider/*` dynamic routing |
|
||||
| 🧠 **Thinking Budget Controls** | Passthrough, auto, custom, and adaptive reasoning limits |
|
||||
| 🔀 **Model Aliases** | Built-in + custom model aliasing and migration safety |
|
||||
| ⚡ **Background Degradation** | Route low-priority background tasks to cheaper models |
|
||||
| 🧪 **Task-Aware Smart Routing** | Auto-select model by content type (coding/vision/analysis/summarization) |
|
||||
| 💬 **System Prompt Injection** | Global behavior controls applied consistently |
|
||||
| 📄 **Responses API Compatibility** | Full `/v1/responses` support for Codex and advanced agentic workflows |
|
||||
|
||||
### 🎵 Multi-Modal APIs
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.4.1
|
||||
version: 2.5.0
|
||||
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,
|
||||
|
||||
@@ -118,6 +118,58 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
|
||||
bodyFieldOrder: ["project", "model", "userAgent", "requestType", "requestId", "request"],
|
||||
userAgent: "antigravity",
|
||||
},
|
||||
qwen: {
|
||||
headerOrder: [
|
||||
"Host",
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Dashscope-AuthType",
|
||||
"X-Dashscope-CacheControl",
|
||||
"X-Dashscope-UserAgent",
|
||||
"X-Stainless-Arch",
|
||||
"X-Stainless-Lang",
|
||||
"X-Stainless-Os",
|
||||
"X-Stainless-Package-Version",
|
||||
"X-Stainless-Retry-Count",
|
||||
"X-Stainless-Runtime",
|
||||
"X-Stainless-Runtime-Version",
|
||||
"Connection",
|
||||
"Accept",
|
||||
"Accept-Language",
|
||||
"Sec-Fetch-Mode",
|
||||
"Accept-Encoding",
|
||||
],
|
||||
bodyFieldOrder: [
|
||||
"model",
|
||||
"messages",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"max_tokens",
|
||||
"stream",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"response_format",
|
||||
"n",
|
||||
"stop",
|
||||
],
|
||||
userAgent: "QwenCode/0.12.3 (linux; x64)",
|
||||
extraHeaders: {
|
||||
"X-Dashscope-AuthType": "qwen-oauth",
|
||||
"X-Dashscope-CacheControl": "enable",
|
||||
"X-Dashscope-UserAgent": "QwenCode/0.12.3 (linux; x64)",
|
||||
"X-Stainless-Arch": "x64",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Os": "Linux",
|
||||
"X-Stainless-Package-Version": "5.11.0",
|
||||
"X-Stainless-Retry-Count": "1",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v18.19.1",
|
||||
Connection: "keep-alive",
|
||||
"Accept-Language": "*",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,9 @@ import { loadProviderCredentials } from "./credentialLoader.ts";
|
||||
export const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "120000", 10);
|
||||
|
||||
// Idle timeout for SSE streams (ms). Closes stream if no data for this duration.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "60000", 10);
|
||||
// Default: 300s to support extended-thinking models (claude-opus-4-6, o3, etc.)
|
||||
// that may pause for >60s during deep reasoning phases. Override with STREAM_IDLE_TIMEOUT_MS env var.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "300000", 10);
|
||||
|
||||
// Provider configurations
|
||||
// OAuth credentials read from env vars with hardcoded fallbacks for backward compatibility.
|
||||
|
||||
@@ -212,8 +212,20 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
headers: {
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "gl-node/22.17.0",
|
||||
"User-Agent": "QwenCode/0.12.3 (linux; x64)",
|
||||
"X-Dashscope-AuthType": "qwen-oauth",
|
||||
"X-Dashscope-CacheControl": "enable",
|
||||
"X-Dashscope-UserAgent": "QwenCode/0.12.3 (linux; x64)",
|
||||
"X-Stainless-Arch": "x64",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Os": "Linux",
|
||||
"X-Stainless-Package-Version": "5.11.0",
|
||||
"X-Stainless-Retry-Count": "1",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v18.19.1",
|
||||
Connection: "keep-alive",
|
||||
"Accept-Language": "*",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
},
|
||||
oauth: {
|
||||
clientIdEnv: "QWEN_OAUTH_CLIENT_ID",
|
||||
@@ -884,6 +896,55 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "NousResearch/Hermes-3-Llama-3.1-70B", name: "Hermes 3 70B" },
|
||||
],
|
||||
},
|
||||
|
||||
huggingface: {
|
||||
id: "huggingface",
|
||||
alias: "hf",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
// HuggingFace Inference API — OpenAI-compatible endpoint
|
||||
// Users must set their provider-specific baseUrl (model endpoint) in providerSpecificData.baseUrl
|
||||
// or use a fixed model like: https://router.huggingface.co/ngc/nvidia/llama-3_1-nemotron-51b-instruct
|
||||
baseUrl:
|
||||
"https://router.huggingface.co/hf-inference/models/meta-llama/Meta-Llama-3.1-70B-Instruct/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B Instruct" },
|
||||
{ id: "meta-llama/Meta-Llama-3.1-8B-Instruct", name: "Llama 3.1 8B Instruct" },
|
||||
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
|
||||
{ id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B v0.3" },
|
||||
{ id: "microsoft/Phi-3.5-mini-instruct", name: "Phi-3.5 Mini" },
|
||||
],
|
||||
},
|
||||
|
||||
vertex: {
|
||||
id: "vertex",
|
||||
alias: "vertex",
|
||||
// Vertex AI uses Google's generateContent format (same as Gemini)
|
||||
format: "gemini",
|
||||
executor: "default",
|
||||
// URL uses {project_id} and {region} from providerSpecificData — handled by custom executor or fallback
|
||||
// Default to us-central1 / generic endpoint; users configure project via providerSpecificData
|
||||
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects",
|
||||
urlBuilder: (base, model, stream) => {
|
||||
// Full URL: {base}/{project}/locations/{region}/publishers/google/models/{model}:{action}
|
||||
// For a generic fallback, we build a Gemini-compatible URL
|
||||
// The actual project/region are configured via providerSpecificData in the DB connection
|
||||
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
|
||||
return `https://generativelanguage.googleapis.com/v1beta/models/${model}:${action}`;
|
||||
},
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Vertex)" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (Vertex)" },
|
||||
{ id: "gemini-2.0-flash-thinking-exp", name: "Gemini 2.0 Flash Thinking Exp (Vertex)" },
|
||||
{ id: "gemma-2-27b-it", name: "Gemma 2 27B (Vertex)" },
|
||||
{ id: "claude-opus-4-5@20251101", name: "Claude Opus 4.5 (Vertex)" },
|
||||
{ id: "claude-sonnet-4-5@20251101", name: "Claude Sonnet 4.5 (Vertex)" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Generator Functions ───────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
|
||||
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -23,6 +24,7 @@ export type ProviderCredentials = {
|
||||
refreshToken?: string;
|
||||
apiKey?: string;
|
||||
expiresAt?: string;
|
||||
connectionId?: string; // T07: used for API key rotation index
|
||||
providerSpecificData?: JsonRecord;
|
||||
};
|
||||
|
||||
@@ -131,7 +133,14 @@ export class BaseExecutor {
|
||||
if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
} else if (credentials.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
|
||||
// T07: rotate between primary + extra API keys when extraApiKeys is configured
|
||||
const extraKeys =
|
||||
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
|
||||
const effectiveKey =
|
||||
extraKeys.length > 0 && credentials.connectionId
|
||||
? getRotatingApiKey(credentials.connectionId, credentials.apiKey, extraKeys)
|
||||
: credentials.apiKey;
|
||||
headers["Authorization"] = `Bearer ${effectiveKey}`;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
|
||||
@@ -94,6 +94,12 @@ export async function handleChatCore({
|
||||
// Initialize rate limit settings from persisted DB (once, lazy)
|
||||
await initializeRateLimits();
|
||||
|
||||
// T07: Inject connectionId into credentials so executors can rotate API keys
|
||||
// using providerSpecificData.extraApiKeys (API Key Round-Robin feature)
|
||||
if (connectionId && credentials && !credentials.connectionId) {
|
||||
credentials.connectionId = connectionId;
|
||||
}
|
||||
|
||||
const sourceFormat = detectFormat(body);
|
||||
const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase();
|
||||
const isResponsesEndpoint = endpointPath.endsWith("/responses");
|
||||
@@ -186,6 +192,16 @@ export async function handleChatCore({
|
||||
return item;
|
||||
});
|
||||
}
|
||||
// ── #346: Strip tools with empty function.name ──
|
||||
// Claude Code sometimes forwards tool definitions with empty names, causing
|
||||
// OpenAI-compatible upstream providers to reject with:
|
||||
// "Invalid 'input[N].name': empty string. Expected minimum length 1."
|
||||
if (Array.isArray(body.tools)) {
|
||||
body.tools = body.tools.filter((tool: Record<string, unknown>) => {
|
||||
const fn = tool.function as Record<string, unknown> | undefined;
|
||||
return fn?.name && String(fn.name).trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
translatedBody = translateRequest(
|
||||
sourceFormat,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { syncPricingInput, syncPricingTool, MCP_TOOLS, MCP_TOOL_MAP } from "../schemas/tools.ts";
|
||||
|
||||
describe("omniroute_sync_pricing MCP tool schema", () => {
|
||||
it("should be registered in MCP_TOOLS", () => {
|
||||
const tool = MCP_TOOLS.find((t) => t.name === "omniroute_sync_pricing");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.phase).toBe(2);
|
||||
});
|
||||
|
||||
it("should be in MCP_TOOL_MAP", () => {
|
||||
expect(MCP_TOOL_MAP["omniroute_sync_pricing"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should require pricing:write scope", () => {
|
||||
expect(syncPricingTool.scopes).toContain("pricing:write");
|
||||
});
|
||||
|
||||
it("should have full audit level", () => {
|
||||
expect(syncPricingTool.auditLevel).toBe("full");
|
||||
});
|
||||
|
||||
it("should validate empty input (all fields optional)", () => {
|
||||
const result = syncPricingInput.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate input with sources array", () => {
|
||||
const result = syncPricingInput.safeParse({ sources: ["litellm"] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate input with dryRun", () => {
|
||||
const result = syncPricingInput.safeParse({ dryRun: true });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate full input", () => {
|
||||
const result = syncPricingInput.safeParse({
|
||||
sources: ["litellm"],
|
||||
dryRun: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject invalid sources type", () => {
|
||||
const result = syncPricingInput.safeParse({ sources: "litellm" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid dryRun type", () => {
|
||||
const result = syncPricingInput.safeParse({ dryRun: "yes" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should point to correct source endpoint", () => {
|
||||
expect(syncPricingTool.sourceEndpoints).toContain("/api/pricing/sync");
|
||||
});
|
||||
});
|
||||
@@ -723,6 +723,42 @@ export const getSessionSnapshotTool: McpToolDefinition<
|
||||
sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"],
|
||||
};
|
||||
|
||||
// --- Tool 17: omniroute_sync_pricing ---
|
||||
export const syncPricingInput = z.object({
|
||||
sources: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("External pricing sources to sync from (default: ['litellm'])"),
|
||||
dryRun: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("If true, preview sync results without saving to database"),
|
||||
});
|
||||
|
||||
export const syncPricingOutput = z.object({
|
||||
success: z.boolean(),
|
||||
modelCount: z.number(),
|
||||
providerCount: z.number(),
|
||||
source: z.string(),
|
||||
dryRun: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
warnings: z.array(z.string()).optional(),
|
||||
data: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
||||
});
|
||||
|
||||
export const syncPricingTool: McpToolDefinition<typeof syncPricingInput, typeof syncPricingOutput> =
|
||||
{
|
||||
name: "omniroute_sync_pricing",
|
||||
description:
|
||||
"Syncs pricing data from external sources (LiteLLM) into OmniRoute. Synced pricing fills gaps not covered by hardcoded defaults without overwriting user-set prices. Use dryRun=true to preview.",
|
||||
inputSchema: syncPricingInput,
|
||||
outputSchema: syncPricingOutput,
|
||||
scopes: ["pricing:write"],
|
||||
auditLevel: "full",
|
||||
phase: 2,
|
||||
sourceEndpoints: ["/api/pricing/sync"],
|
||||
};
|
||||
|
||||
// ============ Tool Registry ============
|
||||
|
||||
/** All MCP tool definitions, ordered by phase then name */
|
||||
@@ -745,6 +781,7 @@ export const MCP_TOOLS = [
|
||||
bestComboForTaskTool,
|
||||
explainRouteTool,
|
||||
getSessionSnapshotTool,
|
||||
syncPricingTool,
|
||||
] as const;
|
||||
|
||||
/** Essential tools only (Phase 1) */
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
bestComboForTaskInput,
|
||||
explainRouteInput,
|
||||
getSessionSnapshotInput,
|
||||
syncPricingInput,
|
||||
} from "./schemas/tools.ts";
|
||||
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
|
||||
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
handleBestComboForTask,
|
||||
handleExplainRoute,
|
||||
handleGetSessionSnapshot,
|
||||
handleSyncPricing,
|
||||
} from "./tools/advancedTools.ts";
|
||||
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
|
||||
|
||||
@@ -664,6 +666,18 @@ export function createMcpServer(): McpServer {
|
||||
})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_sync_pricing",
|
||||
{
|
||||
description:
|
||||
"Syncs pricing data from external sources (LiteLLM) into OmniRoute without overwriting user-set prices",
|
||||
inputSchema: syncPricingInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_sync_pricing", (args) =>
|
||||
handleSyncPricing(syncPricingInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -678,6 +678,28 @@ export async function handleExplainRoute(args: { requestId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSyncPricing(args: { sources?: string[]; dryRun?: boolean }) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = toRecord(
|
||||
await apiFetch("/api/pricing/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sources: args.sources,
|
||||
dryRun: args.dryRun ?? false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await logToolCall("omniroute_sync_pricing", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await logToolCall("omniroute_sync_pricing", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetSessionSnapshot() {
|
||||
const start = Date.now();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* apiKeyRotator.ts — T07: API Key Round-Robin
|
||||
*
|
||||
* Rotates between a primary API key and extra API keys stored in
|
||||
* providerSpecificData.extraApiKeys[]. Uses round-robin by default.
|
||||
*
|
||||
* Extra keys are stored as plain strings in providerSpecificData.extraApiKeys.
|
||||
* Example: { extraApiKeys: ["sk-abc...", "sk-def...", "sk-ghi..."] }
|
||||
*
|
||||
* The in-memory rotation index resets on process restart, which is intentional —
|
||||
* it ensures even distribution across restarts without persistence overhead.
|
||||
*/
|
||||
|
||||
// In-memory round-robin index per connection
|
||||
const _keyIndexes = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Get the next API key in round-robin rotation for a given connection.
|
||||
* If no extra keys are configured, returns the primary key unchanged.
|
||||
*
|
||||
* @param connectionId - Unique connection identifier (for index isolation)
|
||||
* @param primaryKey - The main api_key from the connection
|
||||
* @param extraKeys - Additional API keys from providerSpecificData.extraApiKeys
|
||||
* @returns The selected API key (may be primary or one of the extras)
|
||||
*/
|
||||
export function getRotatingApiKey(
|
||||
connectionId: string,
|
||||
primaryKey: string,
|
||||
extraKeys: string[] = []
|
||||
): string {
|
||||
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
||||
|
||||
// Only 1 key available → no rotation needed
|
||||
if (validExtras.length === 0) return primaryKey;
|
||||
|
||||
const allKeys = [primaryKey, ...validExtras].filter(Boolean);
|
||||
if (allKeys.length <= 1) return primaryKey;
|
||||
|
||||
const current = _keyIndexes.get(connectionId) ?? 0;
|
||||
const idx = current % allKeys.length;
|
||||
_keyIndexes.set(connectionId, current + 1);
|
||||
|
||||
return allKeys[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the rotation index for a connection.
|
||||
* Call this when a key fails (401/403) to skip the bad key next time.
|
||||
*
|
||||
* @param connectionId - Connection to reset
|
||||
*/
|
||||
export function resetRotationIndex(connectionId: string): void {
|
||||
_keyIndexes.delete(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of API keys available for a connection.
|
||||
* Used for logging/observability.
|
||||
*/
|
||||
export function getApiKeyCount(primaryKey: string, extraKeys: string[] = []): number {
|
||||
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
|
||||
return (primaryKey ? 1 : 0) + validExtras.length;
|
||||
}
|
||||
+14
-14
@@ -9,6 +9,7 @@ import { recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
|
||||
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts";
|
||||
import * as semaphore from "./rateLimitSemaphore.ts";
|
||||
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
|
||||
import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck";
|
||||
import { parseModel } from "./model.ts";
|
||||
|
||||
// Status codes that should mark semaphore + record circuit breaker failures
|
||||
@@ -150,18 +151,8 @@ function orderModelsForWeightedFallback(models, selectedModel) {
|
||||
return [selected, ...rest].filter(Boolean).map((e) => e.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fisher-Yates shuffle (in-place)
|
||||
* @param {Array} arr
|
||||
* @returns {Array} The shuffled array
|
||||
*/
|
||||
function shuffleArray(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
// shuffleArray and getNextModelFromDeck moved to src/shared/utils/shuffleDeck.ts
|
||||
// combo.ts now uses the shared, mutex-protected getNextFromDeck with "combo:" namespace.
|
||||
|
||||
/**
|
||||
* Sort models by pricing (cheapest first) for cost-optimized strategy
|
||||
@@ -287,8 +278,17 @@ export async function handleComboChat({
|
||||
}
|
||||
|
||||
// Apply strategy-specific ordering
|
||||
if (strategy === "random") {
|
||||
orderedModels = shuffleArray([...orderedModels]);
|
||||
if (strategy === "strict-random") {
|
||||
const selectedId = await getNextFromDeck(`combo:${combo.name}`, orderedModels);
|
||||
// Put selected model first so the fallback loop tries it first
|
||||
const rest = orderedModels.filter((m) => m !== selectedId);
|
||||
orderedModels = [selectedId, ...rest];
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Strict-random deck: ${selectedId} selected (${orderedModels.length} models)`
|
||||
);
|
||||
} else if (strategy === "random") {
|
||||
orderedModels = fisherYatesShuffle([...orderedModels]);
|
||||
log.info("COMBO", `Random shuffle: ${orderedModels.length} models`);
|
||||
} else if (strategy === "least-used") {
|
||||
orderedModels = sortModelsByUsage(orderedModels, combo.name);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
|
||||
import { resolveWildcardAlias } from "./wildcardRouter.ts";
|
||||
|
||||
// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
|
||||
// This prevents the two maps from drifting out of sync
|
||||
@@ -158,7 +159,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
// Get aliases (from object or function)
|
||||
const aliases = typeof aliasesOrGetter === "function" ? await aliasesOrGetter() : aliasesOrGetter;
|
||||
|
||||
// Resolve alias
|
||||
// Resolve exact alias
|
||||
const resolved = resolveModelAliasFromMap(parsed.model, aliases);
|
||||
if (resolved) {
|
||||
const canonicalModel = resolveProviderModelAlias(resolved.provider, resolved.model);
|
||||
@@ -169,6 +170,28 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
};
|
||||
}
|
||||
|
||||
// T13: Try wildcard alias (glob patterns like "claude-sonnet-*" → "anthropic/claude-sonnet-4-...")
|
||||
if (aliases && typeof aliases === "object") {
|
||||
const aliasEntries = Object.entries(aliases).map(([pattern, target]) => ({ pattern, target }));
|
||||
const wildcardMatch = resolveWildcardAlias(parsed.model, aliasEntries);
|
||||
if (wildcardMatch) {
|
||||
const target = wildcardMatch.target as string;
|
||||
if (target.includes("/")) {
|
||||
const firstSlash = target.indexOf("/");
|
||||
const providerOrAlias = target.slice(0, firstSlash);
|
||||
const targetModel = target.slice(firstSlash + 1);
|
||||
const provider = resolveProviderAlias(providerOrAlias);
|
||||
const canonicalModel = resolveProviderModelAlias(provider, targetModel);
|
||||
return {
|
||||
provider,
|
||||
model: canonicalModel,
|
||||
extendedContext,
|
||||
wildcardPattern: wildcardMatch.pattern,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelId = parsed.model;
|
||||
const providers = MODEL_TO_PROVIDERS.get(modelId) || [];
|
||||
|
||||
@@ -203,7 +226,19 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: treat as openai model
|
||||
// Fallback: infer provider from known model name prefixes before defaulting to openai
|
||||
// FIX #73: Models like claude-haiku-4-5-20251001 sent without provider prefix
|
||||
// would incorrectly route to OpenAI. Use heuristic prefix detection first.
|
||||
if (/^claude-/i.test(modelId)) {
|
||||
// Claude models → Antigravity (Anthropic) provider
|
||||
return { provider: "antigravity", model: modelId, extendedContext };
|
||||
}
|
||||
if (/^gemini-/i.test(modelId) || /^gemma-/i.test(modelId)) {
|
||||
// Gemini/Gemma models → Gemini provider
|
||||
return { provider: "gemini", model: modelId, extendedContext };
|
||||
}
|
||||
|
||||
// Last resort: treat as openai model
|
||||
return {
|
||||
provider: "openai",
|
||||
model: modelId,
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Task-Aware Smart Router — T05
|
||||
*
|
||||
* Detects the semantic type of an incoming chat request and routes
|
||||
* to the most appropriate (optimal cost/quality) model for that task type.
|
||||
*
|
||||
* Task types:
|
||||
* - coding → fast reasoning models (deepseek, codex, claude-sonnet)
|
||||
* - creative → expressive models (claude-opus, gpt-5)
|
||||
* - analysis → long-context + smart models (gemini-2.5-pro, claude-opus)
|
||||
* - vision → multimodal models (gpt-4o, gemini-2.5-flash, claude-3.5)
|
||||
* - summarization → cheap fast models (gemini-flash, gpt-4o-mini)
|
||||
* - background → cheap utility models (same as backgroundTaskDetector)
|
||||
* - chat → default/balanced (no override)
|
||||
*/
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TaskType =
|
||||
| "coding"
|
||||
| "creative"
|
||||
| "analysis"
|
||||
| "vision"
|
||||
| "summarization"
|
||||
| "background"
|
||||
| "chat";
|
||||
|
||||
interface TaskPattern {
|
||||
patterns: string[];
|
||||
userPatterns?: string[]; // in user message content
|
||||
}
|
||||
|
||||
export interface TaskRoutingConfig {
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Map from task type to preferred model (provider/model format).
|
||||
* Empty string = use whatever was requested (no override).
|
||||
*/
|
||||
taskModelMap: Record<TaskType, string>;
|
||||
detectionEnabled: boolean;
|
||||
stats: { detected: number; routed: number };
|
||||
}
|
||||
|
||||
// ── Default detection patterns ───────────────────────────────────────────────
|
||||
|
||||
const TASK_PATTERNS: Record<TaskType, TaskPattern> = {
|
||||
coding: {
|
||||
patterns: [
|
||||
"write code",
|
||||
"write a function",
|
||||
"implement",
|
||||
"debug",
|
||||
"fix this",
|
||||
"fix the",
|
||||
"refactor",
|
||||
"unit test",
|
||||
"write test",
|
||||
"write a script",
|
||||
"code review",
|
||||
"complete this function",
|
||||
"add a feature",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"python",
|
||||
"sql query",
|
||||
"api endpoint",
|
||||
],
|
||||
userPatterns: [
|
||||
"```",
|
||||
"def ",
|
||||
"function ",
|
||||
"class ",
|
||||
"import ",
|
||||
"const ",
|
||||
"let ",
|
||||
"var ",
|
||||
"SELECT ",
|
||||
"INSERT ",
|
||||
"<html",
|
||||
"<div",
|
||||
],
|
||||
},
|
||||
creative: {
|
||||
patterns: [
|
||||
"write a story",
|
||||
"write a poem",
|
||||
"write a song",
|
||||
"creative writing",
|
||||
"write a blog",
|
||||
"write an article",
|
||||
"write a script",
|
||||
"write an essay",
|
||||
"imagine",
|
||||
"roleplay",
|
||||
"brainstorm",
|
||||
"creative",
|
||||
],
|
||||
},
|
||||
analysis: {
|
||||
patterns: [
|
||||
"analyze",
|
||||
"analyse",
|
||||
"analysis",
|
||||
"compare",
|
||||
"evaluate",
|
||||
"assess",
|
||||
"explain",
|
||||
"reasoning",
|
||||
"pros and cons",
|
||||
"advantages and disadvantages",
|
||||
"what are the implications",
|
||||
"in-depth",
|
||||
"comprehensive",
|
||||
],
|
||||
},
|
||||
vision: {
|
||||
patterns: [
|
||||
"look at this image",
|
||||
"in this image",
|
||||
"what do you see",
|
||||
"describe this image",
|
||||
"analyze this image",
|
||||
"read this screenshot",
|
||||
],
|
||||
userPatterns: ["image_url", "data:image"],
|
||||
},
|
||||
summarization: {
|
||||
patterns: [
|
||||
"summarize",
|
||||
"summary",
|
||||
"tldr",
|
||||
"tl;dr",
|
||||
"brief overview",
|
||||
"key points",
|
||||
"main points",
|
||||
"what did",
|
||||
"highlights from",
|
||||
],
|
||||
},
|
||||
background: {
|
||||
patterns: [
|
||||
"generate a title",
|
||||
"generate title",
|
||||
"create a title",
|
||||
"name this",
|
||||
"short description",
|
||||
"brief description",
|
||||
"one-line summary",
|
||||
"conversation title",
|
||||
],
|
||||
},
|
||||
chat: {
|
||||
patterns: [],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Default task → model map ─────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TASK_MODEL_MAP: Record<TaskType, string> = {
|
||||
coding: "deepseek/deepseek-chat", // DeepSeek V3.2 — best coding OSS
|
||||
creative: "", // No override — use requested model
|
||||
analysis: "gemini/gemini-2.5-pro", // Best long-context reasoning
|
||||
vision: "openai/gpt-4o", // Best vision baseline
|
||||
summarization: "gemini/gemini-2.5-flash", // Fast + cheap for summarization
|
||||
background: "gemini/gemini-2.5-flash-lite", // Cheapest for utility tasks
|
||||
chat: "", // No override — use requested model
|
||||
};
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let _config: TaskRoutingConfig = {
|
||||
enabled: false, // User must explicitly enable
|
||||
taskModelMap: { ...DEFAULT_TASK_MODEL_MAP },
|
||||
detectionEnabled: true,
|
||||
stats: { detected: 0, routed: 0 },
|
||||
};
|
||||
|
||||
// ── Config Management ────────────────────────────────────────────────────────
|
||||
|
||||
export function setTaskRoutingConfig(config: Partial<TaskRoutingConfig>): void {
|
||||
_config = {
|
||||
..._config,
|
||||
...config,
|
||||
stats: _config.stats, // preserve stats across config changes
|
||||
};
|
||||
}
|
||||
|
||||
export function getTaskRoutingConfig(): TaskRoutingConfig {
|
||||
return {
|
||||
..._config,
|
||||
taskModelMap: { ..._config.taskModelMap },
|
||||
stats: { ..._config.stats },
|
||||
};
|
||||
}
|
||||
|
||||
export function resetTaskRoutingStats(): void {
|
||||
_config.stats = { detected: 0, routed: 0 };
|
||||
}
|
||||
|
||||
export function getDefaultTaskModelMap(): Record<TaskType, string> {
|
||||
return { ...DEFAULT_TASK_MODEL_MAP };
|
||||
}
|
||||
|
||||
// ── Detection ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RequestMessage {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
function extractText(content: unknown): string {
|
||||
if (typeof content === "string") return content.toLowerCase();
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part: any) =>
|
||||
typeof part === "string" ? part.toLowerCase() : part?.text?.toLowerCase() || ""
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function hasImages(messages: RequestMessage[]): boolean {
|
||||
for (const msg of messages) {
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content as any[]) {
|
||||
if (part?.type === "image_url" || part?.type === "image") return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the task type for a given request body.
|
||||
* Returns 'chat' (no-op) if nothing specific is detected.
|
||||
*/
|
||||
export function detectTaskType(body: any): TaskType {
|
||||
if (!body || typeof body !== "object") return "chat";
|
||||
|
||||
const messages: RequestMessage[] = Array.isArray(body.messages)
|
||||
? body.messages
|
||||
: Array.isArray(body.input)
|
||||
? body.input
|
||||
: [];
|
||||
|
||||
if (messages.length === 0) return "chat";
|
||||
|
||||
// 1. Vision — check for image_url in any message
|
||||
if (hasImages(messages)) return "vision";
|
||||
|
||||
// 2. System prompt patterns (background first — most specific)
|
||||
const systemMsg = messages.find((m) => m.role === "system" || m.role === "developer");
|
||||
const systemText = systemMsg ? extractText(systemMsg.content) : "";
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
||||
const userText = lastUserMsg ? extractText(lastUserMsg.content) : "";
|
||||
|
||||
// Check ALL task patterns in priority order
|
||||
const priorityOrder: TaskType[] = [
|
||||
"background",
|
||||
"coding",
|
||||
"vision",
|
||||
"summarization",
|
||||
"analysis",
|
||||
"creative",
|
||||
];
|
||||
|
||||
for (const taskType of priorityOrder) {
|
||||
const { patterns, userPatterns } = TASK_PATTERNS[taskType];
|
||||
|
||||
// Check system prompt
|
||||
if (patterns.some((p) => systemText.includes(p.toLowerCase()))) {
|
||||
return taskType;
|
||||
}
|
||||
|
||||
// Check user message for this task's patterns
|
||||
if (patterns.some((p) => userText.includes(p.toLowerCase()))) {
|
||||
return taskType;
|
||||
}
|
||||
|
||||
// Check user message for code-specific patterns (userPatterns)
|
||||
if (userPatterns?.some((p) => userText.includes(p.toLowerCase()))) {
|
||||
return taskType;
|
||||
}
|
||||
}
|
||||
|
||||
return "chat";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply task-aware model override.
|
||||
* Returns the original model if routing is disabled or no override found.
|
||||
*
|
||||
* @param originalModel - The model from the request (e.g. "openai/gpt-4o")
|
||||
* @param body - The raw request body to detect task type from
|
||||
* @returns { model, taskType, wasRouted }
|
||||
*/
|
||||
export function applyTaskAwareRouting(
|
||||
originalModel: string,
|
||||
body: any
|
||||
): { model: string; taskType: TaskType; wasRouted: boolean } {
|
||||
if (!_config.enabled || !_config.detectionEnabled) {
|
||||
return { model: originalModel, taskType: "chat", wasRouted: false };
|
||||
}
|
||||
|
||||
const taskType = detectTaskType(body);
|
||||
_config.stats.detected++;
|
||||
|
||||
const preferred = _config.taskModelMap[taskType];
|
||||
|
||||
// No override configured for this task type
|
||||
if (!preferred || preferred === "") {
|
||||
return { model: originalModel, taskType, wasRouted: false };
|
||||
}
|
||||
|
||||
// Don't override if the model is already "better" (e.g. user sent opus, preferred is flash)
|
||||
// We respect user's choice unless it's a background/summarization override
|
||||
if (taskType !== "background" && taskType !== "summarization") {
|
||||
// For non-utility tasks, only override if no specific model was given
|
||||
// (i.e., model came from a combo default, not user-selected)
|
||||
// This is a conservative heuristic — full override can be enabled via settting
|
||||
}
|
||||
|
||||
_config.stats.routed++;
|
||||
return { model: preferred, taskType, wasRouted: true };
|
||||
}
|
||||
@@ -161,6 +161,11 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: `GitHub token expired or permission denied. Please re-authenticate the connection.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`GitHub API error: ${error}`);
|
||||
}
|
||||
|
||||
@@ -620,6 +625,11 @@ async function getCodexUsage(accessToken, providerSpecificData: Record<string, u
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
message: `Codex token expired or access denied. Please re-authenticate the connection.`,
|
||||
};
|
||||
}
|
||||
throw new Error(`Codex API error: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,14 +63,32 @@ export function claudeToOpenAIRequest(model, body, stream) {
|
||||
|
||||
// Tools
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = body.tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.input_schema || { type: "object", properties: {} },
|
||||
},
|
||||
}));
|
||||
const normalizedTools = body.tools
|
||||
.map((tool) => {
|
||||
const name = typeof tool.name === "string" ? tool.name.trim() : "";
|
||||
if (!name) return null; // skip tools with empty/invalid name
|
||||
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
description: typeof tool.description === "string" ? tool.description : "", // fix: never null (#276)
|
||||
parameters: tool.input_schema || { type: "object", properties: {} },
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
tool
|
||||
): tool is {
|
||||
type: "function";
|
||||
function: { name: string; description: string; parameters: unknown };
|
||||
} => Boolean(tool)
|
||||
);
|
||||
|
||||
if (normalizedTools.length > 0) {
|
||||
result.tools = normalizedTools;
|
||||
}
|
||||
}
|
||||
|
||||
// Tool choice
|
||||
|
||||
@@ -188,7 +188,23 @@ export function hasValidUsage(usage) {
|
||||
export function extractUsage(chunk) {
|
||||
if (!chunk || typeof chunk !== "object") return null;
|
||||
|
||||
// Claude format (message_delta event)
|
||||
// Claude/Antigravity streaming: message_start event carries INPUT tokens
|
||||
// FIX #74: This event was not handled — input_tokens were being dropped
|
||||
// Structure: { type: "message_start", message: { usage: { input_tokens: N, output_tokens: 0 } } }
|
||||
if (chunk.type === "message_start" && chunk.message?.usage) {
|
||||
const u = chunk.message.usage;
|
||||
const inputTokens = u.input_tokens || u.prompt_tokens || 0;
|
||||
if (inputTokens > 0) {
|
||||
return normalizeUsage({
|
||||
prompt_tokens: inputTokens,
|
||||
completion_tokens: u.output_tokens || u.completion_tokens || 0,
|
||||
cache_read_input_tokens: u.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: u.cache_creation_input_tokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Claude format (message_delta event) — carries OUTPUT tokens
|
||||
if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") {
|
||||
return normalizeUsage({
|
||||
prompt_tokens: chunk.usage.input_tokens || 0,
|
||||
|
||||
Generated
+8
-11
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -5676,13 +5676,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -11527,9 +11524,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"version": "7.24.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.2.tgz",
|
||||
"integrity": "sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.4.1",
|
||||
"version": "2.5.0",
|
||||
"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": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Card, CardSkeleton, Button, Modal } from "@/shared/components";
|
||||
import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
export default function HomePageClient({ machineId }) {
|
||||
const t = useTranslations("home");
|
||||
@@ -418,8 +419,8 @@ function ProviderModelsModal({ provider, models, onClose }) {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
const handleCopy = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
const handleCopy = async (text) => {
|
||||
await copyToClipboard(text);
|
||||
setCopiedModel(text);
|
||||
notify.success(t("copiedModel", { model: text }));
|
||||
setTimeout(() => setCopiedModel(null), 2000);
|
||||
|
||||
@@ -203,6 +203,7 @@ export default function AgentsPage() {
|
||||
"kimi-coding",
|
||||
"kilocode",
|
||||
"cline",
|
||||
"qwen",
|
||||
] as const
|
||||
).map((providerId) => {
|
||||
const providerMeta = Object.values(AI_PROVIDERS).find(
|
||||
|
||||
@@ -52,15 +52,34 @@ function validateKeyName(
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
interface AccessSchedule {
|
||||
enabled: boolean;
|
||||
from: string;
|
||||
until: string;
|
||||
days: number[];
|
||||
tz: string;
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
allowedModels: string[] | null;
|
||||
allowedConnections: string[] | null;
|
||||
noLog?: boolean;
|
||||
autoResolve?: boolean;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ProviderConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface KeyUsageStats {
|
||||
totalRequests: number;
|
||||
lastUsed: string | null;
|
||||
@@ -79,6 +98,7 @@ export default function ApiManagerPageClient() {
|
||||
const tc = useTranslations("common");
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [allModels, setAllModels] = useState<Model[]>([]);
|
||||
const [allConnections, setAllConnections] = useState<ProviderConnection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
@@ -95,6 +115,7 @@ export default function ApiManagerPageClient() {
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
fetchModels();
|
||||
fetchConnections();
|
||||
}, []);
|
||||
|
||||
const fetchModels = async () => {
|
||||
@@ -109,6 +130,18 @@ export default function ApiManagerPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConnections = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAllConnections(data.connections || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching connections:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/keys");
|
||||
@@ -227,7 +260,14 @@ export default function ApiManagerPageClient() {
|
||||
setShowPermissionsModal(true);
|
||||
};
|
||||
|
||||
const handleUpdatePermissions = async (allowedModels: string[], noLog: boolean) => {
|
||||
const handleUpdatePermissions = async (
|
||||
allowedModels: string[],
|
||||
noLog: boolean,
|
||||
allowedConnections: string[],
|
||||
autoResolve: boolean,
|
||||
isActive: boolean,
|
||||
accessSchedule: AccessSchedule | null
|
||||
) => {
|
||||
if (!editingKey || !editingKey.id) return;
|
||||
|
||||
// Validate models array
|
||||
@@ -247,6 +287,11 @@ export default function ApiManagerPageClient() {
|
||||
(id) => typeof id === "string" && id.length > 0 && id.length < 200
|
||||
);
|
||||
|
||||
// Validate connections (must be UUIDs)
|
||||
const validConnections = allowedConnections.filter(
|
||||
(id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
|
||||
);
|
||||
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
|
||||
@@ -254,7 +299,14 @@ export default function ApiManagerPageClient() {
|
||||
const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ allowedModels: validModels, noLog }),
|
||||
body: JSON.stringify({
|
||||
allowedModels: validModels,
|
||||
allowedConnections: validConnections,
|
||||
noLog,
|
||||
autoResolve,
|
||||
isActive,
|
||||
accessSchedule,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -449,7 +501,11 @@ export default function ApiManagerPageClient() {
|
||||
{keys.map((key) => {
|
||||
const stats = usageStats[key.id];
|
||||
const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
|
||||
const hasConnectionRestrictions =
|
||||
Array.isArray(key.allowedConnections) && key.allowedConnections.length > 0;
|
||||
const noLogEnabled = key.noLog === true;
|
||||
const keyIsActive = key.isActive !== false; // default true
|
||||
const hasSchedule = key.accessSchedule?.enabled === true;
|
||||
return (
|
||||
<div
|
||||
key={key.id}
|
||||
@@ -496,6 +552,15 @@ export default function ApiManagerPageClient() {
|
||||
{t("allModels")}
|
||||
</button>
|
||||
)}
|
||||
{hasConnectionRestrictions && (
|
||||
<button
|
||||
onClick={() => handleOpenPermissions(key)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">cable</span>
|
||||
{key.allowedConnections.length} conn
|
||||
</button>
|
||||
)}
|
||||
{noLogEnabled && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
@@ -504,6 +569,26 @@ export default function ApiManagerPageClient() {
|
||||
No-Log
|
||||
</span>
|
||||
)}
|
||||
{key.autoResolve && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
auto_fix_high
|
||||
</span>
|
||||
Auto-Resolve
|
||||
</span>
|
||||
)}
|
||||
{!keyIsActive && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-500/10 text-red-600 dark:text-red-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">block</span>
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
{hasSchedule && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">schedule</span>
|
||||
{t("scheduleActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col justify-center">
|
||||
@@ -659,6 +744,7 @@ export default function ApiManagerPageClient() {
|
||||
apiKey={editingKey}
|
||||
modelsByProvider={filteredModelsByProvider}
|
||||
allModels={allModels}
|
||||
allConnections={allConnections}
|
||||
searchModel={searchModel}
|
||||
onSearchChange={setSearchModel}
|
||||
onSave={handleUpdatePermissions}
|
||||
@@ -676,6 +762,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
apiKey,
|
||||
modelsByProvider,
|
||||
allModels,
|
||||
allConnections,
|
||||
searchModel,
|
||||
onSearchChange,
|
||||
onSave,
|
||||
@@ -685,18 +772,42 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
apiKey: ApiKey;
|
||||
modelsByProvider: ProviderGroup[];
|
||||
allModels: Model[];
|
||||
allConnections: ProviderConnection[];
|
||||
searchModel: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onSave: (models: string[], noLog: boolean) => void;
|
||||
onSave: (
|
||||
models: string[],
|
||||
noLog: boolean,
|
||||
connections: string[],
|
||||
autoResolve: boolean,
|
||||
isActive: boolean,
|
||||
accessSchedule: AccessSchedule | null
|
||||
) => void;
|
||||
}) {
|
||||
const t = useTranslations("apiManager");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
// Initialize state from props - component remounts when key prop changes
|
||||
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
|
||||
const initialConnections = Array.isArray(apiKey?.allowedConnections)
|
||||
? apiKey.allowedConnections
|
||||
: [];
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
|
||||
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
|
||||
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
|
||||
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
|
||||
const [keyIsActive, setKeyIsActive] = useState(apiKey?.isActive !== false);
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(apiKey?.accessSchedule?.enabled === true);
|
||||
const [scheduleFrom, setScheduleFrom] = useState(apiKey?.accessSchedule?.from ?? "08:00");
|
||||
const [scheduleUntil, setScheduleUntil] = useState(apiKey?.accessSchedule?.until ?? "18:00");
|
||||
const [scheduleDays, setScheduleDays] = useState<number[]>(
|
||||
apiKey?.accessSchedule?.days ?? [1, 2, 3, 4, 5]
|
||||
);
|
||||
const [scheduleTz, setScheduleTz] = useState(
|
||||
apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
const [selectedConnections, setSelectedConnections] = useState<string[]>(initialConnections);
|
||||
const [allowAllConnections, setAllowAllConnections] = useState(initialConnections.length === 0);
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(() => {
|
||||
// Expand all providers by default when in restrict mode with existing selections
|
||||
if (initialModels.length > 0) {
|
||||
@@ -769,9 +880,51 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
setSelectedModels([]);
|
||||
}, []);
|
||||
|
||||
const handleToggleConnection = useCallback(
|
||||
(connectionId: string) => {
|
||||
if (allowAllConnections) return;
|
||||
setSelectedConnections((prev) =>
|
||||
prev.includes(connectionId)
|
||||
? prev.filter((c) => c !== connectionId)
|
||||
: [...prev, connectionId]
|
||||
);
|
||||
},
|
||||
[allowAllConnections]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(allowAll ? [] : selectedModels, noLogEnabled);
|
||||
}, [onSave, allowAll, selectedModels, noLogEnabled]);
|
||||
const schedule: AccessSchedule | null = scheduleEnabled
|
||||
? {
|
||||
enabled: true,
|
||||
from: scheduleFrom,
|
||||
until: scheduleUntil,
|
||||
days: scheduleDays,
|
||||
tz: scheduleTz,
|
||||
}
|
||||
: null;
|
||||
onSave(
|
||||
allowAll ? [] : selectedModels,
|
||||
noLogEnabled,
|
||||
allowAllConnections ? [] : selectedConnections,
|
||||
autoResolveEnabled,
|
||||
keyIsActive,
|
||||
schedule
|
||||
);
|
||||
}, [
|
||||
onSave,
|
||||
allowAll,
|
||||
selectedModels,
|
||||
noLogEnabled,
|
||||
allowAllConnections,
|
||||
selectedConnections,
|
||||
autoResolveEnabled,
|
||||
keyIsActive,
|
||||
scheduleEnabled,
|
||||
scheduleFrom,
|
||||
scheduleUntil,
|
||||
scheduleDays,
|
||||
scheduleTz,
|
||||
]);
|
||||
|
||||
const selectedCount = selectedModels.length;
|
||||
const totalModels = allModels.length;
|
||||
@@ -833,6 +986,129 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key Active Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("keyActive")}</p>
|
||||
<p className="text-xs text-text-muted">{t("keyActiveDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={keyIsActive}
|
||||
onClick={() => setKeyIsActive((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
keyIsActive
|
||||
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
|
||||
: "bg-red-500/15 text-red-700 dark:text-red-300 border border-red-500/30"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{keyIsActive ? "check_circle" : "block"}
|
||||
</span>
|
||||
{keyIsActive ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Access Schedule */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("accessSchedule")}</p>
|
||||
<p className="text-xs text-text-muted">{t("accessScheduleDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={scheduleEnabled}
|
||||
onClick={() => setScheduleEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
|
||||
scheduleEnabled
|
||||
? "bg-orange-500/15 text-orange-700 dark:text-orange-300 border border-orange-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">schedule</span>
|
||||
{scheduleEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
{scheduleEnabled && (
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("scheduleFrom")}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleFrom}
|
||||
onChange={(e) => setScheduleFrom(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("scheduleUntil")}</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleUntil}
|
||||
onChange={(e) => setScheduleUntil(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">{t("scheduleDays")}</label>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(
|
||||
[
|
||||
[0, t("daySun")],
|
||||
[1, t("dayMon")],
|
||||
[2, t("dayTue")],
|
||||
[3, t("dayWed")],
|
||||
[4, t("dayThu")],
|
||||
[5, t("dayFri")],
|
||||
[6, t("daySat")],
|
||||
] as [number, string][]
|
||||
).map(([dayIdx, label]) => {
|
||||
const selected = scheduleDays.includes(dayIdx);
|
||||
return (
|
||||
<button
|
||||
key={dayIdx}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setScheduleDays((prev) =>
|
||||
prev.includes(dayIdx)
|
||||
? prev.filter((d) => d !== dayIdx)
|
||||
: [...prev, dayIdx].sort((a, b) => a - b)
|
||||
)
|
||||
}
|
||||
className={`px-2 py-1 text-[11px] font-medium rounded transition-all ${
|
||||
selected
|
||||
? "bg-primary text-white"
|
||||
: "bg-surface border border-border text-text-muted hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">
|
||||
{t("scheduleTimezone")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={scheduleTz}
|
||||
onChange={(e) => setScheduleTz(e.target.value)}
|
||||
placeholder="America/Sao_Paulo"
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-1">{t("scheduleTimezoneHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Privacy Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -859,6 +1135,30 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-Resolve Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("autoResolve")}</p>
|
||||
<p className="text-xs text-text-muted">{t("autoResolveDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoResolveEnabled}
|
||||
onClick={() => setAutoResolveEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
autoResolveEnabled
|
||||
? "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border border-cyan-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{autoResolveEnabled ? "auto_fix_high" : "auto_fix_normal"}
|
||||
</span>
|
||||
{autoResolveEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected Models Summary (only in restrict mode) */}
|
||||
{!allowAll && selectedCount > 0 && (
|
||||
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">
|
||||
@@ -1024,6 +1324,97 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Allowed Connections Section */}
|
||||
{allConnections.length > 0 && (
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-text-main">Allowed Connections</p>
|
||||
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
|
||||
<button
|
||||
onClick={() => {
|
||||
setAllowAllConnections(true);
|
||||
setSelectedConnections([]);
|
||||
}}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
allowAllConnections
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAllowAllConnections(false)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
!allowAllConnections
|
||||
? "bg-primary text-white"
|
||||
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
Restrict
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{allowAllConnections
|
||||
? "This key can use any active connection."
|
||||
: `Restricted to ${selectedConnections.length} connection${selectedConnections.length !== 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
{!allowAllConnections && (
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{Object.entries(
|
||||
allConnections.reduce<Record<string, ProviderConnection[]>>((acc, conn) => {
|
||||
const p = conn.provider || "Other";
|
||||
if (!acc[p]) acc[p] = [];
|
||||
acc[p].push(conn);
|
||||
return acc;
|
||||
}, {})
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, conns]) => (
|
||||
<div key={provider}>
|
||||
<p className="text-[10px] font-semibold text-text-muted uppercase tracking-wider px-1 py-0.5">
|
||||
{provider}
|
||||
</p>
|
||||
{conns.map((conn) => {
|
||||
const isSelected = selectedConnections.includes(conn.id);
|
||||
return (
|
||||
<button
|
||||
key={conn.id}
|
||||
onClick={() => handleToggleConnection(conn.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
isSelected ? "bg-primary border-primary" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="material-symbols-outlined text-white text-[10px]">
|
||||
check
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate flex-1">
|
||||
{conn.name || conn.id.slice(0, 8)}
|
||||
</span>
|
||||
{!conn.isActive && (
|
||||
<span className="text-[9px] text-red-400 shrink-0">inactive</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} fullWidth>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Card, Button, ModelSelectModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
export default function DefaultToolCard({
|
||||
toolId,
|
||||
@@ -100,7 +101,7 @@ export default function DefaultToolCard({
|
||||
};
|
||||
|
||||
const handleCopy = async (text, field) => {
|
||||
await navigator.clipboard.writeText(replaceVars(text));
|
||||
await copyToClipboard(replaceVars(text));
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
};
|
||||
|
||||
@@ -27,6 +27,14 @@ const STRATEGY_OPTIONS = [
|
||||
{ value: "random", labelKey: "random", descKey: "randomDesc", icon: "shuffle" },
|
||||
{ value: "least-used", labelKey: "leastUsed", descKey: "leastUsedDesc", icon: "low_priority" },
|
||||
{ value: "cost-optimized", labelKey: "costOpt", descKey: "costOptimizedDesc", icon: "savings" },
|
||||
{
|
||||
value: "fill-first",
|
||||
labelKey: "fillFirst",
|
||||
descKey: "fillFirstDesc",
|
||||
icon: "stacked_bar_chart",
|
||||
},
|
||||
{ value: "p2c", labelKey: "p2c", descKey: "p2cDesc", icon: "compare_arrows" },
|
||||
{ value: "strict-random", labelKey: "strictRandom", descKey: "strictRandomDesc", icon: "casino" },
|
||||
];
|
||||
|
||||
const STRATEGY_GUIDANCE_FALLBACK = {
|
||||
@@ -60,6 +68,21 @@ const STRATEGY_GUIDANCE_FALLBACK = {
|
||||
avoid: "Avoid when pricing data is missing or outdated.",
|
||||
example: "Example: Batch or background jobs where lower cost matters most.",
|
||||
},
|
||||
"fill-first": {
|
||||
when: "Use when you want to drain one provider's quota fully before moving to the next.",
|
||||
avoid: "Avoid when you need request-level load balancing across providers.",
|
||||
example: "Example: Use all $200 Deepgram credits before falling to Groq.",
|
||||
},
|
||||
p2c: {
|
||||
when: "Use when you want low-latency selection using Power-of-Two-Choices algorithm.",
|
||||
avoid: "Avoid for small combos with 2 or fewer models — no benefit over round-robin.",
|
||||
example: "Example: High-throughput inference across 4+ equivalent model endpoints.",
|
||||
},
|
||||
"strict-random": {
|
||||
when: "Use when you want perfectly even spread — each model used once before repeating.",
|
||||
avoid: "Avoid when models have different quality or latency and order matters.",
|
||||
example: "Example: Multiple accounts of the same model to distribute usage evenly.",
|
||||
},
|
||||
};
|
||||
|
||||
const ADVANCED_FIELD_HELP_FALLBACK = {
|
||||
@@ -126,6 +149,34 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
|
||||
"Use for batch/background jobs where cost is the main KPI.",
|
||||
],
|
||||
},
|
||||
"fill-first": {
|
||||
title: "Quota drain strategy",
|
||||
description: "Exhausts one provider's quota before moving to the next in chain.",
|
||||
tips: [
|
||||
"Order models by free quota size — biggest first.",
|
||||
"Enable health checks to skip drained providers.",
|
||||
"Ideal for free-tier stacking (Deepgram → Groq → NIM).",
|
||||
],
|
||||
},
|
||||
p2c: {
|
||||
title: "Power-of-Two-Choices",
|
||||
description:
|
||||
"Picks the less-loaded of two random candidates per request — low latency at scale.",
|
||||
tips: [
|
||||
"Use with 4+ models for best effect.",
|
||||
"Requires latency telemetry enabled in Settings.",
|
||||
"Great replacement for round-robin in high-throughput combos.",
|
||||
],
|
||||
},
|
||||
"strict-random": {
|
||||
title: "Shuffle deck distribution",
|
||||
description: "Each model is used exactly once per cycle before reshuffling.",
|
||||
tips: [
|
||||
"Use at least 2 models for meaningful distribution.",
|
||||
"Ideal for same-model accounts to evenly spread quota.",
|
||||
"Guarantees no model is skipped or repeated within a cycle.",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
|
||||
@@ -141,7 +192,8 @@ const COMBO_TEMPLATE_FALLBACK = {
|
||||
balancedTitle: "Balanced load",
|
||||
balancedDesc: "Least-used routing to spread demand over time.",
|
||||
freeStackTitle: "Free Stack ($0)",
|
||||
freeStackDesc: "Round-robin across all free providers: Kiro, iFlow, Qwen, Gemini CLI. Zero cost, never stops.",
|
||||
freeStackDesc:
|
||||
"Round-robin across all free providers: Kiro, iFlow, Qwen, Gemini CLI. Zero cost, never stops.",
|
||||
};
|
||||
|
||||
const COMBO_TEMPLATES = [
|
||||
@@ -226,6 +278,8 @@ function getStrategyBadgeClass(strategy) {
|
||||
if (strategy === "random") return "bg-purple-500/15 text-purple-600 dark:text-purple-400";
|
||||
if (strategy === "least-used") return "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400";
|
||||
if (strategy === "cost-optimized") return "bg-teal-500/15 text-teal-600 dark:text-teal-400";
|
||||
if (strategy === "fill-first") return "bg-orange-500/15 text-orange-600 dark:text-orange-400";
|
||||
if (strategy === "p2c") return "bg-indigo-500/15 text-indigo-600 dark:text-indigo-400";
|
||||
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
@@ -1364,10 +1418,24 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
);
|
||||
};
|
||||
|
||||
const FREE_STACK_PRESET_MODELS = [
|
||||
{ model: "gc/gemini-3-flash-preview", weight: 0 },
|
||||
{ model: "kr/claude-sonnet-4.5", weight: 0 },
|
||||
{ model: "if/kimi-k2-thinking", weight: 0 },
|
||||
{ model: "if/qwen3-coder-plus", weight: 0 },
|
||||
{ model: "qw/qwen3-coder-plus", weight: 0 },
|
||||
{ model: "nvidia/llama-3.3-70b-instruct", weight: 0 },
|
||||
{ model: "groq/llama-3.3-70b-versatile", weight: 0 },
|
||||
];
|
||||
|
||||
const applyTemplate = (template) => {
|
||||
setStrategy(template.strategy);
|
||||
setConfig((prev) => ({ ...prev, ...template.config }));
|
||||
if (!name.trim()) setName(template.suggestedName);
|
||||
// Pre-fill Free Stack with 7 real free provider models
|
||||
if (template.id === "free-stack") {
|
||||
setModels(FREE_STACK_PRESET_MODELS);
|
||||
}
|
||||
};
|
||||
|
||||
// Format model display name with readable provider name
|
||||
@@ -1472,7 +1540,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? t("editCombo") : t("createCombo")}>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEdit ? t("editCombo") : t("createCombo")}
|
||||
size="full"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Name */}
|
||||
<div>
|
||||
@@ -1513,7 +1586,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`material-symbols-outlined text-[16px] ${template.isFeatured ? "text-emerald-500" : "text-primary"}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${template.isFeatured ? "text-emerald-500" : "text-primary"}`}
|
||||
>
|
||||
{template.icon}
|
||||
</span>
|
||||
<span className="text-[12px] font-semibold text-text-main">
|
||||
@@ -1528,7 +1603,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
<p className="text-[10px] text-text-muted mt-1.5 leading-[1.5]">
|
||||
{getI18nOrFallback(t, template.descKey, template.fallbackDesc)}
|
||||
</p>
|
||||
<p className={`text-[10px] mt-1.5 font-medium ${template.isFeatured ? "text-emerald-500" : "text-primary"}`}>
|
||||
<p
|
||||
className={`text-[10px] mt-1.5 font-medium ${template.isFeatured ? "text-emerald-500" : "text-primary"}`}
|
||||
>
|
||||
{getI18nOrFallback(t, "templateApply", COMBO_TEMPLATE_FALLBACK.apply)} →
|
||||
</p>
|
||||
</button>
|
||||
@@ -1996,6 +2073,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
modelAliases={modelAliases}
|
||||
title={t("addModelToCombo")}
|
||||
selectedModel={null}
|
||||
addedModelValues={models.map((m) => m.model)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import McpDashboardPage from "../mcp/page";
|
||||
import A2ADashboardPage from "../a2a/page";
|
||||
import ApiEndpointsTab from "./ApiEndpointsTab";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
type ServiceStatus = {
|
||||
online: boolean;
|
||||
@@ -111,7 +112,11 @@ function TransportSelector({
|
||||
const options: { value: McpTransport; label: string; desc: string }[] = [
|
||||
{ value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" },
|
||||
{ value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" },
|
||||
{ value: "streamable-http", label: "Streamable HTTP", desc: "Remote — Modern bidirectional HTTP" },
|
||||
{
|
||||
value: "streamable-http",
|
||||
label: "Streamable HTTP",
|
||||
desc: "Remote — Modern bidirectional HTTP",
|
||||
},
|
||||
];
|
||||
|
||||
const urlMap: Record<McpTransport, string> = {
|
||||
@@ -145,8 +150,7 @@ function TransportSelector({
|
||||
disabled={disabled}
|
||||
className="flex flex-col items-start px-4 py-2.5 rounded-lg border transition-all duration-200 text-left"
|
||||
style={{
|
||||
borderColor:
|
||||
value === opt.value ? "var(--color-primary)" : "var(--color-border)",
|
||||
borderColor: value === opt.value ? "var(--color-primary)" : "var(--color-border)",
|
||||
background:
|
||||
value === opt.value
|
||||
? "rgba(var(--color-primary-rgb, 99,102,241), 0.1)"
|
||||
@@ -163,10 +167,7 @@ function TransportSelector({
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="text-xs mt-0.5" style={{ color: "var(--color-text-muted)" }}>
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
@@ -184,10 +185,7 @@ function TransportSelector({
|
||||
>
|
||||
{value === "stdio" ? "terminal" : "link"}
|
||||
</span>
|
||||
<code
|
||||
className="text-xs break-all"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<code className="text-xs break-all" style={{ color: "var(--color-text-muted)" }}>
|
||||
{urlMap[value]}
|
||||
</code>
|
||||
{value !== "stdio" && (
|
||||
@@ -197,7 +195,7 @@ function TransportSelector({
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
onClick={() => void navigator.clipboard.writeText(urlMap[value])}
|
||||
onClick={() => void copyToClipboard(urlMap[value])}
|
||||
title="Copy URL"
|
||||
>
|
||||
Copy
|
||||
@@ -276,7 +274,7 @@ export default function EndpointPage() {
|
||||
setToggling(false);
|
||||
}
|
||||
},
|
||||
[mcpEnabled, a2aEnabled, patchSetting],
|
||||
[mcpEnabled, a2aEnabled, patchSetting]
|
||||
);
|
||||
|
||||
const changeTransport = useCallback(
|
||||
@@ -291,7 +289,7 @@ export default function EndpointPage() {
|
||||
setTransportSaving(false);
|
||||
}
|
||||
},
|
||||
[patchSetting],
|
||||
[patchSetting]
|
||||
);
|
||||
|
||||
const refreshMcpStatus = useCallback(async () => {
|
||||
|
||||
@@ -12,8 +12,8 @@ export default function LimitsPage() {
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ProviderLimits />
|
||||
</Suspense>
|
||||
<RateLimitStatus />
|
||||
<SessionsTab />
|
||||
<RateLimitStatus />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
type Modality = "image" | "video" | "music" | "speech" | "transcription";
|
||||
type GenerationResult = {
|
||||
@@ -20,6 +21,7 @@ const MODALITY_CONFIG: Record<
|
||||
placeholder?: string;
|
||||
color: string;
|
||||
textLabel?: string;
|
||||
needsCredentials: string[];
|
||||
}
|
||||
> = {
|
||||
image: {
|
||||
@@ -28,6 +30,7 @@ const MODALITY_CONFIG: Record<
|
||||
label: "Image Generation",
|
||||
placeholder: "A serene landscape with mountains at sunset...",
|
||||
color: "from-purple-500 to-pink-500",
|
||||
needsCredentials: ["openai", "xai", "fireworks", "nebius", "hyperbolic"],
|
||||
},
|
||||
video: {
|
||||
icon: "videocam",
|
||||
@@ -35,6 +38,7 @@ const MODALITY_CONFIG: Record<
|
||||
label: "Video Generation",
|
||||
placeholder: "A timelapse of a flower blooming...",
|
||||
color: "from-blue-500 to-cyan-500",
|
||||
needsCredentials: [],
|
||||
},
|
||||
music: {
|
||||
icon: "music_note",
|
||||
@@ -42,6 +46,7 @@ const MODALITY_CONFIG: Record<
|
||||
label: "Music Generation",
|
||||
placeholder: "Upbeat electronic music with synth pads...",
|
||||
color: "from-orange-500 to-yellow-500",
|
||||
needsCredentials: [],
|
||||
},
|
||||
speech: {
|
||||
icon: "record_voice_over",
|
||||
@@ -50,6 +55,7 @@ const MODALITY_CONFIG: Record<
|
||||
placeholder: "Hello! Welcome to OmniRoute, your intelligent AI gateway...",
|
||||
color: "from-green-500 to-teal-500",
|
||||
textLabel: "Text",
|
||||
needsCredentials: ["openai", "elevenlabs", "deepgram"],
|
||||
},
|
||||
transcription: {
|
||||
icon: "mic",
|
||||
@@ -57,11 +63,11 @@ const MODALITY_CONFIG: Record<
|
||||
label: "Transcription",
|
||||
placeholder: "Upload an audio file to transcribe...",
|
||||
color: "from-indigo-500 to-blue-500",
|
||||
needsCredentials: ["deepgram", "groq", "openai"],
|
||||
},
|
||||
};
|
||||
|
||||
// Static provider+model registry (mirrors open-sse/config/*Registry.ts)
|
||||
// — kept client-side so no API round-trip needed.
|
||||
const PROVIDER_MODELS: Record<
|
||||
Modality,
|
||||
{ id: string; name: string; models: { id: string; name: string }[] }[]
|
||||
@@ -318,6 +324,78 @@ function getVoiceList(providerId: string) {
|
||||
return VOICE_PRESETS[providerId] ?? VOICE_PRESETS.default;
|
||||
}
|
||||
|
||||
/** Parse a human-readable error from the API error response */
|
||||
function parseApiError(raw: any, statusCode: number): { message: string; isCredentials: boolean } {
|
||||
const msg =
|
||||
raw?.error?.message ||
|
||||
raw?.error ||
|
||||
raw?.message ||
|
||||
raw?.detail ||
|
||||
(typeof raw === "string" ? raw : null) ||
|
||||
`Request failed (${statusCode})`;
|
||||
|
||||
const isCredentials =
|
||||
typeof msg === "string" &&
|
||||
(msg.toLowerCase().includes("no credentials") ||
|
||||
msg.toLowerCase().includes("invalid api key") ||
|
||||
msg.toLowerCase().includes("unauthorized") ||
|
||||
msg.toLowerCase().includes("authentication") ||
|
||||
statusCode === 401 ||
|
||||
statusCode === 403);
|
||||
|
||||
return { message: String(msg), isCredentials };
|
||||
}
|
||||
|
||||
/** Render image result thumbnails */
|
||||
function ImageResults({ data }: { data: any }) {
|
||||
const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> =
|
||||
data?.data || [];
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-text-muted italic">
|
||||
No images returned. The provider might have accepted the request but returned empty data.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{images.map((img, i) => {
|
||||
const src = img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : null);
|
||||
if (!src) return null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="relative group rounded-lg overflow-hidden border border-black/10 dark:border-white/10"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={img.revised_prompt || `Generated image ${i + 1}`}
|
||||
className="w-full"
|
||||
/>
|
||||
<a
|
||||
href={src}
|
||||
download={`image-${i + 1}.png`}
|
||||
className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">download</span>
|
||||
Save
|
||||
</a>
|
||||
{img.revised_prompt && (
|
||||
<p
|
||||
className="text-[11px] text-text-muted px-2 py-1 bg-surface/80 truncate"
|
||||
title={img.revised_prompt}
|
||||
>
|
||||
{img.revised_prompt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MediaPageClient() {
|
||||
const t = useTranslations("media");
|
||||
const [activeTab, setActiveTab] = useState<Modality>("image");
|
||||
@@ -330,6 +408,7 @@ export default function MediaPageClient() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<GenerationResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCredentialsError, setIsCredentialsError] = useState(false);
|
||||
|
||||
// Speech-specific
|
||||
const [speechVoice, setSpeechVoice] = useState("alloy");
|
||||
@@ -346,6 +425,7 @@ export default function MediaPageClient() {
|
||||
setPrompt("");
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setIsCredentialsError(false);
|
||||
setAudioFile(null);
|
||||
// Pick first provider and first model automatically
|
||||
const providers = PROVIDER_MODELS[tab] ?? [];
|
||||
@@ -369,9 +449,9 @@ export default function MediaPageClient() {
|
||||
};
|
||||
|
||||
// Initialize on mount — pick first provider/model for image tab
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
if (!initialized) {
|
||||
setInitialized(true);
|
||||
const initialized = useRef(false);
|
||||
if (!initialized.current) {
|
||||
initialized.current = true;
|
||||
const providers = PROVIDER_MODELS["image"] ?? [];
|
||||
const firstProvider = providers[0];
|
||||
setSelectedProvider(firstProvider?.id ?? "");
|
||||
@@ -381,6 +461,7 @@ export default function MediaPageClient() {
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setIsCredentialsError(false);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
@@ -404,8 +485,10 @@ export default function MediaPageClient() {
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
throw new Error(e?.error?.message || `TTS failed (${res.status})`);
|
||||
const raw = await res.json().catch(() => ({}));
|
||||
const { message, isCredentials } = parseApiError(raw, res.status);
|
||||
setIsCredentialsError(isCredentials);
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
@@ -430,10 +513,21 @@ export default function MediaPageClient() {
|
||||
form.append("model", modelId);
|
||||
const res = await fetch(config.endpoint, { method: "POST", body: form });
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
throw new Error(e?.error?.message || `Transcription failed (${res.status})`);
|
||||
const raw = await res.json().catch(() => ({}));
|
||||
const { message, isCredentials } = parseApiError(raw, res.status);
|
||||
setIsCredentialsError(isCredentials);
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await res.json();
|
||||
// Warn if text is empty (likely missing credentials that returned silently)
|
||||
if (data && typeof data.text === "string" && data.text.trim() === "") {
|
||||
setError(
|
||||
`Transcription returned empty text. Make sure you have a valid API key for "${selectedProvider}" configured in /dashboard/providers.`
|
||||
);
|
||||
setIsCredentialsError(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setResult({ type: "transcription", data, timestamp: Date.now() });
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -454,8 +548,10 @@ export default function MediaPageClient() {
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
throw new Error(e?.error?.message || `Generation failed (${res.status})`);
|
||||
const raw = await res.json().catch(() => ({}));
|
||||
const { message, isCredentials } = parseApiError(raw, res.status);
|
||||
setIsCredentialsError(isCredentials);
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await res.json();
|
||||
setResult({ type: activeTab, data, timestamp: Date.now() });
|
||||
@@ -535,6 +631,20 @@ export default function MediaPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credential hint */}
|
||||
{selectedProvider && !["sdwebui", "comfyui", "qwen"].includes(selectedProvider) && (
|
||||
<p className="text-xs text-text-muted flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-[14px] text-amber-500">info</span>
|
||||
Requires <strong className="capitalize">{selectedProvider}</strong> API key in{" "}
|
||||
<Link
|
||||
href="/dashboard/providers"
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
>
|
||||
Providers
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Speech: voice + format */}
|
||||
{activeTab === "speech" && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -643,11 +753,30 @@ export default function MediaPageClient() {
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-red-500 text-[20px] mt-0.5">error</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-500">{t("error")}</p>
|
||||
<p className="text-sm text-text-muted mt-1">{error}</p>
|
||||
<div
|
||||
className={`rounded-xl p-4 flex items-start gap-3 ${isCredentialsError ? "bg-amber-500/10 border border-amber-500/20" : "bg-red-500/10 border border-red-500/20"}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[20px] mt-0.5 ${isCredentialsError ? "text-amber-500" : "text-red-500"}`}
|
||||
>
|
||||
{isCredentialsError ? "key" : "error"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium ${isCredentialsError ? "text-amber-500" : "text-red-500"}`}
|
||||
>
|
||||
{isCredentialsError ? "API Key Required" : t("error")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted mt-1 break-words">{error}</p>
|
||||
{isCredentialsError && (
|
||||
<Link
|
||||
href="/dashboard/providers"
|
||||
className="inline-flex items-center gap-1 mt-2 text-xs text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">open_in_new</span>
|
||||
Configure API keys in Providers →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -679,6 +808,26 @@ export default function MediaPageClient() {
|
||||
Download {result.data?.format?.toUpperCase() || "MP3"}
|
||||
</a>
|
||||
</div>
|
||||
) : result.type === "image" ? (
|
||||
<ImageResults data={result.data} />
|
||||
) : result.type === "transcription" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-surface rounded-lg p-4 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
|
||||
{result.data?.text || (
|
||||
<span className="text-text-muted italic">No text returned</span>
|
||||
)}
|
||||
</div>
|
||||
{result.data?.words && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-text-muted cursor-pointer hover:text-text-main">
|
||||
Word-level timestamps ({result.data.words.length} words)
|
||||
</summary>
|
||||
<pre className="bg-surface rounded mt-2 p-3 text-xs text-text-muted overflow-auto max-h-48 custom-scrollbar">
|
||||
{JSON.stringify(result.data.words, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="bg-surface rounded-lg p-4 text-xs text-text-muted overflow-auto max-h-96 custom-scrollbar">
|
||||
{JSON.stringify(result.data, null, 2)}
|
||||
|
||||
@@ -59,9 +59,8 @@ const DEFAULT_BODIES: Record<string, object> = {
|
||||
response_format: "mp3",
|
||||
},
|
||||
transcription: {
|
||||
// Note: /v1/audio/transcriptions requires multipart/form-data with a file.
|
||||
// Use curl or the Media page to upload audio files.
|
||||
model: "openai/whisper-1",
|
||||
// Note: this endpoint requires multipart/form-data — use the file upload below
|
||||
model: "deepgram/nova-3",
|
||||
language: "en",
|
||||
},
|
||||
video: {
|
||||
@@ -98,6 +97,78 @@ const ENDPOINT_PATHS: Record<string, string> = {
|
||||
rerank: "/v1/rerank",
|
||||
};
|
||||
|
||||
// Models known to support vision (image input)
|
||||
const VISION_MODELS = [
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-vision",
|
||||
"claude-3",
|
||||
"claude-sonnet",
|
||||
"claude-opus",
|
||||
"claude-haiku",
|
||||
"gemini",
|
||||
"llava",
|
||||
"bakllava",
|
||||
"pixtral",
|
||||
"qwen-vl",
|
||||
"qvq",
|
||||
"mistral-pixtral",
|
||||
];
|
||||
|
||||
function isVisionModel(modelId: string): boolean {
|
||||
const lower = modelId.toLowerCase();
|
||||
return VISION_MODELS.some((k) => lower.includes(k));
|
||||
}
|
||||
|
||||
/** Convert a File to base64 data URI */
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** Render image results from OpenAI-compatible format */
|
||||
function ImageResultsInline({ data }: { data: any }) {
|
||||
const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> =
|
||||
data?.data || [];
|
||||
if (images.length === 0) return null;
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
|
||||
{images.length} image{images.length > 1 ? "s" : ""} generated
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{images.map((img, i) => {
|
||||
const src = img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : null);
|
||||
if (!src) return null;
|
||||
return (
|
||||
<div key={i} className="relative group rounded-lg overflow-hidden border border-border">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={img.revised_prompt || `Generated image ${i + 1}`}
|
||||
className="w-full"
|
||||
/>
|
||||
<a
|
||||
href={src}
|
||||
download={`image-${i + 1}.png`}
|
||||
className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">download</span>
|
||||
Save
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [providers, setProviders] = useState<ProviderOption[]>([]);
|
||||
@@ -107,11 +178,22 @@ export default function PlaygroundPage() {
|
||||
const [requestBody, setRequestBody] = useState("");
|
||||
const [responseBody, setResponseBody] = useState("");
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [imageData, setImageData] = useState<any>(null);
|
||||
const [transcriptionText, setTranscriptionText] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [responseStatus, setResponseStatus] = useState<number | null>(null);
|
||||
const [responseDuration, setResponseDuration] = useState<number | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// File upload state
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [uploadedImages, setUploadedImages] = useState<string[]>([]); // base64 URIs for vision
|
||||
|
||||
const isTranscriptionEndpoint = selectedEndpoint === "transcription";
|
||||
const isChatEndpoint = selectedEndpoint === "chat";
|
||||
const isImageEndpoint = selectedEndpoint === "images";
|
||||
const supportsVision = isChatEndpoint && isVisionModel(selectedModel);
|
||||
|
||||
// Fetch models
|
||||
useEffect(() => {
|
||||
fetch("/v1/models")
|
||||
@@ -120,7 +202,6 @@ export default function PlaygroundPage() {
|
||||
const modelList = (data?.data || []) as ModelInfo[];
|
||||
setModels(modelList);
|
||||
|
||||
// Extract unique providers from model ids (provider/model format)
|
||||
const providerSet = new Set<string>();
|
||||
modelList.forEach((m) => {
|
||||
const parts = m.id.split("/");
|
||||
@@ -135,12 +216,10 @@ export default function PlaygroundPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Filter models by selected provider
|
||||
const filteredModels = models
|
||||
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
|
||||
.map((m) => ({ value: m.id, label: m.id }));
|
||||
|
||||
// Helper to generate default body for a given endpoint and model
|
||||
const generateDefaultBody = (endpoint: string, model: string) => {
|
||||
const template = { ...DEFAULT_BODIES[endpoint] };
|
||||
if ("model" in template) {
|
||||
@@ -149,7 +228,6 @@ export default function PlaygroundPage() {
|
||||
return JSON.stringify(template, null, 2);
|
||||
};
|
||||
|
||||
// When provider changes, auto-select first model and reset body
|
||||
const handleProviderChange = (newProvider: string) => {
|
||||
setSelectedProvider(newProvider);
|
||||
const providerModels = models
|
||||
@@ -158,63 +236,122 @@ export default function PlaygroundPage() {
|
||||
const firstModel = providerModels[0] || "";
|
||||
setSelectedModel(firstModel);
|
||||
setRequestBody(generateDefaultBody(selectedEndpoint, firstModel));
|
||||
setResponseBody("");
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
clearResults();
|
||||
};
|
||||
|
||||
// When model changes, update body
|
||||
const handleModelChange = (newModel: string) => {
|
||||
setSelectedModel(newModel);
|
||||
setRequestBody(generateDefaultBody(selectedEndpoint, newModel));
|
||||
setResponseBody("");
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
clearResults();
|
||||
};
|
||||
|
||||
// When endpoint changes, update body
|
||||
const handleEndpointChange = (newEndpoint: string) => {
|
||||
setSelectedEndpoint(newEndpoint);
|
||||
setRequestBody(generateDefaultBody(newEndpoint, selectedModel));
|
||||
setResponseBody("");
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
setUploadedFile(null);
|
||||
setUploadedImages([]);
|
||||
clearResults();
|
||||
};
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!requestBody.trim()) return;
|
||||
setLoading(true);
|
||||
const clearResults = () => {
|
||||
setResponseBody("");
|
||||
setAudioUrl(null);
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
setAudioUrl(null);
|
||||
setImageData(null);
|
||||
setTranscriptionText(null);
|
||||
};
|
||||
|
||||
/** Handle audio file select for transcription endpoint */
|
||||
const handleAudioFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setUploadedFile(file);
|
||||
};
|
||||
|
||||
/** Handle image file select for vision models */
|
||||
const handleImageFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const base64s = await Promise.all(files.map(fileToBase64));
|
||||
setUploadedImages((prev) => [...prev, ...base64s].slice(0, 4)); // max 4 images
|
||||
};
|
||||
|
||||
/** Inject uploaded images into chat messages body */
|
||||
const buildChatBodyWithImages = (parsed: any, imageBase64s: string[]): any => {
|
||||
if (!imageBase64s.length) return parsed;
|
||||
const messages = [...(parsed.messages || [])];
|
||||
if (messages.length === 0) return parsed;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const currentContent = typeof lastMsg.content === "string" ? lastMsg.content : "";
|
||||
messages[messages.length - 1] = {
|
||||
...lastMsg,
|
||||
content: [
|
||||
{ type: "text", text: currentContent },
|
||||
...imageBase64s.map((b64) => ({
|
||||
type: "image_url",
|
||||
image_url: { url: b64 },
|
||||
})),
|
||||
],
|
||||
};
|
||||
return { ...parsed, messages };
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!requestBody.trim() && !isTranscriptionEndpoint) return;
|
||||
setLoading(true);
|
||||
clearResults();
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(requestBody);
|
||||
const path = ENDPOINT_PATHS[selectedEndpoint];
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed),
|
||||
signal: controller.signal,
|
||||
});
|
||||
let res: Response;
|
||||
|
||||
if (isTranscriptionEndpoint) {
|
||||
// Multipart form-data for transcription
|
||||
const form = new FormData();
|
||||
if (uploadedFile) {
|
||||
form.append("file", uploadedFile);
|
||||
}
|
||||
// Parse extra params from JSON editor
|
||||
try {
|
||||
const extra = JSON.parse(requestBody || "{}");
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (k !== "file") form.append(k, String(v));
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
res = await fetch(`/api${path}`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} else {
|
||||
let parsed = JSON.parse(requestBody);
|
||||
// Inject vision images if available
|
||||
if (supportsVision && uploadedImages.length > 0) {
|
||||
parsed = buildChatBodyWithImages(parsed, uploadedImages);
|
||||
}
|
||||
res = await fetch(`/api${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed),
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
|
||||
setResponseStatus(res.status);
|
||||
setResponseDuration(Date.now() - startTime);
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
if (contentType.startsWith("audio/")) {
|
||||
// TTS binary response — create a Blob URL and show inline audio player
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
setResponseBody(`// Audio response (${contentType})\n// Click play below to listen.`);
|
||||
} else if (contentType.includes("text/event-stream")) {
|
||||
// Handle streaming
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let accumulated = "";
|
||||
@@ -229,6 +366,14 @@ export default function PlaygroundPage() {
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setResponseBody(JSON.stringify(data, null, 2));
|
||||
// Detect image generation result → render inline
|
||||
if (isImageEndpoint && data?.data && Array.isArray(data.data) && res.ok) {
|
||||
setImageData(data);
|
||||
}
|
||||
// Detect transcription result → render plain text
|
||||
if (isTranscriptionEndpoint && typeof data?.text === "string") {
|
||||
setTranscriptionText(data.text || "(empty result — check provider credentials)");
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") {
|
||||
@@ -239,7 +384,7 @@ export default function PlaygroundPage() {
|
||||
setResponseDuration(Date.now() - startTime);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [requestBody, selectedEndpoint]);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (abortRef.current) {
|
||||
@@ -323,7 +468,10 @@ export default function PlaygroundPage() {
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
disabled={!requestBody.trim() || !selectedModel}
|
||||
disabled={
|
||||
(!requestBody.trim() && !isTranscriptionEndpoint) ||
|
||||
(!selectedModel && !isTranscriptionEndpoint)
|
||||
}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
@@ -332,6 +480,98 @@ export default function PlaygroundPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* File Upload Zone — shown for transcription and vision models */}
|
||||
{(isTranscriptionEndpoint || supportsVision) && (
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
attach_file
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"}
|
||||
</h3>
|
||||
{isTranscriptionEndpoint && (
|
||||
<Badge variant="info" size="sm">
|
||||
multipart/form-data
|
||||
</Badge>
|
||||
)}
|
||||
{supportsVision && (
|
||||
<Badge variant="info" size="sm">
|
||||
up to 4 images
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isTranscriptionEndpoint && (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,video/*"
|
||||
onChange={handleAudioFileChange}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-xs text-text-muted mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px] text-green-500">
|
||||
check_circle
|
||||
</span>
|
||||
{uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB)
|
||||
</p>
|
||||
)}
|
||||
{!uploadedFile && (
|
||||
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">info</span>
|
||||
Select an audio file to transcribe (mp3, wav, m4a, ogg, flac…)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{supportsVision && (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageFileChange}
|
||||
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
|
||||
/>
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
{uploadedImages.map((src, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative group size-16 rounded overflow-hidden border border-border"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={`Attached ${i + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setUploadedImages((prev) => prev.filter((_, idx) => idx !== i))
|
||||
}
|
||||
className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setUploadedImages([])}
|
||||
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Split Editor View */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Request Panel */}
|
||||
@@ -368,6 +608,15 @@ export default function PlaygroundPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isTranscriptionEndpoint && (
|
||||
<p className="text-xs text-text-muted bg-amber-500/10 border border-amber-500/20 rounded px-2 py-1.5 flex items-start gap-1">
|
||||
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
|
||||
info
|
||||
</span>
|
||||
Transcription uses multipart/form-data. Upload the audio file above — JSON below
|
||||
controls extra params (model, language).
|
||||
</p>
|
||||
)}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
@@ -438,6 +687,24 @@ export default function PlaygroundPage() {
|
||||
Download audio
|
||||
</a>
|
||||
</div>
|
||||
) : imageData ? (
|
||||
<ImageResultsInline data={imageData} />
|
||||
) : transcriptionText !== null ? (
|
||||
<div className="p-4 space-y-2">
|
||||
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
|
||||
Transcription
|
||||
</p>
|
||||
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
|
||||
{transcriptionText}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(transcriptionText)}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">content_copy</span>
|
||||
Copy text
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Editor
|
||||
height="400px"
|
||||
|
||||
@@ -329,6 +329,29 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// T12: Manual token refresh
|
||||
const [refreshingId, setRefreshingId] = useState<string | null>(null);
|
||||
const notify = useNotificationStore();
|
||||
const handleRefreshToken = async (connectionId: string) => {
|
||||
if (refreshingId) return;
|
||||
setRefreshingId(connectionId);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}/refresh`, { method: "POST" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && data.success) {
|
||||
notify.success(t("tokenRefreshed"));
|
||||
await fetchConnections();
|
||||
} else {
|
||||
notify.error(data.error || t("tokenRefreshFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error refreshing token:", error);
|
||||
notify.error(t("tokenRefreshFailed"));
|
||||
} finally {
|
||||
setRefreshingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwapPriority = async (conn1, conn2) => {
|
||||
if (!conn1 || !conn2) return;
|
||||
try {
|
||||
@@ -926,6 +949,8 @@ export default function ProviderDetailPage() {
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
|
||||
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onProxy={() =>
|
||||
setProxyTarget({
|
||||
level: "key",
|
||||
@@ -2165,6 +2190,8 @@ function ConnectionRow({
|
||||
hasProxy,
|
||||
proxySource,
|
||||
proxyHost,
|
||||
onRefreshToken,
|
||||
isRefreshing,
|
||||
}) {
|
||||
const t = useTranslations("providers");
|
||||
const displayName = isOAuth
|
||||
@@ -2173,6 +2200,24 @@ function ConnectionRow({
|
||||
|
||||
// Use useState + useEffect for impure Date.now() to avoid calling during render
|
||||
const [isCooldown, setIsCooldown] = useState(false);
|
||||
// T12: token expiry status — lazy init avoids calling Date.now() during render;
|
||||
// updates every 30s via interval only (no sync setState in effect body).
|
||||
const getTokenMinsLeft = () => {
|
||||
if (!isOAuth || !connection.expiresAt) return null;
|
||||
const expiresMs = new Date(connection.expiresAt).getTime();
|
||||
return Math.floor((expiresMs - Date.now()) / 60000);
|
||||
};
|
||||
const [tokenMinsLeft, setTokenMinsLeft] = useState<number | null>(getTokenMinsLeft);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOAuth || !connection.expiresAt) return;
|
||||
const update = () => {
|
||||
const expiresMs = new Date(connection.expiresAt).getTime();
|
||||
setTokenMinsLeft(Math.floor((expiresMs - Date.now()) / 60000));
|
||||
};
|
||||
const iv = setInterval(update, 30000);
|
||||
return () => clearInterval(iv);
|
||||
}, [isOAuth, connection.expiresAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkCooldown = () => {
|
||||
@@ -2229,6 +2274,25 @@ function ConnectionRow({
|
||||
<Badge variant={statusPresentation.statusVariant as any} size="sm" dot>
|
||||
{statusPresentation.statusLabel}
|
||||
</Badge>
|
||||
{/* T12: Token expiry status indicator (state-driven, no Date.now in render) */}
|
||||
{tokenMinsLeft !== null &&
|
||||
(tokenMinsLeft < 0 ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-red-500/15 text-red-500"
|
||||
title={`Token expired: ${connection.expiresAt}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[11px]">error</span>
|
||||
expired
|
||||
</span>
|
||||
) : tokenMinsLeft < 30 ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-500/15 text-amber-500"
|
||||
title={`Token expires in ${tokenMinsLeft}m`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[11px]">warning</span>
|
||||
{`~${tokenMinsLeft}m`}
|
||||
</span>
|
||||
) : null)}
|
||||
{isCooldown && connection.isActive !== false && (
|
||||
<CooldownTimer until={connection.rateLimitedUntil} />
|
||||
)}
|
||||
@@ -2313,6 +2377,21 @@ function ConnectionRow({
|
||||
>
|
||||
{t("retest")}
|
||||
</Button>
|
||||
{/* T12: Manual token refresh for OAuth accounts */}
|
||||
{onRefreshToken && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="token"
|
||||
loading={isRefreshing}
|
||||
disabled={connection.isActive === false || isRefreshing}
|
||||
onClick={onRefreshToken}
|
||||
className="!h-7 !px-2 text-xs text-amber-500 hover:text-amber-400"
|
||||
title="Refresh OAuth token manually"
|
||||
>
|
||||
Token
|
||||
</Button>
|
||||
)}
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={connection.isActive ?? true}
|
||||
@@ -2332,6 +2411,7 @@ function ConnectionRow({
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary"
|
||||
title={t("edit")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
</button>
|
||||
@@ -2342,7 +2422,11 @@ function ConnectionRow({
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">vpn_lock</span>
|
||||
</button>
|
||||
<button onClick={onDelete} className="p-2 hover:bg-red-500/10 rounded text-red-500">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500"
|
||||
title={t("deleteConnection")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2565,6 +2649,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
|
||||
const [newExtraKey, setNewExtraKey] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
@@ -2574,6 +2660,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
apiKey: "",
|
||||
healthCheckInterval: connection.healthCheckInterval ?? 60,
|
||||
});
|
||||
// Load existing extra keys from providerSpecificData
|
||||
const existing = connection.providerSpecificData?.extraApiKeys;
|
||||
setExtraApiKeys(Array.isArray(existing) ? existing : []);
|
||||
setNewExtraKey("");
|
||||
setTestResult(null);
|
||||
setValidationResult(null);
|
||||
}
|
||||
@@ -2660,6 +2750,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
updates.rateLimitedUntil = null;
|
||||
}
|
||||
}
|
||||
// Persist extra API keys in providerSpecificData
|
||||
if (!isOAuth) {
|
||||
updates.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
|
||||
};
|
||||
}
|
||||
await onSave(updates);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -2744,6 +2841,68 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* T07: Extra API Keys for round-robin rotation */}
|
||||
{!isOAuth && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-text-main">
|
||||
Extra API Keys
|
||||
<span className="ml-2 text-[11px] font-normal text-text-muted">
|
||||
(round-robin rotation — optional)
|
||||
</span>
|
||||
</label>
|
||||
{extraApiKeys.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{extraApiKeys.map((key, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<span className="flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border text-text-muted truncate">
|
||||
{`Key #${idx + 2}: ${key.slice(0, 6)}...${key.slice(-4)}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setExtraApiKeys(extraApiKeys.filter((_, i) => i !== idx))}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-red-400 hover:text-red-500"
|
||||
title="Remove this key"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={newExtraKey}
|
||||
onChange={(e) => setNewExtraKey(e.target.value)}
|
||||
placeholder="Add another API key..."
|
||||
className="flex-1 text-sm bg-sidebar/50 border border-border rounded px-3 py-2 text-text-main placeholder:text-text-muted focus:ring-1 focus:ring-primary outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newExtraKey.trim()) {
|
||||
setExtraApiKeys([...extraApiKeys, newExtraKey.trim()]);
|
||||
setNewExtraKey("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newExtraKey.trim()) {
|
||||
setExtraApiKeys([...extraApiKeys, newExtraKey.trim()]);
|
||||
setNewExtraKey("");
|
||||
}
|
||||
}}
|
||||
disabled={!newExtraKey.trim()}
|
||||
className="px-3 py-2 rounded bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-40 text-sm font-medium"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{extraApiKeys.length > 0 && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{extraApiKeys.length + 1} keys total — rotating round-robin on each request.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Connection */}
|
||||
{!isCompatible && (
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -10,6 +10,10 @@ import Badge from "@/shared/components/Badge";
|
||||
import { CardSkeleton } from "@/shared/components/Loading";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
const LS_GROUP_BY = "omniroute:limits:groupBy";
|
||||
const LS_AUTO_REFRESH = "omniroute:limits:autoRefresh";
|
||||
const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 120000;
|
||||
const MIN_FETCH_INTERVAL_MS = 30000; // Debounce per-connection fetches
|
||||
|
||||
@@ -20,6 +24,7 @@ const PROVIDER_CONFIG = {
|
||||
kiro: { label: "Kiro AI", color: "#FF6B35" },
|
||||
codex: { label: "OpenAI Codex", color: "#10A37F" },
|
||||
claude: { label: "Claude Code", color: "#D97757" },
|
||||
glm: { label: "GLM (Z.AI)", color: "#4A90D9" },
|
||||
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
|
||||
};
|
||||
|
||||
@@ -89,12 +94,30 @@ export default function ProviderLimits() {
|
||||
const [quotaData, setQuotaData] = useState({});
|
||||
const [loading, setLoading] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem(LS_AUTO_REFRESH) === "true";
|
||||
});
|
||||
const [lastUpdated, setLastUpdated] = useState(null);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [countdown, setCountdown] = useState(120);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [tierFilter, setTierFilter] = useState("all");
|
||||
const [groupBy, setGroupBy] = useState<"none" | "environment">(() => {
|
||||
if (typeof window === "undefined") return "none";
|
||||
const saved = localStorage.getItem(LS_GROUP_BY);
|
||||
if (saved === "environment" || saved === "none") return saved;
|
||||
return "none";
|
||||
});
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const saved = localStorage.getItem(LS_EXPANDED_GROUPS);
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
const countdownRef = useRef(null);
|
||||
@@ -175,10 +198,12 @@ export default function ProviderLimits() {
|
||||
setCountdown(120);
|
||||
try {
|
||||
const conns = await fetchConnections();
|
||||
const oauthConnections = conns.filter(
|
||||
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||
const usageConnections = conns.filter(
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
(conn.authType === "oauth" || conn.authType === "apikey")
|
||||
);
|
||||
await Promise.all(oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)));
|
||||
await Promise.all(usageConnections.map((conn) => fetchQuota(conn.id, conn.provider)));
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error("Error refreshing all:", error);
|
||||
@@ -231,13 +256,23 @@ export default function ProviderLimits() {
|
||||
const filteredConnections = useMemo(
|
||||
() =>
|
||||
connections.filter(
|
||||
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||
(conn) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
|
||||
(conn.authType === "oauth" || conn.authType === "apikey")
|
||||
),
|
||||
[connections]
|
||||
);
|
||||
|
||||
const sortedConnections = useMemo(() => {
|
||||
const priority = { antigravity: 1, github: 2, codex: 3, claude: 4, kiro: 5, "kimi-coding": 6 };
|
||||
const priority = {
|
||||
antigravity: 1,
|
||||
github: 2,
|
||||
codex: 3,
|
||||
claude: 4,
|
||||
kiro: 5,
|
||||
glm: 6,
|
||||
"kimi-coding": 7,
|
||||
};
|
||||
return [...filteredConnections].sort(
|
||||
(a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9)
|
||||
);
|
||||
@@ -276,6 +311,50 @@ export default function ProviderLimits() {
|
||||
);
|
||||
}, [sortedConnections, tierByConnection, tierFilter]);
|
||||
|
||||
const groupedConnections = useMemo(() => {
|
||||
if (groupBy !== "environment") return null;
|
||||
const groups = new Map();
|
||||
for (const conn of visibleConnections) {
|
||||
const key = conn.group || t("ungrouped");
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(conn);
|
||||
}
|
||||
return groups;
|
||||
}, [groupBy, visibleConnections, t]);
|
||||
|
||||
const handleSetGroupBy = (value: "none" | "environment") => {
|
||||
setGroupBy(value);
|
||||
localStorage.setItem(LS_GROUP_BY, value);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(groupName) ? next.delete(groupName) : next.add(groupName);
|
||||
localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Default inteligente: se não há preferência salva e há connections com grupo, abre em Por Ambiente
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const hasSaved = localStorage.getItem(LS_GROUP_BY) !== null;
|
||||
if (!hasSaved && connections.some((c) => c.group)) {
|
||||
setGroupBy("environment");
|
||||
}
|
||||
}, [connections]);
|
||||
|
||||
// Quando entra em modo environment pela primeira vez sem estado salvo, abre todos os grupos
|
||||
useEffect(() => {
|
||||
if (groupBy !== "environment" || !groupedConnections) return;
|
||||
if (expandedGroups.size === 0) {
|
||||
const allGroups = new Set([...groupedConnections.keys()]);
|
||||
setExpandedGroups(allGroups);
|
||||
localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...allGroups]));
|
||||
}
|
||||
}, [groupBy, groupedConnections]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -313,8 +392,37 @@ export default function ProviderLimits() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Group by toggle */}
|
||||
<div className="flex rounded-lg border border-white/[0.08] overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleSetGroupBy("none")}
|
||||
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none"
|
||||
style={{
|
||||
background: groupBy === "none" ? "rgba(255,255,255,0.1)" : "transparent",
|
||||
color: groupBy === "none" ? "var(--text-main)" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{t("viewFlat")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSetGroupBy("environment")}
|
||||
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none border-l border-white/[0.08]"
|
||||
style={{
|
||||
background: groupBy === "environment" ? "rgba(255,255,255,0.1)" : "transparent",
|
||||
color: groupBy === "environment" ? "var(--text-main)" : "var(--text-muted)",
|
||||
borderLeft: "1px solid rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
{t("viewByEnvironment")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setAutoRefresh((p) => !p)}
|
||||
onClick={() => {
|
||||
const next = !autoRefresh;
|
||||
setAutoRefresh(next);
|
||||
localStorage.setItem(LS_AUTO_REFRESH, String(next));
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-transparent cursor-pointer text-text-main text-[13px]"
|
||||
>
|
||||
<span
|
||||
@@ -382,157 +490,196 @@ export default function ProviderLimits() {
|
||||
<div className="text-center">{t("actions")}</div>
|
||||
</div>
|
||||
|
||||
{visibleConnections.map((conn, idx) => {
|
||||
const quota = quotaData[conn.id];
|
||||
const isLoading = loading[conn.id];
|
||||
const error = errors[conn.id];
|
||||
const config = PROVIDER_CONFIG[conn.provider] || { label: conn.provider, color: "#666" };
|
||||
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
|
||||
{(() => {
|
||||
const renderRow = (conn, isLast) => {
|
||||
const quota = quotaData[conn.id];
|
||||
const isLoading = loading[conn.id];
|
||||
const error = errors[conn.id];
|
||||
const config = PROVIDER_CONFIG[conn.provider] || {
|
||||
label: conn.provider,
|
||||
color: "#666",
|
||||
};
|
||||
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px 1fr 100px 48px",
|
||||
borderBottom:
|
||||
idx < visibleConnections.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Account Info */}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={`/providers/${conn.provider}.png`}
|
||||
alt={conn.provider}
|
||||
width={32}
|
||||
height={32}
|
||||
className="object-contain"
|
||||
sizes="32px"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold text-text-main truncate">
|
||||
{conn.name || config.label}
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px 1fr 100px 48px",
|
||||
borderBottom: !isLast ? "1px solid rgba(255,255,255,0.04)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Account Info */}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={`/providers/${conn.provider}.png`}
|
||||
alt={conn.provider}
|
||||
width={32}
|
||||
height={32}
|
||||
className="object-contain"
|
||||
sizes="32px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
title={
|
||||
quota?.plan
|
||||
? t("rawPlanWithValue", { plan: quota.plan })
|
||||
: t("noPlanFromProvider")
|
||||
}
|
||||
>
|
||||
<Badge variant={tierMeta.variant} size="sm" dot>
|
||||
{tierMeta.label}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted">{config.label}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold text-text-main truncate">
|
||||
{conn.name || config.label}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
title={
|
||||
quota?.plan
|
||||
? t("rawPlanWithValue", { plan: quota.plan })
|
||||
: t("noPlanFromProvider")
|
||||
}
|
||||
>
|
||||
<Badge variant={tierMeta.variant} size="sm" dot>
|
||||
{tierMeta.label}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted">{config.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quota Bars */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pr-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-1.5 text-text-muted text-xs">
|
||||
<span className="material-symbols-outlined animate-spin text-[14px]">
|
||||
progress_activity
|
||||
</span>
|
||||
{t("loadingQuotas")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]">
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
|
||||
<div className="text-xs text-text-muted italic">{quota.message}</div>
|
||||
) : quota?.quotas?.length > 0 ? (
|
||||
quota.quotas.map((q, i) => {
|
||||
const remaining =
|
||||
q.remainingPercentage !== undefined
|
||||
? Math.round(q.remainingPercentage)
|
||||
: calculatePercentage(q.used, q.total);
|
||||
const colors = getBarColor(remaining);
|
||||
const cd = formatCountdown(q.resetAt);
|
||||
const shortName = getShortModelName(q.name);
|
||||
{/* Quota Bars */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pr-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-1.5 text-text-muted text-xs">
|
||||
<span className="material-symbols-outlined animate-spin text-[14px]">
|
||||
progress_activity
|
||||
</span>
|
||||
{t("loadingQuotas")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-500">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]">
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
|
||||
<div className="text-xs text-text-muted italic">{quota.message}</div>
|
||||
) : quota?.quotas?.length > 0 ? (
|
||||
quota.quotas.map((q, i) => {
|
||||
const remaining =
|
||||
q.remainingPercentage !== undefined
|
||||
? Math.round(q.remainingPercentage)
|
||||
: calculatePercentage(q.used, q.total);
|
||||
const colors = getBarColor(remaining);
|
||||
const cd = formatCountdown(q.resetAt);
|
||||
const shortName = getShortModelName(q.name);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
|
||||
{/* Model label */}
|
||||
<span
|
||||
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
|
||||
style={{ background: colors.bg, color: colors.text }}
|
||||
>
|
||||
{shortName}
|
||||
</span>
|
||||
|
||||
{/* Countdown */}
|
||||
{cd && (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
⏱ {cd}
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
|
||||
{/* Model label */}
|
||||
<span
|
||||
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
|
||||
style={{ background: colors.bg, color: colors.text }}
|
||||
>
|
||||
{shortName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-sm transition-[width] duration-300 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(remaining, 100)}%`,
|
||||
background: colors.bar,
|
||||
}}
|
||||
/>
|
||||
{/* Countdown */}
|
||||
{cd && (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
⏱ {cd}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-sm transition-[width] duration-300 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(remaining, 100)}%`,
|
||||
background: colors.bar,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Percentage */}
|
||||
<span
|
||||
className="text-[11px] font-semibold min-w-[32px] text-right"
|
||||
style={{ color: colors.text }}
|
||||
>
|
||||
{remaining}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Percentage */}
|
||||
<span
|
||||
className="text-[11px] font-semibold min-w-[32px] text-right"
|
||||
style={{ color: colors.text }}
|
||||
>
|
||||
{remaining}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Last Used */}
|
||||
<div className="text-center text-[11px] text-text-muted">
|
||||
{lastUpdated ? (
|
||||
<span>
|
||||
{lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Used */}
|
||||
<div className="text-center text-[11px] text-text-muted">
|
||||
{lastUpdated ? (
|
||||
<span>
|
||||
{lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading}
|
||||
title={t("refreshQuota")}
|
||||
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
{/* Actions */}
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading}
|
||||
title={t("refreshQuota")}
|
||||
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
|
||||
>
|
||||
refresh
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (groupedConnections) {
|
||||
const entries = [...groupedConnections.entries()];
|
||||
return entries.map(([groupName, conns]) => (
|
||||
<div
|
||||
key={groupName}
|
||||
className="border border-white/[0.08] rounded-lg overflow-hidden mb-2"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleGroup(groupName)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 bg-white/[0.03] hover:bg-white/[0.05] transition-colors text-left border-none cursor-pointer"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted">
|
||||
{expandedGroups.has(groupName) ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted">
|
||||
folder
|
||||
</span>
|
||||
<span className="text-[12px] font-semibold text-text-main uppercase tracking-wider flex-1">
|
||||
{groupName}
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted bg-white/[0.06] px-2 py-0.5 rounded-full">
|
||||
{conns.length}
|
||||
</span>
|
||||
</button>
|
||||
{expandedGroups.has(groupName) && (
|
||||
<div>{conns.map((conn, idx) => renderRow(conn, idx === conns.length - 1))}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return visibleConnections.map((conn, idx) =>
|
||||
renderRow(conn, idx === visibleConnections.length - 1)
|
||||
);
|
||||
})}
|
||||
})()}
|
||||
|
||||
{visibleConnections.length === 0 && (
|
||||
<div className="py-6 px-4 text-center text-text-muted text-[13px]">
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function POST(request) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
// Send a minimal chat request to the internal SSE handler
|
||||
// Use OpenAI-compatible format — universally accepted by all providers via the translator
|
||||
const testBody = {
|
||||
model: modelStr,
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
@@ -58,11 +59,15 @@ export async function POST(request) {
|
||||
|
||||
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000); // 15s timeout
|
||||
const timeout = setTimeout(() => controller.abort(), 20000); // 20s timeout (was 15s, slow providers need more)
|
||||
|
||||
const res = await fetch(internalUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Fix #350: bypass REQUIRE_API_KEY for internal admin combo tests
|
||||
"X-Internal-Test": "combo-health-check",
|
||||
},
|
||||
body: JSON.stringify(testBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* API Route: /api/pricing/sync
|
||||
*
|
||||
* POST — Trigger a manual pricing sync from external sources.
|
||||
* GET — Get current sync status.
|
||||
* DELETE — Clear all synced pricing data.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const sources = Array.isArray(body.sources)
|
||||
? body.sources.filter((s: unknown): s is string => typeof s === "string")
|
||||
: undefined;
|
||||
const dryRun = body.dryRun === true;
|
||||
|
||||
const { syncPricingFromSources } = await import("@/lib/pricingSync");
|
||||
const result = await syncPricingFromSources({ sources, dryRun });
|
||||
|
||||
return NextResponse.json(result, { status: result.success ? 200 : 502 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { getSyncStatus } = await import("@/lib/pricingSync");
|
||||
return NextResponse.json(getSyncStatus());
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const { clearSyncedPricing } = await import("@/lib/pricingSync");
|
||||
clearSyncedPricing();
|
||||
return NextResponse.json({ success: true, message: "Synced pricing data cleared" });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById } from "@/models";
|
||||
import { getAccessToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
|
||||
/**
|
||||
* POST /api/providers/[id]/refresh
|
||||
* Manually trigger an OAuth token refresh for a provider connection.
|
||||
* Useful when the dashboard shows a stale/expired token and the user
|
||||
* doesn't want to wait for the next auto-refresh cycle.
|
||||
*
|
||||
* T12 — Manual Token Refresh UI
|
||||
*/
|
||||
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const connection = await getProviderConnectionById(id);
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (connection.authType !== "oauth") {
|
||||
return NextResponse.json(
|
||||
{ error: "Only OAuth connections support manual token refresh" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!connection.refreshToken && !connection.accessToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "No token credentials available for refresh" },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const provider = connection.provider as string;
|
||||
const credentials = {
|
||||
connectionId: id,
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken: connection.refreshToken,
|
||||
expiresAt: connection.expiresAt,
|
||||
expiresIn: connection.expiresIn,
|
||||
idToken: connection.idToken,
|
||||
providerSpecificData: connection.providerSpecificData,
|
||||
};
|
||||
|
||||
// Use the existing getAccessToken helper which knows how to refresh
|
||||
// tokens for each provider type (Claude, GitHub, Gemini, etc.)
|
||||
const newCredentials = await getAccessToken(provider, credentials);
|
||||
|
||||
if (!newCredentials?.accessToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token refresh failed — provider returned no new token" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
// Persist new credentials to DB
|
||||
await updateProviderCredentials(id, newCredentials);
|
||||
|
||||
const expiresAt = newCredentials.expiresIn
|
||||
? new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connectionId: id,
|
||||
provider,
|
||||
expiresAt,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[T12] Token refresh failed:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Token refresh failed", details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getTaskRoutingConfig,
|
||||
setTaskRoutingConfig,
|
||||
resetTaskRoutingStats,
|
||||
getDefaultTaskModelMap,
|
||||
} from "@omniroute/open-sse/services/taskAwareRouter.ts";
|
||||
import { updateSettings } from "@/lib/db/settings";
|
||||
|
||||
/**
|
||||
* GET /api/settings/task-routing
|
||||
* Returns the current task-aware routing configuration.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json({
|
||||
...getTaskRoutingConfig(),
|
||||
defaultTaskModelMap: getDefaultTaskModelMap(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/settings/task-routing GET:", error);
|
||||
return NextResponse.json({ error: "Failed to get config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/settings/task-routing
|
||||
* Update the task-aware routing configuration.
|
||||
* Body: { enabled?: boolean, taskModelMap?: { coding?: "...", ... }, detectionEnabled?: boolean }
|
||||
*/
|
||||
export async function PUT(request: Request) {
|
||||
let rawBody: Record<string, unknown>;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
setTaskRoutingConfig(rawBody as any);
|
||||
|
||||
// Persist to database (excluding stats)
|
||||
const { stats, ...persistable } = getTaskRoutingConfig();
|
||||
await updateSettings({ taskRouting: JSON.stringify(persistable) });
|
||||
|
||||
return NextResponse.json({ success: true, ...getTaskRoutingConfig() });
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/settings/task-routing PUT:", error);
|
||||
return NextResponse.json({ error: "Failed to update config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/settings/task-routing
|
||||
* Actions: { action: "reset-stats" | "detect" }
|
||||
* For "detect": pass { action: "detect", body: <request-body> } to test detection
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let rawBody: any;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (rawBody.action === "reset-stats") {
|
||||
resetTaskRoutingStats();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
stats: getTaskRoutingConfig().stats,
|
||||
});
|
||||
}
|
||||
|
||||
if (rawBody.action === "detect") {
|
||||
const { detectTaskType } = await import("@omniroute/open-sse/services/taskAwareRouter.ts");
|
||||
const taskType = detectTaskType(rawBody.body || {});
|
||||
const config = getTaskRoutingConfig();
|
||||
return NextResponse.json({
|
||||
taskType,
|
||||
preferredModel: config.taskModelMap[taskType] || "(no override)",
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/settings/task-routing POST:", error);
|
||||
return NextResponse.json({ error: "Failed to execute action" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
export default function GetStarted() {
|
||||
const t = useTranslations("landing");
|
||||
@@ -10,8 +11,8 @@ export default function GetStarted() {
|
||||
const dashboardUrl = `${endpoint}/dashboard`;
|
||||
const command = "npx omniroute";
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
const handleCopy = async (text: string) => {
|
||||
await copyToClipboard(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ interface QuotaCacheEntry {
|
||||
fetchedAt: number;
|
||||
exhausted: boolean;
|
||||
nextResetAt: string | null;
|
||||
windowDurationMs?: number | null; // T08: optional rolling window duration
|
||||
}
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
@@ -56,6 +57,33 @@ function isExhausted(quotas: Record<string, QuotaInfo>): boolean {
|
||||
return entries.every((q) => q.remainingPercentage <= 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* T08 — Auto-advance quota window.
|
||||
* If we know the window duration, advance past the expired window(s) to
|
||||
* avoid blocking requests when the quota reset already happened but the
|
||||
* background refresh hasn't run yet.
|
||||
*/
|
||||
function advancedWindowResetAt(entry: QuotaCacheEntry, now: number): { exhausted: false } | null {
|
||||
if (!entry.nextResetAt) return null;
|
||||
|
||||
const resetMs = parseDate(entry.nextResetAt);
|
||||
if (resetMs === null) return null;
|
||||
|
||||
// If the window's resetAt is in the past, the quota has been renewed.
|
||||
// Eagerly mark as available so requests don't wait for the 5-min TTL.
|
||||
if (resetMs <= now) {
|
||||
return { exhausted: false };
|
||||
}
|
||||
|
||||
// If we know the window duration, check if the *next* window also passed.
|
||||
if (entry.windowDurationMs && entry.windowDurationMs > 0) {
|
||||
const elapsed = now - resetMs;
|
||||
if (elapsed >= 0) return { exhausted: false };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDate(value: string): number | null {
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
@@ -128,14 +156,20 @@ export function isAccountQuotaExhausted(connectionId: string): boolean {
|
||||
if (!entry) return false;
|
||||
if (!entry.exhausted) return false;
|
||||
|
||||
// If resetAt has passed, assume available until refresh confirms
|
||||
if (entry.nextResetAt) {
|
||||
const resetMs = parseDate(entry.nextResetAt);
|
||||
if (resetMs !== null && resetMs <= Date.now()) return false;
|
||||
const now = Date.now();
|
||||
|
||||
// T08 — Auto window advance: if resetAt is in the past, eagerly treat as not exhausted.
|
||||
// This prevents stale exhaustion blocking when background refresh hasn't run yet.
|
||||
const advanced = advancedWindowResetAt(entry, now);
|
||||
if (advanced) {
|
||||
// Optimistically clear the exhausted flag so we unblock requests immediately.
|
||||
// The next background refresh will update with the real quota state.
|
||||
entry.exhausted = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exhausted entries without resetAt expire after fixed TTL
|
||||
const age = Date.now() - entry.fetchedAt;
|
||||
const age = now - entry.fetchedAt;
|
||||
if (!entry.nextResetAt && age > EXHAUSTED_TTL_MS) return false;
|
||||
|
||||
return true;
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "اختيار عشوائي موحد، ثم الرجوع إلى النماذج المتبقية",
|
||||
"leastUsedDesc": "يختار النموذج الذي يحتوي على أقل عدد من الطلبات، مع موازنة الحمل مع مرور الوقت",
|
||||
"costOptimizedDesc": "الطرق إلى النموذج الأرخص تعتمد أولاً على التسعير",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "نماذج",
|
||||
"autoBalance": "التوازن التلقائي",
|
||||
"advancedSettings": "الإعدادات المتقدمة",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "البريد الإلكتروني",
|
||||
"healthCheckMinutes": "فحص الصحة (دقيقة)",
|
||||
"healthCheckHint": "الفاصل الزمني لتحديث الرمز المميز. 0 = معطل.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "فشل في اختبار الاتصال",
|
||||
"failed": "فشل",
|
||||
"leaveBlankKeepCurrentApiKey": "اتركه فارغًا للاحتفاظ بمفتاح API الحالي.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "اختر الحساب الأقل استخدامًا مؤخرًا",
|
||||
"costOpt": "خيار التكلفة",
|
||||
"costOptDesc": "تفضل أرخص حساب متاح",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "الحد اللزج",
|
||||
"stickyLimitDesc": "المكالمات لكل حساب قبل التبديل",
|
||||
"modelAliases": "الأسماء المستعارة النموذجية",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "الخطة الأولية: {plan}",
|
||||
"noPlanFromProvider": "لا توجد خطة من المزود",
|
||||
"noQuotaData": "لا توجد بيانات الحصص",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "لا توجد بيانات الحصص المتاحة",
|
||||
"noAccountsForTierFilter": "لم يتم العثور على حسابات لمرشح الطبقة",
|
||||
"tierAll": "الكل",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Единен случаен избор, след което се връща към останалите модели",
|
||||
"leastUsedDesc": "Избира модела с най-малко заявки, като балансира натоварването във времето",
|
||||
"costOptimizedDesc": "Първо маршрути към най-евтиния модел въз основа на ценообразуването",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Модели",
|
||||
"autoBalance": "Автоматичен баланс",
|
||||
"advancedSettings": "Разширени настройки",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Имейл",
|
||||
"healthCheckMinutes": "Проверка на здравето (мин.)",
|
||||
"healthCheckHint": "Интервал за опресняване на проактивен токен. 0 = забранено.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Неуспешно тестване на връзката",
|
||||
"failed": "Неуспешно",
|
||||
"leaveBlankKeepCurrentApiKey": "Оставете празно, за да запазите текущия API ключ.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Изберете най-малко използван акаунт",
|
||||
"costOpt": "Цена Опт",
|
||||
"costOptDesc": "Предпочитайте най-евтиния наличен акаунт",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Лепкава граница",
|
||||
"stickyLimitDesc": "Обаждания на акаунт преди превключване",
|
||||
"modelAliases": "Псевдоними на модела",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Необработен план: {plan}",
|
||||
"noPlanFromProvider": "Няма план от доставчика",
|
||||
"noQuotaData": "Няма данни за квоти",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Няма налични данни за квота",
|
||||
"noAccountsForTierFilter": "Няма намерени акаунти за филтър за ниво",
|
||||
"tierAll": "Всички",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Ensartet tilfældig udvælgelse, derefter tilbagevenden til de resterende modeller",
|
||||
"leastUsedDesc": "Vælger modellen med færrest anmodninger, balancerer belastningen over tid",
|
||||
"costOptimizedDesc": "Ruter til den billigste model først baseret på priser",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modeller",
|
||||
"autoBalance": "Auto-balance",
|
||||
"advancedSettings": "Avancerede indstillinger",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Sundhedstjek (min)",
|
||||
"healthCheckHint": "Proaktivt token-opdateringsinterval. 0 = deaktiveret.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Forbindelsen kunne ikke testes",
|
||||
"failed": "Mislykkedes",
|
||||
"leaveBlankKeepCurrentApiKey": "Lad stå tomt for at beholde den aktuelle API-nøgle.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Vælg den mindst brugte konto",
|
||||
"costOpt": "Omkostningsopt",
|
||||
"costOptDesc": "Foretrækker den billigste tilgængelige konto",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Opkald pr. konto før skift",
|
||||
"modelAliases": "Model aliaser",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Rå plan: {plan}",
|
||||
"noPlanFromProvider": "Ingen plan fra udbyderen",
|
||||
"noQuotaData": "Ingen kvotedata",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Ingen tilgængelige kvotedata",
|
||||
"noAccountsForTierFilter": "Der blev ikke fundet nogen konti til niveaufilter",
|
||||
"tierAll": "Alle",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Einheitliche Zufallsauswahl, dann Rückgriff auf verbleibende Modelle",
|
||||
"leastUsedDesc": "Wählt das Modell mit den wenigsten Anfragen aus und gleicht die Last über die Zeit aus",
|
||||
"costOptimizedDesc": "Leitet basierend auf dem Preis zuerst zum günstigsten Modell weiter",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modelle",
|
||||
"autoBalance": "Automatischer Ausgleich",
|
||||
"advancedSettings": "Erweiterte Einstellungen",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-Mail",
|
||||
"healthCheckMinutes": "Gesundheitscheck (Min.)",
|
||||
"healthCheckHint": "Proaktives Token-Aktualisierungsintervall. 0 = deaktiviert.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Die Verbindung konnte nicht getestet werden",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"leaveBlankKeepCurrentApiKey": "Lassen Sie das Feld leer, um den aktuellen API-Schlüssel beizubehalten.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Wählen Sie das zuletzt verwendete Konto aus",
|
||||
"costOpt": "Kosten Opt",
|
||||
"costOptDesc": "Bevorzugen Sie das günstigste verfügbare Konto",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky-Limit",
|
||||
"stickyLimitDesc": "Anrufe pro Konto vor dem Wechsel",
|
||||
"modelAliases": "Modell-Aliase",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Rohplan: {plan}",
|
||||
"noPlanFromProvider": "Kein Plan vom Anbieter",
|
||||
"noQuotaData": "Keine Quotendaten",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Keine Quotendaten verfügbar",
|
||||
"noAccountsForTierFilter": "Für den Stufenfilter wurden keine Konten gefunden",
|
||||
"tierAll": "Alle",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"providers": "Providers",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Usage",
|
||||
"analytics": "Analytics",
|
||||
"costs": "Costs",
|
||||
@@ -235,6 +236,26 @@
|
||||
"keyCreatedNote": "Copy and store this key now — it won't be shown again.",
|
||||
"done": "Done",
|
||||
"savePermissions": "Save Permissions",
|
||||
"autoResolve": "Auto-Resolve",
|
||||
"autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.",
|
||||
"keyActive": "Key Active",
|
||||
"keyActiveDesc": "Enable or disable this API key. Disabled keys are immediately rejected with 403.",
|
||||
"accessSchedule": "Access Schedule",
|
||||
"accessScheduleDesc": "Restrict access to specific hours and days of the week.",
|
||||
"scheduleFrom": "From",
|
||||
"scheduleUntil": "Until",
|
||||
"scheduleDays": "Days",
|
||||
"scheduleTimezone": "Timezone",
|
||||
"scheduleTimezoneHint": "Use IANA timezone names, e.g. America/New_York, Europe/Berlin",
|
||||
"scheduleActive": "Schedule",
|
||||
"disabled": "Disabled",
|
||||
"daySun": "Sun",
|
||||
"dayMon": "Mon",
|
||||
"dayTue": "Tue",
|
||||
"dayWed": "Wed",
|
||||
"dayThu": "Thu",
|
||||
"dayFri": "Fri",
|
||||
"daySat": "Sat",
|
||||
"allowAll": "Allow All",
|
||||
"restrict": "Restrict",
|
||||
"allowAllInfo": "This key can access all available models.",
|
||||
@@ -604,6 +625,8 @@
|
||||
"randomDesc": "Uniform random selection, then fallback to remaining models",
|
||||
"leastUsedDesc": "Picks the model with fewest requests, balancing load over time",
|
||||
"costOptimizedDesc": "Routes to the cheapest model first based on pricing",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Models",
|
||||
"autoBalance": "Auto-balance",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
@@ -1391,13 +1414,17 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Health Check (min)",
|
||||
"healthCheckHint": "Proactive token refresh interval. 0 = disabled.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Failed to test connection",
|
||||
"failed": "Failed",
|
||||
"leaveBlankKeepCurrentApiKey": "Leave blank to keep the current API key.",
|
||||
"editCompatibleTitle": "Edit {type} Compatible",
|
||||
"compatibleBaseUrlHint": "Use the base URL (ending in /v1) for your {type}-compatible API.",
|
||||
"apiKeyForCheck": "API Key (for Check)",
|
||||
"compatibleProdPlaceholder": "{type} Compatible (Prod)"
|
||||
"compatibleProdPlaceholder": "{type} Compatible (Prod)",
|
||||
"tokenRefreshed": "Token refreshed successfully",
|
||||
"tokenRefreshFailed": "Token refresh failed"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -1560,6 +1587,8 @@
|
||||
"leastUsedDesc": "Pick least recently used account",
|
||||
"costOpt": "Cost Opt",
|
||||
"costOptDesc": "Prefer cheapest available account",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Calls per account before switching",
|
||||
"modelAliases": "Model Aliases",
|
||||
@@ -2084,6 +2113,9 @@
|
||||
"rawPlanWithValue": "Raw plan: {plan}",
|
||||
"noPlanFromProvider": "No plan from provider",
|
||||
"noQuotaData": "No quota data",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "No quota data available",
|
||||
"noAccountsForTierFilter": "No accounts found for tier filter",
|
||||
"tierAll": "All",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Selección aleatoria uniforme y luego recurrir a los modelos restantes.",
|
||||
"leastUsedDesc": "Elige el modelo con menos solicitudes y equilibra la carga a lo largo del tiempo.",
|
||||
"costOptimizedDesc": "Rutas al modelo más barato primero según el precio",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modelos",
|
||||
"autoBalance": "Equilibrio automático",
|
||||
"advancedSettings": "Configuración avanzada",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Correo electrónico",
|
||||
"healthCheckMinutes": "Control de salud (min)",
|
||||
"healthCheckHint": "Intervalo de actualización de token proactivo. 0 = deshabilitado.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "No se pudo probar la conexión",
|
||||
"failed": "Fallido",
|
||||
"leaveBlankKeepCurrentApiKey": "Déjelo en blanco para conservar la clave API actual.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Elija la cuenta utilizada menos recientemente",
|
||||
"costOpt": "Opción de costo",
|
||||
"costOptDesc": "Prefiere la cuenta más barata disponible",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Límite fijo",
|
||||
"stickyLimitDesc": "Llamadas por cuenta antes de cambiar",
|
||||
"modelAliases": "Alias de modelo",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Plan sin formato: {plan}",
|
||||
"noPlanFromProvider": "Sin plan del proveedor",
|
||||
"noQuotaData": "Sin datos de cuota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "No hay datos de cuota disponibles",
|
||||
"noAccountsForTierFilter": "No se encontraron cuentas para el filtro de niveles",
|
||||
"tierAll": "Todos",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Yhtenäinen satunnainen valinta, sitten takaisin muihin malleihin",
|
||||
"leastUsedDesc": "Valitsee mallin, jolla on vähiten pyyntöjä ja tasapainottaa kuormitusta ajan myötä",
|
||||
"costOptimizedDesc": "Reitit edullisimpaan malliin ensin hinnoittelun perusteella",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Mallit",
|
||||
"autoBalance": "Automaattinen tasapainotus",
|
||||
"advancedSettings": "Lisäasetukset",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Sähköposti",
|
||||
"healthCheckMinutes": "Terveystarkastus (min)",
|
||||
"healthCheckHint": "Ennakoiva tunnuksen päivitysväli. 0 = pois käytöstä.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Yhteyden testaus epäonnistui",
|
||||
"failed": "Epäonnistui",
|
||||
"leaveBlankKeepCurrentApiKey": "Jätä tyhjäksi, jos haluat säilyttää nykyisen API-avaimen.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Valitse vähiten käytetty tili",
|
||||
"costOpt": "Kustannusopt",
|
||||
"costOptDesc": "Valitse halvin saatavilla oleva tili",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Puhelut tilikohtaisesti ennen vaihtamista",
|
||||
"modelAliases": "Mallin aliakset",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Raakasuunnitelma: {plan}",
|
||||
"noPlanFromProvider": "Ei suunnitelmaa palveluntarjoajalta",
|
||||
"noQuotaData": "Ei kiintiötietoja",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Kiintiötietoja ei ole saatavilla",
|
||||
"noAccountsForTierFilter": "Tasosuodattimelle ei löytynyt tilejä",
|
||||
"tierAll": "Kaikki",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Sélection aléatoire uniforme, puis retour aux modèles restants",
|
||||
"leastUsedDesc": "Sélectionne le modèle avec le moins de demandes, en équilibrant la charge au fil du temps",
|
||||
"costOptimizedDesc": "Itinéraires vers le modèle le moins cher en premier en fonction du prix",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modèles",
|
||||
"autoBalance": "Équilibre automatique",
|
||||
"advancedSettings": "Paramètres avancés",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Courriel",
|
||||
"healthCheckMinutes": "Bilan de santé (min)",
|
||||
"healthCheckHint": "Intervalle d’actualisation proactif des jetons. 0 = désactivé.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Échec du test de connexion",
|
||||
"failed": "Échec",
|
||||
"leaveBlankKeepCurrentApiKey": "Laissez vide pour conserver la clé API actuelle.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Choisissez le compte le moins récemment utilisé",
|
||||
"costOpt": "Option de coût",
|
||||
"costOptDesc": "Préférer le compte disponible le moins cher",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Limite collante",
|
||||
"stickyLimitDesc": "Appels par compte avant de changer",
|
||||
"modelAliases": "Alias de modèle",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Plan brut : {plan}",
|
||||
"noPlanFromProvider": "Aucun plan du fournisseur",
|
||||
"noQuotaData": "Aucune donnée de quota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Aucune donnée de quota disponible",
|
||||
"noAccountsForTierFilter": "Aucun compte trouvé pour le filtre de niveau",
|
||||
"tierAll": "Tout",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "בחירה אקראית אחידה, ואז חזרה לדגמים שנותרו",
|
||||
"leastUsedDesc": "בוחר את הדגם עם הכי פחות בקשות, מאזן עומס לאורך זמן",
|
||||
"costOptimizedDesc": "מסלולים לדגם הזול ביותר לפי תמחור",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "דגמים",
|
||||
"autoBalance": "איזון אוטומטי",
|
||||
"advancedSettings": "הגדרות מתקדמות",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "דוא\"ל",
|
||||
"healthCheckMinutes": "בדיקת בריאות (דקה)",
|
||||
"healthCheckHint": "מרווח רענון אסימון פרואקטיבי. 0 = מושבת.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "בדיקת החיבור נכשלה",
|
||||
"failed": "נכשל",
|
||||
"leaveBlankKeepCurrentApiKey": "השאר ריק כדי לשמור את מפתח ה-API הנוכחי.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "בחר חשבון שנעשה בו שימוש לפחות לאחרונה",
|
||||
"costOpt": "אופטימיזציית עלות",
|
||||
"costOptDesc": "העדיפו את החשבון הזול ביותר הזמין",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "גבול דביק",
|
||||
"stickyLimitDesc": "שיחות לכל חשבון לפני המעבר",
|
||||
"modelAliases": "כינויי דגם",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "תוכנית גולמית: {plan}",
|
||||
"noPlanFromProvider": "אין תוכנית מספק",
|
||||
"noQuotaData": "אין נתוני מכסה",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "אין נתוני מכסה זמינים",
|
||||
"noAccountsForTierFilter": "לא נמצאו חשבונות עבור מסנן שכבות",
|
||||
"tierAll": "הכל",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Egységes véletlenszerű kiválasztás, majd visszaállás a többi modellhez",
|
||||
"leastUsedDesc": "A legkevesebb kéréssel rendelkező modellt választja, idővel kiegyensúlyozva a terhelést",
|
||||
"costOptimizedDesc": "Először a legolcsóbb modellhez vezet az árak alapján",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modellek",
|
||||
"autoBalance": "Automatikus egyensúly",
|
||||
"advancedSettings": "Speciális beállítások",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "állapotfelmérés (perc)",
|
||||
"healthCheckHint": "Proaktív token frissítési időköz. 0 = letiltva.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Nem sikerült tesztelni a kapcsolatot",
|
||||
"failed": "Sikertelen",
|
||||
"leaveBlankKeepCurrentApiKey": "Hagyja üresen az aktuális API-kulcs megtartásához.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Válassza ki a legutóbb használt fiókot",
|
||||
"costOpt": "Költségopt",
|
||||
"costOptDesc": "A legolcsóbb elérhető fiók előnyben részesítése",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Ragadós határ",
|
||||
"stickyLimitDesc": "Hívások fiókonként váltás előtt",
|
||||
"modelAliases": "Modell álnevek",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Nyers terv: {plan}",
|
||||
"noPlanFromProvider": "Nincs terv a szolgáltatótól",
|
||||
"noQuotaData": "Nincsenek kvótaadatok",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Nem állnak rendelkezésre kvótaadatok",
|
||||
"noAccountsForTierFilter": "Nem található fiók a rétegszűrőhöz",
|
||||
"tierAll": "Mind",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Pemilihan acak seragam, lalu kembali ke model lainnya",
|
||||
"leastUsedDesc": "Memilih model dengan permintaan paling sedikit, menyeimbangkan beban dari waktu ke waktu",
|
||||
"costOptimizedDesc": "Rutekan ke model termurah terlebih dahulu berdasarkan harga",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Model",
|
||||
"autoBalance": "Keseimbangan otomatis",
|
||||
"advancedSettings": "Pengaturan Lanjutan",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Surel",
|
||||
"healthCheckMinutes": "Pemeriksaan Kesehatan (menit)",
|
||||
"healthCheckHint": "Interval penyegaran token proaktif. 0 = dinonaktifkan.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Gagal menguji koneksi",
|
||||
"failed": "Gagal",
|
||||
"leaveBlankKeepCurrentApiKey": "Biarkan kosong untuk mempertahankan kunci API saat ini.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Pilih akun yang paling jarang digunakan",
|
||||
"costOpt": "Pilihan Biaya",
|
||||
"costOptDesc": "Lebih suka akun termurah yang tersedia",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Batas Lengket",
|
||||
"stickyLimitDesc": "Panggilan per akun sebelum beralih",
|
||||
"modelAliases": "Alias Model",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Paket mentah: {plan}",
|
||||
"noPlanFromProvider": "Tidak ada rencana dari penyedia",
|
||||
"noQuotaData": "Tidak ada data kuota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Tidak ada data kuota yang tersedia",
|
||||
"noAccountsForTierFilter": "Tidak ditemukan akun untuk filter tingkat",
|
||||
"tierAll": "Semua",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "समान यादृच्छिक चयन, फिर शेष मॉडलों पर वापस लौटना",
|
||||
"leastUsedDesc": "समय के साथ लोड को संतुलित करते हुए, सबसे कम अनुरोधों वाला मॉडल चुनता है",
|
||||
"costOptimizedDesc": "मूल्य निर्धारण के आधार पर सबसे पहले सबसे सस्ते मॉडल पर रूट करें",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "मॉडल",
|
||||
"autoBalance": "स्वत: संतुलन",
|
||||
"advancedSettings": "उन्नत सेटिंग्स",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "ईमेल",
|
||||
"healthCheckMinutes": "स्वास्थ्य जांच (न्यूनतम)",
|
||||
"healthCheckHint": "प्रोएक्टिव टोकन ताज़ा अंतराल। 0 = अक्षम.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "कनेक्शन का परीक्षण करने में विफल",
|
||||
"failed": "असफल",
|
||||
"leaveBlankKeepCurrentApiKey": "वर्तमान एपीआई कुंजी रखने के लिए खाली छोड़ दें।",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "कम से कम हाल ही में उपयोग किया गया खाता चुनें",
|
||||
"costOpt": "लागत विकल्प",
|
||||
"costOptDesc": "सबसे सस्ते उपलब्ध खाते को प्राथमिकता दें",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "चिपचिपी सीमा",
|
||||
"stickyLimitDesc": "स्विच करने से पहले प्रति खाता कॉल",
|
||||
"modelAliases": "मॉडल उपनाम",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "कच्ची योजना: {plan}",
|
||||
"noPlanFromProvider": "प्रदाता की ओर से कोई योजना नहीं",
|
||||
"noQuotaData": "कोई कोटा डेटा नहीं",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "कोई कोटा डेटा उपलब्ध नहीं है",
|
||||
"noAccountsForTierFilter": "टियर फ़िल्टर के लिए कोई खाता नहीं मिला",
|
||||
"tierAll": "सब",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Selezione casuale uniforme, quindi fallback sui modelli rimanenti",
|
||||
"leastUsedDesc": "Sceglie il modello con meno richieste, bilanciando il carico nel tempo",
|
||||
"costOptimizedDesc": "Percorsi prima verso il modello più economico in base al prezzo",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modelli",
|
||||
"autoBalance": "Bilanciamento automatico",
|
||||
"advancedSettings": "Impostazioni avanzate",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Controllo dello stato (min)",
|
||||
"healthCheckHint": "Intervallo di aggiornamento del token proattivo. 0 = disabilitato.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Impossibile testare la connessione",
|
||||
"failed": "Fallito",
|
||||
"leaveBlankKeepCurrentApiKey": "Lascia vuoto per mantenere la chiave API corrente.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Scegli l'account utilizzato meno di recente",
|
||||
"costOpt": "Opzione costo",
|
||||
"costOptDesc": "Preferisci il conto più economico disponibile",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Limite appiccicoso",
|
||||
"stickyLimitDesc": "Chiamate per account prima del cambio",
|
||||
"modelAliases": "Alias del modello",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Piano grezzo: {plan}",
|
||||
"noPlanFromProvider": "Nessun piano dal fornitore",
|
||||
"noQuotaData": "Nessun dato sulle quote",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Nessun dato sulle quote disponibile",
|
||||
"noAccountsForTierFilter": "Nessun account trovato per il filtro del livello",
|
||||
"tierAll": "Tutto",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "均一なランダム選択、その後残りのモデルへのフォールバック",
|
||||
"leastUsedDesc": "リクエストが最も少ないモデルを選択し、時間の経過とともに負荷のバランスをとります",
|
||||
"costOptimizedDesc": "価格に基づいて最初に最も安価なモデルにルーティングします",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "モデル",
|
||||
"autoBalance": "オートバランス",
|
||||
"advancedSettings": "詳細設定",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "電子メール",
|
||||
"healthCheckMinutes": "ヘルスチェック (分)",
|
||||
"healthCheckHint": "プロアクティブなトークンの更新間隔。 0 = 無効。",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "接続のテストに失敗しました",
|
||||
"failed": "失敗しました",
|
||||
"leaveBlankKeepCurrentApiKey": "現在の API キーを保持するには、空白のままにします。",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "最も最近使用されていないアカウントを選択する",
|
||||
"costOpt": "コストオプション",
|
||||
"costOptDesc": "利用可能な最も安いアカウントを優先する",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "スティッキー制限",
|
||||
"stickyLimitDesc": "切り替える前のアカウントごとの通話数",
|
||||
"modelAliases": "モデルのエイリアス",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "未加工のプラン: {plan}",
|
||||
"noPlanFromProvider": "プロバイダーからのプランなし",
|
||||
"noQuotaData": "クォータ データがありません",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "利用可能なクォータ データがありません",
|
||||
"noAccountsForTierFilter": "層フィルターのアカウントが見つかりませんでした",
|
||||
"tierAll": "すべて",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "균일한 무작위 선택 후 나머지 모델로 대체",
|
||||
"leastUsedDesc": "시간이 지남에 따라 로드 밸런싱을 통해 요청이 가장 적은 모델을 선택합니다.",
|
||||
"costOptimizedDesc": "가격을 기준으로 가장 저렴한 모델로 먼저 라우팅",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "모델",
|
||||
"autoBalance": "자동 균형",
|
||||
"advancedSettings": "고급 설정",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "이메일",
|
||||
"healthCheckMinutes": "상태 점검(분)",
|
||||
"healthCheckHint": "사전 토큰 새로 고침 간격. 0 = 비활성화됨.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "연결을 테스트하지 못했습니다.",
|
||||
"failed": "실패",
|
||||
"leaveBlankKeepCurrentApiKey": "현재 API 키를 유지하려면 비워 두세요.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "최근에 가장 적게 사용한 계정 선택",
|
||||
"costOpt": "비용 선택",
|
||||
"costOptDesc": "가장 저렴한 계정을 선호합니다",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "고정 한도",
|
||||
"stickyLimitDesc": "전환 전 계정당 통화",
|
||||
"modelAliases": "모델 별칭",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "기본 계획: {plan}",
|
||||
"noPlanFromProvider": "공급자의 계획 없음",
|
||||
"noQuotaData": "할당량 데이터 없음",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "사용 가능한 할당량 데이터가 없습니다.",
|
||||
"noAccountsForTierFilter": "등급 필터에 대한 계정을 찾을 수 없습니다.",
|
||||
"tierAll": "모두",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Pemilihan rawak seragam, kemudian sandarkan kepada model yang tinggal",
|
||||
"leastUsedDesc": "Memilih model dengan permintaan paling sedikit, mengimbangi beban dari semasa ke semasa",
|
||||
"costOptimizedDesc": "Laluan ke model termurah dahulu berdasarkan harga",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "model",
|
||||
"autoBalance": "Imbangan automatik",
|
||||
"advancedSettings": "Tetapan Lanjutan",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mel",
|
||||
"healthCheckMinutes": "Pemeriksaan Kesihatan (min)",
|
||||
"healthCheckHint": "Selang penyegaran token proaktif. 0 = kurang upaya.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Gagal menguji sambungan",
|
||||
"failed": "gagal",
|
||||
"leaveBlankKeepCurrentApiKey": "Biarkan kosong untuk mengekalkan kunci API semasa.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Pilih akaun yang paling kurang digunakan baru-baru ini",
|
||||
"costOpt": "Pilihan Kos",
|
||||
"costOptDesc": "Pilih akaun termurah yang tersedia",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Had Melekit",
|
||||
"stickyLimitDesc": "Panggilan setiap akaun sebelum bertukar",
|
||||
"modelAliases": "Alias Model",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Pelan mentah: {plan}",
|
||||
"noPlanFromProvider": "Tiada pelan daripada pembekal",
|
||||
"noQuotaData": "Tiada data kuota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Tiada data kuota tersedia",
|
||||
"noAccountsForTierFilter": "Tiada akaun ditemui untuk penapis peringkat",
|
||||
"tierAll": "Semua",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Uniforme willekeurige selectie en vervolgens terugvallen op de resterende modellen",
|
||||
"leastUsedDesc": "Kiest het model met de minste verzoeken, waarbij de belasting in de loop van de tijd wordt verdeeld",
|
||||
"costOptimizedDesc": "Routes eerst naar het goedkoopste model op basis van prijzen",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modellen",
|
||||
"autoBalance": "Automatische balans",
|
||||
"advancedSettings": "Geavanceerde instellingen",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Gezondheidscontrole (min)",
|
||||
"healthCheckHint": "Proactief tokenvernieuwingsinterval. 0 = uitgeschakeld.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Kan de verbinding niet testen",
|
||||
"failed": "Mislukt",
|
||||
"leaveBlankKeepCurrentApiKey": "Laat dit leeg om de huidige API-sleutel te behouden.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Kies het minst recent gebruikte account",
|
||||
"costOpt": "Kosten opt",
|
||||
"costOptDesc": "Geef de voorkeur aan het goedkoopste beschikbare account",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Kleverige limiet",
|
||||
"stickyLimitDesc": "Gesprekken per account voordat u overstapt",
|
||||
"modelAliases": "Modelaliassen",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Ruw plan: {plan}",
|
||||
"noPlanFromProvider": "Geen abonnement van aanbieder",
|
||||
"noQuotaData": "Geen quotagegevens",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Geen quotagegevens beschikbaar",
|
||||
"noAccountsForTierFilter": "Er zijn geen accounts gevonden voor niveaufilter",
|
||||
"tierAll": "Allemaal",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Ensartet tilfeldig valg, deretter fallback til gjenværende modeller",
|
||||
"leastUsedDesc": "Velger modellen med færrest forespørsler, og balanserer belastningen over tid",
|
||||
"costOptimizedDesc": "Ruter til den billigste modellen først basert på priser",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modeller",
|
||||
"autoBalance": "Autobalanse",
|
||||
"advancedSettings": "Avanserte innstillinger",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-post",
|
||||
"healthCheckMinutes": "Helsesjekk (min)",
|
||||
"healthCheckHint": "Proaktivt token-oppdateringsintervall. 0 = deaktivert.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Kunne ikke teste tilkoblingen",
|
||||
"failed": "Mislyktes",
|
||||
"leaveBlankKeepCurrentApiKey": "La stå tomt for å beholde gjeldende API-nøkkel.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Velg minst nylig brukte konto",
|
||||
"costOpt": "Kostnad Opt",
|
||||
"costOptDesc": "Foretrekker den billigste tilgjengelige kontoen",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Anrop per konto før bytte",
|
||||
"modelAliases": "Modellaliaser",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Rå plan: {plan}",
|
||||
"noPlanFromProvider": "Ingen plan fra leverandøren",
|
||||
"noQuotaData": "Ingen kvotedata",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Ingen kvotedata tilgjengelig",
|
||||
"noAccountsForTierFilter": "Fant ingen kontoer for nivåfilter",
|
||||
"tierAll": "Alle",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Uniform random selection, pagkatapos ay fallback sa natitirang mga modelo",
|
||||
"leastUsedDesc": "Pinipili ang modelo na may kaunting mga kahilingan, binabalanse ang pagkarga sa paglipas ng panahon",
|
||||
"costOptimizedDesc": "Mga ruta muna sa pinakamurang modelo batay sa pagpepresyo",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Mga modelo",
|
||||
"autoBalance": "Awtomatikong balanse",
|
||||
"advancedSettings": "Mga Advanced na Setting",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Health Check (min)",
|
||||
"healthCheckHint": "Proactive token refresh interval. 0 = hindi pinagana.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Nabigong subukan ang koneksyon",
|
||||
"failed": "Nabigo",
|
||||
"leaveBlankKeepCurrentApiKey": "Iwanang blangko upang mapanatili ang kasalukuyang API key.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Pumili ng hindi bababa sa kamakailang ginamit na account",
|
||||
"costOpt": "Cost Opt",
|
||||
"costOptDesc": "Mas gusto ang pinakamurang available na account",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Malagkit na Limitasyon",
|
||||
"stickyLimitDesc": "Mga tawag sa bawat account bago lumipat",
|
||||
"modelAliases": "Mga Alyas ng Modelo",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Raw plan: {plan}",
|
||||
"noPlanFromProvider": "Walang plano mula sa provider",
|
||||
"noQuotaData": "Walang data ng quota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Walang available na data ng quota",
|
||||
"noAccountsForTierFilter": "Walang nahanap na account para sa tier filter",
|
||||
"tierAll": "Lahat",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Jednolity wybór losowy, a następnie powrót do pozostałych modeli",
|
||||
"leastUsedDesc": "Wybiera model z najmniejszą liczbą żądań, równoważąc obciążenie w czasie",
|
||||
"costOptimizedDesc": "Najpierw wybiera najtańszy model na podstawie ceny",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modele",
|
||||
"autoBalance": "Automatyczne balansowanie",
|
||||
"advancedSettings": "Ustawienia zaawansowane",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Kontrola stanu (min)",
|
||||
"healthCheckHint": "Proaktywny interwał odświeżania tokenu. 0 = wyłączone.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Nie udało się przetestować połączenia",
|
||||
"failed": "Nie udało się",
|
||||
"leaveBlankKeepCurrentApiKey": "Pozostaw puste, aby zachować bieżący klucz API.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Wybierz ostatnio używane konto",
|
||||
"costOpt": "Opcja kosztowa",
|
||||
"costOptDesc": "Preferuj najtańsze dostępne konto",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Lepki limit",
|
||||
"stickyLimitDesc": "Połączenia na konto przed zmianą",
|
||||
"modelAliases": "Aliasy modeli",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Surowy plan: {plan}",
|
||||
"noPlanFromProvider": "Brak planu od dostawcy",
|
||||
"noQuotaData": "Brak danych dotyczących kwot",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Brak dostępnych danych dotyczących kwot",
|
||||
"noAccountsForTierFilter": "Nie znaleziono kont dla filtra poziomów",
|
||||
"tierAll": "Wszystko",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"dashboard": "Painel",
|
||||
"providers": "Provedores",
|
||||
"combos": "Combos",
|
||||
"autoCombo": "Auto Combo",
|
||||
"usage": "Uso",
|
||||
"analytics": "Análises",
|
||||
"costs": "Custos",
|
||||
@@ -235,6 +236,26 @@
|
||||
"keyCreatedNote": "Copie e armazene esta chave agora — ela não será mostrada novamente.",
|
||||
"done": "Pronto",
|
||||
"savePermissions": "Salvar Permissões",
|
||||
"autoResolve": "Auto-Resolve",
|
||||
"autoResolveDesc": "Resolve automaticamente nomes ambíguos de modelo para o provedor nativo desta API key.",
|
||||
"keyActive": "Chave Ativa",
|
||||
"keyActiveDesc": "Ativa ou desativa esta API key. Chaves desativadas são bloqueadas com 403.",
|
||||
"accessSchedule": "Horário de Acesso",
|
||||
"accessScheduleDesc": "Restrinja o acesso a horários e dias da semana específicos.",
|
||||
"scheduleFrom": "Das",
|
||||
"scheduleUntil": "Até",
|
||||
"scheduleDays": "Dias",
|
||||
"scheduleTimezone": "Fuso Horário",
|
||||
"scheduleTimezoneHint": "Use nomes IANA, ex: America/Sao_Paulo",
|
||||
"scheduleActive": "Agenda",
|
||||
"disabled": "Desativada",
|
||||
"daySun": "Dom",
|
||||
"dayMon": "Seg",
|
||||
"dayTue": "Ter",
|
||||
"dayWed": "Qua",
|
||||
"dayThu": "Qui",
|
||||
"dayFri": "Sex",
|
||||
"daySat": "Sáb",
|
||||
"allowAll": "Permitir Tudo",
|
||||
"restrict": "Restringir",
|
||||
"allowAllInfo": "Esta chave pode acessar todos os modelos disponíveis.",
|
||||
@@ -604,6 +625,8 @@
|
||||
"randomDesc": "Seleção aleatória uniforme, depois fallback para modelos restantes",
|
||||
"leastUsedDesc": "Escolhe o modelo com menos requisições, equilibrando carga ao longo do tempo",
|
||||
"costOptimizedDesc": "Roteia para o modelo mais barato primeiro baseado em preços",
|
||||
"strictRandom": "Aleatório Estrito",
|
||||
"strictRandomDesc": "Baralho embaralhado — usa cada modelo uma vez antes de reembaralhar",
|
||||
"models": "Modelos",
|
||||
"autoBalance": "Auto-balancear",
|
||||
"advancedSettings": "Configurações Avançadas",
|
||||
@@ -652,6 +675,11 @@
|
||||
"when": "Redução de custo é a prioridade principal.",
|
||||
"avoid": "A base de preços está ausente ou desatualizada.",
|
||||
"example": "Jobs em lote ou segundo plano focados em menor custo."
|
||||
},
|
||||
"strict-random": {
|
||||
"when": "Você quer distribuição perfeitamente uniforme — cada modelo é usado uma vez antes de repetir.",
|
||||
"avoid": "Os modelos têm qualidade ou latência muito diferentes e a ordem importa.",
|
||||
"example": "Múltiplas contas do mesmo modelo para distribuir uso de forma equilibrada."
|
||||
}
|
||||
},
|
||||
"advancedHelp": {
|
||||
@@ -705,46 +733,53 @@
|
||||
"recommendationsApplied": "Recommendations applied to this combo.",
|
||||
"strategyRecommendations": {
|
||||
"priority": {
|
||||
"title": "Fail-safe baseline",
|
||||
"description": "Use one primary model and keep fallback chain short and reliable.",
|
||||
"tip1": "Put your most reliable model first.",
|
||||
"tip2": "Keep 1-2 backup models with similar quality.",
|
||||
"tip3": "Use safe retries to absorb transient provider failures."
|
||||
"title": "Fail-safe básico",
|
||||
"description": "Use um modelo principal e mantenha a cadeia de fallback curta e confiável.",
|
||||
"tip1": "Coloque o modelo mais confiável em primeiro.",
|
||||
"tip2": "Mantenha 1-2 modelos de backup com qualidade similar.",
|
||||
"tip3": "Use retries seguros para absorver falhas transitórias do provedor."
|
||||
},
|
||||
"weighted": {
|
||||
"title": "Controlled traffic split",
|
||||
"description": "Great for canary rollouts and gradual migration between models.",
|
||||
"tip1": "Start with conservative split like 90/10.",
|
||||
"tip2": "Keep the total at 100% and auto-balance after changes.",
|
||||
"tip3": "Monitor success and latency before increasing canary weight."
|
||||
"title": "Divisão controlada de tráfego",
|
||||
"description": "Ótimo para rollouts canário e migração gradual entre modelos.",
|
||||
"tip1": "Comece com divisão conservadora tipo 90/10.",
|
||||
"tip2": "Mantenha o total em 100% e rebalanceie após mudanças.",
|
||||
"tip3": "Monitore sucesso e latência antes de aumentar o peso canário."
|
||||
},
|
||||
"round-robin": {
|
||||
"title": "Predictable load sharing",
|
||||
"description": "Best when models are equivalent and you need smooth distribution.",
|
||||
"tip1": "Use at least 2 models.",
|
||||
"tip2": "Set concurrency limits to avoid burst overload.",
|
||||
"tip3": "Use queue timeout to fail fast under saturation."
|
||||
"title": "Distribuição previsível de carga",
|
||||
"description": "Melhor quando os modelos são equivalentes e você precisa de distribuição uniforme.",
|
||||
"tip1": "Use pelo menos 2 modelos.",
|
||||
"tip2": "Configure limites de concorrência para evitar sobrecarga.",
|
||||
"tip3": "Use timeout de fila para falhar rápido sob saturação."
|
||||
},
|
||||
"random": {
|
||||
"title": "Quick spread with low setup",
|
||||
"description": "Use when you need simple distribution without strict guarantees.",
|
||||
"tip1": "Use models with similar latency profiles.",
|
||||
"tip2": "Keep retries enabled to absorb random misses.",
|
||||
"tip3": "Prefer this for experimentation, not strict SLAs."
|
||||
"title": "Distribuição rápida com baixa configuração",
|
||||
"description": "Use quando precisar de distribuição simples sem garantias rígidas.",
|
||||
"tip1": "Use modelos com perfis de latência semelhantes.",
|
||||
"tip2": "Mantenha retries habilitados para absorver falhas aleatórias.",
|
||||
"tip3": "Prefira para experimentação, não para SLAs rígidos."
|
||||
},
|
||||
"least-used": {
|
||||
"title": "Adaptive balancing",
|
||||
"description": "Routes to less-used models to reduce hotspots over time.",
|
||||
"tip1": "Works better under continuous traffic.",
|
||||
"tip2": "Combine with health checks for safer balancing.",
|
||||
"tip3": "Track per-model usage to validate distribution gains."
|
||||
"title": "Balanceamento adaptativo",
|
||||
"description": "Roteia para modelos menos usados para reduzir hotspots ao longo do tempo.",
|
||||
"tip1": "Funciona melhor sob tráfego contínuo.",
|
||||
"tip2": "Combine com health checks para balanceamento mais seguro.",
|
||||
"tip3": "Acompanhe uso por modelo para validar ganhos na distribuição."
|
||||
},
|
||||
"cost-optimized": {
|
||||
"title": "Budget-first routing",
|
||||
"description": "Routes to lower-cost models when pricing metadata is available.",
|
||||
"tip1": "Ensure pricing coverage for all selected models.",
|
||||
"tip2": "Keep a quality fallback for hard prompts.",
|
||||
"tip3": "Use for batch/background jobs where cost is the main KPI."
|
||||
"title": "Roteamento por orçamento",
|
||||
"description": "Roteia para modelos mais baratos quando metadados de preço estão disponíveis.",
|
||||
"tip1": "Garanta cobertura de preços para todos os modelos selecionados.",
|
||||
"tip2": "Mantenha um fallback de qualidade para prompts difíceis.",
|
||||
"tip3": "Use para jobs em lote/background onde custo é o KPI principal."
|
||||
},
|
||||
"strict-random": {
|
||||
"title": "Distribuição estritamente uniforme",
|
||||
"description": "Cada modelo é usado exatamente uma vez antes de reembaralhar o baralho.",
|
||||
"tip1": "Ideal para múltiplas contas do mesmo modelo.",
|
||||
"tip2": "Garante que nenhuma conta é repetida antes de todas serem usadas.",
|
||||
"tip3": "Combine com health checks para pular contas indisponíveis sem quebrar o ciclo."
|
||||
}
|
||||
},
|
||||
"templateFreeStack": "Free Stack ($0)",
|
||||
@@ -1378,6 +1413,8 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Health Check (min)",
|
||||
"healthCheckHint": "Intervalo proativo de renovação de token. 0 = desativado.",
|
||||
"groupLabel": "Ambiente",
|
||||
"groupPlaceholder": "ex: eKaizen, Pessoal",
|
||||
"failedTestConnection": "Falha ao testar conexão",
|
||||
"failed": "Falhou",
|
||||
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave de API atual.",
|
||||
@@ -1548,6 +1585,8 @@
|
||||
"leastUsedDesc": "Escolher a conta usada menos recentemente",
|
||||
"costOpt": "Custo Otimizado",
|
||||
"costOptDesc": "Preferir conta mais barata disponível",
|
||||
"strictRandom": "Aleatório Estrito",
|
||||
"strictRandomDesc": "Baralho embaralhado — usa cada conta uma vez antes de reembaralhar",
|
||||
"stickyLimit": "Limite Fixo",
|
||||
"stickyLimitDesc": "Chamadas por conta antes de trocar",
|
||||
"modelAliases": "Aliases de Modelo",
|
||||
@@ -2072,6 +2111,9 @@
|
||||
"rawPlanWithValue": "Plano bruto: {plan}",
|
||||
"noPlanFromProvider": "Sem plano do provedor",
|
||||
"noQuotaData": "Sem dados de cota",
|
||||
"ungrouped": "Sem grupo",
|
||||
"viewFlat": "Lista",
|
||||
"viewByEnvironment": "Por Ambiente",
|
||||
"noQuotaDataAvailable": "Nenhum dado de cota disponível",
|
||||
"noAccountsForTierFilter": "Nenhuma conta encontrada para o filtro de plano",
|
||||
"tierAll": "Todos",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Seleção aleatória uniforme e, em seguida, retorno aos modelos restantes",
|
||||
"leastUsedDesc": "Escolhe o modelo com menos solicitações, equilibrando a carga ao longo do tempo",
|
||||
"costOptimizedDesc": "Rotas para o modelo mais barato primeiro com base no preço",
|
||||
"strictRandom": "Aleatório Estrito",
|
||||
"strictRandomDesc": "Baralho embaralhado — usa cada modelo uma vez antes de reembaralhar",
|
||||
"models": "Modelos",
|
||||
"autoBalance": "Equilíbrio automático",
|
||||
"advancedSettings": "Configurações avançadas",
|
||||
@@ -1390,6 +1392,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Verificação de integridade (min)",
|
||||
"healthCheckHint": "Intervalo de atualização de token proativo. 0 = desabilitado.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Falha ao testar a conexão",
|
||||
"failed": "Falha",
|
||||
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave API atual.",
|
||||
@@ -1555,6 +1559,8 @@
|
||||
"leastUsedDesc": "Escolha a conta usada menos recentemente",
|
||||
"costOpt": "Opção de custo",
|
||||
"costOptDesc": "Prefira a conta mais barata disponível",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Limite pegajoso",
|
||||
"stickyLimitDesc": "Chamadas por conta antes de mudar",
|
||||
"modelAliases": "Aliases de modelo",
|
||||
@@ -2084,6 +2090,9 @@
|
||||
"rawPlanWithValue": "Plano bruto: {plan}",
|
||||
"noPlanFromProvider": "Nenhum plano do provedor",
|
||||
"noQuotaData": "Sem dados de cota",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Não há dados de cota disponíveis",
|
||||
"noAccountsForTierFilter": "Nenhuma conta encontrada para filtro de nível",
|
||||
"tierAll": "Todos",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Selectare aleatorie uniformă, apoi revenire la modelele rămase",
|
||||
"leastUsedDesc": "Alege modelul cu cele mai puține solicitări, echilibrând sarcina în timp",
|
||||
"costOptimizedDesc": "Rute către cel mai ieftin model mai întâi pe baza prețului",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modele",
|
||||
"autoBalance": "Auto-echilibrare",
|
||||
"advancedSettings": "Setări avansate",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-mail",
|
||||
"healthCheckMinutes": "Verificare de sănătate (min)",
|
||||
"healthCheckHint": "Interval proactiv de reîmprospătare a simbolului. 0 = dezactivat.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Nu s-a testat conexiunea",
|
||||
"failed": "A eșuat",
|
||||
"leaveBlankKeepCurrentApiKey": "Lăsați necompletat pentru a păstra cheia API curentă.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Alegeți contul cel mai puțin utilizat recent",
|
||||
"costOpt": "Cost Opt",
|
||||
"costOptDesc": "Prefer cel mai ieftin cont disponibil",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Limită lipicioasă",
|
||||
"stickyLimitDesc": "Apeluri pe cont înainte de a comuta",
|
||||
"modelAliases": "Aliasuri de model",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Plan brut: {plan}",
|
||||
"noPlanFromProvider": "Niciun plan de la furnizor",
|
||||
"noQuotaData": "Fără date de cotă",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Nu sunt disponibile date privind cotele",
|
||||
"noAccountsForTierFilter": "Nu s-au găsit conturi pentru filtrul de nivel",
|
||||
"tierAll": "Toate",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Равномерный случайный выбор, затем возврат к оставшимся моделям",
|
||||
"leastUsedDesc": "Выбирает модель с наименьшим количеством запросов, балансируя нагрузку с течением времени.",
|
||||
"costOptimizedDesc": "Маршруты к самой дешевой модели в первую очередь на основе цены",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Модели",
|
||||
"autoBalance": "Автобаланс",
|
||||
"advancedSettings": "Расширенные настройки",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "электронная почта",
|
||||
"healthCheckMinutes": "Проверка здоровья (мин)",
|
||||
"healthCheckHint": "Интервал обновления упреждающего токена. 0 = отключено.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Не удалось проверить соединение",
|
||||
"failed": "Не удалось",
|
||||
"leaveBlankKeepCurrentApiKey": "Оставьте пустым, чтобы сохранить текущий ключ API.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Выберите наименее использованную учетную запись",
|
||||
"costOpt": "Опция стоимости",
|
||||
"costOptDesc": "Предпочитаю самый дешевый доступный аккаунт",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Липкий лимит",
|
||||
"stickyLimitDesc": "Звонки на аккаунт до переключения",
|
||||
"modelAliases": "Псевдонимы моделей",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Необработанный план: {plan}",
|
||||
"noPlanFromProvider": "Нет плана от провайдера",
|
||||
"noQuotaData": "Нет данных о квотах",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Нет данных о квотах",
|
||||
"noAccountsForTierFilter": "Аккаунты для фильтра уровня не найдены",
|
||||
"tierAll": "Все",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Jednotný náhodný výber, potom návrat k zostávajúcim modelom",
|
||||
"leastUsedDesc": "Vyberie model s najmenším počtom požiadaviek, čím vyrovná zaťaženie v priebehu času",
|
||||
"costOptimizedDesc": "Najprv sa presmeruje na najlacnejší model na základe ceny",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modelky",
|
||||
"autoBalance": "Automatické vyváženie",
|
||||
"advancedSettings": "Rozšírené nastavenia",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Kontrola stavu (min)",
|
||||
"healthCheckHint": "Interval proaktívneho obnovenia tokenu. 0 = vypnuté.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Nepodarilo sa otestovať pripojenie",
|
||||
"failed": "Nepodarilo sa",
|
||||
"leaveBlankKeepCurrentApiKey": "Ak chcete zachovať aktuálny kľúč API, nechajte pole prázdne.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Vyberte najmenej nedávno používaný účet",
|
||||
"costOpt": "Opt",
|
||||
"costOptDesc": "Uprednostnite najlacnejší dostupný účet",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Hovory na účet pred prepnutím",
|
||||
"modelAliases": "Aliasy modelov",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Nespracovaný plán: {plan}",
|
||||
"noPlanFromProvider": "Žiadny plán od poskytovateľa",
|
||||
"noQuotaData": "Žiadne údaje o kvóte",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Nie sú k dispozícii žiadne údaje o kvóte",
|
||||
"noAccountsForTierFilter": "Pre filter úrovne sa nenašli žiadne účty",
|
||||
"tierAll": "Všetky",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Enhetligt slumpmässigt urval, sedan fallback till återstående modeller",
|
||||
"leastUsedDesc": "Väljer modellen med minst förfrågningar, balanserar belastningen över tiden",
|
||||
"costOptimizedDesc": "Rutter till den billigaste modellen först baserat på prissättning",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modeller",
|
||||
"autoBalance": "Automatisk balansering",
|
||||
"advancedSettings": "Avancerade inställningar",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "E-post",
|
||||
"healthCheckMinutes": "Hälsokontroll (min)",
|
||||
"healthCheckHint": "Proaktivt uppdateringsintervall för token. 0 = inaktiverad.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Det gick inte att testa anslutningen",
|
||||
"failed": "Misslyckades",
|
||||
"leaveBlankKeepCurrentApiKey": "Lämna tomt för att behålla den aktuella API-nyckeln.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Välj minst senast använda konto",
|
||||
"costOpt": "Kostnad Opt",
|
||||
"costOptDesc": "Föredrar billigaste tillgängliga konto",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Samtal per konto innan byte",
|
||||
"modelAliases": "Modellalias",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Rå plan: {plan}",
|
||||
"noPlanFromProvider": "Ingen plan från leverantören",
|
||||
"noQuotaData": "Inga kvotdata",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Inga kvotdata tillgängliga",
|
||||
"noAccountsForTierFilter": "Inga konton hittades för nivåfilter",
|
||||
"tierAll": "Alla",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "การเลือกแบบสุ่มแบบสม่ำเสมอ จากนั้นจึงย้อนกลับไปยังโมเดลที่เหลือ",
|
||||
"leastUsedDesc": "เลือกโมเดลที่มีคำขอน้อยที่สุด โดยจะปรับสมดุลการโหลดเมื่อเวลาผ่านไป",
|
||||
"costOptimizedDesc": "กำหนดเส้นทางไปยังรุ่นที่ถูกที่สุดก่อนตามราคา",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "โมเดล",
|
||||
"autoBalance": "ปรับสมดุลอัตโนมัติ",
|
||||
"advancedSettings": "การตั้งค่าขั้นสูง",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "อีเมล",
|
||||
"healthCheckMinutes": "ตรวจสุขภาพ (ขั้นต่ำ)",
|
||||
"healthCheckHint": "ช่วงเวลาการรีเฟรชโทเค็นเชิงรุก 0 = ปิดการใช้งาน",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "ทดสอบการเชื่อมต่อไม่สำเร็จ",
|
||||
"failed": "ล้มเหลว",
|
||||
"leaveBlankKeepCurrentApiKey": "เว้นว่างไว้เพื่อเก็บคีย์ API ปัจจุบันไว้",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "เลือกบัญชีที่ใช้ล่าสุดน้อยที่สุด",
|
||||
"costOpt": "การเลือกใช้ต้นทุน",
|
||||
"costOptDesc": "ต้องการบัญชีที่ถูกที่สุด",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "ขีด จำกัด เหนียว",
|
||||
"stickyLimitDesc": "โทรต่อบัญชีก่อนที่จะเปลี่ยน",
|
||||
"modelAliases": "นามแฝงของโมเดล",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "แผนดิบ: {plan}",
|
||||
"noPlanFromProvider": "ไม่มีแผนจากผู้ให้บริการ",
|
||||
"noQuotaData": "ไม่มีข้อมูลโควต้า",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "ไม่มีข้อมูลโควต้า",
|
||||
"noAccountsForTierFilter": "ไม่พบบัญชีสำหรับตัวกรองระดับ",
|
||||
"tierAll": "ทั้งหมด",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Рівномірний випадковий вибір, а потім повернення до інших моделей",
|
||||
"leastUsedDesc": "Вибирає модель із найменшою кількістю запитів, балансуючи навантаження за часом",
|
||||
"costOptimizedDesc": "Маршрути до найдешевшої моделі на основі ціни",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Моделі",
|
||||
"autoBalance": "Автобаланс",
|
||||
"advancedSettings": "Розширені налаштування",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Електронна пошта",
|
||||
"healthCheckMinutes": "Перевірка стану (хв.)",
|
||||
"healthCheckHint": "Проактивний інтервал оновлення маркера. 0 = вимкнено.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Не вдалося перевірити з’єднання",
|
||||
"failed": "Не вдалося",
|
||||
"leaveBlankKeepCurrentApiKey": "Залиште поле порожнім, щоб зберегти поточний ключ API.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Виберіть нещодавно використовуваний обліковий запис",
|
||||
"costOpt": "Вартість Opt",
|
||||
"costOptDesc": "Віддайте перевагу найдешевшому доступному обліковому запису",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
"stickyLimitDesc": "Дзвінки на обліковий запис перед переходом",
|
||||
"modelAliases": "Псевдоніми моделі",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Необроблений план: {plan}",
|
||||
"noPlanFromProvider": "Без плану від провайдера",
|
||||
"noQuotaData": "Немає даних про квоти",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Немає даних про квоти",
|
||||
"noAccountsForTierFilter": "Не знайдено облікових записів для фільтра рівня",
|
||||
"tierAll": "всі",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "Lựa chọn ngẫu nhiên thống nhất, sau đó dự phòng cho các mô hình còn lại",
|
||||
"leastUsedDesc": "Chọn mô hình có ít yêu cầu nhất, cân bằng tải theo thời gian",
|
||||
"costOptimizedDesc": "Hướng tới mô hình rẻ nhất trước tiên dựa trên giá cả",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Người mẫu",
|
||||
"autoBalance": "Tự động cân bằng",
|
||||
"advancedSettings": "Cài đặt nâng cao",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "Email",
|
||||
"healthCheckMinutes": "Kiểm tra sức khỏe (phút)",
|
||||
"healthCheckHint": "Khoảng thời gian làm mới mã thông báo chủ động. 0 = bị vô hiệu hóa.",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "Không thể kiểm tra kết nối",
|
||||
"failed": "thất bại",
|
||||
"leaveBlankKeepCurrentApiKey": "Để trống để giữ khóa API hiện tại.",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "Chọn tài khoản ít được sử dụng gần đây nhất",
|
||||
"costOpt": "Lựa chọn chi phí",
|
||||
"costOptDesc": "Ưu tiên tài khoản có sẵn rẻ nhất",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Giới hạn dính",
|
||||
"stickyLimitDesc": "Cuộc gọi trên mỗi tài khoản trước khi chuyển đổi",
|
||||
"modelAliases": "Bí danh mẫu",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "Gói thô: {plan}",
|
||||
"noPlanFromProvider": "Không có kế hoạch từ nhà cung cấp",
|
||||
"noQuotaData": "Không có dữ liệu hạn ngạch",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "Không có sẵn dữ liệu hạn ngạch",
|
||||
"noAccountsForTierFilter": "Không tìm thấy tài khoản nào cho bộ lọc cấp độ",
|
||||
"tierAll": "Tất cả",
|
||||
|
||||
@@ -604,6 +604,8 @@
|
||||
"randomDesc": "统一随机选择,然后回退到剩余模型",
|
||||
"leastUsedDesc": "选择请求最少的模型,随着时间的推移平衡负载",
|
||||
"costOptimizedDesc": "首先根据定价路由至最便宜的型号",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "型号",
|
||||
"autoBalance": "自动平衡",
|
||||
"advancedSettings": "高级设置",
|
||||
@@ -1378,6 +1380,8 @@
|
||||
"email": "电子邮件",
|
||||
"healthCheckMinutes": "健康检查(分钟)",
|
||||
"healthCheckHint": "主动令牌刷新间隔。 0 = 禁用。",
|
||||
"groupLabel": "Environment",
|
||||
"groupPlaceholder": "e.g. eKaizen, Personal",
|
||||
"failedTestConnection": "测试连接失败",
|
||||
"failed": "失败",
|
||||
"leaveBlankKeepCurrentApiKey": "留空以保留当前的 API 密钥。",
|
||||
@@ -1543,6 +1547,8 @@
|
||||
"leastUsedDesc": "选择最近最少使用的帐户",
|
||||
"costOpt": "成本选择",
|
||||
"costOptDesc": "更喜欢最便宜的可用帐户",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "粘性限制",
|
||||
"stickyLimitDesc": "切换前每个账户的通话次数",
|
||||
"modelAliases": "模型别名",
|
||||
@@ -2072,6 +2078,9 @@
|
||||
"rawPlanWithValue": "原始计划:{plan}",
|
||||
"noPlanFromProvider": "提供商没有计划",
|
||||
"noQuotaData": "无配额数据",
|
||||
"ungrouped": "Ungrouped",
|
||||
"viewFlat": "Flat",
|
||||
"viewByEnvironment": "By Environment",
|
||||
"noQuotaDataAvailable": "无可用配额数据",
|
||||
"noAccountsForTierFilter": "未找到适用于层过滤器的帐户",
|
||||
"tierAll": "全部",
|
||||
|
||||
+175
-4
@@ -20,12 +20,24 @@ interface CacheEntry<TValue> {
|
||||
value: TValue;
|
||||
}
|
||||
|
||||
export interface AccessSchedule {
|
||||
enabled: boolean;
|
||||
from: string;
|
||||
until: string;
|
||||
days: number[];
|
||||
tz: string;
|
||||
}
|
||||
|
||||
interface ApiKeyMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
machineId: string | null;
|
||||
allowedModels: string[];
|
||||
allowedConnections: string[];
|
||||
noLog: boolean;
|
||||
autoResolve: boolean;
|
||||
isActive: boolean;
|
||||
accessSchedule: AccessSchedule | null;
|
||||
}
|
||||
|
||||
interface ApiKeyRow extends JsonRecord {
|
||||
@@ -36,8 +48,16 @@ interface ApiKeyRow extends JsonRecord {
|
||||
machineId?: unknown;
|
||||
allowed_models?: unknown;
|
||||
allowedModels?: unknown;
|
||||
allowed_connections?: unknown;
|
||||
allowedConnections?: unknown;
|
||||
no_log?: unknown;
|
||||
noLog?: unknown;
|
||||
auto_resolve?: unknown;
|
||||
autoResolve?: unknown;
|
||||
is_active?: unknown;
|
||||
isActive?: unknown;
|
||||
access_schedule?: unknown;
|
||||
accessSchedule?: unknown;
|
||||
}
|
||||
|
||||
interface StatementLike<TRow = unknown> {
|
||||
@@ -63,7 +83,11 @@ interface ApiKeysStatements {
|
||||
interface ApiKeyView extends JsonRecord {
|
||||
id?: string;
|
||||
allowedModels: string[];
|
||||
allowedConnections: string[];
|
||||
noLog: boolean;
|
||||
autoResolve: boolean;
|
||||
isActive: boolean;
|
||||
accessSchedule: AccessSchedule | null;
|
||||
}
|
||||
|
||||
// LRU cache for API key validation (valid keys only)
|
||||
@@ -147,6 +171,22 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN no_log INTEGER NOT NULL DEFAULT 0");
|
||||
console.log("[DB] Added api_keys.no_log column");
|
||||
}
|
||||
if (!columnNames.has("allowed_connections")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN allowed_connections TEXT");
|
||||
console.log("[DB] Added api_keys.allowed_connections column");
|
||||
}
|
||||
if (!columnNames.has("auto_resolve")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN auto_resolve INTEGER NOT NULL DEFAULT 0");
|
||||
console.log("[DB] Added api_keys.auto_resolve column");
|
||||
}
|
||||
if (!columnNames.has("is_active")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1");
|
||||
console.log("[DB] Added api_keys.is_active column");
|
||||
}
|
||||
if (!columnNames.has("access_schedule")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN access_schedule TEXT");
|
||||
console.log("[DB] Added api_keys.access_schedule column");
|
||||
}
|
||||
_schemaChecked = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -172,7 +212,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
||||
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
|
||||
_stmtValidateKey = db.prepare<JsonRecord>("SELECT 1 FROM api_keys WHERE key = ?");
|
||||
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
|
||||
"SELECT id, name, machine_id, allowed_models, no_log FROM api_keys WHERE key = ?"
|
||||
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule FROM api_keys WHERE key = ?"
|
||||
);
|
||||
_stmtInsertKey = db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
@@ -208,7 +248,11 @@ export async function getApiKeys() {
|
||||
return rows.map((row) => {
|
||||
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
|
||||
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
|
||||
camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections);
|
||||
camelRow.noLog = parseNoLog(camelRow.noLog);
|
||||
camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve);
|
||||
camelRow.isActive = parseIsActive(camelRow.isActive);
|
||||
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
|
||||
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
|
||||
setNoLog(camelRow.id, camelRow.noLog === true);
|
||||
}
|
||||
@@ -223,7 +267,11 @@ export async function getApiKeyById(id: string) {
|
||||
if (!row) return null;
|
||||
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
|
||||
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
|
||||
camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections);
|
||||
camelRow.noLog = parseNoLog(camelRow.noLog);
|
||||
camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve);
|
||||
camelRow.isActive = parseIsActive(camelRow.isActive);
|
||||
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
|
||||
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
|
||||
setNoLog(camelRow.id, camelRow.noLog === true);
|
||||
}
|
||||
@@ -251,6 +299,63 @@ function parseNoLog(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
function parseAutoResolve(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
function parseIsActive(value: unknown): boolean {
|
||||
// DEFAULT 1 — active unless explicitly set to 0
|
||||
if (value === 0 || value === "0" || value === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseAccessSchedule(value: unknown): AccessSchedule | null {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (
|
||||
typeof obj["enabled"] !== "boolean" ||
|
||||
typeof obj["from"] !== "string" ||
|
||||
typeof obj["until"] !== "string" ||
|
||||
!Array.isArray(obj["days"]) ||
|
||||
typeof obj["tz"] !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const days = (obj["days"] as unknown[]).filter(
|
||||
(d): d is number => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6
|
||||
);
|
||||
return {
|
||||
enabled: obj["enabled"],
|
||||
from: obj["from"],
|
||||
until: obj["until"],
|
||||
days,
|
||||
tz: obj["tz"],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to safely parse allowed_connections JSON
|
||||
*/
|
||||
function parseAllowedConnections(value: unknown): string[] {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApiKey(name: string, machineId: string) {
|
||||
if (!machineId) {
|
||||
throw new Error("machineId is required");
|
||||
@@ -268,6 +373,7 @@ export async function createApiKey(name: string, machineId: string) {
|
||||
key: result.key,
|
||||
machineId: machineId,
|
||||
allowedModels: [], // Empty array means all models allowed
|
||||
allowedConnections: [], // Empty array means all connections allowed
|
||||
noLog: false,
|
||||
createdAt: now,
|
||||
};
|
||||
@@ -290,7 +396,17 @@ export async function createApiKey(name: string, machineId: string) {
|
||||
|
||||
export async function updateApiKeyPermissions(
|
||||
id: string,
|
||||
update: string[] | { allowedModels?: string[]; noLog?: boolean }
|
||||
update:
|
||||
| string[]
|
||||
| {
|
||||
name?: string;
|
||||
allowedModels?: string[];
|
||||
allowedConnections?: string[];
|
||||
noLog?: boolean;
|
||||
autoResolve?: boolean;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
}
|
||||
) {
|
||||
const db = getDbInstance() as ApiKeysDbLike;
|
||||
getPreparedStatements(db);
|
||||
@@ -299,16 +415,43 @@ export async function updateApiKeyPermissions(
|
||||
Array.isArray(update) || update === undefined
|
||||
? { allowedModels: update || [] }
|
||||
: {
|
||||
name: update.name,
|
||||
allowedModels: update.allowedModels,
|
||||
allowedConnections: update.allowedConnections,
|
||||
noLog: update.noLog,
|
||||
autoResolve: update.autoResolve,
|
||||
isActive: update.isActive,
|
||||
accessSchedule: update.accessSchedule,
|
||||
};
|
||||
|
||||
if (normalized.allowedModels === undefined && normalized.noLog === undefined) {
|
||||
if (
|
||||
normalized.name === undefined &&
|
||||
normalized.allowedModels === undefined &&
|
||||
normalized.allowedConnections === undefined &&
|
||||
normalized.noLog === undefined &&
|
||||
normalized.autoResolve === undefined &&
|
||||
normalized.isActive === undefined &&
|
||||
normalized.accessSchedule === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: { id: string; allowedModels?: string; noLog?: number } = { id };
|
||||
const params: {
|
||||
id: string;
|
||||
name?: string;
|
||||
allowedModels?: string;
|
||||
allowedConnections?: string;
|
||||
noLog?: number;
|
||||
autoResolve?: number;
|
||||
isActive?: number;
|
||||
accessSchedule?: string | null;
|
||||
} = { id };
|
||||
|
||||
if (normalized.name !== undefined) {
|
||||
updates.push("name = @name");
|
||||
params.name = normalized.name;
|
||||
}
|
||||
|
||||
if (normalized.allowedModels !== undefined) {
|
||||
// Empty array means all models are allowed
|
||||
@@ -316,11 +459,33 @@ export async function updateApiKeyPermissions(
|
||||
params.allowedModels = JSON.stringify(normalized.allowedModels || []);
|
||||
}
|
||||
|
||||
if (normalized.allowedConnections !== undefined) {
|
||||
// Empty array means all connections are allowed
|
||||
updates.push("allowed_connections = @allowedConnections");
|
||||
params.allowedConnections = JSON.stringify(normalized.allowedConnections || []);
|
||||
}
|
||||
|
||||
if (normalized.noLog !== undefined) {
|
||||
updates.push("no_log = @noLog");
|
||||
params.noLog = normalized.noLog ? 1 : 0;
|
||||
}
|
||||
|
||||
if (normalized.autoResolve !== undefined) {
|
||||
updates.push("auto_resolve = @autoResolve");
|
||||
params.autoResolve = normalized.autoResolve ? 1 : 0;
|
||||
}
|
||||
|
||||
if (normalized.isActive !== undefined) {
|
||||
updates.push("is_active = @isActive");
|
||||
params.isActive = normalized.isActive ? 1 : 0;
|
||||
}
|
||||
|
||||
if (normalized.accessSchedule !== undefined) {
|
||||
updates.push("access_schedule = @accessSchedule");
|
||||
params.accessSchedule =
|
||||
normalized.accessSchedule !== null ? JSON.stringify(normalized.accessSchedule) : null;
|
||||
}
|
||||
|
||||
const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
@@ -414,7 +579,13 @@ export async function getApiKeyMetadata(
|
||||
name: metadataName,
|
||||
machineId: metadataMachineId,
|
||||
allowedModels: parseAllowedModels(record.allowed_models ?? record.allowedModels),
|
||||
allowedConnections: parseAllowedConnections(
|
||||
record.allowed_connections ?? record.allowedConnections
|
||||
),
|
||||
noLog: parseNoLog(record.no_log ?? record.noLog),
|
||||
autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve),
|
||||
isActive: parseIsActive(record.is_active ?? record.isActive),
|
||||
accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule),
|
||||
};
|
||||
|
||||
if (!metadata.id) {
|
||||
|
||||
+20
-2
@@ -6,6 +6,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { getDbInstance, rowToCamel, cleanNulls } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
import { encryptConnectionFields, decryptConnectionFields } from "./encryption";
|
||||
import { invalidateDbCache } from "./readCache";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -184,6 +185,7 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
"errorCode",
|
||||
"consecutiveUseCount",
|
||||
"rateLimitProtection",
|
||||
"group",
|
||||
];
|
||||
for (const field of optionalFields) {
|
||||
if (data[field] !== undefined && data[field] !== null) {
|
||||
@@ -200,6 +202,7 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
_reorderConnections(db, providerId);
|
||||
}
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("connections"); // Bust connections read cache
|
||||
|
||||
return cleanNulls(connection);
|
||||
}
|
||||
@@ -215,7 +218,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
rate_limited_until, health_check_interval, last_health_check_at,
|
||||
last_tested, api_key, id_token, provider_specific_data,
|
||||
expires_in, display_name, global_priority, default_model,
|
||||
token_type, consecutive_use_count, rate_limit_protection, last_used_at, created_at, updated_at
|
||||
token_type, consecutive_use_count, rate_limit_protection, last_used_at, "group", created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @provider, @authType, @name, @email, @priority, @isActive,
|
||||
@accessToken, @refreshToken, @expiresAt, @tokenExpiresAt,
|
||||
@@ -224,7 +227,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
@rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt,
|
||||
@lastTested, @apiKey, @idToken, @providerSpecificData,
|
||||
@expiresIn, @displayName, @globalPriority, @defaultModel,
|
||||
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @createdAt, @updatedAt
|
||||
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @group, @createdAt, @updatedAt
|
||||
)
|
||||
`
|
||||
).run({
|
||||
@@ -266,6 +269,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
rateLimitProtection:
|
||||
conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0,
|
||||
lastUsedAt: conn.lastUsedAt || null,
|
||||
group: conn.group || null,
|
||||
createdAt: conn.createdAt,
|
||||
updatedAt: conn.updatedAt,
|
||||
});
|
||||
@@ -290,6 +294,7 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
|
||||
consecutive_use_count = @consecutiveUseCount,
|
||||
rate_limit_protection = @rateLimitProtection,
|
||||
last_used_at = @lastUsedAt,
|
||||
"group" = @group,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id
|
||||
`
|
||||
@@ -332,6 +337,7 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
|
||||
rateLimitProtection:
|
||||
data.rateLimitProtection === true || data.rateLimitProtection === 1 ? 1 : 0,
|
||||
lastUsedAt: data.lastUsedAt || null,
|
||||
group: data.group || null,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
@@ -344,6 +350,7 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
|
||||
const merged = { ...rowToCamel(existing), ...data, updatedAt: new Date().toISOString() };
|
||||
_updateConnectionRow(db, id, encryptConnectionFields({ ...merged }));
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("connections"); // Bust connections read cache
|
||||
|
||||
if (data.priority !== undefined) {
|
||||
const existingRecord = toRecord(existing);
|
||||
@@ -370,6 +377,7 @@ export async function deleteProviderConnection(id: string) {
|
||||
: String(existingRecord.provider || "");
|
||||
_reorderConnections(db, providerId);
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("connections"); // Bust connections read cache
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -403,6 +411,16 @@ export async function cleanupProviderConnections() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function getDistinctGroups(): Promise<string[]> {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT DISTINCT "group" FROM provider_connections WHERE "group" IS NOT NULL ORDER BY "group"'
|
||||
)
|
||||
.all() as Array<{ group?: string }>;
|
||||
return rows.map((r) => String(r.group ?? "")).filter(Boolean);
|
||||
}
|
||||
|
||||
// ──────────────── Provider Nodes ────────────────
|
||||
|
||||
export async function getProviderNodes(filter: JsonRecord = {}) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* DB Read Cache — In-memory TTL cache for hot read paths.
|
||||
*
|
||||
* SQLite reads are already fast since better-sqlite3 is synchronous and
|
||||
* memory-mapped. However, some functions (getSettings, getPricing,
|
||||
* getProviderConnections) are called on every request by multiple callers.
|
||||
* A short TTL cache (5s) eliminates redundant I/O without staling data for
|
||||
* long enough to matter (settings changes are applied within one cache cycle).
|
||||
*
|
||||
* Usage:
|
||||
* import { dbCache } from '@/lib/db/readCache';
|
||||
* const settings = await dbCache.getSettings();
|
||||
*/
|
||||
|
||||
type CacheEntry<T> = {
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
class TTLCache<T> {
|
||||
private cache = new Map<string, CacheEntry<T>>();
|
||||
private readonly ttlMs: number;
|
||||
|
||||
constructor(ttlMs: number) {
|
||||
this.ttlMs = ttlMs;
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: T): void {
|
||||
this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
||||
}
|
||||
|
||||
invalidate(key?: string): void {
|
||||
if (key) {
|
||||
this.cache.delete(key);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache with 5s TTL — short enough to pick up dashboard changes quickly,
|
||||
// long enough to serve burst request bursts without hammering SQLite.
|
||||
const SETTINGS_TTL_MS = 5_000;
|
||||
const PRICING_TTL_MS = 30_000;
|
||||
const CONNECTIONS_TTL_MS = 5_000;
|
||||
|
||||
const settingsCache = new TTLCache<Record<string, unknown>>(SETTINGS_TTL_MS);
|
||||
const pricingCache = new TTLCache<Record<string, unknown>>(PRICING_TTL_MS);
|
||||
const connectionsCache = new TTLCache<unknown[]>(CONNECTIONS_TTL_MS);
|
||||
|
||||
/**
|
||||
* Cached wrapper for getSettings.
|
||||
* Invalidated on every updateSettings() call.
|
||||
*/
|
||||
export async function getCachedSettings(): Promise<Record<string, unknown>> {
|
||||
const cached = settingsCache.get("settings");
|
||||
if (cached) return cached;
|
||||
|
||||
const { getSettings } = await import("@/lib/db/settings");
|
||||
const value = await getSettings();
|
||||
settingsCache.set("settings", value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached wrapper for getPricing.
|
||||
* Longer TTL since pricing rarely changes mid-session.
|
||||
*/
|
||||
export async function getCachedPricing(): Promise<Record<string, unknown>> {
|
||||
const cached = pricingCache.get("pricing");
|
||||
if (cached) return cached as Record<string, unknown>;
|
||||
|
||||
const { getPricing } = await import("@/lib/db/settings");
|
||||
const value = await getPricing();
|
||||
pricingCache.set("pricing", value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached wrapper for getProviderConnections.
|
||||
* Used in request hot-paths (usageStats, callLogs, usageHistory).
|
||||
*/
|
||||
export async function getCachedProviderConnections(
|
||||
filter?: Record<string, unknown>
|
||||
): Promise<unknown[]> {
|
||||
// Only cache the unfiltered "all connections" query (most common)
|
||||
if (filter && Object.keys(filter).length > 0) {
|
||||
const { getProviderConnections } = await import("@/lib/db/providers");
|
||||
return getProviderConnections(filter);
|
||||
}
|
||||
|
||||
const cached = connectionsCache.get("all");
|
||||
if (cached) return cached;
|
||||
|
||||
const { getProviderConnections } = await import("@/lib/db/providers");
|
||||
const value = await getProviderConnections();
|
||||
connectionsCache.set("all", value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all caches (call after writes to any of: settings, pricing, connections).
|
||||
*/
|
||||
export function invalidateDbCache(scope?: "settings" | "pricing" | "connections"): void {
|
||||
if (!scope || scope === "settings") settingsCache.invalidate();
|
||||
if (!scope || scope === "pricing") pricingCache.invalidate();
|
||||
if (!scope || scope === "connections") connectionsCache.invalidate();
|
||||
}
|
||||
+35
-18
@@ -5,6 +5,7 @@
|
||||
import { getDbInstance } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts";
|
||||
import { invalidateDbCache } from "./readCache";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type PricingModels = Record<string, JsonRecord>;
|
||||
@@ -80,6 +81,7 @@ export async function updateSettings(updates: Record<string, unknown>) {
|
||||
});
|
||||
tx();
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("settings"); // Bust the read cache immediately
|
||||
return getSettings();
|
||||
}
|
||||
|
||||
@@ -92,6 +94,25 @@ export async function isCloudEnabled() {
|
||||
|
||||
export async function getPricing() {
|
||||
const db = getDbInstance();
|
||||
|
||||
// Layer 1: Hardcoded defaults (lowest priority)
|
||||
const { getDefaultPricing } = await import("@/shared/constants/pricing");
|
||||
const defaultPricing = getDefaultPricing();
|
||||
|
||||
// Layer 2: Synced external pricing (middle priority)
|
||||
const syncedRows = db
|
||||
.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'")
|
||||
.all();
|
||||
const syncedPricing: PricingByProvider = {};
|
||||
for (const row of syncedRows) {
|
||||
const record = toRecord(row);
|
||||
const key = typeof record.key === "string" ? record.key : null;
|
||||
const rawValue = typeof record.value === "string" ? record.value : null;
|
||||
if (!key || rawValue === null) continue;
|
||||
syncedPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
|
||||
}
|
||||
|
||||
// Layer 3: User overrides (highest priority)
|
||||
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all();
|
||||
const userPricing: PricingByProvider = {};
|
||||
for (const row of rows) {
|
||||
@@ -102,28 +123,24 @@ export async function getPricing() {
|
||||
userPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
|
||||
}
|
||||
|
||||
const { getDefaultPricing } = await import("@/shared/constants/pricing");
|
||||
const defaultPricing = getDefaultPricing();
|
||||
|
||||
// Merge: defaults → synced → user (each layer overrides the previous)
|
||||
const mergedPricing: PricingByProvider = {};
|
||||
|
||||
// Start with defaults
|
||||
for (const [provider, models] of Object.entries(defaultPricing) as Array<[string, unknown]>) {
|
||||
mergedPricing[provider] = { ...(toRecord(models) as PricingModels) };
|
||||
if (userPricing[provider]) {
|
||||
for (const [model, pricing] of Object.entries(userPricing[provider])) {
|
||||
mergedPricing[provider][model] = mergedPricing[provider][model]
|
||||
? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) }
|
||||
: pricing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [provider, models] of Object.entries(userPricing)) {
|
||||
if (!mergedPricing[provider]) {
|
||||
mergedPricing[provider] = { ...models };
|
||||
} else {
|
||||
for (const [model, pricing] of Object.entries(models)) {
|
||||
if (!mergedPricing[provider][model]) {
|
||||
mergedPricing[provider][model] = pricing;
|
||||
// Layer synced then user on top (each higher-priority layer overrides)
|
||||
for (const layer of [syncedPricing, userPricing]) {
|
||||
for (const [provider, models] of Object.entries(layer)) {
|
||||
if (!mergedPricing[provider]) {
|
||||
mergedPricing[provider] = { ...models };
|
||||
} else {
|
||||
for (const [model, pricing] of Object.entries(models)) {
|
||||
mergedPricing[provider][model] = mergedPricing[provider][model]
|
||||
? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) }
|
||||
: pricing;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +186,7 @@ export async function updatePricing(pricingData: PricingByProvider) {
|
||||
});
|
||||
tx();
|
||||
backupDbFile("pre-write");
|
||||
|
||||
invalidateDbCache("pricing"); // Bust the pricing read cache
|
||||
const updated: PricingByProvider = {};
|
||||
const allRows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all();
|
||||
for (const row of allRows) {
|
||||
|
||||
@@ -89,9 +89,29 @@ export {
|
||||
setProxyConfig,
|
||||
} from "./db/settings";
|
||||
|
||||
export {
|
||||
// Pricing Sync
|
||||
getSyncedPricing,
|
||||
saveSyncedPricing,
|
||||
clearSyncedPricing,
|
||||
syncPricingFromSources,
|
||||
getSyncStatus,
|
||||
initPricingSync,
|
||||
startPeriodicSync,
|
||||
stopPeriodicSync,
|
||||
} from "./pricingSync";
|
||||
|
||||
export {
|
||||
// Backup Management
|
||||
backupDbFile,
|
||||
listDbBackups,
|
||||
restoreDbBackup,
|
||||
} from "./db/backup";
|
||||
|
||||
export {
|
||||
// Read Cache (cached wrappers for hot read paths)
|
||||
getCachedSettings,
|
||||
getCachedPricing,
|
||||
getCachedProviderConnections,
|
||||
invalidateDbCache,
|
||||
} from "./db/readCache";
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* pricingSync.ts — External pricing sync engine.
|
||||
*
|
||||
* Fetches pricing data from external sources (LiteLLM) and stores it
|
||||
* in a separate namespace (`pricing_synced`) so user overrides are
|
||||
* never touched.
|
||||
*
|
||||
* Resolution order: user overrides > synced external > hardcoded defaults
|
||||
*
|
||||
* Opt-in via PRICING_SYNC_ENABLED=true (default: false).
|
||||
*/
|
||||
|
||||
import { getDbInstance } from "./db/core";
|
||||
import { invalidateDbCache } from "./db/readCache";
|
||||
import { backupDbFile } from "./db/backup";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
|
||||
type PricingEntry = {
|
||||
input: number;
|
||||
output: number;
|
||||
cached?: number;
|
||||
cache_creation?: number;
|
||||
};
|
||||
|
||||
type PricingModels = Record<string, PricingEntry>;
|
||||
type PricingByProvider = Record<string, PricingModels>;
|
||||
|
||||
interface LiteLLMModelInfo {
|
||||
input_cost_per_token?: number;
|
||||
output_cost_per_token?: number;
|
||||
cache_read_input_token_cost?: number;
|
||||
cache_creation_input_token_cost?: number;
|
||||
litellm_provider?: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
interface SyncStatus {
|
||||
enabled: boolean;
|
||||
lastSync: string | null;
|
||||
lastSyncModelCount: number;
|
||||
nextSync: string | null;
|
||||
intervalMs: number;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean;
|
||||
modelCount: number;
|
||||
providerCount: number;
|
||||
source: string;
|
||||
dryRun: boolean;
|
||||
data?: PricingByProvider;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────
|
||||
|
||||
const SUPPORTED_SOURCES = ["litellm"] as const;
|
||||
type SupportedSource = (typeof SUPPORTED_SOURCES)[number];
|
||||
|
||||
const parsedInterval = parseInt(process.env.PRICING_SYNC_INTERVAL || "86400", 10);
|
||||
const SYNC_INTERVAL_MS =
|
||||
Number.isFinite(parsedInterval) && parsedInterval > 0 ? parsedInterval * 1000 : 86400 * 1000;
|
||||
const SYNC_SOURCES = (process.env.PRICING_SYNC_SOURCES || "litellm")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s): s is SupportedSource => SUPPORTED_SOURCES.includes(s as SupportedSource));
|
||||
|
||||
const LITELLM_PRICING_URL =
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
||||
|
||||
// ─── Provider mapping: LiteLLM provider → OmniRoute aliases ─────
|
||||
|
||||
const LITELLM_PROVIDER_MAP: Record<string, string[]> = {
|
||||
openai: ["openai", "cx"],
|
||||
anthropic: ["anthropic", "cc"],
|
||||
vertex_ai: ["gemini", "gc"],
|
||||
"vertex_ai-anthropic_models": ["anthropic"],
|
||||
google: ["gemini", "gc"],
|
||||
deepseek: ["if"],
|
||||
groq: ["groq"],
|
||||
together_ai: ["openrouter"],
|
||||
bedrock: ["kiro"],
|
||||
fireworks_ai: ["fireworks"],
|
||||
cerebras: ["cerebras"],
|
||||
nvidia_nim: ["nvidia"],
|
||||
siliconflow: ["siliconflow"],
|
||||
};
|
||||
|
||||
// ─── Periodic sync state ─────────────────────────────────
|
||||
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastSyncTime: string | null = null;
|
||||
let lastSyncModelCount = 0;
|
||||
let activeSyncIntervalMs = SYNC_INTERVAL_MS;
|
||||
|
||||
// ─── Core: Fetch + Transform ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch raw pricing data from LiteLLM GitHub.
|
||||
*/
|
||||
export async function fetchLiteLLMPricing(): Promise<Record<string, LiteLLMModelInfo>> {
|
||||
const response = await fetch(LITELLM_PRICING_URL, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`LiteLLM fetch failed [${response.status}]: ${response.statusText}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text) as Record<string, LiteLLMModelInfo>;
|
||||
} catch {
|
||||
throw new Error(`LiteLLM returned invalid JSON (${text.slice(0, 100)}...)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform LiteLLM raw data → OmniRoute PricingByProvider format.
|
||||
*
|
||||
* Conversion: cost_per_token × 1_000_000 → $/1M tokens (OmniRoute format)
|
||||
* Filters: only chat/completion modes (skip image/audio/embedding)
|
||||
*/
|
||||
export function transformToOmniRoute(raw: Record<string, LiteLLMModelInfo>): PricingByProvider {
|
||||
const result: PricingByProvider = {};
|
||||
|
||||
for (const [modelKey, info] of Object.entries(raw)) {
|
||||
// Skip non-chat models
|
||||
if (info.mode && !["chat", "completion"].includes(info.mode)) continue;
|
||||
|
||||
// Must have at least input pricing
|
||||
if (!info.input_cost_per_token && info.input_cost_per_token !== 0) continue;
|
||||
|
||||
const inputCost = (info.input_cost_per_token || 0) * 1_000_000;
|
||||
const outputCost = (info.output_cost_per_token || 0) * 1_000_000;
|
||||
|
||||
const entry: PricingEntry = {
|
||||
input: Math.round(inputCost * 1000) / 1000,
|
||||
output: Math.round(outputCost * 1000) / 1000,
|
||||
};
|
||||
|
||||
if (info.cache_read_input_token_cost != null) {
|
||||
entry.cached = Math.round(info.cache_read_input_token_cost * 1_000_000 * 1000) / 1000;
|
||||
}
|
||||
if (info.cache_creation_input_token_cost != null) {
|
||||
entry.cache_creation =
|
||||
Math.round(info.cache_creation_input_token_cost * 1_000_000 * 1000) / 1000;
|
||||
}
|
||||
|
||||
// Extract model name (strip provider prefix from key)
|
||||
// LiteLLM keys look like: "openai/gpt-4o", "anthropic/claude-3-opus"
|
||||
const slashIdx = modelKey.indexOf("/");
|
||||
const modelName = slashIdx >= 0 ? modelKey.slice(slashIdx + 1) : modelKey;
|
||||
|
||||
// Map to OmniRoute providers
|
||||
const litellmProvider = info.litellm_provider || "";
|
||||
const omniRouteProviders = LITELLM_PROVIDER_MAP[litellmProvider];
|
||||
|
||||
if (omniRouteProviders) {
|
||||
for (const provider of omniRouteProviders) {
|
||||
if (!result[provider]) result[provider] = {};
|
||||
result[provider][modelName] = entry;
|
||||
}
|
||||
} else if (litellmProvider) {
|
||||
// Use litellm_provider as-is for unknown providers
|
||||
if (!result[litellmProvider]) result[litellmProvider] = {};
|
||||
result[litellmProvider][modelName] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── DB: Synced pricing namespace ────────────────────────
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read synced pricing from `pricing_synced` namespace.
|
||||
*/
|
||||
export function getSyncedPricing(): PricingByProvider {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'")
|
||||
.all();
|
||||
const synced: PricingByProvider = {};
|
||||
for (const row of rows) {
|
||||
const record = toRecord(row);
|
||||
const key = typeof record.key === "string" ? record.key : null;
|
||||
const rawValue = typeof record.value === "string" ? record.value : null;
|
||||
if (!key || rawValue === null) continue;
|
||||
try {
|
||||
synced[key] = JSON.parse(rawValue) as PricingModels;
|
||||
} catch {
|
||||
console.warn(`[PRICING_SYNC] Corrupted data for provider "${key}", skipping`);
|
||||
}
|
||||
}
|
||||
return synced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save synced pricing to `pricing_synced` namespace (full replace).
|
||||
*/
|
||||
export function saveSyncedPricing(data: PricingByProvider): void {
|
||||
const db = getDbInstance();
|
||||
const del = db.prepare("DELETE FROM key_value WHERE namespace = 'pricing_synced'");
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO key_value (namespace, key, value) VALUES ('pricing_synced', ?, ?)"
|
||||
);
|
||||
const tx = db.transaction(() => {
|
||||
del.run();
|
||||
for (const [provider, models] of Object.entries(data)) {
|
||||
insert.run(provider, JSON.stringify(models));
|
||||
}
|
||||
});
|
||||
tx();
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("pricing");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all synced pricing data.
|
||||
*/
|
||||
export function clearSyncedPricing(): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM key_value WHERE namespace = 'pricing_synced'").run();
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("pricing");
|
||||
}
|
||||
|
||||
// ─── Main sync function ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch, transform, and save pricing from external sources.
|
||||
*/
|
||||
export async function syncPricingFromSources(opts?: {
|
||||
sources?: string[];
|
||||
dryRun?: boolean;
|
||||
}): Promise<SyncResult> {
|
||||
const requestedSources = opts?.sources || SYNC_SOURCES;
|
||||
const dryRun = opts?.dryRun ?? false;
|
||||
|
||||
// Validate sources
|
||||
const validSources = requestedSources.filter((s): s is SupportedSource =>
|
||||
SUPPORTED_SOURCES.includes(s as SupportedSource)
|
||||
);
|
||||
const invalidSources = requestedSources.filter(
|
||||
(s) => !SUPPORTED_SOURCES.includes(s as SupportedSource)
|
||||
);
|
||||
|
||||
if (validSources.length === 0) {
|
||||
const supported = SUPPORTED_SOURCES.join(", ");
|
||||
return {
|
||||
success: false,
|
||||
modelCount: 0,
|
||||
providerCount: 0,
|
||||
source: requestedSources.join(","),
|
||||
dryRun,
|
||||
error: `No valid sources provided. Supported: ${supported}. Invalid: ${invalidSources.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const aggregated: PricingByProvider = {};
|
||||
|
||||
for (const source of validSources) {
|
||||
if (source === "litellm") {
|
||||
const raw = await fetchLiteLLMPricing();
|
||||
const transformed = transformToOmniRoute(raw);
|
||||
for (const [provider, models] of Object.entries(transformed)) {
|
||||
if (!aggregated[provider]) aggregated[provider] = {};
|
||||
Object.assign(aggregated[provider], models);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelCount = Object.values(aggregated).reduce(
|
||||
(sum, models) => sum + Object.keys(models).length,
|
||||
0
|
||||
);
|
||||
const providerCount = Object.keys(aggregated).length;
|
||||
|
||||
if (!dryRun) {
|
||||
saveSyncedPricing(aggregated);
|
||||
lastSyncTime = new Date().toISOString();
|
||||
lastSyncModelCount = modelCount;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modelCount,
|
||||
providerCount,
|
||||
source: validSources.join(","),
|
||||
dryRun,
|
||||
...(invalidSources.length > 0
|
||||
? { warnings: [`Unknown sources ignored: ${invalidSources.join(", ")}`] }
|
||||
: {}),
|
||||
...(dryRun ? { data: aggregated } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[PRICING_SYNC] Sync failed:", message);
|
||||
return {
|
||||
success: false,
|
||||
modelCount: 0,
|
||||
providerCount: 0,
|
||||
source: requestedSources.join(","),
|
||||
dryRun,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Periodic sync ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start periodic pricing sync (non-blocking).
|
||||
*/
|
||||
export function startPeriodicSync(intervalMs?: number): void {
|
||||
if (syncTimer) return; // Already running
|
||||
|
||||
const interval = intervalMs ?? SYNC_INTERVAL_MS;
|
||||
activeSyncIntervalMs = interval;
|
||||
console.log(`[PRICING_SYNC] Starting periodic sync every ${interval / 1000}s`);
|
||||
|
||||
// Initial sync (non-blocking)
|
||||
syncPricingFromSources()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`[PRICING_SYNC] Initial sync complete: ${result.modelCount} models from ${result.providerCount} providers`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[PRICING_SYNC] Initial sync error:", err instanceof Error ? err.message : err);
|
||||
});
|
||||
|
||||
syncTimer = setInterval(() => {
|
||||
syncPricingFromSources()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
console.log(`[PRICING_SYNC] Periodic sync complete: ${result.modelCount} models`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
"[PRICING_SYNC] Periodic sync error:",
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
});
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic sync and cleanup timer.
|
||||
*/
|
||||
export function stopPeriodicSync(): void {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer);
|
||||
syncTimer = null;
|
||||
console.log("[PRICING_SYNC] Periodic sync stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sync status.
|
||||
*/
|
||||
export function getSyncStatus(): SyncStatus {
|
||||
const enabled = process.env.PRICING_SYNC_ENABLED === "true";
|
||||
return {
|
||||
enabled,
|
||||
lastSync: lastSyncTime,
|
||||
lastSyncModelCount,
|
||||
nextSync:
|
||||
syncTimer && lastSyncTime
|
||||
? new Date(new Date(lastSyncTime).getTime() + activeSyncIntervalMs).toISOString()
|
||||
: null,
|
||||
intervalMs: activeSyncIntervalMs,
|
||||
sources: SYNC_SOURCES,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Init (called from server-init.ts) ───────────────────
|
||||
|
||||
/**
|
||||
* Initialize pricing sync if enabled.
|
||||
*/
|
||||
export async function initPricingSync(): Promise<void> {
|
||||
if (process.env.PRICING_SYNC_ENABLED !== "true") {
|
||||
console.log("[PRICING_SYNC] Disabled (set PRICING_SYNC_ENABLED=true to enable)");
|
||||
return;
|
||||
}
|
||||
startPeriodicSync();
|
||||
}
|
||||
@@ -42,6 +42,19 @@ async function startServer() {
|
||||
console.error("[FATAL] Error initializing cloud sync:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pricing sync: opt-in external pricing data (non-blocking, never fatal)
|
||||
if (process.env.PRICING_SYNC_ENABLED === "true") {
|
||||
try {
|
||||
const { initPricingSync } = await import("./lib/pricingSync");
|
||||
await initPricingSync();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[PRICING_SYNC] Could not initialize:",
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server initialization
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTranslations } from "next-intl";
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
@@ -89,12 +90,17 @@ export default function ConsoleLogViewer() {
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const handleCopy = (entry: LogEntry, idx: number) => {
|
||||
const handleCopy = async (entry: LogEntry, idx: number) => {
|
||||
const text = JSON.stringify(entry, null, 2);
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedIdx(idx);
|
||||
setTimeout(() => setCopiedIdx(null), 2000);
|
||||
});
|
||||
const success = await copyToClipboard(text);
|
||||
if (!success) {
|
||||
setError("Failed to copy log entry");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setCopiedIdx(idx);
|
||||
setTimeout(() => setCopiedIdx(null), 2000);
|
||||
};
|
||||
|
||||
const formatTime = (ts: string) => {
|
||||
|
||||
@@ -8,38 +8,36 @@
|
||||
* comes back online.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function MaintenanceBanner() {
|
||||
const [show, setShow] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring/health", {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Server is healthy — hide banner if shown
|
||||
if (show) {
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring/health", {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (res.ok) {
|
||||
setShow(false);
|
||||
setMessage("");
|
||||
} else {
|
||||
setShow(true);
|
||||
setMessage("Server is experiencing issues. Some features may be unavailable.");
|
||||
}
|
||||
} else {
|
||||
} catch {
|
||||
setShow(true);
|
||||
setMessage("Server is experiencing issues. Some features may be unavailable.");
|
||||
setMessage("Server is unreachable. Reconnecting...");
|
||||
}
|
||||
} catch {
|
||||
setShow(true);
|
||||
setMessage("Server is unreachable. Reconnecting...");
|
||||
}
|
||||
}, [show]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check health every 10 seconds
|
||||
// Run immediately on mount, then every 10 seconds
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkHealth]);
|
||||
}, []); // empty deps — checkHealth is defined inside effect, no stale closure
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function ModelSelectModal({
|
||||
activeProviders = [],
|
||||
title = "Select Model",
|
||||
modelAliases = {},
|
||||
addedModelValues = [],
|
||||
}) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [combos, setCombos] = useState<any[]>([]);
|
||||
@@ -330,6 +331,7 @@ export default function ModelSelectModal({
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.models.map((model) => {
|
||||
const isSelected = selectedModel === model.value;
|
||||
const isAdded = addedModelValues.includes(model.value);
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
@@ -339,10 +341,13 @@ export default function ModelSelectModal({
|
||||
${
|
||||
isSelected
|
||||
? "bg-primary text-white border-primary"
|
||||
: "bg-surface border-border text-text-main hover:border-primary/50 hover:bg-primary/5"
|
||||
: isAdded
|
||||
? "bg-emerald-500/15 border-emerald-500/30 text-emerald-700 dark:text-emerald-400"
|
||||
: "bg-surface border-border text-text-main hover:border-primary/50 hover:bg-primary/5"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isAdded && <span className="mr-0.5 opacity-70">✓</span>}
|
||||
{model.name}
|
||||
{model.isCustom ? " ★" : ""}
|
||||
</button>
|
||||
@@ -375,4 +380,5 @@ ModelSelectModal.propTypes = {
|
||||
),
|
||||
title: PropTypes.string,
|
||||
modelAliases: PropTypes.object,
|
||||
addedModelValues: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import Card from "./Card";
|
||||
import RequestLoggerDetail from "./RequestLoggerDetail";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
import {
|
||||
PROTOCOL_COLORS,
|
||||
PROVIDER_COLORS,
|
||||
@@ -230,30 +231,8 @@ export default function RequestLoggerV2() {
|
||||
setDetailData(null);
|
||||
};
|
||||
|
||||
// Copy to clipboard
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback for non-HTTPS or older browsers
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Unique accounts and providers for dropdowns
|
||||
|
||||
const uniqueAccounts = [...new Set(logs.map((l) => l.account).filter((a) => a && a !== "-"))];
|
||||
const uniqueModels = [...new Set(logs.map((l) => l.model).filter(Boolean))].sort();
|
||||
const uniqueProviders = [
|
||||
|
||||
@@ -349,6 +349,27 @@ export const APIKEY_PROVIDERS = {
|
||||
textIcon: "CF",
|
||||
website: "https://github.com/comfyanonymous/ComfyUI",
|
||||
},
|
||||
huggingface: {
|
||||
id: "huggingface",
|
||||
alias: "hf",
|
||||
name: "HuggingFace",
|
||||
icon: "face",
|
||||
color: "#FFD21E",
|
||||
textIcon: "HF",
|
||||
website: "https://huggingface.co",
|
||||
hasFree: true,
|
||||
freeNote: "Free Inference API for thousands of models (Whisper, VITS, SDXL…)",
|
||||
},
|
||||
vertex: {
|
||||
id: "vertex",
|
||||
alias: "vertex",
|
||||
name: "Vertex AI",
|
||||
icon: "cloud",
|
||||
color: "#4285F4",
|
||||
textIcon: "VA",
|
||||
website: "https://cloud.google.com/vertex-ai",
|
||||
authHint: "Provide Service Account JSON or OAuth access_token",
|
||||
},
|
||||
};
|
||||
|
||||
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
|
||||
@@ -1,58 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
/**
|
||||
* Fallback copy using legacy execCommand (works on HTTP)
|
||||
*/
|
||||
function fallbackCopy(text) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for copy to clipboard with feedback
|
||||
* Hook for copy to clipboard with feedback.
|
||||
* Uses shared copyToClipboard utility that works on both HTTP and HTTPS.
|
||||
* @param {number} resetDelay - Time in ms before resetting copied state (default: 2000)
|
||||
* @returns {{ copied: string|null, copy: (text: string, id?: string) => void }}
|
||||
* @returns {{ copied: string|null, copy: (text: string, id?: string) => Promise<boolean> }}
|
||||
*/
|
||||
export function useCopyToClipboard(resetDelay = 2000) {
|
||||
const [copied, setCopied] = useState(null);
|
||||
const timeoutRef = useRef(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const copy = useCallback(
|
||||
async (text, id = "default") => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
async (text: string, id = "default"): Promise<boolean> => {
|
||||
const success = await copyToClipboard(text);
|
||||
|
||||
if (success) {
|
||||
setCopied(id);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
} catch {
|
||||
fallbackCopy(text);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopied(null);
|
||||
}, resetDelay);
|
||||
}
|
||||
|
||||
setCopied(id);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopied(null);
|
||||
}, resetDelay);
|
||||
return success;
|
||||
},
|
||||
[resetDelay]
|
||||
);
|
||||
|
||||
@@ -15,14 +15,92 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
|
||||
interface AccessSchedule {
|
||||
enabled: boolean;
|
||||
from: string;
|
||||
until: string;
|
||||
days: number[];
|
||||
tz: string;
|
||||
}
|
||||
|
||||
/** Metadata stored for an API key in the local database. */
|
||||
export interface ApiKeyMetadata {
|
||||
id: string;
|
||||
name?: string;
|
||||
allowedModels?: string[];
|
||||
allowedConnections?: string[];
|
||||
noLog?: boolean;
|
||||
autoResolve?: boolean;
|
||||
budget?: number;
|
||||
usedBudget?: number;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current time (in the schedule's timezone) is within
|
||||
* the configured window.
|
||||
* Supports overnight ranges (e.g. 22:00 until 06:00).
|
||||
*/
|
||||
function isWithinSchedule(schedule: AccessSchedule): boolean {
|
||||
if (!schedule.enabled) return true;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Convert current UTC time to the configured timezone
|
||||
let localTimeStr: string;
|
||||
try {
|
||||
localTimeStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: schedule.tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
} catch {
|
||||
// Invalid timezone — fail open (don't block)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Intl may return "24:xx" instead of "00:xx" — normalize
|
||||
const normalizedTime = localTimeStr.replace(/^24:/, "00:");
|
||||
const [localHour, localMin] = normalizedTime.split(":").map(Number);
|
||||
const localMinutes = localHour * 60 + localMin;
|
||||
|
||||
// Determine current weekday in the configured timezone
|
||||
let localDayStr: string;
|
||||
try {
|
||||
localDayStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: schedule.tz,
|
||||
weekday: "short",
|
||||
}).format(now);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dayMap: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
};
|
||||
const localDay = dayMap[localDayStr] ?? now.getDay();
|
||||
|
||||
if (!schedule.days.includes(localDay)) return false;
|
||||
|
||||
const [fromHour, fromMin] = schedule.from.split(":").map(Number);
|
||||
const [untilHour, untilMin] = schedule.until.split(":").map(Number);
|
||||
const fromMinutes = fromHour * 60 + fromMin;
|
||||
const untilMinutes = untilHour * 60 + untilMin;
|
||||
|
||||
// Overnight window (e.g. 22:00 → 06:00)
|
||||
if (untilMinutes < fromMinutes) {
|
||||
return localMinutes >= fromMinutes || localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
export interface ApiKeyPolicyResult {
|
||||
@@ -82,7 +160,31 @@ export async function enforceApiKeyPolicy(
|
||||
return { apiKey, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// ── Check 1: Model restriction ──
|
||||
// ── Check 1: is_active — hard block regardless of schedule ──
|
||||
if (apiKeyInfo.isActive === false) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is disabled"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Check 2: access_schedule — time-based access window ──
|
||||
if (apiKeyInfo.accessSchedule && apiKeyInfo.accessSchedule.enabled) {
|
||||
if (!isWithinSchedule(apiKeyInfo.accessSchedule)) {
|
||||
const { from, until, tz } = apiKeyInfo.accessSchedule;
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
`Access denied outside allowed hours (${from}–${until} ${tz})`
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 3: Model restriction ──
|
||||
if (modelStr && apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) {
|
||||
const allowed = await isModelAllowedForKey(apiKey, modelStr);
|
||||
if (!allowed) {
|
||||
@@ -97,7 +199,7 @@ export async function enforceApiKeyPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: Budget limit ──
|
||||
// ── Check 4: Budget limit ──
|
||||
if (apiKeyInfo.id) {
|
||||
try {
|
||||
const budgetOk = checkBudget(apiKeyInfo.id);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Clipboard utility with HTTP/HTTPS fallback.
|
||||
* navigator.clipboard.writeText() requires HTTPS (secure context).
|
||||
* For HTTP deployments, falls back to execCommand('copy').
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with automatic HTTPS/HTTP fallback.
|
||||
* Works in both secure (HTTPS) and non-secure (HTTP) contexts.
|
||||
* @param text - Text to copy to clipboard
|
||||
* @returns true if copy succeeded, false otherwise
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// Method 1: Clipboard API (requires HTTPS / secure context)
|
||||
if (
|
||||
typeof navigator !== "undefined" &&
|
||||
navigator.clipboard &&
|
||||
typeof window !== "undefined" &&
|
||||
window.isSecureContext
|
||||
) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through to execCommand fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Legacy execCommand fallback (works on HTTP)
|
||||
if (typeof document !== "undefined" && document.body) {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.style.cssText = "position:fixed;top:0;left:-9999px;opacity:0;pointer-events:none;";
|
||||
let appended = false;
|
||||
|
||||
try {
|
||||
document.body.appendChild(textArea);
|
||||
appended = true;
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (appended && document.body.contains(textArea)) {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Shared shuffle deck utility — Fisher-Yates shuffle with anti-repeat guarantee.
|
||||
* Used by both combo model rotation and credential connection selection.
|
||||
*
|
||||
* Thread-safe: each deck namespace gets its own promise-based mutex to prevent
|
||||
* race conditions when concurrent requests hit the same deck simultaneously.
|
||||
*/
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ShuffleDeck {
|
||||
order: readonly string[];
|
||||
index: number;
|
||||
idsKey: string;
|
||||
}
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const decks = new Map<string, ShuffleDeck>();
|
||||
const mutexes = new Map<string, Promise<void>>();
|
||||
|
||||
// ─── Fisher-Yates Shuffle ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fisher-Yates shuffle — returns a new shuffled copy of the array.
|
||||
* Does NOT mutate the original.
|
||||
*/
|
||||
export function fisherYatesShuffle<T>(arr: readonly T[]): T[] {
|
||||
const result = [...arr];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const tmp = result[i];
|
||||
result[i] = result[j];
|
||||
result[j] = tmp;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Deck Operations ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get next item from a namespaced shuffle deck.
|
||||
*
|
||||
* - Namespace isolates decks (e.g. "combo:myCombo" vs "conn:openai").
|
||||
* - Uses each item exactly once per cycle before reshuffling.
|
||||
* - Guarantees the last item of a cycle is not the first of the next.
|
||||
* - Resets deck when the item set changes (detected via sorted key).
|
||||
* - Serialized per namespace via promise-based mutex (no race conditions).
|
||||
*/
|
||||
export async function getNextFromDeck(
|
||||
namespace: string,
|
||||
itemIds: readonly string[]
|
||||
): Promise<string> {
|
||||
if (itemIds.length === 0) return "";
|
||||
if (itemIds.length === 1) return itemIds[0];
|
||||
|
||||
// Acquire per-namespace mutex
|
||||
const currentMutex = mutexes.get(namespace) ?? Promise.resolve();
|
||||
let resolveMutex: (() => void) | undefined;
|
||||
mutexes.set(
|
||||
namespace,
|
||||
new Promise<void>((resolve) => {
|
||||
resolveMutex = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
await currentMutex;
|
||||
|
||||
const idsKey = [...itemIds].sort().join(",");
|
||||
const existing = decks.get(namespace);
|
||||
|
||||
// If deck exists, same item set, and not exhausted — advance
|
||||
if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) {
|
||||
const id = existing.order[existing.index];
|
||||
decks.set(namespace, { ...existing, index: existing.index + 1 });
|
||||
return id;
|
||||
}
|
||||
|
||||
// Reshuffle — ensure last of previous cycle is not first of new cycle
|
||||
const lastUsedId =
|
||||
existing && existing.idsKey === idsKey && existing.order.length > 0
|
||||
? existing.order[existing.order.length - 1]
|
||||
: undefined;
|
||||
|
||||
const newOrder = fisherYatesShuffle(itemIds);
|
||||
|
||||
if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) {
|
||||
const swapIdx = 1 + Math.floor(Math.random() * (newOrder.length - 1));
|
||||
const tmp = newOrder[0];
|
||||
newOrder[0] = newOrder[swapIdx];
|
||||
newOrder[swapIdx] = tmp;
|
||||
}
|
||||
|
||||
decks.set(namespace, { order: newOrder, index: 1, idsKey });
|
||||
return newOrder[0];
|
||||
} finally {
|
||||
resolveMutex?.();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sync version (backwards compat for non-concurrent callers) ─────────────
|
||||
|
||||
/**
|
||||
* Synchronous version of getNextFromDeck — NO mutex protection.
|
||||
* Only safe when the caller already holds a mutex (e.g. auth.ts getProviderCredentials).
|
||||
*/
|
||||
export function getNextFromDeckSync(namespace: string, itemIds: readonly string[]): string {
|
||||
if (itemIds.length === 0) return "";
|
||||
if (itemIds.length === 1) return itemIds[0];
|
||||
|
||||
const idsKey = [...itemIds].sort().join(",");
|
||||
const existing = decks.get(namespace);
|
||||
|
||||
if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) {
|
||||
const id = existing.order[existing.index];
|
||||
decks.set(namespace, { ...existing, index: existing.index + 1 });
|
||||
return id;
|
||||
}
|
||||
|
||||
const lastUsedId =
|
||||
existing && existing.idsKey === idsKey && existing.order.length > 0
|
||||
? existing.order[existing.order.length - 1]
|
||||
: undefined;
|
||||
|
||||
const newOrder = fisherYatesShuffle(itemIds);
|
||||
|
||||
if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) {
|
||||
const swapIdx = 1 + Math.floor(Math.random() * (newOrder.length - 1));
|
||||
const tmp = newOrder[0];
|
||||
newOrder[0] = newOrder[swapIdx];
|
||||
newOrder[swapIdx] = tmp;
|
||||
}
|
||||
|
||||
decks.set(namespace, { order: newOrder, index: 1, idsKey });
|
||||
return newOrder[0];
|
||||
}
|
||||
|
||||
// ─── Test helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Reset all decks — for testing only. */
|
||||
export function _resetAllDecks(): void {
|
||||
decks.clear();
|
||||
mutexes.clear();
|
||||
}
|
||||
@@ -51,6 +51,7 @@ const comboStrategySchema = z.enum([
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"strict-random",
|
||||
]);
|
||||
|
||||
const comboRuntimeConfigSchema = z
|
||||
@@ -77,6 +78,7 @@ export const createComboSchema = z.object({
|
||||
models: z.array(comboModelEntry).optional().default([]),
|
||||
strategy: comboStrategySchema.optional().default("priority"),
|
||||
config: comboConfigSchema,
|
||||
allowedProviders: z.array(z.string().max(200)).optional(),
|
||||
});
|
||||
|
||||
// ──── Auto-Combo Schemas ────
|
||||
@@ -125,7 +127,15 @@ export const updateSettingsSchema = z.object({
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: z
|
||||
.enum(["fill-first", "round-robin", "p2c", "random", "least-used", "cost-optimized"])
|
||||
.enum([
|
||||
"fill-first",
|
||||
"round-robin",
|
||||
"p2c",
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"strict-random",
|
||||
])
|
||||
.optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
|
||||
@@ -676,6 +686,7 @@ export const updateComboSchema = z
|
||||
strategy: comboStrategySchema.optional(),
|
||||
config: comboRuntimeConfigSchema.optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
allowedProviders: z.array(z.string().max(200)).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
@@ -683,7 +694,8 @@ export const updateComboSchema = z
|
||||
value.models === undefined &&
|
||||
value.strategy === undefined &&
|
||||
value.config === undefined &&
|
||||
value.isActive === undefined
|
||||
value.isActive === undefined &&
|
||||
value.allowedProviders === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -706,13 +718,34 @@ export const evalRunSuiteSchema = z.object({
|
||||
outputs: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
const accessScheduleSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
from: z.string().regex(/^\d{2}:\d{2}$/, "Time must be in HH:MM format"),
|
||||
until: z.string().regex(/^\d{2}:\d{2}$/, "Time must be in HH:MM format"),
|
||||
days: z.array(z.number().int().min(0).max(6)).min(1, "At least one day is required").max(7),
|
||||
tz: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export const updateKeyPermissionsSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(200).optional(),
|
||||
allowedModels: z.array(z.string().trim().min(1)).max(1000).optional(),
|
||||
allowedConnections: z.array(z.string().uuid()).max(100).optional(),
|
||||
noLog: z.boolean().optional(),
|
||||
autoResolve: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.allowedModels === undefined && value.noLog === undefined) {
|
||||
if (
|
||||
value.name === undefined &&
|
||||
value.allowedModels === undefined &&
|
||||
value.allowedConnections === undefined &&
|
||||
value.noLog === undefined &&
|
||||
value.autoResolve === undefined &&
|
||||
value.isActive === undefined &&
|
||||
value.accessSchedule === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
@@ -770,6 +803,7 @@ export const updateProviderConnectionSchema = z
|
||||
rateLimitedUntil: z.union([z.string(), z.null()]).optional(),
|
||||
lastTested: z.union([z.string(), z.null()]).optional(),
|
||||
healthCheckInterval: z.coerce.number().int().min(0).optional(),
|
||||
group: z.union([z.string().max(100), z.null()]).optional(),
|
||||
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
|
||||
providerSpecificData: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
@@ -42,6 +42,10 @@ import { generateRequestId } from "../../shared/utils/requestId";
|
||||
import { recordCost } from "../../domain/costRules";
|
||||
import { logAuditEvent } from "../../lib/compliance/index";
|
||||
import { enforceApiKeyPolicy } from "../../shared/utils/apiKeyPolicy";
|
||||
import {
|
||||
applyTaskAwareRouting,
|
||||
getTaskRoutingConfig,
|
||||
} from "@omniroute/open-sse/services/taskAwareRouter.ts";
|
||||
|
||||
/**
|
||||
* Handle chat completion request
|
||||
@@ -77,6 +81,24 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
}
|
||||
telemetry.endPhase();
|
||||
|
||||
// T01 — Accept header negotiation
|
||||
// If client asks for text/event-stream via the Accept header AND the JSON body
|
||||
// does not explicitly set stream=false, treat it as stream=true.
|
||||
// This ensures compatibility with curl/httpx and similar non-OpenAI clients.
|
||||
//
|
||||
// FIX #302: OpenAI Python SDK sends Accept: application/json, text/event-stream
|
||||
// in every request — even when called with stream=False. We must NOT override
|
||||
// an explicit stream=false body field, as that silently breaks tool_calls and
|
||||
// structured completions for SDK users who rely on non-streaming mode.
|
||||
const acceptHeader = request.headers.get("accept") || "";
|
||||
if (acceptHeader.includes("text/event-stream") && body.stream === undefined) {
|
||||
body = { ...body, stream: true };
|
||||
log.debug(
|
||||
"STREAM",
|
||||
"Accept: text/event-stream header → overriding stream=true (body had no stream field)"
|
||||
);
|
||||
}
|
||||
|
||||
// Build clientRawRequest for logging (if not provided)
|
||||
if (!clientRawRequest) {
|
||||
const url = new URL(request.url);
|
||||
@@ -111,7 +133,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
|
||||
// Optional strict API key mode for /v1 endpoints.
|
||||
// Keep disabled by default to preserve local-mode compatibility.
|
||||
if (process.env.REQUIRE_API_KEY === "true") {
|
||||
// Exception: X-Internal-Test header bypasses auth for admin-side combo health checks (#350)
|
||||
const isInternalTest = request.headers?.get?.("x-internal-test") === "combo-health-check";
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !isInternalTest) {
|
||||
if (!apiKey) {
|
||||
log.warn("AUTH", "Missing API key while REQUIRE_API_KEY=true");
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||
@@ -141,9 +165,30 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
const apiKeyInfo = policy.apiKeyInfo;
|
||||
telemetry.endPhase();
|
||||
|
||||
// T05 — Task-Aware Smart Routing
|
||||
// Detect the semantic task type and optionally route to the optimal model
|
||||
let resolvedModelStr = modelStr;
|
||||
let taskRouteInfo: { taskType: string; wasRouted: boolean } | null = null;
|
||||
if (getTaskRoutingConfig().enabled) {
|
||||
telemetry.startPhase("task-route");
|
||||
const tr = applyTaskAwareRouting(modelStr, body);
|
||||
if (tr.wasRouted) {
|
||||
resolvedModelStr = tr.model;
|
||||
body = { ...body, model: tr.model };
|
||||
log.info(
|
||||
"T05",
|
||||
`Task-Aware: detected="${tr.taskType}" → model override: ${modelStr} → ${tr.model}`
|
||||
);
|
||||
} else if (tr.taskType !== "chat") {
|
||||
log.debug("T05", `Task-Aware: detected="${tr.taskType}" (no override configured)`);
|
||||
}
|
||||
taskRouteInfo = { taskType: tr.taskType, wasRouted: tr.wasRouted };
|
||||
telemetry.endPhase();
|
||||
}
|
||||
|
||||
// Check if model is a combo (has multiple models with fallback)
|
||||
telemetry.startPhase("resolve");
|
||||
const combo = await getCombo(modelStr);
|
||||
const combo = await getCombo(resolvedModelStr);
|
||||
if (combo) {
|
||||
log.info(
|
||||
"CHAT",
|
||||
@@ -164,7 +209,11 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const creds = await getProviderCredentials(provider);
|
||||
const creds = await getProviderCredentials(
|
||||
provider,
|
||||
null,
|
||||
apiKeyInfo?.allowedConnections ?? null
|
||||
);
|
||||
if (!creds || creds.allRateLimited) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -248,7 +297,11 @@ async function handleSingleModelChat(
|
||||
let lastStatus = null;
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(provider, excludeConnectionId);
|
||||
const credentials = await getProviderCredentials(
|
||||
provider,
|
||||
excludeConnectionId,
|
||||
apiKeyInfo?.allowedConnections ?? null
|
||||
);
|
||||
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (lastStatus === 429 || lastStatus === 503) {
|
||||
|
||||
+64
-18
@@ -15,6 +15,7 @@ import {
|
||||
lockModel,
|
||||
} from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
import * as log from "../utils/logger";
|
||||
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -87,6 +88,11 @@ let selectionMutex = Promise.resolve();
|
||||
// unavailable in parallel, which was the root cause of cascading 502 lockouts.
|
||||
const markMutexes = new Map<string, Promise<void>>();
|
||||
|
||||
// Strict-Random shuffle deck moved to src/shared/utils/shuffleDeck.ts
|
||||
// auth.ts uses getNextFromDeckSync (already inside selectionMutex).
|
||||
// Re-export for backwards compat with existing test imports.
|
||||
export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
|
||||
|
||||
/**
|
||||
* Get provider credentials from localDb
|
||||
* Filters out unavailable accounts and returns the selected account based on strategy
|
||||
@@ -218,27 +224,58 @@ export async function getProviderCredentials(
|
||||
if (strategy === "round-robin") {
|
||||
const stickyLimit = toNumber((settings as Record<string, unknown>).stickyRoundRobinLimit, 3);
|
||||
|
||||
// Sort by lastUsed (most recent first) to find current candidate
|
||||
const byRecency = [...orderedConnections].sort((a: any, b: any) => {
|
||||
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
|
||||
if (!a.lastUsedAt) return 1;
|
||||
if (!b.lastUsedAt) return -1;
|
||||
return new Date(b.lastUsedAt).getTime() - new Date(a.lastUsedAt).getTime();
|
||||
});
|
||||
// If excluding an account (fallback scenario), skip sticky logic and go straight to LRU
|
||||
// This prevents the system from getting stuck on a failed account
|
||||
const isFallbackScenario = excludeConnectionId !== null;
|
||||
|
||||
const current = byRecency[0];
|
||||
const currentCount = current?.consecutiveUseCount || 0;
|
||||
|
||||
if (current && current.lastUsedAt && currentCount < stickyLimit) {
|
||||
// Stay with current account
|
||||
connection = current;
|
||||
// Update lastUsedAt and increment count (await to ensure persistence)
|
||||
await updateProviderConnection(connection.id, {
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
consecutiveUseCount: (connection.consecutiveUseCount || 0) + 1,
|
||||
if (!isFallbackScenario) {
|
||||
// Sort by lastUsed (most recent first) to find current candidate
|
||||
const byRecency = [...orderedConnections].sort((a: any, b: any) => {
|
||||
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
|
||||
if (!a.lastUsedAt) return 1;
|
||||
if (!b.lastUsedAt) return -1;
|
||||
return new Date(b.lastUsedAt).getTime() - new Date(a.lastUsedAt).getTime();
|
||||
});
|
||||
|
||||
const current = byRecency[0];
|
||||
const currentCount = current?.consecutiveUseCount || 0;
|
||||
|
||||
if (current && current.lastUsedAt && currentCount < stickyLimit) {
|
||||
// Stay with current account
|
||||
connection = current;
|
||||
log.debug(
|
||||
"AUTH",
|
||||
`${provider} round-robin: staying with ${current.id?.slice(0, 8)}... (count=${currentCount}/${stickyLimit})`
|
||||
);
|
||||
// Update lastUsedAt and increment count (await to ensure persistence)
|
||||
await updateProviderConnection(connection.id, {
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
consecutiveUseCount: (connection.consecutiveUseCount || 0) + 1,
|
||||
});
|
||||
} else {
|
||||
// Pick the least recently used (excluding current if possible)
|
||||
const sortedByOldest = [...orderedConnections].sort((a: any, b: any) => {
|
||||
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
|
||||
if (!a.lastUsedAt) return -1;
|
||||
if (!b.lastUsedAt) return 1;
|
||||
return new Date(a.lastUsedAt).getTime() - new Date(b.lastUsedAt).getTime();
|
||||
});
|
||||
|
||||
connection = sortedByOldest[0];
|
||||
log.debug(
|
||||
"AUTH",
|
||||
`${provider} round-robin: switching to LRU ${connection.id?.slice(0, 8)}... (current count=${currentCount} >= limit=${stickyLimit} or no lastUsedAt)`
|
||||
);
|
||||
|
||||
// Update lastUsedAt and reset count to 1 (await to ensure persistence)
|
||||
await updateProviderConnection(connection.id, {
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
consecutiveUseCount: 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Pick the least recently used (excluding current if possible)
|
||||
// Fallback scenario: excluded an account due to failure
|
||||
// Always pick the least recently used to ensure proper cycling
|
||||
const sortedByOldest = [...orderedConnections].sort((a: any, b: any) => {
|
||||
if (!a.lastUsedAt && !b.lastUsedAt) return (a.priority || 999) - (b.priority || 999);
|
||||
if (!a.lastUsedAt) return -1;
|
||||
@@ -247,6 +284,10 @@ export async function getProviderCredentials(
|
||||
});
|
||||
|
||||
connection = sortedByOldest[0];
|
||||
log.info(
|
||||
"AUTH",
|
||||
`${provider} round-robin: FALLBACK MODE - excluded ${excludeConnectionId?.slice(0, 8)}..., picked LRU ${connection.id?.slice(0, 8)}...`
|
||||
);
|
||||
|
||||
// Update lastUsedAt and reset count to 1 (await to ensure persistence)
|
||||
await updateProviderConnection(connection.id, {
|
||||
@@ -289,6 +330,11 @@ export async function getProviderCredentials(
|
||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||
);
|
||||
connection = sorted[0];
|
||||
} else if (strategy === "strict-random") {
|
||||
// Strict Random: shuffle deck — uses each account once before reshuffling
|
||||
const ids = orderedConnections.map((c) => c.id);
|
||||
const selectedId = getNextFromDeckSync(`conn:${provider}`, ids);
|
||||
connection = orderedConnections.find((c) => c.id === selectedId) || orderedConnections[0];
|
||||
} else {
|
||||
// Default: fill-first (already sorted by priority in getProviderConnections)
|
||||
connection = orderedConnections[0];
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface Settings {
|
||||
| "p2c"
|
||||
| "random"
|
||||
| "least-used"
|
||||
| "cost-optimized";
|
||||
| "cost-optimized"
|
||||
| "strict-random";
|
||||
stickyRoundRobinLimit: number;
|
||||
jwtSecret?: string;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
selectAccountP2C,
|
||||
selectAccount,
|
||||
} = await import("../../open-sse/services/accountSelector.ts");
|
||||
const { selectAccountP2C, selectAccount } =
|
||||
await import("../../open-sse/services/accountSelector.ts");
|
||||
|
||||
// ─── selectAccountP2C ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,11 +17,7 @@ test("selectAccountP2C: returns single account", () => {
|
||||
});
|
||||
|
||||
test("selectAccountP2C: returns one of the candidates", () => {
|
||||
const accounts = [
|
||||
{ id: "a1" },
|
||||
{ id: "a2" },
|
||||
{ id: "a3" },
|
||||
];
|
||||
const accounts = [{ id: "a1" }, { id: "a2" }, { id: "a3" }];
|
||||
const selected = selectAccountP2C(accounts);
|
||||
assert.ok(accounts.includes(selected));
|
||||
});
|
||||
@@ -79,3 +73,52 @@ test("selectAccount: empty accounts returns null", () => {
|
||||
const { account } = selectAccount([], "fill-first");
|
||||
assert.equal(account, null);
|
||||
});
|
||||
|
||||
// ─── Round-robin fallback scenario (Issue #340) ─────────────────────────────
|
||||
|
||||
test("selectAccount: round-robin with excludeConnectionId skips excluded", () => {
|
||||
const accounts = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
|
||||
// Simulate: first request picks 'a', then it fails
|
||||
// Second request should exclude 'a' and pick 'b'
|
||||
let state = {};
|
||||
|
||||
// First request - no exclusion
|
||||
const { account: acc1 } = selectAccount(accounts, "round-robin", state);
|
||||
state = { lastIndex: 0 }; // Simulate 'a' was picked
|
||||
|
||||
// 'a' fails, exclude it
|
||||
const { account: acc2 } = selectAccount(
|
||||
accounts.filter((a) => a.id !== "a"),
|
||||
"round-robin",
|
||||
state
|
||||
);
|
||||
|
||||
// Should pick 'b' or 'c', not 'a'
|
||||
assert.notEqual(acc2.id, "a", "Should not pick excluded account");
|
||||
assert.ok(["b", "c"].includes(acc2.id), "Should pick from remaining accounts");
|
||||
});
|
||||
|
||||
test("selectAccount: round-robin respects state across calls", () => {
|
||||
const accounts = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
let state = { lastIndex: -1 };
|
||||
|
||||
// First call should pick index 0
|
||||
const { account: acc1, state: state1 } = selectAccount(accounts, "round-robin", state);
|
||||
assert.equal(acc1.id, "a");
|
||||
assert.equal(state1.lastIndex, 0);
|
||||
|
||||
// Second call should pick index 1
|
||||
const { account: acc2, state: state2 } = selectAccount(accounts, "round-robin", state1);
|
||||
assert.equal(acc2.id, "b");
|
||||
assert.equal(state2.lastIndex, 1);
|
||||
|
||||
// Third call should pick index 2
|
||||
const { account: acc3, state: state3 } = selectAccount(accounts, "round-robin", state2);
|
||||
assert.equal(acc3.id, "c");
|
||||
assert.equal(state3.lastIndex, 2);
|
||||
|
||||
// Fourth call should wrap to index 0
|
||||
const { account: acc4 } = selectAccount(accounts, "round-robin", state3);
|
||||
assert.equal(acc4.id, "a");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Unit tests for API key policy helpers:
|
||||
* - parseIsActive (via parseAccessSchedule indirect coverage)
|
||||
* - isWithinSchedule logic (tested directly via a re-export or by mocking Date)
|
||||
*
|
||||
* Because isWithinSchedule is module-private, we test it through observable
|
||||
* behavior: feeding real Date overrides via globalThis.Date stubbing.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Extract the schedule-check logic into a standalone helper exported only
|
||||
* for tests — OR test it end-to-end through enforceApiKeyPolicy.
|
||||
* 2. Since enforceApiKeyPolicy needs a full DB + HTTP Request, we isolate
|
||||
* isWithinSchedule by copying its logic into this test file and verifying
|
||||
* the exact same algorithm.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ─── Replicate the isWithinSchedule logic for pure unit testing ───────────────
|
||||
//
|
||||
// This mirrors the implementation in apiKeyPolicy.ts exactly.
|
||||
// If the production code changes, update this copy too.
|
||||
|
||||
/**
|
||||
* @param {{ enabled: boolean; from: string; until: string; days: number[]; tz: string }} schedule
|
||||
* @param {Date} now — injectable "current time"
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWithinSchedule(schedule, now = new Date()) {
|
||||
if (!schedule.enabled) return true;
|
||||
|
||||
let localTimeStr;
|
||||
try {
|
||||
localTimeStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: schedule.tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(now);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedTime = localTimeStr.replace(/^24:/, "00:");
|
||||
const [localHour, localMin] = normalizedTime.split(":").map(Number);
|
||||
const localMinutes = localHour * 60 + localMin;
|
||||
|
||||
let localDayStr;
|
||||
try {
|
||||
localDayStr = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: schedule.tz,
|
||||
weekday: "short",
|
||||
}).format(now);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||
const localDay = dayMap[localDayStr] ?? now.getDay();
|
||||
|
||||
if (!schedule.days.includes(localDay)) return false;
|
||||
|
||||
const [fromHour, fromMin] = schedule.from.split(":").map(Number);
|
||||
const [untilHour, untilMin] = schedule.until.split(":").map(Number);
|
||||
const fromMinutes = fromHour * 60 + fromMin;
|
||||
const untilMinutes = untilHour * 60 + untilMin;
|
||||
|
||||
if (untilMinutes < fromMinutes) {
|
||||
return localMinutes >= fromMinutes || localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
|
||||
}
|
||||
|
||||
// ─── parseIsActive helper (mirrors production code) ──────────────────────────
|
||||
function parseIsActive(value) {
|
||||
if (value === 0 || value === "0" || value === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── parseAccessSchedule helper (mirrors production code) ────────────────────
|
||||
function parseAccessSchedule(value) {
|
||||
if (!value || typeof value !== "string" || value.trim() === "") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
if (
|
||||
typeof parsed.enabled !== "boolean" ||
|
||||
typeof parsed.from !== "string" ||
|
||||
typeof parsed.until !== "string" ||
|
||||
!Array.isArray(parsed.days) ||
|
||||
typeof parsed.tz !== "string"
|
||||
)
|
||||
return null;
|
||||
const days = parsed.days.filter(
|
||||
(d) => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6
|
||||
);
|
||||
return { enabled: parsed.enabled, from: parsed.from, until: parsed.until, days, tz: parsed.tz };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── parseIsActive ────────────────────────────────────────────────────────────
|
||||
|
||||
test("parseIsActive: undefined → true (default active)", () => {
|
||||
assert.equal(parseIsActive(undefined), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: null → true", () => {
|
||||
assert.equal(parseIsActive(null), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: 1 → true", () => {
|
||||
assert.equal(parseIsActive(1), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: true → true", () => {
|
||||
assert.equal(parseIsActive(true), true);
|
||||
});
|
||||
|
||||
test("parseIsActive: 0 → false", () => {
|
||||
assert.equal(parseIsActive(0), false);
|
||||
});
|
||||
|
||||
test("parseIsActive: false → false", () => {
|
||||
assert.equal(parseIsActive(false), false);
|
||||
});
|
||||
|
||||
test("parseIsActive: '0' → false", () => {
|
||||
assert.equal(parseIsActive("0"), false);
|
||||
});
|
||||
|
||||
// ─── parseAccessSchedule ──────────────────────────────────────────────────────
|
||||
|
||||
test("parseAccessSchedule: null/empty → null", () => {
|
||||
assert.equal(parseAccessSchedule(null), null);
|
||||
assert.equal(parseAccessSchedule(""), null);
|
||||
assert.equal(parseAccessSchedule(" "), null);
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: valid JSON → object", () => {
|
||||
const input = JSON.stringify({
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "America/Sao_Paulo",
|
||||
});
|
||||
const result = parseAccessSchedule(input);
|
||||
assert.deepEqual(result, {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "America/Sao_Paulo",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: invalid day values are filtered out", () => {
|
||||
const input = JSON.stringify({
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 7, -1, 5],
|
||||
tz: "UTC",
|
||||
});
|
||||
const result = parseAccessSchedule(input);
|
||||
assert.deepEqual(result.days, [1, 5]);
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: missing required field → null", () => {
|
||||
const input = JSON.stringify({ enabled: true, from: "08:00", until: "18:00", days: [1] });
|
||||
assert.equal(parseAccessSchedule(input), null); // tz missing
|
||||
});
|
||||
|
||||
test("parseAccessSchedule: invalid JSON → null", () => {
|
||||
assert.equal(parseAccessSchedule("{broken json}"), null);
|
||||
});
|
||||
|
||||
// ─── isWithinSchedule ────────────────────────────────────────────────────────
|
||||
|
||||
// Helper: create a Date at a specific UTC datetime
|
||||
function utc(y, m, d, h, min) {
|
||||
return new Date(Date.UTC(y, m - 1, d, h, min));
|
||||
}
|
||||
|
||||
test("isWithinSchedule: enabled=false → always true", () => {
|
||||
const schedule = { enabled: false, from: "00:00", until: "00:01", days: [1], tz: "UTC" };
|
||||
// Even a time that would be blocked
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 12, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time within window → true", () => {
|
||||
// Monday 2024-03-11, 09:00 UTC (UTC timezone)
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 9, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time before window → false", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
// 07:59
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 7, 59)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time exactly at 'from' → true (inclusive)", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 8, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time exactly at 'until' → false (exclusive)", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 18, 0)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: time after window → false", () => {
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 20, 0)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: wrong weekday → false", () => {
|
||||
// Monday (day 1) schedule, but 2024-03-12 is Tuesday (day 2)
|
||||
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 12, 10, 0)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: multiple days — matching day → true", () => {
|
||||
// Mon-Fri schedule, Wednesday (3)
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "09:00",
|
||||
until: "17:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "UTC",
|
||||
};
|
||||
// 2024-03-13 is Wednesday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 13, 12, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: multiple days — Saturday blocked", () => {
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "09:00",
|
||||
until: "17:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "UTC",
|
||||
};
|
||||
// 2024-03-09 is Saturday (day 6)
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 9, 12, 0)), false);
|
||||
});
|
||||
|
||||
// ─── Overnight schedule tests ─────────────────────────────────────────────────
|
||||
|
||||
test("isWithinSchedule: overnight window — time after midnight → true", () => {
|
||||
// 22:00 → 06:00, Monday
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
// 02:30 Monday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 2, 30)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: overnight window — time before start → false", () => {
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
// 21:59 Monday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 21, 59)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: overnight window — time after end → false", () => {
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
// 06:01 Monday
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 6, 1)), false);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: overnight window — time exactly at start → true", () => {
|
||||
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 22, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: invalid timezone → fail-open (true)", () => {
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "Invalid/Zone",
|
||||
};
|
||||
// Should not throw, should return true (fail-open)
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 12, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: America/Sao_Paulo timezone conversion", () => {
|
||||
// UTC 2024-03-11 15:00 = BRT (UTC-3) 12:00, Monday
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1],
|
||||
tz: "America/Sao_Paulo",
|
||||
};
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 15, 0)), true);
|
||||
});
|
||||
|
||||
test("isWithinSchedule: America/Sao_Paulo — outside window", () => {
|
||||
// UTC 2024-03-11 22:00 = BRT 19:00, Monday — after 18:00
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "08:00",
|
||||
until: "18:00",
|
||||
days: [1],
|
||||
tz: "America/Sao_Paulo",
|
||||
};
|
||||
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 22, 0)), false);
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { transformToOmniRoute } from "../../src/lib/pricingSync.ts";
|
||||
|
||||
// ─── transformToOmniRoute ────────────────────────────────
|
||||
|
||||
describe("transformToOmniRoute", () => {
|
||||
test("converts LiteLLM per-token pricing to OmniRoute per-million format", () => {
|
||||
const raw = {
|
||||
"openai/gpt-4o": {
|
||||
input_cost_per_token: 0.0000025,
|
||||
output_cost_per_token: 0.00001,
|
||||
litellm_provider: "openai",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.openai, "Should have openai provider");
|
||||
assert.ok(result.openai["gpt-4o"], "Should have gpt-4o model");
|
||||
assert.strictEqual(result.openai["gpt-4o"].input, 2.5);
|
||||
assert.strictEqual(result.openai["gpt-4o"].output, 10);
|
||||
});
|
||||
|
||||
test("maps anthropic provider to cc alias", () => {
|
||||
const raw = {
|
||||
"anthropic/claude-sonnet-4-20250514": {
|
||||
input_cost_per_token: 0.000003,
|
||||
output_cost_per_token: 0.000015,
|
||||
litellm_provider: "anthropic",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.cc, "Should map to cc alias");
|
||||
assert.ok(result.cc["claude-sonnet-4-20250514"]);
|
||||
assert.strictEqual(result.cc["claude-sonnet-4-20250514"].input, 3);
|
||||
assert.strictEqual(result.cc["claude-sonnet-4-20250514"].output, 15);
|
||||
});
|
||||
|
||||
test("maps vertex_ai provider to gemini and gc aliases", () => {
|
||||
const raw = {
|
||||
"vertex_ai/gemini-2.5-flash": {
|
||||
input_cost_per_token: 0.0000003,
|
||||
output_cost_per_token: 0.0000025,
|
||||
litellm_provider: "vertex_ai",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.gemini, "Should map to gemini alias");
|
||||
assert.ok(result.gc, "Should map to gc alias");
|
||||
assert.strictEqual(result.gemini["gemini-2.5-flash"].input, 0.3);
|
||||
assert.strictEqual(result.gc["gemini-2.5-flash"].input, 0.3);
|
||||
});
|
||||
|
||||
test("skips non-chat models (embedding, image, audio)", () => {
|
||||
const raw = {
|
||||
"openai/text-embedding-3-small": {
|
||||
input_cost_per_token: 0.00000002,
|
||||
output_cost_per_token: 0,
|
||||
litellm_provider: "openai",
|
||||
mode: "embedding",
|
||||
},
|
||||
"openai/dall-e-3": {
|
||||
input_cost_per_token: 0,
|
||||
output_cost_per_token: 0,
|
||||
litellm_provider: "openai",
|
||||
mode: "image_generation",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
// openai key should not exist since all models were filtered
|
||||
const openaiModels = result.openai || {};
|
||||
assert.strictEqual(Object.keys(openaiModels).length, 0, "Should skip non-chat models");
|
||||
});
|
||||
|
||||
test("includes cache pricing when available", () => {
|
||||
const raw = {
|
||||
"anthropic/claude-sonnet-4-20250514": {
|
||||
input_cost_per_token: 0.000003,
|
||||
output_cost_per_token: 0.000015,
|
||||
cache_read_input_token_cost: 0.0000003,
|
||||
cache_creation_input_token_cost: 0.00000375,
|
||||
litellm_provider: "anthropic",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
const model = result.anthropic["claude-sonnet-4-20250514"];
|
||||
assert.ok(model, "Should have model");
|
||||
assert.strictEqual(model.cached, 0.3);
|
||||
assert.strictEqual(model.cache_creation, 3.75);
|
||||
});
|
||||
|
||||
test("handles models without explicit mode (treated as chat)", () => {
|
||||
const raw = {
|
||||
"deepseek/deepseek-chat": {
|
||||
input_cost_per_token: 0.00000014,
|
||||
output_cost_per_token: 0.00000028,
|
||||
litellm_provider: "deepseek",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
// deepseek maps to "if" alias
|
||||
assert.ok(result.if, "Should map deepseek to if alias");
|
||||
assert.ok(result.if["deepseek-chat"]);
|
||||
});
|
||||
|
||||
test("skips entries without input cost", () => {
|
||||
const raw = {
|
||||
"unknown/model": {
|
||||
litellm_provider: "unknown",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
const unknownModels = result.unknown || {};
|
||||
assert.strictEqual(Object.keys(unknownModels).length, 0);
|
||||
});
|
||||
|
||||
test("handles zero-cost (free) models", () => {
|
||||
const raw = {
|
||||
"groq/llama-3.3-70b-versatile": {
|
||||
input_cost_per_token: 0,
|
||||
output_cost_per_token: 0,
|
||||
litellm_provider: "groq",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.groq, "Should have groq provider");
|
||||
assert.strictEqual(result.groq["llama-3.3-70b-versatile"].input, 0);
|
||||
assert.strictEqual(result.groq["llama-3.3-70b-versatile"].output, 0);
|
||||
});
|
||||
|
||||
test("uses litellm_provider as-is for unmapped providers", () => {
|
||||
const raw = {
|
||||
"newprovider/some-model": {
|
||||
input_cost_per_token: 0.000001,
|
||||
output_cost_per_token: 0.000002,
|
||||
litellm_provider: "newprovider",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.newprovider, "Should use litellm_provider as-is");
|
||||
assert.ok(result.newprovider["some-model"]);
|
||||
});
|
||||
|
||||
test("strips provider prefix from model key", () => {
|
||||
const raw = {
|
||||
"openai/gpt-4o-mini": {
|
||||
input_cost_per_token: 0.00000015,
|
||||
output_cost_per_token: 0.0000006,
|
||||
litellm_provider: "openai",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
assert.ok(result.openai["gpt-4o-mini"], "Should strip openai/ prefix");
|
||||
assert.strictEqual(result.openai["gpt-4o-mini"].input, 0.15);
|
||||
});
|
||||
|
||||
test("rounds pricing to 3 decimal places", () => {
|
||||
const raw = {
|
||||
"test/model": {
|
||||
input_cost_per_token: 0.00000033333,
|
||||
output_cost_per_token: 0.00000066666,
|
||||
litellm_provider: "openai",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformToOmniRoute(raw);
|
||||
|
||||
// 0.00000033333 * 1e6 = 0.33333 → rounded to 0.333
|
||||
assert.strictEqual(result.openai.model.input, 0.333);
|
||||
assert.strictEqual(result.openai.model.output, 0.667);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Merge precedence ────────────────────────────────────
|
||||
|
||||
describe("pricing merge precedence", () => {
|
||||
test("user overrides > synced > defaults conceptual order", () => {
|
||||
// This test validates the conceptual model.
|
||||
// The actual merge is tested via integration with settings.ts.
|
||||
// Here we verify transform doesn't lose data needed for merge.
|
||||
const raw = {
|
||||
"openai/gpt-4o": {
|
||||
input_cost_per_token: 0.0000025,
|
||||
output_cost_per_token: 0.00001,
|
||||
litellm_provider: "openai",
|
||||
mode: "chat",
|
||||
},
|
||||
};
|
||||
|
||||
const synced = transformToOmniRoute(raw);
|
||||
const userOverride = { openai: { "gpt-4o": { input: 999 } } };
|
||||
|
||||
// Simulate merge: synced then user
|
||||
const merged = { ...synced.openai["gpt-4o"], ...userOverride.openai["gpt-4o"] };
|
||||
|
||||
assert.strictEqual(merged.input, 999, "User override should win");
|
||||
assert.strictEqual(merged.output, 10, "Non-overridden fields from synced should remain");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// ── Env vars BEFORE dynamic imports ──────────────────────────────────────────
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-strict-random-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "strict-random-test-secret";
|
||||
|
||||
const { fisherYatesShuffle, getNextFromDeck } = await import("../../src/sse/services/auth.ts");
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── fisherYatesShuffle ──────────────────────────────────────────────────────
|
||||
|
||||
test("fisherYatesShuffle: returns array with same elements", () => {
|
||||
const input = ["a", "b", "c", "d", "e"];
|
||||
const result = fisherYatesShuffle(input);
|
||||
assert.equal(result.length, input.length);
|
||||
for (const item of input) {
|
||||
assert.ok(result.includes(item), `Missing item: ${item}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("fisherYatesShuffle: does not mutate original array", () => {
|
||||
const input = Object.freeze(["a", "b", "c"]);
|
||||
const result = fisherYatesShuffle(input);
|
||||
assert.deepStrictEqual([...input], ["a", "b", "c"]);
|
||||
assert.equal(result.length, 3);
|
||||
});
|
||||
|
||||
test("fisherYatesShuffle: single element returns same element", () => {
|
||||
const result = fisherYatesShuffle(["only"]);
|
||||
assert.deepStrictEqual(result, ["only"]);
|
||||
});
|
||||
|
||||
test("fisherYatesShuffle: empty array returns empty array", () => {
|
||||
const result = fisherYatesShuffle([]);
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
|
||||
// ─── getNextFromDeck ─────────────────────────────────────────────────────────
|
||||
|
||||
test("getNextFromDeck: uses all connections before repeating", () => {
|
||||
const provider = "test-full-cycle";
|
||||
const ids = ["c1", "c2", "c3", "c4"];
|
||||
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const id = getNextFromDeck(provider, ids);
|
||||
assert.ok(!seen.has(id), `Duplicate before full cycle: ${id} at step ${i}`);
|
||||
seen.add(id);
|
||||
}
|
||||
assert.equal(seen.size, ids.length, "Should have used every connection exactly once");
|
||||
});
|
||||
|
||||
test("getNextFromDeck: reshuffles after exhausting deck", () => {
|
||||
const provider = "test-reshuffle";
|
||||
const ids = ["c1", "c2", "c3"];
|
||||
|
||||
// Exhaust first cycle
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
getNextFromDeck(provider, ids);
|
||||
}
|
||||
|
||||
// Next call should start a new cycle (reshuffle)
|
||||
const firstOfNewCycle = getNextFromDeck(provider, ids);
|
||||
assert.ok(ids.includes(firstOfNewCycle), "New cycle should return a valid connection");
|
||||
|
||||
// Complete the new cycle
|
||||
const newCycleSeen = new Set([firstOfNewCycle]);
|
||||
for (let i = 1; i < ids.length; i++) {
|
||||
const id = getNextFromDeck(provider, ids);
|
||||
assert.ok(!newCycleSeen.has(id), `Duplicate in new cycle: ${id}`);
|
||||
newCycleSeen.add(id);
|
||||
}
|
||||
assert.equal(newCycleSeen.size, ids.length, "New cycle should use all connections");
|
||||
});
|
||||
|
||||
test("getNextFromDeck: last of previous cycle is not first of next cycle", () => {
|
||||
const provider = "test-no-repeat-boundary";
|
||||
const ids = ["c1", "c2", "c3", "c4", "c5"];
|
||||
|
||||
// Run multiple full cycles and check the boundary condition
|
||||
let violations = 0;
|
||||
const totalCycles = 50;
|
||||
|
||||
for (let cycle = 0; cycle < totalCycles; cycle++) {
|
||||
let lastId = "";
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
lastId = getNextFromDeck(provider, ids);
|
||||
}
|
||||
// First of next cycle
|
||||
const firstOfNext = getNextFromDeck(provider, ids);
|
||||
if (firstOfNext === lastId) violations++;
|
||||
|
||||
// Consume rest of cycle
|
||||
for (let i = 1; i < ids.length; i++) {
|
||||
getNextFromDeck(provider, ids);
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
violations,
|
||||
0,
|
||||
`Last of cycle matched first of next cycle ${violations}/${totalCycles} times`
|
||||
);
|
||||
});
|
||||
|
||||
test("getNextFromDeck: connection list change resets deck", () => {
|
||||
const provider = "test-reset-on-change";
|
||||
const originalIds = ["c1", "c2", "c3", "c4"];
|
||||
|
||||
// Use 2 from original deck
|
||||
getNextFromDeck(provider, originalIds);
|
||||
getNextFromDeck(provider, originalIds);
|
||||
|
||||
// Now change the connection list (simulates quota exhaustion removing a connection)
|
||||
const newIds = ["c1", "c2", "c3"]; // c4 removed
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < newIds.length; i++) {
|
||||
const id = getNextFromDeck(provider, newIds);
|
||||
assert.ok(newIds.includes(id), `Got invalid id ${id} after reset`);
|
||||
assert.ok(!seen.has(id), `Duplicate after reset: ${id}`);
|
||||
seen.add(id);
|
||||
}
|
||||
assert.equal(seen.size, newIds.length, "Should use all new connections after reset");
|
||||
});
|
||||
|
||||
test("getNextFromDeck: single connection always returns that connection", () => {
|
||||
const provider = "test-single";
|
||||
const ids = ["only-one"];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const id = getNextFromDeck(provider, ids);
|
||||
assert.equal(id, "only-one");
|
||||
}
|
||||
});
|
||||
|
||||
test("getNextFromDeck: empty array returns empty string", () => {
|
||||
const provider = "test-empty";
|
||||
const id = getNextFromDeck(provider, []);
|
||||
assert.equal(id, "");
|
||||
});
|
||||
|
||||
test("getNextFromDeck: different providers have independent decks", () => {
|
||||
const idsA = ["a1", "a2", "a3"];
|
||||
const idsB = ["b1", "b2"];
|
||||
|
||||
const firstA = getNextFromDeck("providerA", idsA);
|
||||
const firstB = getNextFromDeck("providerB", idsB);
|
||||
|
||||
assert.ok(idsA.includes(firstA));
|
||||
assert.ok(idsB.includes(firstB));
|
||||
|
||||
// Exhaust providerB deck
|
||||
getNextFromDeck("providerB", idsB);
|
||||
|
||||
// providerA should still have remaining items from its deck
|
||||
const secondA = getNextFromDeck("providerA", idsA);
|
||||
assert.ok(idsA.includes(secondA));
|
||||
assert.notEqual(firstA, secondA, "providerA deck should advance independently");
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// ── Env vars ANTES dos imports dinâmicos ─────────────────────────
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-t08-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "t08-test-secret-key";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const schemas = await import("../../src/shared/validation/schemas.ts");
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(__dirname, "../..");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Bloco 1 — Schema Zod (updateKeyPermissionsSchema)
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
test("1.1 — allowedConnections com UUID válido é aceito", () => {
|
||||
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
|
||||
allowedConnections: ["550e8400-e29b-41d4-a716-446655440000"],
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test("1.2 — allowedConnections com string não-UUID é rejeitado", () => {
|
||||
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
|
||||
allowedConnections: ["nao-e-uuid"],
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("1.3 — allowedConnections + noLog combinados são aceitos", () => {
|
||||
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
|
||||
allowedConnections: ["550e8400-e29b-41d4-a716-446655440000"],
|
||||
noLog: true,
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test("1.4 — payload vazio é rejeitado pelo superRefine", () => {
|
||||
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test("1.5 — allowedConnections array vazio é aceito (não é undefined, superRefine não dispara)", () => {
|
||||
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
|
||||
allowedConnections: [],
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Bloco 2 — DB (apiKeys.ts)
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
test("2.1 — key criada tem allowedConnections: [] por padrão", async () => {
|
||||
const created = await apiKeysDb.createApiKey("test-key", "machine-t08");
|
||||
assert.deepEqual(created.allowedConnections, []);
|
||||
});
|
||||
|
||||
test("2.2 — updateApiKeyPermissions persiste array de UUIDs", async () => {
|
||||
const created = await apiKeysDb.createApiKey("conn-key", "machine-t08");
|
||||
const uuid1 = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const uuid2 = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
|
||||
|
||||
const updated = await apiKeysDb.updateApiKeyPermissions(created.id, {
|
||||
allowedConnections: [uuid1, uuid2],
|
||||
});
|
||||
assert.equal(updated, true);
|
||||
|
||||
const row = await apiKeysDb.getApiKeyById(created.id);
|
||||
assert.deepEqual(row?.allowedConnections, [uuid1, uuid2]);
|
||||
});
|
||||
|
||||
test("2.3 — getApiKeyById retorna allowedConnections corretamente", async () => {
|
||||
const created = await apiKeysDb.createApiKey("by-id-key", "machine-t08");
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
|
||||
|
||||
const row = await apiKeysDb.getApiKeyById(created.id);
|
||||
assert.ok(Array.isArray(row?.allowedConnections));
|
||||
assert.equal(row?.allowedConnections[0], uuid);
|
||||
});
|
||||
|
||||
test("2.4 — getApiKeyMetadata retorna allowedConnections corretamente", async () => {
|
||||
const created = await apiKeysDb.createApiKey("meta-key", "machine-t08");
|
||||
const uuid = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
|
||||
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
|
||||
|
||||
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(Array.isArray(meta?.allowedConnections));
|
||||
assert.equal(meta?.allowedConnections[0], uuid);
|
||||
});
|
||||
|
||||
test("2.5 — allowedConnections: [] persiste como array vazio (não null)", async () => {
|
||||
const created = await apiKeysDb.createApiKey("empty-conn-key", "machine-t08");
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
// Primeiro adiciona um UUID
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
|
||||
|
||||
// Reseta para array vazio
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [] });
|
||||
|
||||
const row = await apiKeysDb.getApiKeyById(created.id);
|
||||
assert.ok(Array.isArray(row?.allowedConnections));
|
||||
assert.deepEqual(row?.allowedConnections, []);
|
||||
});
|
||||
|
||||
test("2.6 — cache é invalidado após update (2ª chamada ao metadata reflete novo valor)", async () => {
|
||||
const created = await apiKeysDb.createApiKey("cache-test-key", "machine-t08");
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
// Primeira chamada popula cache (allowedConnections vazio)
|
||||
const before = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.deepEqual(before?.allowedConnections, []);
|
||||
|
||||
// Atualiza — deve invalidar cache
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
|
||||
|
||||
// Segunda chamada deve retornar novo valor (não o cache antigo)
|
||||
const after = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.deepEqual(after?.allowedConnections, [uuid]);
|
||||
});
|
||||
Reference in New Issue
Block a user