diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml new file mode 100644 index 0000000..93cd310 --- /dev/null +++ b/.github/workflows/ghcr.yml @@ -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 diff --git a/.gitignore b/.gitignore index c7c3919..f763815 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ CLAUDE.local.md data/ .codex .roomodes +.ace-tool/ diff --git a/AGENTS.md b/AGENTS.md index 1c71307..069eb1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/API.en.md b/API.en.md index ca1e7a9..cbd9b55 100644 --- a/API.en.md +++ b/API.en.md @@ -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` diff --git a/API.md b/API.md index 35d97d4..308c86b 100644 --- a/API.md +++ b/API.md @@ -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` diff --git a/README.MD b/README.MD index 412596e..bacc04e 100644 --- a/README.MD +++ b/README.MD @@ -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。 diff --git a/README.en.md b/README.en.md index 747993d..67731e0 100644 --- a/README.en.md +++ b/README.en.md @@ -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. diff --git a/config.example.json b/config.example.json index f93a2c3..ad2f970 100644 --- a/config.example.json +++ b/config.example.json @@ -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" diff --git a/docs/ARCHITECTURE.en.md b/docs/ARCHITECTURE.en.md index 1f6b24a..53cd5fa 100644 --- a/docs/ARCHITECTURE.en.md +++ b/docs/ARCHITECTURE.en.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5094ea4..daaebe4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 与运行时关系 diff --git a/docs/DEPLOY.en.md b/docs/DEPLOY.en.md index de52b4c..4b620a3 100644 --- a/docs/DEPLOY.en.md +++ b/docs/DEPLOY.en.md @@ -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` | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 7509cb3..98cafea 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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` | diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index 8a26aae..b65218e 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -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) diff --git a/internal/account/health.go b/internal/account/health.go new file mode 100644 index 0000000..abfc736 --- /dev/null +++ b/internal/account/health.go @@ -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 +} diff --git a/internal/account/pool_acquire.go b/internal/account/pool_acquire.go index 6d1ec7d..9e17485 100644 --- a/internal/account/pool_acquire.go +++ b/internal/account/pool_acquire.go @@ -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) { diff --git a/internal/account/pool_core.go b/internal/account/pool_core.go index 90e2594..5354f2a 100644 --- a/internal/account/pool_core.go +++ b/internal/account/pool_core.go @@ -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), } } diff --git a/internal/account/pool_limits.go b/internal/account/pool_limits.go index 2ddbaf4..86f945f 100644 --- a/internal/account/pool_limits.go +++ b/internal/account/pool_limits.go @@ -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 } diff --git a/internal/account/pool_test.go b/internal/account/pool_test.go index 279cef4..8493497 100644 --- a/internal/account/pool_test.go +++ b/internal/account/pool_test.go @@ -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) diff --git a/internal/account/pool_waiters.go b/internal/account/pool_waiters.go index 40bd146..f08f70c 100644 --- a/internal/account/pool_waiters.go +++ b/internal/account/pool_waiters.go @@ -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 } } diff --git a/internal/auth/request.go b/internal/auth/request.go index e6a0d88..36b8ab7 100644 --- a/internal/auth/request.go +++ b/internal/auth/request.go @@ -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 ") { diff --git a/internal/config/account.go b/internal/config/account.go index bebb70e..ff01f60 100644 --- a/internal/config/account.go +++ b/internal/config/account.go @@ -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 +} diff --git a/internal/config/codec.go b/internal/config/codec.go index 246df9b..425a6af 100644 --- a/internal/config/codec.go +++ b/internal/config/codec.go @@ -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 { diff --git a/internal/config/config.go b/internal/config/config.go index 4053798..5c29219 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/config/store_accessors.go b/internal/config/store_accessors.go index 4b25284..c521d9a 100644 --- a/internal/config/store_accessors.go +++ b/internal/config/store_accessors.go @@ -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" } diff --git a/internal/config/store_accounts.go b/internal/config/store_accounts.go new file mode 100644 index 0000000..6115031 --- /dev/null +++ b/internal/config/store_accounts.go @@ -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 +} diff --git a/internal/config/validation.go b/internal/config/validation.go index 3e8954c..5382407 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -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") } diff --git a/internal/deepseek/client/client_auth.go b/internal/deepseek/client/client_auth.go index b582df9..050c0fd 100644 --- a/internal/deepseek/client/client_auth.go +++ b/internal/deepseek/client/client_auth.go @@ -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 -} diff --git a/internal/deepseek/client/client_auth_email_test.go b/internal/deepseek/client/client_auth_email_test.go new file mode 100644 index 0000000..8007faf --- /dev/null +++ b/internal/deepseek/client/client_auth_email_test.go @@ -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) + } +} diff --git a/internal/deepseek/client/client_auth_helpers.go b/internal/deepseek/client/client_auth_helpers.go new file mode 100644 index 0000000..4393260 --- /dev/null +++ b/internal/deepseek/client/client_auth_helpers.go @@ -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 +} diff --git a/internal/deepseek/client/client_completion.go b/internal/deepseek/client/client_completion.go index 1b91ce2..8f9e7a6 100644 --- a/internal/deepseek/client/client_completion.go +++ b/internal/deepseek/client/client_completion.go @@ -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 { diff --git a/internal/deepseek/client/client_core.go b/internal/deepseek/client/client_core.go index f730e88..6a38809 100644 --- a/internal/deepseek/client/client_core.go +++ b/internal/deepseek/client/client_core.go @@ -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{}, } diff --git a/internal/deepseek/client/client_session.go b/internal/deepseek/client/client_session.go index 98a7feb..e99183a 100644 --- a/internal/deepseek/client/client_session.go +++ b/internal/deepseek/client/client_session.go @@ -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 } } diff --git a/internal/deepseek/client/client_session_delete.go b/internal/deepseek/client/client_session_delete.go index fa810fd..1061651 100644 --- a/internal/deepseek/client/client_session_delete.go +++ b/internal/deepseek/client/client_session_delete.go @@ -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 } } diff --git a/internal/deepseek/client/client_upload.go b/internal/deepseek/client/client_upload.go index 9e95a23..7c14ccd 100644 --- a/internal/deepseek/client/client_upload.go +++ b/internal/deepseek/client/client_upload.go @@ -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 diff --git a/internal/deepseek/client/device.go b/internal/deepseek/client/device.go new file mode 100644 index 0000000..80fa7b8 --- /dev/null +++ b/internal/deepseek/client/device.go @@ -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[:]) +} diff --git a/internal/deepseek/client/errors.go b/internal/deepseek/client/errors.go index 0c2c18a..f4b3108 100644 --- a/internal/deepseek/client/errors.go +++ b/internal/deepseek/client/errors.go @@ -11,6 +11,7 @@ const ( FailureUnknown FailureKind = "" FailureDirectUnauthorized FailureKind = "direct_unauthorized" FailureManagedUnauthorized FailureKind = "managed_unauthorized" + FailureAccountMuted FailureKind = "account_muted" ) type RequestFailure struct { diff --git a/internal/deepseek/client/mute.go b/internal/deepseek/client/mute.go new file mode 100644 index 0000000..6b8a555 --- /dev/null +++ b/internal/deepseek/client/mute.go @@ -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) +} diff --git a/internal/deepseek/protocol/constants.go b/internal/deepseek/protocol/constants.go index 79e218e..bedd297 100644 --- a/internal/deepseek/protocol/constants.go +++ b/internal/deepseek/protocol/constants.go @@ -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 { diff --git a/internal/deepseek/transport/transport.go b/internal/deepseek/transport/transport.go index 76a8004..79f8c7a 100644 --- a/internal/deepseek/transport/transport.go +++ b/internal/deepseek/transport/transport.go @@ -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 diff --git a/internal/httpapi/admin/accounts/handler_accounts_crud.go b/internal/httpapi/admin/accounts/handler_accounts_crud.go index 7375b40..650fbff 100644 --- a/internal/httpapi/admin/accounts/handler_accounts_crud.go +++ b/internal/httpapi/admin/accounts/handler_accounts_crud.go @@ -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 { diff --git a/internal/httpapi/admin/accounts/handler_accounts_crud_test.go b/internal/httpapi/admin/accounts/handler_accounts_crud_test.go index be2b0ba..3e97232 100644 --- a/internal/httpapi/admin/accounts/handler_accounts_crud_test.go +++ b/internal/httpapi/admin/accounts/handler_accounts_crud_test.go @@ -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"}] diff --git a/internal/httpapi/admin/settings/handler_settings_parse.go b/internal/httpapi/admin/settings/handler_settings_parse.go index 14fb92d..25108c4 100644 --- a/internal/httpapi/admin/settings/handler_settings_parse.go +++ b/internal/httpapi/admin/settings/handler_settings_parse.go @@ -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") } diff --git a/internal/httpapi/admin/settings/handler_settings_read.go b/internal/httpapi/admin/settings/handler_settings_read.go index 7587004..f40ad67 100644 --- a/internal/httpapi/admin/settings/handler_settings_read.go +++ b/internal/httpapi/admin/settings/handler_settings_read.go @@ -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, diff --git a/internal/httpapi/admin/settings/handler_settings_runtime.go b/internal/httpapi/admin/settings/handler_settings_runtime.go index eee3c6e..46aa9d0 100644 --- a/internal/httpapi/admin/settings/handler_settings_runtime.go +++ b/internal/httpapi/admin/settings/handler_settings_runtime.go @@ -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 { diff --git a/internal/httpapi/admin/settings/handler_settings_write.go b/internal/httpapi/admin/settings/handler_settings_write.go index 11ac6b4..2e06492 100644 --- a/internal/httpapi/admin/settings/handler_settings_write.go +++ b/internal/httpapi/admin/settings/handler_settings_write.go @@ -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 { diff --git a/internal/httpapi/admin/shared/deps.go b/internal/httpapi/admin/shared/deps.go index 9adc755..03a2a65 100644 --- a/internal/httpapi/admin/shared/deps.go +++ b/internal/httpapi/admin/shared/deps.go @@ -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 { diff --git a/internal/httpapi/admin/shared/helpers.go b/internal/httpapi/admin/shared/helpers.go index 93b6937..4a4c5c8 100644 --- a/internal/httpapi/admin/shared/helpers.go +++ b/internal/httpapi/admin/shared/helpers.go @@ -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 diff --git a/internal/httpapi/claude/standard_request.go b/internal/httpapi/claude/standard_request.go index 26c6fda..4a1f4e8 100644 --- a/internal/httpapi/claude/standard_request.go +++ b/internal/httpapi/claude/standard_request.go @@ -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__"} diff --git a/internal/httpapi/gemini/convert_request.go b/internal/httpapi/gemini/convert_request.go index 1d32105..eab21ab 100644 --- a/internal/httpapi/gemini/convert_request.go +++ b/internal/httpapi/gemini/convert_request.go @@ -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{ diff --git a/internal/httpapi/openai/deps_injection_test.go b/internal/httpapi/openai/deps_injection_test.go index 0d906aa..7e8f781 100644 --- a/internal/httpapi/openai/deps_injection_test.go +++ b/internal/httpapi/openai/deps_injection_test.go @@ -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{ diff --git a/internal/httpapi/openai/file_inline_upload_test.go b/internal/httpapi/openai/file_inline_upload_test.go index 4ea2445..81718ab 100644 --- a/internal/httpapi/openai/file_inline_upload_test.go +++ b/internal/httpapi/openai/file_inline_upload_test.go @@ -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} diff --git a/internal/httpapi/openai/files/file_inline_upload.go b/internal/httpapi/openai/files/file_inline_upload.go index c8d59a9..45d4caf 100644 --- a/internal/httpapi/openai/files/file_inline_upload.go +++ b/internal/httpapi/openai/files/file_inline_upload.go @@ -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 { diff --git a/internal/httpapi/openai/files/handler_files.go b/internal/httpapi/openai/files/handler_files.go index edfb653..9e27576 100644 --- a/internal/httpapi/openai/files/handler_files.go +++ b/internal/httpapi/openai/files/handler_files.go @@ -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 diff --git a/internal/httpapi/openai/history/history_split.go b/internal/httpapi/openai/history/history_split.go index 96775ef..7232d2f 100644 --- a/internal/httpapi/openai/history/history_split.go +++ b/internal/httpapi/openai/history/history_split.go @@ -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 { diff --git a/internal/js/shared/deepseek-constants.js b/internal/js/shared/deepseek-constants.js index e24cfb1..1dd6af6 100644 --- a/internal/js/shared/deepseek-constants.js +++ b/internal/js/shared/deepseek-constants.js @@ -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), }; diff --git a/internal/promptcompat/limits.go b/internal/promptcompat/limits.go new file mode 100644 index 0000000..c3f0d47 --- /dev/null +++ b/internal/promptcompat/limits.go @@ -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") +} diff --git a/internal/promptcompat/request_normalize.go b/internal/promptcompat/request_normalize.go index 6d3f12d..fad8151 100644 --- a/internal/promptcompat/request_normalize.go +++ b/internal/promptcompat/request_normalize.go @@ -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) diff --git a/webui/src/features/account/AddAccountModal.jsx b/webui/src/features/account/AddAccountModal.jsx index ed97257..699dada 100644 --- a/webui/src/features/account/AddAccountModal.jsx +++ b/webui/src/features/account/AddAccountModal.jsx @@ -63,6 +63,9 @@ export default function AddAccountModal({ onChange={e => setNewAccount({ ...newAccount, mobile: e.target.value })} /> +
+ {t('accountManager.accountLoginHint')} +