Compare commits

...

56 Commits

Author SHA1 Message Date
CJACK.
af9c51f3a7 Merge pull request #245 from CJackHwang/dev
Merge pull request #244 from CJackHwang/codex/temporarily-switch-to-internal-usage-count

Temporarily ignore DeepSeek upstream usage fields and prefer internal token estimation
2026-04-07 21:27:32 +08:00
CJACK.
92bb25265e Merge pull request #246 from CJackHwang/codex/fix-review-comments-before-merging
Fix proxy-bound fallback behavior and redact proxy password responses
2026-04-07 21:26:13 +08:00
CJACK.
84050d87e4 fix proxy fallback binding and redact proxy password responses 2026-04-07 21:22:28 +08:00
CJACK.
c6a6f1cf4e Merge pull request #244 from CJackHwang/codex/temporarily-switch-to-internal-usage-count
Temporarily ignore DeepSeek upstream usage fields and prefer internal token estimation
2026-04-07 20:39:36 +08:00
CJACK.
f4ed10d38d disable token-mismatch gate by default in raw stream simulator 2026-04-07 20:38:29 +08:00
CJACK.
d9e65c9710 remove upstream token-usage plumbing and always estimate from content 2026-04-07 20:12:18 +08:00
CJACK.
a14e5b0847 temporarily ignore upstream token usage fields globally 2026-04-07 19:40:47 +08:00
CJACK.
b59e991ad5 Merge pull request #241 from tanaer/feat/proxy-ip-management-dev
feat: 增加 SOCKS5/SOCKS5H 代理管理与账号代理路由
2026-04-07 17:14:48 +08:00
Jason.li
c84347b625 docs: align agent rules with quality gate lint 2026-04-07 14:19:40 +08:00
Jason.li
8ae2ea10c8 feat(proxy): add proxy IP management and account routing
Add admin CRUD and connectivity checks for SOCKS5/SOCKS5H proxy nodes.

Allow accounts to bind to a proxy, route DeepSeek requests through the selected node, and expose proxy management in the admin UI.
2026-04-07 14:16:13 +08:00
CJACK.
d32765bc84 Merge pull request #240 from CJackHwang/dev
Merge pull request #239 from CJackHwang/codex/fix-escaping-issues-and-token-counting

Fix HTML-escaped tool-call args and preserve upstream token usage (stream & non-stream)
2026-04-07 13:16:49 +08:00
CJACK.
08b1344f81 Merge pull request #242 from CJackHwang/codex/fix-issues-in-pull-request-#240
fix: avoid double-decoding XML entity text in markup tool-call parsing
2026-04-07 13:16:01 +08:00
CJACK.
8b0da7b6f8 fix: avoid double XML entity decoding in toolcall parser 2026-04-07 13:14:30 +08:00
CJACK.
1c95942e5d Merge pull request #239 from CJackHwang/codex/fix-escaping-issues-and-token-counting
Fix HTML-escaped tool-call args and preserve upstream token usage (stream & non-stream)
2026-04-07 12:56:02 +08:00
CJACK.
da7c46b278 Limit HTML unescape to markup tool-call parsing 2026-04-07 12:55:06 +08:00
CJACK.
cfcca69396 Update VERSION 2026-04-07 12:46:15 +08:00
CJACK.
4475bfe92f Merge pull request #238 from CJackHwang/codex/remove-project-structure-section-from-main-document
docs: remove duplicated project structure sections from READMEs
2026-04-07 12:36:30 +08:00
CJACK.
77a401fb19 Fix tool-call HTML escaping and stabilize usage token mapping 2026-04-07 12:35:50 +08:00
CJACK.
a935f61f74 docs: remove duplicated project structure sections from READMEs 2026-04-07 12:32:52 +08:00
CJACK.
80b88b37ff Merge pull request #236 from CJackHwang/codex/review-and-reorganize-all-md-documents
docs: add architecture docs and centralize documentation index; update READMEs and API links
2026-04-07 11:55:11 +08:00
CJACK.
475c9086d2 docs: 为展开目录树补充文件夹作用注释 2026-04-07 11:51:14 +08:00
CJACK.
8cfba9c650 Merge pull request #232 from CJackHwang/dev
refactor: improve XML tool parsing robustness, update system prompt constraints, and simplify tool filtering logic
2026-04-07 11:13:44 +08:00
CJACK.
98131881ed Merge pull request #234 from CJackHwang/codex/fix-documentation-and-accumulated_token_usage
Propagate DeepSeek SSE token usage to /v1/responses and remove stale POW env docs
2026-04-07 11:02:44 +08:00
CJACK.
86ecbc89bd Preserve SSE frame delimiters when injecting Gemini usage 2026-04-07 10:59:27 +08:00
CJACK.
668b9c26bd Unify token usage pass-through on OpenAI translate pipeline 2026-04-07 10:16:23 +08:00
CJACK.
5bcea3d727 Propagate upstream token usage across Gemini usage metadata 2026-04-07 10:16:00 +08:00
CJACK.
96b8587c5b Fix token usage propagation and remove stale env docs 2026-04-07 08:27:03 +08:00
CJACK.
d09260d06f Merge pull request #233 from CJackHwang/main
依赖升级
2026-04-07 07:12:40 +08:00
CJACK.
554b95d232 Merge pull request #231 from CJackHwang/dependabot/npm_and_yarn/webui/npm_and_yarn-7c6ac41456
chore(deps-dev): bump vite from 8.0.3 to 8.0.5 in /webui in the npm_and_yarn group across 1 directory
2026-04-07 07:02:53 +08:00
dependabot[bot]
b54ee05d12 chore(deps-dev): bump vite
Bumps the npm_and_yarn group with 1 update in the /webui directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 8.0.3 to 8.0.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 18:44:30 +00:00
CJACK
9968221633 refactor: improve XML tool parsing robustness, update system prompt constraints, and simplify tool filtering logic 2026-04-07 02:10:45 +08:00
CJACK
b79a13efd5 feat: support explicit prompt token tracking in SSE parsing and stream handlers 2026-04-07 01:39:27 +08:00
CJACK
da778a18fb refactor: replace WASM-based PoW with a high-performance native Go implementation and add context support for cancellation. 2026-04-07 01:20:01 +08:00
CJACK.
10921e0f84 Merge pull request #229 from CJackHwang/dev
refactor: replace WASM-based PoW solver with a native Go implementation in the pow package
2026-04-07 00:57:33 +08:00
CJACK
e7d561694a refactor: replace WASM-based PoW solver with a native Go implementation in the pow package 2026-04-07 00:10:36 +08:00
CJACK.
13687ce787 Merge pull request #227 from CJackHwang/codex/change-empty-responses-to-429
fix(openai): return 429 for empty upstream output
2026-04-06 17:11:07 +08:00
CJACK.
26aa02d4b5 fix(openai): return 429 for empty upstream output 2026-04-06 16:56:17 +08:00
CJACK.
89eaf048c3 Merge pull request #221 from CJackHwang/dev
Dev
2026-04-06 16:50:00 +08:00
CJACK.
904211469a Merge pull request #222 from CJackHwang/codex/resolve-pull-request-issues-and-complete-tests
Add golangci-lint bootstrap and CI lint gate; update docs and .gitignore
2026-04-06 13:56:05 +08:00
CJACK.
530872ff2f Merge pull request #224 from CJackHwang/codex/fix-four-issues-from-pull-requests-99aozg
Fix lint bootstrap behavior and harden SSE token replay parsing
2026-04-06 13:55:30 +08:00
CJACK.
fbe1e25c7b Merge pull request #225 from CJackHwang/codex/fix-golangci-lint-bootstrap-compatibility
Treat missing golangci-lint as bootstrap-compatible
2026-04-06 13:54:50 +08:00
CJACK.
cd7e03d936 Merge pull request #226 from CJackHwang/codex/fix-issue-with-passing-thresholds
Handle deferred Close errors, normalize error messages, and add nolint annotations
2026-04-06 13:54:14 +08:00
CJACK.
37fb758191 Make full quality gates pass across repository 2026-04-06 13:41:58 +08:00
CJACK.
fb6be8a8ee fix lint bootstrap on missing golangci-lint 2026-04-06 13:38:17 +08:00
CJACK.
57114a36f5 fix: address codex review issues for lint bootstrap and token replay 2026-04-06 13:12:36 +08:00
CJACK.
a671d82759 chore: auto-bootstrap golangci-lint for Go 1.26 compatibility 2026-04-06 12:52:56 +08:00
CJACK.
da75ed6966 Merge pull request #220 from CJackHwang/codex/fix-pull-request-review-comments
Migrate and reorganize .golangci.yml to v2 with updated linters and exclusions
2026-04-06 12:33:51 +08:00
CJACK.
3b99d2edbe docs: add full-sample token replay command and report fields 2026-04-06 12:32:31 +08:00
CJACK.
f6c09ebd63 fix: keep node error-branch token semantics and add grep fallback 2026-04-06 12:32:26 +08:00
CJACK.
36af2e00f6 Merge pull request #219 from CJackHwang/dev
Dev
2026-04-06 11:17:39 +08:00
CJACK.
9e0fd83a76 test: validate raw stream token replay and enforce gofmt in lint script 2026-04-06 11:15:08 +08:00
CJACK.
a8c160b05d fix: parse DeepSeek accumulated_token_usage robustly and stabilize lint 2026-04-06 11:14:48 +08:00
CJACK.
89ca57122c fix: migrate golangci config to v2 schema 2026-04-06 09:29:22 +08:00
CJACK
6b6ce3eea8 refactor: move toolcall utility files to internal/toolcall directory 2026-04-06 03:56:42 +08:00
CJACK
870144de17 ci: remove golangci-lint step from quality gates workflow 2026-04-06 03:53:03 +08:00
CJACK
1530246e4f refactor: move tool call parsing and formatting logic to a dedicated internal/toolcall package 2026-04-06 03:19:18 +08:00
159 changed files with 3721 additions and 1184 deletions

View File

@@ -28,6 +28,16 @@ jobs:
cache: "npm"
cache-dependency-path: webui/package-lock.json
- name: Setup golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.11.4
install-mode: binary
verify: true
- name: Go Format & Lint Gates
run: ./scripts/lint.sh
- name: Refactor Line Gate
run: ./tests/scripts/check-refactor-line-gate.sh

View File

@@ -79,7 +79,7 @@ jobs:
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \
go build -trimpath -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION}" -o "${STAGE}/${BIN}" ./cmd/ds2api
cp config.example.json .env.example internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm LICENSE README.MD README.en.md "${STAGE}/"
cp config.example.json .env.example LICENSE README.MD README.en.md "${STAGE}/"
cp -R static/admin "${STAGE}/static/admin"
if [ "${GOOS}" = "windows" ]; then

3
.gitignore vendored
View File

@@ -59,3 +59,6 @@ Thumbs.db
# Claude Code
.claude/
CLAUDE.local.md
# Local tool bootstrap cache
.tmp/

73
.golangci.yml Normal file
View File

@@ -0,0 +1,73 @@
version: "2"
run:
tests: true
linters:
default: standard
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
settings:
dupl:
threshold: 100
goconst:
min-len: 2
min-occurrences: 2
gocritic:
enabled-tags:
- diagnostic
- experimental
- opinionated
- performance
- style
disabled-checks:
- wrapperFunc
- rangeValCopy
- hugeParam
gocyclo:
min-complexity: 15
lll:
line-length: 140
misspell:
locale: US
nakedret:
max-func-lines: 30
prealloc:
simple: true
range-loops: true
for-loops: false
exclusions:
generated: lax
rules:
- path: (.+)\.go$
text: "ST1000: at least one file in a package should have a package comment"
paths:
- third_party$
- builtin$
- examples$
- vendor$
- webui/node_modules$
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofmt
settings:
goimports:
local-prefixes:
- ds2api
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
- vendor$
- webui/node_modules$

23
AGENTS.md Normal file
View File

@@ -0,0 +1,23 @@
# AGENTS.md
These rules apply to all agent-made changes in this repository.
## PR Gate
- Before opening or updating a PR, run the same local gates as `.github/workflows/quality-gates.yml`.
- Required commands:
- `./scripts/lint.sh`
- `./tests/scripts/check-refactor-line-gate.sh`
- `./tests/scripts/run-unit-all.sh`
- `npm run build --prefix webui`
## Go Lint Rules
- Run `gofmt -w` on every changed Go file before commit or push.
- Do not ignore error returns from I/O-style cleanup calls such as `Close`, `Flush`, `Sync`, or similar methods.
- If a cleanup error cannot be returned, log it explicitly.
## Change Scope
- 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.

View File

@@ -4,6 +4,8 @@ Language: [中文](API.md) | [English](API.en.md)
This document describes the actual behavior of the current Go codebase.
Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Deployment](docs/DEPLOY.en.md) / [Testing](docs/TESTING.md)
---
## Table of Contents
@@ -267,6 +269,7 @@ data: [DONE]
- `deepseek-reasoner` / `deepseek-reasoner-search` models emit `delta.reasoning_content`
- Text emits `delta.content`
- Last chunk includes `finish_reason` and `usage`
- Token counting prefers pass-through from upstream DeepSeek SSE (`accumulated_token_usage` / `token_usage`), and only falls back to local estimation when upstream usage is absent
#### Tool Calls
@@ -383,6 +386,7 @@ Business auth required. Returns OpenAI-compatible embeddings shape.
## Claude-Compatible API
Besides `/anthropic/v1/*`, DS2API also supports shortcut paths: `/v1/messages`, `/messages`, `/v1/messages/count_tokens`, `/messages/count_tokens`.
Implementation-wise this path is unified on the OpenAI Chat Completions parse-and-translate pipeline to avoid maintaining divergent parsing chains.
### `GET /anthropic/v1/models`
@@ -517,6 +521,7 @@ Supported paths:
- `/v1/models/{model}:streamGenerateContent` (compat path)
Authentication is the same as other business routes (`Authorization: Bearer <token>` or `x-api-key`).
Implementation-wise this path is unified on the OpenAI Chat Completions parse-and-translate pipeline to avoid maintaining divergent parsing chains.
### `POST /v1beta/models/{model}:generateContent`
@@ -535,6 +540,7 @@ Returns SSE (`text/event-stream`), each chunk as `data: <json>`:
- regular text: incremental text chunks
- `tools` mode: buffered and emitted as `functionCall` at finalize phase
- final chunk: includes `finishReason: "STOP"` and `usageMetadata`
- Token counting prefers pass-through from upstream DeepSeek SSE (`accumulated_token_usage` / `token_usage`), and only falls back to local estimation when upstream usage is absent
---

6
API.md
View File

@@ -4,6 +4,8 @@
本文档描述当前 Go 代码库的实际 API 行为。
文档导航:[总览](README.MD) / [架构说明](docs/ARCHITECTURE.md) / [部署指南](docs/DEPLOY.md) / [测试指南](docs/TESTING.md)
---
## 目录
@@ -267,6 +269,7 @@ data: [DONE]
- `deepseek-reasoner` / `deepseek-reasoner-search` 模型输出 `delta.reasoning_content`
- 普通文本输出 `delta.content`
- 最后一段包含 `finish_reason``usage`
- token 计数优先透传上游 DeepSeek SSE`accumulated_token_usage` / `token_usage`);仅在上游缺失时回退本地估算
#### Tool Calls
@@ -389,6 +392,7 @@ data: [DONE]
## Claude 兼容接口
除标准路径 `/anthropic/v1/*` 外,还支持快捷路径 `/v1/messages``/messages``/v1/messages/count_tokens``/messages/count_tokens`
实现上统一走 OpenAI Chat Completions 解析与回译链路,避免多套解析逻辑分叉维护。
### `GET /anthropic/v1/models`
@@ -523,6 +527,7 @@ data: {"type":"message_stop"}
- `/v1/models/{model}:streamGenerateContent`(兼容路径)
鉴权方式同业务接口(`Authorization: Bearer <token>``x-api-key`)。
实现上统一走 OpenAI Chat Completions 解析与回译链路,避免多套解析逻辑分叉维护。
### `POST /v1beta/models/{model}:generateContent`
@@ -541,6 +546,7 @@ data: {"type":"message_stop"}
- 常规文本:持续返回增量文本 chunk
- `tools` 场景:会缓冲并在结束时输出 `functionCall` 结构
- 结束 chunk包含 `finishReason: "STOP"``usageMetadata`
- token 计数优先透传上游 DeepSeek SSE`accumulated_token_usage` / `token_usage`);仅在上游缺失时回退本地估算
---

View File

@@ -34,7 +34,7 @@ CMD ["/usr/local/bin/ds2api"]
FROM runtime-base AS runtime-from-source
COPY --from=go-builder /out/ds2api /usr/local/bin/ds2api
COPY --from=go-builder /app/internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm /app/sha3_wasm_bg.7b9ca65ddd.wasm
COPY --from=go-builder /app/config.example.json /app/config.example.json
COPY --from=webui-builder /app/static/admin /app/static/admin
@@ -53,13 +53,13 @@ RUN set -eux; \
test -n "${PKG_DIR}"; \
mkdir -p /out/static; \
cp "${PKG_DIR}/ds2api" /out/ds2api; \
cp "${PKG_DIR}/sha3_wasm_bg.7b9ca65ddd.wasm" /out/sha3_wasm_bg.7b9ca65ddd.wasm; \
cp "${PKG_DIR}/config.example.json" /out/config.example.json; \
cp -R "${PKG_DIR}/static/admin" /out/static/admin
FROM runtime-base AS runtime-from-dist
COPY --from=dist-extract /out/ds2api /usr/local/bin/ds2api
COPY --from=dist-extract /out/sha3_wasm_bg.7b9ca65ddd.wasm /app/sha3_wasm_bg.7b9ca65ddd.wasm
COPY --from=dist-extract /out/config.example.json /app/config.example.json
COPY --from=dist-extract /out/static/admin /app/static/admin

View File

@@ -16,6 +16,8 @@
将 DeepSeek Web 对话能力转换为 OpenAI、Claude 与 Gemini 兼容 API。后端为 **Go 全量实现**,前端为 React WebUI 管理台(源码在 `webui/`,部署时自动构建到 `static/admin`)。
文档入口:[文档导航](docs/README.md) / [架构说明](docs/ARCHITECTURE.md) / [接口文档](API.md)
> **重要免责声明**
>
> 本仓库仅供学习、研究、个人实验和内部验证使用,不提供任何形式的商业授权、适用性保证或结果保证。
@@ -24,7 +26,7 @@
>
> 请勿将本项目用于违反服务条款、协议、法律法规或平台规则的场景。商业使用前请自行确认 `LICENSE`、相关协议以及你是否获得了作者的书面许可。
## 架构概览
## 架构概览(摘要)
```mermaid
flowchart LR
@@ -48,7 +50,7 @@ flowchart LR
Auth["Auth Resolver\n(API key / bearer / x-goog-api-key)"]
Pool["Account Pool + Queue\n(并发槽位 + 等待队列)"]
DSClient["DeepSeek Client\n(Session / Auth / HTTP)"]
Pow["PoW WASM\n(wazero 预加载)"]
Pow["PoW 实现\n(纯 Go 毫秒级)"]
Tool["Tool Sieve\n(Go/Node 语义对齐)"]
end
end
@@ -72,6 +74,8 @@ flowchart LR
Bridge --> Client
```
详细架构拆分与目录职责见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
- **后端**Go`cmd/ds2api/`、`api/`、`internal/`),不依赖 Python 运行时
- **前端**React 管理台(`webui/`),运行时托管静态构建产物
- **部署**本地运行、Docker、Vercel Serverless、Linux systemd
@@ -81,7 +85,7 @@ flowchart LR
- **统一路由内核**:所有协议入口统一汇聚到 `internal/server/router.go`,并在同一路由树中注册 OpenAI / Claude / Gemini / Admin / WebUI 路由,避免多入口行为漂移。
- **统一执行链路**Claude / Gemini 入口先经 `internal/translatorcliproxy` 做协议转换,再进入 `openai.ChatCompletions` 统一处理工具调用与流式语义,最后再转换回原协议响应。
- **适配器分层更清晰**`internal/adapter/{claude,gemini}` 负责入口/出口协议封装,`internal/adapter/openai` 负责核心执行DeepSeek 侧调用只保留在 OpenAI 内核中。
- **Tool Calling 双运行时对齐**Go 侧(`internal/util`)与 Vercel Node 侧(`internal/js/helpers/stream-tool-sieve`)保持一致的解析/防泄漏语义,覆盖 JSON / XML / invoke / text-kv 多风格输入。
- **Tool Calling 双运行时对齐**Go 侧(`internal/toolcall`)与 Vercel Node 侧(`internal/js/helpers/stream-tool-sieve`)保持一致的解析/防泄漏语义,覆盖 JSON / XML / invoke / text-kv 多风格输入。
- **配置与运行时设置解耦**:静态配置(`config`)与运行时策略(`settings`)通过 Admin API 分离管理,支持热更新和密码轮换失效旧 JWT。
- **流式能力升级**`/v1/responses` 与 `/v1/chat/completions` 共享更一致的工具调用增量输出策略,降低不同 SDK 下的行为差异。
- **可观测与可运维增强**`/healthz`、`/readyz`、`/admin/version`、`/admin/dev/captures` 形成排障闭环,便于发布后验证。
@@ -95,7 +99,7 @@ flowchart LR
| Gemini 兼容 | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent`(及 `/v1/models/{model}:*` 路径) |
| 多账号轮询 | 自动 token 刷新、邮箱/手机号双登录方式 |
| 并发队列控制 | 每账号 in-flight 上限 + 等待队列,动态计算建议并发值 |
| DeepSeek PoW | WASM 计算(`wazero`),无需外部 Node.js 依赖 |
| DeepSeek PoW | 纯 Go 高性能实现DeepSeekHashV1毫秒级响应 |
| Tool Calling | 防泄漏处理:非代码块高置信特征识别、`delta.tool_calls` 早发、结构化增量输出 |
| Admin API | 配置管理、运行时设置热更新、账号测试 / 批量测试、会话清理、导入导出、Vercel 同步、版本检查 |
| WebUI 管理台 | `/admin` 单页应用(中英文双语、深色模式) |
@@ -344,7 +348,6 @@ cp opencode.json.example opencode.json
| `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` |
| `DS2API_CONFIG_JSON` | 直接注入配置JSON 或 Base64 | — |
| `DS2API_ENV_WRITEBACK` | 环境变量模式下自动写回配置文件并切换文件模式(`1/true/yes/on` | 关闭 |
| `DS2API_WASM_PATH` | PoW WASM 文件路径 | 自动查找 |
| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` |
| `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启Vercel 关闭 |
| `DS2API_DEV_PACKET_CAPTURE` | 本地开发抓包开关(记录最近会话请求/响应体) | 本地非 Vercel 默认开启 |
@@ -432,71 +435,6 @@ go run ./cmd/ds2api
{"query":"广州天气","sample_id":"gz-weather-from-memory"}
```
## 项目结构
```text
ds2api/
├── app/ # 统一 HTTP Handler 组装层(供本地与 Serverless 复用)
├── cmd/
│ ├── ds2api/ # 本地 / 容器启动入口
│ └── ds2api-tests/ # 端到端测试集入口
├── api/
│ ├── index.go # Vercel Serverless Go 入口
│ ├── chat-stream.js # Vercel Node.js 流式转发
│ └── (rewrite targets in vercel.json)
├── internal/
│ ├── account/ # 账号池与并发队列
│ ├── adapter/
│ │ ├── openai/ # OpenAI 兼容适配器(含 Tool Call 解析、Vercel 流式 prepare/release
│ │ ├── claude/ # Claude 兼容适配器
│ │ └── gemini/ # Gemini 兼容适配器generateContent / streamGenerateContent
│ ├── admin/ # Admin API handlers含 Settings 热更新)
│ ├── auth/ # 鉴权与 JWT
│ ├── claudeconv/ # Claude 消息格式转换
│ ├── compat/ # Go 版本兼容与回归测试辅助
│ ├── config/ # 配置加载、校验与热更新
│ ├── deepseek/ # DeepSeek API 客户端、PoW WASM
│ ├── js/ # Node 运行时流式处理与兼容逻辑
│ ├── devcapture/ # 开发抓包模块
│ ├── rawsample/ # 原始流样本可见文本提取与回放辅助
│ ├── format/ # 输出格式化
│ ├── prompt/ # Prompt 构建
│ ├── server/ # HTTP 路由与中间件chi router
│ ├── sse/ # SSE 解析工具
│ ├── stream/ # 统一流式消费引擎
│ ├── testsuite/ # 端到端测试框架与用例编排
│ ├── translatorcliproxy/ # CLIProxy 桥接与流写入组件
│ ├── util/ # 通用工具函数
│ ├── version/ # 版本解析 / 比较与 tag 规范化
│ └── webui/ # WebUI 静态文件托管与自动构建
├── webui/ # React WebUI 源码Vite + Tailwind
│ └── src/
│ ├── app/ # 路由、鉴权、配置状态管理
│ ├── features/ # 业务功能模块account/settings/vercel/apiTester
│ ├── components/ # 登录/落地页等通用组件
│ └── locales/ # 中英文语言包zh.json / en.json
├── scripts/
│ └── build-webui.sh # WebUI 手动构建脚本
├── tests/
│ ├── compat/ # 兼容性测试夹具与期望输出
│ ├── node/ # Node 侧单元测试chat-stream / tool-sieve
│ ├── raw_stream_samples/ # 原始 SSE 样本与回放元数据
│ └── scripts/ # 统一测试脚本入口unit/e2e
├── docs/ # 部署 / 贡献 / 测试等辅助文档
├── static/admin/ # WebUI 构建产物(不提交到 Git
├── .github/
│ ├── workflows/ # GitHub Actions质量门禁 + Release 自动构建)
│ ├── ISSUE_TEMPLATE/ # Issue 模板
│ └── PULL_REQUEST_TEMPLATE.md
├── config.example.json # 配置文件示例
├── .env.example # 环境变量示例
├── Dockerfile # 多阶段构建WebUI + Go
├── docker-compose.yml # 生产环境 Docker Compose
├── docker-compose.dev.yml # 开发环境 Docker Compose
├── vercel.json # Vercel 路由与构建配置
└── go.mod / go.sum # Go 模块依赖
```
## 文档索引
| 文档 | 说明 |
@@ -543,7 +481,7 @@ npm ci --prefix webui && npm run build --prefix webui
go test ./...
# 运行 tool calls 相关测试(调试工具调用问题)
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/
# 运行端到端测试
./tests/scripts/run-live.sh

View File

@@ -16,6 +16,8 @@ Language: [中文](README.MD) | [English](README.en.md)
DS2API converts DeepSeek Web chat capability into OpenAI-compatible, Claude-compatible, and Gemini-compatible APIs. The backend is a **pure Go implementation**, with a React WebUI admin panel (source in `webui/`, build output auto-generated to `static/admin` during deployment).
Documentation entry: [Docs Index](docs/README.md) / [Architecture](docs/ARCHITECTURE.en.md) / [API Reference](API.en.md)
> **Important Disclaimer**
>
> This repository is provided for learning, research, personal experimentation, and internal validation only. It does not grant any commercial authorization and comes with no warranty of fitness, stability, or results.
@@ -24,7 +26,7 @@ DS2API converts DeepSeek Web chat capability into OpenAI-compatible, Claude-comp
>
> Do not use this project in ways that violate service terms, agreements, laws, or platform rules. Before any commercial use, review the `LICENSE`, the relevant terms, and confirm that you have the author's written permission.
## Architecture Overview
## Architecture Overview (Summary)
```mermaid
flowchart LR
@@ -48,7 +50,7 @@ flowchart LR
Auth["Auth Resolver\n(API key / bearer / x-goog-api-key)"]
Pool["Account Pool + Queue\n(in-flight slots + wait queue)"]
DSClient["DeepSeek Client\n(session / auth / HTTP)"]
Pow["PoW WASM\n(wazero preload)"]
Pow["PoW Solver\n(Pure Go ms-level)"]
Tool["Tool Sieve\n(Go/Node semantic parity)"]
end
end
@@ -72,6 +74,8 @@ flowchart LR
Bridge --> Client
```
For the full module-by-module architecture and directory responsibilities, see [docs/ARCHITECTURE.en.md](docs/ARCHITECTURE.en.md).
- **Backend**: Go (`cmd/ds2api/`, `api/`, `internal/`), no Python runtime
- **Frontend**: React admin panel (`webui/`), served as static build at runtime
- **Deployment**: local run, Docker, Vercel serverless, Linux systemd
@@ -81,7 +85,7 @@ flowchart LR
- **Unified routing core**: all protocol entries are now centralized through `internal/server/router.go`, with OpenAI / Claude / Gemini / Admin / WebUI routes registered in one tree to avoid multi-entry drift.
- **Unified execution chain**: Claude/Gemini entries are translated by `internal/translatorcliproxy`, then executed through `openai.ChatCompletions` for shared tool-calling and stream semantics, then translated back to the client protocol.
- **Cleaner adapter boundaries**: `internal/adapter/{claude,gemini}` handles protocol wrappers, while `internal/adapter/openai` remains the execution core; upstream DeepSeek calls are retained only in the OpenAI core.
- **Tool-calling parity across runtimes**: Go (`internal/util`) and Vercel Node (`internal/js/helpers/stream-tool-sieve`) follow aligned parsing/anti-leak semantics across JSON / XML / invoke / text-kv inputs.
- **Tool-calling parity across runtimes**: Go (`internal/toolcall`) and Vercel Node (`internal/js/helpers/stream-tool-sieve`) follow aligned parsing/anti-leak semantics across JSON / XML / invoke / text-kv inputs.
- **Config/runtime separation**: static config (`config`) and runtime policy (`settings`) are managed independently via Admin APIs, enabling hot updates and password rotation with JWT invalidation.
- **Streaming behavior upgrade**: `/v1/responses` and `/v1/chat/completions` now share a more consistent incremental tool-call emission strategy across SDK ecosystems.
- **Improved operability**: `/healthz`, `/readyz`, `/admin/version`, and `/admin/dev/captures` form a tighter post-deploy diagnostics loop.
@@ -95,7 +99,7 @@ flowchart LR
| Gemini compatible | `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent` (plus `/v1/models/{model}:*` paths) |
| Multi-account rotation | Auto token refresh, email/mobile dual login |
| Concurrency control | Per-account in-flight limit + waiting queue, dynamic recommended concurrency |
| DeepSeek PoW | WASM solving via `wazero`, no external Node.js dependency |
| DeepSeek PoW | Pure Go high-performance solver (DeepSeekHashV1), ms-level response |
| Tool Calling | Anti-leak handling: non-code-block feature match, early `delta.tool_calls`, structured incremental output |
| Admin API | Config management, runtime settings hot-reload, account testing/batch test, session cleanup, import/export, Vercel sync, version check |
| WebUI Admin Panel | SPA at `/admin` (bilingual Chinese/English, dark mode) |
@@ -344,7 +348,6 @@ cp opencode.json.example opencode.json
| `DS2API_CONFIG_PATH` | Config file path | `config.json` |
| `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — |
| `DS2API_ENV_WRITEBACK` | Auto-write env-backed config to file and transition to file mode (`1/true/yes/on`) | Disabled |
| `DS2API_WASM_PATH` | PoW WASM file path | Auto-detect |
| `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` |
| `DS2API_AUTO_BUILD_WEBUI` | Auto-build WebUI on startup | Enabled locally, disabled on Vercel |
| `DS2API_ACCOUNT_MAX_INFLIGHT` | Max in-flight requests per account | `2` |
@@ -430,71 +433,6 @@ The save endpoint can target a chain by `query`, `chain_key`, or `capture_id`. E
{"query":"Guangzhou weather","sample_id":"gz-weather-from-memory"}
```
## Project Structure
```text
ds2api/
├── app/ # Unified HTTP handler assembly (shared by local + serverless)
├── cmd/
│ ├── ds2api/ # Local / container entrypoint
│ └── ds2api-tests/ # End-to-end testsuite entrypoint
├── api/
│ ├── index.go # Vercel Serverless Go entry
│ ├── chat-stream.js # Vercel Node.js stream relay
│ └── (rewrite targets in vercel.json)
├── internal/
│ ├── account/ # Account pool and concurrency queue
│ ├── adapter/
│ │ ├── openai/ # OpenAI adapter (incl. tool call parsing, Vercel stream prepare/release)
│ │ ├── claude/ # Claude adapter
│ │ └── gemini/ # Gemini adapter (generateContent / streamGenerateContent)
│ ├── admin/ # Admin API handlers (incl. Settings hot-reload)
│ ├── auth/ # Auth and JWT
│ ├── claudeconv/ # Claude message format conversion
│ ├── compat/ # Go-version compatibility and regression helpers
│ ├── config/ # Config loading, validation, and hot-reload
│ ├── deepseek/ # DeepSeek API client, PoW WASM
│ ├── js/ # Node runtime stream/compat logic
│ ├── devcapture/ # Dev packet capture module
│ ├── rawsample/ # Visible-text extraction and replay helpers for raw stream samples
│ ├── format/ # Output formatting
│ ├── prompt/ # Prompt construction
│ ├── server/ # HTTP routing and middleware (chi router)
│ ├── sse/ # SSE parsing utilities
│ ├── stream/ # Unified stream consumption engine
│ ├── testsuite/ # End-to-end testsuite framework and case orchestration
│ ├── translatorcliproxy/ # CLIProxy bridge and stream writer components
│ ├── util/ # Common utilities
│ ├── version/ # Version parsing/comparison and tag normalization
│ └── webui/ # WebUI static file serving and auto-build
├── webui/ # React WebUI source (Vite + Tailwind)
│ └── src/
│ ├── app/ # Routing, auth, config state
│ ├── features/ # Feature modules (account/settings/vercel/apiTester)
│ ├── components/ # Shared UI pieces (login/landing, etc.)
│ └── locales/ # Language packs (zh.json / en.json)
├── scripts/
│ └── build-webui.sh # Manual WebUI build script
├── tests/
│ ├── compat/ # Compatibility fixtures and expected outputs
│ ├── node/ # Node-side unit tests (chat-stream / tool-sieve)
│ ├── raw_stream_samples/ # Raw SSE samples and replay metadata
│ └── scripts/ # Unified test script entrypoints (unit/e2e)
├── docs/ # Deployment / contributing / testing docs
├── static/admin/ # WebUI build output (not committed to Git)
├── .github/
│ ├── workflows/ # GitHub Actions (quality gates + release automation)
│ ├── ISSUE_TEMPLATE/ # Issue templates
│ └── PULL_REQUEST_TEMPLATE.md
├── config.example.json # Config file template
├── .env.example # Environment variable template
├── Dockerfile # Multi-stage build (WebUI + Go)
├── docker-compose.yml # Production Docker Compose
├── docker-compose.dev.yml # Development Docker Compose
├── vercel.json # Vercel routing and build config
└── go.mod / go.sum # Go module dependencies
```
## Documentation Index
| Document | Description |

View File

@@ -1 +1 @@
3.1.1
3.2.0

View File

@@ -30,8 +30,8 @@ func main() {
opts.Timeout = time.Duration(timeoutSeconds) * time.Second
if err := testsuite.Run(context.Background(), opts); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
_, _ = fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
fmt.Fprintln(os.Stdout, "testsuite completed successfully")
_, _ = fmt.Fprintln(os.Stdout, "testsuite completed successfully")
}

136
docs/ARCHITECTURE.en.md Normal file
View File

@@ -0,0 +1,136 @@
# DS2API Architecture & Project Layout
Language: [中文](ARCHITECTURE.md) | [English](ARCHITECTURE.en.md)
> This file is the single architecture source for directory layout, module boundaries, and execution flow.
## 1. Top-level Layout (expanded)
> Notes: this is the **fully expanded** project directory list (excluding metadata/dependency dirs such as `.git/` and `webui/node_modules/`), with each folder annotated by purpose.
```text
ds2api/
├── .github/ # GitHub collaboration and CI config
│ ├── ISSUE_TEMPLATE/ # Issue templates
│ └── workflows/ # GitHub Actions workflows
├── api/ # Serverless entrypoints (Vercel Go/Node)
├── app/ # Application-level handler assembly
├── cmd/ # Executable entrypoints
│ ├── ds2api/ # Main service bootstrap
│ └── ds2api-tests/ # E2E testsuite CLI bootstrap
├── docs/ # Project documentation
├── internal/ # Core implementation (non-public packages)
│ ├── account/ # Account pool, inflight slots, waiting queue
│ ├── adapter/ # Multi-protocol adapters
│ │ ├── claude/ # Claude protocol adapter
│ │ ├── gemini/ # Gemini protocol adapter
│ │ └── openai/ # OpenAI adapter and shared execution core
│ ├── admin/ # Admin API (config/accounts/ops)
│ ├── auth/ # Auth/JWT/credential resolution
│ ├── claudeconv/ # Claude message conversion helpers
│ ├── compat/ # Compatibility and regression helpers
│ ├── config/ # Config loading/validation/hot reload
│ ├── deepseek/ # DeepSeek upstream client capabilities
│ │ └── transport/ # DeepSeek transport details
│ ├── devcapture/ # Dev capture and troubleshooting
│ ├── format/ # Response formatting layer
│ │ ├── claude/ # Claude output formatting
│ │ └── openai/ # OpenAI output formatting
│ ├── js/ # Node runtime related logic
│ │ ├── chat-stream/ # Node streaming bridge
│ │ ├── helpers/ # JS helper modules
│ │ │ └── stream-tool-sieve/ # JS implementation of tool sieve
│ │ └── shared/ # Shared semantics between Go/Node
│ ├── prompt/ # Prompt composition
│ ├── rawsample/ # Raw sample read/write and management
│ ├── server/ # Router and middleware assembly
│ ├── sse/ # SSE parsing utilities
│ ├── stream/ # Unified stream consumption engine
│ ├── testsuite/ # Testsuite execution framework
│ ├── textclean/ # Text cleanup
│ ├── toolcall/ # Tool-call parsing and repair
│ ├── translatorcliproxy/ # Cross-protocol translation bridge
│ ├── util/ # Shared utility helpers
│ ├── version/ # Version query/compare
│ └── webui/ # WebUI static hosting logic
├── plans/ # Stage plans and manual QA records
├── pow/ # PoW standalone implementation + benchmarks
├── scripts/ # Build/release helper scripts
├── tests/ # Test assets and scripts
│ ├── compat/ # Compatibility fixtures + expected outputs
│ │ ├── expected/ # Expected output samples
│ │ └── fixtures/ # Fixture inputs
│ │ ├── sse_chunks/ # SSE chunk fixtures
│ │ └── toolcalls/ # Tool-call fixtures
│ ├── node/ # Node unit tests
│ ├── raw_stream_samples/ # Upstream raw SSE samples
│ │ ├── content-filter-trigger-20260405-jwt3/ # Content-filter terminal sample
│ │ ├── continue-thinking-snapshot-replay-20260405/ # Continue-thinking sample
│ │ ├── guangzhou-weather-reasoner-search-20260404/ # Search/reference sample
│ │ ├── markdown-format-example-20260405/ # Markdown sample
│ │ └── markdown-format-example-20260405-spacefix/ # Space-fix sample
│ ├── scripts/ # Test entry scripts
│ └── tools/ # Testing helper tools
└── webui/ # React admin console source
├── public/ # Static assets
└── src/ # Frontend source code
├── app/ # Routing/state scaffolding
├── components/ # Shared UI components
├── features/ # Feature modules
│ ├── account/ # Account management page
│ ├── apiTester/ # API tester page
│ ├── settings/ # Settings page
│ └── vercel/ # Vercel sync page
├── layout/ # Layout components
├── locales/ # i18n strings
└── utils/ # Frontend utilities
```
## 2. Primary Request Flow
```mermaid
flowchart LR
C[Client/SDK] --> R[internal/server/router.go]
R --> OA[OpenAI Adapter]
R --> CA[Claude Adapter]
R --> GA[Gemini Adapter]
R --> AD[Admin API]
CA --> BR[translatorcliproxy]
GA --> BR
BR --> CORE[internal/adapter/openai ChatCompletions]
OA --> CORE
CORE --> AUTH[internal/auth + config key/account resolver]
CORE --> POOL[internal/account queue + concurrency]
CORE --> TOOL[internal/toolcall parser + sieve]
CORE --> DS[internal/deepseek client]
DS --> U[DeepSeek upstream]
```
## 3. Responsibilities in `internal/`
- `internal/server`: router tree + middlewares (health, protocol routes, Admin/WebUI).
- `internal/adapter/openai`: shared execution core (chat/responses/embeddings + tool semantics).
- `internal/adapter/{claude,gemini}`: protocol wrappers only (no duplicated upstream execution).
- `internal/translatorcliproxy`: structure translation between Claude/Gemini and OpenAI.
- `internal/deepseek`: upstream request/session/PoW/SSE handling.
- `internal/stream` + `internal/sse`: stream parsing and incremental assembly.
- `internal/toolcall`: JSON/XML/invoke/text-kv tool-call parsing + anti-leak sieve.
- `internal/admin`: config/accounts/vercel sync/version/dev-capture endpoints.
- `internal/config`: config loading/validation + runtime settings hot-reload.
- `internal/account`: managed account pool, inflight slots, waiting queue.
## 4. WebUI Runtime Relation
- `webui/` stores frontend source (Vite + React).
- Runtime serves static output from `static/admin`.
- On first local startup, if `static/admin` is missing, DS2API may auto-build it (Node.js required).
## 5. Documentation Split Strategy
- Onboarding & quick start: `README.MD` / `README.en.md`
- Architecture & layout: `docs/ARCHITECTURE*.md` (this file)
- API contracts: `API.md` / `API.en.md`
- Deployment/testing/contributing: `docs/DEPLOY*`, `docs/TESTING.md`, `docs/CONTRIBUTING*`
- Deep topics: `docs/toolcall-semantics.md`, `docs/DeepSeekSSE行为结构说明-2026-04-05.md`

136
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,136 @@
# DS2API 架构与项目结构说明
语言 / Language: [中文](ARCHITECTURE.md) | [English](ARCHITECTURE.en.md)
> 本文档用于集中维护“代码目录结构 + 模块边界 + 主链路调用关系”。
## 1. 顶层目录结构(展开)
> 说明:以下为仓库内业务相关目录的**完整展开**(排除 `.git/` 与 `webui/node_modules/` 这类依赖/元数据目录),并标注每个文件夹作用。
```text
ds2api/
├── .github/ # GitHub 协作与 CI 配置
│ ├── ISSUE_TEMPLATE/ # Issue 模板
│ └── workflows/ # GitHub Actions 工作流
├── api/ # Serverless 入口Vercel Go/Node
├── app/ # 应用级 handler 装配层
├── cmd/ # 可执行程序入口
│ ├── ds2api/ # 主服务启动入口
│ └── ds2api-tests/ # E2E 测试集 CLI 入口
├── docs/ # 项目文档目录
├── internal/ # 核心业务实现(不对外暴露)
│ ├── account/ # 账号池、并发槽位、等待队列
│ ├── adapter/ # 多协议适配层
│ │ ├── claude/ # Claude 协议适配
│ │ ├── gemini/ # Gemini 协议适配
│ │ └── openai/ # OpenAI 协议与统一执行核心
│ ├── admin/ # Admin API配置/账号/运维)
│ ├── auth/ # 鉴权/JWT/凭证解析
│ ├── claudeconv/ # Claude 消息格式转换工具
│ ├── compat/ # 兼容性辅助与回归支持
│ ├── config/ # 配置加载、校验、热更新
│ ├── deepseek/ # DeepSeek 上游客户端能力
│ │ └── transport/ # DeepSeek 传输层细节
│ ├── devcapture/ # 开发抓包与调试采集
│ ├── format/ # 响应格式化层
│ │ ├── claude/ # Claude 输出格式化
│ │ └── openai/ # OpenAI 输出格式化
│ ├── js/ # Node Runtime 相关逻辑
│ │ ├── chat-stream/ # Node 流式输出桥接
│ │ ├── helpers/ # JS 辅助函数
│ │ │ └── stream-tool-sieve/ # Tool sieve JS 实现
│ │ └── shared/ # Go/Node 共用语义片段
│ ├── prompt/ # Prompt 组装
│ ├── rawsample/ # raw sample 读写与管理
│ ├── server/ # 路由与中间件装配
│ ├── sse/ # SSE 解析工具
│ ├── stream/ # 统一流式消费引擎
│ ├── testsuite/ # 测试集执行框架
│ ├── textclean/ # 文本清洗
│ ├── toolcall/ # 工具调用解析与修复
│ ├── translatorcliproxy/ # 多协议互转桥
│ ├── util/ # 通用工具函数
│ ├── version/ # 版本查询/比较
│ └── webui/ # WebUI 静态托管相关逻辑
├── plans/ # 阶段计划与人工验收记录
├── pow/ # PoW 独立实现与基准
├── scripts/ # 构建/发布/辅助脚本
├── tests/ # 测试资源与脚本
│ ├── compat/ # 兼容性夹具与期望输出
│ │ ├── expected/ # 预期结果样本
│ │ └── fixtures/ # 测试输入夹具
│ │ ├── sse_chunks/ # SSE chunk 夹具
│ │ └── toolcalls/ # toolcall 夹具
│ ├── node/ # Node 单元测试
│ ├── raw_stream_samples/ # 上游原始 SSE 样本
│ │ ├── content-filter-trigger-20260405-jwt3/ # 风控终态样本
│ │ ├── continue-thinking-snapshot-replay-20260405/ # continue 样本
│ │ ├── guangzhou-weather-reasoner-search-20260404/ # 搜索+引用样本
│ │ ├── markdown-format-example-20260405/ # Markdown 样本
│ │ └── markdown-format-example-20260405-spacefix/ # 空格修复样本
│ ├── scripts/ # 测试脚本入口
│ └── tools/ # 测试辅助工具
└── webui/ # React 管理台源码
├── public/ # 静态资源
└── src/ # 前端源码
├── app/ # 路由/状态框架
├── components/ # 共享组件
├── features/ # 功能模块
│ ├── account/ # 账号管理页面
│ ├── apiTester/ # API 测试页面
│ ├── settings/ # 设置页面
│ └── vercel/ # Vercel 同步页面
├── layout/ # 布局组件
├── locales/ # 国际化文案
└── utils/ # 前端工具函数
```
## 2. 请求主链路
```mermaid
flowchart LR
C[Client/SDK] --> R[internal/server/router.go]
R --> OA[OpenAI Adapter]
R --> CA[Claude Adapter]
R --> GA[Gemini Adapter]
R --> AD[Admin API]
CA --> BR[translatorcliproxy]
GA --> BR
BR --> CORE[internal/adapter/openai ChatCompletions]
OA --> CORE
CORE --> AUTH[internal/auth + config key/account resolver]
CORE --> POOL[internal/account queue + concurrency]
CORE --> TOOL[internal/toolcall parser + sieve]
CORE --> DS[internal/deepseek client]
DS --> U[DeepSeek upstream]
```
## 3. internal/ 子模块职责
- `internal/server`路由树和中间件挂载健康检查、协议入口、Admin/WebUI
- `internal/adapter/openai`统一执行内核chat/responses/embeddings 与 tool calling 语义)。
- `internal/adapter/{claude,gemini}`:协议输入输出适配,不重复实现上游调用逻辑。
- `internal/translatorcliproxy`Claude/Gemini 与 OpenAI 结构互转。
- `internal/deepseek`上游请求、会话、PoW、SSE 消费。
- `internal/stream` + `internal/sse`:流式解析与增量处理。
- `internal/toolcall`JSON/XML/invoke/text-kv 工具调用解析及防泄漏筛分。
- `internal/admin`配置管理、账号管理、Vercel 同步、版本检查、开发抓包。
- `internal/config`:配置加载、校验、运行时 settings 热更新。
- `internal/account`:托管账号池、并发槽位、等待队列。
## 4. WebUI 与运行时关系
- `webui/` 是前端源码Vite + React
- 运行时托管目录是 `static/admin`(构建产物)。
- 本地首次启动若 `static/admin` 缺失,会尝试自动构建(依赖 Node.js
## 5. 文档拆分策略
- 总览与快速开始:`README.MD` / `README.en.md`
- 架构与目录:`docs/ARCHITECTURE*.md`(本文件)
- 接口协议:`API.md` / `API.en.md`
- 部署、测试、贡献:`docs/DEPLOY*``docs/TESTING.md``docs/CONTRIBUTING*`
- 专题:`docs/toolcall-semantics.md``docs/DeepSeekSSE行为结构说明-2026-04-05.md`

View File

@@ -59,7 +59,7 @@ docker-compose -f docker-compose.dev.yml up
| Language | Standards |
| --- | --- |
| **Go** | Run `gofmt` and ensure `go test ./...` passes before committing |
| **Go** | Run `./scripts/lint.sh` (gofmt + golangci-lint) and ensure `go test ./...` passes before committing |
| **JavaScript/React** | Follow existing project style (functional components) |
| **Commit messages** | Use semantic prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `style:`, `perf:`, `chore:` |
@@ -94,58 +94,12 @@ Manually build WebUI to `static/admin/`:
## Project Structure
```text
ds2api/
├── app/ # Shared HTTP handler assembly (local + serverless)
├── cmd/
│ ├── ds2api/ # Local/container entrypoint
│ └── ds2api-tests/ # End-to-end testsuite entrypoint
├── api/
│ ├── index.go # Vercel Serverless Go entry
│ ├── chat-stream.js # Vercel Node.js stream relay
│ └── (rewrite targets in vercel.json)
├── internal/
│ ├── account/ # Account pool and concurrency queue
│ ├── adapter/
│ │ ├── openai/ # OpenAI adapter
│ │ ├── claude/ # Claude adapter
│ │ └── gemini/ # Gemini adapter
│ ├── admin/ # Admin API handlers
│ ├── auth/ # Auth and JWT
│ ├── claudeconv/ # Claude message conversion
│ ├── compat/ # Go-version compatibility and regression helpers
│ ├── config/ # Config loading, validation, and hot-reload
│ ├── deepseek/ # DeepSeek client, PoW WASM
│ ├── js/ # Node runtime stream/compat logic
│ ├── devcapture/ # Dev packet capture
│ ├── format/ # Output formatting
│ ├── prompt/ # Prompt building
│ ├── server/ # HTTP routing (chi router)
│ ├── sse/ # SSE parsing utilities
│ ├── stream/ # Unified stream consumption engine
│ ├── testsuite/ # Testsuite framework and scenario orchestration
│ ├── translatorcliproxy/ # CLIProxy bridge and stream writer
│ ├── util/ # Common utilities
│ ├── version/ # Version parsing and comparison
│ └── webui/ # WebUI static hosting
├── webui/ # React WebUI source
│ └── src/
│ ├── app/ # Routing, auth, config state
│ ├── features/ # Feature modules
│ ├── components/ # Shared components
│ └── locales/ # Language packs
├── scripts/ # Build and test scripts
├── tests/
│ ├── compat/ # Compatibility fixtures and expected outputs
│ ├── node/ # Node-side unit tests
│ └── scripts/ # Test script entrypoints (unit/e2e)
├── plans/ # Plans, gates, and manual smoke-test records
├── static/admin/ # WebUI build output (not committed)
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Production
├── docker-compose.dev.yml # Development
└── vercel.json # Vercel config
```
To avoid documentation drift, directory layout and module responsibilities were moved to:
- [docs/ARCHITECTURE.en.md](./ARCHITECTURE.en.md)
- [docs/README.md](./README.md)
Before contributing, review the architecture doc sections for request flow and `internal/` module boundaries.
## Reporting Issues

View File

@@ -59,7 +59,7 @@ docker-compose -f docker-compose.dev.yml up
| 语言 | 规范 |
| --- | --- |
| **Go** | 提交前运行 `gofmt`确保 `go test ./...` 通过 |
| **Go** | 提交前运行 `./scripts/lint.sh`(包含 gofmt+golangci-lint确保 `go test ./...` 通过 |
| **JavaScript/React** | 保持现有代码风格(函数组件) |
| **提交信息** | 使用语义化前缀:`feat:``fix:``docs:``refactor:``style:``perf:``chore:` |
@@ -94,58 +94,12 @@ docker-compose -f docker-compose.dev.yml up
## 项目结构
```text
ds2api/
├── app/ # 统一 HTTP Handler 装配(本地 + Serverless
├── cmd/
│ ├── ds2api/ # 本地/容器启动入口
│ └── ds2api-tests/ # 端到端测试集入口
├── api/
│ ├── index.go # Vercel Serverless Go 入口
│ ├── chat-stream.js # Vercel Node.js 流式转发
│ └── (rewrite targets in vercel.json)
├── internal/
│ ├── account/ # 账号池与并发队列
│ ├── adapter/
│ │ ├── openai/ # OpenAI 兼容适配器
│ │ ├── claude/ # Claude 兼容适配器
│ │ └── gemini/ # Gemini 兼容适配器
│ ├── admin/ # Admin API handlers
│ ├── auth/ # 鉴权与 JWT
│ ├── claudeconv/ # Claude 消息格式转换
│ ├── compat/ # Go 版本兼容与回归测试辅助
│ ├── config/ # 配置加载、校验与热更新
│ ├── deepseek/ # DeepSeek 客户端、PoW WASM
│ ├── js/ # Node 运行时流式/兼容逻辑
│ ├── devcapture/ # 开发抓包
│ ├── format/ # 输出格式化
│ ├── prompt/ # Prompt 构建
│ ├── server/ # HTTP 路由chi router
│ ├── sse/ # SSE 解析工具
│ ├── stream/ # 统一流式消费引擎
│ ├── testsuite/ # 测试集框架与场景编排
│ ├── translatorcliproxy/ # CLIProxy 桥接与流式写入
│ ├── util/ # 通用工具
│ ├── version/ # 版本解析与比较
│ └── webui/ # WebUI 静态托管
├── webui/ # React WebUI 源码
│ └── src/
│ ├── app/ # 路由、鉴权、配置状态
│ ├── features/ # 业务功能模块
│ ├── components/ # 通用组件
│ └── locales/ # 语言包
├── scripts/ # 构建与测试脚本
├── tests/
│ ├── compat/ # 兼容夹具与期望输出
│ ├── node/ # Node 侧单元测试
│ └── scripts/ # 测试脚本入口unit/e2e
├── plans/ # 计划、门禁和手工烟测记录
├── static/admin/ # WebUI 构建产物(不提交)
├── Dockerfile # 多阶段构建
├── docker-compose.yml # 生产环境
├── docker-compose.dev.yml # 开发环境
└── vercel.json # Vercel 配置
```
为避免与其他文档重复维护,目录结构与模块职责已迁移到:
- [docs/ARCHITECTURE.md](./ARCHITECTURE.md)
- [docs/README.md](./README.md)
贡献前建议先阅读架构文档中的“请求主链路”和 `internal/` 模块职责,再定位改动范围。
## 问题反馈

View File

@@ -4,6 +4,8 @@ Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md)
This guide covers all deployment methods for the current Go-based codebase.
Doc map: [Index](./README.md) | [Architecture](./ARCHITECTURE.en.md) | [API](../API.en.md) | [Testing](./TESTING.md)
---
## Table of Contents
@@ -366,7 +368,6 @@ Each archive includes:
- `ds2api` executable (`ds2api.exe` on Windows)
- `static/admin/` (built WebUI assets)
- `sha3_wasm_bg.7b9ca65ddd.wasm` (optional; binary has embedded fallback)
- `config.example.json`, `.env.example`
- `README.MD`, `README.en.md`, `LICENSE`
@@ -456,8 +457,6 @@ server {
# Copy compiled binary and related files to target directory
sudo mkdir -p /opt/ds2api
sudo cp ds2api config.json /opt/ds2api/
# Optional: if you want to use an external WASM file (override the embedded one, from a release package or build output)
# sudo cp /path/to/sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
sudo cp -r static/admin /opt/ds2api/static/admin
```

View File

@@ -4,6 +4,8 @@
本指南基于当前 Go 代码库,详细说明各种部署方式。
本页导航:[文档总索引](./README.md)[架构说明](./ARCHITECTURE.md)[接口文档](../API.md)[测试指南](./TESTING.md)
---
## 目录
@@ -366,7 +368,6 @@ No Output Directory named "public" found after the Build completed.
- `ds2api` 可执行文件Windows 为 `ds2api.exe`
- `static/admin/`WebUI 构建产物)
- `sha3_wasm_bg.7b9ca65ddd.wasm`(可选;程序内置 embed fallback
- `config.example.json``.env.example`
- `README.MD``README.en.md``LICENSE`
@@ -456,8 +457,6 @@ server {
# 将编译好的二进制文件和相关文件复制到目标目录
sudo mkdir -p /opt/ds2api
sudo cp ds2api config.json /opt/ds2api/
# 可选:若你希望使用外置 WASM 文件(覆盖内置版本,来自 release 包或构建产物)
# sudo cp /path/to/sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
sudo cp -r static/admin /opt/ds2api/static/admin
```

View File

@@ -4,6 +4,8 @@
> 当前 corpus 由 4 份原始流组成,覆盖搜索+引用、风控终态、Markdown 输出和空格敏感输出等行为。
> 补充:文末还会注明少量“当前实现已确认、但 corpus 尚未完整覆盖”的行为,例如长思考场景下的自动续写状态。
文档导航:[文档总索引](./README.md) / [测试指南](./TESTING.md) / [样本目录说明](../tests/raw_stream_samples/README.md)
## 1. 样本覆盖
下列样本共同构成了本文的观察基础:

53
docs/README.md Normal file
View File

@@ -0,0 +1,53 @@
# DS2API 文档导航 | Documentation Index
语言 / Language: [中文](README.md) | [English](README.md#english)
## 中文
为减少重复维护,本仓库文档按“入口文档 + 专题文档”拆分。建议从下列顺序阅读:
1. [项目总览README](../README.MD)
2. [架构与目录说明](./ARCHITECTURE.md)
3. [接口文档API](../API.md)
4. [部署指南](./DEPLOY.md)
5. [测试指南](./TESTING.md)
6. [贡献指南](./CONTRIBUTING.md)
### 专题文档
- [Tool Calling 统一语义](./toolcall-semantics.md)
- [DeepSeek SSE 行为结构说明(逆向观察)](./DeepSeekSSE行为结构说明-2026-04-05.md)
### 文档维护约定
- `README.MD` / `README.en.md`:面向首次接触用户,保留“是什么 + 怎么快速跑起来”。
- `docs/ARCHITECTURE*.md`:面向开发者,集中维护项目结构、模块职责与调用链。
- `API*.md`:面向客户端接入者,聚焦接口行为、鉴权和示例。
- 其他 `docs/*.md`:主题化说明,避免在多个文档重复粘贴同一段内容。
---
## English
To reduce maintenance drift, docs are split into an “entry doc + topical docs” layout.
Recommended reading order:
1. [Project overview (README)](../README.en.md)
2. [Architecture and project layout](./ARCHITECTURE.en.md)
3. [API reference](../API.en.md)
4. [Deployment guide](./DEPLOY.en.md)
5. [Testing guide](./TESTING.md)
6. [Contributing guide](./CONTRIBUTING.en.md)
### Topical docs
- [Tool-calling unified semantics](./toolcall-semantics.md)
- [DeepSeek SSE behavior notes (reverse-engineered)](./DeepSeekSSE行为结构说明-2026-04-05.md)
### Maintenance conventions
- `README.MD` / `README.en.md`: onboarding-oriented (“what + quick start”).
- `docs/ARCHITECTURE*.md`: developer-oriented source of truth for module boundaries and execution flow.
- `API*.md`: integration-oriented behavior/contracts.
- Other `docs/*.md`: focused topics, avoid copy-pasting the same section into multiple files.

View File

@@ -2,6 +2,8 @@
语言 / Language: 中文 + English同页
文档导航: [总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [部署指南](./DEPLOY.md) / [接口文档](../API.md)
## 概述 | Overview
DS2API 提供两个层级的测试:
@@ -180,10 +182,10 @@ go test ./...
```bash
# 运行 tool calls 相关测试(推荐用于调试 tool call 解析问题)
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/
# 运行单个测试用例
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/toolcall/
# 运行 format 相关测试
go test -v ./internal/format/...
@@ -198,13 +200,13 @@ go test -v ./internal/adapter/openai/...
```bash
# 1. 运行 tool calls 相关的所有测试
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/
# 2. 查看测试输出中的详细调试信息
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/ 2>&1
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/toolcall/ 2>&1
# 3. 检查具体测试用例的修复效果
# 测试用例位于 internal/util/toolcalls_test.go包含
# 测试用例位于 internal/toolcall/toolcalls_test.go包含
# - TestParseToolCallsWithDeepSeekHallucination: DeepSeek 典型幻觉输出
# - TestRepairLooseJSONWithNestedObjects: 嵌套对象的方括号修复
# - TestParseToolCallsWithMixedWindowsPaths: Windows 路径处理
@@ -235,6 +237,7 @@ go run ./cmd/ds2api-tests --no-preflight
说明:
- 该工具默认重放 `tests/raw_stream_samples/manifest.json` 声明的 canonical 样本,按上游 SSE 顺序做 1:1 仿真解析。
- 默认校验不出现 `FINISHED` 文本泄露,并要求存在结束信号。
- 默认**不**把 `raw accumulated_token_usage` 与本地解析 token 做强一致校验(当前实现以内容估算为准);如需强校验可显式加 `--fail-on-token-mismatch`
- 每次运行都会把本地派生结果写入 `artifacts/raw-stream-sim/<run-id>/<sample-id>/replay.output.txt`,并输出结构化报告。
- 如果你有历史基线目录,可以通过 `--baseline-root` 让工具直接做文本对比。
- 更完整的协议级行为结构说明见 [DeepSeekSSE行为结构说明-2026-04-05.md](./DeepSeekSSE行为结构说明-2026-04-05.md)。

View File

@@ -2,6 +2,8 @@
本文档描述当前代码中 `ParseToolCallsDetailed` / `parseToolCallsDetailed` 的**实际行为**,用于对齐 Go 与 Node Runtime。
文档导航:[总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [测试指南](./TESTING.md)
## 1) 输出结构(当前实现)
- `calls`:解析得到的工具调用列表(`name` + `input`)。

1
go.mod
View File

@@ -8,7 +8,6 @@ require (
github.com/google/uuid v1.6.0
github.com/refraction-networking/utls v1.8.2
github.com/router-for-me/CLIProxyAPI/v6 v6.9.14
github.com/tetratelabs/wazero v1.11.0
)
require (

2
go.sum
View File

@@ -18,8 +18,6 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=

View File

@@ -64,7 +64,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C
rec := httptest.NewRecorder()
h.OpenAI.ChatCompletions(rec, proxyReq)
res := rec.Result()
defer res.Body.Close()
defer func() { _ = res.Body.Close() }()
body, _ := io.ReadAll(res.Body)
for k, vv := range res.Header {
for _, v := range vv {
@@ -94,7 +94,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C
rec := httptest.NewRecorder()
h.OpenAI.ChatCompletions(rec, proxyReq)
res := rec.Result()
defer res.Body.Close()
defer func() { _ = res.Body.Close() }()
body, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode >= 300 {
for k, vv := range res.Header {
@@ -124,7 +124,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C
}
func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, messages []any, thinkingEnabled, searchEnabled bool, toolNames []string) {
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeClaudeError(w, http.StatusInternalServerError, string(body))

View File

@@ -1,12 +1,12 @@
package claude
import (
"ds2api/internal/toolcall"
"encoding/json"
"fmt"
"strings"
"ds2api/internal/prompt"
"ds2api/internal/util"
)
func normalizeClaudeMessages(messages []any) []any {
@@ -98,9 +98,10 @@ func buildClaudeToolPrompt(tools []any) string {
}
return "You have access to these tools:\n\n" +
strings.Join(toolSchemas, "\n\n") + "\n\n" +
util.BuildToolCallInstructions(names)
toolcall.BuildToolCallInstructions(names)
}
//nolint:unused // retained for compatibility with pending Claude tool-result prompt flow.
func formatClaudeToolResultForPrompt(block map[string]any) string {
if block == nil {
return ""

View File

@@ -96,6 +96,7 @@ func looksLikeBase64Payload(v string) bool {
return true
}
//nolint:unused // helper kept for compatibility with upcoming sanitize pipeline.
func marshalCompactJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {

View File

@@ -18,7 +18,7 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
model, _ := req["model"].(string)
messagesRaw, _ := req["messages"].([]any)
if strings.TrimSpace(model) == "" || len(messagesRaw) == 0 {
return claudeNormalizedRequest{}, fmt.Errorf("Request must include 'model' and 'messages'.")
return claudeNormalizedRequest{}, fmt.Errorf("request must include 'model' and 'messages'")
}
if _, ok := req["max_tokens"]; !ok {
req["max_tokens"] = 8192

View File

@@ -24,10 +24,9 @@ type claudeStreamRuntime struct {
bufferToolContent bool
stripReferenceMarkers bool
messageID string
thinking strings.Builder
text strings.Builder
outputTokens int
messageID string
thinking strings.Builder
text strings.Builder
nextBlockIndex int
thinkingBlockOpen bool
@@ -70,9 +69,6 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
if !parsed.Parsed {
return streamengine.ParsedDecision{}
}
if parsed.OutputTokens > 0 {
s.outputTokens = parsed.OutputTokens
}
if parsed.ErrorMessage != "" {
s.upstreamErr = parsed.ErrorMessage
return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReason("upstream_error")}

View File

@@ -1,6 +1,7 @@
package claude
import (
"ds2api/internal/toolcall"
"encoding/json"
"fmt"
"time"
@@ -46,9 +47,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
if s.bufferToolContent {
detected := util.ParseStandaloneToolCalls(finalText, s.toolNames)
detected := toolcall.ParseStandaloneToolCalls(finalText, s.toolNames)
if len(detected) == 0 && finalText == "" && finalThinking != "" {
detected = util.ParseStandaloneToolCalls(finalThinking, s.toolNames)
detected = toolcall.ParseStandaloneToolCalls(finalThinking, s.toolNames)
}
if len(detected) > 0 {
stopReason = "tool_use"
@@ -108,9 +109,6 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
}
outputTokens := util.EstimateTokens(finalThinking) + util.EstimateTokens(finalText)
if s.outputTokens > 0 {
outputTokens = s.outputTokens
}
s.send("message_delta", map[string]any{
"type": "message_delta",
"delta": map[string]any{

View File

@@ -5,6 +5,7 @@ import (
"strings"
)
//nolint:unused // compatibility hook for native Gemini request normalization path.
func collectGeminiPassThrough(req map[string]any) map[string]any {
cfg, _ := req["generationConfig"].(map[string]any)
if len(cfg) == 0 {

View File

@@ -9,6 +9,7 @@ import (
"ds2api/internal/util"
)
//nolint:unused // kept for native Gemini adapter route compatibility.
func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[string]any, stream bool) (util.StandardRequest, error) {
requestedModel := strings.TrimSpace(routeModel)
if requestedModel == "" {
@@ -17,13 +18,13 @@ func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[strin
resolvedModel, ok := config.ResolveModel(store, requestedModel)
if !ok {
return util.StandardRequest{}, fmt.Errorf("Model '%s' is not available.", requestedModel)
return util.StandardRequest{}, fmt.Errorf("model %q is not available", requestedModel)
}
thinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel)
messagesRaw := geminiMessagesFromRequest(req)
if len(messagesRaw) == 0 {
return util.StandardRequest{}, fmt.Errorf("Request must include non-empty contents.")
return util.StandardRequest{}, fmt.Errorf("request must include non-empty contents")
}
toolsRaw := convertGeminiTools(req["tools"])

View File

@@ -2,6 +2,7 @@ package gemini
import "strings"
//nolint:unused // kept for native Gemini adapter route compatibility.
func convertGeminiTools(raw any) []any {
tools, _ := raw.([]any)
if len(tools) == 0 {

View File

@@ -2,6 +2,7 @@ package gemini
import (
"bytes"
"ds2api/internal/toolcall"
"encoding/json"
"io"
"net/http"
@@ -57,7 +58,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream
rec := httptest.NewRecorder()
h.OpenAI.ChatCompletions(rec, proxyReq)
res := rec.Result()
defer res.Body.Close()
defer func() { _ = res.Body.Close() }()
body, _ := io.ReadAll(res.Body)
for k, vv := range res.Header {
for _, v := range vv {
@@ -87,7 +88,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream
rec := httptest.NewRecorder()
h.OpenAI.ChatCompletions(rec, proxyReq)
res := rec.Result()
defer res.Body.Close()
defer func() { _ = res.Body.Close() }()
body, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode >= 300 {
for k, vv := range res.Header {
@@ -131,8 +132,9 @@ func writeGeminiErrorFromOpenAI(w http.ResponseWriter, status int, raw []byte) {
writeGeminiError(w, status, message)
}
//nolint:unused // retained for native Gemini non-stream handling path.
func (h *Handler) handleNonStreamGenerateContent(w http.ResponseWriter, resp *http.Response, model, finalPrompt string, thinkingEnabled bool, toolNames []string) {
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeGeminiError(w, resp.StatusCode, strings.TrimSpace(string(body)))
@@ -147,13 +149,13 @@ func (h *Handler) handleNonStreamGenerateContent(w http.ResponseWriter, resp *ht
cleanVisibleOutput(result.Thinking, stripReferenceMarkers),
cleanVisibleOutput(result.Text, stripReferenceMarkers),
toolNames,
result.OutputTokens,
))
}
func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, finalText string, toolNames []string, outputTokens int) map[string]any {
//nolint:unused // retained for native Gemini non-stream handling path.
func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
parts := buildGeminiPartsFromFinal(finalText, finalThinking, toolNames)
usage := buildGeminiUsage(finalPrompt, finalThinking, finalText, outputTokens)
usage := buildGeminiUsage(finalPrompt, finalThinking, finalText)
return map[string]any{
"candidates": []map[string]any{
{
@@ -170,14 +172,11 @@ func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, final
}
}
func buildGeminiUsage(finalPrompt, finalThinking, finalText string, outputTokens int) map[string]any {
//nolint:unused // retained for native Gemini non-stream handling path.
func buildGeminiUsage(finalPrompt, finalThinking, finalText string) map[string]any {
promptTokens := util.EstimateTokens(finalPrompt)
reasoningTokens := util.EstimateTokens(finalThinking)
completionTokens := util.EstimateTokens(finalText)
if outputTokens > 0 {
completionTokens = outputTokens
reasoningTokens = 0
}
return map[string]any{
"promptTokenCount": promptTokens,
"candidatesTokenCount": reasoningTokens + completionTokens,
@@ -185,10 +184,11 @@ func buildGeminiUsage(finalPrompt, finalThinking, finalText string, outputTokens
}
}
//nolint:unused // retained for native Gemini non-stream handling path.
func buildGeminiPartsFromFinal(finalText, finalThinking string, toolNames []string) []map[string]any {
detected := util.ParseToolCalls(finalText, toolNames)
detected := toolcall.ParseToolCalls(finalText, toolNames)
if len(detected) == 0 && finalThinking != "" {
detected = util.ParseToolCalls(finalThinking, toolNames)
detected = toolcall.ParseToolCalls(finalThinking, toolNames)
}
if len(detected) > 0 {
parts := make([]map[string]any, 0, len(detected))

View File

@@ -17,6 +17,7 @@ type Handler struct {
OpenAI OpenAIChatRunner
}
//nolint:unused // used by native Gemini stream/non-stream runtime helpers.
func (h *Handler) compatStripReferenceMarkers() bool {
if h == nil || h.Store == nil {
return true

View File

@@ -12,8 +12,9 @@ import (
streamengine "ds2api/internal/stream"
)
//nolint:unused // retained for native Gemini stream handling path.
func (h *Handler) handleStreamGenerateContent(w http.ResponseWriter, r *http.Request, resp *http.Response, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) {
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeGeminiError(w, resp.StatusCode, strings.TrimSpace(string(body)))
@@ -49,6 +50,7 @@ func (h *Handler) handleStreamGenerateContent(w http.ResponseWriter, r *http.Req
})
}
//nolint:unused // retained for native Gemini stream handling path.
type geminiStreamRuntime struct {
w http.ResponseWriter
rc *http.ResponseController
@@ -63,11 +65,11 @@ type geminiStreamRuntime struct {
stripReferenceMarkers bool
toolNames []string
thinking strings.Builder
text strings.Builder
outputTokens int
thinking strings.Builder
text strings.Builder
}
//nolint:unused // retained for native Gemini stream handling path.
func newGeminiStreamRuntime(
w http.ResponseWriter,
rc *http.ResponseController,
@@ -93,6 +95,7 @@ func newGeminiStreamRuntime(
}
}
//nolint:unused // retained for native Gemini stream handling path.
func (s *geminiStreamRuntime) sendChunk(payload map[string]any) {
b, _ := json.Marshal(payload)
_, _ = s.w.Write([]byte("data: "))
@@ -103,13 +106,11 @@ func (s *geminiStreamRuntime) sendChunk(payload map[string]any) {
}
}
//nolint:unused // retained for native Gemini stream handling path.
func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedDecision {
if !parsed.Parsed {
return streamengine.ParsedDecision{}
}
if parsed.OutputTokens > 0 {
s.outputTokens = parsed.OutputTokens
}
if parsed.ContentFilter || parsed.ErrorMessage != "" || parsed.Stop {
return streamengine.ParsedDecision{Stop: true}
}
@@ -158,6 +159,7 @@ func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
return streamengine.ParsedDecision{ContentSeen: contentSeen}
}
//nolint:unused // retained for native Gemini stream handling path.
func (s *geminiStreamRuntime) finalize() {
finalThinking := s.thinking.String()
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
@@ -192,6 +194,6 @@ func (s *geminiStreamRuntime) finalize() {
},
},
"modelVersion": s.model,
"usageMetadata": buildGeminiUsage(s.finalPrompt, finalThinking, finalText, s.outputTokens),
"usageMetadata": buildGeminiUsage(s.finalPrompt, finalThinking, finalText),
})
}

View File

@@ -42,19 +42,23 @@ func (m testGeminiAuth) Determine(_ *http.Request) (*auth.RequestAuth, error) {
func (testGeminiAuth) Release(_ *auth.RequestAuth) {}
//nolint:unused // reserved test double for native Gemini DS-call path coverage.
type testGeminiDS struct {
resp *http.Response
err error
}
//nolint:unused // reserved test double for native Gemini DS-call path coverage.
func (m testGeminiDS) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "session-id", nil
}
//nolint:unused // reserved test double for native Gemini DS-call path coverage.
func (m testGeminiDS) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "pow", nil
}
//nolint:unused // reserved test double for native Gemini DS-call path coverage.
func (m testGeminiDS) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
if m.err != nil {
return nil, m.err
@@ -100,6 +104,7 @@ func (s geminiOpenAISuccessStub) ChatCompletions(w http.ResponseWriter, _ *http.
_, _ = w.Write([]byte(out))
}
//nolint:unused // helper retained for native Gemini stream fixture tests.
func makeGeminiUpstreamResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") {

View File

@@ -2,6 +2,7 @@ package gemini
import textclean "ds2api/internal/textclean"
//nolint:unused // retained for native Gemini output post-processing path.
func cleanVisibleOutput(text string, stripReferenceMarkers bool) string {
if text == "" {
return text

View File

@@ -1,6 +1,7 @@
package openai
import (
"ds2api/internal/toolcall"
"encoding/json"
"net/http"
"strings"
@@ -8,7 +9,6 @@ import (
openaifmt "ds2api/internal/format/openai"
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
"ds2api/internal/util"
)
type chatStreamRuntime struct {
@@ -37,7 +37,6 @@ type chatStreamRuntime struct {
streamToolNames map[int]string
thinking strings.Builder
text strings.Builder
outputTokens int
}
func newChatStreamRuntime(
@@ -102,7 +101,7 @@ func (s *chatStreamRuntime) sendDone() {
func (s *chatStreamRuntime) finalize(finishReason string) {
finalThinking := s.thinking.String()
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls"
delta := map[string]any{
@@ -170,12 +169,6 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
finishReason = "tool_calls"
}
usage := openaifmt.BuildChatUsage(s.finalPrompt, finalThinking, finalText)
if s.outputTokens > 0 {
usage["completion_tokens"] = s.outputTokens
if prompt, ok := usage["prompt_tokens"].(int); ok {
usage["total_tokens"] = prompt + s.outputTokens
}
}
s.sendChunk(openaifmt.BuildChatStreamChunk(
s.completionID,
s.created,
@@ -190,9 +183,6 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
if !parsed.Parsed {
return streamengine.ParsedDecision{}
}
if parsed.OutputTokens > 0 {
s.outputTokens = parsed.OutputTokens
}
if parsed.ContentFilter {
return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReasonHandlerRequested}
}
@@ -243,7 +233,7 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
if !s.emitEarlyToolDeltas {
continue
}
filtered := filterIncrementalToolCallDeltasByAllowed(evt.ToolCallDeltas, s.toolNames, s.streamToolNames)
filtered := filterIncrementalToolCallDeltasByAllowed(evt.ToolCallDeltas, s.streamToolNames)
if len(filtered) == 0 {
continue
}

View File

@@ -116,7 +116,7 @@ func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAu
func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled bool, toolNames []string) {
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
writeOpenAIError(w, resp.StatusCode, string(body))
return
@@ -131,19 +131,11 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re
return
}
respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames)
if result.OutputTokens > 0 {
if usage, ok := respBody["usage"].(map[string]any); ok {
usage["completion_tokens"] = result.OutputTokens
if prompt, ok := usage["prompt_tokens"].(int); ok {
usage["total_tokens"] = prompt + result.OutputTokens
}
}
}
writeJSON(w, http.StatusOK, respBody)
}
func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) {
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeOpenAIError(w, resp.StatusCode, string(body))

View File

@@ -107,7 +107,7 @@ type autoDeleteCtxDSStub struct {
}
func (m *autoDeleteCtxDSStub) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*deepseek.DeleteSessionResult, error) {
return m.autoDeleteModeDSStub.DeleteSessionForTokenCtx(ctx, token, sessionID)
return m.DeleteSessionForTokenCtx(ctx, token, sessionID)
}
func (m *autoDeleteCtxDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {

View File

@@ -1,6 +1,7 @@
package openai
import (
"ds2api/internal/toolcall"
"encoding/json"
"fmt"
"strings"
@@ -75,7 +76,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh
// buildToolCallInstructions delegates to the shared util implementation.
func buildToolCallInstructions(toolNames []string) string {
return util.BuildToolCallInstructions(toolNames)
return toolcall.BuildToolCallInstructions(toolNames)
}
func formatIncrementalStreamToolCallDeltas(deltas []toolCallDelta, ids map[int]string) []map[string]any {
@@ -112,7 +113,7 @@ func formatIncrementalStreamToolCallDeltas(deltas []toolCallDelta, ids map[int]s
return out
}
func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, allowedNames []string, seenNames map[int]string) []toolCallDelta {
func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, seenNames map[int]string) []toolCallDelta {
if len(deltas) == 0 {
return nil
}
@@ -138,7 +139,7 @@ func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, allowedNam
return out
}
func formatFinalStreamToolCallsWithStableIDs(calls []util.ParsedToolCall, ids map[int]string) []map[string]any {
func formatFinalStreamToolCallsWithStableIDs(calls []toolcall.ParsedToolCall, ids map[int]string) []map[string]any {
if len(calls) == 0 {
return nil
}

View File

@@ -275,7 +275,7 @@ func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
TestHandleNonStreamFencedToolCallExampleDoesNotPromoteToolCall(t)
}
func TestHandleNonStreamReturns502WhenUpstreamOutputEmpty(t *testing.T) {
func TestHandleNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":""}`,
@@ -284,8 +284,8 @@ func TestHandleNonStreamReturns502WhenUpstreamOutputEmpty(t *testing.T) {
rec := httptest.NewRecorder()
h.handleNonStream(rec, context.Background(), resp, "cid-empty", "deepseek-chat", "prompt", false, nil)
if rec.Code != http.StatusBadGateway {
t.Fatalf("expected status 502 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected status 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
errObj, _ := out["error"].(map[string]any)

View File

@@ -74,16 +74,13 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
}
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "")
if !strings.Contains(finalPrompt, "After receiving a tool result, use it directly.") {
t.Fatalf("vercel prepare finalPrompt missing final-answer instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "Only call another tool if the result is insufficient.") {
t.Fatalf("vercel prepare finalPrompt missing retry guard instruction: %q", finalPrompt)
if !strings.Contains(finalPrompt, "Remember: Output ONLY the <tool_calls>...</tool_calls> XML block when calling tools.") {
t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") {
t.Fatalf("vercel prepare finalPrompt missing xml format instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "Do NOT wrap the XML in markdown code fences") {
if !strings.Contains(finalPrompt, "Do NOT wrap XML in markdown fences") {
t.Fatalf("vercel prepare finalPrompt missing no-fence xml instruction: %q", finalPrompt)
}
if strings.Contains(finalPrompt, "```json") {

View File

@@ -1,6 +1,7 @@
package openai
import (
"ds2api/internal/toolcall"
"encoding/json"
"io"
"net/http"
@@ -106,7 +107,7 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled bool, toolNames []string, toolChoice util.ToolChoicePolicy, traceID string) {
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeOpenAIError(w, resp.StatusCode, strings.TrimSpace(string(body)))
@@ -119,7 +120,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
if writeUpstreamEmptyOutputError(w, sanitizedThinking, sanitizedText, result.ContentFilter) {
return
}
textParsed := util.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames)
textParsed := toolcall.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames)
logResponsesToolPolicyRejection(traceID, toolChoice, textParsed, "text")
callCount := len(textParsed.Calls)
@@ -129,20 +130,12 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
}
responseObj := openaifmt.BuildResponseObject(responseID, model, finalPrompt, sanitizedThinking, sanitizedText, toolNames)
if result.OutputTokens > 0 {
if usage, ok := responseObj["usage"].(map[string]any); ok {
usage["output_tokens"] = result.OutputTokens
if input, ok := usage["input_tokens"].(int); ok {
usage["total_tokens"] = input + result.OutputTokens
}
}
}
h.getResponseStore().put(owner, responseID, responseObj)
writeJSON(w, http.StatusOK, responseObj)
}
func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice util.ToolChoicePolicy, traceID string) {
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeOpenAIError(w, resp.StatusCode, strings.TrimSpace(string(body)))
@@ -200,7 +193,7 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request,
})
}
func logResponsesToolPolicyRejection(traceID string, policy util.ToolChoicePolicy, parsed util.ToolCallParseResult, channel string) {
func logResponsesToolPolicyRejection(traceID string, policy util.ToolChoicePolicy, parsed toolcall.ToolCallParseResult, channel string) {
rejected := filteredRejectedToolNamesForLog(parsed.RejectedToolNames)
if !parsed.RejectedByPolicy || len(rejected) == 0 {
return

View File

@@ -1,6 +1,7 @@
package openai
import (
"ds2api/internal/toolcall"
"net/http"
"strings"
@@ -50,7 +51,6 @@ type responsesStreamRuntime struct {
messagePartAdded bool
sequence int
failed bool
outputTokens int
persistResponse func(obj map[string]any)
}
@@ -107,7 +107,7 @@ func (s *responsesStreamRuntime) finalize() {
s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true)
}
textParsed := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
textParsed := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
detected := textParsed.Calls
s.logToolPolicyRejections(textParsed)
@@ -148,14 +148,6 @@ func (s *responsesStreamRuntime) finalize() {
s.closeIncompleteFunctionItems()
obj := s.buildCompletedResponseObject(finalThinking, finalText, detected)
if s.outputTokens > 0 {
if usage, ok := obj["usage"].(map[string]any); ok {
usage["output_tokens"] = s.outputTokens
if input, ok := usage["input_tokens"].(int); ok {
usage["total_tokens"] = input + s.outputTokens
}
}
}
if s.persistResponse != nil {
s.persistResponse(obj)
}
@@ -163,8 +155,8 @@ func (s *responsesStreamRuntime) finalize() {
s.sendDone()
}
func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed util.ToolCallParseResult) {
logRejected := func(parsed util.ToolCallParseResult, channel string) {
func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed toolcall.ToolCallParseResult) {
logRejected := func(parsed toolcall.ToolCallParseResult, channel string) {
rejected := filteredRejectedToolNamesForLog(parsed.RejectedToolNames)
if !parsed.RejectedByPolicy || len(rejected) == 0 {
return
@@ -184,9 +176,6 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
if !parsed.Parsed {
return streamengine.ParsedDecision{}
}
if parsed.OutputTokens > 0 {
s.outputTokens = parsed.OutputTokens
}
if parsed.ContentFilter || parsed.ErrorMessage != "" || parsed.Stop {
return streamengine.ParsedDecision{Stop: true}
}

View File

@@ -48,7 +48,7 @@ func (s *responsesStreamRuntime) processToolStreamEvents(events []toolStreamEven
if !s.emitEarlyToolDeltas {
continue
}
filtered := filterIncrementalToolCallDeltasByAllowed(evt.ToolCallDeltas, s.toolNames, s.functionNames)
filtered := filterIncrementalToolCallDeltasByAllowed(evt.ToolCallDeltas, s.functionNames)
if len(filtered) == 0 {
continue
}

View File

@@ -1,11 +1,11 @@
package openai
import (
"ds2api/internal/toolcall"
"encoding/json"
"strings"
openaifmt "ds2api/internal/format/openai"
"ds2api/internal/util"
"github.com/google/uuid"
)
@@ -208,7 +208,7 @@ func (s *responsesStreamRuntime) emitFunctionCallDeltaEvents(deltas []toolCallDe
}
}
func (s *responsesStreamRuntime) emitFunctionCallDoneEvents(calls []util.ParsedToolCall) {
func (s *responsesStreamRuntime) emitFunctionCallDoneEvents(calls []toolcall.ParsedToolCall) {
for idx, tc := range calls {
if strings.TrimSpace(tc.Name) == "" {
continue

View File

@@ -1,12 +1,12 @@
package openai
import (
"ds2api/internal/toolcall"
"encoding/json"
"sort"
"strings"
openaifmt "ds2api/internal/format/openai"
"ds2api/internal/util"
)
func (s *responsesStreamRuntime) closeIncompleteFunctionItems() {
@@ -57,7 +57,7 @@ func (s *responsesStreamRuntime) closeIncompleteFunctionItems() {
}
}
func (s *responsesStreamRuntime) buildCompletedResponseObject(finalThinking, finalText string, calls []util.ParsedToolCall) map[string]any {
func (s *responsesStreamRuntime) buildCompletedResponseObject(finalThinking, finalText string, calls []toolcall.ParsedToolCall) map[string]any {
type indexedItem struct {
index int
item map[string]any

View File

@@ -627,7 +627,7 @@ func TestHandleResponsesNonStreamToolChoiceNoneStillAllowsFunctionCall(t *testin
}
}
func TestHandleResponsesNonStreamReturns502WhenUpstreamOutputEmpty(t *testing.T) {
func TestHandleResponsesNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
resp := &http.Response{
@@ -639,8 +639,8 @@ func TestHandleResponsesNonStreamReturns502WhenUpstreamOutputEmpty(t *testing.T)
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, util.DefaultToolChoicePolicy(), "")
if rec.Code != http.StatusBadGateway {
t.Fatalf("expected 502 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
errObj, _ := out["error"].(map[string]any)

View File

@@ -12,11 +12,11 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
model, _ := req["model"].(string)
messagesRaw, _ := req["messages"].([]any)
if strings.TrimSpace(model) == "" || len(messagesRaw) == 0 {
return util.StandardRequest{}, fmt.Errorf("Request must include 'model' and 'messages'.")
return util.StandardRequest{}, fmt.Errorf("request must include 'model' and 'messages'")
}
resolvedModel, ok := config.ResolveModel(store, model)
if !ok {
return util.StandardRequest{}, fmt.Errorf("Model '%s' is not available.", model)
return util.StandardRequest{}, fmt.Errorf("model %q is not available", model)
}
thinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel)
responseModel := strings.TrimSpace(model)
@@ -48,11 +48,11 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
model, _ := req["model"].(string)
model = strings.TrimSpace(model)
if model == "" {
return util.StandardRequest{}, fmt.Errorf("Request must include 'model'.")
return util.StandardRequest{}, fmt.Errorf("request must include 'model'")
}
resolvedModel, ok := config.ResolveModel(store, model)
if !ok {
return util.StandardRequest{}, fmt.Errorf("Model '%s' is not available.", model)
return util.StandardRequest{}, fmt.Errorf("model %q is not available", model)
}
thinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel)
@@ -68,7 +68,7 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
messagesRaw = msgs
}
if len(messagesRaw) == 0 {
return util.StandardRequest{}, fmt.Errorf("Request must include 'input' or 'messages'.")
return util.StandardRequest{}, fmt.Errorf("request must include 'input' or 'messages'")
}
toolPolicy, err := parseToolChoicePolicy(req["tool_choice"], req["tools"])
if err != nil {
@@ -152,7 +152,7 @@ func parseToolChoicePolicy(toolChoiceRaw any, toolsRaw any) (util.ToolChoicePoli
case "required":
policy.Mode = util.ToolChoiceRequired
default:
return util.ToolChoicePolicy{}, fmt.Errorf("Unsupported tool_choice: %q", v)
return util.ToolChoicePolicy{}, fmt.Errorf("unsupported tool_choice: %q", v)
}
case map[string]any:
allowedOverride, hasAllowedOverride, err := parseAllowedToolNames(v["allowed_tools"])
@@ -198,7 +198,7 @@ func parseToolChoicePolicy(toolChoiceRaw any, toolsRaw any) (util.ToolChoicePoli
policy.ForcedName = name
policy.Allowed = namesToSet([]string{name})
default:
return util.ToolChoicePolicy{}, fmt.Errorf("Unsupported tool_choice.type: %q", typ)
return util.ToolChoicePolicy{}, fmt.Errorf("unsupported tool_choice.type: %q", typ)
}
default:
return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice must be a string or object")
@@ -206,7 +206,7 @@ func parseToolChoicePolicy(toolChoiceRaw any, toolsRaw any) (util.ToolChoicePoli
if policy.Mode == util.ToolChoiceRequired || policy.Mode == util.ToolChoiceForced {
if len(declaredNames) == 0 {
return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice=%s requires non-empty tools.", policy.Mode)
return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice=%s requires non-empty tools", policy.Mode)
}
}
if policy.Mode == util.ToolChoiceForced {

View File

@@ -238,3 +238,97 @@ func TestChatCompletionsStreamContentFilterStopsNormallyWithoutLeak(t *testing.T
t.Fatalf("expected finish_reason=stop for content-filter upstream stop, got %#v", choice["finish_reason"])
}
}
func TestResponsesStreamUsageIgnoresBatchAccumulatedTokenUsage(t *testing.T) {
statuses := make([]int, 0, 1)
h := &Handler{
Store: mockOpenAIConfig{wideInput: true},
Auth: streamStatusAuthStub{},
DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(
`data: {"p":"response/content","v":"hello"}`,
`data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":190},{"p":"quasi_status","v":"FINISHED"}]}`,
)},
}
r := chi.NewRouter()
r.Use(captureStatusMiddleware(&statuses))
RegisterRoutes(r, h)
reqBody := `{"model":"deepseek-chat","input":"hi","stream":true}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if len(statuses) != 1 || statuses[0] != http.StatusOK {
t.Fatalf("expected captured status 200, got %#v", statuses)
}
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if len(frames) == 0 {
t.Fatalf("expected at least one json frame, body=%s", rec.Body.String())
}
last := frames[len(frames)-1]
resp, _ := last["response"].(map[string]any)
if resp == nil {
t.Fatalf("expected response payload in final frame, got %#v", last)
}
usage, _ := resp["usage"].(map[string]any)
if usage == nil {
t.Fatalf("expected usage in response payload, got %#v", resp)
}
if got, _ := usage["output_tokens"].(float64); int(got) == 190 {
t.Fatalf("expected upstream accumulated token usage to be ignored, got %#v", usage["output_tokens"])
}
}
func TestResponsesNonStreamUsageIgnoresPromptAndOutputTokenUsage(t *testing.T) {
statuses := make([]int, 0, 1)
h := &Handler{
Store: mockOpenAIConfig{wideInput: true},
Auth: streamStatusAuthStub{},
DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse(
`data: {"p":"response/content","v":"ok"}`,
`data: {"p":"response","o":"BATCH","v":[{"p":"token_usage","v":{"prompt_tokens":11,"completion_tokens":29}},{"p":"quasi_status","v":"FINISHED"}]}`,
)},
}
r := chi.NewRouter()
r.Use(captureStatusMiddleware(&statuses))
RegisterRoutes(r, h)
reqBody := `{"model":"deepseek-chat","input":"hi","stream":false}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if len(statuses) != 1 || statuses[0] != http.StatusOK {
t.Fatalf("expected captured status 200, got %#v", statuses)
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String())
}
usage, _ := out["usage"].(map[string]any)
if usage == nil {
t.Fatalf("expected usage object, got %#v", out)
}
input, _ := usage["input_tokens"].(float64)
output, _ := usage["output_tokens"].(float64)
total, _ := usage["total_tokens"].(float64)
if int(output) == 29 {
t.Fatalf("expected upstream completion token usage to be ignored, got %#v", usage["output_tokens"])
}
if int(total) != int(input)+int(output) {
t.Fatalf("expected total_tokens=input_tokens+output_tokens, usage=%#v", usage)
}
}

View File

@@ -3,7 +3,7 @@ package openai
import (
"strings"
"ds2api/internal/util"
"ds2api/internal/toolcall"
)
func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames []string) []toolStreamEvent {
@@ -226,7 +226,7 @@ func findToolSegmentStart(s string) int {
return start
}
func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) {
func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) {
captured := state.capture.String()
if captured == "" {
return "", nil, "", false
@@ -267,7 +267,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
}
prefixPart := captured[:start]
suffixPart := captured[end:]
parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
parsed := toolcall.ParseStandaloneToolCallsDetailed(obj, toolNames)
if len(parsed.Calls) == 0 {
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
// Parsed as tool-call payload but rejected by schema/policy:

View File

@@ -1,9 +1,8 @@
package openai
import (
"ds2api/internal/toolcall"
"strings"
"ds2api/internal/util"
)
type toolStreamSieveState struct {
@@ -12,7 +11,7 @@ type toolStreamSieveState struct {
capturing bool
recentTextTail string
pendingToolRaw string
pendingToolCalls []util.ParsedToolCall
pendingToolCalls []toolcall.ParsedToolCall
disableDeltas bool
toolNameSent bool
toolName string
@@ -24,7 +23,7 @@ type toolStreamSieveState struct {
type toolStreamEvent struct {
Content string
ToolCalls []util.ParsedToolCall
ToolCalls []toolcall.ParsedToolCall
ToolCallDeltas []toolCallDelta
}

View File

@@ -1,14 +1,14 @@
package openai
import (
"ds2api/internal/toolcall"
"regexp"
"strings"
"ds2api/internal/util"
)
// --- XML tool call support for the streaming sieve ---
//nolint:unused // kept as explicit tag inventory for future XML sieve refinements.
var xmlToolCallClosingTags = []string{"</tool_calls>", "</tool_call>", "</invoke>", "</function_call>", "</function_calls>", "</tool_use>",
// Agent-style XML tags (Roo Code, Cline, etc.)
"</attempt_completion>", "</ask_followup_question>", "</new_task>", "</result>"}
@@ -34,6 +34,8 @@ var xmlToolCallTagPairs = []struct{ open, close string }{
}
// xmlToolCallBlockPattern matches a complete XML tool call block (wrapper or standalone).
//
//nolint:unused // reserved for future fast-path XML block detection.
var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)(<tool_calls>\s*(?:.*?)\s*</tool_calls>|<tool_call>\s*(?:.*?)\s*</tool_call>|<invoke\b[^>]*>(?:.*?)</invoke>|<function_calls?\b[^>]*>(?:.*?)</function_calls?>|<tool_use>(?:.*?)</tool_use>|<attempt_completion>(?:.*?)</attempt_completion>|<ask_followup_question>(?:.*?)</ask_followup_question>|<new_task>(?:.*?)</new_task>)`)
// xmlToolTagsToDetect is the set of XML tag prefixes used by findToolSegmentStart.
@@ -43,7 +45,7 @@ var xmlToolTagsToDetect = []string{"<tool_calls>", "<tool_calls\n", "<tool_call>
"<attempt_completion>", "<ask_followup_question>", "<new_task>"}
// consumeXMLToolCapture tries to extract complete XML tool call blocks from captured text.
func consumeXMLToolCapture(captured string, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) {
func consumeXMLToolCapture(captured string, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) {
lower := strings.ToLower(captured)
// Find the FIRST matching open/close pair, preferring wrapper tags.
// Tag pairs are ordered longest-first (e.g. <tool_calls before <tool_call)
@@ -66,7 +68,7 @@ func consumeXMLToolCapture(captured string, toolNames []string) (prefix string,
xmlBlock := captured[openIdx:closeEnd]
prefixPart := captured[:openIdx]
suffixPart := captured[closeEnd:]
parsed := util.ParseToolCalls(xmlBlock, toolNames)
parsed := toolcall.ParseToolCalls(xmlBlock, toolNames)
if len(parsed) > 0 {
prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart)
return prefixPart, parsed, suffixPart, true

View File

@@ -10,6 +10,6 @@ func writeUpstreamEmptyOutputError(w http.ResponseWriter, thinking, text string,
writeOpenAIErrorWithCode(w, http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter")
return true
}
writeOpenAIErrorWithCode(w, http.StatusBadGateway, "Upstream model returned empty output.", "upstream_empty_output")
writeOpenAIErrorWithCode(w, http.StatusTooManyRequests, "Upstream model returned empty output.", "upstream_empty_output")
return true
}

View File

@@ -26,9 +26,15 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Get("/config/export", h.configExport)
pr.Post("/keys", h.addKey)
pr.Delete("/keys/{key}", h.deleteKey)
pr.Get("/proxies", h.listProxies)
pr.Post("/proxies", h.addProxy)
pr.Put("/proxies/{proxyID}", h.updateProxy)
pr.Delete("/proxies/{proxyID}", h.deleteProxy)
pr.Post("/proxies/test", h.testProxy)
pr.Get("/accounts", h.listAccounts)
pr.Post("/accounts", h.addAccount)
pr.Delete("/accounts/{identifier}", h.deleteAccount)
pr.Put("/accounts/{identifier}/proxy", h.updateAccountProxy)
pr.Get("/queue/status", h.queueStatus)
pr.Post("/accounts/test", h.testSingleAccount)
pr.Post("/accounts/test-all", h.testAllAccounts)

View File

@@ -68,6 +68,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
"identifier": acc.Identifier(),
"email": acc.Email,
"mobile": acc.Mobile,
"proxy_id": acc.ProxyID,
"has_password": acc.Password != "",
"has_token": token != "",
"token_preview": preview,
@@ -86,6 +87,11 @@ func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
return
}
err := h.Store.Update(func(c *config.Config) error {
if acc.ProxyID != "" {
if _, ok := findProxyByID(*c, acc.ProxyID); !ok {
return fmt.Errorf("代理不存在")
}
}
mobileKey := config.CanonicalMobileKey(acc.Mobile)
for _, a := range c.Accounts {
if acc.Email != "" && a.Email == acc.Email {

View File

@@ -115,10 +115,11 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
result["message"] = "登录成功但写入运行时 token 失败: " + err.Error()
return result
}
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token}
sessionID, err := h.DS.CreateSession(ctx, authCtx, 1)
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token, AccountID: identifier, Account: acc}
proxyCtx := authn.WithAuth(ctx, authCtx)
sessionID, err := h.DS.CreateSession(proxyCtx, authCtx, 1)
if err != nil {
newToken, loginErr := h.DS.Login(ctx, acc)
newToken, loginErr := h.DS.Login(proxyCtx, acc)
if loginErr != nil {
result["message"] = "创建会话失败: " + err.Error()
return result
@@ -129,7 +130,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
result["message"] = "刷新 token 成功但写入运行时 token 失败: " + err.Error()
return result
}
sessionID, err = h.DS.CreateSession(ctx, authCtx, 1)
sessionID, err = h.DS.CreateSession(proxyCtx, authCtx, 1)
if err != nil {
result["message"] = "创建会话失败: " + err.Error()
return result
@@ -137,7 +138,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
}
// 获取会话数量
sessionStats, sessionErr := h.DS.GetSessionCountForToken(ctx, token)
sessionStats, sessionErr := h.DS.GetSessionCountForToken(proxyCtx, token)
if sessionErr == nil && sessionStats != nil {
result["session_count"] = sessionStats.FirstPageCount
}
@@ -153,19 +154,19 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
thinking, search = false, false
}
_ = search
pow, err := h.DS.GetPow(ctx, authCtx, 1)
pow, err := h.DS.GetPow(proxyCtx, authCtx, 1)
if err != nil {
result["message"] = "获取 PoW 失败: " + err.Error()
return result
}
payload := map[string]any{"chat_session_id": sessionID, "prompt": deepseek.MessagesPrepare([]map[string]any{{"role": "user", "content": message}}), "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search}
resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1)
resp, err := h.DS.CallCompletion(proxyCtx, authCtx, payload, pow, 1)
if err != nil {
result["message"] = "请求失败: " + err.Error()
return result
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode)
return result
}
@@ -218,7 +219,7 @@ func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusOK {
var parsed any
@@ -244,25 +245,29 @@ func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
}
// 每次先登录刷新一次 token避免使用过期 token。
token, err := h.DS.Login(r.Context(), acc)
authCtx := &authn.RequestAuth{UseConfigToken: false, AccountID: acc.Identifier(), Account: acc}
proxyCtx := authn.WithAuth(r.Context(), authCtx)
token, err := h.DS.Login(proxyCtx, acc)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
return
}
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
authCtx.DeepSeekToken = token
// 删除所有会话
err = h.DS.DeleteAllSessionsForToken(r.Context(), token)
err = h.DS.DeleteAllSessionsForToken(proxyCtx, token)
if err != nil {
// token 可能过期,尝试重新登录并重试一次
newToken, loginErr := h.DS.Login(r.Context(), acc)
newToken, loginErr := h.DS.Login(proxyCtx, acc)
if loginErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
if retryErr := h.DS.DeleteAllSessionsForToken(r.Context(), token); retryErr != nil {
authCtx.DeepSeekToken = token
if retryErr := h.DS.DeleteAllSessionsForToken(proxyCtx, token); retryErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
return
}

View File

@@ -3,6 +3,8 @@ package admin
import (
"net/http"
"strings"
"ds2api/internal/config"
)
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
@@ -10,6 +12,7 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
safe := map[string]any{
"keys": snap.Keys,
"accounts": []map[string]any{},
"proxies": []map[string]any{},
"env_backed": h.Store.IsEnvBacked(),
"env_source_present": h.Store.HasEnvConfigSource(),
"env_writeback_enabled": h.Store.IsEnvWritebackEnabled(),
@@ -36,12 +39,27 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
"identifier": acc.Identifier(),
"email": acc.Email,
"mobile": acc.Mobile,
"proxy_id": acc.ProxyID,
"has_password": strings.TrimSpace(acc.Password) != "",
"has_token": token != "",
"token_preview": preview,
})
}
safe["accounts"] = accounts
proxies := make([]map[string]any, 0, len(snap.Proxies))
for _, proxy := range snap.Proxies {
proxy = config.NormalizeProxy(proxy)
proxies = append(proxies, map[string]any{
"id": proxy.ID,
"name": proxy.Name,
"type": proxy.Type,
"host": proxy.Host,
"port": proxy.Port,
"username": proxy.Username,
"has_password": strings.TrimSpace(proxy.Password) != "",
})
}
safe["proxies"] = proxies
writeJSON(w, http.StatusOK, safe)
}

View File

@@ -85,7 +85,7 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
err := h.Store.Update(func(c *config.Config) error {
for _, k := range c.Keys {
if k == key {
return fmt.Errorf("Key 已存在")
return fmt.Errorf("key 已存在")
}
}
c.Keys = append(c.Keys, key)
@@ -109,7 +109,7 @@ func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) {
}
}
if idx < 0 {
return fmt.Errorf("Key 不存在")
return fmt.Errorf("key 不存在")
}
c.Keys = append(c.Keys[:idx], c.Keys[idx+1:]...)
return nil

View File

@@ -0,0 +1,202 @@
package admin
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"ds2api/internal/config"
"ds2api/internal/deepseek"
)
var proxyConnectivityTester = func(ctx context.Context, proxy config.Proxy) map[string]any {
return deepseek.TestProxyConnectivity(ctx, proxy)
}
func validateProxyMutation(cfg *config.Config) error {
if cfg == nil {
return nil
}
if err := config.ValidateProxyConfig(cfg.Proxies); err != nil {
return err
}
return config.ValidateAccountProxyReferences(cfg.Accounts, cfg.Proxies)
}
func proxyResponse(proxy config.Proxy) map[string]any {
proxy = config.NormalizeProxy(proxy)
return map[string]any{
"id": proxy.ID,
"name": proxy.Name,
"type": proxy.Type,
"host": proxy.Host,
"port": proxy.Port,
"username": proxy.Username,
"has_password": strings.TrimSpace(proxy.Password) != "",
}
}
func (h *Handler) listProxies(w http.ResponseWriter, _ *http.Request) {
proxies := h.Store.Snapshot().Proxies
items := make([]map[string]any, 0, len(proxies))
for _, proxy := range proxies {
proxy = config.NormalizeProxy(proxy)
items = append(items, map[string]any{
"id": proxy.ID,
"name": proxy.Name,
"type": proxy.Type,
"host": proxy.Host,
"port": proxy.Port,
"username": proxy.Username,
"has_password": strings.TrimSpace(proxy.Password) != "",
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
}
func (h *Handler) addProxy(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
proxy := toProxy(req)
err := h.Store.Update(func(c *config.Config) error {
c.Proxies = append(c.Proxies, proxy)
return validateProxyMutation(c)
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "proxy": proxyResponse(proxy)})
}
func (h *Handler) updateProxy(w http.ResponseWriter, r *http.Request) {
proxyID := chi.URLParam(r, "proxyID")
if decoded, err := url.PathUnescape(proxyID); err == nil {
proxyID = decoded
}
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
proxy := toProxy(req)
proxy.ID = strings.TrimSpace(proxyID)
err := h.Store.Update(func(c *config.Config) error {
for i, existing := range c.Proxies {
existing = config.NormalizeProxy(existing)
if existing.ID != proxy.ID {
continue
}
if proxy.Password == "" {
proxy.Password = existing.Password
}
c.Proxies[i] = proxy
return validateProxyMutation(c)
}
return newRequestError("代理不存在")
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "proxy": proxyResponse(proxy)})
}
func (h *Handler) deleteProxy(w http.ResponseWriter, r *http.Request) {
proxyID := chi.URLParam(r, "proxyID")
if decoded, err := url.PathUnescape(proxyID); err == nil {
proxyID = decoded
}
err := h.Store.Update(func(c *config.Config) error {
idx := -1
for i, existing := range c.Proxies {
existing = config.NormalizeProxy(existing)
if existing.ID == strings.TrimSpace(proxyID) {
idx = i
break
}
}
if idx < 0 {
return newRequestError("代理不存在")
}
c.Proxies = append(c.Proxies[:idx], c.Proxies[idx+1:]...)
for i := range c.Accounts {
if strings.TrimSpace(c.Accounts[i].ProxyID) == strings.TrimSpace(proxyID) {
c.Accounts[i].ProxyID = ""
}
}
return validateProxyMutation(c)
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}
func (h *Handler) testProxy(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
proxyID := fieldString(req, "proxy_id")
var proxy config.Proxy
if proxyID != "" {
var ok bool
proxy, ok = findProxyByID(h.Store.Snapshot(), proxyID)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "代理不存在"})
return
}
} else {
proxy = toProxy(req)
}
result := proxyConnectivityTester(r.Context(), proxy)
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) updateAccountProxy(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
if decoded, err := url.PathUnescape(identifier); err == nil {
identifier = decoded
}
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
proxyID := fieldString(req, "proxy_id")
err := h.Store.Update(func(c *config.Config) error {
if proxyID != "" {
if _, ok := findProxyByID(*c, proxyID); !ok {
return newRequestError("代理不存在")
}
}
for i, acc := range c.Accounts {
if !accountMatchesIdentifier(acc, identifier) {
continue
}
c.Accounts[i].ProxyID = proxyID
return validateProxyMutation(c)
}
return newRequestError("账号不存在")
})
if err != nil {
if detail, ok := requestErrorDetail(err); ok {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": detail})
return
}
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "proxy_id": proxyID})
}

View File

@@ -0,0 +1,227 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/account"
"ds2api/internal/config"
)
func newAdminProxyTestHandler(t *testing.T, raw string) *Handler {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", raw)
store := config.LoadStore()
return &Handler{
Store: store,
Pool: account.NewPool(store),
}
}
func TestAddProxyPersistsNormalizedProxy(t *testing.T) {
h := newAdminProxyTestHandler(t, `{"accounts":[]}`)
r := chi.NewRouter()
r.Post("/admin/proxies", h.addProxy)
req := httptest.NewRequest(http.MethodPost, "/admin/proxies", bytes.NewBufferString(`{
"name":" HK Exit ",
"type":" SOCKS5H ",
"host":" 127.0.0.1 ",
"port":1081,
"username":" user ",
"password":" pass "
}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
proxies := h.Store.Snapshot().Proxies
if len(proxies) != 1 {
t.Fatalf("expected 1 proxy, got %d", len(proxies))
}
if proxies[0].Name != "HK Exit" {
t.Fatalf("unexpected proxy name: %#v", proxies[0])
}
if proxies[0].Type != "socks5h" {
t.Fatalf("unexpected proxy type: %#v", proxies[0])
}
if proxies[0].Username != "user" || proxies[0].Password != "pass" {
t.Fatalf("expected trimmed credentials, got %#v", proxies[0])
}
if proxies[0].ID == "" {
t.Fatalf("expected generated proxy id, got %#v", proxies[0])
}
}
func TestAddProxyDoesNotFailOnUnrelatedInvalidRuntimeConfig(t *testing.T) {
router := newHTTPAdminHarness(t, `{
"keys":["k1"],
"runtime":{
"account_max_inflight":8,
"global_max_inflight":4
}
}`, &testingDSMock{})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminReq(http.MethodPost, "/proxies", []byte(`{
"name":"HK Exit",
"type":"socks5h",
"host":"127.0.0.1",
"port":1080
}`)))
if rec.Code != http.StatusOK {
t.Fatalf("expected add proxy success despite unrelated runtime issue, got %d body=%s", rec.Code, rec.Body.String())
}
readRec := httptest.NewRecorder()
router.ServeHTTP(readRec, adminReq(http.MethodGet, "/config", nil))
if readRec.Code != http.StatusOK {
t.Fatalf("config read status=%d body=%s", readRec.Code, readRec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(readRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode config response: %v", err)
}
proxies, _ := payload["proxies"].([]any)
if len(proxies) != 1 {
t.Fatalf("expected proxy to be persisted, got %#v", payload["proxies"])
}
}
func TestDeleteProxyClearsAssignedAccountProxyID(t *testing.T) {
h := newAdminProxyTestHandler(t, `{
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5","host":"127.0.0.1","port":1080}],
"accounts":[{"email":"u@example.com","password":"pwd","proxy_id":"proxy-1"}]
}`)
r := chi.NewRouter()
r.Delete("/admin/proxies/{proxyID}", h.deleteProxy)
req := httptest.NewRequest(http.MethodDelete, "/admin/proxies/proxy-1", nil)
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.Proxies) != 0 {
t.Fatalf("expected proxy removed, got %#v", snap.Proxies)
}
if len(snap.Accounts) != 1 {
t.Fatalf("expected account kept, got %#v", snap.Accounts)
}
if snap.Accounts[0].ProxyID != "" {
t.Fatalf("expected proxy assignment cleared, got %#v", snap.Accounts[0])
}
}
func TestUpdateProxyResponseDoesNotExposeStoredPassword(t *testing.T) {
h := newAdminProxyTestHandler(t, `{
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5h","host":"127.0.0.1","port":1080,"username":"u","password":"secret"}]
}`)
r := chi.NewRouter()
r.Put("/admin/proxies/{proxyID}", h.updateProxy)
req := httptest.NewRequest(http.MethodPut, "/admin/proxies/proxy-1", bytes.NewBufferString(`{
"name":"Node 1",
"type":"socks5h",
"host":"127.0.0.2",
"port":1081,
"username":"u2"
}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
proxy, _ := payload["proxy"].(map[string]any)
if _, exists := proxy["password"]; exists {
t.Fatalf("response should not expose password, got %#v", proxy)
}
if hasPassword, _ := proxy["has_password"].(bool); !hasPassword {
t.Fatalf("expected has_password=true, got %#v", proxy)
}
}
func TestUpdateAccountProxyAssignsProxyID(t *testing.T) {
h := newAdminProxyTestHandler(t, `{
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5h","host":"127.0.0.1","port":1080}],
"accounts":[{"email":"u@example.com","password":"pwd"}]
}`)
r := chi.NewRouter()
r.Put("/admin/accounts/{identifier}/proxy", h.updateAccountProxy)
req := httptest.NewRequest(http.MethodPut, "/admin/accounts/u@example.com/proxy", bytes.NewBufferString(`{"proxy_id":"proxy-1"}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
acc, ok := h.Store.FindAccount("u@example.com")
if !ok {
t.Fatal("expected account")
}
if acc.ProxyID != "proxy-1" {
t.Fatalf("expected proxy assigned, got %#v", acc)
}
}
func TestTestProxyUsesStoredProxy(t *testing.T) {
h := newAdminProxyTestHandler(t, `{
"proxies":[{"id":"proxy-1","name":"Node 1","type":"socks5h","host":"127.0.0.1","port":1080}]
}`)
original := proxyConnectivityTester
defer func() { proxyConnectivityTester = original }()
var got config.Proxy
proxyConnectivityTester = func(_ context.Context, proxy config.Proxy) map[string]any {
got = proxy
return map[string]any{
"success": true,
"proxy_id": proxy.ID,
"proxy_type": proxy.Type,
"response_time": 12,
}
}
r := chi.NewRouter()
r.Post("/admin/proxies/test", h.testProxy)
req := httptest.NewRequest(http.MethodPost, "/admin/proxies/test", bytes.NewBufferString(`{"proxy_id":"proxy-1"}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got.ID != "proxy-1" || got.Type != "socks5h" {
t.Fatalf("expected stored proxy passed to tester, got %#v", got)
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if ok, _ := payload["success"].(bool); !ok {
t.Fatalf("expected success payload, got %#v", payload)
}
}

View File

@@ -288,17 +288,17 @@ func TestQueryRawSampleCapturesGroupsBySessionAndMatchesQuestion(t *testing.T) {
func TestBuildCaptureChainsPreservesCaptureOrderWhenTimestampsCollide(t *testing.T) {
snapshot := []devcapture.Entry{
{
ID: "cap_continue",
CreatedAt: 1712365200,
Label: "deepseek_continue",
RequestBody: `{"chat_session_id":"session-collision","message_id":2}`,
ID: "cap_continue",
CreatedAt: 1712365200,
Label: "deepseek_continue",
RequestBody: `{"chat_session_id":"session-collision","message_id":2}`,
ResponseBody: "data: {\"v\":\"第二段\"}\n\n",
},
{
ID: "cap_completion",
CreatedAt: 1712365200,
Label: "deepseek_completion",
RequestBody: `{"chat_session_id":"session-collision","prompt":"题目"}`,
ID: "cap_completion",
CreatedAt: 1712365200,
Label: "deepseek_completion",
RequestBody: `{"chat_session_id":"session-collision","prompt":"题目"}`,
ResponseBody: "data: {\"v\":\"第一段\"}\n\n",
},
}

View File

@@ -301,7 +301,7 @@ func vercelRequest(ctx context.Context, client *http.Client, method, endpoint st
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
parsed := map[string]any{}
_ = json.Unmarshal(b, &parsed)

View File

@@ -43,7 +43,7 @@ func (h *Handler) getVersion(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, resp)
return
}
defer r.Body.Close()
defer func() { _ = r.Body.Close() }()
if r.StatusCode < 200 || r.StatusCode >= 300 {
resp["check_error"] = "github api status: " + r.Status
writeJSON(w, http.StatusOK, resp)

View File

@@ -65,6 +65,7 @@ func toAccount(m map[string]any) config.Account {
Email: email,
Mobile: mobile,
Password: fieldString(m, "password"),
ProxyID: fieldString(m, "proxy_id"),
}
}
@@ -100,9 +101,36 @@ func accountMatchesIdentifier(acc config.Account, identifier string) bool {
func normalizeAccountForStorage(acc config.Account) config.Account {
acc.Email = strings.TrimSpace(acc.Email)
acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile)
acc.ProxyID = strings.TrimSpace(acc.ProxyID)
return acc
}
func toProxy(m map[string]any) config.Proxy {
return config.NormalizeProxy(config.Proxy{
ID: fieldString(m, "id"),
Name: fieldString(m, "name"),
Type: fieldString(m, "type"),
Host: fieldString(m, "host"),
Port: intFrom(m["port"]),
Username: fieldString(m, "username"),
Password: fieldString(m, "password"),
})
}
func findProxyByID(c config.Config, proxyID string) (config.Proxy, bool) {
id := strings.TrimSpace(proxyID)
if id == "" {
return config.Proxy{}, false
}
for _, proxy := range c.Proxies {
proxy = config.NormalizeProxy(proxy)
if proxy.ID == id {
return proxy, true
}
}
return config.Proxy{}, false
}
func accountDedupeKey(acc config.Account) string {
if email := strings.TrimSpace(acc.Email); email != "" {
return "email:" + email

View File

@@ -130,9 +130,7 @@ func TestMarkTokenInvalidNotConfigToken(t *testing.T) {
a := &RequestAuth{UseConfigToken: false, DeepSeekToken: "direct", resolver: r}
r.MarkTokenInvalid(a)
// Should not panic, token should be unchanged for non-config
if a.DeepSeekToken != "" {
// Actually it does clear it; that's fine - let's check behavior
}
_ = a.DeepSeekToken // Actual behavior may clear it; this test only asserts no panic.
}
func TestMarkTokenInvalidEmptyAccountID(t *testing.T) {

View File

@@ -1,6 +1,7 @@
package compat
import (
"ds2api/internal/toolcall"
"encoding/json"
"os"
"path/filepath"
@@ -36,7 +37,6 @@ func TestGoCompatSSEFixtures(t *testing.T) {
Finished bool `json:"finished"`
NewType string `json:"new_type"`
ContentFilter bool `json:"content_filter"`
OutputTokens int `json:"output_tokens"`
ErrorMessage string `json:"error_message"`
}
mustLoadJSON(t, expectedPath, &expected)
@@ -57,11 +57,10 @@ func TestGoCompatSSEFixtures(t *testing.T) {
res.Stop != expected.Finished ||
res.NextType != expected.NewType ||
res.ContentFilter != expected.ContentFilter ||
res.OutputTokens != expected.OutputTokens ||
res.ErrorMessage != expected.ErrorMessage {
t.Fatalf("fixture %s mismatch:\n got parts=%#v finished=%v newType=%q contentFilter=%v outputTokens=%d errorMessage=%q\nwant parts=%#v finished=%v newType=%q contentFilter=%v outputTokens=%d errorMessage=%q",
name, gotParts, res.Stop, res.NextType, res.ContentFilter, res.OutputTokens, res.ErrorMessage,
expected.Parts, expected.Finished, expected.NewType, expected.ContentFilter, expected.OutputTokens, expected.ErrorMessage)
t.Fatalf("fixture %s mismatch:\n got parts=%#v finished=%v newType=%q contentFilter=%v errorMessage=%q\nwant parts=%#v finished=%v newType=%q contentFilter=%v errorMessage=%q",
name, gotParts, res.Stop, res.NextType, res.ContentFilter, res.ErrorMessage,
expected.Parts, expected.Finished, expected.NewType, expected.ContentFilter, expected.ErrorMessage)
}
}
}
@@ -86,22 +85,22 @@ func TestGoCompatToolcallFixtures(t *testing.T) {
mustLoadJSON(t, fixturePath, &fixture)
var expected struct {
Calls []util.ParsedToolCall `json:"calls"`
SawToolCallSyntax bool `json:"sawToolCallSyntax"`
RejectedByPolicy bool `json:"rejectedByPolicy"`
RejectedToolNames []string `json:"rejectedToolNames"`
Calls []toolcall.ParsedToolCall `json:"calls"`
SawToolCallSyntax bool `json:"sawToolCallSyntax"`
RejectedByPolicy bool `json:"rejectedByPolicy"`
RejectedToolNames []string `json:"rejectedToolNames"`
}
mustLoadJSON(t, expectedPath, &expected)
var got util.ToolCallParseResult
var got toolcall.ToolCallParseResult
switch strings.ToLower(strings.TrimSpace(fixture.Mode)) {
case "standalone":
got = util.ParseStandaloneToolCallsDetailed(fixture.Text, fixture.ToolNames)
got = toolcall.ParseStandaloneToolCallsDetailed(fixture.Text, fixture.ToolNames)
default:
got = util.ParseToolCallsDetailed(fixture.Text, fixture.ToolNames)
got = toolcall.ParseToolCallsDetailed(fixture.Text, fixture.ToolNames)
}
if got.Calls == nil {
got.Calls = []util.ParsedToolCall{}
got.Calls = []toolcall.ParsedToolCall{}
}
if got.RejectedToolNames == nil {
got.RejectedToolNames = []string{}

View File

@@ -20,6 +20,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
if len(c.Accounts) > 0 {
m["accounts"] = c.Accounts
}
if len(c.Proxies) > 0 {
m["proxies"] = c.Proxies
}
if len(c.ClaudeMapping) > 0 {
m["claude_mapping"] = c.ClaudeMapping
}
@@ -70,6 +73,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.Accounts); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "proxies":
if err := json.Unmarshal(v, &c.Proxies); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "claude_mapping":
if err := json.Unmarshal(v, &c.ClaudeMapping); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
@@ -130,6 +137,7 @@ func (c Config) Clone() Config {
clone := Config{
Keys: slices.Clone(c.Keys),
Accounts: slices.Clone(c.Accounts),
Proxies: slices.Clone(c.Proxies),
ClaudeMapping: cloneStringMap(c.ClaudeMapping),
ClaudeModelMap: cloneStringMap(c.ClaudeModelMap),
ModelAliases: cloneStringMap(c.ModelAliases),

View File

@@ -1,8 +1,16 @@
package config
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"strings"
)
type Config struct {
Keys []string `json:"keys,omitempty"`
Accounts []Account `json:"accounts,omitempty"`
Proxies []Proxy `json:"proxies,omitempty"`
ClaudeMapping map[string]string `json:"claude_mapping,omitempty"`
ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"`
ModelAliases map[string]string `json:"model_aliases,omitempty"`
@@ -22,6 +30,38 @@ type Account struct {
Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
ProxyID string `json:"proxy_id,omitempty"`
}
type Proxy struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
func NormalizeProxy(p Proxy) Proxy {
p.ID = strings.TrimSpace(p.ID)
p.Name = strings.TrimSpace(p.Name)
p.Type = strings.ToLower(strings.TrimSpace(p.Type))
p.Host = strings.TrimSpace(p.Host)
p.Username = strings.TrimSpace(p.Username)
p.Password = strings.TrimSpace(p.Password)
if p.ID == "" {
p.ID = StableProxyID(p)
}
if p.Name == "" && p.Host != "" && p.Port > 0 {
p.Name = fmt.Sprintf("%s:%d", p.Host, p.Port)
}
return p
}
func StableProxyID(p Proxy) string {
sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(p.Type)) + "|" + strings.ToLower(strings.TrimSpace(p.Host)) + "|" + fmt.Sprintf("%d", p.Port) + "|" + strings.TrimSpace(p.Username)))
return "proxy_" + hex.EncodeToString(sum[:6])
}
func (c *Config) ClearAccountTokens() {

View File

@@ -32,6 +32,47 @@ func TestLoadStoreClearsTokensFromConfigInput(t *testing.T) {
}
}
func TestLoadStorePreservesProxiesAndAccountProxyAssignment(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"proxies":[
{
"id":"proxy-sh-1",
"name":"Shanghai Exit",
"type":"socks5h",
"host":"127.0.0.1",
"port":1080,
"username":"demo",
"password":"secret"
}
],
"accounts":[
{
"email":"u@example.com",
"password":"p",
"proxy_id":"proxy-sh-1"
}
]
}`)
store := LoadStore()
snap := store.Snapshot()
if len(snap.Proxies) != 1 {
t.Fatalf("expected 1 proxy, got %d", len(snap.Proxies))
}
if snap.Proxies[0].ID != "proxy-sh-1" {
t.Fatalf("unexpected proxy id: %#v", snap.Proxies[0])
}
if snap.Proxies[0].Type != "socks5h" {
t.Fatalf("unexpected proxy type: %#v", snap.Proxies[0])
}
if len(snap.Accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(snap.Accounts))
}
if snap.Accounts[0].ProxyID != "proxy-sh-1" {
t.Fatalf("expected account proxy assignment preserved, got %#v", snap.Accounts[0])
}
}
func TestLoadStoreDropsLegacyTokenOnlyAccounts(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"accounts":[
@@ -58,8 +99,7 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) {
if err != nil {
t.Fatalf("create temp config: %v", err)
}
defer tmp.Close()
defer func() { _ = tmp.Close() }()
if _, err := tmp.WriteString(`{
"accounts":[{"email":"u@example.com","password":"p","token":"persisted-token"}]
}`); err != nil {
@@ -355,7 +395,7 @@ func TestAccountTestStatusIsRuntimeOnlyAndNotPersisted(t *testing.T) {
if err != nil {
t.Fatalf("create temp config: %v", err)
}
defer tmp.Close()
defer func() { _ = tmp.Close() }()
if _, err := tmp.WriteString(`{
"accounts":[{"email":"u@example.com","password":"p","test_status":"ok"}]
}`); err != nil {

View File

@@ -33,10 +33,6 @@ func ConfigPath() string {
return ResolvePath("DS2API_CONFIG_PATH", "config.json")
}
func WASMPath() string {
return ResolvePath("DS2API_WASM_PATH", "sha3_wasm_bg.7b9ca65ddd.wasm")
}
func RawStreamSampleRoot() string {
return ResolvePath("DS2API_RAW_STREAM_SAMPLE_ROOT", "tests/raw_stream_samples")
}

View File

@@ -6,6 +6,9 @@ import (
)
func ValidateConfig(c Config) error {
if err := ValidateProxyConfig(c.Proxies); err != nil {
return err
}
if err := ValidateAdminConfig(c.Admin); err != nil {
return err
}
@@ -21,6 +24,55 @@ func ValidateConfig(c Config) error {
if err := ValidateAutoDeleteConfig(c.AutoDelete); err != nil {
return err
}
if err := ValidateAccountProxyReferences(c.Accounts, c.Proxies); err != nil {
return err
}
return nil
}
func ValidateProxyConfig(proxies []Proxy) error {
seen := make(map[string]struct{}, len(proxies))
for _, proxy := range proxies {
proxy = NormalizeProxy(proxy)
if err := ValidateTrimmedString("proxies.id", proxy.ID, true); err != nil {
return err
}
switch proxy.Type {
case "socks5", "socks5h":
default:
return fmt.Errorf("proxies.type must be one of socks5, socks5h")
}
if err := ValidateTrimmedString("proxies.host", proxy.Host, true); err != nil {
return err
}
if err := ValidateIntRange("proxies.port", proxy.Port, 1, 65535, true); err != nil {
return err
}
if _, ok := seen[proxy.ID]; ok {
return fmt.Errorf("duplicate proxy id: %s", proxy.ID)
}
seen[proxy.ID] = struct{}{}
}
return nil
}
func ValidateAccountProxyReferences(accounts []Account, proxies []Proxy) error {
if len(accounts) == 0 {
return nil
}
ids := make(map[string]struct{}, len(proxies))
for _, proxy := range proxies {
ids[NormalizeProxy(proxy).ID] = struct{}{}
}
for _, acc := range accounts {
proxyID := strings.TrimSpace(acc.ProxyID)
if proxyID == "" {
continue
}
if _, ok := ids[proxyID]; !ok {
return fmt.Errorf("account proxy_id references unknown proxy: %s", proxyID)
}
}
return nil
}

View File

@@ -13,6 +13,7 @@ import (
)
func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) {
clients := c.requestClientsForAccount(acc)
payload := map[string]any{
"password": strings.TrimSpace(acc.Password),
"device_id": "deepseek_to_api",
@@ -27,7 +28,7 @@ func (c *Client) Login(ctx context.Context, acc config.Account) (string, error)
} else {
return "", errors.New("missing email/mobile")
}
resp, err := c.postJSON(ctx, c.regular, DeepSeekLoginURL, BaseHeaders, payload)
resp, err := c.postJSON(ctx, clients.regular, clients.fallback, DeepSeekLoginURL, BaseHeaders, payload)
if err != nil {
return "", err
}
@@ -52,11 +53,12 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
clients := c.requestClientsForAuth(ctx, a)
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreateSessionURL, headers, map[string]any{"agent": "chat"})
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekCreateSessionURL, headers, map[string]any{"agent": "chat"})
if err != nil {
config.Logger.Warn("[create_session] request error", "error", err, "account", a.AccountID)
attempts++
@@ -94,11 +96,12 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
clients := c.requestClientsForAuth(ctx, a)
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"})
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"})
if err != nil {
config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID)
attempts++
@@ -109,7 +112,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
challenge, _ := bizData["challenge"].(map[string]any)
answer, err := c.powSolver.Compute(ctx, challenge)
answer, err := ComputePow(ctx, challenge)
if err != nil {
attempts++
continue

View File

@@ -10,18 +10,20 @@ import (
"ds2api/internal/auth"
"ds2api/internal/config"
trans "ds2api/internal/deepseek/transport"
)
func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
clients := c.requestClientsForAuth(ctx, a)
headers := c.authHeaders(a.DeepSeekToken)
headers["x-ds-pow-response"] = powResp
captureSession := c.capture.Start("deepseek_completion", DeepSeekCompletionURL, a.AccountID, payload)
attempts := 0
for attempts < maxAttempts {
resp, err := c.streamPost(ctx, DeepSeekCompletionURL, headers, payload)
resp, err := c.streamPost(ctx, clients.stream, DeepSeekCompletionURL, headers, payload)
if err != nil {
attempts++
time.Sleep(time.Second)
@@ -44,11 +46,12 @@ func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payloa
return nil, errors.New("completion failed")
}
func (c *Client) streamPost(ctx context.Context, url string, headers map[string]string, payload any) (*http.Response, error) {
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 {
return nil, err
}
clients := c.requestClientsFromContext(ctx)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return nil, err
@@ -56,7 +59,7 @@ func (c *Client) streamPost(ctx context.Context, url string, headers map[string]
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := c.stream.Do(req)
resp, err := doer.Do(req)
if err != nil {
config.Logger.Warn("[deepseek] fingerprint stream request failed, fallback to std transport", "url", url, "error", err)
req2, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
@@ -66,7 +69,7 @@ func (c *Client) streamPost(ctx context.Context, url string, headers map[string]
for k, v := range headers {
req2.Header.Set(k, v)
}
return c.fallbackS.Do(req2)
return clients.fallbackS.Do(req2)
}
return resp, nil
}

View File

@@ -51,6 +51,7 @@ func (c *Client) callContinue(ctx context.Context, a *auth.RequestAuth, sessionI
if strings.TrimSpace(sessionID) == "" || responseMessageID <= 0 {
return nil, errors.New("missing continue identifiers")
}
clients := c.requestClientsForAuth(ctx, a)
headers := c.authHeaders(a.DeepSeekToken)
headers["x-ds-pow-response"] = powResp
payload := map[string]any{
@@ -60,7 +61,7 @@ func (c *Client) callContinue(ctx context.Context, a *auth.RequestAuth, sessionI
}
config.Logger.Info("[auto_continue] calling continue", "session_id", sessionID, "message_id", responseMessageID)
captureSession := c.capture.Start("deepseek_continue", DeepSeekContinueURL, a.AccountID, payload)
resp, err := c.streamPost(ctx, DeepSeekContinueURL, headers, payload)
resp, err := c.streamPost(ctx, clients.stream, DeepSeekContinueURL, headers, payload)
if err != nil {
return nil, err
}

View File

@@ -31,7 +31,7 @@ func TestCallContinuePropagatesPowHeaderToFallbackRequest(t *testing.T) {
var seenURL string
client := &Client{
stream: failingDoer{err: errors.New("stream transport failed")},
stream: failingDoer{err: errors.New("stream transport failed")},
fallbackS: &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
seenPow = req.Header.Get("x-ds-pow-response")
@@ -54,8 +54,7 @@ func TestCallContinuePropagatesPowHeaderToFallbackRequest(t *testing.T) {
if err != nil {
t.Fatalf("callContinue returned error: %v", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if seenPow != "pow-response-abc" {
t.Fatalf("continue request pow header=%q want=%q", seenPow, "pow-response-abc")
}
@@ -105,8 +104,7 @@ func TestCallCompletionAutoContinueThreadsPowHeader(t *testing.T) {
if err != nil {
t.Fatalf("CallCompletion returned error: %v", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
out, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read auto-continued body failed: %v", err)

View File

@@ -3,6 +3,7 @@ package deepseek
import (
"context"
"net/http"
"sync"
"time"
"ds2api/internal/auth"
@@ -23,24 +24,27 @@ type Client struct {
stream trans.Doer
fallback *http.Client
fallbackS *http.Client
powSolver *PowSolver
maxRetries int
proxyClientsMu sync.RWMutex
proxyClients map[string]requestClients
}
func NewClient(store *config.Store, resolver *auth.Resolver) *Client {
return &Client{
Store: store,
Auth: resolver,
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},
powSolver: NewPowSolver(config.WASMPath()),
maxRetries: 3,
Store: store,
Auth: resolver,
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},
maxRetries: 3,
proxyClients: map[string]requestClients{},
}
}
func (c *Client) PreloadPow(ctx context.Context) error {
return c.powSolver.init(ctx)
// PreloadPow 保留兼容接口,纯 Go 实现无需预加载。
func (c *Client) PreloadPow(_ context.Context) error {
return nil
}

View File

@@ -19,7 +19,7 @@ func readResponseBody(resp *http.Response) ([]byte, error) {
if err != nil {
return nil, err
}
defer gz.Close()
defer func() { _ = gz.Close() }()
reader = gz
case "br":
reader = brotli.NewReader(resp.Body)

View File

@@ -11,8 +11,8 @@ import (
trans "ds2api/internal/deepseek/transport"
)
func (c *Client) postJSON(ctx context.Context, doer trans.Doer, url string, headers map[string]string, payload any) (map[string]any, error) {
body, status, err := c.postJSONWithStatus(ctx, doer, url, headers, payload)
func (c *Client) postJSON(ctx context.Context, doer trans.Doer, fallback trans.Doer, url string, headers map[string]string, payload any) (map[string]any, error) {
body, status, err := c.postJSONWithStatus(ctx, doer, fallback, url, headers, payload)
if err != nil {
return nil, err
}
@@ -22,7 +22,7 @@ func (c *Client) postJSON(ctx context.Context, doer trans.Doer, url string, head
return body, nil
}
func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string, payload any) (map[string]any, int, error) {
func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, fallback trans.Doer, url string, headers map[string]string, payload any) (map[string]any, int, error) {
b, err := json.Marshal(payload)
if err != nil {
return nil, 0, err
@@ -44,12 +44,12 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
for k, v := range headers {
req2.Header.Set(k, v)
}
resp, err = c.fallback.Do(req2)
resp, err = fallback.Do(req2)
if err != nil {
return nil, 0, err
}
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
payloadBytes, err := readResponseBody(resp)
if err != nil {
return nil, resp.StatusCode, err
@@ -64,6 +64,7 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
}
func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, int, error) {
clients := c.requestClientsFromContext(ctx)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
@@ -81,12 +82,12 @@ func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url str
for k, v := range headers {
req2.Header.Set(k, v)
}
resp, err = c.fallback.Do(req2)
resp, err = clients.fallback.Do(req2)
if err != nil {
return nil, 0, err
}
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
payloadBytes, err := readResponseBody(resp)
if err != nil {
return nil, resp.StatusCode, err

View File

@@ -0,0 +1,52 @@
package deepseek
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
)
func TestPostJSONWithStatusUsesProvidedFallbackClient(t *testing.T) {
var fallbackCalled bool
client := &Client{}
primary := failingDoer{err: errors.New("primary failed")}
fallbackDoer := doerFunc(func(req *http.Request) (*http.Response, error) {
fallbackCalled = true
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
Request: req,
}, nil
})
resp, status, err := client.postJSONWithStatus(
context.Background(),
primary,
fallbackDoer,
"https://example.com/api",
map[string]string{"x-test": "1"},
map[string]any{"foo": "bar"},
)
if err != nil {
t.Fatalf("postJSONWithStatus error: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status=%d want=%d", status, http.StatusOK)
}
if !fallbackCalled {
t.Fatal("expected provided fallback doer to be called")
}
if ok, _ := resp["ok"].(bool); !ok {
t.Fatalf("unexpected response body: %#v", resp)
}
}
type doerFunc func(*http.Request) (*http.Response, error)
func (f doerFunc) Do(req *http.Request) (*http.Response, error) {
return f(req)
}

View File

@@ -36,6 +36,7 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
clients := c.requestClientsForAuth(ctx, a)
stats := &SessionStats{
AccountID: a.AccountID,
@@ -50,7 +51,7 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
// 构建请求 URL
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
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)
attempts++
@@ -106,10 +107,11 @@ func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAt
// GetSessionCountForToken 直接使用 token 获取会话数量(直通模式)
func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*SessionStats, error) {
clients := c.requestClientsFromContext(ctx)
headers := c.authHeaders(token)
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers)
if err != nil {
return nil, err
}
@@ -160,7 +162,7 @@ func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
// 如果没有 token尝试登录获取
if token == "" {
var err error
token, err = c.Login(ctx, acc)
token, err = c.Login(auth.WithAuth(ctx, &auth.RequestAuth{AccountID: acc.Identifier(), Account: acc}), acc)
if err != nil {
results = append(results, &SessionStats{
AccountID: accountID,
@@ -171,7 +173,8 @@ func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
}
}
stats, err := c.GetSessionCountForToken(ctx, token)
ctxWithAuth := auth.WithAuth(ctx, &auth.RequestAuth{AccountID: acc.Identifier(), Account: acc, DeepSeekToken: token})
stats, err := c.GetSessionCountForToken(ctxWithAuth, token)
if err != nil {
results = append(results, &SessionStats{
AccountID: accountID,
@@ -190,6 +193,7 @@ func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
// FetchSessionPage 获取会话列表(支持分页)
func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, cursor string) ([]SessionInfo, bool, error) {
clients := c.requestClientsForAuth(ctx, a)
headers := c.authHeaders(a.DeepSeekToken)
// 构建请求 URL
@@ -200,7 +204,7 @@ func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, curs
}
reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers)
if err != nil {
return nil, false, err
}

View File

@@ -22,6 +22,7 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
clients := c.requestClientsForAuth(ctx, a)
result := &DeleteSessionResult{
SessionID: sessionID,
@@ -42,7 +43,7 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
"chat_session_id": sessionID,
}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
attempts++
@@ -81,6 +82,7 @@ func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, session
// DeleteSessionForToken 直接使用 token 删除会话(直通模式)
func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*DeleteSessionResult, error) {
clients := c.requestClientsFromContext(ctx)
result := &DeleteSessionResult{
SessionID: sessionID,
}
@@ -95,7 +97,7 @@ func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessio
"chat_session_id": sessionID,
}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
result.ErrorMessage = err.Error()
return result, err
@@ -114,10 +116,11 @@ func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessio
// DeleteAllSessions 删除所有会话(谨慎使用)
func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) error {
clients := c.requestClientsForAuth(ctx, a)
headers := c.authHeaders(a.DeepSeekToken)
payload := map[string]any{}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekDeleteAllSessionsURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_all_sessions] request error", "error", err)
return err
@@ -135,10 +138,11 @@ func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) err
// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) error {
clients := c.requestClientsFromContext(ctx)
headers := c.authHeaders(token)
payload := map[string]any{}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekDeleteAllSessionsURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_all_sessions_for_token] request error", "error", err)
return err

View File

@@ -6,13 +6,13 @@ import (
)
const (
DeepSeekHost = "chat.deepseek.com"
DeepSeekLoginURL = "https://chat.deepseek.com/api/v0/users/login"
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
DeepSeekContinueURL = "https://chat.deepseek.com/api/v0/chat/continue"
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
DeepSeekHost = "chat.deepseek.com"
DeepSeekLoginURL = "https://chat.deepseek.com/api/v0/users/login"
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
DeepSeekContinueURL = "https://chat.deepseek.com/api/v0/chat/continue"
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all"
)

View File

@@ -12,7 +12,6 @@
"skip_contains_patterns": [
"quasi_status",
"elapsed_secs",
"token_usage",
"pending_fragment",
"conversation_mode",
"fragments/-1/status",

View File

@@ -105,43 +105,16 @@ func TestBuildPowHeaderEmptyChallenge(t *testing.T) {
}
}
// ─── PowSolver pool size ─────────────────────────────────────────────
func TestPowPoolSizeFromEnvDefault(t *testing.T) {
t.Setenv("DS2API_POW_POOL_SIZE", "")
got := powPoolSizeFromEnv()
if got < 1 {
t.Fatalf("expected positive default pool size, got %d", got)
}
}
func TestPowPoolSizeFromEnvInvalid(t *testing.T) {
t.Setenv("DS2API_POW_POOL_SIZE", "abc")
got := powPoolSizeFromEnv()
if got < 1 {
t.Fatalf("expected positive default for invalid, got %d", got)
}
}
func TestPowPoolSizeFromEnvSpecificValue(t *testing.T) {
t.Setenv("DS2API_POW_POOL_SIZE", "5")
got := powPoolSizeFromEnv()
if got != 5 {
t.Fatalf("expected 5, got %d", got)
}
}
// ─── NewClient ───────────────────────────────────────────────────────
func TestNewClientInitialState(t *testing.T) {
client := NewClient(nil, nil)
if client.powSolver == nil {
t.Fatal("expected powSolver to be initialized")
if client == nil {
t.Fatal("expected non-nil client")
}
}
func TestNewClientPreloadPowIdempotent(t *testing.T) {
t.Setenv("DS2API_POW_POOL_SIZE", "1")
client := NewClient(nil, nil)
if err := client.PreloadPow(context.Background()); err != nil {
t.Fatalf("first preload failed: %v", err)
@@ -150,16 +123,3 @@ func TestNewClientPreloadPowIdempotent(t *testing.T) {
t.Fatalf("second preload failed: %v", err)
}
}
// ─── PowSolver init and module pool ──────────────────────────────────
func TestPowSolverPoolSizeMatchesEnv(t *testing.T) {
t.Setenv("DS2API_POW_POOL_SIZE", "2")
solver := NewPowSolver("test.wasm")
if err := solver.init(context.Background()); err != nil {
t.Fatalf("init failed: %v", err)
}
if cap(solver.pool) != 2 {
t.Fatalf("expected pool capacity 2, got %d", cap(solver.pool))
}
}

View File

@@ -1,6 +0,0 @@
package deepseek
import _ "embed"
//go:embed assets/sha3_wasm_bg.7b9ca65ddd.wasm
var embeddedWASM []byte

View File

@@ -3,218 +3,27 @@ package deepseek
import (
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"math"
"os"
stdruntime "runtime"
"strconv"
"sync"
"ds2api/internal/config"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"ds2api/pow"
)
type PowSolver struct {
wasmPath string
once sync.Once
err error
runtime wazero.Runtime
compiled wazero.CompiledModule
pool chan *pooledModule
poolSize int
}
type pooledModule struct {
mod api.Module
stackFn api.Function
allocFn api.Function
freeFn api.Function
solveFn api.Function
}
func NewPowSolver(wasmPath string) *PowSolver {
return &PowSolver{wasmPath: wasmPath}
}
func (p *PowSolver) init(ctx context.Context) error {
p.once.Do(func() {
wasmBytes, err := os.ReadFile(p.wasmPath)
if err != nil {
if len(embeddedWASM) == 0 {
p.err = err
return
}
wasmBytes = embeddedWASM
}
p.runtime = wazero.NewRuntime(ctx)
p.compiled, p.err = p.runtime.CompileModule(ctx, wasmBytes)
if p.err == nil {
p.poolSize = powPoolSizeFromEnv()
p.pool = make(chan *pooledModule, p.poolSize)
for range p.poolSize {
inst, err := p.createModule(ctx)
if err != nil {
p.err = err
return
}
p.pool <- inst
}
}
})
return p.err
}
func (p *PowSolver) Compute(ctx context.Context, challenge map[string]any) (int64, error) {
if err := p.init(ctx); err != nil {
return 0, err
}
// ComputePow 使用纯 Go 实现求解 PoW challenge (DeepSeekHashV1)。
func ComputePow(ctx context.Context, challenge map[string]any) (int64, error) {
algo, _ := challenge["algorithm"].(string)
if algo != "DeepSeekHashV1" {
return 0, errors.New("unsupported algorithm")
}
challengeStr, _ := challenge["challenge"].(string)
salt, _ := challenge["salt"].(string)
signature, _ := challenge["signature"].(string)
targetPath, _ := challenge["target_path"].(string)
_ = signature
_ = targetPath
difficulty := toFloat64(challenge["difficulty"], 144000)
expireAt := toInt64(challenge["expire_at"], 1680000000)
prefix := salt + "_" + itoa(expireAt) + "_"
difficulty := toInt64FromFloat(challenge["difficulty"], 144000)
pm, err := p.acquireModule(ctx)
if err != nil {
return 0, err
}
defer p.releaseModule(pm)
mem := pm.mod.Memory()
if mem == nil {
return 0, errors.New("wasm memory missing")
}
retPtrs, err := pm.stackFn.Call(ctx, uint64(uint32(^uint32(15)))) // -16 i32
if err != nil || len(retPtrs) == 0 {
return 0, errors.New("stack alloc failed")
}
retptr := uint32(retPtrs[0])
defer func() {
_, _ = pm.stackFn.Call(context.Background(), 16)
}()
chPtr, chLen, err := writeUTF8(ctx, pm.allocFn, mem, challengeStr)
if err != nil {
return 0, err
}
defer freeUTF8(pm.freeFn, chPtr, chLen)
prefixPtr, prefixLen, err := writeUTF8(ctx, pm.allocFn, mem, prefix)
if err != nil {
return 0, err
}
defer freeUTF8(pm.freeFn, prefixPtr, prefixLen)
if _, err := pm.solveFn.Call(ctx,
uint64(retptr),
uint64(chPtr), uint64(chLen),
uint64(prefixPtr), uint64(prefixLen),
math.Float64bits(difficulty),
); err != nil {
return 0, err
}
statusBytes, ok := mem.Read(retptr, 4)
if !ok {
return 0, errors.New("read status failed")
}
status := int32(binary.LittleEndian.Uint32(statusBytes))
valueBytes, ok := mem.Read(retptr+8, 8)
if !ok {
return 0, errors.New("read value failed")
}
value := math.Float64frombits(binary.LittleEndian.Uint64(valueBytes))
if status == 0 {
return 0, errors.New("pow solve failed")
}
return int64(value), nil
}
func (p *PowSolver) createModule(ctx context.Context) (*pooledModule, error) {
mod, err := p.runtime.InstantiateModule(ctx, p.compiled, wazero.NewModuleConfig())
if err != nil {
return nil, err
}
stackFn := mod.ExportedFunction("__wbindgen_add_to_stack_pointer")
allocFn := mod.ExportedFunction("__wbindgen_export_0")
solveFn := mod.ExportedFunction("wasm_solve")
if stackFn == nil || allocFn == nil || solveFn == nil {
_ = mod.Close(context.Background())
return nil, errors.New("required wasm exports missing")
}
return &pooledModule{
mod: mod,
stackFn: stackFn,
allocFn: allocFn,
freeFn: mod.ExportedFunction("__wbindgen_export_2"),
solveFn: solveFn,
}, nil
}
func (p *PowSolver) acquireModule(ctx context.Context) (*pooledModule, error) {
if p.pool != nil {
for {
select {
case pm := <-p.pool:
if pm != nil {
return pm, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
return p.createModule(ctx)
}
func (p *PowSolver) releaseModule(pm *pooledModule) {
if pm == nil || pm.mod == nil {
return
}
if p.pool != nil {
select {
case p.pool <- pm:
return
default:
}
}
_ = pm.mod.Close(context.Background())
}
func writeUTF8(ctx context.Context, allocFn api.Function, mem api.Memory, text string) (uint32, uint32, error) {
data := []byte(text)
res, err := allocFn.Call(ctx, uint64(len(data)), 1)
if err != nil || len(res) == 0 {
return 0, 0, errors.New("alloc failed")
}
ptr := uint32(res[0])
if !mem.Write(ptr, data) {
return 0, 0, errors.New("mem write failed")
}
return ptr, uint32(len(data)), nil
}
func freeUTF8(freeFn api.Function, ptr, size uint32) {
if freeFn == nil || ptr == 0 || size == 0 {
return
}
_, _ = freeFn.Call(context.Background(), uint64(ptr), uint64(size), 1)
return pow.SolvePow(ctx, challengeStr, salt, expireAt, difficulty)
}
// BuildPowHeader 序列化 {algorithm,challenge,salt,answer,signature,target_path} 为 base64(JSON)。
func BuildPowHeader(challenge map[string]any, answer int64) (string, error) {
payload := map[string]any{
"algorithm": challenge["algorithm"],
@@ -257,32 +66,7 @@ func toInt64(v any, d int64) int64 {
}
}
func itoa(n int64) string {
return strconv.FormatInt(n, 10)
}
func powPoolSizeFromEnv() int {
const fallback = 4
n := fallback
if cpus := stdruntime.GOMAXPROCS(0); cpus > 0 {
n = cpus
}
if raw := os.Getenv("DS2API_POW_POOL_SIZE"); raw != "" {
if v, err := strconv.Atoi(raw); err == nil && v > 0 {
n = v
}
}
if n > 64 {
return 64
}
return n
}
func PreloadWASM(wasmPath string) {
solver := NewPowSolver(wasmPath)
if err := solver.init(context.Background()); err != nil {
config.Logger.Warn("[WASM] preload failed", "error", err)
return
}
config.Logger.Info("[WASM] module preloaded", "path", wasmPath)
// toInt64FromFloat 与 toInt64 等价,仅名称区分用途。
func toInt64FromFloat(v any, d int64) int64 {
return toInt64(v, d)
}

View File

@@ -3,66 +3,18 @@ package deepseek
import (
"context"
"testing"
"time"
)
func TestPowPoolSizeFromEnv(t *testing.T) {
t.Setenv("DS2API_POW_POOL_SIZE", "3")
if got := powPoolSizeFromEnv(); got != 3 {
t.Fatalf("expected pool size 3, got %d", got)
}
}
func TestPowSolverAcquireReleaseReusesModule(t *testing.T) {
t.Setenv("DS2API_POW_POOL_SIZE", "1")
solver := NewPowSolver("missing-file.wasm")
if err := solver.init(context.Background()); err != nil {
t.Fatalf("init failed: %v", err)
}
pm1, err := solver.acquireModule(context.Background())
if err != nil {
t.Fatalf("acquire first module failed: %v", err)
}
solver.releaseModule(pm1)
pm2, err := solver.acquireModule(context.Background())
if err != nil {
t.Fatalf("acquire second module failed: %v", err)
}
if pm1 != pm2 {
t.Fatalf("expected pooled module reuse, got different instances")
}
solver.releaseModule(pm2)
}
func TestPowSolverAcquireHonorsContextWhenPoolExhausted(t *testing.T) {
t.Setenv("DS2API_POW_POOL_SIZE", "1")
solver := NewPowSolver("missing-file.wasm")
if err := solver.init(context.Background()); err != nil {
t.Fatalf("init failed: %v", err)
}
held, err := solver.acquireModule(context.Background())
if err != nil {
t.Fatalf("acquire held module failed: %v", err)
}
defer solver.releaseModule(held)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := solver.acquireModule(ctx); err == nil {
t.Fatalf("expected context cancellation while pool is exhausted")
}
}
func TestClientPreloadPowUsesClientSolver(t *testing.T) {
t.Setenv("DS2API_POW_POOL_SIZE", "1")
func TestPreloadPowNoOp(t *testing.T) {
client := NewClient(nil, nil)
if err := client.PreloadPow(context.Background()); err != nil {
t.Fatalf("preload failed: %v", err)
}
if client.powSolver.runtime == nil || client.powSolver.compiled == nil {
t.Fatalf("expected client pow solver to be initialized")
t.Fatalf("PreloadPow should be no-op, got error: %v", err)
}
}
func TestComputePowUnsupportedAlgorithm(t *testing.T) {
_, err := ComputePow(context.Background(), map[string]any{"algorithm": "unknown"})
if err == nil {
t.Fatal("expected error for unsupported algorithm")
}
}

239
internal/deepseek/proxy.go Normal file
View File

@@ -0,0 +1,239 @@
package deepseek
import (
"context"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/net/proxy"
"ds2api/internal/auth"
"ds2api/internal/config"
trans "ds2api/internal/deepseek/transport"
)
type requestClients struct {
regular trans.Doer
stream trans.Doer
fallback *http.Client
fallbackS *http.Client
}
type hostLookupFunc func(ctx context.Context, network, host string) ([]string, error)
var proxyConnectivityTestURL = "https://chat.deepseek.com/"
var defaultHostLookup hostLookupFunc = func(ctx context.Context, _ string, host string) ([]string, error) {
return net.DefaultResolver.LookupHost(ctx, host)
}
func proxyDialAddress(ctx context.Context, proxyType, address string, lookup hostLookupFunc) (string, error) {
proxyType = strings.ToLower(strings.TrimSpace(proxyType))
if proxyType != "socks5" {
return address, nil
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return "", err
}
if net.ParseIP(host) != nil {
return address, nil
}
if lookup == nil {
lookup = defaultHostLookup
}
addrs, err := lookup(ctx, "ip", host)
if err != nil {
return "", err
}
if len(addrs) == 0 {
return "", fmt.Errorf("no ip address resolved for %s", host)
}
return net.JoinHostPort(addrs[0], port), nil
}
func proxyCacheKey(proxyCfg config.Proxy) string {
proxyCfg = config.NormalizeProxy(proxyCfg)
return strings.Join([]string{
proxyCfg.ID,
proxyCfg.Type,
strings.ToLower(proxyCfg.Host),
strconv.Itoa(proxyCfg.Port),
proxyCfg.Username,
proxyCfg.Password,
}, "|")
}
func proxyDialContext(proxyCfg config.Proxy) (trans.DialContextFunc, error) {
proxyCfg = config.NormalizeProxy(proxyCfg)
var authCfg *proxy.Auth
if proxyCfg.Username != "" || proxyCfg.Password != "" {
authCfg = &proxy.Auth{User: proxyCfg.Username, Password: proxyCfg.Password}
}
forward := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
dialer, err := proxy.SOCKS5("tcp", net.JoinHostPort(proxyCfg.Host, strconv.Itoa(proxyCfg.Port)), authCfg, forward)
if err != nil {
return nil, err
}
return func(ctx context.Context, network, address string) (net.Conn, error) {
target, err := proxyDialAddress(ctx, proxyCfg.Type, address, defaultHostLookup)
if err != nil {
return nil, err
}
if ctxDialer, ok := dialer.(proxy.ContextDialer); ok {
return ctxDialer.DialContext(ctx, network, target)
}
return dialer.Dial(network, target)
}, nil
}
func (c *Client) defaultRequestClients() requestClients {
return requestClients{
regular: c.regular,
stream: c.stream,
fallback: c.fallback,
fallbackS: c.fallbackS,
}
}
func (c *Client) resolveProxyForAccount(acc config.Account) (config.Proxy, bool) {
if c == nil || c.Store == nil {
return config.Proxy{}, false
}
proxyID := strings.TrimSpace(acc.ProxyID)
if proxyID == "" {
return config.Proxy{}, false
}
snap := c.Store.Snapshot()
for _, proxyCfg := range snap.Proxies {
proxyCfg = config.NormalizeProxy(proxyCfg)
if proxyCfg.ID == proxyID {
return proxyCfg, true
}
}
return config.Proxy{}, false
}
func (c *Client) requestClientsFromContext(ctx context.Context) requestClients {
if a, ok := auth.FromContext(ctx); ok {
return c.requestClientsForAccount(a.Account)
}
return c.defaultRequestClients()
}
func (c *Client) requestClientsForAuth(ctx context.Context, a *auth.RequestAuth) requestClients {
if a != nil {
return c.requestClientsForAccount(a.Account)
}
return c.requestClientsFromContext(ctx)
}
func (c *Client) requestClientsForAccount(acc config.Account) requestClients {
proxyCfg, ok := c.resolveProxyForAccount(acc)
if !ok {
return c.defaultRequestClients()
}
key := proxyCacheKey(proxyCfg)
c.proxyClientsMu.RLock()
cached, ok := c.proxyClients[key]
c.proxyClientsMu.RUnlock()
if ok {
return cached
}
dialContext, err := proxyDialContext(proxyCfg)
if err != nil {
config.Logger.Warn("[proxy] build dialer failed", "proxy_id", proxyCfg.ID, "error", err)
return c.defaultRequestClients()
}
bundle := requestClients{
regular: trans.NewWithDialContext(60*time.Second, dialContext),
stream: trans.NewWithDialContext(0, dialContext),
fallback: trans.NewFallbackClient(60*time.Second, dialContext),
fallbackS: trans.NewFallbackClient(0, dialContext),
}
c.proxyClientsMu.Lock()
if c.proxyClients == nil {
c.proxyClients = make(map[string]requestClients)
}
c.proxyClients[key] = bundle
c.proxyClientsMu.Unlock()
return bundle
}
func applyProxyConnectivityHeaders(req *http.Request) {
if req == nil {
return
}
for key, value := range BaseHeaders {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
req.Header.Set(key, value)
}
}
func proxyConnectivityStatus(statusCode int) (bool, string) {
switch {
case statusCode >= 200 && statusCode < 300:
return true, fmt.Sprintf("代理可达,目标返回 HTTP %d", statusCode)
case statusCode >= 300 && statusCode < 500:
return true, fmt.Sprintf("代理可达,但目标返回 HTTP %d可能是风控或挑战", statusCode)
default:
return false, fmt.Sprintf("目标返回 HTTP %d", statusCode)
}
}
func TestProxyConnectivity(ctx context.Context, proxyCfg config.Proxy) map[string]any {
start := time.Now()
proxyCfg = config.NormalizeProxy(proxyCfg)
result := map[string]any{
"success": false,
"proxy_id": proxyCfg.ID,
"proxy_type": proxyCfg.Type,
"response_time": 0,
}
if err := config.ValidateProxyConfig([]config.Proxy{proxyCfg}); err != nil {
result["message"] = "代理配置无效: " + err.Error()
return result
}
dialContext, err := proxyDialContext(proxyCfg)
if err != nil {
result["message"] = "代理拨号器初始化失败: " + err.Error()
return result
}
client := trans.NewFallbackClient(15*time.Second, dialContext)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, proxyConnectivityTestURL, nil)
if err != nil {
result["message"] = err.Error()
return result
}
applyProxyConnectivityHeaders(req)
resp, err := client.Do(req)
result["response_time"] = int(time.Since(start).Milliseconds())
if err != nil {
result["message"] = err.Error()
return result
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
config.Logger.Warn("[proxy] close response body failed", "proxy_id", proxyCfg.ID, "error", closeErr)
}
}()
result["status_code"] = resp.StatusCode
result["success"], result["message"] = proxyConnectivityStatus(resp.StatusCode)
return result
}

View File

@@ -0,0 +1,85 @@
package deepseek
import (
"context"
"net/http"
"strings"
"testing"
)
func TestProxyDialAddressUsesLocalResolutionForSocks5(t *testing.T) {
ctx := context.Background()
resolved, err := proxyDialAddress(ctx, "socks5", "example.com:443", func(_ context.Context, network, host string) ([]string, error) {
if network != "ip" {
t.Fatalf("unexpected lookup network: %q", network)
}
if host != "example.com" {
t.Fatalf("unexpected lookup host: %q", host)
}
return []string{"203.0.113.10"}, nil
})
if err != nil {
t.Fatalf("proxyDialAddress returned error: %v", err)
}
if resolved != "203.0.113.10:443" {
t.Fatalf("expected locally resolved address, got %q", resolved)
}
}
func TestProxyDialAddressKeepsHostnameForSocks5h(t *testing.T) {
ctx := context.Background()
lookups := 0
resolved, err := proxyDialAddress(ctx, "socks5h", "example.com:443", func(_ context.Context, network, host string) ([]string, error) {
lookups++
return []string{"203.0.113.10"}, nil
})
if err != nil {
t.Fatalf("proxyDialAddress returned error: %v", err)
}
if resolved != "example.com:443" {
t.Fatalf("expected hostname preserved for remote DNS, got %q", resolved)
}
if lookups != 0 {
t.Fatalf("expected no local DNS lookup for socks5h, got %d", lookups)
}
}
func TestApplyProxyConnectivityHeadersUsesBaseHeaders(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "https://chat.deepseek.com/", nil)
if err != nil {
t.Fatalf("http.NewRequest returned error: %v", err)
}
applyProxyConnectivityHeaders(req)
for key, want := range BaseHeaders {
if got := req.Header.Get(key); got != want {
t.Fatalf("expected header %q=%q, got %q", key, want, got)
}
}
}
func TestProxyConnectivityStatus(t *testing.T) {
cases := []struct {
name string
statusCode int
success bool
wantText string
}{
{name: "ok", statusCode: 200, success: true, wantText: "HTTP 200"},
{name: "challenge", statusCode: 403, success: true, wantText: "风控或挑战"},
{name: "upstream error", statusCode: 502, success: false, wantText: "HTTP 502"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
success, message := proxyConnectivityStatus(tc.statusCode)
if success != tc.success {
t.Fatalf("expected success=%v, got %v", tc.success, success)
}
if message == "" || !strings.Contains(message, tc.wantText) {
t.Fatalf("expected message to contain %q, got %q", tc.wantText, message)
}
})
}
}

View File

@@ -15,21 +15,33 @@ type Doer interface {
Do(req *http.Request) (*http.Response, error)
}
type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
type Client struct {
http *http.Client
}
func New(timeout time.Duration) *Client {
return NewWithDialContext(timeout, nil)
}
func NewWithDialContext(timeout time.Duration, dialContext DialContextFunc) *Client {
useEnvProxy := dialContext == nil
if dialContext == nil {
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
}
base := &http.Transport{
Proxy: http.ProxyFromEnvironment,
ForceAttemptHTTP2: false,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
DialContext: (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
DialTLSContext: safariTLSDialer(),
DialContext: dialContext,
DialTLSContext: safariTLSDialer(dialContext),
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
if useEnvProxy {
base.Proxy = http.ProxyFromEnvironment
}
return &Client{http: &http.Client{Timeout: timeout, Transport: base}}
}
@@ -37,10 +49,31 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
return c.http.Do(req)
}
func safariTLSDialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
var dialer net.Dialer
func NewFallbackClient(timeout time.Duration, dialContext DialContextFunc) *http.Client {
useEnvProxy := dialContext == nil
if dialContext == nil {
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
}
base := &http.Transport{
ForceAttemptHTTP2: false,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
DialContext: dialContext,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
if useEnvProxy {
base.Proxy = http.ProxyFromEnvironment
}
return &http.Client{Timeout: timeout, Transport: base}
}
func safariTLSDialer(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
}
return func(ctx context.Context, network, addr string) (net.Conn, error) {
plainConn, err := dialer.DialContext(ctx, network, addr)
plainConn, err := dialContext(ctx, network, addr)
if err != nil {
return nil, err
}

View File

@@ -1,6 +1,7 @@
package claude
import (
"ds2api/internal/toolcall"
"fmt"
"time"
@@ -8,9 +9,9 @@ import (
)
func BuildMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any {
detected := util.ParseToolCalls(finalText, toolNames)
detected := toolcall.ParseToolCalls(finalText, toolNames)
if len(detected) == 0 && finalText == "" && finalThinking != "" {
detected = util.ParseToolCalls(finalThinking, toolNames)
detected = toolcall.ParseToolCalls(finalThinking, toolNames)
}
content := make([]map[string]any, 0, 4)
if finalThinking != "" {

View File

@@ -1,14 +1,13 @@
package openai
import (
"ds2api/internal/toolcall"
"strings"
"time"
"ds2api/internal/util"
)
func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, toolNames)
finishReason := "stop"
messageObj := map[string]any{"role": "assistant", "content": finalText}
if strings.TrimSpace(finalThinking) != "" {
@@ -16,7 +15,7 @@ func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalT
}
if len(detected.Calls) > 0 {
finishReason = "tool_calls"
messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected.Calls)
messageObj["tool_calls"] = toolcall.FormatOpenAIToolCalls(detected.Calls)
messageObj["content"] = nil
}

View File

@@ -1,19 +1,18 @@
package openai
import (
"ds2api/internal/toolcall"
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
"ds2api/internal/util"
)
func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
// Strict mode: only standalone, structured tool-call payloads are treated
// as executable tool calls.
detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, toolNames)
exposedOutputText := finalText
output := make([]any, 0, 2)
if len(detected.Calls) > 0 {
@@ -71,7 +70,7 @@ func BuildResponseObjectFromItems(responseID, model, finalPrompt, finalThinking,
}
}
func toResponsesFunctionCallItems(toolCalls []util.ParsedToolCall) []any {
func toResponsesFunctionCallItems(toolCalls []toolcall.ParsedToolCall) []any {
if len(toolCalls) == 0 {
return nil
}

View File

@@ -71,7 +71,6 @@ func BuildResponsesTextDeltaPayload(responseID, itemID string, outputIndex, cont
}
}
func BuildResponsesTextDonePayload(responseID, itemID string, outputIndex, contentIndex int, text string) map[string]any {
return map[string]any{
"type": "response.output_text.done",

View File

@@ -20,6 +20,10 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
};
}
const usage = extractAccumulatedTokenUsage(chunk);
const promptTokens = usage.prompt;
const outputTokens = usage.output;
if (Object.prototype.hasOwnProperty.call(chunk, 'error')) {
return {
parsed: true,
@@ -27,13 +31,13 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: true,
contentFilter: false,
errorMessage: formatErrorMessage(chunk.error),
outputTokens: 0,
promptTokens,
outputTokens,
newType: currentType,
};
}
const pathValue = asString(chunk.p);
const outputTokens = extractAccumulatedTokenUsage(chunk);
if (hasContentFilterStatus(chunk)) {
return {
@@ -42,6 +46,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: true,
contentFilter: true,
errorMessage: '',
promptTokens,
outputTokens,
newType: currentType,
};
@@ -54,6 +59,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: false,
contentFilter: false,
errorMessage: '',
promptTokens,
outputTokens,
newType: currentType,
};
@@ -66,6 +72,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: true,
contentFilter: false,
errorMessage: '',
promptTokens,
outputTokens,
newType: currentType,
};
@@ -76,6 +83,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: false,
contentFilter: false,
errorMessage: '',
promptTokens,
outputTokens,
newType: currentType,
};
@@ -88,6 +96,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: false,
contentFilter: false,
errorMessage: '',
promptTokens,
outputTokens,
newType: currentType,
};
@@ -156,6 +165,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: true,
contentFilter: false,
errorMessage: '',
promptTokens,
outputTokens,
newType,
};
@@ -167,6 +177,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: false,
contentFilter: false,
errorMessage: '',
promptTokens,
outputTokens,
newType,
};
@@ -181,6 +192,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: false,
contentFilter: false,
errorMessage: '',
promptTokens,
outputTokens,
newType,
};
@@ -195,6 +207,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: true,
contentFilter: false,
errorMessage: '',
promptTokens,
outputTokens,
newType,
};
@@ -206,6 +219,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: false,
contentFilter: false,
errorMessage: '',
promptTokens,
outputTokens,
newType,
};
@@ -241,6 +255,7 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: false,
contentFilter: false,
errorMessage: '',
promptTokens,
outputTokens,
newType,
};
@@ -428,47 +443,10 @@ function hasContentFilterStatusValue(v) {
}
function extractAccumulatedTokenUsage(chunk) {
return findAccumulatedTokenUsage(chunk);
}
function findAccumulatedTokenUsage(v) {
if (Array.isArray(v)) {
for (const item of v) {
const n = findAccumulatedTokenUsage(item);
if (n > 0) {
return n;
}
}
return 0;
}
if (!v || typeof v !== 'object') {
return 0;
}
const pathValue = asString(v.p);
if (pathValue && pathValue.toLowerCase().includes('accumulated_token_usage')) {
const n = toInt(v.v);
if (n > 0) {
return n;
}
}
const direct = toInt(v.accumulated_token_usage);
if (direct > 0) {
return direct;
}
for (const value of Object.values(v)) {
const n = findAccumulatedTokenUsage(value);
if (n > 0) {
return n;
}
}
return 0;
}
function toInt(v) {
if (typeof v !== 'number' || !Number.isFinite(v)) {
return 0;
}
return Math.trunc(v);
// 临时策略:忽略上游 usage 字段accumulated_token_usage / token_usage
// 统一使用内部估算计数,避免上下文累计口径误差。
void chunk;
return { prompt: 0, output: 0 };
}
function formatErrorMessage(v) {

View File

@@ -1,15 +1,17 @@
'use strict';
function buildUsage(prompt, thinking, output, outputTokens = 0) {
const promptTokens = estimateTokens(prompt);
function buildUsage(prompt, thinking, output, outputTokens = 0, providedPromptTokens = 0) {
const reasoningTokens = estimateTokens(thinking);
const completionTokens = estimateTokens(output);
const finalPromptTokens = Number.isFinite(providedPromptTokens) && providedPromptTokens > 0 ? Math.trunc(providedPromptTokens) : estimateTokens(prompt);
const overriddenCompletionTokens = Number.isFinite(outputTokens) && outputTokens > 0 ? Math.trunc(outputTokens) : 0;
const finalCompletionTokens = overriddenCompletionTokens > 0 ? overriddenCompletionTokens : reasoningTokens + completionTokens;
return {
prompt_tokens: promptTokens,
prompt_tokens: finalPromptTokens,
completion_tokens: finalCompletionTokens,
total_tokens: promptTokens + finalCompletionTokens,
total_tokens: finalPromptTokens + finalCompletionTokens,
completion_tokens_details: {
reasoning_tokens: reasoningTokens,
},

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