feat(engine): 实现同步引擎与 engine_status 快照

apps/engine 独立进程(syncRound + 防重叠调度);上游 log/token;QPU 采样;
server 读 engine_status;时刻出参 toUnixSec;中文日志;zod-openapi 扩展顺序修复。
This commit is contained in:
2026-10-07 12:54:13 +08:00
parent 3e12db1706
commit d47cb5543c
28 changed files with 676 additions and 102 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@new-api-key-usage/engine",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts"
},
"dependencies": {
"@new-api-key-usage/core": "workspace:*",
"@new-api-key-usage/db": "workspace:*"
}
}
+43
View File
@@ -0,0 +1,43 @@
export interface EngineEnv {
DATABASE_URL: string;
SK_KEY_ENCRYPTION_SECRET: string;
NEWAPI_BASE: string;
SYNC_INTERVAL_MS: number;
LOG_LEVEL: "debug" | "info" | "warning" | "error" | "fatal";
}
function parseLogLevel(
value: string | undefined,
): "debug" | "info" | "warning" | "error" | "fatal" {
const v = (value ?? "info").trim().toLowerCase();
if (
v === "debug" ||
v === "info" ||
v === "warning" ||
v === "error" ||
v === "fatal"
) {
return v;
}
throw new Error("LOG_LEVEL must be debug|info|warning|error|fatal");
}
export function loadEnv(source: Record<string, string | undefined>): EngineEnv {
const databaseUrl = source.DATABASE_URL;
const secret = source.SK_KEY_ENCRYPTION_SECRET;
if (!databaseUrl) throw new Error("DATABASE_URL is required");
if (!secret) throw new Error("SK_KEY_ENCRYPTION_SECRET is required");
const interval = Number(source.SYNC_INTERVAL_MS ?? 300_000);
if (!Number.isInteger(interval) || interval < 1000) {
throw new Error("SYNC_INTERVAL_MS must be an integer >= 1000");
}
return {
DATABASE_URL: databaseUrl,
SK_KEY_ENCRYPTION_SECRET: secret,
NEWAPI_BASE: source.NEWAPI_BASE ?? "http://127.0.0.1:8080",
SYNC_INTERVAL_MS: interval,
LOG_LEVEL: parseLogLevel(source.LOG_LEVEL),
};
}
+29
View File
@@ -0,0 +1,29 @@
import { appLogger, setupLogging } from "@new-api-key-usage/core";
import { createDb, createRepos } from "@new-api-key-usage/db";
import { loadEnv } from "./env.ts";
import { createSyncLoop } from "./loop.ts";
const env = loadEnv(process.env);
setupLogging(env.LOG_LEVEL);
const log = appLogger("engine");
const db = createDb(env.DATABASE_URL);
const repos = createRepos(db);
const loop = createSyncLoop({
repos,
newapiBase: env.NEWAPI_BASE,
skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET,
intervalMs: env.SYNC_INTERVAL_MS,
});
loop.start();
log.info`apps/engine 已就绪`;
process.on("SIGINT", () => {
void loop.stop().then(() => process.exit(0));
});
process.on("SIGTERM", () => {
void loop.stop().then(() => process.exit(0));
});
+75
View File
@@ -0,0 +1,75 @@
/**
* 调度循环(方案 A):一轮结束 → setTimeout 下一枪;inFlight 防重叠。
* 启停不得抹掉已有 last_round。
*/
import { appLogger } from "@new-api-key-usage/core";
import { syncRound, type SyncRoundDeps } from "./sync-round.ts";
const log = appLogger("engine");
export interface LoopOptions extends SyncRoundDeps {
intervalMs: number;
}
export function createSyncLoop(opts: LoopOptions) {
let inFlight = false;
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let last_round: unknown = null;
async function persist(running: boolean) {
await opts.repos.engine.upsertSnapshot({
running,
sync_interval_ms: opts.intervalMs,
last_round,
});
}
async function runOnce(): Promise<void> {
if (stopped) return;
if (inFlight) {
log.warn("上一轮尚未结束,跳过本枪");
schedule();
return;
}
inFlight = true;
try {
last_round = await syncRound(opts);
await persist(!stopped);
} catch (e) {
log.error(`同步轮次异常:${e}`);
await persist(!stopped).catch(() => {});
} finally {
inFlight = false;
schedule();
}
}
function schedule() {
if (stopped) return;
timer = setTimeout(() => {
void runOnce();
}, opts.intervalMs);
}
return {
start() {
log.info(`同步引擎启动:周期=${opts.intervalMs}ms`);
void (async () => {
const snap = await opts.repos.engine.getSnapshot();
last_round = snap?.last_round ?? null;
await persist(true).catch(() => {});
void runOnce();
})();
},
async stop() {
stopped = true;
if (timer) clearTimeout(timer);
// 等在跑的一轮结束,避免 stop 与 upsert 交错
for (let i = 0; i < 50 && inFlight; i++) {
await new Promise((r) => setTimeout(r, 100));
}
await persist(false).catch(() => {});
},
};
}
+174
View File
@@ -0,0 +1,174 @@
/**
* 同步轮次:按《同步引擎设计》§3 编排。
* 只经 packages/core(上游 + 去重)与 packages/db(仓储)写库。
*/
import {
appLogger,
buildDedupeKey,
createUpstreamClient,
KeyCrypto,
parseOtherJson,
toUnixSec,
} from "@new-api-key-usage/core";
import type { Repos } from "@new-api-key-usage/db";
const log = appLogger("engine");
export interface SyncRoundDeps {
repos: Repos;
newapiBase: string;
skKeyEncryptionSecret: string;
}
export interface LastRound {
started_at: number;
keys_total: number;
succeeded: number;
failed: number;
failed_api_key_ids: number[];
}
function parseOther(other: string | undefined): {
billing_source: string | null;
subscription_id: number | null;
raw: Record<string, unknown> | null;
} {
return parseOtherJson(other);
}
export async function syncRound(deps: SyncRoundDeps): Promise<LastRound> {
const started = Date.now();
const started_at = toUnixSec(new Date())!;
const client = createUpstreamClient({ baseUrl: deps.newapiBase });
const crypto = await KeyCrypto.fromEncryptionSecret(
deps.skKeyEncryptionSecret,
);
const keys = await deps.repos.keys.listActive();
const failed_api_key_ids: number[] = [];
let succeeded = 0;
let pulled = 0;
let inserted = 0;
let skipped = 0;
for (const key of keys) {
try {
const sk = await crypto.openKey(key.key_ciphertext);
const usage = await client.fetchTokenUsage(sk);
if (!usage.ok) {
failed_api_key_ids.push(key.id);
log.warn(`同步失败 key_id=${key.id} 原因=usage/${usage.kind}`);
continue;
}
await deps.repos.keys.update(key.id, {
token_name: usage.data.name ?? key.token_name,
total_granted: usage.data.total_granted,
total_used: usage.data.total_used,
total_available: usage.data.total_available,
unlimited_quota: usage.data.unlimited_quota,
model_limits: usage.data.model_limits,
model_limits_enabled: usage.data.model_limits_enabled,
expires_at: normalizeExpires(usage.data.expires_at),
usage_synced_at: new Date(),
});
const logs = await client.fetchTokenLogs(sk);
if (!logs.ok) {
failed_api_key_ids.push(key.id);
log.warn(`同步失败 key_id=${key.id} 原因=logs/${logs.kind}`);
continue;
}
const rows = [...(logs.data ?? [])].sort((a, b) => {
const ta = a.created_at ?? 0;
const tb = b.created_at ?? 0;
if (ta !== tb) return ta - tb;
return String(a.request_id ?? "").localeCompare(String(b.request_id ?? ""));
});
pulled += rows.length;
for (const row of rows) {
const other = parseOther(row.other);
const created_at = new Date(row.created_at * 1000);
const dedupe_key = await buildDedupeKey({
api_key_id: key.id,
request_id: row.request_id,
type: row.type ?? 2,
created_at: row.created_at,
model_name: row.model_name ?? "",
quota: row.quota ?? 0,
prompt_tokens: row.prompt_tokens ?? 0,
completion_tokens: row.completion_tokens ?? 0,
use_time: row.use_time ?? 0,
content: row.content ?? "",
});
const ok = await deps.repos.keys.insertLog({
api_key_id: key.id,
dedupe_key,
billing_source: other.billing_source,
subscription_id: other.subscription_id,
user_id: row.user_id ?? 0,
created_at,
type: row.type ?? 2,
content: row.content ?? "",
username: row.username ?? "",
token_name: row.token_name ?? "",
model_name: row.model_name ?? "",
quota: row.quota ?? 0,
prompt_tokens: row.prompt_tokens ?? 0,
completion_tokens: row.completion_tokens ?? 0,
use_time: row.use_time ?? 0,
is_stream: !!row.is_stream,
channel: row.channel ?? 0,
channel_name: row.channel_name ?? "",
token_id: row.token_id ?? 0,
group_name: row.group ?? "",
ip: row.ip ?? "",
request_id: row.request_id ?? "",
upstream_request_id: row.upstream_request_id ?? null,
other: other.raw,
});
if (ok) inserted += 1;
else skipped += 1;
}
await deps.repos.keys.update(key.id, { log_synced_at: new Date() });
succeeded += 1;
} catch (e) {
failed_api_key_ids.push(key.id);
log.error(`同步异常 key_id=${key.id} ${e}`);
}
}
// QPU 采样:值变化才追加
const qpuRes = await client.fetchQuotaPerUnit();
if (qpuRes.ok) {
const latest = await deps.repos.qpu.latest();
const next = qpuRes.data.quota_per_unit;
if (!latest || latest.qpu !== next) {
await deps.repos.qpu.insertChange(next, new Date());
log.info(`QPU 变更 ${latest?.qpu ?? "-"} -> ${next}`);
}
} else {
log.warn(`QPU 采样失败 ${qpuRes.kind}`);
}
const last_round: LastRound = {
started_at,
keys_total: keys.length,
succeeded,
failed: failed_api_key_ids.length,
failed_api_key_ids,
};
log.info(
`同步轮次完成 key=${keys.length} 成功=${succeeded} 失败=${failed_api_key_ids.length} 拉取=${pulled} 新写=${inserted} 去重跳过=${skipped} 耗时=${Date.now() - started}ms`,
);
return last_round;
}
function normalizeExpires(unixSeconds: number | undefined | null): Date | null {
if (!unixSeconds) return null;
return new Date(unixSeconds * 1000);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"allowImportingTsExtensions": true
},
"include": ["src/**/*.ts"]
}
+29 -16
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "@hono/zod-openapi";
/** 必须先扩展 zod,再加载 core schema(见 zod-openapi-setup) */
import "./zod-openapi-setup.ts";
import {
changePasswordBody,
createMemberBody,
@@ -24,21 +24,34 @@ import {
usernameBody,
} from "@new-api-key-usage/core";
// OpenAPI 组件命名只在 server 层装饰,core 保持 Web 标准中立
extendZodWithOpenApi(z);
/** OpenAPI 组件命名只在 server 层装饰,core 保持 Web 标准中立 */
function nameSchema<T extends { openapi?: (n: string) => unknown }>(
schema: T,
name: string,
): T {
return typeof schema.openapi === "function"
? (schema.openapi(name) as T)
: schema;
}
export const errorSchema = errorEnvelope.openapi("ErrorEnvelope");
export const setupStateSchema = setupState.openapi("SetupState");
export const engineStatusSchema = engineStatus.openapi("EngineStatus");
export const keyItemSchema = keyItem.openapi("KeyItem");
export const memberItemSchema = memberItem.openapi("MemberItem");
export const memberWithTokenSchema = memberWithToken.openapi("MemberWithToken");
export const memberMeSchema = memberMe.openapi("MemberMe");
export const memberOverviewSchema = memberOverview.openapi("MemberOverview");
export const logItemSchema = logItem.openapi("LogItem");
export const logPageSchema = logPage.openapi("LogPage");
export const paymentItemSchema = paymentItem.openapi("PaymentItem");
export const auditLogItemSchema = auditLogItem.openapi("AuditLogItem");
export const errorSchema = nameSchema(errorEnvelope, "ErrorEnvelope");
export const setupStateSchema = nameSchema(setupState, "SetupState");
export const engineStatusSchema = nameSchema(engineStatus, "EngineStatus");
export const keyItemSchema = nameSchema(keyItem, "KeyItem");
export const memberItemSchema = nameSchema(memberItem, "MemberItem");
export const memberWithTokenSchema = nameSchema(
memberWithToken,
"MemberWithToken",
);
export const memberMeSchema = nameSchema(memberMe, "MemberMe");
export const memberOverviewSchema = nameSchema(
memberOverview,
"MemberOverview",
);
export const logItemSchema = nameSchema(logItem, "LogItem");
export const logPageSchema = nameSchema(logPage, "LogPage");
export const paymentItemSchema = nameSchema(paymentItem, "PaymentItem");
export const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem");
export {
changePasswordBody as changePasswordBodySchema,
+4 -2
View File
@@ -43,7 +43,9 @@ export function createApp(opts: CreateAppOptions) {
try {
await next();
} finally {
appLogger("http").info`${c.req.method} ${c.req.path} → ${c.res.status} · ${Date.now() - start}ms`;
appLogger("http").info(
`${c.req.method} ${c.req.path} -> ${c.res.status} | ${Date.now() - start}ms`,
);
}
});
app.use("*", async (c, next) => {
@@ -67,7 +69,7 @@ export function createApp(opts: CreateAppOptions) {
});
app.onError((err, c) => {
appLogger("server").error`未捕获错误 ${c.req.method} ${c.req.path}:${err}`;
appLogger("server").error(`未捕获错误 ${c.req.method} ${c.req.path}:${err}`);
return jsonError(c, 500, ERR.internal());
});
+3 -1
View File
@@ -17,7 +17,9 @@ const app = createApp({
adminInitPassword: env.ADMIN_INIT_PASSWORD,
});
appLogger("server").info`服务已启动,端口 ${env.PORT}(cookie_secure=${env.COOKIE_SECURE},日志级别=${env.LOG_LEVEL}`;
appLogger("server").info(
`服务已启动,端口 ${env.PORT}(cookie_secure=${env.COOKIE_SECURE},日志级别=${env.LOG_LEVEL})`,
);
export default {
port: env.PORT,
+2 -2
View File
@@ -1,5 +1,5 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { unixToIso, type AuditLogItem } from "@new-api-key-usage/core";
import { toUnixSec, type AuditLogItem } from "@new-api-key-usage/core";
import {
requireAdminSession,
type AppVars,
@@ -56,7 +56,7 @@ export function adminAuditRoutes() {
});
const items: AuditLogItem[] = rows.map((row) => ({
id: row.id,
created_at: unixToIso(row.created_at)!,
created_at: toUnixSec(row.created_at)!,
operator: row.operator,
domain: row.domain as AuditLogItem["domain"],
action: row.action,
+19 -15
View File
@@ -3,11 +3,22 @@ import type { EngineStatus } from "@new-api-key-usage/core";
import { requireAdminSession, type AppVars } from "../middleware.ts";
import { engineStatusSchema, err401 } from "../api-schemas.ts";
/** 状态存储归同步引擎设计;此处只暴露契约字段 */
let engineStatus: EngineStatus = {
running: false,
last_round: null,
};
export type EngineStatusView = EngineStatus & { sync_interval_ms: number };
export function toEngineStatusView(row: {
running: boolean;
sync_interval_ms: number;
last_round: unknown;
} | null): EngineStatusView {
if (!row) {
return { running: false, sync_interval_ms: 300_000, last_round: null };
}
return {
running: row.running,
sync_interval_ms: row.sync_interval_ms,
last_round: (row.last_round as EngineStatus["last_round"]) ?? null,
};
}
export function adminEngineRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
@@ -19,7 +30,7 @@ export function adminEngineRoutes() {
path: "/engine",
summary: "引擎状态",
description:
"最近轮次摘要;last_round 为 null 表示引擎尚未跑过。状态存储细节归同步引擎设计。",
"同步引擎单行快照(engine_status);last_round 为 null 表示尚未跑过。sync_interval_ms 只读。",
tags: ["引擎"],
security: [{ adminSession: [] }],
responses: {
@@ -31,17 +42,10 @@ export function adminEngineRoutes() {
},
}),
async (c) => {
return c.json(engineStatus, 200);
const row = await c.get("repos").engine.getSnapshot();
return c.json(toEngineStatusView(row), 200);
},
);
return r;
}
export function setEngineStatus(next: EngineStatus) {
engineStatus = next;
}
export function getEngineStatus(): EngineStatus {
return engineStatus;
}
+10 -7
View File
@@ -3,7 +3,7 @@ import {
ERR,
KeyCrypto,
normalizeExpiresAt,
unixToIso,
toUnixSec,
type KeyItem,
} from "@new-api-key-usage/core";
import type { ApiKeyRow } from "@new-api-key-usage/db";
@@ -30,14 +30,14 @@ function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
status: row.status as "active" | "disabled",
member_id: row.member_id,
member_name: row.member_name ?? null,
registered_at: unixToIso(row.registered_at)!,
log_synced_at: unixToIso(row.log_synced_at),
usage_synced_at: unixToIso(row.usage_synced_at),
registered_at: toUnixSec(row.registered_at)!,
log_synced_at: toUnixSec(row.log_synced_at),
usage_synced_at: toUnixSec(row.usage_synced_at),
total_used: row.total_used,
total_available: row.total_available,
total_granted: row.total_granted,
unlimited_quota: row.unlimited_quota,
expires_at: unixToIso(row.expires_at),
expires_at: toUnixSec(row.expires_at),
};
}
@@ -85,8 +85,11 @@ export function adminKeyRoutes() {
}
let rows = await c.get("repos").keys.listAll(filter);
if (q.recent_failed === "true") {
const { getEngineStatus } = await import("./admin-engine.ts");
const failed = new Set(getEngineStatus().last_round?.failed_api_key_ids ?? []);
const snap = await c.get("repos").engine.getSnapshot();
const failed = new Set(
((snap?.last_round as { failed_api_key_ids?: number[] } | null)
?.failed_api_key_ids) ?? [],
);
rows = rows.filter((r) => failed.has(r.id));
}
return c.json(rows.map(toKeyItem), 200);
+3 -3
View File
@@ -1,5 +1,5 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { ERR, generateToken, unixToIso } from "@new-api-key-usage/core";
import { ERR, generateToken, toUnixSec } from "@new-api-key-usage/core";
import type { MemberRow } from "@new-api-key-usage/db";
import {
jsonError,
@@ -25,8 +25,8 @@ function toMemberItem(row: MemberRow, key_count: number) {
name: row.name,
remark: row.remark,
status: row.status as "active" | "disabled",
token_issued_at: unixToIso(row.token_issued_at),
created_at: unixToIso(row.created_at)!,
token_issued_at: toUnixSec(row.token_issued_at),
created_at: toUnixSec(row.created_at)!,
key_count,
};
}
+4 -4
View File
@@ -4,7 +4,7 @@ import {
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
unixToIso,
toUnixSec,
type PaymentItem,
} from "@new-api-key-usage/core";
import type { PaymentRow } from "@new-api-key-usage/db";
@@ -31,11 +31,11 @@ function toPaymentItem(row: PaymentRow, quota_usd: string): PaymentItem {
return {
id: row.id,
kind: row.kind as PaymentItem["kind"],
paid_at: unixToIso(row.paid_at)!,
paid_at: toUnixSec(row.paid_at)!,
paid_amount: row.paid_amount,
remark: row.remark,
period_start: unixToIso(row.period_start),
period_end: unixToIso(row.period_end),
period_start: toUnixSec(row.period_start),
period_end: toUnixSec(row.period_end),
subscription_id: row.subscription_id,
quota_granted: row.quota_granted,
quota_usd,
+11 -11
View File
@@ -3,7 +3,7 @@ import {
ERR,
resolveQpuTimeline,
sumUsage,
unixToIso,
toUnixSec,
type LogItem,
} from "@new-api-key-usage/core";
import {
@@ -25,7 +25,7 @@ const usageTotals = z.object({
});
const overviewSchema = z.object({
data_cutoff: z.string().nullable(),
data_cutoff: z.number().int().nullable(),
unassigned_subscription: usageTotals,
by_source: z.array(
z.object({
@@ -40,7 +40,7 @@ const overviewSchema = z.object({
member_id: z.number().int().nullable(),
member_name: z.string().nullable(),
key_count: z.number().int(),
data_cutoff: z.string().nullable(),
data_cutoff: z.number().int().nullable(),
usage: usageTotals,
}),
),
@@ -50,7 +50,7 @@ const overviewSchema = z.object({
token_name: z.string(),
member_id: z.number().int().nullable(),
status: z.string(),
data_cutoff: z.string().nullable(),
data_cutoff: z.number().int().nullable(),
usage: usageTotals,
}),
),
@@ -58,7 +58,7 @@ const overviewSchema = z.object({
const qpuLatestSchema = z.object({
qpu: z.number(),
effective_from: z.string(),
effective_from: z.number().int(),
});
const timeRange = z.object({
@@ -106,14 +106,14 @@ export function adminUsageRoutes() {
const latest = await c.get("repos").qpu.latest();
if (!latest) {
return c.json(
{ qpu: 500_000, effective_from: "1970-01-01T00:00:00Z" },
{ qpu: 500_000, effective_from: 0 },
200,
);
}
return c.json(
{
qpu: latest.qpu,
effective_from: unixToIso(latest.effective_from)!,
effective_from: toUnixSec(latest.effective_from)!,
},
200,
);
@@ -208,7 +208,7 @@ export function adminUsageRoutes() {
token_name: v.token_name,
member_id: v.member_id,
status: v.status,
data_cutoff: unixToIso(v.log_synced_at),
data_cutoff: toUnixSec(v.log_synced_at),
usage: sumUsage(v.rows, timeline),
}))
.sort((a, b) => b.usage.quota - a.usage.quota);
@@ -248,13 +248,13 @@ export function adminUsageRoutes() {
member_id: b.member_id,
member_name: b.member_name,
key_count: b.key_count,
data_cutoff: unixToIso(b.cutoff),
data_cutoff: toUnixSec(b.cutoff),
usage: sumUsage(b.rows, timeline),
}));
return c.json(
{
data_cutoff: unixToIso(data_cutoff),
data_cutoff: toUnixSec(data_cutoff),
unassigned_subscription,
by_source,
by_member,
@@ -326,7 +326,7 @@ export function adminUsageRoutes() {
return {
id: row.id,
api_key_id: row.api_key_id,
created_at: unixToIso(row.created_at)!,
created_at: toUnixSec(row.created_at)!,
token_name: row.token_name,
model_name: row.model_name,
type: row.type,
+6 -6
View File
@@ -3,7 +3,7 @@ import {
ERR,
resolveQpuTimeline,
sumUsage,
unixToIso,
toUnixSec,
type LogItem,
type PaymentItem,
} from "@new-api-key-usage/core";
@@ -156,7 +156,7 @@ export function memberViewRoutes() {
const keys = [...keyMap.entries()].map(([key_id, v]) => ({
key_id,
token_name: v.token_name,
log_synced_at: unixToIso(v.log_synced_at),
log_synced_at: toUnixSec(v.log_synced_at),
usage: sumUsage(v.rows, timeline),
}));
@@ -185,11 +185,11 @@ export function memberViewRoutes() {
payments.push({
id: p.id,
kind: p.kind as PaymentItem["kind"],
paid_at: unixToIso(p.paid_at)!,
paid_at: toUnixSec(p.paid_at)!,
paid_amount: p.paid_amount,
remark: p.remark,
period_start: unixToIso(p.period_start),
period_end: unixToIso(p.period_end),
period_start: toUnixSec(p.period_start),
period_end: toUnixSec(p.period_end),
subscription_id: p.subscription_id,
quota_granted: p.quota_granted,
quota_usd:
@@ -272,7 +272,7 @@ export function memberViewRoutes() {
return {
id: row.id,
api_key_id: row.api_key_id,
created_at: unixToIso(row.created_at)!,
created_at: toUnixSec(row.created_at)!,
token_name: row.token_name ?? "",
model_name: row.model_name,
type: row.type,
+7
View File
@@ -0,0 +1,7 @@
/** 必须在 core 的 zod schema 被求值之前执行(api-schemas 首行 side-effect import) */
import { z } from "zod";
import { extendZodWithOpenApi } from "@hono/zod-openapi";
extendZodWithOpenApi(z);
export {};