Compare commits

...

452 Commits

Author SHA1 Message Date
CJACK.
da912f87bf Merge pull request #205 from CJackHwang/dev
Merge pull request #204 from CJackHwang/codex/capture-raw-stream-data-with-search-model

Skip dynamic DeepSeek fragment status paths to avoid FINISHED leakage; add simulator, samples and tests
2026-04-03 13:08:19 +08:00
CJACK.
6b32d84222 Merge pull request #206 from CJackHwang/codex/fix-issues-from-pull-request-review
tests: generate JSON request body in capture-raw-stream-sample.sh and use --data-binary
2026-04-03 13:07:25 +08:00
CJACK.
e1df5c8636 Fail fast when capture request fails 2026-04-03 13:06:57 +08:00
CJACK.
f23382ff5f Merge pull request #204 from CJackHwang/codex/capture-raw-stream-data-with-search-model
Skip dynamic DeepSeek fragment status paths to avoid FINISHED leakage; add simulator, samples and tests
2026-04-03 11:46:38 +08:00
CJACK.
fabdba48c3 Restrict Vercel Node stream path to OpenAI chat to avoid Gemini empty responses 2026-04-03 11:28:54 +08:00
CJACK.
a28e833f33 Promote raw stream replay into standalone simulator tool and add SSE field doc 2026-04-03 10:18:48 +08:00
CJACK.
ec1be468ca Merge pull request #196 from CJackHwang/dev
Merge pull request #193 from CJackHwang/codex/analyze-context-absence-issue

Proxy Claude/Gemini via OpenAI with translation bridge and streaming translator; update deps
2026-04-03 02:19:41 +08:00
CJACK.
fe43f1e6ee Merge pull request #201 from CJackHwang/codex/investigate-pull-request-issue-ws440p
Proxy Claude/Gemini adapters via OpenAI core, preserve model mapping, update README and tests
2026-04-03 02:01:19 +08:00
CJACK.
440d759584 Merge pull request #202 from CJackHwang/codex/update-project-configuration-for-version-upgrades
Bump Node and Go base images and add runtime baseline to Zeabur template
2026-04-03 02:00:17 +08:00
CJACK.
a6a9863fc3 Preserve upstream headers on Gemini proxy error responses 2026-04-03 01:59:35 +08:00
CJACK.
f787e25641 chore(deploy): drop Vercel node runtime pin and bump Docker node 2026-04-03 01:58:16 +08:00
CJACK.
5722f21cdd Align docs and adapters with unified OpenAI proxy architecture 2026-04-03 01:49:33 +08:00
CJACK.
ca3c16c424 Merge pull request #199 from CJackHwang/codex/update-project-documentation-structure
docs: refresh architecture overview and project structure maps
2026-04-03 01:29:09 +08:00
CJACK.
8b86f1c903 docs: refresh architecture and project structure docs 2026-04-03 01:28:48 +08:00
CJACK.
b758ce9234 Merge pull request #198 from CJackHwang/codex/update-project-documentation-and-version-number-0jh1p4
Release 3.0.0: version bump and docs updating unified routing & adapter-layer
2026-04-03 01:17:59 +08:00
CJACK.
1cf28101d6 Merge branch 'dev' into codex/update-project-documentation-and-version-number-0jh1p4 2026-04-03 01:17:37 +08:00
CJACK.
c1bdb6776b Merge pull request #197 from CJackHwang/codex/update-project-documentation-and-version-number
Docs: Bump to v3.0.0 and document unified adapter/router, health endpoints, and Go 1.26 requirement
2026-04-03 01:15:18 +08:00
CJACK.
47544fb385 docs: align architecture diagram and structure with current code 2026-04-03 01:14:01 +08:00
CJACK.
2a05c96f5f docs: refresh architecture diagram and structure sections 2026-04-03 01:13:13 +08:00
CJACK.
cbc68f7e92 Merge pull request #193 from CJackHwang/codex/analyze-context-absence-issue
Proxy Claude/Gemini via OpenAI with translation bridge and streaming translator; update deps
2026-04-03 00:50:47 +08:00
CJACK.
5576043106 Merge pull request #194 from CJackHwang/codex/fix-keep-alive-frame-filtering-issue
Preserve SSE keep-alive, stop on CONTENT_FILTER status, and propagate upstream token usage
2026-04-03 00:49:31 +08:00
CJACK.
287e8d5a60 Merge pull request #195 from CJackHwang/codex/fix-pull-request-review-issues
Hide upstream content-filter text in SSE streams, preserve token usage, and tighten filter detection
2026-04-03 00:48:45 +08:00
CJACK.
8a2c500806 treat content filter as normal stop and hide leaked suffix 2026-04-03 00:47:11 +08:00
CJACK.
e958bf7e40 Fix SSE keep-alive passthrough, content-filter stop, and usage token propagation 2026-04-02 23:58:36 +08:00
CJACK.
443fa4ad8e fix: handle vercel prepare/release passthrough in translated proxy paths 2026-04-02 22:28:36 +08:00
CJACK.
2d62c658f8 Merge pull request #183 from CJackHwang/dev
Merge pull request #182 from CJackHwang/codex/investigate-potential-issues

Fix account pool starvation when only one managed account has token
2026-04-02 20:17:35 +08:00
CJACK.
6a632ad9ef Merge pull request #187 from CJackHwang/codex/investigate-unrecognized-issue-in-ds2api
fix: use schema-correct exec parameter examples in tool prompt
2026-04-02 20:15:30 +08:00
CJACK.
cd2f5ad3b0 Merge pull request #191 from CJackHwang/codex/fix-issue-in-pull-request-190
fix: prioritize quoted functionCall keys in tool sieve
2026-04-02 20:14:17 +08:00
CJACK.
1457b63a76 Merge pull request #192 from CJackHwang/codex/fix-the-reported-issue-in-pr-191
fix: harden functionCall key detection in tool sieve
2026-04-02 20:13:51 +08:00
CJACK.
24655342a7 fix: prefer quoted functionCall keys over bare matches 2026-04-02 20:11:29 +08:00
CJACK.
39f6e066d6 fix: harden functionCall key detection in tool sieve 2026-04-02 19:43:47 +08:00
CJACK.
02d64c192e fix: prioritize quoted functionCall keys in tool sieve 2026-04-02 18:22:30 +08:00
CJACK.
283aa304df Merge pull request #190 from CJackHwang/codex/fix-critical-issue-in-pull-request
Fix tool sieve regression for loose `functionCall` keys
2026-04-02 16:12:50 +08:00
CJACK.
02fe3e4bfc fix: detect loose functionCall keys in tool sieve 2026-04-02 15:19:45 +08:00
CJACK.
15bf77e044 refactor tool sieve functionCall helpers into separate file 2026-04-02 13:40:21 +08:00
CJACK.
add0d0cc06 Merge pull request #188 from MoeclubM/patch-1
完善Docker部署教程
2026-04-02 13:39:43 +08:00
MoeclubM
a87ec3fd68 docs: sync docker config setup steps 2026-04-02 13:34:57 +08:00
CJACK.
50ce88ca3f tighten functionCall detection to quoted JSON keys 2026-04-02 13:32:47 +08:00
MoeCaa
48a5f1c39e 完善Docker部教程
增加配置文件的复制,由于docker-compose.yml映射了文件但是本地还不存在config.json,启动之后会导致app/config.json被映射成文件夹导致运行报错
2026-04-02 13:19:23 +08:00
CJACK.
07578f9c56 fix tool prompt parameter examples for exec tools 2026-04-02 13:09:41 +08:00
CJACK.
5ebc33c347 Merge pull request #186 from CJackHwang/codex/fix-key-copy-issue-in-web-ui
fix(webui): make API key copy action reliable
2026-04-02 13:03:33 +08:00
CJACK.
cc74397edc Merge pull request #184 from CJackHwang/codex/refactor-acquire-to-handle-empty-token-accounts
auth: retry other managed accounts when token ensure fails
2026-04-02 13:00:18 +08:00
CJACK.
1289e8afd8 fix(webui): make API key copy action reliable 2026-04-02 12:59:05 +08:00
CJACK.
e60738b084 auth: preserve ensure error after retry exhaustion 2026-04-02 12:58:09 +08:00
CJACK.
f6cd541c6f auth: retry other managed accounts when token ensure fails 2026-04-02 02:04:58 +08:00
CJACK.
1eb47147c2 Merge pull request #182 from CJackHwang/codex/investigate-potential-issues
Fix account pool starvation when only one managed account has token
2026-04-02 00:55:43 +08:00
CJACK.
da3fafb79a fix pool starvation of tokenless managed accounts 2026-04-02 00:48:41 +08:00
CJACK.
3900aaec47 Merge pull request #180 from CJackHwang/codex/integrate-gemini-claude-openai-into-ds2api
Detect Gemini `functionCall` and Claude `tool_use`, backfill tool_call IDs, and broaden tool-sieve detection
2026-04-01 01:54:21 +08:00
CJACK.
8a74dbff9c Fix lowercase functioncall detection in stream tool sieve 2026-04-01 01:50:56 +08:00
CJACK.
bfca84c2c7 Align tool-call parsing across Go/JS and pass quality gates 2026-04-01 01:24:55 +08:00
CJACK.
1cdfa9c05d Merge pull request #179 from TesseractLHY/main
Fixes #177
2026-04-01 00:08:55 +08:00
TesseractLHY
fe8232bfc1 Fixes bad tool call 2026-03-31 11:16:13 -04:00
CJACK.
063599678a Merge pull request #174 from CJackHwang/dev
Merge pull request #171 from CJackHwang/codex/fix-issue-#170-in-ds2api

Enable env-backed config writeback and bootstrap missing config file
2026-03-31 01:36:31 +08:00
CJACK.
f55aa7564a Merge pull request #176 from CJackHwang/codex/fix-fallback-to-file-config-on-json-parse-failure-u32uz8
修复写回模式回退逻辑并从 CONTENT_FILTER 起截断流输出
2026-03-31 01:35:42 +08:00
CJACK.
3b60e3c8f9 fix(sse): trim stream output from CONTENT_FILTER onward 2026-03-31 01:26:43 +08:00
CJACK.
efebe9ebad Merge pull request #171 from CJackHwang/codex/fix-issue-#170-in-ds2api
Enable env-backed config writeback and bootstrap missing config file
2026-03-31 00:01:27 +08:00
CJACK.
b54b418f96 fix(sse): globally strip leaked CONTENT_FILTER suffix from output 2026-03-30 23:39:47 +08:00
CJACK.
1c5f022b06 refactor(config): split writeback helpers out of store.go for CI gate 2026-03-30 21:38:19 +08:00
CJACK.
836eaf5290 feat(ui): show env mode persistence status and document writeback 2026-03-30 21:18:56 +08:00
CJACK.
958e7a0d04 fix(config): skip writeback bootstrap on invalid env config 2026-03-30 21:02:36 +08:00
CJACK.
f3555ae9b0 feat(config): bootstrap config.json when env writeback is enabled 2026-03-30 20:37:58 +08:00
CJACK.
d50d39e2e5 Merge pull request #169 from CJackHwang/dev
Merge pull request #168 from CJackHwang/codex/fix-vercel-deployment-issue-with-api-calls

fix(js): avoid false tool-call capture on plain tool_calls prose
2026-03-30 16:08:39 +08:00
CJACK.
01393837be Merge pull request #168 from CJackHwang/codex/fix-vercel-deployment-issue-with-api-calls
fix(js): avoid false tool-call capture on plain tool_calls prose
2026-03-30 16:01:51 +08:00
CJACK.
1fe1240240 fix(js): prevent XML wrapper attribute tool_calls scan loop 2026-03-30 15:59:34 +08:00
CJACK.
c07736fbea chore: set shared tool-sieve context tail window to 2048 2026-03-30 15:41:38 +08:00
CJACK.
775bf3b578 refactor(js): align tool-sieve segment start and tail window with go 2026-03-30 15:41:26 +08:00
CJACK.
ab3943ebeb test(js): cover numbered planning prose around tool calls 2026-03-30 15:39:09 +08:00
CJACK.
6efba7b2e4 fix(js): avoid false tool-call capture on plain tool_calls prose 2026-03-30 12:51:33 +08:00
CJACK.
765d0231cd Merge pull request #166 from CJackHwang/dev
chore: relocate sha3 WASM asset to internal directory and update build configurations
2026-03-30 12:23:46 +08:00
CJACK.
aebf3e9119 Merge pull request #167 from CJackHwang/codex/remove-dangling-agent-xml-tags
Fix dangling agent XML cleanup and XML-escape tool-call prompt serialization
2026-03-30 12:22:58 +08:00
CJACK.
535d9298a7 Scope dangling result-tag cleanup to leaked wrapper fragments 2026-03-30 12:22:04 +08:00
CJACK.
b790545d82 Fix dangling agent XML cleanup and escape tool call prompt XML 2026-03-30 11:23:16 +08:00
CJACK
c95bf7b667 chore: relocate sha3 WASM asset to internal directory and update build configurations 2026-03-30 02:23:45 +08:00
CJACK
d79565b250 docs: move documentation files to a dedicated directory and update references 2026-03-30 02:07:24 +08:00
CJACK
dc39de062b refactor: update wasm asset path in vercel configuration and remove obsolete binary file 2026-03-30 02:03:08 +08:00
CJACK
a7c9dfd7c0 refactor: remove configurable toolcall policy and fix to feature matching with high-confidence early emit 2026-03-30 01:56:25 +08:00
CJACK
822b14ed6b feat: add configurable token_refresh_interval_hours to runtime settings with validation and hot-reload support 2026-03-30 01:41:13 +08:00
CJACK
af7c7c6770 refactor: rename sanitizeLeakedToolHistory to sanitizeLeakedOutput for improved clarity 2026-03-30 01:06:22 +08:00
CJACK
868a60b70b chore: bump version from 2.4.1 to 2.5.1 2026-03-30 00:29:17 +08:00
CJACK
30a53b6c43 refactor: remove legacy TOOL_CALL_HISTORY/TOOL_RESULT_HISTORY markers and consolidate tool call formatting into a new prompt package 2026-03-30 00:20:38 +08:00
CJACK.
034c00f10e Merge pull request #163 from CJackHwang/dev
docs: update API documentation, deployment guides, and README with new admin endpoints, compatibility notes, and build instructions
2026-03-29 19:50:40 +08:00
CJACK
c3c644ff8c 111 2026-03-29 19:49:52 +08:00
CJACK
621599f8ad test: update message preparation tests to expect explicit User role markers 2026-03-29 19:41:03 +08:00
CJACK
aeb519c211 docs: update API documentation, deployment guides, and README with new admin endpoints, compatibility notes, and build instructions 2026-03-29 19:17:07 +08:00
CJACK
075728cca6 feat: add support for intercepting and sanitizing agent-style XML tags to prevent output leaks 2026-03-29 17:15:14 +08:00
CJACK
883607ac87 refactor: update prompt formatting to use system instruction tags and explicit user markers for improved model reasoning 2026-03-29 16:40:44 +08:00
CJACK
1d6a8e7008 refactor: centralize tool-calling instructions into a shared utility and update Claude/OpenAI adapters to use the unified format. 2026-03-29 16:05:35 +08:00
CJACK
f041ebab93 refactor: optimize tool-calling prompt instructions and examples for improved model adherence 2026-03-29 15:18:43 +08:00
CJACK
3ab9d44f60 feat: suppress output of partial XML tool tag fragments in stream processing 2026-03-29 14:59:30 +08:00
CJACK
4b42fe9086 fix: prevent XML tool call leakage by strictly matching opening and closing tag pairs during streaming 2026-03-29 14:40:47 +08:00
CJACK
302bcefeb5 feat: implement XML-based tool call extraction and refactor sieve utilities into dedicated modules 2026-03-29 13:01:11 +08:00
CJACK.
19b4f879c5 Merge pull request #161 from CJackHwang/codex/update-ds2api-project-documentation
Prefer XML canonical format for tool calls; prioritize XML/Markup parsing and update docs/tests
2026-03-29 11:22:51 +08:00
CJACK.
56a3ed19e8 fix(toolcall): support canonical xml params and guard json shadowing 2026-03-29 11:15:52 +08:00
CJACK.
958f4e39b5 feat(toolcall): prioritize XML for model output and parsing 2026-03-29 10:53:38 +08:00
CJACK.
6e8f3185d5 Merge pull request #157 from CJackHwang/codex/analyze-toolcall-output-formatting-issue
Sanitize leaked tool-call wire format in assistant text
2026-03-22 22:46:07 +08:00
CJACK.
0925e83b9b Stop embedding tool-call envelopes into prompt content 2026-03-22 22:36:15 +08:00
CJACK.
87c231e736 Sanitize leaked tool-call wire format in assistant text 2026-03-22 22:17:40 +08:00
CJACK.
390f7580e5 Merge pull request #156 from CJackHwang/dev
Merge pull request #153 from CJackHwang/codex/investigate-tool-execution-bugs-in-output-7ocr8f

Relax tool-name allow-listing and improve tool-call detection/parsing across adapters and sieve
2026-03-22 21:40:03 +08:00
CJACK.
5887821a9d Merge pull request #153 from CJackHwang/codex/investigate-tool-execution-bugs-in-output-7ocr8f
Relax tool-name allow-listing and improve tool-call detection/parsing across adapters and sieve
2026-03-22 21:26:55 +08:00
CJACK.
7794006513 Update VERSION 2026-03-22 21:26:34 +08:00
CJACK.
47d4499d47 Merge pull request #155 from CJackHwang/codex/review-and-fix-pr-#153-issues
Sync tool-call compat fixtures and update node test to match permissive tool-call policy
2026-03-22 21:25:18 +08:00
CJACK.
15891ddc25 Fix quality-gate fixture drift for permissive tool-call policy 2026-03-22 21:24:06 +08:00
CJACK.
97a81c4191 Harden toolcall leak interception for function-style payloads 2026-03-22 20:07:12 +08:00
CJACK.
586d31e556 Merge pull request #151 from CJackHwang/dev
Merge pull request #149 from CJackHwang/codex/fix-tool-miscall-during-complex-json-test

Ignore tool_call payloads inside fenced code blocks and chat envelopes; stream-aware code-fence tracking
2026-03-22 16:51:17 +08:00
CJACK.
b0a09dfab0 Merge pull request #149 from CJackHwang/codex/fix-tool-miscall-during-complex-json-test
Ignore tool_call payloads inside fenced code blocks and chat envelopes; stream-aware code-fence tracking
2026-03-22 16:50:44 +08:00
CJACK.
58f753d0c0 Merge pull request #150 from CJackHwang/codex/fix-markup-bypass-in-tool-call-parsing
Do not promote fenced code examples to tool calls and centralize tool-keyword detection
2026-03-22 16:36:39 +08:00
CJACK.
2e0586d060 Merge branch 'codex/fix-tool-miscall-during-complex-json-test' into codex/fix-markup-bypass-in-tool-call-parsing 2026-03-22 16:32:43 +08:00
CJACK.
1676c8e4f2 Add backward-compatible aliases for renamed fenced-example tests 2026-03-22 16:25:03 +08:00
CJACK.
add13366d2 Split parse syntax markers to shared keyword module 2026-03-22 15:55:47 +08:00
CJACK.
d5a23191f2 Refactor stream sieve keyword scanning into shared helper 2026-03-22 15:55:38 +08:00
CJACK.
d2d4e39983 Fix refactor line gate for stream tool sieve helper 2026-03-22 15:28:51 +08:00
CJACK.
6e0dca3b30 Update VERSION 2026-03-22 15:16:29 +08:00
CJACK.
b108a7915a Support nested fenced blocks in stream fence tracking 2026-03-22 15:12:55 +08:00
CJACK.
2caabd8ce6 Add files via upload 2026-03-22 14:18:08 +08:00
CJACK.
c4a73e871a Merge pull request #148 from CJackHwang/dev
Merge pull request #147 from CJackHwang/codex/fix-tool-call-history-retrieval

Preserve tool call/result roundtrip and raw payloads across Claude, Gemini and OpenAI adapters
2026-03-22 13:43:26 +08:00
CJACK.
6802a3d53e Fix Claude tool block normalization and tool_result fidelity 2026-03-22 13:42:01 +08:00
CJACK.
e828006cb0 Merge pull request #147 from CJackHwang/codex/fix-tool-call-history-retrieval
Preserve tool call/result roundtrip and raw payloads across Claude, Gemini and OpenAI adapters
2026-03-22 13:06:23 +08:00
CJACK.
a6499cbece Split Claude sanitize helpers to satisfy refactor line gate 2026-03-22 13:05:41 +08:00
CJACK.
a504905626 Fix Claude/Gemini prompt flattening for tool history and binary parts 2026-03-22 12:47:00 +08:00
CJACK.
59bf78d2c4 Unify adapter message normalization across Claude and Gemini 2026-03-22 12:07:58 +08:00
CJACK.
25b3292497 Merge pull request #146 from CJackHwang/dev
Merge pull request #145 from CJackHwang/codex/determine-which-pr-fixes-json-leak-issue

Merge pull request #144 from CJackHwang/codex/refactor-codebase-to-remove-redundancy

Refactor tool-sieve and response streaming, remove unused helpers and UI wrappers
2026-03-22 11:05:54 +08:00
CJACK.
6cf4f0528c Merge pull request #145 from CJackHwang/codex/determine-which-pr-fixes-json-leak-issue
Merge pull request #144 from CJackHwang/codex/refactor-codebase-to-remove-redundancy

Refactor tool-sieve and response streaming, remove unused helpers and UI wrappers
2026-03-22 10:59:31 +08:00
CJACK.
d8f8dcb704 Merge pull request #144 from CJackHwang/codex/refactor-codebase-to-remove-redundancy
Refactor tool-sieve and response streaming, remove unused helpers and UI wrappers
2026-03-22 10:39:36 +08:00
CJACK.
455489ffeb ci: upgrade GitHub Actions Node runtime to 24 2026-03-22 10:38:18 +08:00
CJACK.
5031ae0e6f ci: align refactor line gate with removed files 2026-03-22 10:38:08 +08:00
CJACK.
3fccec0e22 test: remove unused asFloat helper 2026-03-22 10:24:11 +08:00
CJACK.
00d38f1187 fix: parse claude tool_use function/parameter format 2026-03-22 09:58:29 +08:00
CJACK.
fe0f3d2c17 fix: strip empty json fences from sanitized stream text 2026-03-22 09:29:21 +08:00
CJACK.
f67cbfad35 fix: stop instructing fenced JSON for tool calls 2026-03-22 09:25:01 +08:00
CJACK.
11f66db87d Merge pull request #142 from CJackHwang/dev
Merge pull request #141 from CJackHwang/codex/investigate-json-leakage-in-vercel-deployment-rh84s1

Fix raw tool-call JSON leaks when feature_match mode is off
2026-03-22 08:55:29 +08:00
CJACK.
9afc533153 Merge pull request #141 from CJackHwang/codex/investigate-json-leakage-in-vercel-deployment-rh84s1
Fix raw tool-call JSON leaks when feature_match mode is off
2026-03-22 08:38:18 +08:00
CJACK.
6a39543288 fix tool-call json leaks when feature_match is disabled 2026-03-22 08:29:01 +08:00
CJACK.
7131b06e26 Merge pull request #138 from CJackHwang/dev
Merge pull request #135 from CJackHwang/codex/add-global-token-refresh-logic

Sanitize leaked tool-history markers, simplify normalization, and add managed token refresh
2026-03-22 01:27:27 +08:00
CJACK.
8fa1f998aa Merge pull request #139 from CJackHwang/codex/fix-issues-from-codex-review
[Follow-up] Preserve empty tool completion turns in OpenAI prompt normalization
2026-03-22 01:26:43 +08:00
CJACK.
f8936887d0 fix(openai): preserve empty tool completion turns 2026-03-22 01:19:17 +08:00
CJACK.
db89744055 Merge branch 'main' into dev 2026-03-22 01:07:14 +08:00
CJACK.
65312fc573 Merge pull request #135 from CJackHwang/codex/add-global-token-refresh-logic
Sanitize leaked tool-history markers, simplify normalization, and add managed token refresh
2026-03-22 01:05:10 +08:00
CJACK.
661d753fd3 Merge pull request #137 from CJackHwang/codex/optimize-configuration-file-management
Make account `test_status` runtime-only (in-memory cache)
2026-03-22 01:04:42 +08:00
CJACK.
7ca3f141c6 Pass refactor line gate for tool sieve files 2026-03-22 01:04:01 +08:00
CJACK.
d530d25793 Expand history-sanitize boundary coverage for stream chunks 2026-03-22 00:57:13 +08:00
CJACK.
990cdcf02d refactor config: keep account test status runtime-only 2026-03-22 00:49:53 +08:00
CJACK.
648bb74587 Fix streaming whitespace trim and capture TOOL_RESULT_HISTORY 2026-03-22 00:44:44 +08:00
CJACK.
9e5baed061 Merge pull request #136 from CJackHwang/codex/add-file-import-and-export-for-project-config
feat(webui): add config backup download and file-based import in Settings
2026-03-22 00:31:30 +08:00
CJACK.
4884773639 feat(webui): support backup file export and import 2026-03-22 00:29:01 +08:00
CJACK.
6758514c61 chore: remove obsolete openai tool-history normalization helpers 2026-03-22 00:28:32 +08:00
CJACK.
01f33c409f Update VERSION 2026-03-21 18:04:39 +08:00
CJACK.
55f11e655a Update VERSION 2026-03-21 18:04:11 +08:00
CJACK.
2275e931f9 Merge pull request #133 from CJackHwang/dev
Merge pull request #132 from CJackHwang/codex/toolcallhistory-6t7271

Preserve code fences around standalone tool JSON and add marker-output guards
2026-03-21 17:54:56 +08:00
CJACK.
40594a44db Fix env-backed Vercel sync override and config refresh behavior 2026-03-21 17:53:44 +08:00
CJACK.
67787d9c99 Merge pull request #132 from CJackHwang/codex/toolcallhistory-6t7271
Preserve code fences around standalone tool JSON and add marker-output guards
2026-03-21 17:44:05 +08:00
CJACK.
7061094964 Fix fence-strip regression for closed code blocks before tool JSON 2026-03-21 17:39:08 +08:00
CJACK.
492c603300 Merge pull request #129 from CJackHwang/codex/optimize-vercel-deployment-sync-mechanism
Vercel sync: support env-backed config drafts, hash diffing and UI indicators
2026-03-21 17:21:42 +08:00
CJACK.
7e473dffc9 Fix Vercel sync override to avoid redacted config payloads 2026-03-21 17:19:32 +08:00
CJACK.
43a6e6712f Show UI drift marker for env draft vs Vercel config 2026-03-21 17:08:43 +08:00
CJACK.
ce1b76c90f Merge pull request #126 from CJackHwang/dev
Merge pull request #125 from CJackHwang/codex/align-documentation-with-configuration-updates

Docs: add `auto_delete.sessions`, rename `claude_model_mapping` to `claude_mapping`, and clarify config token handling
2026-03-21 15:44:28 +08:00
CJACK.
1e7e0b2ae3 Merge pull request #125 from CJackHwang/codex/align-documentation-with-configuration-updates
Docs: add `auto_delete.sessions`, rename `claude_model_mapping` to `claude_mapping`, and clarify config token handling
2026-03-21 15:34:35 +08:00
CJACK.
fd158e5ae2 Merge pull request #124 from CJackHwang/codex/fix-codex-review-issues-in-pr-#123
Preserve file-backed account tokens on startup and add regression test
2026-03-21 15:34:01 +08:00
CJACK.
95c96f7744 docs: clarify configured account token is ignored on load 2026-03-21 15:32:09 +08:00
CJACK.
e7f59fac80 Update VERSION 2026-03-21 15:22:09 +08:00
CJACK.
1bf059396f Fix file-backed token reuse at startup 2026-03-21 15:19:41 +08:00
CJACK.
696b403173 Merge pull request #123 from CJackHwang/dev
Merge pull request #122 from CJackHwang/codex/refactor-configuration-to-remove-token-support

Treat account tokens as runtime-only; remove token-only account support and always refresh tokens on admin actions
2026-03-21 15:14:17 +08:00
CJACK.
f4db2732b0 Merge pull request #122 from CJackHwang/codex/refactor-configuration-to-remove-token-support
Treat account tokens as runtime-only; remove token-only account support and always refresh tokens on admin actions
2026-03-21 15:07:19 +08:00
CJACK.
ee88a74dcf Drop legacy token-only accounts when loading config 2026-03-21 15:01:16 +08:00
CJACK.
ca08bb66b9 Add HTTP token-runtime coverage and fix gate tests for tokenless config 2026-03-21 14:27:12 +08:00
CJACK.
708fcb5beb Merge pull request #121 from jacob-sheng/fix/zeabur-build-version-fallback-zh
fix: 修复 Docker 在缺少 BUILD_VERSION 时构建失败
2026-03-21 11:17:58 +08:00
jacob-sheng
7a65d1eaa2 fix: allow Docker builds without BUILD_VERSION 2026-03-21 09:55:53 +08:00
CJACK.
6de2457743 Merge pull request #119 from CJackHwang/dev
Merge pull request #118 from CJackHwang/codex/analyze-and-fix-build-failure-for-pr-117

fix: decouple runtime-from-dist image from go-builder stage
2026-03-21 02:00:35 +08:00
CJACK.
ce44e260bf Merge pull request #118 from CJackHwang/codex/analyze-and-fix-build-failure-for-pr-117
fix: decouple runtime-from-dist image from go-builder stage
2026-03-21 01:59:52 +08:00
CJACK.
09f6537ffc fix: decouple runtime-from-dist image from go-builder stage 2026-03-21 01:32:09 +08:00
CJACK.
ab8f494fdb Merge pull request #117 from CJackHwang/dev
Merge pull request #115 from CJackHwang/codex/fix-version-detection-for-ds2api

Expose version endpoint, add version package, and inject build version into artifacts/Docker images
2026-03-21 00:51:36 +08:00
CJACK.
b56a211da9 Merge pull request #115 from CJackHwang/codex/fix-version-detection-for-ds2api
Expose version endpoint, add version package, and inject build version into artifacts/Docker images
2026-03-21 00:47:57 +08:00
CJACK.
fcce5308cb Merge pull request #116 from CJackHwang/codex/align-vercel-deployment-with-go-version-semantics
Align Vercel JS toolcall detection/format behavior with Go semantics
2026-03-21 00:43:50 +08:00
CJACK.
d27b19cc53 fix: show vercel preview commit version instead of dev 2026-03-21 00:43:09 +08:00
CJACK.
b8ff678f24 Align Vercel JS toolcall filtering with Go semantics 2026-03-21 00:23:22 +08:00
CJACK.
b24ef1282d fix: route /admin/version to api on vercel 2026-03-21 00:18:55 +08:00
CJACK.
65e0de3c82 Merge pull request #112 from CJackHwang/codex/fix-token-expiration-handling
Attempt token refresh for biz_code failures; report config writability and handle token write errors
2026-03-20 23:56:40 +08:00
CJACK.
0c2743a48c fix: align build version source with tags and VERSION fallback 2026-03-20 23:55:10 +08:00
CJACK.
dc73e8a6da Gate biz_code refresh attempts to auth-indicative failures 2026-03-20 23:54:13 +08:00
CJACK.
b8495eeeb3 surface account test config writeability and save failures 2026-03-20 23:34:29 +08:00
CJACK.
b3eae22cef Merge pull request #111 from CJackHwang/dev
Merge pull request #110 from CJackHwang/codex/align-js-runtime-with-go-runtime-logic

Align Vercel JS stream tool-call delta handling with Go runtime
2026-03-20 10:05:25 +08:00
CJACK.
7af0098d1b Merge pull request #110 from CJackHwang/codex/align-js-runtime-with-go-runtime-logic
Align Vercel JS stream tool-call delta handling with Go runtime
2026-03-20 09:49:08 +08:00
CJACK.
17405be300 shrink vercel stream module under line gate limit 2026-03-20 09:47:22 +08:00
CJACK.
5bc03e5de6 align vercel js stream toolcall delta behavior with go runtime 2026-03-20 09:36:45 +08:00
CJACK.
5a5f93148d Merge pull request #109 from CJackHwang/dev
Merge pull request #108 from CJackHwang/codex/clean-up-unused-files-and-update-documentation-uiip50

docs: refresh deployment/testing guides and remove stale investigation report
2026-03-20 03:12:25 +08:00
CJACK.
32dc5b6099 Merge pull request #108 from CJackHwang/codex/clean-up-unused-files-and-update-documentation-uiip50
docs: refresh deployment/testing guides and remove stale investigation report
2026-03-20 03:08:09 +08:00
CJACK.
7936d4675f Merge pull request #107 from CJackHwang/codex/clean-up-unused-files-and-update-documentation
docs: prune stale files and refresh docs, add .env.example, align READMEs/DEPLOY/CONTRIBUTING
2026-03-20 03:07:21 +08:00
CJACK.
808eafa7c6 docs: refresh deployment/testing guides and prune stale report 2026-03-20 03:05:36 +08:00
CJACK.
bcb8ed6df2 docs: prune stale docs and refresh project documentation 2026-03-20 03:05:22 +08:00
CJACK.
8ec5dcc0cc Merge pull request #106 from CJackHwang/dev
Merge pull request #105 from CJackHwang/codex/fix-issues-found-in-review

Merge pull request #104 from CJackHwang/codex/revert-to-commit-efb484b

Restore tool-call parsing and repair logic; remove accidental split files
2026-03-20 02:53:30 +08:00
CJACK.
88a79f212d Fix path control-char repair on JSON fallback parses 2026-03-20 02:52:27 +08:00
CJACK.
b1f8d6192f Merge pull request #105 from CJackHwang/codex/fix-issues-found-in-review
Merge pull request #104 from CJackHwang/codex/revert-to-commit-efb484b

Restore tool-call parsing and repair logic; remove accidental split files
2026-03-20 02:38:35 +08:00
CJACK.
acfb3b225d Split toolcall input parsing to satisfy line gate 2026-03-20 02:37:23 +08:00
CJACK.
99a6164000 Fix path corruption when parsing tool call JSON strings 2026-03-20 02:31:37 +08:00
CJACK.
e49d9d33e2 Merge pull request #104 from CJackHwang/codex/revert-to-commit-efb484b
Restore tool-call parsing and repair logic; remove accidental split files
2026-03-20 02:17:52 +08:00
CJACK.
184a3d1e4e Sync Node tool-call parsing with aggressive fenced/mixed policy 2026-03-20 02:16:37 +08:00
CJACK.
c4ec14f49a Fix refactor line gate for toolcalls_parse 2026-03-20 02:12:34 +08:00
CJACK.
fb5fc0e885 Default to aggressive tool-call interception in mixed/fenced text 2026-03-20 02:03:46 +08:00
CJACK.
20b603666d Allow standalone parser to detect mixed prose tool JSON 2026-03-20 02:03:32 +08:00
CJACK.
4d549b7102 Revert "Merge branch 'dev' into codex/fix-issues-found-in-review"
This reverts commit 33b0d1d144, reversing
changes made to efb484ba4f.
2026-03-20 01:38:11 +08:00
CJACK.
33b0d1d144 Merge branch 'dev' into codex/fix-issues-found-in-review 2026-03-20 01:23:00 +08:00
CJACK.
41c0f7ce28 Merge pull request #102 from CJackHwang/dev
Merge pull request #99 from CJackHwang/codex/refactor-toolcalls_parse.go-for-line-limits

Codex-generated pull request
2026-03-20 01:18:05 +08:00
CJACK.
efb484ba4f Merge pull request #103 from CJackHwang/codex/fix-threshold-issue-and-audit-pr
fix: unblock PR #101 line gate and improve PoW/token retry handling
2026-03-20 01:16:46 +08:00
CJACK.
145501d4a5 fix(tool-sieve): allow mixed prose + tool json interception 2026-03-20 01:15:32 +08:00
CJACK.
2d5103997b fix(tool-sieve): keep mixed prose tool json in strict text mode 2026-03-20 01:15:15 +08:00
CJACK.
52e7e7aae8 fix: unblock line gate and harden pow token recovery 2026-03-20 00:50:05 +08:00
CJACK.
5b5a4000d7 Merge pull request #99 from CJackHwang/codex/refactor-toolcalls_parse.go-for-line-limits
Codex-generated pull request
2026-03-19 21:06:45 +08:00
CJACK.
2bbf603148 fix: address PR #97 review findings 2026-03-18 00:52:24 +08:00
CJACK.
d14b8a0664 Stabilize tool-call parsing and pass refactor gate 2026-03-18 00:45:28 +08:00
CJACK.
f16e0b579e Merge pull request #92 from valkryhx/main
fix(toolcall): fix deepseek function calling bug and add json repair
2026-03-18 00:15:47 +08:00
CJACK.
43cbc4aac0 Merge pull request #97 from CJackHwang/dev
Merge pull request #96 from CJackHwang/codex/update-ci-line-count-limits-cihke3

ci: ignore test files in line gate and raise frontend limit to 500
2026-03-18 00:15:03 +08:00
huangxun
cf569f4749 docs: add testing documentation for tool call debugging
- Add targeted test commands to TESTING.md for debugging tool call issues
- Add quick test commands reference in README.md
- Document specific test cases for DeepSeek tool call parsing
2026-03-17 16:41:16 +08:00
huangxun
c9c59f2490 refactor(toolcall): enhance tool call extraction with multiple keywords and safety limits
- Add support for multiple keywords: tool_calls, function.name:, [tool_call_history]
- Add OOM protection with search limits in extractToolCallObjects
- Add max scan length limit in extractJSONObject to prevent OOM on unclosed objects
- Update tool_sieve to handle more tool call patterns
- Add loose JSON repair in parseToolCallPayload for better error recovery

This improves DeepSeek tool call parsing robustness.
2026-03-17 16:28:27 +08:00
huangxun
16216cc2ca fix(toolcalls): support nested objects in missing array brackets repair
- Upgrade missingArrayBracketsPattern regex to support single-level nested {} objects
- This fixes DeepSeek's list hallucination where tool call JSON objects contain nested fields like {"input": {"q": "value"}}
- Add comprehensive test cases covering 2-5 nested objects, mixed nested/primitive fields, and real DeepSeek 8-queen output patterns
- Add RepairLooseJSON function to repair unquoted keys and missing array brackets

Fixes: DeepSeek tool call parsing with nested JSON objects
2026-03-17 16:24:16 +08:00
CJACK.
de50fd3954 Merge pull request #96 from CJackHwang/codex/update-ci-line-count-limits-cihke3
ci: ignore test files in line gate and raise frontend limit to 500
2026-03-16 23:16:22 +08:00
CJACK.
7648d5f192 ci: keep entry line cap precedence over frontend cap 2026-03-16 23:06:58 +08:00
CJACK.
d35e5eab25 ci: ignore tests in line gate and raise frontend limit 2026-03-16 22:58:13 +08:00
CJACK.
90610a52ce Merge pull request #93 from latticeon/feature/session-management
feat: 添加会话管理功能
2026-03-16 22:12:00 +08:00
latticeon
f6296d506f fix: 修改批量删除会话方式
- 从逐条单个删除改为官方的批量删除接口
- 单个删除函数保留备用
2026-03-16 16:23:39 +08:00
latticeon
dfea092583 fix: 更新测试 mock 结构体以实现新增的接口方法
会话管理功能新增接口方法后,同步更新测试 mock 结构体:
- mockOpenAIConfig: 添加 AutoDeleteSessions() 方法
- streamStatusDSStub: 添加 DeleteAllSessionsForToken() 方法
- testingDSMock: 添加 DeleteAllSessionsForToken() 和 GetSessionCountForToken() 方法

同时修复 client_session_delete.go 中 fmt.Errorf 使用非常量格式字符串的编译错误,改用 errors.New()
2026-03-16 11:58:07 +08:00
latticeon
af7dc134bb fix: 修复会话管理相关问题并拆分文件
1. 修复无限循环问题
   - DeleteAllSessions/DeleteAllSessionsForToken 添加无进度检测
   - 连续 3 轮删除失败则退出循环
   - DeleteAllSessionsForToken 添加 cursor 推进逻辑

2. 修复字段语义不准确
   - TotalCount 重命名为 FirstPageCount
   - 明确该值仅统计第一页,多页账户需关注 HasMore

3. 修复 defer 执行顺序问题
   - 合并两个 defer,确保先删除会话再释放账号
   - 使用同步删除避免并发截断风险

4. 文件拆分
   - 新建 client_session_delete.go 处理会话删除
   - client_session.go 专注于会话查询
2026-03-16 01:44:21 +08:00
latticeon
2657d37f76 添加会话数量显示与清除功能
添加会话清除功能,增强安全性,避免账号被盗等情况泄露源代码
账号列表点击测试后显示账号的会话数量
设置页添加自动清除开关,每次调用后清除被调用账号的所有会话
2026-03-16 00:50:31 +08:00
huangxun
7318d1f4a8 fix(toolcall): fix deepseek function calling bug and add json repair
- Fix: Expand stream sieve keywords to support function.name: and [TOOL_CALL_HISTORY]

- Fix: Add repairInvalidJSONBackslashes to handle unescaped backslashes in Windows paths

- Sync: Update JS stream sieve to match Go implementation

- Test: Add unit tests for backslash repair and deepseek format parsing

- Tool: Move repair json test tool to tests/repair_json_tool.go
2026-03-13 13:47:40 +08:00
CJACK.
f2674487c7 Merge pull request #90 from CJackHwang/dev
Merge pull request #89 from CJackHwang/codex/review-changes-in-pull-request-#88

Support text-kv `function.name`/`function.arguments` fallback and looser name matching
2026-03-09 21:42:28 +08:00
CJACK.
71cdcb43e8 Merge pull request #89 from CJackHwang/codex/review-changes-in-pull-request-#88
Support text-kv `function.name`/`function.arguments` fallback and looser name matching
2026-03-09 19:21:24 +08:00
CJACK.
9c46c3a874 Merge branch 'dev' into codex/review-changes-in-pull-request-#88 2026-03-09 19:20:32 +08:00
CJACK.
12d5f136d5 fix(toolcall): pass gates and align go/js multi-layer parser 2026-03-09 19:16:28 +08:00
CJACK.
00c37d8d2f Merge pull request #88 from valkryhx/main
update openai function calling 成功率高 是因为chat内容和tool内容分开保存,而ds则混合了
2026-03-09 19:04:41 +08:00
huangxun
0f1985af4a feat(util): 增加对混杂文本中 Tool Call 的 fallback 解析支持
- 引入 parseTextKVToolCalls 解析器以处理混杂文本或带历史记录套壳(如 [TOOL_CALL_HISTORY])输出的函数调用提取。
- 将其作为 JSON 和 XML 的 fallback 解析手段集成到主流程。
- 添加单元测试用例且更新相关语义说明文档。
2026-03-09 15:00:16 +08:00
huangxun
fa8affe1b7 Merge remote-tracking branch 'upstream/main' 2026-03-09 14:29:09 +08:00
CJACK.
c59a0b7799 Merge pull request #87 from CJackHwang/dev
Merge pull request #82 from CJackHwang/codex/linear-mention-cja-10-ds2api-go-runtime-js

Align Go/JS tool-call parsing semantics and expand compat fixtures
2026-03-08 13:21:22 +08:00
CJACK.
bd72b91f27 Merge pull request #82 from CJackHwang/codex/linear-mention-cja-10-ds2api-go-runtime-js
Align Go/JS tool-call parsing semantics and expand compat fixtures
2026-03-08 13:19:09 +08:00
CJACK.
9240f85246 Merge pull request #86 from CJackHwang/codex/fix
fix: parse invoke/tool_call arguments in xml compatibility paths
2026-03-08 13:17:29 +08:00
CJACK.
ea4bd1e483 fix: parse invoke/tool_call arguments in xml compatibility paths 2026-03-08 13:16:12 +08:00
CJACK.
9e0de62707 Merge branch 'dev' into codex/linear-mention-cja-10-ds2api-go-runtime-js 2026-03-08 02:40:35 +08:00
CJACK.
128de290db Merge pull request #85 from CJackHwang/revert-84-codex/fix-code-conflicts-in-pr-#82
Revert "Resolve PR #82 merge conflicts and restore tool-call parsing (invoke/argument and XML arguments)"
2026-03-08 02:38:57 +08:00
CJACK.
286d266723 Revert "Resolve PR #82 merge conflicts and restore tool-call parsing (invoke/argument and XML arguments)" 2026-03-08 02:38:29 +08:00
CJACK.
8aad1005b2 Merge pull request #84 from CJackHwang/codex/fix-code-conflicts-in-pr-#82
Resolve PR #82 merge conflicts and restore tool-call parsing (invoke/argument and XML arguments)
2026-03-08 02:31:21 +08:00
CJACK.
11b2f24fc2 Merge origin/dev into PR branch and resolve toolcall parser conflicts 2026-03-08 02:30:12 +08:00
CJACK.
d1f08cbb89 Merge pull request #83 from CJackHwang/dev
Merge pull request #81 from CJackHwang/codex/linear-mention-cja-8

Drop nameless assistant tool_calls and emit parsed tool_calls atomically in sieve
2026-03-08 01:36:38 +08:00
CJACK.
60e9d707d4 Merge origin/dev into PR branch and resolve toolcall test conflicts 2026-03-08 01:10:53 +08:00
CJACK.
9b93badb57 Harden markup tag parsing to avoid mismatched-tag false positives 2026-03-08 00:55:32 +08:00
CJACK.
892213071a Align Go/JS tool-call parsing semantics and compat fixtures 2026-03-08 00:12:43 +08:00
CJACK.
5484d6e59d Merge pull request #81 from CJackHwang/codex/linear-mention-cja-8
Drop nameless assistant tool_calls and emit parsed tool_calls atomically in sieve
2026-03-07 23:15:54 +08:00
CJACK.
0ce3fd22a7 Address PR review: fenced-stream guard and multi ANTML calls 2026-03-07 17:45:43 +08:00
CJACK.
25e40cc3a6 Fix quality gate and expand Claude tool-call format compatibility 2026-03-07 17:27:29 +08:00
CJACK.
af68d21095 Improve Claude Code tool-call compatibility across mixed formats 2026-03-07 16:53:05 +08:00
CJACK.
1fafd25e86 add output_text.done event and remove transient stability report 2026-03-07 16:00:53 +08:00
CJACK.
5f8f28a943 add codex and claude-cli ds2api stability test report 2026-03-07 16:00:36 +08:00
CJACK.
94cf1bfcc7 drop nameless assistant tool history entries 2026-03-07 14:45:10 +08:00
CJACK.
13562cf521 Merge pull request #80 from CJackHwang/dev
Merge pull request #79 from CJackHwang/codex/analyze-and-optimize-issue-#77

fix: 避免 assistant.content=nil 注入 "null" 导致工具历史混杂
2026-03-07 02:13:46 +08:00
valkryhx
d27e700c4f update openai function calling 成功率高 是因为chat内容和tool内容分开保存,而ds则混合了 2026-03-06 23:22:11 +08:00
valkryhx
d6bce5af93 Merge branch 'dev' 2026-03-06 22:49:56 +08:00
CJACK.
75969e710d Merge pull request #79 from CJackHwang/codex/analyze-and-optimize-issue-#77
fix: 避免 assistant.content=nil 注入 "null" 导致工具历史混杂
2026-03-06 22:20:47 +08:00
CJACK.
6c39c8e191 fix: 修复 text 为空时 content 回退丢失问题 2026-03-06 21:24:26 +08:00
CJACK.
0e261ff0a0 refactor: 统一内容归一化逻辑并补充 nil 回归测试 2026-03-06 18:25:27 +08:00
CJACK.
fab326eca1 fix: 修复工具历史注入 null 导致调用格式混乱 2026-03-05 18:20:42 +08:00
CJACK.
c033eceee7 Merge pull request #75 from CJackHwang/dev
Merge pull request #74 from CJackHwang/codex/fix-toolcall-whitelist-issue

Recognize and emit executable tool_calls in mixed prose streams; normalize roles and loosen tool-name matching
2026-03-03 01:30:44 +08:00
CJACK.
a10e03ebe0 Merge pull request #74 from CJackHwang/codex/fix-toolcall-whitelist-issue
Recognize and emit executable tool_calls in mixed prose streams; normalize roles and loosen tool-name matching
2026-03-03 00:40:41 +08:00
CJACK.
a6aa4a1839 补充工具调用行为说明并修正测试文档过时命令 2026-03-03 00:39:02 +08:00
CJACK.
1c749b6803 Merge pull request #73 from CJackHwang/dev
Merge pull request #72 from CJackHwang/codex/review-changes-to-test-account-logic

Normalize mobile login numbers, skip completion flow for session-only account tests, and add tests
2026-03-03 00:07:57 +08:00
CJACK.
c329bf26b6 Merge pull request #72 from CJackHwang/codex/review-changes-to-test-account-logic
Normalize mobile login numbers, skip completion flow for session-only account tests, and add tests
2026-03-02 23:56:27 +08:00
CJACK.
3ae5b57ebe fix(deepseek): normalize mobile before login token refresh 2026-03-02 23:48:54 +08:00
CJACK.
0bf5d5440c Merge pull request #69 from CJackHwang/dev
js对齐
2026-03-01 07:22:42 +08:00
CJACK
d731a1fd4f 门禁 2026-03-01 07:20:24 +08:00
CJACK
93e9fb531d js对齐 2026-03-01 07:15:35 +08:00
CJACK.
6daeb2553d Merge pull request #68 from CJackHwang/dev
修复严重问题
2026-03-01 06:53:23 +08:00
CJACK
321b8a89ee 优化 2026-03-01 06:42:07 +08:00
CJACK
d84875e466 工具调用优化 2026-03-01 06:33:49 +08:00
CJACK
ea8c9a28a9 更新readme和icon 2026-03-01 06:22:41 +08:00
CJACK
a302fb3c25 修复 2026-03-01 05:55:46 +08:00
CJACK.
958bd124cc Merge pull request #64 from CJackHwang/dev
修复已知问题
2026-02-28 18:58:46 +08:00
CJACK.
b89e154e43 Merge pull request #63 from CJackHwang/codex/fix-issues-in-image-analysis
Use repository root Dockerfile, make Go cross-build robust, and fix process wait logic
2026-02-28 18:51:57 +08:00
CJACK.
01924f4a69 fix(docker): auto-detect target arch for local ARM builds 2026-02-28 18:39:33 +08:00
CJACK.
3725694bdf Merge pull request #61 from ronghuaxueleng/main
feat(webui): 账号列表添加搜索过滤功能
2026-02-28 18:16:41 +08:00
root
21b12f583a fix(admin): 账号测试始终发送默认消息以验证完整链路
测试接口不再仅验证会话创建,改为始终发送「你是谁?」
走完整 completion 路径,确保被封禁账号能被正确识别为失败。
2026-02-28 10:18:26 +08:00
root
d97b86e0ee feat(webui): 账号列表添加搜索过滤功能
- 后端 GET /admin/accounts 支持 ?q= 参数,大小写不敏感匹配 identifier/email/mobile
- 前端搜索框内嵌于标题栏按钮行(测试全部按钮前)
- 搜索时重置到第 1 页,分页 total 反映过滤后数量
- 无匹配结果时显示专属提示文案(中英文)
2026-02-28 09:57:19 +08:00
qiangcao
0869ea56cd Merge branch 'CJackHwang:main' into main 2026-02-28 09:18:20 +08:00
CJACK.
4768440627 Merge pull request #60 from CJackHwang/main
同步
2026-02-27 23:18:44 +08:00
CJACK.
9f91da403f Merge pull request #59 from ronghuaxueleng/feature/account-improvements
feat: 账号测试状态持久化、分页选择器、点击账号名复制
2026-02-27 23:16:05 +08:00
CJACK.
89e5ad24b9 Merge pull request #57 from jacob-sheng/feat/zeabur-oneclick
feat(zeabur): 一键部署模板
2026-02-27 23:12:13 +08:00
CJACK.
3f106ac112 Merge pull request #55 from BigUncle/fix/claude-toolcall
fix(claude): 修复工具调用兼容与解析回退
2026-02-27 23:11:46 +08:00
root
f6f6a651fd feat: 账号测试状态持久化、分页选择器、点击账号名复制
- Account 结构加 TestStatus 字段,测试后写入 config.json
- listAccounts 接口返回 test_status,前端根据结果显示红/绿/黄状态点
- 分页选择器支持 10/20/50/100/500/1000/2000/5000
- 点击账号名自动复制到剪贴板,hover 显示复制图标,复制后显示绿色对勾
2026-02-27 21:30:43 +08:00
root
37b867c7ad Merge branch 'docker' 2026-02-27 20:59:16 +08:00
root
25ea28a277 feat: 账号测试状态持久化、分页选择器、点击账号名复制
- Account 结构加 TestStatus 字段,测试后写入 config.json
- listAccounts 接口返回 test_status,前端根据结果显示红/绿/黄状态点
- 分页选择器支持 10/20/50/100/500/1000/2000/5000
- 点击账号名自动复制到剪贴板,hover 显示复制图标,复制后显示绿色对勾
2026-02-27 20:58:18 +08:00
root
0ac49ab32b merge: 合并 main 分支到 docker,保留 docker-compose.yml 和 start.mjs 2026-02-27 20:21:20 +08:00
root
70c59eb71d chore: 将 .claude/ 和 CLAUDE.local.md 从 git 跟踪中排除 2026-02-27 20:19:00 +08:00
AYANGarch
f60a3ea501 docs(readme): add ds2api whale icon 2026-02-26 23:18:57 +08:00
AYANGarch
3f09d60cdc feat(zeabur): add one-click deploy template 2026-02-26 22:54:50 +08:00
BigUncle
d3b5493d2e fix(claude): guard thinking tool-call fallback when final text exists
- only parse tool_calls from thinking when finalText is empty

- apply the same guard in stream runtime finalizer

- add regression tests for non-stream and stream paths
2026-02-26 00:41:39 +08:00
BigUncle
255feb2e65 fix(claude): 修复工具调用兼容与解析回退
- Claude 工具定义兼容 input_schema 与 function.parameters

- tool_calls 解析增加 thinking 回退与大小写无关工具名匹配

- 补充 claude/util 相关回归测试
2026-02-25 18:03:25 +08:00
CJACK.
4b73315df0 Merge pull request #51 from CJackHwang/dev
feat: Implement multi-stage Docker build for releases, reusing pre-bu…
2026-02-23 04:06:18 +08:00
CJACK
a086e0cfa1 feat: Refactor Dockerfile to use BusyBox for core utilities and update healthcheck commands in Docker Compose and deployment documentation. 2026-02-23 04:05:22 +08:00
CJACK
f3bc022a36 feat: Implement multi-stage Docker build for releases, reusing pre-built artifacts from CI and updating documentation. 2026-02-23 03:52:55 +08:00
CJACK
b7cb7ef0c1 ci: use gh cli for release asset upload 2026-02-23 02:20:05 +08:00
CJACK
267420a46a ci: add workflow_dispatch with release tag input 2026-02-23 02:01:01 +08:00
CJACK
3c66ab958a ci: fix GHCR probe and require explicit release tag upload 2026-02-23 01:58:08 +08:00
CJACK.
cf2f79b6f4 Merge pull request #50 from CJackHwang/dev
更新
2026-02-23 01:38:40 +08:00
CJACK
ab6e817c8e 更新 2026-02-23 01:36:46 +08:00
CJACK.
9ae4630a3b Merge pull request #48 from CJackHwang/dev
Merge pull request #47 from CJackHwang/codex/fix-ci-workflow-errors-during-build

ci: 增强 release-artifacts 工作流对 GHCR 超时与上传失败的容错
2026-02-23 00:50:59 +08:00
CJACK.
d1b8537cfb Merge pull request #47 from CJackHwang/codex/fix-ci-workflow-errors-during-build
ci: 增强 release-artifacts 工作流对 GHCR 超时与上传失败的容错
2026-02-23 00:49:51 +08:00
CJACK.
d32b4481da ci: 提升发布流程对 GHCR 网络波动的容错 2026-02-23 00:49:09 +08:00
CJACK.
52a04ac575 Merge pull request #46 from CJackHwang/dev
feat: prevent raw tool call JSON leakage for unknown or rejected tool calls and consolidate container publishing to GHCR.
2026-02-23 00:30:17 +08:00
CJACK
0d3d535c08 feat: prevent raw tool call JSON leakage for unknown or rejected tool calls and consolidate container publishing to GHCR. 2026-02-23 00:27:46 +08:00
CJACK.
224462018a Merge pull request #45 from CJackHwang/dev
Merge pull request #44 from CJackHwang/codex/investigate-release-workflow-error

ci: 增加 Node 单测失败摘要输出
2026-02-22 23:36:36 +08:00
CJACK.
35e89230fd Merge pull request #44 from CJackHwang/codex/investigate-release-workflow-error
ci: 增加 Node 单测失败摘要输出
2026-02-22 23:31:34 +08:00
CJACK.
9a57af6092 ci: 增加 Node 单测失败摘要输出 2026-02-22 23:28:40 +08:00
CJACK.
2e1bd8a481 Merge pull request #42 from CJackHwang/codex/fix-sieve-tool-call-filtering-issues
fix(node): 移除被过滤工具调用的回退重发并对齐 Go 行为
2026-02-22 23:07:49 +08:00
CJACK.
1e678ecc1a fix(node): 移除被过滤工具调用的回退重发并对齐 Go 行为 2026-02-22 23:05:40 +08:00
CJACK.
6b3523a66d Merge pull request #41 from CJackHwang/dev
refactor: Relocate JavaScript source and Node.js test files to dedicated directories and extract OpenAI stream runtime tool call finalization logic.
2026-02-22 22:48:29 +08:00
CJACK
d4017b87c1 refactor: Relocate JavaScript source and Node.js test files to dedicated directories and extract OpenAI stream runtime tool call finalization logic. 2026-02-22 22:37:08 +08:00
CJACK
d3b60edb6f feat: introduce Gemini API compatibility, Claude API shortcuts, and enhanced Admin API endpoints with related documentation and deployment updates. 2026-02-22 22:25:52 +08:00
CJACK
6baf687ecf feat: Add support for x-goog-api-key header and api_key query parameter for API key detection. 2026-02-22 22:14:09 +08:00
CJACK
7da012a4d8 feat: Include an empty content field in the final streamed stop chunk and add tests to validate its structure. 2026-02-22 21:44:08 +08:00
CJACK
6c318f1910 fix: Ensure incomplete tool call items are properly closed and required tool choice failures are correctly handled for malformed payloads. 2026-02-22 21:27:42 +08:00
CJACK
a9403c5392 feat: add Gemini API compatibility, refactor stream rendering, and enhance tool call handling and configuration options 2026-02-22 20:53:42 +08:00
CJACK
ae7dce0b32 feat: Improve OpenAI tool call handling by passing unknown tool calls as content and filtering streamed tool calls by schema. 2026-02-22 19:33:52 +08:00
CJACK
312728c8b6 删计划 2026-02-22 18:34:26 +08:00
CJACK
acf39f2823 feat: introduce new quality gates, Node.js syntax checks, and manual smoke test status validation 2026-02-22 18:33:30 +08:00
CJACK
8de87fb9e0 docs: add initial project plan document (PLAN.md) 2026-02-22 17:27:33 +08:00
CJACK
6c48429b90 feat: Implement DeepSeek integration, refactor model adapters for streaming and tool calls, enhance admin and account management, and introduce new UI features for settings, API testing, and Vercel sync. 2026-02-22 17:25:48 +08:00
CJACK.
cc6af8fd28 Merge pull request #39 from CJackHwang/dev
同步
2026-02-22 01:45:01 +08:00
CJACK
5d3989a9a7 test: Add stream status capture tests for OpenAI and Claude adapters. 2026-02-22 01:28:08 +08:00
CJACK
920767f486 feat: Add Gemini adapter, improve API key fallback for Gemini AI Studio compatibility, and enhance OpenAI tool call streaming. 2026-02-22 01:26:08 +08:00
CJACK
7a4e994f3a test: add requestTraceID priority tests and refine tool call delta assertion in streaming responses. 2026-02-21 19:31:27 +08:00
CJACK
13b1ec46ee feat: Implement request tracing and enhance tool call streaming stability by preventing speculative deltas and improving multi-call finalization. 2026-02-21 19:19:05 +08:00
CJACK
e2cb07f08c feat: Introduce stable call_id for OpenAI function_call and tool_calls events in streaming output, including reasoning text. 2026-02-21 09:47:38 +08:00
CJACK
541816f2ab feat: implement local dev packet capture functionality with admin endpoints and configurable limits for debugging. 2026-02-20 03:46:15 +08:00
CJACK
dec9d03fc5 feat: enhance OpenAI response rendering to include reasoning and improve tool call detection from thinking channel, and refactor testing scripts for unified unit test execution. 2026-02-20 03:30:39 +08:00
CJACK
2781951ce7 feat: Implement Vercel environment detection and pause settings auto-fetch after consecutive failures to prevent excessive API calls. 2026-02-20 03:22:27 +08:00
CJACK
1d2a6bf281 fix: Prevent accidental loss of output text by refining tool call parsing for standalone payloads. 2026-02-20 03:18:28 +08:00
CJACK
db49a3ec02 feat: Standardize tool call and result history formatting for OpenAI and Claude adapters with updated prompt guidance. 2026-02-20 03:06:08 +08:00
CJACK
c509066943 ci: Refactor release workflow to use environment variables for Docker Hub credentials and dynamically determine Docker Hub image name. 2026-02-20 02:23:58 +08:00
CJACK.
0283846543 Merge pull request #38 from CJackHwang/dev
测试
2026-02-19 16:49:40 +08:00
CJACK
210d9f5793 feat: enhance message normalization for OpenAI tool calls and Claude system message tool injection 2026-02-19 04:44:01 +08:00
CJACK
dd6af0788e feat: Enhance server startup logging to display local and LAN URLs by detecting the private IPv4 address. 2026-02-19 03:00:09 +08:00
CJACK
7307a5cc9a feat: Implement admin settings UI, enhance admin authentication with password hashing, and add new streaming runtime logic for Claude and OpenAI adapters with extensive compatibility tests. 2026-02-19 02:45:38 +08:00
CJACK.
3239ef3c3e Merge pull request #37 from CJackHwang/dev
全渠道适配 工具调用优化 后端优化
2026-02-19 01:16:23 +08:00
CJACK
d21aedac83 feat: Hide raw tool call JSON from output_text in OpenAI-style responses when structured tool calls are present. 2026-02-19 00:28:44 +08:00
CJACK
df9aea194c fix: Remove redundant text accumulation to prevent duplicate output in streamed responses and add a test for it. 2026-02-19 00:08:03 +08:00
CJACK
2dcc230852 feat: Introduce DetermineCaller for auth without account pooling and make wide_input_strict_output configurable. 2026-02-18 23:53:50 +08:00
CJACK
51c543631b refactor: Extract OpenAI streaming response payload construction into dedicated utility functions. 2026-02-18 23:40:34 +08:00
CJACK
895423852f refactor: extract Claude and OpenAI response rendering into new util/render package 2026-02-18 23:35:37 +08:00
CJACK
eb253a9d3a feat: Introduce standard request normalization and response building for OpenAI and Claude, enhance tool call streaming, and improve caller identification. 2026-02-18 23:35:17 +08:00
CJACK
3a75b75ae0 feat: Introduce model alias resolution, enhanced configuration options, and improved OpenAI/Claude adapter handling for responses, embeddings, and tool calls. 2026-02-18 23:06:18 +08:00
CJACK
27ecb4b69b feat: Implement response storage and retrieval, add embeddings API, and enhance tool call extraction logic. 2026-02-18 21:42:25 +08:00
root
962700f525 chore: 删除无用文件,清理 .gitignore Python 残留规则 2026-02-18 21:06:02 +08:00
root
e143d13ff6 feat: 编译和安装依赖使用国内镜像 2026-02-18 20:57:23 +08:00
root
2f853d7364 feat: 重写 start.mjs 适配 Go 运行时 2026-02-18 20:53:10 +08:00
root
36099a4ada chore: 删除 Python 残留文件(项目已迁移至 Go) 2026-02-18 20:50:07 +08:00
CJACK
0348fa8a22 feat: Enhance account identification to support email, mobile, and token-only synthetic IDs across API, UI, and documentation. 2026-02-18 20:39:38 +08:00
root
73bdb55cee merge: 合并 main 分支到 docker,保留 docker-compose.yml 和分页接口 2026-02-18 20:38:53 +08:00
CJACK
7fc10573ab feat: Improve tool sieve to correctly preserve trailing text within the same chunk as a tool call. 2026-02-18 17:24:43 +08:00
CJACK
ce74b124d2 fix: Apply responsive height to the Trash2 icon on large screens. 2026-02-18 17:16:57 +08:00
CJACK
f2b10992cc test: Introduce comprehensive edge case tests for various internal packages including SSE, Claude, Auth, Account, Config, Deepseek, Admin, and Util. 2026-02-18 16:52:16 +08:00
CJACK
deec72416e test: Introduce comprehensive edge case tests across multiple modules and refine tool call and OpenAI handler logic. 2026-02-18 16:51:30 +08:00
CJACK
7beeea5779 feat: Implement streaming incremental tool call deltas with a new tool sieve and standalone parser. 2026-02-18 16:10:35 +08:00
CJACK
19289c9008 refactor: Modularize OpenAI message normalization and prompt building, enhancing MessagesPrepare to support additional content types and tool call formatting. 2026-02-18 00:54:54 +08:00
CJACK
89e93a1674 feat: Improve configuration loading robustness, add Vercel-specific fallbacks, and update documentation for config.json best practices. 2026-02-18 00:38:38 +08:00
CJACK.
f62fa22338 Merge pull request #35 from CJackHwang/codex/add-docker-image-build-to-github-actions
ci: include docker image artifacts in release assets
2026-02-17 19:52:26 +08:00
CJACK.
2acf58590a ci: publish docker image archives in release assets 2026-02-17 19:51:53 +08:00
CJACK.
46a56d0389 Merge pull request #34 from CJackHwang/dev
Merge pull request #33 from CJackHwang/codex/add-docker-image-build-to-github-actions

ci: build and publish Docker images in release workflow
2026-02-17 19:46:04 +08:00
CJACK.
cfd57288d7 Merge pull request #33 from CJackHwang/codex/add-docker-image-build-to-github-actions
ci: build and publish Docker images in release workflow
2026-02-17 19:44:20 +08:00
CJACK.
1049a723d8 ci: publish docker image on release 2026-02-17 19:43:12 +08:00
CJACK.
4dae9a3882 Merge pull request #32 from CJackHwang/dev
refactor: Improve chat stream content and tool call parsing with a new recursive extraction function and dedicated tests.
2026-02-17 14:36:29 +08:00
CJACK
05422b2449 refactor: Improve chat stream content and tool call parsing with a new recursive extraction function and dedicated tests. 2026-02-17 14:35:24 +08:00
CJACK.
50e66b1571 Merge pull request #31 from CJackHwang/dev
feat: Standardize tool name fallback to 'unknown' for parity with Go and ensure `parseTool` consistently returns raw input on parsing failures.
2026-02-17 14:20:06 +08:00
CJACK
5106773573 feat: Standardize tool name fallback to 'unknown' for parity with Go and ensure parseTool consistently returns raw input on parsing failures. 2026-02-17 14:18:47 +08:00
CJACK.
a9828e33ad Merge pull request #30 from CJackHwang/dev
feat: Add comprehensive historical and current Claude model IDs for API compatibility and dynamic Docker port configuration.
2026-02-17 14:03:03 +08:00
CJACK
76ae2fed51 feat: Add comprehensive historical and current Claude model IDs for API compatibility and dynamic Docker port configuration. 2026-02-17 14:01:31 +08:00
CJACK
d0549c27c7 feat: Add OpenCode CLI integration instructions to READMEs and provide an example configuration file. 2026-02-17 13:44:14 +08:00
CJACK
7dcddef91f feat: Update Claude model names and IDs across configuration, documentation, and tests, including the default model and thinking delta logic. 2026-02-17 13:36:19 +08:00
CJACK
6697d0d227 feat: enhance tool call streaming and anti-leakage by suppressing invalid or incomplete tool JSON and refining detection in Node.js. 2026-02-17 13:18:52 +08:00
CJACK
d21fb74f29 fix: Prevent partial tool call JSON leaks in stream processing by removing size-based buffer limits and holding incomplete blocks longer. 2026-02-17 12:57:01 +08:00
CJACK.
0f389471ac Merge pull request #29 from CJackHwang/dev
Merge pull request #28 from CJackHwang/codex/fix-api-stream-buffering-issue

Stream Go proxy responses to Vercel clients
2026-02-17 12:10:57 +08:00
CJACK.
5c1dd59502 Merge pull request #28 from CJackHwang/codex/fix-api-stream-buffering-issue
Stream Go proxy responses to Vercel clients
2026-02-17 12:09:29 +08:00
CJACK.
0bdbb3a4ef Stream Go proxy responses to Vercel clients 2026-02-17 12:08:45 +08:00
CJACK.
031f5cd39e Merge pull request #27 from CJackHwang/dev
Dev
2026-02-17 04:51:09 +08:00
CJACK
5fbea97aec docs: Enhance architecture diagrams, update API and deployment configurations, and remove obsolete documentation files. 2026-02-17 04:45:21 +08:00
CJACK
07de35a093 refactor: centralize SSE stream parsing logic into a new sse package and update the PoW solver to honor context cancellation during module acquisition. 2026-02-17 04:40:01 +08:00
CJACK
23d5ac7fa2 feat: centralize DeepSeek SSE parsing, improve account identifier resolution, and simplify CORS configuration. 2026-02-17 03:45:55 +08:00
CJACK
2cde0a1d84 refactor: Enhance WASM POW solver with channel-based pooling and configurable size, update token estimation, and fix CORS origin reflection. 2026-02-17 03:34:48 +08:00
CJACK
534fd1d14b feat: centralize utility functions, abstract SSE stream collection, and add concurrency to admin account testing. 2026-02-17 03:31:19 +08:00
CJACK
4251438ff5 feat: Implement graceful server shutdown, optimize WASM module instantiation, remove tokenizer files, and refine config saving and admin key warning. 2026-02-17 03:23:56 +08:00
CJACK
f8effc5e84 feat: add option to prune old test run directories, keeping a configurable maximum number. 2026-02-17 02:57:21 +08:00
CJACK
d3ac698129 docs: comprehensively update and expand project documentation across various guides for improved clarity and detail. 2026-02-17 02:50:11 +08:00
CJACK
a19f281229 feat: reimplement Claude streaming to use full SSE events with thinking, tool calls, and stream management, and add related test cases. 2026-02-17 02:31:56 +08:00
CJACK
8cbb5a4262 feat: Introduce a new Go-based test suite runner with supporting scripts and documentation. 2026-02-17 02:03:41 +08:00
CJACK
416b9939fc Refactor admin handlers into specialized files and introduce OpenAI tool sieving and Vercel streaming capabilities. 2026-02-17 01:35:10 +08:00
CJACK
eeccc967f5 feat: Improve tool detection by implementing a new content splitting strategy that identifies suspicious prefixes. 2026-02-17 01:02:21 +08:00
CJACK.
003a65d9d2 Merge pull request #25 from CJackHwang/dev
继续解决问题
2026-02-17 00:47:07 +08:00
CJACK
555df63fbc feat: Implement tool call sieving and formatting for streaming responses in Node.js, add Go fallback for non-Vercel environments, and update Vercel configuration. 2026-02-17 00:26:34 +08:00
CJACK
770f5719d8 feat: implement stream lease management for Vercel hybrid streaming path to align occupancy duration with native Go streaming behavior. 2026-02-16 23:22:04 +08:00
CJACK
eb470c33ba feat: Add Vercel deployment protection bypass support, enhance related error handling, and update documentation. 2026-02-16 22:06:41 +08:00
CJACK
d70a0acaa8 feat: Introduce hybrid streaming for Vercel deployments using a Go prepare endpoint and Node.js stream handler to mitigate buffering. 2026-02-16 21:56:01 +08:00
CJACK
d668465734 fix(vercel): route admin static assets before api fallback 2026-02-16 20:50:32 +08:00
CJACK
3cf207bcbb fix: Set Vercel output directory to static/admin and update deployment documentation to clarify this configuration. 2026-02-16 20:38:31 +08:00
CJACK
a6a87853d4 feat: Implement a waiting queue for account acquisition with configurable limits and updated status reporting. 2026-02-16 20:30:21 +08:00
CJACK
888a0e6bff refactor: update web UI asset serving and embedding mechanism. 2026-02-16 19:58:33 +08:00
CJACK
190881f13a refactor: introduce a public app package to expose the internal/server router, resolving internal package import restrictions. 2026-02-16 19:11:55 +08:00
CJACK
f82a7e3e3c fix: embed admin webui and wasm for serverless runtime 2026-02-16 18:56:11 +08:00
CJACK
057862f7fb fix: harden webui path and account pool compatibility 2026-02-16 18:30:27 +08:00
CJACK
844832f31b fix: use valid vercel includeFiles glob 2026-02-16 18:17:16 +08:00
CJACK
a32154f881 fix: ship webui assets in vercel go deployment 2026-02-16 18:16:08 +08:00
CJACK
056c50676f fix: embed admin webui for serverless runtime 2026-02-16 18:10:27 +08:00
CJACK
0913c477a6 fix: make vercel go entrypoint internal-safe 2026-02-16 18:04:12 +08:00
CJACK
63ee2e41c2 feat: implement strict-client compatible streaming tool calls with index and add Vercel build troubleshooting guide. 2026-02-16 17:58:40 +08:00
CJACK.
cff62ab839 Merge pull request #24 from CJackHwang/dev
chore: Standardize Go version to 1.24 across Dockerfile, go.mod, GitHub Actions, and documentation.
2026-02-16 17:52:44 +08:00
CJACK
c7ed01bfe7 chore: Standardize Go version to 1.24 across Dockerfile, go.mod, GitHub Actions, and documentation. 2026-02-16 16:52:05 +08:00
CJACK
dec61b8008 feat: Add GitHub Actions workflow for automated release artifact generation, update Vercel configuration, and document the release process. 2026-02-16 16:35:36 +08:00
CJACK
ac57cabc80 feat: Intercept and format tool calls, including unknown ones, from model responses in the OpenAI adapter, preventing raw JSON leaks and improving stream finalization. 2026-02-16 16:24:21 +08:00
CJACK
6a6f380987 feat: implement per-account inflight request limits and dynamic recommended concurrency calculation. 2026-02-16 16:11:12 +08:00
CJACK
c7ffcd76e6 feat: Enhance DeepSeek API compatibility by updating SSE parsing, standardizing error responses, and improving API key management in the tester UI. 2026-02-16 15:17:42 +08:00
CJACK
57f2041edb feat: enhance tool call parsing robustness, authentication flexibility, and streaming output for tool content 2026-02-16 01:24:52 +08:00
CJACK
bd788a12b1 Remove the ds2api application and update deployment and contributing documentation. 2026-02-15 20:08:21 +08:00
CJACK
a50e2ef5cd feat: Introduce a new Go-based DeepSeek API proxy with adapters for Claude and OpenAI, including SSE parsing and updated build configurations. 2026-02-15 19:50:26 +08:00
CJACK.
d496122840 Merge pull request #21 from CJackHwang/dev
feat: Display Vercel configuration sync status and last sync time, and clear API tester streaming output before new requests.
2026-02-13 23:54:53 +08:00
CJACK
35b99cdf4c feat: Add streaming mode toggle to API tester, enable X-Ds2-Target-Account header, and update API request logic. 2026-02-13 23:43:14 +08:00
CJACK
85ac0d95a4 feat: Display Vercel configuration sync status and last sync time, and clear API tester streaming output before new requests. 2026-02-13 23:11:15 +08:00
CJACK
648b80bb7b feat: Add support for parsing dictionary-based SSE fragments and prevent duplicate stream termination messages. 2026-02-13 22:44:39 +08:00
CJACK
ee0b7f08a0 refactor: centralize account cleanup in API routes and refine login checkbox styling. 2026-02-13 22:30:55 +08:00
CJACK.
d0b159fb8a Merge pull request #19 from ronghuaxueleng/main
feat: 账号管理界面优化
2026-02-07 16:32:20 +08:00
root
d57f547bdb feat: 账号管理界面优化
- 账号列表支持分页(每页10条,倒序显示)
- API 密钥列表支持展开/关闭
2026-02-07 13:42:30 +08:00
root
3f3198c959 feat: 账号管理界面优化
- 账号列表支持分页(每页10条,倒序显示)
- API 密钥列表支持展开/关闭
2026-02-07 13:40:14 +08:00
root
6b8f7f8821 feat: 启动脚本显示所有环境变量 2026-02-07 10:55:34 +08:00
root
ac9a1ae742 merge: 合并 main 分支到 docker 2026-02-07 10:28:18 +08:00
CJACK.
6a536c5c27 Merge pull request #17 from CJackHwang/codex/fix-syntax-error-in-serverless-function
Fix indentation in non-streaming SSE parsing
2026-02-06 03:02:20 +08:00
CJACK.
ce73814583 Fix indentation in openai non-stream parser 2026-02-06 03:01:09 +08:00
CJACK.
792d458ce0 Merge pull request #15 from CJackHwang/codex/add-english-localization-support
feat: add bilingual docs and WebUI i18n toggle
2026-02-06 02:52:37 +08:00
CJACK.
392473722c fix: keep ApiTester i18n effect in component 2026-02-06 02:48:05 +08:00
CJACK.
baca42280f Merge pull request #16 from CJackHwang/codex/analyze-non-streaming-model-output
Unify non-stream parsing with SSE parser
2026-02-06 02:47:28 +08:00
CJACK.
ba96554cc1 Unify non-stream parsing with SSE parser 2026-02-06 02:45:36 +08:00
CJACK.
015ec6eb3c feat: add i18n language toggle and bilingual docs 2026-02-06 02:36:49 +08:00
CJACK.
9626d6ccbd Update README.MD 2026-02-04 20:26:08 +08:00
CJACK.
aa032540dc Merge pull request #11 from CJackHwang/codex/fix-vercel-deployment-adapt-issue-ota5s1
Stop committing WebUI build artifacts; add Vercel build + cache headers; remove committed static/admin files
2026-02-04 20:21:34 +08:00
CJACK.
b6062fdb0b Merge pull request #14 from CJackHwang/codex/update-vercel.json-build-configurations
Remove deprecated Vercel builds config
2026-02-04 20:19:17 +08:00
CJACK.
fda5f9339e Remove deprecated Vercel builds config 2026-02-04 20:17:51 +08:00
CJACK.
86ade10888 Fail build if WebUI output missing 2026-02-04 19:53:05 +08:00
CJACK.
063c5d2540 Build WebUI in Docker image 2026-02-04 19:40:34 +08:00
CJACK.
4e62292500 Merge pull request #12 from CJackHwang/revert-8-codex/fix-vercel-deployment-adapt-issue
Revert "Build WebUI during Vercel deployment and enforce Cache-Control for admin UI"
2026-02-04 18:25:43 +08:00
CJACK.
58d54adee2 Revert "Build WebUI during Vercel deployment and enforce Cache-Control for admin UI" 2026-02-04 18:25:04 +08:00
CJACK.
8ca5581171 Remove committed WebUI build artifacts 2026-02-04 18:05:57 +08:00
CJACK.
2007e0cde3 Merge pull request #8 from CJackHwang/codex/fix-vercel-deployment-adapt-issue
Build WebUI during Vercel deployment and enforce Cache-Control for admin UI
2026-02-04 14:25:41 +08:00
CJACK.
51f8d3c09f Clarify WebUI build ownership in docs 2026-02-04 14:19:27 +08:00
CJACK.
94238070d8 Update accounts module description 2026-02-04 14:12:42 +08:00
CJACK.
a917e19e9d Fix Vercel config routing properties 2026-02-04 14:00:19 +08:00
CJACK.
f65cf0c510 Build webui during Vercel deployment 2026-02-04 13:57:34 +08:00
github-actions[bot]
840042d301 chore: auto-build WebUI [skip ci] 2026-02-04 05:35:55 +00:00
CJACK.
16ea6b7a5a Merge pull request #7 from CJackHwang/codex/review-pr-for-docker-compatibility
Improve dev Docker reload, account token/session handling, and Vercel sync
2026-02-04 13:35:34 +08:00
CJACK.
d67b64633b Fix docker dev reload and token sync 2026-02-04 13:25:43 +08:00
root
bd4c2bacbc merge: 合并 main 分支到 docker 2026-02-02 20:31:42 +08:00
root
6cfc7051c4 Merge remote-tracking branch 'origin/main' into docker 2026-02-02 20:29:11 +08:00
root
22a2a97a76 feat: 添加 Docker 和 GitHub Actions 支持
- 添加 docker/Dockerfile 多阶段构建(前端+后端)
- 添加 docker-compose.yml 支持阿里云镜像部署
- 添加 .github/workflows/release.yml 自动发布到阿里云
- 添加 .dockerignore 优化构建
- 添加 VERSION 版本管理文件
- 添加 start.mjs 本地开发启动脚本
2026-02-02 20:23:33 +08:00
424 changed files with 47632 additions and 273079 deletions

View File

@@ -10,7 +10,9 @@ __pycache__
.Python
build/
develop-eggs/
dist/
dist/*
!dist/docker-input/
!dist/docker-input/*.tar.gz
downloads/
eggs/
.eggs/

View File

@@ -1,74 +1,17 @@
# DS2API 环境变量配置模板
# 复制此文件为 .env 并根据需要修改
# 最后更新2026-02
# ===============================================================
# 核心配置
# ===============================================================
# ----- 服务配置 -----
# 服务端口(默认 5001
# DS2API runtime
PORT=5001
# 服务监听地址
HOST=0.0.0.0
# 日志级别 (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
# Admin authentication
DS2API_ADMIN_KEY=change-me
# ===============================================================
# 数据配置(三选一)
# ===============================================================
# Config loading (choose one)
# 1) file-based config
DS2API_CONFIG_PATH=/app/config.json
# 2) inline JSON or Base64 JSON
# DS2API_CONFIG_JSON=
# 3) legacy compatibility alias
# CONFIG_JSON=
# 方式1: JSON 字符串(适合简单配置)
# DS2API_CONFIG_JSON={"keys":["your-api-key"],"accounts":[{"email":"user@example.com","password":"xxx","token":""}]}
# 方式2: Base64 编码的 JSON推荐用于 Vercel避免特殊字符转义问题
# 生成方式: echo '{"keys":["your-api-key"],"accounts":[...]}' | base64
# DS2API_CONFIG_JSON=eyJrZXlzIjpbInlvdXItYXBpLWtleSJdLCJhY2NvdW50cyI6W3siZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGFzc3dvcmQiOiJ4eHgiLCJ0b2tlbiI6IiJ9XX0=
# 方式3: 配置文件路径(本地开发推荐)
# DS2API_CONFIG_PATH=config.json
# ===============================================================
# 管理界面配置
# ===============================================================
# Admin API 密钥Vercel 部署必填!)
# 用于保护 WebUI 管理界面,首次访问 /admin 时需要输入此密钥登录
DS2API_ADMIN_KEY=your-admin-secret-key
# JWT Token 过期时间(秒,默认 86400 = 24小时
# DS2API_SESSION_EXPIRE=86400
# ===============================================================
# Vercel 集成(可选)
# ===============================================================
# Vercel API Token
# 获取方式: https://vercel.com/account/tokens
# VERCEL_TOKEN=your-vercel-token
# Vercel Project ID
# 获取方式: Vercel 控制台 -> 项目设置 -> General -> Project ID
# VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx
# Vercel Team ID个人项目无需填写团队项目才需要
# VERCEL_TEAM_ID=
# ===============================================================
# 高级配置(可选)
# ===============================================================
# Tokenizer 目录(留空使用项目根目录)
# DS2API_TOKENIZER_DIR=
# 模板目录
# DS2API_TEMPLATES_DIR=templates
# WASM 文件路径PoW 计算用)
# DS2API_WASM_PATH=sha3_wasm_bg.7b9ca65ddd.wasm
# Optional: static admin assets path
# DS2API_STATIC_ADMIN_DIR=/app/static/admin

View File

@@ -1,24 +1,20 @@
#### 💻 变更类型 | Change Type
<!-- For change type, change [ ] to [x]. -->
- [ ] ✨ feat
- [ ] 🐛 fix
- [ ] ♻️ refactor
- [ ] 💄 style
- [ ] 👷 build
- [ ] ⚡️ perf
- [ ] 📝 docs
- [ ] 🔨 chore
#### 🔀 变更说明 | Description of Change
<!-- Thank you for your Pull Request. Please provide a description above. -->
#### 📝 补充信息 | Additional Information
<!-- Add any other context about the Pull Request here. -->
---
> 💡 **提示**:如果修改了 `webui/` 目录下的文件PR 合并后 CI 会自动构建并提交产物,无需手动构建。
#### 💻 变更类型 | Change Type
<!-- For change type, change [ ] to [x]. -->
- [ ] ✨ feat
- [ ] 🐛 fix
- [ ] ♻️ refactor
- [ ] 💄 style
- [ ] 👷 build
- [ ] ⚡️ perf
- [ ] 📝 docs
- [ ] 🔨 chore
#### 🔀 变更说明 | Description of Change
<!-- Thank you for your Pull Request. Please provide a description above. -->
#### 📝 补充信息 | Additional Information
<!-- Add any other context about the Pull Request here. -->

View File

@@ -1,76 +0,0 @@
# 自动构建 WebUI 并提交构建产物
# 触发条件webui 目录下的文件变更
name: Build WebUI
on:
push:
branches:
- main
paths:
- 'webui/**'
- '.github/workflows/build-webui.yml'
pull_request:
branches:
- main
paths:
- 'webui/**'
# 允许手动触发
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
# 只在主仓库运行,避免 fork 仓库运行
if: github.repository == 'CJackHwang/ds2api'
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: webui/package-lock.json
- name: Install dependencies
working-directory: webui
run: npm ci
- name: Build WebUI
working-directory: webui
run: npm run build
- name: Check for changes
id: check_changes
run: |
git add static/admin
if git diff --staged --quiet; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Commit and push changes
if: steps.check_changes.outputs.changed == 'true' && github.event_name == 'push'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git commit -m "chore: auto-build WebUI [skip ci]"
git push
- name: Upload build artifacts (for PR review)
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: webui-build
path: static/admin
retention-days: 7

40
.github/workflows/quality-gates.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Quality Gates
on:
pull_request:
push:
branches:
- dev
permissions:
contents: read
jobs:
quality-gates:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.x"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: webui/package-lock.json
- name: Refactor Line Gate
run: ./tests/scripts/check-refactor-line-gate.sh
- name: Unit Gates (Go + Node)
run: ./tests/scripts/run-unit-all.sh
- name: WebUI Build Gate
run: |
npm ci --prefix webui
npm run build --prefix webui

207
.github/workflows/release-artifacts.yml vendored Normal file
View File

@@ -0,0 +1,207 @@
name: Release Artifacts
on:
release:
types:
- published
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to build/publish (e.g. v2.1.6)"
required: true
type: string
permissions:
contents: write
packages: write
jobs:
build-and-upload:
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.x"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: webui/package-lock.json
- name: Release Blocking Gates
run: |
./tests/scripts/check-stage6-manual-smoke.sh
./tests/scripts/check-refactor-line-gate.sh
./tests/scripts/run-unit-all.sh
- name: Build WebUI
run: |
npm ci --prefix webui
npm run build --prefix webui
- name: Build Multi-Platform Archives
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
BUILD_VERSION="${TAG}"
if [ -z "${BUILD_VERSION}" ] && [ -f VERSION ]; then
BUILD_VERSION="$(cat VERSION | tr -d '[:space:]')"
fi
mkdir -p dist
targets=(
"linux/amd64"
"linux/arm64"
"darwin/amd64"
"darwin/arm64"
"windows/amd64"
)
for target in "${targets[@]}"; do
GOOS="${target%/*}"
GOARCH="${target#*/}"
PKG="ds2api_${TAG}_${GOOS}_${GOARCH}"
STAGE="dist/${PKG}"
BIN="ds2api"
if [ "${GOOS}" = "windows" ]; then
BIN="ds2api.exe"
fi
mkdir -p "${STAGE}/static"
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 -R static/admin "${STAGE}/static/admin"
if [ "${GOOS}" = "windows" ]; then
(cd dist && zip -rq "${PKG}.zip" "${PKG}")
else
tar -C dist -czf "dist/${PKG}.tar.gz" "${PKG}"
fi
rm -rf "${STAGE}"
done
- name: Prepare Docker release inputs
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
mkdir -p dist/docker-input
cp "dist/ds2api_${TAG}_linux_amd64.tar.gz" "dist/docker-input/linux_amd64.tar.gz"
cp "dist/ds2api_${TAG}_linux_arm64.tar.gz" "dist/docker-input/linux_arm64.tar.gz"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Wait for GHCR endpoint
run: |
set -euo pipefail
for i in {1..6}; do
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 https://ghcr.io/v2/ || true)"
if [ "${code}" = "200" ] || [ "${code}" = "401" ] || [ "${code}" = "405" ]; then
exit 0
fi
sleep "$((i * 10))"
done
echo "GHCR endpoint is unreachable after multiple retries (last status: ${code:-unknown})." >&2
exit 1
- name: Log in to GHCR (with retry)
run: |
set -euo pipefail
for i in {1..6}; do
if echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin; then
exit 0
fi
sleep "$((i * 10))"
done
echo "Failed to login to GHCR after multiple retries." >&2
exit 1
- name: Extract Docker metadata
id: meta_release
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ env.RELEASE_TAG }}
type=raw,value=latest
- name: Build and Push Docker Image
uses: docker/build-push-action@v6
env:
DOCKER_BUILD_RECORD_UPLOAD: "false"
DOCKER_BUILD_SUMMARY: "false"
with:
context: .
file: ./Dockerfile
target: runtime-from-dist
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta_release.outputs.tags }}
labels: ${{ steps.meta_release.outputs.labels }}
- name: Export Docker image archives for release assets
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
docker buildx build \
--platform linux/amd64 \
--target runtime-from-dist \
--output type=docker,dest="dist/ds2api_${TAG}_docker_linux_amd64.tar" \
.
docker buildx build \
--platform linux/arm64 \
--target runtime-from-dist \
--output type=docker,dest="dist/ds2api_${TAG}_docker_linux_arm64.tar" \
.
gzip -f "dist/ds2api_${TAG}_docker_linux_amd64.tar"
gzip -f "dist/ds2api_${TAG}_docker_linux_arm64.tar"
- name: Generate checksums
run: |
set -euo pipefail
(cd dist && sha256sum *.tar.gz *.zip > sha256sums.txt)
- name: Validate release tag
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
if [ -z "${TAG}" ]; then
echo "release tag is empty; set release_tag when using workflow_dispatch." >&2
exit 1
fi
- name: Upload Release Assets
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
FILES=(
dist/*.tar.gz
dist/*.zip
dist/sha256sums.txt
)
if gh release view "${TAG}" >/dev/null 2>&1; then
gh release upload "${TAG}" "${FILES[@]}" --clobber
else
gh release create "${TAG}" "${FILES[@]}" --title "${TAG}" --notes ""
fi

129
.github/workflows/release-dockerhub.yml vendored Normal file
View File

@@ -0,0 +1,129 @@
name: Release to Docker Hub
on:
workflow_dispatch:
inputs:
version_type:
description: '版本类型'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get current version
id: get_version
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
TAG_VERSION=${LATEST_TAG#v}
if [ -f VERSION ]; then
FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
else
FILE_VERSION="0.0.0"
fi
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
VERSION="$FILE_VERSION"
else
VERSION="$TAG_VERSION"
fi
echo "Current version: $VERSION"
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
- name: Calculate next version
id: next_version
env:
VERSION_TYPE: ${{ github.event.inputs.version_type }}
run: |
VERSION="${{ steps.get_version.outputs.current_version }}"
BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//')
IFS='.' read -r -a version_parts <<< "$BASE_VERSION"
MAJOR="${version_parts[0]:-0}"
MINOR="${version_parts[1]:-0}"
PATCH="${version_parts[2]:-0}"
case "$VERSION_TYPE" in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
*)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
esac
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Update VERSION file
run: |
echo "${{ steps.next_version.outputs.new_version }}" > VERSION
- name: Commit VERSION and create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION
if ! git diff --cached --quiet; then
git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]"
fi
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
git push origin HEAD:main "$NEW_TAG"
# Docker 构建并推送到 Docker Hub
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:${{ steps.next_version.outputs.new_tag }}
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:${{ steps.next_version.outputs.new_version }}
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:latest
labels: |
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
BUILD_VERSION=${{ steps.next_version.outputs.new_tag }}
cache-from: type=gha
cache-to: type=gha,mode=max

130
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,130 @@
name: Release to Aliyun CR
on:
workflow_dispatch:
inputs:
version_type:
description: '版本类型'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get current version
id: get_version
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
TAG_VERSION=${LATEST_TAG#v}
if [ -f VERSION ]; then
FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
else
FILE_VERSION="0.0.0"
fi
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
VERSION="$FILE_VERSION"
else
VERSION="$TAG_VERSION"
fi
echo "Current version: $VERSION"
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
- name: Calculate next version
id: next_version
env:
VERSION_TYPE: ${{ github.event.inputs.version_type }}
run: |
VERSION="${{ steps.get_version.outputs.current_version }}"
BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//')
IFS='.' read -r -a version_parts <<< "$BASE_VERSION"
MAJOR="${version_parts[0]:-0}"
MINOR="${version_parts[1]:-0}"
PATCH="${version_parts[2]:-0}"
case "$VERSION_TYPE" in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
*)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
esac
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Update VERSION file
run: |
echo "${{ steps.next_version.outputs.new_version }}" > VERSION
- name: Commit VERSION and create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION
if ! git diff --cached --quiet; then
git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]"
fi
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
git push origin HEAD:main "$NEW_TAG"
# Docker 构建并推送到阿里云
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Aliyun Container Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.ALIYUN_REGISTRY }}
username: ${{ secrets.ALIYUN_REGISTRY_USER }}
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }}
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }}
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest
labels: |
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
BUILD_VERSION=${{ steps.next_version.outputs.new_tag }}
cache-from: type=gha
cache-to: type=gha,mode=max

50
.gitignore vendored
View File

@@ -2,37 +2,6 @@
config.json
.env
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
ENV/
env/
.venv
# IDE
.vscode/
.idea/
@@ -44,7 +13,7 @@ env/
# Logs
*.log
logs/
uvicorn.log
artifacts/
# Vercel
.vercel
@@ -55,8 +24,6 @@ webui/node_modules/
webui/dist/
.npm
.pnpm-store/
# 保留 webui/package-lock.json 用于 CI 缓存
# package-lock.json # 如果有根目录的可以忽略
yarn.lock
pnpm-lock.yaml
@@ -64,6 +31,12 @@ pnpm-lock.yaml
*.tsbuildinfo
.cache/
.parcel-cache/
static/admin/
internal/webui/assets/admin/
# Go compiled binaries
ds2api
ds2api-tests
# Environment
.env.local
@@ -74,9 +47,14 @@ pnpm-lock.yaml
htmlcov/
.pytest_cache/
.tox/
*.coverprofile
coverage*.out
cover/
# Misc
*.pyc
*.pyo
.git/
Thumbs.db
# Claude Code
.claude/
CLAUDE.local.md

1191
API.en.md Normal file

File diff suppressed because it is too large Load Diff

1378
API.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,90 +0,0 @@
# 贡献指南
感谢你对 DS2API 的贡献!
## 开发环境设置
### 后端
```bash
# 1. 克隆仓库
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. 创建虚拟环境(推荐)
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 3. 安装依赖
pip install -r requirements.txt
# 4. 配置
cp config.example.json config.json
# 编辑 config.json
# 5. 启动
python dev.py
```
### 前端 (WebUI)
```bash
cd webui
npm install
npm run dev
```
## 代码规范
- **Python**: 遵循 PEP 8使用 4 空格缩进
- **JavaScript/React**: 使用 4 空格缩进,使用函数组件
- **提交信息**: 使用语义化提交格式(如 `feat:`, `fix:`, `docs:`
## 提交 PR
1. Fork 本仓库
2. 创建功能分支 (`git checkout -b feature/xxx`)
3. 提交更改 (`git commit -m 'feat: 添加xxx功能'`)
4. 推送分支 (`git push origin feature/xxx`)
5. 创建 Pull Request
## WebUI 构建
> **重要**: 修改 `webui/` 目录后 **无需手动构建**
当 PR 合并到 `main` 分支后GitHub Actions 会自动:
1. 构建 WebUI
2. 提交构建产物到 `static/admin/`
如果需要本地构建(测试用):
```bash
./scripts/build-webui.sh
```
## 项目结构
```
ds2api/
├── app.py # FastAPI 应用入口
├── dev.py # 开发服务器
├── core/ # 核心模块
│ ├── auth.py # 账号认证与轮询
│ ├── config.py # 配置管理
│ ├── deepseek.py # DeepSeek API 调用
│ ├── models.py # 模型定义
│ ├── pow.py # PoW 计算
│ └── sse_parser.py # SSE 解析
├── routes/ # API 路由
│ ├── openai.py # OpenAI 兼容接口
│ ├── claude.py # Claude 兼容接口
│ ├── home.py # 首页路由
│ └── admin/ # 管理接口
├── webui/ # React WebUI 源码
├── static/admin/ # WebUI 构建产物(自动生成)
└── scripts/ # 辅助脚本
```
## 问题反馈
- 使用 [GitHub Issues](https://github.com/CJackHwang/ds2api/issues) 报告问题
- 提供详细的复现步骤和日志信息

406
DEPLOY.md
View File

@@ -1,406 +0,0 @@
# DS2API 部署指南
本文档详细介绍 DS2API 的各种部署方式。
---
## 目录
- [Vercel 部署(推荐)](#vercel-部署推荐)
- [Docker 部署(推荐)](#docker-部署推荐)
- [本地开发](#本地开发)
- [生产环境部署](#生产环境部署)
- [常见问题](#常见问题)
---
## Vercel 部署(推荐)
### 一键部署
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api)
### 部署步骤
1. **点击部署按钮**
- 登录你的 GitHub 账号
- 授权 Vercel 访问
2. **设置环境变量**
- `DS2API_ADMIN_KEY`: 管理面板密码(**必填**
3. **等待部署完成**
- Vercel 会自动构建并部署项目
- 部署完成后获得访问 URL
4. **配置账号**
- 访问 `https://your-project.vercel.app/admin`
- 输入管理密码登录
- 添加 DeepSeek 账号
- 设置自定义 API Key
5. **同步配置**
- 点击「同步到 Vercel」按钮
- 首次需要输入 Vercel Token 和 Project ID
- 同步成功后配置会持久化
### 获取 Vercel 凭证
**Vercel Token**:
1. 访问 https://vercel.com/account/tokens
2. 点击 "Create Token"
3. 设置名称和有效期
4. 复制生成的 Token
**Project ID**:
1. 进入 Vercel 项目页面
2. 点击 Settings -> General
3. 复制 "Project ID"
---
## 本地开发
### 环境要求
- Python 3.9+
- Node.js 18+ (WebUI 开发)
- pip
### 快速开始
```bash
# 1. 克隆项目
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. 安装 Python 依赖
pip install -r requirements.txt
# 3. 配置账号
cp config.example.json config.json
# 编辑 config.json填入 DeepSeek 账号信息
# 4. 启动服务
python dev.py
```
### 配置文件示例
```json
{
"keys": ["my-api-key-1", "my-api-key-2"],
"accounts": [
{
"email": "your-email@example.com",
"password": "your-password",
"token": ""
},
{
"mobile": "12345678901",
"password": "your-password",
"token": ""
}
]
}
```
**说明**
- `keys`: 自定义 API Key用于调用本服务的接口
- `accounts`: DeepSeek 网页版账号
- 支持 `email``mobile` 登录
- `token` 留空,系统会自动获取
### WebUI 开发
```bash
# 进入 WebUI 目录
cd webui
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
WebUI 开发服务器会启动在 `http://localhost:5173`,并自动代理 API 请求到后端 `http://localhost:5001`
### WebUI 构建
WebUI 构建产物位于 `static/admin/` 目录。
**自动构建(推荐)**
-`webui/` 目录下的文件变更并推送到 `main` 分支时GitHub Actions 会自动构建并提交产物
- PR 合并时会自动触发构建
**手动构建**
```bash
# 方式1使用脚本
./scripts/build-webui.sh
# 方式2直接执行
cd webui
npm install
npm run build
```
> **贡献者注意**:修改 WebUI 后无需手动构建CI 会自动处理。
---
## Docker 部署(推荐)
Docker 部署采用**零侵入、解耦设计**
- Dockerfile 仅执行标准 Python 项目操作,不硬编码任何项目特定配置
- 所有配置通过环境变量和 `.env` 文件管理
- **主代码更新时只需重新构建镜像,无需修改 Docker 配置**
### 快速开始Docker Compose
```bash
# 1. 复制环境变量模板
cp .env.example .env
# 编辑 .env填写 DS2API_ADMIN_KEY 和 DS2API_CONFIG_JSON
# 2. 启动服务
docker-compose up -d
# 3. 查看日志
docker-compose logs -f
# 4. 主代码更新后重新构建
docker-compose up -d --build
```
### 配置文件挂载方式
如需使用 `config.json` 而非环境变量:
```yaml
# docker-compose.yml
services:
ds2api:
build: .
ports:
- "5001:5001"
environment:
- DS2API_ADMIN_KEY=your-admin-key
volumes:
- ./config.json:/app/config.json:ro
restart: unless-stopped
```
### Docker 命令行部署
```bash
# 构建镜像
docker build -t ds2api:latest .
# 使用环境变量运行
docker run -d \
--name ds2api \
-p 5001:5001 \
-e DS2API_ADMIN_KEY=your-admin-key \
-e DS2API_CONFIG_JSON='{"keys":["api-key"],"accounts":[...]}' \
--restart unless-stopped \
ds2api:latest
# 或使用配置文件挂载
docker run -d \
--name ds2api \
-p 5001:5001 \
-e DS2API_ADMIN_KEY=your-admin-key \
-v $(pwd)/config.json:/app/config.json:ro \
--restart unless-stopped \
ds2api:latest
```
### 开发模式(热重载)
```bash
# 使用开发配置启动,代码修改实时生效
docker-compose -f docker-compose.dev.yml up
```
开发模式特性:
- 源代码挂载到容器,修改即时生效
- 日志级别设为 DEBUG
- 自动读取本地 `config.json`
### 维护命令
```bash
# 查看容器状态
docker-compose ps
# 查看日志
docker-compose logs -f ds2api
# 重启服务
docker-compose restart
# 停止服务
docker-compose down
# 完全重建(清除缓存)
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
---
## 生产环境部署
### 使用 systemd (Linux)
1. **创建服务文件**
```bash
sudo nano /etc/systemd/system/ds2api.service
```
```ini
[Unit]
Description=DS2API Service
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/ds2api
ExecStart=/usr/bin/python3 app.py
Restart=always
RestartSec=10
Environment=PORT=5001
Environment=DS2API_ADMIN_KEY=your-admin-key
[Install]
WantedBy=multi-user.target
```
2. **启动服务**
```bash
sudo systemctl daemon-reload
sudo systemctl enable ds2api
sudo systemctl start ds2api
```
3. **查看状态**
```bash
sudo systemctl status ds2api
sudo journalctl -u ds2api -f
```
### Nginx 反向代理
```nginx
server {
listen 80;
server_name api.yourdomain.com;
# SSL 配置(推荐)
# listen 443 ssl http2;
# ssl_certificate /path/to/cert.pem;
# ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
# 关闭缓冲,支持 SSE
proxy_buffering off;
proxy_cache off;
# 连接设置
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE 超时设置
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# 分块传输
chunked_transfer_encoding on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 120;
}
}
```
---
## 常见问题
### Q: 账号验证失败怎么办?
**A**: 检查以下几点:
1. 确认 DeepSeek 账号密码正确
2. 检查账号是否被封禁或需要验证
3. 尝试在浏览器中手动登录一次
4. 查看日志获取详细错误信息
### Q: 流式响应断开怎么办?
**A**:
1. 检查 Nginx/反向代理配置,确保关闭了 `proxy_buffering`
2. 增加 `proxy_read_timeout` 超时时间
3. 检查网络连接稳定性
### Q: Vercel 部署后配置丢失?
**A**:
1. 确保点击了「同步到 Vercel」按钮
2. 检查 Vercel Token 是否正确且未过期
3. 确认 Project ID 正确
### Q: 如何更新到新版本?
**本地部署**:
```bash
git pull origin main
pip install -r requirements.txt
# 重启服务
```
**Docker 部署**:
```bash
# 拉取最新代码
git pull origin main
# 重新构建并启动(无需修改 Docker 配置)
docker-compose up -d --build
```
**Vercel 部署**:
- 项目会自动从 GitHub 同步更新
- 或在 Vercel 控制台手动触发重新部署
### Q: 如何查看日志?
**本地开发**:
```bash
# 设置日志级别
export LOG_LEVEL=DEBUG
python dev.py
```
**Vercel**:
- 访问 Vercel 控制台 -> 项目 -> Deployments -> Logs
### Q: Token 计数不准确?
**A**: DS2API 使用估算方式计算 token 数量(字符数 / 4与 OpenAI 官方的 tokenizer 可能有差异,仅供参考。
---
## 获取帮助
- **GitHub Issues**: https://github.com/CJackHwang/ds2api/issues
- **文档**: https://github.com/CJackHwang/ds2api

View File

@@ -1,20 +1,66 @@
# DS2API Docker 镜像
# 采用极简、零侵入设计,所有配置通过环境变量传递
# 主代码更新时只需重新构建镜像,无需修改 Dockerfile
FROM node:24 AS webui-builder
FROM python:3.11-slim
WORKDIR /app/webui
COPY webui/package.json webui/package-lock.json ./
RUN npm ci
COPY webui ./
RUN npm run build
FROM golang:1.26 AS go-builder
WORKDIR /app
# 安装依赖(利用 Docker 缓存层)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制整个项目(保留原始目录结构)
ARG TARGETOS
ARG TARGETARCH
ARG BUILD_VERSION
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN set -eux; \
GOOS="${TARGETOS:-$(go env GOOS)}"; \
GOARCH="${TARGETARCH:-$(go env GOARCH)}"; \
BUILD_VERSION_RESOLVED="${BUILD_VERSION:-}"; \
if [ -z "${BUILD_VERSION_RESOLVED}" ] && [ -f VERSION ]; then BUILD_VERSION_RESOLVED="$(cat VERSION | tr -d "[:space:]")"; fi; \
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION_RESOLVED}" -o /out/ds2api ./cmd/ds2api
# 暴露服务端口
FROM busybox:1.36.1-musl AS busybox-tools
FROM debian:bookworm-slim AS runtime-base
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=busybox-tools /bin/busybox /usr/local/bin/busybox
EXPOSE 5001
CMD ["/usr/local/bin/ds2api"]
# 启动命令(依赖项目自身的启动逻辑)
CMD ["python", "app.py"]
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
FROM busybox-tools AS dist-extract
ARG TARGETARCH
COPY dist/docker-input/linux_amd64.tar.gz /tmp/ds2api_linux_amd64.tar.gz
COPY dist/docker-input/linux_arm64.tar.gz /tmp/ds2api_linux_arm64.tar.gz
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) ARCHIVE="/tmp/ds2api_linux_amd64.tar.gz" ;; \
arm64) ARCHIVE="/tmp/ds2api_linux_arm64.tar.gz" ;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
tar -xzf "${ARCHIVE}" -C /tmp; \
PKG_DIR="$(find /tmp -maxdepth 1 -type d -name "ds2api_*_linux_${TARGETARCH}" | head -n1)"; \
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
FROM runtime-from-source AS final

650
README.MD
View File

@@ -1,99 +1,275 @@
<p align="center">
<img src="webui/public/ds2api-favicon.svg" width="128" height="128" alt="DS2API icon" />
</p>
# DS2API
[![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE)
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Version](https://img.shields.io/badge/version-1.6.11-blue.svg)](version.txt)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md#docker-部署推荐)
[![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](docs/DEPLOY.md)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/ds2api)
将 DeepSeek 免费对话版转换为 **OpenAI & Claude 兼容 API**,支持多账号轮询、自动 Token 刷新、可视化管理界面。
语言 / Language: [中文](README.MD) | [English](README.en.md)
![p1](https://github.com/user-attachments/assets/07296a50-50d4-4f05-a9e5-280df14e9532)
![p2](https://github.com/user-attachments/assets/03b4a763-766f-4050-aea8-1a183e70ae6a)
![p3](https://github.com/user-attachments/assets/beb9e41d-4c12-45d1-a26c-154280211185)
![p4](https://github.com/user-attachments/assets/fc8b9836-11e3-4c38-a684-eb2c79b80fe9)
![p5](https://github.com/user-attachments/assets/513e9ca7-aa9e-45a6-8f7e-f362b1650675)
将 DeepSeek Web 对话能力转换为 OpenAI、Claude 与 Gemini 兼容 API。后端为 **Go 全量实现**,前端为 React WebUI 管理台(源码在 `webui/`,部署时自动构建到 `static/admin`)。
> **重要免责声明**
>
> 本仓库仅供学习、研究、个人实验和内部验证使用,不提供任何形式的商业授权、适用性保证或结果保证。
>
> 作者及仓库维护者不对因使用、修改、分发、部署或依赖本项目而产生的任何直接或间接损失、账号封禁、数据丢失、法律风险或第三方索赔负责。
>
> 请勿将本项目用于违反服务条款、协议、法律法规或平台规则的场景。商业使用前请自行确认 `LICENSE`、相关协议以及你是否获得了作者的书面许可。
## 架构概览
```mermaid
flowchart LR
Client["🖥️ 客户端 / SDK\n(OpenAI / Claude / Gemini)"]
Upstream["☁️ DeepSeek API"]
subgraph DS2API["DS2API 3.x统一 OpenAI 内核)"]
Router["chi Router + 中间件\n(RequestID / RealIP / Logger / Recoverer / CORS)"]
subgraph Adapters["协议适配层"]
OA["OpenAI\n/v1/*"]
CA["Claude\n/anthropic/* + /v1/messages"]
GA["Gemini\n/v1beta/models/* + /v1/models/*"]
Admin["Admin API\n/admin/*"]
WebUI["WebUI\n/admin静态托管"]
end
subgraph Runtime["运行时核心能力"]
Bridge["CLIProxy 转换桥\n(多协议 <-> OpenAI)"]
OAEngine["OpenAI ChatCompletions\n(统一工具调用与流式语义)"]
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 预加载)"]
Tool["Tool Sieve\n(Go/Node 语义对齐)"]
end
end
Client --> Router
Router --> OA & CA & GA
Router --> Admin
Router --> WebUI
OA --> OAEngine
CA & GA --> Bridge
Bridge --> OAEngine
OAEngine --> Auth
OAEngine -.账号轮询.-> Pool
OAEngine -.工具调用解析.-> Tool
OAEngine -.PoW 计算.-> Pow
Auth --> DSClient
DSClient --> Upstream
Upstream --> DSClient
OAEngine --> Bridge
Bridge --> Client
```
- **后端**Go`cmd/ds2api/`、`api/`、`internal/`),不依赖 Python 运行时
- **前端**React 管理台(`webui/`),运行时托管静态构建产物
- **部署**本地运行、Docker、Vercel Serverless、Linux systemd
### 3.0 底层架构调整(相较旧版本)
- **统一路由内核**:所有协议入口统一汇聚到 `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 多风格输入。
- **配置与运行时设置解耦**:静态配置(`config`)与运行时策略(`settings`)通过 Admin API 分离管理,支持热更新和密码轮换失效旧 JWT。
- **流式能力升级**`/v1/responses` 与 `/v1/chat/completions` 共享更一致的工具调用增量输出策略,降低不同 SDK 下的行为差异。
- **可观测与可运维增强**`/healthz`、`/readyz`、`/admin/version`、`/admin/dev/captures` 形成排障闭环,便于发布后验证。
## 核心能力
| 能力 | 说明 |
| --- | --- |
| OpenAI 兼容 | `GET /v1/models`、`GET /v1/models/{id}`、`POST /v1/chat/completions`、`POST /v1/responses`、`GET /v1/responses/{response_id}`、`POST /v1/embeddings` |
| Claude 兼容 | `GET /anthropic/v1/models`、`POST /anthropic/v1/messages`、`POST /anthropic/v1/messages/count_tokens`(及快捷路径 `/v1/messages`、`/messages` |
| Gemini 兼容 | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent`(及 `/v1/models/{model}:*` 路径) |
| 多账号轮询 | 自动 token 刷新、邮箱/手机号双登录方式 |
| 并发队列控制 | 每账号 in-flight 上限 + 等待队列,动态计算建议并发值 |
| DeepSeek PoW | WASM 计算(`wazero`),无需外部 Node.js 依赖 |
| Tool Calling | 防泄漏处理:非代码块高置信特征识别、`delta.tool_calls` 早发、结构化增量输出 |
| Admin API | 配置管理、运行时设置热更新、账号测试 / 批量测试、会话清理、导入导出、Vercel 同步、版本检查 |
| WebUI 管理台 | `/admin` 单页应用(中英文双语、深色模式) |
| 运维探针 | `GET /healthz`(存活)、`GET /readyz`(就绪) |
## 平台兼容矩阵
| 级别 | 平台 | 当前状态 |
| --- | --- | --- |
| P0 | Codex CLI/SDK`wire_api=chat` / `wire_api=responses` | ✅ |
| P0 | OpenAI SDKJS/Pythonchat + responses | ✅ |
| P0 | Vercel AI SDKopenai-compatible | ✅ |
| P0 | Anthropic SDKmessages | ✅ |
| P0 | Google Gemini SDKgenerateContent | ✅ |
| P1 | LangChain / LlamaIndex / OpenWebUIOpenAI 兼容接入) | ✅ |
| P2 | MCP 独立桥接层 | 规划中 |
## 模型支持
### OpenAI 接口
| 模型 | thinking | search |
| --- | --- | --- |
| `deepseek-chat` | ❌ | ❌ |
| `deepseek-reasoner` | ✅ | ❌ |
| `deepseek-chat-search` | ❌ | ✅ |
| `deepseek-reasoner-search` | ✅ | ✅ |
### Claude 接口
| 模型 | 默认映射 |
| --- | --- |
| `claude-sonnet-4-5` | `deepseek-chat` |
| `claude-haiku-4-5`(兼容 `claude-3-5-haiku-latest` | `deepseek-chat` |
| `claude-opus-4-6` | `deepseek-reasoner` |
可通过配置中的 `claude_mapping` 或 `claude_model_mapping` 覆盖映射关系。
另外,`/anthropic/v1/models` 现已包含 Claude 1.x/2.x/3.x/4.x 历史模型 ID 与常见别名,便于旧客户端直接兼容。
#### Claude Code 接入避坑(实测)
## ✨ 特性
- `ANTHROPIC_BASE_URL` 推荐直接指向 DS2API 根地址(例如 `http://127.0.0.1:5001`Claude Code 会请求 `/v1/messages?beta=true`。
- `ANTHROPIC_API_KEY` 需要与 `config.json` 中 `keys` 一致;建议同时保留常规 key 与 `sk-ant-*` 形态 key兼容不同客户端校验习惯。
- 若系统设置了代理,建议对 DS2API 地址配置 `NO_PROXY=127.0.0.1,localhost,<你的主机IP>`,避免本地回环请求被代理拦截。
- 如遇“工具调用输出成文本、未执行”问题,请升级到包含 Claude 工具调用多格式解析JSON/XML/ANTML/invoke的版本。
- 🔄 **双协议兼容** - 同时支持 OpenAI 和 Claude (Anthropic) API 格式
- 🚀 **多账号轮询** - Round-Robin 负载均衡,支持高并发场景
- 🔐 **Token 自动刷新** - 过期自动重新登录,无需手动维护
- 🌐 **WebUI 管理** - 可视化添加账号、测试 API、同步 Vercel 配置
- 🔍 **联网搜索** - 支持 DeepSeek 原生搜索增强模式
- 🧠 **深度思考** - 支持推理模式,输出思考过程
- 🛠️ **工具调用** - 兼容 OpenAI Function Calling 格式
- ☁️ **Vercel 一键部署** - 无需服务器,快速上线
### Gemini 接口
## 📋 模型支持
Gemini 适配器将模型名通过 `model_aliases` 或内置规则映射到 DeepSeek 原生模型支持 `generateContent` 和 `streamGenerateContent` 两种调用方式,并完整支持 Tool Calling`functionDeclarations` → `functionCall` 输出)。
### OpenAI 兼容接口 (`/v1/chat/completions`)
## 快速开始
| 模型 | 深度思考 | 联网搜索 | 说明 |
|-----|:--------:|:--------:|------|
| `deepseek-chat` | ❌ | ❌ | 标准对话模式 |
| `deepseek-reasoner` | ✅ | ❌ | 推理模式(输出思考过程) |
| `deepseek-chat-search` | ❌ | ✅ | 联网搜索模式 |
| `deepseek-reasoner-search` | ✅ | ✅ | 推理 + 联网搜索 |
### 通用第一步(所有部署方式)
### Claude 兼容接口 (`/anthropic/v1/messages`)
把 `config.json` 作为唯一配置源(推荐做法):
| 模型 | 说明 |
|-----|------|
| `claude-sonnet-4-20250514` | 映射到 deepseek-chat标准模式 |
| `claude-sonnet-4-20250514-fast` | 映射到 deepseek-chat快速模式 |
| `claude-sonnet-4-20250514-slow` | 映射到 deepseek-reasoner推理模式 |
```bash
cp config.example.json config.json
# 编辑 config.json
```
> **提示**Claude 接口实际调用的是 DeepSeek响应格式会自动转换为 Anthropic 标准格式。
后续部署建议:
- 本地运行:直接读取 `config.json`
- Docker / Vercel由 `config.json` 生成 `DS2API_CONFIG_JSON`Base64注入环境变量
- 兼容写法:`DS2API_CONFIG_JSON` 也可以直接写原始 JSON`CONFIG_JSON` 是旧版回退变量
## 🚀 快速开始
### 方式一:本地运行
### 方式一Vercel 部署(推荐
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api)
1. 点击上方按钮,设置管理密码 `DS2API_ADMIN_KEY`
2. 部署完成后访问 `/admin` 管理界面
3. 添加 DeepSeek 账号和自定义 API Key
4. 点击「同步到 Vercel」保存配置
> **首次同步会自动验证账号并保存 Token后续操作无需重复输入凭证。**
### 方式二:本地开发
**前置要求**Go 1.26+Node.js 20+(仅在需要构建 WebUI 时
```bash
# 1. 克隆仓库
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. 安装依赖
pip install -r requirements.txt
# 3. 配置账号
# 2. 配置
cp config.example.json config.json
# 编辑 config.json添加 DeepSeek 账号信息
# 编辑 config.json填入你的 DeepSeek 账号信息和 API key
# 4. 启动服务
python dev.py
# 3. 启动
go run ./cmd/ds2api
```
服务启动后访问 `http://localhost:5001`
默认监听地址:`http://localhost:5001`
## ⚙️ 配置说明
> **WebUI 自动构建**:本地首次启动时,若 `static/admin` 不存在,会自动尝试执行 `npm ci`(仅在缺少依赖时)和 `npm run build -- --outDir static/admin --emptyOutDir`(需要本机有 Node.js。你也可以手动构建`./scripts/build-webui.sh`
### 环境变量
### 方式二Docker 运行
| 变量 | 说明 | 必填 |
|-----|------|:----:|
| `DS2API_ADMIN_KEY` | 管理面板密码 | Vercel 必填 |
| `DS2API_CONFIG_JSON` | 配置 JSON 或 Base64 编码 | 可选 |
| `VERCEL_TOKEN` | Vercel API Token用于同步 | 可选 |
| `VERCEL_PROJECT_ID` | Vercel 项目 ID | 可选 |
| `PORT` | 服务端口(默认 5001 | 可选 |
```bash
# 1. 准备环境变量和配置文件
cp .env.example .env
cp config.example.json config.json
### 配置文件格式 (`config.json`)
# 2. 编辑 .env至少设置 DS2API_ADMIN_KEY
# DS2API_ADMIN_KEY=请替换为强密码
# 3. 启动
docker-compose up -d
# 4. 查看日志
docker-compose logs -f
```
默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请调整 `ports` 配置。
更新镜像:`docker-compose up -d --build`
#### Zeabur 一键部署Dockerfile
1. 点击上方 “Deploy on Zeabur” 按钮,一键部署。
2. 部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录。
3. 在管理台导入/编辑配置(会写入并持久化到 `/data/config.json`)。
说明Zeabur 使用仓库内 `Dockerfile` 直接构建时,不需要额外传入 `BUILD_VERSION`;镜像会优先读取该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。
### 方式三Vercel 部署
1. Fork 仓库到自己的 GitHub
2. 在 Vercel 上导入项目
3. 配置环境变量(最少设置 `DS2API_ADMIN_KEY`;推荐同时设置 `DS2API_CONFIG_JSON`
4. 部署
建议先在仓库目录复制模板并填写:
```bash
cp config.example.json config.json
# 编辑 config.json
```
推荐:先本地把 `config.json` 转成 Base64再粘贴到 `DS2API_CONFIG_JSON`,避免 JSON 格式错误:
```bash
base64 < config.json | tr -d '\n'
```
> **流式说明**`/v1/chat/completions` 在 Vercel 上默认走 `api/chat-stream.js`Node Runtime以保证实时 SSE。鉴权、账号选择、会话/PoW 准备仍由 Go 内部 prepare 接口完成;流式响应(含 `tools`)在 Node 侧执行与 Go 对齐的输出组装与防泄漏处理。
详细部署说明请参阅 [部署指南](docs/DEPLOY.md)。
### 方式四:下载 Release 构建包
每次发布 Release 时GitHub Actions 会自动构建多平台二进制包:
```bash
# 下载对应平台的压缩包后
tar -xzf ds2api_<tag>_linux_amd64.tar.gz
cd ds2api_<tag>_linux_amd64
cp config.example.json config.json
# 编辑 config.json
./ds2api
```
### 方式五OpenCode CLI 接入
1. 复制示例配置:
```bash
cp opencode.json.example opencode.json
```
2. 编辑 `opencode.json`
- 将 `baseURL` 改为你的 DS2API 地址(例如 `https://your-domain.com/v1`
- 将 `apiKey` 改为你的 DS2API key对应 `config.keys`
3. 在项目目录启动 OpenCode CLI按你的安装方式运行 `opencode`)。
> 建议优先使用 OpenAI 兼容路径(`/v1/*`),即示例里的 `@ai-sdk/openai-compatible` provider。
> 若客户端支持 `wire_api`,可分别测试 `responses` 与 `chat`DS2API 两条链路都兼容。
## 配置说明
### `config.json` 示例
```json
{
@@ -101,133 +277,285 @@ python dev.py
"accounts": [
{
"email": "user@example.com",
"password": "your-password",
"token": ""
"password": "your-password"
},
{
"mobile": "12345678901",
"password": "your-password",
"token": ""
"password": "your-password"
}
]
],
"model_aliases": {
"gpt-4o": "deepseek-chat",
"gpt-5-codex": "deepseek-reasoner",
"o3": "deepseek-reasoner"
},
"compat": {
"wide_input_strict_output": true
},
"responses": {
"store_ttl_seconds": 900
},
"embeddings": {
"provider": "deterministic"
},
"claude_mapping": {
"fast": "deepseek-chat",
"slow": "deepseek-reasoner"
},
"admin": {
"jwt_expire_hours": 24
},
"runtime": {
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0,
"token_refresh_interval_hours": 6
},
"auto_delete": {
"sessions": false
}
}
```
> **说明**
> - `keys`: 自定义的 API 密钥,用于调用本服务
> - `accounts`: DeepSeek 网页版账号,支持邮箱或手机号登录
> - `token`: 留空即可,系统会自动获取并刷新
- `keys`API 访问密钥列表,客户端通过 `Authorization: Bearer <key>` 鉴权
- `accounts`DeepSeek 账号列表,支持 `email` 或 `mobile` 登录
- `token`:配置文件中即使填写也会在加载时被清空(不会从 `config.json` 读取 token实际 token 仅在运行时内存中维护并自动刷新
- `model_aliases`:常见模型名(如 GPT/Codex/Claude到 DeepSeek 模型的映射
- `compat.wide_input_strict_output`:建议保持 `true`(当前实现默认宽进严出)
- `toolcall`:策略已固定为特征匹配 + 高置信早发,不再作为可配置项
- `responses.store_ttl_seconds``/v1/responses/{id}` 的内存缓存 TTL
- `embeddings.provider`embedding 提供方(当前内置 `deterministic/mock/builtin`
- `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping`
- `admin`管理后台设置JWT 过期时间、密码哈希等),可通过 Admin Settings API 热更新
- `runtime`:运行时参数(并发限制、队列大小、托管账号 token 刷新间隔),可通过 Admin Settings API 热更新;`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算,`token_refresh_interval_hours=6` 为默认强制重登间隔
- `auto_delete.sessions`:是否在请求结束后自动清理 DeepSeek 会话(默认 `false`,可在 Settings 热更新)
## 📡 API 使用
### 环境变量
完整 API 文档请参阅 **[API.md](API.md)**
| 变量 | 用途 | 默认值 |
| --- | --- | --- |
| `PORT` | 服务端口 | `5001` |
| `LOG_LEVEL` | 日志级别 | `INFO`(可选:`DEBUG`/`WARN`/`ERROR` |
| `DS2API_ADMIN_KEY` | Admin 登录密钥 | `admin` |
| `DS2API_JWT_SECRET` | Admin JWT 签名密钥 | 等同 `DS2API_ADMIN_KEY` |
| `DS2API_JWT_EXPIRE_HOURS` | Admin JWT 过期小时数 | `24` |
| `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` |
| `DS2API_CONFIG_JSON` | 直接注入配置JSON 或 Base64 | — |
| `CONFIG_JSON` | 旧版兼容配置注入 | — |
| `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 默认开启 |
| `DS2API_DEV_PACKET_CAPTURE_LIMIT` | 本地抓包保留条数(超出自动淘汰) | `5` |
| `DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES` | 单条响应体最大记录字节数 | `2097152` |
| `DS2API_ACCOUNT_MAX_INFLIGHT` | 每账号最大并发 in-flight 请求数 | `2` |
| `DS2API_ACCOUNT_CONCURRENCY` | 同上(兼容旧名) | — |
| `DS2API_ACCOUNT_MAX_QUEUE` | 等待队列上限 | `recommended_concurrency` |
| `DS2API_ACCOUNT_QUEUE_SIZE` | 同上(兼容旧名) | — |
| `DS2API_GLOBAL_MAX_INFLIGHT` | 全局最大 in-flight 请求数 | `recommended_concurrency` |
| `DS2API_MAX_INFLIGHT` | 同上(兼容旧名) | — |
| `DS2API_VERCEL_INTERNAL_SECRET` | Vercel 混合流式内部鉴权密钥 | 回退用 `DS2API_ADMIN_KEY` |
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease 过期秒数 | `900` |
| `DS2API_DEV_PACKET_CAPTURE` | 本地开发抓包开关(记录最近会话请求/响应体) | 本地非 Vercel 默认开启 |
| `DS2API_DEV_PACKET_CAPTURE_LIMIT` | 本地抓包保留条数(超出自动淘汰) | `5` |
| `DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES` | 单条响应体最大记录字节数 | `2097152` |
| `VERCEL_TOKEN` | Vercel 同步 token | — |
| `VERCEL_PROJECT_ID` | Vercel 项目 ID | — |
| `VERCEL_TEAM_ID` | Vercel 团队 ID | — |
| `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel 部署保护绕过密钥(内部 Node→Go 调用) | — |
### 快速示例
> 提示:当检测到 `DS2API_CONFIG_JSON/CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。
**获取模型列表**
```bash
curl http://localhost:5001/v1/models
## 鉴权模式
调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式:
| 模式 | 说明 |
| --- | --- |
| **托管账号模式** | `Bearer` 或 `x-api-key` 传入 `config.keys` 中的 key由服务自动轮询选择账号 |
| **直通 token 模式** | 传入 token 不在 `config.keys` 中时,直接作为 DeepSeek token 使用 |
可选请求头 `X-Ds2-Target-Account`:指定使用某个托管账号(值为 email 或 mobile
Gemini 路由还可以使用 `x-goog-api-key`,或在没有认证头时使用 `?key=` / `?api_key=` 作为调用方凭据。
## 并发模型
```
每账号可用并发 = DS2API_ACCOUNT_MAX_INFLIGHT默认 2
建议并发值 = 账号数量 × 每账号并发上限
等待队列上限 = DS2API_ACCOUNT_MAX_QUEUE默认 = 建议并发值)
429 阈值 = in-flight + 等待队列 ≈ 账号数量 × 4
```
**OpenAI 格式调用**
```bash
curl http://localhost:5001/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-chat",
"messages": [{"role": "user", "content": "你好"}],
"stream": true
}'
```
- 当 in-flight 槽位满时,请求进入等待队列,**不会立即 429**
- 超出总承载上限后才返回 `429 Too Many Requests`
- `GET /admin/queue/status` 返回实时并发状态
**Claude 格式调用**
```bash
curl http://localhost:5001/anthropic/v1/messages \
-H "x-api-key: your-api-key" \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "你好"}]
}'
```
## Tool Call 适配
### Python SDK 使用
当请求中带 `tools` 时DS2API 会做防泄漏处理与结构化转译:
```python
from openai import OpenAI
1. 只在**非代码块上下文**启用执行型 toolcall 识别(代码块示例默认不触发)
2. 解析层以 XML/Markup 为最高优先级,同时兼容 JSON / ANTML / invoke / text-kv并统一归一到内部工具调用结构
3. `responses` 流式严格使用官方 item 生命周期事件(`response.output_item.*`、`response.content_part.*`、`response.function_call_arguments.*`
4. `responses` 支持并执行 `tool_choice``auto`/`none`/`required`/强制函数);`required` 违规时非流式返回 `422`,流式返回 `response.failed`
5. 客户端请求哪种协议就按该协议返回工具调用OpenAI/Claude/Gemini 各自原生结构);模型侧优先约束输出规范 XML再由兼容层转译
client = OpenAI(
api_key="your-api-key",
base_url="http://localhost:5001/v1"
)
> 说明:当前版本在 parser 层仍以“尽量解析成功”为优先,未启用基于 allow-list 的工具名硬拒绝。
>
> 想评估“把工具调用封装成 XML 再输入模型”的方案,可参考:`docs/toolcall-semantics.md`。
response = client.chat.completions.create(
model="deepseek-reasoner",
messages=[{"role": "user", "content": "请解释量子纠缠"}],
stream=True
)
## 本地开发抓包工具
for chunk in response:
if chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="")
```
用于定位「responses 思考流/工具调用」等问题。开启后会自动记录最近 N 条 DeepSeek 对话上游请求体与响应体(默认 5 条,超出自动淘汰)。
## 🔧 部署配置
### Nginx 反向代理
```nginx
location / {
proxy_pass http://localhost:5001;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 120;
}
```
### 方式三Docker 部署
启用示例:
```bash
# 1. 克隆仓库并进入目录
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. 配置环境变量
cp .env.example .env
# 编辑 .env填写 DS2API_ADMIN_KEY 和 DS2API_CONFIG_JSON
# 3. 启动服务
docker-compose up -d
# 4. 查看日志
docker-compose logs -f
DS2API_DEV_PACKET_CAPTURE=true \
DS2API_DEV_PACKET_CAPTURE_LIMIT=5 \
go run ./cmd/ds2api
```
> **Docker 优势**:零侵入设计,主代码更新只需 `docker-compose up -d --build`,无需修改 Docker 配置。详见 [DEPLOY.md](DEPLOY.md#docker-部署推荐)。
查询/清空(需 Admin JWT
## ⚠️ 免责声明
- `GET /admin/dev/captures`:查看抓包列表(最新在前)
- `DELETE /admin/dev/captures`:清空抓包
**本项目基于逆向工程实现,服务稳定性无法保证。**
返回字段包含:
- 仅供学习研究使用,**禁止商业用途或对外提供服务**
- 建议正式项目使用 [DeepSeek 官方 API](https://platform.deepseek.com/)
- 使用本项目产生的任何风险由用户自行承担
- `request_body`:发送给 DeepSeek 的完整请求体
- `response_body`:上游返回的原始流式内容拼接文本
- `response_truncated`:是否触发单条大小截断
## 📜 鸣谢
## 项目结构
本项目基于以下开源项目:
```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/ # 开发抓包模块
│ ├── 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
│ └── 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 模块依赖
```
- [iidamie/deepseek2api](https://github.com/iidamie/deepseek2api)
- [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api)
## 文档索引
## 📊 Star History
| 文档 | 说明 |
| --- | --- |
| [API.md](API.md) / [API.en.md](API.en.md) | API 接口文档(含请求/响应示例) |
| [DEPLOY.md](docs/DEPLOY.md) / [DEPLOY.en.md](docs/DEPLOY.en.md) | 部署指南(本地/Docker/Vercel/systemd |
| [CONTRIBUTING.md](docs/CONTRIBUTING.md) / [CONTRIBUTING.en.md](docs/CONTRIBUTING.en.md) | 贡献指南 |
| [TESTING.md](docs/TESTING.md) | 测试集使用指南 |
[![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/ds2api&type=Date)](https://star-history.com/#CJackHwang/ds2api&Date)
## 测试
```bash
# 单元测试Go + Node
./tests/scripts/run-unit-all.sh
# 一键端到端全链路测试(真实账号,生成完整请求/响应日志)
./tests/scripts/run-live.sh
# 或自定义参数
go run ./cmd/ds2api-tests \
--config config.json \
--admin-key admin \
--out artifacts/testsuite \
--timeout 120 \
--retries 2
```
```bash
# 发布前阻断门禁
./tests/scripts/check-stage6-manual-smoke.sh
./tests/scripts/check-refactor-line-gate.sh
./tests/scripts/run-unit-all.sh
npm ci --prefix webui && npm run build --prefix webui
```
## 测试
详细测试指南请参阅 [docs/TESTING.md](docs/TESTING.md)。
### 快速测试命令
```bash
# 运行所有单元测试
go test ./...
# 运行 tool calls 相关测试(调试工具调用问题)
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
# 运行端到端测试
./tests/scripts/run-live.sh
```
## Release 自动构建GitHub Actions
工作流文件:`.github/workflows/release-artifacts.yml`
- **触发条件**:仅在 GitHub Release `published` 时触发(普通 push 不会触发)
- **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`+ `sha256sums.txt`
- **容器镜像发布**:仅推送到 GHCR`ghcr.io/cjackhwang/ds2api`
- **每个压缩包包含**`ds2api` 可执行文件、`static/admin`、WASM 文件(同时支持内置 fallback、配置示例、README、LICENSE
## 免责声明
本项目基于逆向方式实现,仅供学习、研究、个人实验和内部验证使用,不提供任何商业授权、稳定性保证或可用性保证。
作者及仓库维护者不对因使用、修改、分发、部署或依赖本项目而产生的任何直接或间接损失、账号封禁、数据丢失、法律风险或第三方索赔负责。
请勿将本项目用于违反服务条款、协议、法律法规或平台规则的场景。商业使用前请自行确认 `LICENSE`、相关协议以及你是否获得了作者的书面许可。

538
README.en.md Normal file
View File

@@ -0,0 +1,538 @@
<p align="center">
<img src="webui/public/ds2api-favicon.svg" width="128" height="128" alt="DS2API icon" />
</p>
# DS2API
[![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE)
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](docs/DEPLOY.en.md)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/ds2api)
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).
> **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.
>
> The author and repository maintainers are not responsible for any direct or indirect loss, account suspension, data loss, legal risk, or third-party claims arising from use, modification, distribution, deployment, or reliance on this project.
>
> 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
```mermaid
flowchart LR
Client["🖥️ Clients / SDKs\n(OpenAI / Claude / Gemini)"]
Upstream["☁️ DeepSeek API"]
subgraph DS2API["DS2API 3.x (Unified OpenAI Core)"]
Router["chi Router + Middleware\n(RequestID / RealIP / Logger / Recoverer / CORS)"]
subgraph Adapters["Protocol Adapters"]
OA["OpenAI\n/v1/*"]
CA["Claude\n/anthropic/* + /v1/messages"]
GA["Gemini\n/v1beta/models/* + /v1/models/*"]
Admin["Admin API\n/admin/*"]
WebUI["WebUI\n/admin (static hosting)"]
end
subgraph Runtime["Runtime + Core Capabilities"]
Bridge["CLIProxy Bridge\n(multi-protocol <-> OpenAI)"]
OAEngine["OpenAI ChatCompletions\n(unified tools + stream semantics)"]
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)"]
Tool["Tool Sieve\n(Go/Node semantic parity)"]
end
end
Client --> Router
Router --> OA & CA & GA
Router --> Admin
Router --> WebUI
OA --> OAEngine
CA & GA --> Bridge
Bridge --> OAEngine
OAEngine --> Auth
OAEngine -.account rotation.-> Pool
OAEngine -.tool-call parsing.-> Tool
OAEngine -.PoW solving.-> Pow
Auth --> DSClient
DSClient --> Upstream
Upstream --> DSClient
OAEngine --> Bridge
Bridge --> Client
```
- **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
### 3.0 Architecture Changes (vs older releases)
- **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.
- **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.
## Key Capabilities
| Capability | Details |
| --- | --- |
| OpenAI compatible | `GET /v1/models`, `GET /v1/models/{id}`, `POST /v1/chat/completions`, `POST /v1/responses`, `GET /v1/responses/{response_id}`, `POST /v1/embeddings` |
| Claude compatible | `GET /anthropic/v1/models`, `POST /anthropic/v1/messages`, `POST /anthropic/v1/messages/count_tokens` (plus shortcut paths `/v1/messages`, `/messages`) |
| 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 |
| 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) |
| Health Probes | `GET /healthz` (liveness), `GET /readyz` (readiness) |
## Platform Compatibility Matrix
| Tier | Platform | Status |
| --- | --- | --- |
| P0 | Codex CLI/SDK (`wire_api=chat` / `wire_api=responses`) | ✅ |
| P0 | OpenAI SDK (JS/Python, chat + responses) | ✅ |
| P0 | Vercel AI SDK (openai-compatible) | ✅ |
| P0 | Anthropic SDK (messages) | ✅ |
| P0 | Google Gemini SDK (generateContent) | ✅ |
| P1 | LangChain / LlamaIndex / OpenWebUI (OpenAI-compatible integration) | ✅ |
| P2 | MCP standalone bridge | Planned |
## Model Support
### OpenAI Endpoint
| Model | thinking | search |
| --- | --- | --- |
| `deepseek-chat` | ❌ | ❌ |
| `deepseek-reasoner` | ✅ | ❌ |
| `deepseek-chat-search` | ❌ | ✅ |
| `deepseek-reasoner-search` | ✅ | ✅ |
### Claude Endpoint
| Model | Default Mapping |
| --- | --- |
| `claude-sonnet-4-5` | `deepseek-chat` |
| `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`) | `deepseek-chat` |
| `claude-opus-4-6` | `deepseek-reasoner` |
Override mapping via `claude_mapping` or `claude_model_mapping` in config.
In addition, `/anthropic/v1/models` now includes historical Claude 1.x/2.x/3.x/4.x IDs and common aliases for legacy client compatibility.
#### Claude Code integration pitfalls (validated)
- Set `ANTHROPIC_BASE_URL` to the DS2API root URL (for example `http://127.0.0.1:5001`). Claude Code sends requests to `/v1/messages?beta=true`.
- `ANTHROPIC_API_KEY` must match an entry in `keys` from `config.json`. Keeping both a regular key and an `sk-ant-*` style key improves client compatibility.
- If your environment has proxy variables, set `NO_PROXY=127.0.0.1,localhost,<your_host_ip>` for DS2API to avoid proxy interception of local traffic.
- If tool calls are rendered as plain text and not executed, upgrade to a build that includes multi-format Claude tool-call parsing (JSON/XML/ANTML/invoke).
### Gemini Endpoint
The Gemini adapter maps model names to DeepSeek native models via `model_aliases` or built-in heuristics, supporting both `generateContent` and `streamGenerateContent` call patterns with full Tool Calling support (`functionDeclarations``functionCall` output).
## Quick Start
### Universal First Step (all deployment modes)
Use `config.json` as the single source of truth (recommended):
```bash
cp config.example.json config.json
# Edit config.json
```
Recommended per deployment mode:
- Local run: read `config.json` directly
- Docker / Vercel: generate Base64 from `config.json` and inject as `DS2API_CONFIG_JSON`
- Compatibility note: `DS2API_CONFIG_JSON` may also contain raw JSON directly; `CONFIG_JSON` is the legacy fallback variable
### Option 1: Local Run
**Prerequisites**: Go 1.26+, Node.js 20+ (only if building WebUI locally)
```bash
# 1. Clone
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. Configure
cp config.example.json config.json
# Edit config.json with your DeepSeek account info and API keys
# 3. Start
go run ./cmd/ds2api
```
Default URL: `http://localhost:5001`
> **WebUI auto-build**: On first local startup, if `static/admin` is missing, DS2API will auto-run `npm ci` (only when dependencies are missing) and `npm run build -- --outDir static/admin --emptyOutDir` (requires Node.js). You can also build manually: `./scripts/build-webui.sh`
### Option 2: Docker
```bash
# 1. Prepare env file and config file
cp .env.example .env
cp config.example.json config.json
# 2. Edit .env (at least set DS2API_ADMIN_KEY)
# DS2API_ADMIN_KEY=replace-with-a-strong-secret
# 3. Start
docker-compose up -d
# 4. View logs
docker-compose logs -f
```
The default `docker-compose.yml` maps host port `6011` to container port `5001`. If you want `5001` exposed directly, adjust the `ports` mapping.
Rebuild after updates: `docker-compose up -d --build`
#### Zeabur One-Click (Dockerfile)
1. Click the “Deploy on Zeabur” button above to deploy.
2. After deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions.
3. Import / edit config in Admin UI (it will be written and persisted to `/data/config.json`).
Note: when Zeabur builds directly from the repo `Dockerfile`, you do not need to pass `BUILD_VERSION`. The image prefers that build arg when provided, and automatically falls back to the repo-root `VERSION` file when it is absent.
### Option 3: Vercel
1. Fork this repo to your GitHub account
2. Import the project on Vercel
3. Set environment variables (minimum: `DS2API_ADMIN_KEY`; recommended to also set `DS2API_CONFIG_JSON`)
4. Deploy
Recommended first step in repo root:
```bash
cp config.example.json config.json
# Edit config.json
```
Recommended: convert `config.json` to Base64 locally, then paste into `DS2API_CONFIG_JSON` to avoid JSON formatting mistakes:
```bash
base64 < config.json | tr -d '\n'
```
> **Streaming note**: `/v1/chat/completions` on Vercel is routed to `api/chat-stream.js` (Node Runtime) for real-time SSE. Auth, account selection, and session/PoW preparation are still handled by the Go internal prepare endpoint; streaming output (including `tools`) is assembled on Node with Go-aligned anti-leak handling.
For detailed deployment instructions, see the [Deployment Guide](docs/DEPLOY.en.md).
### Option 4: Download Release Binaries
GitHub Actions automatically builds multi-platform archives on each Release:
```bash
# After downloading the archive for your platform
tar -xzf ds2api_<tag>_linux_amd64.tar.gz
cd ds2api_<tag>_linux_amd64
cp config.example.json config.json
# Edit config.json
./ds2api
```
### Option 5: OpenCode CLI
1. Copy the example config:
```bash
cp opencode.json.example opencode.json
```
2. Edit `opencode.json`:
- Set `baseURL` to your DS2API endpoint (for example, `https://your-domain.com/v1`)
- Set `apiKey` to your DS2API key (from `config.keys`)
3. Start OpenCode CLI in the project directory (run `opencode` using your installed method).
> Recommended: use the OpenAI-compatible path (`/v1/*`) via `@ai-sdk/openai-compatible` as shown in the example.
> If your client supports `wire_api`, test both `responses` and `chat`; DS2API supports both paths.
## Configuration
### `config.json` Example
```json
{
"keys": ["your-api-key-1", "your-api-key-2"],
"accounts": [
{
"email": "user@example.com",
"password": "your-password"
},
{
"mobile": "12345678901",
"password": "your-password"
}
],
"model_aliases": {
"gpt-4o": "deepseek-chat",
"gpt-5-codex": "deepseek-reasoner",
"o3": "deepseek-reasoner"
},
"compat": {
"wide_input_strict_output": true
},
"responses": {
"store_ttl_seconds": 900
},
"embeddings": {
"provider": "deterministic"
},
"claude_mapping": {
"fast": "deepseek-chat",
"slow": "deepseek-reasoner"
},
"admin": {
"jwt_expire_hours": 24
},
"runtime": {
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0,
"token_refresh_interval_hours": 6
},
"auto_delete": {
"sessions": false
}
}
```
- `keys`: API access keys; clients authenticate via `Authorization: Bearer <key>`
- `accounts`: DeepSeek account list, supports `email` or `mobile` login
- `token`: Even if set in `config.json`, it is cleared during load (DS2API does not read persisted tokens from config); runtime tokens are maintained/refreshed in memory only
- `model_aliases`: Map common model names (GPT/Codex/Claude) to DeepSeek models
- `compat.wide_input_strict_output`: Keep `true` (current default policy)
- `toolcall`: Fixed to feature matching + high-confidence early emit, no longer configurable
- `responses.store_ttl_seconds`: In-memory TTL for `/v1/responses/{id}`
- `embeddings.provider`: Embeddings provider (`deterministic/mock/builtin` built-in)
- `claude_mapping`: Maps `fast`/`slow` suffixes to corresponding DeepSeek models (still compatible with `claude_model_mapping`)
- `admin`: Admin panel settings (JWT expiry, password hash, etc.), hot-reloadable via Admin Settings API
- `runtime`: Runtime parameters (concurrency limits, queue sizes, managed token refresh interval), hot-reloadable via Admin Settings API; `account_max_queue=0`/`global_max_inflight=0` means auto-calculate from recommended values, `token_refresh_interval_hours=6` is the default forced re-login interval
- `auto_delete.sessions`: Whether to auto-delete DeepSeek sessions after request completion (default `false`, hot-reloadable via Settings)
### Environment Variables
| Variable | Purpose | Default |
| --- | --- | --- |
| `PORT` | Service port | `5001` |
| `LOG_LEVEL` | Log level | `INFO` (`DEBUG`/`WARN`/`ERROR`) |
| `DS2API_ADMIN_KEY` | Admin login key | `admin` |
| `DS2API_JWT_SECRET` | Admin JWT signing secret | Same as `DS2API_ADMIN_KEY` |
| `DS2API_JWT_EXPIRE_HOURS` | Admin JWT TTL in hours | `24` |
| `DS2API_CONFIG_PATH` | Config file path | `config.json` |
| `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — |
| `CONFIG_JSON` | Legacy compatibility config input | — |
| `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` |
| `DS2API_ACCOUNT_CONCURRENCY` | Alias (legacy compat) | — |
| `DS2API_ACCOUNT_MAX_QUEUE` | Waiting queue limit | `recommended_concurrency` |
| `DS2API_ACCOUNT_QUEUE_SIZE` | Alias (legacy compat) | — |
| `DS2API_GLOBAL_MAX_INFLIGHT` | Global max in-flight requests | `recommended_concurrency` |
| `DS2API_MAX_INFLIGHT` | Alias (legacy compat) | — |
| `DS2API_VERCEL_INTERNAL_SECRET` | Vercel hybrid streaming internal auth | Falls back to `DS2API_ADMIN_KEY` |
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL seconds | `900` |
| `DS2API_DEV_PACKET_CAPTURE` | Local dev packet capture switch (record recent request/response bodies) | Enabled by default on non-Vercel local runtime |
| `DS2API_DEV_PACKET_CAPTURE_LIMIT` | Number of captured sessions to retain (auto-evict overflow) | `5` |
| `DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES` | Max recorded bytes per captured response body | `2097152` |
| `VERCEL_TOKEN` | Vercel sync token | — |
| `VERCEL_PROJECT_ID` | Vercel project ID | — |
| `VERCEL_TEAM_ID` | Vercel team ID | — |
| `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel deployment protection bypass for internal Node→Go calls | — |
> Note: when `DS2API_CONFIG_JSON/CONFIG_JSON` is detected, the Admin UI shows mode risk and auto-persistence status (including `DS2API_CONFIG_PATH` and mode-transition hints).
## Authentication Modes
For business endpoints (`/v1/*`, `/anthropic/*`, Gemini routes), DS2API supports two modes:
| Mode | Description |
| --- | --- |
| **Managed account** | Use a key from `config.keys` via `Authorization: Bearer ...` or `x-api-key`; DS2API auto-selects an account |
| **Direct token** | If the token is not in `config.keys`, DS2API treats it as a DeepSeek token directly |
Optional header `X-Ds2-Target-Account`: Pin a specific managed account (value is email or mobile).
Gemini routes also accept `x-goog-api-key`, or `?key=` / `?api_key=` when no auth header is present.
## Concurrency Model
```
Per-account inflight = DS2API_ACCOUNT_MAX_INFLIGHT (default 2)
Recommended concurrency = account_count × per_account_inflight
Queue limit = DS2API_ACCOUNT_MAX_QUEUE (default = recommended concurrency)
429 threshold = inflight + queue ≈ account_count × 4
```
- When inflight slots are full, requests enter a waiting queue — **no immediate 429**
- 429 is returned only when total load exceeds inflight + queue capacity
- `GET /admin/queue/status` returns real-time concurrency state
## Tool Call Adaptation
When `tools` is present in the request, DS2API performs anti-leak handling:
1. Toolcall feature matching is enabled only in **non-code-block context** (fenced examples are ignored)
- In non-code-block context, tool JSON may still be recognized even when mixed with normal prose; surrounding prose can remain as text output.
2. `responses` streaming strictly uses official item lifecycle events (`response.output_item.*`, `response.content_part.*`, `response.function_call_arguments.*`)
3. Tool names not declared in the `tools` schema are strictly rejected and will not be emitted as valid tool calls
4. `responses` supports and enforces `tool_choice` (`auto`/`none`/`required`/forced function); `required` violations return `422` for non-stream and `response.failed` for stream
5. Valid tool call events are only emitted after passing policy validation, preventing invalid tool names from entering the client execution chain
## Local Dev Packet Capture
This is for debugging issues such as Responses reasoning streaming and tool-call handoff. When enabled, DS2API stores the latest N DeepSeek conversation payload pairs (request body + upstream response body), defaulting to 5 entries with auto-eviction.
Enable example:
```bash
DS2API_DEV_PACKET_CAPTURE=true \
DS2API_DEV_PACKET_CAPTURE_LIMIT=5 \
go run ./cmd/ds2api
```
Inspect/clear (Admin JWT required):
- `GET /admin/dev/captures`: list captured items (newest first)
- `DELETE /admin/dev/captures`: clear captured items
Response fields include:
- `request_body`: full payload sent to DeepSeek
- `response_body`: concatenated raw upstream stream body text
- `response_truncated`: whether body-size truncation happened
## 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
│ ├── 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)
│ └── 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 |
| --- | --- |
| [API.md](API.md) / [API.en.md](API.en.md) | API reference with request/response examples |
| [DEPLOY.md](docs/DEPLOY.md) / [DEPLOY.en.md](docs/DEPLOY.en.md) | Deployment guide (local/Docker/Vercel/systemd) |
| [CONTRIBUTING.md](docs/CONTRIBUTING.md) / [CONTRIBUTING.en.md](docs/CONTRIBUTING.en.md) | Contributing guide |
| [TESTING.md](docs/TESTING.md) | Testsuite guide |
## Testing
```bash
# Unit tests (Go + Node)
./tests/scripts/run-unit-all.sh
# One-command live end-to-end tests (real accounts, full request/response logs)
./tests/scripts/run-live.sh
# Or with custom flags
go run ./cmd/ds2api-tests \
--config config.json \
--admin-key admin \
--out artifacts/testsuite \
--timeout 120 \
--retries 2
```
```bash
# Release-blocking gates
./tests/scripts/check-stage6-manual-smoke.sh
./tests/scripts/check-refactor-line-gate.sh
./tests/scripts/run-unit-all.sh
npm ci --prefix webui && npm run build --prefix webui
```
## Release Artifact Automation (GitHub Actions)
Workflow: `.github/workflows/release-artifacts.yml`
- **Trigger**: only on GitHub Release `published` (normal pushes do not trigger builds)
- **Outputs**: multi-platform archives (`linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`) + `sha256sums.txt`
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file (with embedded fallback support), config template, README, LICENSE
## Disclaimer
This project is built through reverse engineering and is provided for learning, research, personal experimentation, and internal validation only. No commercial authorization is granted, and no warranty of stability, fitness, or results is provided.
The author and repository maintainers are not responsible for any direct or indirect loss, account suspension, data loss, legal risk, or third-party claims arising from use, modification, distribution, deployment, or reliance on this project.
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.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
3.0.0

3
api/chat-stream.js Normal file
View File

@@ -0,0 +1,3 @@
'use strict';
module.exports = require('../internal/js/chat-stream/index.js');

20
api/index.go Normal file
View File

@@ -0,0 +1,20 @@
package handler
import (
"net/http"
"sync"
"ds2api/app"
)
var (
once sync.Once
h http.Handler
)
func Handler(w http.ResponseWriter, r *http.Request) {
once.Do(func() {
h = app.NewHandler()
})
h.ServeHTTP(w, r)
}

69
app.py
View File

@@ -1,69 +0,0 @@
# -*- coding: utf-8 -*-
"""
DS2API - DeepSeek to OpenAI API 转换服务
支持:
- OpenAI 兼容接口: /v1/chat/completions, /v1/models
- Claude 兼容接口: /anthropic/v1/messages, /anthropic/v1/models
使用方法:
本地开发: python dev.py
生产环境: uvicorn app:app --host 0.0.0.0 --port 5001
Vercel: 自动部署
"""
import os
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from core.config import IS_VERCEL, logger
# 创建 FastAPI 应用
app = FastAPI(
title="DS2API",
description="DeepSeek to OpenAI/Claude API",
version="1.0.0",
)
# 全局异常处理
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
logger.exception(f"[unhandled_exception] {request.method} {request.url.path}: {exc}")
return JSONResponse(
status_code=500,
content={"error": {"type": "api_error", "message": "Internal Server Error"}},
)
# CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
)
# 注册路由
from routes.openai import router as openai_router
from routes.claude import router as claude_router
from routes.home import router as home_router
from routes.admin import router as admin_router
app.include_router(openai_router)
app.include_router(claude_router)
# admin_router 必须在 home_router 之前,否则 home.py 的 /admin/{path:path} 会拦截 admin API
app.include_router(admin_router)
app.include_router(home_router)
# ----------------------------------------------------------------------
# 本地运行入口
# ----------------------------------------------------------------------
if __name__ == "__main__" and not IS_VERCEL:
import uvicorn
port = int(os.getenv("PORT", "5001"))
uvicorn.run(app, host="0.0.0.0", port=port)

11
app/handler.go Normal file
View File

@@ -0,0 +1,11 @@
package app
import (
"net/http"
"ds2api/internal/server"
)
func NewHandler() http.Handler {
return server.NewApp().Router
}

37
cmd/ds2api-tests/main.go Normal file
View File

@@ -0,0 +1,37 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"ds2api/internal/testsuite"
)
func main() {
opts := testsuite.DefaultOptions()
var timeoutSeconds int
flag.StringVar(&opts.ConfigPath, "config", opts.ConfigPath, "Path to config file (default: config.json)")
flag.StringVar(&opts.AdminKey, "admin-key", opts.AdminKey, "Admin key (default: DS2API_ADMIN_KEY or admin)")
flag.StringVar(&opts.OutputDir, "out", opts.OutputDir, "Output artifact directory")
flag.IntVar(&opts.Port, "port", opts.Port, "Server port (0 means auto-select free port)")
flag.IntVar(&timeoutSeconds, "timeout", int(opts.Timeout.Seconds()), "Per-request timeout in seconds")
flag.IntVar(&opts.Retries, "retries", opts.Retries, "Retry count for network/5xx requests")
flag.BoolVar(&opts.NoPreflight, "no-preflight", opts.NoPreflight, "Skip preflight checks")
flag.IntVar(&opts.MaxKeepRuns, "keep", opts.MaxKeepRuns, "Max test runs to keep (0 = keep all)")
flag.Parse()
if timeoutSeconds <= 0 {
timeoutSeconds = 120
}
opts.Timeout = time.Duration(timeoutSeconds) * time.Second
if err := testsuite.Run(context.Background(), opts); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
fmt.Fprintln(os.Stdout, "testsuite completed successfully")
}

102
cmd/ds2api/main.go Normal file
View File

@@ -0,0 +1,102 @@
package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/server"
"ds2api/internal/webui"
)
func main() {
webui.EnsureBuiltOnStartup()
_ = auth.AdminKey()
app := server.NewApp()
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" {
port = "5001"
}
srv := &http.Server{
Addr: "0.0.0.0:" + port,
Handler: app.Router,
}
localURL := fmt.Sprintf("http://127.0.0.1:%s", port)
lanIP := detectLANIPv4()
lanURL := ""
if lanIP != "" {
lanURL = fmt.Sprintf("http://%s:%s", lanIP, port)
}
// Start server in a goroutine so we can listen for shutdown signals.
go func() {
if lanURL != "" {
config.Logger.Info("starting ds2api", "bind", srv.Addr, "port", port, "local_url", localURL, "lan_url", lanURL, "lan_ip", lanIP)
} else {
config.Logger.Info("starting ds2api", "bind", srv.Addr, "port", port, "local_url", localURL)
config.Logger.Warn("lan ip not detected; check active network interfaces")
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
config.Logger.Error("server stopped unexpectedly", "error", err)
os.Exit(1)
}
}()
// Wait for interrupt signal (Ctrl+C / SIGTERM).
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
sig := <-quit
config.Logger.Info("shutdown signal received", "signal", sig.String())
// Graceful shutdown: allow up to 10 seconds for in-flight requests to complete.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
config.Logger.Error("graceful shutdown failed, forcing exit", "error", err)
os.Exit(1)
}
config.Logger.Info("server gracefully stopped")
}
func detectLANIPv4() string {
ifaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
default:
continue
}
ip = ip.To4()
if ip == nil || !ip.IsPrivate() {
continue
}
return ip.String()
}
}
return ""
}

View File

@@ -9,20 +9,50 @@
{
"_comment": "邮箱登录方式",
"email": "example1@example.com",
"password": "your-password-1",
"token": ""
"password": "your-password-1"
},
{
"_comment": "邮箱登录方式 - 账号2",
"email": "example2@example.com",
"password": "your-password-2",
"token": ""
"password": "your-password-2"
},
{
"_comment": "手机号登录方式(中国大陆)",
"mobile": "12345678901",
"password": "your-password-3",
"token": ""
"password": "your-password-3"
}
]
}
],
"model_aliases": {
"gpt-4o": "deepseek-chat",
"gpt-5-codex": "deepseek-reasoner",
"o3": "deepseek-reasoner"
},
"compat": {
"wide_input_strict_output": true
},
"toolcall": {
"mode": "feature_match",
"early_emit_confidence": "high"
},
"responses": {
"store_ttl_seconds": 900
},
"embeddings": {
"provider": "deterministic"
},
"claude_mapping": {
"fast": "deepseek-chat",
"slow": "deepseek-reasoner"
},
"admin": {
"jwt_expire_hours": 24
},
"runtime": {
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0
},
"auto_delete": {
"sessions": false
}
}

View File

@@ -1 +0,0 @@
# DS2API Core Modules

View File

@@ -1,223 +0,0 @@
# -*- coding: utf-8 -*-
"""账号认证与管理模块 - 轮询(Round-Robin)策略"""
import threading
from fastapi import HTTPException, Request
from .config import CONFIG, logger
from .deepseek import login_deepseek_via_account, BASE_HEADERS
from .utils import get_account_identifier
# -------------------------- 全局账号队列 --------------------------
# 使用列表实现轮询队列,配合线程锁保证并发安全
account_queue = [] # 可用账号队列
in_use_accounts = {} # 正在使用的账号 {account_id: account}
_queue_lock = threading.Lock() # 线程锁
claude_api_key_queue = [] # 维护所有可用的Claude API keys
def init_account_queue():
"""初始化时从配置加载账号(不再随机排序,保持配置顺序)"""
global account_queue, in_use_accounts
with _queue_lock:
account_queue = CONFIG.get("accounts", [])[:] # 深拷贝
in_use_accounts = {}
# 按 token 有无排序:有 token 的账号优先
account_queue.sort(key=lambda a: 0 if a.get("token", "").strip() else 1)
logger.info(f"[init_account_queue] 初始化 {len(account_queue)} 个账号,轮询模式")
def init_claude_api_key_queue():
"""Claude API keys由用户自己的token提供这里初始化为空"""
global claude_api_key_queue
claude_api_key_queue = []
# 初始化
init_account_queue()
init_claude_api_key_queue()
# get_account_identifier 已移至 core.utils
def get_queue_status() -> dict:
"""获取账号队列状态(用于监控)"""
with _queue_lock:
# total 应该是配置中的账号总数,而非队列相加(避免状态不一致导致重复计数)
total_accounts = len(CONFIG.get("accounts", []))
return {
"available": len(account_queue),
"in_use": len(in_use_accounts),
"total": total_accounts,
"available_accounts": [get_account_identifier(a) for a in account_queue],
"in_use_accounts": list(in_use_accounts.keys()),
}
# ----------------------------------------------------------------------
# 账号选择与释放 - 轮询(Round-Robin)策略
# ----------------------------------------------------------------------
def choose_new_account(exclude_ids=None):
"""轮询选择策略:
1. 使用线程锁保证并发安全
2. 优先选择队首的有 token 账号
3. 从队列头部取出账号FIFO
4. 请求完成后调用 release_account 将账号放回队尾
"""
if exclude_ids is None:
exclude_ids = []
with _queue_lock:
# 第一轮:优先选择已有 token 的账号
for i in range(len(account_queue)):
acc = account_queue[i]
acc_id = get_account_identifier(acc)
if acc_id and acc_id not in exclude_ids:
if acc.get("token", "").strip(): # 已有 token
selected = account_queue.pop(i)
in_use_accounts[acc_id] = selected
logger.info(f"[choose_new_account] 轮询选择(有token): {acc_id} | 队列剩余: {len(account_queue)}")
return selected
# 第二轮:选择任意账号(需要登录)
for i in range(len(account_queue)):
acc = account_queue[i]
acc_id = get_account_identifier(acc)
if acc_id and acc_id not in exclude_ids:
selected = account_queue.pop(i)
in_use_accounts[acc_id] = selected
logger.info(f"[choose_new_account] 轮询选择(需登录): {acc_id} | 队列剩余: {len(account_queue)}")
return selected
logger.warning(f"[choose_new_account] 没有可用账号 | 队列: {len(account_queue)}, 使用中: {len(in_use_accounts)}")
return None
def release_account(account: dict):
"""将账号重新加入队列末尾(轮询核心:用完放队尾)"""
if not account:
return
acc_id = get_account_identifier(account)
with _queue_lock:
# 从使用中移除
if acc_id in in_use_accounts:
del in_use_accounts[acc_id]
# 放回队尾
account_queue.append(account)
logger.debug(f"[release_account] 释放账号: {acc_id} | 队列长度: {len(account_queue)}")
else:
logger.warning(f"[release_account] 账号 {acc_id} 不在使用列表中 (可能是因为重置了队列),跳过释放")
# ----------------------------------------------------------------------
# Claude API key 管理函数(简化版本)
# ----------------------------------------------------------------------
def choose_claude_api_key():
"""选择一个可用的Claude API key - 现在直接由用户提供"""
return None
def release_claude_api_key(api_key):
"""释放Claude API key - 现在无需操作"""
pass
# ----------------------------------------------------------------------
# 判断调用模式:配置模式 vs 用户自带 token
# ----------------------------------------------------------------------
def determine_mode_and_token(request: Request):
"""
根据请求头 Authorization 判断使用哪种模式:
- 如果 Bearer token 出现在 CONFIG["keys"] 中,则为配置模式,从 CONFIG["accounts"] 中随机选择一个账号(排除已尝试账号),
检查该账号是否已有 token否则调用登录接口获取
- 否则,直接使用请求中的 Bearer 值作为 DeepSeek token。
结果存入 request.state.deepseek_token配置模式下同时存入 request.state.account 与 request.state.tried_accounts。
"""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=401, detail="Unauthorized: missing Bearer token."
)
caller_key = auth_header.replace("Bearer ", "", 1).strip()
config_keys = CONFIG.get("keys", [])
if caller_key in config_keys:
request.state.use_config_token = True
request.state.tried_accounts = [] # 初始化已尝试账号
selected_account = choose_new_account()
if not selected_account:
raise HTTPException(
status_code=429,
detail="No accounts configured or all accounts are busy.",
)
if not selected_account.get("token", "").strip():
try:
login_deepseek_via_account(selected_account)
except Exception as e:
logger.error(
f"[determine_mode_and_token] 账号 {get_account_identifier(selected_account)} 登录失败:{e}"
)
raise HTTPException(status_code=500, detail="Account login failed.")
request.state.deepseek_token = selected_account.get("token")
request.state.account = selected_account
else:
request.state.use_config_token = False
request.state.deepseek_token = caller_key
def get_auth_headers(request: Request) -> dict:
"""返回 DeepSeek 请求所需的公共请求头"""
return {**BASE_HEADERS, "authorization": f"Bearer {request.state.deepseek_token}"}
# determine_claude_mode_and_token 已移除(直接使用 determine_mode_and_token
# ----------------------------------------------------------------------
# Token 刷新机制
# ----------------------------------------------------------------------
def refresh_account_token(request: Request) -> bool:
"""当 token 过期时,刷新账号 token。
返回 True 表示刷新成功False 表示刷新失败。
调用后 request.state.deepseek_token 会被更新。
"""
if not getattr(request.state, 'use_config_token', False):
# 用户自带 token无法刷新
return False
account = getattr(request.state, 'account', None)
if not account:
return False
acc_id = get_account_identifier(account)
logger.info(f"[refresh_account_token] 尝试刷新账号 {acc_id} 的 token")
try:
# 清除旧 token
account["token"] = ""
# 重新登录
login_deepseek_via_account(account)
# 更新 request 状态
request.state.deepseek_token = account.get("token")
logger.info(f"[refresh_account_token] 账号 {acc_id} token 刷新成功")
return True
except Exception as e:
logger.error(f"[refresh_account_token] 账号 {acc_id} token 刷新失败: {e}")
return False
def mark_token_invalid(request: Request):
"""标记当前账号的 token 为无效,清除它以便下次重新登录"""
if not getattr(request.state, 'use_config_token', False):
return
account = getattr(request.state, 'account', None)
if account:
acc_id = get_account_identifier(account)
logger.warning(f"[mark_token_invalid] 标记账号 {acc_id} 的 token 为无效")
account["token"] = ""

View File

@@ -1,106 +0,0 @@
# -*- coding: utf-8 -*-
"""配置管理模块"""
import base64
import json
import logging
import os
import sys
import transformers
# -------------------------- 获取项目根目录 --------------------------
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
IS_VERCEL = bool(os.getenv("VERCEL")) or bool(os.getenv("NOW_REGION"))
def resolve_path(env_key: str, default_rel: str) -> str:
"""解析路径,支持环境变量覆盖"""
raw = os.getenv(env_key)
if raw:
return raw if os.path.isabs(raw) else os.path.join(BASE_DIR, raw)
return os.path.join(BASE_DIR, default_rel)
# -------------------------- 日志配置 --------------------------
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
force=True,
)
logger = logging.getLogger("ds2api")
# -------------------------- 初始化 tokenizer --------------------------
chat_tokenizer_dir = resolve_path("DS2API_TOKENIZER_DIR", "")
tokenizer = transformers.AutoTokenizer.from_pretrained(
chat_tokenizer_dir, trust_remote_code=True
)
# ----------------------------------------------------------------------
# 配置文件的读写函数
# ----------------------------------------------------------------------
CONFIG_PATH = resolve_path("DS2API_CONFIG_PATH", "config.json")
def load_config() -> dict:
"""加载配置。
优先从环境变量读取:
- DS2API_CONFIG_JSON / CONFIG_JSON: 直接 JSON 字符串,或 base64 编码后的 JSON
若未提供环境变量,再从 CONFIG_PATH 指向的文件读取。
"""
raw_cfg = os.getenv("DS2API_CONFIG_JSON") or os.getenv("CONFIG_JSON")
if raw_cfg:
try:
return json.loads(raw_cfg)
except json.JSONDecodeError:
try:
decoded = base64.b64decode(raw_cfg).decode("utf-8")
return json.loads(decoded)
except Exception as e:
logger.warning(f"[load_config] 环境变量配置解析失败: {e}")
return {}
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.warning(f"[load_config] 无法读取配置文件({CONFIG_PATH}): {e}")
return {}
def save_config(cfg: dict) -> None:
"""将配置写回 config.json。
Vercel 环境文件系统通常是只读的;且如果配置来自环境变量,也无法回写。
所以这里失败不应影响主流程。
"""
if os.getenv("DS2API_CONFIG_JSON") or os.getenv("CONFIG_JSON"):
logger.info("[save_config] 配置来自环境变量,跳过写回")
return
try:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
except PermissionError as e:
logger.warning(f"[save_config] 配置文件不可写({CONFIG_PATH}): {e}")
except Exception as e:
logger.exception(f"[save_config] 写入 config.json 失败: {e}")
# 全局配置
CONFIG = load_config()
if not CONFIG:
logger.warning(
"[config] 未加载到有效配置,请提供 config.json路径可用 DS2API_CONFIG_PATH 指定)或设置环境变量 DS2API_CONFIG_JSON"
)
# WASM 模块文件路径
WASM_PATH = resolve_path("DS2API_WASM_PATH", "sha3_wasm_bg.7b9ca65ddd.wasm")
# 模板目录
TEMPLATES_DIR = resolve_path("DS2API_TEMPLATES_DIR", "templates")
# WebUI 静态文件目录
STATIC_ADMIN_DIR = resolve_path("DS2API_STATIC_ADMIN_DIR", "static/admin")

View File

@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
"""常量定义模块 - 统一管理项目中的所有常量"""
# ----------------------------------------------------------------------
# 网络和超时配置
# ----------------------------------------------------------------------
KEEP_ALIVE_TIMEOUT = 5 # 保活超时(秒)
STREAM_IDLE_TIMEOUT = 30 # 流无新内容超时(秒)
MAX_KEEPALIVE_COUNT = 10 # 最大连续 keepalive 次数
# ----------------------------------------------------------------------
# DeepSeek API 配置
# ----------------------------------------------------------------------
DEEPSEEK_HOST = "chat.deepseek.com"
DEEPSEEK_LOGIN_URL = f"https://{DEEPSEEK_HOST}/api/v0/users/login"
DEEPSEEK_CREATE_SESSION_URL = f"https://{DEEPSEEK_HOST}/api/v0/chat_session/create"
DEEPSEEK_CREATE_POW_URL = f"https://{DEEPSEEK_HOST}/api/v0/chat/create_pow_challenge"
DEEPSEEK_COMPLETION_URL = f"https://{DEEPSEEK_HOST}/api/v0/chat/completion"
# ----------------------------------------------------------------------
# 请求头配置
# ----------------------------------------------------------------------
BASE_HEADERS = {
"Host": "chat.deepseek.com",
"User-Agent": "DeepSeek/1.6.11 Android/35",
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Content-Type": "application/json",
"x-client-platform": "android",
"x-client-version": "1.6.11",
"x-client-locale": "zh_CN",
"accept-charset": "UTF-8",
}
# ----------------------------------------------------------------------
# SSE 解析配置
# ----------------------------------------------------------------------
# 跳过的路径模式(状态相关,不是内容)
SKIP_PATTERNS = [
"quasi_status", "elapsed_secs", "token_usage",
"pending_fragment", "conversation_mode",
"fragments/-1/status", "fragments/-2/status", "fragments/-3/status"
]

View File

@@ -1,138 +0,0 @@
# -*- coding: utf-8 -*-
"""DeepSeek API 相关逻辑"""
import time
from curl_cffi import requests
from fastapi import HTTPException
from .config import CONFIG, save_config, logger
from .utils import get_account_identifier
from .constants import (
DEEPSEEK_HOST,
DEEPSEEK_LOGIN_URL,
DEEPSEEK_CREATE_SESSION_URL,
DEEPSEEK_CREATE_POW_URL,
DEEPSEEK_COMPLETION_URL,
BASE_HEADERS,
)
# get_account_identifier 已移至 core.utils
# ----------------------------------------------------------------------
# 登录函数:支持使用 email 或 mobile 登录
# ----------------------------------------------------------------------
def login_deepseek_via_account(account: dict) -> str:
"""使用 account 中的 email 或 mobile 登录 DeepSeek
成功后将返回的 token 写入 account 并保存至配置文件,返回新 token。
"""
email = account.get("email", "").strip()
mobile = account.get("mobile", "").strip()
password = account.get("password", "").strip()
if not password or (not email and not mobile):
raise HTTPException(
status_code=400,
detail="账号缺少必要的登录信息(必须提供 email 或 mobile 以及 password",
)
if email:
payload = {
"email": email,
"password": password,
"device_id": "deepseek_to_api",
"os": "android",
}
else:
payload = {
"mobile": mobile,
"area_code": None,
"password": password,
"device_id": "deepseek_to_api",
"os": "android",
}
try:
resp = requests.post(
DEEPSEEK_LOGIN_URL, headers=BASE_HEADERS, json=payload, impersonate="safari15_3"
)
resp.raise_for_status()
except Exception as e:
logger.error(f"[login_deepseek_via_account] 登录请求异常: {e}")
raise HTTPException(status_code=500, detail="Account login failed: 请求异常")
try:
logger.warning(f"[login_deepseek_via_account] {resp.text}")
data = resp.json()
except Exception as e:
logger.error(f"[login_deepseek_via_account] JSON解析失败: {e}")
raise HTTPException(
status_code=500, detail="Account login failed: invalid JSON response"
)
# 检查 API 错误码
if data.get("code") != 0:
error_msg = data.get("msg", "Unknown error")
logger.error(f"[login_deepseek_via_account] API错误: {error_msg}")
raise HTTPException(
status_code=500, detail=f"Account login failed: {error_msg}"
)
# 检查业务错误码
biz_code = data.get("data", {}).get("biz_code")
biz_msg = data.get("data", {}).get("biz_msg", "")
if biz_code != 0:
logger.error(f"[login_deepseek_via_account] 业务错误: {biz_msg}")
raise HTTPException(
status_code=500, detail=f"Account login failed: {biz_msg}"
)
# 校验响应数据格式是否正确
if (
data.get("data") is None
or data["data"].get("biz_data") is None
or data["data"]["biz_data"].get("user") is None
):
logger.error(f"[login_deepseek_via_account] 登录响应格式错误: {data}")
raise HTTPException(
status_code=500, detail="Account login failed: invalid response format"
)
new_token = data["data"]["biz_data"]["user"].get("token")
if not new_token:
logger.error(f"[login_deepseek_via_account] 登录响应中缺少 token: {data}")
raise HTTPException(
status_code=500, detail="Account login failed: missing token"
)
account["token"] = new_token
save_config(CONFIG)
return new_token
# ----------------------------------------------------------------------
# 封装对话接口调用的重试机制
# ----------------------------------------------------------------------
def call_completion_endpoint(payload: dict, headers: dict, max_attempts: int = 3):
"""调用 DeepSeek 对话接口,支持重试"""
attempts = 0
while attempts < max_attempts:
try:
deepseek_resp = requests.post(
DEEPSEEK_COMPLETION_URL,
headers=headers,
json=payload,
stream=True,
impersonate="safari15_3",
)
except Exception as e:
logger.warning(f"[call_completion_endpoint] 请求异常: {e}")
time.sleep(1)
attempts += 1
continue
if deepseek_resp.status_code == 200:
return deepseek_resp
else:
logger.warning(
f"[call_completion_endpoint] 调用对话接口失败, 状态码: {deepseek_resp.status_code}"
)
deepseek_resp.close()
time.sleep(1)
attempts += 1
return None

View File

@@ -1,118 +0,0 @@
# -*- coding: utf-8 -*-
"""消息处理模块"""
import re
from .config import CONFIG, logger
# Claude 默认模型
CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-20250514"
# 预编译正则表达式(性能优化)
_MARKDOWN_IMAGE_PATTERN = re.compile(r"!\[(.*?)\]\((.*?)\)")
# ----------------------------------------------------------------------
# 消息预处理函数,将多轮对话合并成最终 prompt
# ----------------------------------------------------------------------
def messages_prepare(messages: list) -> str:
"""处理消息列表,合并连续相同角色的消息,并添加角色标签:
- 对于 assistant 消息,加上 <Assistant> 前缀及 <end▁of▁sentence> 结束标签;
- 对于 user/system 消息(除第一条外)加上 <User> 前缀;
- 如果消息 content 为数组,则提取其中 type 为 "text" 的部分;
- 最后移除 markdown 图片格式的内容。
"""
processed = []
for m in messages:
role = m.get("role", "")
content = m.get("content", "")
if isinstance(content, list):
texts = [
item.get("text", "") for item in content if item.get("type") == "text"
]
text = "\n".join(texts)
else:
text = str(content)
processed.append({"role": role, "text": text})
if not processed:
return ""
# 合并连续同一角色的消息
merged = [processed[0]]
for msg in processed[1:]:
if msg["role"] == merged[-1]["role"]:
merged[-1]["text"] += "\n\n" + msg["text"]
else:
merged.append(msg)
# 添加标签
parts = []
for idx, block in enumerate(merged):
role = block["role"]
text = block["text"]
if role == "assistant":
parts.append(f"<Assistant>{text}<end▁of▁sentence>")
elif role in ("user", "system"):
if idx > 0:
parts.append(f"<User>{text}")
else:
parts.append(text)
else:
parts.append(text)
final_prompt = "".join(parts)
# 仅移除 markdown 图片格式(不全部移除 !- 使用预编译的正则表达式
final_prompt = _MARKDOWN_IMAGE_PATTERN.sub(r"[\1](\2)", final_prompt)
return final_prompt
# ----------------------------------------------------------------------
# OpenAI到Claude格式转换函数
# ----------------------------------------------------------------------
def convert_claude_to_deepseek(claude_request: dict) -> dict:
"""将Claude格式的请求转换为DeepSeek格式基于现有OpenAI接口"""
messages = claude_request.get("messages", [])
model = claude_request.get("model", CLAUDE_DEFAULT_MODEL)
# 从配置文件读取Claude模型映射
claude_mapping = CONFIG.get(
"claude_model_mapping", {"fast": "deepseek-chat", "slow": "deepseek-chat"}
)
# Claude模型映射到DeepSeek模型 - 基于配置和模型特征判断
if (
"opus" in model.lower()
or "reasoner" in model.lower()
or "slow" in model.lower()
):
deepseek_model = claude_mapping.get("slow", "deepseek-chat")
else:
deepseek_model = claude_mapping.get("fast", "deepseek-chat")
deepseek_request = {"model": deepseek_model, "messages": messages.copy()}
# 处理system消息 - 将system参数转换为system role消息
if "system" in claude_request:
system_msg = {"role": "system", "content": claude_request["system"]}
deepseek_request["messages"].insert(0, system_msg)
# 添加可选参数
if "temperature" in claude_request:
deepseek_request["temperature"] = claude_request["temperature"]
if "top_p" in claude_request:
deepseek_request["top_p"] = claude_request["top_p"]
if "stop_sequences" in claude_request:
deepseek_request["stop"] = claude_request["stop_sequences"]
if "stream" in claude_request:
deepseek_request["stream"] = claude_request["stream"]
return deepseek_request
def convert_deepseek_to_claude_format(
deepseek_response: dict, original_claude_model: str = CLAUDE_DEFAULT_MODEL
) -> dict:
"""将DeepSeek响应转换为Claude格式的OpenAI响应"""
# DeepSeek响应已经是OpenAI格式只需要修改模型名称
if isinstance(deepseek_response, dict):
claude_response = deepseek_response.copy()
claude_response["model"] = original_claude_model
return claude_response
return deepseek_response

View File

@@ -1,90 +0,0 @@
# -*- coding: utf-8 -*-
"""模型定义模块 - 集中管理所有支持的模型"""
# DeepSeek 模型列表(官方模型名称)
DEEPSEEK_MODELS = [
{
"id": "deepseek-chat",
"object": "model",
"created": 1677610602,
"owned_by": "deepseek",
"permission": [],
},
{
"id": "deepseek-reasoner",
"object": "model",
"created": 1677610602,
"owned_by": "deepseek",
"permission": [],
},
{
"id": "deepseek-chat-search",
"object": "model",
"created": 1677610602,
"owned_by": "deepseek",
"permission": [],
},
{
"id": "deepseek-reasoner-search",
"object": "model",
"created": 1677610602,
"owned_by": "deepseek",
"permission": [],
},
]
# Claude 模型映射列表
CLAUDE_MODELS = [
{
"id": "claude-sonnet-4-20250514",
"object": "model",
"created": 1715635200,
"owned_by": "anthropic",
},
{
"id": "claude-sonnet-4-20250514-fast",
"object": "model",
"created": 1715635200,
"owned_by": "anthropic",
},
{
"id": "claude-sonnet-4-20250514-slow",
"object": "model",
"created": 1715635200,
"owned_by": "anthropic",
},
]
def get_model_config(model: str) -> tuple[bool, bool]:
"""根据模型名称获取配置
Args:
model: 模型名称
Returns:
(thinking_enabled, search_enabled) 元组
"""
model_lower = model.lower()
if model_lower == "deepseek-chat":
return False, False
elif model_lower == "deepseek-reasoner":
return True, False
elif model_lower == "deepseek-chat-search":
return False, True
elif model_lower == "deepseek-reasoner-search":
return True, True
else:
return None, None # 不支持的模型
def get_openai_models_response() -> dict:
"""获取 OpenAI 格式的模型列表响应"""
return {"object": "list", "data": DEEPSEEK_MODELS}
def get_claude_models_response() -> dict:
"""获取 Claude 格式的模型列表响应"""
return {"object": "list", "data": CLAUDE_MODELS}

View File

@@ -1,253 +0,0 @@
# -*- coding: utf-8 -*-
"""PoW (Proof of Work) 计算模块"""
import base64
import ctypes
import json
import struct
import threading
import time
from curl_cffi import requests
from wasmtime import Engine, Linker, Module, Store
from .config import CONFIG, WASM_PATH, logger
from .utils import get_account_identifier
# ----------------------------------------------------------------------
# WASM 模块缓存 - 避免每次请求都重新加载
# ----------------------------------------------------------------------
_wasm_cache_lock = threading.Lock()
_wasm_engine = None
_wasm_module = None
def _get_cached_wasm_module(wasm_path: str):
"""获取缓存的 WASM 模块,首次调用时加载"""
global _wasm_engine, _wasm_module
if _wasm_module is not None:
return _wasm_engine, _wasm_module
with _wasm_cache_lock:
# 双重检查锁定
if _wasm_module is not None:
return _wasm_engine, _wasm_module
try:
with open(wasm_path, "rb") as f:
wasm_bytes = f.read()
_wasm_engine = Engine()
_wasm_module = Module(_wasm_engine, wasm_bytes)
logger.info(f"[WASM] 已缓存 WASM 模块: {wasm_path}")
except Exception as e:
logger.error(f"[WASM] 加载 WASM 模块失败: {e}")
raise RuntimeError(f"加载 wasm 文件失败: {wasm_path}, 错误: {e}")
return _wasm_engine, _wasm_module
# 启动时预加载 WASM 模块
try:
_get_cached_wasm_module(WASM_PATH)
except Exception as e:
logger.warning(f"[WASM] 启动时预加载失败(将在首次使用时重试): {e}")
# get_account_identifier 已移至 core.utils
# ----------------------------------------------------------------------
# 使用 WASM 模块计算 PoW 答案的辅助函数
# ----------------------------------------------------------------------
def compute_pow_answer(
algorithm: str,
challenge_str: str,
salt: str,
difficulty: int,
expire_at: int,
signature: str,
target_path: str,
wasm_path: str,
) -> int:
"""
使用 WASM 模块计算 DeepSeekHash 答案answer
根据 JS 逻辑:
- 拼接前缀: "{salt}_{expire_at}_"
- 将 challenge 与前缀写入 wasm 内存后调用 wasm_solve 进行求解,
- 从 wasm 内存中读取状态与求解结果,
- 若状态非 0则返回整数形式的答案否则返回 None。
优化:使用缓存的 WASM 模块,避免每次请求都重新加载文件。
"""
if algorithm != "DeepSeekHashV1":
raise ValueError(f"不支持的算法:{algorithm}")
prefix = f"{salt}_{expire_at}_"
# 获取缓存的 WASM 模块(避免重复加载文件)
engine, module = _get_cached_wasm_module(wasm_path)
# 每次调用创建新的 Store 和实例(必须的,因为 Store 不是线程安全的)
store = Store(engine)
linker = Linker(engine)
instance = linker.instantiate(store, module)
exports = instance.exports(store)
try:
memory = exports["memory"]
add_to_stack = exports["__wbindgen_add_to_stack_pointer"]
alloc = exports["__wbindgen_export_0"]
wasm_solve = exports["wasm_solve"]
except KeyError as e:
raise RuntimeError(f"缺少 wasm 导出函数: {e}")
def write_memory(offset: int, data: bytes):
size = len(data)
base_addr = ctypes.cast(memory.data_ptr(store), ctypes.c_void_p).value
ctypes.memmove(base_addr + offset, data, size)
def read_memory(offset: int, size: int) -> bytes:
base_addr = ctypes.cast(memory.data_ptr(store), ctypes.c_void_p).value
return ctypes.string_at(base_addr + offset, size)
def encode_string(text: str):
data = text.encode("utf-8")
length = len(data)
ptr_val = alloc(store, length, 1)
ptr = int(ptr_val.value) if hasattr(ptr_val, "value") else int(ptr_val)
write_memory(ptr, data)
return ptr, length
# 1. 申请 16 字节栈空间
retptr = add_to_stack(store, -16)
# 2. 编码 challenge 与 prefix 到 wasm 内存中
ptr_challenge, len_challenge = encode_string(challenge_str)
ptr_prefix, len_prefix = encode_string(prefix)
# 3. 调用 wasm_solve注意difficulty 以 float 形式传入)
wasm_solve(
store,
retptr,
ptr_challenge,
len_challenge,
ptr_prefix,
len_prefix,
float(difficulty),
)
# 4. 从 retptr 处读取 4 字节状态和 8 字节求解结果
status_bytes = read_memory(retptr, 4)
if len(status_bytes) != 4:
add_to_stack(store, 16)
raise RuntimeError("读取状态字节失败")
status = struct.unpack("<i", status_bytes)[0]
value_bytes = read_memory(retptr + 8, 8)
if len(value_bytes) != 8:
add_to_stack(store, 16)
raise RuntimeError("读取结果字节失败")
value = struct.unpack("<d", value_bytes)[0]
# 5. 恢复栈指针
add_to_stack(store, 16)
if status == 0:
return None
return int(value)
def get_pow_response(request, max_attempts: int = 3):
"""获取 PoW 响应
Args:
request: FastAPI 请求对象
max_attempts: 最大重试次数
Returns:
Base64 编码的 PoW 响应,如果失败返回 None
"""
from .auth import get_auth_headers, choose_new_account
from .deepseek import BASE_HEADERS, login_deepseek_via_account, DEEPSEEK_CREATE_POW_URL
pow_url = DEEPSEEK_CREATE_POW_URL
attempts = 0
while attempts < max_attempts:
headers = get_auth_headers(request)
try:
resp = requests.post(
pow_url,
headers=headers,
json={"target_path": "/api/v0/chat/completion"},
timeout=30,
impersonate="safari15_3",
)
except Exception as e:
logger.error(f"[get_pow_response] 请求异常: {e}")
attempts += 1
continue
try:
data = resp.json()
except Exception as e:
logger.error(f"[get_pow_response] JSON解析异常: {e}")
data = {}
if resp.status_code == 200 and data.get("code") == 0:
challenge = data["data"]["biz_data"]["challenge"]
difficulty = challenge.get("difficulty", 144000)
expire_at = challenge.get("expire_at", 1680000000)
try:
answer = compute_pow_answer(
challenge["algorithm"],
challenge["challenge"],
challenge["salt"],
difficulty,
expire_at,
challenge["signature"],
challenge["target_path"],
WASM_PATH,
)
except Exception as e:
logger.error(f"[get_pow_response] PoW 答案计算异常: {e}")
answer = None
if answer is None:
logger.warning("[get_pow_response] PoW 答案计算失败,重试中...")
resp.close()
attempts += 1
continue
pow_dict = {
"algorithm": challenge["algorithm"],
"challenge": challenge["challenge"],
"salt": challenge["salt"],
"answer": answer,
"signature": challenge["signature"],
"target_path": challenge["target_path"],
}
pow_str = json.dumps(pow_dict, separators=(",", ":"), ensure_ascii=False)
encoded = base64.b64encode(pow_str.encode("utf-8")).decode("utf-8").rstrip()
resp.close()
return encoded
else:
code = data.get("code")
logger.warning(
f"[get_pow_response] 获取 PoW 失败, code={code}, msg={data.get('msg')}"
)
resp.close()
if request.state.use_config_token:
current_id = get_account_identifier(request.state.account)
if not hasattr(request.state, "tried_accounts"):
request.state.tried_accounts = []
if current_id not in request.state.tried_accounts:
request.state.tried_accounts.append(current_id)
new_account = choose_new_account(request.state.tried_accounts)
if new_account is None:
break
try:
login_deepseek_via_account(new_account)
except Exception as e:
logger.error(
f"[get_pow_response] 账号 {get_account_identifier(new_account)} 登录失败:{e}"
)
attempts += 1
continue
request.state.account = new_account
request.state.deepseek_token = new_account.get("token")
else:
attempts += 1
continue
attempts += 1
return None

View File

@@ -1,165 +0,0 @@
# -*- coding: utf-8 -*-
"""会话管理模块 - 封装公共的会话创建和 PoW 获取逻辑"""
from curl_cffi import requests as cffi_requests
from fastapi import HTTPException, Request
from .config import logger
from .utils import get_account_identifier
from .models import get_model_config
from .auth import (
get_auth_headers,
choose_new_account,
release_account,
refresh_account_token,
)
from .deepseek import (
DEEPSEEK_CREATE_SESSION_URL,
DEEPSEEK_CREATE_POW_URL,
login_deepseek_via_account,
call_completion_endpoint,
)
from .pow import get_pow_response
def create_session(request: Request, max_attempts: int = 3) -> str | None:
"""创建 DeepSeek 会话
Args:
request: FastAPI 请求对象
max_attempts: 最大重试次数
Returns:
会话 ID如果失败返回 None
"""
attempts = 0
token_refreshed = False # 标记是否已尝试刷新 token
while attempts < max_attempts:
headers = get_auth_headers(request)
try:
resp = cffi_requests.post(
DEEPSEEK_CREATE_SESSION_URL,
headers=headers,
json={"agent": "chat"},
impersonate="safari15_3",
)
except Exception as e:
logger.error(f"[create_session] 请求异常: {e}")
attempts += 1
continue
try:
data = resp.json()
except Exception as e:
logger.error(f"[create_session] JSON解析异常: {e}")
data = {}
if resp.status_code == 200 and data.get("code") == 0:
session_id = data["data"]["biz_data"]["id"]
resp.close()
return session_id
else:
code = data.get("code")
msg = data.get("msg", "")
logger.warning(
f"[create_session] 创建会话失败, code={code}, msg={msg}"
)
resp.close()
# 配置模式下尝试处理 token 问题
if request.state.use_config_token:
# token 无效(认证失败)时,先尝试刷新当前账号的 token
if code in [40001, 40002, 40003] or "token" in msg.lower() or "unauthorized" in msg.lower():
if not token_refreshed:
logger.info("[create_session] 检测到 token 可能过期,尝试刷新")
if refresh_account_token(request):
token_refreshed = True
continue # 使用新 token 重试
else:
logger.warning("[create_session] token 刷新失败,尝试切换账号")
# token 刷新失败或其他错误,尝试切换账号
current_id = get_account_identifier(request.state.account)
if not hasattr(request.state, "tried_accounts"):
request.state.tried_accounts = []
if current_id not in request.state.tried_accounts:
request.state.tried_accounts.append(current_id)
new_account = choose_new_account(request.state.tried_accounts)
if new_account is None:
break
try:
login_deepseek_via_account(new_account)
except Exception as e:
logger.error(
f"[create_session] 账号 {get_account_identifier(new_account)} 登录失败:{e}"
)
attempts += 1
continue
request.state.account = new_account
request.state.deepseek_token = new_account.get("token")
token_refreshed = False # 新账号重置刷新标记
else:
attempts += 1
continue
attempts += 1
return None
def get_pow(request: Request, max_attempts: int = 3) -> str | None:
"""获取 PoW 响应的包装函数
Args:
request: FastAPI 请求对象
max_attempts: 最大重试次数
Returns:
Base64 编码的 PoW 响应,如果失败返回 None
"""
return get_pow_response(request, max_attempts)
def prepare_completion_request(
request: Request,
session_id: str,
prompt: str,
thinking_enabled: bool = False,
search_enabled: bool = False,
max_attempts: int = 3,
):
"""准备并执行对话补全请求
Args:
request: FastAPI 请求对象
session_id: 会话 ID
prompt: 处理后的提示词
thinking_enabled: 是否启用思考模式
search_enabled: 是否启用搜索
max_attempts: 最大重试次数
Returns:
DeepSeek 响应对象,如果失败返回 None
"""
pow_resp = get_pow(request, max_attempts)
if not pow_resp:
return None
headers = {**get_auth_headers(request), "x-ds-pow-response": pow_resp}
payload = {
"chat_session_id": session_id,
"parent_message_id": None,
"prompt": prompt,
"ref_file_ids": [],
"thinking_enabled": thinking_enabled,
"search_enabled": search_enabled,
}
return call_completion_endpoint(payload, headers, max_attempts)
# get_model_config 已移至 core.models
def cleanup_account(request: Request):
"""清理账号资源(将账号放回队列)"""
if getattr(request.state, "use_config_token", False) and hasattr(request.state, "account"):
release_account(request.state.account)

View File

@@ -1,450 +0,0 @@
# -*- coding: utf-8 -*-
"""DeepSeek SSE 流解析模块
这个模块包含解析 DeepSeek SSE 响应的公共逻辑,供 openai.py、claude.py 和 accounts.py 共用。
合并了原 sse_parser.py 和 stream_parser.py 的功能。
"""
import json
import re
from typing import List, Tuple, Optional, Dict, Any, Generator
from .config import logger
from .constants import SKIP_PATTERNS
# 预编译正则表达式
_TOOL_CALL_PATTERN = re.compile(r'\{\s*["\']tool_calls["\']\s*:\s*\[(.*?)\]\s*\}', re.DOTALL)
_CITATION_PATTERN = re.compile(r"^\[citation:")
# ----------------------------------------------------------------------
# 基础解析函数
# ----------------------------------------------------------------------
def parse_deepseek_sse_line(raw_line: bytes) -> Optional[Dict[str, Any]]:
"""解析 DeepSeek SSE 行
Args:
raw_line: 原始字节行
Returns:
解析后的 chunk 字典,如果解析失败或应跳过则返回 None
"""
try:
line = raw_line.decode("utf-8")
except Exception as e:
logger.warning(f"[parse_deepseek_sse_line] 解码失败: {e}")
return None
if not line or not line.startswith("data:"):
return None
data_str = line[5:].strip()
if data_str == "[DONE]":
return {"type": "done"}
try:
chunk = json.loads(data_str)
return chunk
except json.JSONDecodeError as e:
logger.warning(f"[parse_deepseek_sse_line] JSON解析失败: {e}")
return None
def should_skip_chunk(chunk_path: str) -> bool:
"""判断是否应该跳过这个 chunk状态相关不是内容"""
if chunk_path == "response/search_status":
return True
return any(kw in chunk_path for kw in SKIP_PATTERNS)
def is_response_finished(chunk_path: str, v_value: Any) -> bool:
"""判断是否是响应结束信号"""
return chunk_path == "response/status" and isinstance(v_value, str) and v_value == "FINISHED"
def is_finished_signal(chunk_path: str, v_value: str) -> bool:
"""判断字符串 v_value 是否是结束信号"""
return v_value == "FINISHED" and (not chunk_path or chunk_path == "status")
def is_search_result(item: dict) -> bool:
"""判断是否是搜索结果项url/title/snippet"""
return "url" in item and "title" in item
# ----------------------------------------------------------------------
# 内容提取函数
# ----------------------------------------------------------------------
def extract_content_from_item(item: dict, default_type: str = "text") -> Optional[Tuple[str, str]]:
"""从包含 content 和 type 的项中提取内容
返回 (content, content_type) 或 None
"""
if "content" in item and "type" in item:
inner_type = item.get("type", "").upper()
content = item.get("content", "")
if content:
if inner_type == "THINK" or inner_type == "THINKING":
return (content, "thinking")
elif inner_type == "RESPONSE":
return (content, "text")
else:
return (content, default_type)
return None
def extract_content_recursive(items: List[Dict], default_type: str = "text") -> Optional[List[Tuple[str, str]]]:
"""递归提取列表中的内容
返回 [(content, content_type), ...] 列表,
如果遇到 FINISHED 信号返回 None
"""
extracted: List[Tuple[str, str]] = []
for item in items:
if not isinstance(item, dict):
continue
item_p = item.get("p", "")
item_v = item.get("v")
# 跳过搜索结果项
if is_search_result(item):
continue
# 只有当 p="status" (精确匹配) 且 v="FINISHED" 才认为是真正结束
if item_p == "status" and item_v == "FINISHED":
return None # 信号结束
# 跳过状态相关
if should_skip_chunk(item_p):
continue
# 直接处理包含 content 和 type 的项
result = extract_content_from_item(item, default_type)
if result:
extracted.append(result)
continue
# 确定类型(基于 p 字段)
if "thinking" in item_p:
content_type = "thinking"
elif "content" in item_p or item_p == "response" or item_p == "fragments":
content_type = "text"
else:
content_type = default_type
# 处理不同的 v 类型
if isinstance(item_v, str):
if item_v and item_v != "FINISHED":
extracted.append((item_v, content_type))
elif isinstance(item_v, list):
# 内层可能是 [{"content": "text", "type": "THINK/RESPONSE", ...}] 格式
for inner in item_v:
if isinstance(inner, dict):
# 检查内层的 type 字段
inner_type = inner.get("type", "").upper()
# DeepSeek 使用 THINK 而不是 THINKING
if inner_type == "THINK" or inner_type == "THINKING":
final_type = "thinking"
elif inner_type == "RESPONSE":
final_type = "text"
else:
final_type = content_type # 继承外层类型
content = inner.get("content", "")
if content:
extracted.append((content, final_type))
elif isinstance(inner, str) and inner:
extracted.append((inner, content_type))
return extracted
# ----------------------------------------------------------------------
# 高级解析函数
# ----------------------------------------------------------------------
def parse_sse_chunk_for_content(
chunk: Dict[str, Any],
thinking_enabled: bool = False,
current_fragment_type: str = "thinking"
) -> Tuple[List[Tuple[str, str]], bool, str]:
"""解析单个 SSE chunk 并提取内容
Args:
chunk: 解析后的 JSON chunk
thinking_enabled: 是否启用思考模式
current_fragment_type: 当前活跃的 fragment 类型 ("thinking""text")
用于处理没有明确路径的空 p 字段内容
Returns:
(contents, is_finished, new_fragment_type)
- contents: [(content, content_type), ...] 列表
- is_finished: 是否是结束信号
- new_fragment_type: 更新后的 fragment 类型,供下一个 chunk 使用
"""
if "v" not in chunk:
return ([], False, current_fragment_type)
v_value = chunk["v"]
chunk_path = chunk.get("p", "")
contents: List[Tuple[str, str]] = []
new_fragment_type = current_fragment_type
# 跳过状态相关 chunk
if should_skip_chunk(chunk_path):
return ([], False, current_fragment_type)
# 检查是否是真正的响应结束信号
if is_response_finished(chunk_path, v_value):
return ([], True, current_fragment_type)
# 检测 fragment 类型变化(来自 APPEND 操作)
# 格式: {'p': 'response', 'o': 'BATCH', 'v': [{'p': 'fragments', 'o': 'APPEND', 'v': [{'type': 'THINK/RESPONSE', ...}]}]}
if chunk_path == "response" and isinstance(v_value, list):
for batch_item in v_value:
if isinstance(batch_item, dict) and batch_item.get("p") == "fragments" and batch_item.get("o") == "APPEND":
fragments = batch_item.get("v", [])
for frag in fragments:
if isinstance(frag, dict):
frag_type = frag.get("type", "").upper()
if frag_type == "THINK" or frag_type == "THINKING":
new_fragment_type = "thinking"
elif frag_type == "RESPONSE":
new_fragment_type = "text"
# 也检测直接的 fragments 路径
if "response/fragments" in chunk_path and isinstance(v_value, list):
for frag in v_value:
if isinstance(frag, dict):
frag_type = frag.get("type", "").upper()
if frag_type == "THINK" or frag_type == "THINKING":
new_fragment_type = "thinking"
elif frag_type == "RESPONSE":
new_fragment_type = "text"
# 确定当前内容类型
if chunk_path == "response/thinking_content":
ptype = "thinking"
elif chunk_path == "response/content":
ptype = "text"
elif "response/fragments" in chunk_path and "/content" in chunk_path:
# 如 response/fragments/-1/content - 使用当前 fragment 类型
ptype = new_fragment_type
elif not chunk_path:
# 空路径内容:使用当前活跃的 fragment 类型
if thinking_enabled:
ptype = new_fragment_type
else:
ptype = "text"
else:
ptype = "text"
# 处理字符串值
if isinstance(v_value, str):
if is_finished_signal(chunk_path, v_value):
return ([], True, new_fragment_type)
if v_value:
contents.append((v_value, ptype))
# 处理列表值
elif isinstance(v_value, list):
result = extract_content_recursive(v_value, ptype)
if result is None:
return ([], True, new_fragment_type)
contents.extend(result)
return (contents, False, new_fragment_type)
def extract_content_from_chunk(chunk: Dict[str, Any]) -> Tuple[str, str, bool]:
"""从 DeepSeek chunk 中提取内容(简化版本,兼容旧接口)
Args:
chunk: 解析后的 chunk 字典
Returns:
(content, content_type, is_finished) 元组
content_type 为 "thinking""text"
is_finished 为 True 表示响应结束
"""
if chunk.get("type") == "done":
return "", "text", True
# 检测内容审核/敏感词阻止
if "error" in chunk or chunk.get("code") == "content_filter":
logger.warning(f"[extract_content_from_chunk] 检测到内容过滤: {chunk}")
return "", "text", True
if "v" not in chunk:
return "", "text", False
v_value = chunk["v"]
ptype = "text"
# 检查路径确定类型
path = chunk.get("p", "")
if path == "response/search_status":
return "", "text", False # 跳过搜索状态
elif path == "response/thinking_content":
ptype = "thinking"
elif path == "response/content":
ptype = "text"
if isinstance(v_value, str):
if v_value == "FINISHED":
return "", ptype, True
return v_value, ptype, False
elif isinstance(v_value, list):
for item in v_value:
if isinstance(item, dict):
if item.get("p") == "status" and item.get("v") == "FINISHED":
return "", ptype, True
return "", ptype, False
return "", ptype, False
# ----------------------------------------------------------------------
# 响应收集函数
# ----------------------------------------------------------------------
def collect_deepseek_response(response: Any) -> Tuple[str, str]:
"""收集 DeepSeek 流响应的完整内容
Args:
response: DeepSeek 流响应对象
Returns:
(reasoning_content, text_content) 元组
"""
thinking_parts: List[str] = []
text_parts: List[str] = []
try:
for raw_line in response.iter_lines():
chunk = parse_deepseek_sse_line(raw_line)
if not chunk:
continue
content, content_type, is_finished = extract_content_from_chunk(chunk)
if is_finished:
break
if content:
if content_type == "thinking":
thinking_parts.append(content)
else:
text_parts.append(content)
except Exception as e:
logger.error(f"[collect_deepseek_response] 收集响应失败: {e}")
finally:
try:
response.close()
except Exception:
pass
return "".join(thinking_parts), "".join(text_parts)
# ----------------------------------------------------------------------
# 工具调用解析
# ----------------------------------------------------------------------
def parse_tool_calls(text: str, tools_requested: List[Dict]) -> List[Dict[str, Any]]:
"""从响应文本中解析工具调用
Args:
text: 响应文本
tools_requested: 请求中定义的工具列表
Returns:
检测到的工具调用列表,每项包含 name 和 input
"""
detected_tools: List[Dict[str, Any]] = []
cleaned_text = text.strip()
# 尝试直接解析完整 JSON
if cleaned_text.startswith('{"tool_calls":') and cleaned_text.endswith("]}"):
try:
tool_data = json.loads(cleaned_text)
for tool_call in tool_data.get("tool_calls", []):
tool_name = tool_call.get("name")
tool_input = tool_call.get("input", {})
if any(tool.get("name") == tool_name for tool in tools_requested):
detected_tools.append({"name": tool_name, "input": tool_input})
if detected_tools:
return detected_tools
except json.JSONDecodeError:
pass
# 使用正则匹配
matches = _TOOL_CALL_PATTERN.findall(cleaned_text)
for match in matches:
try:
tool_calls_json = f'{{"tool_calls": [{match}]}}'
tool_data = json.loads(tool_calls_json)
for tool_call in tool_data.get("tool_calls", []):
tool_name = tool_call.get("name")
tool_input = tool_call.get("input", {})
if any(tool.get("name") == tool_name for tool in tools_requested):
detected_tools.append({"name": tool_name, "input": tool_input})
except json.JSONDecodeError:
continue
return detected_tools
# ----------------------------------------------------------------------
# 引用过滤
# ----------------------------------------------------------------------
def should_filter_citation(text: str, search_enabled: bool) -> bool:
"""检查是否应该过滤引用内容
Args:
text: 内容文本
search_enabled: 是否启用搜索
Returns:
是否应该过滤
"""
if not search_enabled:
return False
return _CITATION_PATTERN.match(text) is not None
# ----------------------------------------------------------------------
# 工具调用格式化
# ----------------------------------------------------------------------
def format_openai_tool_calls(
detected_tools: List[Dict[str, Any]],
base_id: str = ""
) -> List[Dict[str, Any]]:
"""将检测到的工具调用格式化为 OpenAI API 格式
Args:
detected_tools: parse_tool_calls 返回的工具调用列表
base_id: 用于生成唯一 ID 的基础字符串(可选)
Returns:
OpenAI 格式的 tool_calls 数组,例如:
[{"id": "call_xxx", "type": "function", "function": {"name": "...", "arguments": "..."}}]
"""
import random
import time
tool_calls_data = []
for idx, tool_info in enumerate(detected_tools):
tool_calls_data.append({
"id": f"call_{base_id or int(time.time())}_{random.randint(1000,9999)}_{idx}",
"type": "function",
"function": {
"name": tool_info["name"],
"arguments": json.dumps(tool_info.get("input", {}), ensure_ascii=False)
}
})
return tool_calls_data

View File

@@ -1,29 +0,0 @@
# -*- coding: utf-8 -*-
"""公共工具函数模块"""
def get_account_identifier(account: dict) -> str:
"""返回账号的唯一标识,优先使用 email否则使用 mobile"""
return account.get("email", "").strip() or account.get("mobile", "").strip()
def estimate_tokens(text) -> int:
"""估算文本的 token 数量(简单估算:字符数/4
Args:
text: 字符串或其他类型
Returns:
估算的 token 数量,最小为 1
"""
if isinstance(text, str):
return max(1, len(text) // 4)
elif isinstance(text, list):
return sum(
estimate_tokens(item.get("text", ""))
if isinstance(item, dict)
else estimate_tokens(str(item))
for item in text
)
else:
return max(1, len(str(text)) // 4)

151
dev.py
View File

@@ -1,151 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DS2API 开发服务器 - 统一启动后端和前端
使用方法:
python dev.py # 同时启动后端和前端
python dev.py --backend # 仅启动后端
python dev.py --frontend # 仅启动前端
python dev.py --install # 安装所有依赖
环境变量:
PORT - 后端服务端口,默认 5001
LOG_LEVEL - 日志级别,默认 INFO
"""
import os
import sys
import signal
import subprocess
import time
from pathlib import Path
# 配置
BACKEND_PORT = int(os.getenv("PORT", "5001"))
FRONTEND_PORT = 5173
HOST = os.getenv("HOST", "0.0.0.0")
LOG_LEVEL = os.getenv("LOG_LEVEL", "info").lower()
PROJECT_DIR = Path(__file__).parent
WEBUI_DIR = PROJECT_DIR / "webui"
REQUIREMENTS_FILE = PROJECT_DIR / "requirements.txt"
processes = []
def install_dependencies():
"""安装所有 Python 和 Node.js 依赖"""
print("\n📦 安装 Python 依赖...")
subprocess.run([
sys.executable, "-m", "pip", "install", "-r", str(REQUIREMENTS_FILE), "-q"
], check=True)
print("✅ Python 依赖安装完成")
if WEBUI_DIR.exists():
print("\n📦 安装前端依赖...")
subprocess.run(["npm", "install"], cwd=WEBUI_DIR, check=True)
print("✅ 前端依赖安装完成")
print("\n🎉 所有依赖安装完成!运行 `python dev.py` 启动服务\n")
def signal_handler(sig, frame):
"""处理退出信号,终止所有子进程"""
print("\n\n🛑 正在关闭所有服务...")
for proc in processes:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
print("👋 已退出\n")
sys.exit(0)
def start_backend():
"""启动后端服务"""
print(f"🚀 启动后端服务... http://localhost:{BACKEND_PORT}")
proc = subprocess.Popen(
[
sys.executable, "-m", "uvicorn",
"app:app",
"--host", HOST,
"--port", str(BACKEND_PORT),
"--reload",
"--reload-dir", str(PROJECT_DIR),
"--log-level", LOG_LEVEL,
],
cwd=PROJECT_DIR,
)
processes.append(proc)
return proc
def start_frontend():
"""启动前端开发服务器"""
if not WEBUI_DIR.exists():
print("⚠️ webui 目录不存在,跳过前端启动")
return None
node_modules = WEBUI_DIR / "node_modules"
if not node_modules.exists():
print("📦 安装前端依赖...")
subprocess.run(["npm", "install"], cwd=WEBUI_DIR, check=True)
print(f"🎨 启动前端服务... http://localhost:{FRONTEND_PORT}")
proc = subprocess.Popen(
["npm", "run", "dev"],
cwd=WEBUI_DIR,
)
processes.append(proc)
return proc
def main():
# 解析参数
if "--install" in sys.argv or "-i" in sys.argv:
install_dependencies()
return
backend_only = "--backend" in sys.argv or "-b" in sys.argv
frontend_only = "--frontend" in sys.argv or "-f" in sys.argv
# 注册信号处理
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
print("\n" + "=" * 50)
print(" DS2API 开发服务器")
print("=" * 50)
if frontend_only:
start_frontend()
elif backend_only:
start_backend()
else:
# 同时启动
start_backend()
time.sleep(1) # 等待后端启动
start_frontend()
print("\n" + "-" * 50)
if not frontend_only:
print(f"📡 后端 API: http://localhost:{BACKEND_PORT}")
if not backend_only:
print(f"🎨 管理界面: http://localhost:{FRONTEND_PORT}")
print("-" * 50)
print("按 Ctrl+C 停止所有服务\n")
# 等待进程结束
try:
while processes:
for proc in processes[:]:
if proc.poll() is not None:
processes.remove(proc)
time.sleep(0.5)
except KeyboardInterrupt:
signal_handler(None, None)
if __name__ == "__main__":
main()

View File

@@ -9,11 +9,14 @@
services:
ds2api:
build: .
build:
context: .
target: go-builder
image: ds2api:dev
container_name: ds2api-dev
command: ["go", "run", "./cmd/ds2api"]
ports:
- "${PORT:-5001}:5001"
- "${PORT:-5001}:${PORT:-5001}"
env_file:
- .env
environment:
@@ -21,12 +24,9 @@ services:
- LOG_LEVEL=DEBUG
volumes:
# 源代码挂载(开发时实时生效)
- ./app.py:/app/app.py:ro
- ./core:/app/core:ro
- ./routes:/app/routes:ro
- ./static:/app/static:ro
- ./:/app
# 配置文件挂载(便于本地修改)
- ./config.json:/app/config.json:ro
- ./config.json:/app/config.json
restart: "no"
stdin_open: true
tty: true

View File

@@ -1,29 +1,14 @@
# DS2API 生产环境配置
# 使用说明:
# 1. 复制 .env.example 为 .env 并填写配置
# 2. docker-compose up -d
# 3. 主代码更新后docker-compose up -d --build
#
# 设计原则:
# - 零侵入:所有项目配置通过 .env 文件传递
# - 易维护:主代码更新只需重新构建镜像
services:
ds2api:
build: .
image: ds2api:latest
container_name: ds2api
ports:
- "${PORT:-5001}:5001"
env_file:
- .env
environment:
# 确保容器内使用正确的主机绑定
- HOST=0.0.0.0
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5001/v1/models')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
image: ghcr.io/cjackhwang/ds2api:latest
container_name: ds2api
restart: always
ports:
- "6011:5001"
volumes:
- ./config.json:/app/config.json # 配置文件
- ./.env:/app/.env # 环境变量
environment:
- TZ=Asia/Shanghai
- LOG_LEVEL=INFO
- DS2API_ADMIN_KEY=${DS2API_ADMIN_KEY:-ds2api}

154
docs/CONTRIBUTING.en.md Normal file
View File

@@ -0,0 +1,154 @@
# Contributing Guide
Language: [中文](CONTRIBUTING.md) | [English](CONTRIBUTING.en.md)
Thanks for your interest in contributing to DS2API!
## Development Setup
### Prerequisites
- Go 1.26+
- Node.js 20+ (for WebUI development)
- npm (bundled with Node.js)
### Backend Development
```bash
# 1. Clone
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. Configure
cp config.example.json config.json
# Edit config.json with test accounts
# 3. Run backend
go run ./cmd/ds2api
# Default: http://localhost:5001
```
### Frontend Development (WebUI)
```bash
# 1. Navigate to WebUI directory
cd webui
# 2. Install dependencies
npm install
# 3. Start dev server (hot reload)
npm run dev
# Default: http://localhost:5173, auto-proxies API to backend
```
WebUI tech stack:
- React + Vite
- Tailwind CSS
- Bilingual language packs: `webui/src/locales/zh.json` / `en.json`
### Docker Development
```bash
docker-compose -f docker-compose.dev.yml up
```
## Code Standards
| Language | Standards |
| --- | --- |
| **Go** | Run `gofmt` 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:` |
## Submitting a PR
1. Fork the repo
2. Create a branch (e.g. `feature/xxx` or `fix/xxx`)
3. Commit changes
4. Push your branch
5. Open a Pull Request
> 💡 If you modify files under `webui/`, no manual build is needed — CI handles it automatically.
> If you want to verify the generated `static/admin/` assets locally, you can still run `./scripts/build-webui.sh`.
## Build WebUI
Manually build WebUI to `static/admin/`:
```bash
./scripts/build-webui.sh
```
## Running Tests
```bash
# Go + Node unit tests (recommended)
./tests/scripts/run-unit-all.sh
# End-to-end live tests (real accounts)
./tests/scripts/run-live.sh
```
## 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
```
## Reporting Issues
Please use [GitHub Issues](https://github.com/CJackHwang/ds2api/issues) and include:
- Steps to reproduce
- Relevant log output
- Environment info (OS, Go version, deployment method)

154
docs/CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,154 @@
# 贡献指南
语言 / Language: [中文](CONTRIBUTING.md) | [English](CONTRIBUTING.en.md)
感谢你对 DS2API 的关注与贡献!
## 开发环境设置
### 前置要求
- Go 1.26+
- Node.js 20+WebUI 开发时)
- npm随 Node.js 提供)
### 后端开发
```bash
# 1. 克隆仓库
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 2. 配置
cp config.example.json config.json
# 编辑 config.json填入测试账号
# 3. 启动后端
go run ./cmd/ds2api
# 默认监听 http://localhost:5001
```
### 前端开发WebUI
```bash
# 1. 进入 WebUI 目录
cd webui
# 2. 安装依赖
npm install
# 3. 启动开发服务器(热更新)
npm run dev
# 默认监听 http://localhost:5173自动代理 API 到后端
```
WebUI 技术栈:
- React + Vite
- Tailwind CSS
- 中英文语言包:`webui/src/locales/zh.json` / `en.json`
### Docker 开发环境
```bash
docker-compose -f docker-compose.dev.yml up
```
## 代码规范
| 语言 | 规范 |
| --- | --- |
| **Go** | 提交前运行 `gofmt`,确保 `go test ./...` 通过 |
| **JavaScript/React** | 保持现有代码风格(函数组件) |
| **提交信息** | 使用语义化前缀:`feat:``fix:``docs:``refactor:``style:``perf:``chore:` |
## 提交 PR
1. Fork 仓库
2. 创建分支(如 `feature/xxx``fix/xxx`
3. 提交更改
4. 推送分支
5. 发起 Pull Request
> 💡 如果修改了 `webui/` 目录下的文件无需手动构建——CI 会自动处理。
> 但如果你本地想验证 `static/admin/` 产物,还是可以手动运行 `./scripts/build-webui.sh`。
## WebUI 构建
手动构建 WebUI 到 `static/admin/`
```bash
./scripts/build-webui.sh
```
## 运行测试
```bash
# Go + Node 单元测试(推荐)
./tests/scripts/run-unit-all.sh
# 端到端全链路测试(真实账号)
./tests/scripts/run-live.sh
```
## 项目结构
```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 配置
```
## 问题反馈
请使用 [GitHub Issues](https://github.com/CJackHwang/ds2api/issues) 并附上:
- 复现步骤
- 相关日志输出
- 运行环境信息OS、Go 版本、部署方式)

572
docs/DEPLOY.en.md Normal file
View File

@@ -0,0 +1,572 @@
# DS2API Deployment Guide
Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md)
This guide covers all deployment methods for the current Go-based codebase.
---
## Table of Contents
- [Prerequisites](#0-prerequisites)
- [1. Local Run](#1-local-run)
- [2. Docker Deployment](#2-docker-deployment)
- [3. Vercel Deployment](#3-vercel-deployment)
- [4. Download Release Binaries](#4-download-release-binaries)
- [5. Reverse Proxy (Nginx)](#5-reverse-proxy-nginx)
- [6. Linux systemd Service](#6-linux-systemd-service)
- [7. Post-Deploy Checks](#7-post-deploy-checks)
- [8. Pre-Release Local Regression](#8-pre-release-local-regression)
---
## 0. Prerequisites
| Dependency | Minimum Version | Notes |
| --- | --- | --- |
| Go | 1.26+ | Build backend |
| Node.js | 20+ | Only needed to build WebUI locally |
| npm | Bundled with Node.js | Install WebUI dependencies |
Config source (choose one):
- **File**: `config.json` (recommended for local/Docker)
- **Environment variable**: `DS2API_CONFIG_JSON` (recommended for Vercel; supports raw JSON or Base64)
- Compatibility note: `CONFIG_JSON` is the legacy fallback variable; `DS2API_CONFIG_JSON` may also contain raw JSON directly
Unified recommendation (best practice):
```bash
cp config.example.json config.json
# Edit config.json
```
Use `config.json` as the single source of truth:
- Local run: read `config.json` directly
- Docker / Vercel: generate `DS2API_CONFIG_JSON` (Base64) from `config.json` and inject it
---
## 1. Local Run
### 1.1 Basic Steps
```bash
# Clone
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# Copy and edit config
cp config.example.json config.json
# Open config.json and fill in:
# - keys: your API access keys
# - accounts: DeepSeek accounts (email or mobile + password)
# Start
go run ./cmd/ds2api
```
Default address: `http://0.0.0.0:5001` (override with `PORT`).
### 1.2 WebUI Build
On first local startup, if `static/admin/` is missing, DS2API will automatically attempt to build the WebUI (requires Node.js/npm; when dependencies are missing it runs `npm ci` first, then `npm run build -- --outDir static/admin --emptyOutDir`).
Manual build:
```bash
./scripts/build-webui.sh
```
Or step by step:
```bash
cd webui
npm install
npm run build
# Output goes to static/admin/
```
Control auto-build via environment variable:
```bash
# Disable auto-build
DS2API_AUTO_BUILD_WEBUI=false go run ./cmd/ds2api
# Force enable auto-build
DS2API_AUTO_BUILD_WEBUI=true go run ./cmd/ds2api
```
### 1.3 Compile to Binary
```bash
go build -o ds2api ./cmd/ds2api
./ds2api
```
---
## 2. Docker Deployment
### 2.1 Basic Steps
```bash
# Copy env template and config file
cp .env.example .env
cp config.example.json config.json
# Edit .env and set at least:
# DS2API_ADMIN_KEY=your-admin-key
# Start
docker-compose up -d
# View logs
docker-compose logs -f
```
The default `docker-compose.yml` maps host port `6011` to container port `5001`. If you want `5001` exposed directly, adjust the `ports` mapping.
### 2.2 Update
```bash
docker-compose up -d --build
```
### 2.3 Docker Architecture
The `Dockerfile` now provides two image paths:
1. **Default local/dev path (`runtime-from-source`)**: a three-stage build (WebUI build + Go build + runtime).
2. **Release path (`runtime-from-dist`)**: CI first creates `dist/ds2api_<tag>_linux_<arch>.tar.gz`, then Docker directly reuses the binary and `static/admin` assets from those release archives, without running `npm build`/`go build` again.
The release path keeps Docker images aligned with release archives and reduces duplicate build work.
Container entry command: `/usr/local/bin/ds2api`, default exposed port: `5001`.
### 2.4 Development Mode
```bash
docker-compose -f docker-compose.dev.yml up
```
Development features:
- Source code mounted (live changes)
- `LOG_LEVEL=DEBUG`
- No auto-restart
### 2.5 Health Check
Docker Compose includes a built-in health check:
```yaml
healthcheck:
test: ["CMD", "/usr/local/bin/busybox", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
```
### 2.6 Docker Troubleshooting
If container logs look normal but the admin panel is unreachable, check these first:
1. **Port alignment**: when `PORT` is not `5001`, use the same port in your URL (for example `http://localhost:8080/admin`).
2. **WebUI assets in dev compose**: `docker-compose.dev.yml` runs `go run` in a dev image and does not auto-install Node.js inside the container; if `static/admin` is missing in your repo, `/admin` will return 404. Build once on host: `./scripts/build-webui.sh`.
### 2.7 Zeabur One-Click (Dockerfile)
This repo includes a `zeabur.yaml` template for one-click deployment on Zeabur:
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
Notes:
- **Port**: DS2API listens on `5001` by default; the template sets `PORT=5001`.
- **Persistent config**: the template mounts `/data` and sets `DS2API_CONFIG_PATH=/data/config.json`. After importing config in Admin UI, it will be written and persisted to this path.
- **Build version**: Zeabur / regular `docker build` does not require `BUILD_VERSION` by default. The image prefers that build arg when provided, and automatically falls back to the repo-root `VERSION` file when it is absent.
- **First login**: after deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions (recommended: rotate to a strong secret after first login).
---
## 3. Vercel Deployment
### 3.1 Steps
1. **Fork** the repo to your GitHub account
2. **Import** the project on Vercel
3. **Set environment variables** (minimum required: one variable):
| Variable | Description |
| --- | --- |
| `DS2API_ADMIN_KEY` | Admin key (required) |
| `DS2API_CONFIG_JSON` | Config content, raw JSON or Base64 (optional, recommended) |
4. **Deploy**
### 3.1.1 Recommended Input (avoid `DS2API_CONFIG_JSON` mistakes)
If you prefer faster one-click bootstrap, you can leave `DS2API_CONFIG_JSON` empty first, then open `/admin` after deployment, import config, and sync it back to Vercel env vars from the "Vercel Sync" page.
Recommended: in repo root, copy the template first and fill your real accounts:
```bash
cp config.example.json config.json
# Edit config.json
```
Do not hand-edit large JSON directly in Vercel. Generate Base64 locally and paste it:
```bash
# Run in repo root
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
echo "$DS2API_CONFIG_JSON"
```
If you choose to preconfigure before first deploy, set these vars in Vercel Project Settings -> Environment Variables:
```text
DS2API_ADMIN_KEY=replace-with-a-strong-secret
DS2API_CONFIG_JSON=<the single-line Base64 output above>
```
Optional but recommended (for WebUI one-click Vercel sync):
```text
VERCEL_TOKEN=your-vercel-token
VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx
VERCEL_TEAM_ID=team_xxxxxxxxxxxx # optional for personal accounts
```
### 3.2 Optional Environment Variables
| Variable | Description | Default |
| --- | --- | --- |
| `DS2API_ACCOUNT_MAX_INFLIGHT` | Per-account inflight limit | `2` |
| `DS2API_ACCOUNT_CONCURRENCY` | Alias (legacy compat) | — |
| `DS2API_ACCOUNT_MAX_QUEUE` | Waiting queue limit | `recommended_concurrency` |
| `DS2API_ACCOUNT_QUEUE_SIZE` | Alias (legacy compat) | — |
| `DS2API_GLOBAL_MAX_INFLIGHT` | Global inflight limit | `recommended_concurrency` |
| `DS2API_MAX_INFLIGHT` | Alias (legacy compat) | — |
| `DS2API_ENV_WRITEBACK` | When `DS2API_CONFIG_JSON` is present, auto-write to `DS2API_CONFIG_PATH` and switch to file-backed mode after success (`1/true/yes/on`) | Disabled |
| `DS2API_VERCEL_INTERNAL_SECRET` | Hybrid streaming internal auth | Falls back to `DS2API_ADMIN_KEY` |
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL | `900` |
| `VERCEL_TOKEN` | Vercel sync token | — |
| `VERCEL_PROJECT_ID` | Vercel project ID | — |
| `VERCEL_TEAM_ID` | Vercel team ID | — |
| `DS2API_VERCEL_PROTECTION_BYPASS` | Deployment protection bypass for internal Node→Go calls | — |
### 3.3 Vercel Architecture
```text
Request ──────┐
vercel.json routing
┌─────┴─────┐
│ │
▼ ▼
api/index.go api/chat-stream.js
(Go Runtime) (Node Runtime)
```
- **Go entry**: `api/index.go` (Serverless Go)
- **Stream entry**: `api/chat-stream.js` (Node Runtime for real-time SSE)
- **Routing**: `vercel.json`
- **Build command**: `npm ci --prefix webui && npm run build --prefix webui` (automatic)
#### Streaming Pipeline
Vercel Go Runtime applies platform-level response buffering, so this project uses a hybrid "**Go prepare + Node stream**" path on Vercel:
1. `api/chat-stream.js` receives `/v1/chat/completions` request
2. Node calls Go internal prepare endpoint (`?__stream_prepare=1`) for session ID, PoW, token
3. Go prepare creates a stream lease, locking the account
4. Node connects directly to DeepSeek upstream, relays SSE in real-time to client (including OpenAI chunk framing and tools anti-leak sieve)
5. After stream ends, Node calls Go release endpoint (`?__stream_release=1`) to free the account
> This adaptation is **Vercel-only**; local and Docker remain pure Go.
#### Non-Stream Fallback and Tool Call Handling
- `api/chat-stream.js` falls back to Go entry (`?__go=1`) for non-stream requests only
- Streaming requests (including requests with `tools`) stay on the Node path and use Go-aligned tool-call anti-leak handling
- WebUI non-stream test calls `?__go=1` directly to avoid Node hop timeout on long requests
#### Function Duration
`vercel.json` sets `maxDuration: 300` for both `api/chat-stream.js` and `api/index.go` (subject to your Vercel plan limits).
### 3.4 Vercel Troubleshooting
#### Go Build Failure
```text
Error: Command failed: go build -ldflags -s -w -o .../bootstrap ...
```
**Cause**: Invalid Go build flag settings in Vercel (`-ldflags` not passed as a single argument).
**Fix**:
1. Open Vercel Project Settings → Build and Development Settings
2. **Clear** custom Go Build Flags / Build Command (recommended)
3. If ldflags must be used, set `-ldflags="-s -w"` (ensure it's one argument)
4. Verify `go.mod` uses a supported version (currently `go 1.24`)
5. Redeploy (recommended: clear cache)
#### Internal Package Import Error
```text
use of internal package ds2api/internal/server not allowed
```
**Cause**: Vercel Go entrypoint directly imports `internal/...`.
**Fix**: This repo uses a public bridge package: `api/index.go``ds2api/app``internal/server`.
#### Output Directory Error
```text
No Output Directory named "public" found after the Build completed.
```
**Fix**: This repo uses `static` as output directory (`"outputDirectory": "static"` in `vercel.json`). If you manually changed Output Directory in Project Settings, set it to `static` or clear it.
#### Deployment Protection Blocking
If API responses return Vercel HTML `Authentication Required`:
- **Option A**: Disable Deployment Protection for that environment (recommended for public APIs)
- **Option B**: Add `x-vercel-protection-bypass` header to requests
- **Option C**: Set `VERCEL_AUTOMATION_BYPASS_SECRET` (or `DS2API_VERCEL_PROTECTION_BYPASS`) for internal Node→Go calls
### 3.5 Build Artifacts Not Committed
- `static/admin` directory is not in Git
- Vercel / Docker automatically generate WebUI assets during build
---
## 4. Download Release Binaries
Built-in GitHub Actions workflow: `.github/workflows/release-artifacts.yml`
- **Trigger**: only on Release `published` (no build on normal push)
- **Outputs**: multi-platform binary archives + `sha256sums.txt`
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
| Platform | Architecture | Format |
| --- | --- | --- |
| Linux | amd64, arm64 | `.tar.gz` |
| macOS | amd64, arm64 | `.tar.gz` |
| Windows | amd64 | `.zip` |
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`
### Usage
```bash
# 1. Download the archive for your platform
# 2. Extract
tar -xzf ds2api_<tag>_linux_amd64.tar.gz
cd ds2api_<tag>_linux_amd64
# 3. Configure
cp config.example.json config.json
# Edit config.json
# 4. Start
./ds2api
```
### Maintainer Release Flow
1. Create and publish a GitHub Release (with tag, for example `vX.Y.Z`)
2. Wait for the `Release Artifacts` workflow to complete
3. Download the matching archive from Release Assets
### Pull from GHCR (Optional)
```bash
# latest
docker pull ghcr.io/cjackhwang/ds2api:latest
# specific version (example)
docker pull ghcr.io/cjackhwang/ds2api:v3.0.0
```
---
## 5. Reverse Proxy (Nginx)
When deploying behind Nginx, **you must disable buffering** for SSE streaming to work:
```nginx
location / {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
tcp_nodelay on;
}
```
For HTTPS, add SSL at the Nginx layer:
```nginx
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
tcp_nodelay on;
}
}
```
---
## 6. Linux systemd Service
### 6.1 Installation
```bash
# 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
```
### 6.2 Create systemd Service File
```ini
# /etc/systemd/system/ds2api.service
[Unit]
Description=DS2API (Go)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/ds2api
Environment=PORT=5001
Environment=DS2API_CONFIG_PATH=/opt/ds2api/config.json
Environment=DS2API_ADMIN_KEY=your-admin-key-here
ExecStart=/opt/ds2api/ds2api
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
### 6.3 Common Commands
```bash
# Reload service config
sudo systemctl daemon-reload
# Enable on boot
sudo systemctl enable ds2api
# Start
sudo systemctl start ds2api
# Check status
sudo systemctl status ds2api
# View logs
sudo journalctl -u ds2api -f
# Restart
sudo systemctl restart ds2api
# Stop
sudo systemctl stop ds2api
```
---
## 7. Post-Deploy Checks
After deployment (any method), verify in order:
```bash
# 1. Liveness probe
curl -s http://127.0.0.1:5001/healthz
# Expected: {"status":"ok"}
# 2. Readiness probe
curl -s http://127.0.0.1:5001/readyz
# Expected: {"status":"ready"}
# 3. Model list
curl -s http://127.0.0.1:5001/v1/models
# Expected: {"object":"list","data":[...]}
# 4. Admin panel (if WebUI is built)
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5001/admin
# Expected: 200
# 5. Test API call
curl http://127.0.0.1:5001/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-chat","messages":[{"role":"user","content":"hello"}]}'
```
---
## 8. Pre-Release Local Regression
Run the full live testsuite before release (real account tests):
```bash
./tests/scripts/run-live.sh
```
With custom flags:
```bash
go run ./cmd/ds2api-tests \
--config config.json \
--admin-key admin \
--out artifacts/testsuite \
--timeout 120 \
--retries 2
```
The testsuite automatically performs:
- ✅ Preflight checks (syntax/build/unit tests)
- ✅ Isolated config copy startup (no mutation to your original `config.json`)
- ✅ Live scenario verification (OpenAI/Claude/Admin/concurrency/toolcall/streaming)
- ✅ Full request/response artifact logging for debugging
For detailed testsuite documentation, see [TESTING.md](TESTING.md).

572
docs/DEPLOY.md Normal file
View File

@@ -0,0 +1,572 @@
# DS2API 部署指南
语言 / Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md)
本指南基于当前 Go 代码库,详细说明各种部署方式。
---
## 目录
- [前置要求](#0-前置要求)
- [一、本地运行](#一本地运行)
- [二、Docker 部署](#二docker-部署)
- [三、Vercel 部署](#三vercel-部署)
- [四、下载 Release 构建包](#四下载-release-构建包)
- [五、反向代理Nginx](#五反向代理nginx)
- [六、Linux systemd 服务化](#六linux-systemd-服务化)
- [七、部署后检查](#七部署后检查)
- [八、发布前进行本地回归](#八发布前进行本地回归)
---
## 0. 前置要求
| 依赖 | 最低版本 | 说明 |
| --- | --- | --- |
| Go | 1.26+ | 编译后端 |
| Node.js | 20+ | 仅在需要本地构建 WebUI 时 |
| npm | 随 Node.js 提供 | 安装 WebUI 依赖 |
配置来源(任选其一):
- **文件方式**`config.json`(推荐本地/Docker 使用)
- **环境变量方式**`DS2API_CONFIG_JSON`(推荐 Vercel 使用,支持 JSON 字符串或 Base64 编码)
- 兼容写法:`CONFIG_JSON` 是旧版回退变量;`DS2API_CONFIG_JSON` 也可以直接写原始 JSON
统一建议(最优实践):
```bash
cp config.example.json config.json
# 编辑 config.json
```
建议把 `config.json` 作为唯一配置源:
- 本地运行:直接读 `config.json`
- Docker / Vercel`config.json` 生成 `DS2API_CONFIG_JSON`Base64注入环境变量
---
## 一、本地运行
### 1.1 基本步骤
```bash
# 克隆仓库
git clone https://github.com/CJackHwang/ds2api.git
cd ds2api
# 复制并编辑配置
cp config.example.json config.json
# 使用你喜欢的编辑器打开 config.json填入
# - keys: 你的 API 访问密钥
# - accounts: DeepSeek 账号email 或 mobile + password
# 启动服务
go run ./cmd/ds2api
```
默认监听 `http://0.0.0.0:5001`,可通过 `PORT` 环境变量覆盖。
### 1.2 WebUI 构建
本地首次启动时,若 `static/admin/` 不存在,服务会自动尝试构建 WebUI需要 Node.js/npm缺依赖时会先执行 `npm ci`,再执行 `npm run build -- --outDir static/admin --emptyOutDir`)。
你也可以手动构建:
```bash
./scripts/build-webui.sh
```
或手动执行:
```bash
cd webui
npm install
npm run build
# 产物输出到 static/admin/
```
通过环境变量控制自动构建行为:
```bash
# 强制关闭自动构建
DS2API_AUTO_BUILD_WEBUI=false go run ./cmd/ds2api
# 强制开启自动构建
DS2API_AUTO_BUILD_WEBUI=true go run ./cmd/ds2api
```
### 1.3 编译为二进制文件
```bash
go build -o ds2api ./cmd/ds2api
./ds2api
```
---
## 二、Docker 部署
### 2.1 基本步骤
```bash
# 复制环境变量模板和配置文件
cp .env.example .env
cp config.example.json config.json
# 编辑 .env请改成你的强密码至少设置
# DS2API_ADMIN_KEY=your-admin-key
# 启动
docker-compose up -d
# 查看日志
docker-compose logs -f
```
默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请调整 `ports` 配置。
### 2.2 更新
```bash
docker-compose up -d --build
```
### 2.3 Docker 架构说明
`Dockerfile` 提供两条构建路径:
1. **本地/开发默认路径(`runtime-from-source`**三阶段构建WebUI 构建 + Go 构建 + 运行阶段)。
2. **Release 路径(`runtime-from-dist`**CI 先生成 `dist/ds2api_<tag>_linux_<arch>.tar.gz`,再由 Docker 直接复用该发布包内的二进制和 `static/admin` 产物组装运行镜像,不再重复执行 `npm build`/`go build`
Release 路径可确保 Docker 镜像与 release 压缩包使用同一套产物,减少重复构建带来的差异。
容器内启动命令:`/usr/local/bin/ds2api`,默认暴露端口 `5001`
### 2.4 开发环境
```bash
docker-compose -f docker-compose.dev.yml up
```
开发模式特性:
- 源代码挂载(修改即生效)
- `LOG_LEVEL=DEBUG`
- 不自动重启
### 2.5 健康检查
Docker Compose 已配置内置健康检查:
```yaml
healthcheck:
test: ["CMD", "/usr/local/bin/busybox", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
```
### 2.6 Docker 常见排查
如果容器日志正常但面板打不开,优先检查:
1. **端口是否一致**`PORT` 改成非 `5001` 时,访问地址也要改成对应端口(如 `http://localhost:8080/admin`)。
2. **开发 compose 的 WebUI 静态文件**`docker-compose.dev.yml` 使用 `go run` 开发镜像,不会在容器内自动安装 Node.js若仓库里没有 `static/admin``/admin` 会返回 404。可先在宿主机构建一次`./scripts/build-webui.sh`
### 2.7 Zeabur 一键部署Dockerfile
仓库提供 `zeabur.yaml` 模板,可在 Zeabur 上一键部署:
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
部署要点:
- **端口**:服务默认监听 `5001`,模板会固定设置 `PORT=5001`
- **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;在管理台导入配置后,会写入并持久化到该路径。
- **构建版本号**Zeabur / 普通 `docker build` 默认不需要传 `BUILD_VERSION`;镜像会优先使用该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。
- **首次登录**:部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录(建议首次登录后自行更换为强密码)。
---
## 三、Vercel 部署
### 3.1 部署步骤
1. **Fork 仓库**到你的 GitHub 账号
2. **在 Vercel 上导入项目**
3. **配置环境变量**(最少只需设置以下一项):
| 变量 | 说明 |
| --- | --- |
| `DS2API_ADMIN_KEY` | 管理密钥(必填) |
| `DS2API_CONFIG_JSON` | 配置内容JSON 字符串或 Base64 编码(可选,建议) |
4. **部署**
### 3.1.1 推荐填写方式(避免 `DS2API_CONFIG_JSON` 填错)
如果你想先完成一键部署,也可以先不填 `DS2API_CONFIG_JSON`,部署后进入 `/admin` 导入配置再在「Vercel 同步」里写回环境变量。
建议先在仓库目录复制示例配置,再按实际账号填写:
```bash
cp config.example.json config.json
# 编辑 config.json
```
不要在 Vercel 面板里手写复杂 JSON建议本地生成 Base64 后粘贴:
```bash
# 在仓库根目录执行
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
echo "$DS2API_CONFIG_JSON"
```
如果你选择在部署前就预置配置,请在 Vercel Project Settings -> Environment Variables 配置:
```text
DS2API_ADMIN_KEY=请替换为强密码
DS2API_CONFIG_JSON=上一步生成的一整行 Base64
```
可选但推荐(用于 WebUI 一键同步 Vercel 配置):
```text
VERCEL_TOKEN=你的 Vercel Token
VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx
VERCEL_TEAM_ID=team_xxxxxxxxxxxx # 个人账号可留空
```
### 3.2 可选环境变量
| 变量 | 说明 | 默认值 |
| --- | --- | --- |
| `DS2API_ACCOUNT_MAX_INFLIGHT` | 每账号并发上限 | `2` |
| `DS2API_ACCOUNT_CONCURRENCY` | 同上(兼容别名) | — |
| `DS2API_ACCOUNT_MAX_QUEUE` | 等待队列上限 | `recommended_concurrency` |
| `DS2API_ACCOUNT_QUEUE_SIZE` | 同上(兼容别名) | — |
| `DS2API_GLOBAL_MAX_INFLIGHT` | 全局并发上限 | `recommended_concurrency` |
| `DS2API_MAX_INFLIGHT` | 同上(兼容别名) | — |
| `DS2API_ENV_WRITEBACK` | 检测到 `DS2API_CONFIG_JSON` 时自动写入 `DS2API_CONFIG_PATH`,并在成功后转为文件模式(`1/true/yes/on` | 关闭 |
| `DS2API_VERCEL_INTERNAL_SECRET` | 混合流式内部鉴权 | 回退用 `DS2API_ADMIN_KEY` |
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease TTL | `900` |
| `VERCEL_TOKEN` | Vercel 同步 token | — |
| `VERCEL_PROJECT_ID` | Vercel 项目 ID | — |
| `VERCEL_TEAM_ID` | Vercel 团队 ID | — |
| `DS2API_VERCEL_PROTECTION_BYPASS` | 部署保护绕过密钥(内部 Node→Go 调用) | — |
### 3.3 Vercel 架构说明
```text
请求 ─────┐
vercel.json 路由规则
┌─────┴─────┐
│ │
▼ ▼
api/index.go api/chat-stream.js
(Go Runtime) (Node Runtime)
```
- **入口文件**`api/index.go`Serverless Go
- **流式入口**`api/chat-stream.js`Node Runtime保证实时 SSE
- **路由重写**`vercel.json`
- **构建命令**`npm ci --prefix webui && npm run build --prefix webui`(自动执行)
#### 流式处理链路
由于 Vercel Go Runtime 存在平台层响应缓冲,本项目在 Vercel 上采用"**Go prepare + Node stream**"的混合链路:
1. `api/chat-stream.js` 收到 `/v1/chat/completions` 请求
2. Node 调用 Go 内部 prepare 接口(`?__stream_prepare=1`),获取会话 ID、PoW、token 等
3. Go prepare 创建 stream lease锁定账号
4. Node 直连 DeepSeek 上游,实时流式转发 SSE 给客户端(含 OpenAI chunk 封装与 tools 防泄漏筛分)
5. 流结束后 Node 调用 Go release 接口(`?__stream_release=1`),释放账号
> 该适配**仅在 Vercel 环境生效**;本地与 Docker 仍走纯 Go 链路。
#### 非流式回退与 Tool Call 处理
- `api/chat-stream.js` 仅对非流式请求回退到 Go 入口(`?__go=1`
- 流式请求(包括带 `tools`)走 Node 路径,并执行与 Go 对齐的 tool-call 防泄漏处理
- WebUI 的"非流式测试"直接请求 `?__go=1`,避免 Node 中转造成长请求超时
#### 函数时长
`vercel.json` 已将 `api/chat-stream.js``api/index.go``maxDuration` 设为 `300`(受 Vercel 套餐上限约束)。
### 3.4 Vercel 常见报错排查
#### Go 构建失败
```text
Error: Command failed: go build -ldflags -s -w -o .../bootstrap ...
```
**原因**Vercel 项目的 Go 构建参数配置不正确(`-ldflags` 没有作为一个整体字符串传递)。
**解决**
1. 进入 Vercel Project Settings → Build and Development Settings
2. **清空**自定义 Go Build Flags / Build Command推荐
3. 若必须设置 ldflags使用 `-ldflags="-s -w"`(保证它是一个参数)
4. 确认仓库 `go.mod` 为受支持版本(当前为 `go 1.24`
5. 重新部署(建议清缓存后 Redeploy
#### Internal 包导入错误
```text
use of internal package ds2api/internal/server not allowed
```
**原因**Vercel Go 入口文件直接 `import internal/...`
**解决**:当前仓库已通过公开桥接包 `app` 解决:`api/index.go``ds2api/app``internal/server`
#### 输出目录错误
```text
No Output Directory named "public" found after the Build completed.
```
**解决**:当前仓库使用 `static` 作为输出目录(`vercel.json``"outputDirectory": "static"`)。若你在项目设置里手动改过 Output Directory请设为 `static` 或清空让仓库配置生效。
#### 部署保护拦截
如果接口返回 Vercel HTML 页面 `Authentication Required`
- **方案 A**:关闭该部署/环境的 Deployment Protection推荐用于公开 API
- **方案 B**:请求中添加 `x-vercel-protection-bypass`
- **方案 C**:设置 `VERCEL_AUTOMATION_BYPASS_SECRET`(或 `DS2API_VERCEL_PROTECTION_BYPASS`),仅影响内部 Node→Go 调用
### 3.5 仓库不提交构建产物
- `static/admin` 目录不在 Git 中
- Vercel / Docker 构建阶段自动生成 WebUI 静态文件
---
## 四、下载 Release 构建包
仓库内置 GitHub Actions 工作流:`.github/workflows/release-artifacts.yml`
- **触发条件**:仅在 Release `published` 时触发(普通 push 不会构建)
- **构建产物**:多平台二进制压缩包 + `sha256sums.txt`
- **容器镜像发布**:仅发布到 GHCR`ghcr.io/cjackhwang/ds2api`
| 平台 | 架构 | 文件格式 |
| --- | --- | --- |
| Linux | amd64, arm64 | `.tar.gz` |
| macOS | amd64, arm64 | `.tar.gz` |
| Windows | amd64 | `.zip` |
每个压缩包包含:
- `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`
### 使用步骤
```bash
# 1. 下载对应平台的压缩包
# 2. 解压
tar -xzf ds2api_<tag>_linux_amd64.tar.gz
cd ds2api_<tag>_linux_amd64
# 3. 配置
cp config.example.json config.json
# 编辑 config.json
# 4. 启动
./ds2api
```
### 维护者发布步骤
1. 在 GitHub 创建并发布 Release带 tag`vX.Y.Z`
2. 等待 Actions 工作流 `Release Artifacts` 完成
3. 在 Release 的 Assets 下载对应平台压缩包
### 拉取 GHCR 镜像(可选)
```bash
# latest
docker pull ghcr.io/cjackhwang/ds2api:latest
# 指定版本(示例)
docker pull ghcr.io/cjackhwang/ds2api:v3.0.0
```
---
## 五、反向代理Nginx
如果在 Nginx 后部署,**必须关闭缓冲**以保证 SSE 流式响应正常工作:
```nginx
location / {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
tcp_nodelay on;
}
```
如果需要 HTTPS可以在 Nginx 层配置 SSL 证书:
```nginx
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
tcp_nodelay on;
}
}
```
---
## 六、Linux systemd 服务化
### 6.1 安装
```bash
# 将编译好的二进制文件和相关文件复制到目标目录
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
```
### 6.2 创建 systemd 服务文件
```ini
# /etc/systemd/system/ds2api.service
[Unit]
Description=DS2API (Go)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/ds2api
Environment=PORT=5001
Environment=DS2API_CONFIG_PATH=/opt/ds2api/config.json
Environment=DS2API_ADMIN_KEY=your-admin-key-here
ExecStart=/opt/ds2api/ds2api
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
### 6.3 常用命令
```bash
# 加载服务配置
sudo systemctl daemon-reload
# 设置开机自启
sudo systemctl enable ds2api
# 启动服务
sudo systemctl start ds2api
# 查看状态
sudo systemctl status ds2api
# 查看日志
sudo journalctl -u ds2api -f
# 重启服务
sudo systemctl restart ds2api
# 停止服务
sudo systemctl stop ds2api
```
---
## 七、部署后检查
无论使用哪种部署方式,启动后建议依次检查:
```bash
# 1. 存活探针
curl -s http://127.0.0.1:5001/healthz
# 预期: {"status":"ok"}
# 2. 就绪探针
curl -s http://127.0.0.1:5001/readyz
# 预期: {"status":"ready"}
# 3. 模型列表
curl -s http://127.0.0.1:5001/v1/models
# 预期: {"object":"list","data":[...]}
# 4. 管理台页面(如果已构建 WebUI
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5001/admin
# 预期: 200
# 5. 测试 API 调用
curl http://127.0.0.1:5001/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-chat","messages":[{"role":"user","content":"hello"}]}'
```
---
## 八、发布前进行本地回归
建议在发布前执行完整的端到端测试集(使用真实账号):
```bash
./tests/scripts/run-live.sh
```
可自定义参数:
```bash
go run ./cmd/ds2api-tests \
--config config.json \
--admin-key admin \
--out artifacts/testsuite \
--timeout 120 \
--retries 2
```
测试集自动执行内容:
- ✅ 语法/构建/单测 preflight
- ✅ 隔离副本配置启动服务(不污染原始 `config.json`
- ✅ 真实调用场景验证OpenAI/Claude/Admin/并发/toolcall/流式)
- ✅ 全量请求与响应日志落盘(用于故障复盘)
详细测试集说明参阅 [TESTING.md](TESTING.md)。

View File

@@ -0,0 +1,82 @@
# DeepSeek SSE 流格式字段分析2026-04-03
> 日期2026-04-03UTC
>
> 样本:`tests/raw_stream_samples/guangzhou-weather-reasoner-search-20260403/upstream.stream.sse`
>
> 模型:`deepseek-reasoner-search`(搜索 + 思考)
## 1. SSE 事件层结构
原始流由标准 SSE 帧组成,常见形态:
```text
event: <type>
data: <json or text>
```
样本中主要 `event` 类型:
- `ready`:流建立后返回请求/响应消息 ID。
- `update_session`:会话时间戳更新。
- `finish`:流式阶段结束。
- (无 `event` 时)默认为 message 事件,`data:` 中承载主要增量数据。
## 2. `data` JSON 常见字段
上游增量主体多为 JSON Patch 风格对象:
- `p`path字段路径`response/fragments/-1/content`
- `o`op可选操作类型常见 `SET` / `APPEND` / `BATCH`
- `v`value字符串、布尔、对象、数组都可能
示例(语义):
- `{"p":"response/fragments/-1/content","o":"APPEND","v":"..."}`
- `{"p":"response/fragments/-16/status","v":"FINISHED"}`
- `{"p":"response/status","o":"SET","v":"FINISHED"}`
## 3. 搜索+思考场景关键路径
### 3.1 文本内容
- `response/fragments/<idx>/content`
- `response/content`
- `response/thinking_content`
- `response/fragments``APPEND` + fragment 数组)
### 3.2 搜索相关
- `response/fragments/<idx>/results`(检索结果数组)
- `response/search_status`(检索状态,建议跳过展示)
### 3.3 状态相关(重点)
- `response/status = FINISHED`**最终结束信号**(需要保留用于结束判定)
- `response/fragments/<idx>/status = FINISHED`**分片级状态**(高频,建议跳过输出)
- `response/quasi_status`:过程状态(建议跳过输出)
## 4. 泄露问题根因FINISHED 重复)
在搜索 + 思考模型中,`response/fragments/<idx>/status` 会出现大量不同 `<idx>`(例如 `-1/-2/-3/-16...`)的 `FINISHED`
若只过滤固定少量索引(例如仅 `-1/-2/-3`),其他索引的状态会当普通文本透传,导致前端出现:
- `FINISHEDFINISHEDFINISHED...`
## 5. 适配建议(已落地)
1. 跳过所有 `response/fragments/-?\d+/status`
2. 继续保留 `response/status=FINISHED` 作为真正结束判定。
3. 通过独立仿真工具持续回放全部样本,作为回归门禁:
```bash
./tests/scripts/run-raw-stream-sim.sh
```
## 6. 后续扩展建议
- 增加不同模型(`deepseek-chat-search` / 非 search / 非 thinking样本。
- 增加异常样本限流、中断、content_filter、空结果
- 为仿真报告加入字段覆盖率统计(路径频次、事件频次、终止路径命中率)。

258
docs/TESTING.md Normal file
View File

@@ -0,0 +1,258 @@
# DS2API 测试指南
语言 / Language: 中文 + English同页
## 概述 | Overview
DS2API 提供两个层级的测试:
| 层级 | 命令 | 说明 |
| --- | --- | --- |
| 单元测试Go | `./tests/scripts/run-unit-go.sh` | 不需要真实账号 |
| 单元测试Node | `./tests/scripts/run-unit-node.sh` | 不需要真实账号 |
| 单元测试(全部) | `./tests/scripts/run-unit-all.sh` | 不需要真实账号 |
| 端到端测试 | `./tests/scripts/run-live.sh` | 使用真实账号执行全链路测试 |
端到端测试集会录制完整的请求/响应日志,用于故障排查。
Node 单元测试脚本会先做 `node --check` 语法门禁,再以 `--test-concurrency=1` 串行执行测试文件,减少模块级共享状态带来的干扰。
---
## 快速开始 | Quick Start
### 单元测试 | Unit Tests
```bash
./tests/scripts/run-unit-all.sh
```
```bash
# 或按语言拆分执行
./tests/scripts/run-unit-go.sh
./tests/scripts/run-unit-node.sh
```
```bash
# 结构与流程门禁
./tests/scripts/check-refactor-line-gate.sh
./tests/scripts/check-node-split-syntax.sh
# 发布阻断:阶段 6 手工烟测签字检查(默认读取 plans/stage6-manual-smoke.md
./tests/scripts/check-stage6-manual-smoke.sh
```
### 端到端测试 | End-to-End Tests
```bash
./tests/scripts/run-live.sh
```
**默认行为**
1. **Preflight 检查**
- `go test ./... -count=1`(单元测试)
- `./tests/scripts/check-node-split-syntax.sh`Node 拆分模块语法门禁)
- `node --test tests/node/stream-tool-sieve.test.js tests/node/chat-stream.test.js tests/node/js_compat_test.js`
- `npm run build --prefix webui`WebUI 构建检查)
2. **隔离启动**:复制 `config.json` 到临时目录,启动独立服务进程
3. **场景测试**
- ✅ OpenAI 非流式 / 流式
- ✅ Claude 非流式 / 流式
- ✅ Admin API登录 / 配置 / 账号管理)
- ✅ Tool Calling
- ✅ 并发压力测试
- ✅ Search 模型
4. **结果收集**:继续执行所有用例(不中断),写入最终汇总
如果你只想跳过这些 preflight 检查,可以直接运行 `go run ./cmd/ds2api-tests --no-preflight`
---
## CLI 参数 | CLI Flags
```bash
go run ./cmd/ds2api-tests \
--config config.json \
--admin-key admin \
--out artifacts/testsuite \
--port 0 \
--timeout 120 \
--retries 2 \
--no-preflight=false \
--keep 5
```
| 参数 | 说明 | 默认值 |
| --- | --- | --- |
| `--config` | 配置文件路径 | `config.json` |
| `--admin-key` | Admin 密钥 | `DS2API_ADMIN_KEY` 环境变量,回退 `admin` |
| `--out` | 产物输出根目录 | `artifacts/testsuite` |
| `--port` | 测试服务端口(`0` = 自动分配空闲端口) | `0` |
| `--timeout` | 单个请求超时秒数 | `120` |
| `--retries` | 网络/5xx 请求重试次数 | `2` |
| `--no-preflight` | 跳过 preflight 检查 | `false` |
| `--keep` | 保留最近几次测试结果(`0` = 全部保留) | `5` |
---
## 自动清理 | Auto Cleanup
每次测试运行完成后,程序会自动扫描输出目录(`--out`),按时间排序保留最近 `--keep` 次运行的结果,超出部分自动删除。
- 默认保留 **5**
- 设置 `--keep 0` 可关闭自动清理
- 被删除的旧运行目录会打印日志提示
---
## 产物结构 | Artifact Layout
每次运行会创建一个以运行 ID 命名的目录:
```text
artifacts/testsuite/<run_id>/
├── summary.json # 机器可读报告
├── summary.md # 人类可读报告
├── server.log # 测试期间服务端日志
├── preflight.log # Preflight 命令输出
└── cases/
└── <case_id>/
├── request.json # 请求体
├── response.headers # 响应头
├── response.body # 响应体
├── stream.raw # 原始 SSE 数据(流式用例)
├── assertions.json # 断言结果
└── meta.json # 元信息(耗时、状态码等)
```
---
## Trace 关联 | Trace Binding
每个测试请求自动注入 trace 信息,便于快速定位问题:
| 位置 | 格式 |
| --- | --- |
| 请求头 | `X-Ds2-Test-Trace: <trace_id>` |
| 查询参数 | `__trace_id=<trace_id>` |
当用例失败时,`summary.md` 中会包含 trace ID。你可以快速搜索对应的服务端日志
```bash
rg "<trace_id>" artifacts/testsuite/<run_id>/server.log
```
---
## 退出码 | Exit Code
| 退出码 | 含义 |
| --- | --- |
| `0` | 所有用例通过 ✅ |
| `1` | 有用例失败 ❌ |
可将测试集作为本地发布门禁使用CI/CD 集成)。
---
## 安全提醒 | Sensitive Data Warning
⚠️ 测试集会存储**完整的原始请求/响应载荷**用于调试。
- **不要**将 artifacts 目录上传到公开仓库
- **不要**在 Issue tracker 中分享未脱敏的 artifact 文件
- 如需分享日志请先手动清除敏感信息token、密码等
---
## 常见用法 | Common Usage
### 仅跑单元测试
```bash
go test ./...
```
### 运行特定模块的单元测试
```bash
# 运行 tool calls 相关测试(推荐用于调试 tool call 解析问题)
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
# 运行单个测试用例
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/
# 运行 format 相关测试
go test -v ./internal/format/...
# 运行 adapter 相关测试
go test -v ./internal/adapter/openai/...
```
### 调试 Tool Call 问题 | Debugging Tool Call Issues
当遇到 DeepSeek 工具调用解析问题时,可以使用以下方法:
```bash
# 1. 运行 tool calls 相关的所有测试
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
# 2. 查看测试输出中的详细调试信息
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/ 2>&1
# 3. 检查具体测试用例的修复效果
# 测试用例位于 internal/util/toolcalls_test.go包含
# - TestParseToolCallsWithDeepSeekHallucination: DeepSeek 典型幻觉输出
# - TestRepairLooseJSONWithNestedObjects: 嵌套对象的方括号修复
# - TestParseToolCallsWithMixedWindowsPaths: Windows 路径处理
```
### 运行 Node.js 测试
```bash
# 运行 Node 测试
node --test tests/node/stream-tool-sieve.test.js
# 或使用脚本
./tests/scripts/run-unit-node.sh
```
### 跑端到端测试(跳过 preflight
```bash
go run ./cmd/ds2api-tests --no-preflight
```
### 运行原始流仿真(独立工具)
```bash
./tests/scripts/run-raw-stream-sim.sh
```
说明:
- 该工具会重放 `tests/raw_stream_samples` 下全部样本,按上游 SSE 顺序做 1:1 仿真解析。
- 默认校验不出现 `FINISHED` 文本泄露,并要求存在结束信号。
- 结果会写入 `artifacts/raw-stream-sim/*.json`,可供其他测试脚本或排障流程复用。
### 指定输出目录和超时
```bash
go run ./cmd/ds2api-tests \
--out /tmp/ds2api-test \
--timeout 60
```
### 在 CI 中使用
```bash
# 确保 config.json 存在且包含有效测试账号
./tests/scripts/run-live.sh
exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "Tests failed! Check artifacts for details."
exit 1
fi
```

View File

@@ -0,0 +1,72 @@
# Tool call parsing semanticsGo/Node 统一语义)
本文档描述当前代码中 `ParseToolCallsDetailed` / `parseToolCallsDetailed` 的**实际行为**,用于对齐 Go 与 Node Runtime。
## 1) 输出结构(当前实现)
- `calls`:解析得到的工具调用列表(`name` + `input`)。
- `sawToolCallSyntax`:检测到工具调用语法特征时为 `true`(例如 `tool_calls``<tool_call>``<function_call>``<invoke>``function.name:`)。
- `rejectedByPolicy`:当前实现固定为 `false`(预留字段,尚未启用 allow-list 拒绝)。
- `rejectedToolNames`:当前实现固定为空数组(预留字段)。
> 说明:`filterToolCallsDetailed` 当前仅做结构清洗,不做工具名策略拒绝。
## 2) 解析管线
1. **示例保护**:若判定为 fenced code block 示例上下文,则跳过执行型解析。
2. **候选片段构建**:从完整文本中构建候选(原文、围绕 `tool_calls` 的 JSON 片段、首尾大括号切片等)。
3. **按序尝试解析(命中即停)**
- 对“明显 JSON 工具载荷候选”(以 `{`/`[` 开头且包含 `tool_calls`/`\"function\"`)先走 JSON 解析,避免 JSON 字符串内偶发 XML 片段误命中;
- 其余候选优先 XML 解析(`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / `antml:function_call` 等);
- JSON 解析(`{"tool_calls": [...]}`、列表、单对象);
- Markup 解析;
- Text-KV 回退(如 `function.name:` + `function.arguments:`)。
4. **兜底**:候选全部失败后,再对全文做 XML / Text-KV 回退。
## 3) XML 能力边界(当前)
当前已支持输入端的“多 XML/标记风格”解析,包括但不限于:
- `<tool_call><tool_name>...</tool_name><parameters>...</parameters></tool_call>`
- `<function_call>tool</function_call><function parameter name="x">...</function parameter>`
- `<invoke name="tool"><parameter name="x">...</parameter></invoke>`
- `antml:function_call` / `antml:argument` / `antml:parameters`
- `tool_use` 家族标签
但**输出端仍统一转换为 OpenAI 兼容 JSON 事件/对象**`message.tool_calls``delta.tool_calls``response.function_call_arguments.*`)。
## 4) 关于“是否可以封装成 XML 再喂给模型”
结论:**可以做,而且当前解析器已经能兼容 XML 作为输入格式之一**,但代码里并没有 `toolcall.prefer_xml_output` 这个开关。现有可调配置只有:
- `toolcall.mode``feature_match` / `off`
- `toolcall.early_emit_confidence``high` / `low` / `off`
推荐思路仍然是“输入兼容层 + 输出按客户端协议渲染”:
1. **Prompt 约束层**:如果你要尝试 XML-first可以在系统提示词里约束模型输出规范 XML tool block例如 `<tool_calls><tool_call>...</tool_call></tool_calls>`)。
2. **解析兼容层**:继续在 parser 中同时接受 JSON / XML / ANTML / invoke / text-kv。
3. **协议归一层**:无论模型输出什么格式,统一落到内部 `ParsedToolCall`
4. **对外渲染层**根据客户端请求协议渲染OpenAI / Claude / Gemini 各自格式)。
这样可以同时获得:
- 减少模型端 JSON 转义/引号错误;
- 不破坏现有 SDK / 客户端生态;
- 逐步灰度(按模型、按租户、按请求开关)。
## 5) 落地建议(低风险迭代)
- 继续使用现有的 `toolcall.mode=feature_match``toolcall.early_emit_confidence=high` 作为默认策略。
- 如果要试 XML-first把它放在 prompt 层或上游模板层,不要假设代码里已有专门的 XML 输出开关。
- 增加观测指标:
- `toolcall_parse_source`json/xml/markup/textkv
- `toolcall_parse_success_rate`
- `toolcall_malformed_rate`
- `toolcall_repair_rate`
- 先在 `responses` 链路灰度,再扩展 `chat.completions`
## 6) 兼容性提醒
- 上游模型若输出混合文本 + XML仍可能出现“半结构化”噪声需要依赖现有 sieve 增量消费策略。
- XML 不等于安全:仍需做 tool 名、参数 schema、执行权限的服务端校验。

25
go.mod Normal file
View File

@@ -0,0 +1,25 @@
module ds2api
go 1.26.0
require (
github.com/andybalholm/brotli v1.0.6
github.com/go-chi/chi/v5 v5.2.3
github.com/google/uuid v1.6.0
github.com/refraction-networking/utls v1.8.2
github.com/tetratelabs/wazero v1.9.0
)
require (
github.com/klauspost/compress v1.17.4 // indirect
github.com/router-for-me/CLIProxyAPI/v6 v6.9.8 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

47
go.sum Normal file
View File

@@ -0,0 +1,47 @@
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/router-for-me/CLIProxyAPI/v6 v6.9.8 h1:O65R38THenp8E1IK0paQlOfop3Y6UYlfqSdLlepidSY=
github.com/router-for-me/CLIProxyAPI/v6 v6.9.8/go.mod h1:P1jsIPFXorYGuS2N/3BlZYkpRKi/z7+oR3+1tdG0u4k=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
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=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,99 @@
package account
import (
"context"
"ds2api/internal/config"
)
func (p *Pool) Acquire(target string, exclude map[string]bool) (config.Account, bool) {
p.mu.Lock()
defer p.mu.Unlock()
return p.acquireLocked(target, normalizeExclude(exclude))
}
func (p *Pool) AcquireWait(ctx context.Context, target string, exclude map[string]bool) (config.Account, bool) {
if ctx == nil {
ctx = context.Background()
}
exclude = normalizeExclude(exclude)
for {
if ctx.Err() != nil {
return config.Account{}, false
}
p.mu.Lock()
if acc, ok := p.acquireLocked(target, exclude); ok {
p.mu.Unlock()
return acc, true
}
if !p.canQueueLocked(target, exclude) {
p.mu.Unlock()
return config.Account{}, false
}
waiter := make(chan struct{})
p.waiters = append(p.waiters, waiter)
p.mu.Unlock()
select {
case <-ctx.Done():
p.mu.Lock()
p.removeWaiterLocked(waiter)
p.mu.Unlock()
return config.Account{}, false
case <-waiter:
}
}
}
func (p *Pool) acquireLocked(target string, exclude map[string]bool) (config.Account, bool) {
if target != "" {
if exclude[target] || !p.canAcquireIDLocked(target) {
return config.Account{}, false
}
acc, ok := p.store.FindAccount(target)
if !ok {
return config.Account{}, false
}
p.inUse[target]++
p.bumpQueue(target)
return acc, true
}
return p.tryAcquire(exclude)
}
func (p *Pool) tryAcquire(exclude map[string]bool) (config.Account, bool) {
for i := 0; i < len(p.queue); i++ {
id := p.queue[i]
if exclude[id] || !p.canAcquireIDLocked(id) {
continue
}
acc, ok := p.store.FindAccount(id)
if !ok {
continue
}
p.inUse[id]++
p.bumpQueue(id)
return acc, true
}
return config.Account{}, false
}
func (p *Pool) bumpQueue(accountID string) {
for i, id := range p.queue {
if id != accountID {
continue
}
p.queue = append(p.queue[:i], p.queue[i+1:]...)
p.queue = append(p.queue, accountID)
return
}
}
func normalizeExclude(exclude map[string]bool) map[string]bool {
if exclude == nil {
return map[string]bool{}
}
return exclude
}

View File

@@ -0,0 +1,132 @@
package account
import (
"sort"
"sync"
"ds2api/internal/config"
)
type Pool struct {
store *config.Store
mu sync.Mutex
queue []string
inUse map[string]int
waiters []chan struct{}
maxInflightPerAccount int
recommendedConcurrency int
maxQueueSize int
globalMaxInflight int
}
func NewPool(store *config.Store) *Pool {
maxPer := 2
if store != nil {
maxPer = store.RuntimeAccountMaxInflight()
}
p := &Pool{
store: store,
inUse: map[string]int{},
maxInflightPerAccount: maxPer,
}
p.Reset()
return p
}
func (p *Pool) Reset() {
accounts := p.store.Accounts()
sort.SliceStable(accounts, func(i, j int) bool {
iHas := accounts[i].Token != ""
jHas := accounts[j].Token != ""
if iHas == jHas {
return i < j
}
return iHas
})
ids := make([]string, 0, len(accounts))
for _, a := range accounts {
id := a.Identifier()
if id != "" {
ids = append(ids, id)
}
}
if p.store != nil {
p.maxInflightPerAccount = p.store.RuntimeAccountMaxInflight()
} else {
p.maxInflightPerAccount = maxInflightFromEnv()
}
recommended := defaultRecommendedConcurrency(len(ids), p.maxInflightPerAccount)
queueLimit := maxQueueFromEnv(recommended)
globalLimit := recommended
if p.store != nil {
queueLimit = p.store.RuntimeAccountMaxQueue(recommended)
globalLimit = p.store.RuntimeGlobalMaxInflight(recommended)
}
p.mu.Lock()
defer p.mu.Unlock()
p.drainWaitersLocked()
p.queue = ids
p.inUse = map[string]int{}
p.recommendedConcurrency = recommended
p.maxQueueSize = queueLimit
p.globalMaxInflight = globalLimit
config.Logger.Info(
"[init_account_queue] initialized",
"total", len(ids),
"max_inflight_per_account", p.maxInflightPerAccount,
"global_max_inflight", p.globalMaxInflight,
"recommended_concurrency", p.recommendedConcurrency,
"max_queue_size", p.maxQueueSize,
)
}
func (p *Pool) Release(accountID string) {
if accountID == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
count := p.inUse[accountID]
if count <= 0 {
return
}
if count == 1 {
delete(p.inUse, accountID)
p.notifyWaiterLocked()
return
}
p.inUse[accountID] = count - 1
p.notifyWaiterLocked()
}
func (p *Pool) Status() map[string]any {
p.mu.Lock()
defer p.mu.Unlock()
available := make([]string, 0, len(p.queue))
inUseAccounts := make([]string, 0, len(p.inUse))
inUseSlots := 0
for _, id := range p.queue {
if p.inUse[id] < p.maxInflightPerAccount {
available = append(available, id)
}
}
for id, count := range p.inUse {
if count > 0 {
inUseAccounts = append(inUseAccounts, id)
inUseSlots += count
}
}
sort.Strings(inUseAccounts)
return map[string]any{
"available": len(available),
"in_use": inUseSlots,
"total": len(p.store.Accounts()),
"available_accounts": available,
"in_use_accounts": inUseAccounts,
"max_inflight_per_account": p.maxInflightPerAccount,
"global_max_inflight": p.globalMaxInflight,
"recommended_concurrency": p.recommendedConcurrency,
"waiting": len(p.waiters),
"max_queue_size": p.maxQueueSize,
}
}

View File

@@ -0,0 +1,249 @@
package account
import (
"context"
"sync"
"testing"
"time"
"ds2api/internal/config"
)
// ─── Pool edge cases ─────────────────────────────────────────────────
func TestPoolEmptyNoAccounts(t *testing.T) {
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "2")
t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "")
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "")
t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "")
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[]}`)
pool := NewPool(config.LoadStore())
if _, ok := pool.Acquire("", nil); ok {
t.Fatal("expected acquire to fail with no accounts")
}
status := pool.Status()
if total, ok := status["total"].(int); !ok || total != 0 {
t.Fatalf("unexpected total: %#v", status["total"])
}
}
func TestPoolReleaseNonExistentAccount(t *testing.T) {
pool := newPoolForTest(t, "2")
pool.Release("nonexistent@example.com") // should not panic
}
func TestPoolReleaseAlreadyReleased(t *testing.T) {
pool := newPoolForTest(t, "2")
acc, ok := pool.Acquire("", nil)
if !ok {
t.Fatal("expected acquire success")
}
pool.Release(acc.Identifier())
pool.Release(acc.Identifier()) // double release should not panic
}
func TestPoolAcquireTargetNotFound(t *testing.T) {
pool := newPoolForTest(t, "2")
if _, ok := pool.Acquire("nonexistent@example.com", nil); ok {
t.Fatal("expected acquire to fail for non-existent target")
}
}
func TestPoolAcquireWithExclusionList(t *testing.T) {
pool := newPoolForTest(t, "2")
acc, ok := pool.Acquire("", map[string]bool{"acc1@example.com": true})
if !ok {
t.Fatal("expected acquire success with exclusion")
}
if acc.Identifier() != "acc2@example.com" {
t.Fatalf("expected acc2 when acc1 excluded, got %q", acc.Identifier())
}
pool.Release(acc.Identifier())
}
func TestPoolAcquireAllExcluded(t *testing.T) {
pool := newPoolForTest(t, "2")
if _, ok := pool.Acquire("", map[string]bool{
"acc1@example.com": true,
"acc2@example.com": true,
}); ok {
t.Fatal("expected acquire to fail when all accounts excluded")
}
}
func TestPoolStatusFields(t *testing.T) {
pool := newPoolForTest(t, "2")
status := pool.Status()
// Check all expected fields are present
for _, key := range []string{"total", "available", "max_inflight_per_account", "recommended_concurrency", "available_accounts", "in_use_accounts", "waiting", "max_queue_size"} {
if _, ok := status[key]; !ok {
t.Fatalf("missing status field: %s", key)
}
}
}
func TestPoolStatusAccountDetails(t *testing.T) {
pool := newPoolForTest(t, "2")
acc, _ := pool.Acquire("acc1@example.com", nil)
status := pool.Status()
inUseAccounts, ok := status["in_use_accounts"].([]string)
if !ok {
t.Fatalf("unexpected in_use_accounts type: %T", status["in_use_accounts"])
}
found := false
for _, id := range inUseAccounts {
if id == "acc1@example.com" {
found = true
break
}
}
if !found {
t.Fatalf("expected acc1 in in_use_accounts, got %v", inUseAccounts)
}
if status["in_use"] != 1 {
t.Fatalf("expected 1 in_use, got %v", status["in_use"])
}
pool.Release(acc.Identifier())
}
func TestPoolAcquireWaitContextCancelled(t *testing.T) {
pool := newSingleAccountPoolForTest(t, "1")
// Exhaust the pool
first, ok := pool.Acquire("", nil)
if !ok {
t.Fatal("expected first acquire to succeed")
}
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
var waitOK bool
go func() {
defer wg.Done()
_, waitOK = pool.AcquireWait(ctx, "", nil)
}()
// Wait until queued
waitForWaitingCount(t, pool, 1)
// Cancel context
cancel()
wg.Wait()
if waitOK {
t.Fatal("expected acquire to fail after context cancellation")
}
pool.Release(first.Identifier())
}
func TestPoolAcquireWaitTargetAccount(t *testing.T) {
pool := newPoolForTest(t, "1")
// Exhaust acc1
acc1, ok := pool.Acquire("acc1@example.com", nil)
if !ok {
t.Fatal("expected acquire acc1 success")
}
// Acquire acc2 directly (should succeed since acc2 is free)
ctx := context.Background()
acc2, ok := pool.AcquireWait(ctx, "acc2@example.com", nil)
if !ok {
t.Fatal("expected acquire acc2 success via AcquireWait")
}
if acc2.Identifier() != "acc2@example.com" {
t.Fatalf("expected acc2, got %q", acc2.Identifier())
}
pool.Release(acc1.Identifier())
pool.Release(acc2.Identifier())
}
func TestPoolMaxQueueSizeOverride(t *testing.T) {
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "")
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "5")
t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "")
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"acc1@example.com","token":"t1"}]}`)
pool := NewPool(config.LoadStore())
status := pool.Status()
if got, ok := status["max_queue_size"].(int); !ok || got != 5 {
t.Fatalf("expected max_queue_size=5, got %#v", status["max_queue_size"])
}
}
func TestPoolQueueSizeAliasEnv(t *testing.T) {
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "")
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "")
t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "7")
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"acc1@example.com","token":"t1"}]}`)
pool := NewPool(config.LoadStore())
status := pool.Status()
if got, ok := status["max_queue_size"].(int); !ok || got != 7 {
t.Fatalf("expected max_queue_size=7, got %#v", status["max_queue_size"])
}
}
func TestPoolMultipleAcquireReleaseCycles(t *testing.T) {
pool := newSingleAccountPoolForTest(t, "1")
for i := 0; i < 10; i++ {
acc, ok := pool.Acquire("", nil)
if !ok {
t.Fatalf("acquire failed at cycle %d", i)
}
pool.Release(acc.Identifier())
}
}
func TestPoolConcurrentAcquireWait(t *testing.T) {
pool := newSingleAccountPoolForTest(t, "1")
first, ok := pool.Acquire("", nil)
if !ok {
t.Fatal("expected first acquire success")
}
const waiters = 3
results := make(chan bool, waiters)
for i := 0; i < waiters; i++ {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, ok := pool.AcquireWait(ctx, "", nil)
results <- ok
}()
}
// Wait for all to be queued (only 1 can queue)
time.Sleep(50 * time.Millisecond)
// Release and allow queued requests to proceed
pool.Release(first.Identifier())
successCount := 0
timeoutCount := 0
for i := 0; i < waiters; i++ {
select {
case ok := <-results:
if ok {
successCount++
// Release for next waiter
pool.Release("acc1@example.com")
} else {
timeoutCount++
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for results")
}
}
// At least 1 should succeed; 2 may fail due to queue limit
if successCount < 1 {
t.Fatalf("expected at least 1 success, got success=%d timeout=%d", successCount, timeoutCount)
}
}

View File

@@ -0,0 +1,91 @@
package account
import (
"os"
"strconv"
"strings"
)
func (p *Pool) ApplyRuntimeLimits(maxInflightPerAccount, maxQueueSize, globalMaxInflight int) {
if maxInflightPerAccount <= 0 {
maxInflightPerAccount = 1
}
if maxQueueSize < 0 {
maxQueueSize = 0
}
if globalMaxInflight <= 0 {
globalMaxInflight = maxInflightPerAccount * len(p.store.Accounts())
if globalMaxInflight <= 0 {
globalMaxInflight = maxInflightPerAccount
}
}
p.mu.Lock()
defer p.mu.Unlock()
p.maxInflightPerAccount = maxInflightPerAccount
p.maxQueueSize = maxQueueSize
p.globalMaxInflight = globalMaxInflight
p.recommendedConcurrency = defaultRecommendedConcurrency(len(p.queue), p.maxInflightPerAccount)
p.notifyWaiterLocked()
}
func maxInflightFromEnv() int {
for _, key := range []string{"DS2API_ACCOUNT_MAX_INFLIGHT", "DS2API_ACCOUNT_CONCURRENCY"} {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
continue
}
n, err := strconv.Atoi(raw)
if err == nil && n > 0 {
return n
}
}
return 2
}
func defaultRecommendedConcurrency(accountCount, maxInflightPerAccount int) int {
if accountCount <= 0 {
return 0
}
if maxInflightPerAccount <= 0 {
maxInflightPerAccount = 2
}
return accountCount * maxInflightPerAccount
}
func maxQueueFromEnv(defaultSize int) int {
for _, key := range []string{"DS2API_ACCOUNT_MAX_QUEUE", "DS2API_ACCOUNT_QUEUE_SIZE"} {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
continue
}
n, err := strconv.Atoi(raw)
if err == nil && n >= 0 {
return n
}
}
if defaultSize < 0 {
return 0
}
return defaultSize
}
func (p *Pool) canAcquireIDLocked(accountID string) bool {
if accountID == "" {
return false
}
if p.inUse[accountID] >= p.maxInflightPerAccount {
return false
}
if p.globalMaxInflight > 0 && p.currentInUseLocked() >= p.globalMaxInflight {
return false
}
return true
}
func (p *Pool) currentInUseLocked() int {
total := 0
for _, n := range p.inUse {
total += n
}
return total
}

View File

@@ -0,0 +1,319 @@
package account
import (
"context"
"sync"
"testing"
"time"
"ds2api/internal/config"
)
func newPoolForTest(t *testing.T, maxInflight string) *Pool {
t.Helper()
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", maxInflight)
t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "")
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "")
t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "")
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[
{"email":"acc1@example.com","token":"token1"},
{"email":"acc2@example.com","token":"token2"}
]
}`)
store := config.LoadStore()
return NewPool(store)
}
func newSingleAccountPoolForTest(t *testing.T, maxInflight string) *Pool {
t.Helper()
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", maxInflight)
t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "")
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "")
t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "")
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[{"email":"acc1@example.com","token":"token1"}]
}`)
return NewPool(config.LoadStore())
}
func waitForWaitingCount(t *testing.T, pool *Pool, want int) {
t.Helper()
deadline := time.Now().Add(800 * time.Millisecond)
for time.Now().Before(deadline) {
status := pool.Status()
if got, ok := status["waiting"].(int); ok && got == want {
return
}
time.Sleep(10 * time.Millisecond)
}
status := pool.Status()
t.Fatalf("waiting count did not reach %d, current status=%v", want, status)
}
func TestPoolRoundRobinWithConcurrentSlots(t *testing.T) {
pool := newPoolForTest(t, "2")
order := make([]string, 0, 4)
for i := 0; i < 4; i++ {
acc, ok := pool.Acquire("", nil)
if !ok {
t.Fatalf("expected acquire success at step %d", i+1)
}
order = append(order, acc.Identifier())
}
want := []string{"acc1@example.com", "acc2@example.com", "acc1@example.com", "acc2@example.com"}
for i := range want {
if order[i] != want[i] {
t.Fatalf("unexpected order at %d: got %q want %q (full=%v)", i, order[i], want[i], order)
}
}
if _, ok := pool.Acquire("", nil); ok {
t.Fatalf("expected acquire to fail when all inflight slots are occupied")
}
pool.Release("acc1@example.com")
acc, ok := pool.Acquire("", nil)
if !ok || acc.Identifier() != "acc1@example.com" {
t.Fatalf("expected reacquire acc1 after releasing one slot, got ok=%v id=%q", ok, acc.Identifier())
}
}
func TestPoolTargetAccountInflightLimit(t *testing.T) {
pool := newPoolForTest(t, "2")
for i := 0; i < 2; i++ {
if _, ok := pool.Acquire("acc1@example.com", nil); !ok {
t.Fatalf("expected target acquire success at step %d", i+1)
}
}
if _, ok := pool.Acquire("acc1@example.com", nil); ok {
t.Fatalf("expected third acquire on same target to fail due to inflight limit")
}
}
func TestPoolConcurrentAcquireDistribution(t *testing.T) {
pool := newPoolForTest(t, "2")
start := make(chan struct{})
results := make(chan string, 6)
var wg sync.WaitGroup
for i := 0; i < 6; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
acc, ok := pool.Acquire("", nil)
if !ok {
results <- "FAIL"
return
}
results <- acc.Identifier()
}()
}
close(start)
wg.Wait()
close(results)
success := 0
fail := 0
perAccount := map[string]int{}
for id := range results {
if id == "FAIL" {
fail++
continue
}
success++
perAccount[id]++
}
if success != 4 || fail != 2 {
t.Fatalf("unexpected concurrent acquire result: success=%d fail=%d perAccount=%v", success, fail, perAccount)
}
for id, n := range perAccount {
if n > 2 {
t.Fatalf("account %s exceeded inflight limit: %d", id, n)
}
}
}
func TestPoolStatusRecommendedConcurrencyDefault(t *testing.T) {
pool := newPoolForTest(t, "")
status := pool.Status()
if got, ok := status["max_inflight_per_account"].(int); !ok || got != 2 {
t.Fatalf("unexpected max_inflight_per_account: %#v", status["max_inflight_per_account"])
}
if got, ok := status["recommended_concurrency"].(int); !ok || got != 4 {
t.Fatalf("unexpected recommended_concurrency: %#v", status["recommended_concurrency"])
}
if got, ok := status["max_queue_size"].(int); !ok || got != 4 {
t.Fatalf("unexpected max_queue_size: %#v", status["max_queue_size"])
}
}
func TestPoolStatusRecommendedConcurrencyRespectsOverride(t *testing.T) {
pool := newPoolForTest(t, "3")
status := pool.Status()
if got, ok := status["max_inflight_per_account"].(int); !ok || got != 3 {
t.Fatalf("unexpected max_inflight_per_account: %#v", status["max_inflight_per_account"])
}
if got, ok := status["recommended_concurrency"].(int); !ok || got != 6 {
t.Fatalf("unexpected recommended_concurrency: %#v", status["recommended_concurrency"])
}
if got, ok := status["max_queue_size"].(int); !ok || got != 6 {
t.Fatalf("unexpected max_queue_size: %#v", status["max_queue_size"])
}
}
func TestPoolAccountConcurrencyAliasEnv(t *testing.T) {
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "")
t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "4")
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[
{"email":"acc1@example.com","token":"token1"},
{"email":"acc2@example.com","token":"token2"}
]
}`)
pool := NewPool(config.LoadStore())
status := pool.Status()
if got, ok := status["max_inflight_per_account"].(int); !ok || got != 4 {
t.Fatalf("unexpected max_inflight_per_account: %#v", status["max_inflight_per_account"])
}
if got, ok := status["recommended_concurrency"].(int); !ok || got != 8 {
t.Fatalf("unexpected recommended_concurrency: %#v", status["recommended_concurrency"])
}
if got, ok := status["max_queue_size"].(int); !ok || got != 8 {
t.Fatalf("unexpected max_queue_size: %#v", status["max_queue_size"])
}
}
func TestPoolDropsLegacyTokenOnlyAccountOnLoad(t *testing.T) {
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[{"token":"token-only-account"}]
}`)
pool := NewPool(config.LoadStore())
status := pool.Status()
if got, ok := status["total"].(int); !ok || got != 0 {
t.Fatalf("unexpected total in pool status: %#v", status["total"])
}
if got, ok := status["available"].(int); !ok || got != 0 {
t.Fatalf("unexpected available in pool status: %#v", status["available"])
}
if _, ok := pool.Acquire("", nil); ok {
t.Fatalf("expected acquire to fail for token-only account")
}
}
func TestPoolAcquireRotatesIntoTokenlessAccounts(t *testing.T) {
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "")
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "")
t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "")
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[
{"email":"acc1@example.com","token":"token1"},
{"email":"acc2@example.com","token":""},
{"email":"acc3@example.com","token":""}
]
}`)
pool := NewPool(config.LoadStore())
for i, want := range []string{"acc1@example.com", "acc2@example.com", "acc3@example.com"} {
acc, ok := pool.Acquire("", nil)
if !ok {
t.Fatalf("expected acquire success at step %d", i+1)
}
if got := acc.Identifier(); got != want {
t.Fatalf("unexpected account at step %d: got %q want %q", i+1, got, want)
}
pool.Release(acc.Identifier())
}
}
func TestPoolAcquireWaitQueuesAndSucceedsAfterRelease(t *testing.T) {
pool := newSingleAccountPoolForTest(t, "1")
first, ok := pool.Acquire("", nil)
if !ok {
t.Fatal("expected first acquire to succeed")
}
type result struct {
id string
ok bool
}
resCh := make(chan result, 1)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
go func() {
acc, ok := pool.AcquireWait(ctx, "", nil)
resCh <- result{id: acc.Identifier(), ok: ok}
}()
waitForWaitingCount(t, pool, 1)
pool.Release(first.Identifier())
select {
case res := <-resCh:
if !res.ok {
t.Fatal("expected queued acquire to succeed after release")
}
if res.id != "acc1@example.com" {
t.Fatalf("unexpected account id from queued acquire: %q", res.id)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for queued acquire result")
}
}
func TestPoolAcquireWaitQueueLimitReturnsFalse(t *testing.T) {
pool := newSingleAccountPoolForTest(t, "1")
first, ok := pool.Acquire("", nil)
if !ok {
t.Fatal("expected first acquire to succeed")
}
type result struct {
id string
ok bool
}
firstWaiter := make(chan result, 1)
ctx1, cancel1 := context.WithTimeout(context.Background(), 1200*time.Millisecond)
defer cancel1()
go func() {
acc, ok := pool.AcquireWait(ctx1, "", nil)
firstWaiter <- result{id: acc.Identifier(), ok: ok}
}()
waitForWaitingCount(t, pool, 1)
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel2()
start := time.Now()
if _, ok := pool.AcquireWait(ctx2, "", nil); ok {
t.Fatal("expected second queued acquire to fail when queue is full")
}
if time.Since(start) > 120*time.Millisecond {
t.Fatalf("queue-full acquire should fail fast, took %s", time.Since(start))
}
pool.Release(first.Identifier())
select {
case res := <-firstWaiter:
if !res.ok {
t.Fatal("expected first queued acquire to succeed after release")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for first queued acquire")
}
}

View File

@@ -0,0 +1,43 @@
package account
func (p *Pool) canQueueLocked(target string, exclude map[string]bool) bool {
if target != "" {
if exclude[target] {
return false
}
if _, ok := p.store.FindAccount(target); !ok {
return false
}
}
if p.maxQueueSize <= 0 {
return false
}
return len(p.waiters) < p.maxQueueSize
}
func (p *Pool) notifyWaiterLocked() {
if len(p.waiters) == 0 {
return
}
waiter := p.waiters[0]
p.waiters = p.waiters[1:]
close(waiter)
}
func (p *Pool) removeWaiterLocked(waiter chan struct{}) bool {
for i, w := range p.waiters {
if w != waiter {
continue
}
p.waiters = append(p.waiters[:i], p.waiters[i+1:]...)
return true
}
return false
}
func (p *Pool) drainWaitersLocked() {
for _, waiter := range p.waiters {
close(waiter)
}
p.waiters = nil
}

View File

@@ -0,0 +1,11 @@
package claude
import (
"ds2api/internal/claudeconv"
)
const defaultClaudeModel = "claude-sonnet-4-5"
func convertClaudeToDeepSeek(claudeReq map[string]any, store ConfigReader) map[string]any {
return claudeconv.ConvertClaudeToDeepSeek(claudeReq, store, defaultClaudeModel)
}

View File

@@ -0,0 +1,33 @@
package claude
import (
"context"
"net/http"
"ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/deepseek"
)
type AuthResolver interface {
Determine(req *http.Request) (*auth.RequestAuth, error)
Release(a *auth.RequestAuth)
}
type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
}
type ConfigReader interface {
ClaudeMapping() map[string]string
}
type OpenAIChatRunner interface {
ChatCompletions(w http.ResponseWriter, r *http.Request)
}
var _ AuthResolver = (*auth.Resolver)(nil)
var _ DeepSeekCaller = (*deepseek.Client)(nil)
var _ ConfigReader = (*config.Store)(nil)

View File

@@ -0,0 +1,33 @@
package claude
import "testing"
type mockClaudeConfig struct {
m map[string]string
}
func (m mockClaudeConfig) ClaudeMapping() map[string]string { return m.m }
func TestNormalizeClaudeRequestUsesConfigInterfaceMapping(t *testing.T) {
req := map[string]any{
"model": "claude-opus-4-6",
"messages": []any{
map[string]any{"role": "user", "content": "hello"},
},
}
out, err := normalizeClaudeRequest(mockClaudeConfig{
m: map[string]string{
"fast": "deepseek-chat",
"slow": "deepseek-reasoner-search",
},
}, req)
if err != nil {
t.Fatalf("normalizeClaudeRequest error: %v", err)
}
if out.Standard.ResolvedModel != "deepseek-reasoner-search" {
t.Fatalf("resolved model mismatch: got=%q", out.Standard.ResolvedModel)
}
if !out.Standard.Thinking || !out.Standard.Search {
t.Fatalf("unexpected flags: thinking=%v search=%v", out.Standard.Thinking, out.Standard.Search)
}
}

View File

@@ -0,0 +1,34 @@
package claude
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestWriteClaudeErrorIncludesUnifiedFields(t *testing.T) {
rec := httptest.NewRecorder()
writeClaudeError(rec, http.StatusUnauthorized, "bad token")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
errObj, _ := body["error"].(map[string]any)
if errObj["message"] != "bad token" {
t.Fatalf("unexpected message: %v", errObj["message"])
}
if errObj["type"] != "invalid_request_error" {
t.Fatalf("unexpected type: %v", errObj["type"])
}
if errObj["code"] != "authentication_failed" {
t.Fatalf("unexpected code: %v", errObj["code"])
}
if _, ok := errObj["param"]; !ok {
t.Fatal("expected param field")
}
}

View File

@@ -0,0 +1,25 @@
package claude
import "net/http"
func writeClaudeError(w http.ResponseWriter, status int, message string) {
code := "invalid_request"
switch status {
case http.StatusUnauthorized:
code = "authentication_failed"
case http.StatusTooManyRequests:
code = "rate_limit_exceeded"
case http.StatusNotFound:
code = "not_found"
case http.StatusInternalServerError:
code = "internal_error"
}
writeJSON(w, status, map[string]any{
"error": map[string]any{
"type": "invalid_request_error",
"message": message,
"code": code,
"param": nil,
},
})
}

View File

@@ -0,0 +1,97 @@
package claude
import (
"fmt"
"strings"
)
func hasSystemMessage(messages []any) bool {
for _, m := range messages {
msg, ok := m.(map[string]any)
if ok && msg["role"] == "system" {
return true
}
}
return false
}
func extractClaudeToolNames(tools []any) []string {
out := make([]string, 0, len(tools))
for _, t := range tools {
m, ok := t.(map[string]any)
if !ok {
continue
}
name, _, _ := extractClaudeToolMeta(m)
if name != "" {
out = append(out, name)
}
}
return out
}
func extractClaudeToolMeta(m map[string]any) (string, string, any) {
name, _ := m["name"].(string)
desc, _ := m["description"].(string)
schemaObj := m["input_schema"]
if schemaObj == nil {
schemaObj = m["parameters"]
}
if fn, ok := m["function"].(map[string]any); ok {
if strings.TrimSpace(name) == "" {
name, _ = fn["name"].(string)
}
if strings.TrimSpace(desc) == "" {
desc, _ = fn["description"].(string)
}
if schemaObj == nil {
if v, ok := fn["input_schema"]; ok {
schemaObj = v
}
}
if schemaObj == nil {
if v, ok := fn["parameters"]; ok {
schemaObj = v
}
}
}
return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj
}
func toMessageMaps(v any) []map[string]any {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(arr))
for _, item := range arr {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
func extractMessageContent(v any) string {
switch x := v.(type) {
case string:
return x
case []any:
parts := make([]string, 0, len(x))
for _, it := range x {
parts = append(parts, fmt.Sprintf("%v", it))
}
return strings.Join(parts, "\n")
default:
return fmt.Sprintf("%v", x)
}
}
func cloneMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}

View File

@@ -0,0 +1,175 @@
package claude
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"ds2api/internal/config"
streamengine "ds2api/internal/stream"
"ds2api/internal/translatorcliproxy"
"ds2api/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
)
func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(r.Header.Get("anthropic-version")) == "" {
r.Header.Set("anthropic-version", "2023-06-01")
}
if h.OpenAI == nil {
writeClaudeError(w, http.StatusInternalServerError, "OpenAI proxy backend unavailable.")
return
}
if h.proxyViaOpenAI(w, r, h.Store) {
return
}
writeClaudeError(w, http.StatusBadGateway, "Failed to proxy Claude request.")
}
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store ConfigReader) bool {
raw, err := io.ReadAll(r.Body)
if err != nil {
writeClaudeError(w, http.StatusBadRequest, "invalid body")
return true
}
var req map[string]any
if err := json.Unmarshal(raw, &req); err != nil {
writeClaudeError(w, http.StatusBadRequest, "invalid json")
return true
}
model, _ := req["model"].(string)
stream := util.ToBool(req["stream"])
// Preserve claude_mapping (fast/slow/opus routing) while proxying via OpenAI.
translateModel := model
if store != nil {
if norm, normErr := normalizeClaudeRequest(store, cloneMap(req)); normErr == nil && strings.TrimSpace(norm.Standard.ResolvedModel) != "" {
translateModel = strings.TrimSpace(norm.Standard.ResolvedModel)
}
}
translatedReq := translatorcliproxy.ToOpenAI(sdktranslator.FormatClaude, translateModel, raw, stream)
isVercelPrepare := strings.TrimSpace(r.URL.Query().Get("__stream_prepare")) == "1"
isVercelRelease := strings.TrimSpace(r.URL.Query().Get("__stream_release")) == "1"
if isVercelRelease {
proxyReq := r.Clone(r.Context())
proxyReq.URL.Path = "/v1/chat/completions"
proxyReq.Body = io.NopCloser(bytes.NewReader(raw))
proxyReq.ContentLength = int64(len(raw))
rec := httptest.NewRecorder()
h.OpenAI.ChatCompletions(rec, proxyReq)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(res.StatusCode)
_, _ = w.Write(body)
return true
}
proxyReq := r.Clone(r.Context())
proxyReq.URL.Path = "/v1/chat/completions"
proxyReq.Body = io.NopCloser(bytes.NewReader(translatedReq))
proxyReq.ContentLength = int64(len(translatedReq))
if stream && !isVercelPrepare {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
streamWriter := translatorcliproxy.NewOpenAIStreamTranslatorWriter(w, sdktranslator.FormatClaude, model, raw, translatedReq)
h.OpenAI.ChatCompletions(streamWriter, proxyReq)
return true
}
rec := httptest.NewRecorder()
h.OpenAI.ChatCompletions(rec, proxyReq)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode >= 300 {
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(res.StatusCode)
_, _ = w.Write(body)
return true
}
if isVercelPrepare {
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(res.StatusCode)
_, _ = w.Write(body)
return true
}
converted := translatorcliproxy.FromOpenAINonStream(sdktranslator.FormatClaude, model, raw, translatedReq, body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(converted)
return true
}
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()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeClaudeError(w, http.StatusInternalServerError, string(body))
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
rc := http.NewResponseController(w)
_, canFlush := w.(http.Flusher)
if !canFlush {
config.Logger.Warn("[claude_stream] response writer does not support flush; streaming may be buffered")
}
streamRuntime := newClaudeStreamRuntime(
w,
rc,
canFlush,
model,
messages,
thinkingEnabled,
searchEnabled,
toolNames,
)
streamRuntime.sendMessageStart()
initialType := "text"
if thinkingEnabled {
initialType = "thinking"
}
streamengine.ConsumeSSE(streamengine.ConsumeConfig{
Context: r.Context(),
Body: resp.Body,
ThinkingEnabled: thinkingEnabled,
InitialType: initialType,
KeepAliveInterval: claudeStreamPingInterval,
IdleTimeout: claudeStreamIdleTimeout,
MaxKeepAliveNoInput: claudeStreamMaxKeepaliveCnt,
}, streamengine.ConsumeHooks{
OnKeepAlive: func() {
streamRuntime.sendPing()
},
OnParsed: streamRuntime.onParsed,
OnFinalize: streamRuntime.onFinalize,
})
}

View File

@@ -0,0 +1,42 @@
package claude
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"ds2api/internal/config"
"ds2api/internal/deepseek"
"ds2api/internal/util"
)
// writeJSON is a package-internal alias to avoid mass-renaming all call-sites.
var writeJSON = util.WriteJSON
type Handler struct {
Store ConfigReader
Auth AuthResolver
DS DeepSeekCaller
OpenAI OpenAIChatRunner
}
var (
claudeStreamPingInterval = time.Duration(deepseek.KeepAliveTimeout) * time.Second
claudeStreamIdleTimeout = time.Duration(deepseek.StreamIdleTimeout) * time.Second
claudeStreamMaxKeepaliveCnt = deepseek.MaxKeepaliveCount
)
func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/anthropic/v1/models", h.ListModels)
r.Post("/anthropic/v1/messages", h.Messages)
r.Post("/anthropic/v1/messages/count_tokens", h.CountTokens)
r.Post("/v1/messages", h.Messages)
r.Post("/messages", h.Messages)
r.Post("/v1/messages/count_tokens", h.CountTokens)
r.Post("/messages/count_tokens", h.CountTokens)
}
func (h *Handler) ListModels(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, config.ClaudeModelsResponse())
}

View File

@@ -0,0 +1,436 @@
package claude
import (
"ds2api/internal/sse"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type claudeFrame struct {
Event string
Payload map[string]any
}
func makeClaudeSSEHTTPResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") {
body += "\n"
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func parseClaudeFrames(t *testing.T, body string) []claudeFrame {
t.Helper()
chunks := strings.Split(body, "\n\n")
frames := make([]claudeFrame, 0, len(chunks))
for _, chunk := range chunks {
chunk = strings.TrimSpace(chunk)
if chunk == "" {
continue
}
lines := strings.Split(chunk, "\n")
eventName := ""
dataPayload := ""
for _, line := range lines {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "event:"):
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
dataPayload = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
if eventName == "" || dataPayload == "" {
continue
}
var payload map[string]any
if err := json.Unmarshal([]byte(dataPayload), &payload); err != nil {
t.Fatalf("decode frame failed: %v, payload=%s", err, dataPayload)
}
frames = append(frames, claudeFrame{Event: eventName, Payload: payload})
}
return frames
}
func findClaudeFrames(frames []claudeFrame, event string) []claudeFrame {
out := make([]claudeFrame, 0)
for _, f := range frames {
if f.Event == event {
out = append(out, f)
}
}
return out
}
func TestHandleClaudeStreamRealtimeTextIncrementsWithEventHeaders(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/content","v":"Hel"}`,
`data: {"p":"response/content","v":"lo"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil)
body := rec.Body.String()
if !strings.Contains(body, "event: message_start") {
t.Fatalf("missing event header: message_start, body=%s", body)
}
if !strings.Contains(body, "event: content_block_delta") {
t.Fatalf("missing event header: content_block_delta, body=%s", body)
}
if !strings.Contains(body, "event: message_stop") {
t.Fatalf("missing event header: message_stop, body=%s", body)
}
frames := parseClaudeFrames(t, body)
deltas := findClaudeFrames(frames, "content_block_delta")
if len(deltas) < 2 {
t.Fatalf("expected at least 2 text deltas, got=%d body=%s", len(deltas), body)
}
combined := strings.Builder{}
for _, f := range deltas {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["type"] == "text_delta" {
combined.WriteString(asString(delta["text"]))
}
}
if combined.String() != "Hello" {
t.Fatalf("unexpected combined text: %q body=%s", combined.String(), body)
}
}
func TestHandleClaudeStreamRealtimeThinkingDelta(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"思"}`,
`data: {"p":"response/thinking_content","v":"考"}`,
`data: {"p":"response/content","v":"ok"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, true, false, nil)
frames := parseClaudeFrames(t, rec.Body.String())
foundThinkingDelta := false
for _, f := range findClaudeFrames(frames, "content_block_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["type"] == "thinking_delta" {
foundThinkingDelta = true
break
}
}
if !foundThinkingDelta {
t.Fatalf("expected thinking_delta event, body=%s", rec.Body.String())
}
}
func TestHandleClaudeStreamRealtimeToolSafety(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
`data: {"p":"response/content","v":",\"input\":{\"q\":\"go\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"search"})
frames := parseClaudeFrames(t, rec.Body.String())
for _, f := range findClaudeFrames(frames, "content_block_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["type"] == "text_delta" && strings.Contains(asString(delta["text"]), `"tool_calls"`) {
t.Fatalf("raw tool_calls JSON leaked in text delta: body=%s", rec.Body.String())
}
}
foundToolUse := false
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" {
foundToolUse = true
break
}
}
if !foundToolUse {
t.Fatalf("expected tool_use block in stream, body=%s", rec.Body.String())
}
foundToolUseStop := false
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "tool_use" {
foundToolUseStop = true
break
}
}
if !foundToolUseStop {
t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String())
}
}
func TestHandleClaudeStreamRealtimeToolDetectionFromThinkingFallback(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
`data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"})
frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" && contentBlock["name"] == "search" {
foundToolUse = true
break
}
}
if !foundToolUse {
t.Fatalf("expected tool_use block from thinking fallback, body=%s", rec.Body.String())
}
}
func TestHandleClaudeStreamRealtimeSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
`data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`,
`data: {"p":"response/content","v":"normal answer"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"})
frames := parseClaudeFrames(t, rec.Body.String())
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" {
t.Fatalf("unexpected tool_use block when final text exists, body=%s", rec.Body.String())
}
}
foundEndTurn := false
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "end_turn" {
foundEndTurn = true
break
}
}
if !foundEndTurn {
t.Fatalf("expected stop_reason=end_turn, body=%s", rec.Body.String())
}
}
func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"error":{"message":"boom"}}`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil)
frames := parseClaudeFrames(t, rec.Body.String())
errFrames := findClaudeFrames(frames, "error")
if len(errFrames) == 0 {
t.Fatalf("expected error event frame, body=%s", rec.Body.String())
}
if errFrames[0].Payload["type"] != "error" {
t.Fatalf("expected error payload type, body=%s", rec.Body.String())
}
}
func TestHandleClaudeStreamRealtimePingEvent(t *testing.T) {
h := &Handler{}
oldPing := claudeStreamPingInterval
oldIdle := claudeStreamIdleTimeout
oldKeepalive := claudeStreamMaxKeepaliveCnt
claudeStreamPingInterval = 10 * time.Millisecond
claudeStreamIdleTimeout = 300 * time.Millisecond
claudeStreamMaxKeepaliveCnt = 50
defer func() {
claudeStreamPingInterval = oldPing
claudeStreamIdleTimeout = oldIdle
claudeStreamMaxKeepaliveCnt = oldKeepalive
}()
pr, pw := io.Pipe()
resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: pr}
go func() {
time.Sleep(40 * time.Millisecond)
_, _ = io.WriteString(pw, "data: {\"p\":\"response/content\",\"v\":\"hi\"}\n")
_, _ = io.WriteString(pw, "data: [DONE]\n")
_ = pw.Close()
}()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil)
frames := parseClaudeFrames(t, rec.Body.String())
if len(findClaudeFrames(frames, "ping")) == 0 {
t.Fatalf("expected ping event in stream, body=%s", rec.Body.String())
}
}
func TestCollectDeepSeekRegression(t *testing.T) {
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"想"}`,
`data: {"p":"response/content","v":"答"}`,
`data: [DONE]`,
)
result := sse.CollectStream(resp, true, true)
if result.Thinking != "想" {
t.Fatalf("unexpected thinking: %q", result.Thinking)
}
if result.Text != "答" {
t.Fatalf("unexpected text: %q", result.Text)
}
}
func asString(v any) string {
s, _ := v.(string)
return s
}
func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.T) {
tests := []struct {
name string
payload string
}{
{name: "xml_tool_call", payload: `<tool_call><tool_name>Bash</tool_name><parameters><command>pwd</command></parameters></tool_call>`},
{name: "xml_json_tool_call", payload: `<tool_call>{"tool":"Bash","params":{"command":"pwd"}}</tool_call>`},
{name: "nested_tool_tag_style", payload: `<tool_call><tool name="Bash"><command>pwd</command></tool></tool_call>`},
{name: "function_tag_style", payload: `<function_call>Bash</function_call><function parameter name="command">pwd</function parameter>`},
{name: "antml_argument_style", payload: `<antml:function_calls><antml:function_call id="1" name="Bash"><antml:argument name="command">pwd</antml:argument></antml:function_call></antml:function_calls>`},
{name: "antml_function_attr_parameters", payload: `<antml:function_calls><antml:function_call id="1" function="Bash"><antml:parameters>{"command":"pwd"}</antml:parameters></antml:function_call></antml:function_calls>`},
{name: "invoke_parameter_style", payload: `<function_calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></function_calls>`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/content","v":"`+strings.ReplaceAll(tc.payload, `"`, `\"`)+`"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"Bash"})
frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" {
foundToolUse = true
break
}
}
if !foundToolUse {
t.Fatalf("expected tool_use block for format %s, body=%s", tc.name, rec.Body.String())
}
})
}
}
func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T) {
h := &Handler{}
payload := "I'll call a tool now.\\n<tool_use><tool_name>write_file</tool_name><parameters>{\\\"path\\\":\\\"/tmp/a.txt\\\",\\\"content\\\":\\\"abc\\\"}</parameters></tool_use>"
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/content","v":"`+payload+`"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"write_file"})
frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" && contentBlock["name"] == "write_file" {
foundToolUse = true
break
}
}
if !foundToolUse {
t.Fatalf("expected tool_use block with leading prose payload, body=%s", rec.Body.String())
}
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "tool_use" {
return
}
}
t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String())
}
func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"Here is an example:\\n```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"Bash\\\",\\\"input\\\":{\\\"command\\\":\\\"pwd\\\"}}]}\"}",
"data: {\"p\":\"response/content\",\"v\":\"\\n```\\nDo not execute it.\"}",
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"})
frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" {
foundToolUse = true
break
}
}
if foundToolUse {
t.Fatalf("expected no tool_use for fenced example, body=%s", rec.Body.String())
}
foundToolStop := false
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "tool_use" {
foundToolStop = true
break
}
}
if foundToolStop {
t.Fatalf("expected stop_reason to remain content-only, body=%s", rec.Body.String())
}
}
// Backward-compatible alias for historical test name used in CI logs.
func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.T) {
TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t)
}

View File

@@ -0,0 +1,51 @@
package claude
import (
"encoding/json"
"net/http"
"ds2api/internal/util"
)
func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) {
a, err := h.Auth.Determine(r)
if err != nil {
writeClaudeError(w, http.StatusUnauthorized, err.Error())
return
}
defer h.Auth.Release(a)
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeClaudeError(w, http.StatusBadRequest, "invalid json")
return
}
model, _ := req["model"].(string)
messages, _ := req["messages"].([]any)
if model == "" || len(messages) == 0 {
writeClaudeError(w, http.StatusBadRequest, "Request must include 'model' and 'messages'.")
return
}
inputTokens := 0
if sys, ok := req["system"].(string); ok {
inputTokens += util.EstimateTokens(sys)
}
for _, item := range messages {
msg, ok := item.(map[string]any)
if !ok {
continue
}
inputTokens += 2
inputTokens += util.EstimateTokens(extractMessageContent(msg["content"]))
}
if tools, ok := req["tools"].([]any); ok {
for _, t := range tools {
b, _ := json.Marshal(t)
inputTokens += util.EstimateTokens(string(b))
}
}
if inputTokens < 1 {
inputTokens = 1
}
writeJSON(w, http.StatusOK, map[string]any{"input_tokens": inputTokens})
}

View File

@@ -0,0 +1,560 @@
package claude
import (
"strings"
"testing"
)
// ─── normalizeClaudeMessages ─────────────────────────────────────────
func TestNormalizeClaudeMessagesSimpleString(t *testing.T) {
msgs := []any{
map[string]any{"role": "user", "content": "Hello"},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected 1 message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["content"] != "Hello" {
t.Fatalf("expected 'Hello', got %v", m["content"])
}
}
func TestNormalizeClaudeMessagesArrayContent(t *testing.T) {
msgs := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "text", "text": "line1"},
map[string]any{"type": "text", "text": "line2"},
},
},
}
got := normalizeClaudeMessages(msgs)
m := got[0].(map[string]any)
if m["content"] != "line1\nline2" {
t.Fatalf("expected joined text, got %q", m["content"])
}
}
func TestNormalizeClaudeMessagesToolResult(t *testing.T) {
msgs := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "tool_result", "content": "tool output"},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "tool" {
t.Fatalf("expected tool role preserved, got %#v", m["role"])
}
content, _ := m["content"].(string)
if content != "tool output" {
t.Fatalf("expected raw tool output content preserved, got %q", content)
}
}
func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) {
msgs := []any{
map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "tool_use",
"id": "call_1",
"name": "search_web",
"input": map[string]any{"query": "latest"},
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized tool-call message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "assistant" {
t.Fatalf("expected assistant role, got %#v", m["role"])
}
tc, _ := m["tool_calls"].([]any)
if len(tc) != 1 {
t.Fatalf("expected one tool call, got %#v", m["tool_calls"])
}
call, _ := tc[0].(map[string]any)
if call["id"] != "call_1" {
t.Fatalf("expected call id preserved, got %#v", call)
}
content, _ := m["content"].(string)
if !containsStr(content, "<tool_calls>") || !containsStr(content, "<tool_name>search_web</tool_name>") {
t.Fatalf("expected assistant content to include XML tool call history, got %q", content)
}
if !containsStr(content, `<parameters>{"query":"latest"}</parameters>`) {
t.Fatalf("expected assistant content to include serialized parameters, got %q", content)
}
}
func TestNormalizeClaudeMessagesDoesNotPromoteUserToolUse(t *testing.T) {
msgs := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_use",
"id": "call_unsafe",
"name": "dangerous_tool",
"input": map[string]any{"value": "x"},
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "user" {
t.Fatalf("expected user role preserved, got %#v", m["role"])
}
if _, ok := m["tool_calls"]; ok {
t.Fatalf("expected no tool_calls promotion for user message, got %#v", m["tool_calls"])
}
content, _ := m["content"].(string)
if !containsStr(content, `"type":"tool_use"`) || !containsStr(content, "dangerous_tool") {
t.Fatalf("expected raw tool_use block preserved in user content, got %q", content)
}
}
func TestNormalizeClaudeMessagesSkipsNonMap(t *testing.T) {
msgs := []any{"not a map", 42}
got := normalizeClaudeMessages(msgs)
if len(got) != 0 {
t.Fatalf("expected 0 messages for non-map items, got %d", len(got))
}
}
func TestNormalizeClaudeMessagesEmpty(t *testing.T) {
got := normalizeClaudeMessages(nil)
if len(got) != 0 {
t.Fatalf("expected 0, got %d", len(got))
}
}
func TestNormalizeClaudeMessagesPreservesRole(t *testing.T) {
msgs := []any{
map[string]any{"role": "assistant", "content": "response"},
}
got := normalizeClaudeMessages(msgs)
m := got[0].(map[string]any)
if m["role"] != "assistant" {
t.Fatalf("expected 'assistant', got %q", m["role"])
}
}
func TestNormalizeClaudeMessagesMixedContentBlocks(t *testing.T) {
msgs := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "text", "text": "Hello"},
map[string]any{"type": "image", "source": map[string]any{"type": "base64", "data": strings.Repeat("A", 2048)}},
map[string]any{"type": "text", "text": "World"},
},
},
}
got := normalizeClaudeMessages(msgs)
m := got[0].(map[string]any)
content, _ := m["content"].(string)
if !containsStr(content, "Hello") || !containsStr(content, "World") || !containsStr(content, `"type":"image"`) {
t.Fatalf("expected text plus non-text block marker preserved, got %q", content)
}
if !containsStr(content, omittedBinaryMarker) {
t.Fatalf("expected binary payload omitted marker, got %q", content)
}
if containsStr(content, strings.Repeat("A", 100)) {
t.Fatalf("expected raw base64 payload not to be included, got %q", content)
}
}
func TestNormalizeClaudeMessagesToolResultNonTextPayloadStringified(t *testing.T) {
msgs := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "call_image_1",
"name": "vision_tool",
"content": []any{
map[string]any{"type": "text", "text": "image analysis"},
map[string]any{
"type": "image",
"source": map[string]any{"type": "base64", "media_type": "image/png", "data": strings.Repeat("B", 2048)},
},
},
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "tool" {
t.Fatalf("expected tool role, got %#v", m["role"])
}
content, _ := m["content"].(string)
if !containsStr(content, `"type":"tool_result"`) || !containsStr(content, `"type":"image"`) {
t.Fatalf("expected non-text tool_result payload to be JSON stringified, got %q", content)
}
if !containsStr(content, omittedBinaryMarker) {
t.Fatalf("expected binary data to be sanitized with omitted marker, got %q", content)
}
if containsStr(content, strings.Repeat("B", 100)) {
t.Fatalf("expected raw base64 payload not to be included, got %q", content)
}
}
func TestNormalizeClaudeMessagesBackfillsToolResultCallIDByName(t *testing.T) {
msgs := []any{
map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "tool_use",
"name": "search_web",
"input": map[string]any{"query": "latest"},
},
},
},
map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_result",
"name": "search_web",
"content": "ok",
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 2 {
t.Fatalf("expected 2 messages, got %#v", got)
}
assistant, _ := got[0].(map[string]any)
tc, _ := assistant["tool_calls"].([]any)
call, _ := tc[0].(map[string]any)
callID, _ := call["id"].(string)
if !strings.HasPrefix(callID, "call_claude_") {
t.Fatalf("expected generated call id, got %#v", call)
}
toolMsg, _ := got[1].(map[string]any)
if toolMsg["tool_call_id"] != callID {
t.Fatalf("expected tool_result to reuse generated id, got %#v", toolMsg)
}
}
// ─── buildClaudeToolPrompt ───────────────────────────────────────────
func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
tools := []any{
map[string]any{
"name": "search",
"description": "Search the web",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string"},
},
},
},
}
prompt := buildClaudeToolPrompt(tools)
if prompt == "" {
t.Fatal("expected non-empty prompt")
}
// Should contain tool name and description
if !containsStr(prompt, "search") {
t.Fatalf("expected 'search' in prompt")
}
if !containsStr(prompt, "Search the web") {
t.Fatalf("expected description in prompt")
}
if !containsStr(prompt, "<tool_calls>") {
t.Fatalf("expected XML tool_calls format in prompt")
}
if !containsStr(prompt, "TOOL CALL FORMAT") {
t.Fatalf("expected tool call format header in prompt")
}
}
func TestBuildClaudeToolPromptMultipleTools(t *testing.T) {
tools := []any{
map[string]any{"name": "tool1", "description": "desc1"},
map[string]any{"name": "tool2", "description": "desc2"},
}
prompt := buildClaudeToolPrompt(tools)
if !containsStr(prompt, "tool1") || !containsStr(prompt, "tool2") {
t.Fatalf("expected both tools in prompt")
}
}
func TestBuildClaudeToolPromptSupportsOpenAIStyleFunctionTool(t *testing.T) {
tools := []any{
map[string]any{
"type": "function",
"function": map[string]any{
"name": "search",
"description": "Search via function tool",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"q": map[string]any{"type": "string"},
},
},
},
},
}
prompt := buildClaudeToolPrompt(tools)
if !containsStr(prompt, "Tool: search") {
t.Fatalf("expected OpenAI-style function tool name in prompt, got: %q", prompt)
}
if !containsStr(prompt, "Search via function tool") {
t.Fatalf("expected OpenAI-style function tool description in prompt, got: %q", prompt)
}
if !containsStr(prompt, "\"q\"") {
t.Fatalf("expected parameters schema serialized in prompt, got: %q", prompt)
}
}
func TestBuildClaudeToolPromptSkipsNonMap(t *testing.T) {
tools := []any{"not a map"}
prompt := buildClaudeToolPrompt(tools)
// No valid tools → empty prompt
if prompt != "" {
t.Fatalf("expected empty prompt for non-map tools, got: %q", prompt)
}
}
// ─── hasSystemMessage ────────────────────────────────────────────────
func TestHasSystemMessageTrue(t *testing.T) {
msgs := []any{
map[string]any{"role": "system", "content": "You are a helper"},
map[string]any{"role": "user", "content": "Hi"},
}
if !hasSystemMessage(msgs) {
t.Fatal("expected true")
}
}
func TestHasSystemMessageFalse(t *testing.T) {
msgs := []any{
map[string]any{"role": "user", "content": "Hi"},
map[string]any{"role": "assistant", "content": "Hello"},
}
if hasSystemMessage(msgs) {
t.Fatal("expected false")
}
}
func TestHasSystemMessageEmpty(t *testing.T) {
if hasSystemMessage(nil) {
t.Fatal("expected false for nil")
}
}
func TestHasSystemMessageNonMap(t *testing.T) {
msgs := []any{"not a map"}
if hasSystemMessage(msgs) {
t.Fatal("expected false for non-map")
}
}
// ─── extractClaudeToolNames ──────────────────────────────────────────
func TestExtractClaudeToolNamesSingle(t *testing.T) {
tools := []any{
map[string]any{"name": "search"},
}
names := extractClaudeToolNames(tools)
if len(names) != 1 || names[0] != "search" {
t.Fatalf("expected [search], got %v", names)
}
}
func TestExtractClaudeToolNamesMultiple(t *testing.T) {
tools := []any{
map[string]any{"name": "search"},
map[string]any{"name": "calculate"},
}
names := extractClaudeToolNames(tools)
if len(names) != 2 {
t.Fatalf("expected 2 names, got %v", names)
}
}
func TestExtractClaudeToolNamesSkipsEmptyName(t *testing.T) {
tools := []any{
map[string]any{"name": ""},
map[string]any{"name": "valid"},
}
names := extractClaudeToolNames(tools)
if len(names) != 1 || names[0] != "valid" {
t.Fatalf("expected [valid], got %v", names)
}
}
func TestExtractClaudeToolNamesSkipsNonMap(t *testing.T) {
tools := []any{"not a map", 42}
names := extractClaudeToolNames(tools)
if len(names) != 0 {
t.Fatalf("expected 0, got %v", names)
}
}
func TestExtractClaudeToolNamesNil(t *testing.T) {
names := extractClaudeToolNames(nil)
if len(names) != 0 {
t.Fatalf("expected 0, got %v", names)
}
}
func TestExtractClaudeToolNamesSupportsOpenAIStyleFunctionTool(t *testing.T) {
tools := []any{
map[string]any{
"type": "function",
"function": map[string]any{
"name": "search",
},
},
}
names := extractClaudeToolNames(tools)
if len(names) != 1 || names[0] != "search" {
t.Fatalf("expected [search], got %v", names)
}
}
// ─── toMessageMaps ───────────────────────────────────────────────────
func TestToMessageMapsNormal(t *testing.T) {
input := []any{
map[string]any{"role": "user", "content": "Hello"},
}
got := toMessageMaps(input)
if len(got) != 1 {
t.Fatalf("expected 1, got %d", len(got))
}
}
func TestToMessageMapsNonSlice(t *testing.T) {
got := toMessageMaps("not a slice")
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
}
func TestToMessageMapsSkipsNonMap(t *testing.T) {
input := []any{"string", map[string]any{"role": "user"}, 42}
got := toMessageMaps(input)
if len(got) != 1 {
t.Fatalf("expected 1 map, got %d", len(got))
}
}
func TestToMessageMapsNil(t *testing.T) {
got := toMessageMaps(nil)
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
}
// ─── extractMessageContent ──────────────────────────────────────────
func TestExtractMessageContentString(t *testing.T) {
if got := extractMessageContent("hello"); got != "hello" {
t.Fatalf("expected 'hello', got %q", got)
}
}
func TestExtractMessageContentArray(t *testing.T) {
input := []any{"part1", "part2"}
got := extractMessageContent(input)
if got != "part1\npart2" {
t.Fatalf("expected joined, got %q", got)
}
}
func TestExtractMessageContentOther(t *testing.T) {
got := extractMessageContent(42)
if got != "42" {
t.Fatalf("expected '42', got %q", got)
}
}
func TestExtractMessageContentNil(t *testing.T) {
got := extractMessageContent(nil)
if got != "<nil>" {
t.Fatalf("expected '<nil>', got %q", got)
}
}
// ─── cloneMap ────────────────────────────────────────────────────────
func TestCloneMapBasic(t *testing.T) {
original := map[string]any{"a": 1, "b": "hello"}
clone := cloneMap(original)
original["a"] = 999
if clone["a"] != 1 {
t.Fatalf("expected 1, got %v", clone["a"])
}
if clone["b"] != "hello" {
t.Fatalf("expected 'hello', got %v", clone["b"])
}
}
func TestCloneMapEmpty(t *testing.T) {
clone := cloneMap(map[string]any{})
if len(clone) != 0 {
t.Fatalf("expected empty, got %v", clone)
}
}
func TestCloneMapNested(t *testing.T) {
// cloneMap is shallow, so nested maps share references
inner := map[string]any{"key": "value"}
original := map[string]any{"nested": inner}
clone := cloneMap(original)
// Shallow clone means inner is shared
inner["key"] = "modified"
cloneNested := clone["nested"].(map[string]any)
if cloneNested["key"] != "modified" {
t.Fatal("expected shallow clone to share nested references")
}
}
// helper
func containsStr(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && findSubstring(s, sub))
}
func findSubstring(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -0,0 +1,225 @@
package claude
import (
"encoding/json"
"fmt"
"strings"
"ds2api/internal/prompt"
"ds2api/internal/util"
)
func normalizeClaudeMessages(messages []any) []any {
out := make([]any, 0, len(messages))
state := &claudeToolCallState{
nameByID: map[string]string{},
lastIDByName: map[string]string{},
callIDSequence: 0,
}
for _, m := range messages {
msg, ok := m.(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", msg["role"])))
switch content := msg["content"].(type) {
case []any:
textParts := make([]string, 0, len(content))
flushText := func() {
if len(textParts) == 0 {
return
}
out = append(out, map[string]any{
"role": role,
"content": strings.Join(textParts, "\n"),
})
textParts = textParts[:0]
}
for _, block := range content {
b, ok := block.(map[string]any)
if !ok {
continue
}
typeStr := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", b["type"])))
switch typeStr {
case "text":
if t, ok := b["text"].(string); ok {
textParts = append(textParts, t)
}
case "tool_use":
if role == "assistant" {
flushText()
if toolMsg := normalizeClaudeToolUseToAssistant(b, state); toolMsg != nil {
out = append(out, toolMsg)
}
continue
}
if raw := strings.TrimSpace(formatClaudeUnknownBlockForPrompt(b)); raw != "" {
textParts = append(textParts, raw)
}
case "tool_result":
flushText()
if toolMsg := normalizeClaudeToolResultToToolMessage(b, state); toolMsg != nil {
out = append(out, toolMsg)
}
default:
if raw := strings.TrimSpace(formatClaudeUnknownBlockForPrompt(b)); raw != "" {
textParts = append(textParts, raw)
}
}
}
flushText()
default:
copied := cloneMap(msg)
out = append(out, copied)
}
}
return out
}
func buildClaudeToolPrompt(tools []any) string {
toolSchemas := make([]string, 0, len(tools))
names := make([]string, 0, len(tools))
for _, t := range tools {
m, ok := t.(map[string]any)
if !ok {
continue
}
name, desc, schemaObj := extractClaudeToolMeta(m)
if name == "" {
continue
}
names = append(names, name)
schema, _ := json.Marshal(schemaObj)
toolSchemas = append(toolSchemas, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema))
}
if len(toolSchemas) == 0 {
return ""
}
return "You have access to these tools:\n\n" +
strings.Join(toolSchemas, "\n\n") + "\n\n" +
util.BuildToolCallInstructions(names)
}
func formatClaudeToolResultForPrompt(block map[string]any) string {
if block == nil {
return ""
}
payload := map[string]any{
"type": "tool_result",
"content": block["content"],
}
if toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"])); toolCallID != "" {
payload["tool_call_id"] = toolCallID
} else if toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_call_id"])); toolCallID != "" {
payload["tool_call_id"] = toolCallID
}
if name := strings.TrimSpace(fmt.Sprintf("%v", block["name"])); name != "" {
payload["name"] = name
}
b, err := json.Marshal(payload)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", payload))
}
return string(b)
}
func normalizeClaudeToolUseToAssistant(block map[string]any, state *claudeToolCallState) map[string]any {
if block == nil {
return nil
}
name := strings.TrimSpace(fmt.Sprintf("%v", block["name"]))
if name == "" {
return nil
}
callID := safeStringValue(block["id"])
if callID == "" {
callID = safeStringValue(block["tool_use_id"])
}
if callID == "" {
callID = state.nextID()
}
state.nameByID[callID] = name
state.lastIDByName[strings.ToLower(name)] = callID
arguments := block["input"]
if arguments == nil {
arguments = map[string]any{}
}
argsJSON, err := json.Marshal(arguments)
if err != nil || len(argsJSON) == 0 {
argsJSON = []byte("{}")
}
toolCalls := []any{
map[string]any{
"id": callID,
"type": "function",
"function": map[string]any{
"name": name,
"arguments": string(argsJSON),
},
},
}
return map[string]any{
"role": "assistant",
"content": prompt.FormatToolCallsForPrompt(toolCalls),
"tool_calls": toolCalls,
}
}
func normalizeClaudeToolResultToToolMessage(block map[string]any, state *claudeToolCallState) map[string]any {
if block == nil {
return nil
}
name := safeStringValue(block["name"])
toolCallID := safeStringValue(block["tool_use_id"])
if toolCallID == "" {
toolCallID = safeStringValue(block["tool_call_id"])
}
if toolCallID == "" {
if name != "" {
toolCallID = strings.TrimSpace(state.lastIDByName[strings.ToLower(name)])
}
}
if toolCallID == "" {
toolCallID = state.nextID()
}
out := map[string]any{
"role": "tool",
"tool_call_id": toolCallID,
"content": normalizeClaudeToolResultContent(block["content"]),
}
if name != "" {
out["name"] = name
state.nameByID[toolCallID] = name
state.lastIDByName[strings.ToLower(name)] = toolCallID
} else if inferred := strings.TrimSpace(state.nameByID[toolCallID]); inferred != "" {
out["name"] = inferred
}
return out
}
func normalizeClaudeToolResultContent(content any) any {
if text, ok := content.(string); ok {
return text
}
payload := map[string]any{
"type": "tool_result",
"content": content,
}
b, err := json.Marshal(sanitizeClaudeBlockForPrompt(payload))
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", content))
}
return string(b)
}
func formatClaudeBlockRaw(block map[string]any) string {
if block == nil {
return ""
}
b, err := json.Marshal(block)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", block))
}
return string(b)
}

View File

@@ -0,0 +1,105 @@
package claude
import (
"encoding/json"
"fmt"
"strings"
)
const (
maxClaudeRawPromptChars = 1024
omittedBinaryMarker = "[omitted_binary_payload]"
)
func formatClaudeUnknownBlockForPrompt(block map[string]any) string {
if block == nil {
return ""
}
safe := sanitizeClaudeBlockForPrompt(block)
raw := strings.TrimSpace(formatClaudeBlockRaw(safe))
if raw == "" {
return ""
}
if len(raw) > maxClaudeRawPromptChars {
return raw[:maxClaudeRawPromptChars] + "...(truncated)"
}
return raw
}
func sanitizeClaudeBlockForPrompt(block map[string]any) map[string]any {
out := cloneMap(block)
for k, v := range out {
if looksLikeBinaryFieldName(k) {
out[k] = omittedBinaryMarker
continue
}
switch inner := v.(type) {
case map[string]any:
out[k] = sanitizeClaudeBlockForPrompt(inner)
case []any:
out[k] = sanitizeClaudeArrayForPrompt(inner)
case string:
out[k] = sanitizeClaudeStringForPrompt(k, inner)
}
}
return out
}
func sanitizeClaudeArrayForPrompt(items []any) []any {
out := make([]any, 0, len(items))
for _, item := range items {
switch v := item.(type) {
case map[string]any:
out = append(out, sanitizeClaudeBlockForPrompt(v))
case []any:
out = append(out, sanitizeClaudeArrayForPrompt(v))
default:
out = append(out, v)
}
}
return out
}
func sanitizeClaudeStringForPrompt(key, value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if looksLikeBinaryFieldName(key) || looksLikeBase64Payload(trimmed) {
return omittedBinaryMarker
}
if len(trimmed) > maxClaudeRawPromptChars {
return trimmed[:maxClaudeRawPromptChars] + "...(truncated)"
}
return trimmed
}
func looksLikeBinaryFieldName(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
return n == "data" || n == "bytes" || n == "base64" || n == "inline_data" || n == "inlinedata"
}
func looksLikeBase64Payload(v string) bool {
if len(v) < 512 {
return false
}
compact := strings.TrimRight(v, "=")
if compact == "" {
return false
}
for _, ch := range compact {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '+' || ch == '/' || ch == '-' || ch == '_' {
continue
}
return false
}
return true
}
func marshalCompactJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
return string(b)
}

View File

@@ -0,0 +1,84 @@
package claude
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type claudeProxyStoreStub struct {
mapping map[string]string
}
func (s claudeProxyStoreStub) ClaudeMapping() map[string]string {
return s.mapping
}
type openAIProxyStub struct {
status int
body string
}
func (s openAIProxyStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
if s.status == 0 {
s.status = http.StatusOK
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(s.status)
_, _ = w.Write([]byte(s.body))
}
type openAIProxyCaptureStub struct {
seenModel string
}
func (s *openAIProxyCaptureStub) ChatCompletions(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
if m, ok := req["model"].(string); ok {
s.seenModel = m
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"ok","choices":[{"message":{"role":"assistant","content":"ok"}}]}`))
}
func TestClaudeProxyViaOpenAIVercelPreparePassthrough(t *testing.T) {
h := &Handler{OpenAI: openAIProxyStub{status: 200, body: `{"lease_id":"lease_123","payload":{"a":1}}`}}
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages?__stream_prepare=1", strings.NewReader(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"stream":true}`))
rec := httptest.NewRecorder()
h.Messages(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("expected json response, got err=%v body=%s", err, rec.Body.String())
}
if _, ok := out["lease_id"]; !ok {
t.Fatalf("expected lease_id in prepare passthrough, got=%v", out)
}
}
func TestClaudeProxyViaOpenAIPreservesClaudeMapping(t *testing.T) {
openAI := &openAIProxyCaptureStub{}
h := &Handler{
Store: claudeProxyStoreStub{mapping: map[string]string{"fast": "deepseek-chat", "slow": "deepseek-reasoner"}},
OpenAI: openAI,
}
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-3-opus","messages":[{"role":"user","content":"hi"}],"stream":false}`))
rec := httptest.NewRecorder()
h.Messages(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := strings.TrimSpace(openAI.seenModel); got != "deepseek-reasoner" {
t.Fatalf("expected mapped proxy model deepseek-reasoner, got %q", got)
}
}

View File

@@ -0,0 +1,44 @@
package claude
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/auth"
)
type routeAliasAuthStub struct{}
func (routeAliasAuthStub) Determine(_ *http.Request) (*auth.RequestAuth, error) {
return nil, auth.ErrUnauthorized
}
func (routeAliasAuthStub) Release(_ *auth.RequestAuth) {}
func TestClaudeRouteAliasesDoNot404(t *testing.T) {
h := &Handler{
Auth: routeAliasAuthStub{},
}
r := chi.NewRouter()
RegisterRoutes(r, h)
paths := []string{
"/anthropic/v1/messages",
"/v1/messages",
"/messages",
"/anthropic/v1/messages/count_tokens",
"/v1/messages/count_tokens",
"/messages/count_tokens",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodPost, path, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code == http.StatusNotFound {
t.Fatalf("expected route %s to be registered, got 404", path)
}
}
}

View File

@@ -0,0 +1,116 @@
package claude
import (
"fmt"
"strings"
"ds2api/internal/config"
"ds2api/internal/deepseek"
"ds2api/internal/util"
)
type claudeNormalizedRequest struct {
Standard util.StandardRequest
NormalizedMessages []any
}
func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNormalizedRequest, error) {
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'.")
}
if _, ok := req["max_tokens"]; !ok {
req["max_tokens"] = 8192
}
normalizedMessages := normalizeClaudeMessages(messagesRaw)
payload := cloneMap(req)
payload["messages"] = normalizedMessages
toolsRequested, _ := req["tools"].([]any)
payload["messages"] = injectClaudeToolPrompt(payload, normalizedMessages, toolsRequested)
dsPayload := convertClaudeToDeepSeek(payload, store)
dsModel, _ := dsPayload["model"].(string)
thinkingEnabled, searchEnabled, ok := config.GetModelConfig(dsModel)
if !ok {
thinkingEnabled = false
searchEnabled = false
}
finalPrompt := deepseek.MessagesPrepare(toMessageMaps(dsPayload["messages"]))
toolNames := extractClaudeToolNames(toolsRequested)
if len(toolNames) == 0 && len(toolsRequested) > 0 {
toolNames = []string{"__any_tool__"}
}
return claudeNormalizedRequest{
Standard: util.StandardRequest{
Surface: "anthropic_messages",
RequestedModel: strings.TrimSpace(model),
ResolvedModel: dsModel,
ResponseModel: strings.TrimSpace(model),
Messages: payload["messages"].([]any),
FinalPrompt: finalPrompt,
ToolNames: toolNames,
Stream: util.ToBool(req["stream"]),
Thinking: thinkingEnabled,
Search: searchEnabled,
},
NormalizedMessages: normalizedMessages,
}, nil
}
func injectClaudeToolPrompt(payload map[string]any, normalizedMessages []any, tools []any) []any {
if len(tools) == 0 {
return normalizedMessages
}
toolPrompt := strings.TrimSpace(buildClaudeToolPrompt(tools))
if toolPrompt == "" {
return normalizedMessages
}
// Prefer top-level Anthropic-style system prompt when available.
if systemText, ok := payload["system"].(string); ok && strings.TrimSpace(systemText) != "" {
payload["system"] = mergeSystemPrompt(systemText, toolPrompt)
return normalizedMessages
}
messages := cloneAnySlice(normalizedMessages)
for i := range messages {
msg, ok := messages[i].(map[string]any)
if !ok {
continue
}
role, _ := msg["role"].(string)
if !strings.EqualFold(strings.TrimSpace(role), "system") {
continue
}
copied := cloneMap(msg)
copied["content"] = mergeSystemPrompt(strings.TrimSpace(fmt.Sprintf("%v", copied["content"])), toolPrompt)
messages[i] = copied
return messages
}
return append([]any{map[string]any{"role": "system", "content": toolPrompt}}, messages...)
}
func mergeSystemPrompt(base, extra string) string {
base = strings.TrimSpace(base)
extra = strings.TrimSpace(extra)
switch {
case base == "":
return extra
case extra == "":
return base
default:
return base + "\n\n" + extra
}
}
func cloneAnySlice(in []any) []any {
if len(in) == 0 {
return nil
}
out := make([]any, len(in))
copy(out, in)
return out
}

View File

@@ -0,0 +1,92 @@
package claude
import (
"testing"
"ds2api/internal/config"
)
func TestNormalizeClaudeRequest(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{}`)
store := config.LoadStore()
req := map[string]any{
"model": "claude-opus-4-6",
"messages": []any{
map[string]any{"role": "user", "content": "hello"},
},
"stream": true,
"tools": []any{
map[string]any{"name": "search", "description": "Search"},
},
}
norm, err := normalizeClaudeRequest(store, req)
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
if norm.Standard.ResolvedModel == "" {
t.Fatalf("expected resolved model")
}
if !norm.Standard.Stream {
t.Fatalf("expected stream=true")
}
if len(norm.Standard.ToolNames) == 0 {
t.Fatalf("expected tool names")
}
if norm.Standard.FinalPrompt == "" {
t.Fatalf("expected non-empty final prompt")
}
}
func TestNormalizeClaudeRequestInjectsToolsIntoExistingSystemMessage(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{}`)
store := config.LoadStore()
req := map[string]any{
"model": "claude-sonnet-4-5",
"messages": []any{
map[string]any{"role": "system", "content": "baseline rule"},
map[string]any{"role": "user", "content": "hello"},
},
"tools": []any{
map[string]any{"name": "search", "description": "Search"},
},
}
norm, err := normalizeClaudeRequest(store, req)
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
if !containsStr(norm.Standard.FinalPrompt, "You have access to these tools") {
t.Fatalf("expected tool prompt injected into final prompt, got=%q", norm.Standard.FinalPrompt)
}
if !containsStr(norm.Standard.FinalPrompt, "baseline rule") {
t.Fatalf("expected existing system message preserved, got=%q", norm.Standard.FinalPrompt)
}
}
func TestNormalizeClaudeRequestInjectsToolsIntoTopLevelSystem(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{}`)
store := config.LoadStore()
req := map[string]any{
"model": "claude-sonnet-4-5",
"system": "top-level system",
"messages": []any{
map[string]any{"role": "user", "content": "hello"},
},
"tools": []any{
map[string]any{"name": "search", "description": "Search"},
},
}
norm, err := normalizeClaudeRequest(store, req)
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
if !containsStr(norm.Standard.FinalPrompt, "top-level system") {
t.Fatalf("expected top-level system preserved, got=%q", norm.Standard.FinalPrompt)
}
if !containsStr(norm.Standard.FinalPrompt, "You have access to these tools") {
t.Fatalf("expected tool prompt injected, got=%q", norm.Standard.FinalPrompt)
}
}

View File

@@ -0,0 +1,157 @@
package claude
import (
"fmt"
"net/http"
"strings"
"time"
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
)
type claudeStreamRuntime struct {
w http.ResponseWriter
rc *http.ResponseController
canFlush bool
model string
toolNames []string
messages []any
thinkingEnabled bool
searchEnabled bool
bufferToolContent bool
messageID string
thinking strings.Builder
text strings.Builder
outputTokens int
nextBlockIndex int
thinkingBlockOpen bool
thinkingBlockIndex int
textBlockOpen bool
textBlockIndex int
ended bool
upstreamErr string
}
func newClaudeStreamRuntime(
w http.ResponseWriter,
rc *http.ResponseController,
canFlush bool,
model string,
messages []any,
thinkingEnabled bool,
searchEnabled bool,
toolNames []string,
) *claudeStreamRuntime {
return &claudeStreamRuntime{
w: w,
rc: rc,
canFlush: canFlush,
model: model,
messages: messages,
thinkingEnabled: thinkingEnabled,
searchEnabled: searchEnabled,
bufferToolContent: len(toolNames) > 0,
toolNames: toolNames,
messageID: fmt.Sprintf("msg_%d", time.Now().UnixNano()),
thinkingBlockIndex: -1,
textBlockIndex: -1,
}
}
func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedDecision {
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")}
}
if parsed.Stop {
return streamengine.ParsedDecision{Stop: true}
}
contentSeen := false
for _, p := range parsed.Parts {
if p.Text == "" {
continue
}
if p.Type != "thinking" && s.searchEnabled && sse.IsCitation(p.Text) {
continue
}
contentSeen = true
if p.Type == "thinking" {
if !s.thinkingEnabled {
continue
}
s.thinking.WriteString(p.Text)
s.closeTextBlock()
if !s.thinkingBlockOpen {
s.thinkingBlockIndex = s.nextBlockIndex
s.nextBlockIndex++
s.send("content_block_start", map[string]any{
"type": "content_block_start",
"index": s.thinkingBlockIndex,
"content_block": map[string]any{
"type": "thinking",
"thinking": "",
},
})
s.thinkingBlockOpen = true
}
s.send("content_block_delta", map[string]any{
"type": "content_block_delta",
"index": s.thinkingBlockIndex,
"delta": map[string]any{
"type": "thinking_delta",
"thinking": p.Text,
},
})
continue
}
s.text.WriteString(p.Text)
if s.bufferToolContent {
if hasUnclosedCodeFence(s.text.String()) {
continue
}
continue
}
s.closeThinkingBlock()
if !s.textBlockOpen {
s.textBlockIndex = s.nextBlockIndex
s.nextBlockIndex++
s.send("content_block_start", map[string]any{
"type": "content_block_start",
"index": s.textBlockIndex,
"content_block": map[string]any{
"type": "text",
"text": "",
},
})
s.textBlockOpen = true
}
s.send("content_block_delta", map[string]any{
"type": "content_block_delta",
"index": s.textBlockIndex,
"delta": map[string]any{
"type": "text_delta",
"text": p.Text,
},
})
}
return streamengine.ParsedDecision{ContentSeen: contentSeen}
}
func hasUnclosedCodeFence(text string) bool {
return strings.Count(text, "```")%2 == 1
}

View File

@@ -0,0 +1,59 @@
package claude
import (
"encoding/json"
"fmt"
"strings"
"ds2api/internal/util"
)
func (s *claudeStreamRuntime) send(event string, v any) {
b, _ := json.Marshal(v)
_, _ = s.w.Write([]byte("event: "))
_, _ = s.w.Write([]byte(event))
_, _ = s.w.Write([]byte("\n"))
_, _ = s.w.Write([]byte("data: "))
_, _ = s.w.Write(b)
_, _ = s.w.Write([]byte("\n\n"))
if s.canFlush {
_ = s.rc.Flush()
}
}
func (s *claudeStreamRuntime) sendError(message string) {
msg := strings.TrimSpace(message)
if msg == "" {
msg = "upstream stream error"
}
s.send("error", map[string]any{
"type": "error",
"error": map[string]any{
"type": "api_error",
"message": msg,
"code": "internal_error",
"param": nil,
},
})
}
func (s *claudeStreamRuntime) sendPing() {
s.send("ping", map[string]any{"type": "ping"})
}
func (s *claudeStreamRuntime) sendMessageStart() {
inputTokens := util.EstimateTokens(fmt.Sprintf("%v", s.messages))
s.send("message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": s.messageID,
"type": "message",
"role": "assistant",
"model": s.model,
"content": []any{},
"stop_reason": nil,
"stop_sequence": nil,
"usage": map[string]any{"input_tokens": inputTokens, "output_tokens": 0},
},
})
}

View File

@@ -0,0 +1,137 @@
package claude
import (
"encoding/json"
"fmt"
"time"
streamengine "ds2api/internal/stream"
"ds2api/internal/util"
)
func (s *claudeStreamRuntime) closeThinkingBlock() {
if !s.thinkingBlockOpen {
return
}
s.send("content_block_stop", map[string]any{
"type": "content_block_stop",
"index": s.thinkingBlockIndex,
})
s.thinkingBlockOpen = false
s.thinkingBlockIndex = -1
}
func (s *claudeStreamRuntime) closeTextBlock() {
if !s.textBlockOpen {
return
}
s.send("content_block_stop", map[string]any{
"type": "content_block_stop",
"index": s.textBlockIndex,
})
s.textBlockOpen = false
s.textBlockIndex = -1
}
func (s *claudeStreamRuntime) finalize(stopReason string) {
if s.ended {
return
}
s.ended = true
s.closeThinkingBlock()
s.closeTextBlock()
finalThinking := s.thinking.String()
finalText := s.text.String()
if s.bufferToolContent {
detected := util.ParseStandaloneToolCalls(finalText, s.toolNames)
if len(detected) == 0 && finalText == "" && finalThinking != "" {
detected = util.ParseStandaloneToolCalls(finalThinking, s.toolNames)
}
if len(detected) > 0 {
stopReason = "tool_use"
for i, tc := range detected {
idx := s.nextBlockIndex + i
s.send("content_block_start", map[string]any{
"type": "content_block_start",
"index": idx,
"content_block": map[string]any{
"type": "tool_use",
"id": fmt.Sprintf("toolu_%d_%d", time.Now().Unix(), idx),
"name": tc.Name,
"input": map[string]any{},
},
})
inputBytes, _ := json.Marshal(tc.Input)
s.send("content_block_delta", map[string]any{
"type": "content_block_delta",
"index": idx,
"delta": map[string]any{
"type": "input_json_delta",
"partial_json": string(inputBytes),
},
})
s.send("content_block_stop", map[string]any{
"type": "content_block_stop",
"index": idx,
})
}
s.nextBlockIndex += len(detected)
} else if finalText != "" {
idx := s.nextBlockIndex
s.nextBlockIndex++
s.send("content_block_start", map[string]any{
"type": "content_block_start",
"index": idx,
"content_block": map[string]any{
"type": "text",
"text": "",
},
})
s.send("content_block_delta", map[string]any{
"type": "content_block_delta",
"index": idx,
"delta": map[string]any{
"type": "text_delta",
"text": finalText,
},
})
s.send("content_block_stop", map[string]any{
"type": "content_block_stop",
"index": idx,
})
}
}
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{
"stop_reason": stopReason,
"stop_sequence": nil,
},
"usage": map[string]any{
"output_tokens": outputTokens,
},
})
s.send("message_stop", map[string]any{"type": "message_stop"})
}
func (s *claudeStreamRuntime) onFinalize(reason streamengine.StopReason, scannerErr error) {
if string(reason) == "upstream_error" {
s.sendError(s.upstreamErr)
return
}
if scannerErr != nil {
s.sendError(scannerErr.Error())
return
}
s.finalize("end_turn")
}

View File

@@ -0,0 +1,66 @@
package claude
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
)
type streamStatusClaudeOpenAIStub struct{}
func (streamStatusClaudeOpenAIStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}
type streamStatusClaudeStoreStub struct{}
func (streamStatusClaudeStoreStub) ClaudeMapping() map[string]string {
return map[string]string{
"fast": "deepseek-chat",
"slow": "deepseek-reasoner",
}
}
func captureClaudeStatusMiddleware(statuses *[]int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
*statuses = append(*statuses, ww.Status())
})
}
}
func TestClaudeMessagesStreamStatusCapturedAs200(t *testing.T) {
statuses := make([]int, 0, 1)
h := &Handler{
Store: streamStatusClaudeStoreStub{},
OpenAI: streamStatusClaudeOpenAIStub{},
}
r := chi.NewRouter()
r.Use(captureClaudeStatusMiddleware(&statuses))
RegisterRoutes(r, h)
reqBody := `{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"stream":true}`
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(reqBody))
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 {
t.Fatalf("expected one captured status, got %d", len(statuses))
}
if statuses[0] != http.StatusOK {
t.Fatalf("expected captured status 200 (not 000), got %d", statuses[0])
}
}

View File

@@ -0,0 +1,25 @@
package claude
import (
"fmt"
"strings"
)
type claudeToolCallState struct {
nameByID map[string]string
lastIDByName map[string]string
callIDSequence int
}
func (s *claudeToolCallState) nextID() string {
s.callIDSequence++
return fmt.Sprintf("call_claude_%d", s.callIDSequence)
}
func safeStringValue(v any) string {
s, ok := v.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}

View File

@@ -0,0 +1,259 @@
package gemini
import (
"fmt"
"strings"
)
const maxGeminiRawPromptChars = 1024
func geminiMessagesFromRequest(req map[string]any) []any {
out := make([]any, 0, 8)
toolCallCounter := 0
nextToolCallID := func() string {
toolCallCounter++
return fmt.Sprintf("call_gemini_%d", toolCallCounter)
}
lastToolCallIDByName := map[string]string{}
if sys := normalizeGeminiSystemInstruction(req["systemInstruction"]); strings.TrimSpace(sys) != "" {
out = append(out, map[string]any{
"role": "system",
"content": sys,
})
}
contents, _ := req["contents"].([]any)
for _, item := range contents {
content, ok := item.(map[string]any)
if !ok {
continue
}
role := mapGeminiRole(content["role"])
if role == "" {
role = "user"
}
parts, _ := content["parts"].([]any)
if len(parts) == 0 {
if text := strings.TrimSpace(asString(content["text"])); text != "" {
out = append(out, map[string]any{
"role": role,
"content": text,
})
}
continue
}
textParts := make([]string, 0, len(parts))
flushText := func() {
if len(textParts) == 0 {
return
}
out = append(out, map[string]any{
"role": role,
"content": strings.Join(textParts, "\n"),
})
textParts = textParts[:0]
}
for _, rawPart := range parts {
part, ok := rawPart.(map[string]any)
if !ok {
continue
}
if text := strings.TrimSpace(asString(part["text"])); text != "" {
textParts = append(textParts, text)
continue
}
if fnCall, ok := part["functionCall"].(map[string]any); ok {
flushText()
if name := strings.TrimSpace(asString(fnCall["name"])); name != "" {
callID := strings.TrimSpace(asString(fnCall["id"]))
if callID == "" {
if callID = strings.TrimSpace(asString(fnCall["call_id"])); callID == "" {
callID = nextToolCallID()
}
}
lastToolCallIDByName[strings.ToLower(name)] = callID
out = append(out, map[string]any{
"role": "assistant",
"tool_calls": []any{
map[string]any{
"id": callID,
"type": "function",
"function": map[string]any{
"name": name,
"arguments": stringifyJSON(fnCall["args"]),
},
},
},
})
}
continue
}
if fnResp, ok := part["functionResponse"].(map[string]any); ok {
flushText()
name := strings.TrimSpace(asString(fnResp["name"]))
callID := strings.TrimSpace(asString(fnResp["id"]))
if callID == "" {
callID = strings.TrimSpace(asString(fnResp["callId"]))
}
if callID == "" {
callID = strings.TrimSpace(asString(fnResp["tool_call_id"]))
}
if callID == "" {
callID = strings.TrimSpace(lastToolCallIDByName[strings.ToLower(name)])
}
if callID == "" {
callID = nextToolCallID()
}
content := fnResp["response"]
if content == nil {
content = fnResp["output"]
}
if content == nil {
content = ""
}
msg := map[string]any{
"role": "tool",
"tool_call_id": callID,
"content": content,
}
if name != "" {
msg["name"] = name
}
out = append(out, msg)
continue
}
if raw := strings.TrimSpace(formatGeminiUnknownPartForPrompt(part)); raw != "" && raw != "null" {
textParts = append(textParts, raw)
}
}
flushText()
}
return out
}
func normalizeGeminiSystemInstruction(raw any) string {
switch v := raw.(type) {
case string:
return strings.TrimSpace(v)
case map[string]any:
if parts, ok := v["parts"].([]any); ok {
texts := make([]string, 0, len(parts))
for _, item := range parts {
part, ok := item.(map[string]any)
if !ok {
continue
}
if text := strings.TrimSpace(asString(part["text"])); text != "" {
texts = append(texts, text)
}
}
return strings.Join(texts, "\n")
}
if text := strings.TrimSpace(asString(v["text"])); text != "" {
return text
}
}
return ""
}
func mapGeminiRole(v any) string {
switch strings.ToLower(strings.TrimSpace(asString(v))) {
case "user":
return "user"
case "model", "assistant":
return "assistant"
case "system":
return "system"
default:
return ""
}
}
func formatGeminiUnknownPartForPrompt(part map[string]any) string {
safe := sanitizeGeminiPartForPrompt(part)
raw := strings.TrimSpace(stringifyJSON(safe))
if raw == "" {
return ""
}
if len(raw) > maxGeminiRawPromptChars {
return raw[:maxGeminiRawPromptChars] + "...(truncated)"
}
return raw
}
func sanitizeGeminiPartForPrompt(part map[string]any) map[string]any {
out := make(map[string]any, len(part))
for k, v := range part {
if looksLikeGeminiBinaryField(k) {
out[k] = "[omitted_binary_payload]"
continue
}
switch x := v.(type) {
case map[string]any:
out[k] = sanitizeGeminiPartForPrompt(x)
case []any:
out[k] = sanitizeGeminiArrayForPrompt(x)
case string:
out[k] = sanitizeGeminiStringForPrompt(k, x)
default:
out[k] = v
}
}
return out
}
func sanitizeGeminiArrayForPrompt(items []any) []any {
out := make([]any, 0, len(items))
for _, item := range items {
switch x := item.(type) {
case map[string]any:
out = append(out, sanitizeGeminiPartForPrompt(x))
case []any:
out = append(out, sanitizeGeminiArrayForPrompt(x))
default:
out = append(out, x)
}
}
return out
}
func sanitizeGeminiStringForPrompt(key, value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if looksLikeGeminiBinaryField(key) || looksLikeGeminiBase64(trimmed) {
return "[omitted_binary_payload]"
}
if len(trimmed) > maxGeminiRawPromptChars {
return trimmed[:maxGeminiRawPromptChars] + "...(truncated)"
}
return trimmed
}
func looksLikeGeminiBinaryField(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
return n == "data" || n == "bytes" || n == "inlinedata" || n == "inline_data" || n == "base64"
}
func looksLikeGeminiBase64(v string) bool {
if len(v) < 512 {
return false
}
compact := strings.TrimRight(v, "=")
if compact == "" {
return false
}
for _, ch := range compact {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '+' || ch == '/' || ch == '-' || ch == '_' {
continue
}
return false
}
return true
}

View File

@@ -0,0 +1,129 @@
package gemini
import (
"strings"
"testing"
)
func TestGeminiMessagesFromRequestPreservesFunctionRoundtrip(t *testing.T) {
req := map[string]any{
"contents": []any{
map[string]any{
"role": "model",
"parts": []any{
map[string]any{
"functionCall": map[string]any{
"id": "call_g1",
"name": "search_web",
"args": map[string]any{"query": "ai"},
},
},
},
},
map[string]any{
"role": "user",
"parts": []any{
map[string]any{
"functionResponse": map[string]any{
"id": "call_g1",
"name": "search_web",
"response": "ok",
},
},
},
},
},
}
got := geminiMessagesFromRequest(req)
if len(got) != 2 {
t.Fatalf("expected two normalized messages, got %#v", got)
}
assistant, _ := got[0].(map[string]any)
if assistant["role"] != "assistant" {
t.Fatalf("expected assistant first, got %#v", assistant)
}
tc, _ := assistant["tool_calls"].([]any)
if len(tc) != 1 {
t.Fatalf("expected one tool call, got %#v", assistant["tool_calls"])
}
toolMsg, _ := got[1].(map[string]any)
if toolMsg["role"] != "tool" || toolMsg["tool_call_id"] != "call_g1" {
t.Fatalf("expected tool message with call id, got %#v", toolMsg)
}
}
func TestGeminiMessagesFromRequestPreservesUnknownPartAsRawJSONText(t *testing.T) {
req := map[string]any{
"contents": []any{
map[string]any{
"role": "user",
"parts": []any{
map[string]any{"text": "hello"},
map[string]any{"inlineData": map[string]any{"mimeType": "image/png", "data": strings.Repeat("A", 2048)}},
},
},
},
}
got := geminiMessagesFromRequest(req)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %#v", got)
}
msg, _ := got[0].(map[string]any)
content, _ := msg["content"].(string)
if !strings.Contains(content, "hello") || !strings.Contains(content, "inlineData") {
t.Fatalf("expected unknown part preserved as raw json text, got %q", content)
}
if !strings.Contains(content, "[omitted_binary_payload]") {
t.Fatalf("expected inlineData payload to be redacted, got %q", content)
}
if strings.Contains(content, strings.Repeat("A", 100)) {
t.Fatalf("expected raw base64 payload not to be embedded, got %q", content)
}
}
func TestGeminiMessagesFromRequestBackfillsFunctionResponseCallIDByName(t *testing.T) {
req := map[string]any{
"contents": []any{
map[string]any{
"role": "model",
"parts": []any{
map[string]any{
"functionCall": map[string]any{
"name": "search_web",
"args": map[string]any{"query": "docs"},
},
},
},
},
map[string]any{
"role": "user",
"parts": []any{
map[string]any{
"functionResponse": map[string]any{
"name": "search_web",
"response": map[string]any{"ok": true},
},
},
},
},
},
}
got := geminiMessagesFromRequest(req)
if len(got) != 2 {
t.Fatalf("expected two normalized messages, got %#v", got)
}
assistant, _ := got[0].(map[string]any)
tc, _ := assistant["tool_calls"].([]any)
call, _ := tc[0].(map[string]any)
callID, _ := call["id"].(string)
if !strings.HasPrefix(callID, "call_gemini_") {
t.Fatalf("expected generated call id prefix, got %#v", call)
}
toolMsg, _ := got[1].(map[string]any)
if toolMsg["tool_call_id"] != callID {
t.Fatalf("expected tool response to inherit generated call id, tool=%#v call=%#v", toolMsg, call)
}
}

View File

@@ -0,0 +1,54 @@
package gemini
import (
"encoding/json"
"strings"
)
func collectGeminiPassThrough(req map[string]any) map[string]any {
cfg, _ := req["generationConfig"].(map[string]any)
if len(cfg) == 0 {
return nil
}
out := map[string]any{}
if v, ok := cfg["temperature"]; ok {
out["temperature"] = v
}
if v, ok := cfg["topP"]; ok {
out["top_p"] = v
}
if v, ok := cfg["maxOutputTokens"]; ok {
out["max_tokens"] = v
}
if v, ok := cfg["stopSequences"]; ok {
out["stop"] = v
}
if len(out) == 0 {
return nil
}
return out
}
func asString(v any) string {
s, _ := v.(string)
return s
}
func stringifyJSON(v any) string {
switch x := v.(type) {
case nil:
return "{}"
case string:
s := strings.TrimSpace(x)
if s == "" {
return "{}"
}
return s
default:
b, err := json.Marshal(x)
if err != nil || len(b) == 0 {
return "{}"
}
return string(b)
}
}

View File

@@ -0,0 +1,46 @@
package gemini
import (
"fmt"
"strings"
"ds2api/internal/adapter/openai"
"ds2api/internal/config"
"ds2api/internal/util"
)
func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[string]any, stream bool) (util.StandardRequest, error) {
requestedModel := strings.TrimSpace(routeModel)
if requestedModel == "" {
return util.StandardRequest{}, fmt.Errorf("model is required in request path")
}
resolvedModel, ok := config.ResolveModel(store, requestedModel)
if !ok {
return util.StandardRequest{}, fmt.Errorf("Model '%s' 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.")
}
toolsRaw := convertGeminiTools(req["tools"])
finalPrompt, toolNames := openai.BuildPromptForAdapter(messagesRaw, toolsRaw, "")
passThrough := collectGeminiPassThrough(req)
return util.StandardRequest{
Surface: "google_gemini",
RequestedModel: requestedModel,
ResolvedModel: resolvedModel,
ResponseModel: requestedModel,
Messages: messagesRaw,
FinalPrompt: finalPrompt,
ToolNames: toolNames,
Stream: stream,
Thinking: thinkingEnabled,
Search: searchEnabled,
PassThrough: passThrough,
}, nil
}

View File

@@ -0,0 +1,71 @@
package gemini
import "strings"
func convertGeminiTools(raw any) []any {
tools, _ := raw.([]any)
if len(tools) == 0 {
return nil
}
out := make([]any, 0, len(tools))
for _, item := range tools {
tool, ok := item.(map[string]any)
if !ok {
continue
}
if fnDecls, ok := tool["functionDeclarations"].([]any); ok && len(fnDecls) > 0 {
for _, declRaw := range fnDecls {
decl, ok := declRaw.(map[string]any)
if !ok {
continue
}
name := strings.TrimSpace(asString(decl["name"]))
if name == "" {
continue
}
function := map[string]any{
"name": name,
}
if desc := strings.TrimSpace(asString(decl["description"])); desc != "" {
function["description"] = desc
}
if params, ok := decl["parameters"].(map[string]any); ok {
function["parameters"] = params
}
out = append(out, map[string]any{
"type": "function",
"function": function,
})
}
continue
}
// OpenAI-style passthrough fallback.
if _, ok := tool["function"].(map[string]any); ok {
out = append(out, tool)
continue
}
// Loose fallback for flattened function schema objects.
name := strings.TrimSpace(asString(tool["name"]))
if name == "" {
continue
}
fn := map[string]any{"name": name}
if desc := strings.TrimSpace(asString(tool["description"])); desc != "" {
fn["description"] = desc
}
if params, ok := tool["parameters"].(map[string]any); ok {
fn["parameters"] = params
}
out = append(out, map[string]any{
"type": "function",
"function": fn,
})
}
if len(out) == 0 {
return nil
}
return out
}

View File

@@ -0,0 +1,33 @@
package gemini
import (
"context"
"net/http"
"ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/deepseek"
)
type AuthResolver interface {
Determine(req *http.Request) (*auth.RequestAuth, error)
Release(a *auth.RequestAuth)
}
type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
}
type ConfigReader interface {
ModelAliases() map[string]string
}
type OpenAIChatRunner interface {
ChatCompletions(w http.ResponseWriter, r *http.Request)
}
var _ AuthResolver = (*auth.Resolver)(nil)
var _ DeepSeekCaller = (*deepseek.Client)(nil)
var _ ConfigReader = (*config.Store)(nil)

View File

@@ -0,0 +1,28 @@
package gemini
import "net/http"
func writeGeminiError(w http.ResponseWriter, status int, message string) {
errorStatus := "INVALID_ARGUMENT"
switch status {
case http.StatusUnauthorized:
errorStatus = "UNAUTHENTICATED"
case http.StatusForbidden:
errorStatus = "PERMISSION_DENIED"
case http.StatusTooManyRequests:
errorStatus = "RESOURCE_EXHAUSTED"
case http.StatusNotFound:
errorStatus = "NOT_FOUND"
default:
if status >= 500 {
errorStatus = "INTERNAL"
}
}
writeJSON(w, status, map[string]any{
"error": map[string]any{
"code": status,
"message": message,
"status": errorStatus,
},
})
}

View File

@@ -0,0 +1,203 @@
package gemini
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/go-chi/chi/v5"
"ds2api/internal/sse"
"ds2api/internal/translatorcliproxy"
"ds2api/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
)
func (h *Handler) handleGenerateContent(w http.ResponseWriter, r *http.Request, stream bool) {
if h.OpenAI == nil {
writeGeminiError(w, http.StatusInternalServerError, "OpenAI proxy backend unavailable.")
return
}
if h.proxyViaOpenAI(w, r, stream) {
return
}
writeGeminiError(w, http.StatusBadGateway, "Failed to proxy Gemini request.")
}
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool {
raw, err := io.ReadAll(r.Body)
if err != nil {
writeGeminiError(w, http.StatusBadRequest, "invalid body")
return true
}
routeModel := strings.TrimSpace(chi.URLParam(r, "model"))
translatedReq := translatorcliproxy.ToOpenAI(sdktranslator.FormatGemini, routeModel, raw, stream)
if !strings.Contains(string(translatedReq), `"stream"`) {
var reqMap map[string]any
if json.Unmarshal(translatedReq, &reqMap) == nil {
reqMap["stream"] = stream
if b, e := json.Marshal(reqMap); e == nil {
translatedReq = b
}
}
}
isVercelPrepare := strings.TrimSpace(r.URL.Query().Get("__stream_prepare")) == "1"
isVercelRelease := strings.TrimSpace(r.URL.Query().Get("__stream_release")) == "1"
if isVercelRelease {
proxyReq := r.Clone(r.Context())
proxyReq.URL.Path = "/v1/chat/completions"
proxyReq.Body = io.NopCloser(bytes.NewReader(raw))
proxyReq.ContentLength = int64(len(raw))
rec := httptest.NewRecorder()
h.OpenAI.ChatCompletions(rec, proxyReq)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(res.StatusCode)
_, _ = w.Write(body)
return true
}
proxyReq := r.Clone(r.Context())
proxyReq.URL.Path = "/v1/chat/completions"
proxyReq.Body = io.NopCloser(bytes.NewReader(translatedReq))
proxyReq.ContentLength = int64(len(translatedReq))
if stream && !isVercelPrepare {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
streamWriter := translatorcliproxy.NewOpenAIStreamTranslatorWriter(w, sdktranslator.FormatGemini, routeModel, raw, translatedReq)
h.OpenAI.ChatCompletions(streamWriter, proxyReq)
return true
}
rec := httptest.NewRecorder()
h.OpenAI.ChatCompletions(rec, proxyReq)
res := rec.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode >= 300 {
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
writeGeminiErrorFromOpenAI(w, res.StatusCode, body)
return true
}
if isVercelPrepare {
for k, vv := range res.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(res.StatusCode)
_, _ = w.Write(body)
return true
}
converted := translatorcliproxy.FromOpenAINonStream(sdktranslator.FormatGemini, routeModel, raw, translatedReq, body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(converted)
return true
}
func writeGeminiErrorFromOpenAI(w http.ResponseWriter, status int, raw []byte) {
message := strings.TrimSpace(string(raw))
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err == nil {
if errObj, ok := parsed["error"].(map[string]any); ok {
if msg, ok := errObj["message"].(string); ok && strings.TrimSpace(msg) != "" {
message = strings.TrimSpace(msg)
}
}
}
if message == "" {
message = http.StatusText(status)
}
writeGeminiError(w, status, message)
}
func (h *Handler) handleNonStreamGenerateContent(w http.ResponseWriter, resp *http.Response, model, finalPrompt string, thinkingEnabled bool, toolNames []string) {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeGeminiError(w, resp.StatusCode, strings.TrimSpace(string(body)))
return
}
result := sse.CollectStream(resp, thinkingEnabled, true)
writeJSON(w, http.StatusOK, buildGeminiGenerateContentResponse(model, finalPrompt, result.Thinking, result.Text, toolNames, result.OutputTokens))
}
func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, finalText string, toolNames []string, outputTokens int) map[string]any {
parts := buildGeminiPartsFromFinal(finalText, finalThinking, toolNames)
usage := buildGeminiUsage(finalPrompt, finalThinking, finalText, outputTokens)
return map[string]any{
"candidates": []map[string]any{
{
"index": 0,
"content": map[string]any{
"role": "model",
"parts": parts,
},
"finishReason": "STOP",
},
},
"modelVersion": model,
"usageMetadata": usage,
}
}
func buildGeminiUsage(finalPrompt, finalThinking, finalText string, outputTokens int) 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,
"totalTokenCount": promptTokens + reasoningTokens + completionTokens,
}
}
func buildGeminiPartsFromFinal(finalText, finalThinking string, toolNames []string) []map[string]any {
detected := util.ParseToolCalls(finalText, toolNames)
if len(detected) == 0 && strings.TrimSpace(finalThinking) != "" {
detected = util.ParseToolCalls(finalThinking, toolNames)
}
if len(detected) > 0 {
parts := make([]map[string]any, 0, len(detected))
for _, tc := range detected {
parts = append(parts, map[string]any{
"functionCall": map[string]any{
"name": tc.Name,
"args": tc.Input,
},
})
}
return parts
}
text := finalText
if strings.TrimSpace(text) == "" {
text = finalThinking
}
return []map[string]any{{"text": text}}
}

View File

@@ -0,0 +1,33 @@
package gemini
import (
"net/http"
"github.com/go-chi/chi/v5"
"ds2api/internal/util"
)
var writeJSON = util.WriteJSON
type Handler struct {
Store ConfigReader
Auth AuthResolver
DS DeepSeekCaller
OpenAI OpenAIChatRunner
}
func RegisterRoutes(r chi.Router, h *Handler) {
r.Post("/v1beta/models/{model}:generateContent", h.GenerateContent)
r.Post("/v1beta/models/{model}:streamGenerateContent", h.StreamGenerateContent)
r.Post("/v1/models/{model}:generateContent", h.GenerateContent)
r.Post("/v1/models/{model}:streamGenerateContent", h.StreamGenerateContent)
}
func (h *Handler) GenerateContent(w http.ResponseWriter, r *http.Request) {
h.handleGenerateContent(w, r, false)
}
func (h *Handler) StreamGenerateContent(w http.ResponseWriter, r *http.Request) {
h.handleGenerateContent(w, r, true)
}

View File

@@ -0,0 +1,185 @@
package gemini
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"ds2api/internal/deepseek"
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
)
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()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeGeminiError(w, resp.StatusCode, strings.TrimSpace(string(body)))
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
rc := http.NewResponseController(w)
_, canFlush := w.(http.Flusher)
runtime := newGeminiStreamRuntime(w, rc, canFlush, model, finalPrompt, thinkingEnabled, searchEnabled, toolNames)
initialType := "text"
if thinkingEnabled {
initialType = "thinking"
}
streamengine.ConsumeSSE(streamengine.ConsumeConfig{
Context: r.Context(),
Body: resp.Body,
ThinkingEnabled: thinkingEnabled,
InitialType: initialType,
KeepAliveInterval: time.Duration(deepseek.KeepAliveTimeout) * time.Second,
IdleTimeout: time.Duration(deepseek.StreamIdleTimeout) * time.Second,
MaxKeepAliveNoInput: deepseek.MaxKeepaliveCount,
}, streamengine.ConsumeHooks{
OnParsed: runtime.onParsed,
OnFinalize: func(_ streamengine.StopReason, _ error) {
runtime.finalize()
},
})
}
type geminiStreamRuntime struct {
w http.ResponseWriter
rc *http.ResponseController
canFlush bool
model string
finalPrompt string
thinkingEnabled bool
searchEnabled bool
bufferContent bool
toolNames []string
thinking strings.Builder
text strings.Builder
outputTokens int
}
func newGeminiStreamRuntime(
w http.ResponseWriter,
rc *http.ResponseController,
canFlush bool,
model string,
finalPrompt string,
thinkingEnabled bool,
searchEnabled bool,
toolNames []string,
) *geminiStreamRuntime {
return &geminiStreamRuntime{
w: w,
rc: rc,
canFlush: canFlush,
model: model,
finalPrompt: finalPrompt,
thinkingEnabled: thinkingEnabled,
searchEnabled: searchEnabled,
bufferContent: len(toolNames) > 0,
toolNames: toolNames,
}
}
func (s *geminiStreamRuntime) sendChunk(payload map[string]any) {
b, _ := json.Marshal(payload)
_, _ = s.w.Write([]byte("data: "))
_, _ = s.w.Write(b)
_, _ = s.w.Write([]byte("\n\n"))
if s.canFlush {
_ = s.rc.Flush()
}
}
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}
}
contentSeen := false
for _, p := range parsed.Parts {
if p.Text == "" {
continue
}
if p.Type != "thinking" && s.searchEnabled && sse.IsCitation(p.Text) {
continue
}
contentSeen = true
if p.Type == "thinking" {
if s.thinkingEnabled {
s.thinking.WriteString(p.Text)
}
continue
}
s.text.WriteString(p.Text)
if s.bufferContent {
continue
}
s.sendChunk(map[string]any{
"candidates": []map[string]any{
{
"index": 0,
"content": map[string]any{
"role": "model",
"parts": []map[string]any{{"text": p.Text}},
},
},
},
"modelVersion": s.model,
})
}
return streamengine.ParsedDecision{ContentSeen: contentSeen}
}
func (s *geminiStreamRuntime) finalize() {
finalThinking := s.thinking.String()
finalText := s.text.String()
if s.bufferContent {
parts := buildGeminiPartsFromFinal(finalText, finalThinking, s.toolNames)
s.sendChunk(map[string]any{
"candidates": []map[string]any{
{
"index": 0,
"content": map[string]any{
"role": "model",
"parts": parts,
},
},
},
"modelVersion": s.model,
})
}
s.sendChunk(map[string]any{
"candidates": []map[string]any{
{
"index": 0,
"content": map[string]any{
"role": "model",
"parts": []map[string]any{
{"text": ""},
},
},
"finishReason": "STOP",
},
},
"modelVersion": s.model,
"usageMetadata": buildGeminiUsage(s.finalPrompt, finalThinking, finalText, s.outputTokens),
})
}

View File

@@ -0,0 +1,313 @@
package gemini
import (
"bufio"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/auth"
)
type testGeminiConfig struct{}
func (testGeminiConfig) ModelAliases() map[string]string { return nil }
type testGeminiAuth struct {
a *auth.RequestAuth
err error
}
func (m testGeminiAuth) Determine(_ *http.Request) (*auth.RequestAuth, error) {
if m.err != nil {
return nil, m.err
}
if m.a != nil {
return m.a, nil
}
return &auth.RequestAuth{
UseConfigToken: false,
DeepSeekToken: "direct-token",
CallerID: "caller:test",
TriedAccounts: map[string]bool{},
}, nil
}
func (testGeminiAuth) Release(_ *auth.RequestAuth) {}
type testGeminiDS struct {
resp *http.Response
err error
}
func (m testGeminiDS) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "session-id", nil
}
func (m testGeminiDS) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "pow", nil
}
func (m testGeminiDS) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
if m.err != nil {
return nil, m.err
}
return m.resp, nil
}
type geminiOpenAIErrorStub struct {
status int
body string
headers map[string]string
}
func (s geminiOpenAIErrorStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
for k, v := range s.headers {
w.Header().Set(k, v)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(s.status)
_, _ = w.Write([]byte(s.body))
}
type geminiOpenAISuccessStub struct {
stream bool
body string
}
func (s geminiOpenAISuccessStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
if s.stream {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello \"},\"finish_reason\":null}]}\n\n"))
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"world\"},\"finish_reason\":\"stop\"}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
return
}
out := s.body
if strings.TrimSpace(out) == "" {
out = `{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"eval_javascript","arguments":"{\"code\":\"1+1\"}"}}]},"finish_reason":"tool_calls"}]}`
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(out))
}
func makeGeminiUpstreamResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") {
body += "\n"
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func TestGeminiRoutesRegistered(t *testing.T) {
h := &Handler{
Store: testGeminiConfig{},
Auth: testGeminiAuth{err: auth.ErrUnauthorized},
}
r := chi.NewRouter()
RegisterRoutes(r, h)
paths := []string{
"/v1beta/models/gemini-2.5-pro:generateContent",
"/v1beta/models/gemini-2.5-pro:streamGenerateContent",
"/v1/models/gemini-2.5-pro:generateContent",
"/v1/models/gemini-2.5-pro:streamGenerateContent",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code == http.StatusNotFound {
t.Fatalf("expected route %s to be registered, got 404", path)
}
}
}
func TestGenerateContentReturnsFunctionCallParts(t *testing.T) {
h := &Handler{
Store: testGeminiConfig{},
OpenAI: geminiOpenAISuccessStub{
body: `{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"eval_javascript","arguments":"{\"code\":\"1+1\"}"}}]},"finish_reason":"tool_calls"}]}`,
},
}
r := chi.NewRouter()
RegisterRoutes(r, h)
body := `{
"contents":[{"role":"user","parts":[{"text":"call tool"}]}],
"tools":[{"functionDeclarations":[{"name":"eval_javascript","description":"eval","parameters":{"type":"object","properties":{"code":{"type":"string"}}}}]}]
}`
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:generateContent", strings.NewReader(body))
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())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response failed: %v", err)
}
candidates, _ := out["candidates"].([]any)
if len(candidates) == 0 {
t.Fatalf("expected non-empty candidates: %#v", out)
}
c0, _ := candidates[0].(map[string]any)
content, _ := c0["content"].(map[string]any)
parts, _ := content["parts"].([]any)
if len(parts) == 0 {
t.Fatalf("expected non-empty parts: %#v", content)
}
part0, _ := parts[0].(map[string]any)
functionCall, _ := part0["functionCall"].(map[string]any)
if functionCall["name"] != "eval_javascript" {
t.Fatalf("expected functionCall name eval_javascript, got %#v", functionCall)
}
}
func TestGenerateContentMixedToolSnippetAlsoTriggersFunctionCall(t *testing.T) {
h := &Handler{Store: testGeminiConfig{}, OpenAI: geminiOpenAISuccessStub{}}
r := chi.NewRouter()
RegisterRoutes(r, h)
body := `{
"contents":[{"role":"user","parts":[{"text":"call tool"}]}],
"tools":[{"functionDeclarations":[{"name":"eval_javascript","description":"eval","parameters":{"type":"object","properties":{"code":{"type":"string"}}}}]}]
}`
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:generateContent", strings.NewReader(body))
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())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response failed: %v", err)
}
candidates, _ := out["candidates"].([]any)
c0, _ := candidates[0].(map[string]any)
content, _ := c0["content"].(map[string]any)
parts, _ := content["parts"].([]any)
part0, _ := parts[0].(map[string]any)
functionCall, _ := part0["functionCall"].(map[string]any)
if functionCall["name"] != "eval_javascript" {
t.Fatalf("expected functionCall name eval_javascript for mixed snippet, got %#v", functionCall)
}
}
func TestStreamGenerateContentEmitsSSE(t *testing.T) {
h := &Handler{
Store: testGeminiConfig{},
OpenAI: geminiOpenAISuccessStub{stream: true},
}
r := chi.NewRouter()
RegisterRoutes(r, h)
body := `{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse", strings.NewReader(body))
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())
}
frames := extractGeminiSSEFrames(t, rec.Body.String())
if len(frames) == 0 {
t.Fatalf("expected non-empty stream frames, body=%s", rec.Body.String())
}
last := frames[len(frames)-1]
candidates, _ := last["candidates"].([]any)
if len(candidates) == 0 {
t.Fatalf("expected finish frame candidates, got %#v", last)
}
c0, _ := candidates[0].(map[string]any)
content, _ := c0["content"].(map[string]any)
if content == nil {
t.Fatalf("expected non-null content in finish frame, got %#v", c0)
}
parts, _ := content["parts"].([]any)
if len(parts) == 0 {
t.Fatalf("expected non-empty parts in finish frame content, got %#v", content)
}
}
func TestGenerateContentOpenAIProxyErrorUsesGeminiEnvelope(t *testing.T) {
h := &Handler{
Store: testGeminiConfig{},
OpenAI: geminiOpenAIErrorStub{
status: http.StatusUnauthorized,
body: `{"error":{"message":"invalid api key"}}`,
headers: map[string]string{
"WWW-Authenticate": `Bearer realm="example"`,
"Retry-After": "30",
"X-RateLimit-Remaining": "0",
},
},
}
r := chi.NewRouter()
RegisterRoutes(r, h)
req := httptest.NewRequest(http.MethodPost, "/v1/models/gemini-2.5-pro:generateContent", strings.NewReader(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d body=%s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("expected json body: %v", err)
}
errObj, _ := out["error"].(map[string]any)
if errObj["status"] != "UNAUTHENTICATED" {
t.Fatalf("expected Gemini status UNAUTHENTICATED, got=%v", errObj["status"])
}
if errObj["message"] != "invalid api key" {
t.Fatalf("expected parsed error message, got=%v", errObj["message"])
}
if got := rec.Header().Get("WWW-Authenticate"); got == "" {
t.Fatalf("expected WWW-Authenticate header to be preserved")
}
if got := rec.Header().Get("Retry-After"); got != "30" {
t.Fatalf("expected Retry-After header 30, got=%q", got)
}
if got := rec.Header().Get("X-RateLimit-Remaining"); got != "0" {
t.Fatalf("expected X-RateLimit-Remaining header 0, got=%q", got)
}
}
func extractGeminiSSEFrames(t *testing.T, body string) []map[string]any {
t.Helper()
scanner := bufio.NewScanner(strings.NewReader(body))
out := make([]map[string]any, 0, 4)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
raw := line
if strings.HasPrefix(line, "data: ") {
raw = strings.TrimSpace(strings.TrimPrefix(line, "data: "))
}
if raw == "" {
continue
}
var frame map[string]any
if err := json.Unmarshal([]byte(raw), &frame); err != nil {
continue
}
out = append(out, frame)
}
return out
}

View File

@@ -0,0 +1,42 @@
package gemini
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type openAIProxyStub struct {
status int
body string
}
func (s openAIProxyStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
if s.status == 0 {
s.status = http.StatusOK
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(s.status)
_, _ = w.Write([]byte(s.body))
}
func TestGeminiProxyViaOpenAIVercelReleasePassthrough(t *testing.T) {
h := &Handler{OpenAI: openAIProxyStub{status: 200, body: `{"success":true}`}}
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:streamGenerateContent?__stream_release=1", strings.NewReader(`{"lease_id":"lease_123"}`))
rec := httptest.NewRecorder()
h.StreamGenerateContent(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("expected json response, got err=%v body=%s", err, rec.Body.String())
}
if v, ok := out["success"].(bool); !ok || !v {
t.Fatalf("expected success=true passthrough, got=%v", out)
}
}

View File

@@ -0,0 +1,292 @@
package openai
import (
"encoding/json"
"net/http"
"strings"
openaifmt "ds2api/internal/format/openai"
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
"ds2api/internal/util"
)
type chatStreamRuntime struct {
w http.ResponseWriter
rc *http.ResponseController
canFlush bool
completionID string
created int64
model string
finalPrompt string
toolNames []string
thinkingEnabled bool
searchEnabled bool
firstChunkSent bool
bufferToolContent bool
emitEarlyToolDeltas bool
toolCallsEmitted bool
toolCallsDoneEmitted bool
toolSieve toolStreamSieveState
streamToolCallIDs map[int]string
streamToolNames map[int]string
thinking strings.Builder
text strings.Builder
outputTokens int
}
func newChatStreamRuntime(
w http.ResponseWriter,
rc *http.ResponseController,
canFlush bool,
completionID string,
created int64,
model string,
finalPrompt string,
thinkingEnabled bool,
searchEnabled bool,
toolNames []string,
bufferToolContent bool,
emitEarlyToolDeltas bool,
) *chatStreamRuntime {
return &chatStreamRuntime{
w: w,
rc: rc,
canFlush: canFlush,
completionID: completionID,
created: created,
model: model,
finalPrompt: finalPrompt,
toolNames: toolNames,
thinkingEnabled: thinkingEnabled,
searchEnabled: searchEnabled,
bufferToolContent: bufferToolContent,
emitEarlyToolDeltas: emitEarlyToolDeltas,
streamToolCallIDs: map[int]string{},
streamToolNames: map[int]string{},
}
}
func (s *chatStreamRuntime) sendKeepAlive() {
if !s.canFlush {
return
}
_, _ = s.w.Write([]byte(": keep-alive\n\n"))
_ = s.rc.Flush()
}
func (s *chatStreamRuntime) sendChunk(v any) {
b, _ := json.Marshal(v)
_, _ = s.w.Write([]byte("data: "))
_, _ = s.w.Write(b)
_, _ = s.w.Write([]byte("\n\n"))
if s.canFlush {
_ = s.rc.Flush()
}
}
func (s *chatStreamRuntime) sendDone() {
_, _ = s.w.Write([]byte("data: [DONE]\n\n"))
if s.canFlush {
_ = s.rc.Flush()
}
}
func (s *chatStreamRuntime) finalize(finishReason string) {
finalThinking := s.thinking.String()
finalText := sanitizeLeakedOutput(s.text.String())
detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls"
delta := map[string]any{
"tool_calls": formatFinalStreamToolCallsWithStableIDs(detected.Calls, s.streamToolCallIDs),
}
if !s.firstChunkSent {
delta["role"] = "assistant"
s.firstChunkSent = true
}
s.sendChunk(openaifmt.BuildChatStreamChunk(
s.completionID,
s.created,
s.model,
[]map[string]any{openaifmt.BuildChatStreamDeltaChoice(0, delta)},
nil,
))
s.toolCallsEmitted = true
s.toolCallsDoneEmitted = true
} else if s.bufferToolContent {
for _, evt := range flushToolSieve(&s.toolSieve, s.toolNames) {
if len(evt.ToolCalls) > 0 {
finishReason = "tool_calls"
s.toolCallsEmitted = true
s.toolCallsDoneEmitted = true
tcDelta := map[string]any{
"tool_calls": formatFinalStreamToolCallsWithStableIDs(evt.ToolCalls, s.streamToolCallIDs),
}
if !s.firstChunkSent {
tcDelta["role"] = "assistant"
s.firstChunkSent = true
}
s.sendChunk(openaifmt.BuildChatStreamChunk(
s.completionID,
s.created,
s.model,
[]map[string]any{openaifmt.BuildChatStreamDeltaChoice(0, tcDelta)},
nil,
))
}
if evt.Content == "" {
continue
}
cleaned := sanitizeLeakedOutput(evt.Content)
if cleaned == "" {
continue
}
delta := map[string]any{
"content": cleaned,
}
if !s.firstChunkSent {
delta["role"] = "assistant"
s.firstChunkSent = true
}
s.sendChunk(openaifmt.BuildChatStreamChunk(
s.completionID,
s.created,
s.model,
[]map[string]any{openaifmt.BuildChatStreamDeltaChoice(0, delta)},
nil,
))
}
}
if len(detected.Calls) > 0 || s.toolCallsEmitted {
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,
s.model,
[]map[string]any{openaifmt.BuildChatStreamFinishChoice(0, finishReason)},
usage,
))
s.sendDone()
}
func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedDecision {
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}
}
if parsed.ErrorMessage != "" {
return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReason("content_filter")}
}
if parsed.Stop {
return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReasonHandlerRequested}
}
newChoices := make([]map[string]any, 0, len(parsed.Parts))
contentSeen := false
for _, p := range parsed.Parts {
if s.searchEnabled && sse.IsCitation(p.Text) {
continue
}
if p.Text == "" {
continue
}
contentSeen = true
delta := map[string]any{}
if !s.firstChunkSent {
delta["role"] = "assistant"
s.firstChunkSent = true
}
if p.Type == "thinking" {
if s.thinkingEnabled {
s.thinking.WriteString(p.Text)
delta["reasoning_content"] = p.Text
}
} else {
s.text.WriteString(p.Text)
if !s.bufferToolContent {
delta["content"] = p.Text
} else {
events := processToolSieveChunk(&s.toolSieve, p.Text, s.toolNames)
for _, evt := range events {
if len(evt.ToolCallDeltas) > 0 {
if !s.emitEarlyToolDeltas {
continue
}
filtered := filterIncrementalToolCallDeltasByAllowed(evt.ToolCallDeltas, s.toolNames, s.streamToolNames)
if len(filtered) == 0 {
continue
}
formatted := formatIncrementalStreamToolCallDeltas(filtered, s.streamToolCallIDs)
if len(formatted) == 0 {
continue
}
tcDelta := map[string]any{
"tool_calls": formatted,
}
s.toolCallsEmitted = true
if !s.firstChunkSent {
tcDelta["role"] = "assistant"
s.firstChunkSent = true
}
newChoices = append(newChoices, openaifmt.BuildChatStreamDeltaChoice(0, tcDelta))
continue
}
if len(evt.ToolCalls) > 0 {
s.toolCallsEmitted = true
s.toolCallsDoneEmitted = true
tcDelta := map[string]any{
"tool_calls": formatFinalStreamToolCallsWithStableIDs(evt.ToolCalls, s.streamToolCallIDs),
}
if !s.firstChunkSent {
tcDelta["role"] = "assistant"
s.firstChunkSent = true
}
newChoices = append(newChoices, openaifmt.BuildChatStreamDeltaChoice(0, tcDelta))
continue
}
if evt.Content != "" {
cleaned := sanitizeLeakedOutput(evt.Content)
if cleaned == "" {
continue
}
contentDelta := map[string]any{
"content": cleaned,
}
if !s.firstChunkSent {
contentDelta["role"] = "assistant"
s.firstChunkSent = true
}
newChoices = append(newChoices, openaifmt.BuildChatStreamDeltaChoice(0, contentDelta))
}
}
}
}
if len(delta) > 0 {
newChoices = append(newChoices, openaifmt.BuildChatStreamDeltaChoice(0, delta))
}
}
if len(newChoices) > 0 {
s.sendChunk(openaifmt.BuildChatStreamChunk(s.completionID, s.created, s.model, newChoices, nil))
}
return streamengine.ParsedDecision{ContentSeen: contentSeen}
}

View File

@@ -0,0 +1,37 @@
package openai
import (
"context"
"net/http"
"ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/deepseek"
)
type AuthResolver interface {
Determine(req *http.Request) (*auth.RequestAuth, error)
DetermineCaller(req *http.Request) (*auth.RequestAuth, error)
Release(a *auth.RequestAuth)
}
type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error
}
type ConfigReader interface {
ModelAliases() map[string]string
CompatWideInputStrictOutput() bool
ToolcallMode() string
ToolcallEarlyEmitConfidence() string
ResponsesStoreTTLSeconds() int
EmbeddingsProvider() string
AutoDeleteSessions() bool
}
var _ AuthResolver = (*auth.Resolver)(nil)
var _ DeepSeekCaller = (*deepseek.Client)(nil)
var _ ConfigReader = (*config.Store)(nil)

View File

@@ -0,0 +1,71 @@
package openai
import "testing"
type mockOpenAIConfig struct {
aliases map[string]string
wideInput bool
toolMode string
earlyEmit string
responsesTTL int
embedProv string
}
func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases }
func (m mockOpenAIConfig) CompatWideInputStrictOutput() bool {
return m.wideInput
}
func (m mockOpenAIConfig) ToolcallMode() string { return m.toolMode }
func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit }
func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL }
func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv }
func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false }
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
cfg := mockOpenAIConfig{
aliases: map[string]string{
"my-model": "deepseek-chat-search",
},
wideInput: true,
}
req := map[string]any{
"model": "my-model",
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
}
out, err := normalizeOpenAIChatRequest(cfg, req, "")
if err != nil {
t.Fatalf("normalizeOpenAIChatRequest error: %v", err)
}
if out.ResolvedModel != "deepseek-chat-search" {
t.Fatalf("resolved model mismatch: got=%q", out.ResolvedModel)
}
if !out.Search || out.Thinking {
t.Fatalf("unexpected model flags: thinking=%v search=%v", out.Thinking, out.Search)
}
}
func TestNormalizeOpenAIResponsesRequestWideInputPolicyFromInterface(t *testing.T) {
req := map[string]any{
"model": "deepseek-chat",
"input": "hi",
}
_, err := normalizeOpenAIResponsesRequest(mockOpenAIConfig{
aliases: map[string]string{},
wideInput: false,
}, req, "")
if err == nil {
t.Fatal("expected error when wide input is disabled and only input is provided")
}
out, err := normalizeOpenAIResponsesRequest(mockOpenAIConfig{
aliases: map[string]string{},
wideInput: true,
}, req, "")
if err != nil {
t.Fatalf("unexpected error when wide input is enabled: %v", err)
}
if out.Surface != "openai_responses" {
t.Fatalf("unexpected surface: %q", out.Surface)
}
}

View File

@@ -0,0 +1,138 @@
package openai
import (
"crypto/sha256"
"encoding/binary"
"encoding/json"
"fmt"
"net/http"
"strings"
"ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/util"
)
func (h *Handler) Embeddings(w http.ResponseWriter, r *http.Request) {
a, err := h.Auth.Determine(r)
if err != nil {
status := http.StatusUnauthorized
detail := err.Error()
if err == auth.ErrNoAccount {
status = http.StatusTooManyRequests
}
writeOpenAIError(w, status, detail)
return
}
defer h.Auth.Release(a)
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeOpenAIError(w, http.StatusBadRequest, "invalid json")
return
}
model, _ := req["model"].(string)
model = strings.TrimSpace(model)
if model == "" {
writeOpenAIError(w, http.StatusBadRequest, "Request must include 'model'.")
return
}
if _, ok := config.ResolveModel(h.Store, model); !ok {
writeOpenAIError(w, http.StatusBadRequest, fmt.Sprintf("Model '%s' is not available.", model))
return
}
inputs := extractEmbeddingInputs(req["input"])
if len(inputs) == 0 {
writeOpenAIError(w, http.StatusBadRequest, "Request must include non-empty 'input'.")
return
}
provider := ""
if h.Store != nil {
provider = strings.ToLower(strings.TrimSpace(h.Store.EmbeddingsProvider()))
}
if provider == "" {
writeOpenAIError(w, http.StatusNotImplemented, "Embeddings provider is not configured. Set embeddings.provider in config.")
return
}
switch provider {
case "mock", "deterministic", "builtin":
// supported local deterministic provider
default:
writeOpenAIError(w, http.StatusNotImplemented, fmt.Sprintf("Embeddings provider '%s' is not supported.", provider))
return
}
data := make([]map[string]any, 0, len(inputs))
totalTokens := 0
for i, input := range inputs {
totalTokens += util.EstimateTokens(input)
data = append(data, map[string]any{
"object": "embedding",
"index": i,
"embedding": deterministicEmbedding(input),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"object": "list",
"data": data,
"model": model,
"usage": map[string]any{
"prompt_tokens": totalTokens,
"total_tokens": totalTokens,
},
})
}
func extractEmbeddingInputs(raw any) []string {
switch v := raw.(type) {
case string:
s := strings.TrimSpace(v)
if s == "" {
return nil
}
return []string{s}
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
switch iv := item.(type) {
case string:
s := strings.TrimSpace(iv)
if s != "" {
out = append(out, s)
}
case []any:
// Token array input support: convert to stable string form.
out = append(out, fmt.Sprintf("%v", iv))
default:
s := strings.TrimSpace(fmt.Sprintf("%v", iv))
if s != "" {
out = append(out, s)
}
}
}
return out
default:
return nil
}
}
func deterministicEmbedding(input string) []float64 {
// Keep response shape stable without external dependencies.
const dims = 64
out := make([]float64, dims)
seed := sha256.Sum256([]byte(input))
buf := seed[:]
for i := 0; i < dims; i++ {
if len(buf) < 4 {
next := sha256.Sum256(buf)
buf = next[:]
}
v := binary.BigEndian.Uint32(buf[:4])
buf = buf[4:]
// map [0, 2^32) -> [-1, 1]
out[i] = (float64(v)/2147483647.5 - 1.0)
}
return out
}

View File

@@ -0,0 +1,96 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/account"
"ds2api/internal/auth"
"ds2api/internal/config"
)
func newResolverWithConfigJSON(t *testing.T, cfgJSON string) (*config.Store, *auth.Resolver) {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", cfgJSON)
store := config.LoadStore()
pool := account.NewPool(store)
resolver := auth.NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) {
return "unused", nil
})
return store, resolver
}
func TestEmbeddingsRouteContract(t *testing.T) {
store, resolver := newResolverWithConfigJSON(t, `{"embeddings":{"provider":"deterministic"}}`)
h := &Handler{Store: store, Auth: resolver}
r := chi.NewRouter()
RegisterRoutes(r, h)
t.Run("unauthorized", func(t *testing.T) {
body := bytes.NewBufferString(`{"model":"gpt-4o","input":"hello"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/embeddings", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("ok", func(t *testing.T) {
body := bytes.NewBufferString(`{"model":"gpt-4o","input":["a","b"]}`)
req := httptest.NewRequest(http.MethodPost, "/v1/embeddings", body)
req.Header.Set("Authorization", "Bearer test-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())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if out["object"] != "list" {
t.Fatalf("unexpected object: %#v", out["object"])
}
data, _ := out["data"].([]any)
if len(data) != 2 {
t.Fatalf("expected 2 embeddings, got %d", len(data))
}
})
}
func TestEmbeddingsRouteProviderMissing(t *testing.T) {
store, resolver := newResolverWithConfigJSON(t, `{}`)
h := &Handler{Store: store, Auth: resolver}
r := chi.NewRouter()
RegisterRoutes(r, h)
body := bytes.NewBufferString(`{"model":"gpt-4o","input":"hello"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/embeddings", body)
req.Header.Set("Authorization", "Bearer test-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("expected 501, got %d body=%s", rec.Code, rec.Body.String())
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response failed: %v", err)
}
errObj, _ := out["error"].(map[string]any)
if _, ok := errObj["code"]; !ok {
t.Fatalf("expected error.code in response: %#v", out)
}
if _, ok := errObj["param"]; !ok {
t.Fatalf("expected error.param in response: %#v", out)
}
}

View File

@@ -0,0 +1,34 @@
package openai
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestWriteOpenAIErrorIncludesUnifiedFields(t *testing.T) {
rec := httptest.NewRecorder()
writeOpenAIError(rec, http.StatusBadRequest, "invalid input")
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
errObj, _ := body["error"].(map[string]any)
if errObj["message"] != "invalid input" {
t.Fatalf("unexpected message: %v", errObj["message"])
}
if errObj["type"] != "invalid_request_error" {
t.Fatalf("unexpected type: %v", errObj["type"])
}
if errObj["code"] != "invalid_request" {
t.Fatalf("unexpected code: %v", errObj["code"])
}
if _, ok := errObj["param"]; !ok {
t.Fatal("expected param field")
}
}

View File

@@ -0,0 +1,182 @@
package openai
import (
"context"
"encoding/json"
"io"
"net/http"
"time"
"ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/deepseek"
openaifmt "ds2api/internal/format/openai"
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
)
func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
if isVercelStreamReleaseRequest(r) {
h.handleVercelStreamRelease(w, r)
return
}
if isVercelStreamPrepareRequest(r) {
h.handleVercelStreamPrepare(w, r)
return
}
a, err := h.Auth.Determine(r)
if err != nil {
status := http.StatusUnauthorized
detail := err.Error()
if err == auth.ErrNoAccount {
status = http.StatusTooManyRequests
}
writeOpenAIError(w, status, detail)
return
}
defer func() {
// 自动删除会话(同步)
// 必须在 Release 之前同步删除,否则:
// 1. 异步删除时账号已被 Release
// 2. 新请求可能获取到同一账号并开始使用
// 3. 异步删除仍在进行,会截断新请求正在使用的会话
if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" {
deleteCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
err := h.DS.DeleteAllSessionsForToken(deleteCtx, a.DeepSeekToken)
if err != nil {
config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "error", err)
} else {
config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID)
}
}
h.Auth.Release(a)
}()
r = r.WithContext(auth.WithAuth(r.Context(), a))
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeOpenAIError(w, http.StatusBadRequest, "invalid json")
return
}
stdReq, err := normalizeOpenAIChatRequest(h.Store, req, requestTraceID(r))
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error())
return
}
sessionID, err := h.DS.CreateSession(r.Context(), a, 3)
if err != nil {
if a.UseConfigToken {
writeOpenAIError(w, http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin.")
} else {
writeOpenAIError(w, http.StatusUnauthorized, "Invalid token. If this should be a DS2API key, add it to config.keys first.")
}
return
}
pow, err := h.DS.GetPow(r.Context(), a, 3)
if err != nil {
writeOpenAIError(w, http.StatusUnauthorized, "Failed to get PoW (invalid token or unknown error).")
return
}
payload := stdReq.CompletionPayload(sessionID)
resp, err := h.DS.CallCompletion(r.Context(), a, payload, pow, 3)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "Failed to get completion.")
return
}
if stdReq.Stream {
h.handleStream(w, r, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames)
return
}
h.handleNonStream(w, r.Context(), resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.ToolNames)
}
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()
body, _ := io.ReadAll(resp.Body)
writeOpenAIError(w, resp.StatusCode, string(body))
return
}
_ = ctx
result := sse.CollectStream(resp, thinkingEnabled, true)
finalThinking := result.Thinking
finalText := sanitizeLeakedOutput(result.Text)
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()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeOpenAIError(w, resp.StatusCode, string(body))
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
rc := http.NewResponseController(w)
_, canFlush := w.(http.Flusher)
if !canFlush {
config.Logger.Warn("[stream] response writer does not support flush; streaming may be buffered")
}
created := time.Now().Unix()
bufferToolContent := len(toolNames) > 0
emitEarlyToolDeltas := h.toolcallFeatureMatchEnabled() && h.toolcallEarlyEmitHighConfidence()
initialType := "text"
if thinkingEnabled {
initialType = "thinking"
}
streamRuntime := newChatStreamRuntime(
w,
rc,
canFlush,
completionID,
created,
model,
finalPrompt,
thinkingEnabled,
searchEnabled,
toolNames,
bufferToolContent,
emitEarlyToolDeltas,
)
streamengine.ConsumeSSE(streamengine.ConsumeConfig{
Context: r.Context(),
Body: resp.Body,
ThinkingEnabled: thinkingEnabled,
InitialType: initialType,
KeepAliveInterval: time.Duration(deepseek.KeepAliveTimeout) * time.Second,
IdleTimeout: time.Duration(deepseek.StreamIdleTimeout) * time.Second,
MaxKeepAliveNoInput: deepseek.MaxKeepaliveCount,
}, streamengine.ConsumeHooks{
OnKeepAlive: func() {
streamRuntime.sendKeepAlive()
},
OnParsed: streamRuntime.onParsed,
OnFinalize: func(reason streamengine.StopReason, _ error) {
if string(reason) == "content_filter" {
streamRuntime.finalize("content_filter")
return
}
streamRuntime.finalize("stop")
},
})
}

View File

@@ -0,0 +1,63 @@
package openai
import "net/http"
func writeOpenAIError(w http.ResponseWriter, status int, message string) {
writeOpenAIErrorWithCode(w, status, message, "")
}
func writeOpenAIErrorWithCode(w http.ResponseWriter, status int, message, code string) {
if code == "" {
code = openAIErrorCode(status)
}
writeJSON(w, status, map[string]any{
"error": map[string]any{
"message": message,
"type": openAIErrorType(status),
"code": code,
"param": nil,
},
})
}
func openAIErrorType(status int) string {
switch status {
case http.StatusBadRequest:
return "invalid_request_error"
case http.StatusUnauthorized:
return "authentication_error"
case http.StatusForbidden:
return "permission_error"
case http.StatusTooManyRequests:
return "rate_limit_error"
case http.StatusServiceUnavailable:
return "service_unavailable_error"
default:
if status >= 500 {
return "api_error"
}
return "invalid_request_error"
}
}
func openAIErrorCode(status int) string {
switch status {
case http.StatusBadRequest:
return "invalid_request"
case http.StatusUnauthorized:
return "authentication_failed"
case http.StatusForbidden:
return "forbidden"
case http.StatusTooManyRequests:
return "rate_limit_exceeded"
case http.StatusNotFound:
return "not_found"
case http.StatusServiceUnavailable:
return "service_unavailable"
default:
if status >= 500 {
return "internal_error"
}
return "invalid_request"
}
}

View File

@@ -0,0 +1,57 @@
package openai
import (
"net/http"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/util"
)
// writeJSON is a package-internal alias kept to avoid mass-renaming across
// every call-site in this package.
var writeJSON = util.WriteJSON
type Handler struct {
Store ConfigReader
Auth AuthResolver
DS DeepSeekCaller
leaseMu sync.Mutex
streamLeases map[string]streamLease
responsesMu sync.Mutex
responses *responseStore
}
type streamLease struct {
Auth *auth.RequestAuth
ExpiresAt time.Time
}
func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/v1/models", h.ListModels)
r.Get("/v1/models/{model_id}", h.GetModel)
r.Post("/v1/chat/completions", h.ChatCompletions)
r.Post("/v1/responses", h.Responses)
r.Get("/v1/responses/{response_id}", h.GetResponseByID)
r.Post("/v1/embeddings", h.Embeddings)
}
func (h *Handler) ListModels(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, config.OpenAIModelsResponse())
}
func (h *Handler) GetModel(w http.ResponseWriter, r *http.Request) {
modelID := strings.TrimSpace(chi.URLParam(r, "model_id"))
model, ok := config.OpenAIModelByID(h.Store, modelID)
if !ok {
writeOpenAIError(w, http.StatusNotFound, "Model not found.")
return
}
writeJSON(w, http.StatusOK, model)
}

View File

@@ -0,0 +1,169 @@
package openai
import (
"encoding/json"
"fmt"
"strings"
"github.com/google/uuid"
"ds2api/internal/util"
)
func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolChoicePolicy) ([]map[string]any, []string) {
if policy.IsNone() {
return messages, nil
}
toolSchemas := make([]string, 0, len(tools))
names := make([]string, 0, len(tools))
isAllowed := func(name string) bool {
if strings.TrimSpace(name) == "" {
return false
}
if len(policy.Allowed) == 0 {
return true
}
_, ok := policy.Allowed[name]
return ok
}
for _, t := range tools {
tool, ok := t.(map[string]any)
if !ok {
continue
}
fn, _ := tool["function"].(map[string]any)
if len(fn) == 0 {
fn = tool
}
name, _ := fn["name"].(string)
desc, _ := fn["description"].(string)
schema, _ := fn["parameters"].(map[string]any)
name = strings.TrimSpace(name)
if !isAllowed(name) {
continue
}
names = append(names, name)
if desc == "" {
desc = "No description available"
}
b, _ := json.Marshal(schema)
toolSchemas = append(toolSchemas, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, string(b)))
}
if len(toolSchemas) == 0 {
return messages, names
}
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\n" + buildToolCallInstructions(names)
if policy.Mode == util.ToolChoiceRequired {
toolPrompt += "\n7) For this response, you MUST call at least one tool from the allowed list."
}
if policy.Mode == util.ToolChoiceForced && strings.TrimSpace(policy.ForcedName) != "" {
toolPrompt += "\n7) For this response, you MUST call exactly this tool name: " + strings.TrimSpace(policy.ForcedName)
toolPrompt += "\n8) Do not call any other tool."
}
for i := range messages {
if messages[i]["role"] == "system" {
old, _ := messages[i]["content"].(string)
messages[i]["content"] = strings.TrimSpace(old + "\n\n" + toolPrompt)
return messages, names
}
}
messages = append([]map[string]any{{"role": "system", "content": toolPrompt}}, messages...)
return messages, names
}
// buildToolCallInstructions delegates to the shared util implementation.
func buildToolCallInstructions(toolNames []string) string {
return util.BuildToolCallInstructions(toolNames)
}
func formatIncrementalStreamToolCallDeltas(deltas []toolCallDelta, ids map[int]string) []map[string]any {
if len(deltas) == 0 {
return nil
}
out := make([]map[string]any, 0, len(deltas))
for _, d := range deltas {
if d.Name == "" && d.Arguments == "" {
continue
}
callID, ok := ids[d.Index]
if !ok || callID == "" {
callID = "call_" + strings.ReplaceAll(uuid.NewString(), "-", "")
ids[d.Index] = callID
}
item := map[string]any{
"index": d.Index,
"id": callID,
"type": "function",
}
fn := map[string]any{}
if d.Name != "" {
fn["name"] = d.Name
}
if d.Arguments != "" {
fn["arguments"] = d.Arguments
}
if len(fn) > 0 {
item["function"] = fn
}
out = append(out, item)
}
return out
}
func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, allowedNames []string, seenNames map[int]string) []toolCallDelta {
if len(deltas) == 0 {
return nil
}
out := make([]toolCallDelta, 0, len(deltas))
for _, d := range deltas {
if d.Name != "" {
if seenNames != nil {
seenNames[d.Index] = d.Name
}
out = append(out, d)
continue
}
if seenNames == nil {
out = append(out, d)
continue
}
name := strings.TrimSpace(seenNames[d.Index])
if name == "" {
continue
}
out = append(out, d)
}
return out
}
func formatFinalStreamToolCallsWithStableIDs(calls []util.ParsedToolCall, ids map[int]string) []map[string]any {
if len(calls) == 0 {
return nil
}
out := make([]map[string]any, 0, len(calls))
for i, c := range calls {
callID := ""
if ids != nil {
callID = strings.TrimSpace(ids[i])
}
if callID == "" {
callID = "call_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if ids != nil {
ids[i] = callID
}
}
args, _ := json.Marshal(c.Input)
out = append(out, map[string]any{
"index": i,
"id": callID,
"type": "function",
"function": map[string]any{
"name": c.Name,
"arguments": string(args),
},
})
}
return out
}

View File

@@ -0,0 +1,9 @@
package openai
func (h *Handler) toolcallFeatureMatchEnabled() bool {
return true
}
func (h *Handler) toolcallEarlyEmitHighConfidence() bool {
return true
}

View File

@@ -0,0 +1,921 @@
package openai
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func makeSSEHTTPResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") {
body += "\n"
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func decodeJSONBody(t *testing.T, body string) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("decode json failed: %v, body=%s", err, body)
}
return out
}
func parseSSEDataFrames(t *testing.T, body string) ([]map[string]any, bool) {
t.Helper()
lines := strings.Split(body, "\n")
frames := make([]map[string]any, 0, len(lines))
done := false
for _, line := range lines {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" {
continue
}
if payload == "[DONE]" {
done = true
continue
}
var frame map[string]any
if err := json.Unmarshal([]byte(payload), &frame); err != nil {
t.Fatalf("decode sse frame failed: %v, payload=%s", err, payload)
}
frames = append(frames, frame)
}
return frames, done
}
func streamHasRawToolJSONContent(frames []map[string]any) bool {
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
content, _ := delta["content"].(string)
if strings.Contains(content, `"tool_calls"`) {
return true
}
}
}
return false
}
func streamHasToolCallsDelta(frames []map[string]any) bool {
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if _, ok := delta["tool_calls"]; ok {
return true
}
}
}
return false
}
func streamFinishReason(frames []map[string]any) string {
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
if reason, ok := choice["finish_reason"].(string); ok && reason != "" {
return reason
}
}
}
return ""
}
func streamToolCallArgumentChunks(frames []map[string]any) []string {
out := make([]string, 0, 4)
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
toolCalls, _ := delta["tool_calls"].([]any)
for _, tc := range toolCalls {
tcm, _ := tc.(map[string]any)
fn, _ := tcm["function"].(map[string]any)
if args, ok := fn["arguments"].(string); ok && args != "" {
out = append(out, args)
}
}
}
}
return out
}
func TestHandleNonStreamToolCallInterceptsChatModel(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, context.Background(), resp, "cid1", "deepseek-chat", "prompt", false, []string{"search"})
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
if len(choices) != 1 {
t.Fatalf("unexpected choices: %#v", out["choices"])
}
choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
}
msg, _ := choice["message"].(map[string]any)
if msg["content"] != nil {
t.Fatalf("expected content nil, got %#v", msg["content"])
}
toolCalls, _ := msg["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected 1 tool call, got %#v", msg["tool_calls"])
}
}
func TestHandleNonStreamToolCallInterceptsReasonerModel(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"先想一下"}`,
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, context.Background(), resp, "cid2", "deepseek-reasoner", "prompt", true, []string{"search"})
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
msg, _ := choice["message"].(map[string]any)
if msg["reasoning_content"] != "先想一下" {
t.Fatalf("expected reasoning_content, got %#v", msg["reasoning_content"])
}
if msg["content"] != nil {
t.Fatalf("expected content nil, got %#v", msg["content"])
}
if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
}
}
func TestHandleNonStreamUnknownToolIntercepted(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, context.Background(), resp, "cid2b", "deepseek-chat", "prompt", false, []string{"search"})
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
}
msg, _ := choice["message"].(map[string]any)
toolCalls, _ := msg["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected tool_calls for unknown schema name, got %#v", msg["tool_calls"])
}
}
func TestHandleNonStreamEmbeddedToolCallExamplePromotesToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"下面是示例:"}`,
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`,
`data: {"p":"response/content","v":"请勿执行。"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, context.Background(), resp, "cid2c", "deepseek-chat", "prompt", false, []string{"search"})
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
}
msg, _ := choice["message"].(map[string]any)
toolCalls, _ := msg["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected one tool_call field for embedded example: %#v", msg["tool_calls"])
}
content, _ := msg["content"].(string)
if strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected raw tool_calls json stripped from content, got %#v", content)
}
}
func TestHandleNonStreamFencedToolCallExampleDoesNotPromoteToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"search\\\",\\\"input\\\":{\\\"q\\\":\\\"go\\\"}}]}\\n```\"}",
`data: [DONE]`,
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, context.Background(), resp, "cid2d", "deepseek-chat", "prompt", false, []string{"search"})
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] == "tool_calls" {
t.Fatalf("expected fenced example to remain content-only, got finish_reason=%#v", choice["finish_reason"])
}
msg, _ := choice["message"].(map[string]any)
toolCalls, _ := msg["tool_calls"].([]any)
if len(toolCalls) != 0 {
t.Fatalf("expected no tool_call field for fenced example: %#v", msg["tool_calls"])
}
content, _ := msg["content"].(string)
if !strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected fenced example content preserved, got %q", content)
}
}
// Backward-compatible alias for historical test name used in CI logs.
func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
TestHandleNonStreamFencedToolCallExampleDoesNotPromoteToolCall(t)
}
func TestHandleStreamToolCallInterceptsWithoutRawContentLeak(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
`data: {"p":"response/content","v":",\"input\":{\"q\":\"go\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid3", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
}
foundToolIndex := false
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
toolCalls, _ := delta["tool_calls"].([]any)
for _, tc := range toolCalls {
tcm, _ := tc.(map[string]any)
if _, ok := tcm["index"].(float64); ok {
foundToolIndex = true
}
}
}
}
if !foundToolIndex {
t.Fatalf("expected stream tool_calls item with index, body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("raw tool_calls JSON leaked in content delta: %s", rec.Body.String())
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamToolCallLargeArgumentsStillIntercepted(t *testing.T) {
h := &Handler{}
large := strings.Repeat("a", 9000)
payload := fmt.Sprintf(`{"tool_calls":[{"name":"search","input":{"q":"%s"}}]}`, large)
splitAt := len(payload) / 2
resp := makeSSEHTTPResponse(
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, payload[:splitAt]),
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, payload[splitAt:]),
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid3-large", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("raw tool_calls JSON leaked in content delta: %s", rec.Body.String())
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamReasonerToolCallInterceptsWithoutRawContentLeak(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"思考中"}`,
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid4", "deepseek-reasoner", "prompt", true, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
}
foundToolIndex := false
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
toolCalls, _ := delta["tool_calls"].([]any)
for _, tc := range toolCalls {
tcm, _ := tc.(map[string]any)
if _, ok := tcm["index"].(float64); ok {
foundToolIndex = true
}
}
}
}
if !foundToolIndex {
t.Fatalf("expected stream tool_calls item with index, body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("raw tool_calls JSON leaked in content delta: %s", rec.Body.String())
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
hasThinkingDelta := false
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if _, ok := delta["reasoning_content"]; ok {
hasThinkingDelta = true
}
}
}
if !hasThinkingDelta {
t.Fatalf("expected reasoning_content delta in reasoner stream: %s", rec.Body.String())
}
}
func TestHandleStreamUnknownToolEmitsToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid5", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta for unknown schema name, body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name: %s", rec.Body.String())
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamUnknownToolNoArgsEmitsToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\"}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid5b", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name (no args): %s", rec.Body.String())
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamToolsPlainTextStreamsBeforeFinish(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"你好,"}`,
`data: {"p":"response/content","v":"这是普通文本回复。"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid6", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if streamHasToolCallsDelta(frames) {
t.Fatalf("did not expect tool_calls delta for plain text: %s", rec.Body.String())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if c, ok := delta["content"].(string); ok {
content.WriteString(c)
}
}
}
if got := content.String(); got == "" {
t.Fatalf("expected streamed content in tool mode plain text, body=%s", rec.Body.String())
}
if streamFinishReason(frames) != "stop" {
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
}
}
func TestHandleStreamToolCallMixedWithPlainTextSegments(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"下面是示例:"}`,
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`,
`data: {"p":"response/content","v":"请勿执行。"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid7", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta in mixed prose stream, body=%s", rec.Body.String())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if c, ok := delta["content"].(string); ok {
content.WriteString(c)
}
}
}
got := content.String()
if !strings.Contains(got, "下面是示例:") || !strings.Contains(got, "请勿执行。") {
t.Fatalf("expected pre/post plain text to pass sieve, got=%q", got)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls for mixed prose, body=%s", rec.Body.String())
}
}
func TestHandleStreamToolCallAfterLeadingTextRemainsText(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"我将调用工具。"}`,
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid7b", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if c, ok := delta["content"].(string); ok {
content.WriteString(c)
}
}
}
got := content.String()
if !strings.Contains(got, "我将调用工具。") {
t.Fatalf("expected leading text to keep streaming, got=%q", got)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamToolCallWithSameChunkTrailingTextRemainsText(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}接下来我会继续说明。"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid7c", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if c, ok := delta["content"].(string); ok {
content.WriteString(c)
}
}
}
got := content.String()
if !strings.Contains(got, "接下来我会继续说明。") {
t.Fatalf("expected trailing plain text to be preserved, got=%q", got)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamFencedToolCallSnippetPromotesToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "下面是调用示例:\n```json\n"),
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\n仅示例不要执行。"),
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid7f", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta for fenced snippet, body=%s", rec.Body.String())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if c, ok := delta["content"].(string); ok {
content.WriteString(c)
}
}
}
got := content.String()
if strings.Contains(strings.ToLower(got), "tool_calls") {
t.Fatalf("expected raw fenced tool_calls snippet stripped from content, got=%q", got)
}
if strings.Contains(strings.ToLower(got), "```json") || strings.Contains(got, "\n```\n") {
t.Fatalf("expected consumed fenced tool payload to not leave empty code fence, got=%q", got)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamStandaloneToolCallAfterClosedFenceKeepsFence(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "先给一个代码示例:\n```text\nhello\n```\n"),
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"),
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid7g", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta for standalone payload, body=%s", rec.Body.String())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if c, ok := delta["content"].(string); ok {
content.WriteString(c)
}
}
}
got := content.String()
if !strings.Contains(got, "```") {
t.Fatalf("expected closed fence before standalone tool json to be preserved, got=%q", got)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamToolCallKeyAppearsLateRemainsText(t *testing.T) {
h := &Handler{}
spaces := strings.Repeat(" ", 200)
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{`+spaces+`"}`,
`data: {"p":"response/content","v":"\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`,
`data: {"p":"response/content","v":"后置正文C。"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid8", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if c, ok := delta["content"].(string); ok {
content.WriteString(c)
}
}
}
got := content.String()
if !strings.Contains(got, "后置正文C。") {
t.Fatalf("expected stream to continue after tool json convergence, got=%q", got)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamInvalidToolJSONDoesNotLeakRawObject(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"前置正文D。"}`,
`data: {"p":"response/content","v":"{'tool_calls':[{'name':'search','input':{'q':'go'}}]}"}`,
`data: {"p":"response/content","v":"后置正文E。"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid9", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if streamHasToolCallsDelta(frames) {
t.Fatalf("did not expect tool_calls delta for invalid json, body=%s", rec.Body.String())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if c, ok := delta["content"].(string); ok {
content.WriteString(c)
}
}
}
got := content.String()
if !strings.Contains(got, "前置正文D。") || !strings.Contains(got, "后置正文E。") {
t.Fatalf("expected pre/post plain text to remain, got=%q", content.String())
}
if !strings.Contains(strings.ToLower(got), "tool_calls") {
t.Fatalf("expected invalid embedded tool-like json to pass through as text, got=%q", got)
}
}
func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid10", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if streamHasToolCallsDelta(frames) {
t.Fatalf("did not expect tool_calls delta for incomplete json, body=%s", rec.Body.String())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if c, ok := delta["content"].(string); ok {
content.WriteString(c)
}
}
}
if !strings.Contains(strings.ToLower(content.String()), "tool_calls") || !strings.Contains(content.String(), "{") {
t.Fatalf("expected incomplete capture to flush as plain text instead of stalling, got=%q", content.String())
}
}
func TestHandleStreamToolCallArgumentsEmitAsSingleCompletedChunk(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go"}`,
`data: {"p":"response/content","v":"lang\",\"page\":1}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid11", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("raw tool_calls JSON leaked in content delta: %s", rec.Body.String())
}
argChunks := streamToolCallArgumentChunks(frames)
if len(argChunks) == 0 {
t.Fatalf("expected tool call arguments chunk, got=%v body=%s", argChunks, rec.Body.String())
}
joined := strings.Join(argChunks, "")
if !strings.Contains(joined, `"q":"golang"`) || !strings.Contains(joined, `"page":1`) {
t.Fatalf("unexpected merged arguments stream: %q", joined)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamMultiToolCallDoesNotMergeNamesOrArguments(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search_web\",\"input\":{\"query\":\"latest ai news\"}},{"}`,
`data: {"p":"response/content","v":"\"name\":\"eval_javascript\",\"input\":{\"code\":\"1+1\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid12", "deepseek-chat", "prompt", false, false, []string{"search_web", "eval_javascript"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
}
foundSearch := false
foundEval := false
foundIndex1 := false
toolCallsDeltaLens := make([]int, 0, 2)
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
rawToolCalls, hasToolCalls := delta["tool_calls"]
if !hasToolCalls {
continue
}
toolCalls, _ := rawToolCalls.([]any)
toolCallsDeltaLens = append(toolCallsDeltaLens, len(toolCalls))
for _, tc := range toolCalls {
tcm, _ := tc.(map[string]any)
if idx, ok := tcm["index"].(float64); ok && int(idx) == 1 {
foundIndex1 = true
}
fn, _ := tcm["function"].(map[string]any)
name, _ := fn["name"].(string)
switch name {
case "search_web":
foundSearch = true
case "eval_javascript":
foundEval = true
case "search_webeval_javascript":
t.Fatalf("unexpected merged tool name: %s, body=%s", name, rec.Body.String())
}
if args, ok := fn["arguments"].(string); ok && strings.Contains(args, `}{"`) {
t.Fatalf("unexpected concatenated tool arguments: %q, body=%s", args, rec.Body.String())
}
}
}
}
if !foundSearch || !foundEval {
t.Fatalf("expected both tool names in stream deltas, foundSearch=%v foundEval=%v body=%s", foundSearch, foundEval, rec.Body.String())
}
if len(toolCallsDeltaLens) != 1 || toolCallsDeltaLens[0] != 2 {
t.Fatalf("expected exactly one tool_calls delta with two calls, got lens=%v body=%s", toolCallsDeltaLens, rec.Body.String())
}
if !foundIndex1 {
t.Fatalf("expected second tool call index in stream deltas, body=%s", rec.Body.String())
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}

View File

@@ -0,0 +1,70 @@
package openai
import (
"regexp"
)
var emptyJSONFencePattern = regexp.MustCompile("(?is)```json\\s*```")
var leakedToolCallArrayPattern = regexp.MustCompile(`(?is)\[\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"call[^"]*"\s*,\s*"type"\s*:\s*"function"\s*}\]`)
var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s*>\s*\{[\s\S]*?"tool_call_id"\s*:\s*"call[^"]*"\s*}`)
// leakedMetaMarkerPattern matches DeepSeek special tokens in BOTH forms:
// - ASCII underscore: <end_of_sentence>
// - U+2581 variant: <end▁of▁sentence> (used in some DeepSeek outputs)
var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking)\s*[\|]>`)
// leakedAgentXMLBlockPatterns catch agent-style XML blocks that leak through
// when the sieve fails to capture them. These are applied only to complete
// wrapper blocks so standalone "<result>" examples in normal output remain
// untouched.
var leakedAgentXMLBlockPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?is)<attempt_completion\b[^>]*>(.*?)</attempt_completion>`),
regexp.MustCompile(`(?is)<ask_followup_question\b[^>]*>(.*?)</ask_followup_question>`),
regexp.MustCompile(`(?is)<new_task\b[^>]*>(.*?)</new_task>`),
}
var leakedAgentWrapperTagPattern = regexp.MustCompile(`(?is)</?(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>`)
var leakedAgentWrapperPlusResultOpenPattern = regexp.MustCompile(`(?is)<(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>\s*<result>`)
var leakedAgentResultPlusWrapperClosePattern = regexp.MustCompile(`(?is)</result>\s*</(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>`)
var leakedAgentResultTagPattern = regexp.MustCompile(`(?is)</?result>`)
func sanitizeLeakedOutput(text string) string {
if text == "" {
return text
}
out := emptyJSONFencePattern.ReplaceAllString(text, "")
out = leakedToolCallArrayPattern.ReplaceAllString(out, "")
out = leakedToolResultBlobPattern.ReplaceAllString(out, "")
out = leakedMetaMarkerPattern.ReplaceAllString(out, "")
out = sanitizeLeakedAgentXMLBlocks(out)
return out
}
func sanitizeLeakedAgentXMLBlocks(text string) string {
out := text
for _, pattern := range leakedAgentXMLBlockPatterns {
out = pattern.ReplaceAllStringFunc(out, func(match string) string {
submatches := pattern.FindStringSubmatch(match)
if len(submatches) < 2 {
return match
}
// Preserve the inner text so leaked agent instructions do not erase
// the actual answer, but strip the wrapper/result markup itself.
return leakedAgentResultTagPattern.ReplaceAllString(submatches[1], "")
})
}
// Fallback for truncated output streams: strip any dangling wrapper tags
// that were not part of a complete block replacement. If we detect leaked
// wrapper tags, strip only adjacent <result> tags to avoid exposing agent
// markup without altering unrelated user-visible <result> examples.
if leakedAgentWrapperTagPattern.MatchString(out) {
out = leakedAgentWrapperPlusResultOpenPattern.ReplaceAllStringFunc(out, func(match string) string {
return leakedAgentResultTagPattern.ReplaceAllString(match, "")
})
out = leakedAgentResultPlusWrapperClosePattern.ReplaceAllStringFunc(out, func(match string) string {
return leakedAgentResultTagPattern.ReplaceAllString(match, "")
})
out = leakedAgentWrapperTagPattern.ReplaceAllString(out, "")
}
return out
}

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