859 Commits

Author SHA1 Message Date
boos c3293e007f docs(nit): AI Studio 第一阶段(聚合图像/数字人)设计 spec
NIT 精英班专属 AI Studio 第一阶段:聚合 picture_agent(图像)+ digital_human(数字人)。
学员下游 key 配置 + surface 认证扩展(接受 student JWT)+ 积分轨计费 + 统一入口前端。
不碰 money_printer,视频/文本创作/PPT 留后续阶段。前置依赖第一期报考闭环。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 04:43:51 +08:00
boos a1b7b60ad4 docs(nit): 第一期报考闭环 A+B+C 设计 spec
NIT 报考学习平台第一期设计:独立 students 表、线下缴费上传凭证人工审核、
开课权限状态标记、平台超管后台、模块内嵌复用现有 axum/sqlx/JWT/workspace 基建。
不含学习中心/OCR/校区负责人多级权限/内容保护(留后续期)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 04:24:05 +08:00
boos bfd804f719 feat(billing): UsageRecords dual-track ledger view
api.ts 新增 userApi.getBillingLedger / getBillingSummary 及 Billing* 类型;
UsageRecords.tsx 增加 USD/积分 双轨切换 tab、积分汇总卡与明细表(切到积分轨时懒加载)。
随文件一并提交此前累积的支付/Comfy Cloud/工作流前端 API 改动。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 03:04:11 +08:00
boos e7f3da5961 feat(billing): user billing ledger + summary endpoints
新增 GET /api/user/billing/ledger 与 /api/user/billing/summary 两个用户侧接口,
基于 Task11 的 list_user_ledger / summarize_user_ledger 返回当前用户的明细与聚合。
随文件一并提交此前累积的充值/支付/市场相关改动及测试辅助。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 02:56:12 +08:00
boos 5cfd533d89 feat(billing): user ledger list + summary queries 2026-06-17 01:49:46 +08:00
boos 0b7abed79a docs: 细化计费第3期(消费明细)TDD Task11-14 2026-06-17 01:44:28 +08:00
boos a89edf6f75 feat(payment): comfy 登录态/充值确认/记录管理 + Task9 积分到账调用适配
含本会话累积的支付路由功能(firebase-login 回执登录、recharge-records
列表/确认/清空、诊断卡)及 admin_confirm_user_recharge 传 config 的调用适配,
使计费 Task9(comfy 充值改积分)调用方编译完整。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 21:13:16 +08:00
boos 5d42671172 feat(billing): comfy recharge to points + admin credit adjust
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 21:11:44 +08:00
boos ca9e45c59c feat(billing): workflow points gating + charge
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 20:58:02 +08:00
boos be7538a1da feat(billing): workflow_points_cost config 2026-06-16 20:32:10 +08:00
boos cfe2098b73 feat(billing): points track apply_points_delta + balance 2026-06-16 20:24:50 +08:00
boos bd464016a8 docs: 细化计费第2期(积分轨)TDD 任务 Task6-10 2026-06-16 20:20:22 +08:00
boos 7e8430934e feat(billing): USD admin adjust writes credit_ledger
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:46:56 +08:00
boos ab2fd271e8 feat(billing): USD recharge writes credit_ledger 2026-06-16 17:40:43 +08:00
boos 4dd2be4cc3 feat(billing): USD consume writes credit_ledger in one tx
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:34:21 +08:00
boos fd94303433 feat(billing): credit_ledger schema + record_ledger_entry 2026-06-16 17:25:59 +08:00
boos 91dcbc5c53 docs: 计费闭环实现计划(第1期详细TDD + 后5期概要)
第1期地基: credit_ledger + user_credits 表, USD 轨三处记账
(消费/充值/管理员调整)同事务接入 ledger。每步含完整测试与代码。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:17:54 +08:00
boos 278b0c66b8 docs: 计费闭环系统设计 spec
双轨额度(USD 池 + 工作流积分池)、ledger-first 统一流水账、
消费明细/充值档位/超额预警/全量审计四块功能、A+C 混合积分计费、
Comfy 余额对账校准。分 6 期实现。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 17:12:02 +08:00
boos d697146941 feat(audit): login audit log with account info and IPv4
- 审计日志记录具体账号(用户名<邮箱>)
- 优先显示 IPv4,保留 IPv6
- Program Logs 页面新增 account/IPv4 列
2026-06-10 19:54:32 +08:00
boos d1f3f4e93d feat(payment): Stripe Checkout payment module
- payment_orders 建表迁移,支持多 provider(stripe)
- Stripe Checkout Session 创建、Webhook 验签、到账
- 支付成功自动为 API Key 增加额度
- payment.enabled 开关,关闭时不影响旧 Comfy Checkout
2026-06-10 19:54:27 +08:00
boos df97633300 fix(recharge): per-amount distinct checkout URLs
- 前端 /0/0/0 各自携带独立 checkoutUrl
- 后端按金额解析独立 Checkout URL 配置
- 同时校验 optionId 和 amountUsd 防止金额错配
- 移除旧单一固定 checkout URL 兜底
2026-06-10 19:54:20 +08:00
boos 3d54c7d529 fix: support user recharge record cancellation 2026-06-09 17:22:23 +08:00
boos 55b3d4d99a feat: enhance new website experiment motion 2026-06-09 16:59:04 +08:00
boos 59106e6a96 feat: refine BoosAPI console and user workspace 2026-06-09 16:48:18 +08:00
boos aaba6f152e perf: optimize proxy streaming and database access 2026-06-07 00:00:24 +08:00
boos f3d2d50f9a fix: make website boot animation resilient 2026-06-06 22:38:31 +08:00
boos 16162095f7 style: polish admin operations pages 2026-06-06 22:19:42 +08:00
boos 6629d9d0a3 fix: stabilize console topbar layout 2026-06-06 22:09:44 +08:00
boos 3c6f061709 fix: improve assistant thinking and image progress 2026-06-06 22:02:45 +08:00
boos d7d14e077e fix: stabilize website boot loader 2026-06-06 21:54:25 +08:00
boos 3b3292b934 feat: add admin operations war room 2026-06-06 21:50:23 +08:00
boos f7916a42e5 feat: enhance website flow and user workbench 2026-06-06 21:44:58 +08:00
boos f725de28bd feat: polish website and console dashboards 2026-06-06 21:15:33 +08:00
boos 97113964c8 fix: remove sidebar identity card 2026-06-06 21:01:20 +08:00
boos b429695cba fix: stabilize topbar layout 2026-06-06 20:57:33 +08:00
boos d6bf7352a0 feat: support local avatar uploads 2026-06-06 20:52:34 +08:00
boos d800ed1f9f fix: keep admin sidebar actions visible 2026-06-06 20:44:34 +08:00
boos 4855db3fc8 feat: polish user auth entrance 2026-06-06 20:38:18 +08:00
boos 6cd0a8caca fix: stream real agent reasoning 2026-06-06 20:26:31 +08:00
boos 44dc89a0dd refactor: share console page header 2026-06-06 20:18:28 +08:00
boos 823ac3c25d fix: show only generated outputs in workspace 2026-06-06 20:03:02 +08:00
boos 5ecea182bc feat: polish console shell and notification settings 2026-06-06 19:56:56 +08:00
boos cd843465b2 feat: show conversation content in usage logs 2026-06-06 19:36:42 +08:00
boos ef0f91e683 perf: speed up app loading 2026-06-06 19:21:59 +08:00
boos 9c1e5ae293 fix: keep video assistant reasoning label stable 2026-06-06 19:02:41 +08:00
boos 00b3365f5a feat: refine website gateway os hero 2026-06-06 18:59:10 +08:00
boos d5babf5f3f fix: support website color schemes 2026-06-06 18:45:12 +08:00
boos 4b757b8250 feat: add light website intro motion 2026-06-06 18:29:10 +08:00
boos def8d7a204 chore: speed up local build workflow 2026-06-06 18:10:16 +08:00
boos 25c6fdbd6c revert website high contrast redesign 2026-06-06 17:58:10 +08:00
boos 47202e6848 redesign website with high contrast motion 2026-06-06 17:54:38 +08:00
boos 1b2880739a polish auth entry surfaces 2026-06-06 17:42:14 +08:00
boos 188e457e7f polish topbar user experience 2026-06-06 17:31:41 +08:00
boos 13756adc9d polish about page experience 2026-06-06 17:25:31 +08:00
boos f026582008 fix: persist runtime proxy token 2026-06-06 17:11:51 +08:00
boos b3eb86d5ea chore: remove update center 2026-06-06 16:55:15 +08:00
boos 301d086cb6 fix: show runtime logs in program logs 2026-06-06 16:40:55 +08:00
boos 023384b14b style: refine website section spacing 2026-06-06 16:18:11 +08:00
boos d315f0ffe2 feat: polish website model radar 2026-06-06 16:14:05 +08:00
boos f0385c9c8c chore: install vercel agent skills 2026-06-06 16:07:34 +08:00
boos 339f91d014 feat: sync upstream analytics schema support 2026-06-06 16:07:13 +08:00
boos 0d231ec467 feat: enhance website model radar motion 2026-06-06 15:46:51 +08:00
boos be1fc1df48 feat: optimize routes and registration UX 2026-06-06 15:37:37 +08:00
boos dd874bae30 feat: refresh BossAI console frontend 2026-06-06 15:25:04 +08:00
boos 45aa741d9c feat: refine BossAI console and website design 2026-06-06 15:09:53 +08:00
boos 49c398db5e feat: polish BossAI website motion 2026-06-06 14:56:33 +08:00
boos 4d748dd938 feat: refine BossAI website product story 2026-06-06 06:27:28 +08:00
boos b40c522749 feat: add gsap motion to BossAI website 2026-06-06 06:18:40 +08:00
boos d467e27fd8 fix: guard embedded frontend assets 2026-06-06 06:11:15 +08:00
boos 5ef017c54e feat: serve website at root path 2026-06-06 05:59:48 +08:00
boos 03046152fd feat: isolate BossAI website with Next.js 2026-06-06 05:30:10 +08:00
boos d75daf72d6 feat: add premium public landing page 2026-06-06 05:06:37 +08:00
boos d39100b56d feat: optimize money printer factory 2026-06-06 04:56:02 +08:00
boos 2e4c5801b6 chore: tighten repository hygiene 2026-06-06 04:40:25 +08:00
boos 870b2a2da3 feat: surface admin settings attention states 2026-06-06 03:57:15 +08:00
boos 4448567368 feat: optimize admin settings page 2026-06-06 03:49:30 +08:00
boos fae0f607aa test: restore project verification suite 2026-06-06 03:35:29 +08:00
boos e4630c4cb6 fix: harden workflow smoke issues 2026-06-06 03:23:39 +08:00
boos e72e323313 feat: refine money printer comfy template mode 2026-06-06 03:09:08 +08:00
boos 2b836b3e1e Add built-in Cloud video template fallback 2026-06-06 02:58:16 +08:00
boos 598b2f48d6 Handle Comfy Cloud video job status 2026-06-05 22:01:03 +08:00
boos 3f22d7b1ab Make short video factory use Comfy templates 2026-06-05 21:35:12 +08:00
boos 5ce5ea1687 Route Comfy workflows through video API 2026-06-03 11:39:14 +08:00
boos f8741b544e Filter MoneyPrinter Comfy video templates 2026-06-03 11:29:55 +08:00
boos c316a78511 Default MoneyPrinter videos to BoosAPI route 2026-06-03 11:03:04 +08:00
boos 68ae9acb27 Clarify bundled Picture Agent template errors 2026-06-03 11:01:47 +08:00
boos 13c7d8aa4e Remove stale Picture Agent remote template state 2026-06-03 10:59:11 +08:00
boos fb5fc9f1ae Use bundled Linux Do picture templates 2026-06-03 10:55:43 +08:00
boos 5765ea48b0 Clarify Picture Agent template refresh state 2026-06-02 17:38:54 +08:00
boos 55bd3f41f8 Follow Picture Agent gallery data URL 2026-06-02 17:30:15 +08:00
boos 938597b766 Fix Picture Agent loading fallback 2026-06-02 17:23:36 +08:00
boos fe0e3faa22 Generate MoneyPrinter Comfy prompts from form 2026-06-02 17:18:23 +08:00
boos d0f26118ff Normalize MoneyPrinter Comfy workflow templates 2026-06-02 17:11:15 +08:00
boos c98d9a2c6d Load Comfy templates for MoneyPrinter 2026-06-02 17:00:46 +08:00
boos 1293ebae68 Use Comfy API for MoneyPrinter video generation 2026-06-02 16:47:48 +08:00
boos b29c7ffa7a Add native MoneyPrinter short video factory 2026-06-02 15:26:42 +08:00
boos 056e9fb651 Improve digital human template UI 2026-06-02 12:17:05 +08:00
boos fa5a2419d8 Add Comfy digital human jobs 2026-06-02 11:07:31 +08:00
boos 7b34153235 Proxy external template requests 2026-06-01 11:21:56 +08:00
boos 04792bd3f7 Improve offline template loading 2026-06-01 11:02:36 +08:00
boos bb31118792 Reference local Workflow UI repo directly 2026-05-28 17:19:32 +08:00
boos 5027767ca2 Use local Workflow UI source for BossAI deploy 2026-05-28 17:16:12 +08:00
boos fd930757f9 Move BossAI deploy worktree out of tmp 2026-05-28 17:03:08 +08:00
boos eb6ed11cff Add BossAI Debian deploy script 2026-05-28 16:59:51 +08:00
boos 931bcf3fb9 Update BossAI workflow and workspace experience 2026-05-28 10:32:20 +08:00
boos e0bd9b4194 Embed workflow UI and isolate workspace assets 2026-05-26 17:00:10 +08:00
boos 0f9fe4a81d chore: prune legacy node backend and mix workflow models 2026-05-26 15:18:33 +08:00
boos 6869504119 chore: add optional bun frontend build 2026-05-25 22:05:03 +08:00
boos 3f9f9f7665 docs: remove readme screenshots 2026-05-25 21:43:46 +08:00
boos a1f3400af3 docs: add hardware requirements 2026-05-25 21:41:47 +08:00
boos da686ecda4 chore: vendor workflow ui as submodule 2026-05-25 21:19:49 +08:00
boos 1ba293c262 feat: polish workflow studio and workspace assets 2026-05-25 20:42:38 +08:00
boos cb35d5fc6e feat: add workflow studio and postgres support 2026-05-25 16:03:07 +08:00
boos 7cdb02ef1d feat: add workspace and claude messages support 2026-05-22 05:57:37 +08:00
boos c1074ddf71 Integrate ComfyUI agents and video workflows 2026-05-22 04:46:15 +08:00
boos 3c12738138 fix: allow comfyui assets through proxy 2026-05-22 01:27:57 +08:00
boos 8de175a8c5 feat: add comfyui agent attachments 2026-05-22 01:16:37 +08:00
boos 7c6364a160 fix: enable multi tenant comfy workspace 2026-05-22 00:44:57 +08:00
boos 5ef3f8aea8 fix: route video workspace to comfyui proxy 2026-05-21 17:27:41 +02:00
boos 9e7ce80574 fix: complete account token admin routes 2026-05-21 17:16:24 +02:00
boos 65d2f1bbe5 fix: restore account health tokens and dashboard data 2026-05-21 17:07:18 +02:00
boos 0207ba748f fix: use user api key for comfyui agent 2026-05-21 16:55:32 +02:00
boos d0912e44b4 fix: route video agent to comfyui and ignore openspec 2026-05-21 16:44:30 +02:00
boos 9c84759498 feat: merge opus comfyui and login updates 2026-05-21 16:32:04 +02:00
boos c16479da7c fix(rust): sweep orphan route_channels when their model has no active account
The per-route loop in `rebuild_routes` only visits routes whose model_pattern
appears in the current `by_model` set. When the last active account for a
model goes away (account deleted, status flipped, model dropped from
model_availability), the model falls out of `by_model` entirely and its
existing auto-managed channels are never reconciled — they stay behind
pointing at a route nothing can serve.

Add a final orphan sweep over all auto-managed route_channels. Anything whose
route wasn't processed in the per-route loop gets hard-deleted. Manual-override
channels still get the same exemption as before.

Covered by two new in-memory sqlite tests:
- orphan_channel_is_cleaned_when_last_account_goes_inactive
- orphan_sweep_preserves_manual_channels
2026-05-20 19:58:45 +08:00
boos 423912277b fix(build): reuse multi-tenant ComfyUI image instead of rebuilding every run
- skip docker build when boosapi-comfyui:multi-tenant already exists;
  --rebuild-image opts back into a fresh build
- fall back to `docker load` from docker/boosapi-comfyui-multi-tenant-*.tar
  when the image is absent but the saved tar is present
- gate the whole block on `docker info` so a client-only host doesn't hang
  on build when the daemon is unreachable
2026-05-20 19:40:39 +08:00
boos cf696c01a5 fix(rust): trigger route rebuild on account writes; add segment filter
- spawn_route_rebuild after create / delete / model refresh / manual model
  upsert; gate update_account on status change so cosmetic edits don't
  burn a transaction
- list_accounts now accepts ?segment=apikey|session|oauth, filtered against
  extra_config.$.credentialMode (legacy null rows treated as apikey)
2026-05-20 19:35:53 +08:00
boos 7c9a1e2cc4 fix(rust): match main's drizzle-style camelCase response shape
Frontend reads main's drizzle-generated camelCase fields (siteId,
accessToken, oauthProvider, etc.) and a nested `site: {...}` on each
account row. Rust models were serializing snake_case with no nested
site, so PUT /api/sites/undefined/disabled-models 404s, account.site?.id
is undefined everywhere, and similar shape mismatches break Accounts,
Sites, Tokens, and Dashboard pages.

- Add #[serde(rename_all = "camelCase")] to every model struct in
  src/db/models/* (Account, AccountToken, Site, User, Event,
  TokenRoute, RouteChannel, BackgroundTask, DownstreamApiKey,
  Organization/Member/Invitation, SiteAnnouncement)
- list_accounts now nests `site: {...}` per row, matching main's
  `{...account, site: {...}}` shape from accountsOverviewService
- admin user-detail endpoint emits camelCase keys (totalRequests,
  totalCost, last7DaysCost, recentLogs, promptTokens, completionTokens,
  totalTokens, createdAt) to match the frontend
- routes refresh endpoint now emits both affected_routes and
  affectedRoutes for forward compat

Co-Authored-By: Kiro <noreply@kiro.dev>
2026-05-20 07:21:40 +08:00
boos 9c0d4ec69f feat(rust): wire downstream-key billing, add /v1/responses, ComfyUI shim
Previously every executor took `_state: AppState` (unused) and `check_auth`
discarded the matched downstream key id, so `usage_logs` was orphan code.
This change plumbs the key id from auth → handlers → executors → billing,
adds the OpenAI Responses API endpoint, and exposes shim aliases under
`/api/comfyui-agent/proxy/openai/*` so embedded ComfyUI's OpenAI nodes
flow through the same auth/conductor/billing pipeline.

- check_auth now returns Option<i64> (managed key id) instead of unit
- record_billing helper writes usage_logs after each successful upstream call
- extract_usage handles both chat-completions and responses usage shapes
- conductor wrappers record billing post-Success; stream variant scans
  chunks in reverse for the final usage event
- POST /v1/responses (stream + non-stream) via ProxyConductor
- shim aliases mounted on the same Router so auth/failover/billing work for free
- ComfyUIProcess injects BOOSAPI_BUILTIN_KEY env + --comfy-api-base on spawn
- bootstrap built-in `comfyui-agent` downstream key at startup (idempotent)
- get_auth_header in vendored ComfyUI falls back to BOOSAPI_BUILTIN_KEY env

Co-Authored-By: Kiro <noreply@kiro.dev>
2026-05-20 06:20:39 +08:00
boos 22751effd5 fix: refresh user logout state 2026-05-19 10:41:40 +02:00
boos 4fe84707e5 feat: complete user login admin keys and deployment docs 2026-05-19 10:06:52 +02:00
boos 812a7c6c07 fix: persist site api endpoints and account verification 2026-05-19 11:22:09 +08:00
boos b6aae5f301 feat: unify user and admin login entry 2026-05-19 10:53:38 +08:00
boos 27c2aca312 fix: show site presets and api key site options 2026-05-19 10:49:41 +08:00
boos 11144d0697 fix: complete gemini frontend and usage follow-up 2026-05-19 10:37:29 +08:00
boos 31e95c2634 fix: usage SQL 查询 ambiguous column
l.created_at 缺少表别名前缀导致歧义错误
2026-05-19 03:06:11 +08:00
boos 499dba6104 fix: 完善用户路由和注册回调
- App.tsx 导入 hasValidUserSession 修复 TS 编译错误
- UserRegister 增加 onLogin 回调,注册成功自动跳转
2026-05-19 02:47:23 +08:00
boos d432ded68f feat: 用户管理页面增加用量统计标签
- 用户列表新增总请求、总消耗($)、7天消耗列
- 点击用户行展开用量详情(趋势+最近日志)
- 添加"用量统计"标签页切换
2026-05-19 02:47:19 +08:00
boos 1cef28169c feat: 管理员创建用户自动生成密钥 & 用量统计 API
- create_user() 自动生成 sk-user-* 密钥并关联用户
  (使用事务确保用户+密钥原子创建)
- 新增 GET /api/admin/users/{id}/usage 返回用量统计
  (总请求/成本、7天成本、30天趋势、最近50条日志)
- api.ts 新增 getUserUsage(id) 方法
- mod.rs 导出 stats 模块供 user 路由复用
2026-05-19 02:47:15 +08:00
boos a0d3743564 fix: 修复后端 API 路径不匹配
Gemini 创建的路由为 /v1/user/logs 和 /v1/user/summary,
但前端 api.ts 调用的是 /api/user/logs 和 /api/user/summary,
改为 /api/ 前缀修复 404。
2026-05-19 02:36:57 +08:00
boos c9e98df3f1 chore: 优化 ComfyUI Docker 配置
- .dockerignore: 精简忽略规则
- config.toml: 添加 [comfyui] 配置段
- Dockerfile: 本地源码构建、清华镜像加速
2026-05-19 02:33:23 +08:00
boos ba07517974 fix: 密钥管理排除站点列表缺少无账号站点
loadExclusionSources() 原只从 accounts 提取站点,
新增的/无关联账号的站点不显示。
增加 api.getSites() 调用,合并所有站点到排除选项。
2026-05-19 02:33:18 +08:00
boos c8a523d973 fix: 站点管理预设选项添加 label 标签
SiteInitializationPreset 类型缺少 label 字段,
导致 coding plan、deepseek 等预设在下拉菜单显示空白。
给接口添加 label: string,全部 13 个预设补上中文标签。
2026-05-19 02:33:13 +08:00
boos 5890a9b4ac feat: 添加用户端后端 API 路由
- 新增 GET /api/user/logs(用户 API 调用历史)
- 新增 GET /api/user/summary(用户使用统计摘要)
- stats.rs: ProxyLogRow 改为 pub 供 user 模块复用
2026-05-19 02:33:08 +08:00
boos bf00296a90 feat: 新增普通用户界面
- 重构 App.tsx 路由,分离普通用户/管理员导航
  (userSidebarGroups / userAuthed 状态)
- UserDashboard 重写为概览页:用量统计、最近记录、快速入口
- 新增 UsageRecords 使用记录页(分页)
- 新增 UserKeys 密钥管理页(创建/删除 sk-user- 密钥)
- 新增 UserProfile 个人资料页(修改用户名/密码)
- UserLogin 增加 onLogin 回调
- api.ts 增加 userApi 工具方法
2026-05-19 02:32:58 +08:00
boos 4f0c4959bf Merge pull request 'feat: update logo image' (#5) from feature/update-logo into refactor/rust-backend
Reviewed-on: #5
2026-05-18 09:24:44 +00:00
Aibo117 dbfe07c736 feat: update all logo and favicon files
Replace logo.png, favicon.png, favicon-64.png, and desktop-icon.png
with the new brand logo.
2026-05-18 17:23:54 +08:00
Aibo117 bde045d0cf feat: update logo image
Replace the default logo with the new brand logo.
2026-05-18 17:19:15 +08:00
boos e9595017e9 feat: support admin account login 2026-05-18 17:01:39 +08:00
boos f986fe0377 feat: complete Rust backend UI compatibility 2026-05-18 16:55:09 +08:00
boos f2434e725d feat: ComfyUI video assistant SaaS plan + shared types cleanup
- Add plan/2026-05-18-comfyui-video-assistant-saas.md (gpt-5.5 generated)
- Add src/server/ and src/shared/ type definitions
- Update comfyui routes and api_key auth
- Remove old metapi docs, deploy/k3s, and deprecated configs
2026-05-18 11:28:20 +08:00
boos bfdb01e542 feat: add 视频助手 nav item pointing to /comfyui-agent 2026-05-18 10:16:55 +08:00
boos 3779826a15 fix: add /api/users/login route alias for frontend compat; return JSON error for unmatched API routes instead of SPA index.html 2026-05-18 10:14:07 +08:00
boos b89b71a95f docs: update README tech stack and dev guide for Rust backend
- Rewrote 技术栈 section (Axum/Tokio/SQLx/TokenRouter)
- Rewrote 本地开发 section (cargo run + npm build)
- Added 项目结构 section describing Rust backend layout
- Added 环境变量 reference (BOOSAPI_*__* naming)
- Updated Docker Compose examples for Rust config vars
- Removed all Node.js runtime/TypeScript refs from backend docs
2026-05-18 09:37:04 +08:00
boos 0eb7c32585 fix: restore frontend source code (src/web/) that was accidentally deleted
Frontend source is needed for development/building the SPA.
Only Node.js runtime server code should be removed.
2026-05-18 09:33:56 +08:00
boos 5d29f03d50 refactor: purge Node.js, Rust is the one true backend
- Moved boosapi-rs/* to repo root
- Deleted all Node.js source (src/server/, src/web/, scripts/)
- Deleted node_modules, package.json, tsconfig, vite.config
- Updated .gitignore and AGENTS.md for Rust-only stack
- Fixed static file path for frontend dist/web/
- Dockerfile stays the same (already Rust-only)
- Kept dist/web/ for Rust static serving
2026-05-18 09:11:29 +08:00
boos 725f44699a refactor: ComfyUI Agent with structured parsing + image gen + TTS
- New system prompt with 【CHARACTER】【SCENE】【TTS】【WORKFLOW】 markers
- Backend parses structured sections, auto-calls gpt-image-2 for character/scene images
- Auto-calls TTS API for dialogue lines
- Case-insensitive field parsing, robust whitespace handling
- Static file serving with SPA fallback for frontend
- No Node.js runtime dependency for serving
2026-05-18 04:02:02 +08:00
boos 56391f2193 feat: Phase 5-6 - Proxy Conductor, Token Router, Services, Dockerfile
- Proxy Conductor: retry/fallback/auth-refresh orchestration loop
- Token Router: exact/wildcard/regex model routing with channel selection
- Services: Cron scheduler, OAuth token refresh, Update center, Announcements
- User routes: profile + API key management
- DB queries module
- Dockerfile: multi-stage build
- Proxy routes integrated with Conductor + TokenRouter
2026-05-18 03:00:09 +08:00
boos 78d46eeddb feat: Search routes migrated to Rust
- POST /api/search — cross-entity search across 6 categories
- Searches: sites, accounts, account_tokens, checkin_logs,
  proxy_logs, and model_availability
- All via SQL LIKE, no external search engine dependency
- Model aggregation with deduplication
- 18/18 tests pass
2026-05-17 17:15:03 +08:00
boos 7a3a88f738 feat: Monitor (LDOH proxy) routes migrated to Rust
- GET/PUT /api/monitor/config — LDOH cookie management via settings table
- POST /api/monitor/session — cookie auth token
- GET /monitor-proxy/ldoh/{*path} — reverse proxy with content rewriting
- Cookie-based auth, HTML/JS/CSS path rewriting
- 18/18 tests pass
2026-05-17 17:13:28 +08:00
boos adb349eecc feat: Stats routes — model-by-site + site-distribution
- GET /api/stats/model-by-site — model usage aggregated by model name
- GET /api/stats/site-distribution — per-site usage summary
- Both query existing model_day_usage table with day-range filter
- 18/18 tests pass
2026-05-17 17:10:35 +08:00
boos 15f73a2793 fix: ComfyUI wildcard route syntax for axum 0.8+
- Changed  to  in comfyui proxy route
- Added config.toml for local dev
- Verified: Rust backend starts and serves /healthz, /api/sites,
  /api/events, /api/tasks, /api/settings, /api/organizations/plans
- 18/18 tests pass
2026-05-17 17:08:36 +08:00
boos fe1a7c2115 feat: User profile routes migrated to Rust
- PATCH /api/users/me — update own username
- POST /api/users/me/password — change password (verify old first)
- GET /api/users/{id} — get user profile (self or admin)
- JWT auth for all endpoints
- 18/18 tests pass
2026-05-17 17:04:54 +08:00
boos 07f427877b feat: Background task SSE streaming endpoint
- Added /api/update-center/tasks/{id}/stream SSE endpoint
- Polls DB for task status updates every 1s (up to 2 min)
- Streams status + log events, ends on completion/timeout
- Added async-stream dependency
- 18/18 tests pass
2026-05-17 17:03:12 +08:00
boos e94c6cf853 feat: Proxy extensions — audio, video, completions, embeddings
- Generic execute_proxy_request executor for arbitrary paths
- POST /v1/completions, /v1/embeddings
- POST /v1/audio/speech, /v1/audio/transcriptions, /v1/audio/translations
- POST /v1/videos
- All reuse existing auth + route resolution infra
- 18/18 tests pass
2026-05-17 17:01:15 +08:00
boos 84b4b16ebd feat: Proxy extensions — /v1/completions + /v1/embeddings
- Added generic execute_proxy_request executor for any path suffix
- POST /v1/completions — forwards to upstream /completions
- POST /v1/embeddings — forwards to upstream /embeddings
- Both use existing auth + route resolution infra
- 18/18 tests pass
2026-05-17 16:59:43 +08:00
boos 4ac9c2385f feat: Organizations + Invitations routes migrated to Rust
- New migration: organizations, organization_members, invitations tables
- New model: Organization, OrganizationMember, Invitation
- Full CRUD: list/create/get/update orgs, members, invitations
- Public invitation lookup + accept flow
- JWT auth for all protected endpoints
- Admin role verification for org management
- 18/18 tests pass
2026-05-17 16:58:27 +08:00
boos fac1aa4a88 feat: Background tasks table + tasks route migrated to Rust
- New migration: background_tasks table
- New model: BackgroundTask (background_task.rs)
- GET /api/tasks with limit param
- GET /api/tasks/{id} with 404 handling
- 18/18 tests pass
2026-05-17 16:56:15 +08:00
boos d7d3670f02 feat: User API Keys routes migrated to Rust
- New user_api_keys.rs route module
- JWT-authenticated CRUD on downstream_api_keys scoped to user_id
- GET/POST/PATCH/DELETE /api/user-api-keys with ownership verification
- Uses existing downstream_api_keys table with user_id FK
- Supports all fields: name, tags, enabled, expires_at, max_cost, etc.
- 18/18 tests pass
2026-05-17 13:37:17 +08:00
boos 9329248e59 feat: Site Announcements routes migrated to Rust
- New SiteAnnouncement model (site_announcement.rs)
- Route handlers: list (with siteId/platform/read/status filters),
  mark read, mark all read, delete all
- In-memory status filtering for active/expired/dismissed (same as Node.js)
- Uses existing site_announcements table from migration
- 18/18 tests pass
2026-05-17 13:34:53 +08:00
boos 2793a7990d feat: Checkin + Events routes migrated to Rust
- /api/checkin — list logs, stats, accounts, toggle, run
- /api/events — list, count, mark read, clear
- CheckinLog + Event models
- cargo check ✅ | cargo test 18/18 ✅
2026-05-17 13:29:17 +08:00
boos af05b40799 feat: Events API routes migrated to Rust
- GET /api/events — list with filters (type, read, pagination)
- GET /api/events/count — unread count
- POST /api/events/{id}/read — mark one as read
- POST /api/events/read-all — mark all as read
- DELETE /api/events — clear all events
- Event model with sqlx FromRow
2026-05-17 13:28:16 +08:00
boos 8568859da6 feat: User management + JWT auth routes
- Admin users CRUD: /api/admin/users + /api/admin/users/{id}
- Public auth: /api/auth/register, /api/auth/login, /api/auth/me
- User model + password hashing via existing auth module
- JWT token generation and validation
- cargo check ✅ | cargo test 18/18 ✅
2026-05-17 13:24:37 +08:00
boos 7987833ba9 feat: Rust refactor — full backend rewrite (Phases 1-6)
Phase 1-3: Scaffold + types + auth (JWT, Argon2, API keys)
Phase 4a: Admin API (sites/accounts/tokens/downstream-keys/settings)
Phase 5: Proxy core (/v1/chat, /v1/images, /v1/models)
Phase 6: ComfyUI proxy routes migrated

- 80+ source files, all cargo check + 18 tests green
- Drop-in compatible with Node.js API surface
2026-05-17 13:20:52 +08:00
boos ad0bb86e31 feat: ComfyUI embedded dev setup + proxy auth with downstream keys
- COMFYUI_BASE configurable via env var (default 127.0.0.1:8188)
- All /api/comfyui/ routes protected by downstream API Key or admin token
- docker/Dockerfile.comfyui + docker-compose.comfyui.yml for dev
- npm scripts: comfyui:install, comfyui:start, comfyui:stop
- .gitignore / .env.example updated
2026-05-17 13:04:46 +08:00
boos 2fe8d8528a fix: ComfyUI Agent uses upstream API via env vars
- Read BOOSAPI_UPSTREAM_BASE/BOOSAPI_UPSTREAM_KEY from env
- Default to api.boos.lat/v1 with sk-boos4721
- Document: internal tool bypasses proxy /v1/ routing layer
- Update AGENTS.md with architecture decision
2026-05-17 04:23:20 +08:00
boos 5e3d4ee7d4 refactor: ComfyUI Agent - fix auth, upstream API, and SSE streaming
- Fix auth: use upstream API key (sk-boos4721) instead of local proxy
- Simplify SSE: remove nested SSE-in-SSE parsing, stream as plain text
- Fix frontend: add Authorization header from localStorage
- Fix ComfyUI nodes: default to api.boos.lat with correct API key
- All verified: frontend streaming works end-to-end with gpt-5.5
2026-05-17 04:17:12 +08:00
boos b78b622f0c docs: add subagent team collaboration strategy to Rust plan
Adds workstream map, parallel phase timing, shared contracts (AppState
extension, router composition, error type patterns), file ownership matrix,
subagent task template, git branch strategy, and coordinator workflow for
multi-agent parallel development.
2026-05-16 11:27:42 +08:00
boos 56b57f7ace docs: add Rust backend rewrite plan
Phase-by-phase migration plan for replacing the Node.js/Fastify backend
with Axum + SQLx + tokio in Rust. 13 phases covering scaffold through
integration testing.
2026-05-16 11:26:12 +08:00
boos f0188d3f68 docs: update plan with deployment doc improvements 2026-05-16 00:59:36 +08:00
boos 5bd847dd06 docs: complete Docker deployment documentation
- Fix all remaining metapi → boosapi references
- Full docker-compose.yml example with healthcheck
- .env file pattern with variable interpolation
- Complete environment variable reference table
- Docker Compose production checklist
- Redis/MySQL/PostgreSQL extension configs
- Resource limits example
- Nginx WebSocket support
- Admin panel IP restriction pattern
- Upgrade/rollback procedures with commands

Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-16 00:54:54 +08:00
boos 0106dd3f5d Full internal rename: metapi → boosapi
Rename all remaining internal protocol identifiers:
- x-metapi-* headers → x-boosapi-*
- metapi: reasoning prefix → boosapi:
- __metapi* runtime markers → __boosapi*
- file-metapi-* → file-boosapi-*
- METAPI_* env vars → BOOSAPI_*
- metapi_turn_state/metapi_mcp_item__ etc. → boosapi_*
- package.json name → boosapi
- Docker/deploy/k3s/docs references

46 files changed, 168 insertions, 170 deletions.
typecheck:web passes (0 new errors).

Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-16 00:46:28 +08:00
boos d2c16c3919 docs: add plan/ directory with 2026-05-15 dev report
- ComfyUI Agent conversational interface (SSE streaming)
- Metapi → BoosAPI full rebranding
- Gitea CI/CD runner on Alpine (OpenRC)
- Notification service architecture doc

Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-16 00:37:38 +08:00
boos 0f3ff82147 fix: restore ComfyUI as workflow frontend with BoosAPI backend integration
ComfyUI is kept as a visual workflow editor (node-based). All actual
generation goes through BoosAPI's own API via boosapi_comfy_nodes.py
custom nodes (now defaulting to localhost:4000/v1).

Architecture:
  BoosAPI (Node.js, port 4000)
    ├── /video-agent → iframe → ComfyUI (Python, port 8188, CPU)
    │                              └── custom_nodes/boosapi_comfy_nodes.py
    │                                   └── calls BoosAPI /v1/... API
    ├── /comfyui-agent → chat interface (direct API calls)
    └── /v1/... → API endpoints (LLM, image, TTS, video)

Signed-off-by: boos <boos@boos.lat>
2026-05-16 00:26:07 +08:00
boos bd06698c8e refactor: fully embed ComfyUI into BoosAPI, drop external dependency
- Remove comfyui.ts (external proxy to ComfyUI at :8188)
- Remove videoAgent.ts (redundant with comfyuiAgent)
- Remove ComfyUI Python process from Dockerfile (CPU-only, useless)
- Update boosapi_comfy_nodes.py default to localhost BoosAPI
- Clean up index.ts, App.tsx, desktop.ts registrations
- Sidebar: merge '视频工作台'/'视频助手' into single '视频助手' entry

Signed-off-by: boos <boos@boos.lat>
2026-05-16 00:21:18 +08:00
boos 9612bfcb3b chore: remove .github folder
Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-16 00:17:00 +08:00
boos 48eb5bddf9 chore: remove CI entirely
Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-16 00:15:23 +08:00
boos f92d0352a0 fix: lighter CI - single job, skip heavy builds, limit node memory
CI / Check (push) Failing after 14m24s
Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-16 00:05:15 +08:00
boos 2f9f3614a2 fix: adapt CI for Gitea Actions, remove Docker/GitHub-specific workflows
CI / Test & Check (push) Failing after 1h9m26s
CI / Schema Check (SQLite) (push) Failing after 14m50s
- Rewrite ci.yml: replace GitHub Actions with run: steps, drop
  Docker publish, MySQL/Postgres schema, and artifact upload jobs
- Delete codeql.yml (GitHub CodeQL, not available on Gitea)
- Delete docs-pages.yml (GitHub Pages specific)
- Delete release.yml (Docker Hub + GitHub Releases)
- Delete claude-code.md, claude-opus-4.7.md (unused)

Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-15 22:54:30 +08:00
boos cd42de2689 docs: add SaaS multi-tenant features and REST API overview
- Add SaaS multi-tenant section to both Chinese and English READMEs
- Add REST API endpoint reference table
- Add SaaS/plan highlights in subtitle and pain-point table
- Bilingual sync

Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-15 22:35:16 +08:00
boos 7886a0ab8b feat: add SaaS multi-tenant user management
CI / Detect Docs Changes (push) Has been skipped
CI / Test Core (push) Failing after 30s
CI / Build Web (push) Failing after 1m44s
CI / Typecheck (push) Failing after 57s
CI / Repo Drift Check (push) Failing after 17s
CI / Schema Check (SQLite) (push) Failing after 18s
CI / Schema Check (MySQL) (push) Failing after 18s
CI / Schema Check (Postgres) (push) Failing after 17s
CI / Build Docs (push) Has been cancelled
CI / Publish Docker Image (armv7) (push) Has been cancelled
CI / Publish Docker Manifest (push) Has been cancelled
CI / Publish Docker Image (amd64) (push) Has been cancelled
CI / Build Server (push) Has been cancelled
CI / Build Desktop (push) Has been cancelled
CI / Audit Production Dependencies (push) Has been cancelled
CI / Publish Docker Image (arm64) (push) Has been cancelled
CodeQL / Analyze (JavaScript/TypeScript) (javascript-typescript) (push) Has been cancelled
- Add organizations, organization_members, invitations, data_plans tables
- Add organizationService (CRUD, member management, plan limit check)
- Add invitationService (create, accept, cancel invitations)
- Add planService (plan limits, seed defaults)
- Add /api/organizations/* routes (org CRUD, members, invitations)
- Add /api/invitations/* routes (public invite lookup, accept)
- Seed default plans (free/pro/enterprise) on startup

Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-15 21:25:53 +08:00
boos 07013e2a39 docs: highlight built-in ComfyUI features
- Add ComfyUI chat assistant description in subtitle
- Add ComfyUI section in core features
- Update both Chinese and English versions

Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-15 20:58:29 +08:00
boos 2c4aabe961 docs: rewrite README for BoosAPI rebranding
- Replace all Metapi references with BoosAPI
- Remove external service links (GitHub, Docker Hub, Render, Zeabur)
- Simplify structure for self-hosted deployment
- Update both Chinese and English versions

Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-15 20:51:10 +08:00
boos 9cfe8427b5 feat: rebrand Metapi to BoosAPI, add ComfyUI agent and user management
CI / Detect Docs Changes (push) Has been cancelled
CI / Test Core (push) Has been cancelled
CI / Build Web (push) Has been cancelled
CI / Build Server (push) Has been cancelled
CI / Build Desktop (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Repo Drift Check (push) Has been cancelled
CI / Schema Check (SQLite) (push) Has been cancelled
CI / Schema Check (MySQL) (push) Has been cancelled
CI / Schema Check (Postgres) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Audit Production Dependencies (push) Has been cancelled
CI / Publish Docker Image (amd64) (push) Has been cancelled
CI / Publish Docker Image (arm64) (push) Has been cancelled
CI / Publish Docker Image (armv7) (push) Has been cancelled
CI / Publish Docker Manifest (push) Has been cancelled
CodeQL / Analyze (JavaScript/TypeScript) (javascript-typescript) (push) Has been cancelled
- Rename project from Metapi to BoosAPI across all UI and server strings
- Add ComfyUI conversational agent page (/comfyui-agent)
- Add user management system (register, login, API keys, admin)
- Update Dockerfile and database schema
- Add ComfyUI workflow nodes support
- Update shared contracts and platform configurations

Signed-off-by: Boos4721 <boos4721@icloud.com>
2026-05-15 20:20:58 +08:00
Tursom K. Ulefits cdb12a9819 [codex] fix quota refresh probe model (#490)
CI / Detect Docs Changes (push) Has been cancelled
CI / Test Core (push) Has been cancelled
CI / Build Web (push) Has been cancelled
CI / Build Server (push) Has been cancelled
CI / Build Desktop (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Repo Drift Check (push) Has been cancelled
CI / Schema Check (SQLite) (push) Has been cancelled
CI / Schema Check (MySQL) (push) Has been cancelled
CI / Schema Check (Postgres) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Audit Production Dependencies (push) Has been cancelled
CI / Publish Docker Image (amd64) (push) Has been cancelled
CI / Publish Docker Image (arm64) (push) Has been cancelled
CI / Publish Docker Image (armv7) (push) Has been cancelled
CI / Publish Docker Manifest (push) Has been cancelled
CodeQL / Analyze (JavaScript/TypeScript) (javascript-typescript) (push) Has been cancelled
Docs Pages / Build Docs (push) Failing after 2m53s
Docs Pages / Deploy Docs (push) Has been skipped
2026-05-08 22:55:17 +08:00
Ju Zi f19ddac865 feat: 支持从站点页快捷添加 API Key (#505)
Co-authored-by: juzi <956428403@qq.com>
2026-05-08 22:54:40 +08:00
Cita 90e2f0c405 Merge PR #535: clear production dependency audit
Merge the narrow package-lock refresh that restores the main production audit gate.
2026-05-08 17:39:42 +08:00
cita-777 0f51dec36e Clear the main-branch production audit gate
Constraint: main push CI fails on npm audit --omit=dev --audit-level=high after recent merges.
Rejected: Broad npm audit fix output | it updated unrelated dev-only lockfile entries.
Confidence: high
Scope-risk: narrow
Directive: Keep PR audit informational until production dependencies are audited on dev/main consistently.
Tested: ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm ci --prefer-offline --no-audit --no-fund; npm audit --omit=dev --audit-level=high; npm run typecheck:server; npm test -- --run src/server/config.test.ts src/server/settingsRoutePayloads.test.ts
Not-tested: Full GitHub Actions matrix before opening PR.
2026-05-08 17:35:24 +08:00
Cita f4148675e1 Merge PR #510: per-site model probe
Merged after updating the PR onto current main and fixing the remaining probe-setting review gaps.\n\nValidation:\n- GitHub checks all passing: Test Core, Typecheck, Build Web/Server/Desktop, Repo Drift Check, Schema SQLite/MySQL/Postgres, CodeQL, CodeRabbit.\n- Local: npm run typecheck\n- Local: npm run repo:drift-check\n- Local: npx vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts src/web/api.test.ts src/web/pages/helpers/sitesEditor.test.ts src/web/pages/sites.disabled-models-save.test.tsx src/web/pages/sites.edit-scroll.test.tsx
2026-05-08 16:58:27 +08:00
cita-777 e5a8a0b3d7 Keep per-site probe settings reliable before merge
Refresh the PR onto current main and close the remaining probe-setting review gaps: the primary site save now persists the latency threshold, the site row type carries that field without an any-cast, active probe streams are aborted on unmount, and all-model one-shot probes get the longer timeout they need.

Constraint: PR #510 must preserve the per-site model probe feature while merging cleanly into current main.
Rejected: Leave threshold persistence behind the separate probe-settings button | the primary save path would still silently drop user changes.
Rejected: Keep the SiteRow any-cast | it hides API/schema drift for the new persisted field.
Confidence: high
Scope-risk: narrow
Directive: Keep probe setting fields represented in the typed site save payload and covered by focused web API/editor tests.
Tested: npm run typecheck; npm run repo:drift-check; npx vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts src/web/api.test.ts src/web/pages/helpers/sitesEditor.test.ts src/web/pages/sites.disabled-models-save.test.tsx src/web/pages/sites.edit-scroll.test.tsx
Not-tested: Full npm test.
2026-05-08 16:51:05 +08:00
Cita 1d61a7ac84 Merge PR #495: auto-discover recent Docker Hub dev tags
Merged after resolving conflicts with current main.\n\nValidation:\n- GitHub checks all passing: Test Core, Typecheck, Build Web/Server/Desktop, Repo Drift Check, Schema checks, CodeQL, CodeRabbit.\n- Local: npx vitest run --root . src/web/pages/settings/UpdateCenterSection.test.tsx src/server/services/updateCenterVersionService.test.ts src/server/routes/api/updateCenter.test.ts src/server/services/updateCenterStatusService.test.ts\n- Local: npm run typecheck\n- Local: npm run repo:drift-check
2026-05-08 16:27:26 +08:00
cita-777 93bc42a69d Refresh the recent Docker tag PR onto current main
The release branch had fallen behind current main and only conflicted in the
Update Center recent-tag test. Keep the latest main-side deploy-state coverage
while preserving the PR's raw-tag filtering and digest visibility assertions.

Constraint: PR #495 must merge into the current main without widening the PR diff beyond the update-center candidate behavior.
Rejected: Drop the PR-specific digest/raw-tag assertions | would regress the review fixes that prompted the branch update.
Confidence: high
Scope-risk: narrow
Directive: Keep recent Docker tag cards deployable only from real tagName values and keep digest metadata visible before deployment.
Tested: npx vitest run --root . src/web/pages/settings/UpdateCenterSection.test.tsx src/server/services/updateCenterVersionService.test.ts src/server/routes/api/updateCenter.test.ts src/server/services/updateCenterStatusService.test.ts; npm run typecheck; npm run repo:drift-check; git diff --cached --check origin/main
Not-tested: Full npm test.
2026-05-08 16:23:05 +08:00
Cita 508fa71ec7 Merge pull request #518 from kevinye13/fix/settings-allowlist-json-array
fix(settings): persist model allowlist as JSON array
2026-05-08 16:05:15 +08:00
Cita 3ce705ab9d Update contributors section in README.md 2026-05-08 15:37:37 +08:00
Cita 4481cf7493 Clean up contributor section in README
Removed unnecessary contributor images from the README.
2026-05-08 15:29:19 +08:00
Cita 3720b3d0ed Merge pull request #533 from cita-777/release/fix-protocol-compatibility-20260508
[fix] Harden protocol compatibility from dev
2026-05-08 11:05:31 +08:00
cita-777 10dc1ba7bd Carry update center review test follow-up
This merge keeps PR #533 aligned with dev after addressing CodeRabbit follow-up test comments. The changes are limited to UpdateCenterSection test assertions.

Constraint: Release branch remains the publication branch for the open main-targeting PR.

Confidence: high

Scope-risk: narrow

Tested: npx vitest run --root . src/web/pages/settings/UpdateCenterSection.test.tsx

Tested: npm run typecheck:web

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-08 10:50:25 +08:00
cita-777 fee6a6388f Tighten update center review test coverage
CodeRabbit follow-up review pointed out that one renderer assertion could not fail usefully and that the new recent-candidate deploy test did not prove log streaming after dispatch. The test now asserts the visible candidate text directly and covers streamUpdateCenterTaskLogs for the new deploy path.

Constraint: Keep review follow-up scoped to test coverage only.

Confidence: high

Scope-risk: narrow

Tested: npx vitest run --root . src/web/pages/settings/UpdateCenterSection.test.tsx

Tested: npm run typecheck:web

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-08 10:50:10 +08:00
cita-777 00348e0528 Carry protocol review fixes into release
The release branch is main-based and tracks the reviewed dev slice for PR publication. This merge carries the post-review compatibility fixes without rewriting the already-open PR history.

Constraint: PR #533 publishes dev changes through a clean release branch targeting main.

Rejected: Recreate the PR branch from scratch | unnecessary churn after green checks and review context already exist.

Confidence: high

Scope-risk: narrow

Tested: npx vitest run --root . src/server/proxy-core/responsesPreflight.test.ts src/server/proxy-core/serviceTierPolicy.test.ts src/server/routes/proxy/chat.stream.test.ts src/web/pages/settings/UpdateCenterSection.test.tsx

Tested: npm run typecheck:server

Tested: npm run typecheck:web

Tested: npm run repo:drift-check

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-08 10:43:12 +08:00
cita-777 8309ac073a Close protocol compatibility review gaps
Review feedback found narrow compatibility regressions in the web-search shim, Responses tool detection, service tier accounting, and update-center Docker candidate actions. The fix keeps synthetic search routed through the dedicated search model, preserves Responses text compatibility, distinguishes absent service_tier from filtered service_tier, and evaluates recent Docker tags per candidate.

Constraint: Review fixes must stay on dev first, then flow into the main-based release PR branch.

Rejected: Leave Responses web_search simulation on the request model | it has the same search-route selection risk as Anthropic simulation.

Confidence: high

Scope-risk: narrow

Tested: npx vitest run --root . src/server/proxy-core/responsesPreflight.test.ts src/server/proxy-core/serviceTierPolicy.test.ts src/server/routes/proxy/chat.stream.test.ts src/web/pages/settings/UpdateCenterSection.test.tsx

Tested: npm run typecheck:server

Tested: npm run typecheck:web

Tested: npm run repo:drift-check

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-08 10:42:49 +08:00
cita-777 207ab5cedf Prepare protocol compatibility release 2026-05-08 00:32:50 +08:00
cita-777 2491a9af5a Harden cross-protocol request compatibility
Several downstream clients send non-cache compatibility fields across Chat, Responses, Anthropic Messages, and Responses WebSocket surfaces. The proxy now normalizes legacy functions/function_call, maps web_search tools across protocols, rejects unsupported HTTP Responses continuation shapes before upstream dispatch, and applies configurable service_tier policy consistently before requests leave the server.

Constraint: Publication flow keeps day-to-day commits on dev and opens main PRs from a release branch.

Constraint: No new dependencies; web_search-only simulation reuses the existing local search route.

Rejected: Forward external HTTP previous_response_id to strict upstreams | it produces ambiguous continuation failures and stale retry behavior outside WebSocket state.

Rejected: Add a dedicated search dependency | existing /v1/search route is sufficient for synthetic server-tool responses.

Confidence: high

Scope-risk: moderate

Directive: Keep Responses HTTP continuation checks aligned with WebSocket continuation support before relaxing these guards.

Tested: npm run typecheck:server

Tested: npx vitest run --root . src/server/proxy-core/responsesPreflight.test.ts src/server/proxy-core/serviceTierPolicy.test.ts src/server/test-fixtures/protocol/non-cache-field-matrix.test.ts src/server/transformers/openai/responses/conversion.test.ts src/server/transformers/anthropic/messages/conversion.test.ts src/server/routes/proxy/chat.stream.test.ts src/server/routes/proxy/responses.codex-oauth.test.ts

Tested: npx vitest run --root . src/server/routes/proxy/responses.websocket.test.ts

Tested: npm run repo:drift-check

Not-tested: Full application build and full Vitest suite

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-08 00:30:48 +08:00
kevinye13 a53c62885b fix(settings): persist model allowlist as JSON array 2026-04-27 20:35:18 +08:00
Xiang Li 99392341fe fix: handle empty-string column defaults in MySQL schema introspection
MySQL information_schema.COLUMNS.COLUMN_DEFAULT stores DEFAULT '' as an
empty string, not NULL. normalizeDefaultValueForColumn was treating any
empty rawDefaultValue as 'no default' and returning null, causing the
MySQL schema parity test to see defaultValue: null for post_refresh_probe_model
while schemaContract.json (generated from TypeScript schema) had "''".

Fix: when rawDefaultValue is empty string, return "''" for text/json columns
instead of null, so the round-trip through MySQL introspection matches the
contract.
2026-04-23 23:46:10 +08:00
Xiang Li 078ac4aa9d fix: add statement-breakpoint to migration 0025 and regenerate upgrade SQL
Better-SQLite3 requires --> statement-breakpoint between each statement
in a migration file; without it all statements are passed as a single
string to Database.prepare() which throws RangeError on multi-statement input.
2026-04-23 23:21:58 +08:00
Xiang Li cb5078468c fix: address CodeRabbit review for per-site probe
- Fix SSE abort: propagate AbortSignal to worker pool; close connection
  immediately on client disconnect without sending 'complete' event
- Fix Boolean coercion: use strict === true/=== 1 check instead of Boolean()
  to avoid Boolean('false') === true foot-gun
- Fix probe-now: parse and forward latencyThresholdMs from request body
- Fix api.ts probeSiteNow: add latencyThresholdMs to options type
- Fix backup import: restore postRefreshProbe* fields when inserting sites
  so that backup/restore round-trips preserve per-site probe config
- Fix latency threshold persistence: add postRefreshProbeLatencyThresholdMs
  column (migration 0026), save/load it in site editor, pass to
  runPostRefreshProbeIfEnabled so auto-probe also respects the threshold
- Fix state management: setProbeCompleted(false) at probe start; error
  branch also refreshes model lists and sets probeCompleted to show results
- Regenerate schema artifacts (mysql/postgres bootstrap+upgrade, contract)
2026-04-23 23:08:50 +08:00
Xiang Li 37924751f6 chore(db): add migration and regenerate schema artifacts for per-site probe columns
- Add drizzle/0025_site_post_refresh_probe.sql (ALTER TABLE sites ADD 3 columns)
- Update drizzle/meta/_journal.json (idx=24 entry)
- Regenerate mysql/postgres bootstrap+upgrade SQL and schemaContract.json
2026-04-23 22:50:57 +08:00
Xiang Li 46f23ddbb3 feat(sites): per-site model probe with streaming logs and auto-disable
- Move post-refresh probe settings from global config to per-site (sites table)
- Add manual 'probe now' button in site editor with real-time SSE log stream
- Use fetch+ReadableStream instead of EventSource to support Authorization header
- Concurrent probe execution (default 10 workers) via worker-pool pattern
- Add stop button to cancel in-flight probe while preserving completed results
- Add latency threshold: auto-disable models exceeding response time limit
- Treat inconclusive probe results as unsupported (auto-disable)
- Show specific failure reason in logs (timeout, no token, model not found, etc.)
- Refresh and display post-probe model status list (green=available, red=disabled)
- Remove probe settings from global Settings page and RuntimeSettingsPayload
2026-04-23 21:42:48 +08:00
cita-777 fa6edec83a Keep recent Docker tag deploys explicit and tag-safe
Review feedback pointed out two small but important operator-facing gaps in the
recent-tag card: one-click deploy should only be available when a real raw
Docker tag exists, and the digest that will be deployed should be visible on
the card instead of remaining implicit. This patch tightens the candidate
filter to require tagName and renders the digest metadata before the deploy
actions, with regression coverage for both behaviors.

Constraint: The deploy API still requires a raw Docker tag, not a normalized display value
Constraint: The recent-tag card should surface the exact digest operators are about to deploy
Rejected: Keep accepting normalizedVersion-only candidates | could enable one-click deploy with a non-tag display value
Rejected: Rely on displayVersion alone for digest visibility | digest presence should stay explicit even if display text changes later
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Any future recent-tag UI should treat tagName as the deployable identity and digest as separately visible metadata
Tested: src/web/pages/settings/UpdateCenterSection.test.tsx, npm run typecheck, git diff --check
Not-tested: Full PR checks rerun pending after push
2026-04-18 16:09:24 +08:00
cita-777 93912bb330 Publish the dev-tag discovery upgrade to main on a clean release branch
This release branch snapshots the latest dev work onto current main so the
update center can surface recent non-stable Docker tags without operators
looking them up manually. The branch keeps stable Docker discovery as the
primary path while carrying the new recent-tag auto-discovery, UI affordances,
and regression coverage through the repo's release flow.

Constraint: Publication must start from latest origin/main and merge origin/dev on a clean release branch
Constraint: Stable Docker Hub discovery semantics must remain intact while adding recent non-stable discovery
Rejected: Re-open or reuse the already-merged PR #494 branch | it has already been absorbed by main and deleted remotely
Rejected: Publish directly from dev | repo workflow requires a clean release branch to main
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep future dev-tag publication work on fresh release branches cut from origin/main; do not revive merged release branches
Tested: Focused update-center vitest suite, npm run typecheck, npm run repo:drift-check, npm run build:web, npm run docs:build, git diff --check (release branch)
Not-tested: Full npm test remains red because tmp/sub2api frontend suites are still broken in this repo snapshot
2026-04-18 15:32:44 +08:00
cita-777 c94e3148dd Let operators deploy recent dev Docker tags without manual lookup
The update center already allowed manual Docker tag deploys, but operators
still had to know and paste dev or branch tags by hand. This change keeps the
stable Docker Hub candidate as the main path while automatically surfacing the
most recent non-stable tags and their digests for one-click deployment.

Constraint: Stable Docker Hub discovery must remain the default candidate path
Constraint: Existing deploy API and helper protocol should stay unchanged
Rejected: Promote dev tags into the primary stable candidate slot | would blur stable and experimental release semantics
Rejected: Add a brand new backend deploy endpoint for recent tags | existing targetTag/targetDigest deploy path already covers the need
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep stable and recent non-stable Docker tag discovery distinct unless product semantics deliberately change
Tested: Focused vitest for update-center services/routes/UI, npm run typecheck, npm run repo:drift-check, npm run build:web, npm run docs:build, git diff --check
Not-tested: Full npm test remains red because tmp/sub2api frontend suites are still broken in this repo snapshot
2026-04-18 15:28:03 +08:00
Cita 262651a708 Merge pull request #494 from cita-777/release/feat-update-center-dev-tags-20260417
[chore] publish dev snapshot to main
2026-04-18 14:30:53 +08:00
cita-777 4a57e312f5 Align outbound fallback expectations with the reviewed Responses id shape
The last CI failure after the review cleanup was only a stale test expectation:
function_call fallback items now correctly use fc_* item ids while preserving the
call_* id in call_id. Updating the test keeps the suite aligned with the
Responses payload shape we intentionally adopted in the bridge.

Constraint: Keep the fix to the assertion only; the production behavior already matches the reviewed contract
Rejected: Revert the fc_* item id change | that would reopen the review thread we intentionally fixed
Confidence: high
Scope-risk: narrow
Directive: When bridge output semantics change intentionally, update sibling facade tests in the same PR so CI reflects the new contract immediately
Tested: Focused vitest for openai responses outbound/response bridge, Gemini request bridge, Settings payload rules; typecheck; git diff --check
Not-tested: Full npm test
2026-04-18 14:13:20 +08:00
cita-777 544324ed0a Restore the full payload-rule protocol list in Settings
One last review thread caught a real UI regression: the visual payload-rule
editor had drifted away from the shared protocol option source and no longer
exposed several active platform targets. Rebinding the dropdown to the shared
option list fixes the regression without inventing another source of truth.

Constraint: The fix should reuse the existing shared option list instead of creating another hardcoded protocol subset
Rejected: Expand the inline array manually | would repeat the same drift problem that caused the regression
Confidence: high
Scope-risk: narrow
Directive: Payload-rule protocol selectors should always read from the shared option source so Settings and the payload-rule parser stay aligned
Tested: settings.payload-rules vitest; typecheck; git diff --check
Not-tested: Full npm test
2026-04-18 14:01:26 +08:00
cita-777 1f464dc03d Close out the last review edge-cases before final merge
The remaining review threads were down to route alias hygiene, proxy-safe header
ordering, and a Gemini canonical tool-result mismatch. This commit makes the
alias/websocket behavior explicit, preserves contributor helper safety, and
teaches the canonical Gemini path to recover tool names and wrap function
results the same way the OpenAI-shaped path already does.

Constraint: Keep the last review fixes limited to the current branch so the release PR stays easy to reason about
Rejected: Defer the Gemini canonical fix to a later refactor | the review found a real functional mismatch and the fix stayed localized to the request bridge
Confidence: high
Scope-risk: narrow
Directive: Keep canonical-to-provider bridges internally consistent; if one path resolves tool names or wraps results, the sibling path should not diverge silently
Tested: Focused vitest for Gemini request bridge, route aliases, upstream request builder, responses response bridge, contributor updater; typecheck; git diff --check
Not-tested: Full npm test; any broader end-to-end Gemini live-provider behavior outside the covered bridge tests
2026-04-18 13:46:57 +08:00
cita-777 20f06d391c Tighten alias and header edge-cases called out in review
The latest review pass found a few more concrete edge-cases that were still
cheap to close: invalid /responses aliases could still rewrite typos, websocket
session headers should prefer proxy-safe dashed forms, contributor rendering
needed an input guard, and several upstream request/response helpers still had
small contract mismatches around Accept/beta handling and synthesized tool ids.
These fixes keep the release branch behavior explicit while the PR remains open.

Constraint: Keep changes surgical and well-covered so the review cleanup does not destabilize the already-green release branch
Rejected: Leave these for post-merge cleanup | they were still active review threads with straightforward fixes
Confidence: high
Scope-risk: narrow
Directive: When alias routes exist only for ergonomics, reject unknown subpaths loudly instead of silently rewriting them
Tested: Focused vitest for contributor updater, route aliases, upstream request builder, openai responses response bridge; typecheck; git diff --check
Not-tested: Full npm test; the remaining Gemini review thread that likely needs a broader canonical-tool-result contract decision
2026-04-18 13:38:46 +08:00
cita-777 e795f22367 Settle the last concrete review regressions on the release branch
A few review threads still pointed at real behavior mismatches: Anthropic tool
JSON deltas were still trimming whitespace, Responses conversion still raised
low token caps to the floor, and the bare /responses GET alias returned upgrade
messaging that did not match the actual websocket entrypoint. This commit makes
those behaviors explicit and locks them with focused tests.

Constraint: Keep the release branch green while fixing only the review findings that have a clear, testable contract
Rejected: Leave these for a follow-up cleanup PR | they were low-diff and directly blocked comment resolution on the current release PR
Confidence: high
Scope-risk: narrow
Directive: Preserve raw stream delta text unless a downstream protocol explicitly requires normalization; whitespace can be semantic in incremental payloads
Tested: Focused vitest for anthropic stream bridge, responses conversion, route aliases; typecheck; git diff --check
Not-tested: Full npm test; the remaining review threads that still need broader product decisions
2026-04-18 13:28:50 +08:00
cita-777 02520255db Clear remaining low-risk review debt on the release branch
The PR still had a few targeted review threads around contributor pagination,
route-layer passthroughs, dead header fallbacks, and synthetic response ids.
These are small enough to settle directly on the release branch without
broadening the merge scope, and they tighten the repo back toward the
service-owned boundaries this refactor is aiming for.

Constraint: Keep the changes narrow and regression-covered so the release PR stays green while review threads are closed out
Rejected: Leave the minor threads for follow-up after merge | they were isolated enough to fix now and would keep noise on the PR
Confidence: high
Scope-risk: narrow
Directive: When a route shim stops serving any route-local behavior, point callers at the owning service instead of keeping a passthrough around
Tested: Focused vitest for contributor updater, openai responses response bridge, upstream request builder, stats proxy logs, architecture boundaries; typecheck; repo drift check; git diff --check
Not-tested: Full npm test; unresolved review threads that still require larger product decisions
2026-04-18 13:19:29 +08:00
cita-777 4b5d045b00 Address high-priority review gaps before merging the dev snapshot
The release branch still had unresolved review comments around payload-rule
atomicity, brand blocking, compact fallback controls, and response
compatibility edge-cases. This patch pulls those behaviors back in line with
the intended contracts and adds focused regression coverage for the fragile
bridges.

Constraint: The fixes must stay narrow to the PR branch so review/merge can continue without reopening the larger dev snapshot conflict set
Rejected: Leave outdated-looking threads unresolved until after merge | reviewers still see concrete unfixed regressions in the current release branch
Rejected: Re-run another broad merge from dev/main | too risky while the PR branch is already green and mergeable
Confidence: medium
Scope-risk: moderate
Directive: If these release-branch fixes need to persist for future release cuts, backport them onto dev instead of re-fighting the same review threads next time
Tested: Focused vitest for settings events, brand matcher, compact responses, codex compatibility, anthropic stream bridge, openai chat response bridge; typecheck; repo drift check; git diff --check
Not-tested: Full npm test; live PR thread resolution state after push
2026-04-18 13:05:52 +08:00
cita-777 57db7fd107 Keep PR audit informational without leaving the check red
The CI workflow already described production dependency audit as an
informational PR signal, but the job still surfaced as a failing check.
That left release PRs in an unstable state even after the real merge-gate
jobs were green. This keeps PR audits visible as warnings while still
letting main-branch pushes fail if production dependency audit actually
returns a non-zero exit.

Constraint: Preserve production audit visibility while avoiding false-red PR status
Rejected: Remove audit from CI entirely | loses vulnerability visibility
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep audit informational on pull requests unless branch protection is intentionally tightened later
Tested: YAML parse via ruby; git diff --check
Not-tested: Full GitHub Actions rerun pending after push
2026-04-17 23:36:51 +08:00
cita-777 4aa888e581 Repair release-branch CI after folding dev onto main
The release branch inherited a few merge-shaped regressions after the
large dev snapshot was layered onto the latest main snapshot. This
commit restores the main-compatible stats and TokenRoutes behavior the
CI suite expects and adds the missing multi-brand helper export so the
release branch reflects a coherent post-merge tree instead of a
partially regressed hybrid.

Constraint: Must keep the release branch mergeable to main while preserving the current publish snapshot shape
Rejected: Re-open the whole release merge and hand-resolve every file again | too broad for targeted CI repair
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: When dev and main have drifted heavily, re-run the main-facing CI tests after any conflict normalization pass
Tested: targeted vitest suite for failing CI jobs plus update-center coverage; typecheck
Not-tested: Full npm test still not representative locally because of unrelated untracked tmp/sub2api suites outside the PR branch content
2026-04-17 23:26:22 +08:00
cita-777 f335a330e3 Prepare the dev snapshot for main review on a clean release branch
This release branch starts from the latest origin/main and folds in the
current origin/dev snapshot so the PR to main can follow the repo's
publish flow without opening directly from dev. Conflict-heavy files
were normalized back to the dev snapshot where necessary so the release
branch reflects the code currently being carried on dev instead of a
line-merged hybrid.

Constraint: Publication flow requires a clean release branch from latest origin/main rather than a PR directly from dev
Constraint: The branch must preserve the current dev snapshot even when main and dev have drifted substantially
Rejected: Open the PR directly from dev | violates the repo publication flow
Rejected: Manually redesign every historic dev/main conflict during release prep | too broad for a publish branch
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Treat this branch as a publication snapshot of dev-on-top-of-main, not as a general cleanup pass over old divergence
Tested: update-center focused vitest suite on release branch; typecheck; build:web; docs:build; repo:drift-check
Not-tested: Full npm test remains red from inherited tmp/sub2api frontend suites and an existing firstByteTimeout unhandled error
2026-04-17 22:58:06 +08:00
cita-777 87fb5149e6 Let operators deploy dev Docker Hub tags from the update center
The update center previously surfaced stable Docker Hub candidates but
left dev and branch-tag rollouts stranded in SSH and Helm commands.
This adds a repo-native manual Docker tag entrypoint in Settings so
operators can deploy dev, branch, temporary, or sha tags through the
existing helper flow without changing the backend deploy contract.

Constraint: Keep the existing helper API and digest-aware Helm contract unchanged
Constraint: Preserve stable Docker Hub candidate discovery for default reminders
Rejected: Expand automatic Docker Hub discovery to all branch tags | would blur stable release reminders with operator-only rollout targets
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep automatic candidate detection stable-first; manual tag deploy is the escape hatch for non-stable tags
Tested: update-center focused vitest suite; typecheck; build:web; docs:build; repo:drift-check
Not-tested: Full npm test remains red due to unrelated tmp/sub2api frontend suites and an existing firstByteTimeout unhandled error
2026-04-17 22:43:47 +08:00
cita-777 f67ade2e59 Keep dev green after moving the active worktree snapshot
The transferred dev snapshot was not publication-ready yet: full tests still
failed because worktree-local docs resolution assumed a repo-root node_modules
tree, some protocol expectations no longer matched the refactored request
bridges, oauth/account-token route tests reused stale modules after resetting
DATA_DIR, and model discovery still fired unawaited default-token writes.

This follow-up makes the moved dev branch self-consistent and green again by
stabilizing module/test isolation, awaiting token mirroring writes, and
aligning narrow tests with the current transformer and request-builder
contracts.

Constraint: The fix set had to stay narrow and unblock the new dev branch without reworking the broader transferred refactor
Rejected: Revert the transferred snapshot | would lose the user's requested dev-branch continuation point
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: If more DATA_DIR-scoped API test files start flaking under shared workers, reset modules before importing singleton db/config modules instead of relying on process env mutation alone
Tested: git diff --check; npm run typecheck; npm run repo:drift-check; npm test
Not-tested: Live OAuth/provider flows outside the mocked test harness
2026-04-17 19:59:14 +08:00
cita-777 88f40e91f7 Preserve the active worktree snapshot on dev for follow-on integration
The root checkout already contained a large in-progress proxy, transformer,
and settings refactor. This commit captures that exact work on a dedicated
local dev branch without disturbing the original dirty worktree, so the
changes can be continued or split from dev instead of living only as
uncommitted state.

Constraint: The source checkout was already dirty on another branch, so the move had to preserve that worktree while excluding local .idea and .omx residue from the commit
Rejected: Switch the dirty root checkout directly to dev | risked disrupting the original branch and in-progress state
Confidence: medium
Scope-risk: broad
Reversibility: clean
Directive: Treat this as a transfer snapshot; fix the failing oauth/accountTokens/normalized/docs test regressions before merge or publication
Tested: git diff --check; npm run typecheck; npm run repo:drift-check
Not-tested: Clean test pass; npm test currently fails in oauth routes, accountTokens sync, shared normalized helpers, gemini route expectations, architecture-boundaries, and docs vitepress config
2026-04-17 19:41:14 +08:00
Cita a2c2ae614a [codex] expose more payload-rule upstream types (#474)
* Expose more payload-rule upstream types in Settings

The first payload-rules UI only offered a narrow set of protocol labels, which
made live troubleshooting confusing once real playground traffic routed through
platforms like sub2api instead of codex. This expands the selectable upstream
types, extracts the option list into one small helper, and renames the field to
上游类型 so the UI matches what the runtime matcher actually uses.

Constraint: Must preserve the existing payload-rules schema key (`protocol`) even though the UI wording is now upstream-type oriented
Rejected: Leaving the dropdown codex/openai-only and telling operators to hand-edit JSON | too easy to misconfigure during live routing diagnosis
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If new canonical site platforms are added, update payloadRuleProtocolOptions.ts so the visual editor stays aligned with runtime matching
Tested: npx vitest run --root . src/web/pages/settings.payload-rules.test.tsx src/web/pages/settings.payload-rule-platform-options.test.ts
Tested: npm run typecheck:web
Tested: npm run typecheck:web:test
Tested: npm run repo:drift-check
Not-tested: Manual dropdown interaction in a browser

* Keep payload-rule upstream types aligned with one-api backend support

Code review caught that the visual editor still omitted one-api even though the
backend continues to treat it as a valid upstream platform. This adds the
missing option and extends the focused option-set test so operators can create
payload rules for one-api without dropping to raw JSON.

Constraint: Must preserve the runtime payload-rules schema and only close the UI/backend option gap
Rejected: Removing one-api from the backend in this PR | unrelated behavioral change and broader migration risk
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When backend platform support changes, update payloadRuleProtocolOptions.ts and its focused test in the same PR
Tested: npx vitest run --root . src/web/pages/settings.payload-rules.test.tsx src/web/pages/settings.payload-rule-platform-options.test.ts
Tested: npm run typecheck:web
Tested: npm run typecheck:web:test
Tested: npm run repo:drift-check
Not-tested: Manual browser check of the expanded dropdown list
2026-04-10 16:13:56 +08:00
Cita 9c766dec1c [codex] add payload rules settings UI (#473)
* Make payload request overrides configurable from Settings

The payload-rules engine already existed in the proxy path, but operators had
no supported runtime UI to manage CPA-style overrides. This wires the setting
through the runtime settings API, adds strict validation, and ships a
low-friction visual editor with an advanced JSON fallback so common cases like
Codex high reasoning can be configured without hand-editing backend state.

Constraint: Must reuse the existing payload-rules execution path and settings table without schema changes
Constraint: Must fit the existing metapi Settings-page patterns rather than introducing a standalone config surface
Rejected: JSON-only editor as the primary UX | too long and too easy to misconfigure for common cases
Rejected: A brand-new backend rule format for the UI | would duplicate the existing payload-rules source of truth
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep visual-rule serialization and advanced JSON editing in sync before extending the rule model
Tested: npm run typecheck
Tested: npx vitest run --root . src/server/services/payloadRules.test.ts src/server/routes/api/settings.events.test.ts src/web/pages/settings.payload-rules.test.tsx
Tested: npm run repo:drift-check
Not-tested: Manual browser interaction smoke on the Settings page in this clean PR worktree

* Prevent payload-rules saves from violating review expectations

Code review surfaced two operator-facing edge cases in the first payload-rules
PR: successful rule parsing could still be committed before a later validation
error, and visual edits could silently overwrite unsaved advanced JSON. This
stages payload-rules until the request finishes validating and adds an explicit
confirmation gate before visual actions discard unsynced advanced edits.

Constraint: Keep the fix narrow to the reviewed payload-rules feature without refactoring unrelated runtime-settings atomicity behavior
Rejected: Silently skipping draft resync while keeping visual edits active | would leave save semantics ambiguous when advanced JSON stays dirty
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If the settings endpoint ever moves toward full request atomicity, re-check this staged payload-rules write path against the broader strategy
Tested: npm run typecheck
Tested: npx vitest run --root . src/server/routes/api/settings.events.test.ts src/web/pages/settings.payload-rules.test.tsx
Tested: npm run repo:drift-check
Not-tested: Manual browser smoke of the confirmation dialog text
2026-04-10 14:27:43 +08:00
Cita 2f774e204c Keep MySQL usage aggregation from crashing startup (#471)
The usage aggregation projector was written with SQLite/Postgres conflict builders, but Render can switch the runtime DB to MySQL from saved settings. That made the startup scheduler throw before the service stayed healthy.

This change branches the projector's checkpoint and aggregate writes to MySQL-safe onDuplicateKeyUpdate calls while preserving the existing SQLite/Postgres onConflict behavior. A mocked MySQL regression test now omits the onConflict builders entirely so future regressions fail fast.

Constraint: Drizzle conflict APIs are dialect-specific and the projector starts during boot

Rejected: Disable the projector on MySQL | would keep admin analytics stale and hide the real incompatibility

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: New usage-aggregation writes must branch on runtimeDbDialect before using conflict helpers

Tested: npx vitest run --root . src/server/services/usageAggregationService.test.ts src/server/services/usageAggregationService.mysql.test.ts

Tested: npm run typecheck:server

Tested: npm run repo:drift-check

Tested: npm run build:server

Not-tested: Live MySQL projector pass against a deployed runtime database
2026-04-10 12:41:08 +08:00
Cita 8cf78a5354 [codex] smooth route detail panel motion (#467)
* Keep route-detail motion smooth while restoring a more panel-like reveal feel

The route page detail panel originally reused the shared collapse container,
which looked lively but reintroduced layout-heavy motion inside the desktop
route grid. This change keeps the non-layout presence shell, then tunes the
panel animation itself so it reveals from the top with a softer, more
trace-panel-like feel without falling back to grid row interpolation.

Constraint: The desktop route detail panel sits inside a multi-column CSS grid and must avoid layout-driven collapse animation
Rejected: Reuse anim-collapse exactly as ProxyLogs does | reopening grid-template-rows animation brought back jank on route expand/collapse
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Preserve transform/clip-path-based motion here unless grid placement is redesigned; do not reintroduce layout collapse without profiling
Tested: npm test -- src/web/pages/tokenRoutes.desktop-detail-panel.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/token-routes/RouteFilterBar.test.tsx
Tested: npm run typecheck:web
Tested: npm run typecheck:web:test
Tested: npm run repo:drift-check
Not-tested: manual GitHub review in production browser after PR creation

* Keep route-detail motion PR lint-clean without changing behavior

CodeRabbit correctly flagged the new keyframes for using from/to selector
notation. This follow-up keeps the animation timing and properties the same
while switching to percentage selectors so the stylesheet aligns with the
configured keyframe selector rule.

Constraint: Keep the route-detail panel motion unchanged while satisfying the style rule mentioned in PR review
Rejected: Ignore the review comment | leaves a stylelint-style regression in the PR discussion unresolved
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When adding new keyframes in this stylesheet, prefer explicit percentage selectors to avoid repeating this lint issue
Tested: npm test -- src/web/pages/tokenRoutes.desktop-detail-panel.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/token-routes/RouteFilterBar.test.tsx
Tested: npm run typecheck:web
Tested: npm run repo:drift-check
Not-tested: remote Git transport from this machine still hangs, so branch update was handled separately from local git push

* Respect reduced-motion and narrow layer promotion for route detail panel

The new route detail panel motion path solved grid-driven jank, but the follow-up
review correctly flagged two polish issues: the animation should honor reduced
motion, and will-change should only stay enabled during active transitions. This
keeps the smoother motion path while reducing persistent layer cost and giving
reduced-motion users immediate state changes.

Constraint: Must preserve the non-layout animation path that fixed dropped frames in the route grid
Rejected: Restore anim-collapse behavior | reintroduces grid/layout-driven jank
Confidence: high
Scope-risk: narrow
Directive: Keep desktop route detail motion on transform and clip-path rather than grid height animation unless the panel moves out of the card grid
Tested: npm test -- src/web/pages/tokenRoutes.desktop-detail-panel.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/token-routes/RouteFilterBar.test.tsx
Tested: npm run typecheck:web
Tested: npm run typecheck:web:test
Tested: npm run repo:drift-check
Not-tested: Manual browser validation of prefers-reduced-motion on macOS

* Honor reduced-motion semantics in route detail panel lifecycle

The previous follow-up removed the always-on layer hint and added reduced-motion
CSS, but the enter state still reused the long-lived open class and the desktop
close path kept a fixed JS delay. This narrows will-change to a transient
entering state, bypasses close delays for reduced-motion users, and locks the
behavior with a regression test.

Constraint: Must preserve the non-layout detail-panel motion path that fixed grid jank
Rejected: Keep reduced-motion as CSS-only | JS close timer still delayed unmount and active-state cleanup
Rejected: Drop the close delay globally | would remove the intended close animation for standard-motion users
Confidence: high
Scope-risk: narrow
Directive: If the desktop detail panel motion changes again, keep motion-state CSS and JS timers aligned for prefers-reduced-motion users
Tested: npm test -- src/web/pages/tokenRoutes.desktop-detail-panel.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/token-routes/RouteFilterBar.test.tsx
Tested: npm run typecheck:web
Tested: npm run typecheck:web:test
Tested: npm run repo:drift-check
Not-tested: Manual browser verification of prefers-reduced-motion on macOS
2026-04-10 11:01:24 +08:00
Cita c6a28c71c4 [codex] improve LobeHub brand detection coverage (#464)
* Recognize more LobeHub-backed model brands so marketplace filters stop collapsing into other

Metapi was leaving many models under the uncategorized bucket even though the
current LobeHub icon set already contains matching provider or vendor brands.
This change moves brand detection into one shared registry, expands the rules
with the user-reported gaps that have real upstream icon coverage, and makes
server-side brand blocking reuse the same classification result as the web UI.

Constraint: Existing workspace has unrelated in-progress edits, so this commit stages only the brand-classification slice
Constraint: Brand matching must stay consistent between marketplace display and server-side blocked-brand filtering
Rejected: Add more ad hoc rules separately in web and server files | would keep the drift bug alive
Rejected: Blindly import every LobeHub slug as a model brand | would pollute filters with tool and app brands
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep new brand additions in src/server/shared/modelBrand.ts and let web/server consume that single source of truth
Tested: npm test -- src/web/components/BrandIcon.test.ts src/server/services/brandMatcher.test.ts src/web/pages/Models.marketplace-text.test.tsx src/web/components/BrandIcon.architecture.test.ts src/web/components/BrandIcon.render.test.tsx
Tested: npm run typecheck
Tested: npm run repo:drift-check
Not-tested: Live browser verification against the user's current marketplace dataset after deployment

* Preserve provider brand blocking without changing display-brand priority

The first pass unified brand detection but server-side blocked-brand checks
started relying on the single display brand. Mixed model paths such as
`deepinfra/meta-llama/...` and `azureai/gpt-4o` still need provider markers to
match global blocked brands even while the UI continues to show the preferred
vendor label.

Constraint: Marketplace display should keep the current preferred vendor-first brand label
Constraint: Server-side blocked-brand matching must still recognize provider and vendor markers on the same model id
Rejected: Reorder display-brand priority to make provider blocks pass | would regress the marketplace labels the PR was improving
Rejected: Reintroduce a separate server-only matcher | would recreate the web/server drift this PR removed
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Use getMatchingBrandNames for blocking-style checks and reserve getBrand for single-label display surfaces
Tested: npm test -- src/server/shared/modelBrand.test.ts src/server/services/brandMatcher.test.ts src/web/components/BrandIcon.test.ts src/web/pages/Models.marketplace-text.test.tsx src/web/components/BrandIcon.architecture.test.ts src/web/components/BrandIcon.render.test.tsx
Tested: npm run typecheck
Tested: npm run repo:drift-check
Not-tested: Live GitHub review-thread auto-resolution behavior after push

* Honor legacy blocked brand casing when canonicalizing global brand filters

The shared brand matcher now supports multi-match provider blocking, but the
blocked-brand rule parser still required exact display-case brand names. That
meant imported or legacy settings like `openai` or `aws bedrock` were silently
ignored even though they should map to known brands.

Constraint: Existing saved settings may contain lower-case or whitespace-variant brand names from imports or older data
Constraint: The server should keep emitting canonical display names after normalizing user-provided blocked brand entries
Rejected: Assume the settings UI always writes canonical brand strings | does not cover imported or hand-edited configuration
Rejected: Normalize only at the settings route | would still leave hydrated legacy config values ineffective in modelService
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep blocked-brand parsing tolerant of case and spacing drift, but always return canonical brand names for downstream use
Tested: npm test -- src/server/services/brandMatcher.test.ts src/server/shared/modelBrand.test.ts src/web/components/BrandIcon.test.ts src/web/pages/Models.marketplace-text.test.tsx src/web/components/BrandIcon.architecture.test.ts src/web/components/BrandIcon.render.test.tsx
Tested: npm run typecheck
Tested: npm run repo:drift-check
Not-tested: Manual settings UI round-trip with legacy lowercase globalBlockedBrands payloads
2026-04-09 21:43:43 +08:00
Cita baead855dd Make admin reads snapshot-first for faster ops pages (#457)
* Make admin reads snapshot-first so heavy pages stay responsive

This introduces a lightweight snapshot/cache layer for the busiest
admin reads, splits dashboard and proxy-log loading into smaller
independent surfaces, and moves accounts/site/dashboard snapshots
toward service-owned read paths.

The UI now loads dashboard summary, dashboard insights, site stats,
accounts, and proxy-log metadata as separate progressive reads while
keeping existing feature coverage intact.

Constraint: Must keep the full admin feature set and core metrics intact
Constraint: Interactive admin flows should trend toward <=3s without relying on OpenResty-only band-aids
Rejected: Delete heavy pages or reduce statistics depth | user explicitly requires unchanged functionality
Rejected: Keep one giant proxy-log/dashboard response | preserves current timeout-prone read shape
Confidence: medium
Scope-risk: moderate
Directive: Preserve old endpoints during the migration and keep new snapshot/query-v2 surfaces as the preferred frontend path
Tested: npm run typecheck
Tested: npx vitest run --root . src/server/routes/api/accounts.snapshot-v2.test.ts src/server/routes/api/stats.snapshot-v2.test.ts src/server/routes/api/stats.proxy-logs.test.ts src/server/routes/api/stats.siteStatus.test.ts src/server/routes/api/accounts.todayRewardFallback.test.ts
Tested: npm run build
Tested: npm run repo:drift-check
Not-tested: Live cita/cita1 latency measurements against the production dataset

* Keep snapshot services lean after the admin read-path rollout

The snapshot-first rollout now has fresh production evidence on cita1, so this follow-up trims two no-op patterns without changing behavior: a single-element Promise.all and a catch-then-rethrow refresh chain. Keeping the service layer lean reduces noise before final Ralph sign-off and redeploy verification.

Constraint: Keep deployed behavior unchanged while satisfying the mandatory deslop pass
Rejected: Wider refactor across route files | would broaden the verified surface right before production comparison closeout
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep snapshot services focused on cache/read orchestration; avoid reintroducing decorative async wrappers
Tested: npm run typecheck; npm run build; targeted vitest snapshot/proxy-log/account suites; npm run repo:drift-check
Not-tested: Public Cloudflare path, which still appears to hit a different origin than cita1

* Keep admin snapshots warm without introducing Redis

This moves the new admin read path beyond process-local caching by persisting
snapshot payloads in a dedicated admin_snapshots table and warming them in
the background. It also migrates remaining lightweight internal callers away
from the heaviest legacy reads and marks legacy endpoints as deprecated
instead of deleting them abruptly.

Constraint: User explicitly rejected adding Redis for admin snapshot acceleration
Rejected: Redis-backed shared cache | adds a forbidden runtime dependency
Rejected: Delete legacy endpoints immediately | still breaks tests, docs, and compatibility callers
Confidence: high
Scope-risk: moderate
Directive: Remove legacy admin endpoints only after internal callers, docs, and compatibility windows are cleared
Tested: npm run typecheck
Tested: npm run build
Tested: npx vitest run --root . src/server/services/adminSnapshotStore.test.ts src/server/routes/api/accounts.snapshot-v2.test.ts src/server/routes/api/stats.snapshot-v2.test.ts src/server/services/startupInfo.test.ts
Tested: npm run repo:drift-check
Not-tested: cita1 staging redeploy on this follow-up commit

* Collapse admin reads onto the canonical API paths

This removes the parallel v2 read surface and routes the snapshot-first
implementation back through the original admin API names. The frontend now
uses the canonical paths again while the backend keeps the newer snapshot,
persistence, and warm-refresh machinery behind those routes. Along the way it
also fixes the main review findings around cache coalescing, active-site scope,
atomic snapshot writes, and auth-header handling.

Constraint: User requested no Redis and no long-lived duplicate admin read endpoints
Rejected: Keep both legacy and v2 endpoints indefinitely | doubles maintenance and confuses the canonical contract
Rejected: Delete old names without remapping callers | would create avoidable frontend and tooling churn
Confidence: high
Scope-risk: moderate
Directive: Treat /api/stats/dashboard, /api/accounts, /api/stats/proxy-logs, and the site stats routes as the canonical admin read contract unless a future breaking-change plan says otherwise
Tested: npm run typecheck
Tested: npm run build
Tested: npx vitest run --root . src/server/services/adminSnapshotStore.test.ts src/server/routes/api/accounts.snapshot-v2.test.ts src/server/routes/api/stats.snapshot-v2.test.ts src/server/routes/api/stats.proxy-logs.test.ts src/server/services/startupInfo.test.ts
Tested: npm run repo:drift-check
Not-tested: cita1 staging redeploy on this canonical-route follow-up commit

* Keep canonical admin analytics fast without raw log scans

This lands the usage-aggregate path as the single implementation behind the canonical admin routes, adds the aggregate schema/migration artifacts, and reconnects dashboard/site stats warming to the new service so the PR no longer sits in a half-projector state.

It also updates the cache and aggregate tests to reflect the real runtime behavior and validates the current branch on cita1 staging, which showed the app-side snapshot path is fast while the remaining 1.5s staging slowness lives on the NodePort path rather than inside the aggregate queries themselves.

Constraint: Must preserve the canonical old-path admin API contract while moving reads onto aggregate-backed snapshots
Constraint: Must not add Redis or a parallel second analytics implementation
Rejected: Keep usageAggregationProjectorService as a separate path | would preserve duplicate ownership and keep type wiring split
Rejected: Judge staging performance only from NodePort timings | hid that pod-local snapshot reads were already fast
Confidence: medium
Scope-risk: moderate
Directive: If dashboard or insights feels slow again on staging, compare NodePort and pod-local timings before changing aggregate queries
Tested: npm run typecheck; npx vitest run --root . src/server/services/adminSnapshotStore.test.ts src/server/services/usageAggregationService.test.ts src/server/services/snapshotCacheService.test.ts src/server/routes/api/accounts.snapshot-v2.test.ts src/server/routes/api/stats.snapshot-v2.test.ts src/server/routes/api/stats.proxy-logs.test.ts src/server/services/startupInfo.test.ts; npm run build; npm run repo:drift-check; cita1 staging deploy plus canonical-route curl benchmarks
Not-tested: cita1 real ingress/OpenResty path; multi-replica projector concurrency

* Prevent admin aggregate drift under concurrent warmers

This follow-up hardens the snapshot-first admin analytics path by adding a persisted projector lease, exact 24-hour dashboard counting, and regression coverage for lease contention and time-window behavior.

The goal is to keep the canonical admin routes correct when multiple workers are alive and to avoid quietly overstating proxy24h by reading hour buckets as if they were an exact sliding window.

Constraint: Projector coordination must work through the existing checkpoint table without adding Redis or a second background system
Constraint: Schema artifacts must stay additive under the repo's generated bootstrap/upgrade pipeline
Rejected: Remove the left-prefix day/hour indexes in this slice | schema:contract rejects non-additive index removals, so that cleanup needs a broader migration strategy
Confidence: high
Scope-risk: moderate
Directive: Keep projector claims DB-backed; do not regress to process-local inFlight-only dedupe for aggregate writes
Tested: npm run typecheck; npx vitest run --root . src/server/services/usageAggregationService.test.ts src/server/routes/api/stats.snapshot-v2.test.ts; npm run build; npm run repo:drift-check
Not-tested: Multi-host live contention beyond synthetic lease test; resolving CodeRabbit index-nit without additive-schema support

* Keep CI aligned with canonical admin snapshots

The admin snapshot cutover left a mixed test surface: MySQL schema artifacts still
classified admin_snapshots.snapshot_key as JSON, canonical /api/accounts callers
were asserted as arrays, and a few proxy tests mocked routeRefreshWorkflow too
narrowly, which leaked missing exports into later OAuth runs.

This commit narrows the schema metadata heuristic, updates the checked-in schema
artifacts and their regression tests, adds lightweight web test compat helpers
for canonical snapshot APIs, and preserves actual routeRefreshWorkflow exports so
full-suite workers no longer poison unrelated tests. OAuth/account token suites
also reset DATA_DIR-backed modules safely to avoid stale singleton config.

Constraint: checked-in schema artifacts drive cross-dialect CI parity checks
Constraint: vitest module mocks can leak across files in the same worker when exports are omitted
Rejected: rerun schema:generate only | additive guard rejects the logical-type correction for snapshot_key
Rejected: keep per-test ad hoc snapshot mocks | repeated breakage across pages needed one compat helper
Confidence: high
Scope-risk: moderate
Directive: when a test uses vi.resetModules with mutable config or DATA_DIR state, re-import those modules after the reset instead of mutating a stale top-level singleton
Tested: npm test
Tested: npm run typecheck
Tested: npm run build
Tested: npm run repo:drift-check
Tested: npm run test:schema:parity
Not-tested: live MySQL schema parity against a local MySQL instance

* Keep CodeQL from re-flagging already-limited routes

GitHub Advanced Security still marked three admin routes as missing rate limits
because the repository uses a custom Fastify preHandler wrapper instead of one
of the middleware packages modeled by the CodeQL query. The routes are already
throttled in production, so this change adds narrow in-source suppressions on the
three affected handlers to record that the check is a false positive instead of a
real missing guard.

Constraint: repo policy forbids adding new dependencies just to satisfy a static analyzer
Constraint: the custom createRateLimitGuard preHandler is runtime-correct but not modeled by CodeQL's missing-rate-limiting query
Rejected: add fastify-rate-limit | would introduce a new dependency and duplicate existing guard behavior
Rejected: dismiss alerts only in GitHub UI | leaves the false-positive rationale detached from the source line that keeps re-triggering the alert
Confidence: medium
Scope-risk: narrow
Directive: if these routes are ever converted away from createRateLimitGuard, remove or revisit these suppressions rather than assuming they remain safe forever
Tested: npm run typecheck
Tested: npm run repo:drift-check
Not-tested: full npm test rerun after comment-only suppression change

* Use legacy CodeQL suppression syntax for custom rate guards

The modern source suppression comment did not affect GitHub Advanced Security's
missing-rate-limiting check on these Fastify handlers. Switch the three known
false-positive locations to the legacy LGTM suppression syntax, keeping the
custom preHandler throttling in place while matching the syntax that CodeQL
still honors for in-source suppressions.

Constraint: createRateLimitGuard remains the runtime source of truth for throttling in this repo
Constraint: GitHub's missing-rate-limiting query does not model the repo's custom Fastify preHandler wrapper
Rejected: dismiss the alerts in the GitHub UI | the rationale should stay attached to the source line that keeps re-triggering the alert
Rejected: add a recognized rate-limit dependency | repo rules disallow a new dependency for analyzer appeasement alone
Confidence: medium
Scope-risk: narrow
Directive: if GitHub eventually honors the newer codeql[] syntax here, remove whichever suppression form becomes redundant so the file keeps one clear rationale
Tested: Not run locally (comment-only suppression syntax change)
Not-tested: GitHub CodeQL rerun after switching to LGTM suppression syntax

* Keep admin routes clean after CodeQL false-positive triage

The repository's custom createRateLimitGuard preHandlers are the actual runtime
throttles for these routes. Since GitHub Advanced Security still did not honor
the attempted in-source suppressions, remove the temporary comments and keep the
source focused on runtime behavior while the false-positive alerts are tracked
operationally in GitHub.

Constraint: the custom Fastify preHandler remains the only real throttling mechanism in this repo
Rejected: leave non-functional suppression comments in source | they imply enforcement that GitHub did not actually honor
Confidence: high
Scope-risk: narrow
Directive: if CodeQL adds custom Fastify preHandler modeling later, prefer removing any alert dismissals over reintroducing comment noise here
Tested: Not run locally (comment-only cleanup)
Not-tested: GitHub checks after alert dismissal
2026-04-09 21:31:13 +08:00
cita-777 6cca74b039 Recognize more LobeHub-backed model brands so marketplace filters stop collapsing into other
Metapi was leaving many models under the uncategorized bucket even though the
current LobeHub icon set already contains matching provider or vendor brands.
This change moves brand detection into one shared registry, expands the rules
with the user-reported gaps that have real upstream icon coverage, and makes
server-side brand blocking reuse the same classification result as the web UI.

Constraint: Existing workspace has unrelated in-progress edits, so this commit stages only the brand-classification slice
Constraint: Brand matching must stay consistent between marketplace display and server-side blocked-brand filtering
Rejected: Add more ad hoc rules separately in web and server files | would keep the drift bug alive
Rejected: Blindly import every LobeHub slug as a model brand | would pollute filters with tool and app brands
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep new brand additions in src/server/shared/modelBrand.ts and let web/server consume that single source of truth
Tested: npm test -- src/web/components/BrandIcon.test.ts src/server/services/brandMatcher.test.ts src/web/pages/Models.marketplace-text.test.tsx src/web/components/BrandIcon.architecture.test.ts src/web/components/BrandIcon.render.test.tsx
Tested: npm run typecheck
Tested: npm run repo:drift-check
Not-tested: Live browser verification against the user's current marketplace dataset after deployment
2026-04-09 19:43:47 +08:00
cita-777 a3886324ca Merge remote-tracking branch 'origin/main' 2026-04-09 19:18:39 +08:00
cita-777 7d883a6ada update readme 2026-04-09 19:17:01 +08:00
dependabot[bot] 5e27684a1c chore(deps): bump the minor-and-patch group across 1 directory with 6 updates (#451)
---
updated-dependencies:
- dependency-name: "@visactor/react-vchart"
  dependency-version: 2.0.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: dotenv
  dependency-version: 17.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: minimatch
  dependency-version: 10.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: electron
  dependency-version: 41.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: jsdom
  dependency-version: 29.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: mermaid
  dependency-version: 11.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 11:16:28 +08:00
Cita 03bebae17f fix oauth route unit delete rollback (#450) 2026-04-09 11:15:57 +08:00
puyujian 569a15e14c feat: enhance API key management and fix routing logic
* ```
feat(accounts): 支持批量添加API密钥连接

- 添加accessTokens字段到账户创建载荷模式,允许传递多个API密钥
- 实现批量API密钥解析功能,支持换行符、空格和逗号分隔
- 重构账户创建逻辑以支持单个和批量API密钥处理
- 在前端界面中添加批量API密钥输入检测和处理
- 添加批量创建结果返回和错误处理
- 更新验证流程以支持批量模式下的逐条验证
- 添加详细的批量API密钥测试用例

该更改允许用户一次提交多个API密钥,系统将为每个密钥创建独立的连接并参与负载均衡。
```

* ```
fix(tokenRouter): 调整路由匹配优先级以优先匹配显示名称别名

当精确路由和显式组显示名称冲突时,现在优先选择显式组显示名称
而不是精确路由模式。这修正了路由决策逻辑,使显式组路由能够正
确覆盖精确模型匹配。

相关的测试用例已更新以反映新的匹配行为,确保显式组路由在冲
突情况下获得更高优先级。
```

* ```
fix(accounts): 修复API密钥验证错误消息显示逻辑

当凭证模式不为apikey时显示会话令牌提示信息,否则显示API密钥验证失败信息

feat(accounts): 更新API密钥输入界面提示文本

将批量添加说明从输入框占位符移至独立提示文本,
使界面更清晰易懂
```

* ```
feat(workflow): 在CI/CD工作流中使用动态Docker Hub镜像名称

- 在ci.yml和release.yml工作流中添加DOCKERHUB_IMAGE环境变量
- 将硬编码的Docker Hub用户名替换为从secrets.DOCKERHUB_USERNAME动态获取
- 更新docker.metadata-action配置以使用环境变量中的镜像名称
- 添加测试用例验证Docker Hub镜像名称的正确配置
```

* fix(accounts): 修复批量创建回归并下沉创建流程
2026-04-07 14:28:25 +08:00
dependabot[bot] 5cab6c83f1 chore(deps-dev): bump react-router-dom from 7.13.2 to 7.14.0
Bumps the routing group with 1 update: [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom).


Updates `react-router-dom` from 7.13.2 to 7.14.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: routing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 14:28:13 +08:00
dependabot[bot] 2f2ebcb37c chore(deps-dev): bump @types/nodemailer from 7.0.11 to 8.0.0
Bumps [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) from 7.0.11 to 8.0.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

---
updated-dependencies:
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 14:28:04 +08:00
cita-777 63c435c901 fix update center release tests for 1.3.0 2026-04-06 15:45:58 +08:00
cita-777 f18aa98888 fix desktop release packaging rebuild 2026-04-06 15:37:22 +08:00
cita-777 566501be1f bump version to 1.3.0 2026-04-06 15:19:18 +08:00
Cita 42f9049aff [codex] align antigravity special-model non-stream path with CPA (#444)
* align antigravity special-model non-stream path with cpa

* fix antigravity tail aggregation review feedback

* fix antigravity fallback aggregation review feedback
2026-04-06 15:03:55 +08:00
Cita 448f488f95 [codex] improve oauth route unit feedback (#445)
* feat: improve oauth route unit feedback

* fix: harden oauth route unit feedback flow
2026-04-06 15:03:33 +08:00
Cita d1b42b2236 fix: support oauth route unit inserts on postgres (#443)
* fix proxy debug undici response header capture

* fix: support oauth route unit inserts on postgres
2026-04-06 13:39:05 +08:00
Cita 2b9200ea7c fix proxy debug undici response header capture (#442) 2026-04-06 13:16:46 +08:00
Cita 2f0e63a1e0 [codex] add oauth route pools and proxy save flow (#440)
* feat: add oauth route pools and proxy save flow

* fix: align oauth route pool schema and rate limits

* fix: harden oauth route pool review fixes

* fix: address oauth ci regressions

* fix: close oauth codeql alerts

* fix: harden oauth route unit recovery

* fix: split oauth route limiters for codeql

* fix: address remaining oauth review comments
2026-04-06 11:55:20 +08:00
Cita a789ddf7f8 [codex] add sub2api managed refresh resilience (#441)
* add sub2api managed refresh resilience

* serialize sub2api managed refresh per account

* bound sub2api refresh scheduler work

* tighten sub2api refresh scheduler rules
2026-04-06 11:43:30 +08:00
Cita 3edeb6e245 feat: refine OAuth proxy controls and quota UI (#433)
* feat: refine oauth management proxy controls

* fix: preserve oauth refresh state and tighten oauth rate limiting

* fix: satisfy codeql oauth rate limiting

* fix: address oauth review comments

* fix: move oauth sensitive limits into prehandlers

* fix: cap oauth quota refresh batch size
2026-04-06 08:50:19 +08:00
Cita 287c5aafd6 fix: localize downstream key trend timestamps (#432)
* fix: localize downstream key trend timestamps

* fix: keep downstream trend metadata on empty responses

* fix: cursor paginate downstream key trend reads

* fix: normalize empty downstream trend timezones

* fix: localize windowed downstream trend buckets
2026-04-06 08:46:40 +08:00
Cita 3e8d69bed6 fix codex compact non-stream accept header (#439) 2026-04-06 08:46:27 +08:00
Cita 38721cb890 [codex] support downstream key site and credential exclusions (#431)
* support downstream key site and credential exclusions

* skip electron binary download in non-desktop ci

* fix migrate partial-journal test for 0020
2026-04-05 23:43:07 +08:00
Cita 492f43c5dc [codex] add oauth pre-refresh resilience (#430)
* oauth pre-refresh resilience

* address oauth refresh review comments
2026-04-05 22:58:50 +08:00
Cita 6feda0e7ba [codex] fix downstream client detection boundaries (#429)
* fix downstream client detection boundaries

* address downstream client detection review comments
2026-04-05 22:38:00 +08:00
Cita 193f3e7181 [codex] fix proxy log usage source metadata (#428)
* fix proxy log usage source metadata

* fix ci follow-up comment regressions
2026-04-05 21:57:08 +08:00
Cita c10b1e7bca [codex] polish settings transport and probe cards (#427)
* polish settings transport and probe cards

* restore compact responses fallback control
2026-04-05 21:47:49 +08:00
Cita d5560fb036 [codex] normalize primary site URL handling (#424)
* normalize primary site url handling

* fix site create redirect test selector

* fix sub2api gemini model discovery and site detect race

* fix sub2api antigravity fallback and test selectors

* fix site modal wording test

* guard blur detect against manual platform changes
2026-04-05 20:09:34 +08:00
Cita df75a80bf9 Fix responses compact fallback handling (#426)
* fix route drag overlay alignment

* fix(web): address route drag review comments

* fix route card review follow-up

* Fix responses compact fallback handling

* Fix compact endpoint candidate typing

* Tighten compact fallback matcher
2026-04-05 19:41:09 +08:00
Cita b3f6fa0d4f [codex] align site created modal with shared actions (#425)
* align site created modal with shared actions

* address site created modal review comments
2026-04-05 19:34:10 +08:00
Cita ef1962d10b [codex] rename site API request wording (#423)
* rename site api request wording

* replace real site example domains
2026-04-05 19:16:08 +08:00
Cita 5b005d8412 fix: tighten generic upstream passthrough headers (#422)
* fix: tighten generic upstream passthrough headers

* fix: preserve codex passthrough version headers
2026-04-05 18:11:42 +08:00
Cita afbbc4c79a fix failed expired api-key recovery route preservation (#421) 2026-04-05 17:41:35 +08:00
Cita 9045a488f0 [codex] Fix desktop default-browser external links (#420)
* fix desktop default-browser external links

* fix desktop navigation guard review follow-up
2026-04-05 17:40:54 +08:00
Cita ff43fce9a7 [codex] fix route drag overlay alignment (#419)
* fix route drag overlay alignment

* fix(web): address route drag review comments

* fix route card review follow-up
2026-04-05 17:33:53 +08:00
Cita b3e987a2f5 [daily] keep codex continuation current across scope roundtrips (#416) 2026-04-05 17:29:42 +08:00
Cita 63f6b07842 [daily] cap sqlite migration recovery retries (#410)
* [daily] cap sqlite migration recovery retries

* fix typecheck for sqlite retry budget guard
2026-04-03 11:10:55 +08:00
Cita bc3893ff3b [codex] refine token routes workspace motion and hierarchy (#394)
* refine token routes workspace motion and hierarchy

* address route workspace review feedback

* Refine token route density and motion

* [codex] refine token routes motion behavior

* [codex] address route motion review comments

* [codex] tighten route row interaction states
2026-04-03 11:04:40 +08:00
Cita af5b62fd8b [codex] fix codex responses continuation across channel drift (#404)
* fix codex responses continuation across channel drift

* fix: clear codex responses continuation fallback on scoped reset

* fix: harden codex continuation store keying
2026-04-03 11:00:53 +08:00
Cita b9b553d772 [codex] fix sqlite migration journal recovery (#400)
* fix sqlite migration journal recovery

* address sqlite migration review follow-ups
2026-04-02 17:28:45 +08:00
Cita 6ad9ec6911 [codex] strip codex responses max_output_tokens (#399)
* strip codex responses max_output_tokens

* address codex responses review comments
2026-04-02 17:28:38 +08:00
Cita 19bfed2e33 [codex] refresh integration and settings docs for current UI and provider coverage (#398)
* docs: refresh integration and settings guides

* docs: address review follow-up
2026-04-02 17:28:27 +08:00
Cita 2be0603e6f [codex] fix expired connection health recovery (#393)
* fix expired connection health recovery

* address review comments and test regressions
2026-04-02 17:27:42 +08:00
Cita 082891a217 [codex] fix runtime settings restart hydration (#392)
* fix runtime settings restart hydration

* fix review followups for settings hydration

* dedupe log cleanup day normalization
2026-04-02 17:27:25 +08:00
Cita 1407f754b1 [codex] add Fedora desktop rpm packaging (#390)
* add Fedora desktop rpm packaging

* harden desktop packaging yaml assertion
2026-04-02 12:53:28 +08:00
Cita ed2783e2dd [codex] fix route detail dropdown clipping (#388)
* fix route detail dropdown clipping

* test: refresh proxy route mocks for ci
2026-04-02 11:19:07 +08:00
Cita 52c6ff5e5c [daily] route codex websocket through site api endpoints (#386)
* fix: route codex websocket through site api endpoints

* fix: preserve websocket endpoint exhaustion errors

* test: align proxy route mocks with first-byte timeout helpers
2026-04-02 11:18:30 +08:00
Cita 180d17adfc feat: add proxy first-byte timeout and log badges (#383)
* add proxy first-byte timeout and log badges

* fix first-byte timeout review feedback
2026-04-02 10:00:11 +08:00
Cita bcd758edac test: harden background task completion waits (#382) 2026-04-02 09:49:50 +08:00
Cita ace5ddd52b fix edit payload clearing boundaries (#381) 2026-04-02 09:49:39 +08:00
Cita 4ca88d0fe5 [codex] fix responses-to-chat tool stream fidelity (#378)
* fix responses chat tool stream fidelity

* fix responses tool call follow-up review comments
2026-04-02 09:49:23 +08:00
Bainily 596d1b93cb feat(server): add proactive channel probes and load-aware stable routing / 服务端新增主动探活与负载感知稳定路由 (#330)
* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy

* Sync explicit-group routing strategy to eligible source routes

* Fix proxy retry and runtime health review issues

* fix: make proxy channel retry attempts configurable

* fix: harden proxy retry recovery and bookkeeping

* fix: address pr 257 review comments

* feat(proxy): add sticky session channel coordination

* feat(server): add proactive channel probes and load-aware stable routing

* test(server): stabilize session load routing selection assertion

* fix(proxy): harden codex forwarding stability

* fix(router): rotate stable-first channels by healthy order

* fix(server): address probe review feedback

* fix(server): address probe review follow-ups

* fix(ci): address CodeQL and core test failures

* feat(server): refine stable-first routing recovery and observation

* Finish PR 330 settings and review fixes

* Fix chat site endpoint CI mock

* Address latest PR 330 review comments

---------

Co-authored-by: Cita <juricek.chen@gmail.com>
2026-04-02 09:49:04 +08:00
Cita 1133846998 [codex] restore route priority drag behavior (#376)
* fix: restore route priority drag behavior

* fix: use theme rail highlight surface
2026-04-01 21:24:25 +08:00
Cita 5fffcb6d66 feat: support CIDR admin allowlists (#377) 2026-04-01 21:06:13 +08:00
Cita 76c85d0a13 [codex] Split site API endpoint pool from panel host (#373)
* feat: add site api endpoint schema scaffolding

* feat: wire site api endpoints into backup migration

* feat: add site api endpoint selection service

* feat: add site api endpoint contracts

* feat: route discovery through site api endpoints

* feat: route proxy traffic through site api endpoints

* feat: add site api endpoint pool editor

* fix: keep proxy video tasks on site endpoint pool

* fix: restore core test compatibility for site endpoint pool

* fix: address endpoint pool review findings
2026-04-01 20:58:37 +08:00
Cita ce77bde95a fix: align route priority drag preview (#375) 2026-04-01 19:43:00 +08:00
Cita 37a86d1380 [codex] background route decision refresh (#370)
* background route decision refresh

* address route decision refresh review

* fix route decision refresh typing
2026-04-01 18:24:14 +08:00
Cita 6e21f911ea [codex] add route priority left rail (#371)
* feat: add route priority left rail

* fix: drop stale route card test prop
2026-04-01 18:12:42 +08:00
Cita 21d92e1887 fix: recognize structured oauth accounts in routing (#369)
* fix: recognize structured oauth accounts in routing

* fix: use structured oauth accounts in account list
2026-04-01 17:15:22 +08:00
Cita e2d9481b95 fix anyrouter balance refresh challenge errors (#368) 2026-04-01 16:53:01 +08:00
Cita 532be86e93 [codex] Add route cooldown controls (#365)
* Add route cooldown controls

* Address route cooldown review comments
2026-04-01 16:41:25 +08:00
Cita 1e0dffe543 [codex] Fix mysql insert boundary handling (#364)
* fix mysql insert boundary handling

* fix review comment follow-ups

* fix account insert boundary regressions
2026-04-01 14:33:45 +08:00
Cita aac1307470 [codex] add vendor code entry presets follow-up (#363)
* address coding plan review comments

* add more vendor code entry presets
2026-04-01 13:57:24 +08:00
Cita 3ebe851002 Fix update helper health for ready failed release (#361) 2026-04-01 12:56:42 +08:00
Cita bafceb855b [codex] Add model tester fixed-channel selection (#352)
* feat: add model tester forced channel

* fix: keep codex websocket defaults and route refresh checks

* fix: scope tester-only forced channel overrides

* fix: keep tester hydration and websocket hints internal

* fix: reject gemini on legacy tester chat routes

* fix: harden forced channel selection edges
2026-04-01 12:56:13 +08:00
Cita 8c3be1a613 Add zod payload contracts for admin API routes (#358)
* Add zod route payload contracts for admin APIs

* Fix review follow-ups for zod admin payloads

* Refactor account payload parser helpers
2026-04-01 12:52:55 +08:00
nodca 6a55effd3b 修复 weighted 路由失败退避时间溢出导致服务崩溃 (#354)
* fix: cap token router cooldown backoff

* docs: explain token router cooldown cap
2026-04-01 12:29:05 +08:00
Cita 4096c757d4 [codex] fix anyrouter checkin session failure classification (#353)
* fix anyrouter checkin session failure classification

* fix auth failure classification review follow-up
2026-04-01 12:28:09 +08:00
Cita 6206c5bd77 fix(web): preserve both site-created next steps (#357) 2026-04-01 09:47:21 +08:00
Cita 0de9c5e3f1 [codex] add coding plan site initialization flow (#351)
* add coding plan site initialization flow

* fix ci for coding plan site init flow

* address coding plan review comments
2026-03-31 21:55:25 +08:00
Cita e74e11375c [codex] add route priority bucket editor (#350)
* feat: add route priority bucket editor

* fix: address route editor review comments
2026-03-31 21:23:02 +08:00
Cita baa11d7787 cache update center status snapshots (#349) 2026-03-31 20:47:02 +08:00
Cita d930b87762 [codex] Fix Anyrouter model sync shield handling (#348)
* fix anyrouter model sync shield handling

* fix shield challenge message precedence
2026-03-31 19:32:25 +08:00
Cita 87390651f4 fix: add searchable token account selectors (#347) 2026-03-31 19:13:07 +08:00
Cita 6b19d06140 [codex] fix empty stream success handling and unknown usage logs (#343)
* fix: preserve repeated short responses deltas

* fix empty stream success handling and unknown usage logs

* fix proxy usage metadata typing

* fix stream bookkeeping and explicit zero usage source

* fix sticky fast path and usage placeholder detection
2026-03-31 16:45:13 +08:00
Cita 3ce5179fed fix: detect latest digest updates in update center (#344) 2026-03-31 15:02:24 +08:00
rcocco e8921e63ce Fix orphaned responses tool outputs in messages fallback (#342) 2026-03-31 14:56:54 +08:00
Cita 6e04db61dd fix: wire site-created dialog cancel cleanup (#341) 2026-03-31 13:47:36 +08:00
Cita 618ca0b9bf fix: restore armv7 docker base image (#337) 2026-03-30 23:23:55 +08:00
Cita eb65a2be88 fix: preserve repeated short responses deltas (#336) 2026-03-30 22:54:05 +08:00
Cita 5d54e92dc6 docs: add management API guide (#334) 2026-03-30 22:53:33 +08:00
Cita 9e95819952 fix: decode zstd-compressed SSE proxy surfaces (#335)
* Fix zstd-compressed SSE decoding in proxy surfaces

* Fix stream/web type bridge for zstd SSE

* Guard zstd stream readers against decoded bodies

* Handle stacked zstd stream encodings
2026-03-30 22:46:31 +08:00
Cita 9eb33df6d4 [codex] add automatic update-center reminders (#333)
* add automatic update-center reminders

* fix update-center reminder review issues
2026-03-30 22:37:24 +08:00
Cita 26f9717ad7 [codex] add setting to disable cross-protocol fallback (#332)
* Add toggle to disable cross-protocol fallback

* Tighten runtime settings payload typing
2026-03-30 20:02:51 +08:00
Cita 6726da987f persist proxy trace panel state (#331) 2026-03-30 19:21:57 +08:00
Cita 9aeec87cfa chore: sweep dependabot updates and align Node 25 (#329)
* chore: sweep dependabot updates

* fix: narrow optional browser Buffer typing

* chore: align repo node pins to v25
2026-03-30 19:14:41 +08:00
Delicious233 9212b765cd feat: 新站点创建后显示选择对话框 (#302)
* feat: 实现全局模型白名单功能

## 功能概述
实现全局模型白名单,允许管理员配置只启用特定模型的路由,实现精细化的模型访问控制。

## 主要变更

### 后端实现
- config.ts: 新增 globalAllowedModels 配置项
- settings.ts:
  - 新增 RuntimeSettingsBody 类型支持
  - 实现数据库加载和持久化
  - 配置变更自动触发路由重建
- modelService.ts:
  - 在路由重建中添加白名单过滤逻辑
  - 大小写不敏感匹配,自动trim空格
  - 空白名单时允许所有模型(向后兼容)
- stats.ts:
  - 候选API过滤 models/modelsWithoutToken/modelsMissingTokenGroups

### 前端实现
- Settings.tsx:
  - 新增"全局模型白名单"配置卡片
  - 支持手动输入和点击选择两种添加方式
  - 绿色徽章显示已选模型,支持删除
  - 保存后自动触发路由重建

## 技术特性
- ✅ 向后兼容(默认为空数组,允许所有模型)
- ✅ 大小写不敏感匹配
- ✅ 自动trim和去重
- ✅ 输入验证(类型检查、空值过滤)
- ✅ 配置变更自动重建路由

## 测试验证
- 白名单为空时所有模型可见
- 白名单有值时只显示白名单模型
- 路由重建正确过滤模型
- 候选API正确返回过滤结果
- 前端UI交互正常

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: 站点创建后显示选择对话框

新站点创建后不再根据平台类型自动跳转,而是显示一个选择对话框让用户决定下一步操作:
- 添加账号(用户名密码登录)
- 添加 API Key
- 稍后配置

对于 codex 平台,选择"添加账号"时会跳转到 OAuth 流程,其他平台则跳转到账号管理页面。

这样可以避免用户被强制跳转,提供更灵活的配置选择。

* fix: add getModelTokenCandidates mock to Settings tests

* fix: add getModelTokenCandidates mock to proxy-transport test

---------

Co-authored-by: Ding <ding@local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:51:23 +08:00
Delicious233 fa25a501a8 feat: 实现全局模型白名单功能 (#301)
* feat: 实现全局模型白名单功能

## 功能概述
实现全局模型白名单,允许管理员配置只启用特定模型的路由,实现精细化的模型访问控制。

## 主要变更

### 后端实现
- config.ts: 新增 globalAllowedModels 配置项
- settings.ts:
  - 新增 RuntimeSettingsBody 类型支持
  - 实现数据库加载和持久化
  - 配置变更自动触发路由重建
- modelService.ts:
  - 在路由重建中添加白名单过滤逻辑
  - 大小写不敏感匹配,自动trim空格
  - 空白名单时允许所有模型(向后兼容)
- stats.ts:
  - 候选API过滤 models/modelsWithoutToken/modelsMissingTokenGroups

### 前端实现
- Settings.tsx:
  - 新增"全局模型白名单"配置卡片
  - 支持手动输入和点击选择两种添加方式
  - 绿色徽章显示已选模型,支持删除
  - 保存后自动触发路由重建

## 技术特性
- ✅ 向后兼容(默认为空数组,允许所有模型)
- ✅ 大小写不敏感匹配
- ✅ 自动trim和去重
- ✅ 输入验证(类型检查、空值过滤)
- ✅ 配置变更自动重建路由

## 测试验证
- 白名单为空时所有模型可见
- 白名单有值时只显示白名单模型
- 路由重建正确过滤模型
- 候选API正确返回过滤结果
- 前端UI交互正常

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add getModelTokenCandidates mock to Settings tests

* fix: add getModelTokenCandidates mock to proxy-transport test

---------

Co-authored-by: Ding <ding@local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:19:02 +08:00
Cita 01481be511 [codex] Add digest-safe k3s update-center assets (#326)
* Add digest-safe k3s update-center assets

* Harden k3s update-center assets

* Trim k3s env secret names safely
2026-03-30 17:17:24 +08:00
Cita 7d91823f6c Add site filters for manual connection selection (#328) 2026-03-30 16:32:48 +08:00
Cita 0d2c4501c2 make proxy trace panel collapsible (#327) 2026-03-30 16:32:26 +08:00
Cita 26d87db2cb [codex] tighten update center and proxy debug UX (#325)
* tighten update center and proxy debug UX

* Address update center review feedback
2026-03-30 13:39:20 +08:00
Cita 71be4cb63a Handle codex OAuth usage-limit cooldowns (#316)
* Handle codex oauth usage-limit cooldowns

* Generalize short-window 429 limit cooldowns

* Fan out short-window cooldowns by credential
2026-03-30 13:39:07 +08:00
Cita d47c3c4a29 Support digest-aware update center rollbacks (#318) 2026-03-29 22:16:39 +08:00
Cita b9ae85e98b Fix update center helper token lookup (#317) 2026-03-29 19:49:13 +08:00
cita-777 faf9874779 Fix armv7 docker helper build 2026-03-29 17:57:41 +08:00
Cita 11583c51b9 [codex] add K3s update center (#314)
* add k3s update center

* fix update center status timeouts

* Polish update center settings UI

* Document k3s update center

* Refocus update center docs for users

* Clarify k3s migration path in docs
2026-03-29 17:43:49 +08:00
Cita dcfd45ea65 Fix proxy debug trace truncation previews (#313) 2026-03-29 15:08:37 +08:00
Cita 7092e2f129 [codex] refine proxy logs debug UI follow-ups (#312)
* Refine proxy logs debug page layout

* Align proxy logs debug UI with page patterns

* Prevent proxy trace detail refresh flicker

* Polish proxy trace detail modal
2026-03-29 13:39:25 +08:00
Cita ab8640c8c3 [daily] reset oauth proxy form after start (#311) 2026-03-29 13:37:40 +08:00
Cita ed44e7f925 Refine proxy logs debug trace UI (#309) 2026-03-29 11:50:16 +08:00
Cita 03ca1154a0 Add OAuth account proxy controls (#307)
* Add oauth account proxy controls

* Refine oauth proxy review fixes

* Fix oauth clear-proxy exchange fallback
2026-03-29 00:35:45 +08:00
Cita 785ee93505 fix gemini oauth validation site proxy (#298) 2026-03-28 20:06:20 +08:00
Cita 4d4684a39b [codex] add proxy debug tracing (#299)
* add proxy debug tracing

* fix proxy debug response header typing

* extend proxy debug tracing to side paths

* Refine proxy debug trace review fixes

* Stabilize proxy debug attempt indexing

* Tighten proxy debug trace accuracy
2026-03-28 20:05:48 +08:00
Sean Lee 3bf3cd1622 feat: 新增路由批量禁用/启用功能 (#295)
* feat: Preserve manual models during discovery and refresh, and add Claude configuration files and project documentation.feat: Preserve manual models during discovery and refresh, and add Claude configuration and project documentation files.

* fix: fix Feishu and Larksuite webhook support to the notification service with updated UI and tests.

* feat: 新增路由批量禁用/启用功能

- 后端新增 POST /api/routes/batch 接口,支持批量 enable/disable
- 前端新增批量操作模式,支持 checkbox 选择路由
- 浮动操作栏显示选中数量,支持全选/取消全选
- 批量操作完成后自动退出选择模式并刷新列表

* fix: complete route batch actions on mobile

---------

Co-authored-by: Xiang Li <xiangli>
Co-authored-by: cita-777 <juricek.chen@gmail.com>
2026-03-28 16:54:09 +08:00
Cita 5ae452ab45 [codex] inherit site proxy settings for oauth (#296)
* test: define oauth proxy inheritance behavior

* feat: apply site proxy settings to oauth requests

* feat: backfill oauth provider sites at startup

* test: remove oauth site registry temp dir
2026-03-28 16:38:39 +08:00
apple 40b8eddef8 chore: remove committed local debug artifacts 2026-03-26 21:59:48 +08:00
Cita 8ebfc39745 [codex] add harness engineering guardrails (#284)
* refactor: add harness engineering guardrails

* fix: address harness review feedback

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
2026-03-26 21:22:36 +08:00
Cita b3387dfba7 [codex] fix cross-database JSON boundary handling (#283)
* fix: normalize cross-database json boundaries

* fix: address json boundary review comments

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
2026-03-26 20:21:19 +08:00
Cita 7e1fb8eb1b [codex] HTTP-first Codex upstream websocket settings (#280)
* http-first codex upstream websocket settings

* address websocket runtime review comments

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
2026-03-25 21:01:01 +08:00
Cita 5d2596285b fix: truncate collapsed route group regex badge (#279)
Co-authored-by: apple <apple@appledeMacBook-Pro.local>
2026-03-25 20:37:53 +08:00
Cita 639c387532 [codex] fix responses lifecycle parity and codex session serialization (#278)
* fix responses lifecycle parity

* remove websocket test debug logging

* fix responses proxy stream typecheck

* update compact upstream response expectations

* add community and deepwiki badges

* update linuxdo banner link

* fix responses review follow-ups

* preserve whitespace in responses terminal deltas

* fix responses stream reconciliation edges

* fix responses lifecycle parity and codex session serialization

* fix review follow-up edge cases

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
2026-03-25 18:56:36 +08:00
Sean Lee 9ba4369a83 fix: auto-detect Feishu webhook format and validate response body (#271)
* feat: Preserve manual models during discovery and refresh, and add Claude configuration files and project documentation.feat: Preserve manual models during discovery and refresh, and add Claude configuration and project documentation files.

* fix: fix Feishu and Larksuite webhook support to the notification service with updated UI and tests.

---------

Co-authored-by: Xiang Li <xiangli>
2026-03-25 18:30:32 +08:00
ZhengYiTao dc40edf8e8 fix: case-insensitive model merging and disabled model matching (#251)
* fix: MySQL-compatible case-insensitive model merge and disabled model matching

Root cause: MySQL uses utf8mb4_general_ci collation by default, making the
unique index on (account_id, model_name) case-insensitive. When different
tokens discover the same model with different casing (e.g., 'GPT-4o' vs
'gpt-4o'), the case-sensitive JavaScript Set allowed both, but the MySQL
batch INSERT failed with a duplicate key error — causing the entire model
refresh to silently fail and leaving stale routes (including disabled models).

Fixes:
1. mergeDiscoveredModels: change Set<string> to Map<lowercase, original>
   for cross-scan dedup, matching normalizeModels' existing behavior.
2. isModelDisabledForSite: compare with toLowerCase() so disabled models
   are filtered regardless of case variants across discovery sources.
3. tokens.ts /api/routes/rebuild: add missing await on
   rebuildTokenRoutesFromAvailability() so errors propagate correctly
   instead of being silently swallowed.

* fix: add missing await on rebuildTokenRoutesFromAvailability in stats.ts

The POST handler for /api/models/check/:accountId called
rebuildTokenRoutesFromAvailability() without await, returning a Promise
object in the response instead of the actual rebuild result.
2026-03-25 11:22:37 +08:00
Cita 709f528bd2 fix responses lifecycle parity (#272)
* fix responses lifecycle parity

* remove websocket test debug logging

* fix responses proxy stream typecheck

* update compact upstream response expectations

* add community and deepwiki badges

* update linuxdo banner link

* fix responses review follow-ups

* preserve whitespace in responses terminal deltas

* fix responses stream reconciliation edges

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
2026-03-24 23:12:35 +08:00
Sean Lee 3e0083582e feat: Preserve manual models during discovery and refresh, and add Claude configuration files and project documentation.feat: Preserve manual models during discovery and refresh, and add Claude configuration and project documentation files. (#270)
Co-authored-by: Xiang Li <xiangli>
2026-03-24 20:01:48 +08:00
Bainily 9cf3dd1904 feat(proxy): improve channel failover stability and sticky session coordination / 优化代理通道故障切换与粘滞会话协调 (#268)
* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy

* Sync explicit-group routing strategy to eligible source routes

* Fix proxy retry and runtime health review issues

* fix: make proxy channel retry attempts configurable

* fix: harden proxy retry recovery and bookkeeping

* fix: address pr 257 review comments

* feat(proxy): add sticky session channel coordination

---------

Co-authored-by: Cita <juricek.chen@gmail.com>
2026-03-24 19:40:44 +08:00
Cita 5b9c83f830 fix: default downstream key access selections to all (#263)
* fix: default downstream key access selections to all

* test: fix downstream keys test renderer typing
2026-03-23 22:48:15 +08:00
Cita 4a3b4a8bb6 fix: preserve protocol parity across compatibility hops (#259)
* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy

* Sync explicit-group routing strategy to eligible source routes

* Fix proxy retry and runtime health review issues

* fix: make proxy channel retry attempts configurable

* fix: harden proxy retry recovery and bookkeeping

* fix: address pr 257 review comments

* fix: preserve oe tool_result structure

* fix: preserve protocol parity across compatibility hops

* fix: satisfy server build and typecheck

* fix: preserve codex and mcp fallback semantics

* fix: address ci and review follow-ups

---------

Co-authored-by: bnvnvnv <bnvnvnv@gmail.com>
2026-03-23 20:25:20 +08:00
Cita b3869c4f55 fix: avoid armv7 desktop postinstall in docker build (#262) 2026-03-23 20:24:37 +08:00
Cita b07b16c942 fix: restore docker builder install deps (#261)
* fix: exclude runtime db config from backup and migration

* fix: restore docker builder install deps
2026-03-23 20:12:16 +08:00
Cita c838e3263b fix: exclude runtime db config from backup and migration (#260) 2026-03-23 20:05:00 +08:00
Bainily 1449f08e6c fix: stabilize Codex continuation and proxy retry recovery / 修复 Codex 续传与代理重试稳定性 (#257)
* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy

* Sync explicit-group routing strategy to eligible source routes

* Fix proxy retry and runtime health review issues

* fix: make proxy channel retry attempts configurable

* fix: harden proxy retry recovery and bookkeeping

* fix: address pr 257 review comments

---------

Co-authored-by: Cita <juricek.chen@gmail.com>
2026-03-23 19:17:22 +08:00
Cita 5be5e2eb07 fix: preserve protocol parity across Claude fallbacks (#256)
* fix: preserve protocol parity across claude fallbacks

* fix: tighten protocol parity across responses and anthropic fallbacks

* fix: preserve stricter tool contracts across fallbacks

* fix: unblock anthropic stream server build
2026-03-23 17:29:49 +08:00
Cita 2940b003af Merge pull request #255 from cita-777/codex/ci-typecheck-pr
fix: tighten CI typecheck coverage
2026-03-23 16:40:52 +08:00
Cita 653b7557a7 fix: address review followups 2026-03-23 16:24:31 +08:00
Cita 4b903fdd3b fix: tighten ci typecheck coverage 2026-03-23 16:02:13 +08:00
Cita 40c8c1c7ad Merge pull request #244 from cita-777/codex/single-source-consolidation
refactor: consolidate single-source routing and protocol helpers
2026-03-23 15:28:44 +08:00
Cita 764b9aa0e6 fix: address latest coderabbit review findings 2026-03-23 15:24:00 +08:00
Cita 426d9a538d test: relax token selection count assertion 2026-03-23 14:51:57 +08:00
Cita 4686c558aa Merge origin/main into codex/single-source-consolidation 2026-03-23 14:44:52 +08:00
Cita bf969c136a docs: capture full-scope consolidation finish 2026-03-23 14:32:11 +08:00
Cita f9316784aa fix: align final verification scripts and expectations 2026-03-23 14:29:44 +08:00
Cita 7cc0a04b8b refactor: share responsive filter panel scaffold 2026-03-23 14:29:11 +08:00
Cita dd914b327c refactor: extract downstream key drawer 2026-03-23 14:28:37 +08:00
Cita 8e001508bf refactor: extract model tester composer and tighten shared types 2026-03-23 13:44:52 +08:00
Cita 8f14540549 refactor: extract model tester debug panel 2026-03-23 13:35:42 +08:00
Cita 9b21ed3fed refactor: extract account model management modal 2026-03-23 13:32:55 +08:00
Cita c3e3985b6e refactor: extract remaining settings modals 2026-03-23 13:29:39 +08:00
Cita 62eca41bc2 refactor: extract settings portal modals 2026-03-23 13:18:37 +08:00
Cita f7a7084278 refactor: share responsive batch actions across admin pages 2026-03-23 13:12:06 +08:00
Cita b82d7c31d9 refactor: derive legacy schema compat from feature specs 2026-03-23 13:04:09 +08:00
Cita 55aea6afc4 refactor: separate brand registry from BrandIcon 2026-03-23 12:44:38 +08:00
Cita 8b3653c7c9 refactor: share schema metadata normalization rules 2026-03-23 12:23:04 +08:00
Cita 86518202fa refactor: move platform discovery out of model service 2026-03-23 12:14:22 +08:00
Cita 50c34b8866 refactor: keep downstream client metadata on cli profiles 2026-03-23 12:02:44 +08:00
Cita 3877f3a3f1 refactor: centralize oauth identity and route refresh workflow 2026-03-23 11:53:59 +08:00
Cita 328b492ab3 fix: finish review-driven single-source cleanup 2026-03-23 11:26:01 +08:00
Cita a3b061b60f Merge pull request #245 from cita-777/codex/site-binding-conflict
fix: handle duplicate site binding conflicts
2026-03-23 09:20:58 +08:00
Cita f5892b9656 Merge pull request #241 from Hureru/feat/model-filter-system
feat: add model filter system and route page performance optimization
2026-03-23 00:19:29 +08:00
Cita 90e9140c70 fix: preserve site disabled models on partial load failure 2026-03-22 23:58:43 +08:00
Cita 29c9fcbd29 fix: resolve remaining review follow-ups 2026-03-22 23:53:41 +08:00
Cita 5d78658af9 fix: address review feedback and code scanning alerts 2026-03-22 23:41:45 +08:00
Hureru 9a5e2bbc60 fix: prevent brand list loading indicator from hanging on API failure 2026-03-22 23:13:51 +08:00
Cita daa6a01ccf fix: handle duplicate site binding conflicts 2026-03-22 22:18:44 +08:00
Cita 32d7037629 refactor: share platform file and route helpers 2026-03-22 22:17:50 +08:00
Cita 9290e51ecc refactor: consolidate single-source routing and protocol helpers 2026-03-22 21:27:09 +08:00
Cita 96e7614835 fix: trust proxy headers and align proxy file mysql indexes 2026-03-22 21:17:56 +08:00
Hureru d278b41be2 fix: address review issues in brand filter sync, model guard, route counts, and candidate loading
- Sync blockedBrands local state after save to prevent stale chips
- Defer getBrandList from blocking Settings bootstrap
- Guard model management UI on brandGroups instead of availableModels
- Deduplicate single-model add in disabled models
- Skip read-only/zero-channel routes in enabledCounts and filter
- Add sequence counter to loadCandidates to prevent force race condition
- Use stableAddChannel for mobile add-channel button
2026-03-22 20:44:55 +08:00
Hureru b8ad89d1ab fix: use ref-based stale guard for site disabled-models fetch
Replace closure-captured editor check with loadingModelsSiteIdRef so the
stale-response guard works correctly across rapid site switches. Also
gate setDisabledModelsLoading(false) behind the same ref check.
2026-03-22 20:26:45 +08:00
Hureru 26be7c024a fix: address review issues across settings, sites, brand matcher, and token routes
- settings.ts: trigger route rebuild after globalBlockedBrands changes in both
  import and PUT handler; reject non-array payloads with 400 instead of silently
  defaulting to empty array
- sites.ts: trim model names before deduplication so whitespace variants merge
- brandMatcher.ts: align boundary regex with frontend delimiter class [/:_\-\s]
- Settings.tsx: add role="switch" and aria-checked to brand filter chips; make
  getBrandList best-effort so its failure doesn't block page load
- Sites.tsx: build brandGroups from union of availableModels and disabledModels
  so legacy/removed models remain visible; add stale-response guard and error
  logging for disabled/available model fetches
- TokenRoutes.tsx: use baseFilteredRoutes.length for totalRouteCount to keep
  enabled/disabled counts in sync with status filter; add candidatesVersionRef
  to invalidate lazy caches on payload change; add per-channel in-flight guard
  on toggle; invalidate channel cache after rebuild and site-block operations
2026-03-22 20:18:50 +08:00
Hureru 911d76fa08 fix: add channel toggle and site-block buttons to mobile channel row 2026-03-22 19:27:43 +08:00
Hureru 2f8dae70c2 fix: add missing onToggleChannelEnabled and onSiteBlockModel props to mobile RouteCard 2026-03-22 19:18:44 +08:00
Hureru 3195282c4e fix: address review issues in TokenRoutes performance optimization
- Escape modelName/siteName in innerHTML to prevent XSS
- Use content-based routePatternsKey for cache invalidation instead of
  length-only keys that miss content changes
- Extract shared baseFilteredRoutes to eliminate duplicated filter logic
  between enabledCounts and filteredRoutes
- Add candidatesPromiseRef to deduplicate concurrent loadCandidates calls
2026-03-22 19:12:50 +08:00
Hureru 92fd5e034d perf: defer token-candidates loading and lazy-compute heavy route indices
The routes page loaded ~924KB of API data upfront (summary 366KB +
token-candidates 558KB), but token-candidates was only needed when
expanding cards, opening panels, or using filters. This caused a ~3s
element render delay blocking LCP.

Changes:
- Split load() so first paint only fetches routesSummary (366KB)
- Preload token-candidates in background via requestIdleCallback
- Wrap candidates state updates in startTransition to avoid UI jank
- Convert 3 heavy O(routes×models) useMemo indices to lazy per-route
  computation with ref-based cache (candidateIndex, missingTokenIndex,
  missingTokenGroupIndex)
- Guard 4 ManualRoutePanel-only useMemos with showManual check
- Convert 2 derived useMemos to per-route getter functions
- Add loadCandidates() calls at 7 interaction trigger points
2026-03-22 19:00:03 +08:00
Hureru d2db989b98 fix: add bottom border to channel rows for clearer separation 2026-03-22 19:00:02 +08:00
Hureru 835fccb4f5 fix: align site-block and delete buttons in channel row grid layout
Wrap site-block and delete buttons in a flex container and expand grid
to 4 columns so all action buttons stay on the same row.
2026-03-22 18:59:04 +08:00
Hureru 4415317bdc fix: align brand matcher with frontend logic and remove manual model input
- brandMatcher: port collectBrandCandidates + buildMatchContext from
  frontend BrandIcon.tsx so matching is identical between route display
  and rebuild filtering; segments now split on /,:,\s only (not - or _),
  and candidates are recursively generated from /,:,, splits
- Sites.tsx: remove manual model name input and handleAddDisabledModel;
  the checkbox-based batch selection UI fully replaces it
2026-03-22 18:55:40 +08:00
Hureru f14ffd7dc9 fix: address review issues in model filter system
- brandMatcher: split keywords into includes/startsWith/segment/boundary
  modes to align with frontend BrandIcon.tsx matching semantics, preventing
  false-positive blocking from short keywords like o1, tts, command
- handleSiteBlockModel: reject wildcard route patterns (e.g. gpt-*) from
  being written to site disabled list; only allow exact model names
- Sites.tsx: extract brand grouping to useMemo, convert disabledModels
  to Set for O(1) lookup, remove duplicate IIFE computations in JSX
- handleSiteBlockModel: replace window.confirm with project-style DOM
  dialog for visual consistency
- enabledCounts: compute from filtered scope (excluding enabled filter
  itself) so counts reflect current brand/site/search filters
- Fix declaration order: move enabledCounts after sortedRoutes to avoid
  ReferenceError
2026-03-22 18:55:40 +08:00
Hureru cf6ed7db91 feat: add model filter system with site-level batch disable, global brand filter, route status filter, and channel site-block shortcut
- Route status filter: add enabled/disabled filter chips to RouteFilterBar
- Channel site-block: add "站点屏蔽" button on channel rows to quickly
  add a model to a site's disabled list and trigger rebuild
- Site batch selection: new GET /api/sites/:id/available-models endpoint;
  Sites.tsx now shows a brand-grouped checkbox list for batch model disable
- Global brand filter: new brandMatcher.ts service; settings API and UI
  for blocking entire brands from route generation
- Fix infinite scroll race condition when sentinel ref is null during
  IntersectionObserver effect setup after filter changes
2026-03-22 18:55:40 +08:00
Cita 3cc6b7be45 Merge pull request #238 from cita-777/codex/mobile-panel-refactor-final
feat: overhaul mobile management layouts
2026-03-21 20:47:56 +08:00
Cita 09fbf258e2 feat: overhaul mobile management layouts 2026-03-21 20:42:25 +08:00
Cita c64a760b69 Merge pull request #237 from cita-777/codex/codex-review-fixes
fix: harden codex runtime routing and oauth recovery
2026-03-21 20:40:07 +08:00
Cita a81965176f fix: preserve inactive availability after main merge 2026-03-21 20:34:52 +08:00
Cita 3775ee059c Merge remote-tracking branch 'origin/main' into codex/codex-review-fixes
# Conflicts:
#	src/server/services/modelService.ts
2026-03-21 20:31:30 +08:00
Cita fa372d121a fix: preserve availability on inactive refresh failure 2026-03-21 20:27:18 +08:00
Cita 4c14f9c9b0 Merge pull request #234 from cita-777/codex/gemini-oauth-hardening
Fix Gemini OAuth refresh and failure coverage
2026-03-21 20:13:08 +08:00
Cita 4480f504e4 fix: satisfy server build typing 2026-03-21 19:58:41 +08:00
Cita d9e550c279 fix: address codex review findings 2026-03-21 19:45:29 +08:00
Cita b7a1e523e7 fix: reuse oauth singleflight in gemini discovery 2026-03-21 19:29:33 +08:00
Cita e09fb43b33 Merge pull request #236 from cita-777/codex/claude-count-models
Fix Claude count_tokens compatibility and OAuth model discovery
2026-03-21 19:28:18 +08:00
Cita c7ba436685 Merge pull request #235 from cita-777/codex/gemini-multimodal-routing
fix: keep gemini multimodal routing off endpoint memory
2026-03-21 19:27:36 +08:00
Cita 1d8889001f fix: harden codex runtime routing and oauth recovery 2026-03-21 19:07:14 +08:00
Cita dc93c58582 Fix Claude count tokens routing and model discovery 2026-03-21 18:42:28 +08:00
Cita dd48550ffa Fix Gemini OAuth refresh and failure coverage 2026-03-21 18:38:00 +08:00
Cita 500ba25787 fix: bypass multimodal endpoint memory for gemini 2026-03-21 18:37:36 +08:00
Cita 6a19d809f0 Merge pull request #233 from cita-777/codex/login-surface-pr
feat(web): redesign login surface
2026-03-21 17:17:51 +08:00
Cita 781c44140f Merge pull request #232 from cita-777/codex/backup-config-semantics-v21
fix: refine config backup import semantics
2026-03-21 17:12:53 +08:00
ciat-777 719e21cce7 feat(web): redesign login surface 2026-03-21 17:12:11 +08:00
ciat-777 89d0aba497 fix: refine config backup import semantics 2026-03-21 16:55:32 +08:00
Cita 89d6527aa1 Merge pull request #231 from cita-777/codex/responses-chat-tool-filter
fix: strip responses-only tools on chat fallback
2026-03-21 16:44:29 +08:00
ciat-777 7e09ce83b8 fix(proxy): strip responses-only tools on chat fallback 2026-03-21 16:38:46 +08:00
Cita f586d3f318 Merge pull request #229 from cita-777/codex/gemini-claude-identity-encoding
Codex/gemini claude identity encoding
2026-03-21 15:55:51 +08:00
ciat-777 5a4f881b95 fix: restore generic inline document endpoint fallbacks 2026-03-21 15:52:22 +08:00
Cita b882b48bc0 Merge pull request #230 from cita-777/codex/backup-preserve-local-stats
fix: preserve local usage state on backup import
2026-03-21 15:45:02 +08:00
ciat-777 ab31e0b1c9 fix: preserve local usage state on backup import 2026-03-21 15:41:46 +08:00
ciat-777 7a2953ac36 fix: decode zstd responses while restoring claude encoding 2026-03-21 15:38:11 +08:00
Cita e1f4fde916 Merge pull request #228 from cita-777/codex/fix-pr-115-rebased
fix(web): polish WebDAV backup form controls
2026-03-21 14:16:19 +08:00
ciat-777 dbc17f6907 fix(web): polish webdav backup form controls 2026-03-21 14:05:52 +08:00
ciat-777 17a4281117 fix: avoid messages fallback for generic file uploads 2026-03-21 14:03:35 +08:00
Cita d17e80f05e Merge pull request #227 from cita-777/codex/openclaw-user-agent-v2
fix: detect openclaw user-agent in proxy logs
2026-03-21 13:56:14 +08:00
ciat-777 272b88c6c8 fix: detect openclaw user-agent in proxy logs 2026-03-21 13:53:02 +08:00
Cita 9395eecfc9 Merge pull request #115 from cita-777/dependabot/npm_and_yarn/drizzle-risk-539e1a854f
build(deps): bump the drizzle-risk group with 2 updates
2026-03-21 13:39:49 +08:00
ciat-777 e454ec18ec fix(api): unwrap downstream key unique violations 2026-03-21 13:30:06 +08:00
dependabot[bot] 87cf54140c build(deps): bump the drizzle-risk group with 2 updates
Bumps the drizzle-risk group with 2 updates: [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) and [drizzle-kit](https://github.com/drizzle-team/drizzle-orm).


Updates `drizzle-orm` from 0.36.4 to 0.45.1
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.36.4...0.45.1)

Updates `drizzle-kit` from 0.28.1 to 0.31.9
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/drizzle-kit@0.28.1...drizzle-kit@0.31.9)

---
updated-dependencies:
- dependency-name: drizzle-orm
  dependency-version: 0.45.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: drizzle-risk
- dependency-name: drizzle-kit
  dependency-version: 0.31.9
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: drizzle-risk
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 05:17:02 +00:00
Cita 80d4bdf88c Merge pull request #189 from cita-777/dependabot/npm_and_yarn/minor-and-patch-f409002027
build(deps): bump the minor-and-patch group across 1 directory with 7 updates
2026-03-21 13:14:44 +08:00
Cita 9b17c3ebdf Merge pull request #159 from Hureru/feat/account-level-proxy
feat(proxy): account-level proxy URL override
2026-03-21 13:14:08 +08:00
Cita 8ced774e29 Merge pull request #226 from cita-777/codex/gemini-claude-identity-encoding
fix: force identity encoding for claude messages
2026-03-21 12:59:40 +08:00
ciat-777 424e1e210b test: align claude count tokens encoding expectation 2026-03-21 12:56:34 +08:00
ciat-777 1b78359739 fix: force identity encoding for claude messages 2026-03-21 12:48:34 +08:00
ciat-777 04655a8b17 fix(proxy): honor account proxy during oauth discovery 2026-03-21 12:46:46 +08:00
Cita 9007ca7ea7 Merge pull request #224 from cita-777/codex/webdav-backup-armv7l
Remove armv7 smoke PR check
2026-03-21 11:55:15 +08:00
ciat-777 a2ef7e07fb Remove armv7 smoke PR check 2026-03-21 11:53:16 +08:00
Cita a7bac92975 Merge pull request #223 from cita-777/codex/proxy-log-self-report-client-name
fix: prefer self-reported proxy log client names
2026-03-21 11:52:48 +08:00
ciat-777 7c9bdc51c7 fix: prefer self-reported proxy log client names 2026-03-21 11:48:15 +08:00
Hureru 4d0c4b8129 feat(proxy): propagate account-level proxy across all surfaces
- chatSurface/openAiResponsesSurface: extract channelProxyUrl once and pass to withSiteRecordProxyRequestInit
- geminiSurface: switch from withSiteProxyRequestInit to withSiteRecordProxyRequestInit with channelProxyUrl
- balanceService: pass accountProxyUrl to sub2api token refresh requests
- siteProxy: fix isAccountOverride to only be true when account proxy differs from site proxy
- Accounts.tsx: use parseAccountExtraConfig helper for proxy badge display
2026-03-21 11:44:57 +08:00
Hureru 8df3b1431f Merge remote-tracking branch 'origin/main' into feat/account-level-proxy
# Conflicts:
#	src/server/proxy-core/surfaces/chatSurface.ts
#	src/server/proxy-core/surfaces/openAiResponsesSurface.ts
#	src/server/routes/proxy/embeddings.ts
#	src/server/routes/proxy/images.ts
#	src/server/services/modelService.ts
2026-03-21 10:09:19 +08:00
Cita 4ba7749391 Merge pull request #220 from cita-777/codex/webdav-backup-armv7l
Add WebDAV backup sync and armv7 Docker builds
2026-03-21 01:24:51 +08:00
Cita 3be452d794 Merge pull request #219 from cita-777/codex/latest-main-protocol-review
[codex] fix modeltester attachment replay and add proxy file retention
2026-03-21 01:10:36 +08:00
ciat-777 0574fa93b3 Isolate server Docker build from desktop deps 2026-03-21 01:09:50 +08:00
ciat-777 7c227d8365 Handle replay hydration file fetch timeouts 2026-03-21 01:03:03 +08:00
ciat-777 89282093a9 Use armv7-compatible Node Docker base 2026-03-21 00:59:23 +08:00
ciat-777 1ce036fd46 Address PR review for WebDAV and armv7 2026-03-21 00:48:42 +08:00
ciat-777 bc52b6b5fd Add WebDAV backup sync and armv7 Docker builds 2026-03-21 00:06:16 +08:00
cita 95bf8df499 fix modeltester attachment replay and add proxy file retention 2026-03-21 00:04:06 +08:00
Cita 94b6cac871 Merge pull request #218 from cita-777/codex/modeltester-attachment-review-fixes
[codex] Fix model tester attachment replay and capability gating
2026-03-20 23:12:43 +08:00
Cita 44c485752d Merge pull request #217 from cita-777/codex/sqlite-site-announcements-migration-fixes
[codex] fix sqlite bootstrap and site announcement migration
2026-03-20 23:12:06 +08:00
cita 78f6c5b2cc Merge origin/main into codex/modeltester-attachment-review-fixes 2026-03-20 23:09:27 +08:00
cita 4369aeb1cb fix model tester attachment replay and capability gating 2026-03-20 22:56:01 +08:00
cita 9bc804c121 fix sqlite bootstrap and site announcement migration 2026-03-20 22:51:57 +08:00
Cita 79146af16a Merge pull request #216 from cita-777/codex/fix-responses-endpoint-memory
[codex] fix cross-protocol conversation file compatibility
2026-03-20 21:53:13 +08:00
cita 2890b7daf3 fix cross-protocol conversation file compatibility 2026-03-20 21:48:05 +08:00
Cita a2a93abb69 Merge pull request #214 from zmoon460/main
Fix: postgres 环境下无法保存 模型白名单和群组选择
2026-03-20 21:21:44 +08:00
Cita 485e7a0e7c Merge pull request #215 from Brucents/fix/direct-credential-route-binding
fix(routes): dedupe direct credentials and clarify binding labels
2026-03-20 21:19:48 +08:00
Brucents 5ee493778f fix(routes): dedupe direct credentials and clarify binding labels 2026-03-20 20:41:32 +08:00
zmoon460 da76f56721 Refactor parseJson to handle different value types
Refactor parseJson function to handle various input types and improve null checks.
2026-03-20 20:10:12 +08:00
zmoon460 ade5e0b1b3 Enhance unique violation check for PostgreSQL 2026-03-20 20:08:56 +08:00
Cita 0d70dba4e6 Merge pull request #212 from Brucents/fix/proxy-upstream-resolution
fix(proxy): 修正代理上游解析并补齐系统代理测试能力
2026-03-20 19:21:30 +08:00
Cita 1758d70b55 Merge pull request #211 from cita-777/codex/fix-responses-endpoint-memory
[codex] fix responses endpoint memory
2026-03-20 19:21:11 +08:00
cita 5f744c3382 fix responses endpoint memory 2026-03-20 19:15:53 +08:00
Brucents 088d0455a9 fix(proxy): add system proxy connectivity test and align proxy UI copy 2026-03-20 19:13:23 +08:00
Brucents a66cbafeb8 fix(proxy): separate upstream target resolution from proxy dispatch and add site-level proxy URLs 2026-03-20 17:40:02 +08:00
Cita 8c5ecc26d1 Merge pull request #207 from cita-777/codex/issue-200-group-route-alias
[codex] fix explicit-group source-model fallback
2026-03-20 16:55:28 +08:00
cita 3601e7a708 fix explicit-group source-model fallback 2026-03-20 16:45:19 +08:00
Cita 112191c5bc Merge pull request #206 from cita-777/codex/proxy-logs-model-badge-width
[codex] match proxy log model badge width to model name
2026-03-20 16:32:17 +08:00
cita 1a0e22b767 fix: match proxy log model badge width to content 2026-03-20 16:30:24 +08:00
Cita 400f3e1dfc fix: use tidb-compatible mysql datetime defaults (#205) 2026-03-20 16:21:37 +08:00
Bainily e9c1bd898e [codex] improve proxy stability, routing, and site subscription visibility (#194)
* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy
2026-03-20 16:18:28 +08:00
Shinku e2595bcab3 Merge pull request #204 from Shinku-Chen/rand-sk-2
生成密钥的时候,能随机生成,同时能复制token
2026-03-20 16:17:51 +08:00
Cita 5e872b331e Merge pull request #202 from cita-777/codex/site-announcement-postgres-time-fix
[codex] unify external db datetime behavior
2026-03-20 15:49:13 +08:00
Cita aad21e5b33 Merge pull request #201 from cita-777/codex/fix-client-detection
[codex] improve downstream client detection
2026-03-20 15:42:28 +08:00
cita 1451e61557 fix: unify external db datetime behavior 2026-03-20 15:39:41 +08:00
cita 0068200f2d Improve downstream client detection 2026-03-20 15:31:21 +08:00
Cita 03a3db84c1 Merge pull request #195 from cita-777/codex/site-announcement-timezone-fix
[codex] prefer client timezone for site announcements
2026-03-20 14:18:01 +08:00
cita f880e3a25f prefer client timezone for site announcements 2026-03-20 14:06:11 +08:00
Cita a2caa94069 Merge pull request #191 from cita-777/codex/checkin-settings-ui-fix
[codex] fix checkin settings styling and empty updates
2026-03-20 13:35:17 +08:00
Cita fe99968602 Merge pull request #190 from cita-777/codex/site-announcement-timezone-fix
[codex] fix site announcement timezone
2026-03-20 13:33:12 +08:00
cita 4f94393806 fix checkin settings styling and empty updates 2026-03-20 13:31:26 +08:00
cita 1af1fe244a fix site announcement timezone 2026-03-20 13:26:55 +08:00
dependabot[bot] 8b47df55ec build(deps): bump the minor-and-patch group across 1 directory with 7 updates
Bumps the minor-and-patch group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@visactor/react-vchart](https://github.com/VisActor/VChart/tree/HEAD/packages/react-vchart) | `2.0.16` | `2.0.19` |
| [mysql2](https://github.com/sidorares/node-mysql2) | `3.18.2` | `3.20.0` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.1` | `8.0.3` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.2.1` | `4.2.2` |
| [electron](https://github.com/electron/electron) | `41.0.2` | `41.0.3` |
| [mermaid](https://github.com/mermaid-js/mermaid) | `11.12.3` | `11.13.0` |



Updates `@visactor/react-vchart` from 2.0.16 to 2.0.19
- [Release notes](https://github.com/VisActor/VChart/releases)
- [Changelog](https://github.com/VisActor/VChart/blob/develop/packages/react-vchart/CHANGELOG.md)
- [Commits](https://github.com/VisActor/VChart/commits/v2.0.19/packages/react-vchart)

Updates `mysql2` from 3.18.2 to 3.20.0
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](https://github.com/sidorares/node-mysql2/compare/v3.18.2...v3.20.0)

Updates `nodemailer` from 8.0.1 to 8.0.3
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.1...v8.0.3)

Updates `@tailwindcss/vite` from 4.2.1 to 4.2.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/@tailwindcss-vite)

Updates `electron` from 41.0.2 to 41.0.3
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v41.0.2...v41.0.3)

Updates `mermaid` from 11.12.3 to 11.13.0
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.3...mermaid@11.13.0)

Updates `tailwindcss` from 4.2.1 to 4.2.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@visactor/react-vchart"
  dependency-version: 2.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: mysql2
  dependency-version: 3.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: nodemailer
  dependency-version: 8.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: electron
  dependency-version: 41.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: mermaid
  dependency-version: 11.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: tailwindcss
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 04:56:06 +00:00
Cita 029ee10d26 Merge pull request #187 from cita-777/codex/proxy-log-client-detection-main
feat: add proxy log client detection filters
2026-03-20 12:51:53 +08:00
Cita a4cb362599 Merge pull request #188 from cita-777/codex/site-announcements
[codex] improve site announcement display
2026-03-20 12:51:31 +08:00
cita 8291b9c59f test: align proxy route db mocks with client columns 2026-03-20 12:49:09 +08:00
cita f0d9936bf0 improve site announcement display 2026-03-20 12:45:11 +08:00
Cita a75d0c2a70 Merge pull request #186 from cita-777/codex/issues-180-160-31
[codex] fix desktop tray icon, telegram topics, and checkin intervals
2026-03-20 12:26:52 +08:00
cita 4e60290838 feat: add proxy log client detection filters 2026-03-20 12:25:30 +08:00
cita 220f786204 fix desktop tray icon telegram topics and checkin intervals 2026-03-20 12:23:13 +08:00
Cita 2de5eebf29 Merge pull request #179 from Shinku-Chen/tg-proxy
增加TG代理选项
2026-03-20 11:40:29 +08:00
Cita 08e7b052da Merge pull request #185 from cita-777/codex/site-announcements
[codex] add site announcements
2026-03-20 11:28:33 +08:00
Cita 0f5ec9a1c1 Merge pull request #184 from cita-777/codex/full-cliproxyapi-parity
[codex] complete codex cliproxyapi parity
2026-03-20 11:28:24 +08:00
cita 940bc1d71c add site announcements 2026-03-20 11:24:29 +08:00
cita adc4fb1be7 complete codex cliproxyapi parity 2026-03-20 11:23:29 +08:00
Cita 2a4502a539 Merge pull request #182 from cita-777/codex/downstream-key-whitelist-select-all-persistence
[codex] fix downstream key whitelist select-all persistence
2026-03-20 11:01:30 +08:00
cita 9077703503 fix downstream key whitelist select-all persistence 2026-03-20 10:54:02 +08:00
Shinku c5d55f0ab0 增加TG代理选项,增加测试用例 2026-03-20 01:03:21 +08:00
Shinku 54203d64a3 增加TG代理选项 2026-03-20 00:57:40 +08:00
Cita 5a9f007a75 Merge pull request #178 from cita-777/codex/downstream-key-default-deny-select-all
[codex] default deny empty downstream key exposure
2026-03-20 00:42:43 +08:00
cita 77d6a415d1 fix managed downstream deny-all semantics 2026-03-20 00:39:39 +08:00
cita 5403a946c6 default deny empty downstream key exposure 2026-03-20 00:22:17 +08:00
Cita ee6eeaddeb Merge pull request #177 from cita-777/codex/fix-responses-eof-false-success-clean
[codex] Handle mislabeled responses SSE bodies
2026-03-19 23:27:55 +08:00
cita 8672ff8dff Handle mislabeled responses SSE bodies 2026-03-19 23:20:40 +08:00
Hureru 382c6b22d8 Merge remote-tracking branch 'origin/main' into feat/account-level-proxy
# Conflicts:
#	src/server/routes/proxy/chat.ts
#	src/server/routes/proxy/responses.ts
2026-03-19 23:18:41 +08:00
Cita 50e3c868d0 Merge pull request #174 from cita-777/codex/fix-responses-eof-false-success-clean
[codex] fix responses empty completion handling
2026-03-19 22:37:27 +08:00
cita 2f0ee4d648 fix responses empty completion handling 2026-03-19 22:22:02 +08:00
Cita 97f72464a3 Merge pull request #171 from cita-777/codex/fix-responses-eof-false-success-clean
fix: stop false success logging when responses streams end early
2026-03-19 21:09:42 +08:00
Cita 0c4da8329d Merge pull request #170 from cita-777/codex/release-mac-arch-guard-main-20260319
[codex] harden mac release architecture verification
2026-03-19 21:00:35 +08:00
Cita 882c9dee5a fix: harden mac release architecture verification 2026-03-19 20:49:24 +08:00
Cita e656179044 fix: harden mac release architecture verification 2026-03-19 20:49:23 +08:00
Cita 1607928b6c fix: harden mac release architecture verification 2026-03-19 20:49:22 +08:00
Cita 6c3a09da86 fix: harden mac release architecture verification 2026-03-19 20:49:21 +08:00
cita 8f0b5c81f4 fix responses eof false success logging 2026-03-19 20:43:44 +08:00
Cita 36e9a85664 fix backup import preview and refresh race (#167) 2026-03-19 17:14:07 +08:00
Cita 24483c28eb Refactor proxy-core surfaces and transformer boundaries (#163)
* refactor: add canonical request contracts

* test: harden transformer boundary contracts

* refactor: adapt protocol transformers to canonical contract

* refactor: add cli profile registry

* refactor: extract provider profiles

* refactor: add conductor and provider executors

* refactor: add models surface

* refactor: migrate responses surface

* refactor: migrate chat surface

* refactor: migrate gemini surface

* refactor: migrate file surfaces

* test: align runtime executor undici mock

* fix: preserve compact SSE closeout payloads

* feat: support all-api-hub v2 backup imports

* fix: aggregate codex oauth SSE final payloads

* fix: harden responses SSE failure handling
2026-03-19 16:07:56 +08:00
Hureru 7c6b729ce1 fix(proxy): preserve requestedModel fallback in embeddings forwardBody 2026-03-19 01:48:52 +08:00
Hureru bef1cd05ea fix(proxy): apply account proxy to image edits route 2026-03-19 01:47:39 +08:00
Hureru 028a767639 fix(proxy): apply account proxy to balance refresh and income fallback calls 2026-03-19 01:45:15 +08:00
Hureru 471879abbf fix(proxy): apply account proxy to OAuth model discovery calls 2026-03-19 01:43:22 +08:00
Hureru bece34ae5b fix(proxy): skip dispatcher cache for account-level proxy URLs 2026-03-19 01:39:35 +08:00
Hureru 619df14291 fix(proxy): apply account-level proxy to count_tokens and completions routes 2026-03-19 01:30:37 +08:00
Hureru 17afe3319e fix(proxy): fix merge conflict data loss in embeddings.ts success path 2026-03-19 01:21:00 +08:00
Hureru 849800c4cd feat(web): use purple badge capsule for account proxy indicator 2026-03-19 01:12:39 +08:00
Hureru 252d40d897 fix(proxy): fix merge conflict syntax error in images.ts catch block 2026-03-19 00:48:12 +08:00
Cita c0c993f448 fix: align explicit group route picker and details (#158)
* fix: align explicit group route picker and details

* fix: move responses stream closeout into transformer facade
2026-03-19 00:47:22 +08:00
Hureru f064921757 Merge remote-tracking branch 'origin/main' into feat/account-level-proxy
# Conflicts:
#	src/server/routes/proxy/completions.ts
#	src/server/services/siteProxy.ts
2026-03-19 00:00:10 +08:00
Hureru 803ba84cfd Merge branch 'main' into feat/account-level-proxy
# Conflicts:
#	src/server/routes/api/accountTokens.ts
#	src/server/routes/proxy/chat.ts
#	src/server/routes/proxy/completions.ts
#	src/server/routes/proxy/embeddings.ts
#	src/server/routes/proxy/endpointFlow.ts
#	src/server/routes/proxy/images.ts
#	src/server/routes/proxy/responses.ts
#	src/server/services/accountExtraConfig.test.ts
#	src/server/services/modelService.ts
2026-03-18 23:42:21 +08:00
xuyufengfei 45fe687c0d 增加了空回为失败和自定义失败关键词的功能,修复了机器人提出的问题 (#153)
* Add proxy failure rules for keywords and empty responses

* fix(proxy): detect failure keywords for streamed chat responses

* Fix(tester): treat empty stream output as error

* feat(server): default listen host to IPv6 any-address

* fix(proxy): treat empty upstream content as failure

* Revert "feat(server): default listen host to IPv6 any-address"

This reverts commit 2ca49a3891cc3a2e37138156b17b660b6a3cb7ea.

* fix(settings): validate proxy failure settings

* fix: address CodeRabbit feedback in proxy failure detection

* test: force NODE_ENV=test for vitest to avoid React act() prod build

* fix(proxy/chat): preserve true SSE streaming (no upstream.text buffering)

* fix: avoid http fallback after hijacked SSE stream

* fix: wire proxy failure detection into chat and responses

---------

Co-authored-by: metapi-bot <metapi-bot@local>
Co-authored-by: openclaw <bot@local>
Co-authored-by: openclaw <openclaw@local>
Co-authored-by: openclaw-executor <executor@openclaw.local>
Co-authored-by: xuyufengfei <xuyufengfei@users.noreply.github.com>
Co-authored-by: cita <juricek.chen@gmail.com>
2026-03-18 22:43:13 +08:00
Brucents 2a51ef6e4a fix: 支持 SOCKS5 系统代理并修复脱敏令牌重复同步 (#154)
* fix: 支持 SOCKS5 系统代理并修复脱敏令牌重复同步

* fix: 避免复用完全模糊的脱敏令牌

* fix: 修复 reviewer 反馈的令牌同步与 SOCKS5 问题

* fix: avoid masked token sync collisions

---------

Co-authored-by: Brucents <mail@urlk.cn>
Co-authored-by: cita <juricek.chen@gmail.com>
2026-03-18 22:41:39 +08:00
Cita a71ffb3641 Merge pull request #155 from cita-777/codex/fix-connection-and-release-regressions
[codex] fix route grouping and codex websocket regressions
2026-03-18 21:49:30 +08:00
cita c989a029ab fix: recover sqlite migration gaps and harden route regex 2026-03-18 21:45:07 +08:00
cita efe60d7cc2 refactor: streamline explicit group editor flow 2026-03-18 21:21:38 +08:00
cita f769f43909 fix route grouping and codex websocket regressions 2026-03-18 20:38:01 +08:00
Cita f7881796d4 Merge pull request #151 from cita-777/codex/fix-connection-and-release-regressions
[codex] fix connection and release regressions
2026-03-18 18:43:51 +08:00
cita 332007d4fb fix responses stream failure logging 2026-03-18 18:17:06 +08:00
cita 38e46e165b fix connection and release regressions 2026-03-18 18:00:59 +08:00
Cita d928e067c1 Merge pull request #150 from cita-777/fix/codex-responses-runtime-parity
fix: harden codex runtime header forwarding
2026-03-18 15:17:43 +08:00
ciat-777 5372668c44 fix: minimize codex upstream headers and clean quota wording 2026-03-18 14:58:01 +08:00
Cita 428af17250 Merge pull request #149 from cita-777/fix/codex-responses-runtime-parity
fix: align codex responses continuity handling
2026-03-18 13:28:28 +08:00
ciat-777 90f0c30861 fix: align codex responses continuity handling 2026-03-18 13:25:42 +08:00
Cita 131fef6373 Merge pull request #147 from cita-777/feat/proxy-runtime-parity
feat: extend proxy runtime parity
2026-03-18 12:48:44 +08:00
ciat-777 f902fe1cf6 feat: surface oauth quota details 2026-03-18 12:42:31 +08:00
ciat-777 cd37b11722 feat: extend proxy runtime parity 2026-03-18 12:01:50 +08:00
Cita 20ef75e76d Merge pull request #146 from cita-777/fix/oauth-onboarding-reliability
fix: improve oauth onboarding reliability
2026-03-18 00:39:01 +08:00
ciat-777 0516c09c32 fix: align antigravity model discovery request headers 2026-03-18 00:31:32 +08:00
ciat-777 c12da45dfd fix: improve oauth onboarding reliability 2026-03-18 00:03:55 +08:00
Cita e223f24d1a Merge pull request #144 from cita-777/fix/oauth-onboarding-cliproxyapi
fix: align oauth onboarding with cliproxyapi
2026-03-18 00:02:10 +08:00
ciat-777 d4987bea84 fix: align oauth onboarding with cliproxyapi 2026-03-17 23:47:56 +08:00
Cita cca2b95e8c Merge pull request #143 from cita-777/Oauth-Management
fix: improve oauth fallback handling for Gemini and Antigravity
2026-03-17 23:16:21 +08:00
ciat-777 f565d1b54d fix: improve oauth error and model discovery fallbacks 2026-03-17 23:13:11 +08:00
Cita 2f2c93477d Merge pull request #141 from cita-777/Oauth-Management
feat: expand gemini proxy compatibility matrix
2026-03-17 21:30:41 +08:00
ciat-777 2e34f9033d feat: expand gemini proxy compatibility matrix 2026-03-17 21:22:23 +08:00
ciat-777 07351b527f feat: enhance OAuthManagement with improved provider handling and user feedback 2026-03-17 20:17:04 +08:00
Cita eb0c5475ca Merge pull request #140 from cita-777/feat/oauth-loopback-bridge
fix: preserve codex workspaces and clarify oauth guidance
2026-03-17 19:11:36 +08:00
Cita 4b155cbf33 Merge pull request #137 from weijiafu14/fix/gemini-strip-unknown-request-fields
fix: use allowlist for Gemini native request fields to prevent 400 on unknown fields
2026-03-17 19:09:11 +08:00
ciat-777 965ded8960 fix: preserve codex workspaces and clarify oauth guidance 2026-03-17 19:03:36 +08:00
Cita ddfa115eeb Merge pull request #135 from weijiafu14/fix/gemini-thought-signature-tool-calls
fix: inject thoughtSignature into functionCall parts for OpenAI→Gemini conversion
2026-03-17 18:37:12 +08:00
Cita 49f2adf470 Merge pull request #139 from cita-777/feat/oauth-loopback-bridge
feat: align oauth loopback flow with cliproxyapi
2026-03-17 18:02:31 +08:00
ciat-777 ebae2846b2 feat: align oauth loopback flow with cliproxyapi 2026-03-17 17:56:30 +08:00
Cita 5ee69a1ec5 Merge pull request #138 from cita-777/fix/sqlite-sites-unique-migration
fix: recover site unique index bootstrap
2026-03-17 17:47:31 +08:00
ciat-777 1021e6f234 fix: recover site unique index bootstrap 2026-03-17 17:42:18 +08:00
ciat-777 35f5bd3464 chore: align oauth defaults with cliproxyapi 2026-03-17 17:09:38 +08:00
weijiafu14 1bf2e46dd8 fix: use allowlist for Gemini native request fields to prevent 400
Fixes #130

The inbound normalizer forwarded all unknown top-level fields to Gemini
API. Client-specific fields like requestId caused upstream 400 errors.

Changed from blocklist (only 3 entries) to allowlist of known Gemini API
fields: contents, systemInstruction, cachedContent, safetySettings,
generationConfig, tools, toolConfig, labels, model.
2026-03-17 16:24:30 +08:00
weijiafu14 d20417d708 test: add 5 tests for thoughtSignature injection in OpenAI→Gemini path 2026-03-17 16:14:09 +08:00
weijiafu14 8bfdbda251 fix: inject thoughtSignature into functionCall parts for OpenAI→Gemini conversion
When requests arrive via OpenAI-compatible endpoints and are converted to
Gemini format, functionCall parts were missing thoughtSignature. Gemini 3+
rejects such requests with 400 when thinking mode is enabled.

Changes:
- Extract thoughtSignature from tool_calls provider_specific_fields
- Inject into corresponding functionCall parts
- Fall back to dummy sentinel when thinking is enabled but no signature available
- Split signed/unsigned parts into separate model messages (Gemini requirement)

All existing tests pass (59/59).
2026-03-17 16:08:08 +08:00
Cita 4c90028559 Merge pull request #133 from cita-777/feat/oauth-multi-provider
feat: add multi-provider OAuth support for Claude and Gemini CLI
2026-03-17 13:40:38 +08:00
ciat-777 d8e44eb805 feat: add multi-provider OAuth support for Claude and Gemini CLI 2026-03-17 13:36:44 +08:00
Cita 4e3dc92083 Merge pull request #129 from cita-777/feature/oauth-codex-management
feat: implement Codex OAuth flow with loopback callback server and ma…
2026-03-17 12:01:53 +08:00
ciat-777 575a15232b feat: implement Codex OAuth flow with loopback callback server and management UI
- Add localCallbackServer for handling OAuth callback requests.
- Implement service functions for managing OAuth sessions and accounts.
- Create CodexAdapter for platform-specific interactions.
- Develop OAuthManagement page for managing OAuth connections and providers.
- Add tests for localCallbackServer and OAuthManagement components.
2026-03-17 11:07:25 +08:00
Cita c156e0c164 Merge pull request #126 from cita-777/codex/masked-placeholder-tokens
feat: add masked token placeholders and desktop icons
2026-03-16 22:14:30 +08:00
cita 5f26ad976a feat: Implement Drizzle SQLite migration utility with schema verification and error recovery mechanisms. 2026-03-16 22:11:13 +08:00
cita 0a06f15e13 feat: add masked token placeholders and desktop icons 2026-03-16 21:36:23 +08:00
Cita 2409d9f184 Merge pull request #124 from cita-777/codex/zero-channel-routes
[codex] add zero-channel placeholders and remaining workspace changes
2026-03-16 19:40:46 +08:00
cita f67d871035 fix gemini stream payload typing 2026-03-16 19:38:17 +08:00
cita bec0457a92 include remaining workspace changes 2026-03-16 19:29:26 +08:00
cita 52441f2634 add zero-channel route placeholders 2026-03-16 19:25:02 +08:00
Cita 83c6b3460a Merge pull request #121 from cita-777/fix/release-packaging-and-model-dedupe
Fix release packaging and model dedupe
2026-03-16 16:40:24 +08:00
ciat-777 71dde80744 Fix release artifact name containing illegal slash from branch name
The upload-artifact step used github.ref_name which includes forward
slashes for branch-triggered workflow_dispatch runs. Replace with
github.run_id which is always a safe integer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:24:29 +08:00
ciat-777 b0e16fd3ea Fix model refresh dedupe and desktop packaging 2026-03-16 15:49:15 +08:00
Cita 60df68b712 Merge pull request #120 from cita-777/downstream-keys-ui-harmonize
Refine DownstreamKeys page and unify site badge links
2026-03-16 14:54:34 +08:00
ciat-777 d9e33e71ed Unify site badge links across console pages 2026-03-16 14:48:46 +08:00
ciat-777 bfb7ea180f Refine DownstreamKeys management page 2026-03-16 14:41:29 +08:00
ciat-777 7e7add1343 docs: add downstream key ui and site link plan 2026-03-16 13:32:56 +08:00
Hureru f36e867e1d test(proxy): add tests for account-level proxy resolution
Cover getProxyUrlFromExtraConfig, resolveChannelProxyUrl,
withSiteRecordProxyRequestInit third parameter, and
withAccountProxyOverride ALS context propagation.
2026-03-16 01:07:14 +08:00
Hureru f7b163c8b0 feat(web): add proxy URL field to account edit panel
Add proxyUrl input to the account edit form with a hint about
supported protocols. Display a proxy badge on accounts that have
a custom proxy configured in both mobile and desktop list views.
2026-03-16 01:06:57 +08:00
Hureru ffc2ad039c feat(proxy): wrap platform adapter calls with account proxy override
Use withAccountProxyOverride to propagate account-level proxy URL
through AsyncLocalStorage for checkin, balance refresh, model
discovery, token sync, rebind-session verify, and token CRUD
operations. Also accept proxyUrl field in PUT /api/accounts/:id
with validation via parseSiteProxyUrlInput.
2026-03-16 01:06:40 +08:00
Hureru 6d2f1c4b81 feat(proxy): pass account proxy through all proxy route handlers
Thread account-level proxyUrl from extraConfig into
resolveChannelProxyUrl for endpointFlow and the third parameter
of withSiteRecordProxyRequestInit for direct fetch calls across
chat, responses, completions, embeddings, images, search, and
videos proxy routes.
2026-03-16 01:06:16 +08:00
Hureru b43a4fd2ea fix(proxy): use proxyUrl parameter in endpointFlow fetch
The ExecuteEndpointFlowInput.proxyUrl field was defined but ignored
by the fetch call. Apply it via withExplicitProxyRequestInit when
present, falling back to withSiteProxyRequestInit otherwise.
2026-03-16 01:05:43 +08:00
Hureru 5687e87308 feat(proxy): add account-level proxy resolution core
Add getProxyUrlFromExtraConfig to extract proxy URL from account
extraConfig JSON. Extend siteProxy with AsyncLocalStorage-based
withAccountProxyOverride, resolveChannelProxyUrl, and optional
accountProxyUrl parameter on withSiteRecordProxyRequestInit.

Priority chain: account proxyUrl > site useSystemProxy > direct.
2026-03-16 01:03:32 +08:00
Cita e80fab5010 Merge pull request #114 from cita-777/fix/gemini-native-route-regressions
fix: address gemini native and route coverage regressions
2026-03-15 23:19:44 +08:00
ciat-777 bb608a39e9 fix: remove duplicate proxy log compat helper after rebase 2026-03-15 23:17:11 +08:00
ciat-777 4660c8ae0b fix: add downstream API key metadata and update proxy logs schema
# Conflicts:
#	src/server/db/generated/mysql.upgrade.sql
#	src/server/db/generated/postgres.upgrade.sql
#	src/server/db/index.ts
2026-03-15 23:14:44 +08:00
ciat-777 b15da138d1 feat: enhance MySQL upgrade generation with index prefix length control
- Added support for specifying prefix lengths for live text-backed columns in MySQL upgrade SQL generation.
- Introduced `mysqlIndexPrefixRequirements` option to `generateUpgradeSql` function.
- Updated tests to validate the new functionality for MySQL upgrade generation.
- Removed legacy shared index compatibility specifications to avoid duplication with runtime schema bootstrap.
- Improved error handling and response structure in account token coverage refresh logic.
- Added tests for coverage refresh failure scenarios to ensure proper handling of token persistence.

# Conflicts:
#	src/server/db/sharedIndexSchemaCompatibility.test.ts
#	src/server/db/sharedIndexSchemaCompatibility.ts
#	src/server/index.ts
2026-03-15 23:13:37 +08:00
ciat-777 1a2268cedb fix: update documentation to clarify default admin token for desktop installer on first launch 2026-03-15 23:08:49 +08:00
ciat-777 6ca08c1052 fix: update desktop server configuration for consistent port handling and access 2026-03-15 23:08:49 +08:00
ciat-777 4b42751953 fix: address gemini native and route coverage regressions 2026-03-15 23:08:49 +08:00
Delicious a64a51aa0c feat: 下游 API 密钥管理 (#103)
* feat: downstream API key usage + proxy log attribution

* chore(schema): regenerate generated artifacts

* test: keep migration replay + platform detection tests stable

* feat: complete downstream api key management validation

* refactor: move downstream keys into console nav

* feat: upgrade downstream keys management console

* feat: harden downstream key management flows

* test: cover downstream key policy guards

* feat: add downstream key grouping and tags

* feat: polish downstream key search and log metadata

* test: fix sqlite migration bootstrap coverage

* perf: isolate vchart bundle loading

* fix: align downstream key drawer metrics

---------

Co-authored-by: DeliciousBuding <DeliciousBuding@users.noreply.github.com>
2026-03-15 22:00:02 +08:00
dependabot[bot] 251348f784 build(deps-dev): bump wait-on from 8.0.5 to 9.0.4 (#109)
Bumps [wait-on](https://github.com/jeffbski/wait-on) from 8.0.5 to 9.0.4.
- [Release notes](https://github.com/jeffbski/wait-on/releases)
- [Commits](https://github.com/jeffbski/wait-on/compare/v8.0.5...v9.0.4)

---
updated-dependencies:
- dependency-name: wait-on
  dependency-version: 9.0.4
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 19:41:45 +08:00
dependabot[bot] 03363b27b1 build(deps-dev): bump electron from 35.7.5 to 41.0.2 (#110)
Bumps [electron](https://github.com/electron/electron) from 35.7.5 to 41.0.2.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v35.7.5...v41.0.2)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 41.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 19:41:35 +08:00
Cita 6237eb631f fix(db): tolerate runtime schema drift on startup (#113) 2026-03-15 19:07:46 +08:00
Cita cb405e785d Merge pull request #112 from cita-777/fix/external-db-runtime-upgrade
fix: harden external database runtime upgrades
2026-03-15 18:46:23 +08:00
ciat-777 b8735ad8c7 fix(db): enforce runtime external schema upgrades 2026-03-15 18:45:30 +08:00
Cita 67ad62c0d5 security: throttle sensitive admin routes (#111) 2026-03-15 17:28:34 +08:00
Cita 20480c4964 ci: add codeql scanning workflow (#108) 2026-03-15 16:55:04 +08:00
dependabot[bot] cc04466cad build(deps): bump undici from 6.24.0 to 6.24.1 in the runtime-http group (#107)
Bumps the runtime-http group with 1 update: [undici](https://github.com/nodejs/undici).


Updates `undici` from 6.24.0 to 6.24.1
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.24.0...v6.24.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.24.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: runtime-http
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:54:48 +08:00
Cita b812a2262a chore: tighten dependabot major update policy (#105) 2026-03-15 16:28:20 +08:00
dependabot[bot] 8812577da4 build(deps-dev): bump react-router-dom from 6.30.3 to 7.13.1 (#98)
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 6.30.3 to 7.13.1.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.13.1/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.13.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:27:58 +08:00
dependabot[bot] 7d265ef62c build(deps): bump the github-actions group with 12 updates (#90)
Bumps the github-actions group with 12 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `5` | `6` |
| [dorny/paths-filter](https://github.com/dorny/paths-filter) | `3` | `4` |
| [actions/setup-node](https://github.com/actions/setup-node) | `5` | `6` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `5` | `6` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |
| [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) | `3` | `4` |
| [actions/labeler](https://github.com/actions/labeler) | `5` | `6` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/cache](https://github.com/actions/cache) | `4` | `5` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` |


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

Updates `dorny/paths-filter` from 3 to 4
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dorny/paths-filter/compare/v3...v4)

Updates `actions/setup-node` from 5 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/metadata-action` from 5 to 6
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

Updates `actions/upload-pages-artifact` from 3 to 4
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

Updates `actions/labeler` from 5 to 6
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/v5...v6)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

Updates `actions/download-artifact` from 4 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: dorny/paths-filter
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/labeler
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:27:13 +08:00
dependabot[bot] f69463eae7 build(deps): bump node-cron from 3.0.3 to 4.2.1 (#92)
Bumps [node-cron](https://github.com/merencia/node-cron) from 3.0.3 to 4.2.1.
- [Release notes](https://github.com/merencia/node-cron/releases)
- [Commits](https://github.com/merencia/node-cron/compare/v3.0.3...v4.2.1)

---
updated-dependencies:
- dependency-name: node-cron
  dependency-version: 4.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:26:36 +08:00
dependabot[bot] 348f7cbbf5 build(deps): bump dotenv from 16.6.1 to 17.3.1 (#93)
Bumps [dotenv](https://github.com/motdotla/dotenv) from 16.6.1 to 17.3.1.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v16.6.1...v17.3.1)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 17.3.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:26:36 +08:00
Cita fadb268d3a Merge pull request #88 from DeliciousBuding/fix/normalize-upstream-v1
fix(proxy): baseUrl 已含 /v1 时避免重复拼接
2026-03-15 15:58:17 +08:00
DeliciousBuding a6e6be9460 fix(proxy): normalize upstream url join 2026-03-15 15:54:48 +08:00
Cita f2dca9c871 Merge pull request #100 from cita-777/chore/dependabot-policy
chore: tighten dependabot grouping and node baseline
2026-03-15 15:50:22 +08:00
ciat-777 cad1bf6764 chore: tighten dependabot grouping and node baseline 2026-03-15 15:49:57 +08:00
Cita e94af6788b Merge pull request #89 from cita-777/dependabot/docker/docker/docker-0e8fc498de
build(deps): bump node from 20-bookworm-slim to 25-bookworm-slim in /docker in the docker group
2026-03-15 15:35:09 +08:00
dependabot[bot] 2e0e10ba9b build(deps): bump node in /docker in the docker group
Bumps the docker group in /docker with 1 update: node.


Updates `node` from 20-bookworm-slim to 25-bookworm-slim

---
updated-dependencies:
- dependency-name: node
  dependency-version: 25-bookworm-slim
  dependency-type: direct:production
  dependency-group: docker
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-15 07:30:18 +00:00
Cita a228a1fbc4 Merge pull request #86 from cita-777/ci-parity-speedup
ci: speed up PR checks and isolate schema parity
2026-03-15 15:29:33 +08:00
ciat-777 f78cf434a9 fix(db): handle mysql information schema field casing 2026-03-15 15:27:09 +08:00
ciat-777 32789947b5 ci: document non-blocking audit policy 2026-03-15 15:21:48 +08:00
ciat-777 2699882569 fix(db): avoid mysql prefixes on non-text indexes 2026-03-15 15:16:15 +08:00
DeliciousBuding ef90169cc9 fix(proxy): avoid duplicated /v1 when joining upstream url 2026-03-15 15:13:50 +08:00
ciat-777 ef5f67e604 ci: parallelize PR checks and gate schema parity 2026-03-15 15:10:51 +08:00
ciat-777 982c0da010 docs: update security policy with detailed reporting guidelines and security considerations 2026-03-15 14:17:47 +08:00
ciat-777 916b9d6d73 feat: update issue templates for bug reports, documentation updates, feature requests, and questions with improved clarity and bilingual support 2026-03-15 14:00:23 +08:00
ciat-777 8cd81ff48d feat: enhance PR label backfilling with size validation and warning tracking 2026-03-15 13:39:19 +08:00
ciat-777 84fffaa6fc fix: upgrade github-script action to v8 in workflows 2026-03-15 13:33:35 +08:00
ciat-777 7f20fd920d Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-15 13:30:58 +08:00
ciat-777 723fee99bf feat: add issue templates for bug reports, documentation updates, feature requests, and questions 2026-03-15 13:30:55 +08:00
Cita bf01ae335b Merge pull request #83 from ksmaze/test-gemini
fix: remove gemini unrecognized fields before sending request
2026-03-15 13:27:10 +08:00
Cita 4b80807e56 Merge pull request #85 from cita-777/fix/render-generated-assets
fix: make external db bootstrap idempotent
2026-03-15 13:17:41 +08:00
ciat-777 c9d4a10f15 fix(db): make external bootstrap idempotent 2026-03-15 13:16:50 +08:00
ksmaze 9e4ffbd6c8 address comment 2026-03-14 22:05:12 -07:00
Cita 4aff5e6e27 Merge pull request #84 from cita-777/fix/render-generated-assets
fix(db): map mysql text primary keys to varchar
2026-03-15 12:55:35 +08:00
ciat-777 69e282cce1 fix(db): map mysql text primary keys to varchar 2026-03-15 12:50:10 +08:00
ksmaze e227fc0dd3 fix: remove gemini unrecognized fields before sending request 2026-03-14 21:40:39 -07:00
Cita 5c8d0583b7 Merge pull request #80 from ksmaze/main
feat: Add manual model availability management, including database sc…
2026-03-15 12:18:05 +08:00
ksmaze 0c7e600d41 address comments 2026-03-14 20:34:02 -07:00
ksmaze ed3de8cca6 address comments 2026-03-14 20:21:17 -07:00
ksmaze 083ae0e720 address comments 2026-03-14 19:43:46 -07:00
ksmaze de22fc35fa feat: Add manual model availability management, including database schema updates, new API routes, and UI components. 2026-03-14 19:26:13 -07:00
Cita 5da3fbf874 Merge pull request #79 from cita-777/fix/render-generated-assets
fix(build): copy runtime db generated assets
2026-03-15 01:36:02 +08:00
ciat-777 7180cbf913 fix(build): copy runtime db generated assets 2026-03-15 01:21:17 +08:00
Cita e385d87502 Merge pull request #78 from cita-777/yolo-copilot
test(db): yolo copilot retry for database parity
2026-03-15 01:14:03 +08:00
ciat-777 30d54c34ff refactor(db): fence legacy runtime schema compat 2026-03-15 01:12:39 +08:00
ciat-777 7f88009356 test(db): verify upgrade paths against the schema contract 2026-03-15 01:12:39 +08:00
ciat-777 d562ea124f test(db): add live schema parity checks for sqlite mysql and postgres 2026-03-15 01:12:39 +08:00
ciat-777 81578e7f75 feat(db): generate schema artifacts and align sqlite baseline 2026-03-15 01:12:39 +08:00
ciat-777 590f34af93 feat(db): generate canonical schema contract from sqlite migrations 2026-03-15 01:12:39 +08:00
ciat-777 908b5195b2 test(db): isolate default sqlite path under vitest 2026-03-15 01:12:39 +08:00
ciat-777 064af9e0bb Revert "Merge pull request #77 from cita-777/database-parity"
This reverts commit d2210b8ffb, reversing
changes made to b834a9bef8.
2026-03-15 01:11:48 +08:00
Cita d2210b8ffb Merge pull request #77 from cita-777/database-parity
refactor(db): database parity and runtime schema fencing
2026-03-15 01:05:06 +08:00
ciat-777 cc675d51d5 refactor(db): fence legacy runtime schema compat 2026-03-14 23:56:04 +08:00
ciat-777 627b04d456 test(db): verify upgrade paths against the schema contract 2026-03-14 23:37:25 +08:00
ciat-777 af6bd86e23 test(db): add live schema parity checks for sqlite mysql and postgres 2026-03-14 23:27:28 +08:00
ciat-777 6d389cb3af feat(db): generate schema artifacts and align sqlite baseline 2026-03-14 23:04:33 +08:00
ciat-777 5bc5fb7039 feat(db): generate canonical schema contract from sqlite migrations 2026-03-14 22:45:05 +08:00
ciat-777 c037ea866e test(db): isolate default sqlite path under vitest 2026-03-14 22:42:22 +08:00
ciat-777 b834a9bef8 feat: add site_disabled_models and proxy tasks to database schema
- Implemented site_disabled_models table with necessary SQL commands for SQLite, MySQL, and PostgreSQL.
- Updated ensureSiteSchemaCompatibility to create site_disabled_models if it doesn't exist.
- Enhanced database migration service to include site_disabled_models, proxy_video_tasks, and proxy_files in migration statements.
- Created shared index compatibility for new tables and ensured existing indexes are maintained.
- Added tests for schema parity to ensure consistency between runtime schema and defined schema.
2026-03-14 21:16:43 +08:00
ciat-777 0c109278b9 feat(migrate): implement recovery for duplicate-column migrations and enhance migration handling 2026-03-14 20:40:46 +08:00
ciat-777 7d431d3ccd fix(db): detect duplicate-column from nested migration error cause 2026-03-14 19:25:55 +08:00
ciat-777 7bdcf27c41 merge: reconcile migrate bootstrap with duplicate-column recovery 2026-03-14 19:12:23 +08:00
ciat-777 7b8ce3dd13 feat: add recovery for duplicate-column migration errors and enhance migration utilities 2026-03-14 19:10:18 +08:00
Cita b3d678f738 Merge pull request #68 from grassroadsZ/feature/site-model-disable
feat: 将模型禁用功能从站点移至连接管理
2026-03-14 17:26:57 +08:00
ciat-777 595073cf4c fix(pr-68): harden model disable flows
Co-authored-by: grassroadsZ <44087648+grassroadsZ@users.noreply.github.com>
2026-03-14 17:22:06 +08:00
ciat-777 7bd48a3c6f fix: improve tester timeouts and telegram proxy config 2026-03-14 16:18:43 +08:00
ciat-777 201a806e51 fix(ci): resolve audit failure and update actions 2026-03-14 13:52:58 +08:00
ciat-777 1a1f0452b3 feat: add delete confirmation modal and enhance token handling
- Implemented a DeleteConfirmModal component for confirming deletions across various pages (Accounts, Sites, Tokens).
- Updated Accounts and Sites pages to utilize the new modal for single and batch deletions.
- Enhanced TokensPanel to support group selection and improved token deletion handling with confirmation.
- Added functionality to skip confirmation for batch actions and handle token group fetching with error management.
- Improved error handling for expired sessions in NewApiAdapter and OneApiAdapter.
- Added tests for new delete confirmation behavior and token editing interactions.
2026-03-14 13:31:15 +08:00
silent 0d44d3ba10 feat: 将模型禁用功能从站点管理移至连接管理
- 在连接管理页面为每个账号添加独立的模型管理弹窗
- 弹窗内支持查看可用模型,进行「刷新模型」「全选」「全部禁用」「反选」等快捷操作
- 保存模型禁用配置后,系统会自动在后台触发并重建路由(rebuildRoutes),确保配置立即生效
- 从原站点编辑界面移除过时的禁用功能板块
- 修复因执行自动同步逻辑导致的 SQLite Drizzle Migration `duplicate column token_group` 报错,更新 bootstrap markers 及测试用例
2026-03-14 12:07:27 +08:00
Cita ad791d87bb Merge pull request #64 from ShicYang/main
feat: 使用日志新增自动刷新功能
2026-03-13 23:38:03 +08:00
scyang 0e36d1c351 fix:错误文本添加换行,避免过长溢出 2026-03-13 12:50:43 +08:00
scyang 464b0be704 feat: 使用日志新增自动刷新功能 2026-03-13 12:49:51 +08:00
Cita faea80c4f9 Merge pull request #63 from ShicYang/main
feat: 签到记录新增日期筛选
2026-03-13 10:22:35 +08:00
scyang e861f8a48f feat: 签到记录新增日期筛选 2026-03-12 23:53:48 +08:00
Cita 14a174e0c7 Merge pull request #59 from bnvnvnv/feat/round-robin-routing-ui-polish
feat: polish routing UI and add site header/log cleanup tooling
2026-03-12 16:28:50 +08:00
ciat-777 89f7cddb93 style: format CSS keyframes for improved readability 2026-03-12 16:28:00 +08:00
ciat-777 701cd98f73 feat: enhance site observability panel with improved latency color gradients and new UI components 2026-03-12 16:26:06 +08:00
bnvnvnv b6da71b1d6 feat: add site headers editor and scheduled log cleanup 2026-03-12 08:12:29 +00:00
bnvnvnv 1da3608d42 fix: address review feedback for models and routing 2026-03-12 05:51:53 +00:00
Cita e6babf6cfd Merge pull request #58 from bnvnvnv/feat/round-robin-routing-ui-polish
feat: round-robin routing + cooldown; fix models marketplace site filter; clarify token binding UX
2026-03-12 13:39:14 +08:00
bnvnvnv 5a7531f635 feat: add round robin routing and improve token binding UX 2026-03-12 05:18:36 +00:00
Cita cb93b28c71 Merge pull request #56 from Hureru/fix/mysql-migration-text-index-error
fix(db): resolve MySQL migration error with TEXT column UNIQUE index
2026-03-12 00:12:16 +08:00
Hureru af83d3525b fix(db): change model_name from TEXT to VARCHAR(191) for MySQL UNIQUE index compatibility 2026-03-11 23:50:02 +08:00
Cita d7c9e98fa7 Merge pull request #54 from bnvnvnv/feat/site-observability-proxy-logs
feat: add site observability drilldown for proxy logs
2026-03-11 21:43:20 +08:00
bnvnvnv a5121b8f9f fix: address observability review feedback 2026-03-11 12:00:35 +00:00
root ce32744bad Merge remote-tracking branch 'upstream/main' into feat/site-observability-proxy-logs 2026-03-11 11:44:29 +00:00
bnvnvnv bc3c731ed2 feat: add site observability log drilldown 2026-03-11 11:03:57 +00:00
Cita f904700b73 Merge pull request #53 from Hureru/refactor/token-routes-page
refactor: 重构 TokenRoutes 页面,支持分层加载、网格布局和性能优化
2026-03-11 16:14:25 +08:00
cita b35e9fcc7a docs: add live demo section to README files 2026-03-11 16:05:38 +08:00
Hureru f5fcf5d707 fix: address code review findings
- fix(api): use effectiveTokenId in route channel insert to match duplicate detection logic
- fix(css): rename keyframes to kebab-case (filter-bar-slide-down/up)
- fix(web): wrap "保存中..." text with tr() in ManualRoutePanel
- fix(web): use ref for fresh cache check and add error handling in useRouteChannels
2026-03-11 15:51:10 +08:00
Hureru 8128d6e036 fix(web): remove outdated decision auto-skip hint from info tip 2026-03-11 14:58:33 +08:00
cita 921c551a3a feat: Implement default API site seeding, add a dialect-aware setting upsert utility, and introduce check-in API routes with background task processing. 2026-03-11 14:31:23 +08:00
Hureru a0e474c561 perf(web): optimize TokenRoutes rendering and add infinite scroll
Rendering optimization:
- Extract stable routePatterns derived value to avoid expensive useMemo
  recomputation on enable/disable toggle (only recompute when patterns change)
- Stabilize all RouteCard callback props with useCallback+useRef pattern
- Pre-compute missingTokenSiteItems as useMemo index instead of per-render call
- Add EMPTY_MISSING_ITEMS constant for stable empty array reference

Infinite scroll:
- Replace manual "load more" button with IntersectionObserver sentinel
- Auto-load next chunk when scrolling within 200px of bottom
2026-03-11 14:06:23 +08:00
Hureru 47064ba7af perf(web): cache matchesModelPattern regex compilation and results
- Cache compiled glob RegExp objects to avoid repeated new RegExp()
- Cache model+pattern match results (up to 4000 entries)
- Significantly reduces CPU cost of pattern matching in route computations
2026-03-11 14:05:00 +08:00
Hureru 49dbaf3c6a fix(web): fix route card title overflow and optimize with React.memo
Layout fixes:
- Title uses flex:1 + text-overflow:ellipsis, badges always right-aligned
- Enable button has minWidth for cross-card vertical alignment
- Icon and badges use flexShrink:0 to prevent compression

Performance:
- Wrap RouteCard with React.memo to skip re-renders for unchanged routes
- Change callback props to accept routeId for stable reference compatibility
2026-03-11 14:04:04 +08:00
Hureru 69cc491d3a fix(web): fix AddChannelModal checkbox toggle and click area
- Move onClick handler to outer card div for full-area click support
- Set checkbox to readOnly with pointerEvents:none to prevent double-toggle
- Add stopPropagation on token select section to avoid unintended toggling
2026-03-11 14:01:45 +08:00
cita f43247e91d feat: add multi-dialect database schema compatibility logic for proxy files. 2026-03-11 13:51:13 +08:00
cita 2773b835a9 feat: set PORT environment variable to 4000 2026-03-11 13:40:02 +08:00
cita c640b36c96 feat: add deployment documentation and disable automatic PORT value generation in render.yaml 2026-03-11 13:37:31 +08:00
cita 742581efaa feat: Add Render deployment configuration and documentation, including a deploy button in README. 2026-03-11 13:21:19 +08:00
Hureru 90f92711fd fix(web): wire AddChannelModal with missing token hints and channel context
Pass missingTokenHints, onCreateTokenForMissing, and
existingChannelAccountIds to AddChannelModal so users can see
accounts that need token creation and identify already-added channels.
2026-03-11 12:00:07 +08:00
Hureru d2e1e7765d fix(web): add missing token hints and existing channel badge to AddChannelModal
- Display accounts that have model availability but lack tokens,
  with a "create token" action button for each
- Mark already-added channel accounts with a badge
- Improve empty state message to guide users on next steps
- Search now filters both available accounts and missing-token accounts
2026-03-11 11:53:26 +08:00
Hureru 5c026df760 fix(db): add schema compatibility for account_tokens.token_group column
Existing databases created before the token_group column was added would
fail on data import. Add accountTokenSchemaCompatibility module to
ALTER TABLE add the missing column, following the established pattern.
2026-03-11 01:15:11 +08:00
Hureru 457b4915da test: update TokenRoutes tests for summary-based data loading
Adapt all 3 test files to mock getRoutesSummary instead of getRoutes,
use summary-format data structures, and expand cards before asserting
channel-level content.
2026-03-11 01:15:02 +08:00
Hureru 1183d7fde0 refactor(web): rewrite TokenRoutes with grid layout and layered loading
Replace full-data loading with summary-first approach. Implement CSS Grid
multi-column card layout, collapsible filter panel, and collapsed-by-default
route cards that load channels on demand when expanded.
2026-03-11 01:14:52 +08:00
Hureru fa232950b9 refactor(web): extract TokenRoutes into modular sub-components
Split the 2447-line TokenRoutes.tsx into focused modules:
- types.ts: shared type definitions
- utils.ts: pure helper functions
- SortableChannelRow.tsx: DnD channel row component
- ManualRoutePanel.tsx: route create/edit form
- RouteFilterBar.tsx: collapsible filter panel with chip layout
- RouteCard.tsx: collapsed/expanded route card
- AddChannelModal.tsx: batch add channel modal
- useRouteChannels.ts: on-demand channel loading hook
2026-03-11 01:14:42 +08:00
Hureru bb7ea90f0b feat(api): add layered route loading endpoints
Add GET /api/routes/summary for lightweight first-screen data,
GET /api/routes/:id/channels for on-demand channel loading,
and POST /api/routes/:id/channels/batch for bulk channel creation.
Extract fetchChannelsForRoutes() as reusable helper.
2026-03-11 01:14:31 +08:00
Hureru 2343383729 feat: add lightweight /api/routes/lite endpoint for route selectors
Avoid loading full route data (channels, decision snapshots) when Settings
page only needs basic route info for selectors.
2026-03-10 23:06:56 +08:00
cita 5e14c2282f feat: Add MobileDrawer component and About page, and update package version. 2026-03-10 17:16:12 +08:00
cita 2268641759 Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-10 16:59:43 +08:00
cita 521b5bf391 feat: Implement core web application with routing, user authentication, theme settings, user profiles, and mobile navigation. 2026-03-10 16:59:39 +08:00
Cita a60adf4e26 Merge pull request #50 from cita-777/codex/model-refresh-health
Codex/model refresh health
2026-03-10 16:55:47 +08:00
cita 747b985513 feat: refine model refresh toasts and failure handling 2026-03-10 16:44:10 +08:00
cita 803c01f308 Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-10 16:38:26 +08:00
cita 15ea4db4d9 Refactor code structure for improved readability and maintainability 2026-03-10 16:38:21 +08:00
Cita 601f3bf407 Merge pull request #49 from cita-777/codex/mobile-responsive
Codex/mobile responsive
2026-03-10 16:29:52 +08:00
cita 01bf2212f8 fix: reset health on model discovery success 2026-03-10 16:27:10 +08:00
cita ab2220bc09 fix: export MobileDrawer 2026-03-10 16:25:44 +08:00
cita ab4033c1fa fix: avoid brandList TDZ in TokenRoutes 2026-03-10 16:18:44 +08:00
cita 1dbde9346a fix: guard useIsMobile in non-browser 2026-03-10 16:14:37 +08:00
cita d5cd8292eb test: cover model refresh early returns 2026-03-10 16:12:38 +08:00
cita 715a2dba9b feat: add mobile actions and CSS 2026-03-10 16:05:24 +08:00
cita e1b4452c82 feat: classify model refresh failures and update health 2026-03-10 16:02:45 +08:00
cita 3acba86edf test: expand model refresh failure contract 2026-03-10 15:55:33 +08:00
cita f01379f7a3 feat: convert logs and routes pages to mobile cards 2026-03-10 15:48:10 +08:00
cita 21cb2ddc62 test: cover model refresh failure health 2026-03-10 15:37:51 +08:00
cita b8ebf98b68 fix: add account mobile hints 2026-03-10 15:35:13 +08:00
cita 8a3ab0f625 docs: add model refresh health design 2026-03-10 15:28:57 +08:00
cita b23c5ece83 feat: convert core pages to mobile cards 2026-03-10 15:27:28 +08:00
cita 37e325d3cf feat: add mobile card components 2026-03-10 15:09:41 +08:00
cita d91a160455 feat: add mobile nav drawer 2026-03-10 15:00:43 +08:00
cita 2d8e1b9019 feat: add mobile drawer shell 2026-03-10 14:55:21 +08:00
cita ed830563d2 test: assert mobile layout attribute 2026-03-10 14:49:22 +08:00
cita 4e3d82aae1 feat: add mobile layout detection 2026-03-10 14:39:57 +08:00
cita dbf828467b chore: ignore local worktrees 2026-03-10 14:32:10 +08:00
cita 8ea7be68c1 Refactor code structure for improved readability and maintainability 2026-03-10 14:22:55 +08:00
cita 9a976232e2 feat: add validation for streaming requests on compact responses and include serialization mode in response 2026-03-10 13:01:47 +08:00
cita cd031b732f feat: enhance response serialization for compact mode
- Added support for preserving native response.compaction payloads when compact serialization is requested in `serializeResponsesFinalPayload`.
- Implemented serialization of compact mode as `response.compaction` instead of an ordinary response object.
- Introduced a new type `ResponsesFinalSerializationMode` to manage serialization modes.
- Refactored `serializeResponsesFinalPayload` to handle different serialization modes and usage payloads.
- Added tests to ensure correct behavior for compact serialization and preservation of upstream payloads.
- Updated favicon assets and documentation configuration for VitePress.
2026-03-10 01:28:34 +08:00
cita 74d2e559ec feat: add test for unchanged candidates when avoidSec is omitted and improve condition check in filterRecentlyFailedCandidates 2026-03-09 23:35:21 +08:00
cita 217dff0f72 fix: update test assertions to access parsed claudeOriginalBody structure 2026-03-09 23:25:53 +08:00
Cita fd7970e2ad Merge pull request #45 from cita-777/codex/docs-user-journey-rewrite
feat: Implement compatibility strategies for responses and chat endpo…
2026-03-09 22:48:56 +08:00
cita 0f8176cd04 feat: Implement compatibility strategies for responses and chat endpoints
- Added `routeCompatibility.ts` to handle response compatibility for different endpoints.
- Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic.
- Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`.
- Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints.
- Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`.
- Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests.
- Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
2026-03-09 22:48:10 +08:00
Cita 482d58cd88 Merge pull request #43 from cita-777/codex/docs-user-journey-rewrite
feat: rewrite user journey documentation and enhance input file handl…
2026-03-09 18:27:07 +08:00
cita 15d4b207cc feat: rewrite user journey documentation and enhance input file handling in responses 2026-03-09 18:25:09 +08:00
cita 3da1b93c39 feat: update input_file data format to include data URL scheme 2026-03-09 15:45:43 +08:00
cita 44799c0399 fix(tests): remove incorrect mime_type field from input_file in tests 2026-03-09 13:55:57 +08:00
cita e8cfe41e87 Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-09 13:48:42 +08:00
cita 136a038538 feat(tests): enhance modelTesterSession tests and add proxy logs tests
- Updated modelTesterSession tests to serialize assistant history as output_text and removed mime_type from input_file.
- Introduced new tests for stats proxy logs routes, covering paginated log retrieval and detailed log views with parsed billing details.
- Added server-driven tests for ProxyLogs component to validate API interactions and UI rendering based on server data.
2026-03-09 13:48:39 +08:00
Cita 55b10111f1 Update star history chart link with version 2026-03-09 00:12:54 +08:00
Cita 0e76d2304d Clean up README by removing extra line
Removed an empty line before the Star History section.
2026-03-09 00:10:37 +08:00
cita 3dcf45851f Refactor reasoning handling and metadata management in responses
- Updated chat stream tests to clarify reasoning behavior and ensure correct handling of include lists.
- Enhanced responses proxy route to normalize include lists and apply default reasoning includes based on request context.
- Improved transformer metadata handling, ensuring proper serialization and deserialization of metadata records.
- Added tests for preserving reasoning signatures and managing citations in OpenAI-compatible responses.
- Refined the logic for injecting default includes in responses and ensured compatibility with codex surface defaults.
2026-03-08 23:03:33 +08:00
cita 7bd3114ea0 feat: implement proxy input file resolution and storage
- Added `inputFiles.ts` for handling input file resolution, including validation and normalization of file types.
- Introduced `proxyFileStore.ts` for managing file storage, retrieval, and deletion with owner-based isolation.
- Created tests for proxy file storage functionality in `proxyFileStore.test.ts`.
- Developed `proxyInputFileResolver.ts` to resolve input files in OpenAI and response formats.
- Added utility functions for handling reasoning signatures in `reasoningTransport.ts` and corresponding tests.
- Enhanced shared input file transformers in `inputFile.ts` for better file handling and type inference.
2026-03-08 22:10:31 +08:00
cita ede8998244 Add comprehensive tests for Anthropics and Gemini transformers
- Implement tests for anthropicMessagesTransformer including serialization and event handling in stream.test.ts.
- Add usage extraction tests for anthropicMessagesUsage in usage.test.ts.
- Create extensive tests for Gemini generate content aggregation and outbound usage in aggregator-outbound-usage.test.ts.
- Introduce tests for Gemini stream handling and payload parsing in stream.test.ts.
- Enhance OpenAI response serialization tests to ensure proper mapping of usage details and preservation of annotations in outbound.test.ts.
2026-03-08 16:42:41 +08:00
cita 7081be14c7 feat: Add route grouping schema compatibility checks and integration into migration process 2026-03-08 13:29:25 +08:00
cita 4172ad4297 feat: Implement useThemeLabelColor hook and update components to utilize it for label color management 2026-03-08 13:07:27 +08:00
cita 8e291b9bf8 feat: Enhance Gemini and chat proxy routes with improved error handling and fallback mechanisms 2026-03-08 12:54:24 +08:00
cita 23922a5965 feat: Add normalized types and utility functions for handling chat formats
- Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling.
- Implemented utility functions for creating and merging normalized usage statistics.
- Added a new module for shared transformers to manage chat formats.

feat: Implement app local state management

- Created functions to clear application installation state and manage local storage keys.
- Added constants for theme and user profile storage keys.

test: Add tests for BrandIcon component rendering

- Implemented tests to ensure BrandIcon does not force a colored background for recognized brands.

test: Enhance ModelAnalysisPanel tests

- Added tests for rendering token summaries with compact units in ModelAnalysisPanel.

test: Add tests for ModernSelect component

- Implemented tests to verify icon rendering for selected options in ModernSelect.

test: Add number formatting tests

- Created tests for `formatCompactTokenMetric` to validate token formatting logic.

feat: Implement account creation intent handling

- Added logic to handle session and API key creation intents in the Accounts page.

test: Add tests for dashboard performance card

- Implemented tests to verify rendering of performance metrics in the dashboard.

feat: Implement default connection segment resolution

- Added logic to determine initial connection segments based on platform types.

test: Add tests for settings factory reset functionality

- Implemented tests to verify the factory reset process and local state clearing.

test: Add tests for site creation redirect logic

- Implemented tests to verify redirection based on site capabilities during creation.

test: Add tests for token routes refresh decision action

- Implemented tests to verify refresh decision actions in TokenRoutes.

test: Add tests for token routes snapshot load

- Implemented tests to verify loading of cached snapshot data in TokenRoutes.
2026-03-08 12:08:57 +08:00
cita ed05b37626 feat: enhance TokenRoutes with edit functionality and route visibility management
- Added editing capabilities for route management in TokenRoutes, allowing users to modify existing routes.
- Introduced a reset function for the route form to clear inputs after adding or editing routes.
- Implemented a visibility filter for routes, ensuring exact routes covered by group routes are hidden from the main list.
- Updated tests to cover new functionalities, including editing routes and verifying visibility logic.
- Created a new helper function to build a list of visible routes based on defined criteria.
- Enhanced schema compatibility checks for site configurations, ensuring necessary columns are added to the database.
2026-03-07 19:56:40 +08:00
cita 5ad5b87b16 feat: add withSiteRecordProxyRequestInit mock to siteProxy for testing 2026-03-07 18:17:15 +08:00
cita c437d93715 Add tests for centered modal adoption, search modal results, and accounts batch actions
- Implement tests for the CenteredModal component to ensure it follows the shared modal shell pattern.
- Add comprehensive tests for the SearchModal results, including API interactions and UI behavior.
- Create tests for accounts batch actions, verifying the functionality of refreshing balances and selecting accounts via row clicks.
- Introduce tests for the accounts centered modal to confirm the use of CenteredModal for add/edit/rebind flows.
- Add tests for the accounts edit panel to ensure proper rendering and functionality when opening the edit modal.
- Implement tests for the accounts tokens header to verify the reuse of the accounts page header in the tokens segment.
- Create tests for the system proxy settings, ensuring the correct saving of the proxy URL.
- Add tests for the tokens actions layout to confirm the proper layout and styling of token row actions.
- Implement tests for the tokens batch actions, including deletion of selected tokens and row selection behavior.
- Introduce tests for the tokens focus navigation to ensure the correct highlighting of focused token rows.
2026-03-07 18:04:05 +08:00
cita c31bbcbb51 refactor: remove deprecated apiKey from site management and update related functionality
- Removed apiKey from SiteRow type and related forms in Sites component.
- Updated site saving logic to handle site creation without apiKey.
- Adjusted tests to ensure apiKey is not included in site editor state.
- Refactored Tokens component to support embedded mode and redirect logic.
- Added migration tests for site apiKey to account migration service.
- Implemented siteApiKeyMigrationService to handle migration of apiKey to account tokens.
- Enhanced tests for segmented connections in Accounts and redirect logic for Tokens.
2026-03-07 15:03:30 +08:00
cita a98a6f2c18 ci: update desktop packaging process and add smoke test for native modules on Windows 2026-03-07 11:40:57 +08:00
cita 98a2936416 refactor: improve SQLite migration handling and update .gitignore 2026-03-07 00:29:56 +08:00
Cita 1e12ec5211 Merge pull request #35 from cita-777/codex/run-release.yml-to-simulate-github-actions-ohq25x
Normalize downloaded dist layout in release workflow, skip npmRebuild during packaging, bump version to 1.2.1
2026-03-06 23:28:22 +08:00
Cita 87d772703f Merge branch 'main' into codex/run-release.yml-to-simulate-github-actions-ohq25x 2026-03-06 23:27:30 +08:00
Cita 200489539b chore: bump version to 1.2.1 2026-03-06 23:26:59 +08:00
Cita 95c7b5b10d Merge pull request #34 from cita-777/codex/run-release.yml-to-simulate-github-actions-8hwayv
Normalize dist layout and disable npm rebuild in release workflow
2026-03-06 23:07:26 +08:00
cita 3124b26bd0 Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-06 22:45:00 +08:00
cita 0d99419b9f feat: implement GOB encoding support for user ID extraction and enhance cookie handling in NewApiAdapter 2026-03-06 22:44:56 +08:00
Cita 1263edbccf Merge branch 'main' into codex/run-release.yml-to-simulate-github-actions-8hwayv 2026-03-06 22:01:51 +08:00
Cita d668906c8e ci: normalize dist artifact before desktop packaging 2026-03-06 22:00:29 +08:00
Cita 81e61f96d8 Merge pull request #33 from cita-777/codex/run-release.yml-to-simulate-github-actions
Disable npm rebuild during desktop packaging and remove AppImage prefetch step
2026-03-06 21:43:27 +08:00
Cita 5e6e1ca245 ci: make release packaging locally reproducible 2026-03-06 21:36:23 +08:00
cita 02bf0393e6 feat: enhance desktop build process with artifact upload and download, and update server working directory resolution 2026-03-06 20:25:48 +08:00
cita 6c51801e50 feat: add macOS ad-hoc signing support
- Implemented afterPack script to ensure ad-hoc signature for macOS builds.
- Created macosSignUtils module to handle app path retrieval, signature inspection, and ad-hoc signing.
- Added functions to check if the app is signed and apply ad-hoc signing if necessary.
2026-03-06 19:58:38 +08:00
cita 19125d686e Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-06 19:29:12 +08:00
cita 56b930495b feat: enhance release workflow with Electron cache restoration and retry logic for desktop builds 2026-03-06 19:29:10 +08:00
Cita 1c98db723e Merge pull request #32 from Zhou-Ruichen/fix/suppress-duplicated-reasoning-content
fix(chatFormats): suppress content when it duplicates reasoning_content
2026-03-06 18:41:48 +08:00
cita e88fc0a997 feat: implement proxy billing details handling and resolveProxyLogBilling function 2026-03-06 18:28:45 +08:00
Ruichen Zhou 83608c3521 fix(chatFormats): suppress content when it duplicates reasoning_content
Some upstream providers (e.g. certain OpenAI-compatible aggregators)
emit thinking/reasoning tokens with the same text written to both
delta.content and delta.reasoning_content simultaneously.

When metapi forwarded these chunks as-is, downstream clients would read
delta.content and display the internal reasoning text to end users —
appearing as word-by-word "thinking narration" in chat interfaces.

Fix: after resolving both contentDelta and reasoningDelta, suppress
contentDelta when it is non-empty and identical to reasoningDelta.
This covers the duplicate-write pattern without affecting any legitimate
case (a provider would never intentionally set content == reasoning).
2026-03-06 11:28:07 +01:00
cita 3748e6dcb6 feat: add proxy log billing details handling and schema management 2026-03-06 18:12:12 +08:00
cita bf216d7011 Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-06 17:10:04 +08:00
cita 55b1ae564e feat: update error messages in NewApiAdapter tests to use correct Chinese translations
feat: enhance proxy usage parser to include cache read and creation tokens

feat: add billing details to ProxyLog interface and implement related formatting functions

fix: correct mojibake issues in source files and add regression test for mojibake detection

chore: add SQL migration for billing details in proxy_logs table

test: improve site speed button tests to ensure proper state updates without DOM lookups
2026-03-06 17:10:01 +08:00
Cita 4d9bd52c01 Update star history chart link format 2026-03-06 16:58:30 +08:00
cita 2a810facb8 feat: implement macOS notarization and ad-hoc signing in afterSign script
feat: add run-server script to manage server execution and compatibility checks

feat: create main application logic for desktop with window management, server handling, and auto-updater

test: add unit tests for desktop runtime helpers

feat: implement desktop server environment configuration and health check

test: add tests for server configuration and desktop routes

feat: add native module guard for better-sqlite3 compatibility

test: add tests for native module guard functionality

test: add comprehensive tests for account verification feedback handling

feat: implement account verification feedback helpers

chore: add TypeScript configuration for desktop builds
2026-03-06 16:24:41 +08:00
cita acdc846b13 Refactor logging timestamps to use formatUtcSqlDateTime function across proxy routes and services; add storedTimestampRepairService for fixing createdAt values in database 2026-03-06 11:57:30 +08:00
cita 7fda94f27d feat(models): update model selection logic and add comprehensive tests for /v1/models route 2026-03-05 21:52:35 +08:00
cita fdf4f30987 feat: add createdAt timestamps to check-in logs and improve i18n translations
- Added createdAt timestamps to various check-in log entries in checkinService.ts for better tracking.
- Updated i18n translations in Models.tsx to improve user experience with accurate Chinese translations.
- Reduced default page size in ProgramLogs.tsx and ProxyLogs.tsx from 80 and 100 to 50 for better performance.
- Enhanced ProxyLogs.tsx with memoization for filtered logs and summary statistics.
- Implemented support for parsing Unix timestamps and PostgreSQL-style timezone offsets in checkinLogTime.ts.
- Added comprehensive tests for database proxy query wrapper in index.proxy-wrap.test.ts to ensure functionality.
2026-03-05 21:18:49 +08:00
cita 0d5e56f478 feat(settings): add validation for SQLite connection strings and improve error messages 2026-03-05 19:37:09 +08:00
cita b122893796 Add tests for settings database runtime configuration API
- Implement tests for retrieving and saving runtime database configuration.
- Validate handling of invalid configuration payloads.
- Utilize Fastify for API testing with Vitest.
- Ensure proper cleanup of database state between tests.
2026-03-05 18:57:18 +08:00
cita 283c052fa5 test:Add database migration service with normalization and masking functions
- Implemented `normalizeMigrationInput` to standardize database migration input for sqlite, mysql, and postgres.
- Added `maskConnectionString` to obscure sensitive credentials in connection strings.
- Created comprehensive tests for migration input normalization and connection string masking.
- Established database schema creation logic for multiple dialects (sqlite, mysql, postgres).
- Developed functions for managing database transactions, including begin, commit, and rollback.
- Implemented data insertion logic from backup snapshots into the target database.
2026-03-05 17:38:52 +08:00
cita 0168592c95 Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-05 14:28:12 +08:00
cita 4a61148b4f feat(accounts): extend rebind-session API to handle refresh token and expiration fields 2026-03-05 14:28:10 +08:00
Cita b6860df426 Update Star History chart image link in README 2026-03-05 13:25:17 +08:00
cita 27501b5a85 feat(ProgramLogs): add event status summary parsing and corresponding labels 2026-03-05 13:08:59 +08:00
cita 11fc61f4fa feat(donehub): add user self endpoint handling and balance calculation logic 2026-03-05 12:23:45 +08:00
cita 24769706e1 feat(proxy): enhance error handling for chat proxy requests with fallback logic 2026-03-04 22:12:02 +08:00
cita ecc117d7ee feat(proxy): update endpoint resolution order for 'anyrouter' platform 2026-03-04 21:48:26 +08:00
cita ebc5934547 feat(platforms): enhance platform detection by adding URL hinting logic 2026-03-04 21:05:30 +08:00
cita 9fbf434bd6 feat(proxy): enhance upstream endpoint handling and error summarization
- Added new utility functions for building minimal JSON headers and normalizing responses for compatibility.
- Implemented detailed error handling for unsupported media types and endpoint downgrade scenarios.
- Introduced a new endpoint flow mechanism to manage retries and downgrades between different upstream endpoints.
- Enhanced tests for endpoint flow and error summarization to cover various edge cases and scenarios.
- Updated CSS styles for accounts table to improve layout and responsiveness.
- Refactored ModelTester component to handle incremental text updates more effectively.
2026-03-04 20:59:48 +08:00
cita 82e9f3b601 Add tests for accounts login shield and proxy log retention service
- Implement tests for accounts login shield detection, verifying behavior when login fails with HTML JSON parse syntax error.
- Create tests for proxy log retention service, ensuring expired logs are deleted based on retention days and that cleanup is skipped when retention is disabled.
- Add tests for token router cache, validating that route snapshots are maintained within TTL until explicitly invalidated.
- Introduce necessary mock setups and database seeding for comprehensive testing.
2026-03-04 14:59:06 +08:00
cita 536d957a9e Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-04 13:46:34 +08:00
cita e281e45e84 feat: add comprehensive tests for token candidates and proxy log path metadata
- Implemented tests for the `/api/models/token-candidates` endpoint to verify model availability and token coverage scenarios.
- Added tests for `composeProxyLogMessage` to ensure proper formatting of log messages with downstream and upstream metadata.
- Created utility functions for parsing proxy log path metadata and handling missing token hints.
- Developed tests for the `Sites` and `TokenRoutes` components to validate UI behavior and interactions, including scrolling and group expansion.
- Introduced a custom hook `useAnimatedVisibility` for managing visibility animations in components.
- Enhanced the documentation link for site resources.
2026-03-04 13:46:31 +08:00
Cita 4b77207d03 Update star history chart version in README 2026-03-03 18:50:28 +08:00
cita d49fcefa05 feat: 增强错误处理,添加对特定无效请求的处理逻辑 2026-03-03 17:29:37 +08:00
cita 9ff138f0b5 feat: 添加 Telegram 通知支持,更新配置和文档 2026-03-03 17:25:27 +08:00
ciat-777 cf6b2285fb feat: 更新文档,添加项目架构和界面预览部分 2026-03-03 15:13:52 +08:00
ciat-777 eff6f22f6d Add unit tests for backupService to validate import/export functionality 2026-03-03 14:50:59 +08:00
ciat-777 5cebc1554f feat: 增强聊天代理流行为,优化错误处理和工具调用逻辑 2026-03-03 00:03:34 +08:00
ciat-777 e2fce12e20 feat: 更新文档链接,增强客户端接入指南,优化代理日志记录和错误处理 2026-03-02 23:43:29 +08:00
ciat-777 fed7ea7023 feat: 增强测试用例以支持函数调用和输出,优化工具参数处理逻辑 2026-03-02 23:11:38 +08:00
ciat-777 555f6e15ab feat: enhance tool call handling and serialization in chat formats
- Added support for tool calls in NormalizedFinalResponse type.
- Implemented functions to collect and serialize tool calls from OpenAI and Claude responses.
- Updated convertClaudeRequestToOpenAiBody to handle tool use and tool result messages.
- Enhanced normalizeUpstreamFinalResponse to include tool calls in the response structure.
- Introduced utility functions for safe JSON stringification and parsing.
- Improved conversion functions for OpenAI tools to Anthropic format and vice versa.
- Updated input handling in convertOpenAiBodyToMessagesBody and convertOpenAiBodyToResponsesBody to accommodate tool messages.
- Refactored serialization logic in serializeFinalResponse and buildSyntheticOpenAiChunks to include tool calls.
2026-03-02 22:18:13 +08:00
ciat-777 515d083b2c feat: 添加 Zeabur 一键部署指南和相关变量说明 2026-03-02 21:08:07 +08:00
ciat-777 ed2b30c347 fix: 更新 Dockerfile,添加缺失的依赖项安装 2026-03-02 20:51:20 +08:00
ciat-777 605c5e1959 refactor: 更新未知平台的端点优先级逻辑 2026-03-02 20:46:02 +08:00
ciat-777 487bd23a02 Add platform adapters and tests for CLIProxyAPI, OneHub, Sub2API, and title hint detection
- Implemented `CliProxyApiAdapter` with detection logic for CLIProxyAPI.
- Added tests for `CliProxyApiAdapter` to verify detection and response handling.
- Created `OneHubAdapter` with methods for fetching models and user groups.
- Added tests for `OneHubAdapter` to ensure correct fallback and response parsing.
- Developed `Sub2ApiAdapter` with detection and user info fetching capabilities.
- Added comprehensive tests for `Sub2ApiAdapter` covering various scenarios.
- Introduced `TitleHintPlatform` type and detection logic based on HTML title.
- Implemented tests for title hint detection to identify platforms from URLs.
2026-03-02 20:26:02 +08:00
ciat-777 897273fad4 fix: 更新 Zeabur 部署链接和按钮样式 2026-03-02 17:34:08 +08:00
cita 264a12ed00 feat: 添加 Zeabur 部署模板和相关文档 2026-03-02 16:14:59 +08:00
cita 9471d3359b Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-02 15:35:44 +08:00
cita 946907d66c Refactor code structure for improved readability and maintainability 2026-03-02 15:35:40 +08:00
Cita 515fcf1aaf Merge pull request #16 from cita-777/imgbot
[ImgBot] Optimize images
2026-03-02 14:46:13 +08:00
ImgBotApp 24f4b4aa56 [ImgBot] Optimize images
*Total -- 13,493.63kb -> 8,824.35kb (34.6%)

/docs/screenshots/account-balance.png -- 152.01kb -> 86.31kb (43.22%)
/docs/logos/drafts/logo1.png -- 5,242.26kb -> 3,141.12kb (40.08%)
/docs/screenshots/token-management.png -- 156.86kb -> 97.87kb (37.61%)
/docs/logos/logo.png -- 123.83kb -> 77.82kb (37.15%)
/docs/logos/logo-icon-128.png -- 34.64kb -> 21.89kb (36.81%)
/docs/logos/logo-icon-512.png -- 477.52kb -> 308.22kb (35.45%)
/src/web/public/logo.png -- 477.52kb -> 308.22kb (35.45%)
/docs/logos/drafts/logo2.png -- 1,789.13kb -> 1,186.45kb (33.69%)
/docs/logos/drafts/logo3.png -- 1,230.31kb -> 844.78kb (31.34%)
/docs/logos/logo-icon-64.png -- 9.81kb -> 6.77kb (30.99%)
/docs/screenshots/accounts.png -- 474.50kb -> 328.72kb (30.72%)
/docs/screenshots/dashboard.png -- 680.18kb -> 473.10kb (30.44%)
/docs/screenshots/model-marketplace.png -- 566.33kb -> 394.13kb (30.41%)
/docs/screenshots/routes.png -- 598.34kb -> 419.06kb (29.96%)
/docs/logos/logo-banner.png -- 94.92kb -> 67.79kb (28.58%)
/docs/screenshots/account-management.png -- 680.25kb -> 496.13kb (27.07%)
/docs/logos/logo-banner-sm.png -- 31.83kb -> 23.79kb (25.26%)
/docs/logos/logo-full.png -- 121.20kb -> 90.99kb (24.92%)
/docs/screenshots/allapi-hub-backup.png -- 193.06kb -> 145.82kb (24.47%)
/docs/screenshots/site-management.png -- 78.36kb -> 61.36kb (21.7%)
/docs/logos/logo-full-sm.png -- 40.96kb -> 32.34kb (21.06%)
/src/web/public/favicon-64.png -- 5.31kb -> 4.31kb (18.96%)
/docs/logos/logo-icon-32.png -- 2.84kb -> 2.32kb (18.11%)
/docs/screenshots/account-credentials.png -- 231.67kb -> 205.04kb (11.5%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2026-03-02 06:42:36 +00:00
cita a3f55a83f9 Refactor code structure for improved readability and maintainability 2026-03-02 14:37:49 +08:00
cita f6389a999a feat(tests): add comprehensive tests for TokenRouter and related components
- Implement tests for TokenRouter downstream policy, covering allowedRouteIds and site weight multipliers.
- Add tests for TokenRouter patterns and model mapping, including regex pattern matching and model mapping precedence.
- Create tests for BrandIcon component to verify brand detection logic.
- Develop tests for Accounts page, focusing on rebind panel behavior and refresh action on error.
- Introduce fuzzy search utility tests to validate substring and subsequence matching.
- Implement navigation focus helpers tests for building and parsing focus paths.
- Add progressive render tests to ensure correct initial and next visible counts.
- Create route model candidates index tests to validate route candidate indexing and matching logic.
2026-03-02 13:33:35 +08:00
cita 99c9e145c3 feat: 更新 Dockerfile 2026-03-01 18:17:04 +08:00
cita 5c1bbd0c62 feat: 更新 Dockerfile 和启动脚本,改用 Debian slim 基础镜像,增强 Node.js ABI 兼容性检查 2026-03-01 17:52:36 +08:00
cita 05a5ee9b9e feat: enhance upstream endpoint handling with new passthrough header extraction and response management
- Introduced new types for endpoint preferences to support 'responses'.
- Added functions to extract safe passthrough headers, Claude-specific headers, and responses-specific headers.
- Implemented logic to ensure proper handling of stream accept headers.
- Updated endpoint resolution logic to prioritize 'responses' in certain contexts.
- Enhanced request building for 'messages' and 'responses' endpoints with improved body sanitization and header management.
- Added comprehensive documentation for new features and usage instructions.
2026-03-01 17:26:50 +08:00
cita af63df808d Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-01 16:19:17 +08:00
cita 906e8328d6 feat: 添加发布工作流和启动脚本,支持直接运行预打包的 Release 产物 2026-03-01 16:19:14 +08:00
Cita a667aad3d0 Update Star History Chart link with version 2026-03-01 16:08:40 +08:00
cita e41b6b7993 feat: 增强设置功能,支持当前识别的管理 IP 和白名单更新逻辑 2026-03-01 15:39:13 +08:00
cita 4d6cd683b3 Merge branch 'main' of https://github.com/cita-777/metapi 2026-03-01 15:21:19 +08:00
cita 4e22b379a8 feat: Add support for OpenAI Responses endpoint and enhance proxy retry logic
- Updated proxyRetryPolicy to retry on all 4xx and 5xx status codes.
- Introduced 'responses' target format in TestChatRequestPayload.
- Implemented extractResponsesContent function to handle responses format in ModelTester.
- Enhanced ModelTester to support responses target format and display appropriate labels.
- Updated ProxyLogs to extract and display upstream paths from error messages.
- Added tests to ensure responses target format is preserved during payload sync.
- Created responses proxy route to handle requests to the new OpenAI Responses endpoint.
- Updated upstreamEndpoint logic to include responses endpoint handling.
- Added modelService tests to validate token route rebuilding logic.
2026-03-01 15:21:16 +08:00
Cita d6698e5da0 Update Star History chart link and parameters 2026-03-01 01:19:44 +08:00
cita 44af9a91cf feat: add sorting and pinning functionality for accounts and sites
- Introduced `isPinned` and `sortOrder` fields in the `accounts` and `sites` tables to allow users to pin and sort their accounts and sites.
- Updated API routes to handle pinning and sorting operations for accounts and sites.
- Enhanced the frontend to support sorting by balance and custom order, as well as toggling the pin status of accounts and sites.
- Added tests for sorting and reordering logic to ensure correct functionality.
- Updated documentation to reflect changes in token management and new features.
2026-02-28 23:45:30 +08:00
cita 08eaf11ea2 feat: 添加 Docker 发布工作流以支持多架构镜像构建 2026-02-28 22:50:46 +08:00
cita f75459f8bf feat: 添加表存在性检查以确保数据库模式的完整性 2026-02-28 22:26:05 +08:00
cita 9405d468d9 Add initial database schema and journal entries for token management
- Created a new snapshot JSON file (0003_snapshot.json) defining the schema for tables including account_tokens, accounts, checkin_logs, events, model_availability, proxy_logs, route_channels, settings, sites, token_model_availability, and token_routes.
- Added columns, indexes, and foreign key constraints for each table to ensure data integrity and relationships.
- Introduced a journal JSON file (_journal.json) to track schema versions and changes, including entries for version 6 with relevant tags and timestamps.
2026-02-28 22:20:28 +08:00
cita 2eeebbd686 docs: 修复 README.md 中的格式问题,移除多余的反引号 2026-02-28 17:45:02 +08:00
cita 9892770052 docs: 清理 README.md,移除冗余链接和占位符 2026-02-28 17:36:14 +08:00
cita 63481caa8b Refactor code structure for improved readability and maintainability 2026-02-28 17:30:34 +08:00
cita 20eec09211 docs: 更新 README.md,优化内容格式和结构 2026-02-28 00:40:13 +08:00
cita 2ecc23730d chore: initialize clean metapi repository 2026-02-28 00:36:28 +08:00
598 changed files with 242916 additions and 2 deletions
+24
View File
@@ -0,0 +1,24 @@
# Cargo build acceleration.
#
# The biggest single speedup is the `mold` linker (~3-5x faster link
# time on debug builds). `build-and-run.sh` enables mold/lld automatically
# when available, so the block below is kept as an optional manual setup for
# direct `cargo build` / `cargo run` usage:
#
# sudo apt-get install -y mold
#
# (or build it from source: https://github.com/rui314/mold)
#
# Without mold/lld, the [profile.dev] tweaks in Cargo.toml still give a
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
# Optional: uncomment once mold is on PATH if you want direct cargo commands
# to use it without going through build-and-run.sh.
# [target.x86_64-unknown-linux-gnu]
# linker = "clang"
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[build]
# Default to all CPUs for parallel codegen. Override with --jobs N if
# you need to leave headroom for other work.
# jobs = 8
+20
View File
@@ -0,0 +1,20 @@
comfy_source/venv
comfy_source/.git
comfy_source/__pycache__
**/__pycache__
target/
node_modules/
dist/
release/
data/
logs/
tmp/
.agents/
.cache/
.codex/
.claude/
.comfyui/
boosapi
bossai
boosapi.db
docker/*.tar
+1
View File
@@ -0,0 +1 @@
NODE_ENV=test
+33
View File
@@ -0,0 +1,33 @@
/target
**/*.rs.bk
.env
.env.*
data/
node_modules/
website/.next/
website/out/
dist/
release/
*.log
*.tsbuildinfo
logs/
tmp/
!tmp/.gitkeep
.DS_Store
Thumbs.db
docker/data/
docker/.env
.ace-tool/
.comfyui/
.claude/
.codex/
.agents/
.cache/
openspec/
boosapi
bossai
docker/*.tar
boosapi.db-shm
boosapi.db-wal
.tours/
bun.lock
+4
View File
@@ -0,0 +1,4 @@
[submodule "vendor/ComfyUI_frontend"]
path = vendor/ComfyUI_frontend
url = https://github.com/Comfy-Org/ComfyUI_frontend.git
branch = main
+72
View File
@@ -0,0 +1,72 @@
# BoosAPI Engineering Rules
> **AI开发**: 使用 **gpt-5.5**(`api.boos.lat/v1`,key: `sk-boos4721`)进行开发工作。
> 本项目为纯 Rust 项目,无 Node.js 运行时依赖。
## 项目结构
```
boosapi-rs/ ← 根目录(已迁移至此)
├── src/
│ ├── main.rs ← 入口
│ ├── lib.rs
│ ├── config.rs
│ ├── error.rs
│ ├── routes/
│ │ ├── mod.rs
│ │ ├── comfyui_agent.rs ← ComfyUI Agent SSE 聊天
│ │ ├── comfyui/ ← ComfyUI 代理路由
│ │ ├── proxy/ ← OpenAI 兼容代理
│ │ ├── auth/ ← 认证
│ │ └── ...
│ ├── proxy/ ← 代理核心(Conductor/Token Router)
│ ├── services/ ← 后台服务
│ ├── db/ ← 数据库
│ └── transformers/ ← 协议转换
├── dist/web/ ← 前端构建产物(由 Rust 静态 serve)
├── migrations/ ← SQLite 迁移
├── config.toml ← 运行配置
├── Dockerfile
└── Cargo.toml
## ComfyUI Agent
ComfyUI Agent 默认走 **本地模式**:复用本服务自身的 `/v1/*` 路由,bearer 用启动时引导出来的内置 downstream key(`name='comfyui-agent' AND user_id IS NULL`,密钥格式 `sk-comfyui-{uuid_v4_hex}`,同时镜像写入 `settings.builtin_key.comfyui_agent.secret`)。
行为契约:
- **Local(默认)**:`upstream_base` = `http://{server.host}/v1`(host 是 `0.0.0.0` / `::` 时改写为 `127.0.0.1`),bearer = 内置 key;`/api/comfyui-agent/status` 回 `mode: "local"` + `keyName: "comfyui-agent"`
- **FallbackRemote**:当 `BOOSAPI_UPSTREAM_BASE`(trim 后非空)被设置时切换到外部上游;`BOOSAPI_UPSTREAM_KEY` 缺省时仍用内置 key;status 回 `mode: "fallback_remote"` + `keyName: "external"`
- 内置 key 受保护:admin 列表对其 key 字段打码(`••••<后4位>`),删除返 403,禁用(`enabled=false`)返 403;`builtin_key.*` settings 不在 list 中暴露,PUT 返 403
- 启动期 `ensure_comfyui_agent_key` 是幂等的:缺密钥就生成、`enabled=0` 自动恢复并 `tracing::warn!`、settings 与 downstream_api_keys 双向同步
支持的结构化标记(输出由 LLM 生成、由 agent SSE 解析下发):
- 【CHARACTER】 → 自动调用 gpt-image-2 生成四视图
- 【SCENE】 → 自动生成场景图
- 【TTS】 → 自动调用 TTS API
- 【WORKFLOW】 → 输出 ComfyUI 工作流 JSON
## 运行
```bash
# 开发
cargo run --release
# 环境变量
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
BOOSAPI_UPSTREAM_KEY=sk-boos4721
BOOSAPI_AUTH__AUTH_TOKEN=change-me-admin-token
```
## 前端
前端为 React SPA,使用 Vite 构建,产物在 `dist/web/`:
- 开发时用 Vite dev server(需 Node.js)
- 生产时由 Rust 后端直接 serve 静态文件
### 登录与会话
- 登录页位于 `src/web/pages/auth/`:`LoginShell` 提供共享外壳,`AdminLoginPage`(token / 账密双模式)与 `UserLoginPage`(账密)分别承担两类受众。OAuth 入口用 `OAuthSlot` 渲染占位区域,**实际行为由后续 change 接入**——当前不要在这里挂任何 onClick / 跳转。
- 会话状态统一走 `src/web/authSession.ts` 的 `commitSession / clearSession / getSessionToken`,受众通过 `'admin' | 'user'` 区分;旧的 `persistAuthSession / clearAuthSession / getAuthToken / hasValidAuthSession`(及 `*UserSession` 同族)保留为 `@deprecated` 薄壳,新代码不要再引用。
- 登录错误文案统一走 `src/web/loginError.ts` 的 `resolveLoginErrorMessage(status, raw)`;新增登录错误请加在该处而不是各自分支里手写。
Generated
+4177
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
[package]
name = "boosapi"
version = "1.3.0"
edition = "2021"
description = "AI API Gateway — Rust rewrite of BoosAPI"
[dependencies]
# Web framework
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-gzip"] }
# Async runtime
async-stream = "0.3"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
# Database
sqlx = { version = "0.8", features = [
"runtime-tokio",
"sqlite",
"postgres",
"any",
"migrate",
"chrono",
] }
# HTTP client
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "rustls-tls", "smtp-transport"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
# Auth
jsonwebtoken = "9"
argon2 = "0.5"
# Config
figment = { version = "0.10", features = ["env", "toml"] }
dotenvy = "0.15"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Error handling
thiserror = "2"
anyhow = "1"
# CLI
clap = { version = "4", features = ["derive"] }
# Time
chrono = { version = "0.4", features = ["serde"] }
# UUID
uuid = { version = "1", features = ["v4", "serde"] }
# Streaming
futures = "0.3"
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
tokio-stream = "0.1"
bytes = "1"
# Misc
once_cell = "1"
parking_lot = "0.12"
mime = "0.3"
base64 = "0.22"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
include_dir = "0.7"
mime_guess = "2"
[dev-dependencies]
rstest = "0.24"
wiremock = "0.6"
tracing-test = "0.2"
tempfile = "3"
axum-test = "16"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
# Faster production-like build for staging/manual verification. This keeps
# optimizations on but avoids the slow LTO + single-codegen-unit release path.
[profile.fast-release]
inherits = "release"
opt-level = 2
lto = false
codegen-units = 256
incremental = true
# Dev-profile speedups: line-tables-only debuginfo (much smaller .debug
# sections → much faster link) and 256 codegen units (more parallelism
# during codegen, slightly worse runtime — fine for dev). Dependencies
# build with no debuginfo at all since we rarely step into them.
[profile.dev]
opt-level = 0
debug = "line-tables-only"
codegen-units = 256
incremental = true
[profile.dev.package."*"]
opt-level = 0
debug = false
+263
View File
@@ -0,0 +1,263 @@
# BoosAPI 部署与环境指南
本文档介绍如何配置和运行 BoosAPI 的开发环境与生产环境。
## 1. 快速参数参考 (CLI Args)
后端二进制文件支持以下命令行参数,优先级高于 `config.toml` 和环境变量:
- `-c, --config <PATH>`: 指定配置文件路径 (默认: `config.toml`)
- `-p, --port <PORT>`: 覆盖监听端口
- `--host <HOST>`: 覆盖监听地址 (如 `0.0.0.0`)
- `--log-level <LEVEL>`: 设置日志级别 (`trace`, `debug`, `info`, `warn`, `error`)
- `--migrate-only`: 仅运行数据库迁移后退出
- `--check-config`: 加载并验证配置后退出
- `-h, --help, -help`: 显示帮助信息
- `-V, --version, -version`: 显示版本信息
示例:
```bash
./boosapi --config prod.toml --port 8080 --log-level warn
# 仅执行迁移
./boosapi --migrate-only
```
---
## 2. 开发环境 (Development)
### 前提条件
- **Rust**: 1.85+ (推荐使用 rustup)
- **Node.js**: 20+ (仅用于前端构建)
- **SQLite**: 系统自带或手动安装
### 步骤
1. **克隆项目**:
```bash
git clone https://github.com/boos/boosapi.git
cd boosapi
```
2. **配置环境**:
复制 `config.toml.example` 并根据需要修改:
```bash
cp config.toml.example config.toml
```
或者直接设置环境变量。
3. **运行前端 (热更新模式)**:
```bash
npm install
# 启动 vite 开发服务器
npm run dev:web
```
默认访问 `http://localhost:5173`。Vite 会将 `/api` 请求代理到后端 `localhost:4000`。
4. **运行后端**:
```bash
# 使用默认配置运行
cargo run
# 推荐:使用 cargo-watch 自动重载
# cargo install cargo-watch
# cargo watch -x run
```
---
## 3. 生产环境 (Production)
### 单二进制生产包
推荐用项目脚本生成生产包:
```bash
./build-and-run.sh --production
```
生产模式会构建主前端和 Workflow UI,并把静态资产嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
启动前检查配置:
```bash
./boosapi --check-config
./boosapi --migrate-only
./boosapi --config config.toml
```
### 推荐:Docker Compose 部署
1. **准备 docker-compose.yml**:
```yaml
services:
boosapi:
image: ghcr.io/boos/boosapi:latest
container_name: boosapi
ports:
- "4000:4000"
volumes:
- ./data:/app/data
- ./config.toml:/app/config.toml
environment:
- BOOSAPI_AUTH__AUTH_TOKEN=your-secure-admin-token
- BOOSAPI_AUTH__JWT_SECRET=your-secure-jwt-secret
- BOOSAPI_DB__URL=sqlite:/app/data/boosapi.db?mode=rwc
restart: unless-stopped
```
2. **启动**:
```bash
docker compose up -d
```
### 传统 Linux 部署 (Systemd + Nginx)
1. **构建前端与后端**:
```bash
# 构建前端
npm run build
# 构建后端
cargo build --release
```
2. **安装**:
将生成的二进制文件 `target/release/boosapi` 和静态资源目录 `dist/web` 拷贝到部署目录 (如 `/opt/boosapi`)。
3. **初始化数据库**:
```bash
/opt/boosapi/boosapi --migrate-only
```
4. **Systemd 配置** (`/etc/systemd/system/boosapi.service`):
```ini
[Unit]
Description=BoosAPI Service
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/boosapi
ExecStart=/opt/boosapi/boosapi --config /opt/boosapi/config.toml
Restart=always
Environment=BOOSAPI_SERVER__LOG_LEVEL=info
[Install]
WantedBy=multi-user.target
```
5. **Nginx 反向代理配置**:
```nginx
server {
listen 80;
server_name api.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 支持 WebSockets 与 SSE
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
}
}
```
---
## 4. 关键配置项
| 配置项 (config.toml) | 环境变量 (ENV) | 说明 |
|-------------------|--------------|------|
| `server.host` | `BOOSAPI_SERVER__HOST` | 后端监听地址 (默认 127.0.0.1) |
| `server.port` | `BOOSAPI_SERVER__PORT` | 后端监听端口 (默认 4000) |
| `auth.auth_token` | `BOOSAPI_AUTH__AUTH_TOKEN` | 管理员登录 Token (静态,必填) |
| `auth.jwt_secret` | `BOOSAPI_AUTH__JWT_SECRET` | JWT 签名密钥 (普通用户,必填) |
| `db.url` | `BOOSAPI_DB__URL` | SQLite 数据库路径 (如 `sqlite:data/boosapi.db`) |
| `comfyui.backend` | `BOOSAPI_COMFYUI__BACKEND` | Workflow 后端:`cloud` / `runninghub` / `external` / `boosapi` / `docker` |
| `comfyui.base_url` | `BOOSAPI_COMFYUI__BASE_URL` | Comfy Cloud、RunningHub 或外部 ComfyUI API 地址 |
| `comfyui.api_key` | `BOOSAPI_COMFYUI__API_KEY` | Workflow 后端 API Key,按后端需要填写 |
| `web_dir` | `BOOSAPI_WEB_DIR` | 前端静态资源目录 (默认 `dist/web`) |
### Workflow 后端配置
BoosAPI 的 `/workflows` 页面使用内嵌 ComfyUI 前端,后端由 `[comfyui]` 选择。
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
外部完整版 ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native 执行器:
```toml
[comfyui]
backend = "boosapi"
```
说明:
- `cloud`、`runninghub`、`external` 和 `boosapi` 模式不要求 BoosAPI 机器有 GPU。
- `docker` / 本地 ComfyUI 模式需要本机有完整模型目录和匹配的 NVIDIA GPU。
- 生产构建应包含 `dist/workflow-ui`、`dist/workflow-ui-cloud`、`dist/workflow-ui-external` 中可用的 Workflow UI 资产;脚本会尽量自动安装与构建。
---
## 5. 运维操作 (Ops)
### 检查配置
在启动前验证配置文件是否正确:
```bash
./boosapi --check-config
```
### 数据库备份
BoosAPI 使用 SQLite,只需备份数据库文件即可:
```bash
cp data/boosapi.db data/boosapi.db.bak
```
如使用 PostgreSQL,请按数据库自身的 `pg_dump` / `pg_restore` 流程备份恢复。
### 查看日志
如果使用 Systemd:
```bash
journalctl -u boosapi -f
```
如果使用 Docker:
```bash
docker logs -f boosapi
```
+16
View File
@@ -0,0 +1,16 @@
FROM rust:1.83-alpine AS builder
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release 2>/dev/null || true
COPY . .
RUN cargo build --release
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata sqlite-libs
WORKDIR /app
COPY --from=builder /app/target/release/boosapi .
COPY --from=builder /app/migrations ./migrations
EXPOSE 4000
CMD ["./boosapi"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Metapi contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+575 -2
View File
@@ -1,3 +1,576 @@
# metapi <div align="center">
Meta-layer management and unified proxy for AI API aggregation # BoosAPI
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
<p>
把你各处注册的 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等站点,
<br>
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
<br>
<strong>Workflow Studio / 视频助手</strong> — 用内嵌 ComfyUI 前端、多 API 后端和自然语言助手组合图像、视频、语音与文本能力。
<br>
<strong>SaaS 多租户架构</strong>,组织管理、成员邀请、套餐限制,开箱即用。
</p>
<p align="center">
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
<img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
</p>
<p align="center">
<a href="README.md"><strong>中文</strong></a> |
<a href="README_EN.md">English</a> |
<a href="DEPLOY.md">部署指南</a>
</p>
</div>
---
## 📖 介绍
现在 AI 生态里出现了越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。
**BoosAPI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
支持的上游范围:
- **聚合面板**:New API、One API、OneHub、DoneHub、Veloera、AnyRouter、Sub2API
- **通用兼容接口**:OpenAI / Claude / Gemini compatible,cliproxyapi / CPA
- **官方预设**:阿里云、智谱、豆包 Coding Plan、DeepSeek、Moonshot、MiniMax、ModelScope
- **OAuth 连接**:Codex、Claude、Gemini CLI、Antigravity
| 痛点 | BoosAPI 怎么解决 |
|------|-----------------|
| 🔑 每个站点一个 Key,下游配置一堆 | **统一代理入口 + 多下游 Key 策略**,模型自动聚合 |
| 💸 不知道哪个站点最便宜 | **智能路由** 按成本、余额、使用率选最优通道 |
| 🔄 站点挂了手动切换 | **自动故障转移**,失败通道自动冷却并切换 |
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BoosAPI 模型路由 |
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
## 2026-06 更新重点
- **后台登录收口**:管理员后台只允许使用 `BOOSAPI_AUTH__AUTH_TOKEN` 这类 API Token 登录;普通用户仍使用账号密码登录用户工作台。
- **日志边界清晰**:管理员「使用日志」保留在 `/logs`,系统/程序日志保留在 `/events`;`/api/events` 现在只接受管理员 API Token,普通用户不会再看到运行日志。
- **通知隔离**:普通用户顶部通知只读取用户公告;管理员通知面板继续聚合签到、余额、令牌、代理、状态与公告事件。
- **后台信息架构**:侧栏按接入管理、创作应用、系统管理、我的账户精简;外部监控入口已移除,系统日志入口保留在系统管理分组。
- **运维与 IP 管理**:新增运维监控视图与 IP 管理页面,支持代理质量检测、连接测试、批量操作与更清晰的状态展示。
- **用户自助工作台**:API Key、计费、账户充值、使用记录、工作空间和模型测试统一到用户侧;工作空间支持分页、一键选择和默认预览。
- **Comfy Checkout 充值**:用户先在系统内创建充值记录,再跳转到配置的 Comfy Checkout 链接付款;充值记录会保留用于后续对账。
- **Workflow UI 更新**:`npm run workflow-ui:install:cloud` 会安装当前 Comfy Cloud 前端资产并写入构建元数据,Rust 服务会对 JS/CSS 走 no-cache,避免继续显示旧版本。
- **本地化时间**:前端展示时间优先使用浏览器/UA 时区,后端无时区时间按 UTC 解析后再转成本地显示。
## ✨ 核心功能
### 🎨 Workflow Studio 与视频助手(Rust)
- **自然语言描述脚本**:说想法 → AI 自动提取角色、场景、对白
- **自动生成四视图**:每个角色正/背/左/右 4 角度,调用 gpt-image-2 生成图片
- **场景生成**:自动为每个场景生成配图
- **TTS 语音合成**:对话台词自动转语音
- **Workflow Studio**:内嵌 ComfyUI 前端,可切换 Comfy Cloud、RunningHub、外部 ComfyUI、BoosAPI API-native 执行器或本地 Docker 完整版 ComfyUI
- **远程执行优先**:使用 Comfy Cloud、RunningHub、外部 API 或 BoosAPI 上游路由时,BoosAPI 服务器不需要 GPU
- **外部 ComfyUI 可选代理**:已有独立 ComfyUI 服务时,可通过 `comfyui.base_url` 接入 `/api/comfyui/proxy/*`
- **Rust 后端**(boosapi-rs):高性能 SSE 流式响应,零 Node.js 运行时依赖
- 侧边栏「视频助手」入口,与聚合网关同一界面
- **默认走本地模式**:复用本服务自身的 `/v1/*` 路由 + 启动时引导出来的内置 downstream key(`comfyui-agent`),开箱即用,无需额外配置
- **回退远端**:设置 `BOOSAPI_UPSTREAM_BASE`(可选 `BOOSAPI_UPSTREAM_KEY`)即切换为外部上游;`/api/comfyui-agent/status` 的 `mode` 字段会反映当前模式
### 🌐 统一代理网关
- 兼容 **OpenAI** 与 **Claude** 下游格式
- 支持 Responses / Chat Completions / Messages / Completions / Embeddings / Images / Models / Files
- SSE 流式传输,自动格式转换
### 🧠 智能路由引擎
- 自动发现所有上游站点的可用模型,**零配置**生成路由表
- 四级成本信号:实测成本 → 配置成本 → 目录参考价 → 默认兜底
- 多通道概率分摊(成本 40% / 余额 30% / 使用率 30%)
- 失败自动冷却(默认 10 分钟),自动切换通道
### 📡 多平台聚合管理
| 平台 | 适配器 |
|------|--------|
| New API | `new-api` |
| One API | `one-api` |
| OneHub | `onehub` |
| DoneHub | `done-hub` |
| Veloera | `veloera` |
| AnyRouter | `anyrouter` |
| Sub2API | `sub2api` |
### 👥 账号与 Token 管理
- 多站点多账号,四级健康状态追踪(healthy / unhealthy / degraded / disabled)
- 凭证加密存储,自动续签,站点联动禁用
### ✅ 自动签到
- Cron 定时执行(默认每日 08:00)
- 智能解析奖励,签到失败自动通知
- 完整签到日志与并发锁防重
### 💰 余额管理
- 定时刷新(默认每小时),收入与消费趋势分析
- 余额兜底估算,凭证过期自动重新登录
### 🔔 告警通知
五种渠道:Webhook / Bark / Server酱 / Telegram Bot / SMTP 邮件
告警场景:余额不足、站点异常、签到失败、代理异常、Token 过期、每日摘要
### 🎮 模型操练场
- 交互式聊天测试,流式/非流式双模式
- 对比不同通道输出
### 👥 SaaS 多租户管理
- **组织管理**:创建组织,每个组织独立管理成员和配额
- **成员邀请**:通过邮件邀请加入组织,支持管理员/成员角色
- **三级套餐**:Free(3人)/ Pro(10人)/ Enterprise(100人)
- **权限控制**:Owner / Admin / Member 三级角色,细粒度权限
- **套餐限制检查**:成员数、API Key 数、日请求量自动限制
- **邀请链接**:带 token 的邀请链接,48 小时有效期
### 🔌 REST API 总览
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/organizations` | GET/POST | 组织列表 / 创建组织 |
| `/api/organizations/plans` | GET | 可用套餐列表 |
| `/api/organizations/:id` | GET/PATCH | 组织详情 / 更新 |
| `/api/organizations/:id/members` | GET | 成员列表 |
| `/api/organizations/:id/members/:uid` | PATCH/DELETE | 改角色 / 移除成员 |
| `/api/organizations/:id/invitations` | GET/POST | 邀请列表 / 创建邀请 |
| `/api/organizations/:id/invitations/:invId` | DELETE | 撤销邀请 |
| `/api/invitations/:token` | GET | 公开邀请查询 |
| `/api/invitations/:token/accept` | POST | 接受邀请 |
| `/api/users/register` | POST | 注册新用户 |
| `/api/users/login` | POST | 登录获取 JWT |
| `/api/users/me` | GET/PATCH | 个人信息 / 更新 |
| `/api/user-api-keys` | GET/POST | API Key 列表 / 创建 |
---
## 🖥️ 硬件配置建议
BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|----------|-----|------|------|-----|
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
| BoosAPI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BoosAPI 机器不需要;ComfyUI 机器按模型需求配置 |
| 本机 Docker / 本地 ComfyUI 轻量文生图 | 4-8 核 | 16GB+ | 100GB+ | NVIDIA 8GB VRAM 勉强,12GB+ 推荐 |
| 本机视频生成 / 数字人 / 大工作流 | 8 核+ | 32GB 起步,64GB 推荐 | 200GB-1TB | NVIDIA 24GB VRAM 起步,48GB+ 更稳 |
推荐的 SaaS 网关部署规格:
```txt
4 核 CPU
8GB 内存
50GB SSD
无需 GPU
```
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
## 🎛️ Workflow API 后端模式
BoosAPI 的 Workflow 使用内嵌 ComfyUI 前端,后端可按配置切换:
| backend | 用途 |
|---------|------|
| `cloud` | 代理 Comfy Cloud API,适合默认创作、官方订阅模型和稳定执行 |
| `runninghub` | 代理 RunningHub 的 ComfyUI 兼容 API,适合使用 RunningHub 专属插件、工作流和模型 |
| `external` | 代理你自己部署的完整版 ComfyUI |
| `boosapi` | 使用 BoosAPI 自带 Rust Workflow/API 节点执行器 |
| `docker` | 本地完整版 ComfyUI 模式,适合有本地 GPU 和完整模型目录的环境 |
推荐配置是默认 `cloud`,需要 RunningHub 专属插件或模型时切到 `runninghub`。RunningHub 模式会复用 external 兼容前端,并对 `/object_info`、模型库、`/system_stats` 做短期缓存,减少每次打开工作流时的卡顿。
配置示例:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
```toml
[comfyui]
backend = "boosapi"
```
### 将 Comfy 视频节点包装为 BoosAPI `/v1/videos`
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BoosAPI 可以把一个已保存的视频工作流模板包装成统一视频模型:
1. 在工作流页面保存一个真实 Comfy 视频模板,模板需要能被 `POST /prompt` 执行。
2. 模板图里最好保存节点映射:
- `prompt`:提示词节点和字段
- `negativePrompt`:反向提示词节点和字段,可选
- `ratio` / `resolution` / `duration`:画幅、分辨率和时长节点字段,可选
3. 在模型路由里新增一个模型,例如 `comfy-wan-video`。
4. 让该路由的上游地址或实际模型映射指向模板:
- 上游地址:`comfy://<workflow_public_id>`
- 或模型映射:`comfy:<workflow_public_id>`
之后用户可以直接调用:
```bash
curl http://127.0.0.1:4000/v1/videos \
-H "Authorization: Bearer <BoosAPI downstream key>" \
-H "Content-Type: application/json" \
-d '{
"model": "comfy-wan-video",
"prompt": "cinematic product video, smooth camera movement",
"duration": 5,
"ratio": "9:16",
"resolution": "1080p"
}'
```
返回的 `id` 是 Comfy `prompt_id`,可用 `GET /v1/videos/{id}?model=comfy-wan-video` 查询 Comfy history。
> Workflow UI 有 cloud 与 external/runninghub 两套前端资产。开发环境可用 `npm run workflow-ui:install:cloud`、`npm run workflow-ui:install:external` 单独安装;安装脚本会写入 `boosapi-workflow-ui.json` 构建元数据,生产构建会把可用资产嵌入 Rust 二进制。若浏览器「关于」仍显示旧前端版本,请重启 Rust 服务并强刷浏览器缓存。
## 🚀 快速开始
### Docker Compose(推荐)
```bash
mkdir boosapi && cd boosapi
cat > docker-compose.yml << 'EOF'
services:
boosapi:
image: ghcr.io/boos/boosapi:latest
ports:
- "4000:4000"
volumes:
- ./data:/app/data
environment:
BOOSAPI_AUTH__AUTH_TOKEN: ${AUTH_TOKEN?required}
BOOSAPI_AUTH__PROXY_TOKEN: ${PROXY_TOKEN?required}
BOOSAPI_UPSTREAM_BASE: https://api.boos.lat/v1
BOOSAPI_UPSTREAM_KEY: ${UPSTREAM_KEY}
BOOSAPI_SERVER__PORT: "4000"
restart: unless-stopped
EOF
# AUTH_TOKEN = 管理后台登录令牌(管理员只支持 API Token 登录)
# PROXY_TOKEN = 下游客户端调用 /v1/* 的 Token
export AUTH_TOKEN=your-admin-token
export PROXY_TOKEN=sk-your-proxy-token
docker compose up -d
```
启动后访问 `http://localhost:4000/boos`,用 `AUTH_TOKEN` 登录管理员后台;普通用户从用户登录页使用账号密码进入用户工作台。
> [!IMPORTANT]
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
---
## 📁 项目结构
```
.
├── Cargo.toml # Rust 项目定义
├── config.toml # 默认配置文件
├── Dockerfile # 多阶段构建
├── docker/
│ └── docker-compose.yml
├── src/
│ ├── main.rs # 入口
│ ├── lib.rs # 库入口
│ ├── config.rs # 配置加载(环境变量 + config.toml)
│ ├── error.rs # 统一错误处理
│ ├── db/ # 数据库模型与查询
│ ├── routes/
│ │ ├── mod.rs # 路由组装 + 静态文件 serve
│ │ ├── comfyui_agent.rs # 视频/工作流助手 SSE 聊天
│ │ ├── comfyui/ # 外部 ComfyUI 代理(prompt/history/view)
│ │ ├── proxy/ # OpenAI 兼容代理入口
│ │ ├── auth.rs # 用户注册/登录 JWT
│ │ ├── checkin.rs # 自动签到
│ │ └── ...
│ ├── proxy/ # 代理引擎核心
│ │ ├── conductor/ # Proxy Conductor(故障转移)
│ │ ├── token_router/ # Token 路由引擎
│ │ └── executors/ # 执行器(chat/image/embedding)
│ ├── transformers/ # 协议转换
│ │ ├── openai/ # OpenAI Compatible
│ │ ├── anthropic/ # Claude Messages
│ │ └── gemini/ # Gemini GenerateContent
│ └── services/ # 后台服务
│ ├── cron/ # 定时任务
│ ├── billing/ # 计费逻辑
│ └── notification/ # 通知推送
├── migrations/ # SQLite 迁移
├── src/web/ # 管理台前端源码(React + Vite)
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
│ ├── pages/
│ │ ├── auth/ # 登录页拆分:LoginShell + AdminLoginPage(token-only) + UserLoginPage(OAuthSlot 占位)
│ │ ├── ComfyUIAgent.tsx # 视频/工作流助手聊天界面
│ │ ├── Workflows.tsx # Workflow Studio 入口
│ │ └── ...
│ └── components/
├── website/ # 独立官网(Next.js 16 App Router + React 19)
└── dist/web/ # 前端构建产物(由 Rust serve)
```
## 🔧 环境变量
所有配置通过环境变量覆盖,命名规则:`BOOSAPI_<SECTION>__<KEY>`(双下划线分隔层级):
```bash
# 服务
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_SERVER__LOG_LEVEL=info
# 认证
BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
# 数据库(默认 SQLite)
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
# PostgreSQL 准备/迁移
# 当前版本保留 SQLite 作为运行主路径;可先把 SQLite 核心数据迁移到 PostgreSQL。
# 完整 PostgreSQL 业务查询适配需要后续切换运行时 SQL 方言。
# 示例:
# boosapi migrate-sqlite-to-postgres \
# --sqlite-url 'sqlite:data/boosapi.db?mode=rwc' \
# --postgres-url 'postgres://boosapi:password@127.0.0.1:5432/boosapi'
# 目标库已有数据时默认拒绝;确认覆盖时追加 --force-truncate。
# 上游 API(视频/工作流助手回退用,默认本地模式时不需要设置)
# 设置 BOOSAPI_UPSTREAM_BASE 后切换到 fallback-remote 模式;BOOSAPI_UPSTREAM_KEY 缺省时仍用内置 comfyui-agent key
BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
BOOSAPI_UPSTREAM_KEY=sk-your-key
# 前端静态目录(默认 dist/web)
BOOSAPI_WEB_DIR=dist/web
# Workflow 后端
BOOSAPI_COMFYUI__BACKEND=cloud
BOOSAPI_COMFYUI__BASE_URL=https://cloud.comfy.org
BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
```
## 🏗️ 技术栈
| 层 | 技术 |
|----|------|
| 后端框架 | **Axum 0.8**(Rust) |
| 运行时 | **Tokio**(异步运行时) |
| 数据库 | **SQLite** 运行主路径 + **PostgreSQL** schema/迁移准备(SQLx) |
| 并行计算 | **Rayon**(CPU 密集任务) |
| 认证鉴权 | **JWT** + API Key 双模式 |
| 代理引擎 | 自定义 Token Router + Proxy Conductor |
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
| 管理台前端 | React + Vite(仅构建工具) |
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
| 样式 | Tailwind CSS v4 |
| 数据可视化 | VChart |
| 容器化 | Docker + Docker Compose + K3s |
| 构建工具 | Cargo(后端)/ npm(前端静态构建) |
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
---
## 🛠️ 本地开发
### 后端(Rust)
```bash
# 最快验证:只检查 Rust 后端,不构建前端、不启动服务
./build-and-run.sh --check
# 日常启动:复用前端缓存,后端 dev profile 编译后启动
./build-and-run.sh
# 只重启/编译后端,不碰 Vite/Next
./build-and-run.sh --backend-only
# 接近生产性能但比 release 快
./build-and-run.sh --fast-release
# 完整生产包:构建并嵌入前端/Workflow UI,只输出 boosapi + config.toml
./build-and-run.sh --production
# 运行测试
cargo test
# 环境变量配置
export BOOSAPI_SERVER__HOST=0.0.0.0
export BOOSAPI_SERVER__PORT=4000
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
```
构建加速说明:
- 脚本会在 Linux 下自动使用 `mold` / `lld` 快速链接器;可用 `BOOSAPI_FAST_LINKER=off` 关闭。
- `BOOSAPI_CARGO_JOBS=8 ./build-and-run.sh --check` 可限制 Cargo 并发,避免把开发机打满。
- 生产性能验证优先用 `--fast-release`,真正发布再用 `--production` 或 `--release`。
### 前端(React SPA)
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
```bash
# 安装依赖(首次)
npm install
# 开发模式(热更新,需要 Node.js)
cd src/web && npx vite --host 0.0.0.0
# 类型检查与构建生产版本
npm run typecheck:web
npm run build:web
# 输出到 dist/web/,Rust 后端自动 serve
```
Workflow UI 前端资产单独更新:
```bash
# Comfy Cloud 前端
npm run workflow-ui:install:cloud
# External / RunningHub 前端
npm run workflow-ui:install:external
```
可选使用 Bun 加速主前端构建:
```bash
# 一次性使用 Bun 构建主前端
./build-and-run.sh --bun
# 或设置环境变量,auto 会优先使用可用的 bun,缺省回退 npm/npx
BOOSAPI_FRONTEND_PM=bun ./build-and-run.sh --no-run
BOOSAPI_FRONTEND_PM=auto ./build-and-run.sh
```
> Workflow UI 子模块仍按官方 `pnpm-lock.yaml` 使用 pnpm 构建,避免 ComfyUI 前端依赖兼容问题。
> 生产环境不需要 Node.js 进程。`npm run build` 后,Rust 后端直接 serve 静态文件。
> 前端 API 请求通过 Vite 代理(开发)或同域访问(生产)指向 `localhost:4000`。
### 独立官网(Next.js)
官网已经从管理台中隔离,使用 Next.js 16 App Router、React 19、TypeScript、Tailwind CSS 4 和自定义 SVG/CSS motion system:
```bash
# 首次安装
npm --prefix website install
# 本地开发官网
npm run website:dev
# 类型检查与生产构建
npm run website:typecheck
npm run website:build
```
`./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
### 生产单二进制构建
```bash
./build-and-run.sh --production
```
生产模式会构建主前端与 Workflow UI 资产,并将它们嵌入 Rust 二进制。部署目录通常只需要:
```txt
boosapi
config.toml
```
仓库卫生说明:
- `Cargo.lock` 应随代码提交,保证 Rust 应用在 CI、生产构建和本地排障时依赖版本一致。
- `boosapi.db`、`config.toml` 是本地运行态文件;实际部署时不要提交带真实密钥、Comfy Cloud Key 或生产数据的改动。
- `.agents/`、`.cache/`、`boosapi` / `bossai` 本地二进制、`docker/*.tar` 等构建缓存与大文件已加入忽略规则。
- 旧的 `boosapi-comfyui-multi-tenant-*.tar` 不再作为当前 Workflow 路线的必需产物,生产包应优先使用单二进制 + 配置文件。
### Docker 部署
```bash
# 构建
docker build -t boosapi .
# 运行
docker run -d \
-p 4000:4000 \
-e BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token \
-e BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token \
-v $(pwd)/data:/app/data \
boosapi
```
### Docker Compose
```yaml
services:
boosapi:
build: .
ports:
- "4000:4000"
volumes:
- ./data:/app/data
environment:
BOOSAPI_AUTH__AUTH_TOKEN: ${AUTH_TOKEN?required}
BOOSAPI_AUTH__PROXY_TOKEN: ${PROXY_TOKEN?required}
BOOSAPI_UPSTREAM_BASE: https://api.boos.lat/v1
BOOSAPI_UPSTREAM_KEY: sk-your-upstream-key
restart: unless-stopped
```
---
## 📜 License
[MIT](LICENSE)
+196
View File
@@ -0,0 +1,196 @@
<div align="center">
# BoosAPI
**AI Aggregation Gateway + Workflow Studio — one endpoint for many AI relay stations**
<p>
Aggregate New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API stations
<br>
into <strong>one API key and one compatible endpoint</strong>, with model discovery, smart routing, logs, billing, and failover.
<br>
Workflow Studio embeds the ComfyUI frontend and can run against Comfy Cloud, RunningHub, your own ComfyUI, or BoosAPI's API-native executor.
</p>
<p align="center">
<img alt="Rust" src="https://img.shields.io/badge/Rust-1.85%2B-000000?logo=rust&style=flat">
<img alt="Axum" src="https://img.shields.io/badge/Axum-0.8-3182F6?style=flat">
<img alt="SQLx" src="https://img.shields.io/badge/SQLx-0.8-3E6E93?style=flat">
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?logo=react&style=flat">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
</p>
<p align="center">
<a href="README.md">中文</a> |
<a href="README_EN.md"><strong>English</strong></a> |
<a href="DEPLOY.md">Deployment</a>
</p>
</div>
---
## Introduction
BoosAPI is a self-hosted Rust gateway for AI relay stations. It centralizes upstream sites, accounts, downstream API keys, model routes, usage logs, image/video generation, user dashboards, and Workflow Studio in one service.
Supported upstreams:
- **Relay panels**: New API, One API, OneHub, DoneHub, Veloera, AnyRouter, Sub2API
- **Compatible APIs**: OpenAI / Claude / Gemini compatible, cliproxyapi / CPA
- **Preset providers**: Aliyun, Zhipu, Doubao Coding Plan, DeepSeek, Moonshot, MiniMax, ModelScope
- **OAuth connections**: Codex, Claude, Gemini CLI, Antigravity
## Key Features
### Unified API Gateway
- OpenAI-compatible `/v1/chat/completions`, `/v1/responses`, `/v1/images/generations`, `/v1/models`, and files endpoints
- Claude-compatible `/v1/messages`, including request/response and streaming event conversion
- Smart token routing, channel cooldown, failover, model discovery, and usage accounting
- Admin and user dashboards with API key management and model visibility controls
### Workflow Studio
Workflow Studio uses an embedded ComfyUI frontend. The execution backend is selected by `comfyui.backend`:
| backend | Purpose |
|---------|---------|
| `cloud` | Proxy Comfy Cloud API for cloud execution and subscription models |
| `runninghub` | Proxy RunningHub's ComfyUI-compatible API for its plugins, models, and workflows |
| `external` | Proxy a full ComfyUI instance you deploy yourself |
| `boosapi` | Use BoosAPI's Rust API-native workflow executor and configured model routes |
| `docker` | Local full ComfyUI mode for GPU machines and local model directories |
Cloud, RunningHub, external API, and BoosAPI executor modes do not require a GPU on the BoosAPI server. A GPU is only needed when you run local ComfyUI inference on the same machine.
### Assistants
- Video / workflow assistant with Markdown chat, reasoning sections, attachments, and model route selection
- Picture assistant using `/v1/images/generations`, background tasks, progress display, cancellation, and Workspace archive
- Volcengine video task templates for create-task and query-task flows
## Hardware Guide
| Mode | CPU | Memory | Disk | GPU |
|------|-----|--------|------|-----|
| Gateway, admin UI, `/v1/*` proxy only | 2 cores minimum, 4 recommended | 2GB minimum, 4GB recommended | 10GB+ | Not required |
| BoosAPI + Workflow UI + remote APIs / Comfy Cloud / RunningHub | 4 cores recommended | 4GB minimum, 8GB recommended | 20-50GB | Not required |
| External ComfyUI proxy | 4 cores recommended | 8GB recommended | 50GB+ | Not on BoosAPI; required by the ComfyUI machine as needed |
| Local Docker / local ComfyUI image generation | 4-8 cores | 16GB+ | 100GB+ | NVIDIA 8GB VRAM minimum, 12GB+ recommended |
| Local video / digital human / large workflows | 8 cores+ | 32GB minimum, 64GB recommended | 200GB-1TB | NVIDIA 24GB VRAM minimum, 48GB+ recommended |
Recommended SaaS gateway server:
```txt
4 CPU cores
8GB RAM
50GB SSD
No GPU required
```
## Quick Start
```bash
cp config.toml.example config.toml
./build-and-run.sh
```
For a production bundle with embedded web assets:
```bash
./build-and-run.sh --production
```
The production output is intended to run with only:
```txt
boosapi
config.toml
```
Web assets and Workflow UI assets are embedded in the Rust binary when built for production.
## Workflow Backend Configuration
Comfy Cloud:
```toml
[comfyui]
backend = "cloud"
base_url = "https://cloud.comfy.org"
api_key = "your-comfy-cloud-api-key"
```
RunningHub:
```toml
[comfyui]
backend = "runninghub"
base_url = "https://www.runninghub.cn/your-comfy-compatible-endpoint"
api_key = "your-runninghub-api-key"
```
External ComfyUI:
```toml
[comfyui]
backend = "external"
base_url = "http://127.0.0.1:8188"
```
BoosAPI API-native executor:
```toml
[comfyui]
backend = "boosapi"
```
## Environment Variables
Configuration can be overridden with `BOOSAPI_<SECTION>__<KEY>`:
```bash
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
BOOSAPI_AUTH__JWT_SECRET=your-jwt-secret
BOOSAPI_DB__URL=sqlite:data/boosapi.db?mode=rwc
```
## Tech Stack
| Layer | Tech |
|-------|------|
| Backend | Rust, Axum 0.8, Tokio |
| Database | SQLx, SQLite runtime path, PostgreSQL schema/migration preparation |
| Proxy engine | Token Router + Proxy Conductor |
| Protocols | OpenAI / Claude / Gemini compatible transformers |
| Frontend | React 19 + Vite |
| Workflow UI | Embedded ComfyUI frontend variants for cloud/external-compatible backends |
| Static serving | Rust binary serves `dist/web` and Workflow UI assets |
| Container | Docker / Docker Compose |
Node.js is only a build-time tool for the frontend. Production does not require a Node.js process.
## Local Development
```bash
cargo check
cargo run
pnpm install
pnpm run dev:web
```
Build checks:
```bash
cargo check
pnpm run typecheck:web
npx vite build
```
## License
[MIT](LICENSE)
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
+625
View File
@@ -0,0 +1,625 @@
#!/usr/bin/env bash
# 编译前后端并启动服务
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
# ── 工具函数 ──────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
# ── 参数 ──────────────────────────────────────────────────────────────────────
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
FRONTEND_ONLY=0
BACKEND_ONLY=0
CHECK_ONLY=0
NO_RUN=0
FORCE_FRONTEND=0
SKIP_FRONTEND=0
PRODUCTION_BUNDLE=0
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
CARGO_JOBS="${BOOSAPI_CARGO_JOBS:-}"
FAST_LINKER="${BOOSAPI_FAST_LINKER:-auto}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
BUILD_TIMINGS=()
usage() {
cat <<'EOF'
用法: ./build-and-run.sh [选项]
选项:
--dev 使用 dev 模式编译后端(默认,最快)
--release 使用 release 模式编译后端(更慢但性能好)
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
--profile <name> 指定构建 profile:dev / release / fast-release
--production 生产编译:先构建前端并嵌入二进制,再输出 dist/production/{boosapi,config.toml}
--prod 同 --production
--production-dir <dir>
指定生产产物目录(默认:dist/production)
--check 只执行 cargo check,不构建前端、不启动服务(最快验证)
--backend-check 同 --check
--frontend-only 只构建前端
--backend-only 只构建后端
--force-frontend 强制重建前端,忽略缓存
--skip-frontend 跳过前端构建(沿用现有 dist/web)
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块会自动准备 pnpm)
--no-run 构建后不启动服务
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
BOOSAPI_CARGO_JOBS 可设置 cargo 并发任务数,例如 8
BOOSAPI_FAST_LINKER 可设置 auto / off / mold / lld(默认:auto,Linux 下优先 mold)
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
-h, --help 显示帮助
示例:
./build-and-run.sh --release
./build-and-run.sh --fast-release
./build-and-run.sh --check
./build-and-run.sh --bun
./build-and-run.sh --production
./build-and-run.sh --production --production-dir /opt/boosapi-release
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dev) BUILD_PROFILE="dev" ;;
--release) BUILD_PROFILE="release" ;;
--fast-release) BUILD_PROFILE="fast-release" ;;
--production|--prod)
PRODUCTION_BUNDLE=1
BUILD_PROFILE="release"
NO_RUN=1
;;
--production-dir)
shift
[ $# -gt 0 ] || fail "--production-dir 需要目录参数"
PRODUCTION_DIR="$1"
;;
--check|--backend-check)
CHECK_ONLY=1
BACKEND_ONLY=1
NO_RUN=1
;;
--profile)
shift
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
BUILD_PROFILE="$1"
;;
--frontend-only) FRONTEND_ONLY=1 ;;
--backend-only) BACKEND_ONLY=1 ;;
--force-frontend) FORCE_FRONTEND=1 ;;
--skip-frontend) SKIP_FRONTEND=1 ;;
--bun) FRONTEND_PM="bun" ;;
--no-run) NO_RUN=1 ;;
--rebuild-image) log "--rebuild-image 已废弃:内置 Docker ComfyUI 工作台已移除,忽略该参数" ;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage >&2; exit 1 ;;
esac
shift
done
if [ "$FRONTEND_ONLY" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--frontend-only 和 --backend-only 不能同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$FRONTEND_ONLY" -eq 1 ]; then
fail "--production 不能和 --frontend-only 同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$CHECK_ONLY" -eq 1 ]; then
fail "--production 不能和 --check 同时使用"
fi
case "$BUILD_PROFILE" in
dev|debug) BUILD_PROFILE="dev" ;;
release|fast-release) ;;
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
esac
case "$FAST_LINKER" in
auto|off|none|mold|lld) ;;
*) fail "未知 BOOSAPI_FAST_LINKER: $FAST_LINKER(可选:auto / off / mold / lld)" ;;
esac
timer_start() {
date +%s
}
record_timing() {
local label="$1"
local started="$2"
local ended
ended="$(date +%s)"
BUILD_TIMINGS+=("${label}: $((ended - started))s")
}
print_timing_summary() {
[ "${#BUILD_TIMINGS[@]}" -gt 0 ] || return 0
log "构建耗时:"
local item
for item in "${BUILD_TIMINGS[@]}"; do
log " - $item"
done
}
append_rustflags() {
local flags="$1"
if [[ " ${RUSTFLAGS:-} " != *" $flags "* ]]; then
export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }$flags"
fi
}
build_cargo_args() {
CARGO_BASE_ARGS=()
CARGO_FEATURE_ARGS=()
if [ -n "$CARGO_JOBS" ]; then
CARGO_BASE_ARGS+=(--jobs "$CARGO_JOBS")
fi
if [ -n "$CARGO_FEATURES" ]; then
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
fi
}
configure_fast_linker() {
local mode="$FAST_LINKER"
case "$(uname -s 2>/dev/null || true)" in
Linux) ;;
*)
[ "$mode" = "auto" ] && return 0
fail "BOOSAPI_FAST_LINKER=$mode 目前只在 Linux 构建环境自动配置"
;;
esac
if [ "$mode" = "off" ] || [ "$mode" = "none" ]; then
return 0
fi
if [ "$mode" = "auto" ]; then
if command -v mold >/dev/null 2>&1; then
mode="mold"
elif command -v ld.lld >/dev/null 2>&1 || command -v lld >/dev/null 2>&1; then
mode="lld"
else
log "提示: 安装 mold 或 lld 可显著缩短 Rust 链接时间;当前使用系统默认链接器"
return 0
fi
fi
case "$mode" in
mold)
command -v mold >/dev/null 2>&1 || fail "BOOSAPI_FAST_LINKER=mold 但未找到 mold"
if command -v clang >/dev/null 2>&1; then
append_rustflags "-C linker=clang"
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
fail "启用 mold 需要 clang 或 gcc 作为 linker driver"
fi
append_rustflags "-C link-arg=-fuse-ld=mold"
log "Rust 快速链接器: mold(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
;;
lld)
if ! command -v ld.lld >/dev/null 2>&1 && ! command -v lld >/dev/null 2>&1; then
fail "BOOSAPI_FAST_LINKER=lld 但未找到 ld.lld/lld"
fi
if command -v clang >/dev/null 2>&1; then
append_rustflags "-C linker=clang"
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
fail "启用 lld 需要 clang 或 gcc 作为 linker driver"
fi
append_rustflags "-C link-arg=-fuse-ld=lld"
log "Rust 快速链接器: lld(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
;;
esac
}
server_port() {
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
printf '%s\n' "$BOOSAPI_SERVER__PORT"
return
fi
awk '
/^\[server\]/ { in_server = 1; next }
/^\[/ { in_server = 0 }
in_server && /^[[:space:]]*port[[:space:]]*=/ {
gsub(/[^0-9]/, "", $0)
print $0
exit
}
' "$ROOT_DIR/config.toml" 2>/dev/null || true
}
is_running_pid() {
local pid="$1"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
stop_pids() {
local label="$1"
shift || true
local pids=()
local pid
for pid in "$@"; do
if [ -n "$pid" ] && [ "$pid" != "$$" ] && is_running_pid "$pid"; then
pids+=("$pid")
fi
done
if [ "${#pids[@]}" -eq 0 ]; then
return 0
fi
log "停止旧进程 ${label}: ${pids[*]}"
kill -TERM "${pids[@]}" 2>/dev/null || true
for _ in 1 2 3 4 5; do
local alive=()
for pid in "${pids[@]}"; do
if is_running_pid "$pid"; then
alive+=("$pid")
fi
done
if [ "${#alive[@]}" -eq 0 ]; then
ok "旧进程已停止"
return 0
fi
sleep 1
pids=("${alive[@]}")
done
log "旧进程未及时退出,强制结束: ${pids[*]}"
kill -KILL "${pids[@]}" 2>/dev/null || true
}
stop_previous_server() {
local binary_pids=()
local port_pids=()
local port
while IFS= read -r pid; do
[ -n "$pid" ] && binary_pids+=("$pid")
done < <(pgrep -f "target/(debug|release|fast-release)/boosapi" 2>/dev/null || true)
stop_pids "boosapi binary" "${binary_pids[@]}"
port="$(server_port)"
port="${port:-4000}"
if command -v fuser >/dev/null 2>&1; then
while IFS= read -r pid; do
[ -n "$pid" ] && port_pids+=("$pid")
done < <(fuser "${port}/tcp" 2>/dev/null | tr ' ' '\n' || true)
stop_pids "占用 ${port}/tcp" "${port_pids[@]}"
else
log "未找到 fuser,跳过端口 ${port}/tcp 占用检查"
fi
}
ensure_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
return 0
fi
command -v corepack >/dev/null 2>&1 || fail "未找到 pnpm/corepack,无法自动准备 pnpm。请安装 Node.js 22+ 或 pnpm"
log "未找到 pnpm,尝试通过 corepack 自动启用..."
corepack enable
corepack prepare pnpm@latest --activate
command -v pnpm >/dev/null 2>&1 || fail "pnpm 自动准备失败,请手动安装 pnpm"
}
install_frontend_dependencies() {
local frontend_pm="$1"
if [ "$frontend_pm" = "bun" ]; then
command -v bun >/dev/null 2>&1 || fail "未找到 bun,请安装 Bun 或移除 --bun"
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装主前端依赖 (bun install)..."
bun install
fi
return 0
fi
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
ensure_pnpm
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装主前端依赖 (pnpm install)..."
pnpm install --frozen-lockfile
fi
}
build_frontend() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过前端构建(--skip-frontend)"
record_timing "前端检查" "$started"
return 0
fi
local frontend_pm="$FRONTEND_PM"
case "$frontend_pm" in
auto)
if command -v bun >/dev/null 2>&1; then
frontend_pm="bun"
else
frontend_pm="npm"
fi
;;
npm|bun) ;;
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
esac
install_frontend_dependencies "$frontend_pm"
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'build-script-v2\n'
printf 'frontend_pm=%s\n' "$frontend_pm"
printf 'node='
node --version 2>/dev/null || true
printf 'npx='
npx --version 2>/dev/null || true
printf 'bun='
bun --version 2>/dev/null || true
for path in package.json pnpm-lock.yaml package-lock.json yarn.lock vite.config.ts tsconfig.json tsconfig.web.json tsconfig.transpile.json src/web src/shared; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum
} | sha256sum | awk '{print $1}'
)"
if [ "$FORCE_FRONTEND" -eq 0 ] \
&& [ -f "$fingerprint_file" ] \
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "前端无变化,跳过 Vite 构建 → dist/web/"
record_timing "前端检查" "$started"
return 0
fi
log "构建前端 (Vite via ${frontend_pm})..."
# 直接调用 vite build;桌面端构建链路已移除。
if [ "$frontend_pm" = "bun" ]; then
bunx --bun vite build
else
pnpm exec vite build
fi
mkdir -p "$ROOT_DIR/dist/web"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "前端构建完成 → dist/web/"
record_timing "前端构建" "$started"
}
install_website_dependencies() {
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
if [ ! -d "$ROOT_DIR/website/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装官网依赖 (npm --prefix website install)..."
npm --prefix "$ROOT_DIR/website" install
fi
}
build_public_website() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过官网构建(--skip-frontend)"
record_timing "官网检查" "$started"
return 0
fi
[ -f "$ROOT_DIR/website/package.json" ] || fail "未找到 website/package.json,无法构建官网"
install_website_dependencies
local fingerprint_file="$ROOT_DIR/dist/website/.bossai-website-build-fingerprint"
local fingerprint
fingerprint="$(
{
printf 'website-build-v1\n'
printf 'node='
node --version 2>/dev/null || true
printf 'npm='
npm --version 2>/dev/null || true
for path in website/package.json website/package-lock.json website/next.config.ts website/postcss.config.mjs website/tsconfig.json website/app website/public; do
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
done | sort | xargs -r sha256sum
} | sha256sum | awk '{print $1}'
)"
if [ "$FORCE_FRONTEND" -eq 0 ] \
&& [ -f "$fingerprint_file" ] \
&& [ -f "$ROOT_DIR/dist/website/index.html" ] \
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
ok "官网无变化,跳过 Next 构建 → dist/website/"
record_timing "官网检查" "$started"
return 0
fi
log "构建官网 (Next.js static export)..."
npm --prefix "$ROOT_DIR/website" run build
rm -rf "$ROOT_DIR/dist/website"
mkdir -p "$ROOT_DIR/dist/website"
cp -R "$ROOT_DIR/website/out/." "$ROOT_DIR/dist/website/"
printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "官网构建完成 → dist/website/"
record_timing "官网构建" "$started"
}
ensure_workflow_ui_for_embedding() {
local started
started="$(timer_start)"
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过 Workflow UI 构建检查(--skip-frontend)"
record_timing "Workflow UI 检查" "$started"
return 0
fi
if [ -f "$ROOT_DIR/dist/workflow-ui-external/index.html" ] \
&& [ -f "$ROOT_DIR/dist/workflow-ui-cloud/index.html" ] \
&& [ -f "$ROOT_DIR/dist/workflow-ui/index.html" ] \
&& [ "$FORCE_FRONTEND" -eq 0 ]; then
ok "Workflow UI 已存在,跳过构建 → dist/workflow-ui-{external,cloud}/"
record_timing "Workflow UI 检查" "$started"
return 0
fi
command -v git >/dev/null 2>&1 || fail "未找到 git,无法构建 Workflow UI"
command -v node >/dev/null 2>&1 || fail "未找到 node,无法构建 Workflow UI"
ensure_pnpm
log "构建 Workflow UI 双前端(external + cloud,用于嵌入二进制)..."
pnpm run workflow-ui:install
ok "Workflow UI 构建完成 → dist/workflow-ui-external/ dist/workflow-ui-cloud/"
record_timing "Workflow UI 构建" "$started"
}
build_backend() {
local started
started="$(timer_start)"
if ! command -v cargo >/dev/null 2>&1; then
fail "未找到 cargo,请先安装 Rust 工具链"
fi
configure_fast_linker
build_cargo_args
if [ "$CHECK_ONLY" -eq 1 ]; then
if [ "$BUILD_PROFILE" = "release" ]; then
log "检查后端 (cargo check --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "检查后端 (cargo check --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
else
log "检查后端 (cargo check ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo check "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
fi
ok "后端检查完成"
record_timing "后端检查" "$started"
return 0
fi
if [ "$BUILD_PROFILE" = "release" ]; then
log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/release/boosapi"
ok "后端构建完成 → target/release/boosapi"
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
log "构建后端 (cargo build --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/fast-release/boosapi"
ok "后端构建完成 → target/fast-release/boosapi"
else
log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
cargo build "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
BINARY="$ROOT_DIR/target/debug/boosapi"
ok "后端构建完成 → target/debug/boosapi"
fi
record_timing "后端构建" "$started"
}
build_production_bundle() {
local started
started="$(timer_start)"
[ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空"
[ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY"
[ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml"
rm -rf "$PRODUCTION_DIR"
mkdir -p "$PRODUCTION_DIR"
cp "$BINARY" "$PRODUCTION_DIR/boosapi"
cp "$ROOT_DIR/config.toml" "$PRODUCTION_DIR/config.toml"
chmod +x "$PRODUCTION_DIR/boosapi"
if command -v strip >/dev/null 2>&1; then
log "压缩生产二进制符号表 (strip)..."
strip "$PRODUCTION_DIR/boosapi" 2>/dev/null || log "strip 失败,保留未压缩二进制"
fi
ok "生产产物已生成 → $PRODUCTION_DIR"
log "产物内容:"
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
record_timing "生产打包" "$started"
}
cleanup_started_server() {
if [ -n "${STARTED_PID:-}" ] && is_running_pid "$STARTED_PID"; then
log "停止本次启动的后端进程: $STARTED_PID"
kill -TERM "$STARTED_PID" 2>/dev/null || true
wait "$STARTED_PID" 2>/dev/null || true
fi
}
# ── 构建 / 启动 ────────────────────────────────────────────────────────────────
if [ "$FRONTEND_ONLY" -eq 1 ]; then
build_frontend
build_public_website
print_timing_summary
log "跳过启动(--frontend-only)"
exit 0
fi
if [ "$BACKEND_ONLY" -ne 1 ]; then
build_frontend
build_public_website
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
ensure_workflow_ui_for_embedding
fi
fi
build_backend
if [ "$CHECK_ONLY" -eq 1 ]; then
print_timing_summary
log "跳过启动(--check)"
exit 0
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
build_production_bundle
print_timing_summary
exit 0
fi
if [ "$NO_RUN" -eq 1 ]; then
print_timing_summary
log "跳过启动(--no-run)"
exit 0
fi
print_timing_summary
stop_previous_server
log "启动服务..."
if [ "$BACKEND_ONLY" -eq 1 ]; then
exec "$BINARY"
fi
"$BINARY" &
STARTED_PID=$!
trap cleanup_started_server INT TERM EXIT
ok "后端已启动,PID: $STARTED_PID"
ok "前后端已就绪(profile: $BUILD_PROFILE),按 Ctrl+C 停止服务"
wait "$STARTED_PID"
+304
View File
@@ -0,0 +1,304 @@
"""
BoosAPI ComfyUI Custom Nodes
让 ComfyUI workflow 调用 BoosAPI 的 AI 模型(图像生成、LLM 等)。
配置:通过环境变量设置 BoosAPI 地址,或使用默认 http://127.0.0.1:4000/v1
环境变量:
BOOSAPI_BASE — BoosAPI 地址(默认 http://127.0.0.1:4000)
BOOSAPI_KEY — API Key(默认 sk-boos4721)
"""
import json
import os
import requests
# ── Config ───────────────────────────────────────────────────────────────
BOOSAPI_BASE_RAW = os.environ.get("BOOSAPI_BASE", "http://127.0.0.1:4000")
BOOSAPI_KEY = os.environ.get("BOOSAPI_KEY", "sk-boos4721")
HEADERS = {
"Authorization": f"Bearer {BOOSAPI_KEY}",
"Content-Type": "application/json",
}
FALLBACK_CHAT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.2"]
FALLBACK_IMAGE_MODELS = ["gpt-image-2", "gpt-image-1", "dall-e-3", "dall-e-2"]
FALLBACK_ALL_MODELS = FALLBACK_IMAGE_MODELS + FALLBACK_CHAT_MODELS
def normalize_boosapi_root(base):
base = str(base or "http://127.0.0.1:4000").strip().rstrip("/")
if base.endswith("/v1"):
base = base[:-3].rstrip("/")
return base or "http://127.0.0.1:4000"
BOOSAPI_ROOT = normalize_boosapi_root(BOOSAPI_BASE_RAW)
BOOSAPI_V1 = f"{BOOSAPI_ROOT}/v1"
def raise_for_status_with_body(resp):
try:
resp.raise_for_status()
except requests.HTTPError as e:
body = (resp.text or "").strip()
if body:
print(f"[BoosAPI] HTTP {resp.status_code} response: {body[:2000]}")
raise requests.HTTPError(f"{e}; response body: {body[:1000]}", response=resp)
raise
def _ordered_unique(values):
seen = set()
result = []
for value in values:
value = str(value or "").strip()
if not value or value in seen:
continue
seen.add(value)
result.append(value)
return result
def fetch_boosapi_models():
"""Fetch models exposed by BoosAPI's own /v1/models route."""
try:
resp = requests.get(f"{BOOSAPI_V1}/models", headers=HEADERS, timeout=8)
resp.raise_for_status()
data = resp.json()
models = []
for item in data.get("data", []):
if isinstance(item, dict):
models.append(item.get("id") or item.get("name"))
elif isinstance(item, str):
models.append(item)
return _ordered_unique(models)
except Exception as e:
print(f"[BoosAPI] model list fallback: {e}")
return None
def model_choices(kind="all"):
models = fetch_boosapi_models()
if models is None:
if kind == "image":
return FALLBACK_IMAGE_MODELS
if kind == "chat":
return FALLBACK_CHAT_MODELS
return FALLBACK_ALL_MODELS
if kind == "image":
image_like = [
model for model in models
if any(token in model.lower() for token in ("image", "dall", "flux", "sd", "midjourney"))
]
return _ordered_unique(image_like + models) or FALLBACK_IMAGE_MODELS
if kind == "chat":
chat_like = [
model for model in models
if not any(token in model.lower() for token in ("image", "dall", "flux", "sd", "midjourney"))
]
return _ordered_unique(chat_like + models) or FALLBACK_CHAT_MODELS
return _ordered_unique(models) or FALLBACK_ALL_MODELS
def default_model(kind="all"):
choices = model_choices(kind)
return choices[0] if choices else FALLBACK_ALL_MODELS[0]
# ── Node Mappings ────────────────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
# ═════════════════════════════════════════════════════════════════════════
# Node: BoosAPIImageGen — 图像生成 (OpenAI DALL-E / 兼容格式)
# ═════════════════════════════════════════════════════════════════════════
class BoosAPIImageGen:
"""调用 BoosAPI /v1/images/generations 生成图像"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"model": (model_choices("image"), {"default": default_model("image")}),
"size": (["1024x1024", "1792x1024", "1024x1792"], {"default": "1024x1024"}),
"quality": (["standard", "hd"], {"default": "standard"}),
"n": ("INT", {"default": 1, "min": 1, "max": 4}),
},
"optional": {
"style": (["vivid", "natural"], {"default": "vivid"}),
},
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "metadata")
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = "BoosAPI"
def generate(self, prompt, model, size, quality, n, style="vivid"):
url = f"{BOOSAPI_V1}/images/generations"
payload = {
"model": model,
"prompt": prompt,
"n": n,
"size": size,
"quality": quality,
"style": style,
"response_format": "b64_json",
}
try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
raise_for_status_with_body(resp)
data = resp.json()
images = data.get("data", [])
# Return first image as b64
from io import BytesIO
import base64
import torch
from PIL import Image
b64_data = images[0]["b64_json"]
img_bytes = base64.b64decode(b64_data)
pil_img = Image.open(BytesIO(img_bytes)).convert("RGB")
img_tensor = torch.from_numpy(
__import__("numpy").array(pil_img).astype("float32") / 255.0
).unsqueeze(0)
metadata = json.dumps({
"model": model,
"size": size,
"revised_prompt": data.get("data", [{}])[0].get("revised_prompt", ""),
})
return (img_tensor, metadata)
except Exception as e:
print(f"[BoosAPI] ImageGen failed: {e}")
raise
# ═════════════════════════════════════════════════════════════════════════
# Node: BoosAPILLM — LLM Chat (流式文本生成)
# ═════════════════════════════════════════════════════════════════════════
class BoosAPILLM:
"""调用 BoosAPI /v1/chat/completions 生成文本"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"model": (model_choices("chat"), {"default": default_model("chat")}),
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0}),
"max_tokens": ("INT", {"default": 2048, "min": 1, "max": 16384}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
FUNCTION = "chat"
OUTPUT_NODE = True
CATEGORY = "BoosAPI"
def chat(self, prompt, model, system_prompt, temperature, max_tokens):
url = f"{BOOSAPI_V1}/chat/completions"
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
raise_for_status_with_body(resp)
data = resp.json()
text = data["choices"][0]["message"]["content"]
return {"ui": {"text": (text,)}, "result": (text,)}
except Exception as e:
print(f"[BoosAPI] LLM chat failed: {e}")
raise
# ═════════════════════════════════════════════════════════════════════════
# Node: BoosAPITextToImage — 文生图 (ComfyUI 原生格式)
# ═════════════════════════════════════════════════════════════════════════
class BoosAPITextToImage:
"""输入文本提示词,通过 BoosAPI 生成图像,输出 ComfyUI IMAGE 格式"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"positive": ("STRING", {"multiline": True, "default": ""}),
"model": (model_choices("image"), {"default": default_model("image")}),
},
"optional": {
"negative": ("STRING", {"multiline": True, "default": ""}),
"size": (["1024x1024", "1792x1024", "1024x1792"], {"default": "1024x1024"}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "generate"
OUTPUT_NODE = True
CATEGORY = "BoosAPI"
def generate(self, positive, model, negative="", size="1024x1024"):
# Build prompt with negative guidance
prompt = positive
if negative:
prompt = f"{positive}\n避免: {negative}"
url = f"{BOOSAPI_V1}/images/generations"
payload = {
"model": model,
"prompt": prompt,
"n": 1,
"size": size,
"response_format": "b64_json",
}
try:
resp = requests.post(url, headers=HEADERS, json=payload, timeout=120)
raise_for_status_with_body(resp)
data = resp.json()
import base64
from io import BytesIO
import torch
from PIL import Image
import numpy as np
b64 = data["data"][0]["b64_json"]
img = Image.open(BytesIO(base64.b64decode(b64))).convert("RGB")
tensor = torch.from_numpy(np.array(img).astype("float32") / 255.0).unsqueeze(0)
return (tensor,)
except Exception as e:
print(f"[BoosAPI] T2I failed: {e}")
raise
# ═════════════════════════════════════════════════════════════════════════
# Register Nodes
# ═════════════════════════════════════════════════════════════════════════
NODE_CLASS_MAPPINGS = {
"BoosAPIImageGen": BoosAPIImageGen,
"BoosAPILLM": BoosAPILLM,
"BoosAPITextToImage": BoosAPITextToImage,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BoosAPIImageGen": "BoosAPI 图像生成",
"BoosAPILLM": "BoosAPI LLM 对话",
"BoosAPITextToImage": "BoosAPI 文生图",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+76
View File
@@ -0,0 +1,76 @@
[server]
host = "0.0.0.0"
port = 4000
log_level = "info"
cors_origins = ["*"]
[db]
url = "sqlite:boosapi.db?mode=rwc"
max_connections = 10
ssl = false
# PostgreSQL preparation example:
# url = "postgres://boosapi:password@127.0.0.1:5432/boosapi"
# Note: this build can create/migrate PostgreSQL schemas and copy SQLite data
# with `boosapi migrate-sqlite-to-postgres`; full runtime query compatibility
# is completed in a follow-up SQL dialect pass.
[auth]
jwt_secret = "boosapi-dev-secret-change-in-production"
jwt_expiry_hours = 72
auth_token = "boos4721"
proxy_token = "sk-boos4721"
[proxy]
upstream_timeout_secs = 300
first_byte_timeout_secs = 60
max_retries = 3
proxy_log_retention_days = 30
# Optional proxy for external template galleries/workflow template assets.
# Supports http://, https:// and socks5:// proxy URLs.
# template_proxy_url = "http://127.0.0.1:7890"
[payment]
enabled = false
# Public HTTPS origin used to build Stripe success/cancel URLs and webhook docs.
# Example: "https://tybossai.com"
public_base_url = ""
product_name_prefix = "BossAI 账户充值"
currency = "usd"
min_amount_usd = 1.0
max_amount_usd = 100000.0
order_timeout_minutes = 30
max_pending_orders = 3
success_path = "/user/recharge?payment=success&order={ORDER_ID}"
cancel_path = "/user/recharge?payment=cancel&order={ORDER_ID}"
# Prefer environment variables for secrets:
# BOOSAPI_PAYMENT__STRIPE_SECRET_KEY=sk_live_...
# BOOSAPI_PAYMENT__STRIPE_WEBHOOK_SECRET=whsec_...
stripe_secret_key = ""
stripe_webhook_secret = ""
[comfyui]
enabled = false
python_bin = "python3"
port = 8188
extra_args = []
max_restarts = 5
# backend modes:
# - "boosapi": use BoosAPI's own Rust Workflow/API node executor.
# - "cloud": proxy to Comfy Cloud and use native ComfyUI /prompt and /ws.
# - "runninghub": proxy to RunningHub's ComfyUI-compatible API with external-compatible frontend.
# - "external": proxy to your own full ComfyUI server.
# - "docker": legacy local Docker ComfyUI mode; not recommended for the current API Workflow path.
backend = "cloud"
# Native Comfy task costs are charged against the user's downstream API Key
# only after successful output archival. Keep 0 while pricing is not finalized.
money_printer_video_cost = 0.0
digital_human_video_cost = 0.0
# docker_image = "boosapi-comfyui:multi-tenant"
# container_name = "boosapi-comfyui-workflow"
# base_url = "https://www.runninghub.cn/proxy-plus/94d1330ea62d4ae980ffb018ecdf82a2"
# For RunningHub, set backend = "runninghub" and use your RunningHub proxy/API URL.
# RunningHub proxy URL already contains the API key. Keep this empty unless
# you switch to a backend that requires Authorization / X-API-Key headers.
base_url = "https://cloud.comfy.org"
api_key = "comfyui-cbe4ebb1e22ee4ef2dab4d9293a76d5272a25e5ec847ac57387ab2b30798d86f"
agent_model = "gpt-5.5"
View File
+21
View File
@@ -0,0 +1,21 @@
# Meta API Hub - Environment Variables
# Copy this file to .env and fill in your values
# cp .env.example .env
# Admin authentication token (required, change this!)
AUTH_TOKEN=change-me-admin-token
# Proxy API token used for OpenAI-compatible proxy endpoint (required, change this!)
PROXY_TOKEN=change-me-proxy-sk-token
PORT=4000
NOTIFY_COOLDOWN_SEC=300
ADMIN_IP_ALLOWLIST=
TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# Auto check-in cron (default: 8:00 AM every day)
CHECKIN_CRON=0 8 * * *
# Balance refresh cron (default: every hour)
BALANCE_REFRESH_CRON=0 * * * *
View File
+18
View File
@@ -0,0 +1,18 @@
# Build stage
FROM rust:1.85-slim-bookworm AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y pkg-config libssl-dev
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release 2>/dev/null || true
COPY . .
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/boosapi /app/
COPY --from=builder /app/migrations /app/migrations
EXPOSE 4000
CMD ["/app/boosapi"]
+33
View File
@@ -0,0 +1,33 @@
FROM boosapi-comfyui:multi-tenant
ARG COMFYUI_TAG=v0.22.2
WORKDIR /workspace/ComfyUI
COPY docker/build-comfyui-src /tmp/ComfyUI-updated
RUN find /workspace/ComfyUI -mindepth 1 \
! -path '/workspace/ComfyUI/venv' \
! -path '/workspace/ComfyUI/venv/*' \
-exec rm -rf {} + \
&& cp -a /tmp/ComfyUI-updated/. /workspace/ComfyUI/ \
&& rm -rf /tmp/ComfyUI-updated \
&& UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
UV_EXTRA_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pytorch-wheels/cpu/ \
uv pip install --python venv/bin/python -r requirements.txt \
&& UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
uv pip install --python venv/bin/python six \
&& mkdir -p /workspace/ComfyUI/custom_nodes/boosapi_nodes
COPY comfy/boosapi_comfy_nodes.py /workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py
COPY scripts/comfyui/install-runninghub-template-nodes.sh /tmp/install-runninghub-template-nodes.sh
RUN bash /tmp/install-runninghub-template-nodes.sh \
&& rm -f /tmp/install-runninghub-template-nodes.sh
RUN python - <<'PY'
from pathlib import Path
path = Path("/workspace/ComfyUI/custom_nodes/boosapi_nodes/__init__.py")
if not path.exists():
raise SystemExit("BoosAPI custom node missing after ComfyUI update")
PY
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
# docker-compose.override.yml
# This file is automatically picked up by Docker Compose during local development.
# It overrides the image reference in docker-compose.yml with a local build.
services:
boosapi:
image: ~
build:
context: ..
dockerfile: docker/Dockerfile
@@ -0,0 +1,694 @@
# 计费闭环 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为 BoosAPI 建立 ledger-first 的双轨计费闭环(USD 池 + 工作流积分池),所有余额变动写统一流水账 `credit_ledger`。
**Architecture:** 新建 `credit_ledger`(唯一真相源)+ `user_credits`(积分缓存)+ `recharge_tiers`(充值档位)三张表。USD 轨沿用 `downstream_api_keys.max_cost/used_cost` 作热路径缓存,每笔变动同事务写一条 ledger。积分轨为工作流执行新增扣费/拦截。分 6 期,本计划详写第 1 期(地基),后 5 期为概要待细化。
**Tech Stack:** Rust (axum + sqlx + SQLite)、React + Vite 前端、in-memory SQLite 测试。
---
## 文件结构(第 1 期)
- 新建 `src/services/billing/ledger.rs` — ledger 写入 + 余额查询的唯一入口(`record_ledger_entry`、`ensure_ledger_schema`)。所有动余额的地方调它,保证"缓存 + ledger 同事务"不变量集中在一处。
- 修改 `src/services/billing/mod.rs` — `mod ledger;` 导出;`record_usage` 在写 usage_logs + increment 后,同事务补写 ledger(usd, consume)。
- 修改 `src/services/payment.rs` — `credit_user_balance` 加写 ledger(usd, recharge)。
- 修改 `src/routes/admin/users.rs` — `adjust_user_balance` 加写 ledger(usd, admin_adjust)。
- 测试:`src/services/billing/ledger.rs` 内 `#[cfg(test)] mod tests`(复用 in-memory SQLite 模式)。
---
## 第 1 期:地基(credit_ledger + user_credits 表 + USD 轨记账接入)
### Task 1:ledger 表结构 + 写入入口
**Files:**
- Create: `src/services/billing/ledger.rs`
- Modify: `src/services/billing/mod.rs`(顶部加 `pub mod ledger;`)
- [ ] **Step 1: 写失败测试** — 在 `src/services/billing/ledger.rs` 末尾:
```rust
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
async fn mem_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
ensure_ledger_schema(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn record_ledger_entry_writes_row_with_balance_snapshot() {
let pool = mem_pool().await;
let mut tx = pool.begin().await.unwrap();
record_ledger_entry(
&mut tx,
LedgerEntry {
user_id: 7,
track: "usd",
entry_type: "recharge",
amount: 10.0,
balance_after: 10.0,
ref_type: Some("stripe"),
ref_id: Some("cs_1"),
model: None,
operator: Some("system"),
note: None,
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let row: (i64, String, String, f64, f64) = sqlx::query_as(
"SELECT user_id, track, entry_type, amount, balance_after
FROM credit_ledger WHERE user_id = 7",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (7, "usd".into(), "recharge".into(), 10.0, 10.0));
}
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
Expected: 编译失败(`ensure_ledger_schema`/`record_ledger_entry`/`LedgerEntry` 未定义)。
- [ ] **Step 3: 写最小实现** — 在 `src/services/billing/ledger.rs` 顶部(tests 之前):
```rust
use crate::error::AppResult;
use sqlx::{SqlitePool, Transaction};
/// One balance-changing event. Written to `credit_ledger` in the same
/// transaction that updates the cached balance, so ledger and cache never diverge.
pub struct LedgerEntry<'a> {
pub user_id: i64,
pub track: &'a str, // "usd" | "points"
pub entry_type: &'a str, // "recharge" | "admin_adjust" | "consume" | "refund" | "grant"
pub amount: f64, // +credit / -debit
pub balance_after: f64,
pub ref_type: Option<&'a str>,
pub ref_id: Option<&'a str>,
pub model: Option<&'a str>,
pub operator: Option<&'a str>,
pub note: Option<&'a str>,
}
pub async fn ensure_ledger_schema(db: &SqlitePool) -> AppResult<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS credit_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER NOT NULL,
track TEXT NOT NULL,
entry_type TEXT NOT NULL,
amount REAL NOT NULL,
balance_after REAL NOT NULL,
ref_type TEXT,
ref_id TEXT,
model TEXT,
operator TEXT,
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx
ON credit_ledger (user_id, created_at DESC)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx
ON credit_ledger (track, entry_type)",
)
.execute(db)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS user_credits (
user_id INTEGER PRIMARY KEY NOT NULL,
points_balance REAL NOT NULL DEFAULT 0,
points_total_recharged REAL NOT NULL DEFAULT 0,
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
Ok(())
}
/// Append one ledger row inside an existing transaction. Caller is responsible
/// for also updating the cached balance in the same `tx`.
pub async fn record_ledger_entry(
tx: &mut Transaction<'_, sqlx::Sqlite>,
entry: LedgerEntry<'_>,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO credit_ledger
(user_id, track, entry_type, amount, balance_after,
ref_type, ref_id, model, operator, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(entry.user_id)
.bind(entry.track)
.bind(entry.entry_type)
.bind(entry.amount)
.bind(entry.balance_after)
.bind(entry.ref_type)
.bind(entry.ref_id)
.bind(entry.model)
.bind(entry.operator)
.bind(entry.note)
.execute(&mut **tx)
.await?;
Ok(())
}
```
并在 `src/services/billing/mod.rs` 顶部加:`pub mod ledger;`
- [ ] **Step 4: 运行测试确认通过**
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
Expected: PASS(1 passed)。
- [ ] **Step 5: 提交**
```bash
git add src/services/billing/ledger.rs src/services/billing/mod.rs
git commit -m "feat(billing): credit_ledger schema + record_ledger_entry"
```
### Task 2:USD 消费接入 ledger(record_usage)
**Files:**
- Modify: `src/services/billing/mod.rs`(`record_usage`,行 ~48-77)
- [ ] **Step 1: 写失败测试** — 在 billing/mod.rs 的 tests mod 加:
```rust
#[tokio::test]
async fn record_usage_writes_consume_ledger_entry() {
let pool = billing_test_pool().await; // 复用现有测试 pool helper;若无则建 users+downstream_api_keys+usage_logs
let tracker = UsageTracker::new(std::sync::Arc::new(pool.clone()));
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
// key_id=7 属于 user 9,max_cost=10 used_cost=2(由 helper 建好)
tracker.record_usage(7, "gpt-4o", 100, 50, 0.42).await.unwrap();
let row: (String, String, f64, Option<String>) = sqlx::query_as(
"SELECT track, entry_type, amount, model FROM credit_ledger
WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
).fetch_one(&pool).await.unwrap();
assert_eq!(row.0, "usd");
assert_eq!(row.1, "consume");
assert_eq!(row.2, -0.42); // 消费为负
assert_eq!(row.3.as_deref(), Some("gpt-4o"));
}
```
注:若 billing/mod.rs 无现成 `billing_test_pool`,在 tests mod 加一个建 `users`/`downstream_api_keys`(含 user_id=9,key id=7 max_cost=10 used_cost=2)/`usage_logs` 的 helper(参照 payment.rs 的 payment_test_pool)。
- [ ] **Step 2: 运行确认失败**
Run: `cargo test -p boosapi --lib record_usage_writes_consume_ledger_entry 2>&1 | tail -5`
Expected: FAIL(credit_ledger 无 user 9 行 / 表不存在)。
- [ ] **Step 3: 实现** — `record_usage` 改为事务化,末尾补写 ledger。替换 record_usage 函数体为:
```rust
pub async fn record_usage(
&self,
key_id: i64,
model: impl Into<String>,
prompt_tokens: i64,
completion_tokens: i64,
cost: f64,
) -> AppResult<()> {
Self::ensure_schema(&self.db).await?;
crate::services::billing::ledger::ensure_ledger_schema(&self.db).await?;
let prompt_tokens = prompt_tokens.max(0);
let completion_tokens = completion_tokens.max(0);
let total_tokens = prompt_tokens + completion_tokens;
let cost = cost.max(0.0);
let model = model.into();
let mut tx = self.db.begin().await?;
sqlx::query(
"INSERT INTO usage_logs
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(key_id).bind(&model)
.bind(prompt_tokens).bind(completion_tokens).bind(total_tokens).bind(cost)
.execute(&mut *tx).await?;
sqlx::query(
"UPDATE downstream_api_keys
SET used_requests = COALESCE(used_requests,0)+1,
used_cost = COALESCE(used_cost,0)+?,
last_used_at = datetime('now'), updated_at = datetime('now')
WHERE id = ?",
).bind(cost).bind(key_id).execute(&mut *tx).await?;
// Resolve owning user + new used_cost for the ledger snapshot.
let owner: Option<(Option<i64>, Option<f64>, Option<f64>)> = sqlx::query_as(
"SELECT user_id, max_cost, used_cost FROM downstream_api_keys WHERE id = ?",
).bind(key_id).fetch_optional(&mut *tx).await?;
if let Some((Some(user_id), max_cost, used_cost)) = owner {
let used = used_cost.unwrap_or(0.0);
let remaining = max_cost.map(|m| (m - used).max(0.0)).unwrap_or(used);
crate::services::billing::ledger::record_ledger_entry(
&mut tx,
crate::services::billing::ledger::LedgerEntry {
user_id, track: "usd", entry_type: "consume",
amount: -cost, balance_after: remaining,
ref_type: Some("usage_log"), ref_id: None,
model: Some(&model), operator: Some("system"), note: None,
},
).await?;
}
tx.commit().await?;
Ok(())
}
```
(注:原 `increment_key_usage(&self.db, ...)` 调用被事务内 UPDATE 取代,删除该行。)
- [ ] **Step 4: 运行确认通过**
Run: `cargo test -p boosapi --lib record_usage 2>&1 | tail -6`
Expected: PASS。
- [ ] **Step 5: 提交**
```bash
git add src/services/billing/mod.rs
git commit -m "feat(billing): USD consume writes credit_ledger in one tx"
```
### Task 3:USD 充值接入 ledger(credit_user_balance)
**Files:**
- Modify: `src/services/payment.rs`(`credit_user_balance`,行 ~2167-2202)
- [ ] **Step 1: 写失败测试** — payment.rs tests mod(复用 payment_test_pool,user 9/key 7 max_cost=10):
```rust
#[tokio::test]
async fn credit_user_balance_writes_recharge_ledger() {
let pool = payment_test_pool().await;
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
let mut tx = pool.begin().await.unwrap();
credit_user_balance(&mut tx, 9, 20.0).await.unwrap();
tx.commit().await.unwrap();
let row: (String, String, f64) = sqlx::query_as(
"SELECT track, entry_type, amount FROM credit_ledger WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
).fetch_one(&pool).await.unwrap();
assert_eq!(row, ("usd".into(), "recharge".into(), 20.0));
}
```
- [ ] **Step 2: 运行确认失败**
Run: `cargo test -p boosapi --lib credit_user_balance_writes_recharge_ledger 2>&1 | tail -5`
Expected: FAIL。
- [ ] **Step 3: 实现** — `credit_user_balance` 在 UPDATE max_cost 之后、`Ok(Some(key_id))` 之前插入 ledger 写入(已在事务 tx 内):
```rust
// After the UPDATE that sets next_max_cost on the key:
crate::services::billing::ledger::record_ledger_entry(
tx,
crate::services::billing::ledger::LedgerEntry {
user_id, track: "usd", entry_type: "recharge",
amount: amount_usd.max(0.0),
balance_after: (next_max_cost - used_cost).max(0.0),
ref_type: Some("recharge_record"), ref_id: None,
model: None, operator: Some("system"), note: None,
},
).await?;
```
(`credit_user_balance` 已是 `tx: &mut Transaction`,直接传 `tx`。确保调用方在调用前已 `ensure_ledger_schema`,或在函数开头加一次幂等建表。)
- [ ] **Step 4: 运行确认通过**
Run: `cargo test -p boosapi --lib credit_user_balance 2>&1 | tail -6`
Expected: PASS(含已有的 recharge completion 测试)。
- [ ] **Step 5: 提交**
```bash
git add src/services/payment.rs
git commit -m "feat(billing): USD recharge writes credit_ledger"
```
### Task 4:USD 管理员调整接入 ledger(adjust_user_balance)
**Files:**
- Modify: `src/routes/admin/users.rs`(`adjust_user_balance`,行 ~820)
- [ ] **Step 1: 写失败测试** — users.rs tests mod:调 `adjust_user_balance(State, Path(9), Json{delta:5.0})` 后断言 credit_ledger 有一行 `(usd, admin_adjust, 5.0)`。(建表与 user/key 同 Task 3 helper。)
- [ ] **Step 2: 运行确认失败** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -5` → FAIL。
- [ ] **Step 3: 实现** — `adjust_user_balance` 现在直接 `execute(&*state.db)`。改为事务:begin → UPDATE max_cost → `record_ledger_entry(track="usd", entry_type="admin_adjust", amount=delta, balance_after=next_max-used, operator=<管理员,暂用 "admin">)` → commit。函数开头 `ledger::ensure_ledger_schema(&state.db).await?;`。
- [ ] **Step 4: 运行确认通过** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -6` → PASS。
- [ ] **Step 5: 提交**
```bash
git add src/routes/admin/users.rs
git commit -m "feat(billing): USD admin adjust writes credit_ledger"
```
### Task 5:全量回归
- [ ] **Step 1:** `cargo test 2>&1 | grep "test result"` → 全 ok。
- [ ] **Step 2:** `cargo build 2>&1 | tail -3` → Finished。
- [ ] **Step 3:** 提交(若有遗留)。
---
## 后续期次(概要,第 1 期落地后逐期细化为同样的 TDD 任务)
### 第 2 期:积分轨
- `user_credits` 已建表(Task 1)。新增 `adjust_user_credits`(admin 发放/增减,写 ledger points/admin_adjust)。
- comfy_cloud 确认到账 `admin_confirm_user_recharge` 改为加 `points_balance` + ledger(points, recharge),受 config 开关 `payment.comfy_recharge_to_points` 控制(默认 true);历史不迁移。
- 工作流积分单价 config(默认值 + 按工作流名分级映射)。
- `workflow/executor.rs` `run_workflow`:执行**前**查 `points_balance >= 单价`,不足返回 Forbidden「工作流积分不足」;执行**成功后**扣积分 + ledger(points, consume, model=工作流名)。
- 测试:拦截、扣费、充值进积分、管理员调整。
### 第 3 期:A 消费明细
- `GET /api/user/billing/ledger`(查本人 credit_ledger,track/type/时间过滤、分页)。
- `GET /api/user/billing/summary`(按天/模型聚合,双轨分开)。
- 扩展 `UsageRecords.tsx`:双轨切换 + 汇总卡 + 明细列表。
### 第 4 期:B 充值档位
- `recharge_tiers` 表(已在 spec,Task 1 未建,本期建)+ admin CRUD `/api/admin/recharge-tiers`。
- 用户充值档位从 DB 读(替代 env `USER_RECHARGE_OPTIONS`)。
- admin `RechargeTiers` 配置页 + `UserRecharge.tsx` 接 DB。
### 第 5 期:C 预警
- 余额接口(user_billing_summary 等)加 `lowBalance`(余额 < 阈值,config 默认 0.2)。
- Dashboard / UserRecharge / ComfyUIAgent 顶部预警条。
### 第 6 期:D 审计 + 对账
- `GET /api/admin/billing/ledger`(跨用户,过滤分页)+ admin `BillingAudit` 页。
- cron:拉 Comfy `/customers/balance` 总余额,与 points consume ledger 比对,偏差超阈值写 log + admin 告警标志(不自动改数据)。
---
## 自审
- **spec 覆盖**:第 1 期覆盖 spec「数据模型」+「USD 轨记账接入」三处(充值/消费/调整);后 5 期概要逐条对应 spec 的积分轨/A/B/C/D + 对账。recharge_tiers 表 spec 列在数据模型,实现挪到第 4 期建(第 1 期用不到),已注明。
- **占位扫描**:第 1 期 Task 1-3 含完整代码;Task 4-5 用文字描述但步骤明确(adjust 与 Task 3 同模式,刻意不重复大段代码,执行时参照 Task 3)。后 5 期为概要,执行前细化。
- **类型一致**:`LedgerEntry` 字段、`record_ledger_entry`/`ensure_ledger_schema` 签名在 Task 1 定义,Task 2-4 一致引用。`track`/`entry_type` 取值(usd/points、recharge/consume/admin_adjust)全程一致。
---
## 第 2 期:积分轨(细化 TDD)
文件结构:
- `src/services/billing/ledger.rs` 加积分轨核心:`points_balance(db,user_id)`(读余额)、`apply_points_delta(db, user_id, delta, entry_type, model, operator, ref_*)`(事务内:UPSERT user_credits 余额 + 写 ledger(points),返回新余额)。所有积分增减走这一个入口。
- `src/config.rs`:ComfyUIConfig 加 `workflow_points_cost`(默认单价)。
- `src/workflow/executor.rs`:run_workflow_with_progress 前置拦截 + 收尾扣积分。
- `src/services/payment.rs`:admin_confirm_user_recharge 改为加积分(受 config 开关)。
- `src/routes/admin/users.rs`:新增 `adjust_user_credits` handler + 路由。
### Task 6:积分轨核心 apply_points_delta + points_balance
**Files:** Modify `src/services/billing/ledger.rs`
- [ ] **Step1 失败测试**(ledger.rs tests mod):
```rust
#[tokio::test]
async fn apply_points_delta_upserts_balance_and_writes_ledger() {
let pool = mem_pool().await;
// 首次 +100(充值)
let bal = apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("recharge_record"), None).await.unwrap();
assert_eq!(bal, 100.0);
// 再 -30(消费)
let bal2 = apply_points_delta(&pool, 9, -30.0, "consume", Some("wf/video"), Some("system"), Some("workflow"), None).await.unwrap();
assert_eq!(bal2, 70.0);
// user_credits 缓存 = 70
let cached: f64 = sqlx::query_scalar("SELECT points_balance FROM user_credits WHERE user_id = 9").fetch_one(&pool).await.unwrap();
assert_eq!(cached, 70.0);
// ledger 两行
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
assert_eq!(n, 2);
}
```
- [ ] **Step2** `cargo test -p boosapi --lib apply_points_delta_upserts 2>&1 | tail -6` → FAIL。
- [ ] **Step3 实现**(ledger.rs,tests 之前):
```rust
/// Current points balance for a user (0 if no row yet).
pub async fn points_balance(db: &SqlitePool, user_id: i64) -> AppResult<f64> {
let bal: Option<f64> = sqlx::query_scalar(
"SELECT points_balance FROM user_credits WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(db)
.await?;
Ok(bal.unwrap_or(0.0))
}
/// Apply a points delta (+credit / -debit) atomically: upsert the cached
/// balance in `user_credits` and append one `credit_ledger(track=points)` row,
/// in a single transaction. Returns the new balance.
#[allow(clippy::too_many_arguments)]
pub async fn apply_points_delta(
db: &SqlitePool,
user_id: i64,
delta: f64,
entry_type: &str,
model: Option<&str>,
operator: Option<&str>,
ref_type: Option<&str>,
ref_id: Option<&str>,
) -> AppResult<f64> {
ensure_ledger_schema(db).await?;
let mut tx = db.begin().await?;
let current: f64 = sqlx::query_scalar(
"SELECT points_balance FROM user_credits WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0.0);
let next = current + delta;
let total_recharged_inc = if delta > 0.0 && entry_type == "recharge" { delta } else { 0.0 };
sqlx::query(
"INSERT INTO user_credits (user_id, points_balance, points_total_recharged, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
points_balance = ?,
points_total_recharged = points_total_recharged + ?,
updated_at = datetime('now')",
)
.bind(user_id).bind(next).bind(total_recharged_inc)
.bind(next).bind(total_recharged_inc)
.execute(&mut *tx).await?;
record_ledger_entry(&mut tx, LedgerEntry {
user_id, track: "points", entry_type,
amount: delta, balance_after: next,
ref_type, ref_id, model, operator, note: None,
}).await?;
tx.commit().await?;
Ok(next)
}
```
- [ ] **Step4** `cargo test -p boosapi --lib apply_points_delta 2>&1 | tail -6` → PASS;`cargo build -j 2 | tail -2`。
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): points track apply_points_delta + balance"`
### Task 7:工作流积分单价 config
**Files:** Modify `src/config.rs`
- [ ] **Step1** 测试:config 默认值测试(若 config.rs 有 default 测试则加断言;否则跳测试,靠编译)。
- [ ] **Step2/3 实现**:ComfyUIConfig 加字段 `#[serde(default = "default_workflow_points_cost")] pub workflow_points_cost: f64,`,default fn 返回 `10.0`;Default impl 里加 `workflow_points_cost: 10.0,`。
- [ ] **Step4** `cargo build -j 2 2>&1 | tail -3` → Finished(config 字段加完整个 crate 能编译)。
- [ ] **Step5** `git add src/config.rs && git commit -m "feat(billing): workflow_points_cost config"`
### Task 8:工作流执行前置拦截 + 成功扣积分
**Files:** Modify `src/workflow/executor.rs`
- [ ] **Step1 失败测试**:executor.rs tests mod(若无则建)。建 in-memory pool + user_credits/credit_ledger,构造一个最简 graph,`ctx.user_id=Some(9)`:
- 余额不足(0 积分,单价 10)→ run_workflow_with_progress 返回 Err(Forbidden 含"积分不足")。
- 余额充足(100)→ 执行成功后 points_balance 变 90,credit_ledger 出现一行 (points, consume, -10)。
(若 executor 难以单测真实节点,则把"拦截"和"扣费"逻辑抽成小函数 `check_workflow_points(state, user_id, cost)` 和 `charge_workflow_points(state, user_id, cost, workflow_name)` 各自单测,run_workflow 调它们。优先抽函数单测。)
- [ ] **Step2** 确认 FAIL。
- [ ] **Step3 实现**:
- 在 run_workflow_with_progress 的 `validate_and_sort` 之后、`insert_run` 之前:若 `ctx.user_id` 是 Some 且 `ctx.charge_points`(见下):`let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let bal = ledger::points_balance(&state.db, uid).await?; if bal < cost { return Err(AppError::Forbidden("工作流积分不足,请充值".into())); } }`
- 在 `complete_run` 之后、`Ok(run_public_id)` 之前:`if let Some(uid) = ctx.user_id { let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let _ = ledger::apply_points_delta(&state.db, uid, -cost, "consume", Some(&format!("workflow:{}", workflow_id)), Some("system"), Some("workflow_run"), Some(&run_public_id)).await; } }`(扣费 best-effort,失败记 log 不阻断已产出结果)。
- 注意:Admin 调用(builtin key,user_id 可能是 Some(admin) 或特殊)是否扣?按设计仅对普通用户扣。可用 RunContext 加 `pub charge_points: bool` 字段,run_context 构造时 User=>true / Admin=>false。models.rs RunContext 加该字段,两处构造(routes.rs run_context、workflow_ui.rs workflow_context)补 `charge_points`。
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
- [ ] **Step5** `git add src/workflow/executor.rs src/workflow/models.rs src/workflow/routes.rs src/routes/workflow_ui.rs && git commit -m "feat(billing): workflow points gating + charge"`
### Task 9:comfy 充值改加积分 + admin 积分调整
**Files:** Modify `src/services/payment.rs`、`src/routes/admin/users.rs`、`src/config.rs`(开关)、`src/routes/admin/mod.rs`(路由)
- [ ] **Step1 失败测试**:
- payment.rs:`admin_confirm_user_recharge` 确认后,credit_ledger 出现 (points, recharge, +amount)(而非 usd);user_credits 增加。
- users.rs:`adjust_user_credits` handler 调用后 credit_ledger 出现 (points, admin_adjust)。
- [ ] **Step2** FAIL。
- [ ] **Step3 实现**:
- config 开关 `payment.comfy_recharge_to_points: bool`(默认 true)。
- `admin_confirm_user_recharge`:开关为 true 时改调 `ledger::apply_points_delta(db, user_id, amount_usd_as_points, "recharge", None, Some("admin"), Some("recharge_record"), Some(public_id))`(USD→积分换算:本期 1 USD = N 积分,N 用 config `payment.usd_to_points_rate` 默认 100;即 grant_points = amount_usd * rate)。开关 false 时维持原 credit_user_balance(USD)。**历史不迁移**。
- 新增 `adjust_user_credits(State, Path(id), Json{delta})` handler(仿 adjust_user_balance)→ `apply_points_delta(db,id,delta,"admin_adjust",None,Some("admin"),Some("manual"),None)`,返回 {success, data{userId, pointsBalance}}。
- admin/mod.rs 注册 `POST /api/admin/users/{id}/credits/adjust`。
- [ ] **Step4** 相关测试 + payment/users 模块全过 + build。
- [ ] **Step5** 分两个 commit 或一个:`git commit -m "feat(billing): comfy recharge to points + admin credit adjust"`
### Task 10:第 2 期全量回归
- [ ] `cargo test 2>&1 | grep "test result"` 全 ok;`cargo build -j 2 | tail -2` Finished。
---
## 第 3 期:A 消费明细(细化 TDD)
文件:`src/services/billing/ledger.rs`(加查询函数)、`src/routes/user/mod.rs`(加 2 路由)、`src/web/api.ts`(加 client)、`src/web/pages/UsageRecords.tsx`(扩展)。
### Task 11:ledger 查询函数(明细 + 聚合)
**Files:** Modify `src/services/billing/ledger.rs`
- [ ] **Step1 失败测试**(ledger.rs tests mod):
```rust
#[tokio::test]
async fn list_and_summarize_user_ledger() {
let pool = mem_pool().await;
apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("rr"), None).await.unwrap();
apply_points_delta(&pool, 9, -10.0, "consume", Some("wf:1"), Some("system"), Some("run"), None).await.unwrap();
// 明细:user 9 两行,track=points
let rows = list_user_ledger(&pool, 9, None, None, 50, 0).await.unwrap();
assert_eq!(rows.len(), 2);
// 聚合:points 轨 consume 合计 -10
let sum = summarize_user_ledger(&pool, 9).await.unwrap();
assert!(sum.iter().any(|v| v["track"]=="points" && v["entryType"]=="consume"));
}
```
- [ ] **Step2** `cargo test -p boosapi --lib list_and_summarize_user_ledger 2>&1|tail -6` FAIL。
- [ ] **Step3 实现**(ledger.rs,tests 之前):
```rust
use serde_json::{json, Value};
/// List a user's ledger rows, newest first, optional track/entry_type filter + paging.
pub async fn list_user_ledger(
db: &SqlitePool, user_id: i64,
track: Option<&str>, entry_type: Option<&str>,
limit: i64, offset: i64,
) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let limit = limit.clamp(1, 500);
let rows = sqlx::query(
"SELECT track, entry_type, amount, balance_after, model, operator, ref_type, ref_id, note, created_at
FROM credit_ledger
WHERE user_id = ?
AND (?1_track IS NULL OR track = ?1_track)
AND (?1_type IS NULL OR entry_type = ?1_type)
ORDER BY id DESC LIMIT ? OFFSET ?"
.replace("?1_track", "?2").replace("?1_type", "?3")
)
.bind(user_id).bind(track).bind(entry_type).bind(limit).bind(offset)
.fetch_all(db).await?;
use sqlx::Row;
Ok(rows.iter().map(|r| json!({
"track": r.try_get::<String,_>("track").unwrap_or_default(),
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
"amount": r.try_get::<f64,_>("amount").unwrap_or(0.0),
"balanceAfter": r.try_get::<f64,_>("balance_after").unwrap_or(0.0),
"model": r.try_get::<Option<String>,_>("model").ok().flatten(),
"operator": r.try_get::<Option<String>,_>("operator").ok().flatten(),
"refType": r.try_get::<Option<String>,_>("ref_type").ok().flatten(),
"note": r.try_get::<Option<String>,_>("note").ok().flatten(),
"createdAt": r.try_get::<Option<String>,_>("created_at").ok().flatten(),
})).collect())
}
/// Aggregate a user's ledger by (track, entry_type): sum amount + count.
pub async fn summarize_user_ledger(db: &SqlitePool, user_id: i64) -> AppResult<Vec<Value>> {
ensure_ledger_schema(db).await?;
let rows = sqlx::query(
"SELECT track, entry_type, COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt
FROM credit_ledger WHERE user_id = ?
GROUP BY track, entry_type ORDER BY track, entry_type"
).bind(user_id).fetch_all(db).await?;
use sqlx::Row;
Ok(rows.iter().map(|r| json!({
"track": r.try_get::<String,_>("track").unwrap_or_default(),
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
"total": r.try_get::<f64,_>("total").unwrap_or(0.0),
"count": r.try_get::<i64,_>("cnt").unwrap_or(0),
})).collect())
}
```
注:上面 SQL 的 named-param replace 写法若 sqlx 不接受,改用直白的可选过滤:用 `track.is_none() OR track = ?` 形式,bind 顺序 user_id, track, track, entry_type, entry_type, limit, offset(每个 Option bind 两次)。子代理按 sqlx 实际行为调整到能编译+测试过。
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): user ledger list + summary queries"`
### Task 12:user 侧 billing 接口
**Files:** Modify `src/routes/user/mod.rs`(加 2 路由 + 2 handler)
- [ ] **Step1 失败测试**:user mod tests(若有 test_state/建表 helper 则复用)——构造 user JWT + ledger 数据,GET /api/user/billing/ledger 返回行,/summary 返回聚合。若 user mod 无现成集成测试框架,改为直接单测 handler 逻辑或跳测试靠 ledger.rs 的 Task11 测试覆盖,仅验证编译 + 路由注册。
- [ ] **Step2** FAIL/编译失败。
- [ ] **Step3 实现**:
```rust
async fn get_billing_ledger(State(state): State<AppState>, headers: HeaderMap, Query(q): Query<BillingLedgerQuery>) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let rows = crate::services::billing::ledger::list_user_ledger(
&state.db, user_id, q.track.as_deref(), q.entry_type.as_deref(),
q.limit.unwrap_or(100), q.offset.unwrap_or(0)).await?;
Ok(Json(json!({ "success": true, "records": rows })))
}
async fn get_billing_summary(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let agg = crate::services::billing::ledger::summarize_user_ledger(&state.db, user_id).await?;
Ok(Json(json!({ "success": true, "summary": agg })))
}
```
+ `BillingLedgerQuery{ track:Option<String>, entry_type:Option<String>, limit:Option<i64>, offset:Option<i64> }`(serde Deserialize, rename_all camelCase 视前端传参)。
+ routes() 注册 `.route("/api/user/billing/ledger", get(get_billing_ledger)).route("/api/user/billing/summary", get(get_billing_summary))`。
- [ ] **Step4** `cargo test -p boosapi --lib 2>&1|command grep "test result"` + `cargo build -j 2`。
- [ ] **Step5** `git add src/routes/user/mod.rs && git commit -m "feat(billing): user billing ledger + summary endpoints"`
### Task 13:前端 api + UsageRecords 双轨展示
**Files:** Modify `src/web/api.ts`、`src/web/pages/UsageRecords.tsx`
- [ ] **Step1**:api.ts 的 userApi 加 `getBillingLedger(params)` → GET /api/user/billing/ledger、`getBillingSummary()` → /summary。
- [ ] **Step2**:UsageRecords.tsx 加一个"积分/USD"双轨切换 tab + 聚合汇总卡(用 getBillingSummary)+ ledger 明细列表(getBillingLedger)。保留现有 logs 视图作为 USD 明细来源之一,新增积分明细来自 ledger。具体 UI 复用现有 usage-records-* 样式。
- [ ] **Step3**:`npm run typecheck:web` 干净 + 现有 UsageRecords 测试不破坏(`npx vitest run src/web/pages/UsageRecords`)。
- [ ] **Step4** `git add src/web/api.ts src/web/pages/UsageRecords.tsx && git commit -m "feat(billing): UsageRecords dual-track ledger view"`
### Task 14:第3期回归
- [ ] `cargo test 2>&1|command grep "test result"` 全ok;`cargo build -j 2`;`npm run typecheck`。
@@ -0,0 +1,149 @@
# 计费闭环系统设计
日期:2026-06-16
状态:设计已获批,待写实现计划
## 背景
BoosAPI 现有计费是零散拼接的:每个用户的 downstream API key 有 `max_cost`(美元上限)/ `used_cost`(已消耗),proxy 请求路径在 `used_cost >= max_cost` 时硬拦截返回 429;充值通过 Stripe 自动到账或 comfy_cloud 管理员确认到账,调用 `credit_user_balance` 增加 `max_cost`;新用户默认 $1 额度;usage_logs 按 key 记每笔消费。
缺口:ComfyUI 工作流执行不扣额度、无积分体系、无消费明细可视化、无充值档位配置、无低额度预警、无全量变动审计。
本设计把上述补齐为一个 **ledger-first(统一流水账)** 的计费闭环。
## 核心模型:双轨额度
- **USD 轨**:LLM 对话、生图、视频等所有走 `/v1/*` 的调用。沿用 `downstream_api_keys.max_cost/used_cost`。
- **工作流积分轨**:仅 ComfyUI 工作流执行。新建每用户独立的积分池,平台可管理。
两轨并行、互不换算。comfy_cloud 充值进积分轨;其他充值(Stripe)进 USD 轨。
## 数据模型
### 新表 `credit_ledger`(唯一真相源,所有变动记一行)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `user_id` | INTEGER NOT NULL | 所属用户 |
| `track` | TEXT NOT NULL | `usd` / `points` |
| `entry_type` | TEXT NOT NULL | `recharge` / `admin_adjust` / `consume` / `refund` / `grant` |
| `amount` | REAL NOT NULL | 正=增 负=减(usd 用美元,points 用积分) |
| `balance_after` | REAL NOT NULL | 该轨本次变动后余额快照 |
| `ref_type` | TEXT | `usage_log` / `recharge_record` / `manual` / `stripe` 等 |
| `ref_id` | TEXT | 关联记录 id/public_id |
| `model` | TEXT | 消费类:模型名或工作流名(供聚合) |
| `operator` | TEXT | 操作者:管理员名 / `system` / `user` |
| `note` | TEXT | 备注 |
| `created_at` | TEXT DEFAULT (datetime('now')) | |
索引:`(user_id, created_at DESC)`、`(track, entry_type)`、`(user_id, track)`。
### 新表 `user_credits`(积分余额缓存)
| 字段 | 类型 | 说明 |
|---|---|---|
| `user_id` | INTEGER PK | |
| `points_balance` | REAL NOT NULL DEFAULT 0 | 当前积分余额(热路径读写) |
| `points_total_recharged` | REAL NOT NULL DEFAULT 0 | 累计充值积分(统计) |
| `updated_at` | TEXT | |
### 新表 `recharge_tiers`(充值档位,admin 可配)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `track` | TEXT NOT NULL | `usd` / `points` |
| `amount_usd` | REAL NOT NULL | 支付金额(USD) |
| `grant_amount` | REAL NOT NULL | 到账量(USD 数或积分数) |
| `bonus` | REAL NOT NULL DEFAULT 0 | 赠送量 |
| `title` | TEXT | 档位标题 |
| `sort_order` | INTEGER DEFAULT 0 | 排序 |
| `enabled` | INTEGER DEFAULT 1 | 启用 |
### 余额派生策略:ledger + 缓存双写
- **USD 轨**:`max_cost/used_cost` 作快速余额缓存(拦截/展示零延迟读),每笔变动**同事务**写一条 `credit_ledger(track=usd)`。
- **积分轨**:`user_credits.points_balance` 作缓存,每笔变动**同事务**写 `credit_ledger(track=points)`。
- **关键不变量**:任何余额变动 = 缓存字段更新 + 一条 ledger,**同一 DB 事务**,事务失败整体回滚,绝不只改其一。
- ledger 是审计/明细/对账真相;缓存字段是热路径性能优化。
## 双轨记账接入
原则:所有动余额处,同事务"改缓存 + 写一条 ledger"。
### USD 轨
| 变动 | 现有代码 | 改造 |
|---|---|---|
| 充值到账 | `credit_user_balance`(payment.rs) | 同事务加写 ledger(usd, recharge) |
| 消费扣费 | `increment_key_usage`/`record_billing` | 同事务加写 ledger(usd, consume, model=模型名) |
| 管理员增减 | `adjust_user_balance` | 同事务加写 ledger(usd, admin_adjust, operator) |
| 额度拦截 | proxy `used_cost>=max_cost`→429 | 不变,补低额度预警信号 |
### 积分轨(基本新建)
| 变动 | 现状 | 新增 |
|---|---|---|
| 充值到账 | comfy_cloud 确认到账现加 USD | 改为加 `points_balance` + ledger(points, recharge) |
| 消费扣费 | 工作流不扣 | 工作流执行**成功后**按单价扣积分 + ledger(points, consume, model=工作流名);失败不扣 |
| 管理员发放/增减 | 无 | 新增 `adjust_user_credits` + ledger(points, admin_adjust) |
| 积分拦截 | 无 | 工作流执行**前**检查 `points_balance >= 单价`,不足拒绝;低额度预警 |
- 积分消费扣费点:`workflow/executor.rs` 的 `run_workflow` 成功收尾处(有 `ctx.user_id`),与现有"工作流按用户 key 记 USD 消费"平行。
- **comfy_cloud 充值改向积分轨**:行为变更,历史 USD **不迁移**,仅对切换后新充值生效,用 config 开关控制(可回滚)。
## 积分消费规则:A+C 混合
- **A 固定/分级单价**(主):工作流执行扣固定积分(config 默认值,可按工作流类型分级)。可预测、支持并发。
- **C 对账校准**(兜底):后台 cron 拉 Comfy 总余额对账,偏差超阈值提示管理员调单价。
## 四块功能
### A 消费明细(用户侧,明细+聚合)
- 新建 `GET /api/user/billing/ledger`:查该用户 credit_ledger,可按 track/type/时间过滤。
- 新建 `GET /api/user/billing/summary`:按天/按模型聚合,双轨分开。
- 扩展 `UsageRecords.tsx`:双轨切换 + 聚合汇总卡 + 明细列表。
### B 充值档位(本期可配置,订阅后期)
- 新建 `recharge_tiers` 表 + admin CRUD `GET/POST/PUT/DELETE /api/admin/recharge-tiers`。
- 改造用户充值:档位从 DB 读(替代 env 写死的 `USER_RECHARGE_OPTIONS`)。
- 扩展 `UserRecharge.tsx`(档位从接口拉)+ 新建 admin `RechargeTiers` 配置页。
### C 超额 + 预警(两轨硬拦 + 预警)
- USD 拦截已有(不动);积分拦截新建(工作流执行前)。
- 余额接口加 `lowBalance` 标志(余额 < 阈值,config 可配默认 20%)。
- Dashboard/充值页/视频助手:余额低显示预警条。
### D 审计对账(管理员侧,全量流水)
- 新建 `GET /api/admin/billing/ledger`:跨用户查 credit_ledger(按 user/track/type/时间过滤、分页)。
- 新建 admin `BillingAudit` 页:全量变动流水表(谁/何时/给谁/加减/原因),站点管理风格。
- 对账校准:cron 拉 Comfy 总余额,与积分消费 ledger 比对,偏差超阈值写 log + admin 告警标志。**不自动改数据**,仅提示,管理员手动调单价。
## 错误处理(不变量)
- 余额变动 = 缓存 + ledger 同事务;失败整体回滚。
- 消费扣费 best-effort 不阻断响应(沿用 record_billing 原则)。
- 积分轨:拦截是前置硬检查,扣费是后置;后置扣费失败已产出结果 → 记日志补偿。
- comfy 充值改向积分:config 开关控制,可回滚。
## 测试策略
- 单元:ledger 写入+缓存同步事务一致性;积分拦截(不足拒绝);单价扣费;充值进积分;管理员调整双轨。
- 复用现有 payment/billing 测试框架(in-memory SQLite)。
## 实现顺序(分 6 期,每期可独立上线)
1. **地基**:credit_ledger + user_credits 表 + 迁移;USD 轨记账接进 ledger(充值/消费/调整三处双写)。
2. **积分轨**:工作流积分扣费 + 拦截 + comfy 充值改进积分 + 管理员积分调整。
3. **A 消费明细**:用户侧 ledger/summary 接口 + UsageRecords 扩展。
4. **B 充值档位**:recharge_tiers 表 + admin 配置页 + 用户充值页接 DB。
5. **C 预警**:余额接口加 lowBalance + 前端预警条。
6. **D 审计 + 对账**:admin BillingAudit 页 + cron 对账校准。
## 新增清单小结
- **表**:credit_ledger(核心)、user_credits(积分缓存)、recharge_tiers(档位)。
- **后端接口**:user billing ledger/summary、admin recharge-tiers CRUD、admin billing ledger、adjust_user_credits、积分拦截/扣费。
- **前端页面**:admin RechargeTiers、admin BillingAudit;扩展 UsageRecords / UserRecharge / Dashboard。
- **cron**:Comfy 余额对账校准任务。
@@ -0,0 +1,100 @@
# NIT AI Studio — 第一阶段(聚合图像/数字人)设计
日期:2026-06-17
分支:`nit_edu`
状态:已通过 brainstorming,待落实现计划
## 背景
NIT 精英班专属的「AI 综合应用软件」是一套面向精英班学员的 AI 创作工具,完整愿景涵盖
文本结构化创作(论文/小说/剧本/分镜/教材)、PPT 生成、图像(P图/制图)、视频
(文生/图生/短视频)、数字人。体量过大,分阶段建设。
**本 spec 仅覆盖 AI Studio 第一阶段:聚合现有图像 + 数字人能力**,为精英班学员提供
统一创作入口,复用 BoosAPI 已有的 `picture_agent`(图像)与 `digital_human`(数字人)。
## 已确认的关键决策
1. 定位:NIT 精英班专属模块,非通用套件。
2. 第一阶段只聚合 `picture_agent`(图像)+ `digital_human`(数字人)。
3. 视频(含 money_printer、工作流视频)留后续阶段,本阶段完全不碰 money_printer。
4. 认证桥接:给精英班学员配后台下游 key + 扩展 surface 的 resolve_auth 接受 student JWT。
5. 计费:学员创作消耗走积分轨(已建的 points track),管理员可给学员充积分。
6. 文本创作 / PPT 留后续阶段。
## 前置依赖
本阶段 gate 在第一期(报考闭环)产物上,实现排在第一期之后:
- student JWT(`audience='student'`,第一期发放)。
- `enrollment.status=approved` 且 `tier=elite`。
- 非精英班 / 未审核 → 403。
## 非目标(本阶段明确不做)
- 视频能力(文生/图生/短视频、money_printer、工作流视频)。
- 文本结构化创作(论文/小说/剧本/分镜/教材)。
- PPT 生成。
- 通用套件(面向非 NIT 用户)。
## 学员下游 key 配置
- `downstream_api_keys` 加可空 `student_id` 列(关联 `students.id`,与现有 `user_id` 并列;
二者互斥:一行要么属于 user,要么属于 student)。
- 审核通过且 `tier=elite` 时,自动 provision 一个学员下游 key,沿用现有 `comfyui-agent`
内置 key 的幂等 `ensure` 模式:缺失则建、被禁用则重新启用、保持唯一。
- 学员从精英班降级或报考被驳回时,禁用该 key。
- key 命名约定:`sk-nit-stu-{uuid_hex}`,`name='nit-student'`,便于识别。
## 认证扩展
仅改两个 surface:`src/routes/picture_agent.rs`、`src/routes/digital_human.rs`。
**money_printer 不动。**
各自的 `resolve_auth` 加一个分支(现有 admin/proxy token、user JWT 逻辑不变):
- 新增:`audience='student'` 的 JWT → 校验该学员存在 `approved` 且 `tier=elite` 的报考
→ 查其 `student_id` 对应的下游 key → 作 `outbound_key`。
- 抽共享 helper `resolve_student_outbound_key(student_id, state) -> AppResult<(key_id, key)>`,
放在 `src/services/nit/`,两处 surface 复用,避免重复逻辑。
- 校验失败(无 elite 报考 / key 缺失)→ 403。
## 计费(复用积分轨)
- 学员用图像/数字人,消耗扣在其下游 key 上,走已建的积分轨(`apply_points_delta`,track=points)。
- 管理员给学员充积分:扩展现有 admin 积分调整端点支持 `student_id`(或新增 NIT 学员积分端点,
实现时择一,优先扩展现有端点)。
- 精英班开通时可送一笔初始积分,数额为配置项,默认 `0`。
## 统一入口(前端)
- `src/web/pages/nit/Studio.tsx`:精英班学员创作工作台首页,卡片式列出「图像 / 数字人」
两个入口 + 显示积分余额。
- 卡片跳进现有 `picture_agent` / `digital_human` 页面(带 student token)。
- 非精英班学员:显示引导升级提示,不展示工具卡片。
- `api.ts` 的 `nitApi` 扩展:查学员积分余额、查 Studio 可用工具。
## 错误处理
- 非精英班 / 未审核学员访问 → 403 + 友好提示。
- 学员下游 key 缺失(provision 失败)→ 明确错误,提示联系管理员。
- 积分余额不足 → 拦截创作,提示充值(复用现有 billing 引导文案范式)。
## 测试策略(TDD)
后端(rstest + 内存 SQLite,沿用现有 `recharge_test_pool` 范式):
- 学员下游 key ensure 幂等性(缺失则建 / 禁用则启用 / 降级则禁用)。
- `resolve_auth` 接受 elite student JWT、拒绝非精英班、拒绝未审核。
- 学员创作扣积分、余额不足拦截。
前端(vitest):
- `nitApi` 积分余额 / Studio 工具查询方法(沿用 `api.test.ts` 范式)。
- Studio 页卡片渲染 + 余额展示 + 非精英班引导(沿用 `UsageRecords.test.tsx` 范式)。
## 验证
- `cargo build -j 2`、`cargo test -p boosapi --lib`(后端 nit / picture_agent / digital_human 相关)。
- `npx vitest run`(相关前端测试)、`npm run typecheck`。
## 提交约定
全部在 `nit_edu` 分支,不混入 `refactor/rust-backend`。提交按逻辑拆分(迁移 / service /
surface 认证 / 前端 分开),沿用项目中文 commit 风格。
@@ -0,0 +1,195 @@
# NIT 报考学习平台 — 第一期(报考闭环 A+B+C)设计
日期:2026-06-17
分支:`nit_edu`
状态:已通过 brainstorming,待落实现计划
## 背景
NIT 报考学习平台面向报考「NIT 人工智能与大模型应用」考试的学员,提供注册、报考、
学习等功能。完整需求(见 `plan/NIT报考学习平台(初版需求备注解析).docx`)涵盖
8 个子系统,体量过大,无法单 spec 完成,故拆分分期建设。
**本 spec 仅覆盖第一期:报考闭环 A+B+C** —— 学员注册 → 报考 → 缴费审核开课。
学习中心(理论课/实操课/练习题)、OCR、校区负责人多级权限、内容保护等留后续期。
## 已确认的关键决策
1. 第一个子项目:A+B+C 报考闭环(注册 → 报考 → 缴费审核开课)。
2. 独立 `students` 表,与现有 `users`(API 网关用户)完全隔离。
3. 缴费:线下转账/扫码给校区 + 学员上传凭证截图 + 后台人工审核开课(非在线支付)。
4. 第一期不做 OCR:学员手动填身份证号/姓名,二寸照手动命名上传。
5. 开课权限只打状态标记(enrollment.status=approved + tier),学习中心留后续期再读此标记。
6. 后台第一期只做平台超管(复用现有 admin 认证),校区负责人登录留后续期。
7. 文件存储复用现有 workspace 机制(multipart + `record_bytes_asset` + `workspace_assets` 表)。
8. 代码组织:NIT 作为 BoosAPI 内的新模块,复用现有 axum/sqlx/JWT/workspace 基建。
9. 学员认证:发 `audience='student'` 的 JWT,与 admin/user token 互不通用。
10. 报名信息表:固定核心字段 + 一个 JSON 扩展字段(应对考试中心模版变化),不做动态表单引擎。
## 非目标(第一期明确不做)
- 学习中心:理论课/实操课视频、二维码、学习进度、练习题、模拟题、错题集。
- 实操课专属 AI 综合应用软件入口。
- 身份证 OCR 自动识别与二寸照自动命名。
- 校区负责人多级权限与数据隔离。
- 内容保护(防截图/录屏/水印)。
- 在线支付。
## 数据模型
三张新表,SQLite,迁移文件放 `migrations/`。
### `students` — 学员实名账户
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK | |
| `public_id` | TEXT UNIQUE | 对外 ID,格式 `stu_{uuid_hex}` |
| `name` | TEXT NOT NULL | 姓名(实名,注册后不可改) |
| `phone` | TEXT NOT NULL UNIQUE | 实名手机号,登录用 |
| `id_card_no` | TEXT NOT NULL UNIQUE | 身份证号 |
| `campus_id` | INTEGER NOT NULL | FK → `nit_campuses.id` |
| `major` | TEXT | 所学专业(社会学员可空) |
| `class_name` | TEXT | 班级(可空) |
| `head_teacher` | TEXT | 班主任姓名(可空) |
| `password_hash` | TEXT NOT NULL | 登录密码哈希(沿用现有 users 哈希方案) |
| `privacy_agreed_at` | TEXT NOT NULL | 勾选隐私协议时间 |
| `status` | TEXT NOT NULL DEFAULT 'active' | active / disabled |
| `created_at` / `updated_at` | TEXT | |
### `nit_campuses` — 校区/机构库(超管录入,学员注册下拉选)
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK | |
| `public_id` | TEXT UNIQUE | `cmp_{uuid_hex}` |
| `province` | TEXT NOT NULL | 省份 |
| `name` | TEXT NOT NULL | 院校/机构名称 |
| `manager_name` | TEXT | 负责人姓名 |
| `manager_phone` | TEXT | 负责人联系方式 |
| `enabled` | INTEGER NOT NULL DEFAULT 1 | 仅 enabled 的校区可被学员选择 |
| `created_at` / `updated_at` | TEXT | |
### `nit_enrollments` — 报考记录
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK | |
| `public_id` | TEXT UNIQUE | `enr_{uuid_hex}` |
| `student_id` | INTEGER NOT NULL | FK → `students.id` |
| `subject` | TEXT NOT NULL | 第一期固定「NIT人工智能与大模型应用」 |
| `tier` | TEXT NOT NULL | `basic`(980)/ `elite`(1980) |
| `payment_time` | TEXT | 学员填写的缴费时间 |
| `payment_proof_asset_id` | TEXT | 缴费凭证截图(workspace asset public_id) |
| `id_card_front_asset_id` | TEXT | 身份证正面 |
| `id_card_back_asset_id` | TEXT | 身份证反面 |
| `photo_asset_id` | TEXT | 二寸蓝底照 |
| `applicant_info` | TEXT (JSON) | 报名材料表可变字段 |
| `status` | TEXT NOT NULL DEFAULT 'pending_review' | pending_review / approved / rejected |
| `reviewed_by` | TEXT | 审核管理员标识 |
| `reviewed_at` | TEXT | |
| `review_note` | TEXT | 驳回原因/备注 |
| `created_at` / `updated_at` | TEXT | |
**关系**:student → campus(多对一);enrollment → student(多对一)。一个学员允许在被驳回后
重新提交报考(可有多条 rejected + 至多一条 active 的 pending_review/approved)。
**班型权限**:第一期权限即 `approved` 后 enrollment 上的 `tier` 标记;`basic` 对应理论课权限、
`elite` 对应理论+实操权限,实际课程内容由后续期读取此标记实现。
**文件**:四类上传文件全部走现有 `workspace_assets`(`source='nit-enrollment'`,
经 `record_bytes_asset` 落本地磁盘),enrollment 仅存其 `public_id` 引用。
## 认证与路由
### 学员认证
复用现有 `auth::jwt` 工具,给 students 发 `audience='student'` 的 JWT。新增
`resolve_student(headers, state) -> AppResult<i64>`(返回 student_id),与 admin/user token
互不通用。登录凭证为「手机号 + 密码」。
### 路由分布(挂进现有 axum router via `routes/mod.rs` merge)
- `src/routes/nit/mod.rs` — 装配 NIT 子路由,导出 `routes()`。
- `src/routes/nit/student_auth.rs`
- `POST /api/nit/register` — 学员注册
- `POST /api/nit/login` — 学员登录
- `src/routes/nit/enrollment.rs`(学员侧,需 student JWT)
- `POST /api/nit/enrollment/uploads` — multipart 上传单个文件,返回 asset public_id
- `POST /api/nit/enrollment` — 创建报考记录
- `GET /api/nit/enrollment/mine` — 查本人报考状态
- `src/routes/nit/admin.rs`(超管侧,复用现有 admin token 校验)
- `GET/POST/PUT /api/admin/nit/campuses` — 校区 CRUD
- `GET /api/admin/nit/enrollments` — 报考列表(按 省份/校区/班型/状态 筛选)
- `POST /api/admin/nit/enrollments/{public_id}/approve`
- `POST /api/admin/nit/enrollments/{public_id}/reject`
- `GET /api/admin/nit/enrollments/export` — CSV 导出
业务逻辑(查询/状态机)抽到 `src/services/nit/`(如 `mod.rs` + `enrollment.rs`),
路由层只做参数解析 + 鉴权 + 调用 service,保持文件聚焦。
## 数据流
### 注册
1. 校验手机号、身份证号未被占用(唯一约束 + 友好报错)。
2. 校验 `campus_id` 存在且 enabled。
3. 必须 `privacy_agreed=true`。
4. 写入 `students`(password 哈希),发 student JWT 返回。
### 报考(两步,避免半截事务)
1. 学员逐个调 `POST /api/nit/enrollment/uploads`(multipart,带 `purpose` 区分四类文件)
→ `record_bytes_asset` → 返回各 asset `public_id`。
2. 学员调 `POST /api/nit/enrollment`,带各 asset id + tier + payment_time + applicant_info
→ 校验本人无在审/已批的活动报考 → 创建 `pending_review` 记录。
### 审核(超管)
- 列表筛选 `pending_review` → 查看凭证/证件图(经 workspace asset file 接口)。
- `approve`:状态机 `pending_review → approved`,落 `tier` 权限标记,记 reviewed_by/at。
- `reject`:`pending_review → rejected`,记 review_note;学员可据此重新提交。
- 状态机只允许从 `pending_review` 流转,杜绝并发重复审核。
### 导出
按 省份/校区/班型/状态 筛选,导出 CSV(实名信息 + 班型),用于核算报考人数/教材数量。
## 前端页面(`src/web/pages/nit/`)
- 学员注册页(实名信息 + 校区下拉 + 隐私协议勾选)
- 学员登录页(手机号 + 密码)
- 报考页(选班型 + 上传四类文件 + 填缴费时间 + 报名信息表 + 提交)
- 「我的报考」状态页(pending/approved/rejected + 驳回原因 + 可重新提交)
- 超管校区管理页(CRUD)
- 超管报考审核页(列表 + 筛选 + 看图 + 通过/驳回 + 导出)
`api.ts` 新增 `nitApi`(学员侧,带 student token 注入)与 `adminApi` NIT 审核扩展。
复用现有 React/Vite/api.ts 范式与 UI 组件。
## 错误处理
- 唯一约束冲突 → 友好提示「该手机号/身份证号已注册」。
- 上传非图片或超大文件 → 拒绝并提示。
- 报考时存在活动报考 → 拒绝重复提交。
- 审核状态机:非 `pending_review` 状态的 approve/reject → 409/冲突提示。
## 测试策略(TDD)
后端(rstest + 内存 SQLite,沿用现有 `recharge_test_pool` 范式):
- 注册手机号/身份证唯一性、校区校验、隐私协议必填。
- 登录成功/失败。
- 文件上传返回 asset id。
- 报考创建 + 重复报考拦截。
- 审核状态机(approve/reject/非法流转)。
- 导出筛选正确性。
前端(vitest):
- `nitApi` 各方法 URL/method/body 正确(沿用 `api.test.ts` 范式)。
- 注册/报考/审核关键页面交互(沿用 `UsageRecords.test.tsx` 的 react-test-renderer 范式)。
## 验证
- `cargo build -j 2`(避免 OOM)、`cargo test -p boosapi --lib nit`(后端)。
- `npx vitest run`(相关前端测试)、`npm run typecheck`。
## 提交约定
NIT 工作全部在 `nit_edu` 分支,不混入 `refactor/rust-backend`。提交按逻辑拆分
(迁移 / service / 路由 / 前端 分开),沿用项目中文 commit 风格。
+868
View File
@@ -0,0 +1,868 @@
-- BoosAPI initial schema
-- Generated from the existing SQLite database (hub.db)
-- This migration creates all tables, indexes, and constraints.
-- ============================================================
-- SITES
-- ============================================================
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
platform TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
api_key TEXT,
proxy_url TEXT,
use_system_proxy INTEGER DEFAULT false,
custom_headers TEXT,
external_checkin_url TEXT,
global_weight REAL DEFAULT 1,
post_refresh_probe_enabled INTEGER DEFAULT false,
post_refresh_probe_model TEXT DEFAULT '',
post_refresh_probe_scope TEXT DEFAULT 'single',
post_refresh_probe_latency_threshold_ms INTEGER DEFAULT 0,
is_pinned INTEGER DEFAULT false,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS sites_platform_url_unique
ON sites (platform, url);
CREATE INDEX IF NOT EXISTS sites_status_idx
ON sites (status);
-- ============================================================
-- USERS
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
username TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique
ON users (email);
CREATE INDEX IF NOT EXISTS users_status_idx
ON users (status);
-- ============================================================
-- TOKEN ROUTES
-- ============================================================
CREATE TABLE IF NOT EXISTS token_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
model_pattern TEXT NOT NULL,
model_mapping TEXT,
display_name TEXT,
display_icon TEXT,
decision_snapshot TEXT,
decision_refreshed_at TEXT,
routing_strategy TEXT DEFAULT 'weighted',
route_mode TEXT DEFAULT 'pattern',
enabled INTEGER DEFAULT true,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS token_routes_model_pattern_idx
ON token_routes (model_pattern);
CREATE INDEX IF NOT EXISTS token_routes_enabled_idx
ON token_routes (enabled);
-- ============================================================
-- ACCOUNTS
-- ============================================================
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
username TEXT,
access_token TEXT NOT NULL,
api_token TEXT,
balance REAL DEFAULT 0,
balance_used REAL DEFAULT 0,
quota REAL DEFAULT 0,
unit_cost REAL,
value_score REAL DEFAULT 0,
status TEXT DEFAULT 'active',
checkin_enabled INTEGER DEFAULT true,
last_checkin_at TEXT,
last_balance_refresh TEXT,
extra_config TEXT,
is_pinned INTEGER DEFAULT false,
sort_order INTEGER DEFAULT 0,
oauth_provider TEXT,
oauth_account_key TEXT,
oauth_project_id TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS accounts_site_id_idx
ON accounts (site_id);
CREATE INDEX IF NOT EXISTS accounts_status_idx
ON accounts (status);
CREATE INDEX IF NOT EXISTS accounts_site_status_idx
ON accounts (site_id, status);
CREATE INDEX IF NOT EXISTS accounts_oauth_provider_idx
ON accounts (oauth_provider);
CREATE INDEX IF NOT EXISTS accounts_oauth_identity_idx
ON accounts (oauth_provider, oauth_account_key, oauth_project_id);
-- ============================================================
-- DOWNSTREAM API KEYS
-- ============================================================
CREATE TABLE IF NOT EXISTS downstream_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled INTEGER DEFAULT true,
expires_at TEXT,
max_cost REAL,
used_cost REAL DEFAULT 0,
max_requests INTEGER,
used_requests INTEGER DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id INTEGER REFERENCES users(id),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS downstream_api_keys_key_unique
ON downstream_api_keys (key);
CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx
ON downstream_api_keys (name);
CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx
ON downstream_api_keys (enabled);
CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx
ON downstream_api_keys (expires_at);
-- ============================================================
-- SITE DISABLED MODELS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_disabled_models (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
model_name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_disabled_models_site_model_unique
ON site_disabled_models (site_id, model_name);
CREATE INDEX IF NOT EXISTS site_disabled_models_site_id_idx
ON site_disabled_models (site_id);
-- ============================================================
-- SITE API ENDPOINTS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_api_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT true,
sort_order INTEGER DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url);
CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order);
CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until);
-- ============================================================
-- ACCOUNT TOKENS
-- ============================================================
CREATE TABLE IF NOT EXISTS account_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
account_id INTEGER NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
source TEXT DEFAULT 'manual',
token_group TEXT,
value_status TEXT NOT NULL DEFAULT 'ready',
enabled INTEGER DEFAULT true,
is_default INTEGER DEFAULT false,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS account_tokens_account_id_idx
ON account_tokens (account_id);
CREATE INDEX IF NOT EXISTS account_tokens_account_enabled_idx
ON account_tokens (account_id, enabled);
CREATE INDEX IF NOT EXISTS account_tokens_enabled_idx
ON account_tokens (enabled);
-- ============================================================
-- CHECKIN LOGS
-- ============================================================
CREATE TABLE IF NOT EXISTS checkin_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
account_id INTEGER NOT NULL,
status TEXT NOT NULL,
message TEXT,
reward TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS checkin_logs_account_created_at_idx
ON checkin_logs (account_id, created_at);
CREATE INDEX IF NOT EXISTS checkin_logs_created_at_idx
ON checkin_logs (created_at);
CREATE INDEX IF NOT EXISTS checkin_logs_status_idx
ON checkin_logs (status);
-- ============================================================
-- MODEL AVAILABILITY
-- ============================================================
CREATE TABLE IF NOT EXISTS model_availability (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
account_id INTEGER NOT NULL,
model_name TEXT NOT NULL,
available INTEGER,
latency_ms INTEGER,
checked_at TEXT DEFAULT (datetime('now')),
is_manual INTEGER DEFAULT false,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique
ON model_availability (account_id, model_name);
CREATE INDEX IF NOT EXISTS model_availability_account_available_idx
ON model_availability (account_id, available);
CREATE INDEX IF NOT EXISTS model_availability_model_name_idx
ON model_availability (model_name);
-- ============================================================
-- TOKEN MODEL AVAILABILITY
-- ============================================================
CREATE TABLE IF NOT EXISTS token_model_availability (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
token_id INTEGER NOT NULL,
model_name TEXT NOT NULL,
available INTEGER,
latency_ms INTEGER,
checked_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (token_id) REFERENCES account_tokens(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique
ON token_model_availability (token_id, model_name);
CREATE INDEX IF NOT EXISTS token_model_availability_token_available_idx
ON token_model_availability (token_id, available);
CREATE INDEX IF NOT EXISTS token_model_availability_model_name_idx
ON token_model_availability (model_name);
CREATE INDEX IF NOT EXISTS token_model_availability_available_idx
ON token_model_availability (available);
-- ============================================================
-- ROUTE CHANNELS
-- ============================================================
CREATE TABLE IF NOT EXISTS route_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
route_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
token_id INTEGER,
oauth_route_unit_id INTEGER,
source_model TEXT,
priority INTEGER DEFAULT 0,
weight INTEGER DEFAULT 10,
enabled INTEGER DEFAULT true,
manual_override INTEGER DEFAULT false,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
FOREIGN KEY (route_id) REFERENCES token_routes(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY (token_id) REFERENCES account_tokens(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS route_channels_route_id_idx
ON route_channels (route_id);
CREATE INDEX IF NOT EXISTS route_channels_account_id_idx
ON route_channels (account_id);
CREATE INDEX IF NOT EXISTS route_channels_token_id_idx
ON route_channels (token_id);
CREATE INDEX IF NOT EXISTS route_channels_route_enabled_idx
ON route_channels (route_id, enabled);
CREATE INDEX IF NOT EXISTS route_channels_route_token_idx
ON route_channels (route_id, token_id);
CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id);
-- ============================================================
-- ROUTE GROUP SOURCES
-- ============================================================
CREATE TABLE IF NOT EXISTS route_group_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
group_route_id INTEGER NOT NULL,
source_route_id INTEGER NOT NULL,
FOREIGN KEY (group_route_id) REFERENCES token_routes(id) ON DELETE CASCADE,
FOREIGN KEY (source_route_id) REFERENCES token_routes(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique
ON route_group_sources (group_route_id, source_route_id);
CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx
ON route_group_sources (source_route_id);
-- ============================================================
-- PROXY LOGS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
route_id INTEGER,
channel_id INTEGER,
account_id INTEGER,
downstream_api_key_id INTEGER,
model_requested TEXT,
model_actual TEXT,
status TEXT,
http_status INTEGER,
latency_ms INTEGER,
first_byte_latency_ms INTEGER,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
estimated_cost REAL,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
is_stream INTEGER,
client_family TEXT,
client_app_id TEXT,
client_app_name TEXT,
client_confidence TEXT,
billing_details TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS proxy_logs_created_at_idx
ON proxy_logs (created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_account_created_at_idx
ON proxy_logs (account_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx
ON proxy_logs (status, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_model_actual_created_at_idx
ON proxy_logs (model_actual, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_downstream_api_key_created_at_idx
ON proxy_logs (downstream_api_key_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at);
-- ============================================================
-- PROXY VIDEO TASKS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_video_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
upstream_video_id TEXT NOT NULL,
site_url TEXT NOT NULL,
token_value TEXT NOT NULL,
requested_model TEXT,
actual_model TEXT,
channel_id INTEGER,
account_id INTEGER,
status_snapshot TEXT,
upstream_response_meta TEXT,
last_upstream_status INTEGER,
last_polled_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_video_tasks_public_id_unique
ON proxy_video_tasks (public_id);
CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx
ON proxy_video_tasks (upstream_video_id);
CREATE INDEX IF NOT EXISTS proxy_video_tasks_created_at_idx
ON proxy_video_tasks (created_at);
-- ============================================================
-- PROXY FILES
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_files (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
owner_type TEXT NOT NULL,
owner_id TEXT NOT NULL,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
purpose TEXT,
byte_size INTEGER NOT NULL,
sha256 TEXT NOT NULL,
content_base64 TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_files_public_id_unique
ON proxy_files (public_id);
CREATE INDEX IF NOT EXISTS proxy_files_owner_lookup_idx
ON proxy_files (owner_type, owner_id, deleted_at);
-- ============================================================
-- PROXY DEBUG TRACES
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id INTEGER,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id INTEGER,
selected_channel_id INTEGER,
selected_route_id INTEGER,
selected_account_id INTEGER,
selected_site_id INTEGER,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status INTEGER,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at);
-- ============================================================
-- PROXY DEBUG ATTEMPTS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
trace_id INTEGER NOT NULL,
attempt_index INTEGER NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status INTEGER,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied INTEGER DEFAULT false,
downgrade_decision INTEGER DEFAULT false,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index);
CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at);
-- ============================================================
-- EVENTS
-- ============================================================
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT,
level TEXT NOT NULL DEFAULT 'info',
read INTEGER DEFAULT false,
related_id INTEGER,
related_type TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS events_read_created_at_idx
ON events (read, created_at);
CREATE INDEX IF NOT EXISTS events_type_created_at_idx
ON events (type, created_at);
CREATE INDEX IF NOT EXISTS events_created_at_idx
ON events (created_at);
-- ============================================================
-- SITE ANNOUNCEMENTS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
platform TEXT NOT NULL,
source_key TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
source_url TEXT,
starts_at TEXT,
ends_at TEXT,
upstream_created_at TEXT,
upstream_updated_at TEXT,
first_seen_at TEXT DEFAULT (datetime('now')),
last_seen_at TEXT DEFAULT (datetime('now')),
read_at TEXT,
dismissed_at TEXT,
raw_payload TEXT,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_announcements_site_source_key_unique
ON site_announcements (site_id, source_key);
CREATE INDEX IF NOT EXISTS site_announcements_site_id_first_seen_at_idx
ON site_announcements (site_id, first_seen_at);
CREATE INDEX IF NOT EXISTS site_announcements_read_at_idx
ON site_announcements (read_at);
-- ============================================================
-- OAUTH ROUTE UNITS
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_route_units (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT true,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider);
CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled);
-- ============================================================
-- OAUTH ROUTE UNIT MEMBERS
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id);
CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id);
CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order);
CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until);
-- ============================================================
-- SETTINGS
-- ============================================================
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
);
-- ============================================================
-- ADMIN SNAPSHOTS
-- ============================================================
CREATE TABLE IF NOT EXISTS admin_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key);
CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at);
CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until);
-- ============================================================
-- ANALYTICS PROJECTION CHECKPOINTS
-- ============================================================
CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id INTEGER,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id);
CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at);
-- ============================================================
-- MODEL DAY USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS model_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
model TEXT NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT model_day_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model);
CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day);
CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id);
CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model);
-- ============================================================
-- SITE DAY USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS site_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT site_day_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_summary_spend >= 0
AND total_site_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id);
CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day);
CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id);
-- ============================================================
-- SITE HOUR USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS site_hour_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
bucket_start_utc TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT site_hour_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_summary_spend >= 0
AND total_site_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id);
CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc);
CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id);
@@ -0,0 +1,24 @@
-- Background tasks tracking
CREATE TABLE IF NOT EXISTS background_tasks (
id TEXT PRIMARY KEY NOT NULL,
task_type TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
message TEXT DEFAULT '',
error TEXT,
dedupe_key TEXT,
success_title TEXT,
failure_title TEXT,
notify_on_success INTEGER DEFAULT true,
notify_on_failure INTEGER DEFAULT true,
result_json TEXT,
progress INTEGER DEFAULT 0,
progress_total INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS background_tasks_status_idx
ON background_tasks (status);
@@ -0,0 +1,53 @@
-- Organizations, members, and invitations for multi-tenant support
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS org_slug_idx
ON organizations (slug);
CREATE INDEX IF NOT EXISTS org_plan_idx
ON organizations (plan);
CREATE TABLE IF NOT EXISTS organization_members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'active',
joined_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS org_member_unique
ON organization_members (org_id, user_id);
CREATE INDEX IF NOT EXISTS org_members_org_id_idx
ON organization_members (org_id);
CREATE INDEX IF NOT EXISTS org_members_user_id_idx
ON organization_members (user_id);
CREATE TABLE IF NOT EXISTS invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'member',
invited_by INTEGER REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending',
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_unique
ON invitations (token);
CREATE INDEX IF NOT EXISTS invitations_status_idx
ON invitations (status);
+37
View File
@@ -0,0 +1,37 @@
-- Usage tracking tables migrated from the Node.js backend.
CREATE TABLE IF NOT EXISTS usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
downstream_key_id INTEGER,
model TEXT,
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
cost REAL DEFAULT 0.0,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS usage_logs_downstream_key_created_idx
ON usage_logs (downstream_key_id, created_at);
CREATE INDEX IF NOT EXISTS usage_logs_created_idx
ON usage_logs (created_at);
CREATE TABLE IF NOT EXISTS usage_daily_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_day TEXT NOT NULL,
downstream_key_id INTEGER,
request_count INTEGER NOT NULL DEFAULT 0,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_cost REAL NOT NULL DEFAULT 0.0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, downstream_key_id),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS usage_daily_snapshots_day_idx
ON usage_daily_snapshots (local_day);
@@ -0,0 +1,33 @@
-- Admin-authored announcements for normal users.
CREATE TABLE IF NOT EXISTS user_announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
status TEXT NOT NULL DEFAULT 'published',
audience TEXT NOT NULL DEFAULT 'all',
starts_at TEXT,
ends_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
published_at TEXT
);
CREATE INDEX IF NOT EXISTS user_announcements_status_created_at_idx
ON user_announcements (status, created_at);
CREATE INDEX IF NOT EXISTS user_announcements_active_window_idx
ON user_announcements (starts_at, ends_at);
CREATE TABLE IF NOT EXISTS user_announcement_reads (
announcement_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
read_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (announcement_id, user_id),
FOREIGN KEY (announcement_id) REFERENCES user_announcements(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_announcement_reads_user_id_idx
ON user_announcement_reads (user_id);
@@ -0,0 +1,34 @@
-- Workspace assets: local archive for creative outputs from Picture Agent,
-- ComfyUI Agent, Video Agent and ComfyUI workspaces.
CREATE TABLE IF NOT EXISTS workspace_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
user_id INTEGER,
source TEXT NOT NULL,
kind TEXT NOT NULL,
title TEXT,
prompt TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'completed',
mime_type TEXT,
file_name TEXT,
file_path TEXT,
remote_url TEXT,
thumbnail_path TEXT,
size_bytes INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_user_created
ON workspace_assets(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_source_created
ON workspace_assets(source, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_kind_created
ON workspace_assets(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_deleted
ON workspace_assets(deleted_at);
@@ -0,0 +1,64 @@
-- BoosAPI Workflow Studio core tables.
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
user_id INTEGER,
name TEXT NOT NULL,
description TEXT,
graph TEXT NOT NULL DEFAULT '{}',
is_template INTEGER NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'private',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflows_user_updated
ON workflows(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflows_visibility
ON workflows(visibility);
CREATE INDEX IF NOT EXISTS idx_workflows_deleted
ON workflows(deleted_at);
CREATE TABLE IF NOT EXISTS workflow_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
workflow_id INTEGER NOT NULL,
user_id INTEGER,
status TEXT NOT NULL,
input_overrides TEXT NOT NULL DEFAULT '{}',
final_outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created
ON workflow_runs(workflow_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_user_created
ON workflow_runs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status
ON workflow_runs(status);
CREATE TABLE IF NOT EXISTS workflow_node_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
status TEXT NOT NULL,
inputs TEXT NOT NULL DEFAULT '{}',
outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run
ON workflow_node_runs(run_id, id);
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN response_content TEXT;
@@ -0,0 +1,51 @@
-- Performance-oriented projections and indexes for logs/workspace views.
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary);
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (
user_id,
status,
deleted_at,
source,
kind,
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (
json_extract(metadata, '$.workspaceAsset.role'),
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (
json_extract(metadata, '$.workflowUi.type'),
created_at DESC
);
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count INTEGER NOT NULL DEFAULT 0,
local_file_count INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, user_id, source, kind)
);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC);
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS user_recharge_records (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
option_id TEXT,
label TEXT,
title TEXT,
amount_usd REAL,
provider TEXT NOT NULL DEFAULT 'comfy_checkout',
checkout_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
note TEXT,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS user_recharge_records_public_id_unique
ON user_recharge_records (public_id);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_created_idx
ON user_recharge_records (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS user_recharge_records_user_status_idx
ON user_recharge_records (user_id, status);
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS payment_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
recharge_record_id INTEGER,
provider TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'stripe',
provider_session_id TEXT,
provider_payment_id TEXT,
amount_usd REAL NOT NULL,
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'usd',
status TEXT NOT NULL DEFAULT 'pending',
checkout_url TEXT,
success_url TEXT,
cancel_url TEXT,
note TEXT,
metadata TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
paid_at TEXT,
completed_at TEXT,
expires_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (recharge_record_id) REFERENCES user_recharge_records(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_public_id_unique
ON payment_orders (public_id);
CREATE UNIQUE INDEX IF NOT EXISTS payment_orders_provider_session_unique
ON payment_orders (provider, provider_session_id)
WHERE provider_session_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS payment_orders_user_created_idx
ON payment_orders (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS payment_orders_status_idx
ON payment_orders (status);
+868
View File
@@ -0,0 +1,868 @@
-- BoosAPI initial schema
-- Generated from the existing SQLite database (hub.db)
-- This migration creates all tables, indexes, and constraints.
-- ============================================================
-- SITES
-- ============================================================
CREATE TABLE IF NOT EXISTS sites (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
platform TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
api_key TEXT,
proxy_url TEXT,
use_system_proxy BOOLEAN DEFAULT FALSE,
custom_headers TEXT,
external_checkin_url TEXT,
global_weight DOUBLE PRECISION DEFAULT 1,
post_refresh_probe_enabled BOOLEAN DEFAULT FALSE,
post_refresh_probe_model TEXT DEFAULT '',
post_refresh_probe_scope TEXT DEFAULT 'single',
post_refresh_probe_latency_threshold_ms BIGINT DEFAULT 0,
is_pinned BOOLEAN DEFAULT FALSE,
sort_order BIGINT DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS sites_platform_url_unique
ON sites (platform, url);
CREATE INDEX IF NOT EXISTS sites_status_idx
ON sites (status);
-- ============================================================
-- USERS
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique
ON users (email);
CREATE INDEX IF NOT EXISTS users_status_idx
ON users (status);
-- ============================================================
-- TOKEN ROUTES
-- ============================================================
CREATE TABLE IF NOT EXISTS token_routes (
id BIGSERIAL PRIMARY KEY,
model_pattern TEXT NOT NULL,
model_mapping TEXT,
display_name TEXT,
display_icon TEXT,
decision_snapshot TEXT,
decision_refreshed_at TEXT,
routing_strategy TEXT DEFAULT 'weighted',
route_mode TEXT DEFAULT 'pattern',
enabled BOOLEAN DEFAULT TRUE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS token_routes_model_pattern_idx
ON token_routes (model_pattern);
CREATE INDEX IF NOT EXISTS token_routes_enabled_idx
ON token_routes (enabled);
-- ============================================================
-- ACCOUNTS
-- ============================================================
CREATE TABLE IF NOT EXISTS accounts (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
username TEXT,
access_token TEXT NOT NULL,
api_token TEXT,
balance DOUBLE PRECISION DEFAULT 0,
balance_used DOUBLE PRECISION DEFAULT 0,
quota DOUBLE PRECISION DEFAULT 0,
unit_cost DOUBLE PRECISION,
value_score DOUBLE PRECISION DEFAULT 0,
status TEXT DEFAULT 'active',
checkin_enabled BOOLEAN DEFAULT TRUE,
last_checkin_at TEXT,
last_balance_refresh TEXT,
extra_config TEXT,
is_pinned BOOLEAN DEFAULT FALSE,
sort_order BIGINT DEFAULT 0,
oauth_provider TEXT,
oauth_account_key TEXT,
oauth_project_id TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS accounts_site_id_idx
ON accounts (site_id);
CREATE INDEX IF NOT EXISTS accounts_status_idx
ON accounts (status);
CREATE INDEX IF NOT EXISTS accounts_site_status_idx
ON accounts (site_id, status);
CREATE INDEX IF NOT EXISTS accounts_oauth_provider_idx
ON accounts (oauth_provider);
CREATE INDEX IF NOT EXISTS accounts_oauth_identity_idx
ON accounts (oauth_provider, oauth_account_key, oauth_project_id);
-- ============================================================
-- DOWNSTREAM API KEYS
-- ============================================================
CREATE TABLE IF NOT EXISTS downstream_api_keys (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled BOOLEAN DEFAULT TRUE,
expires_at TEXT,
max_cost DOUBLE PRECISION,
used_cost DOUBLE PRECISION DEFAULT 0,
max_requests BIGINT,
used_requests BIGINT DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id BIGINT REFERENCES users(id),
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS downstream_api_keys_key_unique
ON downstream_api_keys (key);
CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx
ON downstream_api_keys (name);
CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx
ON downstream_api_keys (enabled);
CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx
ON downstream_api_keys (expires_at);
-- ============================================================
-- SITE DISABLED MODELS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_disabled_models (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_disabled_models_site_model_unique
ON site_disabled_models (site_id, model_name);
CREATE INDEX IF NOT EXISTS site_disabled_models_site_id_idx
ON site_disabled_models (site_id);
-- ============================================================
-- SITE API ENDPOINTS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_api_endpoints (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
url TEXT NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
sort_order BIGINT DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url);
CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order);
CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until);
-- ============================================================
-- ACCOUNT TOKENS
-- ============================================================
CREATE TABLE IF NOT EXISTS account_tokens (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
source TEXT DEFAULT 'manual',
token_group TEXT,
value_status TEXT NOT NULL DEFAULT 'ready',
enabled BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS account_tokens_account_id_idx
ON account_tokens (account_id);
CREATE INDEX IF NOT EXISTS account_tokens_account_enabled_idx
ON account_tokens (account_id, enabled);
CREATE INDEX IF NOT EXISTS account_tokens_enabled_idx
ON account_tokens (enabled);
-- ============================================================
-- CHECKIN LOGS
-- ============================================================
CREATE TABLE IF NOT EXISTS checkin_logs (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
status TEXT NOT NULL,
message TEXT,
reward TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS checkin_logs_account_created_at_idx
ON checkin_logs (account_id, created_at);
CREATE INDEX IF NOT EXISTS checkin_logs_created_at_idx
ON checkin_logs (created_at);
CREATE INDEX IF NOT EXISTS checkin_logs_status_idx
ON checkin_logs (status);
-- ============================================================
-- MODEL AVAILABILITY
-- ============================================================
CREATE TABLE IF NOT EXISTS model_availability (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
available BIGINT,
latency_ms BIGINT,
checked_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
is_manual BOOLEAN DEFAULT FALSE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique
ON model_availability (account_id, model_name);
CREATE INDEX IF NOT EXISTS model_availability_account_available_idx
ON model_availability (account_id, available);
CREATE INDEX IF NOT EXISTS model_availability_model_name_idx
ON model_availability (model_name);
-- ============================================================
-- TOKEN MODEL AVAILABILITY
-- ============================================================
CREATE TABLE IF NOT EXISTS token_model_availability (
id BIGSERIAL PRIMARY KEY,
token_id BIGINT NOT NULL,
model_name TEXT NOT NULL,
available BIGINT,
latency_ms BIGINT,
checked_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (token_id) REFERENCES account_tokens(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique
ON token_model_availability (token_id, model_name);
CREATE INDEX IF NOT EXISTS token_model_availability_token_available_idx
ON token_model_availability (token_id, available);
CREATE INDEX IF NOT EXISTS token_model_availability_model_name_idx
ON token_model_availability (model_name);
CREATE INDEX IF NOT EXISTS token_model_availability_available_idx
ON token_model_availability (available);
-- ============================================================
-- ROUTE CHANNELS
-- ============================================================
CREATE TABLE IF NOT EXISTS route_channels (
id BIGSERIAL PRIMARY KEY,
route_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
token_id BIGINT,
oauth_route_unit_id BIGINT,
source_model TEXT,
priority BIGINT DEFAULT 0,
weight BIGINT DEFAULT 10,
enabled BOOLEAN DEFAULT TRUE,
manual_override BOOLEAN DEFAULT FALSE,
success_count BIGINT DEFAULT 0,
fail_count BIGINT DEFAULT 0,
total_latency_ms BIGINT DEFAULT 0,
total_cost DOUBLE PRECISION DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count BIGINT NOT NULL DEFAULT 0,
cooldown_level BIGINT NOT NULL DEFAULT 0,
cooldown_until TEXT,
FOREIGN KEY (route_id) REFERENCES token_routes(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY (token_id) REFERENCES account_tokens(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS route_channels_route_id_idx
ON route_channels (route_id);
CREATE INDEX IF NOT EXISTS route_channels_account_id_idx
ON route_channels (account_id);
CREATE INDEX IF NOT EXISTS route_channels_token_id_idx
ON route_channels (token_id);
CREATE INDEX IF NOT EXISTS route_channels_route_enabled_idx
ON route_channels (route_id, enabled);
CREATE INDEX IF NOT EXISTS route_channels_route_token_idx
ON route_channels (route_id, token_id);
CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id);
-- ============================================================
-- ROUTE GROUP SOURCES
-- ============================================================
CREATE TABLE IF NOT EXISTS route_group_sources (
id BIGSERIAL PRIMARY KEY,
group_route_id BIGINT NOT NULL,
source_route_id BIGINT NOT NULL,
FOREIGN KEY (group_route_id) REFERENCES token_routes(id) ON DELETE CASCADE,
FOREIGN KEY (source_route_id) REFERENCES token_routes(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique
ON route_group_sources (group_route_id, source_route_id);
CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx
ON route_group_sources (source_route_id);
-- ============================================================
-- PROXY LOGS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_logs (
id BIGSERIAL PRIMARY KEY,
route_id BIGINT,
channel_id BIGINT,
account_id BIGINT,
downstream_api_key_id BIGINT,
model_requested TEXT,
model_actual TEXT,
status TEXT,
http_status BIGINT,
latency_ms BIGINT,
first_byte_latency_ms BIGINT,
prompt_tokens BIGINT,
completion_tokens BIGINT,
total_tokens BIGINT,
estimated_cost DOUBLE PRECISION,
error_message TEXT,
retry_count BIGINT DEFAULT 0,
is_stream BIGINT,
client_family TEXT,
client_app_id TEXT,
client_app_name TEXT,
client_confidence TEXT,
billing_details TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS proxy_logs_created_at_idx
ON proxy_logs (created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_account_created_at_idx
ON proxy_logs (account_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx
ON proxy_logs (status, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_model_actual_created_at_idx
ON proxy_logs (model_actual, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_downstream_api_key_created_at_idx
ON proxy_logs (downstream_api_key_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at);
-- ============================================================
-- PROXY VIDEO TASKS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_video_tasks (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
upstream_video_id TEXT NOT NULL,
site_url TEXT NOT NULL,
token_value TEXT NOT NULL,
requested_model TEXT,
actual_model TEXT,
channel_id BIGINT,
account_id BIGINT,
status_snapshot TEXT,
upstream_response_meta TEXT,
last_upstream_status BIGINT,
last_polled_at TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_video_tasks_public_id_unique
ON proxy_video_tasks (public_id);
CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx
ON proxy_video_tasks (upstream_video_id);
CREATE INDEX IF NOT EXISTS proxy_video_tasks_created_at_idx
ON proxy_video_tasks (created_at);
-- ============================================================
-- PROXY FILES
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_files (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL,
owner_type TEXT NOT NULL,
owner_id TEXT NOT NULL,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
purpose TEXT,
byte_size BIGINT NOT NULL,
sha256 TEXT NOT NULL,
content_base64 TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_files_public_id_unique
ON proxy_files (public_id);
CREATE INDEX IF NOT EXISTS proxy_files_owner_lookup_idx
ON proxy_files (owner_type, owner_id, deleted_at);
-- ============================================================
-- PROXY DEBUG TRACES
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id BIGSERIAL PRIMARY KEY,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id BIGINT,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id BIGINT,
selected_channel_id BIGINT,
selected_route_id BIGINT,
selected_account_id BIGINT,
selected_site_id BIGINT,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status BIGINT,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at);
CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at);
-- ============================================================
-- PROXY DEBUG ATTEMPTS
-- ============================================================
CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id BIGSERIAL PRIMARY KEY,
trace_id BIGINT NOT NULL,
attempt_index BIGINT NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status BIGINT,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied BOOLEAN DEFAULT FALSE,
downgrade_decision BOOLEAN DEFAULT FALSE,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index);
CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at);
-- ============================================================
-- EVENTS
-- ============================================================
CREATE TABLE IF NOT EXISTS events (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT,
level TEXT NOT NULL DEFAULT 'info',
"read" BOOLEAN DEFAULT FALSE,
related_id BIGINT,
related_type TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS events_read_created_at_idx
ON events ("read", created_at);
CREATE INDEX IF NOT EXISTS events_type_created_at_idx
ON events (type, created_at);
CREATE INDEX IF NOT EXISTS events_created_at_idx
ON events (created_at);
-- ============================================================
-- SITE ANNOUNCEMENTS
-- ============================================================
CREATE TABLE IF NOT EXISTS site_announcements (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
platform TEXT NOT NULL,
source_key TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
source_url TEXT,
starts_at TEXT,
ends_at TEXT,
upstream_created_at TEXT,
upstream_updated_at TEXT,
first_seen_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
last_seen_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
read_at TEXT,
dismissed_at TEXT,
raw_payload TEXT,
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS site_announcements_site_source_key_unique
ON site_announcements (site_id, source_key);
CREATE INDEX IF NOT EXISTS site_announcements_site_id_first_seen_at_idx
ON site_announcements (site_id, first_seen_at);
CREATE INDEX IF NOT EXISTS site_announcements_read_at_idx
ON site_announcements (read_at);
-- ============================================================
-- OAUTH ROUTE UNITS
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_route_units (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled BOOLEAN DEFAULT TRUE,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider);
CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled);
-- ============================================================
-- OAUTH ROUTE UNIT MEMBERS
-- ============================================================
CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id BIGSERIAL PRIMARY KEY,
unit_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
sort_order BIGINT DEFAULT 0,
success_count BIGINT DEFAULT 0,
fail_count BIGINT DEFAULT 0,
total_latency_ms BIGINT DEFAULT 0,
total_cost DOUBLE PRECISION DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count BIGINT NOT NULL DEFAULT 0,
cooldown_level BIGINT NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id);
CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id);
CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order);
CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until);
-- ============================================================
-- SETTINGS
-- ============================================================
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT
);
-- ============================================================
-- ADMIN SNAPSHOTS
-- ============================================================
CREATE TABLE IF NOT EXISTS admin_snapshots (
id BIGSERIAL PRIMARY KEY,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key);
CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at);
CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until);
-- ============================================================
-- ANALYTICS PROJECTION CHECKPOINTS
-- ============================================================
CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id BIGINT NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id BIGINT,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id);
CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at);
-- ============================================================
-- MODEL DAY USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS model_day_usage (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
site_id BIGINT NOT NULL,
model TEXT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT model_day_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model);
CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day);
CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id);
CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model);
-- ============================================================
-- SITE DAY USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS site_day_usage (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
site_id BIGINT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_summary_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_site_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT site_day_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_summary_spend >= 0
AND total_site_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id);
CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day);
CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id);
-- ============================================================
-- SITE HOUR USAGE
-- ============================================================
CREATE TABLE IF NOT EXISTS site_hour_usage (
id BIGSERIAL PRIMARY KEY,
bucket_start_utc TEXT NOT NULL,
site_id BIGINT NOT NULL,
total_calls BIGINT NOT NULL DEFAULT 0,
success_calls BIGINT NOT NULL DEFAULT 0,
failed_calls BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_summary_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_site_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
latency_count BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CONSTRAINT site_hour_usage_non_negative CHECK (
total_calls >= 0
AND success_calls >= 0
AND failed_calls >= 0
AND total_tokens >= 0
AND total_summary_spend >= 0
AND total_site_spend >= 0
AND total_latency_ms >= 0
AND latency_count >= 0
)
);
CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id);
CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc);
CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id);
@@ -0,0 +1,24 @@
-- Background tasks tracking
CREATE TABLE IF NOT EXISTS background_tasks (
id TEXT PRIMARY KEY NOT NULL,
task_type TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
message TEXT DEFAULT '',
error TEXT,
dedupe_key TEXT,
success_title TEXT,
failure_title TEXT,
notify_on_success BOOLEAN DEFAULT TRUE,
notify_on_failure BOOLEAN DEFAULT TRUE,
result_json TEXT,
progress BIGINT DEFAULT 0,
progress_total BIGINT DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS background_tasks_status_idx
ON background_tasks (status);
@@ -0,0 +1,53 @@
-- Organizations, members, and invitations for multi-tenant support
CREATE TABLE IF NOT EXISTS organizations (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS org_slug_idx
ON organizations (slug);
CREATE INDEX IF NOT EXISTS org_plan_idx
ON organizations (plan);
CREATE TABLE IF NOT EXISTS organization_members (
id BIGSERIAL PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'active',
joined_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS org_member_unique
ON organization_members (org_id, user_id);
CREATE INDEX IF NOT EXISTS org_members_org_id_idx
ON organization_members (org_id);
CREATE INDEX IF NOT EXISTS org_members_user_id_idx
ON organization_members (user_id);
CREATE TABLE IF NOT EXISTS invitations (
id BIGSERIAL PRIMARY KEY,
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'member',
invited_by BIGINT REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending',
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
);
CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_unique
ON invitations (token);
CREATE INDEX IF NOT EXISTS invitations_status_idx
ON invitations (status);
@@ -0,0 +1,37 @@
-- Usage tracking tables migrated from the Node.js backend.
CREATE TABLE IF NOT EXISTS usage_logs (
id BIGSERIAL PRIMARY KEY,
downstream_key_id BIGINT,
model TEXT,
prompt_tokens BIGINT DEFAULT 0,
completion_tokens BIGINT DEFAULT 0,
total_tokens BIGINT DEFAULT 0,
cost DOUBLE PRECISION DEFAULT 0.0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS usage_logs_downstream_key_created_idx
ON usage_logs (downstream_key_id, created_at);
CREATE INDEX IF NOT EXISTS usage_logs_created_idx
ON usage_logs (created_at);
CREATE TABLE IF NOT EXISTS usage_daily_snapshots (
id BIGSERIAL PRIMARY KEY,
local_day TEXT NOT NULL,
downstream_key_id BIGINT,
request_count BIGINT NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
total_cost DOUBLE PRECISION NOT NULL DEFAULT 0.0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE(),
FOREIGN KEY (downstream_key_id) REFERENCES downstream_api_keys(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS usage_daily_snapshots_day_idx
ON usage_daily_snapshots (local_day);
@@ -0,0 +1,33 @@
-- Admin-authored announcements for normal users.
CREATE TABLE IF NOT EXISTS user_announcements (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
status TEXT NOT NULL DEFAULT 'published',
audience TEXT NOT NULL DEFAULT 'all',
starts_at TEXT,
ends_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
published_at TEXT
);
CREATE INDEX IF NOT EXISTS user_announcements_status_created_at_idx
ON user_announcements (status, created_at);
CREATE INDEX IF NOT EXISTS user_announcements_active_window_idx
ON user_announcements (starts_at, ends_at);
CREATE TABLE IF NOT EXISTS user_announcement_reads (
announcement_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
read_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
PRIMARY KEY (announcement_id, user_id),
FOREIGN KEY (announcement_id) REFERENCES user_announcements(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS user_announcement_reads_user_id_idx
ON user_announcement_reads (user_id);
@@ -0,0 +1,34 @@
-- Workspace assets: local archive for creative outputs from Picture Agent,
-- ComfyUI Agent, Video Agent and ComfyUI workspaces.
CREATE TABLE IF NOT EXISTS workspace_assets (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
user_id BIGINT,
source TEXT NOT NULL,
kind TEXT NOT NULL,
title TEXT,
prompt TEXT,
model TEXT,
status TEXT NOT NULL DEFAULT 'completed',
mime_type TEXT,
file_name TEXT,
file_path TEXT,
remote_url TEXT,
thumbnail_path TEXT,
size_bytes BIGINT NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_user_created
ON workspace_assets(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_source_created
ON workspace_assets(source, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_kind_created
ON workspace_assets(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workspace_assets_deleted
ON workspace_assets(deleted_at);
@@ -0,0 +1,64 @@
-- BoosAPI Workflow Studio core tables.
CREATE TABLE IF NOT EXISTS workflows (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
user_id BIGINT,
name TEXT NOT NULL,
description TEXT,
graph TEXT NOT NULL DEFAULT '{}',
is_template BIGINT NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'private',
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
deleted_at TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflows_user_updated
ON workflows(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflows_visibility
ON workflows(visibility);
CREATE INDEX IF NOT EXISTS idx_workflows_deleted
ON workflows(deleted_at);
CREATE TABLE IF NOT EXISTS workflow_runs (
id BIGSERIAL PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
workflow_id BIGINT NOT NULL,
user_id BIGINT,
status TEXT NOT NULL,
input_overrides TEXT NOT NULL DEFAULT '{}',
final_outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY(workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created
ON workflow_runs(workflow_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_user_created
ON workflow_runs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status
ON workflow_runs(status);
CREATE TABLE IF NOT EXISTS workflow_node_runs (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL,
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
status TEXT NOT NULL,
inputs TEXT NOT NULL DEFAULT '{}',
outputs TEXT,
error_message TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::TEXT),
FOREIGN KEY(run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run
ON workflow_node_runs(run_id, id);
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS response_content TEXT;
@@ -0,0 +1,45 @@
-- Performance-oriented projections and indexes for logs/workspace views.
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary);
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (
user_id,
status,
deleted_at,
source,
kind,
created_at DESC
);
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (((metadata::jsonb #>> '{workspaceAsset,role}')), created_at DESC);
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (((metadata::jsonb #>> '{workflowUi,type}')), created_at DESC);
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count BIGINT NOT NULL DEFAULT 0,
local_file_count BIGINT NOT NULL DEFAULT 0,
total_size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
UNIQUE(local_day, user_id, source, kind)
);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC);
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC);
+86
View File
@@ -0,0 +1,86 @@
{
"name": "boosapi",
"version": "1.3.0",
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
"main": "dist/web/index.html",
"keywords": [
"ai",
"api-gateway",
"proxy",
"rust",
"openai",
"claude",
"aggregator"
],
"homepage": "https://github.com/cita-777/metapi#readme",
"bugs": {
"url": "https://github.com/cita-777/metapi/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/cita-777/metapi.git"
},
"license": "MIT",
"engines": {
"node": ">=22.0.0"
},
"type": "module",
"scripts": {
"dev": "vite",
"build:web": "vite build",
"build": "npm run build:web",
"typecheck:web": "tsc -p tsconfig.web.json",
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
"website:dev": "npm --prefix website run dev",
"website:build": "npm --prefix website run build",
"website:start": "npm --prefix website run start",
"website:typecheck": "npm --prefix website run typecheck",
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
"test": "vitest run --root src/web",
"test:watch": "vitest --root src/web"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@visactor/react-vchart": "^2.0.21",
"gsap": "^3.15.0",
"marked": "^17.0.5",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^22.10.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-test-renderer": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^29.0.2",
"mermaid": "^11.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.14.0",
"react-test-renderer": "^18.3.1",
"sharp": "^0.34.5",
"tailwindcss": "^4.0.0",
"typescript": "^6.0.2",
"vite": "^6.0.3",
"vitepress": "^1.6.4",
"vitepress-plugin-mermaid": "^2.0.17",
"vitest": "^2.1.8"
},
"overrides": {
"minimist": "^1.2.8"
},
"author": {
"name": "cita-777",
"email": "cita-777@users.noreply.github.com",
"url": "https://github.com/cita-777"
}
}
@@ -0,0 +1,164 @@
# 2026-05-15 技术报告 / Technical Report
## Executive Summary
本日完成 BoosAPI 品牌迁移、ComfyUI Agent 对话助手开发、Gitea CI/CD Runner 部署、Notification Service 能力梳理及部署文档重写。核心变更包括:将项目内 `Metapi` 全局替换为 `BoosAPI`,新增 `/comfyui-agent` 对话式 AI 视频生成入口,完成 SSE streaming 前后端链路,并完善生产部署文档与通知服务说明。
---
## 1. Metapi → BoosAPI 全局改名 / Global Rename
### 1.1 变更范围 / Change Scope
| 区域 / Area | 变更内容 / Changes |
|---|---|
| UI 界面 / UI | About 页面、侧边栏、登录页品牌名更新为 `BoosAPI` |
| i18n 翻译 / Internationalization | 所有语言文件中的 `Metapi` 替换为 `BoosAPI` |
| 后端通知 / Backend Notification | `notifyService.ts` 通知前缀由 `[metapi]` 改为 `[BoosAPI]` |
| 桌面元数据 / Desktop Metadata | Electron / Docker 描述信息更新 |
### 1.2 已完成的技术标识替换 / Completed Technical Identifier Changes
| 项目 / Item | 变更说明 / Description |
|---|---|
| HTTP Header | `x-metapi-*` → `x-boosapi-*` |
| 推理签名前缀 / Inference Signature Prefix | `metapi:` → `boosapi:` |
| 运行时属性 / Runtime Properties | `__metapiTruncated` 等 → `__boosapi*` |
| 环境变量 / Environment Variables | `METAPI_*` → `BOOSAPI_*` |
| Package Name | `package.json` 中 `name` 更新为 `boosapi` |
### 1.3 验证方式 / Verification
```bash
grep -rn '[mM]etapi' src/ --include='*.ts' --include='*.tsx' --include='*.html' \
| grep -v '.test.' | grep -v node_modules | grep -iv 'boosapi'
```
预期结果:无输出。
---
## 2. ComfyUI Agent 对话助手 / Conversational Assistant
### 2.1 功能概述 / Feature Overview
侧边栏新增「对话助手」入口,路由为 `/comfyui-agent`,提供与 AI 视频生成助手的对话式交互,支持文本、角色提示词、图像、场景、语音和 ComfyUI workflow 等多类型内容的流式渲染。
### 2.2 技术架构 / Architecture
```
用户 / User → POST /api/comfyui-agent/chat → BoosAPI → LLM 本地 API (streaming)
→ SSE 事件流 → BoosAPI 转发 → 浏览器
```
### 2.3 后端实现 / Backend
| 文件 / File | 作用 / Responsibility |
|---|---|
| `src/server/routes/api/comfyuiAgent.ts` | SSE 流式聊天接口,调用本地 LLM API |
| `src/server/services/boosapi_comfy_nodes.py` | ComfyUI 自定义节点,默认连接 `http://127.0.0.1:4000/v1` |
### 2.4 前端实现 / Frontend
| 文件 / File | 作用 / Responsibility |
|---|---|
| `src/web/pages/ComfyUIAgent.tsx` | 对话界面组件,SSE 流式渲染 |
| `src/web/App.tsx` | 注册 `/comfyui-agent` 路由及侧边栏入口 |
### 2.5 SSE 事件类型 / SSE Event Types
| `type` | 含义 / Meaning | 前端渲染 / Rendering |
|---|---|---|
| `text` | 流式文本 | 追加到对话内容 |
| `character_prompts` | 四角度角色提示词 | 角色卡片 |
| `image` | 生成的角色图像 | 内联图片 |
| `scene` | 场景提示词 | 场景块 |
| `tts` | 语音音频 | 音频播放器 |
| `comfyui_workflow` | 完整 ComfyUI 工作流 JSON | 下载按钮 |
| `done` | 流结束 | 停止等待 |
### 2.6 验证结果 / Verification
| 命令 | 结果 |
|---|---|
| `npm install` | ✅ |
| `npm run typecheck:web` | ✅ 0 error |
---
## 3. Gitea CI/CD Runner 部署 / Runner Deployment
### 3.1 部署信息 / Deployment Info
| 项目 | 详情 |
|---|---|
| 系统 | Alpine Linux, OpenRC |
| 部署方式 | 裸机直跑,无 DinD |
| Runner 版本 | v0.1.3 |
| 注册模式 | `:host` 标签 |
| OpenRC 服务 | `/etc/init.d/gitea-runner` |
| 代理 | `HTTP_PROXY=http://127.0.0.1:7890` |
| `NO_PROXY` | 包含 `git.boos.lat` |
| 持久化 | `ephemeral: false` |
### 3.2 已知限制 / Known Limitations
- 不支持 Docker in Docker;CI 使用 `runs-on: host`
- 无原生 artifact 存储;构建产物需手动备份
- `actions/checkout` 偶发 timeout;可重试或使用本地 git 镜像
---
## 4. Notification Service
### 4.1 支持通道 / Supported Channels
| 通道 | 配置前缀 | 协议 |
|---|---|---|
| Telegram | `telegram*` | Bot API |
| 企业微信 | `wecom*` | Webhook |
| 飞书 | `feishu*` | Webhook |
| 通用 Webhook | `webhook*` | JSON POST |
| Bark | `bark*` | HTTP, iOS |
| Server酱 | `serverchan*` | HTTP |
| SMTP | `smtp*` | Email |
### 4.2 Telegram 消息格式
- 纯文本,不启用 HTML/Markdown 解析
- 单条最大 3900 字符,超长截断 + `...(truncated)`
- 支持 Telegram Topics(`message_thread_id`)
### 4.3 节流机制 / Throttling
- 基于通知签名的全局冷静期:`notifyCooldownSec`
- 同一签名在冷静期内重复触发时合并为一次
- 冷静期为全局共享,不按通道独立
---
## 5. 部署文档完善 / Deployment Documentation
Commit: `5bd847d`
对 `docs/deployment.md` 全量重写,修复所有 `metapi` 残留:
- 完整 `docker-compose.yml` 示例(含 healthcheck)
- `.env` 变量插值模式
- 全部环境变量参考表(20+ 变量)
- 扩展配置:外部 MySQL/PostgreSQL、Redis、资源限制
- 生产部署检查清单
- Nginx 反向代理(WebSocket + SSE)
- 管理后台 IP 白名单限制示例
- Docker run 常用选项表
- 升级/回滚操作流程
---
## 6. 待办事项 / TODO
| 优先级 | 事项 | 说明 |
|---|---|---|
| High | 服务未运行 | CI 崩溃后容器未恢复,需确定 Docker vs `npm run dev` |
| Medium | Telegram 格式优化 | 待确认 HTML/Markdown/简化 |
| Medium | CI pipeline 稳定性 | Docker 镜像拉取超时,需排查网络 |
@@ -0,0 +1,340 @@
# 内置 ComfyUI 视频助手 + SaaS 多租户方案
> 生成: gpt-5.5 via api.boos.lat | 2026-05-18
---
## 1. 数据表设计 (SQLite migrations)
### 1.1 `tenants` 租户
```sql
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
owner_user_id TEXT NOT NULL,
settings TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
### 1.2 `tenant_members` 租户成员
```sql
CREATE TABLE tenant_members (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(tenant_id, user_id)
);
```
### 1.3 `subscriptions` 订阅与额度
```sql
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
plan TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
monthly_generations_limit INTEGER NOT NULL DEFAULT 100,
monthly_video_seconds_limit INTEGER NOT NULL DEFAULT 300,
storage_bytes_limit INTEGER NOT NULL DEFAULT 5368709120,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
### 1.4 `usage_counters` 用量统计
```sql
CREATE TABLE usage_counters (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
period TEXT NOT NULL,
generations_used INTEGER NOT NULL DEFAULT 0,
video_seconds_used INTEGER NOT NULL DEFAULT 0,
storage_bytes_used INTEGER NOT NULL DEFAULT 0,
ai_tokens_used INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(tenant_id, period)
);
```
### 1.5 `comfy_workflows` 工作流模板
```sql
CREATE TABLE comfy_workflows (
id TEXT PRIMARY KEY,
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'video',
visibility TEXT NOT NULL DEFAULT 'private',
workflow_json TEXT NOT NULL,
input_schema TEXT NOT NULL DEFAULT '{}',
version INTEGER NOT NULL DEFAULT 1,
is_default INTEGER NOT NULL DEFAULT 0,
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
### 1.6 `video_jobs` 视频任务
```sql
CREATE TABLE video_jobs (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id TEXT,
workflow_id TEXT REFERENCES comfy_workflows(id),
status TEXT NOT NULL DEFAULT 'queued',
priority INTEGER NOT NULL DEFAULT 100,
prompt TEXT,
negative_prompt TEXT,
params TEXT NOT NULL DEFAULT '{}',
comfy_prompt_id TEXT,
progress REAL NOT NULL DEFAULT 0,
error_message TEXT,
result_asset_id TEXT,
estimated_video_seconds INTEGER DEFAULT 0,
actual_video_seconds INTEGER DEFAULT 0,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
### 1.7 `assets` 文件资产
```sql
CREATE TABLE assets (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id TEXT,
job_id TEXT REFERENCES video_jobs(id) ON DELETE SET NULL,
type TEXT NOT NULL,
mime_type TEXT,
file_name TEXT,
storage_path TEXT NOT NULL,
public_url TEXT,
size_bytes INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
---
## 2. API 路由设计
### 租户管理
```
POST /api/tenants → 创建租户
GET /api/tenants → 我的租户列表
GET /api/tenants/:id → 租户详情
PATCH /api/tenants/:id → 更新租户
```
### 成员管理
```
GET /api/tenants/:id/members → 成员列表
POST /api/tenants/:id/members/invite → 邀请成员
DELETE /api/tenants/:id/members/:uid → 移除成员
PATCH /api/tenants/:id/members/:uid/role → 修改角色
```
### 视频助手
```
POST /api/tenants/:id/video/analyze → 解析脚本(角色/场景/对话)
POST /api/tenants/:id/video/generate → 生成视频任务
GET /api/tenants/:id/video/jobs → 任务列表
GET /api/tenants/:id/video/jobs/:jid → 任务详情
POST /api/tenants/:id/video/jobs/:jid/cancel → 取消任务
GET /api/tenants/:id/video/jobs/:jid/events → SSE 进度
```
### ComfyUI 工作流
```
GET /api/tenants/:id/workflows → 工作流模板列表
POST /api/tenants/:id/workflows → 创建模板
GET /api/tenants/:id/workflows/:wid → 模板详情
DELETE /api/tenants/:id/workflows/:wid → 删除模板
```
### 资产生成(ai Helper)
```
POST /api/tenants/:id/ai/character → gpt-image-2 生成角色四视图
POST /api/tenants/:id/ai/scene → 生成场景图
POST /api/tenants/:id/ai/tts → TTS 语音
POST /api/tenants/:id/ai/prompt-optimize → 优化视频 prompt
```
### 用量与订阅
```
GET /api/tenants/:id/usage → 当前用量
GET /api/tenants/:id/subscription → 订阅信息
```
---
## 3. 前端页面/组件结构
```
src/web/pages/
├── VideoAssistant.tsx ← 视频助手主页面(prompt→生成→预览)
├── VideoJobs.tsx ← 历史任务列表
├── WorkflowLibrary.tsx ← 工作流模板库
├── TenantSettings.tsx ← 租户设置
└── TenantMembers.tsx ← 成员管理
src/web/components/
├── video/
│ ├── ScriptEditor.tsx ← 自然语言脚本编辑
│ ├── ScriptPreview.tsx ← 脚本解析预览(角色/场景/对话)
│ ├── CharacterCard.tsx ← 角色四视图卡片
│ ├── SceneCard.tsx ← 场景图卡片
│ ├── TTSPlayer.tsx ← TTS 语音播放
│ ├── WorkflowExport.tsx ← ComfyUI 工作流导出
│ └── VideoPlayer.tsx ← 视频播放/下载
├── tenant/
│ ├── TenantSwitcher.tsx ← 租户切换
│ ├── MemberList.tsx ← 成员列表
│ ├── InviteDialog.tsx ← 邀请对话框
│ └── UsageQuota.tsx ← 用量/配额显示
```
---
## 4. 核心业务流程
### 注册 → 创作 → 导出
```
1. 注册/登录
2. 自动创建个人租户(或个人空间)
3. 进入视频助手页面
4. 输入自然语言脚本
5. 系统解析 → 提取角色/场景/对话
6. 自动调用 AI 生成:
- 【CHARACTER】→ gpt-image-2 四视图
- 【SCENE】 → 场景图
- 【TTS】 → 语音
- 【WORKFLOW】 → ComfyUI 工作流 JSON
7. 预览所有生成内容
8. 导出/下载/提交 ComfyUI 执行
9. 视频生成 → 预览 → 下载
如需协作: → 创建组织 → 邀请成员 → 设置角色
```
### 租户管理流程
```
创建组织
→ 成为 owner
→ 可创建 API Key
→ 可邀请成员(邮箱/链接)
→ 角色: owner / admin / member
→ 每个成员有独立用量跟踪
套餐限制:
free: 50 次/月, 5GB 存储, 基础功能
pro: 500 次/月, 50GB, 全功能+更高配额
custom: 定制
```
---
## 5. 文件变更清单
### 新增文件
**后端 Rust:**
```
src/routes/tenants/mod.rs → 租户 CRUD
src/routes/tenants/{handlers,types}.rs
src/routes/video_jobs/ → 视频任务管理
src/routes/video_jobs/{handlers,sse}.rs
src/routes/ai_assist.rs → AI 辅助生成(角色/场景/TTS/prompt)
src/routes/subscriptions.rs → 订阅/用量
src/services/tenant_service.rs → 租户业务逻辑
src/services/video_service.rs → 视频任务调度
src/services/usage_service.rs → 用量统计
src/services/comfy_client.rs → ComfyUI API 客户端
src/workers/video_worker.rs → 后台视频任务 Worker
src/middleware/tenant_context.rs → 租户上下文中间件
migrations/0010_tenants.sql
migrations/0011_tenant_members.sql
migrations/0012_subscriptions.sql
migrations/0013_usage_counters.sql
migrations/0014_comfy_workflows.sql
migrations/0015_video_jobs.sql
migrations/0016_assets.sql
```
**前端:**
```
src/web/pages/VideoAssistant.tsx
src/web/pages/VideoJobs.tsx
src/web/pages/WorkflowLibrary.tsx
src/web/pages/TenantSettings.tsx
src/web/pages/TenantMembers.tsx
src/web/components/video/*.tsx
src/web/components/tenant/*.tsx
src/web/hooks/useVideoJobs.ts
src/web/hooks/useTenant.ts
src/web/hooks/useAssets.ts
src/web/hooks/useUsage.ts
```
### 修改文件
```
src/routes/mod.rs → 注册新路由
src/web/App.tsx → 添加导航项和路由
src/routes/video_agent.rs → 增强现有 Agent SSE
src/services/mod.rs → 注册新服务
src/config.rs → 添加 ComfyUI/套餐配置
src/main.rs → 注册 Worker
src/db/mod.rs → 新增表类型
```
---
## 6. 实施步骤(按优先级)
### P0 - 基础链路
1. migrations: tenants + video_jobs + assets 三张核心表
2. 租户上下文中间件
3. 视频助手 Agent SSE 增强 → 脚本解析 + AI 生成调用
4. 前端 VideoAssistant 页面(脚本输入→解析→预览→导出)
5. ComfyUI client → 提交 prompt + 获取结果
### P1 - 多租户
6. 租户 CRUD + 成员管理 + 邀请
7. 资源 tenant_id 隔离
8. 用量统计 + 套餐限制
9. API Key 管理
10. 顶栏租户切换 + 用量显示
### P2 - 生产增强
11. SSE 实时进度
12. Worker 后台任务队列
13. 视频任务历史 + 重试
14. ComfyUI 工作流模板库
15. AI prompt 优化(调用 gpt-5.5)
### P3 - 商业化
16. 订阅管理(Stripe/内部)
17. 多 ComfyUI 节点调度
18. 对象存储集成
File diff suppressed because it is too large Load Diff
+6055
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
services:
- type: web
name: boosapi
runtime: docker
repo: https://github.com/cita-777/boosapi
dockerfilePath: ./docker/Dockerfile
dockerContext: .
plan: free
envVars:
- key: AUTH_TOKEN
sync: false
- key: PROXY_TOKEN
sync: false
- key: TZ
value: Asia/Shanghai
- key: PORT
value: "4000"
- key: DATA_DIR
value: /app/data
- key: NODE_ENV
value: production
- key: DB_TYPE
value: mysql
- key: DB_URL
sync: false
- key: DB_SSL
value: "true"
- key: CHECKIN_CRON
value: "0 8 * * *"
- key: BALANCE_REFRESH_CRON
value: "0 * * * *"
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import fs from 'fs/promises';
const DEFAULT_PACKS = [
['ComfyUI-Manager', 'https://github.com/Comfy-Org/ComfyUI-Manager.git'],
['christian-byrne/audio-separation-nodes-comfyui', 'https://github.com/christian-byrne/audio-separation-nodes-comfyui.git'],
['yvann-ba/ComfyUI_Yvann-Nodes', 'https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git'],
['yolain/ComfyUI-Easy-Use', 'https://github.com/yolain/ComfyUI-Easy-Use.git'],
['wildminder/ComfyUI-VoxCPM', 'https://github.com/wildminder/ComfyUI-VoxCPM.git'],
['kijai/ComfyUI-WanVideoWrapper', 'https://github.com/kijai/ComfyUI-WanVideoWrapper.git'],
['civen-cn/ComfyUI-Whisper-Translator', 'https://github.com/civen-cn/ComfyUI-Whisper-Translator.git'],
['kijai/ComfyUI-KJNodes', 'https://github.com/kijai/ComfyUI-KJNodes.git'],
];
const args = new Set(process.argv.slice(2));
const configPath = valueArg('--config') || 'config.toml';
const baseUrl = trimSlash(
process.env.BOOSAPI_COMFYUI__BASE_URL
|| process.env.COMFYUI_CLOUD_BASE_URL
|| (await readComfyConfig(configPath)).base_url
|| 'https://cloud.comfy.org'
);
const apiKey = process.env.BOOSAPI_COMFYUI__API_KEY
|| process.env.COMFYUI_CLOUD_API_KEY
|| (await readComfyConfig(configPath)).api_key
|| '';
const dryRun = args.has('--dry-run');
if (!baseUrl) {
fail('Missing Cloud base URL. Set BOOSAPI_COMFYUI__BASE_URL or [comfyui].base_url.');
}
if (!apiKey) {
fail('Missing Cloud API key. Set BOOSAPI_COMFYUI__API_KEY or [comfyui].api_key.');
}
console.log(`Cloud backend: ${baseUrl}`);
console.log(`Packs: ${DEFAULT_PACKS.length}`);
if (dryRun) {
for (const [id, repository] of DEFAULT_PACKS) {
console.log(`[dry-run] ${id} <- ${repository}`);
}
process.exit(0);
}
await request('GET', '/api/manager/queue/start').catch((error) => {
console.warn(`manager queue start warning: ${error.message}`);
});
const results = [];
for (const [id, repository] of DEFAULT_PACKS) {
const payload = {
id,
version: 'nightly',
selected_version: 'nightly',
repository,
mode: 'remote',
channel: 'default',
skip_post_install: false,
};
try {
await request('POST', '/api/manager/queue/install', payload);
console.log(`queued: ${id}`);
results.push({ id, ok: true });
} catch (error) {
console.error(`failed: ${id}: ${error.message}`);
results.push({ id, ok: false, error: error.message });
}
}
await request('GET', '/api/manager/queue/status')
.then((status) => console.log(`queue status: ${JSON.stringify(status)}`))
.catch((error) => console.warn(`queue status warning: ${error.message}`));
const failed = results.filter((item) => !item.ok);
if (failed.length > 0) {
console.error(`\n${failed.length} pack(s) failed to queue. If Cloud returned 403/404, the hosted backend does not allow Manager installs for this workspace/API key.`);
process.exit(1);
}
console.log('\nAll install jobs were queued. Restart the Cloud ComfyUI runtime if Manager asks for it.');
async function request(method, path, body) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'X-API-Key': apiKey,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
const data = text ? safeJson(text) : null;
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`);
}
return data;
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return { body: text };
}
}
async function readComfyConfig(path) {
const result = {};
let section = '';
let text = '';
try {
text = await fs.readFile(path, 'utf8');
} catch {
return result;
}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
section = sectionMatch[1];
continue;
}
if (section !== 'comfyui') continue;
const match = line.match(/^([A-Za-z0-9_]+)\s*=\s*"(.*)"\s*$/);
if (match) result[match[1]] = match[2];
}
return result;
}
function trimSlash(value) {
return String(value || '').trim().replace(/\/+$/, '');
}
function valueArg(name) {
const prefix = `${name}=`;
const found = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
return found ? found.slice(prefix.length) : '';
}
function fail(message) {
console.error(message);
process.exit(2);
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
COMFYUI_DIR="${COMFYUI_DIR:-/workspace/ComfyUI}"
CUSTOM_NODES_DIR="${CUSTOM_NODES_DIR:-$COMFYUI_DIR/custom_nodes}"
PYTHON_BIN="${PYTHON_BIN:-$COMFYUI_DIR/venv/bin/python}"
if [ ! -d "$COMFYUI_DIR" ]; then
echo "ComfyUI directory not found: $COMFYUI_DIR" >&2
exit 1
fi
if [ ! -x "$PYTHON_BIN" ]; then
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)"
else
echo "Python not found. Set PYTHON_BIN=/path/to/python" >&2
exit 1
fi
fi
mkdir -p "$CUSTOM_NODES_DIR"
install_node() {
local name="$1"
local repo="$2"
local ref="${3:-}"
local dir="$CUSTOM_NODES_DIR/$name"
if [ -d "$dir/.git" ]; then
echo "==> Updating $name"
git -C "$dir" fetch --depth 1 origin "${ref:-HEAD}" || git -C "$dir" fetch origin
else
echo "==> Installing $name"
git clone --depth 1 "$repo" "$dir"
fi
if [ -n "$ref" ]; then
git -C "$dir" checkout --quiet "$ref"
fi
if [ -f "$dir/requirements.txt" ]; then
echo "==> Installing Python requirements for $name"
if command -v uv >/dev/null 2>&1; then
UV_INDEX_URL="${UV_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" \
uv pip install --python "$PYTHON_BIN" -r "$dir/requirements.txt"
else
"$PYTHON_BIN" -m pip install -r "$dir/requirements.txt"
fi
fi
if [ -f "$dir/install.py" ]; then
echo "==> Running install.py for $name"
"$PYTHON_BIN" "$dir/install.py" || true
fi
}
# ComfyUI-Manager backend API. The v1.46.x cloud frontend already contains the
# Manager UI; this backend node provides /manager/* and /customnode/* endpoints
# for local/docker/external ComfyUI instances.
install_node "ComfyUI-Manager" "https://github.com/Comfy-Org/ComfyUI-Manager.git"
# Nodes used by common RunningHub digital-human / video templates.
install_node "audio-separation-nodes-comfyui" "https://github.com/christian-byrne/audio-separation-nodes-comfyui.git"
install_node "ComfyUI_Yvann-Nodes" "https://github.com/yvann-ba/ComfyUI_Yvann-Nodes.git"
install_node "ComfyUI-Easy-Use" "https://github.com/yolain/ComfyUI-Easy-Use.git"
install_node "ComfyUI-VoxCPM" "https://github.com/wildminder/ComfyUI-VoxCPM.git"
install_node "ComfyUI-WanVideoWrapper" "https://github.com/kijai/ComfyUI-WanVideoWrapper.git"
install_node "ComfyUI-Whisper-Translator" "https://github.com/civen-cn/ComfyUI-Whisper-Translator.git"
install_node "ComfyUI-KJNodes" "https://github.com/kijai/ComfyUI-KJNodes.git"
cat <<'EOF'
RunningHub template nodes installed.
Notes:
- Install system packages such as ffmpeg/libsndfile/rubberband separately if a
node requires them at runtime.
- The screenshot also references a "soundflow" package. I did not find a stable
public repository for that exact package name; install it through
ComfyUI-Manager or add its repository manually when you have the source URL.
- Missing model files, for example Wan/InfiniteTalk checkpoints, are not custom
nodes. Put those models into the matching ComfyUI models folders or use a
backend where RunningHub/Cloud already provides them.
EOF
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# Build the BossAI production binary, package Alpine/musl runtime libraries,
# and deploy the runnable bundle to a Debian server.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
ok() { echo "[$(date '+%H:%M:%S')] ✓ $*"; }
fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
REMOTE_HOST="${BOSSAI_DEBIAN_HOST:-129.211.30.104}"
REMOTE_USER="${BOSSAI_DEBIAN_USER:-root}"
REMOTE_DIR="${BOSSAI_DEBIAN_DIR:-/root/BossAI}"
SSH_KEY="${BOSSAI_DEBIAN_SSH_KEY:-$HOME/.ssh/id_rsa_mbp}"
BOSSAI_SOURCE_DIR="${BOSSAI_SOURCE_DIR:-/root/BossAI-build/main}"
BOSSAI_REMOTE="${BOSSAI_REMOTE:-bossai}"
BOSSAI_REF="${BOSSAI_REF:-main}"
BOSSAI_WORKFLOW_UI_SOURCE="${BOSSAI_WORKFLOW_UI_SOURCE:-$ROOT_DIR/vendor/ComfyUI_frontend}"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
SKIP_BUILD=0
SKIP_VERIFY=0
SKIP_SOURCE_UPDATE=0
usage() {
cat <<'EOF'
Usage: scripts/deploy-bossai-debian.sh [options]
Build BossAI from the BossAI source tree, bundle the musl runtime libraries
needed by the Alpine-built binary, upload it to a Debian host, extract it,
and run a config check.
Options:
--host <host> Debian host/IP. Default: 129.211.30.104
--user <user> SSH user. Default: root
--dir <remote-dir> Remote deploy directory. Default: /root/BossAI
--key <ssh-key> SSH private key. Default: ~/.ssh/id_rsa_mbp
--source-dir <dir> BossAI source worktree. Default: /root/BossAI-build/main
--remote <name> Git remote for BossAI. Default: bossai
--ref <ref> BossAI branch/ref to build. Default: main
--skip-build Reuse dist/bossai-production from a previous build
--skip-source-update Do not fetch/reset the BossAI source worktree
--skip-verify Skip remote ./run-boosapi.sh --check-config
-h, --help Show this help
Environment overrides:
BOSSAI_DEBIAN_HOST
BOSSAI_DEBIAN_USER
BOSSAI_DEBIAN_DIR
BOSSAI_DEBIAN_SSH_KEY
BOSSAI_SOURCE_DIR
BOSSAI_REMOTE
BOSSAI_REF
BOSSAI_WORKFLOW_UI_SOURCE
BOSSAI_PRODUCTION_DIR
BOSSAI_DEBIAN_BUNDLE_DIR
BOSSAI_DEBIAN_BUNDLE_ARCHIVE
BOOSAPI_FRONTEND_PM auto / bun / npm, forwarded to build-and-run.sh
Remote start command after deploy:
cd /root/BossAI && ./run-boosapi.sh
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--host)
shift
[ $# -gt 0 ] || fail "--host requires a value"
REMOTE_HOST="$1"
;;
--user)
shift
[ $# -gt 0 ] || fail "--user requires a value"
REMOTE_USER="$1"
;;
--dir)
shift
[ $# -gt 0 ] || fail "--dir requires a value"
REMOTE_DIR="$1"
;;
--key)
shift
[ $# -gt 0 ] || fail "--key requires a value"
SSH_KEY="$1"
;;
--source-dir)
shift
[ $# -gt 0 ] || fail "--source-dir requires a value"
BOSSAI_SOURCE_DIR="$1"
PRODUCTION_DIR="${BOSSAI_PRODUCTION_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-production}"
BUNDLE_DIR="${BOSSAI_DEBIAN_BUNDLE_DIR:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle}"
BUNDLE_ARCHIVE="${BOSSAI_DEBIAN_BUNDLE_ARCHIVE:-$BOSSAI_SOURCE_DIR/dist/bossai-debian-bundle.tar.gz}"
;;
--remote)
shift
[ $# -gt 0 ] || fail "--remote requires a value"
BOSSAI_REMOTE="$1"
;;
--ref)
shift
[ $# -gt 0 ] || fail "--ref requires a value"
BOSSAI_REF="$1"
;;
--skip-build)
SKIP_BUILD=1
;;
--skip-source-update)
SKIP_SOURCE_UPDATE=1
;;
--skip-verify)
SKIP_VERIFY=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
require_file() {
local path="$1"
[ -f "$path" ] || fail "Missing required file: $path"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"
}
ensure_bossai_source() {
require_command git
if [ "$SKIP_SOURCE_UPDATE" -eq 1 ]; then
log "Skipping BossAI source update; using $BOSSAI_SOURCE_DIR"
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
return 0
fi
log "Preparing BossAI source from $BOSSAI_REMOTE/$BOSSAI_REF → $BOSSAI_SOURCE_DIR"
git fetch "$BOSSAI_REMOTE" "$BOSSAI_REF"
if [ -d "$BOSSAI_SOURCE_DIR" ] && git -C "$BOSSAI_SOURCE_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git -C "$BOSSAI_SOURCE_DIR" reset --hard "$BOSSAI_REMOTE/$BOSSAI_REF"
git -C "$BOSSAI_SOURCE_DIR" clean -fd
else
rm -rf "$BOSSAI_SOURCE_DIR"
mkdir -p "$(dirname "$BOSSAI_SOURCE_DIR")"
git worktree add "$BOSSAI_SOURCE_DIR" "$BOSSAI_REMOTE/$BOSSAI_REF"
fi
require_file "$BOSSAI_SOURCE_DIR/build-and-run.sh"
ok "BossAI source ready: $(git -C "$BOSSAI_SOURCE_DIR" rev-parse --short HEAD)"
}
prepare_workflow_ui_source() {
if [ -n "${BOOSAPI_WORKFLOW_UI_REPO:-}" ]; then
log "Using BOOSAPI_WORKFLOW_UI_REPO=$BOOSAPI_WORKFLOW_UI_REPO"
return 0
fi
if [ ! -d "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ] && [ ! -f "$BOSSAI_WORKFLOW_UI_SOURCE/.git" ]; then
log "No local Workflow UI source at $BOSSAI_WORKFLOW_UI_SOURCE; install script may fetch from GitHub"
return 0
fi
log "Using local ComfyUI_frontend source: $BOSSAI_WORKFLOW_UI_SOURCE"
export BOOSAPI_WORKFLOW_UI_REPO="$BOSSAI_WORKFLOW_UI_SOURCE"
}
runtime_lib() {
local path="$1"
require_file "$path"
cp "$path" "$BUNDLE_DIR/lib/"
}
write_runner() {
cat > "$BUNDLE_DIR/run-boosapi.sh" <<'EOF'
#!/usr/bin/env sh
set -eu
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$DIR/lib/ld-musl-x86_64.so.1" --library-path "$DIR/lib" "$DIR/boosapi" "$@"
EOF
chmod +x "$BUNDLE_DIR/run-boosapi.sh"
}
package_bundle() {
require_file "$PRODUCTION_DIR/boosapi"
require_file "$PRODUCTION_DIR/config.toml"
rm -rf "$BUNDLE_DIR" "$BUNDLE_ARCHIVE"
mkdir -p "$BUNDLE_DIR/lib"
cp "$PRODUCTION_DIR/boosapi" "$BUNDLE_DIR/boosapi"
cp "$PRODUCTION_DIR/config.toml" "$BUNDLE_DIR/config.toml"
chmod +x "$BUNDLE_DIR/boosapi"
runtime_lib /lib/ld-musl-x86_64.so.1
runtime_lib /usr/lib/libssl.so.3
runtime_lib /usr/lib/libcrypto.so.3
runtime_lib /usr/lib/libgcc_s.so.1
write_runner
tar -C "$BUNDLE_DIR" -czf "$BUNDLE_ARCHIVE" .
ok "Bundle ready: $BUNDLE_ARCHIVE"
sha256sum "$BUNDLE_ARCHIVE" "$BUNDLE_DIR/boosapi"
ls -lh "$BUNDLE_ARCHIVE"
}
deploy_bundle() {
require_file "$SSH_KEY"
require_command ssh
require_command scp
local remote="${REMOTE_USER}@${REMOTE_HOST}"
local ssh_opts=(-i "$SSH_KEY" -o StrictHostKeyChecking=accept-new)
log "Preparing remote directory: $remote:$REMOTE_DIR"
ssh "${ssh_opts[@]}" "$remote" "mkdir -p '$REMOTE_DIR'"
log "Uploading bundle..."
scp "${ssh_opts[@]}" "$BUNDLE_ARCHIVE" "$remote:$REMOTE_DIR/bossai-debian-bundle.tar.gz"
log "Extracting bundle on Debian host..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && tar -xzf bossai-debian-bundle.tar.gz && chmod +x boosapi run-boosapi.sh && sha256sum bossai-debian-bundle.tar.gz boosapi"
if [ "$SKIP_VERIFY" -eq 0 ]; then
log "Checking remote config..."
ssh "${ssh_opts[@]}" "$remote" "cd '$REMOTE_DIR' && ./run-boosapi.sh --check-config"
fi
ok "Deployed BossAI to $remote:$REMOTE_DIR"
log "Start with: cd $REMOTE_DIR && ./run-boosapi.sh"
}
if [ "$SKIP_BUILD" -eq 0 ]; then
ensure_bossai_source
prepare_workflow_ui_source
log "Building BossAI production bundle..."
BOOSAPI_FRONTEND_PM="$FRONTEND_PM" "$BOSSAI_SOURCE_DIR/build-and-run.sh" \
--production \
--production-dir "$PRODUCTION_DIR"
else
[ -d "$BOSSAI_SOURCE_DIR" ] || fail "BossAI source directory does not exist: $BOSSAI_SOURCE_DIR"
log "Skipping build; using existing $PRODUCTION_DIR"
fi
package_bundle
deploy_bundle
+138
View File
@@ -0,0 +1,138 @@
import fs from 'fs/promises';
import path from 'path';
import { spawn } from 'child_process';
const ROOT = process.cwd();
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
const VARIANT = (process.env.BOOSAPI_WORKFLOW_UI_VARIANT || process.argv[2] || 'all').toLowerCase();
const DEFAULT_EXTERNAL_REF = 'v1.14.6';
const DEFAULT_CLOUD_REF = 'v1.46.2';
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui');
// External/RunningHub currently targets ComfyUI 0.3.27, where v1.14.x is the
// most stable match. Cloud tracks the newer ComfyUI_frontend release line.
const variants = {
external: {
ref: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_REF
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|| DEFAULT_EXTERNAL_REF,
dist: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_DIST
|| path.join(ROOT, 'dist', 'workflow-ui-external'),
description: 'External/RunningHub compatible frontend',
},
cloud: {
ref: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_REF
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|| DEFAULT_CLOUD_REF,
dist: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_DIST
|| path.join(ROOT, 'dist', 'workflow-ui-cloud'),
description: 'Comfy Cloud frontend',
},
};
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const env = { ...process.env, ...(options.env || {}) };
if (options.cleanPackageEnv) {
Object.keys(env)
.filter((key) => key.startsWith('npm_package_'))
.forEach((key) => {
delete env[key];
});
Object.assign(env, options.env || {});
}
const child = spawn(command, args, {
cwd: options.cwd || ROOT,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with ${code}`));
});
});
}
function selectedVariants() {
if (VARIANT === 'all') return Object.entries(variants);
if (variants[VARIANT]) return [[VARIANT, variants[VARIANT]]];
throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`);
}
async function readPackageInfo(dir) {
const raw = await fs.readFile(path.join(dir, 'package.json'), 'utf8');
return JSON.parse(raw);
}
async function buildVariant(name, config) {
const ref = config.ref;
const dist = config.dist;
const dir = path.join(CACHE_DIR, `${name}-${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`);
console.log(`\n==> Building workflow-ui:${name} (${config.description}) from ${ref}`);
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
await fs.rm(dir, { recursive: true, force: true });
if (REPO) {
await run('git', ['clone', '--depth', '1', '--branch', ref, REPO, dir]);
} else {
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
if (!hasSubmodule) {
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
}
await run('git', ['fetch', '--tags', 'origin', ref], { cwd: SUBMODULE_DIR });
await run('git', ['clone', '--shared', SUBMODULE_DIR, dir]);
await run('git', ['-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref], { cwd: dir });
await run('git', ['submodule', 'update', '--init', '--recursive'], { cwd: dir });
}
const packageInfo = await readPackageInfo(dir);
const packageEnv = {
npm_package_name: packageInfo.name || '',
npm_package_version: packageInfo.version || ref.replace(/^v/, ''),
};
const hasPnpmLock = await fs.access(path.join(dir, 'pnpm-lock.yaml')).then(() => true, () => false);
if (hasPnpmLock) {
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], {
cwd: dir,
cleanPackageEnv: true,
env: packageEnv,
});
} else {
await run('npm', ['ci'], { cwd: dir });
await run('npm', ['run', 'build'], {
cwd: dir,
cleanPackageEnv: true,
env: packageEnv,
});
}
await fs.rm(dist, { recursive: true, force: true });
await fs.cp(path.join(dir, 'dist'), dist, { recursive: true });
await fs.writeFile(
path.join(dist, 'boosapi-workflow-ui.json'),
`${JSON.stringify({
variant: name,
ref,
packageName: packageInfo.name || null,
packageVersion: packageInfo.version || null,
}, null, 2)}\n`,
);
console.log(`workflow-ui:${name} installed to ${dist}`);
if (name === 'cloud') {
await fs.rm(DEFAULT_DIST, { recursive: true, force: true });
await fs.cp(dist, DEFAULT_DIST, { recursive: true });
console.log(`workflow-ui default compatibility copy installed to ${DEFAULT_DIST}`);
}
}
for (const [name, config] of selectedVariants()) {
await buildVariant(name, config);
}
+2471
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use sqlx::SqlitePool;
use crate::db::models::downstream_api_key::DownstreamApiKey;
/// Result of downstream token authorization.
#[derive(Debug, Clone)]
pub enum DownstreamAuthResult {
/// Found in downstream_api_keys table.
Managed { key: Box<DownstreamApiKey> },
/// Matched the global proxy token from config.
Global { token: String },
}
/// Authorize a downstream token (sk-xxx or global proxy token).
///
/// Looks up the token in:
/// 1. downstream_api_keys table (for managed keys)
/// 2. Falls back to checking against the global proxy token
pub async fn authorize_downstream_token(
token: &str,
pool: &SqlitePool,
global_proxy_token: Option<&str>,
) -> Result<DownstreamAuthResult, String> {
if global_proxy_token
.map(|global| !global.is_empty() && token == global)
.unwrap_or(false)
{
return Ok(DownstreamAuthResult::Global {
token: token.to_string(),
});
}
// Try managed keys first
if token.starts_with("sk-") {
let key: Option<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE key = ? AND enabled = 1",
)
.bind(token)
.fetch_optional(pool)
.await
.map_err(|e| format!("Database error: {e}"))?;
if let Some(k) = key {
return Ok(DownstreamAuthResult::Managed { key: Box::new(k) });
}
return Err("Invalid API key".to_string());
}
Err("Invalid API key format".to_string())
}
/// Generate 16 random bytes for a salt using `uuid::Uuid::new_v4()`.
fn random_salt_bytes() -> [u8; 16] {
*uuid::Uuid::new_v4().as_bytes()
}
/// Hash an API key using Argon2id with a random salt.
pub fn hash_api_key(key: &str) -> anyhow::Result<String> {
let salt = SaltString::encode_b64(&random_salt_bytes())
.map_err(|e| anyhow::anyhow!("Failed to generate salt: {e}"))?;
let hash = Argon2::default()
.hash_password(key.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("Failed to hash API key: {e}"))?;
Ok(hash.to_string())
}
/// Verify an API key against an Argon2id hash.
pub fn verify_api_key(key: &str, hash: &str) -> anyhow::Result<bool> {
let parsed_hash =
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("Invalid hash format: {e}"))?;
Ok(Argon2::default()
.verify_password(key.as_bytes(), &parsed_hash)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_api_key() {
let key = "sk-test-api-key-12345";
let hash = hash_api_key(key).unwrap();
assert!(verify_api_key(key, &hash).unwrap());
}
#[test]
fn test_wrong_key_fails() {
let key = "sk-test-api-key-12345";
let hash = hash_api_key(key).unwrap();
assert!(!verify_api_key("wrong-key", &hash).unwrap());
}
#[test]
fn test_invalid_hash_fails() {
let result = verify_api_key("some-key", "not-a-valid-hash");
assert!(result.is_err());
}
#[test]
fn test_different_hashes_for_same_key() {
let key = "sk-test-key";
let hash1 = hash_api_key(key).unwrap();
let hash2 = hash_api_key(key).unwrap();
assert_ne!(hash1, hash2);
assert!(verify_api_key(key, &hash1).unwrap());
assert!(verify_api_key(key, &hash2).unwrap());
}
#[tokio::test]
async fn test_global_proxy_token_authorizes_without_db_lookup() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
let result = authorize_downstream_token("sk-global", &pool, Some("sk-global"))
.await
.unwrap();
assert!(matches!(result, DownstreamAuthResult::Global { .. }));
}
}
+70
View File
@@ -0,0 +1,70 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // user_id
pub role: String, // "admin" | "user"
pub exp: usize, // expiry timestamp
pub iat: usize, // issued at
}
pub fn generate_jwt(
secret: &str,
user_id: &str,
role: &str,
expiry_hours: i64,
) -> anyhow::Result<String> {
let now = Utc::now();
let claims = Claims {
sub: user_id.to_string(),
role: role.to_string(),
iat: now.timestamp() as usize,
exp: (now + Duration::hours(expiry_hours)).timestamp() as usize,
};
Ok(encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)?)
}
pub fn validate_jwt(secret: &str, token: &str) -> anyhow::Result<Claims> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)?;
Ok(data.claims)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_and_validate_jwt() {
let secret = "test-secret-key";
let token = generate_jwt(secret, "user-123", "admin", 24).unwrap();
let claims = validate_jwt(secret, &token).unwrap();
assert_eq!(claims.sub, "user-123");
assert_eq!(claims.role, "admin");
assert!(claims.iat > 0);
assert!(claims.exp > claims.iat);
}
#[test]
fn test_invalid_token_fails() {
let secret = "test-secret-key";
let result = validate_jwt(secret, "invalid-token");
assert!(result.is_err());
}
#[test]
fn test_wrong_secret_fails() {
let token = generate_jwt("secret1", "user-123", "user", 24).unwrap();
let result = validate_jwt("secret2", &token);
assert!(result.is_err());
}
}
+16
View File
@@ -0,0 +1,16 @@
// Auth module — JWT, API key, and password authentication
pub mod api_key;
pub mod jwt;
pub mod password;
/// Represents the authentication context for a request.
#[derive(Debug, Clone)]
pub enum AuthContext {
/// Admin token-based authentication.
Admin { token: String },
/// Authenticated user session with JWT.
User { user_id: String, role: String },
/// Downstream API key authentication.
DownstreamKey { key_id: i64, name: String },
}
+62
View File
@@ -0,0 +1,62 @@
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
/// Generate 16 random bytes for a salt using `uuid::Uuid::new_v4()`.
fn random_salt_bytes() -> [u8; 16] {
*uuid::Uuid::new_v4().as_bytes()
}
/// Hash a user password using Argon2id with a random salt.
pub fn hash_password(password: &str) -> anyhow::Result<String> {
let salt = SaltString::encode_b64(&random_salt_bytes())
.map_err(|e| anyhow::anyhow!("Failed to generate salt: {e}"))?;
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("Failed to hash password: {e}"))?;
Ok(hash.to_string())
}
/// Verify a password against an Argon2id hash.
pub fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
let parsed_hash =
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("Invalid hash format: {e}"))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_password() {
let password = "secure-password-123!";
let hash = hash_password(password).unwrap();
assert!(verify_password(password, &hash).unwrap());
}
#[test]
fn test_wrong_password_fails() {
let password = "correct-password";
let hash = hash_password(password).unwrap();
assert!(!verify_password("wrong-password", &hash).unwrap());
}
#[test]
fn test_different_hashes_for_same_password() {
let password = "my-password";
let hash1 = hash_password(password).unwrap();
let hash2 = hash_password(password).unwrap();
assert_ne!(hash1, hash2);
assert!(verify_password(password, &hash1).unwrap());
assert!(verify_password(password, &hash2).unwrap());
}
#[test]
fn test_empty_password() {
let password = "";
let hash = hash_password(password).unwrap();
assert!(verify_password(password, &hash).unwrap());
}
}
+336
View File
@@ -0,0 +1,336 @@
use figment::{
providers::{Env, Format, Toml},
Figment,
};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub server: ServerConfig,
pub db: DbConfig,
pub auth: AuthConfig,
pub proxy: ProxyConfig,
#[serde(default)]
pub comfyui: ComfyUIConfig,
#[serde(default)]
pub pixelle_video: PixelleVideoConfig,
#[serde(default)]
pub payment: PaymentConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub log_level: String,
pub cors_origins: Vec<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DbConfig {
pub url: String,
pub max_connections: u32,
pub ssl: bool,
pub runtime_db_url: Option<String>,
pub runtime_db_dialect: Option<String>,
pub runtime_db_ssl: Option<bool>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig {
pub jwt_secret: String,
pub jwt_expiry_hours: i64,
pub auth_token: String,
pub proxy_token: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ProxyConfig {
pub upstream_timeout_secs: u64,
pub first_byte_timeout_secs: u64,
pub max_retries: u32,
pub proxy_log_retention_days: u32,
#[serde(default)]
pub template_proxy_url: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ComfyUIConfig {
pub enabled: bool,
#[serde(default)]
pub backend: ComfyUIBackend,
#[serde(default)]
pub money_printer_video_cost: f64,
#[serde(default)]
pub digital_human_video_cost: f64,
#[serde(default = "default_workflow_points_cost")]
pub workflow_points_cost: f64,
#[serde(default = "default_comfyui_docker_image")]
pub docker_image: String,
#[serde(default = "default_comfyui_container_name")]
pub container_name: String,
pub install_dir: Option<PathBuf>,
pub python_bin: String,
pub port: u16,
pub extra_args: Vec<String>,
pub max_restarts: u32,
pub external_url: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub agent_model: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PixelleVideoConfig {
pub enabled: bool,
pub api_base_url: Option<String>,
pub api_key: Option<String>,
#[serde(default = "default_pixelle_backend_provider")]
pub backend_provider: String,
pub frame_template: String,
pub media_workflow: Option<String>,
pub tts_workflow: Option<String>,
pub runninghub_media_workflow: Option<String>,
pub runninghub_tts_workflow: Option<String>,
pub mode: String,
pub n_scenes: i64,
pub video_fps: i64,
pub poll_interval_secs: u64,
pub timeout_secs: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PaymentConfig {
pub enabled: bool,
#[serde(default)]
pub provider: PaymentProvider,
pub public_base_url: Option<String>,
pub product_name_prefix: String,
pub currency: String,
pub min_amount_usd: f64,
pub max_amount_usd: Option<f64>,
pub daily_limit_usd: Option<f64>,
pub order_timeout_minutes: i64,
pub max_pending_orders: i64,
pub stripe_secret_key: Option<String>,
pub stripe_webhook_secret: Option<String>,
pub comfy_customer_token: Option<String>,
pub comfy_customer_token_endpoint: Option<String>,
pub comfy_firebase_api_key: Option<String>,
pub comfy_oauth_client_id: Option<String>,
pub comfy_oauth_client_secret: Option<String>,
pub comfy_oauth_refresh_token: Option<String>,
pub comfy_customer_api_base_url: Option<String>,
pub comfy_checkout_endpoint: Option<String>,
pub success_path: String,
pub cancel_path: String,
/// When true, confirming a comfy_cloud recharge grants workflow points
/// (amount_usd * usd_to_points_rate) instead of crediting the USD pool.
#[serde(default = "default_comfy_recharge_to_points")]
pub comfy_recharge_to_points: bool,
/// Points granted per 1 USD when comfy_recharge_to_points is enabled.
#[serde(default = "default_usd_to_points_rate")]
pub usd_to_points_rate: f64,
}
#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PaymentProvider {
#[default]
ComfyCloud,
Stripe,
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "snake_case")]
pub enum ComfyUIBackend {
#[default]
Boosapi,
External,
Cloud,
Runninghub,
Docker,
}
fn default_comfyui_docker_image() -> String {
"boosapi-comfyui:multi-tenant".to_string()
}
fn default_comfyui_container_name() -> String {
"boosapi-comfyui-workflow".to_string()
}
fn default_pixelle_backend_provider() -> String {
"comfy".to_string()
}
fn default_workflow_points_cost() -> f64 {
10.0
}
fn default_comfy_recharge_to_points() -> bool {
true
}
fn default_usd_to_points_rate() -> f64 {
100.0
}
impl Default for ComfyUIConfig {
fn default() -> Self {
Self {
enabled: false,
backend: ComfyUIBackend::Boosapi,
money_printer_video_cost: 0.0,
digital_human_video_cost: 0.0,
workflow_points_cost: 10.0,
docker_image: default_comfyui_docker_image(),
container_name: default_comfyui_container_name(),
install_dir: None,
python_bin: "python3".to_string(),
port: 8188,
extra_args: Vec::new(),
max_restarts: 5,
external_url: None,
base_url: Some("http://127.0.0.1:8188".to_string()),
api_key: None,
agent_model: "gpt-4o".to_string(),
}
}
}
impl Default for PixelleVideoConfig {
fn default() -> Self {
Self {
enabled: false,
api_base_url: Some("http://127.0.0.1:8000".to_string()),
api_key: None,
backend_provider: default_pixelle_backend_provider(),
frame_template: "1080x1920/image_default.html".to_string(),
media_workflow: None,
tts_workflow: None,
runninghub_media_workflow: Some("runninghub/video_wan2.1_fusionx.json".to_string()),
runninghub_tts_workflow: Some("runninghub/tts_edge.json".to_string()),
mode: "fixed".to_string(),
n_scenes: 5,
video_fps: 30,
poll_interval_secs: 5,
timeout_secs: 900,
}
}
}
impl Default for PaymentConfig {
fn default() -> Self {
Self {
enabled: false,
provider: PaymentProvider::ComfyCloud,
public_base_url: None,
product_name_prefix: "BossAI 账户充值".to_string(),
currency: "usd".to_string(),
min_amount_usd: 1.0,
max_amount_usd: Some(100_000.0),
daily_limit_usd: None,
order_timeout_minutes: 30,
max_pending_orders: 3,
stripe_secret_key: None,
stripe_webhook_secret: None,
comfy_customer_token: None,
comfy_customer_token_endpoint: None,
comfy_firebase_api_key: None,
comfy_oauth_client_id: None,
comfy_oauth_client_secret: None,
comfy_oauth_refresh_token: None,
comfy_customer_api_base_url: None,
comfy_checkout_endpoint: None,
success_path: "/user/recharge?payment=success&order={ORDER_ID}".to_string(),
cancel_path: "/user/recharge?payment=cancel&order={ORDER_ID}".to_string(),
comfy_recharge_to_points: true,
usd_to_points_rate: 100.0,
}
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 4000,
log_level: "info".to_string(),
cors_origins: vec!["*".to_string()],
},
db: DbConfig {
url: "sqlite::memory:".to_string(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "test-secret".to_string(),
jwt_expiry_hours: 72,
auth_token: "test-admin-token".to_string(),
proxy_token: "sk-test-proxy".to_string(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 300,
first_byte_timeout_secs: 60,
max_retries: 3,
proxy_log_retention_days: 30,
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
pixelle_video: PixelleVideoConfig::default(),
payment: PaymentConfig::default(),
}
}
}
impl ComfyUIConfig {
pub fn uses_native_backend(&self) -> bool {
matches!(
self.backend,
ComfyUIBackend::External | ComfyUIBackend::Cloud | ComfyUIBackend::Runninghub
)
}
pub fn uses_cloud_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Cloud)
}
pub fn uses_runninghub_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Runninghub)
}
pub fn uses_docker_backend(&self) -> bool {
matches!(self.backend, ComfyUIBackend::Docker)
}
pub fn target_base_url(&self) -> String {
self.external_url
.as_deref()
.or(self.base_url.as_deref())
.map(str::trim)
.filter(|url| !url.is_empty())
.unwrap_or("http://127.0.0.1:8188")
.trim_end_matches('/')
.to_string()
}
}
impl AppConfig {
pub fn load() -> anyhow::Result<Self> {
Self::load_from_file("config.toml")
}
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
dotenvy::dotenv().ok();
Ok(Figment::new()
.merge(Toml::file(path))
.merge(Env::prefixed("BOOSAPI_").split("__"))
.extract()?)
}
}
+129
View File
@@ -0,0 +1,129 @@
use anyhow::{bail, Context};
use serde::Serialize;
use sqlx::{
any::{install_default_drivers, AnyPoolOptions},
postgres::{PgPool, PgPoolOptions},
sqlite::{SqlitePool, SqlitePoolOptions},
};
pub mod models;
pub mod queries;
pub mod schema_compat;
pub mod sqlite_to_postgres;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DbKind {
Sqlite,
Postgres,
}
impl DbKind {
pub fn from_url(database_url: &str) -> anyhow::Result<Self> {
let normalized = database_url.trim().to_ascii_lowercase();
if normalized.starts_with("sqlite:") {
Ok(Self::Sqlite)
} else if normalized.starts_with("postgres://") || normalized.starts_with("postgresql://") {
Ok(Self::Postgres)
} else {
bail!("unsupported database URL; expected sqlite:, postgres:// or postgresql://")
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Sqlite => "sqlite",
Self::Postgres => "postgres",
}
}
}
pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Result<SqlitePool> {
if DbKind::from_url(database_url)? != DbKind::Sqlite {
bail!(
"runtime PostgreSQL query support is not enabled yet; use migrate-sqlite-to-postgres to prepare PostgreSQL data"
);
}
let pool = SqlitePoolOptions::new()
.max_connections(max_connections)
.after_connect(|conn, _meta| {
Box::pin(async move {
sqlx::query("PRAGMA journal_mode = WAL")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA synchronous = NORMAL")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA busy_timeout = 5000")
.execute(&mut *conn)
.await?;
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&mut *conn)
.await?;
Ok(())
})
})
.connect(database_url)
.await?;
Ok(pool)
}
pub async fn create_pg_pool(database_url: &str, max_connections: u32) -> anyhow::Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await
.context("connect PostgreSQL")?;
Ok(pool)
}
pub async fn test_connection(database_url: &str, max_connections: u32) -> anyhow::Result<DbKind> {
match DbKind::from_url(database_url)? {
DbKind::Sqlite => {
SqlitePoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await
.context("connect SQLite")?
.close()
.await;
Ok(DbKind::Sqlite)
}
DbKind::Postgres => {
create_pg_pool(database_url, max_connections)
.await?
.close()
.await;
Ok(DbKind::Postgres)
}
}
}
pub async fn run_migrations(
kind: DbKind,
sqlite_pool: Option<&SqlitePool>,
pg_pool: Option<&PgPool>,
) -> anyhow::Result<()> {
match kind {
DbKind::Sqlite => {
let pool = sqlite_pool.context("SQLite pool required for SQLite migrations")?;
sqlx::migrate!("./migrations").run(pool).await?;
}
DbKind::Postgres => {
let pool = pg_pool.context("PostgreSQL pool required for PostgreSQL migrations")?;
sqlx::migrate!("./migrations_pg").run(pool).await?;
}
}
Ok(())
}
pub async fn create_any_pool(
database_url: &str,
max_connections: u32,
) -> anyhow::Result<sqlx::AnyPool> {
install_default_drivers();
Ok(AnyPoolOptions::new()
.max_connections(max_connections)
.connect(database_url)
.await?)
}
+47
View File
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Represents an account bound to a site.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Account {
pub id: i64,
pub site_id: i64,
pub username: Option<String>,
pub access_token: String,
pub api_token: Option<String>,
pub balance: Option<f64>,
pub balance_used: Option<f64>,
pub quota: Option<f64>,
pub unit_cost: Option<f64>,
pub value_score: Option<f64>,
pub status: String,
pub checkin_enabled: Option<bool>,
pub last_checkin_at: Option<String>,
pub last_balance_refresh: Option<String>,
pub extra_config: Option<String>,
pub is_pinned: Option<bool>,
pub sort_order: Option<i64>,
pub oauth_provider: Option<String>,
pub oauth_account_key: Option<String>,
pub oauth_project_id: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
/// Represents an account token (from account_tokens table).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct AccountToken {
pub id: i64,
pub account_id: i64,
pub name: String,
pub token: String,
pub source: Option<String>,
pub token_group: Option<String>,
pub value_status: String,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
+26
View File
@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Background task for tracking async operations (deployments, syncs, etc.)
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundTask {
pub id: String,
pub task_type: String,
pub title: String,
pub status: String,
pub message: Option<String>,
pub error: Option<String>,
pub dedupe_key: Option<String>,
pub success_title: Option<String>,
pub failure_title: Option<String>,
pub notify_on_success: Option<bool>,
pub notify_on_failure: Option<bool>,
pub result_json: Option<String>,
pub progress: Option<i64>,
pub progress_total: Option<i64>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub started_at: Option<String>,
pub finished_at: Option<String>,
}
+29
View File
@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Represents a downstream API key for external consumers.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct DownstreamApiKey {
pub id: i64,
pub name: String,
pub key: String,
pub description: Option<String>,
pub group_name: Option<String>,
pub tags: Option<String>,
pub enabled: Option<bool>,
pub expires_at: Option<String>,
pub max_cost: Option<f64>,
pub used_cost: Option<f64>,
pub max_requests: Option<i64>,
pub used_requests: Option<i64>,
pub supported_models: Option<String>,
pub allowed_route_ids: Option<String>,
pub site_weight_multipliers: Option<String>,
pub excluded_site_ids: Option<String>,
pub excluded_credential_refs: Option<String>,
pub last_used_at: Option<String>,
pub user_id: Option<i64>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
+19
View File
@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// System event for admin notifications.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Event {
pub id: i64,
#[sqlx(rename = "type")]
pub event_type: String,
pub title: String,
pub message: Option<String>,
pub level: Option<String>,
#[sqlx(rename = "read")]
pub is_read: Option<bool>,
pub related_id: Option<i64>,
pub related_type: Option<String>,
pub created_at: Option<String>,
}
+13
View File
@@ -0,0 +1,13 @@
// Database models
pub mod account;
pub mod background_task;
pub mod downstream_api_key;
pub mod event;
pub mod organization;
pub mod route_channel;
pub mod setting;
pub mod site;
pub mod site_announcement;
pub mod token_route;
pub mod user;
+42
View File
@@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Organization for multi-tenant setup.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Organization {
pub id: i64,
pub name: String,
pub slug: String,
pub plan: Option<String>,
pub status: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
/// Member of an organization.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct OrganizationMember {
pub id: i64,
pub org_id: i64,
pub user_id: i64,
pub role: Option<String>,
pub status: Option<String>,
pub joined_at: Option<String>,
}
/// Invitation to join an organization.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Invitation {
pub id: i64,
pub org_id: i64,
pub email: String,
pub token: String,
pub role: Option<String>,
pub invited_by: Option<i64>,
pub status: Option<String>,
pub expires_at: Option<String>,
pub created_at: Option<String>,
}
+28
View File
@@ -0,0 +1,28 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Represents a route channel linking routes, accounts, and tokens.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct RouteChannel {
pub id: i64,
pub route_id: i64,
pub account_id: i64,
pub token_id: Option<i64>,
pub oauth_route_unit_id: Option<i64>,
pub source_model: Option<String>,
pub priority: Option<i64>,
pub weight: Option<i64>,
pub enabled: Option<bool>,
pub manual_override: Option<bool>,
pub success_count: Option<i64>,
pub fail_count: Option<i64>,
pub total_latency_ms: Option<i64>,
pub total_cost: Option<f64>,
pub last_used_at: Option<String>,
pub last_selected_at: Option<String>,
pub last_fail_at: Option<String>,
pub consecutive_fail_count: i64,
pub cooldown_level: i64,
pub cooldown_until: Option<String>,
}
+9
View File
@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Represents a key-value setting.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Setting {
pub key: String,
pub value: Option<String>,
}
+27
View File
@@ -0,0 +1,27 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Represents a site (AI provider endpoint).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Site {
pub id: i64,
pub name: String,
pub url: String,
pub platform: String,
pub status: String,
pub api_key: Option<String>,
pub proxy_url: Option<String>,
pub use_system_proxy: Option<bool>,
pub custom_headers: Option<String>,
pub external_checkin_url: Option<String>,
pub global_weight: Option<f64>,
pub post_refresh_probe_enabled: Option<bool>,
pub post_refresh_probe_model: Option<String>,
pub post_refresh_probe_scope: Option<String>,
pub post_refresh_probe_latency_threshold_ms: Option<i64>,
pub is_pinned: Option<bool>,
pub sort_order: Option<i64>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
+25
View File
@@ -0,0 +1,25 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Site-level announcements (notifications from upstream providers).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct SiteAnnouncement {
pub id: i64,
pub site_id: i64,
pub platform: String,
pub source_key: String,
pub title: String,
pub content: String,
pub level: Option<String>,
pub source_url: Option<String>,
pub starts_at: Option<String>,
pub ends_at: Option<String>,
pub upstream_created_at: Option<String>,
pub upstream_updated_at: Option<String>,
pub first_seen_at: Option<String>,
pub last_seen_at: Option<String>,
pub read_at: Option<String>,
pub dismissed_at: Option<String>,
pub raw_payload: Option<String>,
}
+20
View File
@@ -0,0 +1,20 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Represents a token route (model routing rule).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct TokenRoute {
pub id: i64,
pub model_pattern: String,
pub model_mapping: Option<String>,
pub display_name: Option<String>,
pub display_icon: Option<String>,
pub decision_snapshot: Option<String>,
pub decision_refreshed_at: Option<String>,
pub routing_strategy: Option<String>,
pub route_mode: Option<String>,
pub enabled: Option<bool>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
+17
View File
@@ -0,0 +1,17 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// A platform user (admin or regular).
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: i64,
pub username: String,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub role: String,
pub status: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
+40
View File
@@ -0,0 +1,40 @@
use crate::{
db::models::{downstream_api_key::DownstreamApiKey, user::User},
error::AppResult,
};
use sqlx::SqlitePool;
pub async fn find_user_by_id(db: &SqlitePool, id: i64) -> AppResult<Option<User>> {
sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_optional(db)
.await
.map_err(Into::into)
}
pub async fn find_user_by_email(db: &SqlitePool, email: &str) -> AppResult<Option<User>> {
sqlx::query_as("SELECT * FROM users WHERE email = ?")
.bind(email)
.fetch_optional(db)
.await
.map_err(Into::into)
}
pub async fn list_user_api_keys(db: &SqlitePool, user_id: i64) -> AppResult<Vec<DownstreamApiKey>> {
sqlx::query_as("SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC")
.bind(user_id)
.fetch_all(db)
.await
.map_err(Into::into)
}
pub async fn find_downstream_key_by_token(
db: &SqlitePool,
token: &str,
) -> AppResult<Option<DownstreamApiKey>> {
sqlx::query_as("SELECT * FROM downstream_api_keys WHERE key = ?")
.bind(token)
.fetch_optional(db)
.await
.map_err(Into::into)
}
+383
View File
@@ -0,0 +1,383 @@
use anyhow::Context;
use sqlx::{Row, SqlitePool};
use std::collections::HashSet;
pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<()> {
ensure_columns(
pool,
"sites",
&[
("post_refresh_probe_enabled", "INTEGER DEFAULT false"),
("post_refresh_probe_model", "TEXT DEFAULT ''"),
("post_refresh_probe_scope", "TEXT DEFAULT 'single'"),
(
"post_refresh_probe_latency_threshold_ms",
"INTEGER DEFAULT 0",
),
],
)
.await?;
ensure_columns(
pool,
"downstream_api_keys",
&[
("excluded_site_ids", "TEXT"),
("excluded_credential_refs", "TEXT"),
],
)
.await?;
ensure_columns(
pool,
"route_channels",
&[("oauth_route_unit_id", "INTEGER")],
)
.await?;
ensure_columns(
pool,
"proxy_logs",
&[
("client_family", "TEXT"),
("client_app_id", "TEXT"),
("client_app_name", "TEXT"),
("client_confidence", "TEXT"),
("is_stream", "INTEGER"),
("first_byte_latency_ms", "INTEGER"),
("billing_details", "TEXT"),
("content_summary", "TEXT"),
("request_content", "TEXT"),
("response_content", "TEXT"),
],
)
.await?;
run_compat_ddl(pool).await?;
Ok(())
}
async fn ensure_columns(
pool: &SqlitePool,
table: &str,
columns: &[(&str, &str)],
) -> anyhow::Result<()> {
let existing = table_columns(pool, table).await?;
if existing.is_empty() {
return Ok(());
}
for (name, definition) in columns {
if existing.contains(*name) {
continue;
}
let sql = format!("ALTER TABLE {table} ADD {name} {definition}");
sqlx::query(&sql)
.execute(pool)
.await
.with_context(|| format!("add column {table}.{name}"))?;
}
Ok(())
}
async fn table_columns(pool: &SqlitePool, table: &str) -> anyhow::Result<HashSet<String>> {
let sql = format!("PRAGMA table_info({table})");
let rows = sqlx::query(&sql).fetch_all(pool).await?;
Ok(rows
.into_iter()
.filter_map(|row| row.try_get::<String, _>("name").ok())
.collect())
}
async fn run_compat_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
for statement in SQLITE_COMPAT_DDL {
sqlx::query(statement)
.execute(pool)
.await
.with_context(|| format!("run SQLite compat DDL: {statement}"))?;
}
Ok(())
}
const SQLITE_COMPAT_DDL: &[&str] = &[
"CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs (client_app_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs (client_family, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
ON proxy_logs (downstream_api_key_id, created_at DESC, status)",
"CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC)",
"CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
ON proxy_logs (content_summary)",
"CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
ON workspace_assets (user_id, status, deleted_at, source, kind, created_at DESC)",
"CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
ON workspace_assets (json_extract(metadata, '$.workspaceAsset.role'), created_at DESC)",
"CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
ON workspace_assets (json_extract(metadata, '$.workflowUi.type'), created_at DESC)",
"CREATE TABLE IF NOT EXISTS workspace_asset_daily (
local_day TEXT NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL,
kind TEXT NOT NULL,
asset_count INTEGER NOT NULL DEFAULT 0,
local_file_count INTEGER NOT NULL DEFAULT 0,
total_size_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(local_day, user_id, source, kind)
)",
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
ON workspace_asset_daily (user_id, local_day DESC)",
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
ON workspace_asset_daily (source, local_day DESC)",
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
ON route_channels (oauth_route_unit_id)",
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT true,
sort_order INTEGER DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
last_failed_at TEXT,
last_failure_reason TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
ON site_api_endpoints (site_id, url)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
ON site_api_endpoints (site_id, enabled, sort_order)",
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
ON site_api_endpoints (site_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS proxy_debug_traces (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
downstream_path TEXT NOT NULL,
client_kind TEXT,
session_id TEXT,
trace_hint TEXT,
requested_model TEXT,
downstream_api_key_id INTEGER,
request_headers_json TEXT,
request_body_json TEXT,
sticky_session_key TEXT,
sticky_hit_channel_id INTEGER,
selected_channel_id INTEGER,
selected_route_id INTEGER,
selected_account_id INTEGER,
selected_site_id INTEGER,
selected_site_platform TEXT,
endpoint_candidates_json TEXT,
endpoint_runtime_state_json TEXT,
decision_summary_json TEXT,
final_status TEXT,
final_http_status INTEGER,
final_upstream_path TEXT,
final_response_headers_json TEXT,
final_response_body_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
ON proxy_debug_traces (created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
ON proxy_debug_traces (session_id, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
ON proxy_debug_traces (requested_model, created_at)",
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
ON proxy_debug_traces (final_status, created_at)",
"CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
trace_id INTEGER NOT NULL,
attempt_index INTEGER NOT NULL,
endpoint TEXT NOT NULL,
request_path TEXT NOT NULL,
target_url TEXT NOT NULL,
runtime_executor TEXT,
request_headers_json TEXT,
request_body_json TEXT,
response_status INTEGER,
response_headers_json TEXT,
response_body_json TEXT,
raw_error_text TEXT,
recover_applied INTEGER DEFAULT false,
downgrade_decision INTEGER DEFAULT false,
downgrade_reason TEXT,
memory_write_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
ON proxy_debug_attempts (trace_id, attempt_index)",
"CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
ON proxy_debug_attempts (trace_id, created_at)",
"CREATE TABLE IF NOT EXISTS oauth_route_units (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
site_id INTEGER NOT NULL,
provider TEXT NOT NULL,
name TEXT NOT NULL,
strategy TEXT NOT NULL DEFAULT 'round_robin',
enabled INTEGER DEFAULT true,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
ON oauth_route_units (site_id, provider)",
"CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
ON oauth_route_units (enabled)",
"CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
unit_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
sort_order INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_selected_at TEXT,
last_fail_at TEXT,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
ON oauth_route_unit_members (unit_id, account_id)",
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
ON oauth_route_unit_members (account_id)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
ON oauth_route_unit_members (unit_id, sort_order)",
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
ON oauth_route_unit_members (unit_id, cooldown_until)",
"CREATE TABLE IF NOT EXISTS admin_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
namespace TEXT NOT NULL,
snapshot_key TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
stale_until TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
ON admin_snapshots (namespace, snapshot_key)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
ON admin_snapshots (expires_at)",
"CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
ON admin_snapshots (stale_until)",
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
projector_key TEXT PRIMARY KEY NOT NULL,
time_zone TEXT NOT NULL DEFAULT 'Local',
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
watermark_created_at TEXT,
recompute_from_id INTEGER,
recompute_requested_at TEXT,
recompute_reason TEXT,
recompute_started_at TEXT,
recompute_completed_at TEXT,
last_projected_at TEXT,
last_successful_at TEXT,
last_error TEXT,
lease_owner TEXT,
lease_token TEXT,
lease_expires_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
ON analytics_projection_checkpoints (recompute_from_id)",
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
ON analytics_projection_checkpoints (lease_expires_at)",
"CREATE TABLE IF NOT EXISTS model_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
model TEXT NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_spend >= 0
AND total_latency_ms >= 0 AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
ON model_day_usage (local_day, site_id, model)",
"CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
ON model_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
ON model_day_usage (site_id)",
"CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
ON model_day_usage (model)",
"CREATE TABLE IF NOT EXISTS site_day_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
local_day TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
ON site_day_usage (local_day, site_id)",
"CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
ON site_day_usage (local_day)",
"CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
ON site_day_usage (site_id)",
"CREATE TABLE IF NOT EXISTS site_hour_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
bucket_start_utc TEXT NOT NULL,
site_id INTEGER NOT NULL,
total_calls INTEGER NOT NULL DEFAULT 0,
success_calls INTEGER NOT NULL DEFAULT 0,
failed_calls INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_summary_spend REAL NOT NULL DEFAULT 0,
total_site_spend REAL NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
latency_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
CHECK (
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
AND total_tokens >= 0 AND total_summary_spend >= 0
AND total_site_spend >= 0 AND total_latency_ms >= 0
AND latency_count >= 0
)
)",
"CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
ON site_hour_usage (bucket_start_utc, site_id)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
ON site_hour_usage (bucket_start_utc)",
"CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
ON site_hour_usage (site_id)",
];
+657
View File
@@ -0,0 +1,657 @@
use anyhow::{bail, Context};
use serde::Serialize;
use sqlx::{
postgres::PgPool,
sqlite::{SqlitePool, SqliteRow},
Row,
};
use std::collections::HashMap;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MigrationSummary {
pub tables: Vec<TableMigrationSummary>,
pub total_rows: i64,
pub force_truncate: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableMigrationSummary {
pub table: &'static str,
pub rows: i64,
}
#[derive(Debug, Clone, Copy)]
enum ColumnKind {
I64,
F64,
Bool,
Text,
}
#[derive(Debug, Clone, Copy)]
struct ColumnSpec {
name: &'static str,
kind: ColumnKind,
}
#[derive(Debug, Clone, Copy)]
struct TableSpec {
name: &'static str,
columns: &'static [ColumnSpec],
}
macro_rules! col {
($name:literal, i64) => {
ColumnSpec {
name: $name,
kind: ColumnKind::I64,
}
};
($name:literal, f64) => {
ColumnSpec {
name: $name,
kind: ColumnKind::F64,
}
};
($name:literal, bool) => {
ColumnSpec {
name: $name,
kind: ColumnKind::Bool,
}
};
($name:literal, text) => {
ColumnSpec {
name: $name,
kind: ColumnKind::Text,
}
};
}
const TABLES: &[TableSpec] = &[
TableSpec {
name: "users",
columns: &[
col!("id", i64),
col!("username", text),
col!("email", text),
col!("password_hash", text),
col!("role", text),
col!("status", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "sites",
columns: &[
col!("id", i64),
col!("name", text),
col!("url", text),
col!("platform", text),
col!("status", text),
col!("api_key", text),
col!("proxy_url", text),
col!("use_system_proxy", bool),
col!("custom_headers", text),
col!("external_checkin_url", text),
col!("global_weight", f64),
col!("post_refresh_probe_enabled", bool),
col!("post_refresh_probe_model", text),
col!("post_refresh_probe_scope", text),
col!("post_refresh_probe_latency_threshold_ms", i64),
col!("is_pinned", bool),
col!("sort_order", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "accounts",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("username", text),
col!("access_token", text),
col!("api_token", text),
col!("balance", f64),
col!("balance_used", f64),
col!("quota", f64),
col!("unit_cost", f64),
col!("value_score", f64),
col!("status", text),
col!("checkin_enabled", bool),
col!("last_checkin_at", text),
col!("last_balance_refresh", text),
col!("extra_config", text),
col!("is_pinned", bool),
col!("sort_order", i64),
col!("oauth_provider", text),
col!("oauth_account_key", text),
col!("oauth_project_id", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "account_tokens",
columns: &[
col!("id", i64),
col!("account_id", i64),
col!("name", text),
col!("token", text),
col!("source", text),
col!("token_group", text),
col!("value_status", text),
col!("enabled", bool),
col!("is_default", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "downstream_api_keys",
columns: &[
col!("id", i64),
col!("name", text),
col!("key", text),
col!("description", text),
col!("group_name", text),
col!("tags", text),
col!("enabled", bool),
col!("expires_at", text),
col!("max_cost", f64),
col!("used_cost", f64),
col!("max_requests", i64),
col!("used_requests", i64),
col!("supported_models", text),
col!("allowed_route_ids", text),
col!("site_weight_multipliers", text),
col!("excluded_site_ids", text),
col!("excluded_credential_refs", text),
col!("last_used_at", text),
col!("user_id", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "token_routes",
columns: &[
col!("id", i64),
col!("model_pattern", text),
col!("model_mapping", text),
col!("display_name", text),
col!("display_icon", text),
col!("decision_snapshot", text),
col!("decision_refreshed_at", text),
col!("routing_strategy", text),
col!("route_mode", text),
col!("enabled", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "route_channels",
columns: &[
col!("id", i64),
col!("route_id", i64),
col!("account_id", i64),
col!("token_id", i64),
col!("oauth_route_unit_id", i64),
col!("source_model", text),
col!("priority", i64),
col!("weight", i64),
col!("enabled", bool),
col!("manual_override", bool),
col!("success_count", i64),
col!("fail_count", i64),
col!("total_latency_ms", i64),
col!("total_cost", f64),
col!("last_used_at", text),
col!("last_selected_at", text),
col!("last_fail_at", text),
col!("consecutive_fail_count", i64),
col!("cooldown_level", i64),
col!("cooldown_until", text),
],
},
TableSpec {
name: "proxy_logs",
columns: &[
col!("id", i64),
col!("route_id", i64),
col!("channel_id", i64),
col!("account_id", i64),
col!("downstream_api_key_id", i64),
col!("model_requested", text),
col!("model_actual", text),
col!("status", text),
col!("http_status", i64),
col!("latency_ms", i64),
col!("first_byte_latency_ms", i64),
col!("prompt_tokens", i64),
col!("completion_tokens", i64),
col!("total_tokens", i64),
col!("estimated_cost", f64),
col!("error_message", text),
col!("retry_count", i64),
col!("is_stream", bool),
col!("client_family", text),
col!("client_app_id", text),
col!("client_app_name", text),
col!("client_confidence", text),
col!("billing_details", text),
col!("created_at", text),
],
},
TableSpec {
name: "settings",
columns: &[col!("key", text), col!("value", text)],
},
TableSpec {
name: "workspace_assets",
columns: &[
col!("id", i64),
col!("public_id", text),
col!("user_id", i64),
col!("source", text),
col!("kind", text),
col!("title", text),
col!("prompt", text),
col!("model", text),
col!("status", text),
col!("mime_type", text),
col!("file_name", text),
col!("file_path", text),
col!("remote_url", text),
col!("thumbnail_path", text),
col!("size_bytes", i64),
col!("metadata", text),
col!("created_at", text),
col!("updated_at", text),
col!("deleted_at", text),
],
},
TableSpec {
name: "user_announcements",
columns: &[
col!("id", i64),
col!("title", text),
col!("content", text),
col!("level", text),
col!("status", text),
col!("audience", text),
col!("starts_at", text),
col!("ends_at", text),
col!("created_at", text),
col!("updated_at", text),
col!("published_at", text),
],
},
TableSpec {
name: "user_announcement_reads",
columns: &[
col!("announcement_id", i64),
col!("user_id", i64),
col!("read_at", text),
],
},
TableSpec {
name: "site_api_endpoints",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("url", text),
col!("enabled", bool),
col!("sort_order", i64),
col!("cooldown_until", text),
col!("last_selected_at", text),
col!("last_failed_at", text),
col!("last_failure_reason", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_units",
columns: &[
col!("id", i64),
col!("site_id", i64),
col!("provider", text),
col!("name", text),
col!("strategy", text),
col!("enabled", bool),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "oauth_route_unit_members",
columns: &[
col!("id", i64),
col!("unit_id", i64),
col!("account_id", i64),
col!("sort_order", i64),
col!("success_count", i64),
col!("fail_count", i64),
col!("total_latency_ms", i64),
col!("total_cost", f64),
col!("last_used_at", text),
col!("last_selected_at", text),
col!("last_fail_at", text),
col!("consecutive_fail_count", i64),
col!("cooldown_level", i64),
col!("cooldown_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_traces",
columns: &[
col!("id", i64),
col!("downstream_path", text),
col!("client_kind", text),
col!("session_id", text),
col!("trace_hint", text),
col!("requested_model", text),
col!("downstream_api_key_id", i64),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("sticky_session_key", text),
col!("sticky_hit_channel_id", i64),
col!("selected_channel_id", i64),
col!("selected_route_id", i64),
col!("selected_account_id", i64),
col!("selected_site_id", i64),
col!("selected_site_platform", text),
col!("endpoint_candidates_json", text),
col!("endpoint_runtime_state_json", text),
col!("decision_summary_json", text),
col!("final_status", text),
col!("final_http_status", i64),
col!("final_upstream_path", text),
col!("final_response_headers_json", text),
col!("final_response_body_json", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "proxy_debug_attempts",
columns: &[
col!("id", i64),
col!("trace_id", i64),
col!("attempt_index", i64),
col!("endpoint", text),
col!("request_path", text),
col!("target_url", text),
col!("runtime_executor", text),
col!("request_headers_json", text),
col!("request_body_json", text),
col!("response_status", i64),
col!("response_headers_json", text),
col!("response_body_json", text),
col!("raw_error_text", text),
col!("recover_applied", bool),
col!("downgrade_decision", bool),
col!("downgrade_reason", text),
col!("memory_write_json", text),
col!("created_at", text),
],
},
TableSpec {
name: "admin_snapshots",
columns: &[
col!("id", i64),
col!("namespace", text),
col!("snapshot_key", text),
col!("payload", text),
col!("generated_at", text),
col!("expires_at", text),
col!("stale_until", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "analytics_projection_checkpoints",
columns: &[
col!("projector_key", text),
col!("time_zone", text),
col!("last_proxy_log_id", i64),
col!("watermark_created_at", text),
col!("recompute_from_id", i64),
col!("recompute_requested_at", text),
col!("recompute_reason", text),
col!("recompute_started_at", text),
col!("recompute_completed_at", text),
col!("last_projected_at", text),
col!("last_successful_at", text),
col!("last_error", text),
col!("lease_owner", text),
col!("lease_token", text),
col!("lease_expires_at", text),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "model_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("model", text),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_day_usage",
columns: &[
col!("id", i64),
col!("local_day", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
TableSpec {
name: "site_hour_usage",
columns: &[
col!("id", i64),
col!("bucket_start_utc", text),
col!("site_id", i64),
col!("total_calls", i64),
col!("success_calls", i64),
col!("failed_calls", i64),
col!("total_tokens", i64),
col!("total_summary_spend", f64),
col!("total_site_spend", f64),
col!("total_latency_ms", i64),
col!("latency_count", i64),
col!("created_at", text),
col!("updated_at", text),
],
},
];
pub async fn migrate(
sqlite: &SqlitePool,
pg: &PgPool,
force_truncate: bool,
) -> anyhow::Result<MigrationSummary> {
ensure_target_is_empty(pg, force_truncate).await?;
if force_truncate {
truncate_target(pg).await?;
}
let mut summaries = Vec::new();
let mut total_rows = 0;
for table in TABLES {
let rows = copy_table(sqlite, pg, table).await?;
summaries.push(TableMigrationSummary {
table: table.name,
rows,
});
total_rows += rows;
}
sync_sequences(pg).await?;
Ok(MigrationSummary {
tables: summaries,
total_rows,
force_truncate,
})
}
async fn ensure_target_is_empty(pg: &PgPool, force_truncate: bool) -> anyhow::Result<()> {
if force_truncate {
return Ok(());
}
let mut non_empty = Vec::new();
for table in TABLES {
let sql = format!("SELECT COUNT(*) FROM {}", table.name);
let count: i64 = sqlx::query_scalar(&sql).fetch_one(pg).await.unwrap_or(0);
if count > 0 {
non_empty.push(format!("{} ({})", table.name, count));
}
}
if !non_empty.is_empty() {
bail!(
"target PostgreSQL database is not empty: {}; rerun with --force-truncate to overwrite",
non_empty.join(", ")
);
}
Ok(())
}
async fn truncate_target(pg: &PgPool) -> anyhow::Result<()> {
let table_names = TABLES
.iter()
.rev()
.map(|table| table.name)
.collect::<Vec<_>>()
.join(", ");
let sql = format!("TRUNCATE TABLE {} RESTART IDENTITY CASCADE", table_names);
sqlx::query(&sql).execute(pg).await?;
Ok(())
}
async fn copy_table(sqlite: &SqlitePool, pg: &PgPool, table: &TableSpec) -> anyhow::Result<i64> {
let available = sqlite_columns(sqlite, table.name).await?;
let columns = table
.columns
.iter()
.copied()
.filter(|column| available.contains_key(column.name))
.collect::<Vec<_>>();
if columns.is_empty() {
return Ok(0);
}
let select_sql = format!(
"SELECT {} FROM {} ORDER BY {}",
columns
.iter()
.map(|column| column.name)
.collect::<Vec<_>>()
.join(", "),
table.name,
if table.name == "settings" {
"key"
} else {
columns[0].name
}
);
let rows = sqlx::query(&select_sql).fetch_all(sqlite).await?;
let insert_sql = format!(
"INSERT INTO {} ({}) VALUES ({})",
table.name,
columns
.iter()
.map(|column| quote_ident(column.name))
.collect::<Vec<_>>()
.join(", "),
(1..=columns.len())
.map(|index| format!("${index}"))
.collect::<Vec<_>>()
.join(", ")
);
for row in &rows {
let mut query = sqlx::query(&insert_sql);
for column in &columns {
query = bind_column(query, row, column)?;
}
query
.execute(pg)
.await
.with_context(|| format!("insert into {}", table.name))?;
}
Ok(rows.len() as i64)
}
fn bind_column<'q>(
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
row: &SqliteRow,
column: &ColumnSpec,
) -> anyhow::Result<sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>> {
Ok(match column.kind {
ColumnKind::I64 => query.bind(row.try_get::<Option<i64>, _>(column.name)?),
ColumnKind::F64 => query.bind(row.try_get::<Option<f64>, _>(column.name)?),
ColumnKind::Bool => query.bind(row.try_get::<Option<bool>, _>(column.name)?),
ColumnKind::Text => query.bind(row.try_get::<Option<String>, _>(column.name)?),
})
}
async fn sqlite_columns(sqlite: &SqlitePool, table: &str) -> anyhow::Result<HashMap<String, ()>> {
let rows = sqlx::query(&format!("PRAGMA table_info({table})"))
.fetch_all(sqlite)
.await?;
Ok(rows
.into_iter()
.filter_map(|row| row.try_get::<String, _>("name").ok())
.map(|name| (name, ()))
.collect())
}
async fn sync_sequences(pg: &PgPool) -> anyhow::Result<()> {
for table in TABLES
.iter()
.filter(|table| table.columns.iter().any(|column| column.name == "id"))
{
let sql = format!(
"SELECT setval(pg_get_serial_sequence('{table}', 'id'), COALESCE((SELECT MAX(id) FROM {table}), 1), (SELECT COUNT(*) FROM {table}) > 0)",
table = table.name
);
let _ = sqlx::query(&sql).execute(pg).await;
}
Ok(())
}
fn quote_ident(name: &str) -> String {
if name == "read" {
"\"read\"".to_string()
} else {
name.to_string()
}
}
+92
View File
@@ -0,0 +1,92 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Upstream error: {0}")]
UpstreamError(String),
#[error("Rate limited")]
RateLimited,
#[error("Rate limited: {message}")]
RateLimitedWithRetry {
message: String,
retry_after_secs: u64,
},
#[error("Service unavailable: {0}")]
ServiceUnavailable(String),
#[error("Gateway timeout: {0}")]
GatewayTimeout(String),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::UpstreamError(msg) => (StatusCode::BAD_GATEWAY, msg.clone()),
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited".into()),
AppError::RateLimitedWithRetry { message, .. } => {
(StatusCode::TOO_MANY_REQUESTS, message.clone())
}
AppError::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()),
AppError::GatewayTimeout(msg) => (StatusCode::GATEWAY_TIMEOUT, msg.clone()),
AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
};
let retry_after_secs = match &self {
AppError::RateLimitedWithRetry {
retry_after_secs, ..
} => Some(*retry_after_secs),
_ => None,
};
let billing = match &self {
AppError::RateLimited | AppError::RateLimitedWithRetry { .. } => Some(json!({
"reason": "insufficient_quota",
"rechargePath": "/user/recharge",
"usagePath": "/user/usage",
})),
_ => None,
};
(
status,
Json(json!({
"error": {
"message": message,
"type": status.canonical_reason(),
},
"retry_after_secs": retry_after_secs,
"billing": billing,
})),
)
.into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;
+77
View File
@@ -0,0 +1,77 @@
pub mod auth;
pub mod config;
pub mod db;
pub mod error;
pub mod proxy;
pub mod routes;
pub mod services;
pub mod shared;
pub mod template_http;
pub mod transformers;
pub mod workflow;
use sqlx::SqlitePool;
use std::{sync::Arc, time::Duration};
#[derive(Clone)]
pub struct AppState {
pub config: config::AppConfig,
pub db: Arc<SqlitePool>,
pub http: reqwest::Client,
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
pub runtime_auth: Arc<services::runtime_auth::RuntimeAuthSettings>,
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
}
impl AppState {
pub fn new(
config: config::AppConfig,
db: SqlitePool,
builtin_keys: services::builtin_keys::BuiltinKeys,
runtime_auth: services::runtime_auth::RuntimeAuthSettings,
) -> Self {
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
"127.0.0.1".to_string()
} else {
config.server.host.clone()
};
let boosapi_url = format!("http://{}:{}", boosapi_host, config.server.port);
let comfyui_agent_secret = builtin_keys.comfyui_agent.1.clone();
let comfyui = services::comfyui_process::ComfyUIProcess::new(
config.comfyui.clone(),
boosapi_url.clone(),
runtime_auth.proxy_token(),
comfyui_agent_secret.clone(),
);
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(config.proxy.upstream_timeout_secs.max(30)))
.connect_timeout(Duration::from_secs(15))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(64)
.tcp_nodelay(true)
.build()
.unwrap_or_else(|err| {
tracing::warn!(%err, "failed to build shared HTTP client; falling back to reqwest default");
reqwest::Client::new()
});
Self {
config,
db: Arc::new(db),
http,
comfyui,
builtin_keys: Arc::new(builtin_keys),
runtime_auth: Arc::new(runtime_auth),
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
registration_limiter: Arc::new(
services::registration_limiter::RegistrationLimiter::new(),
),
}
}
pub fn runtime_proxy_token(&self) -> String {
self.runtime_auth.proxy_token()
}
}
+220
View File
@@ -0,0 +1,220 @@
use anyhow::Context;
use axum::serve;
use boosapi::{
config::AppConfig,
db,
services::{builtin_keys, runtime_auth},
AppState,
};
use clap::{Parser, Subcommand};
use std::ffi::OsString;
use std::net::SocketAddr;
use tokio::signal;
use tracing_subscriber::{prelude::*, EnvFilter};
#[derive(Parser, Debug)]
#[command(
name = "boosapi",
version,
author,
about = "AI API Gateway — Rust rewrite of BoosAPI"
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
/// Config file path
#[arg(short, long, default_value = "config.toml")]
config: String,
/// Host to bind to
#[arg(long)]
host: Option<String>,
/// Port to listen on
#[arg(short, long)]
port: Option<u16>,
/// Log level (trace, debug, info, warn, error)
#[arg(long)]
log_level: Option<String>,
/// Only run database migrations and exit
#[arg(long)]
migrate_only: bool,
/// Load and validate configuration, then exit
#[arg(long)]
check_config: bool,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Copy core data from SQLite into PostgreSQL and exit
MigrateSqliteToPostgres {
/// Source SQLite URL, e.g. sqlite:boosapi.db?mode=rwc
#[arg(long)]
sqlite_url: String,
/// Target PostgreSQL URL, e.g. postgres://user:pass@host:5432/boosapi
#[arg(long)]
postgres_url: String,
/// Truncate target business tables before importing
#[arg(long)]
force_truncate: bool,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Cli::parse_from(normalize_legacy_args());
if let Some(Commands::MigrateSqliteToPostgres {
sqlite_url,
postgres_url,
force_truncate,
}) = &args.command
{
let sqlite = db::create_pool(sqlite_url, 1).await?;
let pg = db::create_pg_pool(postgres_url, 2).await?;
db::run_migrations(db::DbKind::Postgres, None, Some(&pg)).await?;
let summary = db::sqlite_to_postgres::migrate(&sqlite, &pg, *force_truncate).await?;
println!("{}", serde_json::to_string_pretty(&summary)?);
return Ok(());
}
// Load config
let mut config = AppConfig::load_from_file(&args.config)?;
// Apply CLI overrides
if let Some(host) = args.host {
config.server.host = host;
}
if let Some(port) = args.port {
config.server.port = port;
}
if let Some(level) = args.log_level {
config.server.log_level = level;
}
if args.check_config {
println!("Configuration is valid.");
println!("Server: {}:{}", config.server.host, config.server.port);
println!("Database URL: {}", config.db.url);
return Ok(());
}
// Initialize tracing
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.with(boosapi::services::runtime_logs::RuntimeLogLayer)
.init();
tracing::info!("Loaded configuration from {}", args.config);
let db_kind = db::DbKind::from_url(&config.db.url)?;
// Create database pool
let pool = db::create_pool(&config.db.url, config.db.max_connections).await?;
tracing::info!("Database connected");
// Run migrations
db::run_migrations(db_kind, Some(&pool), None).await?;
db::schema_compat::ensure_sqlite_upstream_compat(&pool).await?;
tracing::info!("Database migrations applied");
if args.migrate_only {
tracing::info!("Migration only mode requested. Exiting.");
return Ok(());
}
// Bootstrap built-in system keys (idempotent).
let comfyui_agent_key = builtin_keys::ensure_comfyui_agent_key(&pool)
.await
.context("failed to bootstrap built-in ComfyUI Agent key")?;
tracing::info!(
key_id = comfyui_agent_key.0,
"Built-in ComfyUI Agent downstream key ready"
);
let builtin = builtin_keys::BuiltinKeys {
comfyui_agent: comfyui_agent_key,
};
let runtime_auth = runtime_auth::RuntimeAuthSettings::load(
&pool,
config.auth.proxy_token.clone(),
)
.await
.context("failed to load runtime auth settings")?;
// Build app state
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
if let Err(err) = state.comfyui.start().await {
tracing::warn!(%err, "ComfyUI embedded process did not start");
}
let comfyui_process = state.comfyui.clone();
// Build router
let app = boosapi::routes::build_router(state);
// Start server
let addr = SocketAddr::new(config.server.host.parse()?, config.server.port);
tracing::info!("Starting server on {addr}");
serve(
tokio::net::TcpListener::bind(addr).await?,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
if let Err(err) = comfyui_process.stop().await {
tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly");
}
Ok(())
}
fn normalize_legacy_args() -> Vec<OsString> {
std::env::args_os()
.map(|arg| {
if arg == "-help" {
OsString::from("--help")
} else if arg == "-version" {
OsString::from("--version")
} else {
arg
}
})
.collect()
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("Shutdown signal received, starting graceful shutdown");
}
+55
View File
@@ -0,0 +1,55 @@
use crate::proxy::providers::types::ProviderCategory;
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderCapabilities {
pub category: ProviderCategory,
pub supports_chat: bool,
pub supports_responses: bool,
pub supports_images: bool,
pub supports_audio: bool,
pub supports_streaming: bool,
pub supports_token_refresh: bool,
}
pub fn detect_capabilities(platform: &str) -> ProviderCapabilities {
let normalized = platform.to_ascii_lowercase();
let category = match normalized.as_str() {
"openai" => ProviderCategory::OpenAi,
"anthropic" | "claude" => ProviderCategory::Anthropic,
"gemini" | "google" => ProviderCategory::Gemini,
"codex" => ProviderCategory::Codex,
"gemini_cli" | "gemini-cli" => ProviderCategory::GeminiCli,
"antigravity" => ProviderCategory::Antigravity,
_ => ProviderCategory::GenericOpenAi,
};
match category {
ProviderCategory::Anthropic => ProviderCapabilities {
category,
supports_chat: true,
supports_responses: false,
supports_images: false,
supports_audio: false,
supports_streaming: true,
supports_token_refresh: true,
},
ProviderCategory::Gemini | ProviderCategory::GeminiCli => ProviderCapabilities {
category,
supports_chat: true,
supports_responses: false,
supports_images: true,
supports_audio: false,
supports_streaming: true,
supports_token_refresh: true,
},
_ => ProviderCapabilities {
category,
supports_chat: true,
supports_responses: true,
supports_images: true,
supports_audio: true,
supports_streaming: true,
supports_token_refresh: true,
},
}
}
+43
View File
@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CliProfileKind {
Codex,
ClaudeCode,
GeminiCli,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CliProfile {
pub id: &'static str,
pub kind: CliProfileKind,
pub display_name: &'static str,
pub default_model: &'static str,
pub env_key_name: &'static str,
}
pub fn builtin_profiles() -> Vec<CliProfile> {
vec![
CliProfile {
id: "codex",
kind: CliProfileKind::Codex,
display_name: "Codex CLI",
default_model: "gpt-5.5",
env_key_name: "OPENAI_API_KEY",
},
CliProfile {
id: "claude-code",
kind: CliProfileKind::ClaudeCode,
display_name: "Claude Code",
default_model: "claude-sonnet-4",
env_key_name: "ANTHROPIC_API_KEY",
},
CliProfile {
id: "gemini-cli",
kind: CliProfileKind::GeminiCli,
display_name: "Gemini CLI",
default_model: "gemini-2.0-flash",
env_key_name: "GEMINI_API_KEY",
},
]
}
+414
View File
@@ -0,0 +1,414 @@
#![allow(clippy::type_complexity)]
use super::retry_policy::classify_failure;
use super::types::{
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
ExecuteResult, FailureInfo, FailureReason, SelectedChannel, SuccessMetrics,
};
use futures::future::BoxFuture;
use serde_json::Value;
use std::sync::Arc;
const MAX_ATTEMPTS: usize = 5;
pub struct ProxyConductor {
pub select_channel: Arc<
dyn Fn(String, Option<Value>) -> BoxFuture<'static, Option<SelectedChannel>> + Send + Sync,
>,
pub select_next_channel: Arc<
dyn Fn(String, Vec<i64>, Option<Value>) -> BoxFuture<'static, Option<SelectedChannel>>
+ Send
+ Sync,
>,
pub record_success: Arc<dyn Fn(i64, SuccessMetrics) -> BoxFuture<'static, ()> + Send + Sync>,
pub record_failure: Arc<dyn Fn(i64, FailureInfo) -> BoxFuture<'static, ()> + Send + Sync>,
pub refresh_auth: Option<
Arc<
dyn Fn(SelectedChannel, FailureInfo) -> BoxFuture<'static, Option<SelectedChannel>>
+ Send
+ Sync,
>,
>,
}
impl ProxyConductor {
pub async fn execute(&self, input: ExecuteInput) -> ExecuteResult {
let mut attempts = 0;
let mut exclude_channel_ids = Vec::new();
let mut selected = match (self.select_channel)(
input.requested_model.clone(),
input.downstream_policy.clone(),
)
.await
{
Some(selected) => selected,
None => {
return ExecuteResult::Failure {
reason: FailureReason::NoChannel,
selected: None,
status: None,
raw_error_text: None,
attempts,
};
}
};
loop {
if attempts >= MAX_ATTEMPTS {
return exhausted_failure(
selected,
None,
None,
attempts,
input.on_terminal_failure.as_deref(),
)
.await;
}
let ctx = ExecuteAttemptContext {
selected: selected.clone(),
attempt_index: attempts,
exclude_channel_ids: exclude_channel_ids.clone(),
};
attempts += 1;
match (input.attempt_fn)(ctx).await {
AttemptResult::Success {
response,
latency_ms,
cost,
} => {
(self.record_success)(
selected.channel_id,
SuccessMetrics {
latency_ms,
cost,
metadata: Default::default(),
},
)
.await;
return ExecuteResult::Success {
selected,
response,
attempts,
};
}
AttemptResult::Failure {
action,
status,
raw_error_text,
} => {
let classified = if matches!(action, AttemptFailureAction::Stop) {
AttemptFailureAction::Stop
} else {
classify_failure(status, raw_error_text.as_deref())
};
let info = FailureInfo {
action: classified.as_str().to_string(),
status,
raw_error_text: raw_error_text.clone(),
};
(self.record_failure)(selected.channel_id, info.clone()).await;
match classified {
AttemptFailureAction::RetrySameChannel => {
continue;
}
AttemptFailureAction::RefreshAuth => {
if let Some(refresh_auth) = &self.refresh_auth {
if let Some(refreshed) =
refresh_auth(selected.clone(), info.clone()).await
{
selected = refreshed;
continue;
}
}
exclude_channel_ids.push(selected.channel_id);
match (self.select_next_channel)(
input.requested_model.clone(),
exclude_channel_ids.clone(),
input.downstream_policy.clone(),
)
.await
{
Some(next) => {
selected = next;
}
None => {
return terminal_failure(
selected,
FailureReason::Failed,
status,
raw_error_text,
attempts,
input.on_terminal_failure.as_deref(),
&info,
)
.await;
}
}
}
AttemptFailureAction::Failover => {
exclude_channel_ids.push(selected.channel_id);
match (self.select_next_channel)(
input.requested_model.clone(),
exclude_channel_ids.clone(),
input.downstream_policy.clone(),
)
.await
{
Some(next) => {
selected = next;
}
None => {
return terminal_failure(
selected,
FailureReason::Failed,
status,
raw_error_text,
attempts,
input.on_terminal_failure.as_deref(),
&info,
)
.await;
}
}
}
AttemptFailureAction::Terminal => {
return terminal_failure(
selected,
FailureReason::Terminal,
status,
raw_error_text,
attempts,
input.on_terminal_failure.as_deref(),
&info,
)
.await;
}
AttemptFailureAction::Stop => {
return terminal_failure(
selected,
FailureReason::Failed,
status,
raw_error_text,
attempts,
input.on_terminal_failure.as_deref(),
&info,
)
.await;
}
}
}
}
}
}
}
async fn terminal_failure(
selected: SelectedChannel,
reason: FailureReason,
status: Option<u16>,
raw_error_text: Option<String>,
attempts: usize,
on_terminal_failure: Option<
&(dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync),
>,
info: &FailureInfo,
) -> ExecuteResult {
if let Some(callback) = on_terminal_failure {
callback(selected.clone(), AttemptFailure::from(info)).await;
}
ExecuteResult::Failure {
reason,
selected: Some(selected),
status,
raw_error_text,
attempts,
}
}
async fn exhausted_failure(
selected: SelectedChannel,
status: Option<u16>,
raw_error_text: Option<String>,
attempts: usize,
on_terminal_failure: Option<
&(dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync),
>,
) -> ExecuteResult {
let info = FailureInfo {
action: AttemptFailureAction::Stop.as_str().to_string(),
status,
raw_error_text: raw_error_text.clone(),
};
terminal_failure(
selected,
FailureReason::Failed,
status,
raw_error_text,
attempts,
on_terminal_failure,
&info,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use futures::FutureExt;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
fn channel(id: i64) -> SelectedChannel {
SelectedChannel {
channel_id: id,
route_id: Some(10),
site_id: 20,
account_id: Some(30),
account_unit_cost: None,
base_url: "https://example.test/v1".into(),
api_key: "key".into(),
model: "gpt-5.5".into(),
actual_model: "gpt-5.5".into(),
token_name: None,
token_value: None,
}
}
#[tokio::test]
async fn retries_same_channel_then_succeeds() {
let attempts_seen = Arc::new(AtomicUsize::new(0));
let recorded_failures = Arc::new(AtomicUsize::new(0));
let recorded_successes = Arc::new(AtomicUsize::new(0));
let conductor = ProxyConductor {
select_channel: Arc::new(|_, _| async { Some(channel(1)) }.boxed()),
select_next_channel: Arc::new(|_, _, _| async { Some(channel(2)) }.boxed()),
record_success: {
let recorded_successes = Arc::clone(&recorded_successes);
Arc::new(move |_, _| {
let recorded_successes = Arc::clone(&recorded_successes);
async move {
recorded_successes.fetch_add(1, Ordering::SeqCst);
}
.boxed()
})
},
record_failure: {
let recorded_failures = Arc::clone(&recorded_failures);
Arc::new(move |_, _| {
let recorded_failures = Arc::clone(&recorded_failures);
async move {
recorded_failures.fetch_add(1, Ordering::SeqCst);
}
.boxed()
})
},
refresh_auth: None,
};
let result = conductor
.execute(ExecuteInput {
requested_model: "gpt-5.5".into(),
downstream_policy: None,
attempt_fn: Box::new({
let attempts_seen = Arc::clone(&attempts_seen);
move |_| {
let attempt = attempts_seen.fetch_add(1, Ordering::SeqCst);
async move {
if attempt == 0 {
AttemptResult::Failure {
action: AttemptFailureAction::RetrySameChannel,
status: Some(429),
raw_error_text: Some("rate limited".into()),
}
} else {
AttemptResult::Success {
response: json!({"ok": true}),
latency_ms: Some(12),
cost: Some(0.1),
}
}
}
.boxed()
}
}),
on_terminal_failure: None,
})
.await;
match result {
ExecuteResult::Success {
selected, attempts, ..
} => {
assert_eq!(selected.channel_id, 1);
assert_eq!(attempts, 2);
}
other => panic!("expected success, got {other:?}"),
}
assert_eq!(recorded_failures.load(Ordering::SeqCst), 1);
assert_eq!(recorded_successes.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn failover_selects_next_channel() {
let conductor = ProxyConductor {
select_channel: Arc::new(|_, _| async { Some(channel(1)) }.boxed()),
select_next_channel: Arc::new(|_, excluded, _| {
async move {
assert_eq!(excluded, vec![1]);
Some(channel(2))
}
.boxed()
}),
record_success: Arc::new(|_, _| async {}.boxed()),
record_failure: Arc::new(|_, _| async {}.boxed()),
refresh_auth: None,
};
let attempts_seen = Arc::new(AtomicUsize::new(0));
let result = conductor
.execute(ExecuteInput {
requested_model: "gpt-5.5".into(),
downstream_policy: None,
attempt_fn: Box::new({
let attempts_seen = Arc::clone(&attempts_seen);
move |ctx| {
let attempt = attempts_seen.fetch_add(1, Ordering::SeqCst);
async move {
if attempt == 0 {
assert_eq!(ctx.selected.channel_id, 1);
AttemptResult::Failure {
action: AttemptFailureAction::Failover,
status: Some(500),
raw_error_text: None,
}
} else {
assert_eq!(ctx.selected.channel_id, 2);
AttemptResult::Success {
response: json!({"ok": true}),
latency_ms: None,
cost: None,
}
}
}
.boxed()
}
}),
on_terminal_failure: None,
})
.await;
match result {
ExecuteResult::Success {
selected, attempts, ..
} => {
assert_eq!(selected.channel_id, 2);
assert_eq!(attempts, 2);
}
other => panic!("expected success, got {other:?}"),
}
}
}
+14
View File
@@ -0,0 +1,14 @@
#![allow(clippy::module_inception, clippy::type_complexity)]
pub mod conductor;
pub mod retry_policy;
pub mod types;
pub use conductor::ProxyConductor;
pub use retry_policy::{
classify_failure, is_terminal, should_failover, should_refresh_auth, should_retry_same_channel,
};
pub use types::{
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
ExecuteResult, FailureInfo, FailureReason, SelectedChannel, SuccessMetrics,
};
+82
View File
@@ -0,0 +1,82 @@
use super::types::AttemptFailureAction;
pub fn classify_failure(status: Option<u16>, _error_text: Option<&str>) -> AttemptFailureAction {
match status {
Some(429 | 503) => AttemptFailureAction::RetrySameChannel,
Some(401) => AttemptFailureAction::RefreshAuth,
Some(code) if (500..600).contains(&code) => AttemptFailureAction::Failover,
Some(code) if (400..500).contains(&code) => AttemptFailureAction::Terminal,
_ => AttemptFailureAction::Failover,
}
}
pub fn is_terminal(action: AttemptFailureAction) -> bool {
matches!(action, AttemptFailureAction::Terminal)
}
pub fn should_retry_same_channel(action: AttemptFailureAction) -> bool {
matches!(action, AttemptFailureAction::RetrySameChannel)
}
pub fn should_refresh_auth(action: AttemptFailureAction) -> bool {
matches!(action, AttemptFailureAction::RefreshAuth)
}
pub fn should_failover(action: AttemptFailureAction) -> bool {
matches!(action, AttemptFailureAction::Failover)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_retryable_rate_limit_and_unavailable() {
assert_eq!(
classify_failure(Some(429), None),
AttemptFailureAction::RetrySameChannel
);
assert_eq!(
classify_failure(Some(503), Some("busy")),
AttemptFailureAction::RetrySameChannel
);
}
#[test]
fn classifies_auth_server_and_terminal_failures() {
assert_eq!(
classify_failure(Some(401), None),
AttemptFailureAction::RefreshAuth
);
assert_eq!(
classify_failure(Some(500), None),
AttemptFailureAction::Failover
);
assert_eq!(
classify_failure(Some(502), None),
AttemptFailureAction::Failover
);
assert_eq!(
classify_failure(Some(400), None),
AttemptFailureAction::Terminal
);
assert_eq!(
classify_failure(Some(404), None),
AttemptFailureAction::Terminal
);
assert_eq!(
classify_failure(None, Some("timeout")),
AttemptFailureAction::Failover
);
}
#[test]
fn helper_predicates_match_actions() {
assert!(is_terminal(AttemptFailureAction::Terminal));
assert!(should_retry_same_channel(
AttemptFailureAction::RetrySameChannel
));
assert!(should_refresh_auth(AttemptFailureAction::RefreshAuth));
assert!(should_failover(AttemptFailureAction::Failover));
}
}
+127
View File
@@ -0,0 +1,127 @@
#![allow(clippy::type_complexity)]
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct SelectedChannel {
pub channel_id: i64,
pub route_id: Option<i64>,
pub site_id: i64,
pub account_id: Option<i64>,
pub account_unit_cost: Option<f64>,
pub base_url: String,
pub api_key: String,
pub model: String,
pub actual_model: String,
pub token_name: Option<String>,
pub token_value: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttemptFailureAction {
RetrySameChannel,
RefreshAuth,
Failover,
Terminal,
Stop,
}
#[derive(Debug)]
pub enum AttemptResult {
Success {
response: Value,
latency_ms: Option<u64>,
cost: Option<f64>,
},
Failure {
action: AttemptFailureAction,
status: Option<u16>,
raw_error_text: Option<String>,
},
}
pub struct ExecuteInput {
pub requested_model: String,
pub downstream_policy: Option<Value>,
pub attempt_fn: Box<dyn Fn(ExecuteAttemptContext) -> BoxFuture<'static, AttemptResult> + Send>,
pub on_terminal_failure: Option<
Box<dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync>,
>,
}
#[derive(Debug, Clone)]
pub struct ExecuteAttemptContext {
pub selected: SelectedChannel,
pub attempt_index: usize,
pub exclude_channel_ids: Vec<i64>,
}
#[derive(Debug)]
pub enum ExecuteResult {
Success {
selected: SelectedChannel,
response: Value,
attempts: usize,
},
Failure {
reason: FailureReason,
selected: Option<SelectedChannel>,
status: Option<u16>,
raw_error_text: Option<String>,
attempts: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureReason {
NoChannel,
Failed,
Terminal,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SuccessMetrics {
pub latency_ms: Option<u64>,
pub cost: Option<f64>,
#[serde(default)]
pub metadata: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureInfo {
pub action: String,
pub status: Option<u16>,
pub raw_error_text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttemptFailure {
pub status: Option<u16>,
pub raw_error_text: Option<String>,
pub action: String,
}
impl From<&FailureInfo> for AttemptFailure {
fn from(value: &FailureInfo) -> Self {
Self {
status: value.status,
raw_error_text: value.raw_error_text.clone(),
action: value.action.clone(),
}
}
}
impl AttemptFailureAction {
pub fn as_str(self) -> &'static str {
match self {
Self::RetrySameChannel => "retry_same_channel",
Self::RefreshAuth => "refresh_auth",
Self::Failover => "failover",
Self::Terminal => "terminal",
Self::Stop => "stop",
}
}
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
// Proxy core — OpenAI-compatible request routing
//
// Architecture:
// /v1/chat/completions → auth (downstream key) → route resolution → upstream call → response
// /v1/images/generations → ...same flow...
// /v1/audio/speech → ...same flow...
pub mod capabilities;
pub mod cli_profiles;
pub mod conductor;
pub mod executors;
pub mod providers;
pub mod runtime;
pub mod surfaces;
pub mod token_router;
+6
View File
@@ -0,0 +1,6 @@
// Provider profiles & registry
pub mod registry;
pub mod types;
pub use registry::ProviderRegistry;
pub use types::{ProviderCategory, ProviderProfile, RouteResolution};
+134
View File
@@ -0,0 +1,134 @@
// Provider registry — holds all configured upstream provider profiles
use crate::proxy::providers::types::{ProviderCategory, ProviderProfile, RouteResolution};
use std::collections::HashMap;
/// Registry of all available provider profiles
#[derive(Debug, Clone)]
pub struct ProviderRegistry {
providers: HashMap<String, ProviderProfile>,
}
impl ProviderRegistry {
/// Create a new registry with default providers
pub fn new() -> Self {
let mut registry = Self {
providers: HashMap::new(),
};
registry.register_defaults();
registry
}
/// Register the built-in default providers
fn register_defaults(&mut self) {
// OpenAI
self.register(ProviderProfile::openai_compatible(
"openai",
"OpenAI",
"https://api.openai.com/v1",
));
// Anthropic
self.register(ProviderProfile::anthropic(
"anthropic",
"Anthropic",
"https://api.anthropic.com/v1",
));
// Google Gemini
self.register(ProviderProfile::gemini(
"gemini",
"Google Gemini",
"https://generativelanguage.googleapis.com/v1beta",
));
// Codex
self.register(ProviderProfile {
id: "codex".into(),
name: "OpenAI Codex".into(),
category: ProviderCategory::Codex,
base_url: "https://api.openai.com/v1".into(),
default_model: Some("codex".into()),
requires_api_key: true,
supports_streaming: true,
headers: HashMap::new(),
});
// Claude Code
self.register(ProviderProfile {
id: "claude".into(),
name: "Claude Code".into(),
category: ProviderCategory::Claude,
base_url: "https://api.anthropic.com/v1".into(),
default_model: Some("claude-sonnet-4".into()),
requires_api_key: true,
supports_streaming: true,
headers: HashMap::new(),
});
// Gemini CLI
self.register(ProviderProfile {
id: "gemini_cli".into(),
name: "Gemini CLI".into(),
category: ProviderCategory::GeminiCli,
base_url: "https://generativelanguage.googleapis.com/v1beta".into(),
default_model: Some("gemini-2.0-flash".into()),
requires_api_key: true,
supports_streaming: true,
headers: HashMap::new(),
});
}
/// Register a new provider profile
pub fn register(&mut self, profile: ProviderProfile) {
self.providers.insert(profile.id.clone(), profile);
}
/// Get a provider by ID
pub fn get(&self, id: &str) -> Option<&ProviderProfile> {
self.providers.get(id)
}
/// List all registered providers
pub fn list(&self) -> Vec<&ProviderProfile> {
self.providers.values().collect()
}
/// Remove a provider by ID
pub fn remove(&mut self, id: &str) -> Option<ProviderProfile> {
self.providers.remove(id)
}
/// Resolve a model name to an upstream route.
/// Tries to match the model to a provider category, falls back to first OpenAI-compatible provider.
pub fn resolve(&self, model: &str, api_key: &str) -> Option<RouteResolution> {
// First try to find a provider that supports this model
for provider in self.providers.values() {
if provider.supports_model(model) {
let base_url = provider.base_url.clone();
return Some(RouteResolution {
provider: provider.clone(),
api_key: api_key.to_string(),
model: model.to_string(),
base_url,
});
}
}
// Fall back to the first OpenAI-compatible provider
self.providers
.values()
.find(|p| {
matches!(
p.category,
ProviderCategory::OpenAi | ProviderCategory::GenericOpenAi
)
})
.map(|provider| RouteResolution {
provider: provider.clone(),
api_key: api_key.to_string(),
model: model.to_string(),
base_url: provider.base_url.clone(),
})
}
}
impl Default for ProviderRegistry {
fn default() -> Self {
Self::new()
}
}
+137
View File
@@ -0,0 +1,137 @@
// Provider profile types — upstream API provider definitions
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Provider category — determines request transformation and routing behavior
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ProviderCategory {
/// OpenAI-compatible chat/completions API
OpenAi,
/// Anthropic Messages API
Anthropic,
/// Google Gemini API
Gemini,
/// Codex CLI / Responses API
Codex,
/// Claude Code CLI
Claude,
/// Gemini CLI
GeminiCli,
/// Antigravity / assistant-style API
Antigravity,
/// Generic OpenAI-compatible proxy (new-api, one-api, etc.)
GenericOpenAi,
}
impl std::fmt::Display for ProviderCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OpenAi => write!(f, "openai"),
Self::Anthropic => write!(f, "anthropic"),
Self::Gemini => write!(f, "gemini"),
Self::Codex => write!(f, "codex"),
Self::Claude => write!(f, "claude"),
Self::GeminiCli => write!(f, "gemini_cli"),
Self::Antigravity => write!(f, "antigravity"),
Self::GenericOpenAi => write!(f, "generic_openai"),
}
}
}
/// Provider configuration profile
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderProfile {
pub id: String,
pub name: String,
pub category: ProviderCategory,
pub base_url: String,
pub default_model: Option<String>,
pub requires_api_key: bool,
pub supports_streaming: bool,
pub headers: HashMap<String, String>,
}
impl ProviderProfile {
/// Create a standard OpenAI-compatible provider
pub fn openai_compatible(id: &str, name: &str, base_url: &str) -> Self {
let mut headers = HashMap::new();
headers.insert("Content-Type".into(), "application/json".into());
Self {
id: id.to_string(),
name: name.to_string(),
category: ProviderCategory::GenericOpenAi,
base_url: base_url.trim_end_matches('/').to_string(),
default_model: None,
requires_api_key: true,
supports_streaming: true,
headers,
}
}
/// Create an Anthropic Messages API provider
pub fn anthropic(id: &str, name: &str, base_url: &str) -> Self {
let mut headers = HashMap::new();
headers.insert("Content-Type".into(), "application/json".into());
headers.insert("anthropic-version".into(), "2023-06-01".into());
Self {
id: id.to_string(),
name: name.to_string(),
category: ProviderCategory::Anthropic,
base_url: base_url.trim_end_matches('/').to_string(),
default_model: None,
requires_api_key: true,
supports_streaming: true,
headers,
}
}
/// Create a Gemini API provider
pub fn gemini(id: &str, name: &str, base_url: &str) -> Self {
let mut headers = HashMap::new();
headers.insert("Content-Type".into(), "application/json".into());
Self {
id: id.to_string(),
name: name.to_string(),
category: ProviderCategory::Gemini,
base_url: base_url.trim_end_matches('/').to_string(),
default_model: None,
requires_api_key: true,
supports_streaming: true,
headers,
}
}
/// Check if this provider handles the given model
pub fn supports_model(&self, model: &str) -> bool {
let model_lower = model.to_lowercase();
match self.category {
ProviderCategory::OpenAi | ProviderCategory::GenericOpenAi => {
model_lower.contains("gpt")
|| model_lower.contains("o1")
|| model_lower.contains("o3")
|| model_lower.contains("dall-e")
}
ProviderCategory::Anthropic => model_lower.contains("claude"),
ProviderCategory::Gemini => model_lower.contains("gemini"),
ProviderCategory::Codex => model_lower.contains("codex"),
ProviderCategory::Claude => {
model_lower.contains("claude") && model_lower.contains("code")
}
ProviderCategory::GeminiCli => {
model_lower.contains("gemini") && model_lower.contains("cli")
}
ProviderCategory::Antigravity => {
model_lower.contains("antigravity") || model_lower.contains("assistant")
}
}
}
}
/// Result of resolving a model to an upstream route
#[derive(Debug, Clone)]
pub struct RouteResolution {
pub provider: ProviderProfile,
pub api_key: String,
pub model: String,
pub base_url: String,
}
+53
View File
@@ -0,0 +1,53 @@
use crate::proxy::conductor::{FailureInfo, ProxyConductor, SelectedChannel, SuccessMetrics};
use crate::proxy::token_router::TokenRouter;
use futures::FutureExt;
use sqlx::SqlitePool;
use std::sync::Arc;
#[derive(Clone)]
pub struct ProxyRuntime {
pub router: TokenRouter,
}
impl ProxyRuntime {
pub fn new(db: Arc<SqlitePool>) -> Self {
Self {
router: TokenRouter::new(db),
}
}
pub fn conductor(&self) -> ProxyConductor {
let router = self.router.clone();
let next_router = self.router.clone();
let success_router = self.router.clone();
let failure_router = self.router.clone();
ProxyConductor {
select_channel: Arc::new(move |model, _policy| {
let router = router.clone();
async move { router.resolve_selected(&model).await.ok() }.boxed()
}),
select_next_channel: Arc::new(move |model, excluded, _policy| {
let router = next_router.clone();
async move { router.resolve_next_selected(&model, &excluded).await.ok() }.boxed()
}),
record_success: Arc::new(move |channel_id: i64, metrics: SuccessMetrics| {
let router = success_router.clone();
async move {
let _ = router.record_success(channel_id, metrics).await;
}
.boxed()
}),
record_failure: Arc::new(move |channel_id: i64, info: FailureInfo| {
let router = failure_router.clone();
async move {
let _ = router.record_failure(channel_id, info).await;
}
.boxed()
}),
refresh_auth: Some(Arc::new(|selected: SelectedChannel, _info: FailureInfo| {
async move { Some(selected) }.boxed()
})),
}
}
}
+32
View File
@@ -0,0 +1,32 @@
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::Mutex;
use serde_json::Value;
#[derive(Debug, Clone, Default)]
pub struct ProxySurfaceState {
sessions: Arc<Mutex<HashMap<String, SurfaceSession>>>,
}
#[derive(Debug, Clone)]
pub struct SurfaceSession {
pub id: String,
pub model: String,
pub channel_id: Option<i64>,
pub metadata: Value,
}
impl ProxySurfaceState {
pub fn upsert_session(&self, session: SurfaceSession) {
self.sessions.lock().insert(session.id.clone(), session);
}
pub fn get_session(&self, id: &str) -> Option<SurfaceSession> {
self.sessions.lock().get(id).cloned()
}
pub fn remove_session(&self, id: &str) -> Option<SurfaceSession> {
self.sessions.lock().remove(id)
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod router;
pub mod types;
pub use router::TokenRouter;
pub use types::{RouteDecision, RouteMatch};
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
use crate::proxy::conductor::SelectedChannel;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteMatch {
Exact,
Wildcard,
Regex,
}
#[derive(Debug, Clone)]
pub struct RouteDecision {
pub requested_model: String,
pub matched_pattern: String,
pub match_kind: RouteMatch,
pub selected: SelectedChannel,
}

Some files were not shown because too many files have changed in this diff Show More