feat: add account health and ghcr publishing

This commit is contained in:
highkay
2026-05-14 13:56:33 +08:00
parent 3b92608108
commit 7359171a7f
60 changed files with 2140 additions and 203 deletions
+68
View File
@@ -0,0 +1,68 @@
name: Build and Publish GHCR Image
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
ghcr:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Resolve image name
id: image
run: |
echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.image.outputs.name }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push image
uses: docker/build-push-action@v6
env:
DOCKER_BUILD_RECORD_UPLOAD: "false"
DOCKER_BUILD_SUMMARY: "false"
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
BUILD_VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1
View File
@@ -67,3 +67,4 @@ CLAUDE.local.md
data/
.codex
.roomodes
.ace-tool/
+262
View File
@@ -2,6 +2,242 @@
These rules apply to all agent-made changes in this repository.
## Project Summary
DS2API converts DeepSeek Web chat capability into OpenAI-, Claude-, and Gemini-compatible APIs.
- Backend: pure Go implementation, centered on `cmd/ds2api`, `api/`, and `internal/`.
- Frontend: React + Vite admin console in `webui/`, built into `static/admin` for runtime hosting.
- Deployments: local source run, Docker, Vercel Serverless, and Linux/systemd.
- Main docs:
- `README.MD`: product overview and quick start.
- `docs/ARCHITECTURE.md`: directory structure and module boundaries.
- `API.md`: external API contract.
- `docs/prompt-compatibility.md`: source of truth for the API-to-DeepSeek prompt compatibility pipeline.
## Main Features
- OpenAI-compatible surface:
- `GET /v1/models`
- `GET /v1/models/{model_id}`
- `POST /v1/chat/completions`
- `POST /v1/responses`
- `GET /v1/responses/{response_id}`
- `POST /v1/files`
- `POST /v1/embeddings`
- Claude-compatible surface:
- `GET /anthropic/v1/models`
- `POST /anthropic/v1/messages`
- `POST /anthropic/v1/messages/count_tokens`
- shortcut paths under `/v1/messages` and `/messages`
- Gemini-compatible surface:
- `POST /v1beta/models/{model}:generateContent`
- `POST /v1beta/models/{model}:streamGenerateContent`
- same handlers also mounted under `/v1/models/{model}:*`
- Admin API and WebUI:
- config import/export
- runtime settings hot update
- account and proxy management
- queue status and account testing
- Vercel sync
- chat history inspection and cleanup
- local dev/raw-sample capture
- Runtime features:
- shared auth resolver and account pool
- long-history split into uploaded transcript files
- canonical XML tool calling plus stream-time anti-leak handling
- DeepSeek PoW implemented in Go
- `/healthz` and `/readyz` probes
## Key Entrypoints
- `cmd/ds2api/main.go`
- local binary entry
- loads `.env`, refreshes logger, ensures WebUI build, starts HTTP server
- `app/handler.go`
- app-level handler factory used by serverless runtime
- `api/index.go`
- Vercel Go entrypoint
- `internal/server/router.go`
- root router, middleware, health probes, protocol route mounting, admin mount, WebUI mount
- `api/chat-stream.js`
- Vercel Node streaming entry for `/v1/chat/completions`
- `internal/js/chat-stream/*`
- Node-side prepare/stream/release bridge and tool-sieve logic
- `start.mjs`
- local dev helper for backend, frontend, build, and stop/status commands
- `cmd/ds2api-tests/main.go`
- CLI entry for end-to-end testsuite execution
## Code Structure
### Top Level
- `api/`: Vercel serverless entrypoints, including the Node streaming bridge.
- `app/`: app handler assembly for serverless use.
- `cmd/`: executable entrypoints for the main server and testsuite CLI.
- `docs/`: architecture, deploy, testing, contributing, compatibility docs.
- `internal/`: core implementation.
- `pow/`: PoW implementation and related benchmarks/helpers.
- `scripts/`: lint/build/release helper scripts.
- `tests/`: fixtures, node tests, raw SSE samples, and test scripts.
- `webui/`: React admin source code.
### Core Internal Modules
- `internal/server`: root router and middleware wiring.
- `internal/httpapi/openai`: OpenAI HTTP surface, split into `chat`, `responses`, `files`, `embeddings`, `history`, `shared`.
- `internal/httpapi/claude`: Claude request normalization and response adaptation.
- `internal/httpapi/gemini`: Gemini request normalization and response adaptation.
- `internal/httpapi/admin`: admin root handler plus subpackages for `auth`, `accounts`, `configmgmt`, `settings`, `proxies`, `rawsamples`, `vercel`, `history`, `devcapture`, `version`.
- `internal/promptcompat`: the compatibility kernel that converts structured API inputs into DeepSeek-style prompt plus file references.
- `internal/prompt`: prompt assembly and role-tag formatting.
- `internal/toolcall` and `internal/toolstream`: canonical XML tool-call parsing, repair, filtering, and streaming deltas.
- `internal/deepseek/client`, `internal/deepseek/protocol`, `internal/deepseek/transport`: upstream login, session, completion, file, protocol, and transport behavior.
- `internal/account`: account pool, queue, and concurrency limits.
- `internal/auth`: API key, bearer, admin, and request auth resolution.
- `internal/chathistory`: persisted server-side chat history store and retention logic.
- `internal/config`: config loading, validation, store accessors, runtime settings.
- `internal/stream` and `internal/sse`: shared streaming parse/consume logic.
- `internal/webui`: runtime hosting for `static/admin`.
- `internal/testsuite`: reusable end-to-end testsuite engine.
### Frontend Structure
- `webui/src/app`: app bootstrapping, auth, config fetch, route composition.
- `webui/src/components`: shared UI pieces.
- `webui/src/features`: feature pages such as account management, API tester, settings, and Vercel sync.
- `webui/src/layout`: dashboard shell and layout wiring.
- `webui/src/locales`: bilingual text resources.
- `webui/vite.config.js`: dev proxy and build output to `../static/admin`.
## Main Flows
### 1. Standard API Request Flow
`cmd/ds2api/main.go` or `api/index.go`
-> `internal/server/router.go`
-> protocol handler in `internal/httpapi/openai`, `internal/httpapi/claude`, or `internal/httpapi/gemini`
-> request normalization in `internal/promptcompat`
-> prompt assembly in `internal/prompt`
-> optional inline-file preprocessing and history split
-> auth/account selection through `internal/auth` and `internal/account`
-> upstream call via `internal/deepseek/client`
-> stream or non-stream rendering back into protocol-specific output
The adapter-layer contract should stay simple: request normalization -> DeepSeek invocation -> protocol-shaped rendering.
### 2. Prompt Compatibility Flow
This is the most important logic in the repository.
- Structured client inputs are not forwarded upstream as-is.
- They are converted into:
- one prompt string
- one `ref_file_ids` array
- a few control flags such as thinking/search
- Tools are injected into prompt text, not passed as native upstream tool schema.
- Historical tool calls are preserved as canonical XML in prompt-visible history.
- Long histories can be moved into uploaded transcript files such as `HISTORY.txt`.
When changing this area, always inspect:
- `internal/promptcompat/*`
- `internal/prompt/*`
- `internal/httpapi/openai/history/*`
- `internal/httpapi/openai/files/*`
- `internal/toolcall/*`
- `internal/toolstream/*`
- `docs/prompt-compatibility.md`
### 3. Vercel Streaming Flow
Vercel uses a hybrid path only for OpenAI chat streaming.
- Route rewrite: `vercel.json`
- Node entry: `api/chat-stream.js`
- Node implementation: `internal/js/chat-stream/*`
- Go-side prepare/release hooks: OpenAI chat handler in `internal/httpapi/openai/chat/*`
Flow:
1. `/v1/chat/completions` on Vercel rewrites to `api/chat-stream.js`.
2. Node asks Go for `__stream_prepare=1` to resolve auth, session, PoW, and account lease.
3. Node streams directly from DeepSeek upstream and converts SSE into OpenAI chunks.
4. Node applies tool anti-leak and finish-state rules aligned with Go.
5. Node calls Go `__stream_release=1` to release the account lease.
Do not change this flow in only one runtime. Go and Node stream semantics must remain aligned.
### 4. Admin and WebUI Flow
- Admin route mount: `internal/httpapi/admin/handler.go`
- Admin auth:
- public login/verify routes first
- protected routes under `RequireAdmin`
- WebUI runtime mount: `internal/webui/handler.go`
- Frontend route shell: `webui/src/app/AppRoutes.jsx`
Keep the split clear:
- `/admin/config*`: static configuration state
- `/admin/settings*`: runtime behavior and hot updates
If you add or change admin capability, check both backend admin routes and the matching `webui/src/features/*` page.
## Tech Stack
- Go `1.26`
- `github.com/go-chi/chi/v5` for HTTP routing and middleware
- `github.com/refraction-networking/utls` for upstream transport compatibility
- `github.com/router-for-me/CLIProxyAPI/v6` for proxy integration
- `github.com/google/uuid`
- React `18`
- React Router `7`
- Vite `8`
- Tailwind CSS `3`
- Docker multi-stage build
- Vercel Go + Node hybrid runtime for streaming deployment
## Configuration and Runtime Notes
- Primary config template: `config.example.json`
- Main config source in practice: `config.json` or `DS2API_CONFIG_JSON`
- Important config domains:
- `keys` and `api_keys`
- `accounts`
- `model_aliases`
- `compat`
- `responses`
- `history_split`
- `embeddings`
- `admin`
- `runtime`
- `auto_delete`
If you change config shape, check:
- `internal/config/*`
- `config.example.json`
- `README.MD`
- `API.md`
- relevant admin config endpoints and WebUI forms
## Where To Look First
- API routing issue: `internal/server/router.go`
- OpenAI contract issue: `internal/httpapi/openai/*`
- Claude contract issue: `internal/httpapi/claude/*`
- Gemini contract issue: `internal/httpapi/gemini/*`
- Prompt/history/tool issue: `internal/promptcompat/*`, `internal/prompt/*`, `internal/toolcall/*`, `internal/toolstream/*`
- Upstream DeepSeek behavior: `internal/deepseek/*`
- Account queue or auth issue: `internal/account/*`, `internal/auth/*`
- Admin API issue: `internal/httpapi/admin/*`
- WebUI issue: `webui/src/*` and `internal/webui/*`
- Vercel-only stream issue: `api/chat-stream.js`, `internal/js/chat-stream/*`, `vercel.json`
- Chat history issue: `internal/chathistory/*`
- End-to-end/live test behavior: `internal/testsuite/*`, `cmd/ds2api-tests/main.go`, `tests/scripts/run-live.sh`
## PR Gate
- Before opening or updating a PR, run the same local gates as `.github/workflows/quality-gates.yml`.
@@ -21,9 +257,35 @@ These rules apply to all agent-made changes in this repository.
- Keep changes additive and tightly scoped to the requested feature or bugfix.
- Do not mix unrelated refactors into feature PRs unless they are required to make the change pass gates.
- Preserve the adapter layering:
- protocol surface
- prompt compatibility core
- shared runtime
- upstream client
- Avoid duplicating compatibility logic across OpenAI, Claude, and Gemini paths when a shared module already exists.
## Documentation Sync
- When business logic or user-visible behavior changes, update the corresponding documentation in the same change.
- `docs/prompt-compatibility.md` is the source-of-truth document for the “API -> pure-text web-chat context” compatibility flow.
- If a change affects message normalization, tool prompt injection, prompt-visible tool history, file/reference handling, history split, or completion payload assembly, update `docs/prompt-compatibility.md` in the same change.
- If a change affects route layout, module boundaries, or major flow descriptions, update `docs/ARCHITECTURE.md`.
- If a change affects external request or response behavior, update `API.md` and `API.en.md`.
- If a change affects deployment behavior, Vercel/Docker entrypoints, or required environment variables, update `docs/DEPLOY.md`.
- If a change affects developer workflow or verification steps, update `docs/TESTING.md` and/or `docs/CONTRIBUTING.md`.
## Validation Guidance
- For backend-only changes, start with targeted Go tests around the touched package, then run the required gates.
- For stream or tool-call changes, verify both Go and Node sides when applicable.
- For WebUI changes, run `npm run build --prefix webui`; use `./scripts/build-webui.sh` if you need the runtime artifact locally.
- For high-risk protocol or upstream-behavior changes, consider `./tests/scripts/run-live.sh`.
## Useful Commands
- Start backend locally: `go run ./cmd/ds2api`
- Start guided local dev flow: `node start.mjs dev`
- Build backend binary: `node start.mjs build`
- Build WebUI: `node start.mjs webui` or `./scripts/build-webui.sh`
- Run all unit tests: `./tests/scripts/run-unit-all.sh`
- Run live testsuite: `./tests/scripts/run-live.sh`
+4 -1
View File
@@ -427,6 +427,7 @@ Constraints and behavior:
- `Content-Type` must be `multipart/form-data` (otherwise `400`).
- Total request size limit is `100 MiB` (over-limit returns `413`).
- If `runtime.disable_upstream_file_uploads=true`, `/v1/files` and inline/base64 file uploads return `400`, and history split will not upload `HISTORY.txt`.
- Success returns an OpenAI `file` object (`id/object/bytes/filename/purpose/status`, etc.) and includes `account_id` for source-account tracing.
---
@@ -707,7 +708,7 @@ Reads runtime settings and status, including:
- `success`
- `admin` (`has_password_hash`, `jwt_expire_hours`, `jwt_valid_after_unix`, `default_password_warning`)
- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`, `token_refresh_interval_hours`)
- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`, `token_refresh_interval_hours`, `disable_upstream_file_uploads`, `account_health_*`)
- `compat` (`wide_input_strict_output`, `strip_reference_markers`)
- `responses` / `embeddings`
- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`)
@@ -722,6 +723,8 @@ Hot-updates runtime settings. Supported fields:
- `admin.jwt_expire_hours`
- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours`
- `runtime.disable_upstream_file_uploads`
- `runtime.account_health_enabled` and `runtime.account_health_*_seconds` cooldown/recovery fields
- `compat.wide_input_strict_output` / `compat.strip_reference_markers`
- `responses.store_ttl_seconds`
- `embeddings.provider`
+4 -1
View File
@@ -428,6 +428,7 @@ data: [DONE]
- 请求必须为 `multipart/form-data`,否则返回 `400`。
- 请求体总大小上限 `100 MiB`(超限返回 `413`)。
- 若 `runtime.disable_upstream_file_uploads=true`,`/v1/files` 与 inline/base64 文件上传都会返回 `400`,history split 也不会上传 `HISTORY.txt`。
- 成功返回 OpenAI `file` 对象(`id/object/bytes/filename/purpose/status` 等字段),并附带 `account_id` 便于定位来源账号。
---
@@ -708,7 +709,7 @@ data: {"type":"message_stop"}
- `success`
- `admin`(`has_password_hash`、`jwt_expire_hours`、`jwt_valid_after_unix`、`default_password_warning`)
- `runtime`(`account_max_inflight`、`account_max_queue`、`global_max_inflight`、`token_refresh_interval_hours`)
- `runtime`(`account_max_inflight`、`account_max_queue`、`global_max_inflight`、`token_refresh_interval_hours`、`disable_upstream_file_uploads`、`account_health_*`)
- `compat`(`wide_input_strict_output`、`strip_reference_markers`)
- `responses` / `embeddings`
- `auto_delete`(`mode`:`none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理)
@@ -723,6 +724,8 @@ data: {"type":"message_stop"}
- `admin.jwt_expire_hours`
- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours`
- `runtime.disable_upstream_file_uploads`
- `runtime.account_health_enabled` 与 `runtime.account_health_*_seconds` 冷却/恢复参数
- `compat.wide_input_strict_output` / `compat.strip_reference_markers`
- `responses.store_ttl_seconds`
- `embeddings.provider`
+1 -1
View File
@@ -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 刷新、账号健康冷却与上游文件上传开关,可通过 Admin Settings 热更新。
- `auto_delete.mode`:请求结束后的远端会话清理策略,支持 `none` / `single` / `all`。
- `history_split`:多轮历史拆分策略,已全局强制开启;可调整触发阈值,避免长历史全部内联进 prompt。
+1 -1
View File
@@ -274,7 +274,7 @@ Common fields:
- `keys` / `api_keys`: client API keys; `api_keys` adds `name` and `remark` metadata while `keys` remains compatible.
- `accounts`: managed DeepSeek accounts, supporting `email` or `mobile` login plus proxy/name/remark metadata.
- `model_aliases`: one shared alias map for OpenAI / Claude / Gemini model names.
- `runtime`: account concurrency, queueing, and token refresh behavior, hot-reloadable via Admin Settings.
- `runtime`: account concurrency, queueing, token refresh, account health cooldowns, and upstream file-upload policy, hot-reloadable via Admin Settings.
- `auto_delete.mode`: remote session cleanup after each request, supporting `none` / `single` / `all`.
- `history_split`: multi-turn history split policy, now forced on globally; tune its trigger threshold to avoid inlining all long history into the prompt.
+12 -1
View File
@@ -64,7 +64,18 @@
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0,
"token_refresh_interval_hours": 6
"token_refresh_interval_hours": 6,
"disable_upstream_file_uploads": false,
"account_health_enabled": true,
"account_health_recovery_window_seconds": 300,
"account_health_max_cooldown_seconds": 1800,
"account_health_cooldown_429_seconds": 30,
"account_health_cooldown_403_seconds": 60,
"account_health_cooldown_auth_seconds": 120,
"account_health_cooldown_5xx_seconds": 10,
"account_health_cooldown_network_seconds": 5,
"account_health_cooldown_empty_seconds": 0,
"account_health_cooldown_muted_seconds": 300
},
"auto_delete": {
"mode": "none"
+1 -1
View File
@@ -179,7 +179,7 @@ flowchart LR
- `internal/httpapi/admin/*`: Admin API root assembly plus auth/accounts/config/settings/proxies/rawsamples/vercel/history/devcapture/version resource packages.
- `internal/chathistory`: server-side conversation history persistence, pagination, detail lookup, and retention policy.
- `internal/config`: config loading/validation + runtime settings hot-reload.
- `internal/account`: managed account pool, inflight slots, waiting queue.
- `internal/account`: managed account pool, inflight slots, waiting queue, and account health weight/cooldown scheduling.
## 4. WebUI Runtime Relation
+1 -1
View File
@@ -179,7 +179,7 @@ flowchart LR
- `internal/httpapi/admin/*`:Admin API 根装配与 auth/accounts/config/settings/proxies/rawsamples/vercel/history/devcapture/version 等资源子包。
- `internal/chathistory`:服务器端对话记录持久化、分页、单条详情和保留策略。
- `internal/config`:配置加载、校验、运行时 settings 热更新。
- `internal/account`:托管账号池、并发槽位、等待队列。
- `internal/account`:托管账号池、并发槽位、等待队列、账号健康降权与冷却调度。
## 4. WebUI 与运行时关系
+2
View File
@@ -68,6 +68,8 @@ Built-in GitHub Actions workflow: `.github/workflows/release-artifacts.yml`
- **Outputs**: multi-platform binary archives + `sha256sums.txt`
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
In addition, `.github/workflows/ghcr.yml` automatically builds and pushes multi-arch GHCR images on `main` pushes, `v*` tag pushes, or manual dispatch. Tags include the branch name, version tag, `sha-*`, and `latest` for the default branch.
| Platform | Architecture | Format |
| --- | --- | --- |
| Linux | amd64, arm64 | `.tar.gz` |
+2
View File
@@ -68,6 +68,8 @@ cp config.example.json config.json
- **构建产物**:多平台二进制压缩包 + `sha256sums.txt`
- **容器镜像发布**:仅发布到 GHCR(`ghcr.io/cjackhwang/ds2api`)
另外,`.github/workflows/ghcr.yml` 会在 `main` push、`v*` tag push 或手动触发时自动构建并推送多架构 GHCR 镜像,tag 包含分支名、版本 tag、`sha-*`,默认分支额外发布 `latest`。
| 平台 | 架构 | 文件格式 |
| --- | --- | --- |
| Linux | amd64, arm64 | `.tar.gz` |
+3
View File
@@ -50,6 +50,8 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
-> 下游网页对话接口
```
标准化后的最终 prompt 统一限制为 131072 个 Unicode 字符;OpenAI、Claude、Gemini 三条链路都会在上游调用前返回 `context too long` 错误。
对应的关键代码入口:
- OpenAI Chat / Responses:
@@ -225,6 +227,7 @@ OpenAI 文件相关实现:
- inline/base64/data URL 上传:
[internal/httpapi/openai/files/file_inline_upload.go](../internal/httpapi/openai/files/file_inline_upload.go)
- 当 `runtime.disable_upstream_file_uploads=true` 时,显式 `/v1/files`、inline/base64 上传和 history split 的上游文件上传都会关闭;已有 `file_id` / `ref_file_ids` 仍按普通引用收集。
- 文件 ID 收集:
[internal/promptcompat/file_refs.go](../internal/promptcompat/file_refs.go)
+264
View File
@@ -0,0 +1,264 @@
package account
import (
"math"
"strings"
"time"
)
type PenaltyKind string
const (
PenaltyUnknown PenaltyKind = ""
PenaltyHTTP429 PenaltyKind = "http_429"
PenaltyHTTP403 PenaltyKind = "http_403"
PenaltyAuthFailed PenaltyKind = "auth_failed"
PenaltyHTTP5xx PenaltyKind = "http_5xx"
PenaltyNetwork PenaltyKind = "network"
PenaltyEmpty PenaltyKind = "empty_output"
PenaltyMuted PenaltyKind = "muted"
)
type HealthConfig struct {
Enabled bool
RecoveryWindowSeconds int
MaxCooldownSeconds int
Cooldown429Seconds int
Cooldown403Seconds int
CooldownAuthSeconds int
Cooldown5xxSeconds int
CooldownNetworkSeconds int
CooldownEmptySeconds int
CooldownMutedSeconds int
}
func DefaultHealthConfig() HealthConfig {
return HealthConfig{
Enabled: true,
RecoveryWindowSeconds: 300,
MaxCooldownSeconds: 1800,
Cooldown429Seconds: 30,
Cooldown403Seconds: 60,
CooldownAuthSeconds: 120,
Cooldown5xxSeconds: 10,
CooldownNetworkSeconds: 5,
CooldownEmptySeconds: 0,
CooldownMutedSeconds: 300,
}
}
type HealthConfigReader interface {
AccountHealthEnabled() bool
AccountHealthRecoveryWindowSeconds() int
AccountHealthMaxCooldownSeconds() int
AccountHealthCooldown429Seconds() int
AccountHealthCooldown403Seconds() int
AccountHealthCooldownAuthSeconds() int
AccountHealthCooldown5xxSeconds() int
AccountHealthCooldownNetworkSeconds() int
AccountHealthCooldownEmptySeconds() int
AccountHealthCooldownMutedSeconds() int
}
func LoadHealthConfigFromStore(r HealthConfigReader) HealthConfig {
if r == nil {
return DefaultHealthConfig()
}
def := DefaultHealthConfig()
cfg := HealthConfig{Enabled: r.AccountHealthEnabled()}
cfg.RecoveryWindowSeconds = pickPositive(r.AccountHealthRecoveryWindowSeconds(), def.RecoveryWindowSeconds)
cfg.MaxCooldownSeconds = pickPositive(r.AccountHealthMaxCooldownSeconds(), def.MaxCooldownSeconds)
cfg.Cooldown429Seconds = pickPositive(r.AccountHealthCooldown429Seconds(), def.Cooldown429Seconds)
cfg.Cooldown403Seconds = pickPositive(r.AccountHealthCooldown403Seconds(), def.Cooldown403Seconds)
cfg.CooldownAuthSeconds = pickPositive(r.AccountHealthCooldownAuthSeconds(), def.CooldownAuthSeconds)
cfg.Cooldown5xxSeconds = pickPositive(r.AccountHealthCooldown5xxSeconds(), def.Cooldown5xxSeconds)
cfg.CooldownNetworkSeconds = pickPositive(r.AccountHealthCooldownNetworkSeconds(), def.CooldownNetworkSeconds)
cfg.CooldownMutedSeconds = pickPositive(r.AccountHealthCooldownMutedSeconds(), def.CooldownMutedSeconds)
if r.AccountHealthCooldownEmptySeconds() >= 0 {
cfg.CooldownEmptySeconds = r.AccountHealthCooldownEmptySeconds()
} else {
cfg.CooldownEmptySeconds = def.CooldownEmptySeconds
}
return cfg
}
func pickPositive(v, fallback int) int {
if v > 0 {
return v
}
return fallback
}
type healthConfig struct {
enabled bool
recoveryWindow time.Duration
maxCooldown time.Duration
baseCooldown429 time.Duration
baseCooldown403 time.Duration
baseCooldownAuth time.Duration
baseCooldown5xx time.Duration
baseCooldownNetwork time.Duration
baseCooldownEmpty time.Duration
baseCooldownMuted time.Duration
}
func (c HealthConfig) toInternal() healthConfig {
return healthConfig{
enabled: c.Enabled,
recoveryWindow: time.Duration(c.RecoveryWindowSeconds) * time.Second,
maxCooldown: time.Duration(c.MaxCooldownSeconds) * time.Second,
baseCooldown429: time.Duration(c.Cooldown429Seconds) * time.Second,
baseCooldown403: time.Duration(c.Cooldown403Seconds) * time.Second,
baseCooldownAuth: time.Duration(c.CooldownAuthSeconds) * time.Second,
baseCooldown5xx: time.Duration(c.Cooldown5xxSeconds) * time.Second,
baseCooldownNetwork: time.Duration(c.CooldownNetworkSeconds) * time.Second,
baseCooldownEmpty: time.Duration(c.CooldownEmptySeconds) * time.Second,
baseCooldownMuted: time.Duration(c.CooldownMutedSeconds) * time.Second,
}
}
const minWeight = 0.05
const weightTieEpsilon = 0.05
func weightDeltaByKind(kind PenaltyKind) float64 {
switch kind {
case PenaltyHTTP429:
return 0.40
case PenaltyHTTP403:
return 0.60
case PenaltyAuthFailed:
return 0.70
case PenaltyHTTP5xx:
return 0.20
case PenaltyNetwork:
return 0.10
case PenaltyEmpty:
return 0.10
case PenaltyMuted:
return 0.80
default:
return 0.20
}
}
func baseCooldownByKind(cfg healthConfig, kind PenaltyKind) time.Duration {
switch kind {
case PenaltyHTTP429:
return cfg.baseCooldown429
case PenaltyHTTP403:
return cfg.baseCooldown403
case PenaltyAuthFailed:
return cfg.baseCooldownAuth
case PenaltyHTTP5xx:
return cfg.baseCooldown5xx
case PenaltyNetwork:
return cfg.baseCooldownNetwork
case PenaltyEmpty:
return cfg.baseCooldownEmpty
case PenaltyMuted:
return cfg.baseCooldownMuted
default:
return cfg.baseCooldown5xx
}
}
type accountHealth struct {
weight float64
failureCount int
lastFailureAt time.Time
lastFailureKind PenaltyKind
lastSuccessAt time.Time
cooldownUntil time.Time
}
func newAccountHealth() *accountHealth {
return &accountHealth{weight: 1.0}
}
func (h *accountHealth) effectiveWeight(cfg healthConfig, now time.Time) float64 {
if h == nil {
return 1.0
}
w := h.weight
if w < minWeight {
w = minWeight
}
if w >= 1.0 {
return 1.0
}
if h.lastFailureAt.IsZero() || cfg.recoveryWindow <= 0 {
return w
}
elapsed := now.Sub(h.lastFailureAt)
if elapsed <= 0 {
return w
}
recovered := w + elapsed.Seconds()/cfg.recoveryWindow.Seconds()
if recovered > 1.0 {
return 1.0
}
return recovered
}
func (h *accountHealth) cooldownRemaining(now time.Time) time.Duration {
if h == nil || h.cooldownUntil.IsZero() {
return 0
}
if !h.cooldownUntil.After(now) {
return 0
}
return h.cooldownUntil.Sub(now)
}
func (h *accountHealth) applyPenalty(cfg healthConfig, kind PenaltyKind, now time.Time) {
h.failureCount++
h.lastFailureAt = now
h.lastFailureKind = kind
h.weight -= weightDeltaByKind(kind)
if h.weight < minWeight {
h.weight = minWeight
}
base := baseCooldownByKind(cfg, kind)
if base <= 0 {
return
}
exp := math.Pow(2, float64(h.failureCount-1))
if exp > 256 {
exp = 256
}
cooldown := time.Duration(float64(base) * exp)
if cfg.maxCooldown > 0 && cooldown > cfg.maxCooldown {
cooldown = cfg.maxCooldown
}
h.cooldownUntil = now.Add(cooldown)
}
func (h *accountHealth) recordSuccess(now time.Time) {
if h == nil {
return
}
h.failureCount = 0
h.lastSuccessAt = now
h.cooldownUntil = time.Time{}
h.lastFailureKind = ""
}
func ParsePenaltyKind(raw string) PenaltyKind {
switch strings.ToLower(strings.TrimSpace(raw)) {
case string(PenaltyHTTP429):
return PenaltyHTTP429
case string(PenaltyHTTP403):
return PenaltyHTTP403
case string(PenaltyAuthFailed):
return PenaltyAuthFailed
case string(PenaltyHTTP5xx):
return PenaltyHTTP5xx
case string(PenaltyNetwork):
return PenaltyNetwork
case string(PenaltyEmpty):
return PenaltyEmpty
case string(PenaltyMuted):
return PenaltyMuted
}
return PenaltyUnknown
}
+97 -9
View File
@@ -2,6 +2,8 @@ package account
import (
"context"
"math"
"time"
"ds2api/internal/config"
)
@@ -51,7 +53,7 @@ func (p *Pool) acquireLocked(target string, exclude map[string]bool) (config.Acc
if exclude[target] || !p.canAcquireIDLocked(target) {
return config.Account{}, false
}
acc, ok := p.store.FindAccount(target)
acc, ok := p.store.FindAvailableAccount(target, p.now())
if !ok {
return config.Account{}, false
}
@@ -64,20 +66,106 @@ func (p *Pool) acquireLocked(target string, exclude map[string]bool) (config.Acc
}
func (p *Pool) tryAcquire(exclude map[string]bool) (config.Account, bool) {
for i := 0; i < len(p.queue); i++ {
id := p.queue[i]
if len(p.queue) == 0 {
return config.Account{}, false
}
now := p.now()
primary := make([]string, 0, len(p.queue))
fallback := make([]string, 0, len(p.queue))
for _, id := range p.queue {
if exclude[id] || !p.canAcquireIDLocked(id) {
continue
}
acc, ok := p.store.FindAccount(id)
if !ok {
if _, ok := p.store.FindAvailableAccount(id, now); !ok {
continue
}
p.inUse[id]++
p.bumpQueue(id)
return acc, true
fallback = append(fallback, id)
if p.healthCfg.enabled {
if h := p.health[id]; h != nil && h.cooldownRemaining(now) > 0 {
continue
}
}
primary = append(primary, id)
}
return config.Account{}, false
candidates := primary
if len(candidates) == 0 {
candidates = fallback
}
if len(candidates) == 0 {
return config.Account{}, false
}
id, ok := p.selectCandidate(candidates, now)
if !ok {
return config.Account{}, false
}
acc, ok := p.store.FindAvailableAccount(id, now)
if !ok {
return config.Account{}, false
}
p.inUse[id]++
p.bumpQueue(id)
return acc, true
}
func (p *Pool) selectCandidate(candidates []string, now time.Time) (string, bool) {
switch len(candidates) {
case 0:
return "", false
case 1:
return candidates[0], true
}
if !p.healthCfg.enabled {
return candidates[0], true
}
first := p.scoreLocked(candidates[0], now)
allTied := true
for i := 1; i < len(candidates); i++ {
if math.Abs(p.scoreLocked(candidates[i], now)-first) > weightTieEpsilon {
allTied = false
break
}
}
if allTied {
return candidates[0], true
}
a := p.rng.Intn(len(candidates))
b := p.rng.Intn(len(candidates))
for b == a && len(candidates) > 1 {
b = p.rng.Intn(len(candidates))
}
idA := candidates[a]
idB := candidates[b]
scoreA := p.scoreLocked(idA, now)
scoreB := p.scoreLocked(idB, now)
if scoreB > scoreA {
return idB, true
}
if scoreA > scoreB {
return idA, true
}
for _, id := range candidates {
if id == idA || id == idB {
return id, true
}
}
return idA, true
}
func (p *Pool) scoreLocked(accountID string, now time.Time) float64 {
weight := 1.0
if p.healthCfg.enabled {
if h := p.health[accountID]; h != nil {
weight = h.effectiveWeight(p.healthCfg, now)
}
}
if p.maxInflightPerAccount <= 0 {
return weight
}
loadFactor := 1.0 - float64(p.inUse[accountID])/float64(p.maxInflightPerAccount)
if loadFactor < 0 {
loadFactor = 0
}
return weight * loadFactor
}
func (p *Pool) bumpQueue(accountID string) {
+163 -5
View File
@@ -1,8 +1,10 @@
package account
import (
"math/rand"
"sort"
"sync"
"time"
"ds2api/internal/config"
)
@@ -17,6 +19,10 @@ type Pool struct {
recommendedConcurrency int
maxQueueSize int
globalMaxInflight int
healthCfg healthConfig
health map[string]*accountHealth
rng *rand.Rand
now func() time.Time
}
func NewPool(store *config.Store) *Pool {
@@ -28,13 +34,23 @@ func NewPool(store *config.Store) *Pool {
store: store,
inUse: map[string]int{},
maxInflightPerAccount: maxPer,
health: map[string]*accountHealth{},
healthCfg: DefaultHealthConfig().toInternal(),
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
now: time.Now,
}
if store != nil {
p.healthCfg = LoadHealthConfigFromStore(store).toInternal()
}
p.Reset()
return p
}
func (p *Pool) Reset() {
accounts := p.store.Accounts()
var accounts []config.Account
if p.store != nil {
accounts = p.store.Accounts()
}
sort.SliceStable(accounts, func(i, j int) bool {
iHas := accounts[i].Token != ""
jHas := accounts[j].Token != ""
@@ -46,7 +62,7 @@ func (p *Pool) Reset() {
ids := make([]string, 0, len(accounts))
for _, a := range accounts {
id := a.Identifier()
if id != "" {
if id != "" && a.IsActive() {
ids = append(ids, id)
}
}
@@ -70,6 +86,10 @@ func (p *Pool) Reset() {
p.recommendedConcurrency = recommended
p.maxQueueSize = queueLimit
p.globalMaxInflight = globalLimit
if p.store != nil {
p.healthCfg = LoadHealthConfigFromStore(p.store).toInternal()
}
p.pruneHealthLocked(ids)
config.Logger.Info(
"[init_account_queue] initialized",
"total", len(ids),
@@ -77,6 +97,7 @@ func (p *Pool) Reset() {
"global_max_inflight", p.globalMaxInflight,
"recommended_concurrency", p.recommendedConcurrency,
"max_queue_size", p.maxQueueSize,
"health_enabled", p.healthCfg.enabled,
)
}
@@ -99,16 +120,147 @@ func (p *Pool) Release(accountID string) {
p.notifyWaiterLocked()
}
func (p *Pool) Penalize(accountID string, kind PenaltyKind) {
if accountID == "" || kind == PenaltyUnknown {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if !p.healthCfg.enabled {
return
}
h := p.healthLocked(accountID)
h.applyPenalty(p.healthCfg, kind, p.now())
}
func (p *Pool) RecordSuccess(accountID string) {
if accountID == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if !p.healthCfg.enabled {
return
}
h := p.healthLocked(accountID)
h.recordSuccess(p.now())
p.notifyWaiterLocked()
}
func (p *Pool) ApplyHealthConfig(cfg HealthConfig) {
p.mu.Lock()
defer p.mu.Unlock()
p.healthCfg = cfg.toInternal()
p.notifyWaiterLocked()
}
func (p *Pool) HealthEnabled() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.healthCfg.enabled
}
func (p *Pool) healthLocked(accountID string) *accountHealth {
if p.health == nil {
p.health = map[string]*accountHealth{}
}
h, ok := p.health[accountID]
if !ok {
h = newAccountHealth()
p.health[accountID] = h
}
return h
}
func (p *Pool) pruneHealthLocked(currentIDs []string) {
if len(p.health) == 0 {
return
}
keep := make(map[string]struct{}, len(currentIDs))
for _, id := range currentIDs {
keep[id] = struct{}{}
}
for id := range p.health {
if _, ok := keep[id]; !ok {
delete(p.health, id)
}
}
}
func (p *Pool) healthSnapshotLocked(now time.Time) []map[string]any {
if len(p.queue) == 0 {
return []map[string]any{}
}
out := make([]map[string]any, 0, len(p.queue))
ids := append([]string(nil), p.queue...)
sort.Strings(ids)
for _, id := range ids {
h := p.health[id]
entry := map[string]any{
"id": id,
"in_use": p.inUse[id],
"weight": 1.0,
"failure_count": 0,
"cooldown_remaining": 0,
"last_failure_kind": "",
"last_success_at": int64(0),
"last_failure_at": int64(0),
"active": true,
"muted": false,
"mute_until": float64(0),
"last_used": float64(0),
}
if p.store != nil {
if acc, ok := p.store.FindAccount(id); ok {
entry["active"] = acc.IsActive()
entry["muted"] = acc.IsMuted(now)
entry["mute_until"] = acc.MuteUntil
entry["last_used"] = acc.LastUsed
}
}
if h != nil {
entry["weight"] = roundToTwo(h.effectiveWeight(p.healthCfg, now))
entry["failure_count"] = h.failureCount
entry["cooldown_remaining"] = int(h.cooldownRemaining(now).Seconds())
entry["last_failure_kind"] = string(h.lastFailureKind)
if !h.lastSuccessAt.IsZero() {
entry["last_success_at"] = h.lastSuccessAt.Unix()
}
if !h.lastFailureAt.IsZero() {
entry["last_failure_at"] = h.lastFailureAt.Unix()
}
}
out = append(out, entry)
}
return out
}
func roundToTwo(v float64) float64 {
return float64(int(v*100+0.5)) / 100
}
func (p *Pool) Status() map[string]any {
p.mu.Lock()
defer p.mu.Unlock()
now := p.now()
available := make([]string, 0, len(p.queue))
inUseAccounts := make([]string, 0, len(p.inUse))
inUseSlots := 0
for _, id := range p.queue {
if p.inUse[id] < p.maxInflightPerAccount {
available = append(available, id)
if p.inUse[id] >= p.maxInflightPerAccount {
continue
}
if p.store != nil {
if _, ok := p.store.FindAvailableAccount(id, now); !ok {
continue
}
}
if p.healthCfg.enabled {
if h := p.health[id]; h != nil && h.cooldownRemaining(now) > 0 {
continue
}
}
available = append(available, id)
}
for id, count := range p.inUse {
if count > 0 {
@@ -117,10 +269,14 @@ func (p *Pool) Status() map[string]any {
}
}
sort.Strings(inUseAccounts)
total := len(p.queue)
if p.store != nil {
total = len(p.store.Accounts())
}
return map[string]any{
"available": len(available),
"in_use": inUseSlots,
"total": len(p.store.Accounts()),
"total": total,
"available_accounts": available,
"in_use_accounts": inUseAccounts,
"max_inflight_per_account": p.maxInflightPerAccount,
@@ -128,5 +284,7 @@ func (p *Pool) Status() map[string]any {
"recommended_concurrency": p.recommendedConcurrency,
"waiting": len(p.waiters),
"max_queue_size": p.maxQueueSize,
"health_enabled": p.healthCfg.enabled,
"accounts": p.healthSnapshotLocked(now),
}
}
+5 -1
View File
@@ -14,7 +14,11 @@ func (p *Pool) ApplyRuntimeLimits(maxInflightPerAccount, maxQueueSize, globalMax
maxQueueSize = 0
}
if globalMaxInflight <= 0 {
globalMaxInflight = maxInflightPerAccount * len(p.store.Accounts())
accountCount := len(p.queue)
if p.store != nil {
accountCount = len(p.store.Accounts())
}
globalMaxInflight = maxInflightPerAccount * accountCount
if globalMaxInflight <= 0 {
globalMaxInflight = maxInflightPerAccount
}
+39
View File
@@ -236,6 +236,45 @@ func TestPoolAcquireRotatesIntoTokenlessAccounts(t *testing.T) {
}
}
func TestPoolSkipsMutedAccountsUntilMuteExpires(t *testing.T) {
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "")
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[
{"email":"muted@example.com","token":"token1","muted":true,"mute_until":4102444800},
{"email":"active@example.com","token":"token2"}
]
}`)
pool := NewPool(config.LoadStore())
acc, ok := pool.Acquire("", nil)
if !ok {
t.Fatal("expected acquire to skip muted account and select active account")
}
if got := acc.Identifier(); got != "active@example.com" {
t.Fatalf("expected active account, got %q", got)
}
}
func TestPoolPenalizeDemotesAccountDuringHealthSelection(t *testing.T) {
pool := newPoolForTest(t, "1")
pool.Penalize("acc1@example.com", PenaltyHTTP429)
acc, ok := pool.Acquire("", nil)
if !ok {
t.Fatal("expected acquire to select non-penalized account")
}
if got := acc.Identifier(); got != "acc2@example.com" {
t.Fatalf("expected acc2 after acc1 penalty, got %q", got)
}
status := pool.Status()
accounts, _ := status["accounts"].([]map[string]any)
if len(accounts) == 0 {
t.Fatalf("expected health account status, got %#v", status["accounts"])
}
}
func TestPoolAcquireWaitQueuesAndSucceedsAfterRelease(t *testing.T) {
pool := newSingleAccountPoolForTest(t, "1")
first, ok := pool.Acquire("", nil)
+1 -1
View File
@@ -5,7 +5,7 @@ func (p *Pool) canQueueLocked(target string, exclude map[string]bool) bool {
if exclude[target] {
return false
}
if _, ok := p.store.FindAccount(target); !ok {
if _, ok := p.store.FindAvailableAccount(target, p.now()); !ok {
return false
}
}
+72
View File
@@ -28,9 +28,11 @@ type RequestAuth struct {
DeepSeekToken string
CallerID string
AccountID string
TargetAccount string
Account config.Account
TriedAccounts map[string]bool
resolver *Resolver
Penalized bool
}
type LoginFunc func(ctx context.Context, acc config.Account) (string, error)
@@ -99,6 +101,7 @@ func (r *Resolver) acquireManagedRequestAuth(ctx context.Context, callerID, targ
UseConfigToken: true,
CallerID: callerID,
AccountID: acc.Identifier(),
TargetAccount: target,
Account: acc,
TriedAccounts: tried,
resolver: r,
@@ -107,6 +110,7 @@ func (r *Resolver) acquireManagedRequestAuth(ctx context.Context, callerID, targ
if err := r.ensureManagedToken(ctx, a); err != nil {
lastEnsureErr = err
tried[a.AccountID] = true
r.penalize(a, account.PenaltyAuthFailed)
r.Pool.Release(a.AccountID)
if target != "" {
return nil, err
@@ -166,6 +170,7 @@ func (r *Resolver) RefreshToken(ctx context.Context, a *RequestAuth) bool {
a.Account.Token = ""
if err := r.loginAndPersist(ctx, a); err != nil {
config.Logger.Error("[refresh_token] failed", "account", a.AccountID, "error", err)
r.penalize(a, account.PenaltyAuthFailed)
return false
}
return true
@@ -182,13 +187,26 @@ func (r *Resolver) MarkTokenInvalid(a *RequestAuth) {
}
func (r *Resolver) SwitchAccount(ctx context.Context, a *RequestAuth) bool {
return r.SwitchAccountWithPenalty(ctx, a, account.PenaltyUnknown)
}
func (r *Resolver) SwitchAccountWithPenalty(ctx context.Context, a *RequestAuth, kind account.PenaltyKind) bool {
if !a.UseConfigToken {
return false
}
if strings.TrimSpace(a.TargetAccount) != "" {
if a.AccountID != "" && kind != account.PenaltyUnknown {
r.penalize(a, kind)
}
return false
}
if a.TriedAccounts == nil {
a.TriedAccounts = map[string]bool{}
}
if a.AccountID != "" {
if kind != account.PenaltyUnknown {
r.penalize(a, kind)
}
a.TriedAccounts[a.AccountID] = true
r.Pool.Release(a.AccountID)
}
@@ -200,21 +218,75 @@ func (r *Resolver) SwitchAccount(ctx context.Context, a *RequestAuth) bool {
a.Account = acc
a.AccountID = acc.Identifier()
if err := r.ensureManagedToken(ctx, a); err != nil {
r.penalize(a, account.PenaltyAuthFailed)
a.TriedAccounts[a.AccountID] = true
r.Pool.Release(a.AccountID)
continue
}
a.Penalized = false
return true
}
}
func (a *RequestAuth) SwitchAccount(ctx context.Context) bool {
if a == nil || a.resolver == nil {
return false
}
return a.resolver.SwitchAccount(ctx, a)
}
func (a *RequestAuth) SwitchAccountWithPenalty(ctx context.Context, kind account.PenaltyKind) bool {
if a == nil || a.resolver == nil {
return false
}
return a.resolver.SwitchAccountWithPenalty(ctx, a, kind)
}
func (r *Resolver) MarkAccountMuted(a *RequestAuth, muteUntil float64) {
if r == nil || r.Store == nil || a == nil || !a.UseConfigToken || a.AccountID == "" {
return
}
if a.TriedAccounts == nil {
a.TriedAccounts = map[string]bool{}
}
a.TriedAccounts[a.AccountID] = true
a.Account.Muted = true
a.Account.MuteUntil = muteUntil
if err := r.Store.MarkAccountMuted(a.AccountID, muteUntil); err != nil {
config.Logger.Warn("[account_mute] persist failed", "account", a.AccountID, "mute_until", muteUntil, "error", err)
}
r.penalize(a, account.PenaltyMuted)
}
func (a *RequestAuth) MarkAccountMuted(muteUntil float64) {
if a == nil || a.resolver == nil {
return
}
a.resolver.MarkAccountMuted(a, muteUntil)
}
func (r *Resolver) Release(a *RequestAuth) {
if a == nil || !a.UseConfigToken || a.AccountID == "" {
return
}
if !a.Penalized {
r.Pool.RecordSuccess(a.AccountID)
}
r.Pool.Release(a.AccountID)
}
func (r *Resolver) penalize(a *RequestAuth, kind account.PenaltyKind) {
if r == nil || r.Pool == nil || a == nil || a.AccountID == "" || kind == account.PenaltyUnknown {
return
}
r.Pool.Penalize(a.AccountID, kind)
a.Penalized = true
}
func (r *Resolver) Penalize(a *RequestAuth, kind account.PenaltyKind) {
r.penalize(a, kind)
}
func extractCallerToken(req *http.Request) string {
authHeader := strings.TrimSpace(req.Header.Get("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
+22 -1
View File
@@ -1,6 +1,9 @@
package config
import "strings"
import (
"strings"
"time"
)
func (a Account) Identifier() string {
if strings.TrimSpace(a.Email) != "" {
@@ -11,3 +14,21 @@ func (a Account) Identifier() string {
}
return ""
}
func (a Account) IsActive() bool {
return a.Active == nil || *a.Active
}
func (a Account) IsMuted(now time.Time) bool {
if !a.Muted {
return false
}
if a.MuteUntil <= 0 {
return true
}
return a.MuteUntil > float64(now.UnixNano())/1e9
}
func (a Account) MuteExpired(now time.Time) bool {
return a.Muted && a.MuteUntil > 0 && a.MuteUntil <= float64(now.UnixNano())/1e9
}
+19 -1
View File
@@ -32,7 +32,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
if strings.TrimSpace(c.Admin.PasswordHash) != "" || c.Admin.JWTExpireHours > 0 || c.Admin.JWTValidAfterUnix > 0 {
m["admin"] = c.Admin
}
if c.Runtime.AccountMaxInflight > 0 || c.Runtime.AccountMaxQueue > 0 || c.Runtime.GlobalMaxInflight > 0 || c.Runtime.TokenRefreshIntervalHours > 0 {
if runtimeConfigPresent(c.Runtime) {
m["runtime"] = c.Runtime
}
if c.Compat.WideInputStrictOutput != nil || c.Compat.StripReferenceMarkers != nil {
@@ -57,6 +57,24 @@ func (c Config) MarshalJSON() ([]byte, error) {
return json.Marshal(m)
}
func runtimeConfigPresent(runtime RuntimeConfig) bool {
return runtime.AccountMaxInflight > 0 ||
runtime.AccountMaxQueue > 0 ||
runtime.GlobalMaxInflight > 0 ||
runtime.TokenRefreshIntervalHours > 0 ||
runtime.DisableUpstreamFileUploads != nil ||
runtime.AccountHealthEnabled != nil ||
runtime.AccountHealthRecoveryWindowSeconds > 0 ||
runtime.AccountHealthMaxCooldownSeconds > 0 ||
runtime.AccountHealthCooldown429Seconds > 0 ||
runtime.AccountHealthCooldown403Seconds > 0 ||
runtime.AccountHealthCooldownAuthSeconds > 0 ||
runtime.AccountHealthCooldown5xxSeconds > 0 ||
runtime.AccountHealthCooldownNetworkSeconds > 0 ||
runtime.AccountHealthCooldownEmptySeconds > 0 ||
runtime.AccountHealthCooldownMutedSeconds > 0
}
func (c *Config) UnmarshalJSON(b []byte) error {
raw := map[string]json.RawMessage{}
if err := json.Unmarshal(b, &raw); err != nil {
+26 -11
View File
@@ -26,13 +26,17 @@ type Config struct {
}
type Account struct {
Name string `json:"name,omitempty"`
Remark string `json:"remark,omitempty"`
Email string `json:"email,omitempty"`
Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
ProxyID string `json:"proxy_id,omitempty"`
Name string `json:"name,omitempty"`
Remark string `json:"remark,omitempty"`
Email string `json:"email,omitempty"`
Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
ProxyID string `json:"proxy_id,omitempty"`
Active *bool `json:"active,omitempty"`
Muted bool `json:"muted,omitempty"`
MuteUntil float64 `json:"mute_until,omitempty"`
LastUsed float64 `json:"last_used,omitempty"`
}
type APIKey struct {
@@ -161,10 +165,21 @@ type AdminConfig struct {
}
type RuntimeConfig struct {
AccountMaxInflight int `json:"account_max_inflight,omitempty"`
AccountMaxQueue int `json:"account_max_queue,omitempty"`
GlobalMaxInflight int `json:"global_max_inflight,omitempty"`
TokenRefreshIntervalHours int `json:"token_refresh_interval_hours,omitempty"`
AccountMaxInflight int `json:"account_max_inflight,omitempty"`
AccountMaxQueue int `json:"account_max_queue,omitempty"`
GlobalMaxInflight int `json:"global_max_inflight,omitempty"`
TokenRefreshIntervalHours int `json:"token_refresh_interval_hours,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"`
AccountHealthMaxCooldownSeconds int `json:"account_health_max_cooldown_seconds,omitempty"`
AccountHealthCooldown429Seconds int `json:"account_health_cooldown_429_seconds,omitempty"`
AccountHealthCooldown403Seconds int `json:"account_health_cooldown_403_seconds,omitempty"`
AccountHealthCooldownAuthSeconds int `json:"account_health_cooldown_auth_seconds,omitempty"`
AccountHealthCooldown5xxSeconds int `json:"account_health_cooldown_5xx_seconds,omitempty"`
AccountHealthCooldownNetworkSeconds int `json:"account_health_cooldown_network_seconds,omitempty"`
AccountHealthCooldownEmptySeconds int `json:"account_health_cooldown_empty_seconds,omitempty"`
AccountHealthCooldownMutedSeconds int `json:"account_health_cooldown_muted_seconds,omitempty"`
}
type ResponsesConfig struct {
+83
View File
@@ -159,6 +159,89 @@ func (s *Store) RuntimeTokenRefreshIntervalHours() int {
return 6
}
func (s *Store) UpstreamFileUploadsEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.Runtime.DisableUpstreamFileUploads == nil || !*s.cfg.Runtime.DisableUpstreamFileUploads
}
type upstreamFileUploadsEnabledReader interface {
UpstreamFileUploadsEnabled() bool
}
func UpstreamFileUploadsEnabledFrom(reader any) bool {
if reader == nil {
return true
}
if r, ok := reader.(upstreamFileUploadsEnabledReader); ok {
return r.UpstreamFileUploadsEnabled()
}
return true
}
func (s *Store) AccountHealthEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.cfg.Runtime.AccountHealthEnabled == nil {
return true
}
return *s.cfg.Runtime.AccountHealthEnabled
}
func (s *Store) AccountHealthRecoveryWindowSeconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.Runtime.AccountHealthRecoveryWindowSeconds
}
func (s *Store) AccountHealthMaxCooldownSeconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.Runtime.AccountHealthMaxCooldownSeconds
}
func (s *Store) AccountHealthCooldown429Seconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.Runtime.AccountHealthCooldown429Seconds
}
func (s *Store) AccountHealthCooldown403Seconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.Runtime.AccountHealthCooldown403Seconds
}
func (s *Store) AccountHealthCooldownAuthSeconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.Runtime.AccountHealthCooldownAuthSeconds
}
func (s *Store) AccountHealthCooldown5xxSeconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.Runtime.AccountHealthCooldown5xxSeconds
}
func (s *Store) AccountHealthCooldownNetworkSeconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.Runtime.AccountHealthCooldownNetworkSeconds
}
func (s *Store) AccountHealthCooldownEmptySeconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.Runtime.AccountHealthCooldownEmptySeconds
}
func (s *Store) AccountHealthCooldownMutedSeconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.Runtime.AccountHealthCooldownMutedSeconds
}
func (s *Store) AutoDeleteSessions() bool {
return s.AutoDeleteMode() != "none"
}
+93
View File
@@ -0,0 +1,93 @@
package config
import (
"errors"
"strings"
"time"
)
func (s *Store) MarkAccountMuted(identifier string, muteUntil float64) error {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return errors.New("account identifier is required")
}
if muteUntil <= 0 {
muteUntil = float64(time.Now().Add(5 * time.Minute).Unix())
}
s.mu.Lock()
defer s.mu.Unlock()
idx, ok := s.findAccountIndexLocked(identifier)
if !ok {
return errors.New("account not found")
}
s.cfg.Accounts[idx].Muted = true
s.cfg.Accounts[idx].MuteUntil = muteUntil
s.cfg.Accounts[idx].LastUsed = float64(time.Now().Unix())
return s.saveLocked()
}
func (s *Store) ClearAccountMute(identifier string) error {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return errors.New("account identifier is required")
}
s.mu.Lock()
defer s.mu.Unlock()
idx, ok := s.findAccountIndexLocked(identifier)
if !ok {
return errors.New("account not found")
}
s.cfg.Accounts[idx].Muted = false
s.cfg.Accounts[idx].MuteUntil = 0
return s.saveLocked()
}
func (s *Store) TouchAccountLastUsed(identifier string, ts float64) error {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return errors.New("account identifier is required")
}
if ts <= 0 {
ts = float64(time.Now().Unix())
}
s.mu.Lock()
defer s.mu.Unlock()
idx, ok := s.findAccountIndexLocked(identifier)
if !ok {
return errors.New("account not found")
}
s.cfg.Accounts[idx].LastUsed = ts
return s.saveLocked()
}
func (s *Store) FindAvailableAccount(identifier string, now time.Time) (Account, bool) {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return Account{}, false
}
if now.IsZero() {
now = time.Now()
}
s.mu.Lock()
defer s.mu.Unlock()
idx, ok := s.findAccountIndexLocked(identifier)
if !ok {
return Account{}, false
}
acc := s.cfg.Accounts[idx]
if !acc.IsActive() {
return Account{}, false
}
if acc.MuteExpired(now) {
s.cfg.Accounts[idx].Muted = false
s.cfg.Accounts[idx].MuteUntil = 0
acc = s.cfg.Accounts[idx]
if err := s.saveLocked(); err != nil {
Logger.Warn("[account_mute] clear expired mute failed", "account", identifier, "error", err)
}
}
if acc.IsMuted(now) {
return Account{}, false
}
return acc, true
}
+27
View File
@@ -96,6 +96,33 @@ 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_health_recovery_window_seconds", runtime.AccountHealthRecoveryWindowSeconds, 1, 86400, false); err != nil {
return err
}
if err := ValidateIntRange("runtime.account_health_max_cooldown_seconds", runtime.AccountHealthMaxCooldownSeconds, 1, 86400, false); err != nil {
return err
}
if err := ValidateIntRange("runtime.account_health_cooldown_429_seconds", runtime.AccountHealthCooldown429Seconds, 1, 86400, false); err != nil {
return err
}
if err := ValidateIntRange("runtime.account_health_cooldown_403_seconds", runtime.AccountHealthCooldown403Seconds, 1, 86400, false); err != nil {
return err
}
if err := ValidateIntRange("runtime.account_health_cooldown_auth_seconds", runtime.AccountHealthCooldownAuthSeconds, 1, 86400, false); err != nil {
return err
}
if err := ValidateIntRange("runtime.account_health_cooldown_5xx_seconds", runtime.AccountHealthCooldown5xxSeconds, 1, 86400, false); err != nil {
return err
}
if err := ValidateIntRange("runtime.account_health_cooldown_network_seconds", runtime.AccountHealthCooldownNetworkSeconds, 1, 86400, false); err != nil {
return err
}
if err := ValidateIntRange("runtime.account_health_cooldown_empty_seconds", runtime.AccountHealthCooldownEmptySeconds, 0, 86400, false); err != nil {
return err
}
if err := ValidateIntRange("runtime.account_health_cooldown_muted_seconds", runtime.AccountHealthCooldownMutedSeconds, 1, 86400, false); err != nil {
return err
}
if runtime.AccountMaxInflight > 0 && runtime.GlobalMaxInflight > 0 && runtime.GlobalMaxInflight < runtime.AccountMaxInflight {
return fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
+38 -141
View File
@@ -7,18 +7,19 @@ import (
"fmt"
"net/http"
"strings"
"unicode"
"ds2api/internal/account"
"ds2api/internal/auth"
"ds2api/internal/config"
)
func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) {
clients := c.requestClientsForAccount(acc)
accountID := strings.TrimSpace(acc.Identifier())
payload := map[string]any{
"password": strings.TrimSpace(acc.Password),
"device_id": "deepseek_to_api",
"os": "android",
"device_id": DeviceID(accountID),
"os": "Android",
}
if email := strings.TrimSpace(acc.Email); email != "" {
payload["email"] = email
@@ -62,10 +63,25 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekCreateSessionURL, headers, map[string]any{"agent": "chat"})
if err != nil {
config.Logger.Warn("[create_session] request error", "error", err, "account", a.AccountID)
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
refreshed = false
attempts++
clients = c.requestClientsForAuth(ctx, a)
continue
}
attempts++
continue
}
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if muted, muteErr := c.handleMutedResponse(ctx, a, "create session", resp); muted {
if muteErr != nil {
return "", muteErr
}
refreshed = false
attempts++
clients = c.requestClientsForAuth(ctx, a)
continue
}
if status == http.StatusOK && code == 0 && bizCode == 0 {
sessionID := extractCreateSessionID(resp)
if sessionID != "" {
@@ -80,9 +96,10 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
if c.Auth.SwitchAccountWithPenalty(ctx, a, penaltyForFailedStatus(status, code, bizCode, msg, bizMsg)) {
refreshed = false
attempts++
clients = c.requestClientsForAuth(ctx, a)
continue
}
}
@@ -115,10 +132,25 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe
config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID, "target_path", targetPath)
lastFailureKind = FailureUnknown
lastFailureMessage = err.Error()
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
refreshed = false
attempts++
clients = c.requestClientsForAuth(ctx, a)
continue
}
attempts++
continue
}
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if muted, muteErr := c.handleMutedResponse(ctx, a, "get pow", resp); muted {
if muteErr != nil {
return "", muteErr
}
refreshed = false
attempts++
clients = c.requestClientsForAuth(ctx, a)
continue
}
if status == http.StatusOK && code == 0 && bizCode == 0 {
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
@@ -144,9 +176,10 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
if c.Auth.SwitchAccountWithPenalty(ctx, a, penaltyForFailedStatus(status, code, bizCode, msg, bizMsg)) {
refreshed = false
attempts++
clients = c.requestClientsForAuth(ctx, a)
continue
}
}
@@ -157,139 +190,3 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe
}
return "", errors.New("get pow failed")
}
func (c *Client) authHeaders(token string) map[string]string {
headers := make(map[string]string, len(dsprotocol.BaseHeaders)+1)
for k, v := range dsprotocol.BaseHeaders {
headers[k] = v
}
headers["authorization"] = "Bearer " + token
return headers
}
func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool {
msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return true
}
if code == 40001 || code == 40002 || code == 40003 || bizCode == 40001 || bizCode == 40002 || bizCode == 40003 {
return true
}
return strings.Contains(msg, "token") ||
strings.Contains(msg, "unauthorized") ||
strings.Contains(msg, "expired") ||
strings.Contains(msg, "not login") ||
strings.Contains(msg, "login required") ||
strings.Contains(msg, "invalid jwt")
}
func shouldAttemptRefresh(status int, code int, bizCode int, msg string, bizMsg string) bool {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) {
return true
}
// Some DeepSeek failures come back as HTTP 200/code=0 but with non-zero biz_code.
// Only attempt refresh when these biz failures still look auth-related.
return status == http.StatusOK &&
code == 0 &&
bizCode != 0 &&
isAuthIndicativeBizFailure(msg, bizMsg)
}
func isAuthIndicativeBizFailure(msg string, bizMsg string) bool {
combined := strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
authKeywords := []string{
"auth",
"authorization",
"credential",
"expired",
"invalid jwt",
"jwt",
"login",
"not login",
"session expired",
"token",
"unauthorized",
"登录",
"未登录",
"认证",
"凭证",
"会话过期",
"令牌",
}
for _, keyword := range authKeywords {
if strings.Contains(combined, keyword) {
return true
}
}
return false
}
func authFailureKind(useConfigToken bool) FailureKind {
if useConfigToken {
return FailureManagedUnauthorized
}
return FailureDirectUnauthorized
}
func failureMessage(msg string, bizMsg string, fallback string) string {
if trimmed := strings.TrimSpace(bizMsg); trimmed != "" {
return trimmed
}
if trimmed := strings.TrimSpace(msg); trimmed != "" {
return trimmed
}
return strings.TrimSpace(fallback)
}
// DeepSeek has returned create-session ids in both biz_data.id and
// biz_data.chat_session.id across observed response variants; accept either.
func extractCreateSessionID(resp map[string]any) string {
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
if sessionID, _ := bizData["id"].(string); strings.TrimSpace(sessionID) != "" {
return strings.TrimSpace(sessionID)
}
if chatSession, ok := bizData["chat_session"].(map[string]any); ok {
if sessionID, _ := chatSession["id"].(string); strings.TrimSpace(sessionID) != "" {
return strings.TrimSpace(sessionID)
}
}
return ""
}
func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) {
code = intFrom(resp["code"])
msg, _ = resp["msg"].(string)
data, _ := resp["data"].(map[string]any)
bizCode = intFrom(data["biz_code"])
bizMsg, _ = data["biz_msg"].(string)
if strings.TrimSpace(bizMsg) == "" {
if bizData, ok := data["biz_data"].(map[string]any); ok {
bizMsg, _ = bizData["msg"].(string)
}
}
return code, bizCode, msg, bizMsg
}
func normalizeMobileForLogin(raw string) (mobile string, areaCode any) {
s := strings.TrimSpace(raw)
if s == "" {
return "", nil
}
hasPlus := strings.HasPrefix(s, "+")
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if unicode.IsDigit(r) {
b.WriteRune(r)
}
}
digits := b.String()
if digits == "" {
return "", nil
}
if (hasPlus || strings.HasPrefix(digits, "86")) && strings.HasPrefix(digits, "86") && len(digits) == 13 {
return digits[2:], nil
}
return digits, nil
}
@@ -0,0 +1,79 @@
package client
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"ds2api/internal/config"
)
type captureLoginDoer struct {
method string
url string
headers http.Header
body []byte
}
func (d *captureLoginDoer) Do(req *http.Request) (*http.Response, error) {
d.method = req.Method
d.url = req.URL.String()
d.headers = req.Header.Clone()
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
d.body = body
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(
`{"code":0,"msg":"","data":{"biz_code":0,"biz_msg":"","biz_data":{"user":{"token":"token-from-email-login"}}}}`,
)),
}, nil
}
func TestLoginPrefersEmailPayloadWhenEmailPresent(t *testing.T) {
doer := &captureLoginDoer{}
c := &Client{
regular: doer,
stream: doer,
fallback: &http.Client{},
fallbackS: &http.Client{},
}
token, err := c.Login(context.Background(), config.Account{
Email: "global@example.com",
Mobile: "+8613800138000",
Password: "secret",
})
if err != nil {
t.Fatalf("login returned error: %v", err)
}
if token != "token-from-email-login" {
t.Fatalf("unexpected token: %q", token)
}
if doer.method != http.MethodPost {
t.Fatalf("unexpected method: %q", doer.method)
}
var payload map[string]any
if err := json.Unmarshal(doer.body, &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload["email"] != "global@example.com" {
t.Fatalf("expected email payload, got %#v", payload)
}
if _, ok := payload["mobile"]; ok {
t.Fatalf("did not expect mobile in email login payload, got %#v", payload)
}
if _, ok := payload["area_code"]; ok {
t.Fatalf("did not expect area_code in email login payload, got %#v", payload)
}
if payload["password"] != "secret" {
t.Fatalf("expected password preserved, got %#v", payload)
}
}
@@ -0,0 +1,162 @@
package client
import (
dsprotocol "ds2api/internal/deepseek/protocol"
"net/http"
"strings"
"unicode"
"ds2api/internal/account"
)
func penaltyForFailedStatus(status int, code int, bizCode int, msg string, bizMsg string) account.PenaltyKind {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) || isAuthIndicativeBizFailure(msg, bizMsg) {
return account.PenaltyAuthFailed
}
switch {
case status == http.StatusTooManyRequests:
return account.PenaltyHTTP429
case status == http.StatusForbidden:
return account.PenaltyHTTP403
case status >= 500 && status <= 599:
return account.PenaltyHTTP5xx
default:
return account.PenaltyUnknown
}
}
func (c *Client) authHeaders(token string) map[string]string {
headers := make(map[string]string, len(dsprotocol.BaseHeaders)+1)
for k, v := range dsprotocol.BaseHeaders {
headers[k] = v
}
headers["authorization"] = "Bearer " + token
return headers
}
func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool {
msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return true
}
if code == 40001 || code == 40002 || code == 40003 || bizCode == 40001 || bizCode == 40002 || bizCode == 40003 {
return true
}
return strings.Contains(msg, "token") ||
strings.Contains(msg, "unauthorized") ||
strings.Contains(msg, "expired") ||
strings.Contains(msg, "not login") ||
strings.Contains(msg, "login required") ||
strings.Contains(msg, "invalid jwt")
}
func shouldAttemptRefresh(status int, code int, bizCode int, msg string, bizMsg string) bool {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) {
return true
}
// Some DeepSeek failures come back as HTTP 200/code=0 but with non-zero biz_code.
// Only attempt refresh when these biz failures still look auth-related.
return status == http.StatusOK &&
code == 0 &&
bizCode != 0 &&
isAuthIndicativeBizFailure(msg, bizMsg)
}
func isAuthIndicativeBizFailure(msg string, bizMsg string) bool {
combined := strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
authKeywords := []string{
"auth",
"authorization",
"credential",
"expired",
"invalid jwt",
"jwt",
"login",
"not login",
"session expired",
"token",
"unauthorized",
"登录",
"未登录",
"认证",
"凭证",
"会话过期",
"令牌",
}
for _, keyword := range authKeywords {
if strings.Contains(combined, keyword) {
return true
}
}
return false
}
func authFailureKind(useConfigToken bool) FailureKind {
if useConfigToken {
return FailureManagedUnauthorized
}
return FailureDirectUnauthorized
}
func failureMessage(msg string, bizMsg string, fallback string) string {
if trimmed := strings.TrimSpace(bizMsg); trimmed != "" {
return trimmed
}
if trimmed := strings.TrimSpace(msg); trimmed != "" {
return trimmed
}
return strings.TrimSpace(fallback)
}
// DeepSeek has returned create-session ids in both biz_data.id and
// biz_data.chat_session.id across observed response variants; accept either.
func extractCreateSessionID(resp map[string]any) string {
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
if sessionID, _ := bizData["id"].(string); strings.TrimSpace(sessionID) != "" {
return strings.TrimSpace(sessionID)
}
if chatSession, ok := bizData["chat_session"].(map[string]any); ok {
if sessionID, _ := chatSession["id"].(string); strings.TrimSpace(sessionID) != "" {
return strings.TrimSpace(sessionID)
}
}
return ""
}
func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) {
code = intFrom(resp["code"])
msg, _ = resp["msg"].(string)
data, _ := resp["data"].(map[string]any)
bizCode = intFrom(data["biz_code"])
bizMsg, _ = data["biz_msg"].(string)
if strings.TrimSpace(bizMsg) == "" {
if bizData, ok := data["biz_data"].(map[string]any); ok {
bizMsg, _ = bizData["msg"].(string)
}
}
return code, bizCode, msg, bizMsg
}
func normalizeMobileForLogin(raw string) (mobile string, areaCode any) {
s := strings.TrimSpace(raw)
if s == "" {
return "", nil
}
hasPlus := strings.HasPrefix(s, "+")
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if unicode.IsDigit(r) {
b.WriteRune(r)
}
}
digits := b.String()
if digits == "" {
return "", nil
}
if (hasPlus || strings.HasPrefix(digits, "86")) && strings.HasPrefix(digits, "86") && len(digits) == 13 {
return digits[2:], nil
}
return digits, nil
}
@@ -9,6 +9,7 @@ import (
"net/http"
"time"
"ds2api/internal/account"
"ds2api/internal/auth"
"ds2api/internal/config"
trans "ds2api/internal/deepseek/transport"
@@ -26,11 +27,39 @@ func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payloa
for attempts < maxAttempts {
resp, err := c.streamPost(ctx, clients.stream, dsprotocol.DeepSeekCompletionURL, headers, payload)
if err != nil {
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
nextPow, powErr := c.GetPow(ctx, a, maxAttempts)
if powErr != nil {
return nil, powErr
}
clients = c.requestClientsForAuth(ctx, a)
headers = c.authHeaders(a.DeepSeekToken)
headers["x-ds-pow-response"] = nextPow
powResp = nextPow
}
attempts++
time.Sleep(time.Second)
continue
}
if resp.StatusCode == http.StatusOK {
muted, muteErr := c.detectCompletionMute(ctx, a, resp)
if muteErr != nil {
_ = resp.Body.Close()
return nil, muteErr
}
if muted {
_ = resp.Body.Close()
nextPow, powErr := c.GetPow(ctx, a, maxAttempts)
if powErr != nil {
return nil, powErr
}
attempts++
clients = c.requestClientsForAuth(ctx, a)
headers = c.authHeaders(a.DeepSeekToken)
headers["x-ds-pow-response"] = nextPow
powResp = nextPow
continue
}
if captureSession != nil {
resp.Body = captureSession.WrapBody(resp.Body, resp.StatusCode)
}
@@ -40,13 +69,37 @@ func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payloa
if captureSession != nil {
resp.Body = captureSession.WrapBody(resp.Body, resp.StatusCode)
}
penalty := completionPenaltyForStatus(resp.StatusCode)
_ = resp.Body.Close()
if a.UseConfigToken && penalty != account.PenaltyUnknown && c.Auth.SwitchAccountWithPenalty(ctx, a, penalty) {
nextPow, powErr := c.GetPow(ctx, a, maxAttempts)
if powErr != nil {
return nil, powErr
}
clients = c.requestClientsForAuth(ctx, a)
headers = c.authHeaders(a.DeepSeekToken)
headers["x-ds-pow-response"] = nextPow
powResp = nextPow
}
attempts++
time.Sleep(time.Second)
}
return nil, errors.New("completion failed")
}
func completionPenaltyForStatus(status int) account.PenaltyKind {
switch {
case status == http.StatusTooManyRequests:
return account.PenaltyHTTP429
case status == http.StatusForbidden:
return account.PenaltyHTTP403
case status >= 500 && status <= 599:
return account.PenaltyHTTP5xx
default:
return account.PenaltyUnknown
}
}
func (c *Client) streamPost(ctx context.Context, doer trans.Doer, url string, headers map[string]string, payload any) (*http.Response, error) {
b, err := json.Marshal(payload)
if err != nil {
+2 -2
View File
@@ -37,8 +37,8 @@ func NewClient(store *config.Store, resolver *auth.Resolver) *Client {
capture: devcapture.Global(),
regular: trans.New(60 * time.Second),
stream: trans.New(0),
fallback: &http.Client{Timeout: 60 * time.Second},
fallbackS: &http.Client{Timeout: 0},
fallback: trans.NewFallbackClient(60*time.Second, nil),
fallbackS: trans.NewFallbackClient(0, nil),
maxRetries: 3,
proxyClients: map[string]requestClients{},
}
+9 -1
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"strings"
"ds2api/internal/account"
"ds2api/internal/auth"
"ds2api/internal/config"
)
@@ -55,6 +56,12 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers)
if err != nil {
config.Logger.Warn("[get_session_count] request error", "error", err, "account", a.AccountID)
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
refreshed = false
attempts++
clients = c.requestClientsForAuth(ctx, a)
continue
}
attempts++
continue
}
@@ -92,9 +99,10 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
if c.Auth.SwitchAccountWithPenalty(ctx, a, penaltyForFailedStatus(status, code, bizCode, msg, bizMsg)) {
refreshed = false
attempts++
clients = c.requestClientsForAuth(ctx, a)
continue
}
}
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"ds2api/internal/account"
"ds2api/internal/auth"
"ds2api/internal/config"
)
@@ -47,6 +48,12 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
refreshed = false
attempts++
clients = c.requestClientsForAuth(ctx, a)
continue
}
attempts++
continue
}
@@ -67,9 +74,10 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
if c.Auth.SwitchAccountWithPenalty(ctx, a, penaltyForFailedStatus(status, code, bizCode, msg, bizMsg)) {
refreshed = false
attempts++
clients = c.requestClientsForAuth(ctx, a)
continue
}
}
+14 -1
View File
@@ -14,6 +14,7 @@ import (
"strconv"
"strings"
"ds2api/internal/account"
"ds2api/internal/auth"
"ds2api/internal/config"
trans "ds2api/internal/deepseek/transport"
@@ -90,6 +91,9 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
powHeader = ""
lastFailureKind = FailureUnknown
lastFailureMessage = err.Error()
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
refreshed = false
}
attempts++
continue
}
@@ -110,6 +114,15 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
}
}
code, bizCode, msg, bizMsg := extractResponseStatus(parsed)
if muted, muteErr := c.handleMutedResponse(ctx, a, "upload file", parsed); muted {
if muteErr != nil {
return nil, muteErr
}
refreshed = false
powHeader = ""
attempts++
continue
}
if resp.StatusCode == http.StatusOK && code == 0 && bizCode == 0 {
result := extractUploadFileResult(parsed)
result.Raw = parsed
@@ -150,7 +163,7 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
if c.Auth.SwitchAccountWithPenalty(ctx, a, penaltyForFailedStatus(resp.StatusCode, code, bizCode, msg, bizMsg)) {
refreshed = false
attempts++
continue
+16
View File
@@ -0,0 +1,16 @@
package client
import (
"crypto/sha512"
"encoding/base64"
"strings"
)
func DeviceID(accountIdentifier string) string {
trimmed := strings.TrimSpace(accountIdentifier)
if trimmed == "" {
trimmed = "ds2api"
}
hash := sha512.Sum512([]byte(trimmed))
return base64.StdEncoding.EncodeToString(hash[:])
}
+1
View File
@@ -11,6 +11,7 @@ const (
FailureUnknown FailureKind = ""
FailureDirectUnauthorized FailureKind = "direct_unauthorized"
FailureManagedUnauthorized FailureKind = "managed_unauthorized"
FailureAccountMuted FailureKind = "account_muted"
)
type RequestFailure struct {
+97
View File
@@ -0,0 +1,97 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"ds2api/internal/auth"
"ds2api/internal/config"
)
type muteInfo struct {
Muted bool
Until float64
}
func extractMuteInfo(resp map[string]any) muteInfo {
if resp == nil {
return 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
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"])}
}
return muteInfo{}
}
func floatFrom(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case json.Number:
f, _ := x.Float64()
return f
case string:
f, _ := strconv.ParseFloat(strings.TrimSpace(x), 64)
return f
default:
return 0
}
}
func (c *Client) handleMutedResponse(ctx context.Context, a *auth.RequestAuth, op string, resp map[string]any) (bool, error) {
info := extractMuteInfo(resp)
if !info.Muted {
return false, nil
}
if a != nil && a.UseConfigToken {
a.MarkAccountMuted(info.Until)
config.Logger.Warn("[account_mute] upstream muted account", "op", op, "account", a.AccountID, "mute_until", info.Until)
if a.SwitchAccount(ctx) {
return true, nil
}
}
msg := "account is muted"
if info.Until > 0 {
msg = fmt.Sprintf("%s until %.3f", msg, info.Until)
}
return true, &RequestFailure{Op: op, Kind: FailureAccountMuted, Message: msg}
}
func (c *Client) detectCompletionMute(ctx context.Context, a *auth.RequestAuth, resp *http.Response) (bool, error) {
if resp == nil || resp.Body == nil {
return false, nil
}
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
looksLikeJSON := strings.Contains(contentType, "json")
if !looksLikeJSON && (resp.ContentLength <= 0 || resp.ContentLength > 1<<20 || strings.Contains(contentType, "event-stream")) {
return false, nil
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
return false, err
}
resp.Body = io.NopCloser(bytes.NewReader(body))
parsed := map[string]any{}
if len(body) == 0 || json.Unmarshal(body, &parsed) != nil {
return false, nil
}
return c.handleMutedResponse(ctx, a, "completion", parsed)
}
+16
View File
@@ -3,6 +3,10 @@ package protocol
import (
_ "embed"
"encoding/json"
"fmt"
"math/rand"
"strconv"
"time"
)
const (
@@ -48,6 +52,7 @@ var defaultSkipExactPaths = []string{
}
var BaseHeaders = cloneStringMap(defaultBaseHeaders)
var RangersID string
var SkipContainsPatterns = cloneStringSlice(defaultSkipContainsPatterns)
var SkipExactPathSet = toStringSet(defaultSkipExactPaths)
@@ -61,13 +66,16 @@ type sharedConstants struct {
var sharedConstantsJSON []byte
func init() {
RangersID = fmt.Sprintf("%d", rand.Uint64()%10000000000000000000)
cfg := sharedConstants{}
if err := json.Unmarshal(sharedConstantsJSON, &cfg); err != nil {
applyDynamicBaseHeaders()
return
}
if len(cfg.BaseHeaders) > 0 {
BaseHeaders = cloneStringMap(cfg.BaseHeaders)
}
applyDynamicBaseHeaders()
if len(cfg.SkipContainsPattern) > 0 {
SkipContainsPatterns = cloneStringSlice(cfg.SkipContainsPattern)
}
@@ -76,6 +84,14 @@ func init() {
}
}
func applyDynamicBaseHeaders() {
_, offset := time.Now().Zone()
BaseHeaders["x-client-timezone-offset"] = strconv.Itoa(offset)
if RangersID != "" {
BaseHeaders["x-rangers-id"] = RangersID
}
}
func cloneStringMap(in map[string]string) map[string]string {
out := make(map[string]string, len(in))
for k, v := range in {
+19 -5
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"net/http/cookiejar"
"time"
utls "github.com/refraction-networking/utls"
@@ -17,6 +18,17 @@ type Doer interface {
type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
type okhttpTransport struct {
base *http.Transport
}
func (t *okhttpTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Header.Get("OkHttp-Preemptive") == "" {
req.Header.Set("OkHttp-Preemptive", "1")
}
return t.base.RoundTrip(req)
}
type Client struct {
http *http.Client
}
@@ -36,13 +48,14 @@ func NewWithDialContext(timeout time.Duration, dialContext DialContextFunc) *Cli
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
DialContext: dialContext,
DialTLSContext: safariTLSDialer(dialContext),
DialTLSContext: chromeTLSDialer(dialContext),
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
if useEnvProxy {
base.Proxy = http.ProxyFromEnvironment
}
return &Client{http: &http.Client{Timeout: timeout, Transport: base}}
jar, _ := cookiejar.New(nil)
return &Client{http: &http.Client{Timeout: timeout, Transport: &okhttpTransport{base: base}, Jar: jar}}
}
func (c *Client) Do(req *http.Request) (*http.Response, error) {
@@ -65,10 +78,11 @@ func NewFallbackClient(timeout time.Duration, dialContext DialContextFunc) *http
if useEnvProxy {
base.Proxy = http.ProxyFromEnvironment
}
return &http.Client{Timeout: timeout, Transport: base}
jar, _ := cookiejar.New(nil)
return &http.Client{Timeout: timeout, Transport: &okhttpTransport{base: base}, Jar: jar}
}
func safariTLSDialer(dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
func chromeTLSDialer(dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
if dialContext == nil {
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
}
@@ -79,7 +93,7 @@ func safariTLSDialer(dialContext DialContextFunc) func(ctx context.Context, netw
}
host, _, _ := net.SplitHostPort(addr)
uCfg := &utls.Config{ServerName: host}
uConn := utls.UClient(plainConn, uCfg, utls.HelloSafari_Auto)
uConn := utls.UClient(plainConn, uCfg, utls.HelloChrome_Auto)
if err := forceHTTP11ALPN(uConn); err != nil {
_ = plainConn.Close()
return nil, err
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -55,6 +56,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
end = total
}
items := make([]map[string]any, 0, end-start)
now := time.Now()
for _, acc := range accounts[start:end] {
testStatus, _ := h.Store.AccountTestStatus(acc.Identifier())
token := strings.TrimSpace(acc.Token)
@@ -69,6 +71,10 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
"has_token": token != "",
"token_preview": maskSecretPreview(token),
"test_status": testStatus,
"active": acc.IsActive(),
"muted": acc.IsMuted(now),
"mute_until": acc.MuteUntil,
"last_used": acc.LastUsed,
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
@@ -121,6 +127,7 @@ func (h *Handler) updateAccount(w http.ResponseWriter, r *http.Request) {
}
name, nameOK := fieldStringOptional(req, "name")
remark, remarkOK := fieldStringOptional(req, "remark")
activeRaw, activeOK := req["active"]
err := h.Store.Update(func(c *config.Config) error {
for i, acc := range c.Accounts {
@@ -133,6 +140,10 @@ func (h *Handler) updateAccount(w http.ResponseWriter, r *http.Request) {
if remarkOK {
c.Accounts[i].Remark = remark
}
if activeOK {
active := boolFromAny(activeRaw)
c.Accounts[i].Active = &active
}
return nil
}
return newRequestError("账号不存在")
@@ -145,9 +156,23 @@ func (h *Handler) updateAccount(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
if activeOK {
h.Pool.Reset()
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
func boolFromAny(v any) bool {
switch x := v.(type) {
case bool:
return x
case string:
return strings.EqualFold(strings.TrimSpace(x), "true") || strings.TrimSpace(x) == "1"
default:
return false
}
}
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
if decoded, err := url.PathUnescape(identifier); err == nil {
@@ -1,6 +1,7 @@
package accounts
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
@@ -87,6 +88,34 @@ func TestUpdateAccountMetadataPreservesCredentials(t *testing.T) {
}
}
func TestAddAccountAcceptsEmailOnly(t *testing.T) {
h := newAdminTestHandler(t, `{"accounts":[]}`)
r := chi.NewRouter()
r.Post("/admin/accounts", h.addAccount)
body := []byte(`{"email":"global@example.com","password":"secret"}`)
req := httptest.NewRequest(http.MethodPost, "/admin/accounts", bytes.NewReader(body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if len(snap.Accounts) != 1 {
t.Fatalf("expected one account, got %#v", snap.Accounts)
}
acc := snap.Accounts[0]
if acc.Email != "global@example.com" {
t.Fatalf("expected email account stored, got %#v", acc)
}
if acc.Identifier() != "global@example.com" {
t.Fatalf("expected email identifier, got %q", acc.Identifier())
}
}
func TestListAccountsMasksTokenPreview(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"email":"u@example.com","password":"pwd"}]
@@ -46,7 +46,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
}
if raw, ok := req["runtime"].(map[string]any); ok {
cfg := &config.RuntimeConfig{}
cfg := &config.RuntimeConfig{AccountHealthCooldownEmptySeconds: -1}
if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil {
@@ -75,6 +75,77 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
}
cfg.TokenRefreshIntervalHours = n
}
if v, exists := raw["disable_upstream_file_uploads"]; exists {
b := boolFrom(v)
cfg.DisableUpstreamFileUploads = &b
}
if v, exists := raw["account_health_enabled"]; exists {
b := boolFrom(v)
cfg.AccountHealthEnabled = &b
}
if v, exists := raw["account_health_recovery_window_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_health_recovery_window_seconds", n, 1, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountHealthRecoveryWindowSeconds = n
}
if v, exists := raw["account_health_max_cooldown_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_health_max_cooldown_seconds", n, 1, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountHealthMaxCooldownSeconds = n
}
if v, exists := raw["account_health_cooldown_429_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_health_cooldown_429_seconds", n, 1, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountHealthCooldown429Seconds = n
}
if v, exists := raw["account_health_cooldown_403_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_health_cooldown_403_seconds", n, 1, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountHealthCooldown403Seconds = n
}
if v, exists := raw["account_health_cooldown_auth_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_health_cooldown_auth_seconds", n, 1, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountHealthCooldownAuthSeconds = n
}
if v, exists := raw["account_health_cooldown_5xx_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_health_cooldown_5xx_seconds", n, 1, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountHealthCooldown5xxSeconds = n
}
if v, exists := raw["account_health_cooldown_network_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_health_cooldown_network_seconds", n, 1, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountHealthCooldownNetworkSeconds = n
}
if v, exists := raw["account_health_cooldown_empty_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_health_cooldown_empty_seconds", n, 0, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountHealthCooldownEmptySeconds = n
}
if v, exists := raw["account_health_cooldown_muted_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_health_cooldown_muted_seconds", n, 1, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountHealthCooldownMutedSeconds = n
}
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"ds2api/internal/account"
authn "ds2api/internal/auth"
"ds2api/internal/config"
)
@@ -12,6 +13,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
recommended := defaultRuntimeRecommended(len(snap.Accounts), h.Store.RuntimeAccountMaxInflight())
needsSync := config.IsVercel() && snap.VercelSyncHash != "" && snap.VercelSyncHash != h.computeSyncHash()
health := account.LoadHealthConfigFromStore(h.Store)
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"admin": map[string]any{
@@ -21,10 +23,21 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
"default_password_warning": authn.UsingDefaultAdminKey(h.Store),
},
"runtime": map[string]any{
"account_max_inflight": h.Store.RuntimeAccountMaxInflight(),
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
"token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
"account_max_inflight": h.Store.RuntimeAccountMaxInflight(),
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
"token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
"disable_upstream_file_uploads": !h.Store.UpstreamFileUploadsEnabled(),
"account_health_enabled": health.Enabled,
"account_health_recovery_window_seconds": health.RecoveryWindowSeconds,
"account_health_max_cooldown_seconds": health.MaxCooldownSeconds,
"account_health_cooldown_429_seconds": health.Cooldown429Seconds,
"account_health_cooldown_403_seconds": health.Cooldown403Seconds,
"account_health_cooldown_auth_seconds": health.CooldownAuthSeconds,
"account_health_cooldown_5xx_seconds": health.Cooldown5xxSeconds,
"account_health_cooldown_network_seconds": health.CooldownNetworkSeconds,
"account_health_cooldown_empty_seconds": health.CooldownEmptySeconds,
"account_health_cooldown_muted_seconds": health.CooldownMutedSeconds,
},
"compat": snap.Compat,
"responses": snap.Responses,
@@ -1,6 +1,9 @@
package settings
import "ds2api/internal/config"
import (
"ds2api/internal/account"
"ds2api/internal/config"
)
func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *config.RuntimeConfig) error {
merged := current
@@ -17,6 +20,39 @@ func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *confi
if incoming.TokenRefreshIntervalHours > 0 {
merged.TokenRefreshIntervalHours = incoming.TokenRefreshIntervalHours
}
if incoming.DisableUpstreamFileUploads != nil {
merged.DisableUpstreamFileUploads = incoming.DisableUpstreamFileUploads
}
if incoming.AccountHealthEnabled != nil {
merged.AccountHealthEnabled = incoming.AccountHealthEnabled
}
if incoming.AccountHealthRecoveryWindowSeconds > 0 {
merged.AccountHealthRecoveryWindowSeconds = incoming.AccountHealthRecoveryWindowSeconds
}
if incoming.AccountHealthMaxCooldownSeconds > 0 {
merged.AccountHealthMaxCooldownSeconds = incoming.AccountHealthMaxCooldownSeconds
}
if incoming.AccountHealthCooldown429Seconds > 0 {
merged.AccountHealthCooldown429Seconds = incoming.AccountHealthCooldown429Seconds
}
if incoming.AccountHealthCooldown403Seconds > 0 {
merged.AccountHealthCooldown403Seconds = incoming.AccountHealthCooldown403Seconds
}
if incoming.AccountHealthCooldownAuthSeconds > 0 {
merged.AccountHealthCooldownAuthSeconds = incoming.AccountHealthCooldownAuthSeconds
}
if incoming.AccountHealthCooldown5xxSeconds > 0 {
merged.AccountHealthCooldown5xxSeconds = incoming.AccountHealthCooldown5xxSeconds
}
if incoming.AccountHealthCooldownNetworkSeconds > 0 {
merged.AccountHealthCooldownNetworkSeconds = incoming.AccountHealthCooldownNetworkSeconds
}
if incoming.AccountHealthCooldownEmptySeconds >= 0 {
merged.AccountHealthCooldownEmptySeconds = incoming.AccountHealthCooldownEmptySeconds
}
if incoming.AccountHealthCooldownMutedSeconds > 0 {
merged.AccountHealthCooldownMutedSeconds = incoming.AccountHealthCooldownMutedSeconds
}
}
return validateRuntimeSettings(merged)
}
@@ -31,6 +67,7 @@ func (h *Handler) applyRuntimeSettings() {
maxQueue := h.Store.RuntimeAccountMaxQueue(recommended)
global := h.Store.RuntimeGlobalMaxInflight(recommended)
h.Pool.ApplyRuntimeLimits(maxPer, maxQueue, global)
h.Pool.ApplyHealthConfig(account.LoadHealthConfigFromStore(h.Store))
}
func defaultRuntimeRecommended(accountCount, maxPer int) int {
@@ -48,6 +48,39 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
if runtimeCfg.TokenRefreshIntervalHours > 0 {
c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours
}
if runtimeCfg.DisableUpstreamFileUploads != nil {
c.Runtime.DisableUpstreamFileUploads = runtimeCfg.DisableUpstreamFileUploads
}
if runtimeCfg.AccountHealthEnabled != nil {
c.Runtime.AccountHealthEnabled = runtimeCfg.AccountHealthEnabled
}
if runtimeCfg.AccountHealthRecoveryWindowSeconds > 0 {
c.Runtime.AccountHealthRecoveryWindowSeconds = runtimeCfg.AccountHealthRecoveryWindowSeconds
}
if runtimeCfg.AccountHealthMaxCooldownSeconds > 0 {
c.Runtime.AccountHealthMaxCooldownSeconds = runtimeCfg.AccountHealthMaxCooldownSeconds
}
if runtimeCfg.AccountHealthCooldown429Seconds > 0 {
c.Runtime.AccountHealthCooldown429Seconds = runtimeCfg.AccountHealthCooldown429Seconds
}
if runtimeCfg.AccountHealthCooldown403Seconds > 0 {
c.Runtime.AccountHealthCooldown403Seconds = runtimeCfg.AccountHealthCooldown403Seconds
}
if runtimeCfg.AccountHealthCooldownAuthSeconds > 0 {
c.Runtime.AccountHealthCooldownAuthSeconds = runtimeCfg.AccountHealthCooldownAuthSeconds
}
if runtimeCfg.AccountHealthCooldown5xxSeconds > 0 {
c.Runtime.AccountHealthCooldown5xxSeconds = runtimeCfg.AccountHealthCooldown5xxSeconds
}
if runtimeCfg.AccountHealthCooldownNetworkSeconds > 0 {
c.Runtime.AccountHealthCooldownNetworkSeconds = runtimeCfg.AccountHealthCooldownNetworkSeconds
}
if runtimeCfg.AccountHealthCooldownEmptySeconds >= 0 {
c.Runtime.AccountHealthCooldownEmptySeconds = runtimeCfg.AccountHealthCooldownEmptySeconds
}
if runtimeCfg.AccountHealthCooldownMutedSeconds > 0 {
c.Runtime.AccountHealthCooldownMutedSeconds = runtimeCfg.AccountHealthCooldownMutedSeconds
}
}
if compatCfg != nil {
if compatCfg.WideInputStrictOutput != nil {
+12
View File
@@ -32,6 +32,17 @@ type ConfigStore interface {
RuntimeAccountMaxQueue(defaultSize int) int
RuntimeGlobalMaxInflight(defaultSize int) int
RuntimeTokenRefreshIntervalHours() int
UpstreamFileUploadsEnabled() bool
AccountHealthEnabled() bool
AccountHealthRecoveryWindowSeconds() int
AccountHealthMaxCooldownSeconds() int
AccountHealthCooldown429Seconds() int
AccountHealthCooldown403Seconds() int
AccountHealthCooldownAuthSeconds() int
AccountHealthCooldown5xxSeconds() int
AccountHealthCooldownNetworkSeconds() int
AccountHealthCooldownEmptySeconds() int
AccountHealthCooldownMutedSeconds() int
AutoDeleteMode() string
HistorySplitEnabled() bool
HistorySplitTriggerAfterTurns() int
@@ -43,6 +54,7 @@ type PoolController interface {
Reset()
Status() map[string]any
ApplyRuntimeLimits(maxInflightPerAccount, maxQueueSize, globalMaxInflight int)
ApplyHealthConfig(cfg account.HealthConfig)
}
type OpenAIChatCaller interface {
+17 -1
View File
@@ -159,7 +159,7 @@ func toStringSlice(v any) ([]string, bool) {
func toAccount(m map[string]any) config.Account {
email := fieldString(m, "email")
mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile"))
return config.Account{
acc := config.Account{
Name: fieldString(m, "name"),
Remark: fieldString(m, "remark"),
Email: email,
@@ -167,6 +167,11 @@ func toAccount(m map[string]any) config.Account {
Password: fieldString(m, "password"),
ProxyID: fieldString(m, "proxy_id"),
}
if v, ok := m["active"]; ok {
active := boolValue(v)
acc.Active = &active
}
return acc
}
func toAPIKeys(v any) ([]config.APIKey, bool) {
@@ -295,6 +300,17 @@ func fieldStringOptional(m map[string]any, key string) (string, bool) {
return strings.TrimSpace(fmt.Sprintf("%v", v)), true
}
func boolValue(v any) bool {
switch x := v.(type) {
case bool:
return x
case string:
return strings.EqualFold(strings.TrimSpace(x), "true") || strings.TrimSpace(x) == "1"
default:
return false
}
}
func statusOr(v int, d int) int {
if v == 0 {
return d
@@ -38,6 +38,9 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
}
thinkingEnabled := util.ResolveThinkingEnabled(req, false)
finalPrompt := prompt.MessagesPrepareWithThinking(toMessageMaps(dsPayload["messages"]), thinkingEnabled)
if err := promptcompat.ValidatePromptLength(finalPrompt); err != nil {
return claudeNormalizedRequest{}, err
}
toolNames := extractClaudeToolNames(toolsRequested)
if len(toolNames) == 0 && len(toolsRequested) > 0 {
toolNames = []string{"__any_tool__"}
@@ -30,6 +30,9 @@ func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[strin
toolsRaw := convertGeminiTools(req["tools"])
finalPrompt, toolNames := promptcompat.BuildOpenAIPromptForAdapter(messagesRaw, toolsRaw, "", thinkingEnabled)
if err := promptcompat.ValidatePromptLength(finalPrompt); err != nil {
return promptcompat.StandardRequest{}, err
}
passThrough := collectGeminiPassThrough(req)
return promptcompat.StandardRequest{
@@ -16,6 +16,7 @@ type mockOpenAIConfig struct {
embedProv string
historySplitEnabled bool
historySplitTurns int
uploadsEnabled *bool
}
func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases }
@@ -41,6 +42,9 @@ func (m mockOpenAIConfig) HistorySplitTriggerAfterTurns() int {
}
return m.historySplitTurns
}
func (m mockOpenAIConfig) UpstreamFileUploadsEnabled() bool {
return m.uploadsEnabled == nil || *m.uploadsEnabled
}
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
cfg := mockOpenAIConfig{
@@ -146,6 +146,29 @@ func TestPreprocessInlineFileInputsDeduplicatesIdenticalPayloads(t *testing.T) {
}
}
func TestChatCompletionsRejectsInlineUploadWhenUpstreamUploadsDisabled(t *testing.T) {
disabled := false
ds := &inlineUploadDSStub{}
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true, uploadsEnabled: &disabled}, Auth: streamStatusAuthStub{}, DS: ds}
reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ChatCompletions(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.uploadCalls) != 0 {
t.Fatalf("expected no upload calls when upstream uploads are disabled, got %d", len(ds.uploadCalls))
}
if ds.completionReq != nil {
t.Fatalf("did not expect completion call when inline upload is rejected")
}
}
func TestChatCompletionsUploadsInlineFilesBeforeCompletion(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
@@ -12,6 +12,7 @@ import (
"strings"
"ds2api/internal/auth"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
"ds2api/internal/httpapi/openai/shared"
"ds2api/internal/promptcompat"
@@ -57,6 +58,12 @@ func (h *Handler) PreprocessInlineFileInputs(ctx context.Context, a *auth.Reques
if h == nil || h.DS == nil || len(req) == 0 {
return nil
}
if !config.UpstreamFileUploadsEnabledFrom(h.Store) {
if containsInlineUploadPayload(req) {
return &inlineFileUploadError{status: http.StatusBadRequest, message: "Upstream file uploads are disabled by runtime configuration."}
}
return nil
}
state := &inlineUploadState{
ctx: ctx,
handler: h,
@@ -78,6 +85,27 @@ func (h *Handler) PreprocessInlineFileInputs(ctx context.Context, a *auth.Reques
return nil
}
func containsInlineUploadPayload(raw any) bool {
switch x := raw.(type) {
case []any:
for _, item := range x {
if containsInlineUploadPayload(item) {
return true
}
}
case map[string]any:
if _, ok, _ := decodeOpenAIInlineFileBlock(x); ok {
return true
}
for _, key := range []string{"messages", "input", "attachments", "content", "files", "items", "data", "source", "file", "image_url"} {
if nested, ok := x[key]; ok && containsInlineUploadPayload(nested) {
return true
}
}
}
return false
}
func WriteInlineFileError(w http.ResponseWriter, err error) {
inlineErr, ok := err.(*inlineFileUploadError)
if !ok || inlineErr == nil {
@@ -8,6 +8,7 @@ import (
"ds2api/internal/auth"
"ds2api/internal/chathistory"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
"ds2api/internal/httpapi/openai/shared"
)
@@ -33,6 +34,10 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
return
}
defer h.Auth.Release(a)
if !config.UpstreamFileUploadsEnabledFrom(h.Store) {
shared.WriteOpenAIError(w, http.StatusBadRequest, "Upstream file uploads are disabled by runtime configuration.")
return
}
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))), "multipart/form-data") {
shared.WriteOpenAIError(w, http.StatusBadRequest, "content-type must be multipart/form-data")
return
@@ -7,6 +7,7 @@ import (
"strings"
"ds2api/internal/auth"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
"ds2api/internal/httpapi/openai/shared"
"ds2api/internal/promptcompat"
@@ -27,6 +28,9 @@ func (s Service) Apply(ctx context.Context, a *auth.RequestAuth, stdReq promptco
if s.DS == nil || s.Store == nil || a == nil {
return stdReq, nil
}
if !config.UpstreamFileUploadsEnabledFrom(s.Store) {
return stdReq, nil
}
promptMessages, historyMessages := SplitOpenAIHistoryMessages(stdReq.Messages, s.Store.HistorySplitTriggerAfterTurns())
if len(historyMessages) == 0 {
+8 -1
View File
@@ -58,9 +58,16 @@ function loadSharedConstants() {
}
const shared = loadSharedConstants();
const rangersId = String(Math.floor(Math.random() * 10000000000000000000));
const timezoneOffset = String(-new Date().getTimezoneOffset() * 60);
const baseHeaders = {
...shared.baseHeaders,
'x-client-timezone-offset': timezoneOffset,
'x-rangers-id': rangersId,
};
module.exports = {
BASE_HEADERS: Object.freeze(shared.baseHeaders),
BASE_HEADERS: Object.freeze(baseHeaders),
SKIP_PATTERNS: Object.freeze(shared.skipPatterns),
SKIP_EXACT_PATHS: new Set(shared.skipExactPaths),
};
+12
View File
@@ -0,0 +1,12 @@
package promptcompat
import "fmt"
const MaxPromptRunes = 131072
func ValidatePromptLength(prompt string) error {
if len([]rune(prompt)) <= MaxPromptRunes {
return nil
}
return fmt.Errorf("context too long: maximum 128k characters allowed")
}
@@ -31,6 +31,9 @@ func NormalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
}
toolPolicy := DefaultToolChoicePolicy()
finalPrompt, toolNames := BuildOpenAIPrompt(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled)
if err := ValidatePromptLength(finalPrompt); err != nil {
return StandardRequest{}, err
}
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
passThrough := collectOpenAIChatPassThrough(req)
refFileIDs := CollectOpenAIRefFileIDs(req)
@@ -85,6 +88,9 @@ func NormalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
return StandardRequest{}, err
}
finalPrompt, toolNames := BuildOpenAIPrompt(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled)
if err := ValidatePromptLength(finalPrompt); err != nil {
return StandardRequest{}, err
}
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
if !toolPolicy.IsNone() {
toolPolicy.Allowed = namesToSet(toolNames)
@@ -63,6 +63,9 @@ export default function AddAccountModal({
onChange={e => setNewAccount({ ...newAccount, mobile: e.target.value })}
/>
</div>
<div className="rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
{t('accountManager.accountLoginHint')}
</div>
<div>
<label className="block text-sm font-medium mb-1.5">{t('accountManager.passwordLabel')} <span className="text-destructive">*</span></label>
<input
+5 -4
View File
@@ -99,7 +99,7 @@
"updateKeySuccess": "API key updated successfully.",
"addAccountSuccess": "Account added successfully.",
"updateAccountSuccess": "Account metadata updated successfully.",
"requiredFields": "Password and email/mobile are required.",
"requiredFields": "Password plus at least one of email or mobile is required.",
"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.",
@@ -122,7 +122,7 @@
"deleteKeyTitle": "Delete key",
"noApiKeys": "No API keys found.",
"accountsTitle": "DeepSeek Accounts",
"accountsDesc": "Manage the DeepSeek account pool and edit name/remark.",
"accountsDesc": "Manage the DeepSeek account pool with either email or mobile login, and edit name/remark.",
"testAll": "Refresh all tokens",
"addAccount": "Add account",
"testingAllAccounts": "Refreshing tokens for all accounts...",
@@ -154,8 +154,9 @@
"namePlaceholder": "e.g. Primary Account A",
"remarkOptional": "Remark (optional)",
"remarkPlaceholder": "e.g. Team shared / test only",
"emailOptional": "Email (optional)",
"mobileOptional": "Mobile (optional)",
"emailOptional": "Email (for overseas login)",
"mobileOptional": "Mobile (for mainland China login)",
"accountLoginHint": "Overseas / English entry points usually support email login directly, while mainland China entry points usually use mobile login. DS2API supports both account identifiers here.",
"passwordLabel": "Password",
"passwordPlaceholder": "Account password",
"addAccountLoading": "Adding...",
+5 -4
View File
@@ -99,7 +99,7 @@
"updateKeySuccess": "API 密钥更新成功",
"addAccountSuccess": "账号添加成功",
"updateAccountSuccess": "账号信息更新成功",
"requiredFields": "需要填写密码以及邮箱或手机号",
"requiredFields": "需要填写密码,以及邮箱或手机号(至少一种)",
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
"deleteAccountConfirm": "确定要删除此账号吗?",
"invalidIdentifier": "账号标识无效,无法执行操作",
@@ -122,7 +122,7 @@
"deleteKeyTitle": "删除密钥",
"noApiKeys": "未找到 API 密钥",
"accountsTitle": "DeepSeek 账号",
"accountsDesc": "管理 DeepSeek 账号池,支持修改名称和备注",
"accountsDesc": "管理 DeepSeek 账号池,支持邮箱或手机号登录,并可修改名称和备注",
"testAll": "刷新全部 Token",
"addAccount": "添加账号",
"testingAllAccounts": "正在刷新所有账号 Token...",
@@ -154,8 +154,9 @@
"namePlaceholder": "例如:主账号 A",
"remarkOptional": "备注(可选)",
"remarkPlaceholder": "例如:团队共享 / 仅测试用",
"emailOptional": "邮箱 (可选)",
"mobileOptional": "手机号 (可选)",
"emailOptional": "邮箱(海外登录可用)",
"mobileOptional": "手机号(中国大陆登录可用)",
"accountLoginHint": "海外 IP / 英文入口通常可直接使用邮箱登录;中国大陆入口通常使用手机号。这里两种账号标识都支持。",
"passwordLabel": "密码",
"passwordPlaceholder": "账号密码",
"addAccountLoading": "添加中...",