add:增加缓存

This commit is contained in:
lsy committed 2026-03-18 01:20:30 +08:00
1 parent 4e3b1315a1
commit 54baba28a3
36 files changed
+2374 -357

No files matched your search

+4
View File
@@ -9,6 +9,10 @@
### 变更
- **[settings/proxy/usage/admin-ui]**: 运行时设置可在后台配置,新增队列日限额与直写比例并接入 UsageLimiter — by lsy
- 方案: [202603180031_usage-queue-simplified](archive/2026-03/202603180031_usage-queue-simplified/)
- **[cache/settings/proxy/usage/admin-ui]**: 引入分组缓存与自动失效,面板/日志/模型短期缓存可配置,清理节流并补齐索引 — by lsy
- 方案: [202603172242_cache-strategy-optimizations](plan/202603172242_cache-strategy-optimizations/)
- **[deploy/proxy/settings]**: 调整 stream usage 默认值并避免部署覆盖云端变量 — by lsy
- 方案: [202603170025_runtime-defaults-no-override](archive/2026-03/202603170025_runtime-defaults-no-override/)
- **[admin-ui/usage]**: 使用日志渠道筛选改为搜索多选,分页条数与列偏好本地记忆 — by lsy
+4 -1
View File
@@ -38,7 +38,10 @@
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
- 系统设置增加定时签到开关与时间(中国时间)
- 系统设置提供模型失败冷却分钟数配置
- 系统设置展示运行时配置(只读),以卡片显示名称 + 环境变量名 + 当前值,并提示在部署环境中修改环境变量
- 系统设置提供运行时配置可编辑(上游超时、流式解析模式/上限、队列开关/日限额/直写比例)
- 系统设置展示运行时配置(只读),以卡片显示名称 + 环境变量名 + 当前值,并提示设置优先生效
- 系统设置提供缓存开关与 TTL(面板/日志/模型/鉴权/渠道/调用令牌/设置)配置
- 系统设置展示缓存版本号并提供刷新入口
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数(无数据时显示 0 页)
- 每页条数选择与列显示偏好本地持久化
- 页面布局拆分为“标题区 + 筛选区 + 数据区”,避免单一大卡片堆叠
+1
View File
@@ -14,6 +14,7 @@
- 可选筛选:`channel_ids` / `token_ids` / `model`
- 管理台默认展示全量时间范围,并在趋势卡片内提供统计颗粒(按日/周/月)
- 统计颗粒与时间范围在前端本地存储并优先复用
- `GET /api/dashboard` 支持短时缓存(TTL 可配置,版本化失效)
## 依赖关系
- `usage_logs` 表
+1
View File
@@ -13,6 +13,7 @@
- 使用“模型广场测试结果”作为可用模型来源
- 模型归一化支持字符串数组与对象数组(对象取 `id` 字段)
- 管理台模型广场支持搜索、渠道筛选与分页展示
- `GET /api/models` 支持短时缓存(TTL 可配置,版本化失效)
## 依赖关系
- `channels` 表
+7 -1
View File
@@ -17,6 +17,8 @@
- 调用令牌按模型列表匹配选择,未配置或无列表时回退 `channels.api_key`
- 调用令牌模型列表来自渠道连通测试写回
- done-hub 仅使用 `base_url`,不再按多地址切换
- 令牌鉴权、启用渠道与调用令牌支持短时缓存;禁用/变更会触发分组缓存版本失效
- 失败冷却/阈值判断不做跨请求缓存,确保即时生效
- 支持从非流式 JSON、响应头与流式 SSE(含 `response.usage` 与 `usageMetadata`)解析 usage 字段
- 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage
- 流式 usage 解析支持 `full/lite/off` 模式(`lite` 仅解析包含 usage 的事件,并可限制最大扫描字节)
@@ -24,6 +26,8 @@
- 可配置失败重试轮询(响应 5xx/429 时触发)
- 记录流式请求标记、首 token 延迟与推理强度到 usage_logs
- 使用日志与模型能力写入可通过 `USAGE_QUEUE` 异步化(队列不可用时自动回退同步写入)
- 队列写入采用“两态简化逻辑”:未超限时按比例直写/队列,超限后全部直写
- 队列日限额由 `UsageLimiter` DO 维护,限制 `USAGE_QUEUE` 的日内发送量
- 当 usage 解析被跳过或超限时,会记录 `usage_skipped/usage_missing` 用于可观测性
- 上游类型由 `metadata_json.site_type` 决定(`openai/anthropic/gemini`,其他类型视为 openai 兼容)
- 允许 `metadata_json.model_mapping` 将下游模型映射为上游模型
@@ -37,7 +41,9 @@
- 仅 429/5xx/408/timeout/exception 计入冷却,4xx 请求错误不触发冷却
- 映射模型场景失败时同时记录下游模型错误,确保冷却窗口生效
- 上游成功响应会刷新能力表,清空错误并延长可用窗口
- CPU 保护开关(环境变量): `PROXY_STREAM_USAGE_MODE`, `PROXY_STREAM_USAGE_MAX_BYTES`, `PROXY_STREAM_USAGE_MAX_PARSERS`, `PROXY_USAGE_QUEUE_ENABLED`
- 运行时配置优先读取 settings,环境变量仅回退
- CPU 保护相关配置: `proxy_stream_usage_mode`, `proxy_stream_usage_max_bytes`, `proxy_stream_usage_max_parsers`, `proxy_usage_queue_enabled`
- 队列策略配置: `usage_queue_daily_limit`, `usage_queue_direct_write_ratio`
- 默认值: `PROXY_STREAM_USAGE_MODE=full`, `PROXY_STREAM_USAGE_MAX_BYTES=0`, `PROXY_STREAM_USAGE_MAX_PARSERS=0`, `PROXY_USAGE_QUEUE_ENABLED=true`
- `PROXY_STREAM_USAGE_MAX_BYTES` 与 `PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制
+28 -5
View File
@@ -12,15 +12,38 @@
## 行为规范
- 配置写入后即时生效
- 未配置时使用环境变量默认值
- 未配置时使用环境变量/默认值回退
- 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间)
- 更新签到时间后会触发 `CheckinScheduler` 重新调度
- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到
- 模型失败冷却以分钟计(默认 10 分钟),用于跳过最近失败的渠道/模型
- 代理 CPU 降载相关开关使用环境变量(非 DB 设置)
- `GET /api/settings` 额外返回 `runtime_config`(环境变量读取结果),用于展示流式 usage 解析与队列状态
- 运行时配置默认值为 `PROXY_STREAM_USAGE_MODE=full`、`PROXY_STREAM_USAGE_MAX_BYTES=0`、`PROXY_STREAM_USAGE_MAX_PARSERS=0`
- 运行时配置中 `PROXY_STREAM_USAGE_MAX_BYTES/PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制
- 运行时配置可写入 settings(环境变量作为回退)
- `GET /api/settings` 返回:
- `runtime_settings`(可写配置值)
- `runtime_config`(生效值 + 队列绑定/生效状态)
- 运行时配置字段:
- `proxy_upstream_timeout_ms`
- `proxy_stream_usage_mode`
- `proxy_stream_usage_max_bytes`
- `proxy_stream_usage_max_parsers`
- `proxy_usage_queue_enabled`
- `usage_queue_daily_limit`
- `usage_queue_direct_write_ratio`
- 运行时配置默认值:
- `PROXY_STREAM_USAGE_MODE=full`
- `PROXY_STREAM_USAGE_MAX_BYTES=0`
- `PROXY_STREAM_USAGE_MAX_PARSERS=0`
- `PROXY_UPSTREAM_TIMEOUT_MS=30000`
- `usage_queue_daily_limit=10000`
- `usage_queue_direct_write_ratio=0.5`
- `PROXY_STREAM_USAGE_MAX_BYTES/PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制
- 缓存配置字段:
- `cache_enabled`
- `cache_ttl_dashboard_seconds` / `cache_ttl_usage_seconds` / `cache_ttl_models_seconds`
- `cache_ttl_tokens_seconds` / `cache_ttl_channels_seconds` / `cache_ttl_call_tokens_seconds`
- `cache_ttl_settings_seconds`
- `cache_config` 中包含分组版本号(dashboard/usage/models/tokens/channels/call_tokens/settings),用于精准失效
- `POST /api/settings/cache/refresh` 会 bump 所有缓存版本(用于紧急刷新)
## 依赖关系
- `settings` 表
+2
View File
@@ -11,8 +11,10 @@
## 行为规范
- 代理调用统一写入 `usage_logs`(含无可用渠道/冷却提前返回)
- 使用日志可通过 `USAGE_QUEUE` 异步写入(可能存在短暂延迟)
- 队列写入受 `usage_queue_daily_limit` 限制,超限后自动回退为 Worker 直写
- 流式 usage 解析被跳过或超限时会记录 `usage_skipped/usage_missing` 以便排查
- 保留天数可配置,查询时触发清理
- `GET /api/usage` 支持短时缓存(TTL 可配置,版本化失效)
- 查询结果附带渠道与令牌名称
- `GET /api/usage` 支持 `offset/limit` 分页并返回 `total`
- 支持按 `model/channel/token/status` 关键词搜索(保留 `channel_id/token_id` 精确过滤,新增 `channel_ids`/`token_ids`/`models`/`statuses` 多选过滤)
+163 -2
View File
@@ -305,8 +305,12 @@ const App = () => {
);
const [checkinLastRun, setCheckinLastRun] = useState<string | null>(null);
const [, setPendingActions] = useState<Set<string>>(() => new Set());
const pendingActionsRef = useRef<Set<string>>(new Set());
const noticeTimersRef = useRef<Map<number, number>>(new Map());
const pendingActionsRef = useRef<Set<string>>(new Set()) as {
current: Set<string>;
};
const noticeTimersRef = useRef<Map<number, number>>(new Map()) as {
current: Map<number, number>;
};
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null);
const [confirmPending, setConfirmPending] = useState(false);
@@ -649,6 +653,9 @@ const App = () => {
if (!data.settings) {
return;
}
const cacheConfig = data.settings.cache_config;
const runtimeSettings =
data.settings.runtime_settings ?? data.settings.runtime_config;
setSettingsForm({
log_retention_days: String(data.settings.log_retention_days ?? 30),
session_ttl_hours: String(data.settings.session_ttl_hours ?? 12),
@@ -657,6 +664,41 @@ const App = () => {
model_failure_cooldown_minutes: String(
data.settings.model_failure_cooldown_minutes ?? 10,
),
proxy_upstream_timeout_ms: String(
runtimeSettings?.upstream_timeout_ms ?? 30000,
),
proxy_stream_usage_mode:
runtimeSettings?.stream_usage_mode ?? "full",
proxy_stream_usage_max_bytes: String(
runtimeSettings?.stream_usage_max_bytes ?? 0,
),
proxy_stream_usage_max_parsers: String(
runtimeSettings?.stream_usage_max_parsers ?? 0,
),
proxy_usage_queue_enabled:
runtimeSettings?.usage_queue_enabled ?? true,
usage_queue_daily_limit: String(
runtimeSettings?.usage_queue_daily_limit ?? 10000,
),
usage_queue_direct_write_ratio: String(
runtimeSettings?.usage_queue_direct_write_ratio ?? 0.5,
),
cache_enabled: cacheConfig?.enabled ?? true,
cache_ttl_dashboard_seconds: String(
cacheConfig?.dashboard_ttl_seconds ?? 30,
),
cache_ttl_usage_seconds: String(cacheConfig?.usage_ttl_seconds ?? 15),
cache_ttl_models_seconds: String(cacheConfig?.models_ttl_seconds ?? 60),
cache_ttl_tokens_seconds: String(cacheConfig?.tokens_ttl_seconds ?? 15),
cache_ttl_channels_seconds: String(
cacheConfig?.channels_ttl_seconds ?? 15,
),
cache_ttl_call_tokens_seconds: String(
cacheConfig?.call_tokens_ttl_seconds ?? 15,
),
cache_ttl_settings_seconds: String(
cacheConfig?.settings_ttl_seconds ?? 30,
),
});
}, [data.settings]);
@@ -1260,6 +1302,34 @@ const App = () => {
const failureCooldownMinutes = Number(
settingsForm.model_failure_cooldown_minutes,
);
const upstreamTimeoutMs = Number(settingsForm.proxy_upstream_timeout_ms);
const streamUsageMode =
settingsForm.proxy_stream_usage_mode.trim().toLowerCase();
const streamUsageMaxBytes = Number(
settingsForm.proxy_stream_usage_max_bytes,
);
const streamUsageMaxParsers = Number(
settingsForm.proxy_stream_usage_max_parsers,
);
const usageQueueDailyLimit = Number(
settingsForm.usage_queue_daily_limit,
);
const usageQueueDirectRatio = Number(
settingsForm.usage_queue_direct_write_ratio,
);
const cacheDashboardTtl = Number(
settingsForm.cache_ttl_dashboard_seconds,
);
const cacheUsageTtl = Number(settingsForm.cache_ttl_usage_seconds);
const cacheModelsTtl = Number(settingsForm.cache_ttl_models_seconds);
const cacheTokensTtl = Number(settingsForm.cache_ttl_tokens_seconds);
const cacheChannelsTtl = Number(settingsForm.cache_ttl_channels_seconds);
const cacheCallTokensTtl = Number(
settingsForm.cache_ttl_call_tokens_seconds,
);
const cacheSettingsTtl = Number(
settingsForm.cache_ttl_settings_seconds,
);
if (
Number.isNaN(retention) ||
retention < 1 ||
@@ -1273,6 +1343,53 @@ const App = () => {
pushNotice("warning", "失败冷却需为正整数");
return;
}
if (Number.isNaN(upstreamTimeoutMs) || upstreamTimeoutMs < 0) {
pushNotice("warning", "上游超时需为非负整数");
return;
}
if (!["full", "lite", "off"].includes(streamUsageMode)) {
pushNotice("warning", "流式解析模式需为 full/lite/off");
return;
}
if (Number.isNaN(streamUsageMaxBytes) || streamUsageMaxBytes < 0) {
pushNotice("warning", "最大字节数需为非负整数");
return;
}
if (Number.isNaN(streamUsageMaxParsers) || streamUsageMaxParsers < 0) {
pushNotice("warning", "并发上限需为非负整数");
return;
}
if (Number.isNaN(usageQueueDailyLimit) || usageQueueDailyLimit < 0) {
pushNotice("warning", "队列日限额需为非负整数");
return;
}
if (
Number.isNaN(usageQueueDirectRatio) ||
usageQueueDirectRatio < 0 ||
usageQueueDirectRatio > 1
) {
pushNotice("warning", "直写比例需在 0-1 之间");
return;
}
if (
Number.isNaN(cacheDashboardTtl) ||
cacheDashboardTtl < 0 ||
Number.isNaN(cacheUsageTtl) ||
cacheUsageTtl < 0 ||
Number.isNaN(cacheModelsTtl) ||
cacheModelsTtl < 0 ||
Number.isNaN(cacheTokensTtl) ||
cacheTokensTtl < 0 ||
Number.isNaN(cacheChannelsTtl) ||
cacheChannelsTtl < 0 ||
Number.isNaN(cacheCallTokensTtl) ||
cacheCallTokensTtl < 0 ||
Number.isNaN(cacheSettingsTtl) ||
cacheSettingsTtl < 0
) {
pushNotice("warning", "缓存 TTL 需为非负整数");
return;
}
startAction(actionKey);
const payload: Record<string, number | string | boolean> = {
log_retention_days: retention,
@@ -1280,6 +1397,21 @@ const App = () => {
checkin_schedule_time:
settingsForm.checkin_schedule_time.trim() || "00:10",
model_failure_cooldown_minutes: failureCooldownMinutes,
proxy_upstream_timeout_ms: upstreamTimeoutMs,
proxy_stream_usage_mode: streamUsageMode,
proxy_stream_usage_max_bytes: streamUsageMaxBytes,
proxy_stream_usage_max_parsers: streamUsageMaxParsers,
proxy_usage_queue_enabled: settingsForm.proxy_usage_queue_enabled,
usage_queue_daily_limit: usageQueueDailyLimit,
usage_queue_direct_write_ratio: usageQueueDirectRatio,
cache_enabled: settingsForm.cache_enabled,
cache_ttl_dashboard_seconds: cacheDashboardTtl,
cache_ttl_usage_seconds: cacheUsageTtl,
cache_ttl_models_seconds: cacheModelsTtl,
cache_ttl_tokens_seconds: cacheTokensTtl,
cache_ttl_channels_seconds: cacheChannelsTtl,
cache_ttl_call_tokens_seconds: cacheCallTokensTtl,
cache_ttl_settings_seconds: cacheSettingsTtl,
};
const password = settingsForm.admin_password.trim();
if (password) {
@@ -1310,6 +1442,30 @@ const App = () => {
],
);
const handleCacheRefresh = useCallback(async () => {
const actionKey = buildActionKey("settings:cache:refresh");
if (isActionPending(actionKey)) {
return;
}
startAction(actionKey);
try {
await apiFetch("/api/settings/cache/refresh", { method: "POST" });
await loadSettings();
pushNotice("success", "缓存已刷新");
} catch (error) {
pushNotice("error", (error as Error).message);
} finally {
endAction(actionKey);
}
}, [
apiFetch,
endAction,
isActionPending,
loadSettings,
pushNotice,
startAction,
]);
const handleSiteDelete = useCallback(
async (id: string) => {
const actionKey = buildActionKey("site:delete", id);
@@ -1741,10 +1897,15 @@ const App = () => {
<SettingsView
settingsForm={settingsForm}
adminPasswordSet={data.settings?.admin_password_set ?? false}
cacheConfig={data.settings?.cache_config ?? null}
isRefreshingCache={isActionPending(
buildActionKey("settings:cache:refresh"),
)}
runtimeConfig={data.settings?.runtime_config ?? null}
isSaving={isActionPending(buildActionKey("settings:submit"))}
onSubmit={handleSettingsSubmit}
onFormChange={handleSettingsFormChange}
onRefreshCache={handleCacheRefresh}
/>
);
}
+2 -3
View File
@@ -1,6 +1,5 @@
import type { JSX } from "hono/jsx";
import { useMemo, useState } from "hono/jsx/dom";
import { cx } from "./utils";
import { cx, type ClassName } from "./utils";
export type AreaChartPoint = {
label: string;
@@ -13,7 +12,7 @@ export type AreaChartProps = {
height?: number;
valueLabel?: string;
secondaryLabel?: string;
class?: JSX.ClassValue;
class?: ClassName;
};
export const AreaChart = ({
+2 -3
View File
@@ -1,8 +1,7 @@
import type { JSX } from "hono/jsx";
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import { Button } from "./button";
import { Popover, PopoverContent } from "./popover";
import { cx } from "./utils";
import { cx, type ClassName } from "./utils";
export type ColumnOption = {
id: string;
@@ -16,7 +15,7 @@ export type ColumnPickerProps = {
columns: ColumnOption[];
value: string[];
onChange: (next: string[]) => void;
class?: JSX.ClassValue;
class?: ClassName;
};
export const ColumnPicker = ({
+2 -3
View File
@@ -1,8 +1,7 @@
import type { JSX } from "hono/jsx";
import { useEffect, useMemo, useState } from "hono/jsx/dom";
import { Input } from "./input";
import { PopoverContent } from "./popover";
import { cx } from "./utils";
import { cx, type ClassName } from "./utils";
export type MultiSelectOption = {
value: string;
@@ -16,7 +15,7 @@ export type MultiSelectProps = {
placeholder?: string;
searchPlaceholder?: string;
emptyLabel?: string;
class?: JSX.ClassValue;
class?: ClassName;
};
export const MultiSelect = ({
+2 -3
View File
@@ -1,6 +1,5 @@
import type { JSX } from "hono/jsx";
import { Button } from "./button";
import { cx } from "./utils";
import { cx, type ClassName } from "./utils";
export type PaginationItem = number | "ellipsis";
@@ -10,7 +9,7 @@ export type PaginationProps = {
items: PaginationItem[];
onPageChange: (next: number) => void;
disabled?: boolean;
class?: JSX.ClassValue;
class?: ClassName;
};
export const Pagination = ({
+7 -2
View File
@@ -1,2 +1,7 @@
export const cx = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(" ");
export type ClassName = string | false | null | undefined;
type ClassInput = ClassName | Promise<string>;
export const cx = (...classes: ClassInput[]) =>
classes
.filter((value): value is string => typeof value === "string" && value.length > 0)
.join(" ");
+15
View File
@@ -51,6 +51,21 @@ export const initialSettingsForm: SettingsForm = {
admin_password: "",
checkin_schedule_time: "00:10",
model_failure_cooldown_minutes: "10",
proxy_upstream_timeout_ms: "30000",
proxy_stream_usage_mode: "full",
proxy_stream_usage_max_bytes: "0",
proxy_stream_usage_max_parsers: "0",
proxy_usage_queue_enabled: true,
usage_queue_daily_limit: "10000",
usage_queue_direct_write_ratio: "0.5",
cache_enabled: true,
cache_ttl_dashboard_seconds: "30",
cache_ttl_usage_seconds: "15",
cache_ttl_models_seconds: "60",
cache_ttl_tokens_seconds: "15",
cache_ttl_channels_seconds: "15",
cache_ttl_call_tokens_seconds: "15",
cache_ttl_settings_seconds: "30",
};
export const initialDashboardQuery: DashboardQuery = {
+42 -1
View File
@@ -122,18 +122,44 @@ export type Settings = {
admin_password_set?: boolean;
checkin_schedule_time?: string;
model_failure_cooldown_minutes?: number;
runtime_settings?: RuntimeProxySettings;
runtime_config?: RuntimeProxyConfig;
cache_config?: CacheConfig;
};
export type RuntimeProxyConfig = {
export type RuntimeProxySettings = {
upstream_timeout_ms: number;
stream_usage_mode: string;
stream_usage_max_bytes: number;
stream_usage_max_parsers: number;
usage_queue_enabled: boolean;
usage_queue_daily_limit: number;
usage_queue_direct_write_ratio: number;
};
export type RuntimeProxyConfig = RuntimeProxySettings & {
usage_queue_bound: boolean;
usage_queue_active: boolean;
};
export type CacheConfig = {
enabled: boolean;
dashboard_ttl_seconds: number;
usage_ttl_seconds: number;
models_ttl_seconds: number;
tokens_ttl_seconds: number;
channels_ttl_seconds: number;
call_tokens_ttl_seconds: number;
settings_ttl_seconds: number;
version_dashboard: number;
version_usage: number;
version_models: number;
version_tokens: number;
version_channels: number;
version_call_tokens: number;
version_settings: number;
};
export type ModelChannel = {
id: string;
name: string;
@@ -191,6 +217,21 @@ export type SettingsForm = {
admin_password: string;
checkin_schedule_time: string;
model_failure_cooldown_minutes: string;
proxy_upstream_timeout_ms: string;
proxy_stream_usage_mode: string;
proxy_stream_usage_max_bytes: string;
proxy_stream_usage_max_parsers: string;
proxy_usage_queue_enabled: boolean;
usage_queue_daily_limit: string;
usage_queue_direct_write_ratio: string;
cache_enabled: boolean;
cache_ttl_dashboard_seconds: string;
cache_ttl_usage_seconds: string;
cache_ttl_models_seconds: string;
cache_ttl_tokens_seconds: string;
cache_ttl_channels_seconds: string;
cache_ttl_call_tokens_seconds: string;
cache_ttl_settings_seconds: string;
};
export type TokenForm = {
+391 -7
View File
@@ -1,13 +1,16 @@
import { Button, Card, Input } from "../components/ui";
import type { RuntimeProxyConfig, SettingsForm } from "../core/types";
import { Button, Card, Input, Select, Switch } from "../components/ui";
import type { CacheConfig, RuntimeProxyConfig, SettingsForm } from "../core/types";
type SettingsViewProps = {
settingsForm: SettingsForm;
adminPasswordSet: boolean;
isSaving: boolean;
cacheConfig?: CacheConfig | null;
isRefreshingCache?: boolean;
runtimeConfig?: RuntimeProxyConfig | null;
onSubmit: (event: Event) => void;
onFormChange: (patch: Partial<SettingsForm>) => void;
onRefreshCache: () => void;
};
/**
@@ -23,24 +26,32 @@ export const SettingsView = ({
settingsForm,
adminPasswordSet,
isSaving,
cacheConfig,
isRefreshingCache,
runtimeConfig,
onSubmit,
onFormChange,
onRefreshCache,
}: SettingsViewProps) => {
const runtimeItems = [
{
label: "上游超时(毫秒)",
value: runtimeConfig?.upstream_timeout_ms ?? 30000,
env: "PROXY_UPSTREAM_TIMEOUT_MS",
},
{
label: "流式 usage 解析模式",
value: runtimeConfig?.stream_usage_mode ?? "lite",
value: runtimeConfig?.stream_usage_mode ?? "full",
env: "PROXY_STREAM_USAGE_MODE",
},
{
label: "流式解析最大字节",
value: runtimeConfig?.stream_usage_max_bytes ?? 262144,
value: runtimeConfig?.stream_usage_max_bytes ?? 0,
env: "PROXY_STREAM_USAGE_MAX_BYTES",
},
{
label: "流式解析并发上限",
value: runtimeConfig?.stream_usage_max_parsers ?? 2,
value: runtimeConfig?.stream_usage_max_parsers ?? 0,
env: "PROXY_STREAM_USAGE_MAX_PARSERS",
},
{
@@ -48,6 +59,19 @@ export const SettingsView = ({
value: runtimeConfig?.usage_queue_enabled ? "启用" : "关闭",
env: "PROXY_USAGE_QUEUE_ENABLED",
},
{
label: "队列日限额",
value: runtimeConfig?.usage_queue_daily_limit ?? 10000,
env: "usage_queue_daily_limit",
},
{
label: "直写比例",
value:
runtimeConfig?.usage_queue_direct_write_ratio !== undefined
? `${Math.round(runtimeConfig.usage_queue_direct_write_ratio * 100)}%`
: "50%",
env: "usage_queue_direct_write_ratio",
},
{
label: "队列绑定",
value: runtimeConfig?.usage_queue_bound ? "已绑定" : "未绑定",
@@ -181,6 +205,366 @@ export const SettingsView = ({
密码状态:{adminPasswordSet ? "已设置" : "未设置"}
</p>
</div>
<div class="lg:col-span-2">
<div class="app-title text-sm">运行时配置</div>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
以下配置可在后台修改,环境变量仅作为回退值。
</p>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="proxy-upstream-timeout"
>
上游超时(毫秒)
</label>
<Input
id="proxy-upstream-timeout"
name="proxy_upstream_timeout_ms"
type="number"
min="0"
value={settingsForm.proxy_upstream_timeout_ms}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
proxy_upstream_timeout_ms: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
设置为 0 表示不限制超时。
</p>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="proxy-stream-usage-mode"
>
流式 usage 解析模式
</label>
<Select
id="proxy-stream-usage-mode"
name="proxy_stream_usage_mode"
value={settingsForm.proxy_stream_usage_mode}
onChange={(event) => {
const target = event.currentTarget as HTMLSelectElement | null;
onFormChange({
proxy_stream_usage_mode: target?.value ?? "full",
});
}}
>
<option value="full">full(完整解析)</option>
<option value="lite">lite(轻量解析)</option>
<option value="off">off(关闭解析)</option>
</Select>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="proxy-stream-usage-max-bytes"
>
流式解析最大字节
</label>
<Input
id="proxy-stream-usage-max-bytes"
name="proxy_stream_usage_max_bytes"
type="number"
min="0"
value={settingsForm.proxy_stream_usage_max_bytes}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
proxy_stream_usage_max_bytes: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
0 表示不限制。
</p>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="proxy-stream-usage-max-parsers"
>
流式解析并发上限
</label>
<Input
id="proxy-stream-usage-max-parsers"
name="proxy_stream_usage_max_parsers"
type="number"
min="0"
value={settingsForm.proxy_stream_usage_max_parsers}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
proxy_stream_usage_max_parsers: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
0 表示不限制。
</p>
</div>
<div class="lg:col-span-2">
<div class="flex items-center justify-between rounded-xl border border-white/70 bg-white/70 px-3 py-3">
<div>
<div class="text-sm font-semibold text-[color:var(--app-ink)]">
启用用量队列
</div>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
启用后将按比例把 usage 写入队列。
</p>
</div>
<Switch
checked={settingsForm.proxy_usage_queue_enabled}
onToggle={(next) => {
onFormChange({ proxy_usage_queue_enabled: next });
}}
/>
</div>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="usage-queue-daily-limit"
>
队列日限额
</label>
<Input
id="usage-queue-daily-limit"
name="usage_queue_daily_limit"
type="number"
min="0"
value={settingsForm.usage_queue_daily_limit}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
usage_queue_daily_limit: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
达到上限后自动切回 Worker 直写。
</p>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="usage-queue-direct-ratio"
>
直写比例(0-1)
</label>
<Input
id="usage-queue-direct-ratio"
name="usage_queue_direct_write_ratio"
type="number"
min="0"
max="1"
step="0.01"
value={settingsForm.usage_queue_direct_write_ratio}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
usage_queue_direct_write_ratio: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
示例:0.5 表示 50% 直写。
</p>
</div>
<div class="lg:col-span-2">
<div class="flex items-center justify-between rounded-xl border border-white/70 bg-white/70 px-3 py-3">
<div>
<div class="text-sm font-semibold text-[color:var(--app-ink)]">
启用面板缓存
</div>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
对数据面板、日志列表与模型列表启用短时缓存,降低 D1 读取量。
</p>
</div>
<Switch
checked={settingsForm.cache_enabled}
onToggle={(next) => {
onFormChange({ cache_enabled: next });
}}
/>
</div>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="cache-dashboard-ttl"
>
面板缓存 TTL(秒)
</label>
<Input
id="cache-dashboard-ttl"
name="cache_ttl_dashboard_seconds"
type="number"
min="0"
value={settingsForm.cache_ttl_dashboard_seconds}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
cache_ttl_dashboard_seconds: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
0 表示关闭该接口缓存。
</p>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="cache-usage-ttl"
>
日志缓存 TTL(秒)
</label>
<Input
id="cache-usage-ttl"
name="cache_ttl_usage_seconds"
type="number"
min="0"
value={settingsForm.cache_ttl_usage_seconds}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
cache_ttl_usage_seconds: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
日志查询建议设置较短 TTL。
</p>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="cache-models-ttl"
>
模型缓存 TTL(秒)
</label>
<Input
id="cache-models-ttl"
name="cache_ttl_models_seconds"
type="number"
min="0"
value={settingsForm.cache_ttl_models_seconds}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
cache_ttl_models_seconds: target?.value ?? "",
});
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="cache-tokens-ttl"
>
Token 鉴权缓存 TTL(秒)
</label>
<Input
id="cache-tokens-ttl"
name="cache_ttl_tokens_seconds"
type="number"
min="0"
value={settingsForm.cache_ttl_tokens_seconds}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
cache_ttl_tokens_seconds: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
禁用令牌会自动失效,不影响即时生效。
</p>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="cache-channels-ttl"
>
渠道缓存 TTL(秒)
</label>
<Input
id="cache-channels-ttl"
name="cache_ttl_channels_seconds"
type="number"
min="0"
value={settingsForm.cache_ttl_channels_seconds}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
cache_ttl_channels_seconds: target?.value ?? "",
});
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="cache-call-tokens-ttl"
>
调用令牌缓存 TTL(秒)
</label>
<Input
id="cache-call-tokens-ttl"
name="cache_ttl_call_tokens_seconds"
type="number"
min="0"
value={settingsForm.cache_ttl_call_tokens_seconds}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
cache_ttl_call_tokens_seconds: target?.value ?? "",
});
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="cache-settings-ttl"
>
设置缓存 TTL(秒)
</label>
<Input
id="cache-settings-ttl"
name="cache_ttl_settings_seconds"
type="number"
min="0"
value={settingsForm.cache_ttl_settings_seconds}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
cache_ttl_settings_seconds: target?.value ?? "",
});
}}
/>
</div>
<div class="flex items-end justify-between lg:col-span-2">
<div class="text-xs text-[color:var(--app-ink-muted)]">
缓存版本:
{cacheConfig
? `tokens ${cacheConfig.version_tokens}, channels ${cacheConfig.version_channels}, call_tokens ${cacheConfig.version_call_tokens}, models ${cacheConfig.version_models}, dashboard ${cacheConfig.version_dashboard}, usage ${cacheConfig.version_usage}, settings ${cacheConfig.version_settings}`
: "-"}
</div>
<Button
variant="ghost"
size="sm"
type="button"
disabled={isRefreshingCache}
onClick={onRefreshCache}
>
{isRefreshingCache ? "刷新中..." : "刷新缓存"}
</Button>
</div>
<div class="flex items-end lg:col-span-2">
<Button variant="primary" size="lg" type="submit" disabled={isSaving}>
{isSaving ? "保存中..." : "保存设置"}
@@ -191,8 +575,8 @@ export const SettingsView = ({
<Card class="p-5">
<h4 class="app-title text-base">运行时配置(只读)</h4>
<p class="mt-2 text-xs text-[color:var(--app-ink-muted)]">
需要调整请在 Cloudflare Dashboard 或部署环境中修改以下环境变量,
此处仅展示当前生效值(环境变量名已在每项卡片内标注)。
此处展示实际生效值(设置优先,环境变量作为回退)。
环境变量名已在每项卡片内标注。
</p>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
{runtimeItems.map((item) => (
@@ -0,0 +1,18 @@
CREATE INDEX IF NOT EXISTS idx_usage_logs_created_at ON usage_logs (created_at);
CREATE INDEX IF NOT EXISTS idx_usage_logs_channel_id ON usage_logs (channel_id);
CREATE INDEX IF NOT EXISTS idx_usage_logs_token_id ON usage_logs (token_id);
CREATE INDEX IF NOT EXISTS idx_usage_logs_model ON usage_logs (model);
CREATE INDEX IF NOT EXISTS idx_usage_logs_status ON usage_logs (status);
CREATE INDEX IF NOT EXISTS idx_usage_logs_upstream_status ON usage_logs (upstream_status);
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_channel_ok
ON channel_model_capabilities (channel_id, last_ok_at);
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_model_err
ON channel_model_capabilities (model, last_err_at);
CREATE INDEX IF NOT EXISTS idx_channel_call_tokens_channel_id
ON channel_call_tokens (channel_id, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tokens_key_hash
ON tokens (key_hash);
+16 -2
View File
@@ -35,7 +35,8 @@ CREATE TABLE IF NOT EXISTS channel_call_tokens (
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS channel_call_tokens_channel_id ON channel_call_tokens(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_call_tokens_channel_id
ON channel_call_tokens (channel_id, created_at);
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
@@ -52,6 +53,8 @@ CREATE TABLE IF NOT EXISTS tokens (
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tokens_key_hash ON tokens (key_hash);
CREATE TABLE IF NOT EXISTS usage_logs (
id TEXT PRIMARY KEY,
token_id TEXT,
@@ -73,7 +76,12 @@ CREATE TABLE IF NOT EXISTS usage_logs (
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS usage_logs_created_at ON usage_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_usage_logs_created_at ON usage_logs (created_at);
CREATE INDEX IF NOT EXISTS idx_usage_logs_channel_id ON usage_logs (channel_id);
CREATE INDEX IF NOT EXISTS idx_usage_logs_token_id ON usage_logs (token_id);
CREATE INDEX IF NOT EXISTS idx_usage_logs_model ON usage_logs (model);
CREATE INDEX IF NOT EXISTS idx_usage_logs_status ON usage_logs (status);
CREATE INDEX IF NOT EXISTS idx_usage_logs_upstream_status ON usage_logs (upstream_status);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -105,3 +113,9 @@ CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_model
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_channel
ON channel_model_capabilities (channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_channel_ok
ON channel_model_capabilities (channel_id, last_ok_at);
CREATE INDEX IF NOT EXISTS idx_channel_model_capabilities_model_err
ON channel_model_capabilities (model, last_err_at);
+1
View File
@@ -14,6 +14,7 @@ export type Bindings = {
PROXY_STREAM_USAGE_MAX_PARSERS?: string;
PROXY_USAGE_QUEUE_ENABLED?: string;
USAGE_QUEUE?: Queue<UsageQueueEvent>;
USAGE_LIMITER?: DurableObjectNamespace;
CHECKIN_SCHEDULER: DurableObjectNamespace;
};
+1
View File
@@ -144,3 +144,4 @@ export default {
};
export { CheckinScheduler } from "./services/checkin-scheduler";
export { UsageLimiter } from "./services/usage-limiter";
+21 -5
View File
@@ -1,9 +1,11 @@
import { createMiddleware } from "hono/factory";
import type { AppEnv } from "../env";
import { getCacheConfig } from "../services/settings";
import { canConsumeQuota, normalizeQuota } from "../services/quota";
import { sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { getBearerToken } from "../utils/request";
import { withJsonCache } from "../utils/cache";
export type TokenRecord = {
id: string;
@@ -25,11 +27,25 @@ export const tokenAuth = createMiddleware<AppEnv>(async (c, next) => {
}
const tokenHash = await sha256Hex(token);
const record = await c.env.DB.prepare(
"SELECT id, name, quota_total, quota_used, status, allowed_channels, expires_at FROM tokens WHERE key_hash = ?",
)
.bind(tokenHash)
.first<TokenRecord>();
const cacheConfig = await getCacheConfig(c.env.DB);
const record = await withJsonCache<TokenRecord | null>(
{
namespace: "tokens",
key: tokenHash,
version: cacheConfig.version_tokens,
ttlSeconds: cacheConfig.tokens_ttl_seconds,
enabled: cacheConfig.enabled,
},
async () => {
const row = await c.env.DB
.prepare(
"SELECT id, name, quota_total, quota_used, status, allowed_channels, expires_at FROM tokens WHERE key_hash = ?",
)
.bind(tokenHash)
.first<TokenRecord>();
return row ?? null;
},
);
if (!record) {
return jsonError(c, 401, "invalid_token", "invalid_token");
+5
View File
@@ -17,6 +17,7 @@ import {
testChannelTokens,
updateChannelTestResult,
} from "../services/channel-testing";
import { bumpCacheVersions } from "../services/settings";
import { generateToken } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { safeJsonParse } from "../utils/json";
@@ -108,6 +109,7 @@ channels.post("/", async (c) => {
updated_at: now,
});
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return c.json({ id });
});
@@ -151,6 +153,7 @@ channels.patch("/:id", async (c) => {
updated_at: nowIso(),
});
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return c.json({ ok: true });
});
@@ -160,6 +163,7 @@ channels.patch("/:id", async (c) => {
channels.delete("/:id", async (c) => {
const id = c.req.param("id");
await deleteChannel(c.env.DB, id);
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return c.json({ ok: true });
});
@@ -216,6 +220,7 @@ channels.post("/:id/test", async (c) => {
modelsJson: modelsToJson(summary.models),
});
await bumpCacheVersions(c.env.DB, ["channels", "models", "call_tokens"]);
return c.json({
ok: true,
models,
+74 -58
View File
@@ -1,5 +1,7 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { getCacheConfig } from "../services/settings";
import { withApiCache } from "../utils/cache";
const dashboard = new Hono<AppEnv>();
@@ -43,65 +45,79 @@ function buildDateFilters(query: Record<string, string>) {
* Returns aggregated usage metrics.
*/
dashboard.get("/", async (c) => {
const query = c.req.query();
const interval =
query.interval === "week" || query.interval === "month"
? query.interval
: "day";
const rawLimit = Number(query.limit ?? 30);
const normalizedLimit = Number.isNaN(rawLimit) ? 30 : Math.floor(rawLimit);
const limit = Math.min(Math.max(normalizedLimit, 1), 366);
const { sql, params } = buildDateFilters(query);
const bucketExpression =
interval === "week"
? "strftime('%Y-W%W', created_at)"
: interval === "month"
? "substr(created_at, 1, 7)"
: "substr(created_at, 1, 10)";
const summary = await c.env.DB.prepare(
`SELECT COUNT(*) as total_requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(AVG(latency_ms), 0) as avg_latency, COALESCE(SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END), 0) as total_errors FROM usage_logs${sql}`,
)
.bind(...params)
.first();
const trend = await c.env.DB.prepare(
`SELECT ${bucketExpression} as bucket, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY bucket ORDER BY bucket ASC LIMIT ?`,
)
.bind(...params, limit)
.all();
const byModel = await c.env.DB.prepare(
`SELECT model, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY model ORDER BY requests DESC LIMIT 20`,
)
.bind(...params)
.all();
const byChannel = await c.env.DB.prepare(
`SELECT channels.id as channel_id, channels.name as channel_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id${sql} GROUP BY channels.id, channels.name ORDER BY requests DESC LIMIT 20`,
)
.bind(...params)
.all();
const byToken = await c.env.DB.prepare(
`SELECT tokens.id as token_id, tokens.name as token_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN tokens ON tokens.id = usage_logs.token_id${sql} GROUP BY tokens.id, tokens.name ORDER BY requests DESC LIMIT 20`,
)
.bind(...params)
.all();
return c.json({
summary: summary ?? {
total_requests: 0,
total_tokens: 0,
avg_latency: 0,
total_errors: 0,
const cacheConfig = await getCacheConfig(c.env.DB);
return withApiCache(
c,
{
namespace: "dashboard",
version: cacheConfig.version_dashboard,
ttlSeconds: cacheConfig.dashboard_ttl_seconds,
enabled: cacheConfig.enabled,
},
trend: trend.results ?? [],
interval,
byModel: byModel.results ?? [],
byChannel: byChannel.results ?? [],
byToken: byToken.results ?? [],
});
async () => {
const query = c.req.query();
const interval =
query.interval === "week" || query.interval === "month"
? query.interval
: "day";
const rawLimit = Number(query.limit ?? 30);
const normalizedLimit = Number.isNaN(rawLimit)
? 30
: Math.floor(rawLimit);
const limit = Math.min(Math.max(normalizedLimit, 1), 366);
const { sql, params } = buildDateFilters(query);
const bucketExpression =
interval === "week"
? "strftime('%Y-W%W', created_at)"
: interval === "month"
? "substr(created_at, 1, 7)"
: "substr(created_at, 1, 10)";
const summary = await c.env.DB.prepare(
`SELECT COUNT(*) as total_requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(AVG(latency_ms), 0) as avg_latency, COALESCE(SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END), 0) as total_errors FROM usage_logs${sql}`,
)
.bind(...params)
.first();
const trend = await c.env.DB.prepare(
`SELECT ${bucketExpression} as bucket, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY bucket ORDER BY bucket ASC LIMIT ?`,
)
.bind(...params, limit)
.all();
const byModel = await c.env.DB.prepare(
`SELECT model, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens FROM usage_logs${sql} GROUP BY model ORDER BY requests DESC LIMIT 20`,
)
.bind(...params)
.all();
const byChannel = await c.env.DB.prepare(
`SELECT channels.id as channel_id, channels.name as channel_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id${sql} GROUP BY channels.id, channels.name ORDER BY requests DESC LIMIT 20`,
)
.bind(...params)
.all();
const byToken = await c.env.DB.prepare(
`SELECT tokens.id as token_id, tokens.name as token_name, COUNT(usage_logs.id) as requests, COALESCE(SUM(usage_logs.total_tokens), 0) as tokens FROM usage_logs LEFT JOIN tokens ON tokens.id = usage_logs.token_id${sql} GROUP BY tokens.id, tokens.name ORDER BY requests DESC LIMIT 20`,
)
.bind(...params)
.all();
return c.json({
summary: summary ?? {
total_requests: 0,
total_tokens: 0,
avg_latency: 0,
total_errors: 0,
},
trend: trend.results ?? [],
interval,
byModel: byModel.results ?? [],
byChannel: byChannel.results ?? [],
byToken: byToken.results ?? [],
});
},
);
});
export default dashboard;
+36 -22
View File
@@ -2,6 +2,8 @@ import { Hono } from "hono";
import type { AppEnv } from "../env";
import { listModelEntriesWithFallback } from "../services/channel-model-capabilities";
import { listActiveChannels } from "../services/channel-repo";
import { getCacheConfig } from "../services/settings";
import { withApiCache } from "../utils/cache";
const models = new Hono<AppEnv>();
@@ -9,29 +11,41 @@ const models = new Hono<AppEnv>();
* Returns aggregated models from all channels.
*/
models.get("/", async (c) => {
const channels = await listActiveChannels(c.env.DB);
const entries = await listModelEntriesWithFallback(
c.env.DB,
channels.map((channel) => ({
id: channel.id,
name: channel.name,
models_json: channel.models_json,
})),
const cacheConfig = await getCacheConfig(c.env.DB);
return withApiCache(
c,
{
namespace: "models",
version: cacheConfig.version_models,
ttlSeconds: cacheConfig.models_ttl_seconds,
enabled: cacheConfig.enabled,
},
async () => {
const channels = await listActiveChannels(c.env.DB);
const entries = await listModelEntriesWithFallback(
c.env.DB,
channels.map((channel) => ({
id: channel.id,
name: channel.name,
models_json: channel.models_json,
})),
);
const map = new Map<
string,
{ id: string; channels: { id: string; name: string }[] }
>();
for (const entry of entries) {
const existing = map.get(entry.id) ?? { id: entry.id, channels: [] };
existing.channels.push({ id: entry.channelId, name: entry.channelName });
map.set(entry.id, existing);
}
return c.json({
models: Array.from(map.values()),
});
},
);
const map = new Map<
string,
{ id: string; channels: { id: string; name: string }[] }
>();
for (const entry of entries) {
const existing = map.get(entry.id) ?? { id: entry.id, channels: [] };
existing.channels.push({ id: entry.channelId, name: entry.channelName });
map.set(entry.id, existing);
}
return c.json({
models: Array.from(map.values()),
});
});
export default models;
+10
View File
@@ -20,6 +20,7 @@ import {
fetchChannelModels,
updateChannelTestResult,
} from "../services/channel-testing";
import { bumpCacheVersions } from "../services/settings";
import {
mergeMetadata,
modelsToJson,
@@ -207,6 +208,7 @@ newapi.put("/tag", async (c) => {
.run();
}
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return newApiSuccess(c);
});
@@ -227,6 +229,7 @@ newapi.post("/tag/enabled", async (c) => {
.run();
}
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return newApiSuccess(c);
});
@@ -247,6 +250,7 @@ newapi.post("/tag/disabled", async (c) => {
.run();
}
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return newApiSuccess(c);
});
@@ -295,6 +299,7 @@ newapi.post("/", async (c) => {
updated_at: now,
});
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return newApiSuccess(c);
});
@@ -348,6 +353,7 @@ newapi.put("/", async (c) => {
updated_at: nowIso(),
});
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return newApiSuccess(c);
});
@@ -358,6 +364,7 @@ newapi.delete("/:id", async (c) => {
return newApiFailure(c, 404, "渠道不存在");
}
await deleteChannel(c.env.DB, id);
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return newApiSuccess(c);
});
@@ -386,6 +393,7 @@ newapi.get("/test/:id", async (c) => {
models: result.models,
});
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return newApiSuccess(c, undefined, "测试成功");
});
@@ -415,6 +423,7 @@ newapi.post("/test", async (c) => {
elapsed: result.elapsed,
models: result.models,
});
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return newApiSuccess(c, undefined, "测试成功");
});
@@ -443,6 +452,7 @@ newapi.get("/fetch_models/:id", async (c) => {
models: result.models,
});
await bumpCacheVersions(c.env.DB, ["channels", "models"]);
return newApiSuccess(c, result.models);
});
+122 -66
View File
@@ -21,7 +21,9 @@ import {
listVerifiedModelsByChannel,
} from "../services/channel-model-capabilities";
import {
getCacheConfig,
getModelFailureCooldownMinutes,
getProxyRuntimeSettings,
} from "../services/settings";
import {
applyGeminiModelToPath,
@@ -41,10 +43,15 @@ import {
parseDownstreamStream,
} from "../services/provider-transform";
import { processUsageQueueEvent, type UsageQueueEvent } from "../services/usage-queue";
import {
getUsageLimiterStub,
reserveUsageQueue,
} from "../services/usage-limiter";
import { jsonError } from "../utils/http";
import { safeJsonParse } from "../utils/json";
import { extractReasoningEffort } from "../utils/reasoning";
import { normalizeBaseUrl } from "../utils/url";
import { withJsonCache } from "../utils/cache";
import {
type NormalizedUsage,
parseUsageFromHeaders,
@@ -80,10 +87,6 @@ type ErrorDetails = {
const FAILURE_COUNT_THRESHOLD = 2;
const STREAM_USAGE_DEFAULT_MODE: StreamUsageMode = "full";
const STREAM_USAGE_DEFAULT_MAX_BYTES = 0;
const STREAM_USAGE_DEFAULT_MAX_PARSERS = 0;
let activeStreamUsageParsers = 0;
function normalizeMessage(value: string | null): string | null {
@@ -97,71 +100,97 @@ function normalizeMessage(value: string | null): string | null {
return trimmed;
}
function parseNonNegativeInt(value: string | undefined): number | null {
if (value === undefined || value === null || value === "") {
return null;
}
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
return null;
}
function normalizeStreamUsageMode(value: string | undefined): StreamUsageMode {
const normalized = (value ?? "").toLowerCase();
if (normalized === "off") {
return "off";
}
if (normalized === "full") {
return "full";
}
return STREAM_USAGE_DEFAULT_MODE;
}
function getStreamUsageOptions(env: AppEnv["Bindings"]): StreamUsageOptions {
const maxBytesRaw = parseNonNegativeInt(env.PROXY_STREAM_USAGE_MAX_BYTES);
function getStreamUsageOptions(
settings: {
stream_usage_mode: string;
stream_usage_max_bytes: number;
},
): StreamUsageOptions {
return {
mode: normalizeStreamUsageMode(env.PROXY_STREAM_USAGE_MODE),
maxBytes:
maxBytesRaw === null ? STREAM_USAGE_DEFAULT_MAX_BYTES : maxBytesRaw,
mode: settings.stream_usage_mode as StreamUsageMode,
maxBytes: Math.max(0, Math.floor(settings.stream_usage_max_bytes)),
};
}
function getStreamUsageMaxParsers(env: AppEnv["Bindings"]): number {
const maxParsersRaw = parseNonNegativeInt(env.PROXY_STREAM_USAGE_MAX_PARSERS);
if (maxParsersRaw === null) {
return STREAM_USAGE_DEFAULT_MAX_PARSERS;
}
if (maxParsersRaw === 0) {
function getStreamUsageMaxParsers(settings: {
stream_usage_max_parsers: number;
}): number {
const maxParsers = Math.max(0, Math.floor(settings.stream_usage_max_parsers));
if (maxParsers === 0) {
return Number.POSITIVE_INFINITY;
}
return maxParsersRaw;
return maxParsers;
}
function usageQueueEnabled(env: AppEnv["Bindings"]): boolean {
if (env.PROXY_USAGE_QUEUE_ENABLED === "false") {
return false;
}
return Boolean(env.USAGE_QUEUE);
}
function scheduleUsageEvent(
function createUsageEventScheduler(
c: { env: AppEnv["Bindings"]; executionCtx?: ExecutionContextLike },
event: UsageQueueEvent,
): void {
settings: {
usage_queue_enabled: boolean;
usage_queue_daily_limit: number;
usage_queue_direct_write_ratio: number;
},
): (event: UsageQueueEvent) => void {
const queue = c.env.USAGE_QUEUE;
if (queue && usageQueueEnabled(c.env)) {
const task = queue.send(event).catch((error) => {
console.warn("[usage-queue:send_failed]", {
const queueBound = Boolean(queue);
const queueEnabled = settings.usage_queue_enabled && queueBound;
const limiter = c.env.USAGE_LIMITER
? getUsageLimiterStub(c.env.USAGE_LIMITER)
: null;
const directRatio = settings.usage_queue_direct_write_ratio;
const dailyLimit = settings.usage_queue_daily_limit;
let overLimit = false;
let decisionChain = Promise.resolve();
const decide = async (): Promise<boolean> => {
if (!queueEnabled) {
return false;
}
if (overLimit) {
return false;
}
if (Math.random() < directRatio) {
return false;
}
if (!limiter || dailyLimit <= 0) {
return true;
}
try {
const result = await reserveUsageQueue(limiter, {
limit: dailyLimit,
amount: 1,
});
if (!result.allowed) {
overLimit = true;
}
return result.allowed;
} catch (error) {
console.warn("[usage-limiter:reserve_failed]", {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
};
const decideSequential = () => {
const decision = decisionChain.then(decide);
decisionChain = decision.catch(() => undefined);
return decision;
};
return (event: UsageQueueEvent) => {
const task = decideSequential().then((useQueue) => {
if (useQueue && queue) {
return queue.send(event).catch((error) => {
console.warn("[usage-queue:send_failed]", {
error: error instanceof Error ? error.message : String(error),
});
return processUsageQueueEvent(c.env.DB, event);
});
}
return processUsageQueueEvent(c.env.DB, event);
});
scheduleDbWrite(c, task);
return;
}
scheduleDbWrite(c, processUsageQueueEvent(c.env.DB, event));
};
}
async function extractErrorDetails(
@@ -426,6 +455,11 @@ async function fetchWithTimeout(
proxy.all("/*", tokenAuth, async (c) => {
const tokenRecord = c.get("tokenRecord") as TokenRecord;
const requestStart = Date.now();
const [cacheConfig, runtimeSettings] = await Promise.all([
getCacheConfig(c.env.DB),
getProxyRuntimeSettings(c.env.DB, c.env),
]);
const scheduleUsageEvent = createUsageEventScheduler(c, runtimeSettings);
let requestText = await c.req.text();
const parsedBody = requestText
? safeJsonParse<Record<string, unknown> | null>(requestText, null)
@@ -517,15 +551,37 @@ proxy.all("/*", tokenAuth, async (c) => {
});
};
const channelResult = await c.env.DB.prepare(
"SELECT * FROM channels WHERE status = ?",
)
.bind("active")
.all();
const activeChannels = (channelResult.results ?? []) as ChannelRecord[];
const callTokenRows = await listCallTokens(c.env.DB, {
channelIds: activeChannels.map((channel) => channel.id),
});
const activeChannels = await withJsonCache<ChannelRecord[]>(
{
namespace: "channels",
key: "active",
version: cacheConfig.version_channels,
ttlSeconds: cacheConfig.channels_ttl_seconds,
enabled: cacheConfig.enabled,
},
async () => {
const result = await c.env.DB
.prepare("SELECT * FROM channels WHERE status = ?")
.bind("active")
.all();
return (result.results ?? []) as ChannelRecord[];
},
);
const channelIds = activeChannels.map((channel) => channel.id);
const callTokenKey = channelIds.slice().sort().join(",");
const callTokenRows = await withJsonCache(
{
namespace: "call_tokens",
key: callTokenKey,
version: cacheConfig.version_call_tokens,
ttlSeconds: cacheConfig.call_tokens_ttl_seconds,
enabled: cacheConfig.enabled,
},
() =>
listCallTokens(c.env.DB, {
channelIds,
}),
);
const callTokenMap = new Map<string, CallTokenItem[]>();
for (const row of callTokenRows) {
const entry: CallTokenItem = {
@@ -609,7 +665,7 @@ proxy.all("/*", tokenAuth, async (c) => {
const ordered = createWeightedOrder(candidates);
const upstreamTimeoutMs = Math.max(
1000,
Number(c.env.PROXY_UPSTREAM_TIMEOUT_MS ?? "30000"),
Number(runtimeSettings.upstream_timeout_ms ?? 30000),
);
const nowSeconds = Math.floor(Date.now() / 1000);
let lastResponse: Response | null = null;
@@ -1099,8 +1155,8 @@ proxy.all("/*", tokenAuth, async (c) => {
if (isStream && lastResponse.ok) {
const executionCtx = (c as { executionCtx?: ExecutionContextLike })
.executionCtx;
const streamUsageOptions = getStreamUsageOptions(c.env);
const streamUsageMaxParsers = getStreamUsageMaxParsers(c.env);
const streamUsageOptions = getStreamUsageOptions(runtimeSettings);
const streamUsageMaxParsers = getStreamUsageMaxParsers(runtimeSettings);
const canParseStream =
streamUsageOptions.mode !== "off" &&
activeStreamUsageParsers < streamUsageMaxParsers;
+291 -1
View File
@@ -5,17 +5,22 @@ import {
shouldResetLastRun,
} from "../services/checkin-scheduler";
import {
bumpCacheVersions,
getCacheConfig,
getCheckinScheduleTime,
getRetentionDays,
getSessionTtlHours,
getModelFailureCooldownMinutes,
getProxyRuntimeSettings,
getRuntimeProxyConfig,
isAdminPasswordSet,
setCacheConfig,
setAdminPasswordHash,
setCheckinScheduleTime,
setRetentionDays,
setSessionTtlHours,
setModelFailureCooldownMinutes,
setProxyRuntimeSettings,
} from "../services/settings";
import { sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
@@ -33,13 +38,18 @@ settings.get("/", async (c) => {
const modelFailureCooldownMinutes = await getModelFailureCooldownMinutes(
c.env.DB,
);
const runtimeSettings = await getProxyRuntimeSettings(c.env.DB, c.env);
const runtimeConfig = getRuntimeProxyConfig(c.env, runtimeSettings);
const cacheConfig = await getCacheConfig(c.env.DB);
return c.json({
log_retention_days: retention,
session_ttl_hours: sessionTtlHours,
admin_password_set: adminPasswordSet,
checkin_schedule_time: checkinScheduleTime,
model_failure_cooldown_minutes: modelFailureCooldownMinutes,
runtime_config: getRuntimeProxyConfig(c.env),
runtime_config: runtimeConfig,
runtime_settings: runtimeSettings,
cache_config: cacheConfig,
});
});
@@ -53,6 +63,27 @@ settings.put("/", async (c) => {
}
let touched = false;
let cacheTouched = false;
let runtimeTouched = false;
const cachePatch: {
enabled?: boolean;
dashboardTtlSeconds?: number;
usageTtlSeconds?: number;
modelsTtlSeconds?: number;
tokensTtlSeconds?: number;
channelsTtlSeconds?: number;
callTokensTtlSeconds?: number;
settingsTtlSeconds?: number;
} = {};
const runtimePatch: {
upstream_timeout_ms?: number;
stream_usage_mode?: string;
stream_usage_max_bytes?: number;
stream_usage_max_parsers?: number;
usage_queue_enabled?: boolean;
usage_queue_daily_limit?: number;
usage_queue_direct_write_ratio?: number;
} = {};
let scheduleTouched = false;
let scheduleReset = false;
@@ -98,6 +129,242 @@ settings.put("/", async (c) => {
touched = true;
}
if (body.cache_enabled !== undefined) {
const raw = body.cache_enabled;
let enabled: boolean | null = null;
if (typeof raw === "boolean") {
enabled = raw;
} else if (typeof raw === "number") {
enabled = raw !== 0;
} else if (typeof raw === "string") {
const normalized = raw.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
enabled = true;
} else if (["0", "false", "no", "off"].includes(normalized)) {
enabled = false;
}
}
if (enabled === null) {
return jsonError(
c,
400,
"invalid_cache_enabled",
"invalid_cache_enabled",
);
}
cachePatch.enabled = enabled;
cacheTouched = true;
}
if (body.cache_ttl_dashboard_seconds !== undefined) {
const ttl = Number(body.cache_ttl_dashboard_seconds);
if (Number.isNaN(ttl) || ttl < 0) {
return jsonError(
c,
400,
"invalid_cache_ttl_dashboard_seconds",
"invalid_cache_ttl_dashboard_seconds",
);
}
cachePatch.dashboardTtlSeconds = Math.floor(ttl);
cacheTouched = true;
}
if (body.cache_ttl_usage_seconds !== undefined) {
const ttl = Number(body.cache_ttl_usage_seconds);
if (Number.isNaN(ttl) || ttl < 0) {
return jsonError(
c,
400,
"invalid_cache_ttl_usage_seconds",
"invalid_cache_ttl_usage_seconds",
);
}
cachePatch.usageTtlSeconds = Math.floor(ttl);
cacheTouched = true;
}
if (body.cache_ttl_models_seconds !== undefined) {
const ttl = Number(body.cache_ttl_models_seconds);
if (Number.isNaN(ttl) || ttl < 0) {
return jsonError(
c,
400,
"invalid_cache_ttl_models_seconds",
"invalid_cache_ttl_models_seconds",
);
}
cachePatch.modelsTtlSeconds = Math.floor(ttl);
cacheTouched = true;
}
if (body.cache_ttl_tokens_seconds !== undefined) {
const ttl = Number(body.cache_ttl_tokens_seconds);
if (Number.isNaN(ttl) || ttl < 0) {
return jsonError(
c,
400,
"invalid_cache_ttl_tokens_seconds",
"invalid_cache_ttl_tokens_seconds",
);
}
cachePatch.tokensTtlSeconds = Math.floor(ttl);
cacheTouched = true;
}
if (body.cache_ttl_channels_seconds !== undefined) {
const ttl = Number(body.cache_ttl_channels_seconds);
if (Number.isNaN(ttl) || ttl < 0) {
return jsonError(
c,
400,
"invalid_cache_ttl_channels_seconds",
"invalid_cache_ttl_channels_seconds",
);
}
cachePatch.channelsTtlSeconds = Math.floor(ttl);
cacheTouched = true;
}
if (body.cache_ttl_call_tokens_seconds !== undefined) {
const ttl = Number(body.cache_ttl_call_tokens_seconds);
if (Number.isNaN(ttl) || ttl < 0) {
return jsonError(
c,
400,
"invalid_cache_ttl_call_tokens_seconds",
"invalid_cache_ttl_call_tokens_seconds",
);
}
cachePatch.callTokensTtlSeconds = Math.floor(ttl);
cacheTouched = true;
}
if (body.cache_ttl_settings_seconds !== undefined) {
const ttl = Number(body.cache_ttl_settings_seconds);
if (Number.isNaN(ttl) || ttl < 0) {
return jsonError(
c,
400,
"invalid_cache_ttl_settings_seconds",
"invalid_cache_ttl_settings_seconds",
);
}
cachePatch.settingsTtlSeconds = Math.floor(ttl);
cacheTouched = true;
}
if (body.proxy_upstream_timeout_ms !== undefined) {
const timeoutMs = Number(body.proxy_upstream_timeout_ms);
if (Number.isNaN(timeoutMs) || timeoutMs < 0) {
return jsonError(
c,
400,
"invalid_proxy_upstream_timeout_ms",
"invalid_proxy_upstream_timeout_ms",
);
}
runtimePatch.upstream_timeout_ms = Math.floor(timeoutMs);
runtimeTouched = true;
}
if (body.proxy_stream_usage_mode !== undefined) {
const mode = String(body.proxy_stream_usage_mode).trim().toLowerCase();
if (!["full", "lite", "off"].includes(mode)) {
return jsonError(
c,
400,
"invalid_proxy_stream_usage_mode",
"invalid_proxy_stream_usage_mode",
);
}
runtimePatch.stream_usage_mode = mode;
runtimeTouched = true;
}
if (body.proxy_stream_usage_max_bytes !== undefined) {
const maxBytes = Number(body.proxy_stream_usage_max_bytes);
if (Number.isNaN(maxBytes) || maxBytes < 0) {
return jsonError(
c,
400,
"invalid_proxy_stream_usage_max_bytes",
"invalid_proxy_stream_usage_max_bytes",
);
}
runtimePatch.stream_usage_max_bytes = Math.floor(maxBytes);
runtimeTouched = true;
}
if (body.proxy_stream_usage_max_parsers !== undefined) {
const maxParsers = Number(body.proxy_stream_usage_max_parsers);
if (Number.isNaN(maxParsers) || maxParsers < 0) {
return jsonError(
c,
400,
"invalid_proxy_stream_usage_max_parsers",
"invalid_proxy_stream_usage_max_parsers",
);
}
runtimePatch.stream_usage_max_parsers = Math.floor(maxParsers);
runtimeTouched = true;
}
if (body.proxy_usage_queue_enabled !== undefined) {
const raw = body.proxy_usage_queue_enabled;
let enabled: boolean | null = null;
if (typeof raw === "boolean") {
enabled = raw;
} else if (typeof raw === "number") {
enabled = raw !== 0;
} else if (typeof raw === "string") {
const normalized = raw.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
enabled = true;
} else if (["0", "false", "no", "off"].includes(normalized)) {
enabled = false;
}
}
if (enabled === null) {
return jsonError(
c,
400,
"invalid_proxy_usage_queue_enabled",
"invalid_proxy_usage_queue_enabled",
);
}
runtimePatch.usage_queue_enabled = enabled;
runtimeTouched = true;
}
if (body.usage_queue_daily_limit !== undefined) {
const limit = Number(body.usage_queue_daily_limit);
if (Number.isNaN(limit) || limit < 0) {
return jsonError(
c,
400,
"invalid_usage_queue_daily_limit",
"invalid_usage_queue_daily_limit",
);
}
runtimePatch.usage_queue_daily_limit = Math.floor(limit);
runtimeTouched = true;
}
if (body.usage_queue_direct_write_ratio !== undefined) {
const ratio = Number(body.usage_queue_direct_write_ratio);
if (Number.isNaN(ratio) || ratio < 0 || ratio > 1) {
return jsonError(
c,
400,
"invalid_usage_queue_direct_write_ratio",
"invalid_usage_queue_direct_write_ratio",
);
}
runtimePatch.usage_queue_direct_write_ratio = ratio;
runtimeTouched = true;
}
if (typeof body.admin_password === "string" && body.admin_password.trim()) {
const hash = await sha256Hex(body.admin_password.trim());
await setAdminPasswordHash(c.env.DB, hash);
@@ -137,6 +404,16 @@ settings.put("/", async (c) => {
scheduleReset = shouldResetLastRun(currentTime, timeValue);
}
if (cacheTouched) {
await setCacheConfig(c.env.DB, cachePatch);
touched = true;
}
if (runtimeTouched) {
await setProxyRuntimeSettings(c.env.DB, runtimePatch);
touched = true;
}
if (!touched) {
return jsonError(c, 400, "settings_empty", "settings_empty");
}
@@ -152,4 +429,17 @@ settings.put("/", async (c) => {
return c.json({ ok: true });
});
settings.post("/cache/refresh", async (c) => {
await bumpCacheVersions(c.env.DB, [
"dashboard",
"usage",
"models",
"tokens",
"channels",
"call_tokens",
"settings",
]);
return c.json({ ok: true });
});
export default settings;
+4
View File
@@ -17,6 +17,7 @@ import {
parseSiteMetadata,
type SiteType,
} from "../services/site-metadata";
import { bumpCacheVersions } from "../services/settings";
import { generateToken } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { nowIso } from "../utils/time";
@@ -313,6 +314,7 @@ sites.post("/", async (c) => {
toCallTokenRows(id, callTokens, now),
);
await bumpCacheVersions(c.env.DB, ["channels", "models", "call_tokens"]);
return c.json({ id });
});
@@ -414,12 +416,14 @@ sites.patch("/:id", async (c) => {
);
}
await bumpCacheVersions(c.env.DB, ["channels", "models", "call_tokens"]);
return c.json({ ok: true });
});
sites.delete("/:id", async (c) => {
const id = c.req.param("id");
await deleteChannel(c.env.DB, id);
await bumpCacheVersions(c.env.DB, ["channels", "models", "call_tokens"]);
return c.json({ ok: true });
});
+5 -1
View File
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { bumpCacheVersions } from "../services/settings";
import { generateToken, sha256Hex } from "../utils/crypto";
import { jsonError } from "../utils/http";
import { safeJsonParse } from "../utils/json";
@@ -61,7 +62,7 @@ const normalizeExpiresAt = (
tokens.get("/", async (c) => {
const result = await c.env.DB.prepare(
"SELECT id, name, key_prefix, quota_total, quota_used, status, allowed_channels, expires_at, created_at, updated_at FROM tokens ORDER BY created_at DESC",
).all();
).all<TokenRow>();
const tokens = (result.results ?? []).map((row) => ({
...row,
allowed_channels: normalizeAllowedChannels(row.allowed_channels ?? null),
@@ -111,6 +112,7 @@ tokens.post("/", async (c) => {
)
.run();
await bumpCacheVersions(c.env.DB, ["tokens"]);
return c.json({
id,
token: rawToken,
@@ -167,6 +169,7 @@ tokens.patch("/:id", async (c) => {
)
.run();
await bumpCacheVersions(c.env.DB, ["tokens"]);
return c.json({ ok: true });
});
@@ -192,6 +195,7 @@ tokens.get("/:id/reveal", async (c) => {
tokens.delete("/:id", async (c) => {
const id = c.req.param("id");
await c.env.DB.prepare("DELETE FROM tokens WHERE id = ?").bind(id).run();
await bumpCacheVersions(c.env.DB, ["tokens"]);
return c.json({ ok: true });
});
+155 -139
View File
@@ -1,7 +1,8 @@
import { Hono } from "hono";
import type { AppEnv } from "../env";
import { getRetentionDays } from "../services/settings";
import { getCacheConfig, getRetentionDays } from "../services/settings";
import { pruneUsageLogs } from "../services/usage";
import { withApiCache } from "../utils/cache";
const usage = new Hono<AppEnv>();
@@ -9,154 +10,169 @@ const usage = new Hono<AppEnv>();
* Lists usage logs with filters.
*/
usage.get("/", async (c) => {
const query = c.req.query();
const filters: string[] = [];
const params: Array<string | number> = [];
const cacheConfig = await getCacheConfig(c.env.DB);
return withApiCache(
c,
{
namespace: "usage",
version: cacheConfig.version_usage,
ttlSeconds: cacheConfig.usage_ttl_seconds,
enabled: cacheConfig.enabled,
},
async () => {
const query = c.req.query();
const filters: string[] = [];
const params: Array<string | number> = [];
if (query.from) {
filters.push("usage_logs.created_at >= ?");
params.push(query.from);
}
if (query.to) {
filters.push("usage_logs.created_at <= ?");
params.push(query.to);
}
if (query.model) {
const model = String(query.model).trim();
if (model) {
filters.push("usage_logs.model LIKE ? COLLATE NOCASE");
params.push(`%${model}%`);
}
}
if (query.channel_id) {
filters.push("usage_logs.channel_id = ?");
params.push(query.channel_id);
}
if (query.token_id) {
filters.push("usage_logs.token_id = ?");
params.push(query.token_id);
}
if (query.channel_ids) {
const channelIds = String(query.channel_ids)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (channelIds.length > 0) {
const placeholders = channelIds.map(() => "?").join(", ");
filters.push(`usage_logs.channel_id IN (${placeholders})`);
params.push(...channelIds);
}
}
if (query.token_ids) {
const tokenIds = String(query.token_ids)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (tokenIds.length > 0) {
const placeholders = tokenIds.map(() => "?").join(", ");
filters.push(`usage_logs.token_id IN (${placeholders})`);
params.push(...tokenIds);
}
}
if (query.models) {
const models = String(query.models)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (models.length > 0) {
const placeholders = models.map(() => "?").join(", ");
filters.push(`usage_logs.model IN (${placeholders})`);
params.push(...models);
}
}
if (query.statuses) {
const statuses = String(query.statuses)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (statuses.length > 0) {
const numericStatuses = statuses
.map((item) => Number(item))
.filter((value) => !Number.isNaN(value));
const textStatuses = statuses.filter((item) =>
Number.isNaN(Number(item)),
);
const statusFilters: string[] = [];
if (numericStatuses.length > 0) {
const placeholders = numericStatuses.map(() => "?").join(", ");
statusFilters.push(`usage_logs.upstream_status IN (${placeholders})`);
params.push(...numericStatuses);
if (query.from) {
filters.push("usage_logs.created_at >= ?");
params.push(query.from);
}
if (textStatuses.length > 0) {
const placeholders = textStatuses.map(() => "?").join(", ");
statusFilters.push(`usage_logs.status IN (${placeholders})`);
params.push(...textStatuses);
if (query.to) {
filters.push("usage_logs.created_at <= ?");
params.push(query.to);
}
if (statusFilters.length > 0) {
filters.push(`(${statusFilters.join(" OR ")})`);
if (query.model) {
const model = String(query.model).trim();
if (model) {
filters.push("usage_logs.model LIKE ? COLLATE NOCASE");
params.push(`%${model}%`);
}
}
}
}
if (query.channel) {
const channel = String(query.channel).trim();
if (channel) {
filters.push("channels.name LIKE ? COLLATE NOCASE");
params.push(`%${channel}%`);
}
}
if (query.token) {
const token = String(query.token).trim();
if (token) {
filters.push("tokens.name LIKE ? COLLATE NOCASE");
params.push(`%${token}%`);
}
}
if (query.status) {
const rawStatus = String(query.status).trim();
if (rawStatus) {
const numericStatus = Number(rawStatus);
if (Number.isNaN(numericStatus)) {
filters.push("usage_logs.status LIKE ? COLLATE NOCASE");
params.push(`%${rawStatus}%`);
} else {
filters.push("usage_logs.upstream_status = ?");
params.push(numericStatus);
if (query.channel_id) {
filters.push("usage_logs.channel_id = ?");
params.push(query.channel_id);
}
if (query.token_id) {
filters.push("usage_logs.token_id = ?");
params.push(query.token_id);
}
if (query.channel_ids) {
const channelIds = String(query.channel_ids)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (channelIds.length > 0) {
const placeholders = channelIds.map(() => "?").join(", ");
filters.push(`usage_logs.channel_id IN (${placeholders})`);
params.push(...channelIds);
}
}
if (query.token_ids) {
const tokenIds = String(query.token_ids)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (tokenIds.length > 0) {
const placeholders = tokenIds.map(() => "?").join(", ");
filters.push(`usage_logs.token_id IN (${placeholders})`);
params.push(...tokenIds);
}
}
if (query.models) {
const models = String(query.models)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (models.length > 0) {
const placeholders = models.map(() => "?").join(", ");
filters.push(`usage_logs.model IN (${placeholders})`);
params.push(...models);
}
}
if (query.statuses) {
const statuses = String(query.statuses)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (statuses.length > 0) {
const numericStatuses = statuses
.map((item) => Number(item))
.filter((value) => !Number.isNaN(value));
const textStatuses = statuses.filter((item) =>
Number.isNaN(Number(item)),
);
const statusFilters: string[] = [];
if (numericStatuses.length > 0) {
const placeholders = numericStatuses.map(() => "?").join(", ");
statusFilters.push(
`usage_logs.upstream_status IN (${placeholders})`,
);
params.push(...numericStatuses);
}
if (textStatuses.length > 0) {
const placeholders = textStatuses.map(() => "?").join(", ");
statusFilters.push(`usage_logs.status IN (${placeholders})`);
params.push(...textStatuses);
}
if (statusFilters.length > 0) {
filters.push(`(${statusFilters.join(" OR ")})`);
}
}
}
if (query.channel) {
const channel = String(query.channel).trim();
if (channel) {
filters.push("channels.name LIKE ? COLLATE NOCASE");
params.push(`%${channel}%`);
}
}
if (query.token) {
const token = String(query.token).trim();
if (token) {
filters.push("tokens.name LIKE ? COLLATE NOCASE");
params.push(`%${token}%`);
}
}
if (query.status) {
const rawStatus = String(query.status).trim();
if (rawStatus) {
const numericStatus = Number(rawStatus);
if (Number.isNaN(numericStatus)) {
filters.push("usage_logs.status LIKE ? COLLATE NOCASE");
params.push(`%${rawStatus}%`);
} else {
filters.push("usage_logs.upstream_status = ?");
params.push(numericStatus);
}
}
}
}
}
const rawLimit = Number(query.limit ?? 50);
const normalizedLimit = Number.isNaN(rawLimit) ? 50 : Math.floor(rawLimit);
const limit = Math.min(Math.max(normalizedLimit, 1), 200);
const rawOffset = Number(query.offset ?? 0);
const normalizedOffset = Number.isNaN(rawOffset)
? 0
: Math.max(0, Math.floor(rawOffset));
const rawLimit = Number(query.limit ?? 50);
const normalizedLimit = Number.isNaN(rawLimit) ? 50 : Math.floor(rawLimit);
const limit = Math.min(Math.max(normalizedLimit, 1), 200);
const rawOffset = Number(query.offset ?? 0);
const normalizedOffset = Number.isNaN(rawOffset)
? 0
: Math.max(0, Math.floor(rawOffset));
const whereSql = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
const baseSql =
"FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id LEFT JOIN tokens ON tokens.id = usage_logs.token_id";
const whereSql =
filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
const baseSql =
"FROM usage_logs LEFT JOIN channels ON channels.id = usage_logs.channel_id LEFT JOIN tokens ON tokens.id = usage_logs.token_id";
const retention = await getRetentionDays(c.env.DB);
await pruneUsageLogs(c.env.DB, retention);
const retention = await getRetentionDays(c.env.DB);
await pruneUsageLogs(c.env.DB, retention);
const countRow = await c.env.DB.prepare(
`SELECT COUNT(*) as total ${baseSql} ${whereSql}`,
)
.bind(...params)
.first<{ total: number }>();
const total = Number(countRow?.total ?? 0);
const countRow = await c.env.DB.prepare(
`SELECT COUNT(*) as total ${baseSql} ${whereSql}`,
)
.bind(...params)
.first<{ total: number }>();
const total = Number(countRow?.total ?? 0);
const listSql = `SELECT usage_logs.*, channels.name as channel_name, tokens.name as token_name ${baseSql} ${whereSql} ORDER BY usage_logs.created_at DESC LIMIT ? OFFSET ?`;
const result = await c.env.DB.prepare(listSql)
.bind(...params, limit, normalizedOffset)
.all();
return c.json({
logs: result.results ?? [],
total,
limit,
offset: normalizedOffset,
});
const listSql = `SELECT usage_logs.*, channels.name as channel_name, tokens.name as token_name ${baseSql} ${whereSql} ORDER BY usage_logs.created_at DESC LIMIT ? OFFSET ?`;
const result = await c.env.DB.prepare(listSql)
.bind(...params, limit, normalizedOffset)
.all();
return c.json({
logs: result.results ?? [],
total,
limit,
offset: normalizedOffset,
});
},
);
});
export default usage;
+662 -26
View File
@@ -1,5 +1,6 @@
import type { D1Database } from "@cloudflare/workers-types";
import type { Bindings } from "../env";
import { withJsonCache } from "../utils/cache";
import { nowIso } from "../utils/time";
const DEFAULT_LOG_RETENTION_DAYS = 30;
@@ -9,21 +10,145 @@ const DEFAULT_MODEL_FAILURE_COOLDOWN_MINUTES = 10;
const DEFAULT_PROXY_STREAM_USAGE_MODE = "full";
const DEFAULT_PROXY_STREAM_USAGE_MAX_BYTES = 0;
const DEFAULT_PROXY_STREAM_USAGE_MAX_PARSERS = 0;
const DEFAULT_CACHE_ENABLED = true;
const DEFAULT_CACHE_VERSION = 1;
const DEFAULT_CACHE_DASHBOARD_TTL_SECONDS = 30;
const DEFAULT_CACHE_USAGE_TTL_SECONDS = 15;
const DEFAULT_CACHE_MODELS_TTL_SECONDS = 60;
const DEFAULT_CACHE_TOKENS_TTL_SECONDS = 15;
const DEFAULT_CACHE_CHANNELS_TTL_SECONDS = 15;
const DEFAULT_CACHE_CALL_TOKENS_TTL_SECONDS = 15;
const DEFAULT_CACHE_SETTINGS_TTL_SECONDS = 30;
const DEFAULT_PROXY_UPSTREAM_TIMEOUT_MS = 30000;
const DEFAULT_USAGE_QUEUE_DAILY_LIMIT = 10000;
const DEFAULT_USAGE_QUEUE_DIRECT_WRITE_RATIO = 0.5;
const CACHE_CONFIG_TTL_MS = 1000;
const SETTING_SNAPSHOT_TTL_MS = 1000;
const RETENTION_KEY = "log_retention_days";
const SESSION_TTL_KEY = "session_ttl_hours";
const ADMIN_PASSWORD_HASH_KEY = "admin_password_hash";
const CHECKIN_SCHEDULE_TIME_KEY = "checkin_schedule_time";
const MODEL_FAILURE_COOLDOWN_KEY = "model_failure_cooldown_minutes";
const CACHE_ENABLED_KEY = "cache_enabled";
const CACHE_DASHBOARD_TTL_KEY = "cache_ttl_dashboard_seconds";
const CACHE_USAGE_TTL_KEY = "cache_ttl_usage_seconds";
const CACHE_MODELS_TTL_KEY = "cache_ttl_models_seconds";
const CACHE_TOKENS_TTL_KEY = "cache_ttl_tokens_seconds";
const CACHE_CHANNELS_TTL_KEY = "cache_ttl_channels_seconds";
const CACHE_CALL_TOKENS_TTL_KEY = "cache_ttl_call_tokens_seconds";
const CACHE_SETTINGS_TTL_KEY = "cache_ttl_settings_seconds";
const CACHE_VERSION_DASHBOARD_KEY = "cache_v_dashboard";
const CACHE_VERSION_USAGE_KEY = "cache_v_usage";
const CACHE_VERSION_MODELS_KEY = "cache_v_models";
const CACHE_VERSION_TOKENS_KEY = "cache_v_tokens";
const CACHE_VERSION_CHANNELS_KEY = "cache_v_channels";
const CACHE_VERSION_CALL_TOKENS_KEY = "cache_v_call_tokens";
const CACHE_VERSION_SETTINGS_KEY = "cache_v_settings";
const PROXY_UPSTREAM_TIMEOUT_KEY = "proxy_upstream_timeout_ms";
const PROXY_STREAM_USAGE_MODE_KEY = "proxy_stream_usage_mode";
const PROXY_STREAM_USAGE_MAX_BYTES_KEY = "proxy_stream_usage_max_bytes";
const PROXY_STREAM_USAGE_MAX_PARSERS_KEY = "proxy_stream_usage_max_parsers";
const PROXY_USAGE_QUEUE_ENABLED_KEY = "proxy_usage_queue_enabled";
const USAGE_QUEUE_DAILY_LIMIT_KEY = "usage_queue_daily_limit";
const USAGE_QUEUE_DIRECT_WRITE_RATIO_KEY = "usage_queue_direct_write_ratio";
export type RuntimeProxyConfig = {
upstream_timeout_ms: number;
stream_usage_mode: string;
stream_usage_max_bytes: number;
stream_usage_max_parsers: number;
usage_queue_enabled: boolean;
usage_queue_daily_limit: number;
usage_queue_direct_write_ratio: number;
usage_queue_bound: boolean;
usage_queue_active: boolean;
};
export type ProxyRuntimeSettings = {
upstream_timeout_ms: number;
stream_usage_mode: string;
stream_usage_max_bytes: number;
stream_usage_max_parsers: number;
usage_queue_enabled: boolean;
usage_queue_daily_limit: number;
usage_queue_direct_write_ratio: number;
};
export type CacheConfig = {
enabled: boolean;
dashboard_ttl_seconds: number;
usage_ttl_seconds: number;
models_ttl_seconds: number;
tokens_ttl_seconds: number;
channels_ttl_seconds: number;
call_tokens_ttl_seconds: number;
settings_ttl_seconds: number;
version_dashboard: number;
version_usage: number;
version_models: number;
version_tokens: number;
version_channels: number;
version_call_tokens: number;
version_settings: number;
};
export type CacheConfigUpdate = {
enabled?: boolean;
dashboardTtlSeconds?: number;
usageTtlSeconds?: number;
modelsTtlSeconds?: number;
tokensTtlSeconds?: number;
channelsTtlSeconds?: number;
callTokensTtlSeconds?: number;
settingsTtlSeconds?: number;
};
type CacheConfigSnapshot = {
value: CacheConfig;
expiresAt: number;
};
let cacheConfigSnapshot: CacheConfigSnapshot | null = null;
type SettingSnapshot<T> = {
value: T;
expiresAt: number;
};
let retentionSnapshot: SettingSnapshot<number> | null = null;
let sessionTtlSnapshot: SettingSnapshot<number> | null = null;
let adminPasswordSnapshot: SettingSnapshot<string | null> | null = null;
let checkinScheduleSnapshot: SettingSnapshot<string> | null = null;
let modelCooldownSnapshot: SettingSnapshot<number> | null = null;
type CacheControlSnapshot<T> = {
value: T;
expiresAt: number;
};
type SettingsCacheControl = {
enabled: boolean;
ttlSeconds: number;
version: number;
};
let settingsCacheControlSnapshot: CacheControlSnapshot<SettingsCacheControl> | null =
null;
type CacheVersionScope =
| "dashboard"
| "usage"
| "models"
| "tokens"
| "channels"
| "call_tokens"
| "settings";
const CACHE_VERSION_KEYS: Record<CacheVersionScope, string> = {
dashboard: CACHE_VERSION_DASHBOARD_KEY,
usage: CACHE_VERSION_USAGE_KEY,
models: CACHE_VERSION_MODELS_KEY,
tokens: CACHE_VERSION_TOKENS_KEY,
channels: CACHE_VERSION_CHANNELS_KEY,
call_tokens: CACHE_VERSION_CALL_TOKENS_KEY,
settings: CACHE_VERSION_SETTINGS_KEY,
};
async function readSetting(
db: D1Database,
key: string,
@@ -59,8 +184,15 @@ function parsePositiveNumber(value: string | null, fallback: number): number {
return fallback;
}
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (!value) {
function parseBooleanEnv(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value === null) {
return fallback;
}
return parseBooleanSetting(value, fallback);
}
function parsePositiveEnv(value: string | undefined, fallback: number): number {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number(value);
@@ -81,6 +213,127 @@ function parseNonNegativeInt(value: string | undefined, fallback: number): numbe
return fallback;
}
function parseNonNegativeSetting(value: string | null, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
if (!Number.isNaN(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
return fallback;
}
function parsePositiveSetting(value: string | null, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
if (!Number.isNaN(parsed) && parsed > 0) {
return Math.floor(parsed);
}
return fallback;
}
function parseBooleanSetting(value: string | null, fallback: boolean): boolean {
if (value === null || value === undefined) {
return fallback;
}
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return fallback;
}
function parseRatioSetting(value: string | null, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
return Math.min(1, Math.max(0, parsed));
}
return fallback;
}
async function getCachedSetting<T>(
snapshot: SettingSnapshot<T> | null,
loader: () => Promise<T>,
onUpdate: (next: SettingSnapshot<T> | null) => void,
): Promise<T> {
if (snapshot && snapshot.expiresAt > Date.now()) {
return snapshot.value;
}
const value = await loader();
onUpdate({
value,
expiresAt: Date.now() + SETTING_SNAPSHOT_TTL_MS,
});
return value;
}
function setCacheConfigSnapshot(value: CacheConfig): void {
cacheConfigSnapshot = {
value,
expiresAt: Date.now() + CACHE_CONFIG_TTL_MS,
};
}
function clearCacheConfigSnapshot(): void {
cacheConfigSnapshot = null;
}
function setSettingsCacheControlSnapshot(
value: SettingsCacheControl,
): void {
settingsCacheControlSnapshot = {
value,
expiresAt: Date.now() + CACHE_CONFIG_TTL_MS,
};
}
function clearSettingsCacheControlSnapshot(): void {
settingsCacheControlSnapshot = null;
}
async function readCacheVersion(
db: D1Database,
scope: CacheVersionScope,
): Promise<number> {
const key = CACHE_VERSION_KEYS[scope];
const value = await readSetting(db, key);
return parsePositiveSetting(value, DEFAULT_CACHE_VERSION);
}
async function bumpCacheVersion(
db: D1Database,
scope: CacheVersionScope,
): Promise<void> {
const key = CACHE_VERSION_KEYS[scope];
await db
.prepare(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = CAST(settings.value AS INTEGER) + 1, updated_at = excluded.updated_at",
)
.bind(key, String(DEFAULT_CACHE_VERSION + 1), nowIso())
.run();
}
export async function bumpCacheVersions(
db: D1Database,
scopes: CacheVersionScope[],
): Promise<void> {
if (scopes.length === 0) {
return;
}
await Promise.all(scopes.map((scope) => bumpCacheVersion(db, scope)));
clearCacheConfigSnapshot();
clearSettingsCacheControlSnapshot();
}
function normalizeStreamUsageMode(value: string | undefined): string {
const normalized = (value ?? "").toLowerCase();
if (normalized === "off" || normalized === "full" || normalized === "lite") {
@@ -89,37 +342,372 @@ function normalizeStreamUsageMode(value: string | undefined): string {
return DEFAULT_PROXY_STREAM_USAGE_MODE;
}
async function getSettingsCacheControl(
db: D1Database,
): Promise<SettingsCacheControl> {
const snapshot = settingsCacheControlSnapshot;
if (snapshot && snapshot.expiresAt > Date.now()) {
return snapshot.value;
}
const enabled = parseBooleanSetting(
await readSetting(db, CACHE_ENABLED_KEY),
DEFAULT_CACHE_ENABLED,
);
const ttlSeconds = parseNonNegativeSetting(
await readSetting(db, CACHE_SETTINGS_TTL_KEY),
DEFAULT_CACHE_SETTINGS_TTL_SECONDS,
);
const version = await readCacheVersion(db, "settings");
const value = { enabled, ttlSeconds, version };
setSettingsCacheControlSnapshot(value);
return value;
}
export async function getSettingsSnapshot(
db: D1Database,
): Promise<Record<string, string>> {
const control = await getSettingsCacheControl(db);
return withJsonCache(
{
namespace: "settings",
key: "all",
version: control.version,
ttlSeconds: control.ttlSeconds,
enabled: control.enabled,
},
() => listSettings(db),
);
}
export async function getProxyRuntimeSettings(
db: D1Database,
env: Bindings,
): Promise<ProxyRuntimeSettings> {
const settings = await getSettingsSnapshot(db);
const upstreamTimeout = parsePositiveSetting(
settings[PROXY_UPSTREAM_TIMEOUT_KEY] ?? null,
parsePositiveEnv(env.PROXY_UPSTREAM_TIMEOUT_MS, DEFAULT_PROXY_UPSTREAM_TIMEOUT_MS),
);
const streamUsageMode = normalizeStreamUsageMode(
settings[PROXY_STREAM_USAGE_MODE_KEY] ?? env.PROXY_STREAM_USAGE_MODE,
);
const streamUsageMaxBytes = parseNonNegativeSetting(
settings[PROXY_STREAM_USAGE_MAX_BYTES_KEY] ?? null,
parseNonNegativeInt(
env.PROXY_STREAM_USAGE_MAX_BYTES,
DEFAULT_PROXY_STREAM_USAGE_MAX_BYTES,
),
);
const streamUsageMaxParsers = parseNonNegativeSetting(
settings[PROXY_STREAM_USAGE_MAX_PARSERS_KEY] ?? null,
parseNonNegativeInt(
env.PROXY_STREAM_USAGE_MAX_PARSERS,
DEFAULT_PROXY_STREAM_USAGE_MAX_PARSERS,
),
);
const usageQueueEnabled = parseBooleanSetting(
settings[PROXY_USAGE_QUEUE_ENABLED_KEY] ?? null,
parseBooleanEnv(env.PROXY_USAGE_QUEUE_ENABLED, true),
);
const usageQueueDailyLimit = parseNonNegativeSetting(
settings[USAGE_QUEUE_DAILY_LIMIT_KEY] ?? null,
DEFAULT_USAGE_QUEUE_DAILY_LIMIT,
);
const usageQueueDirectWriteRatio = parseRatioSetting(
settings[USAGE_QUEUE_DIRECT_WRITE_RATIO_KEY] ?? null,
DEFAULT_USAGE_QUEUE_DIRECT_WRITE_RATIO,
);
return {
upstream_timeout_ms: upstreamTimeout,
stream_usage_mode: streamUsageMode,
stream_usage_max_bytes: streamUsageMaxBytes,
stream_usage_max_parsers: streamUsageMaxParsers,
usage_queue_enabled: usageQueueEnabled,
usage_queue_daily_limit: usageQueueDailyLimit,
usage_queue_direct_write_ratio: usageQueueDirectWriteRatio,
};
}
/**
* Returns runtime proxy configuration derived from environment variables.
* Returns runtime proxy configuration derived from settings and environment.
*
* @param env - Worker bindings.
* @returns Runtime proxy configuration for display.
*/
export function getRuntimeProxyConfig(env: Bindings): RuntimeProxyConfig {
const usageQueueEnabled = env.PROXY_USAGE_QUEUE_ENABLED !== "false";
export function getRuntimeProxyConfig(
env: Bindings,
settings: ProxyRuntimeSettings,
): RuntimeProxyConfig {
const usageQueueBound = Boolean(env.USAGE_QUEUE);
const usageQueueEnabled = settings.usage_queue_enabled;
return {
stream_usage_mode: normalizeStreamUsageMode(env.PROXY_STREAM_USAGE_MODE),
stream_usage_max_bytes: parseNonNegativeInt(
env.PROXY_STREAM_USAGE_MAX_BYTES,
DEFAULT_PROXY_STREAM_USAGE_MAX_BYTES,
),
stream_usage_max_parsers: parseNonNegativeInt(
env.PROXY_STREAM_USAGE_MAX_PARSERS,
DEFAULT_PROXY_STREAM_USAGE_MAX_PARSERS,
),
usage_queue_enabled: usageQueueEnabled,
...settings,
usage_queue_bound: usageQueueBound,
usage_queue_active: usageQueueEnabled && usageQueueBound,
};
}
export async function setProxyRuntimeSettings(
db: D1Database,
update: Partial<ProxyRuntimeSettings>,
): Promise<void> {
const tasks: Promise<void>[] = [];
if (update.upstream_timeout_ms !== undefined) {
tasks.push(
upsertSetting(
db,
PROXY_UPSTREAM_TIMEOUT_KEY,
String(Math.max(0, Math.floor(update.upstream_timeout_ms))),
),
);
}
if (update.stream_usage_mode !== undefined) {
tasks.push(
upsertSetting(db, PROXY_STREAM_USAGE_MODE_KEY, update.stream_usage_mode),
);
}
if (update.stream_usage_max_bytes !== undefined) {
tasks.push(
upsertSetting(
db,
PROXY_STREAM_USAGE_MAX_BYTES_KEY,
String(Math.max(0, Math.floor(update.stream_usage_max_bytes))),
),
);
}
if (update.stream_usage_max_parsers !== undefined) {
tasks.push(
upsertSetting(
db,
PROXY_STREAM_USAGE_MAX_PARSERS_KEY,
String(Math.max(0, Math.floor(update.stream_usage_max_parsers))),
),
);
}
if (update.usage_queue_enabled !== undefined) {
tasks.push(
upsertSetting(
db,
PROXY_USAGE_QUEUE_ENABLED_KEY,
update.usage_queue_enabled ? "1" : "0",
),
);
}
if (update.usage_queue_daily_limit !== undefined) {
tasks.push(
upsertSetting(
db,
USAGE_QUEUE_DAILY_LIMIT_KEY,
String(Math.max(0, Math.floor(update.usage_queue_daily_limit))),
),
);
}
if (update.usage_queue_direct_write_ratio !== undefined) {
tasks.push(
upsertSetting(
db,
USAGE_QUEUE_DIRECT_WRITE_RATIO_KEY,
String(update.usage_queue_direct_write_ratio),
),
);
}
if (tasks.length === 0) {
return;
}
await Promise.all(tasks);
await bumpCacheVersions(db, ["settings"]);
}
export async function getCacheConfig(db: D1Database): Promise<CacheConfig> {
const snapshot = cacheConfigSnapshot;
if (snapshot && snapshot.expiresAt > Date.now()) {
return snapshot.value;
}
const settings = await getSettingsSnapshot(db);
const enabled = parseBooleanSetting(
settings[CACHE_ENABLED_KEY] ?? null,
DEFAULT_CACHE_ENABLED,
);
const dashboardTtl = parseNonNegativeSetting(
settings[CACHE_DASHBOARD_TTL_KEY] ?? null,
DEFAULT_CACHE_DASHBOARD_TTL_SECONDS,
);
const usageTtl = parseNonNegativeSetting(
settings[CACHE_USAGE_TTL_KEY] ?? null,
DEFAULT_CACHE_USAGE_TTL_SECONDS,
);
const modelsTtl = parseNonNegativeSetting(
settings[CACHE_MODELS_TTL_KEY] ?? null,
DEFAULT_CACHE_MODELS_TTL_SECONDS,
);
const tokensTtl = parseNonNegativeSetting(
settings[CACHE_TOKENS_TTL_KEY] ?? null,
DEFAULT_CACHE_TOKENS_TTL_SECONDS,
);
const channelsTtl = parseNonNegativeSetting(
settings[CACHE_CHANNELS_TTL_KEY] ?? null,
DEFAULT_CACHE_CHANNELS_TTL_SECONDS,
);
const callTokensTtl = parseNonNegativeSetting(
settings[CACHE_CALL_TOKENS_TTL_KEY] ?? null,
DEFAULT_CACHE_CALL_TOKENS_TTL_SECONDS,
);
const settingsTtl = parseNonNegativeSetting(
settings[CACHE_SETTINGS_TTL_KEY] ?? null,
DEFAULT_CACHE_SETTINGS_TTL_SECONDS,
);
const versionDashboard = parsePositiveSetting(
settings[CACHE_VERSION_DASHBOARD_KEY] ?? null,
DEFAULT_CACHE_VERSION,
);
const versionUsage = parsePositiveSetting(
settings[CACHE_VERSION_USAGE_KEY] ?? null,
DEFAULT_CACHE_VERSION,
);
const versionModels = parsePositiveSetting(
settings[CACHE_VERSION_MODELS_KEY] ?? null,
DEFAULT_CACHE_VERSION,
);
const versionTokens = parsePositiveSetting(
settings[CACHE_VERSION_TOKENS_KEY] ?? null,
DEFAULT_CACHE_VERSION,
);
const versionChannels = parsePositiveSetting(
settings[CACHE_VERSION_CHANNELS_KEY] ?? null,
DEFAULT_CACHE_VERSION,
);
const versionCallTokens = parsePositiveSetting(
settings[CACHE_VERSION_CALL_TOKENS_KEY] ?? null,
DEFAULT_CACHE_VERSION,
);
const versionSettings = parsePositiveSetting(
settings[CACHE_VERSION_SETTINGS_KEY] ?? null,
DEFAULT_CACHE_VERSION,
);
const value = {
enabled,
dashboard_ttl_seconds: dashboardTtl,
usage_ttl_seconds: usageTtl,
models_ttl_seconds: modelsTtl,
tokens_ttl_seconds: tokensTtl,
channels_ttl_seconds: channelsTtl,
call_tokens_ttl_seconds: callTokensTtl,
settings_ttl_seconds: settingsTtl,
version_dashboard: versionDashboard,
version_usage: versionUsage,
version_models: versionModels,
version_tokens: versionTokens,
version_channels: versionChannels,
version_call_tokens: versionCallTokens,
version_settings: versionSettings,
};
setCacheConfigSnapshot(value);
return value;
}
export async function setCacheConfig(
db: D1Database,
update: CacheConfigUpdate,
): Promise<CacheConfig> {
const tasks: Promise<void>[] = [];
if (update.enabled !== undefined) {
tasks.push(
upsertSetting(db, CACHE_ENABLED_KEY, update.enabled ? "1" : "0"),
);
}
if (update.dashboardTtlSeconds !== undefined) {
tasks.push(
upsertSetting(
db,
CACHE_DASHBOARD_TTL_KEY,
String(Math.max(0, Math.floor(update.dashboardTtlSeconds))),
),
);
}
if (update.usageTtlSeconds !== undefined) {
tasks.push(
upsertSetting(
db,
CACHE_USAGE_TTL_KEY,
String(Math.max(0, Math.floor(update.usageTtlSeconds))),
),
);
}
if (update.modelsTtlSeconds !== undefined) {
tasks.push(
upsertSetting(
db,
CACHE_MODELS_TTL_KEY,
String(Math.max(0, Math.floor(update.modelsTtlSeconds))),
),
);
}
if (update.tokensTtlSeconds !== undefined) {
tasks.push(
upsertSetting(
db,
CACHE_TOKENS_TTL_KEY,
String(Math.max(0, Math.floor(update.tokensTtlSeconds))),
),
);
}
if (update.channelsTtlSeconds !== undefined) {
tasks.push(
upsertSetting(
db,
CACHE_CHANNELS_TTL_KEY,
String(Math.max(0, Math.floor(update.channelsTtlSeconds))),
),
);
}
if (update.callTokensTtlSeconds !== undefined) {
tasks.push(
upsertSetting(
db,
CACHE_CALL_TOKENS_TTL_KEY,
String(Math.max(0, Math.floor(update.callTokensTtlSeconds))),
),
);
}
if (update.settingsTtlSeconds !== undefined) {
tasks.push(
upsertSetting(
db,
CACHE_SETTINGS_TTL_KEY,
String(Math.max(0, Math.floor(update.settingsTtlSeconds))),
),
);
}
if (tasks.length === 0) {
return getCacheConfig(db);
}
await Promise.all(tasks);
await bumpCacheVersions(db, [
"dashboard",
"usage",
"models",
"tokens",
"channels",
"call_tokens",
"settings",
]);
return getCacheConfig(db);
}
/**
* Returns the log retention days from settings or default fallback.
*/
export async function getRetentionDays(db: D1Database): Promise<number> {
const value = await readSetting(db, RETENTION_KEY);
return parsePositiveNumber(value, DEFAULT_LOG_RETENTION_DAYS);
return getCachedSetting(
retentionSnapshot,
async () => {
const value = await readSetting(db, RETENTION_KEY);
return parsePositiveNumber(value, DEFAULT_LOG_RETENTION_DAYS);
},
(next) => {
retentionSnapshot = next;
},
);
}
/**
@@ -131,14 +719,23 @@ export async function setRetentionDays(
): Promise<void> {
const value = Math.max(1, Math.floor(days)).toString();
await upsertSetting(db, RETENTION_KEY, value);
retentionSnapshot = null;
}
/**
* Returns the session TTL hours from settings or default fallback.
*/
export async function getSessionTtlHours(db: D1Database): Promise<number> {
const value = await readSetting(db, SESSION_TTL_KEY);
return parsePositiveNumber(value, DEFAULT_SESSION_TTL_HOURS);
return getCachedSetting(
sessionTtlSnapshot,
async () => {
const value = await readSetting(db, SESSION_TTL_KEY);
return parsePositiveNumber(value, DEFAULT_SESSION_TTL_HOURS);
},
(next) => {
sessionTtlSnapshot = next;
},
);
}
/**
@@ -150,6 +747,7 @@ export async function setSessionTtlHours(
): Promise<void> {
const value = Math.max(1, Math.floor(hours)).toString();
await upsertSetting(db, SESSION_TTL_KEY, value);
sessionTtlSnapshot = null;
}
/**
@@ -158,7 +756,13 @@ export async function setSessionTtlHours(
export async function getAdminPasswordHash(
db: D1Database,
): Promise<string | null> {
return readSetting(db, ADMIN_PASSWORD_HASH_KEY);
return getCachedSetting(
adminPasswordSnapshot,
() => readSetting(db, ADMIN_PASSWORD_HASH_KEY),
(next) => {
adminPasswordSnapshot = next;
},
);
}
/**
@@ -172,6 +776,7 @@ export async function setAdminPasswordHash(
return;
}
await upsertSetting(db, ADMIN_PASSWORD_HASH_KEY, hash);
adminPasswordSnapshot = null;
}
/**
@@ -183,10 +788,18 @@ export async function isAdminPasswordSet(db: D1Database): Promise<boolean> {
}
export async function getCheckinScheduleTime(db: D1Database): Promise<string> {
const timeRaw = await readSetting(db, CHECKIN_SCHEDULE_TIME_KEY);
return timeRaw && timeRaw.length > 0
? timeRaw
: DEFAULT_CHECKIN_SCHEDULE_TIME;
return getCachedSetting(
checkinScheduleSnapshot,
async () => {
const timeRaw = await readSetting(db, CHECKIN_SCHEDULE_TIME_KEY);
return timeRaw && timeRaw.length > 0
? timeRaw
: DEFAULT_CHECKIN_SCHEDULE_TIME;
},
(next) => {
checkinScheduleSnapshot = next;
},
);
}
export async function setCheckinScheduleTime(
@@ -194,13 +807,22 @@ export async function setCheckinScheduleTime(
time: string,
): Promise<void> {
await upsertSetting(db, CHECKIN_SCHEDULE_TIME_KEY, time);
checkinScheduleSnapshot = null;
}
export async function getModelFailureCooldownMinutes(
db: D1Database,
): Promise<number> {
const value = await readSetting(db, MODEL_FAILURE_COOLDOWN_KEY);
return parsePositiveNumber(value, DEFAULT_MODEL_FAILURE_COOLDOWN_MINUTES);
return getCachedSetting(
modelCooldownSnapshot,
async () => {
const value = await readSetting(db, MODEL_FAILURE_COOLDOWN_KEY);
return parsePositiveNumber(value, DEFAULT_MODEL_FAILURE_COOLDOWN_MINUTES);
},
(next) => {
modelCooldownSnapshot = next;
},
);
}
export async function setModelFailureCooldownMinutes(
@@ -209,6 +831,7 @@ export async function setModelFailureCooldownMinutes(
): Promise<void> {
const value = Math.max(1, Math.floor(minutes)).toString();
await upsertSetting(db, MODEL_FAILURE_COOLDOWN_KEY, value);
modelCooldownSnapshot = null;
}
/**
@@ -224,3 +847,16 @@ export async function listSettings(
}
return map;
}
/**
* Resets in-memory setting snapshots (testing utility).
*/
export function resetSettingsSnapshots(): void {
retentionSnapshot = null;
sessionTtlSnapshot = null;
adminPasswordSnapshot = null;
checkinScheduleSnapshot = null;
modelCooldownSnapshot = null;
cacheConfigSnapshot = null;
settingsCacheControlSnapshot = null;
}
+109
View File
@@ -0,0 +1,109 @@
import type {
DurableObjectNamespace,
DurableObjectState,
} from "@cloudflare/workers-types";
import { beijingDateString } from "../utils/time";
const LIMITER_NAME = "usage-limiter";
const DATE_KEY = "usage_date";
const COUNT_KEY = "usage_count";
export type UsageLimiterReserveResult = {
ok: boolean;
allowed: boolean;
count: number;
limit: number;
date: string;
};
export const getUsageLimiterStub = (namespace: DurableObjectNamespace) =>
namespace.get(namespace.idFromName(LIMITER_NAME));
export async function reserveUsageQueue(
stub: { fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response> },
options: { limit: number; amount?: number },
): Promise<UsageLimiterReserveResult> {
const limit = Math.max(0, Math.floor(options.limit));
const amount = Math.max(1, Math.floor(options.amount ?? 1));
const response = await stub.fetch("https://usage-limiter/reserve", {
method: "POST",
body: JSON.stringify({ limit, amount }),
});
if (!response.ok) {
throw new Error(`usage_limiter_failed:${response.status}`);
}
const payload = (await response.json()) as UsageLimiterReserveResult;
return payload;
}
export class UsageLimiter {
private state: DurableObjectState;
constructor(state: DurableObjectState) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/reserve") {
let limit = 0;
let amount = 1;
try {
const payload = (await request.json()) as {
limit?: number;
amount?: number;
};
limit = Math.max(0, Math.floor(Number(payload?.limit ?? 0)));
amount = Math.max(1, Math.floor(Number(payload?.amount ?? 1)));
} catch {
return new Response("Invalid payload", { status: 400 });
}
const nowDate = beijingDateString(new Date());
let storedDate =
(await this.state.storage.get<string>(DATE_KEY)) ?? null;
let count = (await this.state.storage.get<number>(COUNT_KEY)) ?? 0;
if (storedDate !== nowDate) {
storedDate = nowDate;
count = 0;
}
const nextCount = count + amount;
const allowed = limit <= 0 ? true : nextCount <= limit;
if (allowed) {
count = nextCount;
}
await this.state.storage.put({
[DATE_KEY]: storedDate,
[COUNT_KEY]: count,
});
return new Response(
JSON.stringify({
ok: true,
allowed,
count,
limit,
date: storedDate,
}),
{ headers: { "Content-Type": "application/json" } },
);
}
if (request.method === "GET" && url.pathname === "/status") {
const nowDate = beijingDateString(new Date());
let storedDate =
(await this.state.storage.get<string>(DATE_KEY)) ?? null;
let count = (await this.state.storage.get<number>(COUNT_KEY)) ?? 0;
if (storedDate !== nowDate) {
storedDate = nowDate;
count = 0;
}
return new Response(
JSON.stringify({
ok: true,
date: storedDate,
count,
}),
{ headers: { "Content-Type": "application/json" } },
);
}
return new Response("Not Found", { status: 404 });
}
}
+13 -6
View File
@@ -1,8 +1,4 @@
import type {
D1Database,
ExecutionContext,
QueueBatch,
} from "@cloudflare/workers-types";
import type { D1Database, ExecutionContext } from "@cloudflare/workers-types";
import type { Bindings } from "../env";
import { recordChannelModelError, upsertChannelModelCapabilities } from "./channel-model-capabilities";
import type { UsageInput } from "./usage";
@@ -31,6 +27,17 @@ export type UsageQueueEvent =
};
};
type QueueMessage<T> = {
body: T;
ack: () => void;
retry: () => void;
};
type QueueBatchLike<T> = {
queue: string;
messages: QueueMessage<T>[];
};
function resolveNowSeconds(value?: number): number {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
@@ -69,7 +76,7 @@ export async function processUsageQueueEvent(
}
export async function handleUsageQueue(
batch: QueueBatch<UsageQueueEvent>,
batch: QueueBatchLike<UsageQueueEvent>,
env: Bindings,
ctx: ExecutionContext,
): Promise<void> {
+13
View File
@@ -20,6 +20,10 @@ export type UsageInput = {
errorMessage?: string | null;
};
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
let lastPruneAt = 0;
let lastPruneRetention: number | null = null;
/**
* Inserts a usage record and updates token quota.
*/
@@ -84,6 +88,15 @@ export async function pruneUsageLogs(
db: D1Database,
retentionDays: number,
): Promise<void> {
const now = Date.now();
if (
lastPruneRetention === retentionDays &&
now - lastPruneAt < PRUNE_INTERVAL_MS
) {
return;
}
lastPruneRetention = retentionDays;
lastPruneAt = now;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - retentionDays);
await db
+137
View File
@@ -0,0 +1,137 @@
import type { Context } from "hono";
import type { AppEnv } from "../env";
import { sha256Hex } from "./crypto";
import { getBearerToken } from "./request";
export type CacheNamespace =
| "usage"
| "dashboard"
| "models"
| "tokens"
| "channels"
| "call_tokens"
| "settings";
export type ApiCacheOptions = {
namespace: CacheNamespace;
version: number;
ttlSeconds: number;
enabled: boolean;
};
const CACHE_HEADER = "X-Cache";
const INTERNAL_CACHE_ORIGIN = "https://cache.internal";
type CacheStore = {
default: Cache;
};
function getCacheStorage(): Cache | null {
const storage = (globalThis as unknown as { caches?: CacheStore }).caches;
return storage?.default ?? null;
}
async function buildCacheKey(
c: Context<AppEnv>,
namespace: CacheNamespace,
version: number,
): Promise<Request> {
const url = new URL(c.req.url);
const token = getBearerToken(c);
const authHash = token ? await sha256Hex(token) : "anonymous";
const entries = Array.from(url.searchParams.entries());
entries.push(["__cache_ns", namespace]);
entries.push(["__cache_v", String(version)]);
entries.push(["__cache_ah", authHash]);
entries.sort((a, b) => {
const keyCompare = a[0].localeCompare(b[0]);
return keyCompare !== 0 ? keyCompare : a[1].localeCompare(b[1]);
});
url.search = new URLSearchParams(entries).toString();
return new Request(url.toString(), { method: "GET" });
}
function normalizeTtlSeconds(value: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 0;
}
return Math.floor(value);
}
function buildInternalCacheRequest(
namespace: CacheNamespace,
key: string,
version: number,
): Request {
const safeKey = encodeURIComponent(key);
const url = new URL(`${INTERNAL_CACHE_ORIGIN}/${namespace}/${safeKey}`);
url.searchParams.set("v", String(version));
return new Request(url.toString(), { method: "GET" });
}
export async function withJsonCache<T>(
options: ApiCacheOptions & { key: string; cacheNull?: boolean },
loader: () => Promise<T>,
): Promise<T> {
const ttlSeconds = normalizeTtlSeconds(options.ttlSeconds);
if (!options.enabled || ttlSeconds <= 0) {
return loader();
}
const cache = getCacheStorage();
if (!cache) {
return loader();
}
const cacheKey = buildInternalCacheRequest(
options.namespace,
options.key,
options.version,
);
const cached = await cache.match(cacheKey);
if (cached) {
return (await cached.json()) as T;
}
const value = await loader();
if (value !== null || options.cacheNull) {
const headers = new Headers({ "Cache-Control": `max-age=${ttlSeconds}` });
const response = new Response(JSON.stringify(value), { headers });
await cache.put(cacheKey, response);
}
return value;
}
export async function withApiCache(
c: Context<AppEnv>,
options: ApiCacheOptions,
handler: () => Promise<Response>,
): Promise<Response> {
const ttlSeconds = normalizeTtlSeconds(options.ttlSeconds);
if (!options.enabled || ttlSeconds <= 0 || c.req.method !== "GET") {
return handler();
}
const cache = getCacheStorage();
if (!cache) {
return handler();
}
const cacheKey = await buildCacheKey(c, options.namespace, options.version);
const cached = await cache.match(cacheKey);
if (cached) {
const response = new Response(cached.body, cached);
response.headers.set(CACHE_HEADER, "HIT");
return response;
}
const response = await handler();
if (!response.ok) {
return response;
}
const headers = new Headers(response.headers);
headers.set("Cache-Control", `max-age=${ttlSeconds}`);
headers.set(CACHE_HEADER, "MISS");
const responseForClient = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
await cache.put(cacheKey, responseForClient.clone());
return responseForClient;
}
+8
View File
@@ -17,6 +17,10 @@ run_worker_first = true
name = "CHECKIN_SCHEDULER"
class_name = "CheckinScheduler"
[[durable_objects.bindings]]
name = "USAGE_LIMITER"
class_name = "UsageLimiter"
[[queues.producers]]
queue = "usage-events"
binding = "USAGE_QUEUE"
@@ -31,5 +35,9 @@ max_retries = 3
tag = "v1"
new_sqlite_classes = ["CheckinScheduler"]
[[migrations]]
tag = "v2"
new_sqlite_classes = ["UsageLimiter"]