Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 659e2b414d | |||
| 7bcb58e3db | |||
| 2d7d7776a6 | |||
| c5f429521c | |||
| 426d8636bc | |||
| a265c7096e | |||
| 1c9953b1ba | |||
| 601cc21a44 | |||
| 102c42dfe4 | |||
| 4953727aa7 | |||
| e6af874b47 | |||
| 801b4eef4c | |||
| fe5c20a04e | |||
| 246fd05fae | |||
| a09b298127 | |||
| f89f40778f | |||
| 3d0c8d8d45 | |||
| 0e5e8bf14e | |||
| ce34d329d3 | |||
| eaf4a5805c | |||
| 8420e565d4 | |||
| 1b68deb0f6 | |||
| d1497c9ac8 | |||
| 03d4cbf6d5 | |||
| 718be831af | |||
| 9d5ec523be | |||
| 81c43b45fb | |||
| 146a491769 | |||
| 4c53388579 | |||
| 3403ddcc6e | |||
| 684b81d835 | |||
| 4f32da57fd | |||
| 97265e48b3 | |||
| 64797158e2 | |||
| 8359293dcd | |||
| b2dc53d18b | |||
| edf8dd2a12 | |||
| 5a777bd598 | |||
| bd39e01ee1 | |||
| e3ed29aab6 | |||
| 896ce9c0e2 | |||
| 82934132e9 | |||
| a2012b70de | |||
| bcfeba8a57 |
@@ -55,6 +55,8 @@ logs/*
|
||||
# analysis directories (generated, not tracked)
|
||||
.analysis/
|
||||
antigravity-manager-analysis/
|
||||
.sisyphus/
|
||||
.plans/
|
||||
|
||||
# docs (allow specific tracked files)
|
||||
docs/*
|
||||
|
||||
+161
@@ -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 (0–1) instead of percentage (0–100) (#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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">⇵</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">⇅</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 (0–1), display as percentage (0–100)
|
||||
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 (0–1); UI shows percentage (0–100)
|
||||
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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
+862
-787
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]) => [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user