feat: 引入 usage 队列与本地部署脚本

- usage 队列异步写入与流式解析限流
- 管理台展示运行时配置
- 本地 dev/部署脚本与 CI 队列创建
This commit is contained in:
lsy committed 2026-03-16 22:13:09 +08:00
1 parent e12d38f384
commit d205289f9f
27 files changed
+1338 -291

No files matched your search

+34
View File
@@ -260,6 +260,23 @@ jobs:
if: steps.check-db.outputs.db_exists != 'true'
run: bunx wrangler telemetry disable
- name: Create or verify Queue
if: steps.check-db.outputs.db_exists != 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
echo "检查并创建 Queue..."
QUEUE_LIST=$(bunx wrangler queues list --json --config apps/worker/wrangler.toml 2>/dev/null || echo "[]")
EXISTING_QUEUE=$(echo "$QUEUE_LIST" | jq -r '.[] | select(.name=="usage-events") | .name')
if [ -n "$EXISTING_QUEUE" ]; then
echo "✅ Queue 已存在: usage-events"
else
echo "创建 Queue: usage-events"
bunx wrangler queues create usage-events --config apps/worker/wrangler.toml
fi
- name: Create D1 database (init only)
if: steps.check-db.outputs.db_exists != 'true'
env:
@@ -359,6 +376,23 @@ jobs:
- name: Disable wrangler telemetry
run: bunx wrangler telemetry disable
- name: Create or verify Queue
if: needs.check-config.outputs.deploy_worker == 'true' || needs.check-config.outputs.should_migrate == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
echo "检查并创建 Queue..."
QUEUE_LIST=$(bunx wrangler queues list --json --config apps/worker/wrangler.toml 2>/dev/null || echo "[]")
EXISTING_QUEUE=$(echo "$QUEUE_LIST" | jq -r '.[] | select(.name=="usage-events") | .name')
if [ -n "$EXISTING_QUEUE" ]; then
echo "✅ Queue 已存在: usage-events"
else
echo "创建 Queue: usage-events"
bunx wrangler queues create usage-events --config apps/worker/wrangler.toml
fi
- name: Create or verify D1 Database
if: needs.check-config.outputs.deploy_worker == 'true' || needs.check-config.outputs.deploy_ui == 'true' || needs.check-config.outputs.should_migrate == 'true'
env:
+54
View File
@@ -2,8 +2,20 @@
## [Unreleased]
### 新增
- **[deploy-workflow]**: 新增本地一键部署脚本(init/update) — by lsy
- 方案: [202603161914_local-deploy-script](archive/2026-03/202603161914_local-deploy-script/)
### 变更
- **[deploy-workflow]**: 本地部署脚本改为纯本地流程(构建 + 本地迁移) — by lsy
- 方案: [202603162008_local-deploy-local-only](archive/2026-03/202603162008_local-deploy-local-only/)
- **[settings/admin-ui]**: 设置接口返回运行时配置,管理台只读展示并指引环境变量配置
- 方案: [202603161748_settings-runtime-env-display](archive/2026-03/202603161748_settings-runtime-env-display/)
- **[deploy-workflow]**: 部署流程自动创建 `usage-events` Queue(存在则跳过) — by lsy
- 方案: [202603161714_deploy-create-queue](archive/2026-03/202603161714_deploy-create-queue/)
- 决策: deploy-create-queue#D001(CI 自动创建队列)
- **[admin-ui/dashboard]**: Dashboard 样板页引入 Apple 风主题(bento grid 卡片/表格/按钮重绘)
- 方案: [202603152243_apple-ui-sample-dashboard](archive/2026-03/202603152243_apple-ui-sample-dashboard/)
- 文件: apps/ui/src/features/DashboardView.tsx, apps/ui/src/styles.css, .helloagents/modules/admin-ui.md, .helloagents/modules/dashboard.md
@@ -20,6 +32,8 @@
### 微调
- **[proxy/settings]**: 流式 usage 解析上限支持 `0` 表示无限制 — by lsy
- 方案: [202603161849_stream-usage-unlimited](archive/2026-03/202603161849_stream-usage-unlimited/)
- **[worker/models]**: 引入通道模型能力表并基于“模型广场测试结果+可配置TTL(默认2小时)”进行模型发布与路由匹配
- 类型: 微调(无方案包)
- 文件: apps/worker/migrations/0004_add_channel_model_capabilities.sql, apps/worker/src/services/channel-model-capabilities.ts, apps/worker/src/services/channel-testing.ts, apps/worker/src/services/settings.ts, apps/worker/src/routes/settings.ts, apps/worker/src/routes/models.ts, apps/worker/src/routes/newapiUsers.ts, apps/worker/src/routes/proxy.ts, tests/worker/channel-model-capabilities.test.ts, tests/worker/newapi.test.ts, helloagents/modules/settings.md, helloagents/modules/models.md, helloagents/modules/proxy.md
@@ -180,6 +194,46 @@
- 方案: [202603160049_modal-toast-search-fixes](archive/2026-03/202603160049_modal-toast-search-fixes/)
- 决策: modal-toast-search-fixes#D001(通知统一为右上 Toast)
### 快速修改
- **[tooling]**: dev 脚本适配 bun 可执行路径解析 — by lsy
- 类型: 快速修改(无方案包)
- 文件: scripts/dev.mjs
- **[deploy-workflow]**: 本地部署脚本适配 bun 可执行路径解析 — by lsy
- 类型: 快速修改(无方案包)
- 文件: scripts/deploy.mjs
- **[tooling]**: 将 `dev:all` 简化为 `dev` 脚本 — by lsy
- 类型: 快速修改(无方案包)
- 文件: package.json, README.md
- **[deploy-workflow]**: 缺少环境变量时 .env 优先从 .env.example 生成 — by lsy
- 类型: 快速修改(无方案包)
- 文件: scripts/deploy.mjs, .helloagents/modules/deploy-workflow.md
- **[deploy-workflow]**: .env.example 注释改为中文并补充默认代理配置 — by lsy
- 类型: 快速修改(无方案包)
- 文件: .env.example
- **[deploy-workflow]**: 本地部署脚本补充 .env 占位与 .env.example 模板 — by lsy
- 类型: 快速修改(无方案包)
- 文件: scripts/deploy.mjs, .env.example, .helloagents/modules/deploy-workflow.md, README.md
- **[deploy-workflow]**: 本地部署脚本未传参时支持交互选择 — by lsy
- 类型: 快速修改(无方案包)
- 文件: scripts/deploy.mjs, README.md, .helloagents/modules/deploy-workflow.md
- **[deploy-workflow]**: 增加本地部署脚本的 package scripts 快捷入口 — by lsy
- 类型: 快速修改(无方案包)
- 文件: package.json, README.md
- **[docs]**: README 补充系统设置的运行时环境变量说明
- 类型: 快速修改(无方案包)
- 文件: README.md
- **[admin-ui]**: 加深弹窗遮罩并支持多条通知弹窗,使用日志表头遮罩更清晰 — by lsy
- 类型: 快速修改(无方案包)
- 文件: apps/ui/src/App.tsx:129-220,1357-1510, apps/ui/src/features/AppLayout.tsx:1-195, apps/ui/src/features/SitesView.tsx:582, apps/ui/src/features/TokensView.tsx:415, apps/ui/src/features/UsageView.tsx:209-219,377, apps/ui/src/styles.css:424-440
## [0.9.7] - 2026-03-16
### 变更
- **[proxy/usage]**: 引入 usage 队列异步写入与流式解析降载,增加 CPU 降载开关 — by lsy
- 方案: [202603161542_proxy-cpu-limit-mitigation](archive/2026-03/202603161542_proxy-cpu-limit-mitigation/)
- 决策: proxy-cpu-limit-mitigation#D001(用量写入异步化策略)
## [0.9.6] - 2026-03-15
### 修复
+1
View File
@@ -33,6 +33,7 @@
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
- 系统设置增加定时签到开关与时间(中国时间)
- 系统设置提供模型失败冷却分钟数配置
- 系统设置展示运行时配置(只读),提示在部署环境中修改环境变量
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择
- Token 仅本地保存,不回显
- 移动端使用顶部栏 + 抽屉导航,侧边栏在桌面端保持固定
+12
View File
@@ -16,6 +16,18 @@ GitHub Actions 部署流程,负责区分 update/init 动作,控制 UI/Worker
**行为**: 按变更范围选择前端/后端部署,必要时执行迁移
**结果**: 日常更新发布
### 队列自动创建
**条件**: 部署 Worker 或执行迁移
**行为**: 检查 `usage-events` Queue 是否存在,不存在则创建
**结果**: 队列资源已就绪,避免部署后运行失败
### 本地部署脚本
**条件**: 本地执行 `scripts/deploy.mjs`
**行为**: 执行本地流程(构建 UI、本地迁移)
**结果**: 支持一键初始化与更新部署(不触发远程部署)
**补充**: 未指定 action 时进入交互选择(init/update、target、migrate)
**补充**: `.env` 不存在时自动从 `.env.example` 生成(或创建空文件)
## 依赖关系
```yaml
+5
View File
@@ -19,9 +19,12 @@
- done-hub 仅使用 `base_url`,不再按多地址切换
- 支持从非流式 JSON、响应头与流式 SSE(含 `response.usage` 与 `usageMetadata`)解析 usage 字段
- 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage
- 流式 usage 解析支持 `full/lite/off` 模式(`lite` 仅解析包含 usage 的事件,并可限制最大扫描字节)
- 对 `/v1/responses` 且上游返回 400/404 时回退为 `/responses` 重试一次
- 可配置失败重试轮询(响应 5xx/429 时触发)
- 记录流式请求标记、首 token 延迟与推理强度到 usage_logs
- 使用日志与模型能力写入可通过 `USAGE_QUEUE` 异步化(队列不可用时自动回退同步写入)
- 当 usage 解析被跳过或超限时,会记录 `usage_skipped/usage_missing` 用于可观测性
- 上游类型由 `metadata_json.site_type` 决定(`openai/anthropic/gemini`,其他类型视为 openai 兼容)
- 允许 `metadata_json.model_mapping` 将下游模型映射为上游模型
- 允许 `metadata_json.endpoint_overrides` 覆盖 chat/embedding/image 上游地址(可用 `{model}` 占位)
@@ -34,6 +37,8 @@
- 仅 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`
- `PROXY_STREAM_USAGE_MAX_BYTES` 与 `PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制(未配置则使用默认值)
## 依赖关系
- `channels` / `tokens` / `usage_logs` 表
+3
View File
@@ -17,6 +17,9 @@
- 更新签到时间后会触发 `CheckinScheduler` 重新调度
- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到
- 模型失败冷却以分钟计(默认 10 分钟),用于跳过最近失败的渠道/模型
- 代理 CPU 降载相关开关使用环境变量(非 DB 设置)
- `GET /api/settings` 额外返回 `runtime_config`(环境变量读取结果),用于展示流式 usage 解析与队列状态
- 运行时配置中 `PROXY_STREAM_USAGE_MAX_BYTES/PROXY_STREAM_USAGE_MAX_PARSERS` 设为 `0` 表示无限制
## 依赖关系
- `settings` 表
+2
View File
@@ -10,6 +10,8 @@
## 行为规范
- 代理调用统一写入 `usage_logs`(含无可用渠道/冷却提前返回)
- 使用日志可通过 `USAGE_QUEUE` 异步写入(可能存在短暂延迟)
- 流式 usage 解析被跳过或超限时会记录 `usage_skipped/usage_missing` 以便排查
- 保留天数可配置,查询时触发清理
- 查询结果附带渠道与令牌名称
- `GET /api/usage` 支持 `offset/limit` 分页并返回 `total`
+34
View File
@@ -86,6 +86,7 @@ bun run dev:worker
- 日志保留天数(默认 30)
- 会话时长(小时,默认 12)
- 管理员密码(首次登录在登录页输入密码将自动设置,可在系统设置中修改)
- 运行时配置(只读,来自环境变量):`PROXY_STREAM_USAGE_MODE`、`PROXY_STREAM_USAGE_MAX_BYTES`、`PROXY_STREAM_USAGE_MAX_PARSERS`、`PROXY_USAGE_QUEUE_ENABLED`(需在部署环境中修改)
### 3) 启动管理台 UI
@@ -114,6 +115,39 @@ bun run --filter api-worker db:migrate
- `bun run typecheck`
- `bun run lint`
- `bun run format`
- `bun run dev` 同时启动 Worker 与 UI
## 本地一键部署脚本
脚本位置:`scripts/deploy.mjs`,用于在本地复刻 GitHub Actions 的 init/update 部署流程。
前置环境:
- Bun(确保 `bun`/`bunx` 可用)
- Wrangler(通过 `bunx wrangler` 使用)
- `.env.example` 可作为本地环境变量模板(可选)
常用命令:
```bash
node scripts/deploy.mjs init
node scripts/deploy.mjs update --target auto --migrate auto
```
或使用 package scripts:
```bash
bun run deploy:init
bun run deploy:update -- --target auto --migrate auto
```
说明:
- 该脚本仅执行本地流程(构建 + 本地迁移),不会触发远程部署
- `init` 会强制执行本地迁移
- `update` 默认按变更范围决定是否迁移(无法检测时会默认迁移)
- `bun run deploy` 会进入交互选择(init/update、部署目标、迁移策略)
- 若 `.env` 不存在,会自动从 `.env.example` 生成(或创建空文件)
## API 接口
+45 -15
View File
@@ -126,7 +126,7 @@ const App = () => {
return pathToTab[normalized] ?? "dashboard";
});
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<NoticeMessage | null>(null);
const [notices, setNotices] = useState<NoticeMessage[]>([]);
const [data, setData] = useState<AdminData>(initialData);
const [settingsForm, setSettingsForm] =
useState<SettingsForm>(initialSettingsForm);
@@ -159,6 +159,7 @@ 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 [confirmState, setConfirmState] = useState<ConfirmState | null>(null);
const [confirmPending, setConfirmPending] = useState(false);
@@ -173,25 +174,52 @@ const App = () => {
const pushNotice = useCallback(
(tone: NoticeTone, message: string, durationMs?: number) => {
setNotice({ tone, message, id: Date.now(), durationMs });
setNotices((prev) => [
...prev,
{ tone, message, id: Date.now() + Math.random(), durationMs },
]);
},
[],
);
const dismissNotice = useCallback(() => {
setNotice(null);
const dismissNotice = useCallback((id?: number) => {
setNotices((prev) => {
if (id === undefined) {
return [];
}
return prev.filter((item) => item.id !== id);
});
}, []);
useEffect(() => {
if (!notice) {
return;
const timers = noticeTimersRef.current;
const activeIds = new Set(notices.map((item) => item.id));
for (const [id, timer] of timers) {
if (!activeIds.has(id)) {
window.clearTimeout(timer);
timers.delete(id);
}
}
const durationMs = notice.durationMs ?? 4500;
const timer = window.setTimeout(() => {
setNotice(null);
}, durationMs);
return () => window.clearTimeout(timer);
}, [notice]);
for (const notice of notices) {
if (timers.has(notice.id)) {
continue;
}
const durationMs = notice.durationMs ?? 4500;
const timer = window.setTimeout(() => {
dismissNotice(notice.id);
}, durationMs);
timers.set(notice.id, timer);
}
}, [dismissNotice, notices]);
useEffect(() => {
return () => {
for (const timer of noticeTimersRef.current.values()) {
window.clearTimeout(timer);
}
noticeTimersRef.current.clear();
};
}, []);
const startAction = useCallback((key: string) => {
if (pendingActionsRef.current.has(key)) {
@@ -1326,6 +1354,7 @@ const App = () => {
() => tabs.find((tab) => tab.id === activeTab)?.label ?? "管理台",
[activeTab],
);
const loginNotice = notices[notices.length - 1] ?? null;
const renderContent = () => {
if (loading) {
@@ -1440,6 +1469,7 @@ const App = () => {
<SettingsView
settingsForm={settingsForm}
adminPasswordSet={data.settings?.admin_password_set ?? false}
runtimeConfig={data.settings?.runtime_config ?? null}
isSaving={isActionPending(buildActionKey("settings:submit"))}
onSubmit={handleSettingsSubmit}
onFormChange={handleSettingsFormChange}
@@ -1458,7 +1488,7 @@ const App = () => {
activeTab={activeTab}
activeLabel={activeLabel}
token={token}
notice={notice}
notices={notices}
onDismissNotice={dismissNotice}
onTabChange={handleTabChange}
onLogout={handleLogout}
@@ -1468,7 +1498,7 @@ const App = () => {
) : (
<LoginView
isSubmitting={isActionPending(buildActionKey("login:submit"))}
notice={notice}
notice={loginNotice}
onSubmit={handleLogin}
/>
)}
@@ -1476,7 +1506,7 @@ const App = () => {
<div class="fixed inset-0 z-50">
<button
aria-label="关闭弹窗"
class="absolute inset-0 bg-slate-950/40"
class="absolute inset-0 bg-slate-950/60"
type="button"
onClick={closeConfirm}
/>
+10
View File
@@ -101,6 +101,16 @@ export type Settings = {
admin_password_set?: boolean;
checkin_schedule_time?: string;
model_failure_cooldown_minutes?: number;
runtime_config?: RuntimeProxyConfig;
};
export type RuntimeProxyConfig = {
stream_usage_mode: string;
stream_usage_max_bytes: number;
stream_usage_max_parsers: number;
usage_queue_enabled: boolean;
usage_queue_bound: boolean;
usage_queue_active: boolean;
};
export type ModelChannel = {
+28 -27
View File
@@ -5,8 +5,8 @@ type AppLayoutProps = {
activeTab: TabId;
activeLabel: string;
token: string | null;
notice: NoticeMessage | null;
onDismissNotice: () => void;
notices: NoticeMessage[];
onDismissNotice: (id: number) => void;
onTabChange: (tabId: TabId) => void;
onLogout: () => void;
children?: unknown;
@@ -26,7 +26,7 @@ export const AppLayout = ({
activeTab,
activeLabel,
token,
notice,
notices,
onDismissNotice,
onTabChange,
onLogout,
@@ -44,7 +44,6 @@ export const AppLayout = ({
error: "错误",
info: "信息",
};
const noticeDuration = notice?.durationMs ?? 4500;
const closeMobileNav = () => {
const toggle = document.querySelector<HTMLInputElement>("#app-nav-toggle");
if (toggle) {
@@ -163,32 +162,34 @@ export const AppLayout = ({
</div>
{children}
</main>
{notice && (
<output
aria-live="polite"
class="app-toast"
style={`--toast-duration: ${noticeDuration}ms`}
>
<div class={`app-toast-card ${noticeToneStyles[notice.tone]}`}>
<div class="flex items-start justify-between gap-3">
<div>
<span class="app-chip text-[10px]">
{noticeToneLabel[notice.tone]}
</span>
<div class="mt-1 text-sm font-semibold text-[color:var(--app-ink)]">
{notice.message}
{notices.length > 0 && (
<output aria-live="polite" class="app-toast">
{notices.map((notice) => (
<div
class={`app-toast-card ${noticeToneStyles[notice.tone]}`}
key={notice.id}
style={`--toast-duration: ${notice.durationMs ?? 4500}ms`}
>
<div class="flex items-start justify-between gap-3">
<div>
<span class="app-chip text-[10px]">
{noticeToneLabel[notice.tone]}
</span>
<div class="mt-1 text-sm font-semibold text-[color:var(--app-ink)]">
{notice.message}
</div>
</div>
<button
class="app-button app-focus h-8 px-3 text-[11px]"
type="button"
onClick={() => onDismissNotice(notice.id)}
>
关闭
</button>
</div>
<button
class="app-button app-focus h-8 px-3 text-[11px]"
type="button"
onClick={onDismissNotice}
>
关闭
</button>
<span aria-hidden="true" class="app-toast-progress" />
</div>
<span aria-hidden="true" class="app-toast-progress" />
</div>
))}
</output>
)}
<button
+188 -137
View File
@@ -1,9 +1,10 @@
import type { SettingsForm } from "../core/types";
import type { RuntimeProxyConfig, SettingsForm } from "../core/types";
type SettingsViewProps = {
settingsForm: SettingsForm;
adminPasswordSet: boolean;
isSaving: boolean;
runtimeConfig?: RuntimeProxyConfig | null;
onSubmit: (event: Event) => void;
onFormChange: (patch: Partial<SettingsForm>) => void;
};
@@ -21,142 +22,192 @@ export const SettingsView = ({
settingsForm,
adminPasswordSet,
isSaving,
runtimeConfig,
onSubmit,
onFormChange,
}: SettingsViewProps) => (
<div class="app-card animate-fade-up p-5">
<div class="mb-4 flex items-center justify-between">
<h3 class="app-title text-lg">系统设置</h3>
}: SettingsViewProps) => {
const runtimeItems = [
{
label: "流式 usage 解析模式",
value: runtimeConfig?.stream_usage_mode ?? "lite",
},
{
label: "流式解析最大字节",
value: runtimeConfig?.stream_usage_max_bytes ?? 262144,
},
{
label: "流式解析并发上限",
value: runtimeConfig?.stream_usage_max_parsers ?? 2,
},
{
label: "队列开关",
value: runtimeConfig?.usage_queue_enabled ? "启用" : "关闭",
},
{
label: "队列绑定",
value: runtimeConfig?.usage_queue_bound ? "已绑定" : "未绑定",
},
{
label: "队列实际生效",
value: runtimeConfig?.usage_queue_active ? "是" : "否",
},
];
return (
<div class="app-card animate-fade-up p-5">
<div class="mb-4 flex items-center justify-between">
<h3 class="app-title text-lg">系统设置</h3>
</div>
<form class="grid gap-3.5 lg:grid-cols-2" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="retention"
>
日志保留天数
</label>
<input
class="app-input app-focus"
id="retention"
name="log_retention_days"
type="number"
min="1"
value={settingsForm.log_retention_days}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
log_retention_days: target?.value ?? "",
});
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="session-ttl"
>
会话时长(小时)
</label>
<input
class="app-input app-focus"
id="session-ttl"
name="session_ttl_hours"
type="number"
min="1"
value={settingsForm.session_ttl_hours}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
session_ttl_hours: target?.value ?? "",
});
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="checkin-schedule-time"
>
签到时间(中国时间)
</label>
<input
class="app-input app-focus"
id="checkin-schedule-time"
name="checkin_schedule_time"
type="time"
value={settingsForm.checkin_schedule_time}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
checkin_schedule_time: target?.value ?? "",
});
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="failure-cooldown"
>
失败冷却(分钟)
</label>
<input
class="app-input app-focus"
id="failure-cooldown"
name="model_failure_cooldown_minutes"
type="number"
min="1"
value={settingsForm.model_failure_cooldown_minutes}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
model_failure_cooldown_minutes: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
同一模型失败后在该时间内跳过对应渠道。
</p>
</div>
<div class="lg:col-span-2">
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="admin-password"
>
管理员密码
</label>
<input
class="app-input app-focus"
id="admin-password"
name="admin_password"
type="password"
placeholder={
adminPasswordSet
? "已设置,留空则不修改"
: "未设置,保存后即为登录密码"
}
value={settingsForm.admin_password}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
admin_password: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
密码状态:{adminPasswordSet ? "已设置" : "未设置"}
</p>
</div>
<div class="flex items-end lg:col-span-2">
<button
class="app-button app-button-primary app-focus h-11 px-4 text-sm"
type="submit"
disabled={isSaving}
>
{isSaving ? "保存中..." : "保存设置"}
</button>
</div>
</form>
<div class="mt-6 border-t border-white/70 pt-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) => (
<div class="flex items-center justify-between rounded-xl bg-white/70 px-3 py-2 text-sm">
<span class="text-[color:var(--app-ink-muted)]">{item.label}</span>
<span class="font-semibold">{item.value}</span>
</div>
))}
</div>
<div class="mt-3 text-xs text-[color:var(--app-ink-muted)]">
<span class="font-semibold">环境变量:</span> PROXY_STREAM_USAGE_MODE,
PROXY_STREAM_USAGE_MAX_BYTES, PROXY_STREAM_USAGE_MAX_PARSERS,
PROXY_USAGE_QUEUE_ENABLED
</div>
</div>
</div>
<form class="grid gap-3.5 lg:grid-cols-2" onSubmit={onSubmit}>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="retention"
>
日志保留天数
</label>
<input
class="app-input app-focus"
id="retention"
name="log_retention_days"
type="number"
min="1"
value={settingsForm.log_retention_days}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
log_retention_days: target?.value ?? "",
});
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="session-ttl"
>
会话时长(小时)
</label>
<input
class="app-input app-focus"
id="session-ttl"
name="session_ttl_hours"
type="number"
min="1"
value={settingsForm.session_ttl_hours}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
session_ttl_hours: target?.value ?? "",
});
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="checkin-schedule-time"
>
签到时间(中国时间)
</label>
<input
class="app-input app-focus"
id="checkin-schedule-time"
name="checkin_schedule_time"
type="time"
value={settingsForm.checkin_schedule_time}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
checkin_schedule_time: target?.value ?? "",
});
}}
/>
</div>
<div>
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="failure-cooldown"
>
失败冷却(分钟)
</label>
<input
class="app-input app-focus"
id="failure-cooldown"
name="model_failure_cooldown_minutes"
type="number"
min="1"
value={settingsForm.model_failure_cooldown_minutes}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
model_failure_cooldown_minutes: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
同一模型失败后在该时间内跳过对应渠道。
</p>
</div>
<div class="lg:col-span-2">
<label
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
for="admin-password"
>
管理员密码
</label>
<input
class="app-input app-focus"
id="admin-password"
name="admin_password"
type="password"
placeholder={
adminPasswordSet
? "已设置,留空则不修改"
: "未设置,保存后即为登录密码"
}
value={settingsForm.admin_password}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement | null;
onFormChange({
admin_password: target?.value ?? "",
});
}}
/>
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
密码状态:{adminPasswordSet ? "已设置" : "未设置"}
</p>
</div>
<div class="flex items-end lg:col-span-2">
<button
class="app-button app-button-primary app-focus h-11 px-4 text-sm"
type="submit"
disabled={isSaving}
>
{isSaving ? "保存中..." : "保存设置"}
</button>
</div>
</form>
</div>
);
);
};
+1 -1
View File
@@ -579,7 +579,7 @@ export const SitesView = ({
<div class="fixed inset-0 z-50">
<button
aria-label="关闭弹窗"
class="absolute inset-0 bg-slate-950/40"
class="absolute inset-0 bg-slate-950/60"
type="button"
onClick={onCloseModal}
/>
+1 -1
View File
@@ -412,7 +412,7 @@ export const TokensView = ({
<div class="fixed inset-0 z-50">
<button
aria-label="关闭弹窗"
class="absolute inset-0 bg-slate-950/40"
class="absolute inset-0 bg-slate-950/60"
type="button"
onClick={onCloseModal}
/>
+12 -12
View File
@@ -206,17 +206,17 @@ export const UsageView = ({
<table class="app-table min-w-[960px] w-full text-xs sm:text-sm">
<thead>
<tr>
<th class="sticky top-0 bg-white/70">时间</th>
<th class="sticky top-0 bg-white/70">模型</th>
<th class="sticky top-0 bg-white/70">渠道</th>
<th class="sticky top-0 bg-white/70">令牌</th>
<th class="sticky top-0 bg-white/70">输入 Tokens</th>
<th class="sticky top-0 bg-white/70">输出 Tokens</th>
<th class="sticky top-0 bg-white/70">用时 (s)</th>
<th class="sticky top-0 bg-white/70">首 token 延迟 (s)</th>
<th class="sticky top-0 bg-white/70">流式</th>
<th class="sticky top-0 bg-white/70">推理强度</th>
<th class="sticky top-0 bg-white/70">状态码</th>
<th class="sticky top-0 bg-slate-100/90">时间</th>
<th class="sticky top-0 bg-slate-100/90">模型</th>
<th class="sticky top-0 bg-slate-100/90">渠道</th>
<th class="sticky top-0 bg-slate-100/90">令牌</th>
<th class="sticky top-0 bg-slate-100/90">输入 Tokens</th>
<th class="sticky top-0 bg-slate-100/90">输出 Tokens</th>
<th class="sticky top-0 bg-slate-100/90">用时 (s)</th>
<th class="sticky top-0 bg-slate-100/90">首 token 延迟 (s)</th>
<th class="sticky top-0 bg-slate-100/90">流式</th>
<th class="sticky top-0 bg-slate-100/90">推理强度</th>
<th class="sticky top-0 bg-slate-100/90">状态码</th>
</tr>
</thead>
<tbody>
@@ -374,7 +374,7 @@ export const UsageView = ({
<div class="fixed inset-0 z-50">
<button
aria-label="关闭弹窗"
class="absolute inset-0 bg-slate-950/40"
class="absolute inset-0 bg-slate-950/60"
type="button"
onClick={closeErrorModal}
/>
+5
View File
@@ -427,12 +427,17 @@ body {
right: 24px;
z-index: 60;
width: min(360px, calc(100% - 48px));
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
}
.app-toast-card {
position: relative;
overflow: hidden;
box-shadow: var(--app-shadow);
pointer-events: auto;
}
.app-toast-progress {
+7
View File
@@ -1,12 +1,19 @@
import type {
D1Database,
DurableObjectNamespace,
Queue,
} from "@cloudflare/workers-types";
import type { UsageQueueEvent } from "./services/usage-queue";
export type Bindings = {
DB: D1Database;
CORS_ORIGIN?: 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?: string;
USAGE_QUEUE?: Queue<UsageQueueEvent>;
CHECKIN_SCHEDULER: DurableObjectNamespace;
};
+5 -1
View File
@@ -14,6 +14,7 @@ import settingsRoutes from "./routes/settings";
import siteRoutes from "./routes/sites";
import tokenRoutes from "./routes/tokens";
import usageRoutes from "./routes/usage";
import { handleUsageQueue } from "./services/usage-queue";
const app = new Hono<AppEnv>({ strict: false });
app.use(
@@ -137,6 +138,9 @@ app.notFound(async (c) => {
return assets.fetch(new Request(url.toString(), c.req.raw));
});
export default app;
export default {
fetch: app.fetch,
queue: handleUsageQueue,
};
export { CheckinScheduler } from "./services/checkin-scheduler";
+196 -97
View File
@@ -19,8 +19,6 @@ import {
import {
listCoolingDownChannelsForModel,
listVerifiedModelsByChannel,
recordChannelModelError,
upsertChannelModelCapabilities,
} from "../services/channel-model-capabilities";
import {
getModelFailureCooldownMinutes,
@@ -42,7 +40,7 @@ import {
parseDownstreamModel,
parseDownstreamStream,
} from "../services/provider-transform";
import { recordUsage } from "../services/usage";
import { processUsageQueueEvent, type UsageQueueEvent } from "../services/usage-queue";
import { jsonError } from "../utils/http";
import { safeJsonParse } from "../utils/json";
import { extractReasoningEffort } from "../utils/reasoning";
@@ -52,6 +50,8 @@ import {
parseUsageFromHeaders,
parseUsageFromJson,
parseUsageFromSse,
type StreamUsageMode,
type StreamUsageOptions,
} from "../utils/usage";
import { adaptChatResponse } from "../services/chat-response-adapter";
@@ -80,6 +80,12 @@ type ErrorDetails = {
const FAILURE_COUNT_THRESHOLD = 2;
const STREAM_USAGE_DEFAULT_MODE: StreamUsageMode = "lite";
const STREAM_USAGE_DEFAULT_MAX_BYTES = 256 * 1024;
const STREAM_USAGE_DEFAULT_MAX_PARSERS = 2;
let activeStreamUsageParsers = 0;
function normalizeMessage(value: string | null): string | null {
if (!value) {
return null;
@@ -91,6 +97,73 @@ 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);
return {
mode: normalizeStreamUsageMode(env.PROXY_STREAM_USAGE_MODE),
maxBytes:
maxBytesRaw === null ? STREAM_USAGE_DEFAULT_MAX_BYTES : maxBytesRaw,
};
}
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) {
return Number.POSITIVE_INFINITY;
}
return maxParsersRaw;
}
function usageQueueEnabled(env: AppEnv["Bindings"]): boolean {
if (env.PROXY_USAGE_QUEUE_ENABLED === "false") {
return false;
}
return Boolean(env.USAGE_QUEUE);
}
function scheduleUsageEvent(
c: { env: AppEnv["Bindings"]; executionCtx?: ExecutionContextLike },
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]", {
error: error instanceof Error ? error.message : String(error),
});
return processUsageQueueEvent(c.env.DB, event);
});
scheduleDbWrite(c, task);
return;
}
scheduleDbWrite(c, processUsageQueueEvent(c.env.DB, event));
}
async function extractErrorDetails(
response: Response,
): Promise<{ errorCode: string | null; errorMessage: string | null }> {
@@ -424,9 +497,9 @@ proxy.all("/*", tokenAuth, async (c) => {
}) => {
const latencyMs = Date.now() - requestStart;
const errorMessage = options.message ?? options.code;
scheduleDbWrite(
c,
recordUsage(c.env.DB, {
scheduleUsageEvent(c, {
type: "usage",
payload: {
tokenId: tokenRecord.id,
channelId: null,
model: downstreamModel,
@@ -440,8 +513,8 @@ proxy.all("/*", tokenAuth, async (c) => {
upstreamStatus: options.status,
errorCode: options.code,
errorMessage,
}),
);
},
});
};
const channelResult = await c.env.DB.prepare(
@@ -809,15 +882,14 @@ proxy.all("/*", tokenAuth, async (c) => {
selectedUpstreamModel = upstreamModel;
lastErrorDetails = null;
if (recordModel) {
scheduleDbWrite(
c,
upsertChannelModelCapabilities(
c.env.DB,
channel.id,
[recordModel],
scheduleUsageEvent(c, {
type: "capability_upsert",
payload: {
channelId: channel.id,
models: [recordModel],
nowSeconds,
),
);
},
});
}
break;
}
@@ -832,16 +904,15 @@ proxy.all("/*", tokenAuth, async (c) => {
errorInfo.errorCode,
);
if (recordModel && cooldownSeconds > 0 && cooldownEligible) {
scheduleDbWrite(
c,
recordChannelModelError(
c.env.DB,
channel.id,
recordModel,
String(response.status),
scheduleUsageEvent(c, {
type: "model_error",
payload: {
channelId: channel.id,
model: recordModel,
errorCode: String(response.status),
nowSeconds,
),
);
},
});
}
if (
downstreamModel &&
@@ -849,16 +920,15 @@ proxy.all("/*", tokenAuth, async (c) => {
cooldownSeconds > 0 &&
cooldownEligible
) {
scheduleDbWrite(
c,
recordChannelModelError(
c.env.DB,
channel.id,
downstreamModel,
String(response.status),
scheduleUsageEvent(c, {
type: "model_error",
payload: {
channelId: channel.id,
model: downstreamModel,
errorCode: String(response.status),
nowSeconds,
),
);
},
});
}
} catch (error) {
const isTimeout =
@@ -886,16 +956,15 @@ proxy.all("/*", tokenAuth, async (c) => {
isTimeout ? "timeout" : "exception",
);
if (recordModel && cooldownSeconds > 0 && cooldownEligible) {
scheduleDbWrite(
c,
recordChannelModelError(
c.env.DB,
channel.id,
recordModel,
isTimeout ? "timeout" : "exception",
scheduleUsageEvent(c, {
type: "model_error",
payload: {
channelId: channel.id,
model: recordModel,
errorCode: isTimeout ? "timeout" : "exception",
nowSeconds,
),
);
},
});
}
if (
downstreamModel &&
@@ -903,16 +972,15 @@ proxy.all("/*", tokenAuth, async (c) => {
cooldownSeconds > 0 &&
cooldownEligible
) {
scheduleDbWrite(
c,
recordChannelModelError(
c.env.DB,
channel.id,
downstreamModel,
isTimeout ? "timeout" : "exception",
scheduleUsageEvent(c, {
type: "model_error",
payload: {
channelId: channel.id,
model: downstreamModel,
errorCode: isTimeout ? "timeout" : "exception",
nowSeconds,
),
);
},
});
}
lastResponse = null;
}
@@ -936,19 +1004,22 @@ proxy.all("/*", tokenAuth, async (c) => {
latency_ms: latencyMs,
last_channel_id: lastChannel?.id ?? null,
});
await recordUsage(c.env.DB, {
tokenId: tokenRecord.id,
model: downstreamModel,
requestPath: lastRequestPath,
totalTokens: 0,
latencyMs,
firstTokenLatencyMs: isStream ? null : latencyMs,
stream: isStream,
reasoningEffort,
status: "error",
upstreamStatus: lastErrorDetails?.upstreamStatus ?? null,
errorCode: lastErrorDetails?.errorCode ?? "upstream_unavailable",
errorMessage: lastErrorDetails?.errorMessage ?? "upstream_unavailable",
scheduleUsageEvent(c, {
type: "usage",
payload: {
tokenId: tokenRecord.id,
model: downstreamModel,
requestPath: lastRequestPath,
totalTokens: 0,
latencyMs,
firstTokenLatencyMs: isStream ? null : latencyMs,
stream: isStream,
reasoningEffort,
status: "error",
upstreamStatus: lastErrorDetails?.upstreamStatus ?? null,
errorCode: lastErrorDetails?.errorCode ?? "upstream_unavailable",
errorMessage: lastErrorDetails?.errorMessage ?? "upstream_unavailable",
},
});
return jsonError(c, 502, "upstream_unavailable", "upstream_unavailable");
}
@@ -966,6 +1037,7 @@ proxy.all("/*", tokenAuth, async (c) => {
const record = async (
usage: NormalizedUsage | null,
firstTokenLatencyMs?: number | null,
usageMissingReason?: string | null,
) => {
const normalized = usage ?? {
totalTokens: 0,
@@ -974,27 +1046,39 @@ proxy.all("/*", tokenAuth, async (c) => {
};
const resolvedFirstTokenLatencyMs =
firstTokenLatencyMs ?? (isStream ? null : latencyMs);
await recordUsage(c.env.DB, {
tokenId: tokenRecord.id,
channelId: channelForUsage.id,
model: downstreamModel,
requestPath: lastRequestPath,
totalTokens: normalized.totalTokens,
promptTokens: normalized.promptTokens,
completionTokens: normalized.completionTokens,
cost: 0,
latencyMs,
firstTokenLatencyMs: resolvedFirstTokenLatencyMs,
stream: isStream,
reasoningEffort,
status: lastResponse.ok ? "ok" : "error",
upstreamStatus: lastResponse.ok
? lastResponse.status
: errorDetails?.upstreamStatus ?? lastResponse.status,
errorCode: lastResponse.ok ? null : errorDetails?.errorCode ?? null,
errorMessage: lastResponse.ok
? null
: errorDetails?.errorMessage ?? null,
const usageMissing = !usage;
const errorCode = lastResponse.ok
? usageMissing
? usageMissingReason ?? "usage_missing"
: null
: errorDetails?.errorCode ?? null;
const errorMessage = lastResponse.ok
? usageMissing
? usageMissingReason ?? "usage_missing"
: null
: errorDetails?.errorMessage ?? null;
scheduleUsageEvent(c, {
type: "usage",
payload: {
tokenId: tokenRecord.id,
channelId: channelForUsage.id,
model: downstreamModel,
requestPath: lastRequestPath,
totalTokens: normalized.totalTokens,
promptTokens: normalized.promptTokens,
completionTokens: normalized.completionTokens,
cost: 0,
latencyMs,
firstTokenLatencyMs: resolvedFirstTokenLatencyMs,
stream: isStream,
reasoningEffort,
status: lastResponse.ok ? "ok" : "error",
upstreamStatus: lastResponse.ok
? lastResponse.status
: errorDetails?.upstreamStatus ?? lastResponse.status,
errorCode,
errorMessage,
},
});
};
const headerUsage = parseUsageFromHeaders(lastResponse.headers);
@@ -1012,22 +1096,37 @@ proxy.all("/*", tokenAuth, async (c) => {
}
const immediateUsage = jsonUsage ?? headerUsage;
if (isStream) {
if (isStream && lastResponse.ok) {
const executionCtx = (c as { executionCtx?: ExecutionContextLike })
.executionCtx;
const task = parseUsageFromSse(lastResponse.clone())
.then((streamUsage) => {
const usageValue = immediateUsage ?? streamUsage.usage;
return record(usageValue, streamUsage.firstTokenLatencyMs);
})
.catch(() => undefined);
if (executionCtx?.waitUntil) {
executionCtx.waitUntil(task);
const streamUsageOptions = getStreamUsageOptions(c.env);
const streamUsageMaxParsers = getStreamUsageMaxParsers(c.env);
const canParseStream =
streamUsageOptions.mode !== "off" &&
activeStreamUsageParsers < streamUsageMaxParsers;
if (!canParseStream) {
await record(immediateUsage, null, "usage_skipped");
} else {
task.catch(() => undefined);
activeStreamUsageParsers += 1;
const task = parseUsageFromSse(lastResponse.clone(), streamUsageOptions)
.then((streamUsage) => {
const usageValue = immediateUsage ?? streamUsage.usage;
const reason =
usageValue ? null : "usage_missing";
return record(usageValue, streamUsage.firstTokenLatencyMs, reason);
})
.catch(() => undefined)
.finally(() => {
activeStreamUsageParsers = Math.max(0, activeStreamUsageParsers - 1);
});
if (executionCtx?.waitUntil) {
executionCtx.waitUntil(task);
} else {
task.catch(() => undefined);
}
}
} else {
await record(immediateUsage, latencyMs);
await record(immediateUsage, latencyMs, immediateUsage ? null : "usage_missing");
}
}
+2
View File
@@ -9,6 +9,7 @@ import {
getRetentionDays,
getSessionTtlHours,
getModelFailureCooldownMinutes,
getRuntimeProxyConfig,
isAdminPasswordSet,
setAdminPasswordHash,
setCheckinScheduleTime,
@@ -38,6 +39,7 @@ settings.get("/", async (c) => {
admin_password_set: adminPasswordSet,
checkin_schedule_time: checkinScheduleTime,
model_failure_cooldown_minutes: modelFailureCooldownMinutes,
runtime_config: getRuntimeProxyConfig(c.env),
});
});
+68
View File
@@ -1,16 +1,29 @@
import type { D1Database } from "@cloudflare/workers-types";
import type { Bindings } from "../env";
import { nowIso } from "../utils/time";
const DEFAULT_LOG_RETENTION_DAYS = 30;
const DEFAULT_SESSION_TTL_HOURS = 12;
const DEFAULT_CHECKIN_SCHEDULE_TIME = "00:10";
const DEFAULT_MODEL_FAILURE_COOLDOWN_MINUTES = 10;
const DEFAULT_PROXY_STREAM_USAGE_MODE = "lite";
const DEFAULT_PROXY_STREAM_USAGE_MAX_BYTES = 256 * 1024;
const DEFAULT_PROXY_STREAM_USAGE_MAX_PARSERS = 2;
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";
export type RuntimeProxyConfig = {
stream_usage_mode: string;
stream_usage_max_bytes: number;
stream_usage_max_parsers: number;
usage_queue_enabled: boolean;
usage_queue_bound: boolean;
usage_queue_active: boolean;
};
async function readSetting(
db: D1Database,
key: string,
@@ -46,6 +59,61 @@ function parsePositiveNumber(value: string | null, fallback: number): number {
return fallback;
}
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
if (!Number.isNaN(parsed) && parsed > 0) {
return Math.floor(parsed);
}
return fallback;
}
function parseNonNegativeInt(value: string | undefined, fallback: number): number {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number(value);
if (!Number.isNaN(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
return fallback;
}
function normalizeStreamUsageMode(value: string | undefined): string {
const normalized = (value ?? "").toLowerCase();
if (normalized === "off" || normalized === "full" || normalized === "lite") {
return normalized;
}
return DEFAULT_PROXY_STREAM_USAGE_MODE;
}
/**
* Returns runtime proxy configuration derived from environment variables.
*
* @param env - Worker bindings.
* @returns Runtime proxy configuration for display.
*/
export function getRuntimeProxyConfig(env: Bindings): RuntimeProxyConfig {
const usageQueueEnabled = env.PROXY_USAGE_QUEUE_ENABLED !== "false";
const usageQueueBound = Boolean(env.USAGE_QUEUE);
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,
usage_queue_bound: usageQueueBound,
usage_queue_active: usageQueueEnabled && usageQueueBound,
};
}
/**
* Returns the log retention days from settings or default fallback.
*/
+89
View File
@@ -0,0 +1,89 @@
import type {
D1Database,
ExecutionContext,
QueueBatch,
} from "@cloudflare/workers-types";
import type { Bindings } from "../env";
import { recordChannelModelError, upsertChannelModelCapabilities } from "./channel-model-capabilities";
import type { UsageInput } from "./usage";
import { recordUsage } from "./usage";
export type UsageQueueEvent =
| {
type: "usage";
payload: UsageInput;
}
| {
type: "capability_upsert";
payload: {
channelId: string;
models: string[];
nowSeconds?: number;
};
}
| {
type: "model_error";
payload: {
channelId: string;
model: string | null;
errorCode: string;
nowSeconds?: number;
};
};
function resolveNowSeconds(value?: number): number {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
return Math.floor(Date.now() / 1000);
}
export async function processUsageQueueEvent(
db: D1Database,
event: UsageQueueEvent,
): Promise<void> {
if (event.type === "usage") {
await recordUsage(db, event.payload);
return;
}
if (event.type === "capability_upsert") {
const nowSeconds = resolveNowSeconds(event.payload.nowSeconds);
await upsertChannelModelCapabilities(
db,
event.payload.channelId,
event.payload.models,
nowSeconds,
);
return;
}
if (event.type === "model_error") {
const nowSeconds = resolveNowSeconds(event.payload.nowSeconds);
await recordChannelModelError(
db,
event.payload.channelId,
event.payload.model,
event.payload.errorCode,
nowSeconds,
);
}
}
export async function handleUsageQueue(
batch: QueueBatch<UsageQueueEvent>,
env: Bindings,
ctx: ExecutionContext,
): Promise<void> {
const tasks = batch.messages.map(async (message) => {
try {
await processUsageQueueEvent(env.DB, message.body);
message.ack();
} catch (error) {
console.error("[usage-queue:error]", {
queue: batch.queue,
error: error instanceof Error ? error.message : String(error),
});
message.retry();
}
});
ctx.waitUntil(Promise.all(tasks));
}
+46
View File
@@ -11,6 +11,19 @@ export type StreamUsage = {
firstTokenLatencyMs: number | null;
};
export type StreamUsageMode = "full" | "lite" | "off";
export type StreamUsageOptions = {
mode?: StreamUsageMode;
maxBytes?: number;
};
const USAGE_HINTS = [
"\"usage\"",
"\"usageMetadata\"",
"\"usage_metadata\"",
];
function toNumber(value: unknown): number | null {
if (value === null || value === undefined) {
return null;
@@ -152,22 +165,44 @@ export function parseUsageFromHeaders(
export async function parseUsageFromSse(
response: Response,
options: StreamUsageOptions = {},
): Promise<StreamUsage> {
if (!response.body) {
return { usage: null, firstTokenLatencyMs: null };
}
const mode: StreamUsageMode = options.mode ?? "full";
if (mode === "off") {
return { usage: null, firstTokenLatencyMs: null };
}
const maxBytes =
typeof options.maxBytes === "number" && options.maxBytes > 0
? options.maxBytes
: Number.POSITIVE_INFINITY;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let usage: NormalizedUsage | null = null;
const start = Date.now();
let firstTokenLatencyMs: number | null = null;
let bytesRead = 0;
const payloadMayContainUsage = (payload: string): boolean => {
if (!payload) {
return false;
}
return USAGE_HINTS.some((hint) => payload.includes(hint));
};
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
bytesRead += value?.byteLength ?? 0;
if (bytesRead > maxBytes) {
await reader.cancel();
break;
}
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
@@ -179,10 +214,18 @@ export async function parseUsageFromSse(
if (firstTokenLatencyMs === null) {
firstTokenLatencyMs = Date.now() - start;
}
if (mode === "lite" && !payloadMayContainUsage(payload)) {
newlineIndex = buffer.indexOf("\n");
continue;
}
const parsed = safeJsonParse<unknown>(payload, null);
const candidate = parseUsageFromJson(parsed);
if (candidate) {
usage = candidate;
if (mode === "lite") {
await reader.cancel();
return { usage, firstTokenLatencyMs };
}
}
}
}
@@ -197,6 +240,9 @@ export async function parseUsageFromSse(
if (firstTokenLatencyMs === null) {
firstTokenLatencyMs = Date.now() - start;
}
if (mode === "lite" && !payloadMayContainUsage(payload)) {
return { usage, firstTokenLatencyMs };
}
const parsed = safeJsonParse<unknown>(payload, null);
const candidate = parseUsageFromJson(parsed);
if (candidate) {
+14
View File
@@ -5,6 +5,10 @@ compatibility_date = "2026-02-14"
[vars]
CORS_ORIGIN = "http://localhost:5173"
PROXY_UPSTREAM_TIMEOUT_MS = "30000"
PROXY_STREAM_USAGE_MODE = "lite"
PROXY_STREAM_USAGE_MAX_BYTES = "262144"
PROXY_STREAM_USAGE_MAX_PARSERS = "2"
PROXY_USAGE_QUEUE_ENABLED = "true"
[[d1_databases]]
binding = "DB"
@@ -21,6 +25,16 @@ run_worker_first = true
name = "CHECKIN_SCHEDULER"
class_name = "CheckinScheduler"
[[queues.producers]]
queue = "usage-events"
binding = "USAGE_QUEUE"
[[queues.consumers]]
queue = "usage-events"
max_batch_size = 10
max_batch_timeout = 30
max_retries = 3
[[migrations]]
tag = "v1"
new_sqlite_classes = ["CheckinScheduler"]
+4
View File
@@ -8,6 +8,10 @@
"scripts": {
"dev:worker": "bun --cwd apps/worker dev",
"dev:ui": "bun --filter api-worker-ui dev -- --port 4173",
"dev": "node scripts/dev.mjs",
"deploy": "node scripts/deploy.mjs update",
"deploy:init": "node scripts/deploy.mjs init",
"deploy:update": "node scripts/deploy.mjs update",
"test": "vitest",
"format": "bunx --bun biome format --write .",
"lint": "bunx --bun biome lint --write .",
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { access, readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_CONFIG = "apps/worker/wrangler.toml";
const DEFAULT_ENV_FILE = path.join(ROOT, ".env");
const DEFAULT_ENV_EXAMPLE = path.join(ROOT, ".env.example");
const BUN_CMD = (() => {
if (process.env.BUN_BIN && existsSync(process.env.BUN_BIN)) {
return process.env.BUN_BIN;
}
const npmExec = process.env.npm_execpath;
if (npmExec && npmExec.toLowerCase().includes("bun")) {
return npmExec;
}
if (process.env.BUN_INSTALL) {
const candidate = path.join(
process.env.BUN_INSTALL,
"bin",
process.platform === "win32" ? "bun.exe" : "bun",
);
if (existsSync(candidate)) {
return candidate;
}
}
return "bun";
})();
const parseArgs = () => {
const args = process.argv.slice(2);
const options = {
action: null,
target: "auto",
migrate: "auto",
config: DEFAULT_CONFIG,
skipInstall: false,
};
const positionals = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg.startsWith("--")) {
const key = arg.slice(2);
const value = args[i + 1];
if (key === "action" && value) {
options.action = value;
i += 1;
} else if (key === "target" && value) {
options.target = value;
i += 1;
} else if (key === "migrate" && value) {
options.migrate = value;
i += 1;
} else if (key === "config" && value) {
options.config = value;
i += 1;
} else if (key === "skip-install") {
options.skipInstall = true;
} else if (key === "help") {
printHelp();
process.exit(0);
}
} else {
positionals.push(arg);
}
}
if (positionals[0] === "init" || positionals[0] === "update") {
options.action = positionals[0];
}
return options;
};
const printHelp = () => {
console.log("Usage:");
console.log(" node scripts/deploy.mjs init [--target both] [--migrate true]");
console.log(" node scripts/deploy.mjs update [--target auto] [--migrate auto]");
console.log("");
console.log("Options:");
console.log(" --action init|update 部署动作(可用位置参数替代)");
console.log(" --target frontend|backend|both|auto");
console.log(" --migrate true|false|auto");
console.log(" --config <path> wrangler.toml 路径");
console.log(" --skip-install 跳过 bun install");
console.log("");
console.log("说明: 本脚本仅执行本地流程(构建 + 本地迁移),不会触发远程部署。");
};
const run = (command, args, options = {}) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: ROOT,
env: { ...process.env, ...options.env },
stdio: "inherit",
});
child.on("error", (error) => {
if (error.code === "ENOENT") {
reject(
new Error(
`无法执行 ${command},请确认已安装 Bun 并配置 PATH,或设置 BUN_BIN 指向 bun 可执行文件。`,
),
);
return;
}
reject(error);
});
child.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${command} failed with code ${code}`));
}
});
});
const runCapture = (command, args, options = {}) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: ROOT,
env: { ...process.env, ...options.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
if (error.code === "ENOENT") {
reject(
new Error(
`无法执行 ${command},请确认已安装 Bun 并配置 PATH,或设置 BUN_BIN 指向 bun 可执行文件。`,
),
);
return;
}
reject(error);
});
child.on("close", (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
reject(
new Error(`${command} failed: ${stderr.trim() || stdout.trim()}`),
);
}
});
});
const runBun = (args, options) => run(BUN_CMD, args, options);
const runBunx = (args, options) => run(BUN_CMD, ["x", ...args], options);
const parseEnvLine = (line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
return null;
}
const idx = trimmed.indexOf("=");
if (idx === -1) {
return null;
}
const key = trimmed.slice(0, idx).trim();
let value = trimmed.slice(idx + 1).trim();
if (
(value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
return { key, value };
};
const loadEnvFile = async (filePath) => {
if (!existsSync(filePath)) {
return [];
}
const raw = await readFile(filePath, "utf8");
const lines = raw.split(/\r?\n/);
for (const line of lines) {
const parsed = parseEnvLine(line);
if (!parsed) {
continue;
}
if (process.env[parsed.key] === undefined) {
process.env[parsed.key] = parsed.value;
}
}
return lines;
};
const ensureEnvFile = async (filePath, examplePath) => {
if (existsSync(filePath)) {
return;
}
if (existsSync(examplePath)) {
const template = await readFile(examplePath, "utf8");
await writeFile(filePath, template, "utf8");
return;
}
await writeFile(filePath, "# 本地部署环境变量(自动生成)\n", "utf8");
};
const promptChoice = async (rl, label, choices, defaultValue) => {
const choicesText = choices.join("/");
const defaultText = defaultValue ? ` [${defaultValue}]` : "";
while (true) {
const answer = (await rl.question(`${label} (${choicesText})${defaultText}: `))
.trim()
.toLowerCase();
if (!answer && defaultValue) {
return defaultValue;
}
const numeric = Number(answer);
if (Number.isInteger(numeric) && numeric >= 1 && numeric <= choices.length) {
return choices[numeric - 1];
}
if (choices.includes(answer)) {
return answer;
}
console.log(`无效输入,请输入 ${choicesText} 或序号 1-${choices.length}`);
}
};
const promptOptions = async (options) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
const action = await promptChoice(
rl,
"选择部署动作",
["init", "update"],
"update",
);
options.action = action;
if (action === "init") {
options.target = "both";
options.migrate = "true";
return options;
}
options.target = await promptChoice(
rl,
"选择部署目标",
["frontend", "backend", "both", "auto"],
options.target,
);
options.migrate = await promptChoice(
rl,
"是否执行迁移",
["true", "false", "auto"],
options.migrate,
);
return options;
} finally {
rl.close();
}
};
const ensureUiBuild = async () => {
await runBun(["run", "--filter", "api-worker-ui", "build"]);
const distDir = path.join(ROOT, "apps/ui/dist");
const indexFile = path.join(distDir, "index.html");
if (!existsSync(distDir) || !existsSync(indexFile)) {
throw new Error("UI 构建失败:dist 或 index.html 不存在");
}
};
const getChangedFiles = async () => {
try {
const insideRepo = await runCapture("git", [
"rev-parse",
"--is-inside-work-tree",
]);
if (insideRepo !== "true") {
return null;
}
await runCapture("git", ["rev-parse", "HEAD~1"]);
const diff = await runCapture("git", [
"diff",
"--name-only",
"HEAD~1",
"HEAD",
]);
return diff.split("\n").filter(Boolean);
} catch {
return null;
}
};
const resolveTarget = (target, changedFiles) => {
if (target !== "auto") {
if (target === "backend") {
return { deployUi: false, deployWorker: true };
}
if (target === "frontend") {
return { deployUi: true, deployWorker: false };
}
return { deployUi: true, deployWorker: true };
}
if (!changedFiles) {
return { deployUi: true, deployWorker: true };
}
const deployUi = changedFiles.some((file) => file.startsWith("apps/ui/"));
const deployWorker = changedFiles.some((file) =>
file.startsWith("apps/worker/"),
);
if (!deployUi && !deployWorker) {
return { deployUi: true, deployWorker: true };
}
return { deployUi, deployWorker };
};
const resolveMigrate = (migrate, changedFiles) => {
if (migrate === "true") {
return true;
}
if (migrate === "false") {
return false;
}
if (!changedFiles) {
return true;
}
return changedFiles.some((file) =>
file.startsWith("apps/worker/migrations/"),
);
};
const main = async () => {
const options = parseArgs();
if (!options.action) {
await promptOptions(options);
}
const configPath = path.resolve(ROOT, options.config);
await access(configPath);
await ensureEnvFile(DEFAULT_ENV_FILE, DEFAULT_ENV_EXAMPLE);
await loadEnvFile(DEFAULT_ENV_FILE);
if (options.action === "init") {
options.target = "both";
options.migrate = "true";
}
const changedFiles = await getChangedFiles();
const targets = resolveTarget(options.target, changedFiles);
const shouldMigrate = resolveMigrate(options.migrate, changedFiles);
console.log("本地部署计划:");
console.log(`- action: ${options.action}`);
console.log(`- target: ${options.target}`);
console.log(`- migrate: ${options.migrate} => ${shouldMigrate}`);
console.log(`- build UI: ${targets.deployUi}`);
console.log(`- prepare backend: ${targets.deployWorker}`);
console.log("- mode: local (no remote deploy)");
if (!options.skipInstall) {
await runBun(["install"]);
}
if (targets.deployUi) {
await ensureUiBuild();
}
await runBunx(["wrangler", "telemetry", "disable"]);
if (shouldMigrate) {
await runBunx([
"wrangler",
"d1",
"migrations",
"apply",
"DB",
"--local",
"--config",
configPath,
]);
}
console.log("✅ 本地部署完成");
};
main().catch((error) => {
console.error(`❌ 部署失败: ${error.message}`);
process.exit(1);
});
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
const BUN_CMD = (() => {
if (process.env.BUN_BIN && existsSync(process.env.BUN_BIN)) {
return process.env.BUN_BIN;
}
const npmExec = process.env.npm_execpath;
if (npmExec && npmExec.toLowerCase().includes("bun")) {
return npmExec;
}
if (process.env.BUN_INSTALL) {
const candidate = path.join(
process.env.BUN_INSTALL,
"bin",
process.platform === "win32" ? "bun.exe" : "bun",
);
if (existsSync(candidate)) {
return candidate;
}
}
return "bun";
})();
const commands = [
{ name: "worker", cmd: BUN_CMD, args: ["run", "dev:worker"] },
{ name: "ui", cmd: BUN_CMD, args: ["run", "dev:ui"] },
];
const children = new Map();
let shuttingDown = false;
const shutdown = (code = 0) => {
if (shuttingDown) {
return;
}
shuttingDown = true;
for (const child of children.values()) {
if (!child.killed) {
child.kill("SIGINT");
}
}
process.exit(code);
};
for (const command of commands) {
const child = spawn(command.cmd, command.args, { stdio: "inherit" });
children.set(command.name, child);
child.on("error", (error) => {
if (error.code === "ENOENT") {
console.error(
"❌ 未找到 Bun,请确认已安装并配置 PATH,或设置 BUN_BIN 指向 bun 可执行文件。",
);
shutdown(1);
return;
}
console.error(`❌ 启动 ${command.name} 失败: ${error.message}`);
shutdown(1);
});
child.on("exit", (code) => {
if (shuttingDown) {
return;
}
if (code && code !== 0) {
shutdown(code);
return;
}
const allExited = Array.from(children.values()).every(
(item) => item.exitCode !== null,
);
if (allExited) {
shutdown(0);
}
});
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));