Compare commits

...

44 Commits

Author SHA1 Message Date
diegosouzapw 659e2b414d feat(release): v2.8.2 — model alias routing fix, log export, 2 merged PRs
Build Electron Desktop App / Validate version (push) Failing after 25s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-19 11:13:49 -03:00
diegosouzapw 7bcb58e3db feat(logs): add export button with time range dropdown (1h, 6h, 12h, 24h)
- New API: /api/logs/export?hours=24&type=call-logs
- UI: Export button with dropdown on /dashboard/logs page
- Supports export of request-logs, proxy-logs, and call-logs
- Downloads as JSON file with Content-Disposition header
2026-03-19 11:11:07 -03:00
diegosouzapw 2d7d7776a6 fix(routing): model aliases now affect routing, not just format detection (#472)
Previously resolveModelAlias() output was used only for getModelTargetFormat()
but the original model was sent in translatedBody.model and to the executor.
Now effectiveModel is propagated to all downstream operations.
2026-03-19 11:07:29 -03:00
Prakersh Maheshwari c5f429521c fix(pricing): add missing Codex 5.3/5.4 and Anthropic model ID entries (#479)
* fix(pricing): add missing Codex 5.3/5.4 and Anthropic model ID entries

Missing pricing entries cause $0.00 cost for:
- GPT 5.3 Codex family (gpt-5.3-codex, -high, -xhigh, -low, -none)
- GPT 5.4 (with hyphen: gpt-5.4)
- GPT 5.1 Codex Mini High
- Common Anthropic model IDs without dates (claude-opus-4-6,
  claude-sonnet-4-6, claude-opus-4, claude-sonnet-4)
- Dated variants used by Claude Code (claude-opus-4-5-20251101,
  claude-sonnet-4-5-20250929)

* refactor: extract shared pricing constants to reduce duplication

Address review feedback: extract duplicated pricing objects into
named constants (GPT_5_3_CODEX_PRICING, CLAUDE_OPUS_4_PRICING, etc.)
and add clarifying comment about intentional hyphen/dot variant entries.
2026-03-19 11:04:30 -03:00
diegosouzapw 426d8636bc fix(stream): extract usage from remaining buffer in flush handler (#480) 2026-03-19 11:02:13 -03:00
diegosouzapw a265c7096e feat(release): v2.8.1 — streaming log fix, Kiro compat, cache tokens, Chinese i18n, configurable tool call ID
Build Electron Desktop App / Validate version (push) Failing after 31s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-19 08:45:54 -03:00
diegosouzapw 1c9953b1ba chore: remove ZWS_README_V1.md (internal contributor doc) 2026-03-19 08:43:17 -03:00
diegosouzapw 601cc21a44 feat: call log response content, per-model tool call ID, key PATCH & validation (#470) 2026-03-19 08:41:01 -03:00
Ethan Hunt 102c42dfe4 feat: Improve the Chinese translation (#475)
Co-authored-by: gmw <rorschach1167@qq.com>
2026-03-19 08:37:51 -03:00
Prakersh Maheshwari 4953727aa7 fix(callLogs): support Claude format usage and include cache tokens (#476)
saveCallLog only read prompt_tokens/completion_tokens (OpenAI format).
When sourceFormat=claude, the openai-to-claude translator writes
input_tokens/output_tokens instead, causing all cross-format requests
(Codex-via-Claude, Kiro-via-Claude, etc.) to show 0|0 tokens in
call_logs.

Also includes cache_read and cache_creation tokens in tokens_in total
so heavily-cached requests don't show misleadingly low input counts.

Changes:
- Read prompt_tokens || input_tokens (supports both formats)
- Read completion_tokens || output_tokens (supports both formats)
- Sum cache_read_input_tokens + cache_creation_input_tokens into total
2026-03-19 08:37:49 -03:00
Prakersh Maheshwari e6af874b47 fix(usage): include cache tokens in usage history input total (#477)
logUsage stored only non-cached input tokens in usage_history.tokens_input.
For heavily-cached Claude requests (common with Claude Code), this shows
near-zero input when the real total is 150K+, causing the analytics
dashboard to severely underreport input token usage.

Now sums: input = prompt_tokens + cache_read + cache_creation
2026-03-19 08:37:46 -03:00
Prakersh Maheshwari 801b4eef4c fix(kiro): strip injected model field from request body (#478)
chatCore.ts injects translatedBody.model for all providers after
translation. Kiro API (AWS CodeWhisperer) has strict schema validation
and rejects unknown top-level fields — only conversationState, profileArn,
and inferenceConfig are valid. This causes 100% of Kiro requests to fail
with "Improperly formed request".

Strip the injected model field in KiroExecutor.transformRequest().
2026-03-19 08:37:44 -03:00
diegosouzapw fe5c20a04e feat(release): v2.8.0 — Bailian Coding Plan, editable provider URLs, 812 tests
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-19 02:28:45 -03:00
diegosouzapw 246fd05fae feat(providers): add Bailian Coding Plan provider with editable base URL (#467) 2026-03-19 02:25:29 -03:00
diegosouzapw a09b298127 feat(release): v2.7.10 — Alibaba Cloud Coding, Kimi Coding API-key, Docker pino fix
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-19 01:50:00 -03:00
Jefferson Nunn f89f40778f feat: add API-key Kimi Coding provider path (#463)
* feat: add api-key Kimi Coding provider support

* fix(kimi-coding): honor apikey auth header in executor

Ensure DefaultExecutor sends x-api-key for kimi-coding-apikey at runtime
and deduplicate shared kimi coding config blocks in registry and models
config to reduce drift between oauth and apikey variants.

---------

Co-authored-by: OmniRoute Agent <agent@omniroute.local>
2026-03-19 01:48:26 -03:00
dtk 3d0c8d8d45 feat: add alibaba cloud coding plan provider support (#465)
Co-authored-by: dtk <git@derzsi.cloud>
2026-03-19 01:48:23 -03:00
diegosouzapw 0e5e8bf14e fix(docker): add missing split2 dependency to container image (#459) 2026-03-19 01:46:26 -03:00
diegosouzapw ce34d329d3 chore(release): v2.7.9
Build Electron Desktop App / Validate version (push) Failing after 28s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-18 17:19:42 -03:00
diegosouzapw eaf4a5805c "fix: resolved UI combo setting schema strip (#458)"
"fix: safe crypto fallback for MITM on windows (#456)"
2026-03-18 17:18:31 -03:00
Sergey Morozov 8420e565d4 feat: add responses subpath passthrough for codex (#457) 2026-03-18 17:18:29 -03:00
diegosouzapw 1b68deb0f6 feat(release): v2.7.8 — budget save fix + combo agent UI + omniModel tag strip
Build Electron Desktop App / Validate version (push) Failing after 32s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- fix(budget): warningThreshold sent as fraction 0-1 not percentage 0-100 (#451)
- feat(combos): Agent Features UI in combo modal (system_message, tool_filter_regex,
  context_cache_protection) — previously server-only (#454)
- fix(combos): strip <omniModel> tags before forwarding to provider (#454)
2026-03-18 15:38:04 -03:00
Diego Rodrigues de Sa e Souza d1497c9ac8 Merge pull request #455 from diegosouzapw/fix/issue-451-454-budget-combo-ui
fix: budget warningThreshold + combo agent UI fields + omniModel tag strip
2026-03-18 15:37:17 -03:00
diegosouzapw 03d4cbf6d5 fix: budget warningThreshold fraction mismatch + combo agent UI fields + omniModel tag strip
- fix(budget): BudgetTab sent integer percentage (80) but schema validated
  fraction (0-1). Now divides by 100 on POST and multiplies by 100 on GET (#451)

- fix(combos): expose Agent Features UI in combo create/edit modal — fields for
  system_message override, tool_filter_regex, and context_cache_protection were
  implemented server-side (#399/#401) but missing from the dashboard UI (#454)

- fix(combos): strip <omniModel> tags from messages before forwarding to provider.
  The internal cache-pinning tag was being sent to the provider, causing cache
  misses as providers treated each tagged request as a new session (#454)
2026-03-18 15:32:47 -03:00
diegosouzapw 718be831af feat(release): v2.7.7 — Docker pino crash fix + Codex responses worker fix
Build Electron Desktop App / Validate version (push) Failing after 35s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- fix(docker): copy pino-abstract-transport + pino-pretty in standalone (#449)
- fix(responses): remove initTranslators() from /v1/responses route (#450)
- chore(deps): commit package-lock.json with each version bump
2026-03-18 15:13:26 -03:00
Diego Rodrigues de Sa e Souza 9d5ec523be Merge pull request #453 from diegosouzapw/fix/issue-449-450-pino-docker-responses-worker
fix: pino Docker crash + Codex /v1/responses worker exit + package-lock sync
2026-03-18 15:11:38 -03:00
diegosouzapw 81c43b45fb fix: pino-abstract-transport missing in Docker + responses worker crash + lock sync
- fix(docker): copy pino-abstract-transport and pino-pretty explicitly in
  runner-base stage — Next.js standalone trace omits them, causing
  'Cannot find module pino-abstract-transport' crash on startup (#449)

- fix(responses): remove initTranslators() call from /v1/responses route —
  bootstrapping translator registry from a Next.js Route Handler worker
  caused 'the worker has exited' uncaughtException on Codex CLI requests.
  Translators are already bootstrapped server-side via open-sse (#450)

- chore: include package-lock.json in commit (was being left behind on
  version bumps, causing npm ci to install inconsistent deps in Docker)
2026-03-18 15:08:57 -03:00
diegosouzapw 146a491769 feat(release): v2.7.5 — login UX + Windows CLI healthcheck
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- fix(ux): show default password hint on login page (#437)
- fix(cli): spawn shell:true on Windows for .cmd CLI resolution (#447)
2026-03-18 14:52:05 -03:00
Diego Rodrigues de Sa e Souza 4c53388579 Merge pull request #448 from diegosouzapw/fix/issue-437-447-435-login-healthcheck-gemini
fix: login default password hint + Windows CLI healthcheck shell resolution
2026-03-18 14:51:19 -03:00
diegosouzapw 3403ddcc6e fix: login password hint + Windows CLI healthcheck + i18n key
- fix(ux): add default password hint on login page for first-time users (#437)
  The fallback password (123456) is now shown as a hint below the
  password input so users don't get locked out during initial setup.

- fix(cli): add shell:true to spawn on Windows so .cmd wrappers are
  resolved correctly via PATHEXT (#447). Claude, opencode, and other
  npm-installed CLIs show as 'not runnable' on Windows even when
  installed because spawn() cannot find .cmd files without shell:true.

- i18n: add defaultPasswordHint key to en.json auth namespace
2026-03-18 14:44:49 -03:00
diegosouzapw 684b81d835 feat(release): v2.7.4 — search playground, i18n fixes, Copilot limits, Serper validation
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- feat(search): search playground + search tools page + local rerank (#443 @Regis-RCR)
- fix(analytics): localize day/date labels with Intl.DateTimeFormat (#444 @hijak)
- fix(copilot): correct account type display, filter unlimited quotas (#445 @hijak)
- fix(providers): stop rejecting valid Serper API keys on non-4xx (#446 @hijak)
2026-03-18 12:11:00 -03:00
Diego Rodrigues de Sa e Souza 4f32da57fd Merge pull request #443 from Regis-RCR/feat/search-playground
feat(search): add search playground, search tools, and local rerank routing
2026-03-18 12:09:51 -03:00
Diego Rodrigues de Sa e Souza 97265e48b3 Merge pull request #444 from hijak/fix/analytics-day-date-translations
fix: localize analytics day and date labels
2026-03-18 12:07:03 -03:00
Diego Rodrigues de Sa e Souza 64797158e2 Merge pull request #445 from hijak/fix/copilot-account-type-limits
fix: correct GitHub Copilot account type and limits
2026-03-18 12:06:59 -03:00
Diego Rodrigues de Sa e Souza 8359293dcd Merge pull request #446 from hijak/fix/serper-api-key-validation
fix: stop rejecting valid Serper API keys
2026-03-18 12:06:36 -03:00
Jack Cowey b2dc53d18b fix(search): return consistent validation result shape
Keep search provider validation responses consistent with other validators so Serper regression tests and CI assertions can rely on unsupported=false.

Made-with: Cursor
2026-03-18 12:55:25 +00:00
Jack Cowey edf8dd2a12 fix(search): accept authenticated serper validation responses
Treat non-auth Serper validation errors as successful authentication so valid API keys are not rejected during provider setup.

Made-with: Cursor
2026-03-18 12:29:14 +00:00
Jack Cowey 5a777bd598 fix(github): correct copilot plan and quota mapping
Normalize GitHub Copilot account tiers from the usage payload and hide misleading unlimited buckets so account type and limits render correctly in the dashboard.

Made-with: Cursor
2026-03-18 12:25:17 +00:00
Jack Cowey bd39e01ee1 fix(analytics): localize most active day and weekly labels
Use the active app locale for analytics weekday and date formatting so the dashboard no longer shows hardcoded Portuguese labels.

Made-with: Cursor
2026-03-18 12:17:56 +00:00
Regis e3ed29aab6 feat(search): add search playground, search tools, and local rerank routing
Search Playground (Phase 1):
- Web Search as 10th endpoint in Playground with isolated SearchPlayground component
- Endpoint selector moved first; Provider/Model/Send hidden when search selected
- Provider dropdown via GET /api/search/providers, formatted results with cache indicator

Search Tools page (Phase 2) at /dashboard/search-tools:
- Split panel: SearchForm (left) with query, provider, filters + ResultsPanel (right)
- Compare Providers: parallel queries with latency, cost, response size, URL overlap
- Rerank Pipeline: model selector from /v1/models, results with position delta
- Search History: last 10 searches from call_logs with replay
- Sidebar entry under Debug section

Backend:
- GET /api/search/providers — list providers with auth guard + SEARCH_CREDENTIAL_FALLBACKS
- GET /api/search/stats — cache stats, provider aggregates, recent searches (auth guard)
- Add local provider_nodes routing for /v1/rerank (oMLX, vLLM support)

Bug fixes (from F-27 PR #432):
- Fix Brave news normalizer: data.results directly, not data.news.results
- Enforce max_results truncation after normalization for all providers
- Fix EndpointPageClient: use /api/search/providers instead of /api/v1/search
- Add isAuthenticated() guards on /api/search/providers and /api/search/stats

Response size metric in results meta bar and compare table.
i18n: 30+ keys in search namespace (en.json)
2026-03-18 12:43:24 +01:00
diegosouzapw 896ce9c0e2 feat(release): v2.7.3 — fix Codex direct API weekly quota fallback
Build Electron Desktop App / Validate version (push) Failing after 36s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- fix(codex): resolveQuotaWindow() prefix-matches 'weekly' → 'weekly (7d)' cache keys
- fix(codex): applyCodexWindowPolicy() enforces useWeekly/use5h toggles in direct API
- 4 new regression tests, 766 total passing
- Closes #440
2026-03-18 08:41:13 -03:00
Diego Rodrigues de Sa e Souza 82934132e9 Merge pull request #441 from rexname/fix/issue-440-direct-api-fallback
fix(codex): block weekly-exhausted accounts in direct API fallback
2026-03-18 08:40:19 -03:00
rexname a2012b70de chore(review): harden window normalization and deterministic quota matching 2026-03-18 14:17:37 +07:00
rexname bcfeba8a57 fix(codex): enforce weekly quota blocking for direct API fallback 2026-03-18 13:57:25 +07:00
71 changed files with 6025 additions and 1332 deletions
+2
View File
@@ -55,6 +55,8 @@ logs/*
# analysis directories (generated, not tracked)
.analysis/
antigravity-manager-analysis/
.sisyphus/
.plans/
# docs (allow specific tracked files)
docs/*
+161
View File
@@ -4,6 +4,167 @@
---
## [2.8.2] — 2026-03-19
> Sprint: 2 merged PRs, model aliases routing fix, log export, and issue triage.
### Features
- **Log Export**: New Export button on `/dashboard/logs` with time range dropdown (1h, 6h, 12h, 24h). Downloads JSON of request/proxy/call logs via `/api/logs/export` API (#user-request)
### Bug Fixes
- **Model Aliases Routing** (#472): Settings → Model Aliases now correctly affect provider routing, not just format detection. Previously `resolveModelAlias()` output was only used for `getModelTargetFormat()` but the original model ID was sent to the provider
- **Stream Flush Usage** (#480): Usage data from the last SSE event in the buffer is now correctly extracted during stream flush (merged from @prakersh)
### Merged PRs
- #480 — Extract usage from remaining buffer in flush handler (@prakersh)
- #479 — Add missing Codex 5.3/5.4 and Anthropic model ID pricing entries (@prakersh)
---
## [2.8.1] — 2026-03-19
> Sprint: Five community PRs — streaming call log fixes, Kiro compatibility, cache token analytics, Chinese translation, and configurable tool call IDs.
### ✨ Features
- **feat(logs)**: Call log response content now correctly accumulated from raw provider chunks (OpenAI/Claude/Gemini) before translation, fixing empty response payloads in streaming mode (#470, @zhangqiang8vip)
- **feat(providers)**: Per-model configurable 9-char tool call ID normalization (Mistral-style) — only models with the option enabled get truncated IDs (#470)
- **feat(api)**: Key PATCH API expanded to support `allowedConnections`, `name`, `autoResolve`, `isActive`, and `accessSchedule` fields (#470)
- **feat(dashboard)**: Response-first layout in request log detail UI (#470)
- **feat(i18n)**: Improved Chinese (zh-CN) translation — complete retranslation (#475, @only4copilot)
### 🐛 Bug Fixes
- **fix(kiro)**: Strip injected `model` field from request body — Kiro API rejects unknown top-level fields (#478, @prakersh)
- **fix(usage)**: Include cache read + cache creation tokens in usage history input totals for accurate analytics (#477, @prakersh)
- **fix(callLogs)**: Support Claude format usage fields (`input_tokens`/`output_tokens`) alongside OpenAI format, include all cache token variants (#476, @prakersh)
---
## [2.8.0] — 2026-03-19
> Sprint: Bailian Coding Plan provider with editable base URLs, plus community contributions for Alibaba Cloud and Kimi Coding.
### ✨ Features
- **feat(providers)**: Added Bailian Coding Plan (`bailian-coding-plan`) — Alibaba Model Studio with Anthropic-compatible API. Static catalog of 8 models including Qwen3.5 Plus, Qwen3 Coder, MiniMax M2.5, GLM 5, and Kimi K2.5. Includes custom auth validation (400=valid, 401/403=invalid) (#467, @Mind-Dragon)
- **feat(admin)**: Editable default URL in Provider Admin create/edit flows — users can configure custom base URLs per connection. Persisted in `providerSpecificData.baseUrl` with Zod schema validation rejecting non-http(s) schemes (#467)
### 🧪 Tests
- Added 30+ unit tests and 2 e2e scenarios for Bailian Coding Plan provider covering auth validation, schema hardening, route-level behavior, and cross-layer integration
---
## [2.7.10] — 2026-03-19
> Sprint: Two new community-contributed providers (Alibaba Cloud Coding, Kimi Coding API-key) and Docker pino fix.
### ✨ Features
- **feat(providers)**: Added Alibaba Cloud Coding Plan support with two OpenAI-compatible endpoints — `alicode` (China) and `alicode-intl` (International), each with 8 models (#465, @dtk1985)
- **feat(providers)**: Added dedicated `kimi-coding-apikey` provider path — API-key-based Kimi Coding access is no longer forced through OAuth-only `kimi-coding` route. Includes registry, constants, models API, config, and validation test (#463, @Mind-Dragon)
### 🐛 Bug Fixes
- **fix(docker)**: Added missing `split2` dependency to Docker image — `pino-abstract-transport` requires it at runtime but it was not being copied into the standalone container, causing `Cannot find module 'split2'` crashes (#459)
---
## [2.7.9] — 2026-03-18
> Sprint: Codex responses subpath passthrough natively supported, Windows MITM crash fixed, and Combos agent schemas adjusted.
### ✨ Features
- **feat(codex)**: Native responses subpath passthrough for Codex — natively routes `POST /v1/responses/compact` to Codex upstream, maintaining Claude Code compatibility without stripping the `/compact` suffix (#457)
### 🐛 Bug Fixes
- **fix(combos)**: Zod schemas (`updateComboSchema` and `createComboSchema`) now include `system_message`, `tool_filter_regex`, and `context_cache_protection`. Fixes bug where agent-specific settings created via the dashboard were silently discarded by the backend validation layer (#458)
- **fix(mitm)**: Kiro MITM profile crash on Windows fixed — `node-machine-id` failed due to missing `REG.exe` env, and the fallback threw a fatal `crypto is not defined` error. Fallback now safely and correctly imports crypto (#456)
---
## [2.7.8] — 2026-03-18
> Sprint: Budget save bug + combo agent features UI + omniModel tag security fix.
### 🐛 Bug Fixes
- **fix(budget)**: "Save Limits" no longer returns 422 — `warningThreshold` is now correctly sent as fraction (01) instead of percentage (0100) (#451)
- **fix(combos)**: `<omniModel>` internal cache tag is now stripped before forwarding requests to providers, preventing cache session breaks (#454)
### ✨ Features
- **feat(combos)**: Agent Features section added to combo create/edit modal — expose `system_message` override, `tool_filter_regex`, and `context_cache_protection` directly from the dashboard (#454)
---
## [2.7.7] — 2026-03-18
> Sprint: Docker pino crash, Codex CLI responses worker fix, package-lock sync.
### 🐛 Bug Fixes
- **fix(docker)**: `pino-abstract-transport` and `pino-pretty` now explicitly copied in Docker runner stage — Next.js standalone trace misses these peer deps, causing `Cannot find module pino-abstract-transport` crash on startup (#449)
- **fix(responses)**: Remove `initTranslators()` from `/v1/responses` route — was crashing Next.js worker with `the worker has exited` uncaughtException on Codex CLI requests (#450)
### 🔧 Maintenance
- **chore(deps)**: `package-lock.json` now committed on every version bump to ensure Docker `npm ci` uses exact dependency versions
---
## [2.7.5] — 2026-03-18
> Sprint: UX improvements and Windows CLI healthcheck fix.
### 🐛 Bug Fixes
- **fix(ux)**: Show default password hint on login page — new users now see `"Default password: 123456"` below the password input (#437)
- **fix(cli)**: Claude CLI and other npm-installed tools now correctly detected as runnable on Windows — spawn uses `shell:true` to resolve `.cmd` wrappers via PATHEXT (#447)
---
## [2.7.4] — 2026-03-18
> Sprint: Search Tools dashboard, i18n fixes, Copilot limits, Serper validation fix.
### 🚀 Features
- **feat(search)**: Add Search Playground (10th endpoint), Search Tools page with Compare Providers/Rerank Pipeline/Search History, local rerank routing, auth guards on search API (#443 by @Regis-RCR)
- New route: `/dashboard/search-tools`
- Sidebar entry under Debug section
- `GET /api/search/providers` and `GET /api/search/stats` with auth guards
- Local provider_nodes routing for `/v1/rerank`
- 30+ i18n keys in search namespace
### 🐛 Bug Fixes
- **fix(search)**: Fix Brave news normalizer (was returning 0 results), enforce max_results truncation post-normalization, fix Endpoints page fetch URL (#443 by @Regis-RCR)
- **fix(analytics)**: Localize analytics day/date labels — replace hardcoded Portuguese strings with `Intl.DateTimeFormat(locale)` (#444 by @hijak)
- **fix(copilot)**: Correct GitHub Copilot account type display, filter misleading unlimited quota rows from limits dashboard (#445 by @hijak)
- **fix(providers)**: Stop rejecting valid Serper API keys — treat non-4xx responses as valid authentication (#446 by @hijak)
---
## [2.7.3] — 2026-03-18
> Sprint: Codex direct API quota fallback fix.
### 🐛 Bug Fixes
- **fix(codex)**: Block weekly-exhausted accounts in direct API fallback (#440)
- `resolveQuotaWindow()` prefix matching: `"weekly"` now matches `"weekly (7d)"` cache keys
- `applyCodexWindowPolicy()` enforces `useWeekly`/`use5h` toggles correctly
- 4 new regression tests (766 total)
---
## [2.7.2] — 2026-03-18
> Sprint: Light mode UI contrast fixes.
+5
View File
@@ -32,6 +32,11 @@ COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/standalone ./
# Explicitly copy @swc/helpers — not always traced by standalone output but needed at runtime
COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
# Explicitly copy pino transport dependencies — pino spawns a worker that requires
# pino-abstract-transport at runtime; Next.js standalone trace does not capture it (#449)
COPY --from=builder /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
COPY --from=builder /app/node_modules/pino-pretty ./node_modules/pino-pretty
COPY --from=builder /app/node_modules/split2 ./node_modules/split2
COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs
COPY --from=builder /app/scripts/bootstrap-env.mjs ./bootstrap-env.mjs
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.7.2
version: 2.8.2
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,
+4
View File
@@ -121,6 +121,10 @@ const nextConfig = {
source: "/responses",
destination: "/api/v1/responses",
},
{
source: "/responses/:path*",
destination: "/api/v1/responses/:path*",
},
{
source: "/models",
destination: "/api/v1/models",
+90 -13
View File
@@ -78,6 +78,22 @@ interface LegacyProvider {
clientVersion?: string;
}
const KIMI_CODING_SHARED = {
format: "claude",
executor: "default",
baseUrl: "https://api.kimi.com/coding/v1/messages",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
models: [
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
] as RegistryModel[],
} as const;
// ── Registry ──────────────────────────────────────────────────────────────
export const REGISTRY: Record<string, RegistryEntry> = {
@@ -521,6 +537,32 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
"bailian-coding-plan": {
id: "bailian-coding-plan",
alias: "bcp",
format: "claude",
executor: "default",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
chatPath: "/messages",
urlSuffix: "?beta=true",
authType: "apikey",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max (2026-01-23)" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
],
},
zai: {
id: "zai",
alias: "zai",
@@ -559,16 +601,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"kimi-coding": {
id: "kimi-coding",
alias: "kmc",
format: "claude",
executor: "default",
baseUrl: "https://api.kimi.com/coding/v1/messages",
...KIMI_CODING_SHARED,
urlSuffix: "?beta=true",
authType: "oauth",
authHeader: "x-api-key",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
oauth: {
clientIdEnv: "KIMI_CODING_OAUTH_CLIENT_ID",
clientIdDefault: "17e5f671-d194-4dfb-9706-5516cb48c098",
@@ -576,11 +611,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
refreshUrl: "https://auth.kimi.com/api/oauth/token",
authUrl: "https://auth.kimi.com/api/oauth/device_authorization",
},
models: [
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
],
},
"kimi-coding-apikey": {
id: "kimi-coding-apikey",
alias: "kmca",
...KIMI_CODING_SHARED,
authType: "apikey",
},
kilocode: {
@@ -699,6 +736,46 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
alicode: {
id: "alicode",
alias: "alicode",
format: "openai",
executor: "default",
baseUrl: "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
},
"alicode-intl": {
id: "alicode-intl",
alias: "alicode-intl",
format: "openai",
executor: "default",
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
},
deepseek: {
id: "deepseek",
alias: "ds",
+1
View File
@@ -26,6 +26,7 @@ export type ProviderCredentials = {
expiresAt?: string;
connectionId?: string; // T07: used for API key rotation index
providerSpecificData?: JsonRecord;
requestEndpointPath?: string;
};
export type ExecutorLog = {
+38 -3
View File
@@ -9,6 +9,17 @@ type EffortLevel = (typeof EFFORT_ORDER)[number];
const CODEX_FAST_WIRE_VALUE = "priority";
let defaultFastServiceTierEnabled = false;
function getResponsesSubpath(endpointPath: unknown): string | null {
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
const match = normalizedEndpoint.match(/(?:^|\/)responses(?:(\/.*))?$/i);
if (!match) return null;
return match[1] || "";
}
function isCompactResponsesEndpoint(endpointPath: unknown): boolean {
return getResponsesSubpath(endpointPath)?.toLowerCase() === "/compact";
}
function normalizeServiceTierValue(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
@@ -60,13 +71,31 @@ export class CodexExecutor extends BaseExecutor {
super("codex", PROVIDERS.codex);
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
void model;
void stream;
void urlIndex;
const responsesSubpath = getResponsesSubpath(credentials?.requestEndpointPath);
if (responsesSubpath !== null) {
const baseUrl = String(this.config.baseUrl || "").replace(/\/$/, "");
if (baseUrl.endsWith("/responses")) {
return `${baseUrl}${responsesSubpath}`;
}
return `${baseUrl}/responses${responsesSubpath}`;
}
return super.buildUrl(model, stream, urlIndex, credentials);
}
/**
* Codex Responses endpoint is SSE-first.
* Always request event-stream from upstream, even when client requested stream=false.
* Includes chatgpt-account-id header for strict workspace binding.
*/
buildHeaders(credentials, stream = true) {
const headers = super.buildHeaders(credentials, true);
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
const headers = super.buildHeaders(credentials, isCompactRequest ? false : true);
// Add workspace binding header if workspaceId is persisted
const workspaceId = credentials?.providerSpecificData?.workspaceId;
@@ -107,9 +136,15 @@ export class CodexExecutor extends BaseExecutor {
*/
transformRequest(model, body, stream, credentials) {
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
// Codex /responses rejects stream=false; we aggregate SSE back to JSON when needed.
body.stream = true;
// Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely.
if (isCompactRequest) {
delete body.stream;
delete body.stream_options;
} else {
body.stream = true;
}
delete body._nativeCodexPassthrough;
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
+2
View File
@@ -54,6 +54,8 @@ export class DefaultExecutor extends BaseExecutor {
break;
case "glm":
case "kimi-coding":
case "bailian-coding-plan":
case "kimi-coding-apikey":
case "minimax":
case "minimax-cn":
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
+5 -2
View File
@@ -77,10 +77,13 @@ export class KiroExecutor extends BaseExecutor {
}
transformRequest(model: string, body: unknown, stream: boolean, credentials: unknown): unknown {
void model;
void stream;
void credentials;
return body;
// Kiro uses conversationState.currentMessage.userInputMessage.modelId,
// not a top-level "model" field. chatCore injects translatedBody.model
// which Kiro API rejects as unknown top-level field.
const { model: _model, ...rest } = body as Record<string, unknown>;
return rest;
}
/**
+33 -18
View File
@@ -23,6 +23,7 @@ import {
appendRequestLog,
saveCallLog,
} from "@/lib/usageDb";
import { getModelNormalizeToolCallId } from "@/lib/db/models";
import { getExecutor } from "../executors/index.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
@@ -60,9 +61,8 @@ export function shouldUseNativeCodexPassthrough({
}): boolean {
if (provider !== "codex") return false;
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false;
return String(endpointPath || "")
.toLowerCase()
.endsWith("/responses");
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
return /(?:^|\/)responses(?:\/.*)?$/i.test(normalizedEndpoint);
}
/**
@@ -140,8 +140,8 @@ export async function handleChatCore({
}
const sourceFormat = detectFormat(body);
const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase();
const isResponsesEndpoint = endpointPath.endsWith("/responses");
const endpointPath = String(clientRawRequest?.endpoint || "");
const isResponsesEndpoint = /(?:^|\/)responses(?:\/.*)?$/i.test(endpointPath);
const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({
provider,
sourceFormat,
@@ -157,10 +157,16 @@ export async function handleChatCore({
// Detect source format and get target format
// Model-specific targetFormat takes priority over provider default
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315)
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315, #472)
// Custom aliases take priority over built-in and must be resolved here so the
// downstream getModelTargetFormat() lookup uses the correct, aliased model ID.
// downstream getModelTargetFormat() lookup AND the actual provider request use
// the correct, aliased model ID. Without this, aliases only affect format detection.
const resolvedModel = resolveModelAlias(model);
// Use resolvedModel for all downstream operations (routing, provider requests, logging)
const effectiveModel = resolvedModel !== model ? resolvedModel : model;
if (resolvedModel !== model) {
log?.info?.("ALIAS", `Model alias applied: ${model}${resolvedModel}`);
}
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
@@ -311,6 +317,7 @@ export async function handleChatCore({
}
}
const normalizeToolCallId = getModelNormalizeToolCallId(provider || "", model || "");
translatedBody = translateRequest(
sourceFormat,
targetFormat,
@@ -319,7 +326,8 @@ export async function handleChatCore({
stream,
credentials,
provider,
reqLogger
reqLogger,
{ normalizeToolCallId }
);
}
} catch (error) {
@@ -365,8 +373,8 @@ export async function handleChatCore({
delete translatedBody._toolNameMap;
delete translatedBody._disableToolPrefix;
// Update model in body
translatedBody.model = model;
// Update model in body — use resolved alias so the provider gets the correct model ID (#472)
translatedBody.model = effectiveModel;
// Strip unsupported parameters for reasoning models (o1, o3, etc.)
const unsupported = getUnsupportedParams(provider, model);
@@ -385,6 +393,8 @@ export async function handleChatCore({
// Get executor for this provider
const executor = getExecutor(provider);
const getExecutionCredentials = () =>
nativeCodexPassthrough ? { ...credentials, requestEndpointPath: endpointPath } : credentials;
// Create stream controller for disconnect detection
const streamController = createStreamController({ onDisconnect, log, provider, model });
@@ -393,7 +403,7 @@ export async function handleChatCore({
const dedupEnabled = shouldDeduplicate(dedupRequestBody);
const dedupHash = dedupEnabled ? computeRequestHash(dedupRequestBody) : null;
const executeProviderRequest = async (modelToCall = model, allowDedup = false) => {
const executeProviderRequest = async (modelToCall = effectiveModel, allowDedup = false) => {
const execute = async () => {
const bodyToSend =
translatedBody.model === modelToCall
@@ -405,7 +415,7 @@ export async function handleChatCore({
model: modelToCall,
body: bodyToSend,
stream,
credentials,
credentials: getExecutionCredentials(),
signal: streamController.signal,
log,
extendedContext,
@@ -441,8 +451,8 @@ export async function handleChatCore({
trackPendingRequest(model, provider, connectionId, true);
// T5: track which models we've tried for intra-family fallback
const triedModels = new Set<string>([model]);
let currentModel = model;
const triedModels = new Set<string>([effectiveModel]);
let currentModel = effectiveModel;
// Log start
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => {});
@@ -461,7 +471,7 @@ export async function handleChatCore({
let finalBody;
try {
const result = await executeProviderRequest(model, true);
const result = await executeProviderRequest(effectiveModel, true);
providerResponse = result.response;
providerUrl = result.url;
@@ -545,7 +555,7 @@ export async function handleChatCore({
model,
body: translatedBody,
stream,
credentials,
credentials: getExecutionCredentials(),
signal: streamController.signal,
log,
extendedContext,
@@ -870,8 +880,12 @@ export async function handleChatCore({
// Create transform stream with logger for streaming response
let transformStream;
// Callback to save call log when stream completes (streaming calls were never logged before!)
const onStreamComplete = ({ status: streamStatus, usage: streamUsage }) => {
// Callback to save call log when stream completes (include responseBody when provided by stream)
const onStreamComplete = ({
status: streamStatus,
usage: streamUsage,
responseBody: streamResponseBody,
}) => {
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
@@ -882,6 +896,7 @@ export async function handleChatCore({
duration: Date.now() - startTime,
tokens: streamUsage || {},
requestBody: body,
responseBody: streamResponseBody ?? undefined,
sourceFormat,
targetFormat,
comboName,
+22 -6
View File
@@ -75,7 +75,12 @@ interface SearchHandlerOptions {
timeRange?: string;
offset?: number;
domainFilter?: string[];
contentOptions?: { snippet?: boolean; full_page?: boolean; format?: string; max_characters?: number };
contentOptions?: {
snippet?: boolean;
full_page?: boolean;
format?: string;
max_characters?: number;
};
strictFilters?: boolean;
providerOptions?: Record<string, unknown>;
credentials: Record<string, any>;
@@ -189,7 +194,9 @@ function normalizeBraveResponse(
searchType: string
): { results: SearchResult[]; totalResults: number | null } {
const now = new Date().toISOString();
const container = searchType === "news" ? data.news : data.web;
// Brave news endpoint returns { results: [...] } directly,
// while web endpoint returns { web: { results: [...] } }
const container = searchType === "news" ? data.news || data : data.web;
const items = container?.results;
if (!Array.isArray(items)) return { results: [], totalResults: null };
@@ -593,7 +600,9 @@ async function tryProvider(
search_type: searchType,
max_results: maxResults,
},
}).catch(() => { /* non-critical — logging must not block search response */ });
}).catch(() => {
/* non-critical — logging must not block search response */
});
return {
success: false,
@@ -603,7 +612,10 @@ async function tryProvider(
}
const data = await response.json();
const { results, totalResults } = normalizeResponse(config.id, data, query, searchType);
const normalized = normalizeResponse(config.id, data, query, searchType);
// Enforce max_results — some providers return more than requested
const results = normalized.results.slice(0, maxResults);
const totalResults = normalized.totalResults;
const duration = Date.now() - startTime;
saveCallLog({
@@ -617,7 +629,9 @@ async function tryProvider(
tokens: { prompt_tokens: 0, completion_tokens: 0 },
requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults },
responseBody: { results_count: results.length, cached: false },
}).catch(() => { /* non-critical — logging must not block search response */ });
}).catch(() => {
/* non-critical — logging must not block search response */
});
return {
success: true,
@@ -653,7 +667,9 @@ async function tryProvider(
requestType: "search",
error: err.message,
requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults },
}).catch(() => { /* non-critical — logging must not block search response */ });
}).catch(() => {
/* non-critical — logging must not block search response */
});
return {
success: false,
+19
View File
@@ -123,6 +123,20 @@ export function applyToolFilter(
});
}
/**
* Strip all <omniModel> tags from message content before forwarding to the provider.
* The tag is an internal OmniRoute marker; providers must never see it or their
* cache will treat every tagged request as a new session (#454).
*/
export function stripModelTags(messages: Message[]): Message[] {
return messages.map((msg) => {
if (typeof msg.content === "string" && CACHE_TAG_PATTERN.test(msg.content)) {
return { ...msg, content: msg.content.replace(CACHE_TAG_PATTERN, "").trimEnd() };
}
return msg;
});
}
// ── Main Middleware ──────────────────────────────────────────────────────────
/**
@@ -158,6 +172,11 @@ export function applyComboAgentMiddleware(
comboConfig.tool_filter_regex
);
// 4. Strip internal <omniModel> tags before forwarding to provider (#454)
// These tags are OmniRoute-internal markers and must never reach the provider
// since providers would treat each tagged request as a new cache session.
messages = stripModelTags(messages);
return {
body: {
...body,
+166 -39
View File
@@ -75,6 +75,30 @@ function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unk
return obj[snakeKey] ?? obj[camelKey] ?? null;
}
function clampPercentage(value: number): number {
return Math.max(0, Math.min(100, value));
}
function toDisplayLabel(value: string): string {
return value
.replace(/^copilot[_\s-]*/i, "")
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => {
if (/^pro\+$/i.test(part)) return "Pro+";
if (/^[a-z]{2,}$/.test(part)) return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
return part;
})
.join(" ")
.trim();
}
function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota {
if (!quota) return false;
if (quota.unlimited && quota.total <= 0) return false;
return quota.total > 0 || quota.remainingPercentage !== undefined;
}
/**
* Get usage data for a provider connection
* @param {Object} connection - Provider connection with accessToken
@@ -170,48 +194,65 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
}
const data = await response.json();
const dataRecord = toRecord(data);
// Handle different response formats (paid vs free)
if (data.quota_snapshots) {
if (dataRecord.quota_snapshots) {
// Paid plan format
const snapshots = data.quota_snapshots;
const resetAt = parseResetTime(data.quota_reset_date);
const snapshots = toRecord(dataRecord.quota_snapshots);
const resetAt = parseResetTime(getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"));
const premiumQuota = formatGitHubQuotaSnapshot(snapshots.premium_interactions, resetAt);
const chatQuota = formatGitHubQuotaSnapshot(snapshots.chat, resetAt);
const completionsQuota = formatGitHubQuotaSnapshot(snapshots.completions, resetAt);
const quotas: Record<string, UsageQuota> = {};
if (shouldDisplayGitHubQuota(premiumQuota)) {
quotas.premium_interactions = premiumQuota;
}
if (shouldDisplayGitHubQuota(chatQuota)) {
quotas.chat = chatQuota;
}
if (shouldDisplayGitHubQuota(completionsQuota)) {
quotas.completions = completionsQuota;
}
return {
plan: data.copilot_plan,
resetDate: data.quota_reset_date,
quotas: {
chat: { ...formatGitHubQuotaSnapshot(snapshots.chat), resetAt },
completions: { ...formatGitHubQuotaSnapshot(snapshots.completions), resetAt },
premium_interactions: {
...formatGitHubQuotaSnapshot(snapshots.premium_interactions),
resetAt,
},
},
plan: inferGitHubPlanName(dataRecord, premiumQuota),
resetDate: getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"),
quotas,
};
} else if (data.monthly_quotas || data.limited_user_quotas) {
} else if (dataRecord.monthly_quotas || dataRecord.limited_user_quotas) {
// Free/limited plan format
const monthlyQuotas = data.monthly_quotas || {};
const usedQuotas = data.limited_user_quotas || {};
const resetAt = parseResetTime(data.limited_user_reset_date);
const monthlyQuotas = toRecord(dataRecord.monthly_quotas);
const usedQuotas = toRecord(dataRecord.limited_user_quotas);
const resetDate = getFieldValue(dataRecord, "limited_user_reset_date", "limitedUserResetDate");
const resetAt = parseResetTime(resetDate);
const quotas: Record<string, UsageQuota> = {};
const addLimitedQuota = (name: string) => {
const total = toNumber(getFieldValue(monthlyQuotas, name, name), 0);
const used = Math.max(0, toNumber(getFieldValue(usedQuotas, name, name), 0));
if (total <= 0) return null;
const clampedUsed = Math.min(used, total);
quotas[name] = {
used: clampedUsed,
total,
remaining: Math.max(total - clampedUsed, 0),
remainingPercentage: clampPercentage(((total - clampedUsed) / total) * 100),
unlimited: false,
resetAt,
};
return quotas[name];
};
const premiumQuota = addLimitedQuota("premium_interactions");
addLimitedQuota("chat");
addLimitedQuota("completions");
return {
plan: data.copilot_plan || data.access_type_sku,
resetDate: data.limited_user_reset_date,
quotas: {
chat: {
used: usedQuotas.chat || 0,
total: monthlyQuotas.chat || 0,
unlimited: false,
resetAt,
},
completions: {
used: usedQuotas.completions || 0,
total: monthlyQuotas.completions || 0,
unlimited: false,
resetAt,
},
},
plan: inferGitHubPlanName(dataRecord, premiumQuota),
resetDate,
quotas,
};
}
@@ -221,17 +262,103 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
}
}
function formatGitHubQuotaSnapshot(quota) {
if (!quota) return { used: 0, total: 0, unlimited: true };
function formatGitHubQuotaSnapshot(quota, resetAt: string | null = null): UsageQuota | null {
const source = toRecord(quota);
if (Object.keys(source).length === 0) return null;
const unlimited = source.unlimited === true;
const entitlement = toNumber(source.entitlement, Number.NaN);
const totalValue = toNumber(source.total, Number.NaN);
const remainingValue = toNumber(source.remaining, Number.NaN);
const usedValue = toNumber(source.used, Number.NaN);
const percentRemainingValue = toNumber(
getFieldValue(source, "percent_remaining", "percentRemaining"),
Number.NaN
);
let total = Number.isFinite(totalValue)
? Math.max(0, totalValue)
: Number.isFinite(entitlement)
? Math.max(0, entitlement)
: 0;
let remaining = Number.isFinite(remainingValue) ? Math.max(0, remainingValue) : undefined;
let used = Number.isFinite(usedValue) ? Math.max(0, usedValue) : undefined;
let remainingPercentage = Number.isFinite(percentRemainingValue)
? clampPercentage(percentRemainingValue)
: undefined;
if (used === undefined && total > 0 && remaining !== undefined) {
used = Math.max(total - remaining, 0);
}
if (remaining === undefined && total > 0 && used !== undefined) {
remaining = Math.max(total - used, 0);
}
if (remainingPercentage === undefined && total > 0 && remaining !== undefined) {
remainingPercentage = clampPercentage((remaining / total) * 100);
}
if (total <= 0 && remainingPercentage !== undefined) {
total = 100;
used = 100 - remainingPercentage;
remaining = remainingPercentage;
}
return {
used: quota.entitlement - quota.remaining,
total: quota.entitlement,
remaining: quota.remaining,
unlimited: quota.unlimited || false,
used: Math.max(0, used ?? 0),
total,
remaining,
remainingPercentage,
resetAt,
unlimited,
};
}
function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): string {
const rawPlan = getFieldValue(data, "copilot_plan", "copilotPlan");
const rawSku = getFieldValue(data, "access_type_sku", "accessTypeSku");
const planText = typeof rawPlan === "string" ? rawPlan.trim() : "";
const skuText = typeof rawSku === "string" ? rawSku.trim() : "";
const combined = `${skuText} ${planText}`.trim().toUpperCase();
const monthlyQuotas = toRecord(getFieldValue(data, "monthly_quotas", "monthlyQuotas"));
const premiumTotal =
premiumQuota?.total ||
toNumber(getFieldValue(monthlyQuotas, "premium_interactions", "premiumInteractions"), 0);
const chatTotal = toNumber(getFieldValue(monthlyQuotas, "chat", "chat"), 0);
if (
combined.includes("PRO+") ||
combined.includes("PRO_PLUS") ||
combined.includes("PROPLUS")
) {
return "Copilot Pro+";
}
if (combined.includes("ENTERPRISE")) return "Copilot Enterprise";
if (combined.includes("BUSINESS")) return "Copilot Business";
if (combined.includes("STUDENT")) return "Copilot Student";
if (combined.includes("FREE")) return "Copilot Free";
if (combined.includes("PRO")) return "Copilot Pro";
if (premiumTotal >= 1400) return "Copilot Pro+";
if (premiumTotal >= 900) return "Copilot Enterprise";
if (premiumTotal >= 250) {
if (combined.includes("INDIVIDUAL")) return "Copilot Pro";
return "Copilot Business";
}
if (premiumTotal > 0 || chatTotal === 50) return "Copilot Free";
if (skuText) {
const label = toDisplayLabel(skuText);
return label ? `Copilot ${label}` : "GitHub Copilot";
}
if (planText) {
const label = toDisplayLabel(planText);
return label ? `Copilot ${label}` : "GitHub Copilot";
}
return "GitHub Copilot";
}
/**
* Gemini CLI Usage (Google Cloud)
*/
+58 -15
View File
@@ -1,26 +1,69 @@
// Tool call helper functions for translator
// Generate unique tool call ID
const ALPHANUM9 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
// Generate unique tool call ID (default long form)
export function generateToolCallId() {
return `call_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
}
// Ensure all tool_calls have id field and arguments is string (some providers require it)
export function ensureToolCallIds(body) {
// Generate 9-char [a-zA-Z0-9] id for providers that require it (e.g. Mistral)
function generateToolCallId9(): string {
let s = "";
for (let i = 0; i < 9; i++) {
s += ALPHANUM9[Math.floor(Math.random() * ALPHANUM9.length)];
}
return s;
}
/** @param options.use9CharId - When true, normalize ids to 9-char [a-zA-Z0-9] (e.g. Mistral); when false, only fix type/arguments, leave ids as-is */
export function ensureToolCallIds(body, options?: { use9CharId?: boolean }) {
if (!body.messages || !Array.isArray(body.messages)) return body;
for (const msg of body.messages) {
if (msg.role === "assistant" && msg.tool_calls && Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
if (!tc.id) {
tc.id = generateToolCallId();
}
if (!tc.type) {
tc.type = "function";
}
// Ensure arguments is JSON string, not object
if (tc.function?.arguments && typeof tc.function.arguments !== "string") {
tc.function.arguments = JSON.stringify(tc.function.arguments);
const use9CharId = options?.use9CharId === true;
for (let i = 0; i < body.messages.length; i++) {
const msg = body.messages[i];
if (msg.role !== "assistant" || !msg.tool_calls || !Array.isArray(msg.tool_calls)) continue;
const used9 = new Set<string>();
const newIdsInOrder: string[] = [];
for (const tc of msg.tool_calls) {
if (!tc.type) {
tc.type = "function";
}
if (tc.function?.arguments && typeof tc.function.arguments !== "string") {
tc.function.arguments = JSON.stringify(tc.function.arguments);
}
if (use9CharId) {
let newId: string;
do {
newId = generateToolCallId9();
} while (used9.has(newId));
used9.add(newId);
newIdsInOrder.push(newId);
tc.id = newId;
} else {
// Leave id as-is, only ensure it exists for later tool message matching
const id =
tc.id != null && String(tc.id).trim() !== "" ? String(tc.id) : generateToolCallId();
tc.id = id;
newIdsInOrder.push(id);
}
}
// Tool responses (role "tool") follow in same order as tool_calls; set tool_call_id by index.
// Stop when we hit another assistant so we only link tool messages that immediately follow this one.
if (newIdsInOrder.length > 0) {
let idx = 0;
for (let j = i + 1; j < body.messages.length; j++) {
const later = body.messages[j];
if (later.role === "assistant") break;
if (later.role !== "tool") continue;
if (idx < newIdsInOrder.length) {
later.tool_call_id = newIdsInOrder[idx];
idx++;
}
}
}
+10 -3
View File
@@ -66,6 +66,7 @@ function normalizeOpenAIResponsesRequest(body) {
return normalized;
}
/** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */
// Translate request: source -> openai -> target
export function translateRequest(
sourceFormat,
@@ -75,9 +76,11 @@ export function translateRequest(
stream = true,
credentials = null,
provider = null,
reqLogger = null
reqLogger = null,
options?: { normalizeToolCallId?: boolean }
) {
let result = body;
const use9CharId = options?.normalizeToolCallId === true;
// Phase 2: Apply thinking budget control before normalization
result = applyThinkingBudget(result);
@@ -85,8 +88,8 @@ export function translateRequest(
// Normalize thinking config: remove if lastMessage is not user
normalizeThinkingConfig(result);
// Always ensure tool_calls have id (some providers require it)
ensureToolCallIds(result);
// Ensure tool_calls have id; optionally normalize to 9-char for providers like Mistral
ensureToolCallIds(result, { use9CharId });
// Fix missing tool responses (insert empty tool_result if needed)
fixMissingToolResponses(result);
@@ -140,6 +143,10 @@ export function translateRequest(
result = normalizeOpenAIResponsesRequest(result);
}
// Ensure unique tool_call ids on final payload (translators may have introduced duplicates)
ensureToolCallIds(result, { use9CharId });
fixMissingToolResponses(result);
return result;
}
+156 -19
View File
@@ -30,6 +30,8 @@ type StreamLogger = {
type StreamCompletePayload = {
status: number;
usage: unknown;
/** Minimal response body for call log (streaming: usage + note; non-streaming not used) */
responseBody?: unknown;
};
type StreamOptions = {
@@ -51,6 +53,8 @@ type TranslateState = ReturnType<typeof initState> & {
toolNameMap?: unknown;
usage?: unknown;
finishReason?: unknown;
/** Accumulated message content for call log response body */
accumulatedContent?: string;
};
function getOpenAIIntermediateChunks(value: unknown): unknown[] {
@@ -106,14 +110,21 @@ export function createSSEStream(options: StreamOptions = {}) {
let buffer = "";
let usage = null;
// State for translate mode
// State for translate mode (accumulatedContent for call log response body)
const state: TranslateState | null =
mode === STREAM_MODE.TRANSLATE
? { ...(initState(sourceFormat) as TranslateState), provider, toolNameMap }
? {
...(initState(sourceFormat) as TranslateState),
provider,
toolNameMap,
accumulatedContent: "",
}
: null;
// Track content length for usage estimation (both modes)
let totalContentLength = 0;
// Passthrough: accumulate content for call log response body
let passthroughAccumulatedContent = "";
// Guard against duplicate [DONE] events — ensures exactly one per stream
let doneSent = false;
@@ -201,9 +212,10 @@ export function createSSEStream(options: StreamOptions = {}) {
if (extracted) {
usage = extracted;
}
// Track content length from Responses format
// Track content length and accumulate for call log
if (parsed.delta && typeof parsed.delta === "string") {
totalContentLength += parsed.delta.length;
passthroughAccumulatedContent += parsed.delta;
}
} else if (isClaudeSSE) {
// Claude SSE: extract usage, track content, forward as-is
@@ -213,14 +225,23 @@ export function createSSEStream(options: StreamOptions = {}) {
// message_start carries input_tokens, message_delta carries output_tokens
if (!usage) usage = {};
if (extracted.prompt_tokens > 0) usage.prompt_tokens = extracted.prompt_tokens;
if (extracted.completion_tokens > 0) usage.completion_tokens = extracted.completion_tokens;
if (extracted.completion_tokens > 0)
usage.completion_tokens = extracted.completion_tokens;
if (extracted.total_tokens > 0) usage.total_tokens = extracted.total_tokens;
if (extracted.cache_read_input_tokens) usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
if (extracted.cache_creation_input_tokens) usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
if (extracted.cache_read_input_tokens)
usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
if (extracted.cache_creation_input_tokens)
usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
}
// Track content length and accumulate from Claude format
if (parsed.delta?.text) {
totalContentLength += parsed.delta.text.length;
passthroughAccumulatedContent += parsed.delta.text;
}
if (parsed.delta?.thinking) {
totalContentLength += parsed.delta.thinking.length;
passthroughAccumulatedContent += parsed.delta.thinking;
}
// Track content length from Claude format
if (parsed.delta?.text) totalContentLength += parsed.delta.text.length;
if (parsed.delta?.thinking) totalContentLength += parsed.delta.thinking.length;
} else {
// Chat Completions: full sanitization pipeline
parsed = sanitizeStreamingChunk(parsed);
@@ -246,6 +267,10 @@ export function createSSEStream(options: StreamOptions = {}) {
if (content && typeof content === "string") {
totalContentLength += content.length;
}
if (typeof delta?.content === "string")
passthroughAccumulatedContent += delta.content;
if (typeof delta?.reasoning_content === "string")
passthroughAccumulatedContent += delta.reasoning_content;
const extracted = extractUsage(parsed);
if (extracted) {
@@ -301,23 +326,45 @@ export function createSSEStream(options: StreamOptions = {}) {
continue;
}
// Track content length for estimation (from various formats)
// Include both regular content and reasoning/thinking content
// Track content length and accumulate for call log (from raw provider chunk, so content is never missed)
// Do this before translation so we capture content regardless of translator output shape
// Claude format
if (parsed.delta?.text) {
totalContentLength += parsed.delta.text.length;
const t = parsed.delta.text;
totalContentLength += t.length;
if (state?.accumulatedContent !== undefined && typeof t === "string")
state.accumulatedContent += t;
}
if (parsed.delta?.thinking) {
totalContentLength += parsed.delta.thinking.length;
const t = parsed.delta.thinking;
totalContentLength += t.length;
if (state?.accumulatedContent !== undefined && typeof t === "string")
state.accumulatedContent += t;
}
// OpenAI format
if (parsed.choices?.[0]?.delta?.content) {
totalContentLength += parsed.choices[0].delta.content.length;
const c = parsed.choices[0].delta.content;
if (typeof c === "string") {
totalContentLength += c.length;
if (state?.accumulatedContent !== undefined) state.accumulatedContent += c;
} else if (Array.isArray(c)) {
for (const part of c) {
if (part?.text && typeof part.text === "string") {
totalContentLength += part.text.length;
if (state?.accumulatedContent !== undefined)
state.accumulatedContent += part.text;
}
}
}
}
if (parsed.choices?.[0]?.delta?.reasoning_content) {
totalContentLength += parsed.choices[0].delta.reasoning_content.length;
const r = parsed.choices[0].delta.reasoning_content;
if (typeof r === "string") {
totalContentLength += r.length;
if (state?.accumulatedContent !== undefined) state.accumulatedContent += r;
}
}
// Gemini format - may have multiple parts
@@ -325,10 +372,30 @@ export function createSSEStream(options: StreamOptions = {}) {
for (const part of parsed.candidates[0].content.parts) {
if (part.text && typeof part.text === "string") {
totalContentLength += part.text.length;
if (state?.accumulatedContent !== undefined) state.accumulatedContent += part.text;
}
}
}
// Generic fallback: delta string, top-level content/text (e.g. some SSE payloads)
if (state?.accumulatedContent !== undefined) {
if (typeof (parsed as JsonRecord).delta === "string") {
const d = (parsed as JsonRecord).delta as string;
state.accumulatedContent += d;
totalContentLength += d.length;
}
if (typeof (parsed as JsonRecord).content === "string") {
const c = (parsed as JsonRecord).content as string;
state.accumulatedContent += c;
totalContentLength += c.length;
}
if (typeof (parsed as JsonRecord).text === "string") {
const t = (parsed as JsonRecord).text as string;
state.accumulatedContent += t;
totalContentLength += t.length;
}
}
// Extract usage
const extracted = extractUsage(parsed);
if (extracted) state.usage = extracted; // Keep original usage for logging
@@ -344,6 +411,9 @@ export function createSSEStream(options: StreamOptions = {}) {
if (translated?.length > 0) {
for (const item of translated) {
// Content for call log is accumulated only from parsed (above) to avoid double-counting;
// do not add again from item here.
// Filter empty chunks
if (!hasValuableContent(item, sourceFormat)) {
continue; // Skip this empty chunk
@@ -415,10 +485,30 @@ export function createSSEStream(options: StreamOptions = {}) {
status: "200 OK",
}).catch(() => {});
}
// Notify caller for call log persistence
// Notify caller for call log persistence (include full response body with accumulated content)
if (onComplete) {
try {
onComplete({ status: 200, usage });
const u = usage as Record<string, unknown> | null;
const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0);
const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0);
const content = passthroughAccumulatedContent.trim() || "";
const responseBody = {
choices: [
{
message: {
role: "assistant",
content,
},
},
],
usage: {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: prompt + completion,
},
_streamed: true,
};
onComplete({ status: 200, usage, responseBody });
} catch {}
}
return;
@@ -428,6 +518,33 @@ export function createSSEStream(options: StreamOptions = {}) {
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
// Extract usage from remaining buffer — if the usage-bearing event
// (e.g. response.completed) is the last SSE line, it ends up here
// in the flush handler where extractUsage was not called.
// Non-destructive merge: some providers send usage across multiple
// events (e.g. prompt_tokens in message_start, completion_tokens
// in message_delta). Direct assignment would lose earlier data.
const extracted = extractUsage(parsed);
if (extracted) {
if (!state.usage) {
state.usage = extracted;
} else {
if (extracted.prompt_tokens > 0)
state.usage.prompt_tokens = extracted.prompt_tokens;
if (extracted.completion_tokens > 0)
state.usage.completion_tokens = extracted.completion_tokens;
if (extracted.total_tokens > 0) state.usage.total_tokens = extracted.total_tokens;
if (extracted.cache_read_input_tokens > 0)
state.usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
if (extracted.cache_creation_input_tokens > 0)
state.usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
if (extracted.cached_tokens > 0)
state.usage.cached_tokens = extracted.cached_tokens;
if (extracted.reasoning_tokens > 0)
state.usage.reasoning_tokens = extracted.reasoning_tokens;
}
}
const translated = translateResponse(targetFormat, sourceFormat, parsed, state);
// Log OpenAI intermediate chunks
@@ -497,10 +614,30 @@ export function createSSEStream(options: StreamOptions = {}) {
status: "200 OK",
}).catch(() => {});
}
// Notify caller for call log persistence
// Notify caller for call log persistence (include full response body with accumulated content)
if (onComplete) {
try {
onComplete({ status: 200, usage: state?.usage });
const u = state?.usage as Record<string, unknown> | null | undefined;
const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0);
const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0);
const content = (state?.accumulatedContent ?? "").trim() || "";
const responseBody = {
choices: [
{
message: {
role: "assistant",
content,
},
},
],
usage: {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: prompt + completion,
},
_streamed: true,
};
onComplete({ status: 200, usage: state?.usage, responseBody });
} catch {}
}
} catch (error) {
+3 -1
View File
@@ -400,8 +400,10 @@ export function logUsage(provider, usage, model = null, connectionId = null, api
console.log(msg);
// Save to usage DB
// input = total input tokens (non-cached + cache_read + cache_creation)
// This ensures analytics show correct totals for heavily-cached requests
const tokens = {
input: inTokens,
input: inTokens + (cacheRead || 0) + (cacheCreation || 0),
output: outTokens,
cacheRead: cacheRead || 0,
cacheCreation: cacheCreation || 0,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.7.0",
"version": "2.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.7.0",
"version": "2.8.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.7.2",
"version": "2.8.2",
"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": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -1181,6 +1181,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const [config, setConfig] = useState(combo?.config || {});
const [showStrategyNudge, setShowStrategyNudge] = useState(false);
const strategyChangeMountedRef = useRef(false);
// Agent features (#399 / #401 / #454)
const [agentSystemMessage, setAgentSystemMessage] = useState<string>(combo?.system_message || "");
const [agentToolFilter, setAgentToolFilter] = useState<string>(combo?.tool_filter_regex || "");
const [agentContextCache, setAgentContextCache] = useState<boolean>(
!!combo?.context_cache_protection
);
// DnD state
const hasPricingForModel = useCallback(
@@ -1532,6 +1538,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
saveData.config = configToSave;
}
// Agent features (#399 / #401 / #454)
if (agentSystemMessage.trim()) saveData.system_message = agentSystemMessage.trim();
else delete saveData.system_message;
if (agentToolFilter.trim()) saveData.tool_filter_regex = agentToolFilter.trim();
else delete saveData.tool_filter_regex;
if (agentContextCache) saveData.context_cache_protection = true;
else delete saveData.context_cache_protection;
await onSave(saveData);
setSaving(false);
};
@@ -2052,6 +2066,72 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
)}
{/* Agent Features (#399 / #401 / #454) */}
<div className="flex flex-col gap-2 p-3 bg-black/[0.02] dark:bg-white/[0.02] rounded-lg border border-black/5 dark:border-white/5">
<div className="flex items-center gap-1.5 mb-1">
<span className="material-symbols-outlined text-[14px] text-primary">smart_toy</span>
<p className="text-xs font-medium">Agent Features</p>
<span className="text-[10px] text-text-muted">
optional, for agent/tool workflows
</span>
</div>
{/* System Message Override */}
<div>
<label className="text-[11px] font-medium text-text-muted block mb-0.5">
System Message Override
</label>
<textarea
rows={2}
value={agentSystemMessage}
onChange={(e) => setAgentSystemMessage(e.target.value)}
placeholder="Override the system prompt for all requests routed through this combo…"
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none resize-none"
/>
<p className="text-[10px] text-text-muted mt-0.5">
Replaces any system message sent by the client. Leave empty to pass through client
system messages.
</p>
</div>
{/* Tool Filter Regex */}
<div>
<label className="text-[11px] font-medium text-text-muted block mb-0.5">
Tool Filter Regex
</label>
<input
type="text"
value={agentToolFilter}
onChange={(e) => setAgentToolFilter(e.target.value)}
placeholder="e.g. ^(bash|computer)$"
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none font-mono"
/>
<p className="text-[10px] text-text-muted mt-0.5">
Only tools whose name matches this regex are forwarded to the provider. Leave empty
to forward all tools.
</p>
</div>
{/* Context Cache Protection */}
<div className="flex items-center justify-between gap-2">
<div>
<label className="text-[11px] font-medium text-text-muted block">
Context Cache Protection
</label>
<p className="text-[10px] text-text-muted">
Pins the provider/model across turns to preserve cache sessions. Internal tags are
stripped before forwarding to the provider.
</p>
</div>
<input
type="checkbox"
checked={agentContextCache}
onChange={(e) => setAgentContextCache(e.target.checked)}
className="accent-primary shrink-0"
/>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
@@ -39,10 +39,10 @@ export default function APIPageClient({ machineId }) {
const fetchSearchProviders = async () => {
try {
const res = await fetch("/v1/search");
const res = await fetch("/api/search/providers");
if (res.ok) {
const data = await res.json();
setSearchProviders(data.data || []);
setSearchProviders(data.providers || []);
}
} catch {
// Search endpoint may not be available
+119 -11
View File
@@ -1,27 +1,135 @@
"use client";
import { useState } from "react";
import { useState, useRef, useEffect } from "react";
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer";
import AuditLogTab from "./AuditLogTab";
import { useTranslations } from "next-intl";
const TIME_RANGES = [
{ label: "1h", hours: 1 },
{ label: "6h", hours: 6 },
{ label: "12h", hours: 12 },
{ label: "24h", hours: 24 },
];
const TAB_TO_LOG_TYPE: Record<string, string> = {
"request-logs": "request-logs",
"proxy-logs": "proxy-logs",
"audit-logs": "call-logs",
console: "call-logs",
};
export default function LogsPage() {
const [activeTab, setActiveTab] = useState("request-logs");
const [showExport, setShowExport] = useState(false);
const [exporting, setExporting] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const t = useTranslations("logs");
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowExport(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
async function handleExport(hours: number) {
setExporting(true);
setShowExport(false);
try {
const logType = TAB_TO_LOG_TYPE[activeTab] || "call-logs";
const res = await fetch(`/api/logs/export?hours=${hours}&type=${logType}`);
if (!res.ok) throw new Error("Export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `omniroute-${logType}-${hours}h-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed:", err);
} finally {
setExporting(false);
}
}
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "request-logs", label: t("requestLogs") },
{ value: "proxy-logs", label: t("proxyLogs") },
{ value: "audit-logs", label: t("auditLog") },
{ value: "console", label: t("console") },
]}
value={activeTab}
onChange={setActiveTab}
/>
<div className="flex items-center justify-between gap-4 flex-wrap">
<SegmentedControl
options={[
{ value: "request-logs", label: t("requestLogs") },
{ value: "proxy-logs", label: t("proxyLogs") },
{ value: "audit-logs", label: t("auditLog") },
{ value: "console", label: t("console") },
]}
value={activeTab}
onChange={setActiveTab}
/>
<div className="relative" ref={dropdownRef}>
<button
id="export-logs-btn"
onClick={() => setShowExport(!showExport)}
disabled={exporting}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg
bg-[var(--card-bg,#1e1e2e)] border border-[var(--border,#333)]
text-[var(--text-secondary,#aaa)] hover:text-[var(--text-primary,#fff)]
hover:border-[var(--accent,#7c3aed)] transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path
d="M8 2v8m0 0l-3-3m3 3l3-3M3 12h10"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{exporting ? "Exporting..." : "Export"}
</button>
{showExport && (
<div
className="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-lg
bg-[var(--card-bg,#1e1e2e)] border border-[var(--border,#333)]
shadow-xl overflow-hidden animate-in fade-in"
>
<div className="px-3 py-2 text-xs text-[var(--text-muted,#666)] border-b border-[var(--border,#333)] font-medium">
Time Range
</div>
{TIME_RANGES.map((range) => (
<button
key={range.hours}
id={`export-${range.hours}h-btn`}
onClick={() => handleExport(range.hours)}
className="w-full px-3 py-2 text-sm text-left hover:bg-[var(--hover-bg,#2a2a3e)]
text-[var(--text-secondary,#aaa)] hover:text-[var(--text-primary,#fff)]
transition-colors flex items-center justify-between"
>
<span>Last {range.label}</span>
<span className="text-xs text-[var(--text-muted,#666)]">
{range.hours === 24 ? "default" : ""}
</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Content */}
{activeTab === "request-logs" && <RequestLoggerV2 />}
@@ -0,0 +1,406 @@
"use client";
import { useState, useEffect, useRef } from "react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { Card, Button, Select, Badge } from "@/shared/components";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
interface SearchProvider {
id: string;
name: string;
status: "active" | "no_credentials";
cost_per_query: number;
}
interface SearchResult {
title: string;
url: string;
snippet: string;
score?: number;
date?: string;
}
interface SearchResponse {
id: string;
provider: string;
results: SearchResult[];
query: string;
answer: string | null;
cached: boolean;
usage: {
queries_used: number;
search_cost_usd: number;
};
metrics: {
response_time_ms: number;
upstream_latency_ms: number;
total_results_available: number | null;
};
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
export default function SearchPlayground() {
const t = useTranslations("search");
const [providers, setProviders] = useState<SearchProvider[]>([]);
const [selectedProvider, setSelectedProvider] = useState("");
const [requestBody, setRequestBody] = useState(
JSON.stringify(
{
query: "latest AI developments",
max_results: 5,
search_type: "web",
},
null,
2
)
);
const [response, setResponse] = useState<SearchResponse | null>(null);
const [rawResponse, setRawResponse] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [duration, setDuration] = useState(0);
const [statusCode, setStatusCode] = useState(0);
const [showJson, setShowJson] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
fetch("/api/search/providers")
.then((res) => res.json())
.then((data) => {
const allProviders = data.providers || [];
setProviders(allProviders);
const firstActive = allProviders.find((p: SearchProvider) => p.status === "active");
if (firstActive) setSelectedProvider(firstActive.id);
})
.catch(() => {});
}, []);
const handleSend = async () => {
setLoading(true);
setError("");
setResponse(null);
setRawResponse("");
setStatusCode(0);
const controller = new AbortController();
abortRef.current = controller;
const timeout = setTimeout(() => controller.abort(), 15_000);
const start = Date.now();
try {
let body: any;
try {
body = JSON.parse(requestBody);
} catch {
setError("Invalid JSON in request body");
setLoading(false);
clearTimeout(timeout);
return;
}
if (selectedProvider) body.provider = selectedProvider;
const res = await fetch("/api/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
setDuration(Date.now() - start);
setStatusCode(res.status);
const data = await res.json();
setRawResponse(JSON.stringify(data, null, 2));
if (res.ok) {
setResponse(data);
} else {
setError(data.error?.message || data.error || `Error ${res.status}`);
}
} catch (err: any) {
setDuration(Date.now() - start);
if (err.name === "AbortError") {
setError("Request timed out (15s)");
} else {
setError(err.message || "Network error");
}
} finally {
setLoading(false);
clearTimeout(timeout);
}
};
const handleCancel = () => {
abortRef.current?.abort();
};
const getScoreColor = (score: number) => {
if (score >= 0.9) return "text-success";
if (score >= 0.7) return "text-warning";
return "text-error";
};
const getScoreBg = (score: number) => {
if (score >= 0.9) return "bg-green-500/10";
if (score >= 0.7) return "bg-yellow-500/10";
return "bg-red-500/10";
};
const noProviders = providers.filter((p) => p.status === "active").length === 0;
const editorTheme =
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
? "vs-dark"
: "light";
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">upload</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<Badge variant="info" size="sm">
POST /v1/search
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => navigator.clipboard.writeText(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() =>
setRequestBody(
JSON.stringify(
{
query: "latest AI developments",
max_results: 5,
search_type: "web",
},
null,
2
)
)
}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme={editorTheme}
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<Select
value={selectedProvider}
onChange={(e: any) => setSelectedProvider(e.target.value)}
options={providers.map((p) => ({
value: p.id,
label: `${p.name}${p.status === "no_credentials" ? " (no key)" : ""}`,
}))}
className="w-full"
/>
</div>
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
</Button>
) : (
<Button
icon="search"
onClick={handleSend}
disabled={noProviders || !requestBody.trim()}
>
{t("webSearch")}
</Button>
)}
</div>
{noProviders && <p className="text-xs text-text-muted">{t("noSearchProviders")}</p>}
</div>
</Card>
{/* Response panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
{statusCode > 0 && (
<>
<Badge variant={statusCode < 400 ? "success" : "error"} size="sm">
{statusCode}
</Badge>
<span className="text-xs text-text-muted">{duration}ms</span>
</>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
{response && (
<div className="flex gap-1">
<button
className={`text-xs px-3 py-1 rounded-md ${
!showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(false)}
>
{t("formatted")}
</button>
<button
className={`text-xs px-3 py-1 rounded-md ${
showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(true)}
>
{t("rawJson")}
</button>
</div>
)}
</div>
<div className="border border-border rounded-lg overflow-hidden min-h-[400px]">
{loading && (
<div className="flex items-center justify-center h-[400px]">
<span className="material-symbols-outlined text-[24px] text-primary animate-spin">
progress_activity
</span>
</div>
)}
{error && !loading && (
<div className="p-4">
<div className="text-error text-sm">{error}</div>
</div>
)}
{response && !showJson && !loading && (
<div className="p-4 space-y-3">
{/* Meta bar */}
<div className="flex justify-between items-center p-2 bg-bg-alt rounded-lg">
<div className="flex items-center gap-3 text-xs text-text-muted">
<span>
{response.results.length} {t("searchResults").toLowerCase()}
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
{response.provider}
</span>
<span>${response.usage?.search_cost_usd?.toFixed(4)}</span>
<span>{formatBytes(rawResponse.length)}</span>
</div>
<span
className={`text-xs flex items-center gap-1 ${
response.cached ? "text-success" : "text-warning"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
response.cached ? "bg-success" : "bg-warning"
}`}
/>
{response.cached ? t("cacheHit") : t("cacheMiss")}
</span>
</div>
{/* Results */}
{response.results.map((r, i) => (
<div
key={i}
className="border-l-[3px] border-l-primary p-3 bg-surface rounded-r-lg border border-border"
>
<div className="flex justify-between items-start">
<span className="text-sm font-medium text-text-main">
{i + 1}. {r.title}
</span>
{r.score != null && (
<span
className={`text-[10px] px-2 py-0.5 rounded-md ml-2 whitespace-nowrap ${getScoreBg(r.score)} ${getScoreColor(r.score)}`}
>
{r.score.toFixed(2)}
</span>
)}
</div>
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent text-[11px] block mt-0.5"
>
{r.url}
</a>
<p className="text-xs text-text-muted mt-1 leading-relaxed">{r.snippet}</p>
</div>
))}
</div>
)}
{response && showJson && !loading && (
<Editor
height="400px"
defaultLanguage="json"
value={rawResponse}
theme={editorTheme}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
}}
/>
)}
{!loading && !error && !response && (
<div className="flex items-center justify-center h-[400px] text-text-muted text-sm">
{t("emptyState")}
</div>
)}
</div>
</div>
</Card>
</div>
);
}
+305 -279
View File
@@ -5,6 +5,9 @@ import { Card, Button, Select, Badge } from "@/shared/components";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
const SearchPlayground = dynamic(() => import("./SearchPlayground"), {
ssr: false,
});
interface ModelInfo {
id: string;
@@ -27,6 +30,7 @@ const ENDPOINT_OPTIONS = [
{ value: "video", label: "Video Generation" },
{ value: "music", label: "Music Generation" },
{ value: "rerank", label: "Rerank" },
{ value: "search", label: "Web Search" },
];
const DEFAULT_BODIES: Record<string, object> = {
@@ -83,6 +87,11 @@ const DEFAULT_BODIES: Record<string, object> = {
],
top_n: 2,
},
search: {
query: "latest AI developments",
max_results: 5,
search_type: "web",
},
};
const ENDPOINT_PATHS: Record<string, string> = {
@@ -95,6 +104,7 @@ const ENDPOINT_PATHS: Record<string, string> = {
video: "/v1/videos/generations",
music: "/v1/music/generations",
rerank: "/v1/rerank",
search: "/v1/search",
};
// Models known to support vision (image input)
@@ -189,6 +199,7 @@ export default function PlaygroundPage() {
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadedImages, setUploadedImages] = useState<string[]>([]); // base64 URIs for vision
const isSearchEndpoint = selectedEndpoint === "search";
const isTranscriptionEndpoint = selectedEndpoint === "transcription";
const isChatEndpoint = selectedEndpoint === "chat";
const isImageEndpoint = selectedEndpoint === "images";
@@ -419,33 +430,7 @@ export default function PlaygroundPage() {
{/* Controls */}
<Card>
<div className="p-4 flex flex-col sm:flex-row items-end gap-4">
{/* Provider */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Provider
</label>
<Select
value={selectedProvider}
onChange={(e: any) => handleProviderChange(e.target.value)}
options={providers}
className="w-full"
/>
</div>
{/* Model */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Model
</label>
<Select
value={selectedModel}
onChange={(e: any) => handleModelChange(e.target.value)}
options={filteredModels}
className="w-full"
/>
</div>
{/* Endpoint */}
{/* Endpoint — always first */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Endpoint
@@ -458,274 +443,315 @@ export default function PlaygroundPage() {
/>
</div>
{/* Send Button */}
<div className="shrink-0">
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
</Button>
) : (
<Button
icon="send"
onClick={handleSend}
disabled={
(!requestBody.trim() && !isTranscriptionEndpoint) ||
(!selectedModel && !isTranscriptionEndpoint)
}
>
Send
</Button>
)}
</div>
{/* Provider — hidden in search mode */}
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Provider
</label>
<Select
value={selectedProvider}
onChange={(e: any) => handleProviderChange(e.target.value)}
options={providers}
className="w-full"
/>
</div>
)}
{/* Model — hidden in search mode */}
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Model
</label>
<Select
value={selectedModel}
onChange={(e: any) => handleModelChange(e.target.value)}
options={filteredModels}
className="w-full"
/>
</div>
)}
{/* Send Button — hidden in search mode (SearchPlayground has its own) */}
{!isSearchEndpoint && (
<div className="shrink-0">
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
</Button>
) : (
<Button
icon="send"
onClick={handleSend}
disabled={
(!requestBody.trim() && !isTranscriptionEndpoint) ||
(!selectedModel && !isTranscriptionEndpoint)
}
>
Send
</Button>
)}
</div>
)}
</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>
{/* Search mode — isolated sub-component */}
{isSearchEndpoint ? (
<SearchPlayground />
) : (
<>
{/* 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>
)}
{!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"
/>
{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((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"
onClick={() => setUploadedImages([])}
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
>
<span className="material-symbols-outlined text-[16px]">close</span>
Clear all
</button>
</div>
))}
)}
</div>
)}
</div>
</Card>
)}
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
upload
</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<Badge variant="info" size="sm">
POST {ENDPOINT_PATHS[selectedEndpoint]}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setUploadedImages([])}
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
onClick={() => handleCopy(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
Clear all
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() => {
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
if ("model" in template) (template as any).model = selectedModel;
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
)}
</div>
)}
</div>
</Card>
)}
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
upload
</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<Badge variant="info" size="sm">
POST {ENDPOINT_PATHS[selectedEndpoint]}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() => {
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
if ("model" in template) (template as any).model = selectedModel;
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</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"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
</div>
</Card>
{/* Response Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
{responseStatus !== null && (
<Badge
variant={responseStatus >= 200 && responseStatus < 300 ? "success" : "error"}
size="sm"
>
{responseStatus}
</Badge>
)}
{responseDuration !== null && (
<span className="text-xs text-text-muted">{responseDuration}ms</span>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(responseBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
{audioUrl ? (
<div className="p-4 space-y-3">
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
<a
href={audioUrl}
download="speech.mp3"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
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
{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="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 className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
) : (
<Editor
height="400px"
defaultLanguage="json"
value={responseBody}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
)}
</div>
</div>
</Card>
{/* Response Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
{responseStatus !== null && (
<Badge
variant={
responseStatus >= 200 && responseStatus < 300 ? "success" : "error"
}
size="sm"
>
{responseStatus}
</Badge>
)}
{responseDuration !== null && (
<span className="text-xs text-text-muted">{responseDuration}ms</span>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(responseBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
{audioUrl ? (
<div className="p-4 space-y-3">
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
<a
href={audioUrl}
download="speech.mp3"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
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"
defaultLanguage="json"
value={responseBody}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
)}
</div>
</div>
</Card>
</div>
</Card>
</div>
</>
)}
</div>
);
}
@@ -286,9 +286,13 @@ export default function ProviderDetailPage() {
if (res.ok) {
await fetchConnections();
setShowEditModal(false);
return null;
}
const data = await res.json().catch(() => ({}));
return data.error?.message || data.error || t("failedSaveConnection");
} catch (error) {
console.log("Error updating connection:", error);
return t("failedSaveConnectionRetry");
}
};
@@ -1473,6 +1477,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
const [editingModelId, setEditingModelId] = useState<string | null>(null);
const [editingApiFormat, setEditingApiFormat] = useState("chat-completions");
const [editingEndpoints, setEditingEndpoints] = useState<string[]>(["chat"]);
const [editingNormalizeToolCallId, setEditingNormalizeToolCallId] = useState(false);
const [savingModelId, setSavingModelId] = useState<string | null>(null);
const fetchCustomModels = useCallback(async () => {
@@ -1544,12 +1549,14 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
? model.supportedEndpoints
: ["chat"]
);
setEditingNormalizeToolCallId(Boolean(model.normalizeToolCallId));
};
const cancelEdit = () => {
setEditingModelId(null);
setEditingApiFormat("chat-completions");
setEditingEndpoints(["chat"]);
setEditingNormalizeToolCallId(false);
setSavingModelId(null);
};
@@ -1573,6 +1580,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
source: model?.source || "manual",
apiFormat: editingApiFormat,
supportedEndpoints: editingEndpoints,
normalizeToolCallId: editingNormalizeToolCallId,
}),
});
@@ -1734,6 +1742,14 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
🔊 Audio
</span>
)}
{model.normalizeToolCallId && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-500/15 text-slate-400 font-medium"
title="9-char tool call ID (Mistral)"
>
ID×9
</span>
)}
</div>
{editingModelId === model.id && (
@@ -1786,6 +1802,16 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
))}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-text-main cursor-pointer">
<input
type="checkbox"
checked={editingNormalizeToolCallId}
onChange={(e) => setEditingNormalizeToolCallId(e.target.checked)}
className="rounded border-border"
/>
Normalize Tool Call ID (9 chars, Mistral)
</label>
</div>
<div className="mt-3 flex items-center gap-2">
<Button
@@ -2618,10 +2644,14 @@ function AddApiKeyModal({
onClose,
}) {
const t = useTranslations("providers");
const isBailian = provider === "bailian-coding-plan";
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
const [formData, setFormData] = useState({
name: "",
apiKey: "",
priority: 1,
baseUrl: isBailian ? defaultBailianUrl : "",
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
@@ -2652,6 +2682,16 @@ function AddApiKeyModal({
setSaving(true);
setSaveError(null);
try {
let validatedBailianBaseUrl = null;
if (isBailian) {
const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBailianUrl);
if (checked.error) {
setSaveError(checked.error);
return;
}
validatedBailianBaseUrl = checked.value;
}
let isValid = false;
try {
setValidating(true);
@@ -2675,12 +2715,22 @@ function AddApiKeyModal({
return;
}
const error = await onSave({
const payload = {
name: formData.name,
apiKey: formData.apiKey,
priority: formData.priority,
testStatus: "active",
});
providerSpecificData: undefined,
};
// Include baseUrl in providerSpecificData for bailian-coding-plan
if (isBailian) {
payload.providerSpecificData = {
baseUrl: validatedBailianBaseUrl,
};
}
const error = await onSave(payload);
if (error) {
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
}
@@ -2751,6 +2801,15 @@ function AddApiKeyModal({
setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })
}
/>
{isBailian && (
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={defaultBailianUrl}
hint="Optional: Custom base URL for bailian-coding-plan provider"
/>
)}
<div className="flex gap-2">
<Button
onClick={handleSubmit}
@@ -2778,6 +2837,19 @@ AddApiKeyModal.propTypes = {
onClose: PropTypes.func.isRequired,
};
function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) {
const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl;
try {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return { value: null, error: "Base URL must use http or https" };
}
return { value, error: null };
} catch {
return { value: null, error: "Base URL must be a valid URL" };
}
}
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
@@ -2785,22 +2857,29 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
priority: 1,
apiKey: "",
healthCheckInterval: 60,
baseUrl: "",
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
const [newExtraKey, setNewExtraKey] = useState("");
const isBailian = connection?.provider === "bailian-coding-plan";
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
useEffect(() => {
if (connection) {
const existingBaseUrl = connection.providerSpecificData?.baseUrl;
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
apiKey: "",
healthCheckInterval: connection.healthCheckInterval ?? 60,
baseUrl: existingBaseUrl || (isBailian ? defaultBailianUrl : ""),
});
// Load existing extra keys from providerSpecificData
const existing = connection.providerSpecificData?.extraApiKeys;
@@ -2808,8 +2887,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
setNewExtraKey("");
setTestResult(null);
setValidationResult(null);
setSaveError(null);
}
}, [connection]);
}, [connection, isBailian]);
const handleTest = async () => {
if (!connection?.provider) return;
@@ -2855,12 +2935,24 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
const handleSubmit = async () => {
setSaving(true);
setSaveError(null);
try {
const updates: any = {
name: formData.name,
priority: formData.priority,
healthCheckInterval: formData.healthCheckInterval,
};
let validatedBailianBaseUrl = null;
if (isBailian) {
const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBailianUrl);
if (checked.error) {
setSaveError(checked.error);
return;
}
validatedBailianBaseUrl = checked.value;
}
if (!isOAuth && formData.apiKey) {
updates.apiKey = formData.apiKey;
let isValid = validationResult === "success";
@@ -2892,14 +2984,21 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
updates.rateLimitedUntil = null;
}
}
// Persist extra API keys in providerSpecificData
// Persist extra API keys and baseUrl in providerSpecificData
if (!isOAuth) {
updates.providerSpecificData = {
...(connection.providerSpecificData || {}),
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
};
// Update baseUrl for bailian-coding-plan
if (isBailian) {
updates.providerSpecificData.baseUrl = validatedBailianBaseUrl;
}
}
const error = await onSave(updates);
if (error) {
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
}
await onSave(updates);
} finally {
setSaving(false);
}
@@ -2980,9 +3079,24 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
{saveError && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{saveError}
</div>
)}
</>
)}
{isBailian && (
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={defaultBailianUrl}
hint="Custom base URL for bailian-coding-plan provider"
/>
)}
{/* T07: Extra API Keys for round-robin rotation */}
{!isOAuth && (
<div className="flex flex-col gap-2">
@@ -0,0 +1,297 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
const SearchForm = dynamic(() => import("./components/SearchForm"), {
ssr: false,
});
const SearchHistory = dynamic(() => import("./components/SearchHistory"), {
ssr: false,
});
const ResultsPanel = dynamic(() => import("./components/ResultsPanel"), {
ssr: false,
});
const ProviderComparison = dynamic(() => import("./components/ProviderComparison"), { ssr: false });
const RerankPanel = dynamic(() => import("./components/RerankPanel"), {
ssr: false,
});
import type { SearchFormData } from "./components/SearchForm";
import type { CompareResult } from "./components/ProviderComparison";
interface SearchProvider {
id: string;
name: string;
status: "active" | "no_credentials";
cost_per_query: number;
}
interface SearchResult {
title: string;
url: string;
snippet: string;
score?: number;
}
interface SearchResponse {
id: string;
provider: string;
query: string;
results: SearchResult[];
cached: boolean;
usage: {
queries_used: number;
search_cost_usd: number;
};
metrics: {
response_time_ms: number;
upstream_latency_ms: number;
total_results_available: number | null;
};
}
export default function SearchToolsClient() {
const t = useTranslations("search");
const [providers, setProviders] = useState<SearchProvider[]>([]);
const [response, setResponse] = useState<SearchResponse | null>(null);
const [rawJson, setRawJson] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [statusCode, setStatusCode] = useState(0);
const [duration, setDuration] = useState(0);
const [lastQuery, setLastQuery] = useState<SearchFormData | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [showCompare, setShowCompare] = useState(false);
const [compareLoading, setCompareLoading] = useState(false);
const [compareResults, setCompareResults] = useState<CompareResult[]>([]);
const [initialCompareResult, setInitialCompareResult] = useState<CompareResult | null>(null);
const [showRerank, setShowRerank] = useState(false);
useEffect(() => {
fetch("/api/search/providers")
.then((res) => res.json())
.then((data) => setProviders(data.providers || []))
.catch(() => {});
}, []);
const handleSearch = async (formData: SearchFormData) => {
setLoading(true);
setError("");
setResponse(null);
setRawJson("");
setStatusCode(0);
setShowCompare(false);
setShowRerank(false);
setCompareResults([]);
const controller = new AbortController();
abortRef.current = controller;
const timeout = setTimeout(() => controller.abort(), 15_000);
const start = Date.now();
try {
const body: any = { ...formData };
if (!body.provider) delete body.provider;
const res = await fetch("/api/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
setDuration(Date.now() - start);
setStatusCode(res.status);
const data = await res.json();
setRawJson(JSON.stringify(data, null, 2));
setLastQuery(formData);
if (res.ok) {
setResponse(data);
} else {
setError(data.error?.message || data.error || `Error ${res.status}`);
}
} catch (err: any) {
setDuration(Date.now() - start);
if (err.name === "AbortError") {
setError("Request timed out (15s)");
} else {
setError(err.message || "Network error");
}
} finally {
setLoading(false);
clearTimeout(timeout);
}
};
const handleCompare = async () => {
if (!response || !lastQuery) return;
const usedProvider = response.provider;
const otherProviders = providers
.filter((p) => p.status === "active" && p.id !== usedProvider)
.map((p) => p.id);
if (otherProviders.length === 0) return;
const initial: CompareResult = {
provider: usedProvider,
latency: response.metrics.response_time_ms,
cost: response.usage.search_cost_usd,
resultCount: response.results.length,
responseSize: rawJson.length,
urls: response.results.map((r) => r.url),
};
setInitialCompareResult(initial);
setShowCompare(true);
setCompareLoading(true);
const promises = otherProviders.map(async (providerId) => {
const start = Date.now();
try {
const res = await fetch("/api/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...lastQuery, provider: providerId }),
});
const data = await res.json();
const elapsed = Date.now() - start;
if (!res.ok) {
return {
provider: providerId,
latency: elapsed,
cost: 0,
resultCount: 0,
responseSize: 0,
urls: [],
error: data.error?.message || `Error ${res.status}`,
} as CompareResult;
}
const respJson = JSON.stringify(data);
return {
provider: providerId,
latency: data.metrics?.response_time_ms || elapsed,
cost: data.usage?.search_cost_usd || 0,
resultCount: data.results?.length || 0,
responseSize: respJson.length,
urls: (data.results || []).map((r: any) => r.url),
} as CompareResult;
} catch (err: any) {
return {
provider: providerId,
latency: Date.now() - start,
cost: 0,
resultCount: 0,
responseSize: 0,
urls: [],
error: err.message,
} as CompareResult;
}
});
const results = await Promise.allSettled(promises);
setCompareResults(
results.map((r) =>
r.status === "fulfilled"
? r.value
: {
provider: "unknown",
latency: 0,
cost: 0,
resultCount: 0,
responseSize: 0,
urls: [],
error: "Failed",
}
)
);
setCompareLoading(false);
};
const handleCancel = () => {
abortRef.current?.abort();
};
const handleHistoryReplay = (entry: any) => {
handleSearch({
query: entry.query,
provider: entry.provider || "",
search_type: entry.filters?.search_type || "web",
max_results: entry.filters?.max_results || 5,
...entry.filters,
});
};
return (
<div className="flex h-[calc(100vh-120px)]">
<div className="w-[340px] flex-shrink-0 bg-bg-alt border-r border-border overflow-y-auto flex flex-col">
<SearchForm
onSearch={handleSearch}
loading={loading}
onCancel={handleCancel}
providers={providers}
/>
<SearchHistory onReplay={handleHistoryReplay} />
</div>
<div className="flex-1 overflow-y-auto">
<ResultsPanel
response={response}
rawJson={rawJson}
loading={loading}
error={error}
statusCode={statusCode}
duration={duration}
/>
{response && (
<div className="px-4 py-2 flex gap-2">
<button
className="flex-1 bg-surface border border-border rounded-lg p-2 text-center hover:border-accent/30 transition-colors flex items-center justify-center gap-2"
onClick={handleCompare}
disabled={compareLoading}
>
<span className="text-accent text-sm">&#8693;</span>
<span className="text-xs text-text-muted">{t("compareProviders")}</span>
</button>
<button
className="flex-1 bg-surface border border-border rounded-lg p-2 text-center hover:border-primary/30 transition-colors flex items-center justify-center gap-2"
onClick={() => setShowRerank(!showRerank)}
>
<span className="text-primary text-sm">&#8645;</span>
<span className="text-xs text-text-muted">{t("rerankResults")}</span>
</button>
</div>
)}
{showCompare && initialCompareResult && (
<div className="px-4 pb-3">
<ProviderComparison
initialProvider={response!.provider}
initialResult={initialCompareResult}
otherResults={compareResults}
loading={compareLoading}
onClose={() => setShowCompare(false)}
/>
</div>
)}
{showRerank && response && (
<div className="px-4 pb-3">
<RerankPanel
query={response.query}
results={response.results}
onClose={() => setShowRerank(false)}
/>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,168 @@
"use client";
import { useTranslations } from "next-intl";
export interface CompareResult {
provider: string;
latency: number;
cost: number;
resultCount: number;
responseSize: number;
urls: string[];
error?: string;
}
interface ProviderComparisonProps {
initialProvider: string;
initialResult: CompareResult;
otherResults: CompareResult[];
loading: boolean;
onClose: () => void;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
export default function ProviderComparison({
initialProvider,
initialResult,
otherResults,
loading,
onClose,
}: ProviderComparisonProps) {
const t = useTranslations("search");
const allResults = [initialResult, ...otherResults];
const initialUrls = new Set(initialResult.urls);
const valid = allResults.filter((r) => !r.error);
const latencies = valid.map((r) => r.latency);
const costs = valid.map((r) => r.cost);
const sizes = valid.map((r) => r.responseSize);
const bestLatency = Math.min(...latencies);
const worstLatency = Math.max(...latencies);
const bestCost = Math.min(...costs);
const worstCost = Math.max(...costs);
const bestSize = Math.min(...sizes);
const worstSize = Math.max(...sizes);
const getLatencyColor = (val: number) => {
if (val === bestLatency) return "text-success font-medium";
if (val === worstLatency) return "text-warning";
return "text-text-main";
};
const getCostColor = (val: number) => {
if (val === bestCost) return "text-success font-medium";
if (val === worstCost) return "text-warning";
return "text-text-main";
};
const getSizeColor = (val: number) => {
if (val === bestSize) return "text-success font-medium";
if (val === worstSize) return "text-warning";
return "text-text-main";
};
return (
<div className="bg-surface border border-accent/20 rounded-lg overflow-hidden">
<div className="flex justify-between items-center px-4 py-2.5 bg-accent/5 border-b border-accent/15">
<span className="text-xs font-semibold text-accent flex items-center gap-1.5">
{t("compareProviders")}
</span>
<button onClick={onClose} className="text-text-muted text-xs hover:text-text-main">
</button>
</div>
<div className="p-3 overflow-x-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="material-symbols-outlined text-[20px] text-accent animate-spin">
progress_activity
</span>
<span className="text-xs text-text-muted ml-2">{t("compareProviders")}...</span>
</div>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="text-left p-2 text-text-muted font-semibold" />
{allResults.map((r) => (
<th
key={r.provider}
className={`text-center p-2 font-semibold ${
r.provider === initialProvider ? "text-primary" : "text-text-muted"
}`}
>
{r.provider.replace("-search", "")}
{r.provider === initialProvider && " ✓"}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">{t("latency")}</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getLatencyColor(r.latency)}`}
>
{r.error ? "Error" : `${r.latency}ms`}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">{t("cost")}</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getCostColor(r.cost)}`}
>
{r.error ? "Error" : `$${r.cost.toFixed(4)}`}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">{t("results")}</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : "text-text-main"}`}
>
{r.error ? "Error" : r.resultCount}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">Size</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getSizeColor(r.responseSize)}`}
>
{r.error ? "Error" : formatBytes(r.responseSize)}
</td>
))}
</tr>
<tr>
<td className="p-2 text-text-muted">{t("urlOverlap")}</td>
{allResults.map((r) => (
<td key={r.provider} className="text-center p-2 text-text-main">
{r.provider === initialProvider
? "—"
: r.error
? "Error"
: `${r.urls.filter((u) => initialUrls.has(u)).length}/${r.resultCount}`}
</td>
))}
</tr>
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,152 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button, Select } from "@/shared/components";
interface RerankResult {
index: number;
originalIndex: number;
title: string;
snippet: string;
score: number;
delta: number;
}
interface RerankPanelProps {
query: string;
results: { title: string; snippet: string; url: string }[];
onClose: () => void;
}
export default function RerankPanel({ query, results, onClose }: RerankPanelProps) {
const t = useTranslations("search");
const [models, setModels] = useState<{ value: string; label: string }[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [reranked, setReranked] = useState<RerankResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
fetch("/v1/models")
.then((res) => res.json())
.then((data) => {
const rerankModels = (data?.data || [])
.filter((m: any) => m.id.toLowerCase().includes("rerank"))
.map((m: any) => ({ value: m.id, label: m.id }));
setModels(rerankModels);
if (rerankModels.length > 0) setSelectedModel(rerankModels[0].value);
})
.catch(() => {});
}, []);
const handleRerank = async () => {
setLoading(true);
setError("");
try {
const res = await fetch("/api/v1/rerank", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: selectedModel,
query,
documents: results.map((r) => r.snippet),
top_n: results.length,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error?.message || data.error || `Error ${res.status}`);
return;
}
const rerankedResults: RerankResult[] = (data.results || []).map(
(r: any, newIndex: number) => {
const origIndex = r.index;
return {
index: newIndex,
originalIndex: origIndex,
title: results[origIndex]?.title || "",
snippet: results[origIndex]?.snippet || "",
score: r.relevance_score,
delta: origIndex - newIndex,
};
}
);
setReranked(rerankedResults);
} catch (err: any) {
setError(err.message || "Rerank failed");
} finally {
setLoading(false);
}
};
const getDeltaDisplay = (delta: number) => {
if (delta > 0) return <span className="text-success">{delta}</span>;
if (delta < 0) return <span className="text-error">{Math.abs(delta)}</span>;
return <span className="text-text-muted">=</span>;
};
const noModels = models.length === 0;
return (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border">
<span className="text-xs font-semibold text-text-main flex items-center gap-1.5">
{t("rerankResults")}
</span>
<button onClick={onClose} className="text-text-muted text-xs hover:text-text-main">
</button>
</div>
<div className="p-4">
{noModels ? (
<p className="text-xs text-text-muted">{t("noRerankModels")}</p>
) : (
<>
<div className="flex gap-2 items-end mb-3">
<div className="flex-1">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("rerankModel")}
</label>
<Select
value={selectedModel}
onChange={(e: any) => setSelectedModel(e.target.value)}
options={models}
className="w-full"
/>
</div>
<Button variant="primary" onClick={handleRerank} disabled={loading || !selectedModel}>
{loading ? "Reranking..." : t("rerank")}
</Button>
</div>
{error && <p className="text-xs text-error mb-2">{error}</p>}
{reranked.length > 0 && (
<div className="space-y-2">
{reranked.map((r) => (
<div key={r.index} className="flex items-start gap-3 p-2 bg-bg-alt rounded-lg">
<div className="flex flex-col items-center min-w-[32px]">
<span className="text-xs font-medium text-text-main">#{r.index + 1}</span>
<span className="text-[10px]">{getDeltaDisplay(r.delta)}</span>
</div>
<div className="flex-1">
<div className="text-xs font-medium text-text-main">{r.title}</div>
<div className="text-[10px] text-text-muted mt-0.5 line-clamp-2">
{r.snippet}
</div>
</div>
<span className="text-[10px] text-accent whitespace-nowrap">
{r.score.toFixed(4)}
</span>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
import { Badge } from "@/shared/components";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
interface SearchResult {
title: string;
url: string;
snippet: string;
score?: number;
date?: string;
}
interface SearchResponse {
id: string;
provider: string;
results: SearchResult[];
query: string;
answer: string | null;
cached: boolean;
usage: {
queries_used: number;
search_cost_usd: number;
};
metrics: {
response_time_ms: number;
upstream_latency_ms: number;
total_results_available: number | null;
};
}
interface ResultsPanelProps {
response: SearchResponse | null;
rawJson: string;
loading: boolean;
error: string;
statusCode: number;
duration: number;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
export default function ResultsPanel({
response,
rawJson,
loading,
error,
statusCode,
duration,
}: ResultsPanelProps) {
const t = useTranslations("search");
const [showJson, setShowJson] = useState(false);
const getScoreColor = (score: number) => {
if (score >= 0.9) return "text-success";
if (score >= 0.7) return "text-warning";
return "text-error";
};
const getScoreBg = (score: number) => {
if (score >= 0.9) return "bg-green-500/10";
if (score >= 0.7) return "bg-yellow-500/10";
return "bg-red-500/10";
};
const editorTheme =
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
? "vs-dark"
: "light";
return (
<div className="flex flex-col">
{/* Header */}
<div className="flex justify-between items-center p-3 border-b border-border">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("searchResults")}
</span>
{statusCode > 0 && (
<>
<Badge variant={statusCode < 400 ? "success" : "error"} size="sm">
{statusCode}
</Badge>
<span className="text-xs text-text-muted">{duration}ms</span>
</>
)}
</div>
{response && (
<div className="flex gap-1">
<button
className={`text-xs px-3 py-1 rounded-md ${
!showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(false)}
>
{t("formatted")}
</button>
<button
className={`text-xs px-3 py-1 rounded-md ${
showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(true)}
>
{t("rawJson")}
</button>
</div>
)}
</div>
{/* Content */}
{loading && (
<div className="flex items-center justify-center py-20">
<span className="material-symbols-outlined text-[24px] text-primary animate-spin">
progress_activity
</span>
</div>
)}
{error && !loading && (
<div className="p-4">
<div className="text-error text-sm">{error}</div>
</div>
)}
{response && !showJson && !loading && (
<div className="p-4 space-y-3">
{/* Meta bar */}
<div className="flex justify-between items-center p-2 bg-bg-alt rounded-lg">
<div className="flex items-center gap-3 text-xs text-text-muted">
<span>
{response.results.length} {t("results").toLowerCase()}
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
{response.provider}
</span>
<span>{response.metrics?.response_time_ms}ms</span>
<span>${response.usage?.search_cost_usd?.toFixed(4)}</span>
<span>{formatBytes(rawJson.length)}</span>
</div>
<span
className={`text-xs flex items-center gap-1 ${
response.cached ? "text-success" : "text-warning"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
response.cached ? "bg-success" : "bg-warning"
}`}
/>
{response.cached ? t("cacheHit") : t("cacheMiss")}
</span>
</div>
{/* Results list */}
{response.results.map((r, i) => (
<div
key={i}
className="border-l-[3px] border-l-primary p-3 bg-surface rounded-r-lg border border-border"
>
<div className="flex justify-between items-start">
<span className="text-sm font-medium text-text-main">
{i + 1}. {r.title}
</span>
{r.score != null && (
<span
className={`text-[10px] px-2 py-0.5 rounded-md ml-2 whitespace-nowrap ${getScoreBg(r.score)} ${getScoreColor(r.score)}`}
>
{r.score.toFixed(2)}
</span>
)}
</div>
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent text-[11px] block mt-0.5"
>
{r.url}
</a>
<p className="text-xs text-text-muted mt-1 leading-relaxed">{r.snippet}</p>
</div>
))}
</div>
)}
{response && showJson && !loading && (
<div className="h-64">
<Editor
height="100%"
language="json"
value={rawJson}
theme={editorTheme}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 12,
automaticLayout: true,
wordWrap: "on",
}}
/>
</div>
)}
{!loading && !error && !response && (
<div className="flex items-center justify-center py-20 text-text-muted text-sm">
{t("emptyState")}
</div>
)}
</div>
);
}
@@ -0,0 +1,308 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Select } from "@/shared/components";
interface SearchProvider {
id: string;
name: string;
status: "active" | "no_credentials";
cost_per_query: number;
}
export interface SearchFormData {
query: string;
provider: string;
search_type: string;
max_results: number;
country?: string;
language?: string;
time_range?: string;
include_domains?: string[];
exclude_domains?: string[];
safe_search?: string;
}
interface SearchFormProps {
onSearch: (data: SearchFormData) => void;
loading: boolean;
onCancel: () => void;
providers: SearchProvider[];
}
export default function SearchForm({ onSearch, loading, onCancel, providers }: SearchFormProps) {
const t = useTranslations("search");
const [query, setQuery] = useState("");
const [provider, setProvider] = useState("auto");
const [searchType, setSearchType] = useState("web");
const [maxResults, setMaxResults] = useState(5);
const [showFilters, setShowFilters] = useState(false);
const [country, setCountry] = useState("");
const [language, setLanguage] = useState("");
const [timeRange, setTimeRange] = useState("");
const [includeDomains, setIncludeDomains] = useState<string[]>([]);
const [excludeDomains, setExcludeDomains] = useState<string[]>([]);
const [safeSearch, setSafeSearch] = useState("moderate");
const [domainInput, setDomainInput] = useState("");
const [excludeDomainInput, setExcludeDomainInput] = useState("");
const activeProviders = providers.filter((p) => p.status === "active");
const noProviders = activeProviders.length === 0;
const handleSubmit = () => {
const data: SearchFormData = {
query,
provider: provider === "auto" ? "" : provider,
search_type: searchType,
max_results: maxResults,
};
if (country) data.country = country;
if (language) data.language = language;
if (timeRange) data.time_range = timeRange;
if (includeDomains.length > 0) data.include_domains = includeDomains;
if (excludeDomains.length > 0) data.exclude_domains = excludeDomains;
if (safeSearch !== "moderate") data.safe_search = safeSearch;
onSearch(data);
};
const addDomain = (type: "include" | "exclude") => {
const input = type === "include" ? domainInput : excludeDomainInput;
const setter = type === "include" ? setIncludeDomains : setExcludeDomains;
const list = type === "include" ? includeDomains : excludeDomains;
if (input.trim() && !list.includes(input.trim())) {
setter([...list, input.trim()]);
}
type === "include" ? setDomainInput("") : setExcludeDomainInput("");
};
const removeDomain = (domain: string, type: "include" | "exclude") => {
const setter = type === "include" ? setIncludeDomains : setExcludeDomains;
const list = type === "include" ? includeDomains : excludeDomains;
setter(list.filter((d) => d !== domain));
};
return (
<div className="flex flex-col h-full">
{/* Query */}
<div className="p-4 border-b border-border">
<label className="block text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
{t("searchQuery")}
</label>
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter search query..."
className="w-full bg-surface border border-border rounded-lg p-2.5 text-sm text-text-main resize-none h-16 focus:outline-none focus:ring-2 focus:ring-primary/30"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!noProviders && query.trim()) handleSubmit();
}
}}
/>
</div>
{/* Provider + Type + Max Results */}
<div className="p-4 border-b border-border space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("provider")}
</label>
<Select
value={provider}
onChange={(e: any) => setProvider(e.target.value)}
options={[
{ value: "auto", label: "auto (cheapest)" },
...activeProviders.map((p) => ({
value: p.id,
label: p.name,
})),
]}
className="w-full"
/>
</div>
<div className="flex-1">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("searchType")}
</label>
<Select
value={searchType}
onChange={(e: any) => setSearchType(e.target.value)}
options={[
{ value: "web", label: "web" },
{ value: "news", label: "news" },
]}
className="w-full"
/>
</div>
</div>
<div className="w-20">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("maxResults")}
</label>
<input
type="number"
value={maxResults}
onChange={(e) => setMaxResults(parseInt(e.target.value) || 5)}
min={1}
max={100}
className="w-full bg-surface border border-border rounded-lg px-2.5 py-1.5 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
{/* Filters (collapsible) */}
<div className="p-4 border-b border-border">
<button
className="flex justify-between items-center w-full"
onClick={() => setShowFilters(!showFilters)}
>
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
{t("filters")}
</span>
<span className="text-text-muted text-xs">{showFilters ? "▼" : "▶"}</span>
</button>
{showFilters && (
<div className="mt-3 space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[10px] text-text-muted mb-1">{t("country")}</label>
<input
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="any"
className="w-full bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
/>
</div>
<div className="flex-1">
<label className="block text-[10px] text-text-muted mb-1">{t("language")}</label>
<input
value={language}
onChange={(e) => setLanguage(e.target.value)}
placeholder="any"
className="w-full bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">{t("timeRange")}</label>
<Select
value={timeRange}
onChange={(e: any) => setTimeRange(e.target.value)}
options={[
{ value: "", label: "any" },
{ value: "day", label: "Past day" },
{ value: "week", label: "Past week" },
{ value: "month", label: "Past month" },
{ value: "year", label: "Past year" },
]}
className="w-full"
/>
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">
{t("includeDomains")}
</label>
<div className="flex gap-1">
<input
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
placeholder="example.com"
className="flex-1 bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
onKeyDown={(e) => e.key === "Enter" && addDomain("include")}
/>
<button onClick={() => addDomain("include")} className="text-primary text-lg px-1">
+
</button>
</div>
{includeDomains.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{includeDomains.map((d) => (
<span
key={d}
className="text-[10px] bg-primary/10 text-primary px-2 py-0.5 rounded-full flex items-center gap-1"
>
{d}
<button
onClick={() => removeDomain(d, "include")}
className="text-primary/60"
>
×
</button>
</span>
))}
</div>
)}
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">
{t("excludeDomains")}
</label>
<div className="flex gap-1">
<input
value={excludeDomainInput}
onChange={(e) => setExcludeDomainInput(e.target.value)}
placeholder="example.com"
className="flex-1 bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
onKeyDown={(e) => e.key === "Enter" && addDomain("exclude")}
/>
<button onClick={() => addDomain("exclude")} className="text-primary text-lg px-1">
+
</button>
</div>
{excludeDomains.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{excludeDomains.map((d) => (
<span
key={d}
className="text-[10px] bg-error/10 text-error px-2 py-0.5 rounded-full flex items-center gap-1"
>
{d}
<button onClick={() => removeDomain(d, "exclude")} className="text-error/60">
×
</button>
</span>
))}
</div>
)}
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">{t("safeSearch")}</label>
<Select
value={safeSearch}
onChange={(e: any) => setSafeSearch(e.target.value)}
options={[
{ value: "off", label: "Off" },
{ value: "moderate", label: "Moderate" },
{ value: "strict", label: "Strict" },
]}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Search button */}
<div className="p-4 border-b border-border">
{loading ? (
<Button variant="danger" onClick={onCancel} className="w-full">
Cancel
</Button>
) : (
<Button
variant="primary"
onClick={handleSubmit}
disabled={noProviders || !query.trim()}
className="w-full"
>
Search
</Button>
)}
{noProviders && <p className="text-xs text-text-muted mt-2">{t("noSearchProviders")}</p>}
</div>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
interface HistoryEntry {
query: string;
provider: string;
timestamp: string;
filters: Record<string, any>;
}
interface SearchHistoryProps {
onReplay: (entry: HistoryEntry) => void;
}
function timeAgo(timestamp: string): string {
try {
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const diff = Date.now() - new Date(timestamp).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return rtf.format(0, "minute");
if (minutes < 60) return rtf.format(-minutes, "minute");
const hours = Math.floor(minutes / 60);
if (hours < 24) return rtf.format(-hours, "hour");
return rtf.format(-Math.floor(hours / 24), "day");
} catch {
return new Date(timestamp).toLocaleString();
}
}
export default function SearchHistory({ onReplay }: SearchHistoryProps) {
const t = useTranslations("search");
const [entries, setEntries] = useState<HistoryEntry[]>([]);
useEffect(() => {
fetch("/api/search/stats")
.then((res) => res.json())
.then((data) => setEntries(data.recent_searches || []))
.catch(() => {});
}, []);
if (entries.length === 0) return null;
return (
<div className="p-4 flex-1">
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
{t("searchHistory")}
</span>
<div className="mt-2 space-y-1.5">
{entries.map((entry, i) => (
<button
key={`${entry.timestamp}:${entry.provider}:${entry.query}`}
onClick={() => onReplay(entry)}
className="w-full text-left p-2 bg-surface border border-border rounded-lg hover:border-primary/30 transition-colors"
>
<div className="text-xs text-text-main truncate">{entry.query}</div>
<div className="flex justify-between mt-0.5">
<span className="text-[10px] text-text-muted">{entry.provider}</span>
<span className="text-[10px] text-text-muted">{timeAgo(entry.timestamp)}</span>
</div>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import SearchToolsClient from "./SearchToolsClient";
export default function SearchToolsPage() {
return <SearchToolsClient />;
}
@@ -83,7 +83,11 @@ export default function BudgetTab() {
if (data.monthlyLimitUsd)
setForm((f) => ({ ...f, monthlyLimitUsd: String(data.monthlyLimitUsd) }));
if (data.warningThreshold)
setForm((f) => ({ ...f, warningThreshold: String(data.warningThreshold) }));
// stored as fraction (01), display as percentage (0100)
setForm((f) => ({
...f,
warningThreshold: String(Math.round(data.warningThreshold * 100)),
}));
}
} catch {
// silent
@@ -104,7 +108,8 @@ export default function BudgetTab() {
apiKeyId: selectedKey,
dailyLimitUsd: form.dailyLimitUsd ? parseFloat(form.dailyLimitUsd) : null,
monthlyLimitUsd: form.monthlyLimitUsd ? parseFloat(form.monthlyLimitUsd) : null,
warningThreshold: parseInt(form.warningThreshold) || 80,
// schema expects a fraction (01); UI shows percentage (0100)
warningThreshold: (parseInt(form.warningThreshold) || 80) / 100,
}),
});
if (res.ok) {
@@ -92,11 +92,15 @@ export function parseQuotaData(provider, data) {
case "github":
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
if (quota?.unlimited && (!quota?.total || quota.total <= 0)) {
return;
}
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: safePercentage(quota.remainingPercentage),
});
});
}
@@ -214,6 +218,14 @@ export function normalizePlanTier(plan) {
const upper = raw.toUpperCase();
if (
upper.includes("PRO+") ||
upper.includes("PRO PLUS") ||
upper.includes("PROPLUS")
) {
return { key: "plus", label: "Pro+", variant: "secondary", rank: 4, raw };
}
if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) {
return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw };
}
@@ -227,6 +239,10 @@ export function normalizePlanTier(plan) {
return { key: "business", label: "Business", variant: "warning", rank: 5, raw };
}
if (upper.includes("STUDENT")) {
return { key: "pro", label: "Student", variant: "primary", rank: 3, raw };
}
if (upper.includes("ULTRA")) {
return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw };
}
@@ -241,7 +257,6 @@ export function normalizePlanTier(plan) {
if (
upper.includes("FREE") ||
upper.includes("INDIVIDUAL") ||
upper.includes("BASIC") ||
upper.includes("TRIAL") ||
upper.includes("LEGACY")
+26 -4
View File
@@ -55,9 +55,26 @@ export async function PATCH(request, { params }) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { allowedModels, noLog } = validation.data;
const {
name,
allowedModels,
allowedConnections,
noLog,
autoResolve,
isActive,
accessSchedule,
} = validation.data;
const updated = await updateApiKeyPermissions(id, { allowedModels, noLog });
const payload: Parameters<typeof updateApiKeyPermissions>[1] = {};
if (name !== undefined) payload.name = name;
if (allowedModels !== undefined) payload.allowedModels = allowedModels;
if (allowedConnections !== undefined) payload.allowedConnections = allowedConnections;
if (noLog !== undefined) payload.noLog = noLog;
if (autoResolve !== undefined) payload.autoResolve = autoResolve;
if (isActive !== undefined) payload.isActive = isActive;
if (accessSchedule !== undefined) payload.accessSchedule = accessSchedule;
const updated = await updateApiKeyPermissions(id, payload);
if (!updated) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
@@ -67,8 +84,13 @@ export async function PATCH(request, { params }) {
return NextResponse.json({
message: "API key settings updated successfully",
allowedModels,
noLog,
...(name !== undefined && { name }),
...(allowedModels !== undefined && { allowedModels }),
...(allowedConnections !== undefined && { allowedConnections }),
...(noLog !== undefined && { noLog }),
...(autoResolve !== undefined && { autoResolve }),
...(isActive !== undefined && { isActive }),
...(accessSchedule !== undefined && { accessSchedule }),
});
} catch (error) {
console.log("Error updating key permissions:", error);
+58
View File
@@ -0,0 +1,58 @@
import { getDbInstance } from "@/lib/db/core";
/**
* GET /api/logs/export — export logs as JSON
* Query params: ?hours=24 (1, 6, 12, 24; default 24)
* &type=call-logs|request-logs|proxy-logs (default call-logs)
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const hours = Math.min(Math.max(parseInt(searchParams.get("hours") || "24") || 24, 1), 168);
const logType = searchParams.get("type") || "call-logs";
const since = new Date(Date.now() - hours * 3600 * 1000).toISOString();
const db = getDbInstance();
let rows: unknown[] = [];
let tableName = "";
if (logType === "call-logs") {
tableName = "call_logs";
const stmt = db.prepare(
"SELECT * FROM call_logs WHERE timestamp >= @since ORDER BY timestamp DESC"
);
rows = stmt.all({ since });
} else if (logType === "request-logs") {
tableName = "request_logs";
const stmt = db.prepare(
"SELECT * FROM request_logs WHERE timestamp >= @since ORDER BY timestamp DESC"
);
rows = stmt.all({ since });
} else if (logType === "proxy-logs") {
tableName = "proxy_logs";
const stmt = db.prepare(
"SELECT * FROM proxy_logs WHERE timestamp >= @since ORDER BY timestamp DESC"
);
rows = stmt.all({ since });
}
const filename = `omniroute-${tableName}-${hours}h-${new Date().toISOString().slice(0, 10)}.json`;
return new Response(
JSON.stringify({ logs: rows, count: rows.length, hours, type: logType }, null, 2),
{
status: 200,
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
},
}
);
} catch (error) {
return Response.json(
{ error: { message: (error as Error).message, type: "server_error" } },
{ status: 500 }
);
}
}
+3 -1
View File
@@ -113,12 +113,14 @@ export async function PUT(request) {
return Response.json({ error: validation.error }, { status: 400 });
}
const { provider, modelId, modelName, apiFormat, supportedEndpoints } = validation.data;
const { provider, modelId, modelName, apiFormat, supportedEndpoints, normalizeToolCallId } =
validation.data;
const model = await updateCustomModel(provider, modelId, {
modelName,
apiFormat,
supportedEndpoints,
normalizeToolCallId,
});
if (!model) {
+36 -6
View File
@@ -28,8 +28,16 @@ type ProviderModelsConfigEntry = {
parseResponse: (data: any) => any;
};
const KIMI_CODING_MODELS_CONFIG: ProviderModelsConfigEntry = {
url: "https://api.kimi.com/coding/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "x-api-key",
parseResponse: (data) => data.data || data.models || [],
};
// Providers that return hardcoded models (no remote /models API)
const STATIC_MODEL_PROVIDERS = {
const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: string }>> = {
deepgram: () => [
{ id: "nova-3", name: "Nova 3 (Transcription)" },
{ id: "nova-2", name: "Nova 2 (Transcription)" },
@@ -53,8 +61,31 @@ const STATIC_MODEL_PROVIDERS = {
{ id: "sonar-reasoning-pro", name: "Sonar Reasoning Pro (Advanced CoT + Search)" },
{ id: "sonar-deep-research", name: "Sonar Deep Research (Expert Analysis)" },
],
"bailian-coding-plan": () => [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max (2026-01-23)" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
],
};
/**
* Get static models for a provider (if available).
* Exported for testing purposes.
* @param provider - Provider ID
* @returns Array of models or undefined if provider doesn't use static models
*/
export function getStaticModelsForProvider(
provider: string
): Array<{ id: string; name: string }> | undefined {
const staticModelsFn = STATIC_MODEL_PROVIDERS[provider];
return staticModelsFn ? staticModelsFn() : undefined;
}
// Provider models endpoints configuration
const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
claude: {
@@ -134,11 +165,10 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
parseResponse: (data) => data.data || [],
},
"kimi-coding": {
url: "https://api.kimi.com/coding/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "x-api-key",
parseResponse: (data) => data.data || data.models || [],
...KIMI_CODING_MODELS_CONFIG,
},
"kimi-coding-apikey": {
...KIMI_CODING_MODELS_CONFIG,
},
anthropic: {
url: "https://api.anthropic.com/v1/models",
+13 -3
View File
@@ -46,8 +46,16 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } =
validation.data;
const {
provider,
apiKey,
name,
priority,
globalPriority,
defaultModel,
testStatus,
providerSpecificData: incomingPsd,
} = validation.data;
// Business validation
const isValidProvider =
@@ -59,7 +67,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid provider" }, { status: 400 });
}
let providerSpecificData: Record<string, any> | null = null;
let providerSpecificData = incomingPsd || null;
const allowMultipleCompatibleConnections =
process.env.ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE === "true";
@@ -78,6 +86,7 @@ export async function POST(request: Request) {
}
providerSpecificData = {
...(providerSpecificData || {}),
prefix: node.prefix,
apiType: node.apiType,
baseUrl: node.baseUrl,
@@ -100,6 +109,7 @@ export async function POST(request: Request) {
}
providerSpecificData = {
...(providerSpecificData || {}),
prefix: node.prefix,
baseUrl: node.baseUrl,
nodeName: node.name,
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import {
SEARCH_PROVIDERS,
SEARCH_CREDENTIAL_FALLBACKS,
} from "@omniroute/open-sse/config/searchRegistry.ts";
import { getDbInstance } from "@/lib/db/core";
import { isAuthenticated } from "@/shared/utils/apiAuth";
export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const db = getDbInstance();
const providers = Object.values(SEARCH_PROVIDERS).map((p) => {
let status: "active" | "no_credentials" = "no_credentials";
try {
const cred = db
.prepare(
"SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1"
)
.get(p.id);
// Use canonical fallback mapping (e.g. perplexity-search → perplexity)
const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[p.id];
const fallbackCred =
!cred && fallbackId
? db
.prepare(
"SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1"
)
.get(fallbackId)
: null;
if (cred || fallbackCred) status = "active";
} catch {
// DB error — report as no_credentials
}
return {
id: p.id,
name: p.name,
status,
cost_per_query: p.costPerQuery,
};
});
return NextResponse.json({ providers });
} catch (error) {
return NextResponse.json({ error: "Failed to list providers" }, { status: 500 });
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { getCacheStats } from "@omniroute/open-sse/services/searchCache.ts";
import { SEARCH_PROVIDERS } from "@omniroute/open-sse/config/searchRegistry.ts";
import { getDbInstance } from "@/lib/db/core";
import { isAuthenticated } from "@/shared/utils/apiAuth";
export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const db = getDbInstance();
const cache = getCacheStats();
// Provider aggregate stats — cost is per-query from registry
const providerStats = db
.prepare(
`
SELECT provider, COUNT(*) as requests,
CAST(AVG(duration) AS INTEGER) as avg_latency_ms
FROM call_logs
WHERE request_type = 'search'
GROUP BY provider
`
)
.all();
const providers: Record<
string,
{ requests: number; avg_latency_ms: number; total_cost: number }
> = {};
for (const row of providerStats as any[]) {
const costPerQuery = SEARCH_PROVIDERS[row.provider]?.costPerQuery || 0;
providers[row.provider] = {
requests: row.requests,
avg_latency_ms: row.avg_latency_ms,
total_cost: parseFloat((row.requests * costPerQuery).toFixed(4)),
};
}
// Recent searches
const recentRows = db
.prepare(
`
SELECT request_body, provider, timestamp
FROM call_logs
WHERE request_type = 'search'
ORDER BY timestamp DESC
LIMIT 10
`
)
.all();
const recent_searches = (recentRows as any[]).map((row) => {
let query = "";
let filters = {};
try {
const body = JSON.parse(row.request_body);
query = body.query || "";
const { query: _q, provider: _p, ...rest } = body;
filters = rest;
} catch {
// Unparseable request_body
}
return {
query,
provider: row.provider,
timestamp: row.timestamp,
filters,
};
});
return NextResponse.json({ cache, providers, recent_searches });
} catch (error) {
return NextResponse.json({ error: "Failed to get stats" }, { status: 500 });
}
}
+126 -23
View File
@@ -6,12 +6,13 @@ import {
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { parseRerankModel, getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1RerankSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getProviderNodes } from "@/lib/localDb";
/**
* Handle CORS preflight
@@ -26,11 +27,29 @@ export async function OPTIONS() {
});
}
/**
* Build dynamic rerank provider from a local provider_node.
* Local OpenAI-compatible backends (oMLX, vLLM, etc.) expose /v1/rerank
* under the same base URL as chat.
*/
function buildDynamicRerankProvider(node: any) {
// Strip trailing /v1 if present — we'll add /rerank
let base = node.baseUrl || "";
if (base.endsWith("/v1")) base = base.slice(0, -3);
return {
id: node.prefix,
baseUrl: `${base}/v1/rerank`,
authType: "apikey",
authHeader: "bearer",
providerId: node.id, // full provider connection ID for credential lookup
};
}
/**
* POST /v1/rerank - Cohere-compatible rerank endpoint
*
* Reranks a list of documents against a query using the specified model.
* Supports providers: Cohere, Together AI, NVIDIA, Fireworks AI.
* Supports cloud providers (Cohere, Together, NVIDIA, Fireworks)
* and local provider_nodes (oMLX, vLLM, etc.) via dynamic routing.
*/
export async function POST(request) {
// Optional API key validation
@@ -58,29 +77,113 @@ export async function POST(request) {
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
const { provider } = parseRerankModel(body.model);
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Invalid rerank model: ${body.model}. Use format: provider/model`
);
// Load local provider_nodes for rerank routing (localhost only)
let localProviders: ReturnType<typeof buildDynamicRerankProvider>[] = [];
try {
const nodes = await getProviderNodes();
localProviders = (Array.isArray(nodes) ? nodes : [])
.filter((n: any) => {
try {
const hostname = new URL(n.baseUrl).hostname;
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]"
);
} catch {
return false;
}
})
.map((n) => {
try {
return buildDynamicRerankProvider(n);
} catch {
return null;
}
})
.filter((p): p is NonNullable<typeof p> => p !== null);
} catch {
// Non-critical — continue with cloud providers only
}
const credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
// Try cloud registry first
const { provider, model: modelId } = parseRerankModel(body.model);
if (provider) {
// Cloud provider matched
const credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
const response = await handleRerank({
model: body.model,
query: body.query,
documents: body.documents,
top_n: body.top_n,
return_documents: body.return_documents,
credentials,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
const response = await handleRerank({
model: body.model,
query: body.query,
documents: body.documents,
top_n: body.top_n,
return_documents: body.return_documents,
credentials,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
// Try local provider_nodes (model format: prefix/model-name)
const parts = body.model.split("/");
if (parts.length >= 2) {
const prefix = parts[0];
const localModel = parts.slice(1).join("/");
const localProvider = localProviders.find((p) => p.id === prefix);
if (localProvider) {
const credentials = await getProviderCredentials(localProvider.providerId);
if (!credentials) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for local provider: ${prefix}`
);
}
const token = credentials?.apiKey || credentials?.accessToken;
try {
const res = await fetch(localProvider.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
model: localModel,
query: body.query,
documents: body.documents,
top_n: body.top_n || body.documents.length,
return_documents: body.return_documents !== false,
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
return errorResponse(
res.status,
errData.message || errData.detail || `Provider returned HTTP ${res.status}`
);
}
const data = await res.json();
return Response.json(data, {
headers: { "Access-Control-Allow-Origin": CORS_ORIGIN },
});
} catch (err: any) {
return errorResponse(500, `Rerank request failed: ${err.message}`);
}
}
}
return response;
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Invalid rerank model: ${body.model}. Use format: provider/model`
);
}
@@ -0,0 +1,33 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
let initialized = false;
async function ensureInitialized() {
if (!initialized) {
await initTranslators();
initialized = true;
console.log("[SSE] Translators initialized for /v1/responses/*");
}
}
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
/**
* POST /v1/responses/:path* - OpenAI Responses subpaths
* Reuses the shared chat handler so native Codex passthrough can keep
* arbitrary Responses suffixes all the way to the upstream provider.
*/
export async function POST(request) {
await ensureInitialized();
return await handleChat(request);
}
+9 -12
View File
@@ -1,16 +1,14 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
let initialized = false;
async function ensureInitialized() {
if (!initialized) {
await initTranslators();
initialized = true;
console.log("[SSE] Translators initialized for /v1/responses");
}
}
// NOTE: We do NOT call initTranslators() here — the translator registry is
// bootstrapped at module level inside open-sse/translator/index.ts when it
// is first imported. Calling it again from a Next.js Route Handler caused a
// "the worker has exited" uncaughtException crash on Codex CLI requests (#450)
// because the dynamic import runs in a Next.js server worker context where
// certain Node APIs used by the translator bootstrap are not available.
// The translators are always initialized via the open-sse side (chatCore),
// so /v1/responses just delegates to handleChat which handles everything.
export async function OPTIONS() {
return new Response(null, {
@@ -24,9 +22,8 @@ export async function OPTIONS() {
/**
* POST /v1/responses - OpenAI Responses API format
* Now handled by translator pattern (openai-responses format auto-detected)
* Handled by the unified chat handler (openai-responses format auto-detected).
*/
export async function POST(request) {
await ensureInitialized();
return await handleChat(request);
}
+1
View File
@@ -203,6 +203,7 @@ export default function LoginPage() {
{error}
</p>
)}
<p className="text-xs text-text-muted/60 pt-0.5">{t("defaultPasswordHint")}</p>
</div>
<Button
+40 -1
View File
@@ -101,6 +101,45 @@ function clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function normalizeWindowKey(value: unknown): string {
if (typeof value !== "string") return "";
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function resolveQuotaWindow(
quotas: Record<string, QuotaInfo>,
windowName: string
): QuotaInfo | null {
const direct = quotas[windowName];
if (direct) return direct;
const normalizedTarget = normalizeWindowKey(windowName);
if (!normalizedTarget) return null;
const prefixMatches: Array<{ key: string; quota: QuotaInfo }> = [];
for (const [key, quota] of Object.entries(quotas)) {
const normalizedKey = normalizeWindowKey(key);
if (!normalizedKey) continue;
if (normalizedKey === normalizedTarget) return quota;
// Support canonical selection of generic windows from labeled windows,
// e.g. "weekly" from "weekly (7d)" or "session" from "session (5h)".
if (normalizedKey.startsWith(`${normalizedTarget} `)) {
prefixMatches.push({ key, quota });
}
}
// Deterministic fallback: choose the lexicographically first matching key.
if (prefixMatches.length > 0) {
prefixMatches.sort((a, b) => a.key.localeCompare(b.key));
return prefixMatches[0].quota;
}
return null;
}
function earliestResetAt(quotas: Record<string, QuotaInfo>): string | null {
let earliest: string | null = null;
let earliestMs = Infinity;
@@ -201,7 +240,7 @@ export function getQuotaWindowStatus(
const now = Date.now();
const window = entry.quotas[windowName];
const window = resolveQuotaWindow(entry.quotas, windowName);
if (!window) return null;
const remainingPercentage = clampPercent(window.remainingPercentage);
+39 -1
View File
@@ -74,6 +74,7 @@
"settings": "Settings",
"translator": "Translator",
"playground": "Playground",
"searchTools": "Search Tools",
"agents": "Agents",
"docs": "Docs",
"issues": "Issues",
@@ -328,6 +329,42 @@
"videoDescription": "Create videos with AnimateDiff, Stable Video Diffusion via ComfyUI or SD WebUI.",
"musicDescription": "Compose music using Stable Audio Open or MusicGen via ComfyUI."
},
"search": {
"searchQuery": "Search Query",
"searchResults": "Search Results",
"cachedResult": "Cached",
"searchCost": "Cost",
"searchTools": "Search Tools",
"searchToolsDesc": "Advanced search testing with provider comparison",
"compareProviders": "Compare Providers",
"rerankResults": "Rerank Results",
"searchHistory": "Search History",
"urlOverlap": "URL Overlap",
"noSearchProviders": "No search providers configured. Add providers in Settings.",
"noRerankModels": "No rerank model available",
"webSearch": "Web Search",
"provider": "Provider",
"searchType": "Search Type",
"maxResults": "Max Results",
"filters": "Filters",
"country": "Country",
"language": "Language",
"timeRange": "Time Range",
"includeDomains": "Include Domains",
"excludeDomains": "Exclude Domains",
"safeSearch": "Safe Search",
"formatted": "Formatted",
"rawJson": "JSON",
"cacheMiss": "cache miss",
"cacheHit": "cache hit",
"latency": "Latency",
"cost": "Cost",
"results": "Results",
"rerank": "Rerank",
"rerankModel": "Rerank Model",
"positionDelta": "Position Change",
"emptyState": "Send a search query to see results"
},
"cliTools": {
"title": "CLI Tools",
"noActiveProviders": "No active providers",
@@ -2256,7 +2293,8 @@
"orRemovePasswordHashField": "or remove the passwordHash field",
"restartServerWithNewPassword": "Restart the server - it will use the new password",
"backToLogin": "Back to Login",
"forgotPassword": "Forgot password?"
"forgotPassword": "Forgot password?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)"
},
"landing": {
"brandName": "OmniRoute",
File diff suppressed because it is too large Load Diff
+25
View File
@@ -200,6 +200,9 @@ export async function updateCustomModel(providerId, modelId, updates = {}) {
...(updates.supportedEndpoints !== undefined
? { supportedEndpoints: updates.supportedEndpoints }
: {}),
...(updates.normalizeToolCallId !== undefined
? { normalizeToolCallId: Boolean(updates.normalizeToolCallId) }
: {}),
};
models[index] = next;
@@ -212,3 +215,25 @@ export async function updateCustomModel(providerId, modelId, updates = {}) {
backupDbFile("pre-write");
return next;
}
/**
* Whether the given provider/model has "normalize tool call id" (9-char Mistral-style) enabled.
* Only custom models can have this set; returns false for built-in models.
*/
export function getModelNormalizeToolCallId(providerId: string, modelId: string): boolean {
const db = getDbInstance();
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?")
.get(providerId);
const value = getKeyValue(row).value;
if (!value) return false;
let models: { id: string; normalizeToolCallId?: boolean }[];
try {
models = JSON.parse(value);
} catch {
return false;
}
if (!Array.isArray(models)) return false;
const m = models.find((x: { id: string }) => x.id === modelId);
return Boolean(m?.normalizeToolCallId);
}
+58 -5
View File
@@ -300,6 +300,52 @@ async function validateInworldProvider({ apiKey }: any) {
}
}
async function validateBailianCodingPlanProvider({ apiKey, providerSpecificData = {} }: any) {
try {
const rawBaseUrl =
normalizeBaseUrl(providerSpecificData.baseUrl) ||
"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
const baseUrl = rawBaseUrl.endsWith("/messages")
? rawBaseUrl.slice(0, -"/messages".length)
: rawBaseUrl;
// bailian-coding-plan uses DashScope Anthropic-compatible messages endpoint
// It does NOT expose /v1/models — use messages probe directly
const messagesUrl = `${baseUrl}/messages`;
const response = await fetch(messagesUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "qwen3-coder-plus",
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
});
// 401/403 => invalid key
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
// Non-auth 4xx (e.g., 400 bad request) means auth passed but request was malformed
if (response.status >= 400 && response.status < 500) {
return { valid: true, error: null };
}
if (response.ok) {
return { valid: true, error: null };
}
return { valid: false, error: `Validation failed: ${response.status}` };
} catch (error: any) {
return { valid: false, error: error.message || "Validation failed" };
}
}
async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = {} }: any) {
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
if (!baseUrl) {
@@ -445,16 +491,22 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
async function validateSearchProvider(
url: string,
init: RequestInit
): Promise<{ valid: boolean; error: string | null }> {
): Promise<{ valid: boolean; error: string | null; unsupported: false }> {
try {
const response = await fetch(url, init);
if (response.ok) return { valid: true, error: null };
if (response.ok) return { valid: true, error: null, unsupported: false };
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
return { valid: false, error: "Invalid API key", unsupported: false };
}
return { valid: false, error: `Validation failed: ${response.status}` };
// For provider setup we only need to confirm authentication passed.
// Search providers may return non-auth statuses for exhausted credits,
// rate limiting, or request-shape quirks while still accepting the key.
if (response.status < 500) {
return { valid: true, error: null, unsupported: false };
}
return { valid: false, error: `Validation failed: ${response.status}`, unsupported: false };
} catch (error: any) {
return { valid: false, error: error.message || "Validation failed" };
return { valid: false, error: error.message || "Validation failed", unsupported: false };
}
}
@@ -531,6 +583,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
nanobanana: validateNanoBananaProvider,
elevenlabs: validateElevenLabsProvider,
inworld: validateInworldProvider,
"bailian-coding-plan": validateBailianCodingPlanProvider,
// Search providers — use factored validator
...Object.fromEntries(
Object.entries(SEARCH_VALIDATOR_CONFIGS).map(([id, configFn]) => [
+6 -2
View File
@@ -184,8 +184,12 @@ export async function saveCallLog(entry: any) {
account,
connectionId: entry.connectionId || null,
duration: entry.duration || 0,
tokensIn: entry.tokens?.prompt_tokens || 0,
tokensOut: entry.tokens?.completion_tokens || 0,
tokensIn: toNumber(
(entry.tokens?.prompt_tokens ?? entry.tokens?.input_tokens ?? 0) +
(entry.tokens?.cache_read_input_tokens ?? entry.tokens?.cached_tokens ?? 0) +
(entry.tokens?.cache_creation_input_tokens ?? 0)
),
tokensOut: toNumber(entry.tokens?.completion_tokens ?? entry.tokens?.output_tokens ?? 0),
requestType: entry.requestType || null,
sourceFormat: entry.sourceFormat || null,
targetFormat: entry.targetFormat || null,
+10 -10
View File
@@ -223,21 +223,21 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
</div>
) : (
<>
{/* Request Payload */}
{requestJson && (
{/* Response Payload (返回) — show first */}
{responseJson && (
<PayloadSection
title="Request Payload"
json={requestJson}
onCopy={() => onCopy(requestJson)}
title="Response Payload (返回)"
json={responseJson}
onCopy={() => onCopy(responseJson)}
/>
)}
{/* Response Payload */}
{responseJson && (
{/* Request Payload (请求) */}
{requestJson && (
<PayloadSection
title="Response Payload"
json={responseJson}
onCopy={() => onCopy(responseJson)}
title="Request Payload (请求)"
json={requestJson}
onCopy={() => onCopy(requestJson)}
/>
)}
+1
View File
@@ -32,6 +32,7 @@ const debugItemDefs = [
{ href: "/dashboard/translator", i18nKey: "translator", icon: "translate" },
{ href: "/dashboard/playground", i18nKey: "playground", icon: "science" },
{ href: "/dashboard/media", i18nKey: "media", icon: "auto_awesome" },
{ href: "/dashboard/search-tools", i18nKey: "searchTools", icon: "manage_search" },
];
const systemItemDefs = [
+40 -24
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useLocale } from "next-intl";
import Card from "../Card";
import { getModelColor } from "@/shared/constants/colors";
import {
@@ -25,6 +26,14 @@ import {
Area,
} from "recharts";
function createDateFormatter(locale: string, options: Intl.DateTimeFormatOptions) {
try {
return new Intl.DateTimeFormat(locale, options);
} catch {
return new Intl.DateTimeFormat(undefined, options);
}
}
// ── Custom Tooltip for dark theme ──────────────────────────────────────────
function DarkTooltip({
@@ -724,6 +733,15 @@ export function WeeklyPattern({ weeklyPattern }) {
// ── MostActiveDay7d ────────────────────────────────────────────────────────
export function MostActiveDay7d({ activityMap }) {
const locale = useLocale();
const weekdayFormatter = useMemo(
() => createDateFormatter(locale, { weekday: "long" }),
[locale]
);
const dateFormatter = useMemo(
() => createDateFormatter(locale, { month: "short", day: "numeric" }),
[locale]
);
const data = useMemo(() => {
if (!activityMap) return null;
const today = new Date();
@@ -743,27 +761,12 @@ export function MostActiveDay7d({ activityMap }) {
if (!peakKey || peakVal === 0) return null;
const peakDate = new Date(peakKey + "T12:00:00");
const weekdays = ["domingo", "segunda", "terça", "quarta", "quinta", "sexta", "sábado"];
const months = [
"jan",
"fev",
"mar",
"abr",
"mai",
"jun",
"jul",
"ago",
"set",
"out",
"nov",
"dez",
];
return {
weekday: weekdays[peakDate.getDay()],
label: `${peakDate.getDate()} de ${months[peakDate.getMonth()]}`,
weekday: weekdayFormatter.format(peakDate),
label: dateFormatter.format(peakDate),
tokens: peakVal,
};
}, [activityMap]);
}, [activityMap, dateFormatter, weekdayFormatter]);
return (
<Card className="p-4 flex flex-col justify-center" style={{ flex: 1, minHeight: 0 }}>
@@ -784,7 +787,7 @@ export function MostActiveDay7d({ activityMap }) {
</>
) : (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Sem dados nos últimos 7 dias
No data in the last 7 days
</span>
)}
</Card>
@@ -794,6 +797,15 @@ export function MostActiveDay7d({ activityMap }) {
// ── WeeklySquares7d ────────────────────────────────────────────────────────
export function WeeklySquares7d({ activityMap }) {
const locale = useLocale();
const weekdayFormatter = useMemo(
() => createDateFormatter(locale, { weekday: "short" }),
[locale]
);
const dateFormatter = useMemo(
() => createDateFormatter(locale, { month: "short", day: "numeric" }),
[locale]
);
const days = useMemo(() => {
if (!activityMap) return [];
const today = new Date();
@@ -806,11 +818,15 @@ export function WeeklySquares7d({ activityMap }) {
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const val = activityMap[key] || 0;
if (val > maxVal) maxVal = val;
const shortDays = ["DOM", "SEG", "TER", "QUA", "QUI", "SEX", "SÁB"];
result.push({ key, val, label: shortDays[d.getDay()] });
result.push({
key,
val,
label: weekdayFormatter.format(d),
dateLabel: dateFormatter.format(d),
});
}
return result.map((d) => ({ ...d, intensity: maxVal > 0 ? d.val / maxVal : 0 }));
}, [activityMap]);
}, [activityMap, dateFormatter, weekdayFormatter]);
function getSquareStyle(intensity) {
if (intensity === 0) return { background: "rgba(255,255,255,0.04)" };
@@ -829,11 +845,11 @@ export function WeeklySquares7d({ activityMap }) {
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, justifyContent: "center" }}>
{days.map((d, i) => (
<div
key={i}
key={d.key}
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}
>
<div
title={`${d.key}: ${fmtFull(d.val)} tokens`}
title={`${d.dateLabel}: ${fmtFull(d.val)} tokens`}
style={{
width: 36,
height: 36,
+2
View File
@@ -33,8 +33,10 @@ export const API_ENDPOINTS = {
export const PROVIDER_ENDPOINTS = {
openrouter: "https://openrouter.ai/api/v1/chat/completions",
glm: "https://api.z.ai/api/anthropic/v1/messages",
"bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
kimi: "https://api.moonshot.ai/v1/chat/completions",
"kimi-coding": "https://api.kimi.com/coding/v1/messages",
"kimi-coding-apikey": "https://api.kimi.com/coding/v1/messages",
minimax: "https://api.minimax.io/anthropic/v1/messages",
"minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages",
openai: "https://api.openai.com/v1/chat/completions",
+71 -1
View File
@@ -2,6 +2,47 @@
// All rates are in dollars per million tokens ($/1M tokens)
// Based on user-provided pricing for Antigravity models and industry standards for others
// Shared pricing constants to reduce duplication
const GPT_5_3_CODEX_PRICING = {
input: 5.0,
output: 20.0,
cached: 2.5,
reasoning: 30.0,
cache_creation: 5.0,
};
const CLAUDE_OPUS_4_PRICING = {
input: 15.0,
output: 75.0,
cached: 7.5,
reasoning: 112.5,
cache_creation: 15.0,
};
const CLAUDE_SONNET_4_PRICING = {
input: 3.0,
output: 15.0,
cached: 1.5,
reasoning: 15.0,
cache_creation: 3.0,
};
const CLAUDE_OPUS_46_PRICING = {
input: 5.0,
output: 25.0,
cached: 2.5,
reasoning: 37.5,
cache_creation: 5.0,
};
const CLAUDE_SONNET_46_PRICING = {
input: 3.0,
output: 15.0,
cached: 1.5,
reasoning: 22.5,
cache_creation: 3.0,
};
export const DEFAULT_PRICING = {
// OAuth Providers (using aliases)
@@ -46,7 +87,14 @@ export const DEFAULT_PRICING = {
// OpenAI Codex (cx)
cx: {
// Issue #334: add gpt5.4
// GPT 5.4
"gpt-5.4": {
input: 5.0,
output: 20.0,
cached: 2.5,
reasoning: 30.0,
cache_creation: 5.0,
},
"gpt5.4": {
input: 5.0,
output: 20.0,
@@ -54,6 +102,19 @@ export const DEFAULT_PRICING = {
reasoning: 30.0,
cache_creation: 5.0,
},
// GPT 5.3 Codex family (all same pricing tier)
"gpt-5.3-codex": GPT_5_3_CODEX_PRICING,
"gpt-5.3-codex-xhigh": GPT_5_3_CODEX_PRICING,
"gpt-5.3-codex-high": GPT_5_3_CODEX_PRICING,
"gpt-5.3-codex-low": GPT_5_3_CODEX_PRICING,
"gpt-5.3-codex-none": GPT_5_3_CODEX_PRICING,
"gpt-5.1-codex-mini-high": {
input: 1.5,
output: 6.0,
cached: 0.75,
reasoning: 9.0,
cache_creation: 1.5,
},
"gpt-5.2-codex": {
input: 5.0,
output: 20.0,
@@ -525,6 +586,15 @@ export const DEFAULT_PRICING = {
reasoning: 37.5,
cache_creation: 5.0,
},
// Common model IDs (without dates) used across providers
// Intentional duplicates of dot-notation variants (e.g. claude-opus-4.6)
// to cover hyphen-notation IDs (claude-opus-4-6) used by some clients
"claude-opus-4-6": CLAUDE_OPUS_46_PRICING,
"claude-sonnet-4-6": CLAUDE_SONNET_46_PRICING,
"claude-opus-4-5-20251101": CLAUDE_OPUS_4_PRICING,
"claude-sonnet-4-5-20250929": CLAUDE_SONNET_4_PRICING,
"claude-sonnet-4": CLAUDE_SONNET_4_PRICING,
"claude-opus-4": CLAUDE_OPUS_4_PRICING,
},
// Gemini
+37
View File
@@ -53,6 +53,7 @@ export const OAUTH_PROVIDERS = {
},
};
// API Key Providers
export const APIKEY_PROVIDERS = {
openrouter: {
id: "openrouter",
@@ -73,6 +74,15 @@ export const APIKEY_PROVIDERS = {
textIcon: "GL",
website: "https://open.bigmodel.cn",
},
"bailian-coding-plan": {
id: "bailian-coding-plan",
alias: "bcp",
name: "Alibaba Coding Plan",
icon: "code",
color: "#FF6A00",
textIcon: "BCP",
website: "https://www.alibabacloud.com/help/en/model-studio/coding-plan",
},
kimi: {
id: "kimi",
alias: "kimi",
@@ -82,6 +92,15 @@ export const APIKEY_PROVIDERS = {
textIcon: "KM",
website: "https://kimi.moonshot.cn",
},
"kimi-coding-apikey": {
id: "kimi-coding-apikey",
alias: "kmca",
name: "Kimi Coding (API Key)",
icon: "psychology",
color: "#1E40AF",
textIcon: "KC",
website: "https://kimi.com",
},
minimax: {
id: "minimax",
alias: "minimax",
@@ -100,6 +119,24 @@ export const APIKEY_PROVIDERS = {
textIcon: "MC",
website: "https://www.minimaxi.com",
},
alicode: {
id: "alicode",
alias: "alicode",
name: "Alibaba",
icon: "cloud",
color: "#FF6A00",
textIcon: "ALi",
website: "https://bailian.console.aliyun.com",
},
"alicode-intl": {
id: "alicode-intl",
alias: "alicode-intl",
name: "Alibaba Intl",
icon: "cloud",
color: "#FF6A00",
textIcon: "ALi",
website: "https://modelstudio.console.alibabacloud.com",
},
openai: {
id: "openai",
alias: "openai",
+8 -1
View File
@@ -121,7 +121,14 @@ const runProcess = (
let timedOut = false;
let settled = false;
const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"] });
const child = spawn(command, args, {
env,
stdio: ["ignore", "pipe", "pipe"],
// On Windows, npm installs CLI wrappers as .cmd scripts (e.g. claude.cmd).
// Without shell:true, spawn cannot resolve them via PATHEXT and the
// healthcheck fails even when the CLI is correctly installed (#447).
...(isWindows() ? { shell: true } : {}),
});
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
+20 -14
View File
@@ -23,13 +23,16 @@ export async function getConsistentMachineId(salt = null) {
} catch (error) {
console.log("Error getting machine ID:", error);
// Fallback to random ID if node-machine-id fails
return crypto.randomUUID
? crypto.randomUUID()
: "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
try {
const cryptoFallback = await import("crypto");
return cryptoFallback.randomUUID();
} catch {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
}
@@ -44,13 +47,16 @@ export async function getRawMachineId() {
} catch (error) {
console.log("Error getting raw machine ID:", error);
// Fallback to random ID if node-machine-id fails
return crypto.randomUUID
? crypto.randomUUID()
: "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
try {
const cryptoFallback = await import("crypto");
return cryptoFallback.randomUUID();
} catch {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
}
+50 -2
View File
@@ -1,5 +1,14 @@
import { z } from "zod";
function isHttpUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
// Re-export validation helpers from dedicated module to avoid webpack barrel-file
// optimization bug that truncates exports from large files.
export { validateBody, isValidationFailure } from "./helpers";
@@ -15,6 +24,21 @@ export const createProviderSchema = z.object({
globalPriority: z.number().int().min(1).max(100).nullable().optional(),
defaultModel: z.string().max(200).nullable().optional(),
testStatus: z.string().max(50).optional(),
providerSpecificData: z
.record(z.string(), z.unknown())
.optional()
.superRefine((data, ctx) => {
if (!data) return;
const baseUrl = data.baseUrl;
if (baseUrl === undefined) return;
if (typeof baseUrl !== "string" || !isHttpUrl(baseUrl)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
path: ["baseUrl"],
});
}
}),
});
// ──── API Key Schemas ────
@@ -80,6 +104,9 @@ export const createComboSchema = z.object({
strategy: comboStrategySchema.optional().default("priority"),
config: comboConfigSchema,
allowedProviders: z.array(z.string().max(200)).optional(),
system_message: z.string().max(50000).optional(),
tool_filter_regex: z.string().max(1000).optional(),
context_cache_protection: z.boolean().optional(),
});
// ──── Auto-Combo Schemas ────
@@ -320,6 +347,7 @@ export const providerModelMutationSchema = z.object({
source: z.string().trim().max(80).optional(),
apiFormat: z.enum(["chat-completions", "responses"]).default("chat-completions"),
supportedEndpoints: z.array(z.enum(["chat", "embeddings", "images", "audio"])).default(["chat"]),
normalizeToolCallId: z.boolean().optional(),
});
const pricingFieldsSchema = z
@@ -813,6 +841,9 @@ export const updateComboSchema = z
config: comboRuntimeConfigSchema.optional(),
isActive: z.boolean().optional(),
allowedProviders: z.array(z.string().max(200)).optional(),
system_message: z.string().max(50000).optional(),
tool_filter_regex: z.string().max(1000).optional(),
context_cache_protection: z.boolean().optional(),
})
.superRefine((value, ctx) => {
if (
@@ -821,7 +852,10 @@ export const updateComboSchema = z
value.strategy === undefined &&
value.config === undefined &&
value.isActive === undefined &&
value.allowedProviders === undefined
value.allowedProviders === undefined &&
value.system_message === undefined &&
value.tool_filter_regex === undefined &&
value.context_cache_protection === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -936,7 +970,21 @@ export const updateProviderConnectionSchema = z
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(),
providerSpecificData: z
.record(z.string(), z.unknown())
.optional()
.superRefine((data, ctx) => {
if (!data) return;
const baseUrl = data.baseUrl;
if (baseUrl === undefined) return;
if (typeof baseUrl !== "string" || !isHttpUrl(baseUrl)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
path: ["baseUrl"],
});
}
}),
})
.superRefine((value, ctx) => {
if (Object.keys(value).length === 0) {
+8 -3
View File
@@ -135,9 +135,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
log.debug("AUTH", "No API key provided (local mode)");
}
// Optional strict API key mode for /v1 endpoints.
// Keep disabled by default to preserve local-mode compatibility.
// Exception: X-Internal-Test header bypasses auth for admin-side combo health checks (#350)
// Optional strict API key mode for /v1 endpoints (require key on every request).
const isInternalTest = request.headers?.get?.("x-internal-test") === "combo-health-check";
if (process.env.REQUIRE_API_KEY === "true" && !isInternalTest) {
if (!apiKey) {
@@ -149,6 +147,13 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
log.warn("AUTH", "Invalid API key while REQUIRE_API_KEY=true");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
} else if (apiKey && !isInternalTest) {
// Client sent a Bearer key — it must exist in DB (otherwise reject to avoid "key ignored" confusion).
const valid = await isValidApiKey(apiKey);
if (!valid) {
log.warn("AUTH", "API key not found or invalid (must be created in API Manager)");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
}
if (!modelStr) {
+30 -5
View File
@@ -132,12 +132,38 @@ function normalizeWindowName(windowName: unknown): string | null {
return normalized.length > 0 ? normalized : null;
}
function getLegacyCodexWindows(providerSpecificData: JsonRecord): string[] {
function uniqueWindows(windows: string[]): string[] {
return [...new Set(windows)];
}
function normalizeCodexWindowName(windowName: unknown): string | null {
if (typeof windowName !== "string") return null;
const normalized = windowName.trim().toLowerCase();
if (normalized === "session (5h)" || normalized === "5h" || normalized === "five_hour") {
return "session";
}
if (normalized === "weekly (7d)" || normalized === "7d" || normalized === "seven_day") {
return "weekly";
}
return normalized;
}
function applyCodexWindowPolicy(rawWindows: string[], providerSpecificData: JsonRecord): string[] {
const codexPolicy = getCodexLimitPolicy(providerSpecificData);
const windows: string[] = [];
const normalizedRaw = rawWindows.map(normalizeCodexWindowName).filter(Boolean) as string[];
// Preserve explicitly configured custom windows, but enforce canonical Codex windows
// from toggles so weekly exhaustion is never skipped when useWeekly=true.
let windows = [...normalizedRaw];
windows = windows.filter((windowName) => {
if (windowName === "session") return codexPolicy.use5h;
if (windowName === "weekly") return codexPolicy.useWeekly;
return true;
});
if (codexPolicy.use5h) windows.push("session");
if (codexPolicy.useWeekly) windows.push("weekly");
return windows;
return uniqueWindows(windows);
}
export function resolveQuotaLimitPolicy(
@@ -149,8 +175,7 @@ export function resolveQuotaLimitPolicy(
const windows = rawWindows.map(normalizeWindowName).filter(Boolean) as string[];
if (provider === "codex") {
const fallbackWindows = getLegacyCodexWindows(providerSpecificData);
const defaultWindows = windows.length > 0 ? windows : fallbackWindows;
const defaultWindows = applyCodexWindowPolicy(windows, providerSpecificData);
const enabled = toBooleanOrDefault(rawPolicy.enabled, defaultWindows.length > 0);
return {
@@ -0,0 +1,239 @@
import { expect, test } from "@playwright/test";
const DEFAULT_BAILIAN_URL = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
test.describe("Bailian Coding Plan Provider", () => {
test.describe.configure({ mode: "serial" });
test("default URL visible and editable in Add API Key modal", async ({ page }) => {
const capturedPayloads: { createProvider?: Record<string, unknown> } = {};
await page.route("**/api/providers", async (route) => {
const method = route.request().method();
if (method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
return;
}
if (method === "POST") {
const payload = route.request().postDataJSON();
capturedPayloads.createProvider = payload;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
connection: {
id: "conn-bailian-test",
provider: "bailian-coding-plan",
name: payload.name || "Test Connection",
testStatus: "active",
providerSpecificData: payload.providerSpecificData,
},
}),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/api/providers/validate", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ valid: true }),
});
});
await page.route("**/api/provider-nodes", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ nodes: [] }),
});
});
await page.goto("/dashboard/providers/bailian-coding-plan");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
const addKeyButton = page.getByRole("button", {
name: /add.*api.*key|add.*key|add.*connection|connect/i,
});
if (
await addKeyButton
.first()
.isVisible({ timeout: 5000 })
.catch(() => false)
) {
await addKeyButton.first().click();
}
const dialog = page.getByRole("dialog").first();
await expect(dialog).toBeVisible({ timeout: 10000 });
const baseUrlInput = dialog
.getByLabel(/base.*url/i)
.or(dialog.locator("input").filter({ has: page.locator("..").getByText(/base.*url/i) }));
await expect(baseUrlInput).toBeVisible({ timeout: 5000 });
const inputValue = await baseUrlInput.inputValue();
expect(inputValue).toBe(DEFAULT_BAILIAN_URL);
const nameInput = dialog.getByLabel(/name/i).or(dialog.locator("input").first());
await nameInput.fill("Test Bailian Connection");
const apiKeyInput = dialog
.getByLabel(/api.*key/i)
.or(dialog.locator('input[type="password"]').first());
await apiKeyInput.fill("test-api-key-12345");
const customUrl = "https://custom.example.com/anthropic/v1";
await baseUrlInput.fill(customUrl);
const saveButton = dialog
.getByRole("button", {
name: /save|add|create|connect/i,
})
.last();
await expect(saveButton).toBeEnabled({ timeout: 5000 });
await saveButton.click();
await expect(dialog)
.toBeHidden({ timeout: 10000 })
.catch(() => undefined);
expect(capturedPayloads.createProvider).toBeDefined();
const payload = capturedPayloads.createProvider;
expect(payload?.providerSpecificData).toBeDefined();
expect((payload?.providerSpecificData as Record<string, unknown>)?.baseUrl).toBe(customUrl);
});
test("invalid URL blocks save with validation error", async ({ page }) => {
let validationErrorCaptured = false;
let createAttempted = false;
await page.route("**/api/providers", async (route) => {
const method = route.request().method();
if (method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
return;
}
if (method === "POST") {
createAttempted = true;
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
message: "Invalid request",
details: [
{
field: "providerSpecificData.baseUrl",
message: "providerSpecificData.baseUrl must be a valid URL",
},
],
}),
});
return;
}
await route.fulfill({ status: 405 });
});
await page.route("**/api/providers/validate", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ valid: true }),
});
});
await page.route("**/api/provider-nodes", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ nodes: [] }),
});
});
await page.goto("/dashboard/providers/bailian-coding-plan");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
const addKeyButton = page.getByRole("button", {
name: /add.*api.*key|add.*key|add.*connection|connect/i,
});
if (
await addKeyButton
.first()
.isVisible({ timeout: 5000 })
.catch(() => false)
) {
await addKeyButton.first().click();
}
const dialog = page.getByRole("dialog").first();
await expect(dialog).toBeVisible({ timeout: 10000 });
const baseUrlInput = dialog
.getByLabel(/base.*url/i)
.or(dialog.locator("input").filter({ has: page.locator("..").getByText(/base.*url/i) }));
await expect(baseUrlInput).toBeVisible({ timeout: 5000 });
const nameInput = dialog.getByLabel(/name/i).or(dialog.locator("input").first());
await nameInput.fill("Test Invalid URL Connection");
const apiKeyInput = dialog
.getByLabel(/api.*key/i)
.or(dialog.locator('input[type="password"]').first());
await apiKeyInput.fill("test-api-key-12345");
await baseUrlInput.fill("not-a-url");
const saveButton = dialog
.getByRole("button", {
name: /save|add|create|connect/i,
})
.last();
await saveButton.click();
const errorLocator = page
.locator("text=/invalid.*url|url.*invalid|must be a valid url/i")
.or(
page
.locator(".text-red-500")
.or(page.locator('[class*="error"]').or(page.locator('[class*="text-destructive"]')))
);
await page.waitForTimeout(1000);
const errorVisible = await errorLocator.isVisible({ timeout: 5000 }).catch(() => false);
if (!errorVisible) {
await page.waitForTimeout(2000);
const modalStillOpen = await dialog.isVisible();
if (modalStillOpen) {
validationErrorCaptured = true;
}
}
expect(errorVisible).toBe(true);
expect(createAttempted).toBe(false);
});
});
@@ -0,0 +1,631 @@
import test from "node:test";
import assert from "node:assert/strict";
// Import the constants directly
const { APIKEY_PROVIDERS, OAUTH_PROVIDERS } =
await import("../../src/shared/constants/providers.ts");
// Import validateProviderApiKey for Scenario C tests
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
test("APIKEY_PROVIDERS includes bailian-coding-plan", () => {
assert.ok(
APIKEY_PROVIDERS["bailian-coding-plan"],
"bailian-coding-plan should be present in APIKEY_PROVIDERS"
);
const provider = APIKEY_PROVIDERS["bailian-coding-plan"];
assert.equal(provider.id, "bailian-coding-plan", "Provider id should be 'bailian-coding-plan'");
assert.equal(provider.alias, "bcp", "Provider alias should be 'bcp'");
assert.ok(provider.name, "Provider should have a name");
});
test("bailian-coding-plan not in OAUTH_PROVIDERS", () => {
assert.equal(
OAUTH_PROVIDERS["bailian-coding-plan"],
undefined,
"bailian-coding-plan should NOT be present in OAUTH_PROVIDERS"
);
});
// Schema validation tests for providerSpecificData.baseUrl
const { validateBody, createProviderSchema, updateProviderConnectionSchema } =
await import("../../src/shared/validation/schemas.ts");
const VALID_BAILIAN_URL = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
test("createProviderSchema accepts valid baseUrl in providerSpecificData", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
providerSpecificData: {
baseUrl: VALID_BAILIAN_URL,
},
});
assert.equal(validation.success, true, "Should accept valid URL");
if (validation.success) {
assert.equal(
validation.data.providerSpecificData?.baseUrl,
VALID_BAILIAN_URL,
"Should preserve valid baseUrl"
);
}
});
test("createProviderSchema accepts missing providerSpecificData", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
});
assert.equal(validation.success, true, "Should accept without providerSpecificData");
});
test("createProviderSchema accepts empty providerSpecificData", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
providerSpecificData: {},
});
assert.equal(validation.success, true, "Should accept empty providerSpecificData");
});
test("createProviderSchema rejects invalid baseUrl in providerSpecificData", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
providerSpecificData: {
baseUrl: "not-a-valid-url",
},
});
assert.equal(validation.success, false, "Should reject invalid URL");
if (!validation.success && typeof validation.error === "object" && validation.error !== null) {
const errorObj = validation.error;
const details = Array.isArray(errorObj.details) ? errorObj.details : [];
const errorStr = details.map((d) => d.message || "").join(", ");
assert.ok(
errorStr.includes("baseUrl") && errorStr.includes("URL"),
`Error should mention baseUrl and URL. Got: ${errorStr}`
);
}
});
test("createProviderSchema rejects malformed baseUrl (no protocol)", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
providerSpecificData: {
baseUrl: "example.com/path",
},
});
assert.equal(validation.success, false, "Should reject URL without protocol");
});
test("createProviderSchema rejects baseUrl with non-string value", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-test-key",
name: "Test Bailian",
providerSpecificData: {
baseUrl: 12345,
},
});
assert.equal(validation.success, false, "Should reject non-string baseUrl");
});
test("updateProviderConnectionSchema accepts valid baseUrl in providerSpecificData", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: VALID_BAILIAN_URL,
},
});
assert.equal(validation.success, true, "Should accept valid URL");
if (validation.success) {
assert.equal(
validation.data.providerSpecificData?.baseUrl,
VALID_BAILIAN_URL,
"Should preserve valid baseUrl"
);
}
});
test("updateProviderConnectionSchema rejects invalid baseUrl in providerSpecificData", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "invalid-url-abc",
},
});
assert.equal(validation.success, false, "Should reject invalid URL");
if (!validation.success && typeof validation.error === "object" && validation.error !== null) {
const errorObj = validation.error;
const details = Array.isArray(errorObj.details) ? errorObj.details : [];
const errorStr = details.map((d) => d.message || "").join(", ");
assert.ok(
errorStr.includes("baseUrl") && errorStr.includes("URL"),
`Error should mention baseUrl and URL. Got: ${errorStr}`
);
}
});
test("updateProviderConnectionSchema accepts partial update without baseUrl", () => {
const validation = validateBody(updateProviderConnectionSchema, {
name: "Updated Name",
priority: 5,
});
assert.equal(validation.success, true, "Should accept update without baseUrl");
});
test("updateProviderConnectionSchema rejects baseUrl with trailing garbage", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "https://example.com not-a-url",
},
});
assert.equal(validation.success, false, "Should reject URL with trailing garbage");
});
test("updateProviderConnectionSchema accepts https protocol", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "https://secure.example.com/v1",
},
});
assert.equal(validation.success, true, "Should accept https URL");
});
test("updateProviderConnectionSchema accepts http protocol", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "http://localhost:3000/v1",
},
});
assert.equal(validation.success, true, "Should accept http URL");
});
// ============================================================================
// ROUTE-LEVEL TESTS: Static model listing behavior for bailian-coding-plan
// ============================================================================
// Import the exported helper function from the route
const { getStaticModelsForProvider } =
await import("../../src/app/api/providers/[id]/models/route.ts");
test("getStaticModelsForProvider returns 8 models for bailian-coding-plan", () => {
const models = getStaticModelsForProvider("bailian-coding-plan");
assert.ok(models, "Should return models for bailian-coding-plan");
assert.ok(Array.isArray(models), "Should return an array");
assert.equal(models.length, 8, "Should return exactly 8 models");
});
test("getStaticModelsForProvider returns correct model IDs for bailian-coding-plan", () => {
const models = getStaticModelsForProvider("bailian-coding-plan");
if (!models) {
assert.fail("Models should not be undefined");
return;
}
const expectedIds = [
"qwen3.5-plus",
"qwen3-max-2026-01-23",
"qwen3-coder-next",
"qwen3-coder-plus",
"MiniMax-M2.5",
"glm-5",
"glm-4.7",
"kimi-k2.5",
];
const actualIds = models.map((m) => m.id);
for (const expectedId of expectedIds) {
assert.ok(actualIds.includes(expectedId), `Should include model: ${expectedId}`);
}
// Verify no extra models
assert.equal(actualIds.length, expectedIds.length, "Should have exactly the expected models");
});
test("getStaticModelsForProvider returns models with correct structure", () => {
const models = getStaticModelsForProvider("bailian-coding-plan");
if (!models) {
assert.fail("Models should not be undefined");
return;
}
for (const model of models) {
assert.ok(model.id, `Model should have id: ${JSON.stringify(model)}`);
assert.ok(model.name, `Model should have name: ${JSON.stringify(model)}`);
assert.equal(typeof model.id, "string", "Model id should be string");
assert.equal(typeof model.name, "string", "Model name should be string");
}
});
test("getStaticModelsForProvider returns undefined for non-static providers", () => {
// Test with providers that are NOT in STATIC_MODEL_PROVIDERS
const nonStaticProviders = ["openai", "anthropic", "deepseek", "groq", "unknown-provider"];
for (const provider of nonStaticProviders) {
const models = getStaticModelsForProvider(provider);
assert.equal(models, undefined, `Should return undefined for non-static provider: ${provider}`);
}
});
test("getStaticModelsForProvider returns models for other static providers", () => {
// Verify other static providers still work
const staticProviders = ["deepgram", "assemblyai", "nanobanana", "perplexity"];
for (const provider of staticProviders) {
const models = getStaticModelsForProvider(provider);
assert.ok(models, `Should return models for static provider: ${provider}`);
assert.ok(models.length > 0, `Should return non-empty models for: ${provider}`);
}
});
test("getStaticModelsForProvider returns models matching registry for bailian-coding-plan", async () => {
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const models = getStaticModelsForProvider("bailian-coding-plan");
const registryEntry = REGISTRY["bailian-coding-plan"];
assert.ok(models, "Static models should be defined");
assert.ok(registryEntry, "Registry entry should exist");
const registryModels = registryEntry.models;
// Verify counts match
assert.equal(
models.length,
registryModels.length,
`Static model count (${models.length}) should match registry (${registryModels.length})`
);
// Verify all model IDs match
const staticIds = new Set(models.map((m) => m.id));
const registryIds = new Set(registryModels.map((m) => m.id));
assert.equal(staticIds.size, registryIds.size, "Should have same number of unique model IDs");
// Verify each model ID exists in both
for (const model of models) {
assert.ok(registryIds.has(model.id), `Registry should have model: ${model.id}`);
}
});
test("bailian-coding-plan static models have no duplicates", () => {
const models = getStaticModelsForProvider("bailian-coding-plan");
if (!models) {
assert.fail("Models should not be undefined");
return;
}
const ids = models.map((m) => m.id);
const uniqueIds = new Set(ids);
assert.equal(ids.length, uniqueIds.size, "All model IDs should be unique (no duplicates)");
});
test("bailian-coding-plan static models are complete and valid", () => {
const models = getStaticModelsForProvider("bailian-coding-plan");
if (!models) {
assert.fail("Models should not be undefined");
return;
}
// Verify array is not empty
assert.ok(models.length > 0, "Models array should not be empty");
// Verify no null/undefined entries
for (let i = 0; i < models.length; i++) {
assert.ok(models[i], `Model at index ${i} should not be null/undefined`);
}
// Verify no empty model IDs or names
for (const model of models) {
assert.ok(
model.id && model.id.trim().length > 0,
`Model ID should be non-empty: ${JSON.stringify(model)}`
);
assert.ok(
model.name && model.name.trim().length > 0,
`Model name should be non-empty: ${JSON.stringify(model)}`
);
}
});
// ============================================================================
// SCENARIO C TESTS: validateProviderApiKey for bailian-coding-plan
// These test the key validation outcomes with mocked fetch
// ============================================================================
test("validateProviderApiKey returns invalid for 401 response (bailian-coding-plan)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "invalid-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(result.valid, false, "Should return invalid for 401");
assert.equal(result.error, "Invalid API key", "Error should be 'Invalid API key'");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateProviderApiKey returns invalid for 403 response (bailian-coding-plan)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "forbidden-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(result.valid, false, "Should return invalid for 403");
assert.equal(result.error, "Invalid API key", "Error should be 'Invalid API key'");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateProviderApiKey returns valid for 400 response (bailian-coding-plan)", async () => {
const originalFetch = globalThis.fetch;
// 400 means auth passed but request was malformed
// This is a valid auth path for bailian-coding-plan
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "invalid request" }), {
status: 400,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "valid-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(
result.valid,
true,
"Should return valid for 400 (auth passed, request malformed)"
);
assert.equal(result.error, null, "Error should be null for valid auth");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateProviderApiKey returns valid for 200 response (bailian-coding-plan)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ model: "qwen3-coder-plus" }), {
status: 200,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "valid-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(result.valid, true, "Should return valid for 200");
assert.equal(result.error, null, "Error should be null for valid auth");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateProviderApiKey returns invalid for 500 response (bailian-coding-plan)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "upstream unavailable" }), {
status: 500,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "bad-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(result.valid, false, "Should return invalid for 500");
assert.equal(result.error, "Validation failed: 500");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateProviderApiKey avoids double /messages suffix for bailian-coding-plan", async () => {
const originalFetch = globalThis.fetch;
const urls = [];
globalThis.fetch = async (url) => {
urls.push(String(url));
return new Response(JSON.stringify({ error: "invalid request" }), {
status: 400,
headers: { "content-type": "application/json" },
});
};
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "valid-key",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
},
});
assert.equal(result.valid, true);
assert.equal(urls.length, 1);
assert.equal(
urls[0],
"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
"Should probe exactly one /messages suffix"
);
} finally {
globalThis.fetch = originalFetch;
}
});
// ============================================================================
// SCENARIO A TESTS: POST /api/providers create flow validation
// These test that the schema (used by POST route) accepts valid bailian data
// ============================================================================
test("POST /api/providers validation: bailian-coding-plan with baseUrl passes schema", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-placeholder-key",
name: "Test Bailian Provider",
providerSpecificData: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(validation.success, true, "Schema should accept valid bailian-coding-plan payload");
if (validation.success) {
assert.equal(validation.data.provider, "bailian-coding-plan");
assert.equal(
validation.data.providerSpecificData?.baseUrl,
"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1"
);
}
});
test("POST /api/providers validation: bailian-coding-plan with custom baseUrl passes schema", () => {
const customUrl = "https://custom.dashscope.aliyuncs.com/apps/anthropic/v1";
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-another-placeholder",
name: "Custom Bailian",
providerSpecificData: {
baseUrl: customUrl,
},
});
assert.equal(validation.success, true, "Schema should accept custom baseUrl");
if (validation.success) {
assert.equal(validation.data.providerSpecificData?.baseUrl, customUrl);
}
});
test("POST /api/providers validation rejects non-http(s) baseUrl", () => {
const validation = validateBody(createProviderSchema, {
provider: "bailian-coding-plan",
apiKey: "sk-placeholder-key",
name: "Bad URL Scheme",
providerSpecificData: {
baseUrl: "ftp://example.com/v1",
},
});
assert.equal(validation.success, false, "Schema should reject non-http(s) URL schemes");
});
// ============================================================================
// SCENARIO B TESTS: PUT /api/providers/{id} update flow validation
// These test that the schema (used by PUT route) accepts valid baseUrl updates
// ============================================================================
test("PUT /api/providers/{id} validation: updating baseUrl passes schema", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "https://updated.dashscope.aliyuncs.com/apps/anthropic/v1",
},
});
assert.equal(validation.success, true, "Schema should accept baseUrl update");
if (validation.success) {
assert.equal(
validation.data.providerSpecificData?.baseUrl,
"https://updated.dashscope.aliyuncs.com/apps/anthropic/v1"
);
}
});
test("PUT /api/providers/{id} validation: baseUrl update with other fields passes schema", () => {
const validation = validateBody(updateProviderConnectionSchema, {
name: "Updated Bailian Name",
priority: 5,
providerSpecificData: {
baseUrl: "https://new-url.example.com/v1",
},
});
assert.equal(
validation.success,
true,
"Schema should accept update with baseUrl and other fields"
);
if (validation.success) {
assert.equal(validation.data.name, "Updated Bailian Name");
assert.equal(validation.data.priority, 5);
assert.equal(validation.data.providerSpecificData?.baseUrl, "https://new-url.example.com/v1");
}
});
test("PUT /api/providers/{id} validation rejects non-http(s) baseUrl", () => {
const validation = validateBody(updateProviderConnectionSchema, {
providerSpecificData: {
baseUrl: "file:///etc/passwd",
},
});
assert.equal(validation.success, false, "Schema should reject non-http(s) URL schemes");
});
+92
View File
@@ -0,0 +1,92 @@
import test from "node:test";
import assert from "node:assert/strict";
const usageService = await import("../../open-sse/services/usage.ts");
const providerLimitUtils = await import(
"../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx"
);
test("github copilot business seats infer business plan and hide unlimited buckets", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
access_type_sku: "copilot_business_seat",
quota_reset_date: "2026-04-01T00:00:00Z",
quota_snapshots: {
chat: { unlimited: true },
completions: { unlimited: true },
premium_interactions: {
entitlement: 300,
remaining: 180,
unlimited: false,
},
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
}
);
try {
const usage = await usageService.getUsageForProvider({
provider: "github",
accessToken: "gho_test",
providerSpecificData: {},
});
assert.equal(usage.plan, "Copilot Business");
assert.deepEqual(Object.keys(usage.quotas), ["premium_interactions"]);
assert.equal(usage.quotas.premium_interactions.total, 300);
assert.equal(usage.quotas.premium_interactions.used, 120);
assert.equal(usage.quotas.premium_interactions.remaining, 180);
assert.equal(usage.quotas.premium_interactions.remainingPercentage, 60);
const parsed = providerLimitUtils.parseQuotaData("github", usage);
assert.equal(parsed.length, 1);
assert.equal(parsed[0].name, "premium_interactions");
assert.equal(parsed[0].remainingPercentage, 60);
assert.equal(providerLimitUtils.normalizePlanTier(usage.plan).key, "business");
} finally {
globalThis.fetch = originalFetch;
}
});
test("github copilot individual paid plans no longer normalize as free", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
copilot_plan: "individual",
quota_reset_date: "2026-04-01T00:00:00Z",
quota_snapshots: {
premium_interactions: {
entitlement: 300,
remaining: 120,
unlimited: false,
},
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
}
);
try {
const usage = await usageService.getUsageForProvider({
provider: "github",
accessToken: "gho_test",
providerSpecificData: {},
});
assert.equal(usage.plan, "Copilot Pro");
assert.equal(providerLimitUtils.normalizePlanTier(usage.plan).key, "pro");
assert.equal(providerLimitUtils.normalizePlanTier("individual").key, "unknown");
} finally {
globalThis.fetch = originalFetch;
}
});
+75 -4
View File
@@ -7,10 +7,8 @@ import { detectFormat } from "../../open-sse/services/provider.ts";
import { shouldUseNativeCodexPassthrough } from "../../open-sse/handlers/chatCore.ts";
import { translateRequest } from "../../open-sse/translator/index.ts";
import { GithubExecutor } from "../../open-sse/executors/github.ts";
import {
CodexExecutor,
setDefaultFastServiceTierEnabled,
} from "../../open-sse/executors/codex.ts";
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
import { CodexExecutor, setDefaultFastServiceTierEnabled } from "../../open-sse/executors/codex.ts";
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts";
import { parseSSEToResponsesOutput } from "../../open-sse/handlers/sseParser.ts";
@@ -60,6 +58,14 @@ test("GithubExecutor keeps non-codex model on /chat/completions", () => {
assert.match(url, /\/chat\/completions$/);
});
test("DefaultExecutor uses x-api-key for kimi-coding-apikey", () => {
const executor = new DefaultExecutor("kimi-coding-apikey");
const headers = executor.buildHeaders({ apiKey: "sk-kimi-test" }, true);
assert.equal(headers["x-api-key"], "sk-kimi-test");
assert.equal(headers.Authorization, undefined);
});
test("CodexExecutor forces stream=true for upstream compatibility", () => {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
@@ -108,6 +114,24 @@ test("shouldUseNativeCodexPassthrough only enables responses-native Codex reques
false
);
assert.equal(
shouldUseNativeCodexPassthrough({
provider: "codex",
sourceFormat: FORMATS.OPENAI_RESPONSES,
endpointPath: "/v1/responses/compact",
}),
true
);
assert.equal(
shouldUseNativeCodexPassthrough({
provider: "codex",
sourceFormat: FORMATS.OPENAI_RESPONSES,
endpointPath: "/v1/responses/items/history",
}),
true
);
assert.equal(
shouldUseNativeCodexPassthrough({
provider: "codex",
@@ -140,6 +164,18 @@ test("CodexExecutor always requests SSE accept header", () => {
assert.equal(headers.Accept, "text/event-stream");
});
test("CodexExecutor does not request SSE accept header for compact requests", () => {
const executor = new CodexExecutor();
const headers = executor.buildHeaders(
{
accessToken: "test-token",
requestEndpointPath: "/v1/responses/compact",
},
false
);
assert.equal(headers.Accept, undefined);
});
test("CodexExecutor preserves native responses payloads for Codex passthrough", () => {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
@@ -167,6 +203,41 @@ test("CodexExecutor preserves native responses payloads for Codex passthrough",
assert.ok(!("_nativeCodexPassthrough" in transformed));
});
test("CodexExecutor strips streaming fields for compact passthrough", () => {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
"gpt-5.1-codex",
{
model: "gpt-5.1-codex",
input: "compact this session",
stream: false,
stream_options: { include_usage: true },
_nativeCodexPassthrough: true,
},
false,
{
requestEndpointPath: "/v1/responses/compact",
}
);
assert.equal("stream" in transformed, false);
assert.equal("stream_options" in transformed, false);
assert.ok(!("_nativeCodexPassthrough" in transformed));
});
test("CodexExecutor routes responses subpaths to matching upstream paths", () => {
const executor = new CodexExecutor();
const compactUrl = executor.buildUrl("gpt-5.1-codex", true, 0, {
requestEndpointPath: "/v1/responses/compact",
});
assert.match(compactUrl, /\/responses\/compact$/);
const genericSubpathUrl = executor.buildUrl("gpt-5.1-codex", true, 0, {
requestEndpointPath: "/v1/responses/items/history",
});
assert.match(genericSubpathUrl, /\/responses\/items\/history$/);
});
test("translateNonStreamingResponse converts Responses API payload to OpenAI chat.completion", () => {
const responseBody = {
id: "resp_123",
@@ -21,6 +21,26 @@ test("resolveQuotaLimitPolicy keeps codex legacy defaults when generic policy is
assert.equal(policy.thresholdPercent, 90);
});
test("resolveQuotaLimitPolicy enforces codex weekly window when weekly toggle is enabled", () => {
const policy = auth.resolveQuotaLimitPolicy("codex", {
codexLimitPolicy: { use5h: true, useWeekly: true },
limitPolicy: { enabled: true, windows: ["session"] },
});
assert.equal(policy.enabled, true);
assert.deepEqual(policy.windows.sort(), ["session", "weekly"]);
});
test("resolveQuotaLimitPolicy removes codex weekly window when weekly toggle is disabled", () => {
const policy = auth.resolveQuotaLimitPolicy("codex", {
codexLimitPolicy: { use5h: true, useWeekly: false },
limitPolicy: { enabled: true, windows: ["session", "weekly"] },
});
assert.equal(policy.enabled, true);
assert.deepEqual(policy.windows, ["session"]);
});
test("resolveQuotaLimitPolicy disables non-codex policy by default", () => {
const policy = auth.resolveQuotaLimitPolicy("openai", {});
assert.equal(policy.enabled, false);
@@ -60,6 +80,26 @@ test("evaluateQuotaLimitPolicy blocks when configured window reaches threshold",
assert.equal(result.resetAt, resetAt);
});
test("evaluateQuotaLimitPolicy matches canonical weekly window against labeled cache keys", () => {
const resetAt = new Date(Date.now() + 60_000).toISOString();
quotaCache.setQuotaCache("conn-policy-weekly-label", "codex", {
"weekly (7d)": { remainingPercentage: 0, resetAt },
});
const result = auth.evaluateQuotaLimitPolicy(
"codex",
buildConnection("conn-policy-weekly-label", {
codexLimitPolicy: { use5h: true, useWeekly: true },
limitPolicy: { enabled: true, windows: ["weekly"] },
})
);
assert.equal(result.blocked, true);
assert.equal(result.reasons.length, 1);
assert.match(result.reasons[0], /weekly usage/i);
assert.equal(result.resetAt, resetAt);
});
test("evaluateQuotaLimitPolicy does not block when no quota data exists", () => {
const result = auth.evaluateQuotaLimitPolicy(
"openai",
@@ -0,0 +1,157 @@
import test from "node:test";
import assert from "node:assert/strict";
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
test("serper validation accepts authenticated non-auth upstream errors", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "credits_exhausted" }), {
status: 402,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "serper-search",
apiKey: "valid-serper-key",
});
assert.equal(result.valid, true);
assert.equal(result.error, null);
assert.equal(result.unsupported, false);
} finally {
globalThis.fetch = originalFetch;
}
});
test("serper validation still rejects unauthorized keys", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 403,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "serper-search",
apiKey: "bad-serper-key",
});
assert.equal(result.valid, false);
assert.equal(result.error, "Invalid API key");
assert.equal(result.unsupported, false);
} finally {
globalThis.fetch = originalFetch;
}
});
test("kimi-coding-apikey validation uses Kimi Coding messages endpoint", async () => {
const originalFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (url, init = {}) => {
calls.push({
url: String(url),
method: init.method || "GET",
headers: init.headers || {},
});
return new Response(JSON.stringify({ ok: true }), {
status: 400,
headers: { "content-type": "application/json" },
});
};
try {
const result = await validateProviderApiKey({
provider: "kimi-coding-apikey",
apiKey: "sk-kimi-test",
});
assert.equal(result.valid, true);
assert.equal(result.error, null);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "https://api.kimi.com/coding/v1/messages");
assert.equal(calls[0].method, "POST");
assert.equal(calls[0].headers["x-api-key"], "sk-kimi-test");
assert.equal(calls[0].headers["Anthropic-Version"], "2023-06-01");
for (const call of calls) {
assert.equal(call.url.includes("?beta=true/messages"), false);
assert.equal(call.url.includes("?beta=true/models"), false);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("bailian-coding-plan validation accepts 400 as valid auth path", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "invalid request" }), {
status: 400,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "valid-bailian-key",
});
assert.equal(result.valid, true);
assert.equal(result.error, null);
} finally {
globalThis.fetch = originalFetch;
}
});
test("bailian-coding-plan validation rejects 401 as invalid key", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "bad-bailian-key",
});
assert.equal(result.valid, false);
assert.equal(result.error, "Invalid API key");
} finally {
globalThis.fetch = originalFetch;
}
});
test("bailian-coding-plan validation rejects 403 as invalid key", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "content-type": "application/json" },
});
try {
const result = await validateProviderApiKey({
provider: "bailian-coding-plan",
apiKey: "bad-bailian-key",
});
assert.equal(result.valid, false);
assert.equal(result.error, "Invalid API key");
} finally {
globalThis.fetch = originalFetch;
}
});