Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions app/api/channels/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { NextResponse } from "next/server";
import { and, sql, gte, lte } from "drizzle-orm";
import type { SQL } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { usageRecords, modelPrices } from "@/lib/db/schema";
import { estimateCost, priceMap } from "@/lib/usage";

type ChannelAggRow = {
channel: string | null;
requests: number;
tokens: number;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
errorCount: number;
};

type ChannelModelAggRow = {
channel: string | null;
model: string;
requests: number;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
};

type PriceRow = typeof modelPrices.$inferSelect;

function toNumber(value: unknown): number {
const num = Number(value ?? 0);
return Number.isFinite(num) ? num : 0;
}

function parseDateInput(value?: string | Date | null) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isFinite(date.getTime()) ? date : null;
}

function withDayStart(date: Date) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}

function withDayEnd(date: Date) {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
}

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const startParam = searchParams.get("start");
const endParam = searchParams.get("end");
const daysParam = searchParams.get("days");

const startDate = parseDateInput(startParam);
const endDate = parseDateInput(endParam);
const hasCustomRange = startDate && endDate && endDate >= startDate;

const DAY_MS = 24 * 60 * 60 * 1000;
const days = hasCustomRange
? Math.max(1, Math.round((withDayEnd(endDate).getTime() - withDayStart(startDate).getTime()) / DAY_MS) + 1)
: Math.min(Math.max(Math.floor(Number(daysParam) || 14), 1), 90);

const since = hasCustomRange ? withDayStart(startDate!) : new Date(Date.now() - days * DAY_MS);
const until = hasCustomRange ? withDayEnd(endDate!) : undefined;

const whereParts: SQL[] = [gte(usageRecords.occurredAt, since)];
if (until) whereParts.push(lte(usageRecords.occurredAt, until));
const whereClause = whereParts.length ? and(...whereParts) : undefined;

try {
// Fetch aggregated channel statistics
const channelAggRows: ChannelAggRow[] = await db
.select({
channel: usageRecords.channel,
requests: sql<number>`count(*)`,
tokens: sql<number>`sum(${usageRecords.totalTokens})`,
inputTokens: sql<number>`sum(${usageRecords.inputTokens})`,
outputTokens: sql<number>`sum(${usageRecords.outputTokens})`,
reasoningTokens: sql<number>`coalesce(sum(${usageRecords.reasoningTokens}), 0)`,
cachedTokens: sql<number>`coalesce(sum(${usageRecords.cachedTokens}), 0)`,
errorCount: sql<number>`sum(case when ${usageRecords.isError} then 1 else 0 end)`
})
.from(usageRecords)
.where(whereClause)
.groupBy(usageRecords.channel)
.orderBy(sql`count(*) desc`);

// Fetch channel-model breakdown for cost calculation
const channelModelAggRows: ChannelModelAggRow[] = await db
.select({
channel: usageRecords.channel,
model: usageRecords.model,
requests: sql<number>`count(*)`,
inputTokens: sql<number>`sum(${usageRecords.inputTokens})`,
outputTokens: sql<number>`sum(${usageRecords.outputTokens})`,
reasoningTokens: sql<number>`coalesce(sum(${usageRecords.reasoningTokens}), 0)`,
cachedTokens: sql<number>`coalesce(sum(${usageRecords.cachedTokens}), 0)`
})
.from(usageRecords)
.where(whereClause)
.groupBy(usageRecords.channel, usageRecords.model);

// Fetch pricing information
const priceRows: PriceRow[] = await db.select().from(modelPrices);
const prices = priceMap(
priceRows.map((p: PriceRow) => ({
model: p.model,
inputPricePer1M: Number(p.inputPricePer1M),
cachedInputPricePer1M: Number(p.cachedInputPricePer1M),
outputPricePer1M: Number(p.outputPricePer1M)
}))
);

// Calculate costs per channel
const channelCostMap = new Map<string, number>();
for (const row of channelModelAggRows) {
const channelKey = row.channel ?? "未知渠道";
const cost = estimateCost(
{
inputTokens: toNumber(row.inputTokens),
cachedTokens: toNumber(row.cachedTokens),
outputTokens: toNumber(row.outputTokens),
reasoningTokens: toNumber(row.reasoningTokens)
},
row.model,
prices
);
channelCostMap.set(channelKey, (channelCostMap.get(channelKey) ?? 0) + cost);
}

// Build response
const channels = channelAggRows.map((row) => {
const channelKey = row.channel ?? "未知渠道";
return {
channel: channelKey,
requests: toNumber(row.requests),
totalTokens: toNumber(row.tokens),
inputTokens: toNumber(row.inputTokens),
outputTokens: toNumber(row.outputTokens),
reasoningTokens: toNumber(row.reasoningTokens),
cachedTokens: toNumber(row.cachedTokens),
errorCount: toNumber(row.errorCount),
cost: Number((channelCostMap.get(channelKey) ?? 0).toFixed(4))
};
});

return NextResponse.json({ channels, days });
} catch (error) {
console.error("Error fetching channel statistics:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
);
}
}
2 changes: 2 additions & 0 deletions app/api/records/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export async function GET(request: Request) {
const cursor = searchParams.get("cursor");
const model = searchParams.get("model");
const route = searchParams.get("route");
const channel = searchParams.get("channel");
const start = searchParams.get("start");
const end = searchParams.get("end");
const includeFilters = searchParams.get("includeFilters") === "1";
Expand All @@ -43,6 +44,7 @@ export async function GET(request: Request) {
cursor,
model: model || undefined,
route: route || undefined,
channel: channel || undefined,
start,
end,
includeFilters
Expand Down
116 changes: 115 additions & 1 deletion app/api/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,114 @@ async function isAuthorized(request: Request) {
return false;
}

async function fetchAuthIndexMapping(): Promise<Map<string, string>> {
// 从 CLIProxyAPI 的 /auth-files 接口获取 auth_index -> 渠道名的映射
try {
const baseUrl = config.cliproxy.baseUrl.replace(/\/$/, "");
const url = baseUrl.endsWith("/v0/management")
? `${baseUrl}/auth-files`
: `${baseUrl}/v0/management/auth-files`;

const res = await fetch(url, {
headers: {
Authorization: `Bearer ${config.cliproxy.apiKey}`,
"Content-Type": "application/json"
},
cache: "no-store"
});

const map = new Map<string, string>();
if (!res.ok) {
console.warn(`Failed to fetch auth files mapping: ${res.status} ${res.statusText}`);
return map;
}

const data = (await res.json()) as { files?: Array<{ auth_index?: string; provider?: string; name?: string; email?: string }> };
for (const file of data.files ?? []) {
if (file.auth_index) {
// 用 "provider/name" 作为可读的渠道名
const channelName = [file.provider, file.name]
.filter(Boolean)
.join("/");
map.set(String(file.auth_index), channelName || file.auth_index);
}
}
return map;
} catch (error) {
console.warn("Error fetching auth index mapping:", error);
return new Map();
}
}

async function fetchApiKeyChannelMapping(): Promise<Map<string, string>> {
// 从 4 个 API key 端点获取 api-key -> 渠道名的映射
// 当 auth_index 无法通过 /auth-files 匹配时,用 source 匹配这些 api-key
try {
const baseUrl = config.cliproxy.baseUrl.replace(/\/$/, "");
const prefix = baseUrl.endsWith("/v0/management") ? baseUrl : `${baseUrl}/v0/management`;
const headers = {
Authorization: `Bearer ${config.cliproxy.apiKey}`,
"Content-Type": "application/json"
};

const endpoints = [
"openai-compatibility",
"claude-api-key",
"codex-api-key",
"gemini-api-key"
] as const;

const results = await Promise.allSettled(
endpoints.map((ep) =>
fetch(`${prefix}/${ep}`, { headers, cache: "no-store" }).then((r) =>
r.ok ? r.json() : null
)
)
);

const map = new Map<string, string>();

for (let i = 0; i < endpoints.length; i++) {
const result = results[i];
if (result.status !== "fulfilled" || !result.value) continue;
const data = result.value;
const ep = endpoints[i];

if (ep === "openai-compatibility") {
// 结构: { "openai-compatibility": [{ name, api-key-entries: [{ api-key }] }] }
const entries = data["openai-compatibility"] as Array<{
name?: string;
"api-key-entries"?: Array<{ "api-key"?: string }>;
}> | undefined;
for (const entry of entries ?? []) {
const channelName = entry.name || ep;
for (const keyEntry of entry["api-key-entries"] ?? []) {
if (keyEntry["api-key"] && !map.has(keyEntry["api-key"])) {
map.set(keyEntry["api-key"], channelName);
}
}
}
} else {
// 结构: { "<ep>": [{ api-key, base-url }] }
const entries = data[ep] as Array<{
"api-key"?: string;
"base-url"?: string;
}> | undefined;
for (const entry of entries ?? []) {
if (entry["api-key"] && !map.has(entry["api-key"])) {
map.set(entry["api-key"], entry["base-url"] || ep);
}
}
}
}

return map;
} catch (error) {
console.warn("Error fetching API key channel mapping:", error);
return new Map();
}
}

async function performSync(request: Request) {
if (!config.password && !config.cronSecret && !PASSWORD) return missingPassword();
if (!(await isAuthorized(request))) return unauthorized();
Expand Down Expand Up @@ -87,7 +195,13 @@ async function performSync(request: Request) {
);
}

const rows = toUsageRecords(payload, pulledAt);
// 获取 auth_index 到渠道名的映射(并行请求)
const [authMap, apiKeyMap] = await Promise.all([
fetchAuthIndexMapping(),
fetchApiKeyChannelMapping()
]);

const rows = toUsageRecords(payload, pulledAt, authMap, apiKeyMap);

if (rows.length === 0) {
return NextResponse.json({ status: "ok", inserted: 0, message: "No usage data" });
Expand Down
Loading