add:增加缓存
This commit is contained in:
1 parent
4e3b1315a1
commit
54baba28a3
36 files changed
+2374
-357
No files matched your search
@@ -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
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
|
||||
- 系统设置增加定时签到开关与时间(中国时间)
|
||||
- 系统设置提供模型失败冷却分钟数配置
|
||||
- 系统设置展示运行时配置(只读),以卡片显示名称 + 环境变量名 + 当前值,并提示在部署环境中修改环境变量
|
||||
- 系统设置提供运行时配置可编辑(上游超时、流式解析模式/上限、队列开关/日限额/直写比例)
|
||||
- 系统设置展示运行时配置(只读),以卡片显示名称 + 环境变量名 + 当前值,并提示设置优先生效
|
||||
- 系统设置提供缓存开关与 TTL(面板/日志/模型/鉴权/渠道/调用令牌/设置)配置
|
||||
- 系统设置展示缓存版本号并提供刷新入口
|
||||
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数(无数据时显示 0 页)
|
||||
- 每页条数选择与列显示偏好本地持久化
|
||||
- 页面布局拆分为“标题区 + 筛选区 + 数据区”,避免单一大卡片堆叠
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- 可选筛选:`channel_ids` / `token_ids` / `model`
|
||||
- 管理台默认展示全量时间范围,并在趋势卡片内提供统计颗粒(按日/周/月)
|
||||
- 统计颗粒与时间范围在前端本地存储并优先复用
|
||||
- `GET /api/dashboard` 支持短时缓存(TTL 可配置,版本化失效)
|
||||
|
||||
## 依赖关系
|
||||
- `usage_logs` 表
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- 使用“模型广场测试结果”作为可用模型来源
|
||||
- 模型归一化支持字符串数组与对象数组(对象取 `id` 字段)
|
||||
- 管理台模型广场支持搜索、渠道筛选与分页展示
|
||||
- `GET /api/models` 支持短时缓存(TTL 可配置,版本化失效)
|
||||
|
||||
## 依赖关系
|
||||
- `channels` 表
|
||||
|
||||
@@ -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` 表示无限制
|
||||
|
||||
|
||||
@@ -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` 表
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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(" ");
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -144,3 +144,4 @@ export default {
|
||||
};
|
||||
|
||||
export { CheckinScheduler } from "./services/checkin-scheduler";
|
||||
export { UsageLimiter } from "./services/usage-limiter";
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user