feat: add account probes and mute scanning
This commit is contained in:
@@ -717,6 +717,8 @@ Reads runtime settings and status, including:
|
||||
- `env_backed`, `needs_vercel_sync`
|
||||
- `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings
|
||||
|
||||
> `runtime.account_mute_scan_interval_seconds` is a config-file field, not a hot-updated `/admin/settings` field. It controls the local long-running background `/api/v0/users/current` mute scan interval, defaults to `43200` seconds, and does not run on Vercel Serverless.
|
||||
|
||||
### `PUT /admin/settings`
|
||||
|
||||
Hot-updates runtime settings. Supported fields:
|
||||
@@ -756,6 +758,8 @@ The request can send config directly, or wrapped as `{"config": {...}, "mode":"m
|
||||
Query params `?mode=merge` / `?mode=replace` are also supported.
|
||||
`replace` mode replaces the full config shape while preserving Vercel sync metadata. `merge` mode merges `keys`, `api_keys`, `accounts`, and `model_aliases`, and overwrites non-empty fields under `admin`, `runtime`, `responses`, and `embeddings`. Manage `compat`, `auto_delete`, and `history_split` via `/admin/settings` or the config file; legacy `toolcall` fields are ignored.
|
||||
|
||||
`merge` mode persists a non-zero `runtime.account_mute_scan_interval_seconds`, but changing that scan interval still requires restarting the local process before the background task uses it.
|
||||
|
||||
> Note: `merge` mode does not update `compat`, `auto_delete`, or `history_split`.
|
||||
|
||||
### `GET /admin/config/export`
|
||||
|
||||
@@ -718,6 +718,8 @@ data: {"type":"message_stop"}
|
||||
- `env_backed`、`needs_vercel_sync`
|
||||
- `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改
|
||||
|
||||
> `runtime.account_mute_scan_interval_seconds` 是配置文件字段,不属于 `/admin/settings` 热更新范围。它控制本地长进程后台 `/api/v0/users/current` 禁言扫描间隔,默认 `43200` 秒;Vercel Serverless 不运行该后台扫描器。
|
||||
|
||||
### `PUT /admin/settings`
|
||||
|
||||
热更新运行时设置。支持更新:
|
||||
@@ -757,6 +759,8 @@ data: {"type":"message_stop"}
|
||||
也支持在查询参数里传 `?mode=merge` / `?mode=replace`。
|
||||
`replace` 模式会按完整配置结构替换(保留 Vercel 同步元信息);`merge` 模式会合并 `keys`、`api_keys`、`accounts`、`model_aliases`,并覆盖 `admin`、`runtime`、`responses`、`embeddings` 中的非空字段。`compat`、`auto_delete`、`history_split` 建议通过 `/admin/settings` 或配置文件管理;`toolcall` 相关字段会被忽略。
|
||||
|
||||
`merge` 模式会把非零 `runtime.account_mute_scan_interval_seconds` 写入配置,但该扫描间隔仍需重启本地进程后才会影响后台任务。
|
||||
|
||||
> 注意:`merge` 模式不会更新 `compat`、`auto_delete`、`history_split`。
|
||||
|
||||
### `GET /admin/config/export`
|
||||
|
||||
@@ -276,7 +276,7 @@ go run ./cmd/ds2api
|
||||
- `keys` / `api_keys`:客户端访问密钥,`api_keys` 支持 `name` 与 `remark` 元信息,`keys` 继续兼容。
|
||||
- `accounts`:DeepSeek 托管账号,支持 `email` 或 `mobile` 登录,可配置代理、名称和备注。
|
||||
- `model_aliases`:OpenAI / Claude / Gemini 共用的模型 alias 映射。
|
||||
- `runtime`:账号并发、队列、token 刷新、账号健康冷却与上游文件上传开关,可通过 Admin Settings 热更新。
|
||||
- `runtime`:账号并发、队列、token 刷新、账号健康冷却、上游文件上传开关,以及本地后台禁言扫描间隔;其中 `account_mute_scan_interval_seconds` 只随配置/重启生效,不通过 Admin Settings 热更新。
|
||||
- `auto_delete.mode`:请求结束后的远端会话清理策略,支持 `none` / `single` / `all`。
|
||||
- `history_split`:多轮历史拆分策略,已全局强制开启;可调整触发阈值,避免长历史全部内联进 prompt。
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ func main() {
|
||||
config.Logger.Error("server initialization failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
backgroundCtx, stopBackground := context.WithCancel(context.Background())
|
||||
defer stopBackground()
|
||||
app.StartBackground(backgroundCtx)
|
||||
|
||||
port := strings.TrimSpace(os.Getenv("PORT"))
|
||||
if port == "" {
|
||||
port = "5001"
|
||||
@@ -64,6 +68,7 @@ func main() {
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
sig := <-quit
|
||||
config.Logger.Info("shutdown signal received", "signal", sig.String())
|
||||
stopBackground()
|
||||
|
||||
// Graceful shutdown: allow up to 10 seconds for in-flight requests to complete.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"account_max_queue": 0,
|
||||
"global_max_inflight": 0,
|
||||
"token_refresh_interval_hours": 6,
|
||||
"account_mute_scan_interval_seconds": 43200,
|
||||
"disable_upstream_file_uploads": false,
|
||||
"account_health_enabled": true,
|
||||
"account_health_recovery_window_seconds": 300,
|
||||
|
||||
+42
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
本文记录需要定期跟踪的 DS2API 上游和活跃 fork,用于复查可吸收的实现、规避重复调研,并给出当前处理建议。
|
||||
|
||||
最近复查时间:2026-05-25。
|
||||
最近复查时间:2026-05-27。
|
||||
|
||||
## 本地引用
|
||||
|
||||
@@ -15,11 +15,16 @@ git fetch https://github.com/fuwei99/ds2api +refs/heads/main:refs/remotes/review
|
||||
git fetch https://github.com/ricardosantis/ds2api +refs/heads/main:refs/remotes/review/ricardosantis-main
|
||||
git fetch https://github.com/tempppw01/ds2api +refs/heads/main:refs/remotes/review/tempppw01-main
|
||||
git fetch https://github.com/TonyWu2333/ds2api +refs/heads/main:refs/remotes/review/TonyWu2333-main
|
||||
git fetch https://github.com/1cyberlangke1/dsp +refs/heads/main:refs/remotes/review/1cyberlangke1-dsp-main
|
||||
git fetch https://github.com/emptysuns/ds2api +refs/heads/main:refs/remotes/review/emptysuns-main
|
||||
git fetch https://github.com/hefengfan0615/ds2api +refs/heads/main:refs/remotes/review/hefengfan0615-main
|
||||
git fetch https://github.com/voktoylo/ds2api +refs/heads/main:refs/remotes/review/voktoylo-main
|
||||
git fetch https://github.com/xiaotian2333/ds2api +refs/heads/main:refs/remotes/review/xiaotian2333-main
|
||||
```
|
||||
|
||||
已下线或不可刷新仓库只保留本地旧快照,不再放入常规 fetch 清单:
|
||||
|
||||
- `1cyberlangke1/dsp:main` -> `refs/remotes/review/1cyberlangke1-dsp-main`:2026-05-25 与 2026-05-27 两次确认 GitHub 返回 `repository not found`,本地 `0759d7f` 仅作历史对照。
|
||||
|
||||
对比新增提交:
|
||||
|
||||
```bash
|
||||
@@ -37,17 +42,21 @@ git diff --stat refs/remotes/review/cjack-main..refs/remotes/review/<name>
|
||||
| `ricardosantis/ds2api:main` | `review/ricardosantis-main` | `dc1a76c` | Heroku/Procfile/runtime/env 示例、本地部署文档 | 主要是部署文档和平台样板,价值较低;仅在需要 Heroku 文档时参考。 |
|
||||
| `tempppw01/ds2api:main` | `review/tempppw01-main` | `9aaa8b4` | 默认禁用上游上传、移除默认 prompt 注入、latest user text prompts、DockerHub workflow | 上游上传禁用已作为可选 runtime 开关吸收;不采用“默认关闭”和 prompt 形状大改,除非有真实回归证据。 |
|
||||
| `TonyWu2333/ds2api:main` | `review/TonyWu2333-main` | `6dbdcec` | 128k prompt 限制、模型别名微调、prompt role marker 中文化 | 128k 限制已吸收;`6dbdcec` 仅把 DeepSeek marker 换成中文文本,不建议合入,除非动态验证证明更稳。 |
|
||||
| `1cyberlangke1/dsp:main` | `review/1cyberlangke1-dsp-main` | `0759d7f` | DeepSeek 指纹、device id、x-rangers-id、TLS/transport 调整;同时大规模删除 Vercel/文档/JS 桥 | 只监控上游指纹相关小块;已吸收兼容子集。该 fork 范围偏离 DS2API,不可整合。2026-05-25 复查时 GitHub fetch 返回 repository not found,当前 ref 只能视为本地旧快照。 |
|
||||
| `1cyberlangke1/dsp:main` | `review/1cyberlangke1-dsp-main` | `0759d7f` | DeepSeek 指纹、device id、x-rangers-id、TLS/transport 调整;同时大规模删除 Vercel/文档/JS 桥 | 冻结为历史快照,不再默认刷新。2026-05-25 与 2026-05-27 两次 GitHub fetch 均返回 `repository not found`;已吸收兼容子集,其余范围偏离 DS2API,不可整合。 |
|
||||
| `emptysuns/ds2api:main` | `review/emptysuns-main` | `2f87937` | prompt 默认策略、response replacements、tool interception 统一、SSE/stream 边界清洗、late thinking 抑制、WebUI 设置面板 | 有研究价值但改动面很大。建议作为单独专题审查:先用 raw SSE/协议测试证明具体问题,再按 `responserewrite`、`toolpolicy`、`sse normalizer` 等小块拆分引入。 |
|
||||
| `hefengfan0615/ds2api:main` | `review/hefengfan0615-main` | `37f6206` | 随机 device_id、随机 DeepSeek header/TLS 指纹;后续继续发散为 Qwen2API 改造、Qwen SSE 解析、忽略 PoW、账号相关 `/v1/models` 和 WebUI 模型加载调整 | 不整合。新增 Qwen2API 方向已偏离 DS2API 的 DeepSeek upstream owner,并替换/绕过 DeepSeek SSE、PoW、登录和模型契约;账号相关模型列表 UI 可以作为独立需求重新设计,但不能从该分支直接搬。详见 [hefengfan0615 上游评估报告](./upstream-hefengfan0615-evaluation.md)。 |
|
||||
| `voktoylo/ds2api:main` | `review/voktoylo-main` | `ec21187` | `/api/v0/users/current` 主动禁言扫描、`test_status=failed` 账号跳过、`503 upstream_unavailable` 自动切号并标记、账号管理未刷新/刷新所选/错误原因展示 | 已吸收高价值子集:本地长进程后台 `users/current` 禁言扫描复用持久化 `muted/mute_until` 与账号健康冷却,Vercel Node streaming 对上游 `403/429/5xx` 非 200 响应释放 lease 时附带惩罚;仍不整块合入其重复状态源和 UI 大改。 |
|
||||
| `xiaotian2333/ds2api:main` | `review/xiaotian2333-main` | `6531c90` | 敏感词拦截、`users/current` 禁言检查、插入消息前换行;同时删除 toolcall/toolstream/Node stream sieve 和大量工具调用测试 | 不合入。删除工具调用链路与 OpenAI/Claude/Gemini 协议兼容目标冲突,且 `go test ./...` 当前无法编译;敏感词拦截若需要,应作为独立网关策略重新设计。 |
|
||||
|
||||
## 当前高价值候选
|
||||
|
||||
1. `fuwei99` 的 `x-hif-leim` 查询:2026-05-25 多次无凭证探针确认 `https://hif-leim.deepseek.com/query` 稳定返回 `code=0` 和 `biz_data.value`。已按 best-effort 方式重新实现到 Go completion/continue 和 Vercel Node streaming:成功则带 `x-hif-leim`,失败则软降级,且有短超时和失败退避。仍缺少真实账号 A/B 数据,后续应验证加 header 前后的 403/429/空回复分布。
|
||||
2. `emptysuns` 的 stream/SSE 边界修复:包括 UTF-8 replacement 边界、Claude SSE charset、late thinking 抑制、response replacement 在 SSE 边界归一。这些直接影响流式输出质量,但必须用样本回放和三协议测试验证。
|
||||
3. `emptysuns` 的 tool interception policy:有助于收敛 OpenAI/Claude/Gemini 工具拦截策略,不过会触碰现有 canonical XML tool-call 语义,不能整块搬。
|
||||
4. `hefengfan0615` 早期请求外形/指纹方向:可能对 DeepSeek 上游风控有帮助,但必须先用真实登录、建会话、PoW、completion 四段探针验证成功率和错误分布;不要直接引入 `math/rand` 全局随机、不稳定 TLS 指纹,或后续 Qwen2API 改造。
|
||||
5. `fuwei99` 的容器配置 bootstrap 和模型映射:可能改善容器首次启动体验和模型覆盖,但要先和本仓库 `config`/`model_aliases` 语义对齐。
|
||||
1. 本地 `C:\Users\highk\Downloads\deepseek-2api-4242` 的账号探针:`/api/v0/users/current` 比 `/api/v0/client/settings` 更适合验证 DeepSeek token;`/api/v0/client/settings?scope=model&did=...` 可读取 `model_configs.value` 与 `vision.switchable`。本仓库已只把这两块吸收到 Admin/WebUI 的账号检查和运行时能力展示,不改变 public 模型目录、模型别名、prompt 兼容流或协议契约。
|
||||
2. `fuwei99` 的 `x-hif-leim` 查询:2026-05-25 多次无凭证探针确认 `https://hif-leim.deepseek.com/query` 稳定返回 `code=0` 和 `biz_data.value`。已按 best-effort 方式重新实现到 Go completion/continue 和 Vercel Node streaming:成功则带 `x-hif-leim`,失败则软降级,且有短超时和失败退避。仍缺少真实账号 A/B 数据,后续应验证加 header 前后的 403/429/空回复分布。
|
||||
3. `emptysuns` 的 stream/SSE 边界修复:包括 UTF-8 replacement 边界、Claude SSE charset、late thinking 抑制、response replacement 在 SSE 边界归一。这些直接影响流式输出质量,但必须用样本回放和三协议测试验证。
|
||||
4. `emptysuns` 的 tool interception policy:有助于收敛 OpenAI/Claude/Gemini 工具拦截策略,不过会触碰现有 canonical XML tool-call 语义,不能整块搬。
|
||||
5. `hefengfan0615` 早期请求外形/指纹方向:可能对 DeepSeek 上游风控有帮助,但必须先用真实登录、建会话、PoW、completion 四段探针验证成功率和错误分布;不要直接引入 `math/rand` 全局随机、不稳定 TLS 指纹,或后续 Qwen2API 改造。
|
||||
6. `fuwei99` 的容器配置 bootstrap 和模型映射:可能改善容器首次启动体验和模型覆盖,但要先和本仓库 `config`/`model_aliases` 语义对齐。
|
||||
7. `voktoylo` 的主动禁言扫描和失败账号降权:已按本仓库现有结构吸收 scanner 与 Vercel 非 200 惩罚两小块。未采用上游 `mutestate` 重复状态源、`test_status=failed` 持久跳过和账号管理页大改;这些后续只有在真实样本证明必要时再单独设计。
|
||||
|
||||
## 当前不建议合入
|
||||
|
||||
@@ -59,6 +68,29 @@ git diff --stat refs/remotes/review/cjack-main..refs/remotes/review/<name>
|
||||
6. `hefengfan0615` 的 Qwen2API 改造:包括登录 payload、session payload、completion payload、忽略 PoW、Qwen SSE 分支和模型目录的成套替换。它是另一个上游服务适配,不是 DS2API 的 DeepSeek upstream 修复。
|
||||
7. `fuwei99` 的中文 prompt marker 和 `invaild-file*` current-input-file 包装/改名:会改变核心 prompt 形状和 file-reference 语义,且分支通过批量测试改写适配自身实现,不能证明对本仓库有效。
|
||||
8. 未记录 fork 中的个人配置/凭证提交:`lamthien8x` 和 `dangtai2710` 当前仍主要是 `config.json` / `config.example.json` 中的个人配置或明文凭据改动,不能吸收。
|
||||
9. `xiaotian2333` 的工具调用删除链路:大规模删除 `internal/toolcall`、`internal/toolstream`、Node stream sieve、prompt tool injection 和协议测试,会直接破坏当前工具调用兼容契约;不能用“禁用工具”方式修复上游风险。
|
||||
|
||||
## 2026-05-27 增量复查
|
||||
|
||||
已刷新长期监控 ref:
|
||||
|
||||
- 未变化:`CJackHwang` `8316cf8`、`qingdeng888` `42bc1ed`、`fuwei99` `5ee2a1f`、`ricardosantis` `dc1a76c`、`tempppw01` `9aaa8b4`、`TonyWu2333` `6dbdcec`、`emptysuns` `2f87937`、`hefengfan0615` `37f6206`。
|
||||
- 无法刷新:`1cyberlangke1/dsp` 远端仍返回 `repository not found`,已从常规 fetch 清单移除;本地 `0759d7f` 只保留为旧快照。
|
||||
|
||||
本次新增长期监控 fork:
|
||||
|
||||
- `voktoylo/ds2api`:`ec21187`。相对 `CJackHwang/main` 有 7 个提交、54 个文件,主要新增 `internal/account/mutescan`、`internal/account/mutestate`、`/api/v0/users/current` 账号禁言探针、pool 跳过 failed/muted 账号、`503 upstream_unavailable` 切号标记,以及账号管理页的状态分组和刷新所选。
|
||||
- 验证:隔离 worktree 跑 `go test ./...` 通过;`npm run build --prefix webui` 通过;`git diff --check refs/remotes/review/cjack-main...refs/remotes/review/voktoylo-main` 未通过,问题集中在 `webui/src/features/account/useAccountsData.js` 的尾随空白。
|
||||
- 结论:有价值但只适合拆分吸收。本仓库已经有持久化 `muted/mute_until`、reactive mute detection、账号健康冷却和账号 probe,直接合入会重复状态源;已吸收主动 `users/current` 扫描和 Vercel 上游非 200 惩罚,silent block/UI 错误原因仍留作后续专题。
|
||||
- `xiaotian2333/ds2api`:`6531c90`。相对 `CJackHwang/main` 有 6 个提交、126 个文件,新增 `internal/sensitivewords` 和 `users/current` 禁言检查,但同时删除工具调用、toolstream、Node stream sieve、工具 prompt 注入和大量协议测试。
|
||||
- 验证:`git diff --check refs/remotes/review/cjack-main...refs/remotes/review/xiaotian2333-main` 通过;隔离 worktree 跑 `go test ./...` 未通过,代表性错误包括 `promptcompat.DefaultToolChoicePolicy`、`ToolChoicePolicy`、`buildClaudeToolPrompt`、`buildOpenAIFinalPrompt` 和 `Turn.ToolCalls` 被删除后仍被测试/调用引用。
|
||||
- 结论:不纳入可合入候选。敏感词拦截可以作为独立产品策略重新设计,但不能接受以删除工具调用兼容层为代价的分支。
|
||||
|
||||
上次未确认的短期 fork 本次已拉取到本地:
|
||||
|
||||
- `6um6n7qu/ds2api`:`85d8f7f`。新增的是另一套占位入口和 `config` 包,包含 `github.com/yourusername/ds2api/internal/api` 这类不匹配本仓库 module 的 import,并混入 Synology NAS 配置语义;不纳入监控。隔离 worktree 跑 `go test ./...` 未通过,根包在 setup 阶段失败。
|
||||
- `maple323/ds2api`:`f1f7dd8`。只把 `.env.example` 改名成 `config.json`,内容仍是 env 格式并含示例 admin key;`git diff --check refs/remotes/review/cjack-main...HEAD` 通过,但没有可吸收业务价值。
|
||||
- `zzz449/ds2api`:`28b9b5b`。删除 Output integrity guard,并把 `.gitignore` 改成带 Markdown fence 的通用模板;隔离 worktree 跑 `go test ./internal/prompt` 编译失败,`messages_test.go` 仍引用被删除的 `outputIntegrityGuardPrompt`,不纳入监控。
|
||||
|
||||
## 2026-05-25 增量复查
|
||||
|
||||
@@ -66,7 +98,7 @@ git diff --stat refs/remotes/review/cjack-main..refs/remotes/review/<name>
|
||||
|
||||
- 未变化:`CJackHwang` `8316cf8`、`qingdeng888` `42bc1ed`、`ricardosantis` `dc1a76c`、`tempppw01` `9aaa8b4`、`TonyWu2333` `6dbdcec`、`emptysuns` `2f87937`。
|
||||
- 有更新:`fuwei99` 从 `08b4aa5` 到 `5ee2a1f`;`hefengfan0615` 从本地旧 ref `2fd3c0d` 到 `37f6206`。
|
||||
- 无法刷新:`1cyberlangke1/dsp` 远端返回 repository not found,本地 `0759d7f` 保留为旧快照。
|
||||
- 无法刷新:`1cyberlangke1/dsp` 远端返回 repository not found,本地 `0759d7f` 保留为旧快照;2026-05-27 复查后已从常规 fetch 清单移除。
|
||||
|
||||
`fuwei99` 增量结论:
|
||||
|
||||
@@ -87,7 +119,7 @@ git diff --stat refs/remotes/review/cjack-main..refs/remotes/review/<name>
|
||||
- `kingleykin/ds2api`:`e8eea7d`,主要是越南语 `START_HERE` 和示例配置;不纳入监控。
|
||||
- `dangtai2710/ds2api`:`dd730aa`,只改 `config.example.json` 中示例账号字段;不纳入监控。
|
||||
- `leeamkim/ds2api`:`379d85a`,是 Synology/NAS 私有客户端骨架和 timeout 调整,和本仓库 DeepSeek/OpenAI/Claude/Gemini 适配主线无关;不纳入监控。
|
||||
- `6um6n7qu/ds2api`、`maple323/ds2api`、`zzz449/ds2api`:GitHub API rate limit 后,本机 `git ls-remote` 多次返回 TLS EOF,本次未能确认远端 HEAD;保持“不纳入长期监控”的旧结论,但下次可从 GitHub forks API 重新拉取。
|
||||
- `6um6n7qu/ds2api`、`maple323/ds2api`、`zzz449/ds2api`:GitHub API rate limit 后,本机 `git ls-remote` 多次返回 TLS EOF,本次未能确认远端 HEAD;保持“不纳入长期监控”的旧结论。2026-05-27 已重新拉取并复查,仍无可吸收价值。
|
||||
|
||||
## 2026-05-19 其他上游快扫
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package mutescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/config"
|
||||
dsclient "ds2api/internal/deepseek/client"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultInterval = 12 * time.Hour
|
||||
MinInterval = 30 * time.Second
|
||||
maxConcurrency = 4
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Accounts() []config.Account
|
||||
MarkAccountMuted(identifier string, muteUntil float64) error
|
||||
ClearAccountMute(identifier string) error
|
||||
}
|
||||
|
||||
type Checker interface {
|
||||
GetAccountMuteStatus(ctx context.Context, token string) (*dsclient.AccountMuteStatus, error)
|
||||
}
|
||||
|
||||
type Penalizer interface {
|
||||
Penalize(accountID string, kind account.PenaltyKind)
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Total int
|
||||
Checked int
|
||||
Muted int
|
||||
Cleared int
|
||||
Failed int
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
store Store
|
||||
checker Checker
|
||||
penalizer Penalizer
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func New(store Store, checker Checker, penalizer Penalizer, interval time.Duration) *Scanner {
|
||||
if interval <= 0 {
|
||||
interval = DefaultInterval
|
||||
}
|
||||
if interval < MinInterval {
|
||||
interval = MinInterval
|
||||
}
|
||||
return &Scanner{store: store, checker: checker, penalizer: penalizer, interval: interval}
|
||||
}
|
||||
|
||||
func (s *Scanner) Start(ctx context.Context) {
|
||||
if s == nil || s.store == nil || s.checker == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
go s.run(ctx)
|
||||
}
|
||||
|
||||
func (s *Scanner) run(ctx context.Context) {
|
||||
s.RefreshNow(ctx)
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.RefreshNow(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scanner) RefreshNow(ctx context.Context) Summary {
|
||||
if s == nil || s.store == nil || s.checker == nil {
|
||||
return Summary{}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
accounts := s.store.Accounts()
|
||||
summary := Summary{Total: len(accounts)}
|
||||
sem := make(chan struct{}, maxConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
scan:
|
||||
for _, acc := range accounts {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
acc := acc
|
||||
identifier := acc.Identifier()
|
||||
if identifier == "" || !acc.IsActive() || strings.TrimSpace(acc.Token) == "" {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break scan
|
||||
case sem <- struct{}{}:
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
status, err := s.checker.GetAccountMuteStatus(ctx, acc.Token)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[account_mute_scan] current-user probe failed", "account", identifier, "error", err)
|
||||
mu.Lock()
|
||||
summary.Failed++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
summary.Checked++
|
||||
mu.Unlock()
|
||||
|
||||
if status != nil && status.Muted {
|
||||
if err := s.store.MarkAccountMuted(identifier, status.MuteUntil); err != nil {
|
||||
config.Logger.Warn("[account_mute_scan] failed to mark muted account", "account", identifier, "error", err)
|
||||
mu.Lock()
|
||||
summary.Failed++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
if s.penalizer != nil {
|
||||
s.penalizer.Penalize(identifier, account.PenaltyMuted)
|
||||
}
|
||||
mu.Lock()
|
||||
summary.Muted++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if acc.Muted || acc.MuteUntil > 0 {
|
||||
if err := s.store.ClearAccountMute(identifier); err != nil {
|
||||
config.Logger.Warn("[account_mute_scan] failed to clear account mute", "account", identifier, "error", err)
|
||||
mu.Lock()
|
||||
summary.Failed++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
summary.Cleared++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
if summary.Checked > 0 || summary.Muted > 0 || summary.Cleared > 0 || summary.Failed > 0 {
|
||||
config.Logger.Info("[account_mute_scan] completed", "total", summary.Total, "checked", summary.Checked, "muted", summary.Muted, "cleared", summary.Cleared, "failed", summary.Failed)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package mutescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/config"
|
||||
dsclient "ds2api/internal/deepseek/client"
|
||||
)
|
||||
|
||||
func TestScannerRefreshMarksClearsAndSkipsAccounts(t *testing.T) {
|
||||
store := &fakeMuteStore{accounts: []config.Account{
|
||||
{Email: "muted@example.com", Token: "tok-muted"},
|
||||
{Email: "old@example.com", Token: "tok-clear", Muted: true, MuteUntil: 9999},
|
||||
{Email: "err@example.com", Token: "tok-error", Muted: true, MuteUntil: 8888},
|
||||
{Email: "inactive@example.com", Token: "tok-inactive", Active: boolPtr(false)},
|
||||
{Email: "empty-token@example.com"},
|
||||
{Token: "tok-token-only"},
|
||||
}}
|
||||
checker := &fakeMuteChecker{
|
||||
statuses: map[string]*dsclient.AccountMuteStatus{
|
||||
"tok-muted": {Muted: true, MuteUntil: 12345},
|
||||
"tok-clear": {Muted: false},
|
||||
},
|
||||
errs: map[string]error{
|
||||
"tok-error": errors.New("probe failed"),
|
||||
},
|
||||
}
|
||||
penalizer := &fakeMutePenalizer{}
|
||||
scanner := New(store, checker, penalizer, 0)
|
||||
|
||||
summary := scanner.RefreshNow(context.Background())
|
||||
if summary.Total != len(store.accounts) || summary.Checked != 2 || summary.Muted != 1 || summary.Cleared != 1 || summary.Failed != 1 {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
if got := store.marked["muted@example.com"]; got != 12345 {
|
||||
t.Fatalf("expected muted@example.com marked until 12345, got %v", got)
|
||||
}
|
||||
if len(store.cleared) != 1 || store.cleared[0] != "old@example.com" {
|
||||
t.Fatalf("unexpected cleared accounts: %#v", store.cleared)
|
||||
}
|
||||
if len(penalizer.ids) != 1 || penalizer.ids[0] != "muted@example.com" {
|
||||
t.Fatalf("unexpected penalties: %#v", penalizer.ids)
|
||||
}
|
||||
if len(checker.tokens) != 3 {
|
||||
t.Fatalf("expected 3 probes, got %#v", checker.tokens)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeMuteStore struct {
|
||||
mu sync.Mutex
|
||||
accounts []config.Account
|
||||
marked map[string]float64
|
||||
cleared []string
|
||||
}
|
||||
|
||||
func (s *fakeMuteStore) Accounts() []config.Account {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]config.Account, len(s.accounts))
|
||||
copy(out, s.accounts)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *fakeMuteStore) MarkAccountMuted(identifier string, muteUntil float64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.marked == nil {
|
||||
s.marked = map[string]float64{}
|
||||
}
|
||||
s.marked[identifier] = muteUntil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeMuteStore) ClearAccountMute(identifier string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cleared = append(s.cleared, identifier)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeMuteChecker struct {
|
||||
mu sync.Mutex
|
||||
statuses map[string]*dsclient.AccountMuteStatus
|
||||
errs map[string]error
|
||||
tokens []string
|
||||
}
|
||||
|
||||
func (c *fakeMuteChecker) GetAccountMuteStatus(_ context.Context, token string) (*dsclient.AccountMuteStatus, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.tokens = append(c.tokens, token)
|
||||
if err := c.errs[token]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.statuses[token], nil
|
||||
}
|
||||
|
||||
type fakeMutePenalizer struct {
|
||||
mu sync.Mutex
|
||||
ids []string
|
||||
}
|
||||
|
||||
func (p *fakeMutePenalizer) Penalize(accountID string, kind account.PenaltyKind) {
|
||||
if kind != account.PenaltyMuted {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.ids = append(p.ids, accountID)
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AccountCapabilityProbe struct {
|
||||
Vision *bool `json:"vision,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
CheckedAt int64 `json:"checked_at,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
func (p AccountCapabilityProbe) Clone() AccountCapabilityProbe {
|
||||
out := p
|
||||
if p.Vision != nil {
|
||||
vision := *p.Vision
|
||||
out.Vision = &vision
|
||||
}
|
||||
out.Models = slices.Clone(p.Models)
|
||||
return out
|
||||
}
|
||||
|
||||
type AccountRuntimeProbe struct {
|
||||
TokenValid *bool `json:"token_valid,omitempty"`
|
||||
TokenHTTPStatus int `json:"token_http_status,omitempty"`
|
||||
TokenCode int `json:"token_code,omitempty"`
|
||||
TokenBizCode int `json:"token_biz_code,omitempty"`
|
||||
TokenMessage string `json:"token_message,omitempty"`
|
||||
Capabilities AccountCapabilityProbe `json:"capabilities,omitempty"`
|
||||
CapabilityError string `json:"capability_error,omitempty"`
|
||||
CheckedAt int64 `json:"checked_at,omitempty"`
|
||||
}
|
||||
|
||||
func (p AccountRuntimeProbe) Clone() AccountRuntimeProbe {
|
||||
out := p
|
||||
if p.TokenValid != nil {
|
||||
valid := *p.TokenValid
|
||||
out.TokenValid = &valid
|
||||
}
|
||||
out.Capabilities = p.Capabilities.Clone()
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAccountRuntimeProbe(identifier string, probe AccountRuntimeProbe) error {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx, ok := s.findAccountIndexLocked(identifier)
|
||||
if !ok {
|
||||
return errors.New("account not found")
|
||||
}
|
||||
s.setAccountRuntimeProbeLocked(s.cfg.Accounts[idx], probe, identifier)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountRuntimeProbe(identifier string) (AccountRuntimeProbe, bool) {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
return AccountRuntimeProbe{}, false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
probe, ok := s.accProbe[identifier]
|
||||
if !ok {
|
||||
return AccountRuntimeProbe{}, false
|
||||
}
|
||||
return probe.Clone(), true
|
||||
}
|
||||
@@ -62,6 +62,7 @@ func runtimeConfigPresent(runtime RuntimeConfig) bool {
|
||||
runtime.AccountMaxQueue > 0 ||
|
||||
runtime.GlobalMaxInflight > 0 ||
|
||||
runtime.TokenRefreshIntervalHours > 0 ||
|
||||
runtime.AccountMuteScanIntervalSeconds > 0 ||
|
||||
runtime.DisableUpstreamFileUploads != nil ||
|
||||
runtime.AccountHealthEnabled != nil ||
|
||||
runtime.AccountHealthRecoveryWindowSeconds > 0 ||
|
||||
|
||||
@@ -169,6 +169,7 @@ type RuntimeConfig struct {
|
||||
AccountMaxQueue int `json:"account_max_queue,omitempty"`
|
||||
GlobalMaxInflight int `json:"global_max_inflight,omitempty"`
|
||||
TokenRefreshIntervalHours int `json:"token_refresh_interval_hours,omitempty"`
|
||||
AccountMuteScanIntervalSeconds int `json:"account_mute_scan_interval_seconds,omitempty"`
|
||||
DisableUpstreamFileUploads *bool `json:"disable_upstream_file_uploads,omitempty"`
|
||||
AccountHealthEnabled *bool `json:"account_health_enabled,omitempty"`
|
||||
AccountHealthRecoveryWindowSeconds int `json:"account_health_recovery_window_seconds,omitempty"`
|
||||
|
||||
@@ -321,6 +321,38 @@ func TestRuntimeTokenRefreshIntervalHoursUsesConfigValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeAccountMuteScanIntervalSecondsDefaultsToTwelveHours(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"keys":["k1"],
|
||||
"accounts":[{"email":"u@example.com","password":"p"}]
|
||||
}`)
|
||||
|
||||
store := LoadStore()
|
||||
if got := store.RuntimeAccountMuteScanIntervalSeconds(); got != 43200 {
|
||||
t.Fatalf("expected default mute scan interval 43200, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeAccountMuteScanIntervalSecondsUsesConfigValue(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"keys":["k1"],
|
||||
"accounts":[{"email":"u@example.com","password":"p"}],
|
||||
"runtime":{"account_mute_scan_interval_seconds":3600}
|
||||
}`)
|
||||
|
||||
store := LoadStore()
|
||||
if got := store.RuntimeAccountMuteScanIntervalSeconds(); got != 3600 {
|
||||
t.Fatalf("expected configured mute scan interval 3600, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRuntimeConfigRejectsInvalidMuteScanInterval(t *testing.T) {
|
||||
err := ValidateRuntimeConfig(RuntimeConfig{AccountMuteScanIntervalSeconds: 10})
|
||||
if err == nil || !strings.Contains(err.Error(), "runtime.account_mute_scan_interval_seconds") {
|
||||
t.Fatalf("expected mute scan interval validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"accounts":[{"email":"user@example.com","password":"p"}]
|
||||
@@ -424,3 +456,57 @@ func TestAccountTestStatusIsRuntimeOnlyAndNotPersisted(t *testing.T) {
|
||||
t.Fatalf("expected test_status to stay out of persisted config, got: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountRuntimeProbeIsRuntimeOnlyAndPreservedAcrossReindex(t *testing.T) {
|
||||
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp config: %v", err)
|
||||
}
|
||||
defer func() { _ = tmp.Close() }()
|
||||
if _, err := tmp.WriteString(`{"accounts":[{"email":"u@example.com","password":"p"}]}`); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("DS2API_CONFIG_JSON", "")
|
||||
t.Setenv("DS2API_CONFIG_PATH", tmp.Name())
|
||||
|
||||
store := LoadStore()
|
||||
vision := true
|
||||
valid := true
|
||||
probe := AccountRuntimeProbe{
|
||||
TokenValid: &valid,
|
||||
TokenHTTPStatus: 200,
|
||||
Capabilities: AccountCapabilityProbe{
|
||||
Vision: &vision,
|
||||
Models: []string{"chat", "vision"},
|
||||
CheckedAt: 123,
|
||||
Source: "client_settings",
|
||||
},
|
||||
CheckedAt: 456,
|
||||
}
|
||||
if err := store.UpdateAccountRuntimeProbe("u@example.com", probe); err != nil {
|
||||
t.Fatalf("update runtime probe: %v", err)
|
||||
}
|
||||
if got, ok := store.AccountRuntimeProbe("u@example.com"); !ok || got.TokenValid == nil || !*got.TokenValid {
|
||||
t.Fatalf("expected runtime probe by email, got %#v (ok=%v)", got, ok)
|
||||
}
|
||||
|
||||
if err := store.Update(func(c *Config) error {
|
||||
c.Accounts[0].Remark = "updated"
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
got, ok := store.AccountRuntimeProbe("u@example.com")
|
||||
if !ok || got.Capabilities.Vision == nil || !*got.Capabilities.Vision {
|
||||
t.Fatalf("expected runtime probe to survive reindex, got %#v (ok=%v)", got, ok)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(tmp.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if strings.Contains(string(content), "client_settings") || strings.Contains(string(content), "token_valid") {
|
||||
t.Fatalf("expected runtime probe to stay out of persisted config, got: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@ import (
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
cfg Config
|
||||
path string
|
||||
fromEnv bool
|
||||
keyMap map[string]struct{} // O(1) API key lookup index
|
||||
accMap map[string]int // O(1) account lookup: identifier -> slice index
|
||||
accTest map[string]string // runtime-only account test status cache
|
||||
mu sync.RWMutex
|
||||
cfg Config
|
||||
path string
|
||||
fromEnv bool
|
||||
keyMap map[string]struct{} // O(1) API key lookup index
|
||||
accMap map[string]int // O(1) account lookup: identifier -> slice index
|
||||
accTest map[string]string // runtime-only account test status cache
|
||||
accProbe map[string]AccountRuntimeProbe
|
||||
}
|
||||
|
||||
func LoadStore() *Store {
|
||||
|
||||
@@ -159,6 +159,15 @@ func (s *Store) RuntimeTokenRefreshIntervalHours() int {
|
||||
return 6
|
||||
}
|
||||
|
||||
func (s *Store) RuntimeAccountMuteScanIntervalSeconds() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.cfg.Runtime.AccountMuteScanIntervalSeconds > 0 {
|
||||
return s.cfg.Runtime.AccountMuteScanIntervalSeconds
|
||||
}
|
||||
return 43200
|
||||
}
|
||||
|
||||
func (s *Store) UpstreamFileUploadsEnabled() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -3,12 +3,14 @@ package config
|
||||
// rebuildIndexes must be called with the lock already held (or during init).
|
||||
func (s *Store) rebuildIndexes() {
|
||||
prevStatus := s.accTest
|
||||
prevProbe := s.accProbe
|
||||
s.keyMap = make(map[string]struct{}, len(s.cfg.Keys))
|
||||
for _, k := range s.cfg.Keys {
|
||||
s.keyMap[k] = struct{}{}
|
||||
}
|
||||
s.accMap = make(map[string]int, len(s.cfg.Accounts))
|
||||
s.accTest = make(map[string]string, len(s.cfg.Accounts))
|
||||
s.accProbe = make(map[string]AccountRuntimeProbe, len(s.cfg.Accounts))
|
||||
for i, acc := range s.cfg.Accounts {
|
||||
id := acc.Identifier()
|
||||
if id != "" {
|
||||
@@ -16,6 +18,9 @@ func (s *Store) rebuildIndexes() {
|
||||
if status, ok := prevStatus[id]; ok {
|
||||
s.setAccountTestStatusLocked(acc, status, "")
|
||||
}
|
||||
if probe, ok := prevProbe[id]; ok {
|
||||
s.setAccountRuntimeProbeLocked(acc, probe, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,3 +58,22 @@ func (s *Store) setAccountTestStatusLocked(acc Account, status, hintedIdentifier
|
||||
s.accTest[hintedIdentifier] = status
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) setAccountRuntimeProbeLocked(acc Account, probe AccountRuntimeProbe, hintedIdentifier string) {
|
||||
probe = probe.Clone()
|
||||
if s.accProbe == nil {
|
||||
s.accProbe = make(map[string]AccountRuntimeProbe)
|
||||
}
|
||||
if id := acc.Identifier(); id != "" {
|
||||
s.accProbe[id] = probe
|
||||
}
|
||||
if email := acc.Email; email != "" {
|
||||
s.accProbe[email] = probe
|
||||
}
|
||||
if mobile := CanonicalMobileKey(acc.Mobile); mobile != "" {
|
||||
s.accProbe[mobile] = probe
|
||||
}
|
||||
if hintedIdentifier = lower(hintedIdentifier); hintedIdentifier != "" {
|
||||
s.accProbe[hintedIdentifier] = probe
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ func ValidateRuntimeConfig(runtime RuntimeConfig) error {
|
||||
if err := ValidateIntRange("runtime.token_refresh_interval_hours", runtime.TokenRefreshIntervalHours, 1, 720, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateIntRange("runtime.account_mute_scan_interval_seconds", runtime.AccountMuteScanIntervalSeconds, 30, 604800, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateIntRange("runtime.account_health_recovery_window_seconds", runtime.AccountHealthRecoveryWindowSeconds, 1, 86400, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dsprotocol "ds2api/internal/deepseek/protocol"
|
||||
)
|
||||
|
||||
type TokenValidationResult struct {
|
||||
Valid bool
|
||||
HTTPStatus int
|
||||
Code int
|
||||
BizCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
type AccountCapabilities struct {
|
||||
Vision *bool
|
||||
Models []string
|
||||
CheckedAt int64
|
||||
Source string
|
||||
}
|
||||
|
||||
type AccountMuteStatus struct {
|
||||
Muted bool
|
||||
MuteUntil float64
|
||||
CheckedAt int64
|
||||
HTTPStatus int
|
||||
Code int
|
||||
BizCode int
|
||||
Message string
|
||||
Source string
|
||||
}
|
||||
|
||||
func (c *Client) ValidateToken(ctx context.Context, token string) (*TokenValidationResult, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return &TokenValidationResult{Valid: false, Message: "empty token"}, nil
|
||||
}
|
||||
clients := c.requestClientsFromContext(ctx)
|
||||
body, status, err := c.getJSONWithStatus(ctx, clients.regular, dsprotocol.DeepSeekCurrentUserURL, c.authHeaders(token))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, bizCode, msg, bizMsg := extractResponseStatus(body)
|
||||
valid := status == http.StatusOK && code == 0 && bizCode == 0 && !isTokenInvalid(status, code, bizCode, msg, bizMsg)
|
||||
result := &TokenValidationResult{Valid: valid, HTTPStatus: status, Code: code, BizCode: bizCode}
|
||||
if !valid {
|
||||
result.Message = failureMessage(msg, bizMsg, fmt.Sprintf("HTTP %d", status))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetAccountMuteStatus(ctx context.Context, token string) (*AccountMuteStatus, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, errors.New("empty token")
|
||||
}
|
||||
clients := c.requestClientsFromContext(ctx)
|
||||
body, status, err := c.getJSONWithStatus(ctx, clients.regular, dsprotocol.DeepSeekCurrentUserURL, c.authHeaders(token))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, bizCode, msg, bizMsg := extractResponseStatus(body)
|
||||
info := extractMuteInfo(body)
|
||||
result := &AccountMuteStatus{
|
||||
Muted: info.Muted,
|
||||
MuteUntil: info.Until,
|
||||
CheckedAt: time.Now().Unix(),
|
||||
HTTPStatus: status,
|
||||
Code: code,
|
||||
BizCode: bizCode,
|
||||
Message: failureMessage(msg, bizMsg, fmt.Sprintf("HTTP %d", status)),
|
||||
Source: "users_current",
|
||||
}
|
||||
if info.Muted {
|
||||
return result, nil
|
||||
}
|
||||
if status != http.StatusOK || code != 0 || bizCode != 0 || isTokenInvalid(status, code, bizCode, msg, bizMsg) {
|
||||
return nil, fmt.Errorf("current user failed: %s", result.Message)
|
||||
}
|
||||
result.Message = ""
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetAccountCapabilities(ctx context.Context, token string, accountID string) (*AccountCapabilities, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, errors.New("empty token")
|
||||
}
|
||||
endpoint, err := url.Parse(dsprotocol.DeepSeekClientSettingsURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := endpoint.Query()
|
||||
q.Set("did", DeviceID(accountID))
|
||||
q.Set("scope", "model")
|
||||
endpoint.RawQuery = q.Encode()
|
||||
|
||||
clients := c.requestClientsFromContext(ctx)
|
||||
body, status, err := c.getJSONWithStatus(ctx, clients.regular, endpoint.String(), c.authHeaders(token))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, bizCode, msg, bizMsg := extractResponseStatus(body)
|
||||
if status != http.StatusOK || code != 0 || bizCode != 0 || isTokenInvalid(status, code, bizCode, msg, bizMsg) {
|
||||
return nil, fmt.Errorf("client settings failed: %s", failureMessage(msg, bizMsg, fmt.Sprintf("HTTP %d", status)))
|
||||
}
|
||||
|
||||
configs := extractModelConfigs(body)
|
||||
modelSet := map[string]struct{}{}
|
||||
var vision *bool
|
||||
seenVision := false
|
||||
for _, item := range configs {
|
||||
modelType := stringFromProbeAny(item["model_type"])
|
||||
if modelType == "" {
|
||||
modelType = stringFromProbeAny(item["modelType"])
|
||||
}
|
||||
if modelType != "" {
|
||||
modelSet[modelType] = struct{}{}
|
||||
}
|
||||
if modelType == "vision" {
|
||||
seenVision = true
|
||||
if value, ok := boolFromProbeAny(item["switchable"]); ok {
|
||||
vision = boolPtr(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(configs) > 0 && !seenVision {
|
||||
vision = boolPtr(false)
|
||||
}
|
||||
models := make([]string, 0, len(modelSet))
|
||||
for model := range modelSet {
|
||||
models = append(models, model)
|
||||
}
|
||||
sort.Strings(models)
|
||||
return &AccountCapabilities{
|
||||
Vision: vision,
|
||||
Models: models,
|
||||
CheckedAt: time.Now().Unix(),
|
||||
Source: "client_settings",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractModelConfigs(body map[string]any) []map[string]any {
|
||||
data, _ := body["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
settings, _ := bizData["settings"].(map[string]any)
|
||||
modelConfigs, _ := settings["model_configs"].(map[string]any)
|
||||
switch raw := modelConfigs["value"].(type) {
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
var decoded []map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
|
||||
return decoded
|
||||
}
|
||||
var rawItems []any
|
||||
if err := json.Unmarshal([]byte(raw), &rawItems); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stringFromProbeAny(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func boolFromProbeAny(v any) (bool, bool) {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x, true
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(x)) {
|
||||
case "true", "1":
|
||||
return true, true
|
||||
case "false", "0":
|
||||
return false, true
|
||||
}
|
||||
case float64:
|
||||
return x != 0, true
|
||||
case int:
|
||||
return x != 0, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dsprotocol "ds2api/internal/deepseek/protocol"
|
||||
)
|
||||
|
||||
func TestValidateTokenUsesCurrentUserEndpoint(t *testing.T) {
|
||||
var gotURL string
|
||||
var gotAuth string
|
||||
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
|
||||
gotURL = req.URL.String()
|
||||
gotAuth = req.Header.Get("Authorization")
|
||||
return probeResponse(http.StatusOK, `{"code":0,"data":{"biz_code":0}}`, req), nil
|
||||
})}
|
||||
|
||||
result, err := c.ValidateToken(context.Background(), "tok")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken error: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected token valid, got %#v", result)
|
||||
}
|
||||
if gotURL != dsprotocol.DeepSeekCurrentUserURL {
|
||||
t.Fatalf("unexpected endpoint: %s", gotURL)
|
||||
}
|
||||
if gotAuth != "Bearer tok" {
|
||||
t.Fatalf("unexpected auth header: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenMarksInvalidStatus(t *testing.T) {
|
||||
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return probeResponse(http.StatusUnauthorized, `{"code":401,"msg":"invalid token","data":{"biz_code":401}}`, req), nil
|
||||
})}
|
||||
|
||||
result, err := c.ValidateToken(context.Background(), "bad")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken error: %v", err)
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatalf("expected invalid token, got %#v", result)
|
||||
}
|
||||
if result.HTTPStatus != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status 401, got %d", result.HTTPStatus)
|
||||
}
|
||||
if !strings.Contains(result.Message, "invalid token") {
|
||||
t.Fatalf("expected invalid token message, got %q", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountCapabilitiesParsesModelConfigsArray(t *testing.T) {
|
||||
var gotURL string
|
||||
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
|
||||
gotURL = req.URL.String()
|
||||
return probeResponse(http.StatusOK, `{
|
||||
"code":0,
|
||||
"data":{"biz_code":0,"biz_data":{"settings":{"model_configs":{"value":[
|
||||
{"model_type":"chat","switchable":true},
|
||||
{"model_type":"vision","switchable":true},
|
||||
{"model_type":"expert","switchable":false}
|
||||
]}}}}
|
||||
}`, req), nil
|
||||
})}
|
||||
|
||||
result, err := c.GetAccountCapabilities(context.Background(), "tok", "u@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountCapabilities error: %v", err)
|
||||
}
|
||||
if result.Vision == nil || !*result.Vision {
|
||||
t.Fatalf("expected vision switchable, got %#v", result.Vision)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Models, []string{"chat", "expert", "vision"}) {
|
||||
t.Fatalf("unexpected models: %#v", result.Models)
|
||||
}
|
||||
if result.Source != "client_settings" {
|
||||
t.Fatalf("unexpected source: %q", result.Source)
|
||||
}
|
||||
if !strings.HasPrefix(gotURL, dsprotocol.DeepSeekClientSettingsURL+"?") {
|
||||
t.Fatalf("unexpected endpoint: %s", gotURL)
|
||||
}
|
||||
if !strings.Contains(gotURL, "scope=model") || !strings.Contains(gotURL, "did=") {
|
||||
t.Fatalf("expected scope and did query, got %s", gotURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountCapabilitiesParsesModelConfigsString(t *testing.T) {
|
||||
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return probeResponse(http.StatusOK, `{
|
||||
"code":0,
|
||||
"data":{"biz_code":0,"biz_data":{"settings":{"model_configs":{"value":"[{\"model_type\":\"vision\",\"switchable\":\"false\"}]"}}}}
|
||||
}`, req), nil
|
||||
})}
|
||||
|
||||
result, err := c.GetAccountCapabilities(context.Background(), "tok", "u@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountCapabilities error: %v", err)
|
||||
}
|
||||
if result.Vision == nil || *result.Vision {
|
||||
t.Fatalf("expected vision switchable=false, got %#v", result.Vision)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Models, []string{"vision"}) {
|
||||
t.Fatalf("unexpected models: %#v", result.Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountCapabilitiesMarksVisionUnavailableWhenAbsent(t *testing.T) {
|
||||
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return probeResponse(http.StatusOK, `{
|
||||
"code":0,
|
||||
"data":{"biz_code":0,"biz_data":{"settings":{"model_configs":{"value":[
|
||||
{"model_type":"chat","switchable":true},
|
||||
{"model_type":"expert","switchable":true}
|
||||
]}}}}
|
||||
}`, req), nil
|
||||
})}
|
||||
|
||||
result, err := c.GetAccountCapabilities(context.Background(), "tok", "u@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountCapabilities error: %v", err)
|
||||
}
|
||||
if result.Vision == nil || *result.Vision {
|
||||
t.Fatalf("expected vision unavailable, got %#v", result.Vision)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Models, []string{"chat", "expert"}) {
|
||||
t.Fatalf("unexpected models: %#v", result.Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountMuteStatusParsesNestedChatMute(t *testing.T) {
|
||||
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return probeResponse(http.StatusOK, `{
|
||||
"code":0,
|
||||
"data":{"biz_code":0,"biz_data":{"chat":{"is_muted":1,"mute_until":1234.5}}}
|
||||
}`, req), nil
|
||||
})}
|
||||
|
||||
result, err := c.GetAccountMuteStatus(context.Background(), "tok")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountMuteStatus error: %v", err)
|
||||
}
|
||||
if !result.Muted || result.MuteUntil != 1234.5 {
|
||||
t.Fatalf("unexpected mute status: %#v", result)
|
||||
}
|
||||
if result.Source != "users_current" {
|
||||
t.Fatalf("unexpected source: %q", result.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountMuteStatusReturnsMutedBizFailure(t *testing.T) {
|
||||
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return probeResponse(http.StatusOK, `{
|
||||
"code":0,
|
||||
"data":{"biz_code":5,"biz_msg":"muted","biz_data":{"chat":{"is_muted":true,"mute_until":"4567"}}}
|
||||
}`, req), nil
|
||||
})}
|
||||
|
||||
result, err := c.GetAccountMuteStatus(context.Background(), "tok")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountMuteStatus error: %v", err)
|
||||
}
|
||||
if !result.Muted || result.MuteUntil != 4567 {
|
||||
t.Fatalf("unexpected mute status: %#v", result)
|
||||
}
|
||||
if result.BizCode != 5 {
|
||||
t.Fatalf("expected biz_code 5, got %d", result.BizCode)
|
||||
}
|
||||
}
|
||||
|
||||
func probeResponse(status int, body string, req *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,32 @@ func extractMuteInfo(resp map[string]any) muteInfo {
|
||||
_, bizCode, msg, bizMsg := extractResponseStatus(resp)
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
isMuted := intFrom(bizData["is_muted"]) == 1
|
||||
chat, _ := bizData["chat"].(map[string]any)
|
||||
isMuted := muteFlagFrom(bizData["is_muted"]) || muteFlagFrom(chat["is_muted"])
|
||||
muteUntil := floatFrom(bizData["mute_until"])
|
||||
if muteUntil == 0 {
|
||||
muteUntil = floatFrom(chat["mute_until"])
|
||||
}
|
||||
combined := strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
|
||||
if bizCode == 5 || isMuted || strings.Contains(combined, "muted") {
|
||||
return muteInfo{Muted: true, Until: floatFrom(bizData["mute_until"])}
|
||||
if bizCode == 5 || isMuted || strings.Contains(combined, "muted") || strings.Contains(combined, "禁言") {
|
||||
return muteInfo{Muted: true, Until: muteUntil}
|
||||
}
|
||||
return muteInfo{}
|
||||
}
|
||||
|
||||
func muteFlagFrom(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(x)) {
|
||||
case "true", "1", "yes":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return intFrom(v) == 1
|
||||
}
|
||||
|
||||
func floatFrom(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
const (
|
||||
DeepSeekHost = "chat.deepseek.com"
|
||||
DeepSeekLoginURL = "https://chat.deepseek.com/api/v0/users/login"
|
||||
DeepSeekCurrentUserURL = "https://chat.deepseek.com/api/v0/users/current"
|
||||
DeepSeekClientSettingsURL = "https://chat.deepseek.com/api/v0/client/settings"
|
||||
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
|
||||
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
|
||||
DeepSeekHifLeimURL = "https://hif-leim.deepseek.com/query"
|
||||
|
||||
@@ -60,7 +60,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
for _, acc := range accounts[start:end] {
|
||||
testStatus, _ := h.Store.AccountTestStatus(acc.Identifier())
|
||||
token := strings.TrimSpace(acc.Token)
|
||||
items = append(items, map[string]any{
|
||||
item := map[string]any{
|
||||
"identifier": acc.Identifier(),
|
||||
"name": acc.Name,
|
||||
"remark": acc.Remark,
|
||||
@@ -75,7 +75,18 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
"muted": acc.IsMuted(now),
|
||||
"mute_until": acc.MuteUntil,
|
||||
"last_used": acc.LastUsed,
|
||||
})
|
||||
}
|
||||
if probe, ok := h.Store.AccountRuntimeProbe(acc.Identifier()); ok {
|
||||
item["runtime_probe"] = runtimeProbeResponseMap(probe)
|
||||
item["token_valid"] = boolPtrValue(probe.TokenValid)
|
||||
item["token_checked_at"] = probe.CheckedAt
|
||||
item["token_status"] = tokenStatusResponseMap(probe)
|
||||
item["capabilities"] = capabilityProbeResponseMap(probe.Capabilities)
|
||||
if probe.CapabilityError != "" {
|
||||
item["capability_error"] = probe.CapabilityError
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func runtimeProbeResponseMap(probe config.AccountRuntimeProbe) map[string]any {
|
||||
return map[string]any{
|
||||
"token_valid": boolPtrValue(probe.TokenValid),
|
||||
"token_http_status": probe.TokenHTTPStatus,
|
||||
"token_code": probe.TokenCode,
|
||||
"token_biz_code": probe.TokenBizCode,
|
||||
"token_message": probe.TokenMessage,
|
||||
"capabilities": capabilityProbeResponseMap(probe.Capabilities),
|
||||
"capability_error": probe.CapabilityError,
|
||||
"checked_at": probe.CheckedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func tokenStatusResponseMap(probe config.AccountRuntimeProbe) map[string]any {
|
||||
return map[string]any{
|
||||
"valid": boolPtrValue(probe.TokenValid),
|
||||
"http_status": probe.TokenHTTPStatus,
|
||||
"code": probe.TokenCode,
|
||||
"biz_code": probe.TokenBizCode,
|
||||
"message": probe.TokenMessage,
|
||||
"checked_at": probe.CheckedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func capabilityProbeResponseMap(cap config.AccountCapabilityProbe) map[string]any {
|
||||
return map[string]any{
|
||||
"vision": boolPtrValue(cap.Vision),
|
||||
"models": slices.Clone(cap.Models),
|
||||
"checked_at": cap.CheckedAt,
|
||||
"source": cap.Source,
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeProbeHasData(probe config.AccountRuntimeProbe) bool {
|
||||
return probe.TokenValid != nil ||
|
||||
probe.CheckedAt != 0 ||
|
||||
probe.Capabilities.Vision != nil ||
|
||||
len(probe.Capabilities.Models) > 0 ||
|
||||
probe.Capabilities.CheckedAt != 0 ||
|
||||
probe.CapabilityError != ""
|
||||
}
|
||||
|
||||
func boolPtrValue(v *bool) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authn "ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
model, _ := req["model"].(string)
|
||||
message, _ := req["message"].(string)
|
||||
apiKey, _ := req["api_key"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-v4-flash"
|
||||
}
|
||||
if message == "" {
|
||||
message = "你好"
|
||||
}
|
||||
if apiKey == "" {
|
||||
keys := h.Store.Snapshot().Keys
|
||||
if len(keys) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"})
|
||||
return
|
||||
}
|
||||
apiKey = keys[0]
|
||||
}
|
||||
host := r.Host
|
||||
scheme := "http"
|
||||
if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false}
|
||||
b, _ := json.Marshal(payload)
|
||||
request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b))
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
config.Logger.Warn("[admin] close self-test response body failed", "error", err)
|
||||
}
|
||||
}()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var parsed any
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
identifier, _ := req["identifier"].(string)
|
||||
if strings.TrimSpace(identifier) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"})
|
||||
return
|
||||
}
|
||||
acc, ok := findAccountByIdentifier(h.Store, identifier)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, AccountID: acc.Identifier(), Account: acc}
|
||||
proxyCtx := authn.WithAuth(r.Context(), authCtx)
|
||||
token, err := h.DS.Login(proxyCtx, acc)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
authCtx.DeepSeekToken = token
|
||||
|
||||
err = h.DS.DeleteAllSessionsForToken(proxyCtx, token)
|
||||
if err != nil {
|
||||
newToken, loginErr := h.DS.Login(proxyCtx, acc)
|
||||
if loginErr != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
token = newToken
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
authCtx.DeepSeekToken = token
|
||||
if retryErr := h.DS.DeleteAllSessionsForToken(proxyCtx, token); retryErr != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
authn "ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/prompt"
|
||||
"ds2api/internal/promptcompat"
|
||||
@@ -39,22 +36,14 @@ func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
|
||||
return
|
||||
}
|
||||
model, _ := req["model"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-v4-flash"
|
||||
}
|
||||
message, _ := req["message"].(string)
|
||||
result := h.testAccount(r.Context(), acc, model, message)
|
||||
result := h.testAccount(r.Context(), acc, accountTestOptionsFromRequest(req))
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
model, _ := req["model"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-v4-flash"
|
||||
}
|
||||
opts := accountTestOptionsFromRequest(req)
|
||||
accounts := h.Store.Snapshot().Accounts
|
||||
if len(accounts) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}})
|
||||
@@ -64,7 +53,7 @@ func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
// Concurrent testing with a semaphore to limit parallelism.
|
||||
const maxConcurrency = 5
|
||||
results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any {
|
||||
return h.testAccount(r.Context(), account, model, "")
|
||||
return h.testAccount(r.Context(), account, opts)
|
||||
})
|
||||
|
||||
success := 0
|
||||
@@ -76,6 +65,47 @@ func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results})
|
||||
}
|
||||
|
||||
type accountTestOptions struct {
|
||||
Model string
|
||||
Message string
|
||||
Mode string
|
||||
ProbeCapabilities bool
|
||||
}
|
||||
|
||||
func accountTestOptionsFromRequest(req map[string]any) accountTestOptions {
|
||||
model, _ := req["model"].(string)
|
||||
message, _ := req["message"].(string)
|
||||
mode, _ := req["mode"].(string)
|
||||
return normalizeAccountTestOptions(accountTestOptions{
|
||||
Model: model,
|
||||
Message: message,
|
||||
Mode: mode,
|
||||
ProbeCapabilities: boolFromAny(req["probe_capabilities"]),
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeAccountTestOptions(opts accountTestOptions) accountTestOptions {
|
||||
opts.Model = strings.TrimSpace(opts.Model)
|
||||
if opts.Model == "" {
|
||||
opts.Model = "deepseek-v4-flash"
|
||||
}
|
||||
opts.Message = strings.TrimSpace(opts.Message)
|
||||
opts.Mode = strings.ToLower(strings.TrimSpace(opts.Mode))
|
||||
switch opts.Mode {
|
||||
case "token", "session", "message":
|
||||
default:
|
||||
if opts.Message != "" {
|
||||
opts.Mode = "message"
|
||||
} else {
|
||||
opts.Mode = "session"
|
||||
}
|
||||
}
|
||||
if opts.Mode == "message" && opts.Message == "" {
|
||||
opts.Message = "你好"
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = 1
|
||||
@@ -96,25 +126,36 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int,
|
||||
return results
|
||||
}
|
||||
|
||||
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
|
||||
func (h *Handler) testAccount(ctx context.Context, acc config.Account, opts accountTestOptions) map[string]any {
|
||||
start := time.Now()
|
||||
opts = normalizeAccountTestOptions(opts)
|
||||
identifier := acc.Identifier()
|
||||
runtimeProbe := config.AccountRuntimeProbe{}
|
||||
result := map[string]any{
|
||||
"account": identifier,
|
||||
"success": false,
|
||||
"response_time": 0,
|
||||
"message": "",
|
||||
"model": model,
|
||||
"model": opts.Model,
|
||||
"mode": opts.Mode,
|
||||
"session_count": 0,
|
||||
"config_writable": !h.Store.IsEnvBacked(),
|
||||
}
|
||||
defer func() {
|
||||
status := "failed"
|
||||
if ok, _ := result["success"].(bool); ok {
|
||||
if success, _ := result["success"].(bool); success {
|
||||
status = "ok"
|
||||
}
|
||||
_ = h.Store.UpdateAccountTestStatus(identifier, status)
|
||||
if runtimeProbeHasData(runtimeProbe) {
|
||||
_ = h.Store.UpdateAccountRuntimeProbe(identifier, runtimeProbe)
|
||||
}
|
||||
}()
|
||||
|
||||
if opts.Mode == "token" {
|
||||
return h.testAccountTokenMode(ctx, acc, opts, result, &runtimeProbe, start)
|
||||
}
|
||||
|
||||
token, err := h.DS.Login(ctx, acc)
|
||||
if err != nil {
|
||||
result["message"] = "登录失败: " + err.Error()
|
||||
@@ -124,8 +165,11 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
result["message"] = "登录成功但写入运行时 token 失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token, AccountID: identifier, Account: acc}
|
||||
proxyCtx := authn.WithAuth(ctx, authCtx)
|
||||
acc.Token = token
|
||||
proxyCtx, authCtx := accountProbeContext(ctx, acc, identifier, token)
|
||||
if _, tokenProbeErr := h.attachTokenProbe(proxyCtx, token, result, &runtimeProbe); tokenProbeErr != nil {
|
||||
result["token_probe_error"] = tokenProbeErr.Error()
|
||||
}
|
||||
sessionID, err := h.DS.CreateSession(proxyCtx, authCtx, 1)
|
||||
if err != nil {
|
||||
newToken, loginErr := h.DS.Login(proxyCtx, acc)
|
||||
@@ -134,11 +178,16 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
return result
|
||||
}
|
||||
token = newToken
|
||||
acc.Token = token
|
||||
authCtx.DeepSeekToken = token
|
||||
authCtx.Account = acc
|
||||
if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil {
|
||||
result["message"] = "刷新 token 成功但写入运行时 token 失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
if _, tokenProbeErr := h.attachTokenProbe(proxyCtx, token, result, &runtimeProbe); tokenProbeErr != nil {
|
||||
result["token_probe_error"] = tokenProbeErr.Error()
|
||||
}
|
||||
sessionID, err = h.DS.CreateSession(proxyCtx, authCtx, 1)
|
||||
if err != nil {
|
||||
result["message"] = "创建会话失败: " + err.Error()
|
||||
@@ -152,12 +201,17 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
result["session_count"] = sessionStats.FirstPageCount
|
||||
}
|
||||
|
||||
if strings.TrimSpace(message) == "" {
|
||||
if opts.ProbeCapabilities {
|
||||
h.attachCapabilityProbe(proxyCtx, identifier, token, result, &runtimeProbe)
|
||||
}
|
||||
|
||||
if opts.Mode != "message" {
|
||||
result["success"] = true
|
||||
result["message"] = "Token 刷新成功(登录与会话创建成功)"
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
model := opts.Model
|
||||
thinking, search, ok := config.GetModelConfig(model)
|
||||
resolvedModel, resolved := config.ResolveModel(modelAliasSnapshotReader{
|
||||
aliases: h.Store.Snapshot().ModelAliases,
|
||||
@@ -176,7 +230,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
}
|
||||
payload := promptcompat.StandardRequest{
|
||||
ResolvedModel: model,
|
||||
FinalPrompt: prompt.MessagesPrepare([]map[string]any{{"role": "user", "content": message}}),
|
||||
FinalPrompt: prompt.MessagesPrepare([]map[string]any{{"role": "user", "content": opts.Message}}),
|
||||
Thinking: thinking,
|
||||
Search: search,
|
||||
}.CompletionPayload(sessionID)
|
||||
@@ -203,95 +257,3 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
model, _ := req["model"].(string)
|
||||
message, _ := req["message"].(string)
|
||||
apiKey, _ := req["api_key"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-v4-flash"
|
||||
}
|
||||
if message == "" {
|
||||
message = "你好"
|
||||
}
|
||||
if apiKey == "" {
|
||||
keys := h.Store.Snapshot().Keys
|
||||
if len(keys) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"})
|
||||
return
|
||||
}
|
||||
apiKey = keys[0]
|
||||
}
|
||||
host := r.Host
|
||||
scheme := "http"
|
||||
if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false}
|
||||
b, _ := json.Marshal(payload)
|
||||
request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b))
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var parsed any
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
identifier, _ := req["identifier"].(string)
|
||||
if strings.TrimSpace(identifier) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"})
|
||||
return
|
||||
}
|
||||
acc, ok := findAccountByIdentifier(h.Store, identifier)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 每次先登录刷新一次 token,避免使用过期 token。
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, AccountID: acc.Identifier(), Account: acc}
|
||||
proxyCtx := authn.WithAuth(r.Context(), authCtx)
|
||||
token, err := h.DS.Login(proxyCtx, acc)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
authCtx.DeepSeekToken = token
|
||||
|
||||
// 删除所有会话
|
||||
err = h.DS.DeleteAllSessionsForToken(proxyCtx, token)
|
||||
if err != nil {
|
||||
// token 可能过期,尝试重新登录并重试一次
|
||||
newToken, loginErr := h.DS.Login(proxyCtx, acc)
|
||||
if loginErr != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
token = newToken
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
authCtx.DeepSeekToken = token
|
||||
if retryErr := h.DS.DeleteAllSessionsForToken(proxyCtx, token); retryErr != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authn "ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
dsclient "ds2api/internal/deepseek/client"
|
||||
)
|
||||
|
||||
func (h *Handler) testAccountTokenMode(ctx context.Context, acc config.Account, opts accountTestOptions, result map[string]any, runtimeProbe *config.AccountRuntimeProbe, start time.Time) map[string]any {
|
||||
identifier := acc.Identifier()
|
||||
token := strings.TrimSpace(acc.Token)
|
||||
if token != "" {
|
||||
proxyCtx, _ := accountProbeContext(ctx, acc, identifier, token)
|
||||
tokenResult, err := h.attachTokenProbe(proxyCtx, token, result, runtimeProbe)
|
||||
if err != nil {
|
||||
result["message"] = "Token 验证失败: " + err.Error()
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
if tokenResult.Valid {
|
||||
if opts.ProbeCapabilities {
|
||||
h.attachCapabilityProbe(proxyCtx, identifier, token, result, runtimeProbe)
|
||||
}
|
||||
result["success"] = true
|
||||
result["message"] = "Token 验证成功"
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
if strings.TrimSpace(acc.Password) == "" {
|
||||
result["message"] = "Token 无效: " + tokenFailureMessage(tokenResult)
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(acc.Password) == "" {
|
||||
if token == "" {
|
||||
applyTokenProbeResult(result, runtimeProbe, &dsclient.TokenValidationResult{Valid: false, Message: "empty token"})
|
||||
}
|
||||
result["message"] = "没有可验证的 token,且账号缺少密码,无法刷新"
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
|
||||
newToken, err := h.DS.Login(ctx, acc)
|
||||
if err != nil {
|
||||
result["message"] = "登录失败: " + err.Error()
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
if err := h.Store.UpdateAccountToken(identifier, newToken); err != nil {
|
||||
result["message"] = "登录成功但写入运行时 token 失败: " + err.Error()
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
acc.Token = newToken
|
||||
proxyCtx, _ := accountProbeContext(ctx, acc, identifier, newToken)
|
||||
tokenResult, err := h.attachTokenProbe(proxyCtx, newToken, result, runtimeProbe)
|
||||
if err != nil {
|
||||
result["message"] = "Token 验证失败: " + err.Error()
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
if !tokenResult.Valid {
|
||||
result["message"] = "Token 无效: " + tokenFailureMessage(tokenResult)
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
if opts.ProbeCapabilities {
|
||||
h.attachCapabilityProbe(proxyCtx, identifier, newToken, result, runtimeProbe)
|
||||
}
|
||||
result["success"] = true
|
||||
result["message"] = "Token 刷新并验证成功"
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
|
||||
func accountProbeContext(ctx context.Context, acc config.Account, identifier, token string) (context.Context, *authn.RequestAuth) {
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token, AccountID: identifier, Account: acc}
|
||||
return authn.WithAuth(ctx, authCtx), authCtx
|
||||
}
|
||||
|
||||
func (h *Handler) attachTokenProbe(ctx context.Context, token string, result map[string]any, runtimeProbe *config.AccountRuntimeProbe) (*dsclient.TokenValidationResult, error) {
|
||||
tokenResult, err := h.DS.ValidateToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyTokenProbeResult(result, runtimeProbe, tokenResult)
|
||||
return tokenResult, nil
|
||||
}
|
||||
|
||||
func applyTokenProbeResult(result map[string]any, runtimeProbe *config.AccountRuntimeProbe, tokenResult *dsclient.TokenValidationResult) {
|
||||
if tokenResult == nil {
|
||||
return
|
||||
}
|
||||
runtimeProbe.TokenValid = boolPtr(tokenResult.Valid)
|
||||
runtimeProbe.TokenHTTPStatus = tokenResult.HTTPStatus
|
||||
runtimeProbe.TokenCode = tokenResult.Code
|
||||
runtimeProbe.TokenBizCode = tokenResult.BizCode
|
||||
runtimeProbe.TokenMessage = tokenResult.Message
|
||||
runtimeProbe.CheckedAt = time.Now().Unix()
|
||||
result["token_valid"] = tokenResult.Valid
|
||||
result["token_status"] = tokenStatusResponseMap(*runtimeProbe)
|
||||
}
|
||||
|
||||
func (h *Handler) attachCapabilityProbe(ctx context.Context, identifier, token string, result map[string]any, runtimeProbe *config.AccountRuntimeProbe) {
|
||||
capabilities, err := h.DS.GetAccountCapabilities(ctx, token, identifier)
|
||||
if err != nil {
|
||||
runtimeProbe.CapabilityError = err.Error()
|
||||
result["capability_error"] = err.Error()
|
||||
return
|
||||
}
|
||||
capProbe := config.AccountCapabilityProbe{
|
||||
Vision: cloneBoolPtr(capabilities.Vision),
|
||||
Models: slices.Clone(capabilities.Models),
|
||||
CheckedAt: capabilities.CheckedAt,
|
||||
Source: capabilities.Source,
|
||||
}
|
||||
runtimeProbe.Capabilities = capProbe
|
||||
result["capabilities"] = capabilityProbeResponseMap(capProbe)
|
||||
}
|
||||
|
||||
func cloneBoolPtr(v *bool) *bool {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
out := *v
|
||||
return &out
|
||||
}
|
||||
|
||||
func tokenFailureMessage(result *dsclient.TokenValidationResult) string {
|
||||
if result == nil {
|
||||
return "unknown failure"
|
||||
}
|
||||
if strings.TrimSpace(result.Message) != "" {
|
||||
return result.Message
|
||||
}
|
||||
if result.HTTPStatus != 0 {
|
||||
return fmt.Sprintf("HTTP %d", result.HTTPStatus)
|
||||
}
|
||||
return "invalid token"
|
||||
}
|
||||
@@ -22,6 +22,8 @@ type testingDSMock struct {
|
||||
getPowCalls int
|
||||
callCompletionCalls int
|
||||
deleteAllSessionsCalls int
|
||||
validateTokenCalls int
|
||||
capabilityCalls int
|
||||
deleteAllSessionsError error
|
||||
deleteAllSessionsErrorOnce bool
|
||||
}
|
||||
@@ -62,6 +64,17 @@ func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*d
|
||||
return &dsclient.SessionStats{Success: true}, nil
|
||||
}
|
||||
|
||||
func (m *testingDSMock) ValidateToken(_ context.Context, token string) (*dsclient.TokenValidationResult, error) {
|
||||
m.validateTokenCalls++
|
||||
return &dsclient.TokenValidationResult{Valid: strings.TrimSpace(token) != "", HTTPStatus: http.StatusOK}, nil
|
||||
}
|
||||
|
||||
func (m *testingDSMock) GetAccountCapabilities(_ context.Context, _ string, _ string) (*dsclient.AccountCapabilities, error) {
|
||||
m.capabilityCalls++
|
||||
vision := true
|
||||
return &dsclient.AccountCapabilities{Vision: &vision, Models: []string{"chat", "vision"}, CheckedAt: 123, Source: "client_settings"}, nil
|
||||
}
|
||||
|
||||
func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
|
||||
store := config.LoadStore()
|
||||
@@ -72,7 +85,7 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
|
||||
t.Fatal("expected test account")
|
||||
}
|
||||
|
||||
result := h.testAccount(context.Background(), acc, "deepseek-v4-flash", "")
|
||||
result := h.testAccount(context.Background(), acc, accountTestOptions{Model: "deepseek-v4-flash", Mode: "session"})
|
||||
|
||||
if ok, _ := result["success"].(bool); !ok {
|
||||
t.Fatalf("expected success=true, got %#v", result)
|
||||
@@ -87,6 +100,9 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
|
||||
if ds.getPowCalls != 0 || ds.callCompletionCalls != 0 {
|
||||
t.Fatalf("expected no completion flow calls, got getPow=%d callCompletion=%d", ds.getPowCalls, ds.callCompletionCalls)
|
||||
}
|
||||
if ds.validateTokenCalls != 1 || ds.capabilityCalls != 0 {
|
||||
t.Fatalf("unexpected probe calls: validate=%d capability=%d", ds.validateTokenCalls, ds.capabilityCalls)
|
||||
}
|
||||
updated, ok := store.FindAccount("batch@example.com")
|
||||
if !ok {
|
||||
t.Fatal("expected updated account")
|
||||
@@ -100,6 +116,77 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestAccount_ProbeCapabilitiesStoresRuntimeProbe(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
|
||||
store := config.LoadStore()
|
||||
ds := &testingDSMock{}
|
||||
h := &Handler{Store: store, DS: ds}
|
||||
acc, ok := store.FindAccount("batch@example.com")
|
||||
if !ok {
|
||||
t.Fatal("expected test account")
|
||||
}
|
||||
|
||||
result := h.testAccount(context.Background(), acc, accountTestOptions{
|
||||
Model: "deepseek-v4-flash",
|
||||
Mode: "session",
|
||||
ProbeCapabilities: true,
|
||||
})
|
||||
|
||||
if ok, _ := result["success"].(bool); !ok {
|
||||
t.Fatalf("expected success=true, got %#v", result)
|
||||
}
|
||||
if ds.validateTokenCalls != 1 || ds.capabilityCalls != 1 {
|
||||
t.Fatalf("expected token and capability probe, got validate=%d capability=%d", ds.validateTokenCalls, ds.capabilityCalls)
|
||||
}
|
||||
probe, ok := store.AccountRuntimeProbe("batch@example.com")
|
||||
if !ok {
|
||||
t.Fatal("expected runtime probe cached")
|
||||
}
|
||||
if probe.TokenValid == nil || !*probe.TokenValid {
|
||||
t.Fatalf("expected token valid probe, got %#v", probe)
|
||||
}
|
||||
if probe.Capabilities.Vision == nil || !*probe.Capabilities.Vision {
|
||||
t.Fatalf("expected vision capability, got %#v", probe.Capabilities)
|
||||
}
|
||||
if !containsString(probe.Capabilities.Models, "vision") {
|
||||
t.Fatalf("expected vision model in capability list, got %#v", probe.Capabilities.Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestAccount_TokenModeOnlyValidatesExistingToken(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
|
||||
store := config.LoadStore()
|
||||
if err := store.UpdateAccountToken("batch@example.com", "seed-token"); err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
ds := &testingDSMock{}
|
||||
h := &Handler{Store: store, DS: ds}
|
||||
acc, ok := store.FindAccount("batch@example.com")
|
||||
if !ok {
|
||||
t.Fatal("expected test account")
|
||||
}
|
||||
|
||||
result := h.testAccount(context.Background(), acc, accountTestOptions{
|
||||
Model: "deepseek-v4-flash",
|
||||
Mode: "token",
|
||||
ProbeCapabilities: true,
|
||||
})
|
||||
|
||||
if ok, _ := result["success"].(bool); !ok {
|
||||
t.Fatalf("expected success=true, got %#v", result)
|
||||
}
|
||||
if ds.loginCalls != 0 || ds.createSessionCalls != 0 || ds.getPowCalls != 0 || ds.callCompletionCalls != 0 {
|
||||
t.Fatalf("token mode should not call login/session/completion: login=%d session=%d pow=%d completion=%d", ds.loginCalls, ds.createSessionCalls, ds.getPowCalls, ds.callCompletionCalls)
|
||||
}
|
||||
if ds.validateTokenCalls != 1 || ds.capabilityCalls != 1 {
|
||||
t.Fatalf("expected token and capability probe, got validate=%d capability=%d", ds.validateTokenCalls, ds.capabilityCalls)
|
||||
}
|
||||
msg, _ := result["message"].(string)
|
||||
if !strings.Contains(msg, "Token 验证成功") {
|
||||
t.Fatalf("expected token validation success message, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAllSessions_RetryWithReloginOnDeleteFailure(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"expired-token"}]}`)
|
||||
store := config.LoadStore()
|
||||
@@ -167,6 +254,14 @@ func (m *completionPayloadDSMock) GetSessionCountForToken(_ context.Context, _ s
|
||||
return &dsclient.SessionStats{Success: true}, nil
|
||||
}
|
||||
|
||||
func (m *completionPayloadDSMock) ValidateToken(_ context.Context, token string) (*dsclient.TokenValidationResult, error) {
|
||||
return &dsclient.TokenValidationResult{Valid: strings.TrimSpace(token) != "", HTTPStatus: http.StatusOK}, nil
|
||||
}
|
||||
|
||||
func (m *completionPayloadDSMock) GetAccountCapabilities(_ context.Context, _ string, _ string) (*dsclient.AccountCapabilities, error) {
|
||||
return &dsclient.AccountCapabilities{Source: "client_settings"}, nil
|
||||
}
|
||||
|
||||
func TestTestAccount_MessageModeUsesExpertModelTypeForExpertModel(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"seed-token"}]}`)
|
||||
store := config.LoadStore()
|
||||
@@ -177,7 +272,7 @@ func TestTestAccount_MessageModeUsesExpertModelTypeForExpertModel(t *testing.T)
|
||||
t.Fatal("expected test account")
|
||||
}
|
||||
|
||||
result := h.testAccount(context.Background(), acc, "deepseek-v4-pro", "hello")
|
||||
result := h.testAccount(context.Background(), acc, accountTestOptions{Model: "deepseek-v4-pro", Message: "hello", Mode: "message"})
|
||||
|
||||
if ok, _ := result["success"].(bool); !ok {
|
||||
t.Fatalf("expected success=true, got %#v", result)
|
||||
@@ -200,7 +295,7 @@ func TestTestAccount_MessageModeUsesVisionModelTypeForVisionModel(t *testing.T)
|
||||
t.Fatal("expected test account")
|
||||
}
|
||||
|
||||
result := h.testAccount(context.Background(), acc, "deepseek-v4-vision", "hello")
|
||||
result := h.testAccount(context.Background(), acc, accountTestOptions{Model: "deepseek-v4-vision", Message: "hello", Mode: "message"})
|
||||
|
||||
if ok, _ := result["success"].(bool); !ok {
|
||||
t.Fatalf("expected success=true, got %#v", result)
|
||||
@@ -209,3 +304,12 @@ func TestTestAccount_MessageModeUsesVisionModelTypeForVisionModel(t *testing.T)
|
||||
t.Fatalf("expected model_type vision, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package accounts
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -26,7 +27,11 @@ func RunAccountTestsConcurrently(accounts []config.Account, maxConcurrency int,
|
||||
}
|
||||
|
||||
func (h *Handler) TestAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
|
||||
return h.testAccount(ctx, acc, model, message)
|
||||
opts := accountTestOptions{Model: model, Message: message}
|
||||
if strings.TrimSpace(message) != "" {
|
||||
opts.Mode = "message"
|
||||
}
|
||||
return h.testAccount(ctx, acc, opts)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAccounts(w http.ResponseWriter, r *http.Request) { h.listAccounts(w, r) }
|
||||
|
||||
@@ -115,6 +115,9 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
|
||||
if incoming.Runtime.TokenRefreshIntervalHours > 0 {
|
||||
next.Runtime.TokenRefreshIntervalHours = incoming.Runtime.TokenRefreshIntervalHours
|
||||
}
|
||||
if incoming.Runtime.AccountMuteScanIntervalSeconds > 0 {
|
||||
next.Runtime.AccountMuteScanIntervalSeconds = incoming.Runtime.AccountMuteScanIntervalSeconds
|
||||
}
|
||||
}
|
||||
|
||||
normalizeSettingsConfig(&next)
|
||||
|
||||
@@ -35,6 +35,12 @@ func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) e
|
||||
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) {
|
||||
return &dsclient.SessionStats{}, nil
|
||||
}
|
||||
func (m *testingDSMock) ValidateToken(_ context.Context, token string) (*dsclient.TokenValidationResult, error) {
|
||||
return &dsclient.TokenValidationResult{Valid: token != "", HTTPStatus: http.StatusOK}, nil
|
||||
}
|
||||
func (m *testingDSMock) GetAccountCapabilities(_ context.Context, _ string, _ string) (*dsclient.AccountCapabilities, error) {
|
||||
return &dsclient.AccountCapabilities{Source: "client_settings"}, nil
|
||||
}
|
||||
|
||||
func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
@@ -18,6 +18,8 @@ type ConfigStore interface {
|
||||
UpdateAccountToken(identifier, token string) error
|
||||
UpdateAccountTestStatus(identifier, status string) error
|
||||
AccountTestStatus(identifier string) (string, bool)
|
||||
UpdateAccountRuntimeProbe(identifier string, probe config.AccountRuntimeProbe) error
|
||||
AccountRuntimeProbe(identifier string) (config.AccountRuntimeProbe, bool)
|
||||
Update(mutator func(*config.Config) error) error
|
||||
ExportJSONAndBase64() (string, string, error)
|
||||
IsEnvBacked() bool
|
||||
@@ -68,6 +70,8 @@ type DeepSeekCaller interface {
|
||||
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
|
||||
GetSessionCountForToken(ctx context.Context, token string) (*dsclient.SessionStats, error)
|
||||
DeleteAllSessionsForToken(ctx context.Context, token string) error
|
||||
ValidateToken(ctx context.Context, token string) (*dsclient.TokenValidationResult, error)
|
||||
GetAccountCapabilities(ctx context.Context, token string, accountID string) (*dsclient.AccountCapabilities, error)
|
||||
}
|
||||
|
||||
var _ ConfigStore = (*config.Store)(nil)
|
||||
|
||||
@@ -82,6 +82,14 @@ func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*d
|
||||
return &dsclient.SessionStats{}, nil
|
||||
}
|
||||
|
||||
func (m *testingDSMock) ValidateToken(_ context.Context, token string) (*dsclient.TokenValidationResult, error) {
|
||||
return &dsclient.TokenValidationResult{Valid: token != "", HTTPStatus: http.StatusOK}, nil
|
||||
}
|
||||
|
||||
func (m *testingDSMock) GetAccountCapabilities(_ context.Context, _ string, _ string) (*dsclient.AccountCapabilities, error) {
|
||||
return &dsclient.AccountCapabilities{Source: "client_settings"}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) configHandler() *adminconfig.Handler {
|
||||
return &adminconfig.Handler{Store: h.Store, Pool: h.Pool, DS: h.DS, OpenAI: h.OpenAI, ChatHistory: h.ChatHistory}
|
||||
}
|
||||
|
||||
@@ -104,10 +104,6 @@ func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int,
|
||||
return shared.UpstreamEmptyOutputDetail(contentFilter, text, thinking)
|
||||
}
|
||||
|
||||
func writeUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string, contentFilter bool) bool {
|
||||
return shared.WriteUpstreamEmptyOutputError(w, text, thinking, contentFilter)
|
||||
}
|
||||
|
||||
func shouldPenalizeUpstreamEmptyOutput(status int, code string) bool {
|
||||
return shared.ShouldPenalizeUpstreamEmptyOutput(status, code)
|
||||
}
|
||||
|
||||
@@ -119,6 +119,10 @@ async function handleVercelStream(req, res, rawBody, payload) {
|
||||
if (!completionRes.ok || !completionRes.body) {
|
||||
const detail = completionRes.body ? await completionRes.text() : '';
|
||||
const status = completionRes.ok ? 500 : completionRes.status || 500;
|
||||
const penalty = completionPenaltyForStatus(status);
|
||||
if (penalty) {
|
||||
await releaseLease(penalty);
|
||||
}
|
||||
writeOpenAIError(res, status, detail);
|
||||
return;
|
||||
}
|
||||
@@ -396,6 +400,20 @@ function upstreamEmptyOutputDetail(contentFilter, _text, thinking) {
|
||||
};
|
||||
}
|
||||
|
||||
function completionPenaltyForStatus(status) {
|
||||
const value = Number(status) || 0;
|
||||
if (value === 429) {
|
||||
return 'http_429';
|
||||
}
|
||||
if (value === 403) {
|
||||
return 'http_403';
|
||||
}
|
||||
if (value >= 500 && value <= 599) {
|
||||
return 'http_5xx';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function sendFailedChunk(res, status, message, code) {
|
||||
res.write(`data: ${JSON.stringify({
|
||||
status_code: status,
|
||||
@@ -416,4 +434,5 @@ function sendFailedChunk(res, status, message, code) {
|
||||
|
||||
module.exports = {
|
||||
handleVercelStream,
|
||||
completionPenaltyForStatus,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/account/mutescan"
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/chathistory"
|
||||
"ds2api/internal/config"
|
||||
@@ -35,6 +36,7 @@ type App struct {
|
||||
Pool *account.Pool
|
||||
Resolver *auth.Resolver
|
||||
DS *dsclient.Client
|
||||
MuteScan *mutescan.Scanner
|
||||
Router http.Handler
|
||||
}
|
||||
|
||||
@@ -54,6 +56,11 @@ func NewApp() (*App, error) {
|
||||
} else {
|
||||
config.Logger.Info("[PoW] pure Go solver ready")
|
||||
}
|
||||
var muteScan *mutescan.Scanner
|
||||
if !config.IsVercel() {
|
||||
interval := time.Duration(store.RuntimeAccountMuteScanIntervalSeconds()) * time.Second
|
||||
muteScan = mutescan.New(store, dsClient, pool, interval)
|
||||
}
|
||||
chatHistoryStore := chathistory.New(config.ChatHistoryPath())
|
||||
if err := chatHistoryStore.Err(); err != nil {
|
||||
config.Logger.Warn("[chat_history] unavailable", "path", chatHistoryStore.Path(), "error", err)
|
||||
@@ -111,7 +118,14 @@ func NewApp() (*App, error) {
|
||||
http.NotFound(w, req)
|
||||
})
|
||||
|
||||
return &App{Store: store, Pool: pool, Resolver: resolver, DS: dsClient, Router: r}, nil
|
||||
return &App{Store: store, Pool: pool, Resolver: resolver, DS: dsClient, MuteScan: muteScan, Router: r}, nil
|
||||
}
|
||||
|
||||
func (a *App) StartBackground(ctx context.Context) {
|
||||
if a == nil || a.MuteScan == nil {
|
||||
return
|
||||
}
|
||||
a.MuteScan.Start(ctx)
|
||||
}
|
||||
|
||||
func timeout(d time.Duration) func(http.Handler) http.Handler {
|
||||
|
||||
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
|
||||
const handler = require('../../api/chat-stream.js');
|
||||
const { handleVercelStream } = require('../../internal/js/chat-stream/vercel_stream.js');
|
||||
const { handleVercelStream, completionPenaltyForStatus } = require('../../internal/js/chat-stream/vercel_stream.js');
|
||||
const {
|
||||
createToolSieveState,
|
||||
processToolSieveChunk,
|
||||
@@ -126,7 +126,7 @@ function releasePayload(fetchCalls) {
|
||||
return JSON.parse(Buffer.from(releaseCall.options.body).toString('utf8'));
|
||||
}
|
||||
|
||||
async function runMockVercelStream(upstreamLines, prepareOverrides = {}) {
|
||||
async function runMockVercelStream(upstreamLines, prepareOverrides = {}, options = {}) {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchURLs = [];
|
||||
const fetchCalls = [];
|
||||
@@ -144,10 +144,10 @@ async function runMockVercelStream(upstreamLines, prepareOverrides = {}) {
|
||||
payload: { prompt: 'hello' },
|
||||
...prepareOverrides,
|
||||
};
|
||||
global.fetch = async (url, options = {}) => {
|
||||
global.fetch = async (url, fetchOptions = {}) => {
|
||||
const textURL = String(url);
|
||||
fetchURLs.push(textURL);
|
||||
fetchCalls.push({ url: textURL, options });
|
||||
fetchCalls.push({ url: textURL, options: fetchOptions });
|
||||
if (textURL.includes('__stream_prepare=1')) {
|
||||
return jsonResponse(prepareBody);
|
||||
}
|
||||
@@ -157,7 +157,7 @@ async function runMockVercelStream(upstreamLines, prepareOverrides = {}) {
|
||||
if (textURL.includes('hif-leim.deepseek.com/query')) {
|
||||
return jsonResponse({ code: 0, data: { biz_code: 0, biz_data: { value: 'node-hif-value' } } });
|
||||
}
|
||||
return sseResponse(upstreamLines);
|
||||
return options.upstreamResponse || sseResponse(upstreamLines);
|
||||
};
|
||||
try {
|
||||
const req = new MockStreamRequest();
|
||||
@@ -218,6 +218,22 @@ test('vercel stream adds hif-leim header to DeepSeek completion request', async
|
||||
assert.equal(completionCall.options.headers['x-hif-leim'], 'node-hif-value');
|
||||
});
|
||||
|
||||
test('completionPenaltyForStatus maps retryable upstream statuses', () => {
|
||||
assert.equal(completionPenaltyForStatus(429), 'http_429');
|
||||
assert.equal(completionPenaltyForStatus(403), 'http_403');
|
||||
assert.equal(completionPenaltyForStatus(503), 'http_5xx');
|
||||
assert.equal(completionPenaltyForStatus(400), '');
|
||||
assert.equal(completionPenaltyForStatus(0), '');
|
||||
});
|
||||
|
||||
test('vercel stream penalizes lease on upstream 503 response', async () => {
|
||||
const { res, fetchCalls } = await runMockVercelStream([], {}, {
|
||||
upstreamResponse: new Response('busy', { status: 503 }),
|
||||
});
|
||||
assert.equal(res.statusCode, 503);
|
||||
assert.equal(releasePayload(fetchCalls).penalty, 'http_5xx');
|
||||
});
|
||||
|
||||
test('resolveToolcallPolicy defaults to feature-match + early emit when prepare flags missing', () => {
|
||||
const policy = resolveToolcallPolicy(
|
||||
{},
|
||||
|
||||
@@ -142,6 +142,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
envBacked={Boolean(config?.env_backed)}
|
||||
queueStatus={queueStatus}
|
||||
/>
|
||||
|
||||
<AddKeyModal
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Check, Copy, Pencil, Play, Plus, Trash2, FolderX } from 'lucide-react'
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, Check, Copy, Eye, Loader2, Pencil, Play, Plus, ShieldCheck, Timer, Trash2, FolderX } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
function normalizeID(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function queueAccountID(item) {
|
||||
return normalizeID(item?.id || item?.account || item?.identifier || item?.email || item?.mobile)
|
||||
}
|
||||
|
||||
function numberValue(value) {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
export default function AccountsTable({
|
||||
t,
|
||||
accounts,
|
||||
@@ -31,6 +44,7 @@ export default function AccountsTable({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
envBacked = false,
|
||||
queueStatus,
|
||||
}) {
|
||||
const [copiedId, setCopiedId] = useState(null)
|
||||
|
||||
@@ -40,6 +54,13 @@ export default function AccountsTable({
|
||||
setTimeout(() => setCopiedId(null), 1500)
|
||||
})
|
||||
}
|
||||
|
||||
const queueHealthByID = new Map(
|
||||
(queueStatus?.accounts || [])
|
||||
.map(item => [queueAccountID(item), item])
|
||||
.filter(([key]) => key)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
@@ -109,11 +130,20 @@ export default function AccountsTable({
|
||||
const assignedProxy = proxies.find(proxy => proxy.id === acc.proxy_id)
|
||||
const runtimeUnknown = envBacked && !acc.test_status
|
||||
const isActive = acc.test_status === 'ok' || acc.has_token
|
||||
const health = queueHealthByID.get(id) || {}
|
||||
const capabilities = acc.capabilities || acc.runtime_probe?.capabilities || {}
|
||||
const tokenValid = acc.token_valid ?? acc.runtime_probe?.token_valid ?? acc.token_status?.valid
|
||||
const visionCapability = capabilities.vision
|
||||
const cooldownRemaining = numberValue(health.cooldown_remaining)
|
||||
const failureCount = numberValue(health.failure_count)
|
||||
const lastFailureKind = String(health.last_failure_kind || '').trim()
|
||||
const muted = Boolean(acc.muted || health.muted)
|
||||
return (
|
||||
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={clsx(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
muted ? "bg-slate-400" :
|
||||
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
|
||||
isActive ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
|
||||
runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
|
||||
@@ -133,7 +163,7 @@ export default function AccountsTable({
|
||||
{acc.remark && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">{acc.remark}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground mt-0.5 max-w-full">
|
||||
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
|
||||
{acc.token_preview && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
@@ -164,6 +194,50 @@ export default function AccountsTable({
|
||||
{t('accountManager.proxyBadge', { name: assignedProxy ? (assignedProxy.name || `${assignedProxy.host}:${assignedProxy.port}`) : acc.proxy_id })}
|
||||
</span>
|
||||
)}
|
||||
{(tokenValid === true || tokenValid === false || acc.token_checked_at) && (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1 font-mono px-1.5 py-0.5 rounded text-[10px]",
|
||||
tokenValid === false ? "bg-red-500/10 text-red-500" :
|
||||
tokenValid === true ? "bg-emerald-500/10 text-emerald-500" :
|
||||
"bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<ShieldCheck className="w-3 h-3" />
|
||||
{tokenValid === false ? t('accountManager.tokenInvalid') : tokenValid === true ? t('accountManager.tokenValid') : t('accountManager.tokenUnknown')}
|
||||
</span>
|
||||
)}
|
||||
{(visionCapability === true || visionCapability === false || capabilities.checked_at) && (
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1 font-mono px-1.5 py-0.5 rounded text-[10px]",
|
||||
visionCapability === true ? "bg-sky-500/10 text-sky-500" :
|
||||
visionCapability === false ? "bg-muted text-muted-foreground" :
|
||||
"bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<Eye className="w-3 h-3" />
|
||||
{visionCapability === false ? t('accountManager.visionUnavailable') : visionCapability === true ? t('accountManager.visionAvailable') : t('accountManager.visionUnknown')}
|
||||
</span>
|
||||
)}
|
||||
{cooldownRemaining > 0 && (
|
||||
<span className="inline-flex items-center gap-1 font-mono bg-orange-500/10 text-orange-500 px-1.5 py-0.5 rounded text-[10px]">
|
||||
<Timer className="w-3 h-3" />
|
||||
{t('accountManager.cooldownSeconds', { seconds: cooldownRemaining })}
|
||||
</span>
|
||||
)}
|
||||
{failureCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 font-mono bg-red-500/10 text-red-500 px-1.5 py-0.5 rounded text-[10px]">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{t('accountManager.failureCount', { count: failureCount })}
|
||||
</span>
|
||||
)}
|
||||
{lastFailureKind && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
{t('accountManager.lastFailureKind', { kind: lastFailureKind })}
|
||||
</span>
|
||||
)}
|
||||
{muted && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
{t('accountManager.muted')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,9 +266,10 @@ export default function AccountsTable({
|
||||
<button
|
||||
onClick={() => onTestAccount(id)}
|
||||
disabled={testing[id]}
|
||||
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing[id] ? t('actions.testing') : t('actions.test')}
|
||||
{testing[id] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||
<span>{testing[id] ? t('actions.testing') : t('actions.test')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteAccount(id)}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { CheckCircle2, Server, ShieldCheck } from 'lucide-react'
|
||||
|
||||
function formatMetric(value) {
|
||||
if (value === undefined || value === null || value === '') return '-'
|
||||
return value
|
||||
}
|
||||
|
||||
function MetricRow({ label, value }) {
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="font-mono text-foreground">{formatMetric(value)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QueueCards({ queueStatus, t }) {
|
||||
if (!queueStatus) {
|
||||
return null
|
||||
@@ -16,6 +30,10 @@ export default function QueueCards({ queueStatus, t }) {
|
||||
<span className="text-3xl font-bold text-foreground">{queueStatus.available}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
|
||||
</div>
|
||||
<MetricRow
|
||||
label={t('accountManager.recommendedConcurrency')}
|
||||
value={queueStatus.recommended_concurrency}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
|
||||
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
@@ -26,6 +44,16 @@ export default function QueueCards({ queueStatus, t }) {
|
||||
<span className="text-3xl font-bold text-foreground">{queueStatus.in_use}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('accountManager.threadsUnit')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<MetricRow
|
||||
label={t('accountManager.waiting')}
|
||||
value={queueStatus.waiting}
|
||||
/>
|
||||
<MetricRow
|
||||
label={t('accountManager.queueLimit')}
|
||||
value={queueStatus.max_queue_size}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
|
||||
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
@@ -36,6 +64,16 @@ export default function QueueCards({ queueStatus, t }) {
|
||||
<span className="text-3xl font-bold text-foreground">{queueStatus.total}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<MetricRow
|
||||
label={t('accountManager.healthCheck')}
|
||||
value={queueStatus.health_enabled ? t('accountManager.enabled') : t('accountManager.disabled')}
|
||||
/>
|
||||
<MetricRow
|
||||
label={t('accountManager.globalLimit')}
|
||||
value={queueStatus.global_max_inflight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -218,7 +218,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
const res = await apiFetch('/admin/accounts/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier: accountID }),
|
||||
body: JSON.stringify({ identifier: accountID, mode: 'session', probe_capabilities: true }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
@@ -264,7 +264,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
|
||||
const res = await apiFetch('/admin/accounts/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier: id }),
|
||||
body: JSON.stringify({ identifier: id, mode: 'session', probe_capabilities: true }),
|
||||
})
|
||||
const data = await res.json()
|
||||
results.push({ id, success: data.success, message: data.message, time: data.response_time })
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"generate": "Generate",
|
||||
"test": "Refresh token",
|
||||
"testing": "Refreshing...",
|
||||
"test": "Check account",
|
||||
"testing": "Checking...",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"messages": {
|
||||
@@ -103,14 +103,21 @@
|
||||
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
|
||||
"deleteAccountConfirm": "Are you sure you want to delete this account?",
|
||||
"invalidIdentifier": "Invalid account identifier. Operation aborted.",
|
||||
"testAllConfirm": "Refresh all account tokens and verify login?",
|
||||
"testAllCompleted": "Completed: {success}/{total} refreshed",
|
||||
"testFailed": "Test failed: {error}",
|
||||
"testAllConfirm": "Check tokens, sessions, and model capabilities for all accounts?",
|
||||
"testAllCompleted": "Completed: {success}/{total} checks passed",
|
||||
"testFailed": "Check failed: {error}",
|
||||
"available": "Available",
|
||||
"inUse": "In use",
|
||||
"totalPool": "Total pool",
|
||||
"accountsUnit": "accounts",
|
||||
"threadsUnit": "threads",
|
||||
"recommendedConcurrency": "Recommended concurrency",
|
||||
"waiting": "Waiting",
|
||||
"queueLimit": "Queue limit",
|
||||
"healthCheck": "Health check",
|
||||
"globalLimit": "Global limit",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"apiKeysTitle": "API Keys",
|
||||
"apiKeysDesc": "Manage the API access key pool. Click the pencil icon on each row to edit name and remark.",
|
||||
"addKey": "Add key",
|
||||
@@ -123,13 +130,23 @@
|
||||
"noApiKeys": "No API keys found.",
|
||||
"accountsTitle": "DeepSeek Accounts",
|
||||
"accountsDesc": "Manage the DeepSeek account pool with either email or mobile login, and edit name/remark.",
|
||||
"testAll": "Refresh all tokens",
|
||||
"testAll": "Check all accounts",
|
||||
"addAccount": "Add account",
|
||||
"testingAllAccounts": "Refreshing tokens for all accounts...",
|
||||
"testingAllAccounts": "Checking all accounts...",
|
||||
"sessionActive": "Session active",
|
||||
"reauthRequired": "Retest status required",
|
||||
"runtimeStatusUnknown": "Will be determined after sync",
|
||||
"testStatusFailed": "Last test failed",
|
||||
"tokenValid": "Token valid",
|
||||
"tokenInvalid": "Token invalid",
|
||||
"tokenUnknown": "Token unknown",
|
||||
"visionAvailable": "Vision available",
|
||||
"visionUnavailable": "Vision unavailable",
|
||||
"visionUnknown": "Vision unknown",
|
||||
"cooldownSeconds": "Cooldown {seconds}s",
|
||||
"failureCount": "Failures {count}",
|
||||
"lastFailureKind": "Reason: {kind}",
|
||||
"muted": "Muted",
|
||||
"noAccounts": "No accounts found.",
|
||||
"modalAddKeyTitle": "Add API key",
|
||||
"modalEditKeyTitle": "Edit API key",
|
||||
@@ -231,7 +248,7 @@
|
||||
"requestFailed": "Request failed.",
|
||||
"networkError": "Network error: {error}",
|
||||
"requestSuccess": "{account}: Request successful ({time}ms)",
|
||||
"testSuccess": "{account}: Token refresh successful ({time}ms)",
|
||||
"testSuccess": "{account}: Account check passed ({time}ms)",
|
||||
"config": "Configuration",
|
||||
"modelLabel": "Model",
|
||||
"modelPickerHint": "Use the dropdown to pick a model. The list scrolls automatically.",
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
"delete": "删除",
|
||||
"copy": "复制",
|
||||
"generate": "生成",
|
||||
"test": "刷新 Token",
|
||||
"testing": "正在刷新...",
|
||||
"test": "检查账号",
|
||||
"testing": "检查中...",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"messages": {
|
||||
@@ -103,14 +103,21 @@
|
||||
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
|
||||
"deleteAccountConfirm": "确定要删除此账号吗?",
|
||||
"invalidIdentifier": "账号标识无效,无法执行操作",
|
||||
"testAllConfirm": "刷新所有账号 Token 并验证登录?",
|
||||
"testAllCompleted": "完成:{success}/{total} 刷新成功",
|
||||
"testFailed": "测试失败: {error}",
|
||||
"testAllConfirm": "检查所有账号 Token、会话与模型能力?",
|
||||
"testAllCompleted": "完成:{success}/{total} 检查通过",
|
||||
"testFailed": "检查失败: {error}",
|
||||
"available": "可用",
|
||||
"inUse": "正在使用",
|
||||
"totalPool": "账号池总数",
|
||||
"accountsUnit": "个账号",
|
||||
"threadsUnit": "线程",
|
||||
"recommendedConcurrency": "建议并发",
|
||||
"waiting": "等待中",
|
||||
"queueLimit": "队列上限",
|
||||
"healthCheck": "健康检查",
|
||||
"globalLimit": "全局上限",
|
||||
"enabled": "开启",
|
||||
"disabled": "关闭",
|
||||
"apiKeysTitle": "API 密钥",
|
||||
"apiKeysDesc": "管理 API 访问密钥池,点每行右侧铅笔可修改名称和备注",
|
||||
"addKey": "添加密钥",
|
||||
@@ -123,13 +130,23 @@
|
||||
"noApiKeys": "未找到 API 密钥",
|
||||
"accountsTitle": "DeepSeek 账号",
|
||||
"accountsDesc": "管理 DeepSeek 账号池,支持邮箱或手机号登录,并可修改名称和备注",
|
||||
"testAll": "刷新全部 Token",
|
||||
"testAll": "检查全部账号",
|
||||
"addAccount": "添加账号",
|
||||
"testingAllAccounts": "正在刷新所有账号 Token...",
|
||||
"testingAllAccounts": "正在检查所有账号...",
|
||||
"sessionActive": "已建立会话",
|
||||
"reauthRequired": "需重新测试状态",
|
||||
"runtimeStatusUnknown": "状态以同步后为准",
|
||||
"testStatusFailed": "上次测试失败",
|
||||
"tokenValid": "Token 有效",
|
||||
"tokenInvalid": "Token 失效",
|
||||
"tokenUnknown": "Token 未知",
|
||||
"visionAvailable": "Vision 可用",
|
||||
"visionUnavailable": "Vision 不可用",
|
||||
"visionUnknown": "Vision 未知",
|
||||
"cooldownSeconds": "冷却 {seconds}s",
|
||||
"failureCount": "失败 {count}",
|
||||
"lastFailureKind": "原因: {kind}",
|
||||
"muted": "已静默",
|
||||
"noAccounts": "未找到任何账号",
|
||||
"modalAddKeyTitle": "添加 API 密钥",
|
||||
"modalEditKeyTitle": "编辑 API 密钥",
|
||||
@@ -231,7 +248,7 @@
|
||||
"requestFailed": "请求失败",
|
||||
"networkError": "网络错误: {error}",
|
||||
"requestSuccess": "{account}: 请求成功 ({time}ms)",
|
||||
"testSuccess": "{account}: Token 刷新成功 ({time}ms)",
|
||||
"testSuccess": "{account}: 账号检查通过 ({time}ms)",
|
||||
"config": "配置",
|
||||
"modelLabel": "模型",
|
||||
"modelPickerHint": "使用下拉列表选择模型,长列表会自动滚动。",
|
||||
|
||||
Reference in New Issue
Block a user