feat: 引入 usage 队列与本地部署脚本
- usage 队列异步写入与流式解析限流 - 管理台展示运行时配置 - 本地 dev/部署脚本与 CI 队列创建
This commit is contained in:
1 parent
e12d38f384
commit
d205289f9f
27 files changed
+1338
-291
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
|
||||
- 系统设置增加定时签到开关与时间(中国时间)
|
||||
- 系统设置提供模型失败冷却分钟数配置
|
||||
- 系统设置展示运行时配置(只读),提示在部署环境中修改环境变量
|
||||
- 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择
|
||||
- Token 仅本地保存,不回显
|
||||
- 移动端使用顶部栏 + 抽屉导航,侧边栏在桌面端保持固定
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 表
|
||||
|
||||
@@ -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` 表
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
## 行为规范
|
||||
- 代理调用统一写入 `usage_logs`(含无可用渠道/冷却提前返回)
|
||||
- 使用日志可通过 `USAGE_QUEUE` 异步写入(可能存在短暂延迟)
|
||||
- 流式 usage 解析被跳过或超限时会记录 `usage_skipped/usage_missing` 以便排查
|
||||
- 保留天数可配置,查询时触发清理
|
||||
- 查询结果附带渠道与令牌名称
|
||||
- `GET /api/usage` 支持 `offset/limit` 分页并返回 `total`
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 .",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
Reference in new issue
Block a user