Compare commits

...

19 Commits

Author SHA1 Message Date
diegosouzapw 1b68deb0f6 feat(release): v2.7.8 — budget save fix + combo agent UI + omniModel tag strip
Build Electron Desktop App / Validate version (push) Failing after 32s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- fix(budget): warningThreshold sent as fraction 0-1 not percentage 0-100 (#451)
- feat(combos): Agent Features UI in combo modal (system_message, tool_filter_regex,
  context_cache_protection) — previously server-only (#454)
- fix(combos): strip <omniModel> tags before forwarding to provider (#454)
2026-03-18 15:38:04 -03:00
Diego Rodrigues de Sa e Souza d1497c9ac8 Merge pull request #455 from diegosouzapw/fix/issue-451-454-budget-combo-ui
fix: budget warningThreshold + combo agent UI fields + omniModel tag strip
2026-03-18 15:37:17 -03:00
diegosouzapw 03d4cbf6d5 fix: budget warningThreshold fraction mismatch + combo agent UI fields + omniModel tag strip
- fix(budget): BudgetTab sent integer percentage (80) but schema validated
  fraction (0-1). Now divides by 100 on POST and multiplies by 100 on GET (#451)

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

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

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

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

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

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

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

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

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

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

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

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

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

Response size metric in results meta bar and compare table.
i18n: 30+ keys in search namespace (en.json)
2026-03-18 12:43:24 +01:00
33 changed files with 2817 additions and 399 deletions
+63
View File
@@ -4,6 +4,69 @@
---
## [2.7.8] — 2026-03-18
> Sprint: Budget save bug + combo agent features UI + omniModel tag security fix.
### 🐛 Bug Fixes
- **fix(budget)**: "Save Limits" no longer returns 422 — `warningThreshold` is now correctly sent as fraction (01) instead of percentage (0100) (#451)
- **fix(combos)**: `<omniModel>` internal cache tag is now stripped before forwarding requests to providers, preventing cache session breaks (#454)
### ✨ Features
- **feat(combos)**: Agent Features section added to combo create/edit modal — expose `system_message` override, `tool_filter_regex`, and `context_cache_protection` directly from the dashboard (#454)
---
## [2.7.7] — 2026-03-18
> Sprint: Docker pino crash, Codex CLI responses worker fix, package-lock sync.
### 🐛 Bug Fixes
- **fix(docker)**: `pino-abstract-transport` and `pino-pretty` now explicitly copied in Docker runner stage — Next.js standalone trace misses these peer deps, causing `Cannot find module pino-abstract-transport` crash on startup (#449)
- **fix(responses)**: Remove `initTranslators()` from `/v1/responses` route — was crashing Next.js worker with `the worker has exited` uncaughtException on Codex CLI requests (#450)
### 🔧 Maintenance
- **chore(deps)**: `package-lock.json` now committed on every version bump to ensure Docker `npm ci` uses exact dependency versions
---
## [2.7.5] — 2026-03-18
> Sprint: UX improvements and Windows CLI healthcheck fix.
### 🐛 Bug Fixes
- **fix(ux)**: Show default password hint on login page — new users now see `"Default password: 123456"` below the password input (#437)
- **fix(cli)**: Claude CLI and other npm-installed tools now correctly detected as runnable on Windows — spawn uses `shell:true` to resolve `.cmd` wrappers via PATHEXT (#447)
---
## [2.7.4] — 2026-03-18
> Sprint: Search Tools dashboard, i18n fixes, Copilot limits, Serper validation fix.
### 🚀 Features
- **feat(search)**: Add Search Playground (10th endpoint), Search Tools page with Compare Providers/Rerank Pipeline/Search History, local rerank routing, auth guards on search API (#443 by @Regis-RCR)
- New route: `/dashboard/search-tools`
- Sidebar entry under Debug section
- `GET /api/search/providers` and `GET /api/search/stats` with auth guards
- Local provider_nodes routing for `/v1/rerank`
- 30+ i18n keys in search namespace
### 🐛 Bug Fixes
- **fix(search)**: Fix Brave news normalizer (was returning 0 results), enforce max_results truncation post-normalization, fix Endpoints page fetch URL (#443 by @Regis-RCR)
- **fix(analytics)**: Localize analytics day/date labels — replace hardcoded Portuguese strings with `Intl.DateTimeFormat(locale)` (#444 by @hijak)
- **fix(copilot)**: Correct GitHub Copilot account type display, filter misleading unlimited quota rows from limits dashboard (#445 by @hijak)
- **fix(providers)**: Stop rejecting valid Serper API keys — treat non-4xx responses as valid authentication (#446 by @hijak)
---
## [2.7.3] — 2026-03-18
> Sprint: Codex direct API quota fallback fix.
+4
View File
@@ -32,6 +32,10 @@ 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/scripts/run-standalone.mjs ./run-standalone.mjs
COPY --from=builder /app/scripts/runtime-env.mjs ./runtime-env.mjs
COPY --from=builder /app/scripts/bootstrap-env.mjs ./bootstrap-env.mjs
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.7.3
version: 2.7.8
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,
+22 -6
View File
@@ -75,7 +75,12 @@ interface SearchHandlerOptions {
timeRange?: string;
offset?: number;
domainFilter?: string[];
contentOptions?: { snippet?: boolean; full_page?: boolean; format?: string; max_characters?: number };
contentOptions?: {
snippet?: boolean;
full_page?: boolean;
format?: string;
max_characters?: number;
};
strictFilters?: boolean;
providerOptions?: Record<string, unknown>;
credentials: Record<string, any>;
@@ -189,7 +194,9 @@ function normalizeBraveResponse(
searchType: string
): { results: SearchResult[]; totalResults: number | null } {
const now = new Date().toISOString();
const container = searchType === "news" ? data.news : data.web;
// Brave news endpoint returns { results: [...] } directly,
// while web endpoint returns { web: { results: [...] } }
const container = searchType === "news" ? data.news || data : data.web;
const items = container?.results;
if (!Array.isArray(items)) return { results: [], totalResults: null };
@@ -593,7 +600,9 @@ async function tryProvider(
search_type: searchType,
max_results: maxResults,
},
}).catch(() => { /* non-critical — logging must not block search response */ });
}).catch(() => {
/* non-critical — logging must not block search response */
});
return {
success: false,
@@ -603,7 +612,10 @@ async function tryProvider(
}
const data = await response.json();
const { results, totalResults } = normalizeResponse(config.id, data, query, searchType);
const normalized = normalizeResponse(config.id, data, query, searchType);
// Enforce max_results — some providers return more than requested
const results = normalized.results.slice(0, maxResults);
const totalResults = normalized.totalResults;
const duration = Date.now() - startTime;
saveCallLog({
@@ -617,7 +629,9 @@ async function tryProvider(
tokens: { prompt_tokens: 0, completion_tokens: 0 },
requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults },
responseBody: { results_count: results.length, cached: false },
}).catch(() => { /* non-critical — logging must not block search response */ });
}).catch(() => {
/* non-critical — logging must not block search response */
});
return {
success: true,
@@ -653,7 +667,9 @@ async function tryProvider(
requestType: "search",
error: err.message,
requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults },
}).catch(() => { /* non-critical — logging must not block search response */ });
}).catch(() => {
/* non-critical — logging must not block search response */
});
return {
success: false,
+19
View File
@@ -123,6 +123,20 @@ export function applyToolFilter(
});
}
/**
* Strip all <omniModel> tags from message content before forwarding to the provider.
* The tag is an internal OmniRoute marker; providers must never see it or their
* cache will treat every tagged request as a new session (#454).
*/
export function stripModelTags(messages: Message[]): Message[] {
return messages.map((msg) => {
if (typeof msg.content === "string" && CACHE_TAG_PATTERN.test(msg.content)) {
return { ...msg, content: msg.content.replace(CACHE_TAG_PATTERN, "").trimEnd() };
}
return msg;
});
}
// ── Main Middleware ──────────────────────────────────────────────────────────
/**
@@ -158,6 +172,11 @@ export function applyComboAgentMiddleware(
comboConfig.tool_filter_regex
);
// 4. Strip internal <omniModel> tags before forwarding to provider (#454)
// These tags are OmniRoute-internal markers and must never reach the provider
// since providers would treat each tagged request as a new cache session.
messages = stripModelTags(messages);
return {
body: {
...body,
+166 -39
View File
@@ -75,6 +75,30 @@ function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unk
return obj[snakeKey] ?? obj[camelKey] ?? null;
}
function clampPercentage(value: number): number {
return Math.max(0, Math.min(100, value));
}
function toDisplayLabel(value: string): string {
return value
.replace(/^copilot[_\s-]*/i, "")
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => {
if (/^pro\+$/i.test(part)) return "Pro+";
if (/^[a-z]{2,}$/.test(part)) return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
return part;
})
.join(" ")
.trim();
}
function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota {
if (!quota) return false;
if (quota.unlimited && quota.total <= 0) return false;
return quota.total > 0 || quota.remainingPercentage !== undefined;
}
/**
* Get usage data for a provider connection
* @param {Object} connection - Provider connection with accessToken
@@ -170,48 +194,65 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
}
const data = await response.json();
const dataRecord = toRecord(data);
// Handle different response formats (paid vs free)
if (data.quota_snapshots) {
if (dataRecord.quota_snapshots) {
// Paid plan format
const snapshots = data.quota_snapshots;
const resetAt = parseResetTime(data.quota_reset_date);
const snapshots = toRecord(dataRecord.quota_snapshots);
const resetAt = parseResetTime(getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"));
const premiumQuota = formatGitHubQuotaSnapshot(snapshots.premium_interactions, resetAt);
const chatQuota = formatGitHubQuotaSnapshot(snapshots.chat, resetAt);
const completionsQuota = formatGitHubQuotaSnapshot(snapshots.completions, resetAt);
const quotas: Record<string, UsageQuota> = {};
if (shouldDisplayGitHubQuota(premiumQuota)) {
quotas.premium_interactions = premiumQuota;
}
if (shouldDisplayGitHubQuota(chatQuota)) {
quotas.chat = chatQuota;
}
if (shouldDisplayGitHubQuota(completionsQuota)) {
quotas.completions = completionsQuota;
}
return {
plan: data.copilot_plan,
resetDate: data.quota_reset_date,
quotas: {
chat: { ...formatGitHubQuotaSnapshot(snapshots.chat), resetAt },
completions: { ...formatGitHubQuotaSnapshot(snapshots.completions), resetAt },
premium_interactions: {
...formatGitHubQuotaSnapshot(snapshots.premium_interactions),
resetAt,
},
},
plan: inferGitHubPlanName(dataRecord, premiumQuota),
resetDate: getFieldValue(dataRecord, "quota_reset_date", "quotaResetDate"),
quotas,
};
} else if (data.monthly_quotas || data.limited_user_quotas) {
} else if (dataRecord.monthly_quotas || dataRecord.limited_user_quotas) {
// Free/limited plan format
const monthlyQuotas = data.monthly_quotas || {};
const usedQuotas = data.limited_user_quotas || {};
const resetAt = parseResetTime(data.limited_user_reset_date);
const monthlyQuotas = toRecord(dataRecord.monthly_quotas);
const usedQuotas = toRecord(dataRecord.limited_user_quotas);
const resetDate = getFieldValue(dataRecord, "limited_user_reset_date", "limitedUserResetDate");
const resetAt = parseResetTime(resetDate);
const quotas: Record<string, UsageQuota> = {};
const addLimitedQuota = (name: string) => {
const total = toNumber(getFieldValue(monthlyQuotas, name, name), 0);
const used = Math.max(0, toNumber(getFieldValue(usedQuotas, name, name), 0));
if (total <= 0) return null;
const clampedUsed = Math.min(used, total);
quotas[name] = {
used: clampedUsed,
total,
remaining: Math.max(total - clampedUsed, 0),
remainingPercentage: clampPercentage(((total - clampedUsed) / total) * 100),
unlimited: false,
resetAt,
};
return quotas[name];
};
const premiumQuota = addLimitedQuota("premium_interactions");
addLimitedQuota("chat");
addLimitedQuota("completions");
return {
plan: data.copilot_plan || data.access_type_sku,
resetDate: data.limited_user_reset_date,
quotas: {
chat: {
used: usedQuotas.chat || 0,
total: monthlyQuotas.chat || 0,
unlimited: false,
resetAt,
},
completions: {
used: usedQuotas.completions || 0,
total: monthlyQuotas.completions || 0,
unlimited: false,
resetAt,
},
},
plan: inferGitHubPlanName(dataRecord, premiumQuota),
resetDate,
quotas,
};
}
@@ -221,17 +262,103 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
}
}
function formatGitHubQuotaSnapshot(quota) {
if (!quota) return { used: 0, total: 0, unlimited: true };
function formatGitHubQuotaSnapshot(quota, resetAt: string | null = null): UsageQuota | null {
const source = toRecord(quota);
if (Object.keys(source).length === 0) return null;
const unlimited = source.unlimited === true;
const entitlement = toNumber(source.entitlement, Number.NaN);
const totalValue = toNumber(source.total, Number.NaN);
const remainingValue = toNumber(source.remaining, Number.NaN);
const usedValue = toNumber(source.used, Number.NaN);
const percentRemainingValue = toNumber(
getFieldValue(source, "percent_remaining", "percentRemaining"),
Number.NaN
);
let total = Number.isFinite(totalValue)
? Math.max(0, totalValue)
: Number.isFinite(entitlement)
? Math.max(0, entitlement)
: 0;
let remaining = Number.isFinite(remainingValue) ? Math.max(0, remainingValue) : undefined;
let used = Number.isFinite(usedValue) ? Math.max(0, usedValue) : undefined;
let remainingPercentage = Number.isFinite(percentRemainingValue)
? clampPercentage(percentRemainingValue)
: undefined;
if (used === undefined && total > 0 && remaining !== undefined) {
used = Math.max(total - remaining, 0);
}
if (remaining === undefined && total > 0 && used !== undefined) {
remaining = Math.max(total - used, 0);
}
if (remainingPercentage === undefined && total > 0 && remaining !== undefined) {
remainingPercentage = clampPercentage((remaining / total) * 100);
}
if (total <= 0 && remainingPercentage !== undefined) {
total = 100;
used = 100 - remainingPercentage;
remaining = remainingPercentage;
}
return {
used: quota.entitlement - quota.remaining,
total: quota.entitlement,
remaining: quota.remaining,
unlimited: quota.unlimited || false,
used: Math.max(0, used ?? 0),
total,
remaining,
remainingPercentage,
resetAt,
unlimited,
};
}
function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): string {
const rawPlan = getFieldValue(data, "copilot_plan", "copilotPlan");
const rawSku = getFieldValue(data, "access_type_sku", "accessTypeSku");
const planText = typeof rawPlan === "string" ? rawPlan.trim() : "";
const skuText = typeof rawSku === "string" ? rawSku.trim() : "";
const combined = `${skuText} ${planText}`.trim().toUpperCase();
const monthlyQuotas = toRecord(getFieldValue(data, "monthly_quotas", "monthlyQuotas"));
const premiumTotal =
premiumQuota?.total ||
toNumber(getFieldValue(monthlyQuotas, "premium_interactions", "premiumInteractions"), 0);
const chatTotal = toNumber(getFieldValue(monthlyQuotas, "chat", "chat"), 0);
if (
combined.includes("PRO+") ||
combined.includes("PRO_PLUS") ||
combined.includes("PROPLUS")
) {
return "Copilot Pro+";
}
if (combined.includes("ENTERPRISE")) return "Copilot Enterprise";
if (combined.includes("BUSINESS")) return "Copilot Business";
if (combined.includes("STUDENT")) return "Copilot Student";
if (combined.includes("FREE")) return "Copilot Free";
if (combined.includes("PRO")) return "Copilot Pro";
if (premiumTotal >= 1400) return "Copilot Pro+";
if (premiumTotal >= 900) return "Copilot Enterprise";
if (premiumTotal >= 250) {
if (combined.includes("INDIVIDUAL")) return "Copilot Pro";
return "Copilot Business";
}
if (premiumTotal > 0 || chatTotal === 50) return "Copilot Free";
if (skuText) {
const label = toDisplayLabel(skuText);
return label ? `Copilot ${label}` : "GitHub Copilot";
}
if (planText) {
const label = toDisplayLabel(planText);
return label ? `Copilot ${label}` : "GitHub Copilot";
}
return "GitHub Copilot";
}
/**
* Gemini CLI Usage (Google Cloud)
*/
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.7.0",
"version": "2.7.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.7.0",
"version": "2.7.7",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.7.3",
"version": "2.7.8",
"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": {
@@ -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
@@ -0,0 +1,406 @@
"use client";
import { useState, useEffect, useRef } from "react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { Card, Button, Select, Badge } from "@/shared/components";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
interface SearchProvider {
id: string;
name: string;
status: "active" | "no_credentials";
cost_per_query: number;
}
interface SearchResult {
title: string;
url: string;
snippet: string;
score?: number;
date?: string;
}
interface SearchResponse {
id: string;
provider: string;
results: SearchResult[];
query: string;
answer: string | null;
cached: boolean;
usage: {
queries_used: number;
search_cost_usd: number;
};
metrics: {
response_time_ms: number;
upstream_latency_ms: number;
total_results_available: number | null;
};
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
export default function SearchPlayground() {
const t = useTranslations("search");
const [providers, setProviders] = useState<SearchProvider[]>([]);
const [selectedProvider, setSelectedProvider] = useState("");
const [requestBody, setRequestBody] = useState(
JSON.stringify(
{
query: "latest AI developments",
max_results: 5,
search_type: "web",
},
null,
2
)
);
const [response, setResponse] = useState<SearchResponse | null>(null);
const [rawResponse, setRawResponse] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [duration, setDuration] = useState(0);
const [statusCode, setStatusCode] = useState(0);
const [showJson, setShowJson] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
fetch("/api/search/providers")
.then((res) => res.json())
.then((data) => {
const allProviders = data.providers || [];
setProviders(allProviders);
const firstActive = allProviders.find((p: SearchProvider) => p.status === "active");
if (firstActive) setSelectedProvider(firstActive.id);
})
.catch(() => {});
}, []);
const handleSend = async () => {
setLoading(true);
setError("");
setResponse(null);
setRawResponse("");
setStatusCode(0);
const controller = new AbortController();
abortRef.current = controller;
const timeout = setTimeout(() => controller.abort(), 15_000);
const start = Date.now();
try {
let body: any;
try {
body = JSON.parse(requestBody);
} catch {
setError("Invalid JSON in request body");
setLoading(false);
clearTimeout(timeout);
return;
}
if (selectedProvider) body.provider = selectedProvider;
const res = await fetch("/api/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
setDuration(Date.now() - start);
setStatusCode(res.status);
const data = await res.json();
setRawResponse(JSON.stringify(data, null, 2));
if (res.ok) {
setResponse(data);
} else {
setError(data.error?.message || data.error || `Error ${res.status}`);
}
} catch (err: any) {
setDuration(Date.now() - start);
if (err.name === "AbortError") {
setError("Request timed out (15s)");
} else {
setError(err.message || "Network error");
}
} finally {
setLoading(false);
clearTimeout(timeout);
}
};
const handleCancel = () => {
abortRef.current?.abort();
};
const getScoreColor = (score: number) => {
if (score >= 0.9) return "text-success";
if (score >= 0.7) return "text-warning";
return "text-error";
};
const getScoreBg = (score: number) => {
if (score >= 0.9) return "bg-green-500/10";
if (score >= 0.7) return "bg-yellow-500/10";
return "bg-red-500/10";
};
const noProviders = providers.filter((p) => p.status === "active").length === 0;
const editorTheme =
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
? "vs-dark"
: "light";
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">upload</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<Badge variant="info" size="sm">
POST /v1/search
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => navigator.clipboard.writeText(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() =>
setRequestBody(
JSON.stringify(
{
query: "latest AI developments",
max_results: 5,
search_type: "web",
},
null,
2
)
)
}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme={editorTheme}
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<Select
value={selectedProvider}
onChange={(e: any) => setSelectedProvider(e.target.value)}
options={providers.map((p) => ({
value: p.id,
label: `${p.name}${p.status === "no_credentials" ? " (no key)" : ""}`,
}))}
className="w-full"
/>
</div>
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
</Button>
) : (
<Button
icon="search"
onClick={handleSend}
disabled={noProviders || !requestBody.trim()}
>
{t("webSearch")}
</Button>
)}
</div>
{noProviders && <p className="text-xs text-text-muted">{t("noSearchProviders")}</p>}
</div>
</Card>
{/* Response panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
{statusCode > 0 && (
<>
<Badge variant={statusCode < 400 ? "success" : "error"} size="sm">
{statusCode}
</Badge>
<span className="text-xs text-text-muted">{duration}ms</span>
</>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
{response && (
<div className="flex gap-1">
<button
className={`text-xs px-3 py-1 rounded-md ${
!showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(false)}
>
{t("formatted")}
</button>
<button
className={`text-xs px-3 py-1 rounded-md ${
showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(true)}
>
{t("rawJson")}
</button>
</div>
)}
</div>
<div className="border border-border rounded-lg overflow-hidden min-h-[400px]">
{loading && (
<div className="flex items-center justify-center h-[400px]">
<span className="material-symbols-outlined text-[24px] text-primary animate-spin">
progress_activity
</span>
</div>
)}
{error && !loading && (
<div className="p-4">
<div className="text-error text-sm">{error}</div>
</div>
)}
{response && !showJson && !loading && (
<div className="p-4 space-y-3">
{/* Meta bar */}
<div className="flex justify-between items-center p-2 bg-bg-alt rounded-lg">
<div className="flex items-center gap-3 text-xs text-text-muted">
<span>
{response.results.length} {t("searchResults").toLowerCase()}
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
{response.provider}
</span>
<span>${response.usage?.search_cost_usd?.toFixed(4)}</span>
<span>{formatBytes(rawResponse.length)}</span>
</div>
<span
className={`text-xs flex items-center gap-1 ${
response.cached ? "text-success" : "text-warning"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
response.cached ? "bg-success" : "bg-warning"
}`}
/>
{response.cached ? t("cacheHit") : t("cacheMiss")}
</span>
</div>
{/* Results */}
{response.results.map((r, i) => (
<div
key={i}
className="border-l-[3px] border-l-primary p-3 bg-surface rounded-r-lg border border-border"
>
<div className="flex justify-between items-start">
<span className="text-sm font-medium text-text-main">
{i + 1}. {r.title}
</span>
{r.score != null && (
<span
className={`text-[10px] px-2 py-0.5 rounded-md ml-2 whitespace-nowrap ${getScoreBg(r.score)} ${getScoreColor(r.score)}`}
>
{r.score.toFixed(2)}
</span>
)}
</div>
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent text-[11px] block mt-0.5"
>
{r.url}
</a>
<p className="text-xs text-text-muted mt-1 leading-relaxed">{r.snippet}</p>
</div>
))}
</div>
)}
{response && showJson && !loading && (
<Editor
height="400px"
defaultLanguage="json"
value={rawResponse}
theme={editorTheme}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
}}
/>
)}
{!loading && !error && !response && (
<div className="flex items-center justify-center h-[400px] text-text-muted text-sm">
{t("emptyState")}
</div>
)}
</div>
</div>
</Card>
</div>
);
}
+305 -279
View File
@@ -5,6 +5,9 @@ import { Card, Button, Select, Badge } from "@/shared/components";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
const SearchPlayground = dynamic(() => import("./SearchPlayground"), {
ssr: false,
});
interface ModelInfo {
id: string;
@@ -27,6 +30,7 @@ const ENDPOINT_OPTIONS = [
{ value: "video", label: "Video Generation" },
{ value: "music", label: "Music Generation" },
{ value: "rerank", label: "Rerank" },
{ value: "search", label: "Web Search" },
];
const DEFAULT_BODIES: Record<string, object> = {
@@ -83,6 +87,11 @@ const DEFAULT_BODIES: Record<string, object> = {
],
top_n: 2,
},
search: {
query: "latest AI developments",
max_results: 5,
search_type: "web",
},
};
const ENDPOINT_PATHS: Record<string, string> = {
@@ -95,6 +104,7 @@ const ENDPOINT_PATHS: Record<string, string> = {
video: "/v1/videos/generations",
music: "/v1/music/generations",
rerank: "/v1/rerank",
search: "/v1/search",
};
// Models known to support vision (image input)
@@ -189,6 +199,7 @@ export default function PlaygroundPage() {
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadedImages, setUploadedImages] = useState<string[]>([]); // base64 URIs for vision
const isSearchEndpoint = selectedEndpoint === "search";
const isTranscriptionEndpoint = selectedEndpoint === "transcription";
const isChatEndpoint = selectedEndpoint === "chat";
const isImageEndpoint = selectedEndpoint === "images";
@@ -419,33 +430,7 @@ export default function PlaygroundPage() {
{/* Controls */}
<Card>
<div className="p-4 flex flex-col sm:flex-row items-end gap-4">
{/* Provider */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Provider
</label>
<Select
value={selectedProvider}
onChange={(e: any) => handleProviderChange(e.target.value)}
options={providers}
className="w-full"
/>
</div>
{/* Model */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Model
</label>
<Select
value={selectedModel}
onChange={(e: any) => handleModelChange(e.target.value)}
options={filteredModels}
className="w-full"
/>
</div>
{/* Endpoint */}
{/* Endpoint — always first */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Endpoint
@@ -458,274 +443,315 @@ export default function PlaygroundPage() {
/>
</div>
{/* Send Button */}
<div className="shrink-0">
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
</Button>
) : (
<Button
icon="send"
onClick={handleSend}
disabled={
(!requestBody.trim() && !isTranscriptionEndpoint) ||
(!selectedModel && !isTranscriptionEndpoint)
}
>
Send
</Button>
)}
</div>
{/* Provider — hidden in search mode */}
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Provider
</label>
<Select
value={selectedProvider}
onChange={(e: any) => handleProviderChange(e.target.value)}
options={providers}
className="w-full"
/>
</div>
)}
{/* Model — hidden in search mode */}
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Model
</label>
<Select
value={selectedModel}
onChange={(e: any) => handleModelChange(e.target.value)}
options={filteredModels}
className="w-full"
/>
</div>
)}
{/* Send Button — hidden in search mode (SearchPlayground has its own) */}
{!isSearchEndpoint && (
<div className="shrink-0">
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
</Button>
) : (
<Button
icon="send"
onClick={handleSend}
disabled={
(!requestBody.trim() && !isTranscriptionEndpoint) ||
(!selectedModel && !isTranscriptionEndpoint)
}
>
Send
</Button>
)}
</div>
)}
</div>
</Card>
{/* File Upload Zone — shown for transcription and vision models */}
{(isTranscriptionEndpoint || supportsVision) && (
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
attach_file
</span>
<h3 className="text-sm font-semibold text-text-main">
{isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"}
</h3>
{isTranscriptionEndpoint && (
<Badge variant="info" size="sm">
multipart/form-data
</Badge>
)}
{supportsVision && (
<Badge variant="info" size="sm">
up to 4 images
</Badge>
)}
</div>
{isTranscriptionEndpoint && (
<div>
<input
type="file"
accept="audio/*,video/*"
onChange={handleAudioFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedFile && (
<p className="text-xs text-text-muted mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px] text-green-500">
check_circle
</span>
{uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB)
</p>
{/* Search mode — isolated sub-component */}
{isSearchEndpoint ? (
<SearchPlayground />
) : (
<>
{/* File Upload Zone — shown for transcription and vision models */}
{(isTranscriptionEndpoint || supportsVision) && (
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
attach_file
</span>
<h3 className="text-sm font-semibold text-text-main">
{isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"}
</h3>
{isTranscriptionEndpoint && (
<Badge variant="info" size="sm">
multipart/form-data
</Badge>
)}
{supportsVision && (
<Badge variant="info" size="sm">
up to 4 images
</Badge>
)}
</div>
{isTranscriptionEndpoint && (
<div>
<input
type="file"
accept="audio/*,video/*"
onChange={handleAudioFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedFile && (
<p className="text-xs text-text-muted mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px] text-green-500">
check_circle
</span>
{uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB)
</p>
)}
{!uploadedFile && (
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">info</span>
Select an audio file to transcribe (mp3, wav, m4a, ogg, flac)
</p>
)}
</div>
)}
{!uploadedFile && (
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">info</span>
Select an audio file to transcribe (mp3, wav, m4a, ogg, flac)
</p>
)}
</div>
)}
{supportsVision && (
<div>
<input
type="file"
accept="image/*"
multiple
onChange={handleImageFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedImages.length > 0 && (
<div className="flex gap-2 mt-2 flex-wrap">
{uploadedImages.map((src, i) => (
<div
key={i}
className="relative group size-16 rounded overflow-hidden border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={`Attached ${i + 1}`}
className="w-full h-full object-cover"
/>
{supportsVision && (
<div>
<input
type="file"
accept="image/*"
multiple
onChange={handleImageFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedImages.length > 0 && (
<div className="flex gap-2 mt-2 flex-wrap">
{uploadedImages.map((src, i) => (
<div
key={i}
className="relative group size-16 rounded overflow-hidden border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={`Attached ${i + 1}`}
className="w-full h-full object-cover"
/>
<button
onClick={() =>
setUploadedImages((prev) => prev.filter((_, idx) => idx !== i))
}
className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
<button
onClick={() =>
setUploadedImages((prev) => prev.filter((_, idx) => idx !== i))
}
className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
onClick={() => setUploadedImages([])}
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
>
<span className="material-symbols-outlined text-[16px]">close</span>
Clear all
</button>
</div>
))}
)}
</div>
)}
</div>
</Card>
)}
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
upload
</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<Badge variant="info" size="sm">
POST {ENDPOINT_PATHS[selectedEndpoint]}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setUploadedImages([])}
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
onClick={() => handleCopy(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
Clear all
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() => {
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
if ("model" in template) (template as any).model = selectedModel;
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
)}
</div>
)}
</div>
</Card>
)}
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
upload
</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<Badge variant="info" size="sm">
POST {ENDPOINT_PATHS[selectedEndpoint]}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() => {
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
if ("model" in template) (template as any).model = selectedModel;
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
</div>
{isTranscriptionEndpoint && (
<p className="text-xs text-text-muted bg-amber-500/10 border border-amber-500/20 rounded px-2 py-1.5 flex items-start gap-1">
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
info
</span>
Transcription uses multipart/form-data. Upload the audio file above JSON below
controls extra params (model, language).
</p>
)}
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
</div>
</Card>
{/* Response Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
{responseStatus !== null && (
<Badge
variant={responseStatus >= 200 && responseStatus < 300 ? "success" : "error"}
size="sm"
>
{responseStatus}
</Badge>
)}
{responseDuration !== null && (
<span className="text-xs text-text-muted">{responseDuration}ms</span>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(responseBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
{audioUrl ? (
<div className="p-4 space-y-3">
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
<a
href={audioUrl}
download="speech.mp3"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
Download audio
</a>
</div>
) : imageData ? (
<ImageResultsInline data={imageData} />
) : transcriptionText !== null ? (
<div className="p-4 space-y-2">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
Transcription
{isTranscriptionEndpoint && (
<p className="text-xs text-text-muted bg-amber-500/10 border border-amber-500/20 rounded px-2 py-1.5 flex items-start gap-1">
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
info
</span>
Transcription uses multipart/form-data. Upload the audio file above JSON below
controls extra params (model, language).
</p>
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
{transcriptionText}
</div>
<button
onClick={() => handleCopy(transcriptionText)}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<span className="material-symbols-outlined text-[12px]">content_copy</span>
Copy text
</button>
)}
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
) : (
<Editor
height="400px"
defaultLanguage="json"
value={responseBody}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
)}
</div>
</div>
</Card>
{/* Response Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
{responseStatus !== null && (
<Badge
variant={
responseStatus >= 200 && responseStatus < 300 ? "success" : "error"
}
size="sm"
>
{responseStatus}
</Badge>
)}
{responseDuration !== null && (
<span className="text-xs text-text-muted">{responseDuration}ms</span>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(responseBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
{audioUrl ? (
<div className="p-4 space-y-3">
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
<a
href={audioUrl}
download="speech.mp3"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
Download audio
</a>
</div>
) : imageData ? (
<ImageResultsInline data={imageData} />
) : transcriptionText !== null ? (
<div className="p-4 space-y-2">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
Transcription
</p>
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
{transcriptionText}
</div>
<button
onClick={() => handleCopy(transcriptionText)}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<span className="material-symbols-outlined text-[12px]">content_copy</span>
Copy text
</button>
</div>
) : (
<Editor
height="400px"
defaultLanguage="json"
value={responseBody}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
)}
</div>
</div>
</Card>
</div>
</Card>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,297 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
const SearchForm = dynamic(() => import("./components/SearchForm"), {
ssr: false,
});
const SearchHistory = dynamic(() => import("./components/SearchHistory"), {
ssr: false,
});
const ResultsPanel = dynamic(() => import("./components/ResultsPanel"), {
ssr: false,
});
const ProviderComparison = dynamic(() => import("./components/ProviderComparison"), { ssr: false });
const RerankPanel = dynamic(() => import("./components/RerankPanel"), {
ssr: false,
});
import type { SearchFormData } from "./components/SearchForm";
import type { CompareResult } from "./components/ProviderComparison";
interface SearchProvider {
id: string;
name: string;
status: "active" | "no_credentials";
cost_per_query: number;
}
interface SearchResult {
title: string;
url: string;
snippet: string;
score?: number;
}
interface SearchResponse {
id: string;
provider: string;
query: string;
results: SearchResult[];
cached: boolean;
usage: {
queries_used: number;
search_cost_usd: number;
};
metrics: {
response_time_ms: number;
upstream_latency_ms: number;
total_results_available: number | null;
};
}
export default function SearchToolsClient() {
const t = useTranslations("search");
const [providers, setProviders] = useState<SearchProvider[]>([]);
const [response, setResponse] = useState<SearchResponse | null>(null);
const [rawJson, setRawJson] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [statusCode, setStatusCode] = useState(0);
const [duration, setDuration] = useState(0);
const [lastQuery, setLastQuery] = useState<SearchFormData | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [showCompare, setShowCompare] = useState(false);
const [compareLoading, setCompareLoading] = useState(false);
const [compareResults, setCompareResults] = useState<CompareResult[]>([]);
const [initialCompareResult, setInitialCompareResult] = useState<CompareResult | null>(null);
const [showRerank, setShowRerank] = useState(false);
useEffect(() => {
fetch("/api/search/providers")
.then((res) => res.json())
.then((data) => setProviders(data.providers || []))
.catch(() => {});
}, []);
const handleSearch = async (formData: SearchFormData) => {
setLoading(true);
setError("");
setResponse(null);
setRawJson("");
setStatusCode(0);
setShowCompare(false);
setShowRerank(false);
setCompareResults([]);
const controller = new AbortController();
abortRef.current = controller;
const timeout = setTimeout(() => controller.abort(), 15_000);
const start = Date.now();
try {
const body: any = { ...formData };
if (!body.provider) delete body.provider;
const res = await fetch("/api/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
setDuration(Date.now() - start);
setStatusCode(res.status);
const data = await res.json();
setRawJson(JSON.stringify(data, null, 2));
setLastQuery(formData);
if (res.ok) {
setResponse(data);
} else {
setError(data.error?.message || data.error || `Error ${res.status}`);
}
} catch (err: any) {
setDuration(Date.now() - start);
if (err.name === "AbortError") {
setError("Request timed out (15s)");
} else {
setError(err.message || "Network error");
}
} finally {
setLoading(false);
clearTimeout(timeout);
}
};
const handleCompare = async () => {
if (!response || !lastQuery) return;
const usedProvider = response.provider;
const otherProviders = providers
.filter((p) => p.status === "active" && p.id !== usedProvider)
.map((p) => p.id);
if (otherProviders.length === 0) return;
const initial: CompareResult = {
provider: usedProvider,
latency: response.metrics.response_time_ms,
cost: response.usage.search_cost_usd,
resultCount: response.results.length,
responseSize: rawJson.length,
urls: response.results.map((r) => r.url),
};
setInitialCompareResult(initial);
setShowCompare(true);
setCompareLoading(true);
const promises = otherProviders.map(async (providerId) => {
const start = Date.now();
try {
const res = await fetch("/api/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...lastQuery, provider: providerId }),
});
const data = await res.json();
const elapsed = Date.now() - start;
if (!res.ok) {
return {
provider: providerId,
latency: elapsed,
cost: 0,
resultCount: 0,
responseSize: 0,
urls: [],
error: data.error?.message || `Error ${res.status}`,
} as CompareResult;
}
const respJson = JSON.stringify(data);
return {
provider: providerId,
latency: data.metrics?.response_time_ms || elapsed,
cost: data.usage?.search_cost_usd || 0,
resultCount: data.results?.length || 0,
responseSize: respJson.length,
urls: (data.results || []).map((r: any) => r.url),
} as CompareResult;
} catch (err: any) {
return {
provider: providerId,
latency: Date.now() - start,
cost: 0,
resultCount: 0,
responseSize: 0,
urls: [],
error: err.message,
} as CompareResult;
}
});
const results = await Promise.allSettled(promises);
setCompareResults(
results.map((r) =>
r.status === "fulfilled"
? r.value
: {
provider: "unknown",
latency: 0,
cost: 0,
resultCount: 0,
responseSize: 0,
urls: [],
error: "Failed",
}
)
);
setCompareLoading(false);
};
const handleCancel = () => {
abortRef.current?.abort();
};
const handleHistoryReplay = (entry: any) => {
handleSearch({
query: entry.query,
provider: entry.provider || "",
search_type: entry.filters?.search_type || "web",
max_results: entry.filters?.max_results || 5,
...entry.filters,
});
};
return (
<div className="flex h-[calc(100vh-120px)]">
<div className="w-[340px] flex-shrink-0 bg-bg-alt border-r border-border overflow-y-auto flex flex-col">
<SearchForm
onSearch={handleSearch}
loading={loading}
onCancel={handleCancel}
providers={providers}
/>
<SearchHistory onReplay={handleHistoryReplay} />
</div>
<div className="flex-1 overflow-y-auto">
<ResultsPanel
response={response}
rawJson={rawJson}
loading={loading}
error={error}
statusCode={statusCode}
duration={duration}
/>
{response && (
<div className="px-4 py-2 flex gap-2">
<button
className="flex-1 bg-surface border border-border rounded-lg p-2 text-center hover:border-accent/30 transition-colors flex items-center justify-center gap-2"
onClick={handleCompare}
disabled={compareLoading}
>
<span className="text-accent text-sm">&#8693;</span>
<span className="text-xs text-text-muted">{t("compareProviders")}</span>
</button>
<button
className="flex-1 bg-surface border border-border rounded-lg p-2 text-center hover:border-primary/30 transition-colors flex items-center justify-center gap-2"
onClick={() => setShowRerank(!showRerank)}
>
<span className="text-primary text-sm">&#8645;</span>
<span className="text-xs text-text-muted">{t("rerankResults")}</span>
</button>
</div>
)}
{showCompare && initialCompareResult && (
<div className="px-4 pb-3">
<ProviderComparison
initialProvider={response!.provider}
initialResult={initialCompareResult}
otherResults={compareResults}
loading={compareLoading}
onClose={() => setShowCompare(false)}
/>
</div>
)}
{showRerank && response && (
<div className="px-4 pb-3">
<RerankPanel
query={response.query}
results={response.results}
onClose={() => setShowRerank(false)}
/>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,168 @@
"use client";
import { useTranslations } from "next-intl";
export interface CompareResult {
provider: string;
latency: number;
cost: number;
resultCount: number;
responseSize: number;
urls: string[];
error?: string;
}
interface ProviderComparisonProps {
initialProvider: string;
initialResult: CompareResult;
otherResults: CompareResult[];
loading: boolean;
onClose: () => void;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
export default function ProviderComparison({
initialProvider,
initialResult,
otherResults,
loading,
onClose,
}: ProviderComparisonProps) {
const t = useTranslations("search");
const allResults = [initialResult, ...otherResults];
const initialUrls = new Set(initialResult.urls);
const valid = allResults.filter((r) => !r.error);
const latencies = valid.map((r) => r.latency);
const costs = valid.map((r) => r.cost);
const sizes = valid.map((r) => r.responseSize);
const bestLatency = Math.min(...latencies);
const worstLatency = Math.max(...latencies);
const bestCost = Math.min(...costs);
const worstCost = Math.max(...costs);
const bestSize = Math.min(...sizes);
const worstSize = Math.max(...sizes);
const getLatencyColor = (val: number) => {
if (val === bestLatency) return "text-success font-medium";
if (val === worstLatency) return "text-warning";
return "text-text-main";
};
const getCostColor = (val: number) => {
if (val === bestCost) return "text-success font-medium";
if (val === worstCost) return "text-warning";
return "text-text-main";
};
const getSizeColor = (val: number) => {
if (val === bestSize) return "text-success font-medium";
if (val === worstSize) return "text-warning";
return "text-text-main";
};
return (
<div className="bg-surface border border-accent/20 rounded-lg overflow-hidden">
<div className="flex justify-between items-center px-4 py-2.5 bg-accent/5 border-b border-accent/15">
<span className="text-xs font-semibold text-accent flex items-center gap-1.5">
{t("compareProviders")}
</span>
<button onClick={onClose} className="text-text-muted text-xs hover:text-text-main">
</button>
</div>
<div className="p-3 overflow-x-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="material-symbols-outlined text-[20px] text-accent animate-spin">
progress_activity
</span>
<span className="text-xs text-text-muted ml-2">{t("compareProviders")}...</span>
</div>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="text-left p-2 text-text-muted font-semibold" />
{allResults.map((r) => (
<th
key={r.provider}
className={`text-center p-2 font-semibold ${
r.provider === initialProvider ? "text-primary" : "text-text-muted"
}`}
>
{r.provider.replace("-search", "")}
{r.provider === initialProvider && " ✓"}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">{t("latency")}</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getLatencyColor(r.latency)}`}
>
{r.error ? "Error" : `${r.latency}ms`}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">{t("cost")}</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getCostColor(r.cost)}`}
>
{r.error ? "Error" : `$${r.cost.toFixed(4)}`}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">{t("results")}</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : "text-text-main"}`}
>
{r.error ? "Error" : r.resultCount}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">Size</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getSizeColor(r.responseSize)}`}
>
{r.error ? "Error" : formatBytes(r.responseSize)}
</td>
))}
</tr>
<tr>
<td className="p-2 text-text-muted">{t("urlOverlap")}</td>
{allResults.map((r) => (
<td key={r.provider} className="text-center p-2 text-text-main">
{r.provider === initialProvider
? "—"
: r.error
? "Error"
: `${r.urls.filter((u) => initialUrls.has(u)).length}/${r.resultCount}`}
</td>
))}
</tr>
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,152 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button, Select } from "@/shared/components";
interface RerankResult {
index: number;
originalIndex: number;
title: string;
snippet: string;
score: number;
delta: number;
}
interface RerankPanelProps {
query: string;
results: { title: string; snippet: string; url: string }[];
onClose: () => void;
}
export default function RerankPanel({ query, results, onClose }: RerankPanelProps) {
const t = useTranslations("search");
const [models, setModels] = useState<{ value: string; label: string }[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [reranked, setReranked] = useState<RerankResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
fetch("/v1/models")
.then((res) => res.json())
.then((data) => {
const rerankModels = (data?.data || [])
.filter((m: any) => m.id.toLowerCase().includes("rerank"))
.map((m: any) => ({ value: m.id, label: m.id }));
setModels(rerankModels);
if (rerankModels.length > 0) setSelectedModel(rerankModels[0].value);
})
.catch(() => {});
}, []);
const handleRerank = async () => {
setLoading(true);
setError("");
try {
const res = await fetch("/api/v1/rerank", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: selectedModel,
query,
documents: results.map((r) => r.snippet),
top_n: results.length,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error?.message || data.error || `Error ${res.status}`);
return;
}
const rerankedResults: RerankResult[] = (data.results || []).map(
(r: any, newIndex: number) => {
const origIndex = r.index;
return {
index: newIndex,
originalIndex: origIndex,
title: results[origIndex]?.title || "",
snippet: results[origIndex]?.snippet || "",
score: r.relevance_score,
delta: origIndex - newIndex,
};
}
);
setReranked(rerankedResults);
} catch (err: any) {
setError(err.message || "Rerank failed");
} finally {
setLoading(false);
}
};
const getDeltaDisplay = (delta: number) => {
if (delta > 0) return <span className="text-success">{delta}</span>;
if (delta < 0) return <span className="text-error">{Math.abs(delta)}</span>;
return <span className="text-text-muted">=</span>;
};
const noModels = models.length === 0;
return (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border">
<span className="text-xs font-semibold text-text-main flex items-center gap-1.5">
{t("rerankResults")}
</span>
<button onClick={onClose} className="text-text-muted text-xs hover:text-text-main">
</button>
</div>
<div className="p-4">
{noModels ? (
<p className="text-xs text-text-muted">{t("noRerankModels")}</p>
) : (
<>
<div className="flex gap-2 items-end mb-3">
<div className="flex-1">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("rerankModel")}
</label>
<Select
value={selectedModel}
onChange={(e: any) => setSelectedModel(e.target.value)}
options={models}
className="w-full"
/>
</div>
<Button variant="primary" onClick={handleRerank} disabled={loading || !selectedModel}>
{loading ? "Reranking..." : t("rerank")}
</Button>
</div>
{error && <p className="text-xs text-error mb-2">{error}</p>}
{reranked.length > 0 && (
<div className="space-y-2">
{reranked.map((r) => (
<div key={r.index} className="flex items-start gap-3 p-2 bg-bg-alt rounded-lg">
<div className="flex flex-col items-center min-w-[32px]">
<span className="text-xs font-medium text-text-main">#{r.index + 1}</span>
<span className="text-[10px]">{getDeltaDisplay(r.delta)}</span>
</div>
<div className="flex-1">
<div className="text-xs font-medium text-text-main">{r.title}</div>
<div className="text-[10px] text-text-muted mt-0.5 line-clamp-2">
{r.snippet}
</div>
</div>
<span className="text-[10px] text-accent whitespace-nowrap">
{r.score.toFixed(4)}
</span>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
import { Badge } from "@/shared/components";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
interface SearchResult {
title: string;
url: string;
snippet: string;
score?: number;
date?: string;
}
interface SearchResponse {
id: string;
provider: string;
results: SearchResult[];
query: string;
answer: string | null;
cached: boolean;
usage: {
queries_used: number;
search_cost_usd: number;
};
metrics: {
response_time_ms: number;
upstream_latency_ms: number;
total_results_available: number | null;
};
}
interface ResultsPanelProps {
response: SearchResponse | null;
rawJson: string;
loading: boolean;
error: string;
statusCode: number;
duration: number;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
export default function ResultsPanel({
response,
rawJson,
loading,
error,
statusCode,
duration,
}: ResultsPanelProps) {
const t = useTranslations("search");
const [showJson, setShowJson] = useState(false);
const getScoreColor = (score: number) => {
if (score >= 0.9) return "text-success";
if (score >= 0.7) return "text-warning";
return "text-error";
};
const getScoreBg = (score: number) => {
if (score >= 0.9) return "bg-green-500/10";
if (score >= 0.7) return "bg-yellow-500/10";
return "bg-red-500/10";
};
const editorTheme =
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
? "vs-dark"
: "light";
return (
<div className="flex flex-col">
{/* Header */}
<div className="flex justify-between items-center p-3 border-b border-border">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("searchResults")}
</span>
{statusCode > 0 && (
<>
<Badge variant={statusCode < 400 ? "success" : "error"} size="sm">
{statusCode}
</Badge>
<span className="text-xs text-text-muted">{duration}ms</span>
</>
)}
</div>
{response && (
<div className="flex gap-1">
<button
className={`text-xs px-3 py-1 rounded-md ${
!showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(false)}
>
{t("formatted")}
</button>
<button
className={`text-xs px-3 py-1 rounded-md ${
showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(true)}
>
{t("rawJson")}
</button>
</div>
)}
</div>
{/* Content */}
{loading && (
<div className="flex items-center justify-center py-20">
<span className="material-symbols-outlined text-[24px] text-primary animate-spin">
progress_activity
</span>
</div>
)}
{error && !loading && (
<div className="p-4">
<div className="text-error text-sm">{error}</div>
</div>
)}
{response && !showJson && !loading && (
<div className="p-4 space-y-3">
{/* Meta bar */}
<div className="flex justify-between items-center p-2 bg-bg-alt rounded-lg">
<div className="flex items-center gap-3 text-xs text-text-muted">
<span>
{response.results.length} {t("results").toLowerCase()}
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
{response.provider}
</span>
<span>{response.metrics?.response_time_ms}ms</span>
<span>${response.usage?.search_cost_usd?.toFixed(4)}</span>
<span>{formatBytes(rawJson.length)}</span>
</div>
<span
className={`text-xs flex items-center gap-1 ${
response.cached ? "text-success" : "text-warning"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
response.cached ? "bg-success" : "bg-warning"
}`}
/>
{response.cached ? t("cacheHit") : t("cacheMiss")}
</span>
</div>
{/* Results list */}
{response.results.map((r, i) => (
<div
key={i}
className="border-l-[3px] border-l-primary p-3 bg-surface rounded-r-lg border border-border"
>
<div className="flex justify-between items-start">
<span className="text-sm font-medium text-text-main">
{i + 1}. {r.title}
</span>
{r.score != null && (
<span
className={`text-[10px] px-2 py-0.5 rounded-md ml-2 whitespace-nowrap ${getScoreBg(r.score)} ${getScoreColor(r.score)}`}
>
{r.score.toFixed(2)}
</span>
)}
</div>
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent text-[11px] block mt-0.5"
>
{r.url}
</a>
<p className="text-xs text-text-muted mt-1 leading-relaxed">{r.snippet}</p>
</div>
))}
</div>
)}
{response && showJson && !loading && (
<div className="h-64">
<Editor
height="100%"
language="json"
value={rawJson}
theme={editorTheme}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 12,
automaticLayout: true,
wordWrap: "on",
}}
/>
</div>
)}
{!loading && !error && !response && (
<div className="flex items-center justify-center py-20 text-text-muted text-sm">
{t("emptyState")}
</div>
)}
</div>
);
}
@@ -0,0 +1,308 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Select } from "@/shared/components";
interface SearchProvider {
id: string;
name: string;
status: "active" | "no_credentials";
cost_per_query: number;
}
export interface SearchFormData {
query: string;
provider: string;
search_type: string;
max_results: number;
country?: string;
language?: string;
time_range?: string;
include_domains?: string[];
exclude_domains?: string[];
safe_search?: string;
}
interface SearchFormProps {
onSearch: (data: SearchFormData) => void;
loading: boolean;
onCancel: () => void;
providers: SearchProvider[];
}
export default function SearchForm({ onSearch, loading, onCancel, providers }: SearchFormProps) {
const t = useTranslations("search");
const [query, setQuery] = useState("");
const [provider, setProvider] = useState("auto");
const [searchType, setSearchType] = useState("web");
const [maxResults, setMaxResults] = useState(5);
const [showFilters, setShowFilters] = useState(false);
const [country, setCountry] = useState("");
const [language, setLanguage] = useState("");
const [timeRange, setTimeRange] = useState("");
const [includeDomains, setIncludeDomains] = useState<string[]>([]);
const [excludeDomains, setExcludeDomains] = useState<string[]>([]);
const [safeSearch, setSafeSearch] = useState("moderate");
const [domainInput, setDomainInput] = useState("");
const [excludeDomainInput, setExcludeDomainInput] = useState("");
const activeProviders = providers.filter((p) => p.status === "active");
const noProviders = activeProviders.length === 0;
const handleSubmit = () => {
const data: SearchFormData = {
query,
provider: provider === "auto" ? "" : provider,
search_type: searchType,
max_results: maxResults,
};
if (country) data.country = country;
if (language) data.language = language;
if (timeRange) data.time_range = timeRange;
if (includeDomains.length > 0) data.include_domains = includeDomains;
if (excludeDomains.length > 0) data.exclude_domains = excludeDomains;
if (safeSearch !== "moderate") data.safe_search = safeSearch;
onSearch(data);
};
const addDomain = (type: "include" | "exclude") => {
const input = type === "include" ? domainInput : excludeDomainInput;
const setter = type === "include" ? setIncludeDomains : setExcludeDomains;
const list = type === "include" ? includeDomains : excludeDomains;
if (input.trim() && !list.includes(input.trim())) {
setter([...list, input.trim()]);
}
type === "include" ? setDomainInput("") : setExcludeDomainInput("");
};
const removeDomain = (domain: string, type: "include" | "exclude") => {
const setter = type === "include" ? setIncludeDomains : setExcludeDomains;
const list = type === "include" ? includeDomains : excludeDomains;
setter(list.filter((d) => d !== domain));
};
return (
<div className="flex flex-col h-full">
{/* Query */}
<div className="p-4 border-b border-border">
<label className="block text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
{t("searchQuery")}
</label>
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter search query..."
className="w-full bg-surface border border-border rounded-lg p-2.5 text-sm text-text-main resize-none h-16 focus:outline-none focus:ring-2 focus:ring-primary/30"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!noProviders && query.trim()) handleSubmit();
}
}}
/>
</div>
{/* Provider + Type + Max Results */}
<div className="p-4 border-b border-border space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("provider")}
</label>
<Select
value={provider}
onChange={(e: any) => setProvider(e.target.value)}
options={[
{ value: "auto", label: "auto (cheapest)" },
...activeProviders.map((p) => ({
value: p.id,
label: p.name,
})),
]}
className="w-full"
/>
</div>
<div className="flex-1">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("searchType")}
</label>
<Select
value={searchType}
onChange={(e: any) => setSearchType(e.target.value)}
options={[
{ value: "web", label: "web" },
{ value: "news", label: "news" },
]}
className="w-full"
/>
</div>
</div>
<div className="w-20">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("maxResults")}
</label>
<input
type="number"
value={maxResults}
onChange={(e) => setMaxResults(parseInt(e.target.value) || 5)}
min={1}
max={100}
className="w-full bg-surface border border-border rounded-lg px-2.5 py-1.5 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
{/* Filters (collapsible) */}
<div className="p-4 border-b border-border">
<button
className="flex justify-between items-center w-full"
onClick={() => setShowFilters(!showFilters)}
>
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
{t("filters")}
</span>
<span className="text-text-muted text-xs">{showFilters ? "▼" : "▶"}</span>
</button>
{showFilters && (
<div className="mt-3 space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[10px] text-text-muted mb-1">{t("country")}</label>
<input
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="any"
className="w-full bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
/>
</div>
<div className="flex-1">
<label className="block text-[10px] text-text-muted mb-1">{t("language")}</label>
<input
value={language}
onChange={(e) => setLanguage(e.target.value)}
placeholder="any"
className="w-full bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">{t("timeRange")}</label>
<Select
value={timeRange}
onChange={(e: any) => setTimeRange(e.target.value)}
options={[
{ value: "", label: "any" },
{ value: "day", label: "Past day" },
{ value: "week", label: "Past week" },
{ value: "month", label: "Past month" },
{ value: "year", label: "Past year" },
]}
className="w-full"
/>
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">
{t("includeDomains")}
</label>
<div className="flex gap-1">
<input
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
placeholder="example.com"
className="flex-1 bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
onKeyDown={(e) => e.key === "Enter" && addDomain("include")}
/>
<button onClick={() => addDomain("include")} className="text-primary text-lg px-1">
+
</button>
</div>
{includeDomains.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{includeDomains.map((d) => (
<span
key={d}
className="text-[10px] bg-primary/10 text-primary px-2 py-0.5 rounded-full flex items-center gap-1"
>
{d}
<button
onClick={() => removeDomain(d, "include")}
className="text-primary/60"
>
×
</button>
</span>
))}
</div>
)}
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">
{t("excludeDomains")}
</label>
<div className="flex gap-1">
<input
value={excludeDomainInput}
onChange={(e) => setExcludeDomainInput(e.target.value)}
placeholder="example.com"
className="flex-1 bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
onKeyDown={(e) => e.key === "Enter" && addDomain("exclude")}
/>
<button onClick={() => addDomain("exclude")} className="text-primary text-lg px-1">
+
</button>
</div>
{excludeDomains.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{excludeDomains.map((d) => (
<span
key={d}
className="text-[10px] bg-error/10 text-error px-2 py-0.5 rounded-full flex items-center gap-1"
>
{d}
<button onClick={() => removeDomain(d, "exclude")} className="text-error/60">
×
</button>
</span>
))}
</div>
)}
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">{t("safeSearch")}</label>
<Select
value={safeSearch}
onChange={(e: any) => setSafeSearch(e.target.value)}
options={[
{ value: "off", label: "Off" },
{ value: "moderate", label: "Moderate" },
{ value: "strict", label: "Strict" },
]}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Search button */}
<div className="p-4 border-b border-border">
{loading ? (
<Button variant="danger" onClick={onCancel} className="w-full">
Cancel
</Button>
) : (
<Button
variant="primary"
onClick={handleSubmit}
disabled={noProviders || !query.trim()}
className="w-full"
>
Search
</Button>
)}
{noProviders && <p className="text-xs text-text-muted mt-2">{t("noSearchProviders")}</p>}
</div>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
interface HistoryEntry {
query: string;
provider: string;
timestamp: string;
filters: Record<string, any>;
}
interface SearchHistoryProps {
onReplay: (entry: HistoryEntry) => void;
}
function timeAgo(timestamp: string): string {
try {
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const diff = Date.now() - new Date(timestamp).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return rtf.format(0, "minute");
if (minutes < 60) return rtf.format(-minutes, "minute");
const hours = Math.floor(minutes / 60);
if (hours < 24) return rtf.format(-hours, "hour");
return rtf.format(-Math.floor(hours / 24), "day");
} catch {
return new Date(timestamp).toLocaleString();
}
}
export default function SearchHistory({ onReplay }: SearchHistoryProps) {
const t = useTranslations("search");
const [entries, setEntries] = useState<HistoryEntry[]>([]);
useEffect(() => {
fetch("/api/search/stats")
.then((res) => res.json())
.then((data) => setEntries(data.recent_searches || []))
.catch(() => {});
}, []);
if (entries.length === 0) return null;
return (
<div className="p-4 flex-1">
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
{t("searchHistory")}
</span>
<div className="mt-2 space-y-1.5">
{entries.map((entry, i) => (
<button
key={`${entry.timestamp}:${entry.provider}:${entry.query}`}
onClick={() => onReplay(entry)}
className="w-full text-left p-2 bg-surface border border-border rounded-lg hover:border-primary/30 transition-colors"
>
<div className="text-xs text-text-main truncate">{entry.query}</div>
<div className="flex justify-between mt-0.5">
<span className="text-[10px] text-text-muted">{entry.provider}</span>
<span className="text-[10px] text-text-muted">{timeAgo(entry.timestamp)}</span>
</div>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import SearchToolsClient from "./SearchToolsClient";
export default function SearchToolsPage() {
return <SearchToolsClient />;
}
@@ -83,7 +83,11 @@ export default function BudgetTab() {
if (data.monthlyLimitUsd)
setForm((f) => ({ ...f, monthlyLimitUsd: String(data.monthlyLimitUsd) }));
if (data.warningThreshold)
setForm((f) => ({ ...f, warningThreshold: String(data.warningThreshold) }));
// stored as fraction (01), display as percentage (0100)
setForm((f) => ({
...f,
warningThreshold: String(Math.round(data.warningThreshold * 100)),
}));
}
} catch {
// silent
@@ -104,7 +108,8 @@ export default function BudgetTab() {
apiKeyId: selectedKey,
dailyLimitUsd: form.dailyLimitUsd ? parseFloat(form.dailyLimitUsd) : null,
monthlyLimitUsd: form.monthlyLimitUsd ? parseFloat(form.monthlyLimitUsd) : null,
warningThreshold: parseInt(form.warningThreshold) || 80,
// schema expects a fraction (01); UI shows percentage (0100)
warningThreshold: (parseInt(form.warningThreshold) || 80) / 100,
}),
});
if (res.ok) {
@@ -92,11 +92,15 @@ export function parseQuotaData(provider, data) {
case "github":
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
if (quota?.unlimited && (!quota?.total || quota.total <= 0)) {
return;
}
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: safePercentage(quota.remainingPercentage),
});
});
}
@@ -214,6 +218,14 @@ export function normalizePlanTier(plan) {
const upper = raw.toUpperCase();
if (
upper.includes("PRO+") ||
upper.includes("PRO PLUS") ||
upper.includes("PROPLUS")
) {
return { key: "plus", label: "Pro+", variant: "secondary", rank: 4, raw };
}
if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) {
return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw };
}
@@ -227,6 +239,10 @@ export function normalizePlanTier(plan) {
return { key: "business", label: "Business", variant: "warning", rank: 5, raw };
}
if (upper.includes("STUDENT")) {
return { key: "pro", label: "Student", variant: "primary", rank: 3, raw };
}
if (upper.includes("ULTRA")) {
return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw };
}
@@ -241,7 +257,6 @@ export function normalizePlanTier(plan) {
if (
upper.includes("FREE") ||
upper.includes("INDIVIDUAL") ||
upper.includes("BASIC") ||
upper.includes("TRIAL") ||
upper.includes("LEGACY")
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import {
SEARCH_PROVIDERS,
SEARCH_CREDENTIAL_FALLBACKS,
} from "@omniroute/open-sse/config/searchRegistry.ts";
import { getDbInstance } from "@/lib/db/core";
import { isAuthenticated } from "@/shared/utils/apiAuth";
export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const db = getDbInstance();
const providers = Object.values(SEARCH_PROVIDERS).map((p) => {
let status: "active" | "no_credentials" = "no_credentials";
try {
const cred = db
.prepare(
"SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1"
)
.get(p.id);
// Use canonical fallback mapping (e.g. perplexity-search → perplexity)
const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[p.id];
const fallbackCred =
!cred && fallbackId
? db
.prepare(
"SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1"
)
.get(fallbackId)
: null;
if (cred || fallbackCred) status = "active";
} catch {
// DB error — report as no_credentials
}
return {
id: p.id,
name: p.name,
status,
cost_per_query: p.costPerQuery,
};
});
return NextResponse.json({ providers });
} catch (error) {
return NextResponse.json({ error: "Failed to list providers" }, { status: 500 });
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { getCacheStats } from "@omniroute/open-sse/services/searchCache.ts";
import { SEARCH_PROVIDERS } from "@omniroute/open-sse/config/searchRegistry.ts";
import { getDbInstance } from "@/lib/db/core";
import { isAuthenticated } from "@/shared/utils/apiAuth";
export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const db = getDbInstance();
const cache = getCacheStats();
// Provider aggregate stats — cost is per-query from registry
const providerStats = db
.prepare(
`
SELECT provider, COUNT(*) as requests,
CAST(AVG(duration) AS INTEGER) as avg_latency_ms
FROM call_logs
WHERE request_type = 'search'
GROUP BY provider
`
)
.all();
const providers: Record<
string,
{ requests: number; avg_latency_ms: number; total_cost: number }
> = {};
for (const row of providerStats as any[]) {
const costPerQuery = SEARCH_PROVIDERS[row.provider]?.costPerQuery || 0;
providers[row.provider] = {
requests: row.requests,
avg_latency_ms: row.avg_latency_ms,
total_cost: parseFloat((row.requests * costPerQuery).toFixed(4)),
};
}
// Recent searches
const recentRows = db
.prepare(
`
SELECT request_body, provider, timestamp
FROM call_logs
WHERE request_type = 'search'
ORDER BY timestamp DESC
LIMIT 10
`
)
.all();
const recent_searches = (recentRows as any[]).map((row) => {
let query = "";
let filters = {};
try {
const body = JSON.parse(row.request_body);
query = body.query || "";
const { query: _q, provider: _p, ...rest } = body;
filters = rest;
} catch {
// Unparseable request_body
}
return {
query,
provider: row.provider,
timestamp: row.timestamp,
filters,
};
});
return NextResponse.json({ cache, providers, recent_searches });
} catch (error) {
return NextResponse.json({ error: "Failed to get stats" }, { status: 500 });
}
}
+126 -23
View File
@@ -6,12 +6,13 @@ import {
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { parseRerankModel, getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1RerankSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getProviderNodes } from "@/lib/localDb";
/**
* Handle CORS preflight
@@ -26,11 +27,29 @@ export async function OPTIONS() {
});
}
/**
* Build dynamic rerank provider from a local provider_node.
* Local OpenAI-compatible backends (oMLX, vLLM, etc.) expose /v1/rerank
* under the same base URL as chat.
*/
function buildDynamicRerankProvider(node: any) {
// Strip trailing /v1 if present — we'll add /rerank
let base = node.baseUrl || "";
if (base.endsWith("/v1")) base = base.slice(0, -3);
return {
id: node.prefix,
baseUrl: `${base}/v1/rerank`,
authType: "apikey",
authHeader: "bearer",
providerId: node.id, // full provider connection ID for credential lookup
};
}
/**
* POST /v1/rerank - Cohere-compatible rerank endpoint
*
* Reranks a list of documents against a query using the specified model.
* Supports providers: Cohere, Together AI, NVIDIA, Fireworks AI.
* Supports cloud providers (Cohere, Together, NVIDIA, Fireworks)
* and local provider_nodes (oMLX, vLLM, etc.) via dynamic routing.
*/
export async function POST(request) {
// Optional API key validation
@@ -58,29 +77,113 @@ export async function POST(request) {
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
const { provider } = parseRerankModel(body.model);
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Invalid rerank model: ${body.model}. Use format: provider/model`
);
// Load local provider_nodes for rerank routing (localhost only)
let localProviders: ReturnType<typeof buildDynamicRerankProvider>[] = [];
try {
const nodes = await getProviderNodes();
localProviders = (Array.isArray(nodes) ? nodes : [])
.filter((n: any) => {
try {
const hostname = new URL(n.baseUrl).hostname;
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]"
);
} catch {
return false;
}
})
.map((n) => {
try {
return buildDynamicRerankProvider(n);
} catch {
return null;
}
})
.filter((p): p is NonNullable<typeof p> => p !== null);
} catch {
// Non-critical — continue with cloud providers only
}
const credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
// Try cloud registry first
const { provider, model: modelId } = parseRerankModel(body.model);
if (provider) {
// Cloud provider matched
const credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
const response = await handleRerank({
model: body.model,
query: body.query,
documents: body.documents,
top_n: body.top_n,
return_documents: body.return_documents,
credentials,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
const response = await handleRerank({
model: body.model,
query: body.query,
documents: body.documents,
top_n: body.top_n,
return_documents: body.return_documents,
credentials,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
// Try local provider_nodes (model format: prefix/model-name)
const parts = body.model.split("/");
if (parts.length >= 2) {
const prefix = parts[0];
const localModel = parts.slice(1).join("/");
const localProvider = localProviders.find((p) => p.id === prefix);
if (localProvider) {
const credentials = await getProviderCredentials(localProvider.providerId);
if (!credentials) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for local provider: ${prefix}`
);
}
const token = credentials?.apiKey || credentials?.accessToken;
try {
const res = await fetch(localProvider.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
model: localModel,
query: body.query,
documents: body.documents,
top_n: body.top_n || body.documents.length,
return_documents: body.return_documents !== false,
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
return errorResponse(
res.status,
errData.message || errData.detail || `Provider returned HTTP ${res.status}`
);
}
const data = await res.json();
return Response.json(data, {
headers: { "Access-Control-Allow-Origin": CORS_ORIGIN },
});
} catch (err: any) {
return errorResponse(500, `Rerank request failed: ${err.message}`);
}
}
}
return response;
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Invalid rerank model: ${body.model}. Use format: provider/model`
);
}
+9 -12
View File
@@ -1,16 +1,14 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
let initialized = false;
async function ensureInitialized() {
if (!initialized) {
await initTranslators();
initialized = true;
console.log("[SSE] Translators initialized for /v1/responses");
}
}
// NOTE: We do NOT call initTranslators() here — the translator registry is
// bootstrapped at module level inside open-sse/translator/index.ts when it
// is first imported. Calling it again from a Next.js Route Handler caused a
// "the worker has exited" uncaughtException crash on Codex CLI requests (#450)
// because the dynamic import runs in a Next.js server worker context where
// certain Node APIs used by the translator bootstrap are not available.
// The translators are always initialized via the open-sse side (chatCore),
// so /v1/responses just delegates to handleChat which handles everything.
export async function OPTIONS() {
return new Response(null, {
@@ -24,9 +22,8 @@ export async function OPTIONS() {
/**
* POST /v1/responses - OpenAI Responses API format
* Now handled by translator pattern (openai-responses format auto-detected)
* Handled by the unified chat handler (openai-responses format auto-detected).
*/
export async function POST(request) {
await ensureInitialized();
return await handleChat(request);
}
+1
View File
@@ -203,6 +203,7 @@ export default function LoginPage() {
{error}
</p>
)}
<p className="text-xs text-text-muted/60 pt-0.5">{t("defaultPasswordHint")}</p>
</div>
<Button
+39 -1
View File
@@ -74,6 +74,7 @@
"settings": "Settings",
"translator": "Translator",
"playground": "Playground",
"searchTools": "Search Tools",
"agents": "Agents",
"docs": "Docs",
"issues": "Issues",
@@ -328,6 +329,42 @@
"videoDescription": "Create videos with AnimateDiff, Stable Video Diffusion via ComfyUI or SD WebUI.",
"musicDescription": "Compose music using Stable Audio Open or MusicGen via ComfyUI."
},
"search": {
"searchQuery": "Search Query",
"searchResults": "Search Results",
"cachedResult": "Cached",
"searchCost": "Cost",
"searchTools": "Search Tools",
"searchToolsDesc": "Advanced search testing with provider comparison",
"compareProviders": "Compare Providers",
"rerankResults": "Rerank Results",
"searchHistory": "Search History",
"urlOverlap": "URL Overlap",
"noSearchProviders": "No search providers configured. Add providers in Settings.",
"noRerankModels": "No rerank model available",
"webSearch": "Web Search",
"provider": "Provider",
"searchType": "Search Type",
"maxResults": "Max Results",
"filters": "Filters",
"country": "Country",
"language": "Language",
"timeRange": "Time Range",
"includeDomains": "Include Domains",
"excludeDomains": "Exclude Domains",
"safeSearch": "Safe Search",
"formatted": "Formatted",
"rawJson": "JSON",
"cacheMiss": "cache miss",
"cacheHit": "cache hit",
"latency": "Latency",
"cost": "Cost",
"results": "Results",
"rerank": "Rerank",
"rerankModel": "Rerank Model",
"positionDelta": "Position Change",
"emptyState": "Send a search query to see results"
},
"cliTools": {
"title": "CLI Tools",
"noActiveProviders": "No active providers",
@@ -2256,7 +2293,8 @@
"orRemovePasswordHashField": "or remove the passwordHash field",
"restartServerWithNewPassword": "Restart the server - it will use the new password",
"backToLogin": "Back to Login",
"forgotPassword": "Forgot password?"
"forgotPassword": "Forgot password?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)"
},
"landing": {
"brandName": "OmniRoute",
+11 -5
View File
@@ -445,16 +445,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 };
}
}
+1
View File
@@ -32,6 +32,7 @@ const debugItemDefs = [
{ href: "/dashboard/translator", i18nKey: "translator", icon: "translate" },
{ href: "/dashboard/playground", i18nKey: "playground", icon: "science" },
{ href: "/dashboard/media", i18nKey: "media", icon: "auto_awesome" },
{ href: "/dashboard/search-tools", i18nKey: "searchTools", icon: "manage_search" },
];
const systemItemDefs = [
+40 -24
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useLocale } from "next-intl";
import Card from "../Card";
import { getModelColor } from "@/shared/constants/colors";
import {
@@ -25,6 +26,14 @@ import {
Area,
} from "recharts";
function createDateFormatter(locale: string, options: Intl.DateTimeFormatOptions) {
try {
return new Intl.DateTimeFormat(locale, options);
} catch {
return new Intl.DateTimeFormat(undefined, options);
}
}
// ── Custom Tooltip for dark theme ──────────────────────────────────────────
function DarkTooltip({
@@ -724,6 +733,15 @@ export function WeeklyPattern({ weeklyPattern }) {
// ── MostActiveDay7d ────────────────────────────────────────────────────────
export function MostActiveDay7d({ activityMap }) {
const locale = useLocale();
const weekdayFormatter = useMemo(
() => createDateFormatter(locale, { weekday: "long" }),
[locale]
);
const dateFormatter = useMemo(
() => createDateFormatter(locale, { month: "short", day: "numeric" }),
[locale]
);
const data = useMemo(() => {
if (!activityMap) return null;
const today = new Date();
@@ -743,27 +761,12 @@ export function MostActiveDay7d({ activityMap }) {
if (!peakKey || peakVal === 0) return null;
const peakDate = new Date(peakKey + "T12:00:00");
const weekdays = ["domingo", "segunda", "terça", "quarta", "quinta", "sexta", "sábado"];
const months = [
"jan",
"fev",
"mar",
"abr",
"mai",
"jun",
"jul",
"ago",
"set",
"out",
"nov",
"dez",
];
return {
weekday: weekdays[peakDate.getDay()],
label: `${peakDate.getDate()} de ${months[peakDate.getMonth()]}`,
weekday: weekdayFormatter.format(peakDate),
label: dateFormatter.format(peakDate),
tokens: peakVal,
};
}, [activityMap]);
}, [activityMap, dateFormatter, weekdayFormatter]);
return (
<Card className="p-4 flex flex-col justify-center" style={{ flex: 1, minHeight: 0 }}>
@@ -784,7 +787,7 @@ export function MostActiveDay7d({ activityMap }) {
</>
) : (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Sem dados nos últimos 7 dias
No data in the last 7 days
</span>
)}
</Card>
@@ -794,6 +797,15 @@ export function MostActiveDay7d({ activityMap }) {
// ── WeeklySquares7d ────────────────────────────────────────────────────────
export function WeeklySquares7d({ activityMap }) {
const locale = useLocale();
const weekdayFormatter = useMemo(
() => createDateFormatter(locale, { weekday: "short" }),
[locale]
);
const dateFormatter = useMemo(
() => createDateFormatter(locale, { month: "short", day: "numeric" }),
[locale]
);
const days = useMemo(() => {
if (!activityMap) return [];
const today = new Date();
@@ -806,11 +818,15 @@ export function WeeklySquares7d({ activityMap }) {
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const val = activityMap[key] || 0;
if (val > maxVal) maxVal = val;
const shortDays = ["DOM", "SEG", "TER", "QUA", "QUI", "SEX", "SÁB"];
result.push({ key, val, label: shortDays[d.getDay()] });
result.push({
key,
val,
label: weekdayFormatter.format(d),
dateLabel: dateFormatter.format(d),
});
}
return result.map((d) => ({ ...d, intensity: maxVal > 0 ? d.val / maxVal : 0 }));
}, [activityMap]);
}, [activityMap, dateFormatter, weekdayFormatter]);
function getSquareStyle(intensity) {
if (intensity === 0) return { background: "rgba(255,255,255,0.04)" };
@@ -829,11 +845,11 @@ export function WeeklySquares7d({ activityMap }) {
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, justifyContent: "center" }}>
{days.map((d, i) => (
<div
key={i}
key={d.key}
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}
>
<div
title={`${d.key}: ${fmtFull(d.val)} tokens`}
title={`${d.dateLabel}: ${fmtFull(d.val)} tokens`}
style={{
width: 36,
height: 36,
+8 -1
View File
@@ -121,7 +121,14 @@ const runProcess = (
let timedOut = false;
let settled = false;
const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"] });
const child = spawn(command, args, {
env,
stdio: ["ignore", "pipe", "pipe"],
// On Windows, npm installs CLI wrappers as .cmd scripts (e.g. claude.cmd).
// Without shell:true, spawn cannot resolve them via PATHEXT and the
// healthcheck fails even when the CLI is correctly installed (#447).
...(isWindows() ? { shell: true } : {}),
});
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
+92
View File
@@ -0,0 +1,92 @@
import test from "node:test";
import assert from "node:assert/strict";
const usageService = await import("../../open-sse/services/usage.ts");
const providerLimitUtils = await import(
"../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx"
);
test("github copilot business seats infer business plan and hide unlimited buckets", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
access_type_sku: "copilot_business_seat",
quota_reset_date: "2026-04-01T00:00:00Z",
quota_snapshots: {
chat: { unlimited: true },
completions: { unlimited: true },
premium_interactions: {
entitlement: 300,
remaining: 180,
unlimited: false,
},
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
}
);
try {
const usage = await usageService.getUsageForProvider({
provider: "github",
accessToken: "gho_test",
providerSpecificData: {},
});
assert.equal(usage.plan, "Copilot Business");
assert.deepEqual(Object.keys(usage.quotas), ["premium_interactions"]);
assert.equal(usage.quotas.premium_interactions.total, 300);
assert.equal(usage.quotas.premium_interactions.used, 120);
assert.equal(usage.quotas.premium_interactions.remaining, 180);
assert.equal(usage.quotas.premium_interactions.remainingPercentage, 60);
const parsed = providerLimitUtils.parseQuotaData("github", usage);
assert.equal(parsed.length, 1);
assert.equal(parsed[0].name, "premium_interactions");
assert.equal(parsed[0].remainingPercentage, 60);
assert.equal(providerLimitUtils.normalizePlanTier(usage.plan).key, "business");
} finally {
globalThis.fetch = originalFetch;
}
});
test("github copilot individual paid plans no longer normalize as free", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
copilot_plan: "individual",
quota_reset_date: "2026-04-01T00:00:00Z",
quota_snapshots: {
premium_interactions: {
entitlement: 300,
remaining: 120,
unlimited: false,
},
},
}),
{
status: 200,
headers: { "content-type": "application/json" },
}
);
try {
const usage = await usageService.getUsageForProvider({
provider: "github",
accessToken: "gho_test",
providerSpecificData: {},
});
assert.equal(usage.plan, "Copilot Pro");
assert.equal(providerLimitUtils.normalizePlanTier(usage.plan).key, "pro");
assert.equal(providerLimitUtils.normalizePlanTier("individual").key, "unknown");
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -0,0 +1,50 @@
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;
}
});