mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(settings): include usage and budget records in json backups
Export and restore usage history, domain cost history, and domain budget data so analytics and budget state survive settings transfers. Also adjust cost overview fallbacks to rank by request volume when cost data is unavailable, count conversationState entries in chat request logging, and align e2e coverage with the proxy settings route.
This commit is contained in:
@@ -2827,6 +2827,8 @@ export async function handleChatCore({
|
||||
translatedBody.messages?.length ||
|
||||
translatedBody.contents?.length ||
|
||||
translatedBody.request?.contents?.length ||
|
||||
(translatedBody.conversationState?.history?.length ?? 0) +
|
||||
(translatedBody.conversationState?.currentMessage ? 1 : 0) ||
|
||||
0;
|
||||
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
|
||||
|
||||
|
||||
@@ -304,18 +304,20 @@ export default function CostOverviewTab() {
|
||||
requestedModelCoveragePct: 0,
|
||||
streak: 0,
|
||||
};
|
||||
const hasCostData = summary.totalCost > 0;
|
||||
|
||||
const providersByCost = [...(analytics?.byProvider || [])]
|
||||
.filter((provider) => provider.cost > 0)
|
||||
.sort((left, right) => right.cost - left.cost);
|
||||
.filter((provider) => (hasCostData ? provider.cost > 0 : provider.requests > 0))
|
||||
.sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests));
|
||||
const modelsByCost = [...(analytics?.byModel || [])]
|
||||
.filter((model) => model.cost > 0)
|
||||
.sort((left, right) => right.cost - left.cost);
|
||||
.filter((model) => (hasCostData ? model.cost > 0 : model.requests > 0))
|
||||
.sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests));
|
||||
const apiKeysByCost = [...(analytics?.byApiKey || [])]
|
||||
.filter((apiKey) => apiKey.cost > 0)
|
||||
.sort((left, right) => right.cost - left.cost);
|
||||
.filter((apiKey) => (hasCostData ? apiKey.cost > 0 : apiKey.requests > 0))
|
||||
.sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests));
|
||||
const accountsByCost = [...(analytics?.byAccount || [])]
|
||||
.filter((account) => account.cost > 0)
|
||||
.sort((left, right) => right.cost - left.cost);
|
||||
.filter((account) => (hasCostData ? account.cost > 0 : account.requests > 0))
|
||||
.sort((left, right) => (hasCostData ? right.cost - left.cost : right.requests - left.requests));
|
||||
const avgCostPerRequest =
|
||||
summary.totalRequests > 0 ? summary.totalCost / summary.totalRequests : 0;
|
||||
const dailyTrend = analytics?.dailyTrend || [];
|
||||
@@ -642,7 +644,7 @@ export default function CostOverviewTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary.totalCost <= 0 ? (
|
||||
{summary.totalCost <= 0 && summary.totalRequests <= 0 ? (
|
||||
<Card className="p-6">
|
||||
<EmptyState
|
||||
icon="payments"
|
||||
@@ -652,14 +654,20 @@ export default function CostOverviewTab() {
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1.4fr_1fr] gap-4">
|
||||
<CostTrendCard
|
||||
title={t("costTrend")}
|
||||
rows={analytics?.dailyTrend || []}
|
||||
locale={locale}
|
||||
/>
|
||||
<ProviderSpendCard title={t("providerShare")} rows={providersByCost} locale={locale} />
|
||||
</div>
|
||||
{hasCostData && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1.4fr_1fr] gap-4">
|
||||
<CostTrendCard
|
||||
title={t("costTrend")}
|
||||
rows={analytics?.dailyTrend || []}
|
||||
locale={locale}
|
||||
/>
|
||||
<ProviderSpendCard
|
||||
title={t("providerShare")}
|
||||
rows={providersByCost}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<TopListCard
|
||||
@@ -670,6 +678,7 @@ export default function CostOverviewTab() {
|
||||
secondaryLabel={t("tokens")}
|
||||
rows={providersByCost}
|
||||
locale={locale}
|
||||
hasCostData={hasCostData}
|
||||
/>
|
||||
<TopListCard
|
||||
title={t("topModels")}
|
||||
@@ -679,6 +688,7 @@ export default function CostOverviewTab() {
|
||||
secondaryLabel={t("tokens")}
|
||||
rows={modelsByCost}
|
||||
locale={locale}
|
||||
hasCostData={hasCostData}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1047,14 +1057,16 @@ function TopListCard({
|
||||
secondaryKey,
|
||||
secondaryLabel,
|
||||
locale,
|
||||
hasCostData,
|
||||
}: {
|
||||
title: string;
|
||||
rows: Array<Record<string, string | number>>;
|
||||
rows: Array<Record<string, any>>;
|
||||
nameKey: string;
|
||||
valueKey: string;
|
||||
secondaryKey?: string;
|
||||
secondaryLabel?: string;
|
||||
locale: string;
|
||||
hasCostData?: boolean;
|
||||
}) {
|
||||
const currencyFormatter = createCurrencyFormatter(locale);
|
||||
|
||||
@@ -1080,7 +1092,11 @@ function TopListCard({
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-sm font-mono text-text-muted">
|
||||
{currencyFormatter.format(Number(row[valueKey] || 0))}
|
||||
{hasCostData || Number(row[valueKey] || 0) > 0 ? (
|
||||
currencyFormatter.format(Number(row[valueKey] || 0))
|
||||
) : (
|
||||
<span className="text-xs italic opacity-70">Legacy / Free</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1104,7 +1120,7 @@ function CostBreakdownTable({
|
||||
locale,
|
||||
}: {
|
||||
title: string;
|
||||
rows: Array<Record<string, string | number | null>>;
|
||||
rows: Array<Record<string, any>>;
|
||||
columns: ColumnDef[];
|
||||
locale: string;
|
||||
}) {
|
||||
@@ -1114,7 +1130,7 @@ function CostBreakdownTable({
|
||||
const num = Number(value || 0);
|
||||
switch (format) {
|
||||
case "currency":
|
||||
return currencyFormatter.format(num);
|
||||
return num > 0 ? currencyFormatter.format(num) : "Legacy / Free";
|
||||
case "compact":
|
||||
return new Intl.NumberFormat(locale, { notation: "compact" }).format(num);
|
||||
case "number":
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getCombos,
|
||||
getApiKeys,
|
||||
} from "@/lib/localDb";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
/**
|
||||
@@ -32,12 +33,20 @@ export async function GET(request: Request) {
|
||||
const combos = await getCombos();
|
||||
const apiKeys = await getApiKeys();
|
||||
|
||||
const db = getDbInstance();
|
||||
const usageHistory = db.prepare("SELECT * FROM usage_history").all();
|
||||
const domainCostHistory = db.prepare("SELECT * FROM domain_cost_history").all();
|
||||
const domainBudgets = db.prepare("SELECT * FROM domain_budgets").all();
|
||||
|
||||
const exportData = {
|
||||
settings: safeSettings,
|
||||
providerConnections,
|
||||
providerNodes,
|
||||
combos,
|
||||
apiKeys,
|
||||
usageHistory,
|
||||
domainCostHistory,
|
||||
domainBudgets,
|
||||
// Metadata to identify export version
|
||||
_meta: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
|
||||
@@ -67,7 +67,9 @@ export async function POST(request: Request) {
|
||||
|
||||
console.log(
|
||||
`[JSON Import] Imported ${counts.connections} connections, ${counts.nodes} nodes, ` +
|
||||
`${counts.combos} combos, ${counts.apiKeys} API keys`
|
||||
`${counts.combos} combos, ${counts.apiKeys} API keys, ` +
|
||||
`${counts.usageHistory} usage rows, ${counts.domainCostHistory} cost rows, ` +
|
||||
`${counts.domainBudgets} budgets`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface LegacyJsonData {
|
||||
combos?: unknown;
|
||||
keys?: unknown;
|
||||
};
|
||||
usageHistory?: Record<string, unknown>[];
|
||||
domainCostHistory?: Record<string, unknown>[];
|
||||
domainBudgets?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +46,15 @@ export interface LegacyJsonData {
|
||||
export function runJsonMigration(
|
||||
db: SqliteDatabase,
|
||||
data: LegacyJsonData
|
||||
): { connections: number; nodes: number; combos: number; apiKeys: number } {
|
||||
): {
|
||||
connections: number;
|
||||
nodes: number;
|
||||
combos: number;
|
||||
apiKeys: number;
|
||||
usageHistory: number;
|
||||
domainCostHistory: number;
|
||||
domainBudgets: number;
|
||||
} {
|
||||
const insertConn = db.prepare(`
|
||||
INSERT OR REPLACE INTO provider_connections (
|
||||
id, provider, auth_type, name, email, priority, is_active,
|
||||
@@ -210,6 +221,89 @@ export function runJsonMigration(
|
||||
createdAt: apiKey.createdAt ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
// 7. Usage History
|
||||
if (data.usageHistory && data.usageHistory.length > 0) {
|
||||
const insertUsageHistory = db.prepare(`
|
||||
INSERT OR REPLACE INTO usage_history (
|
||||
id, provider, model, connection_id, api_key_id, api_key_name,
|
||||
tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation,
|
||||
tokens_reasoning, status, success, latency_ms, ttft_ms, error_code, timestamp
|
||||
) VALUES (
|
||||
@id, @provider, @model, @connection_id, @api_key_id, @api_key_name,
|
||||
@tokens_input, @tokens_output, @tokens_cache_read, @tokens_cache_creation,
|
||||
@tokens_reasoning, @status, @success, @latency_ms, @ttft_ms, @error_code, @timestamp
|
||||
)
|
||||
`);
|
||||
for (const row of data.usageHistory) {
|
||||
insertUsageHistory.run({
|
||||
id: row.id,
|
||||
provider: row.provider ?? null,
|
||||
model: row.model ?? null,
|
||||
connection_id: row.connection_id ?? null,
|
||||
api_key_id: row.api_key_id ?? null,
|
||||
api_key_name: row.api_key_name ?? null,
|
||||
tokens_input: row.tokens_input ?? 0,
|
||||
tokens_output: row.tokens_output ?? 0,
|
||||
tokens_cache_read: row.tokens_cache_read ?? 0,
|
||||
tokens_cache_creation: row.tokens_cache_creation ?? 0,
|
||||
tokens_reasoning: row.tokens_reasoning ?? 0,
|
||||
status: row.status ?? null,
|
||||
success: row.success ?? 1,
|
||||
latency_ms: row.latency_ms ?? 0,
|
||||
ttft_ms: row.ttft_ms ?? 0,
|
||||
error_code: row.error_code ?? null,
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Domain Cost History
|
||||
if (data.domainCostHistory && data.domainCostHistory.length > 0) {
|
||||
const insertCostHistory = db.prepare(`
|
||||
INSERT OR REPLACE INTO domain_cost_history (
|
||||
id, api_key_id, cost, timestamp
|
||||
) VALUES (
|
||||
@id, @api_key_id, @cost, @timestamp
|
||||
)
|
||||
`);
|
||||
for (const row of data.domainCostHistory) {
|
||||
insertCostHistory.run({
|
||||
id: row.id,
|
||||
api_key_id: row.api_key_id,
|
||||
cost: row.cost,
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 9. Domain Budgets
|
||||
if (data.domainBudgets && data.domainBudgets.length > 0) {
|
||||
const insertBudgets = db.prepare(`
|
||||
INSERT OR REPLACE INTO domain_budgets (
|
||||
api_key_id, daily_limit_usd, weekly_limit_usd, monthly_limit_usd,
|
||||
warning_threshold, reset_interval, reset_time, budget_reset_at,
|
||||
last_budget_reset_at, warning_emitted_at, warning_period_start
|
||||
) VALUES (
|
||||
@api_key_id, @daily_limit_usd, @weekly_limit_usd, @monthly_limit_usd,
|
||||
@warning_threshold, @reset_interval, @reset_time, @budget_reset_at,
|
||||
@last_budget_reset_at, @warning_emitted_at, @warning_period_start
|
||||
)
|
||||
`);
|
||||
for (const row of data.domainBudgets) {
|
||||
insertBudgets.run({
|
||||
api_key_id: row.api_key_id,
|
||||
daily_limit_usd: row.daily_limit_usd,
|
||||
weekly_limit_usd: row.weekly_limit_usd ?? 0,
|
||||
monthly_limit_usd: row.monthly_limit_usd ?? 0,
|
||||
warning_threshold: row.warning_threshold ?? 0.8,
|
||||
reset_interval: row.reset_interval ?? "daily",
|
||||
reset_time: row.reset_time ?? "00:00",
|
||||
budget_reset_at: row.budget_reset_at ?? null,
|
||||
last_budget_reset_at: row.last_budget_reset_at ?? null,
|
||||
warning_emitted_at: row.warning_emitted_at ?? null,
|
||||
warning_period_start: row.warning_period_start ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
migrate();
|
||||
@@ -219,5 +313,8 @@ export function runJsonMigration(
|
||||
nodes: (data.providerNodes ?? []).length,
|
||||
combos: (data.combos ?? []).length,
|
||||
apiKeys: (data.apiKeys ?? []).length,
|
||||
usageHistory: (data.usageHistory ?? []).length,
|
||||
domainCostHistory: (data.domainCostHistory ?? []).length,
|
||||
domainBudgets: (data.domainBudgets ?? []).length,
|
||||
};
|
||||
}
|
||||
|
||||
17
test-kiro.ts
Normal file
17
test-kiro.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { buildKiroPayload } from "./open-sse/translator/request/openai-to-kiro.js";
|
||||
|
||||
const messages = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
messages.push({ role: "user", content: `user ${i}` });
|
||||
messages.push({ role: "assistant", content: `assistant ${i}` });
|
||||
}
|
||||
messages.push({ role: "user", content: "use tool" });
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
tool_calls: [{ id: "call_1", function: { name: "test", arguments: "{}" } }],
|
||||
});
|
||||
messages.push({ role: "tool", tool_call_id: "call_1", content: "ok" });
|
||||
messages.push({ role: "user", content: "last user" });
|
||||
|
||||
const payload = buildKiroPayload("kr/claude-sonnet-4.5", { messages, tools: [] }, false, {});
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
@@ -162,7 +162,7 @@ test.describe("Combo Unification", () => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const sidebar = page.locator("aside, nav").first();
|
||||
await expect(sidebar.getByText("Combos")).toBeVisible();
|
||||
await expect(sidebar.getByText("Combos", { exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByText("Auto Combo")).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -172,8 +172,7 @@ test.describe("Proxy Registry smoke flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await gotoDashboardRoute(page, "/dashboard/settings?tab=advanced");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await gotoDashboardRoute(page, "/dashboard/system/proxy");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Proxy Registry" })).toBeVisible();
|
||||
|
||||
|
||||
@@ -30,9 +30,7 @@ test.describe("Settings Toggles", () => {
|
||||
);
|
||||
|
||||
test("Debug mode toggle should work", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/settings");
|
||||
await waitForSettingsShell(page);
|
||||
await page.getByRole("tab", { name: /advanced/i }).click();
|
||||
await gotoDashboardRoute(page, "/dashboard/system/proxy");
|
||||
|
||||
const debugToggle = getDebugToggle(page);
|
||||
|
||||
@@ -99,9 +97,7 @@ test.describe("Settings Toggles", () => {
|
||||
});
|
||||
|
||||
test("Debug mode should persist after page reload", async ({ page }) => {
|
||||
await gotoDashboardRoute(page, "/dashboard/settings");
|
||||
await waitForSettingsShell(page);
|
||||
await page.getByRole("tab", { name: /advanced/i }).click();
|
||||
await gotoDashboardRoute(page, "/dashboard/system/proxy");
|
||||
|
||||
const debugToggle = getDebugToggle(page);
|
||||
|
||||
@@ -113,8 +109,7 @@ test.describe("Settings Toggles", () => {
|
||||
const nextState = initialState === "true" ? "false" : "true";
|
||||
await expect(debugToggle).toHaveAttribute("aria-checked", nextState, { timeout: 15000 });
|
||||
await page.reload();
|
||||
await waitForSettingsShell(page);
|
||||
await page.getByRole("tab", { name: /advanced/i }).click();
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
const reloadedToggle = getDebugToggle(page);
|
||||
await expect(reloadedToggle).toBeEnabled({ timeout: 15000 });
|
||||
await expect(reloadedToggle).toHaveAttribute("aria-checked", nextState, {
|
||||
|
||||
Reference in New Issue
Block a user