feat: add account health and ghcr publishing
This commit is contained in:
@@ -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
|
||||
@@ -67,3 +67,4 @@ CLAUDE.local.md
|
||||
data/
|
||||
.codex
|
||||
.roomodes
|
||||
.ace-tool/
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -276,7 +276,7 @@ go run ./cmd/ds2api
|
||||
- `keys` / `api_keys`:客户端访问密钥,`api_keys` 支持 `name` 与 `remark` 元信息,`keys` 继续兼容。
|
||||
- `accounts`:DeepSeek 托管账号,支持 `email` 或 `mobile` 登录,可配置代理、名称和备注。
|
||||
- `model_aliases`:OpenAI / Claude / Gemini 共用的模型 alias 映射。
|
||||
- `runtime`:账号并发、队列与 token 刷新策略,可通过 Admin Settings 热更新。
|
||||
- `runtime`:账号并发、队列、token 刷新、账号健康冷却与上游文件上传开关,可通过 Admin Settings 热更新。
|
||||
- `auto_delete.mode`:请求结束后的远端会话清理策略,支持 `none` / `single` / `all`。
|
||||
- `history_split`:多轮历史拆分策略,已全局强制开启;可调整触发阈值,避免长历史全部内联进 prompt。
|
||||
|
||||
|
||||
+1
-1
@@ -274,7 +274,7 @@ Common fields:
|
||||
- `keys` / `api_keys`: client API keys; `api_keys` adds `name` and `remark` metadata while `keys` remains compatible.
|
||||
- `accounts`: managed DeepSeek accounts, supporting `email` or `mobile` login plus proxy/name/remark metadata.
|
||||
- `model_aliases`: one shared alias map for OpenAI / Claude / Gemini model names.
|
||||
- `runtime`: account concurrency, queueing, and token refresh behavior, hot-reloadable via Admin Settings.
|
||||
- `runtime`: account concurrency, queueing, token refresh, account health cooldowns, and upstream file-upload policy, hot-reloadable via Admin Settings.
|
||||
- `auto_delete.mode`: remote session cleanup after each request, supporting `none` / `single` / `all`.
|
||||
- `history_split`: multi-turn history split policy, now forced on globally; tune its trigger threshold to avoid inlining all long history into the prompt.
|
||||
|
||||
|
||||
+12
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 与运行时关系
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
||||
if strings.TrimSpace(c.Admin.PasswordHash) != "" || c.Admin.JWTExpireHours > 0 || c.Admin.JWTValidAfterUnix > 0 {
|
||||
m["admin"] = c.Admin
|
||||
}
|
||||
if c.Runtime.AccountMaxInflight > 0 || c.Runtime.AccountMaxQueue > 0 || c.Runtime.GlobalMaxInflight > 0 || c.Runtime.TokenRefreshIntervalHours > 0 {
|
||||
if runtimeConfigPresent(c.Runtime) {
|
||||
m["runtime"] = c.Runtime
|
||||
}
|
||||
if c.Compat.WideInputStrictOutput != nil || c.Compat.StripReferenceMarkers != nil {
|
||||
@@ -57,6 +57,24 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func runtimeConfigPresent(runtime RuntimeConfig) bool {
|
||||
return runtime.AccountMaxInflight > 0 ||
|
||||
runtime.AccountMaxQueue > 0 ||
|
||||
runtime.GlobalMaxInflight > 0 ||
|
||||
runtime.TokenRefreshIntervalHours > 0 ||
|
||||
runtime.DisableUpstreamFileUploads != nil ||
|
||||
runtime.AccountHealthEnabled != nil ||
|
||||
runtime.AccountHealthRecoveryWindowSeconds > 0 ||
|
||||
runtime.AccountHealthMaxCooldownSeconds > 0 ||
|
||||
runtime.AccountHealthCooldown429Seconds > 0 ||
|
||||
runtime.AccountHealthCooldown403Seconds > 0 ||
|
||||
runtime.AccountHealthCooldownAuthSeconds > 0 ||
|
||||
runtime.AccountHealthCooldown5xxSeconds > 0 ||
|
||||
runtime.AccountHealthCooldownNetworkSeconds > 0 ||
|
||||
runtime.AccountHealthCooldownEmptySeconds > 0 ||
|
||||
runtime.AccountHealthCooldownMutedSeconds > 0
|
||||
}
|
||||
|
||||
func (c *Config) UnmarshalJSON(b []byte) error {
|
||||
raw := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
|
||||
+26
-11
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -7,18 +7,19 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) {
|
||||
clients := c.requestClientsForAccount(acc)
|
||||
accountID := strings.TrimSpace(acc.Identifier())
|
||||
payload := map[string]any{
|
||||
"password": strings.TrimSpace(acc.Password),
|
||||
"device_id": "deepseek_to_api",
|
||||
"os": "android",
|
||||
"device_id": DeviceID(accountID),
|
||||
"os": "Android",
|
||||
}
|
||||
if email := strings.TrimSpace(acc.Email); email != "" {
|
||||
payload["email"] = email
|
||||
@@ -62,10 +63,25 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
|
||||
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekCreateSessionURL, headers, map[string]any{"agent": "chat"})
|
||||
if err != nil {
|
||||
config.Logger.Warn("[create_session] request error", "error", err, "account", a.AccountID)
|
||||
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
continue
|
||||
}
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
|
||||
if muted, muteErr := c.handleMutedResponse(ctx, a, "create session", resp); muted {
|
||||
if muteErr != nil {
|
||||
return "", muteErr
|
||||
}
|
||||
refreshed = false
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
continue
|
||||
}
|
||||
if status == http.StatusOK && code == 0 && bizCode == 0 {
|
||||
sessionID := extractCreateSessionID(resp)
|
||||
if sessionID != "" {
|
||||
@@ -80,9 +96,10 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
|
||||
continue
|
||||
}
|
||||
}
|
||||
if c.Auth.SwitchAccount(ctx, a) {
|
||||
if c.Auth.SwitchAccountWithPenalty(ctx, a, penaltyForFailedStatus(status, code, bizCode, msg, bizMsg)) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -115,10 +132,25 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe
|
||||
config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID, "target_path", targetPath)
|
||||
lastFailureKind = FailureUnknown
|
||||
lastFailureMessage = err.Error()
|
||||
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
continue
|
||||
}
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
|
||||
if muted, muteErr := c.handleMutedResponse(ctx, a, "get pow", resp); muted {
|
||||
if muteErr != nil {
|
||||
return "", muteErr
|
||||
}
|
||||
refreshed = false
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
continue
|
||||
}
|
||||
if status == http.StatusOK && code == 0 && bizCode == 0 {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
@@ -144,9 +176,10 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe
|
||||
continue
|
||||
}
|
||||
}
|
||||
if c.Auth.SwitchAccount(ctx, a) {
|
||||
if c.Auth.SwitchAccountWithPenalty(ctx, a, penaltyForFailedStatus(status, code, bizCode, msg, bizMsg)) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -157,139 +190,3 @@ func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targe
|
||||
}
|
||||
return "", errors.New("get pow failed")
|
||||
}
|
||||
|
||||
func (c *Client) authHeaders(token string) map[string]string {
|
||||
headers := make(map[string]string, len(dsprotocol.BaseHeaders)+1)
|
||||
for k, v := range dsprotocol.BaseHeaders {
|
||||
headers[k] = v
|
||||
}
|
||||
headers["authorization"] = "Bearer " + token
|
||||
return headers
|
||||
}
|
||||
|
||||
func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool {
|
||||
msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return true
|
||||
}
|
||||
if code == 40001 || code == 40002 || code == 40003 || bizCode == 40001 || bizCode == 40002 || bizCode == 40003 {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(msg, "token") ||
|
||||
strings.Contains(msg, "unauthorized") ||
|
||||
strings.Contains(msg, "expired") ||
|
||||
strings.Contains(msg, "not login") ||
|
||||
strings.Contains(msg, "login required") ||
|
||||
strings.Contains(msg, "invalid jwt")
|
||||
}
|
||||
|
||||
func shouldAttemptRefresh(status int, code int, bizCode int, msg string, bizMsg string) bool {
|
||||
if isTokenInvalid(status, code, bizCode, msg, bizMsg) {
|
||||
return true
|
||||
}
|
||||
// Some DeepSeek failures come back as HTTP 200/code=0 but with non-zero biz_code.
|
||||
// Only attempt refresh when these biz failures still look auth-related.
|
||||
return status == http.StatusOK &&
|
||||
code == 0 &&
|
||||
bizCode != 0 &&
|
||||
isAuthIndicativeBizFailure(msg, bizMsg)
|
||||
}
|
||||
|
||||
func isAuthIndicativeBizFailure(msg string, bizMsg string) bool {
|
||||
combined := strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
|
||||
authKeywords := []string{
|
||||
"auth",
|
||||
"authorization",
|
||||
"credential",
|
||||
"expired",
|
||||
"invalid jwt",
|
||||
"jwt",
|
||||
"login",
|
||||
"not login",
|
||||
"session expired",
|
||||
"token",
|
||||
"unauthorized",
|
||||
"登录",
|
||||
"未登录",
|
||||
"认证",
|
||||
"凭证",
|
||||
"会话过期",
|
||||
"令牌",
|
||||
}
|
||||
for _, keyword := range authKeywords {
|
||||
if strings.Contains(combined, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func authFailureKind(useConfigToken bool) FailureKind {
|
||||
if useConfigToken {
|
||||
return FailureManagedUnauthorized
|
||||
}
|
||||
return FailureDirectUnauthorized
|
||||
}
|
||||
|
||||
func failureMessage(msg string, bizMsg string, fallback string) string {
|
||||
if trimmed := strings.TrimSpace(bizMsg); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(msg); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
// DeepSeek has returned create-session ids in both biz_data.id and
|
||||
// biz_data.chat_session.id across observed response variants; accept either.
|
||||
func extractCreateSessionID(resp map[string]any) string {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
if sessionID, _ := bizData["id"].(string); strings.TrimSpace(sessionID) != "" {
|
||||
return strings.TrimSpace(sessionID)
|
||||
}
|
||||
if chatSession, ok := bizData["chat_session"].(map[string]any); ok {
|
||||
if sessionID, _ := chatSession["id"].(string); strings.TrimSpace(sessionID) != "" {
|
||||
return strings.TrimSpace(sessionID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) {
|
||||
code = intFrom(resp["code"])
|
||||
msg, _ = resp["msg"].(string)
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizCode = intFrom(data["biz_code"])
|
||||
bizMsg, _ = data["biz_msg"].(string)
|
||||
if strings.TrimSpace(bizMsg) == "" {
|
||||
if bizData, ok := data["biz_data"].(map[string]any); ok {
|
||||
bizMsg, _ = bizData["msg"].(string)
|
||||
}
|
||||
}
|
||||
return code, bizCode, msg, bizMsg
|
||||
}
|
||||
|
||||
func normalizeMobileForLogin(raw string) (mobile string, areaCode any) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
hasPlus := strings.HasPrefix(s, "+")
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
digits := b.String()
|
||||
if digits == "" {
|
||||
return "", nil
|
||||
}
|
||||
if (hasPlus || strings.HasPrefix(digits, "86")) && strings.HasPrefix(digits, "86") && len(digits) == 13 {
|
||||
return digits[2:], nil
|
||||
}
|
||||
return digits, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
type captureLoginDoer struct {
|
||||
method string
|
||||
url string
|
||||
headers http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (d *captureLoginDoer) Do(req *http.Request) (*http.Response, error) {
|
||||
d.method = req.Method
|
||||
d.url = req.URL.String()
|
||||
d.headers = req.Header.Clone()
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.body = body
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"code":0,"msg":"","data":{"biz_code":0,"biz_msg":"","biz_data":{"user":{"token":"token-from-email-login"}}}}`,
|
||||
)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestLoginPrefersEmailPayloadWhenEmailPresent(t *testing.T) {
|
||||
doer := &captureLoginDoer{}
|
||||
c := &Client{
|
||||
regular: doer,
|
||||
stream: doer,
|
||||
fallback: &http.Client{},
|
||||
fallbackS: &http.Client{},
|
||||
}
|
||||
|
||||
token, err := c.Login(context.Background(), config.Account{
|
||||
Email: "global@example.com",
|
||||
Mobile: "+8613800138000",
|
||||
Password: "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("login returned error: %v", err)
|
||||
}
|
||||
if token != "token-from-email-login" {
|
||||
t.Fatalf("unexpected token: %q", token)
|
||||
}
|
||||
if doer.method != http.MethodPost {
|
||||
t.Fatalf("unexpected method: %q", doer.method)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(doer.body, &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload["email"] != "global@example.com" {
|
||||
t.Fatalf("expected email payload, got %#v", payload)
|
||||
}
|
||||
if _, ok := payload["mobile"]; ok {
|
||||
t.Fatalf("did not expect mobile in email login payload, got %#v", payload)
|
||||
}
|
||||
if _, ok := payload["area_code"]; ok {
|
||||
t.Fatalf("did not expect area_code in email login payload, got %#v", payload)
|
||||
}
|
||||
if payload["password"] != "secret" {
|
||||
t.Fatalf("expected password preserved, got %#v", payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
dsprotocol "ds2api/internal/deepseek/protocol"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"ds2api/internal/account"
|
||||
)
|
||||
|
||||
func penaltyForFailedStatus(status int, code int, bizCode int, msg string, bizMsg string) account.PenaltyKind {
|
||||
if isTokenInvalid(status, code, bizCode, msg, bizMsg) || isAuthIndicativeBizFailure(msg, bizMsg) {
|
||||
return account.PenaltyAuthFailed
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusTooManyRequests:
|
||||
return account.PenaltyHTTP429
|
||||
case status == http.StatusForbidden:
|
||||
return account.PenaltyHTTP403
|
||||
case status >= 500 && status <= 599:
|
||||
return account.PenaltyHTTP5xx
|
||||
default:
|
||||
return account.PenaltyUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) authHeaders(token string) map[string]string {
|
||||
headers := make(map[string]string, len(dsprotocol.BaseHeaders)+1)
|
||||
for k, v := range dsprotocol.BaseHeaders {
|
||||
headers[k] = v
|
||||
}
|
||||
headers["authorization"] = "Bearer " + token
|
||||
return headers
|
||||
}
|
||||
|
||||
func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool {
|
||||
msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return true
|
||||
}
|
||||
if code == 40001 || code == 40002 || code == 40003 || bizCode == 40001 || bizCode == 40002 || bizCode == 40003 {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(msg, "token") ||
|
||||
strings.Contains(msg, "unauthorized") ||
|
||||
strings.Contains(msg, "expired") ||
|
||||
strings.Contains(msg, "not login") ||
|
||||
strings.Contains(msg, "login required") ||
|
||||
strings.Contains(msg, "invalid jwt")
|
||||
}
|
||||
|
||||
func shouldAttemptRefresh(status int, code int, bizCode int, msg string, bizMsg string) bool {
|
||||
if isTokenInvalid(status, code, bizCode, msg, bizMsg) {
|
||||
return true
|
||||
}
|
||||
// Some DeepSeek failures come back as HTTP 200/code=0 but with non-zero biz_code.
|
||||
// Only attempt refresh when these biz failures still look auth-related.
|
||||
return status == http.StatusOK &&
|
||||
code == 0 &&
|
||||
bizCode != 0 &&
|
||||
isAuthIndicativeBizFailure(msg, bizMsg)
|
||||
}
|
||||
|
||||
func isAuthIndicativeBizFailure(msg string, bizMsg string) bool {
|
||||
combined := strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
|
||||
authKeywords := []string{
|
||||
"auth",
|
||||
"authorization",
|
||||
"credential",
|
||||
"expired",
|
||||
"invalid jwt",
|
||||
"jwt",
|
||||
"login",
|
||||
"not login",
|
||||
"session expired",
|
||||
"token",
|
||||
"unauthorized",
|
||||
"登录",
|
||||
"未登录",
|
||||
"认证",
|
||||
"凭证",
|
||||
"会话过期",
|
||||
"令牌",
|
||||
}
|
||||
for _, keyword := range authKeywords {
|
||||
if strings.Contains(combined, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func authFailureKind(useConfigToken bool) FailureKind {
|
||||
if useConfigToken {
|
||||
return FailureManagedUnauthorized
|
||||
}
|
||||
return FailureDirectUnauthorized
|
||||
}
|
||||
|
||||
func failureMessage(msg string, bizMsg string, fallback string) string {
|
||||
if trimmed := strings.TrimSpace(bizMsg); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(msg); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
// DeepSeek has returned create-session ids in both biz_data.id and
|
||||
// biz_data.chat_session.id across observed response variants; accept either.
|
||||
func extractCreateSessionID(resp map[string]any) string {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
if sessionID, _ := bizData["id"].(string); strings.TrimSpace(sessionID) != "" {
|
||||
return strings.TrimSpace(sessionID)
|
||||
}
|
||||
if chatSession, ok := bizData["chat_session"].(map[string]any); ok {
|
||||
if sessionID, _ := chatSession["id"].(string); strings.TrimSpace(sessionID) != "" {
|
||||
return strings.TrimSpace(sessionID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) {
|
||||
code = intFrom(resp["code"])
|
||||
msg, _ = resp["msg"].(string)
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizCode = intFrom(data["biz_code"])
|
||||
bizMsg, _ = data["biz_msg"].(string)
|
||||
if strings.TrimSpace(bizMsg) == "" {
|
||||
if bizData, ok := data["biz_data"].(map[string]any); ok {
|
||||
bizMsg, _ = bizData["msg"].(string)
|
||||
}
|
||||
}
|
||||
return code, bizCode, msg, bizMsg
|
||||
}
|
||||
|
||||
func normalizeMobileForLogin(raw string) (mobile string, areaCode any) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
hasPlus := strings.HasPrefix(s, "+")
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
digits := b.String()
|
||||
if digits == "" {
|
||||
return "", nil
|
||||
}
|
||||
if (hasPlus || strings.HasPrefix(digits, "86")) && strings.HasPrefix(digits, "86") && len(digits) == 13 {
|
||||
return digits[2:], nil
|
||||
}
|
||||
return digits, nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
trans "ds2api/internal/deepseek/transport"
|
||||
@@ -26,11 +27,39 @@ func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payloa
|
||||
for attempts < maxAttempts {
|
||||
resp, err := c.streamPost(ctx, clients.stream, dsprotocol.DeepSeekCompletionURL, headers, payload)
|
||||
if err != nil {
|
||||
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
|
||||
nextPow, powErr := c.GetPow(ctx, a, maxAttempts)
|
||||
if powErr != nil {
|
||||
return nil, powErr
|
||||
}
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
headers = c.authHeaders(a.DeepSeekToken)
|
||||
headers["x-ds-pow-response"] = nextPow
|
||||
powResp = nextPow
|
||||
}
|
||||
attempts++
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
muted, muteErr := c.detectCompletionMute(ctx, a, resp)
|
||||
if muteErr != nil {
|
||||
_ = resp.Body.Close()
|
||||
return nil, muteErr
|
||||
}
|
||||
if muted {
|
||||
_ = resp.Body.Close()
|
||||
nextPow, powErr := c.GetPow(ctx, a, maxAttempts)
|
||||
if powErr != nil {
|
||||
return nil, powErr
|
||||
}
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
headers = c.authHeaders(a.DeepSeekToken)
|
||||
headers["x-ds-pow-response"] = nextPow
|
||||
powResp = nextPow
|
||||
continue
|
||||
}
|
||||
if captureSession != nil {
|
||||
resp.Body = captureSession.WrapBody(resp.Body, resp.StatusCode)
|
||||
}
|
||||
@@ -40,13 +69,37 @@ func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payloa
|
||||
if captureSession != nil {
|
||||
resp.Body = captureSession.WrapBody(resp.Body, resp.StatusCode)
|
||||
}
|
||||
penalty := completionPenaltyForStatus(resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
if a.UseConfigToken && penalty != account.PenaltyUnknown && c.Auth.SwitchAccountWithPenalty(ctx, a, penalty) {
|
||||
nextPow, powErr := c.GetPow(ctx, a, maxAttempts)
|
||||
if powErr != nil {
|
||||
return nil, powErr
|
||||
}
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
headers = c.authHeaders(a.DeepSeekToken)
|
||||
headers["x-ds-pow-response"] = nextPow
|
||||
powResp = nextPow
|
||||
}
|
||||
attempts++
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return nil, errors.New("completion failed")
|
||||
}
|
||||
|
||||
func completionPenaltyForStatus(status int) account.PenaltyKind {
|
||||
switch {
|
||||
case status == http.StatusTooManyRequests:
|
||||
return account.PenaltyHTTP429
|
||||
case status == http.StatusForbidden:
|
||||
return account.PenaltyHTTP403
|
||||
case status >= 500 && status <= 599:
|
||||
return account.PenaltyHTTP5xx
|
||||
default:
|
||||
return account.PenaltyUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) streamPost(ctx context.Context, doer trans.Doer, url string, headers map[string]string, payload any) (*http.Response, error) {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
|
||||
@@ -37,8 +37,8 @@ func NewClient(store *config.Store, resolver *auth.Resolver) *Client {
|
||||
capture: devcapture.Global(),
|
||||
regular: trans.New(60 * time.Second),
|
||||
stream: trans.New(0),
|
||||
fallback: &http.Client{Timeout: 60 * time.Second},
|
||||
fallbackS: &http.Client{Timeout: 0},
|
||||
fallback: trans.NewFallbackClient(60*time.Second, nil),
|
||||
fallbackS: trans.NewFallbackClient(0, nil),
|
||||
maxRetries: 3,
|
||||
proxyClients: map[string]requestClients{},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
@@ -55,6 +56,12 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
|
||||
resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[get_session_count] request error", "error", err, "account", a.AccountID)
|
||||
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
continue
|
||||
}
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
@@ -92,9 +99,10 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
|
||||
continue
|
||||
}
|
||||
}
|
||||
if c.Auth.SwitchAccount(ctx, a) {
|
||||
if c.Auth.SwitchAccountWithPenalty(ctx, a, penaltyForFailedStatus(status, code, bizCode, msg, bizMsg)) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
@@ -47,6 +48,12 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
|
||||
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, dsprotocol.DeepSeekDeleteSessionURL, headers, payload)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
|
||||
if a.UseConfigToken && c.Auth.SwitchAccountWithPenalty(ctx, a, account.PenaltyNetwork) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
continue
|
||||
}
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
@@ -67,9 +74,10 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
|
||||
continue
|
||||
}
|
||||
}
|
||||
if c.Auth.SwitchAccount(ctx, a) {
|
||||
if c.Auth.SwitchAccountWithPenalty(ctx, a, penaltyForFailedStatus(status, code, bizCode, msg, bizMsg)) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
clients = c.requestClientsForAuth(ctx, a)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,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
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
FailureUnknown FailureKind = ""
|
||||
FailureDirectUnauthorized FailureKind = "direct_unauthorized"
|
||||
FailureManagedUnauthorized FailureKind = "managed_unauthorized"
|
||||
FailureAccountMuted FailureKind = "account_muted"
|
||||
)
|
||||
|
||||
type RequestFailure struct {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"time"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
@@ -17,6 +18,17 @@ type Doer interface {
|
||||
|
||||
type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
type okhttpTransport struct {
|
||||
base *http.Transport
|
||||
}
|
||||
|
||||
func (t *okhttpTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("OkHttp-Preemptive") == "" {
|
||||
req.Header.Set("OkHttp-Preemptive", "1")
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
@@ -36,13 +48,14 @@ func NewWithDialContext(timeout time.Duration, dialContext DialContextFunc) *Cli
|
||||
MaxIdleConnsPerHost: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DialContext: dialContext,
|
||||
DialTLSContext: safariTLSDialer(dialContext),
|
||||
DialTLSContext: chromeTLSDialer(dialContext),
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
if useEnvProxy {
|
||||
base.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
return &Client{http: &http.Client{Timeout: timeout, Transport: base}}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &Client{http: &http.Client{Timeout: timeout, Transport: &okhttpTransport{base: base}, Jar: jar}}
|
||||
}
|
||||
|
||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
@@ -65,10 +78,11 @@ func NewFallbackClient(timeout time.Duration, dialContext DialContextFunc) *http
|
||||
if useEnvProxy {
|
||||
base.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
return &http.Client{Timeout: timeout, Transport: base}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &http.Client{Timeout: timeout, Transport: &okhttpTransport{base: base}, Jar: jar}
|
||||
}
|
||||
|
||||
func safariTLSDialer(dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
func chromeTLSDialer(dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if dialContext == nil {
|
||||
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||
}
|
||||
@@ -79,7 +93,7 @@ func safariTLSDialer(dialContext DialContextFunc) func(ctx context.Context, netw
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
uCfg := &utls.Config{ServerName: host}
|
||||
uConn := utls.UClient(plainConn, uCfg, utls.HelloSafari_Auto)
|
||||
uConn := utls.UClient(plainConn, uCfg, utls.HelloChrome_Auto)
|
||||
if err := forceHTTP11ALPN(uConn); err != nil {
|
||||
_ = plainConn.Close()
|
||||
return nil, err
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -55,6 +56,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
end = total
|
||||
}
|
||||
items := make([]map[string]any, 0, end-start)
|
||||
now := time.Now()
|
||||
for _, acc := range accounts[start:end] {
|
||||
testStatus, _ := h.Store.AccountTestStatus(acc.Identifier())
|
||||
token := strings.TrimSpace(acc.Token)
|
||||
@@ -69,6 +71,10 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
"has_token": token != "",
|
||||
"token_preview": maskSecretPreview(token),
|
||||
"test_status": testStatus,
|
||||
"active": acc.IsActive(),
|
||||
"muted": acc.IsMuted(now),
|
||||
"mute_until": acc.MuteUntil,
|
||||
"last_used": acc.LastUsed,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
|
||||
@@ -121,6 +127,7 @@ func (h *Handler) updateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
name, nameOK := fieldStringOptional(req, "name")
|
||||
remark, remarkOK := fieldStringOptional(req, "remark")
|
||||
activeRaw, activeOK := req["active"]
|
||||
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
for i, acc := range c.Accounts {
|
||||
@@ -133,6 +140,10 @@ func (h *Handler) updateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if remarkOK {
|
||||
c.Accounts[i].Remark = remark
|
||||
}
|
||||
if activeOK {
|
||||
active := boolFromAny(activeRaw)
|
||||
c.Accounts[i].Active = &active
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return newRequestError("账号不存在")
|
||||
@@ -145,9 +156,23 @@ func (h *Handler) updateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if activeOK {
|
||||
h.Pool.Reset()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
|
||||
}
|
||||
|
||||
func boolFromAny(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(x), "true") || strings.TrimSpace(x) == "1"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
identifier := chi.URLParam(r, "identifier")
|
||||
if decoded, err := url.PathUnescape(identifier); err == nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -87,6 +88,34 @@ func TestUpdateAccountMetadataPreservesCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAccountAcceptsEmailOnly(t *testing.T) {
|
||||
h := newAdminTestHandler(t, `{"accounts":[]}`)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/admin/accounts", h.addAccount)
|
||||
|
||||
body := []byte(`{"email":"global@example.com","password":"secret"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/accounts", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
snap := h.Store.Snapshot()
|
||||
if len(snap.Accounts) != 1 {
|
||||
t.Fatalf("expected one account, got %#v", snap.Accounts)
|
||||
}
|
||||
acc := snap.Accounts[0]
|
||||
if acc.Email != "global@example.com" {
|
||||
t.Fatalf("expected email account stored, got %#v", acc)
|
||||
}
|
||||
if acc.Identifier() != "global@example.com" {
|
||||
t.Fatalf("expected email identifier, got %q", acc.Identifier())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAccountsMasksTokenPreview(t *testing.T) {
|
||||
h := newAdminTestHandler(t, `{
|
||||
"accounts":[{"email":"u@example.com","password":"pwd"}]
|
||||
|
||||
@@ -46,7 +46,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
}
|
||||
|
||||
if raw, ok := req["runtime"].(map[string]any); ok {
|
||||
cfg := &config.RuntimeConfig{}
|
||||
cfg := &config.RuntimeConfig{AccountHealthCooldownEmptySeconds: -1}
|
||||
if v, exists := raw["account_max_inflight"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil {
|
||||
@@ -75,6 +75,77 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
}
|
||||
cfg.TokenRefreshIntervalHours = n
|
||||
}
|
||||
if v, exists := raw["disable_upstream_file_uploads"]; exists {
|
||||
b := boolFrom(v)
|
||||
cfg.DisableUpstreamFileUploads = &b
|
||||
}
|
||||
if v, exists := raw["account_health_enabled"]; exists {
|
||||
b := boolFrom(v)
|
||||
cfg.AccountHealthEnabled = &b
|
||||
}
|
||||
if v, exists := raw["account_health_recovery_window_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_health_recovery_window_seconds", n, 1, 86400, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountHealthRecoveryWindowSeconds = n
|
||||
}
|
||||
if v, exists := raw["account_health_max_cooldown_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_health_max_cooldown_seconds", n, 1, 86400, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountHealthMaxCooldownSeconds = n
|
||||
}
|
||||
if v, exists := raw["account_health_cooldown_429_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_health_cooldown_429_seconds", n, 1, 86400, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountHealthCooldown429Seconds = n
|
||||
}
|
||||
if v, exists := raw["account_health_cooldown_403_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_health_cooldown_403_seconds", n, 1, 86400, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountHealthCooldown403Seconds = n
|
||||
}
|
||||
if v, exists := raw["account_health_cooldown_auth_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_health_cooldown_auth_seconds", n, 1, 86400, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountHealthCooldownAuthSeconds = n
|
||||
}
|
||||
if v, exists := raw["account_health_cooldown_5xx_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_health_cooldown_5xx_seconds", n, 1, 86400, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountHealthCooldown5xxSeconds = n
|
||||
}
|
||||
if v, exists := raw["account_health_cooldown_network_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_health_cooldown_network_seconds", n, 1, 86400, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountHealthCooldownNetworkSeconds = n
|
||||
}
|
||||
if v, exists := raw["account_health_cooldown_empty_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_health_cooldown_empty_seconds", n, 0, 86400, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountHealthCooldownEmptySeconds = n
|
||||
}
|
||||
if v, exists := raw["account_health_cooldown_muted_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if err := config.ValidateIntRange("runtime.account_health_cooldown_muted_seconds", n, 1, 86400, true); err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
cfg.AccountHealthCooldownMutedSeconds = n
|
||||
}
|
||||
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/account"
|
||||
authn "ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
@@ -12,6 +13,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
snap := h.Store.Snapshot()
|
||||
recommended := defaultRuntimeRecommended(len(snap.Accounts), h.Store.RuntimeAccountMaxInflight())
|
||||
needsSync := config.IsVercel() && snap.VercelSyncHash != "" && snap.VercelSyncHash != h.computeSyncHash()
|
||||
health := account.LoadHealthConfigFromStore(h.Store)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"admin": map[string]any{
|
||||
@@ -21,10 +23,21 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
"default_password_warning": authn.UsingDefaultAdminKey(h.Store),
|
||||
},
|
||||
"runtime": map[string]any{
|
||||
"account_max_inflight": h.Store.RuntimeAccountMaxInflight(),
|
||||
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
|
||||
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
|
||||
"token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
|
||||
"account_max_inflight": h.Store.RuntimeAccountMaxInflight(),
|
||||
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
|
||||
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
|
||||
"token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
|
||||
"disable_upstream_file_uploads": !h.Store.UpstreamFileUploadsEnabled(),
|
||||
"account_health_enabled": health.Enabled,
|
||||
"account_health_recovery_window_seconds": health.RecoveryWindowSeconds,
|
||||
"account_health_max_cooldown_seconds": health.MaxCooldownSeconds,
|
||||
"account_health_cooldown_429_seconds": health.Cooldown429Seconds,
|
||||
"account_health_cooldown_403_seconds": health.Cooldown403Seconds,
|
||||
"account_health_cooldown_auth_seconds": health.CooldownAuthSeconds,
|
||||
"account_health_cooldown_5xx_seconds": health.Cooldown5xxSeconds,
|
||||
"account_health_cooldown_network_seconds": health.CooldownNetworkSeconds,
|
||||
"account_health_cooldown_empty_seconds": health.CooldownEmptySeconds,
|
||||
"account_health_cooldown_muted_seconds": health.CooldownMutedSeconds,
|
||||
},
|
||||
"compat": snap.Compat,
|
||||
"responses": snap.Responses,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package settings
|
||||
|
||||
import "ds2api/internal/config"
|
||||
import (
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *config.RuntimeConfig) error {
|
||||
merged := current
|
||||
@@ -17,6 +20,39 @@ func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *confi
|
||||
if incoming.TokenRefreshIntervalHours > 0 {
|
||||
merged.TokenRefreshIntervalHours = incoming.TokenRefreshIntervalHours
|
||||
}
|
||||
if incoming.DisableUpstreamFileUploads != nil {
|
||||
merged.DisableUpstreamFileUploads = incoming.DisableUpstreamFileUploads
|
||||
}
|
||||
if incoming.AccountHealthEnabled != nil {
|
||||
merged.AccountHealthEnabled = incoming.AccountHealthEnabled
|
||||
}
|
||||
if incoming.AccountHealthRecoveryWindowSeconds > 0 {
|
||||
merged.AccountHealthRecoveryWindowSeconds = incoming.AccountHealthRecoveryWindowSeconds
|
||||
}
|
||||
if incoming.AccountHealthMaxCooldownSeconds > 0 {
|
||||
merged.AccountHealthMaxCooldownSeconds = incoming.AccountHealthMaxCooldownSeconds
|
||||
}
|
||||
if incoming.AccountHealthCooldown429Seconds > 0 {
|
||||
merged.AccountHealthCooldown429Seconds = incoming.AccountHealthCooldown429Seconds
|
||||
}
|
||||
if incoming.AccountHealthCooldown403Seconds > 0 {
|
||||
merged.AccountHealthCooldown403Seconds = incoming.AccountHealthCooldown403Seconds
|
||||
}
|
||||
if incoming.AccountHealthCooldownAuthSeconds > 0 {
|
||||
merged.AccountHealthCooldownAuthSeconds = incoming.AccountHealthCooldownAuthSeconds
|
||||
}
|
||||
if incoming.AccountHealthCooldown5xxSeconds > 0 {
|
||||
merged.AccountHealthCooldown5xxSeconds = incoming.AccountHealthCooldown5xxSeconds
|
||||
}
|
||||
if incoming.AccountHealthCooldownNetworkSeconds > 0 {
|
||||
merged.AccountHealthCooldownNetworkSeconds = incoming.AccountHealthCooldownNetworkSeconds
|
||||
}
|
||||
if incoming.AccountHealthCooldownEmptySeconds >= 0 {
|
||||
merged.AccountHealthCooldownEmptySeconds = incoming.AccountHealthCooldownEmptySeconds
|
||||
}
|
||||
if incoming.AccountHealthCooldownMutedSeconds > 0 {
|
||||
merged.AccountHealthCooldownMutedSeconds = incoming.AccountHealthCooldownMutedSeconds
|
||||
}
|
||||
}
|
||||
return validateRuntimeSettings(merged)
|
||||
}
|
||||
@@ -31,6 +67,7 @@ func (h *Handler) applyRuntimeSettings() {
|
||||
maxQueue := h.Store.RuntimeAccountMaxQueue(recommended)
|
||||
global := h.Store.RuntimeGlobalMaxInflight(recommended)
|
||||
h.Pool.ApplyRuntimeLimits(maxPer, maxQueue, global)
|
||||
h.Pool.ApplyHealthConfig(account.LoadHealthConfigFromStore(h.Store))
|
||||
}
|
||||
|
||||
func defaultRuntimeRecommended(accountCount, maxPer int) int {
|
||||
|
||||
@@ -48,6 +48,39 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if runtimeCfg.TokenRefreshIntervalHours > 0 {
|
||||
c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours
|
||||
}
|
||||
if runtimeCfg.DisableUpstreamFileUploads != nil {
|
||||
c.Runtime.DisableUpstreamFileUploads = runtimeCfg.DisableUpstreamFileUploads
|
||||
}
|
||||
if runtimeCfg.AccountHealthEnabled != nil {
|
||||
c.Runtime.AccountHealthEnabled = runtimeCfg.AccountHealthEnabled
|
||||
}
|
||||
if runtimeCfg.AccountHealthRecoveryWindowSeconds > 0 {
|
||||
c.Runtime.AccountHealthRecoveryWindowSeconds = runtimeCfg.AccountHealthRecoveryWindowSeconds
|
||||
}
|
||||
if runtimeCfg.AccountHealthMaxCooldownSeconds > 0 {
|
||||
c.Runtime.AccountHealthMaxCooldownSeconds = runtimeCfg.AccountHealthMaxCooldownSeconds
|
||||
}
|
||||
if runtimeCfg.AccountHealthCooldown429Seconds > 0 {
|
||||
c.Runtime.AccountHealthCooldown429Seconds = runtimeCfg.AccountHealthCooldown429Seconds
|
||||
}
|
||||
if runtimeCfg.AccountHealthCooldown403Seconds > 0 {
|
||||
c.Runtime.AccountHealthCooldown403Seconds = runtimeCfg.AccountHealthCooldown403Seconds
|
||||
}
|
||||
if runtimeCfg.AccountHealthCooldownAuthSeconds > 0 {
|
||||
c.Runtime.AccountHealthCooldownAuthSeconds = runtimeCfg.AccountHealthCooldownAuthSeconds
|
||||
}
|
||||
if runtimeCfg.AccountHealthCooldown5xxSeconds > 0 {
|
||||
c.Runtime.AccountHealthCooldown5xxSeconds = runtimeCfg.AccountHealthCooldown5xxSeconds
|
||||
}
|
||||
if runtimeCfg.AccountHealthCooldownNetworkSeconds > 0 {
|
||||
c.Runtime.AccountHealthCooldownNetworkSeconds = runtimeCfg.AccountHealthCooldownNetworkSeconds
|
||||
}
|
||||
if runtimeCfg.AccountHealthCooldownEmptySeconds >= 0 {
|
||||
c.Runtime.AccountHealthCooldownEmptySeconds = runtimeCfg.AccountHealthCooldownEmptySeconds
|
||||
}
|
||||
if runtimeCfg.AccountHealthCooldownMutedSeconds > 0 {
|
||||
c.Runtime.AccountHealthCooldownMutedSeconds = runtimeCfg.AccountHealthCooldownMutedSeconds
|
||||
}
|
||||
}
|
||||
if compatCfg != nil {
|
||||
if compatCfg.WideInputStrictOutput != nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -159,7 +159,7 @@ func toStringSlice(v any) ([]string, bool) {
|
||||
func toAccount(m map[string]any) config.Account {
|
||||
email := fieldString(m, "email")
|
||||
mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile"))
|
||||
return config.Account{
|
||||
acc := config.Account{
|
||||
Name: fieldString(m, "name"),
|
||||
Remark: fieldString(m, "remark"),
|
||||
Email: email,
|
||||
@@ -167,6 +167,11 @@ func toAccount(m map[string]any) config.Account {
|
||||
Password: fieldString(m, "password"),
|
||||
ProxyID: fieldString(m, "proxy_id"),
|
||||
}
|
||||
if v, ok := m["active"]; ok {
|
||||
active := boolValue(v)
|
||||
acc.Active = &active
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
func toAPIKeys(v any) ([]config.APIKey, bool) {
|
||||
@@ -295,6 +300,17 @@ func fieldStringOptional(m map[string]any, key string) (string, bool) {
|
||||
return strings.TrimSpace(fmt.Sprintf("%v", v)), true
|
||||
}
|
||||
|
||||
func boolValue(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(x), "true") || strings.TrimSpace(x) == "1"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func statusOr(v int, d int) int {
|
||||
if v == 0 {
|
||||
return d
|
||||
|
||||
@@ -38,6 +38,9 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
|
||||
}
|
||||
thinkingEnabled := util.ResolveThinkingEnabled(req, false)
|
||||
finalPrompt := prompt.MessagesPrepareWithThinking(toMessageMaps(dsPayload["messages"]), thinkingEnabled)
|
||||
if err := promptcompat.ValidatePromptLength(finalPrompt); err != nil {
|
||||
return claudeNormalizedRequest{}, err
|
||||
}
|
||||
toolNames := extractClaudeToolNames(toolsRequested)
|
||||
if len(toolNames) == 0 && len(toolsRequested) > 0 {
|
||||
toolNames = []string{"__any_tool__"}
|
||||
|
||||
@@ -30,6 +30,9 @@ func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[strin
|
||||
|
||||
toolsRaw := convertGeminiTools(req["tools"])
|
||||
finalPrompt, toolNames := promptcompat.BuildOpenAIPromptForAdapter(messagesRaw, toolsRaw, "", thinkingEnabled)
|
||||
if err := promptcompat.ValidatePromptLength(finalPrompt); err != nil {
|
||||
return promptcompat.StandardRequest{}, err
|
||||
}
|
||||
passThrough := collectGeminiPassThrough(req)
|
||||
|
||||
return promptcompat.StandardRequest{
|
||||
|
||||
@@ -16,6 +16,7 @@ type mockOpenAIConfig struct {
|
||||
embedProv string
|
||||
historySplitEnabled bool
|
||||
historySplitTurns int
|
||||
uploadsEnabled *bool
|
||||
}
|
||||
|
||||
func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases }
|
||||
@@ -41,6 +42,9 @@ func (m mockOpenAIConfig) HistorySplitTriggerAfterTurns() int {
|
||||
}
|
||||
return m.historySplitTurns
|
||||
}
|
||||
func (m mockOpenAIConfig) UpstreamFileUploadsEnabled() bool {
|
||||
return m.uploadsEnabled == nil || *m.uploadsEnabled
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
|
||||
cfg := mockOpenAIConfig{
|
||||
|
||||
@@ -146,6 +146,29 @@ func TestPreprocessInlineFileInputsDeduplicatesIdenticalPayloads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsRejectsInlineUploadWhenUpstreamUploadsDisabled(t *testing.T) {
|
||||
disabled := false
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true, uploadsEnabled: &disabled}, Auth: streamStatusAuthStub{}, DS: ds}
|
||||
reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
|
||||
req.Header.Set("Authorization", "Bearer direct-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.ChatCompletions(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(ds.uploadCalls) != 0 {
|
||||
t.Fatalf("expected no upload calls when upstream uploads are disabled, got %d", len(ds.uploadCalls))
|
||||
}
|
||||
if ds.completionReq != nil {
|
||||
t.Fatalf("did not expect completion call when inline upload is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsUploadsInlineFilesBeforeCompletion(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
dsclient "ds2api/internal/deepseek/client"
|
||||
"ds2api/internal/httpapi/openai/shared"
|
||||
"ds2api/internal/promptcompat"
|
||||
@@ -57,6 +58,12 @@ func (h *Handler) PreprocessInlineFileInputs(ctx context.Context, a *auth.Reques
|
||||
if h == nil || h.DS == nil || len(req) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !config.UpstreamFileUploadsEnabledFrom(h.Store) {
|
||||
if containsInlineUploadPayload(req) {
|
||||
return &inlineFileUploadError{status: http.StatusBadRequest, message: "Upstream file uploads are disabled by runtime configuration."}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
state := &inlineUploadState{
|
||||
ctx: ctx,
|
||||
handler: h,
|
||||
@@ -78,6 +85,27 @@ func (h *Handler) PreprocessInlineFileInputs(ctx context.Context, a *auth.Reques
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsInlineUploadPayload(raw any) bool {
|
||||
switch x := raw.(type) {
|
||||
case []any:
|
||||
for _, item := range x {
|
||||
if containsInlineUploadPayload(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
if _, ok, _ := decodeOpenAIInlineFileBlock(x); ok {
|
||||
return true
|
||||
}
|
||||
for _, key := range []string{"messages", "input", "attachments", "content", "files", "items", "data", "source", "file", "image_url"} {
|
||||
if nested, ok := x[key]; ok && containsInlineUploadPayload(nested) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func WriteInlineFileError(w http.ResponseWriter, err error) {
|
||||
inlineErr, ok := err.(*inlineFileUploadError)
|
||||
if !ok || inlineErr == nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/chathistory"
|
||||
"ds2api/internal/config"
|
||||
dsclient "ds2api/internal/deepseek/client"
|
||||
"ds2api/internal/httpapi/openai/shared"
|
||||
)
|
||||
@@ -33,6 +34,10 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer h.Auth.Release(a)
|
||||
if !config.UpstreamFileUploadsEnabledFrom(h.Store) {
|
||||
shared.WriteOpenAIError(w, http.StatusBadRequest, "Upstream file uploads are disabled by runtime configuration.")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))), "multipart/form-data") {
|
||||
shared.WriteOpenAIError(w, http.StatusBadRequest, "content-type must be multipart/form-data")
|
||||
return
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
dsclient "ds2api/internal/deepseek/client"
|
||||
"ds2api/internal/httpapi/openai/shared"
|
||||
"ds2api/internal/promptcompat"
|
||||
@@ -27,6 +28,9 @@ func (s Service) Apply(ctx context.Context, a *auth.RequestAuth, stdReq promptco
|
||||
if s.DS == nil || s.Store == nil || a == nil {
|
||||
return stdReq, nil
|
||||
}
|
||||
if !config.UpstreamFileUploadsEnabledFrom(s.Store) {
|
||||
return stdReq, nil
|
||||
}
|
||||
|
||||
promptMessages, historyMessages := SplitOpenAIHistoryMessages(stdReq.Messages, s.Store.HistorySplitTriggerAfterTurns())
|
||||
if len(historyMessages) == 0 {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package promptcompat
|
||||
|
||||
import "fmt"
|
||||
|
||||
const MaxPromptRunes = 131072
|
||||
|
||||
func ValidatePromptLength(prompt string) error {
|
||||
if len([]rune(prompt)) <= MaxPromptRunes {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("context too long: maximum 128k characters allowed")
|
||||
}
|
||||
@@ -31,6 +31,9 @@ func NormalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
|
||||
}
|
||||
toolPolicy := DefaultToolChoicePolicy()
|
||||
finalPrompt, toolNames := BuildOpenAIPrompt(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled)
|
||||
if err := ValidatePromptLength(finalPrompt); err != nil {
|
||||
return StandardRequest{}, err
|
||||
}
|
||||
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
|
||||
passThrough := collectOpenAIChatPassThrough(req)
|
||||
refFileIDs := CollectOpenAIRefFileIDs(req)
|
||||
@@ -85,6 +88,9 @@ func NormalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
|
||||
return StandardRequest{}, err
|
||||
}
|
||||
finalPrompt, toolNames := BuildOpenAIPrompt(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled)
|
||||
if err := ValidatePromptLength(finalPrompt); err != nil {
|
||||
return StandardRequest{}, err
|
||||
}
|
||||
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
|
||||
if !toolPolicy.IsNone() {
|
||||
toolPolicy.Allowed = namesToSet(toolNames)
|
||||
|
||||
@@ -63,6 +63,9 @@ export default function AddAccountModal({
|
||||
onChange={e => setNewAccount({ ...newAccount, mobile: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
{t('accountManager.accountLoginHint')}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">{t('accountManager.passwordLabel')} <span className="text-destructive">*</span></label>
|
||||
<input
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"updateKeySuccess": "API key updated successfully.",
|
||||
"addAccountSuccess": "Account added successfully.",
|
||||
"updateAccountSuccess": "Account metadata updated successfully.",
|
||||
"requiredFields": "Password and email/mobile are required.",
|
||||
"requiredFields": "Password plus at least one of email or mobile is required.",
|
||||
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
|
||||
"deleteAccountConfirm": "Are you sure you want to delete this account?",
|
||||
"invalidIdentifier": "Invalid account identifier. Operation aborted.",
|
||||
@@ -122,7 +122,7 @@
|
||||
"deleteKeyTitle": "Delete key",
|
||||
"noApiKeys": "No API keys found.",
|
||||
"accountsTitle": "DeepSeek Accounts",
|
||||
"accountsDesc": "Manage the DeepSeek account pool and edit name/remark.",
|
||||
"accountsDesc": "Manage the DeepSeek account pool with either email or mobile login, and edit name/remark.",
|
||||
"testAll": "Refresh all tokens",
|
||||
"addAccount": "Add account",
|
||||
"testingAllAccounts": "Refreshing tokens for all accounts...",
|
||||
@@ -154,8 +154,9 @@
|
||||
"namePlaceholder": "e.g. Primary Account A",
|
||||
"remarkOptional": "Remark (optional)",
|
||||
"remarkPlaceholder": "e.g. Team shared / test only",
|
||||
"emailOptional": "Email (optional)",
|
||||
"mobileOptional": "Mobile (optional)",
|
||||
"emailOptional": "Email (for overseas login)",
|
||||
"mobileOptional": "Mobile (for mainland China login)",
|
||||
"accountLoginHint": "Overseas / English entry points usually support email login directly, while mainland China entry points usually use mobile login. DS2API supports both account identifiers here.",
|
||||
"passwordLabel": "Password",
|
||||
"passwordPlaceholder": "Account password",
|
||||
"addAccountLoading": "Adding...",
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"updateKeySuccess": "API 密钥更新成功",
|
||||
"addAccountSuccess": "账号添加成功",
|
||||
"updateAccountSuccess": "账号信息更新成功",
|
||||
"requiredFields": "需要填写密码以及邮箱或手机号",
|
||||
"requiredFields": "需要填写密码,以及邮箱或手机号(至少一种)",
|
||||
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
|
||||
"deleteAccountConfirm": "确定要删除此账号吗?",
|
||||
"invalidIdentifier": "账号标识无效,无法执行操作",
|
||||
@@ -122,7 +122,7 @@
|
||||
"deleteKeyTitle": "删除密钥",
|
||||
"noApiKeys": "未找到 API 密钥",
|
||||
"accountsTitle": "DeepSeek 账号",
|
||||
"accountsDesc": "管理 DeepSeek 账号池,支持修改名称和备注",
|
||||
"accountsDesc": "管理 DeepSeek 账号池,支持邮箱或手机号登录,并可修改名称和备注",
|
||||
"testAll": "刷新全部 Token",
|
||||
"addAccount": "添加账号",
|
||||
"testingAllAccounts": "正在刷新所有账号 Token...",
|
||||
@@ -154,8 +154,9 @@
|
||||
"namePlaceholder": "例如:主账号 A",
|
||||
"remarkOptional": "备注(可选)",
|
||||
"remarkPlaceholder": "例如:团队共享 / 仅测试用",
|
||||
"emailOptional": "邮箱 (可选)",
|
||||
"mobileOptional": "手机号 (可选)",
|
||||
"emailOptional": "邮箱(海外登录可用)",
|
||||
"mobileOptional": "手机号(中国大陆登录可用)",
|
||||
"accountLoginHint": "海外 IP / 英文入口通常可直接使用邮箱登录;中国大陆入口通常使用手机号。这里两种账号标识都支持。",
|
||||
"passwordLabel": "密码",
|
||||
"passwordPlaceholder": "账号密码",
|
||||
"addAccountLoading": "添加中...",
|
||||
|
||||
Reference in New Issue
Block a user