Compare commits

..

121 Commits

Author SHA1 Message Date
CJACK.
01f33c409f Update VERSION 2026-03-21 18:04:39 +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.
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.
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
159 changed files with 6964 additions and 2762 deletions

View File

@@ -1,93 +1,15 @@
# DS2API environment template (Go runtime)
# Copy this file to .env and adjust values.
# Updated: 2026-02
# ---------------------------------------------------------------
# Runtime
# ---------------------------------------------------------------
# HTTP listen port (default: 5001)
# DS2API runtime
PORT=5001
# Log level: DEBUG | INFO | WARN | ERROR
LOG_LEVEL=INFO
# Max concurrent inflight requests per account in managed-key mode.
# Default: 2
# Recommended client concurrency is calculated dynamically as:
# account_count * DS2API_ACCOUNT_MAX_INFLIGHT
# So by default it is account_count * 2.
# Requests beyond inflight slots enter a waiting queue first.
# Default queue size equals recommended concurrency, so 429 starts after:
# account_count * DS2API_ACCOUNT_MAX_INFLIGHT * 2
# Alias: DS2API_ACCOUNT_CONCURRENCY
# DS2API_ACCOUNT_MAX_INFLIGHT=2
# Admin authentication
DS2API_ADMIN_KEY=change-me
# Optional waiting queue size override for managed-key mode.
# Default: recommended_concurrency (same as account_count * inflight_limit)
# Alias: DS2API_ACCOUNT_QUEUE_SIZE
# DS2API_ACCOUNT_MAX_QUEUE=10
# Config loading (choose one)
# 1) file-based config
DS2API_CONFIG_PATH=/app/config.json
# 2) inline JSON or Base64 JSON
# DS2API_CONFIG_JSON=
# ---------------------------------------------------------------
# Admin auth
# ---------------------------------------------------------------
# Admin key for /admin login and protected admin APIs.
# Default is "admin" when unset, but setting it explicitly is recommended.
DS2API_ADMIN_KEY=admin
# Optional JWT signing secret for admin token.
# Defaults to DS2API_ADMIN_KEY when unset.
# DS2API_JWT_SECRET=change-me
# Optional admin JWT validity in hours (default: 24)
# DS2API_JWT_EXPIRE_HOURS=24
# ---------------------------------------------------------------
# Config source (choose one)
# ---------------------------------------------------------------
# Option A: config file path (local/dev recommended)
# DS2API_CONFIG_PATH=config.json
# Option B: JSON string
# DS2API_CONFIG_JSON={"keys":["your-api-key"],"accounts":[{"email":"user@example.com","password":"xxx","token":""}]}
# Option C: Base64 encoded JSON (recommended for Vercel env var)
# DS2API_CONFIG_JSON=eyJrZXlzIjpbInlvdXItYXBpLWtleSJdLCJhY2NvdW50cyI6W3siZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGFzc3dvcmQiOiJ4eHgiLCJ0b2tlbiI6IiJ9XX0=
#
# Generate from local config.json:
# DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
# ---------------------------------------------------------------
# Paths (optional)
# ---------------------------------------------------------------
# WASM file used for PoW solving
# DS2API_WASM_PATH=sha3_wasm_bg.7b9ca65ddd.wasm
# Built admin static assets directory
# DS2API_STATIC_ADMIN_DIR=static/admin
# Auto-build WebUI on startup when static/admin is missing.
# Default: enabled on local/Docker, disabled on Vercel.
# DS2API_AUTO_BUILD_WEBUI=true
# Internal auth secret used by the Vercel hybrid streaming path
# (Go prepare endpoint <-> Node stream function).
# Optional: falls back to DS2API_ADMIN_KEY when unset.
# DS2API_VERCEL_INTERNAL_SECRET=change-me
# Stream lease TTL seconds for Vercel hybrid streaming.
# During this window, the managed account stays occupied until Node calls release.
# Default: 900 (15 minutes)
# DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS=900
# ---------------------------------------------------------------
# Vercel sync integration (optional)
# ---------------------------------------------------------------
# VERCEL_TOKEN=your-vercel-token
# VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx
# VERCEL_TEAM_ID=team_xxxxxxxxxxxx
# Optional: Vercel deployment protection bypass secret.
# If deployment protection is enabled, DS2API will use this value as
# x-vercel-protection-bypass for internal Node->Go calls on Vercel.
# You can also use VERCEL_AUTOMATION_BYPASS_SECRET directly.
# DS2API_VERCEL_PROTECTION_BYPASS=your-bypass-secret
# Optional: static admin assets path
# DS2API_STATIC_ADMIN_DIR=/app/static/admin

View File

@@ -51,6 +51,10 @@ jobs:
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=(
@@ -73,7 +77,7 @@ jobs:
mkdir -p "${STAGE}/static"
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \
go build -trimpath -ldflags="-s -w" -o "${STAGE}/${BIN}" ./cmd/ds2api
go build -trimpath -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION}" -o "${STAGE}/${BIN}" ./cmd/ds2api
cp config.example.json .env.example sha3_wasm_bg.7b9ca65ddd.wasm LICENSE README.MD README.en.md "${STAGE}/"
cp -R static/admin "${STAGE}/static/admin"

View File

@@ -123,5 +123,7 @@ jobs:
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

View File

@@ -1,128 +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 }}
cache-from: type=gha
cache-to: type=gha,mode=max
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

View File

@@ -623,6 +623,7 @@ Reads runtime settings and status, including:
- `admin` (JWT expiry, default-password warning, etc.)
- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`)
- `toolcall` / `responses` / `embeddings`
- `auto_delete` (`sessions`)
- `claude_mapping` / `model_aliases`
- `env_backed`, `needs_vercel_sync`
@@ -635,6 +636,7 @@ Hot-updates runtime settings. Supported fields:
- `toolcall.mode` / `toolcall.early_emit_confidence`
- `responses.store_ttl_seconds`
- `embeddings.provider`
- `auto_delete.sessions`
- `claude_mapping`
- `model_aliases`

9
API.md
View File

@@ -284,6 +284,11 @@ data: [DONE]
**流式**:命中高置信特征后立即输出 `delta.tool_calls`(不等待完整 JSON 闭合),并持续发送 arguments 增量;已确认的 toolcall 原始 JSON 不会回流到 `delta.content`
补充说明:
- **非代码块上下文**下,工具 JSON 即使与普通文本混合,也会按特征识别并产出可执行 tool call前后普通文本仍可透传
- Markdown fenced code block例如 ```json ... ```)中的 `tool_calls` 仅视为示例文本,不会被执行。
---
### `GET /v1/models/{id}`
@@ -301,7 +306,7 @@ OpenAI Responses 风格接口,兼容 `input` 或 `messages`。
| `messages` | array | ❌ | 与 `input` 二选一 |
| `instructions` | string | ❌ | 自动前置为 system 消息 |
| `stream` | boolean | ❌ | 默认 `false` |
| `tools` | array | ❌ | 与 chat 同样的工具识别与转译策略 |
| `tools` | array | ❌ | 与 chat 同样的工具识别与转译策略(含代码块示例豁免) |
| `tool_choice` | string/object | ❌ | 支持 `auto`/`none`/`required` 与强制函数(`{"type":"function","name":"..."}` |
**非流式响应**:返回标准 `response` 对象,`id` 形如 `resp_xxx`,并写入内存 TTL 存储。
@@ -623,6 +628,7 @@ data: {"type":"message_stop"}
- `admin`JWT 过期、默认密码告警等)
- `runtime``account_max_inflight``account_max_queue``global_max_inflight`
- `toolcall` / `responses` / `embeddings`
- `auto_delete``sessions`
- `claude_mapping` / `model_aliases`
- `env_backed``needs_vercel_sync`
@@ -635,6 +641,7 @@ data: {"type":"message_stop"}
- `toolcall.mode` / `toolcall.early_emit_confidence`
- `responses.store_ttl_seconds`
- `embeddings.provider`
- `auto_delete.sessions`
- `claude_mapping`
- `model_aliases`

View File

@@ -99,7 +99,7 @@ ds2api/
├── api/
│ ├── index.go # Vercel Serverless Go entry
│ ├── chat-stream.js # Vercel Node.js stream relay
│ └── helpers/ # Node.js helper modules
│ └── (rewrite targets in vercel.json)
├── internal/
│ ├── account/ # Account pool and concurrency queue
│ ├── adapter/
@@ -112,6 +112,7 @@ ds2api/
│ ├── compat/ # Compatibility helpers
│ ├── config/ # Config loading and hot-reload
│ ├── deepseek/ # DeepSeek client, PoW WASM
│ ├── js/ # Node runtime stream/compat logic
│ ├── devcapture/ # Dev packet capture
│ ├── format/ # Output formatting
│ ├── prompt/ # Prompt building
@@ -123,7 +124,9 @@ ds2api/
│ └── webui/ # WebUI static hosting
├── webui/ # React WebUI source
│ └── src/
│ ├── components/ # Components
│ ├── app/ # Routing, auth, config state
│ ├── features/ # Feature modules
│ ├── components/ # Shared components
│ └── locales/ # Language packs
├── scripts/ # Build and test scripts
├── static/admin/ # WebUI build output (not committed)

View File

@@ -99,7 +99,7 @@ ds2api/
├── api/
│ ├── index.go # Vercel Serverless Go 入口
│ ├── chat-stream.js # Vercel Node.js 流式转发
│ └── helpers/ # Node.js 辅助模块
│ └── (rewrite targets in vercel.json)
├── internal/
│ ├── account/ # 账号池与并发队列
│ ├── adapter/
@@ -112,6 +112,7 @@ ds2api/
│ ├── compat/ # 兼容性辅助
│ ├── config/ # 配置加载与热更新
│ ├── deepseek/ # DeepSeek 客户端、PoW WASM
│ ├── js/ # Node 运行时流式/兼容逻辑
│ ├── devcapture/ # 开发抓包
│ ├── format/ # 输出格式化
│ ├── prompt/ # Prompt 构建
@@ -123,7 +124,9 @@ ds2api/
│ └── webui/ # WebUI 静态托管
├── webui/ # React WebUI 源码
│ └── src/
│ ├── components/ # 组件
│ ├── app/ # 路由、鉴权、配置状态
│ ├── features/ # 业务功能模块
│ ├── components/ # 通用组件
│ └── locales/ # 语言包
├── scripts/ # 构建与测试脚本
├── static/admin/ # WebUI 构建产物(不提交)

View File

@@ -113,12 +113,8 @@ go build -o ds2api ./cmd/ds2api
# Copy env template
cp .env.example .env
# Generate single-line Base64 from config.json
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
# Edit .env and set:
# Edit .env and set at least:
# DS2API_ADMIN_KEY=your-admin-key
# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON}
# Start
docker-compose up -d
@@ -185,6 +181,7 @@ 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).
---
@@ -366,7 +363,7 @@ Each archive includes:
- `ds2api` executable (`ds2api.exe` on Windows)
- `static/admin/` (built WebUI assets)
- `sha3_wasm_bg.7b9ca65ddd.wasm`
- `sha3_wasm_bg.7b9ca65ddd.wasm` (optional; binary has embedded fallback)
- `config.example.json`, `.env.example`
- `README.MD`, `README.en.md`, `LICENSE`
@@ -455,7 +452,9 @@ server {
```bash
# Copy compiled binary and related files to target directory
sudo mkdir -p /opt/ds2api
sudo cp ds2api config.json sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
sudo cp ds2api config.json /opt/ds2api/
# Optional: if you want to use an external WASM file (override embedded one)
# sudo cp sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
sudo cp -r static/admin /opt/ds2api/static/admin
```

View File

@@ -113,12 +113,8 @@ go build -o ds2api ./cmd/ds2api
# 复制环境变量模板
cp .env.example .env
# 从 config.json 生成单行 Base64
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
# 编辑 .env请改成你的强密码设置
# 编辑 .env请改成你的强密码至少设置
# DS2API_ADMIN_KEY=your-admin-key
# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON}
# 启动
docker-compose up -d
@@ -185,6 +181,7 @@ healthcheck:
- **端口**:服务默认监听 `5001`,模板会固定设置 `PORT=5001`
- **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;在管理台导入配置后,会写入并持久化到该路径。
- **构建版本号**Zeabur / 普通 `docker build` 默认不需要传 `BUILD_VERSION`;镜像会优先使用该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。
- **首次登录**:部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录(建议首次登录后自行更换为强密码)。
---
@@ -366,7 +363,7 @@ No Output Directory named "public" found after the Build completed.
- `ds2api` 可执行文件Windows 为 `ds2api.exe`
- `static/admin/`WebUI 构建产物)
- `sha3_wasm_bg.7b9ca65ddd.wasm`
- `sha3_wasm_bg.7b9ca65ddd.wasm`(可选;程序内置 embed fallback
- `config.example.json``.env.example`
- `README.MD``README.en.md``LICENSE`
@@ -455,7 +452,9 @@ server {
```bash
# 将编译好的二进制文件和相关文件复制到目标目录
sudo mkdir -p /opt/ds2api
sudo cp ds2api config.json sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
sudo cp ds2api config.json /opt/ds2api/
# 可选:若你希望使用外置 WASM 文件(覆盖内置版本)
# sudo cp sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
sudo cp -r static/admin /opt/ds2api/static/admin
```

View File

@@ -10,19 +10,24 @@ FROM golang:1.24 AS go-builder
WORKDIR /app
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)}"; \
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -o /out/ds2api ./cmd/ds2api
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
COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
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"]

View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="assets/ds2api-icon.svg" width="128" height="128" alt="DS2API icon" />
<img src="webui/public/ds2api-favicon.svg" width="128" height="128" alt="DS2API icon" />
</p>
# DS2API
@@ -10,6 +10,7 @@
[![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)](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)
语言 / Language: [中文](README.MD) | [English](README.en.md)
@@ -105,6 +106,14 @@ flowchart LR
可通过配置中的 `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的版本。
### Gemini 接口
Gemini 适配器将模型名通过 `model_aliases` 或内置规则映射到 DeepSeek 原生模型,支持 `generateContent` 和 `streamGenerateContent` 两种调用方式,并完整支持 Tool Calling`functionDeclarations` → `functionCall` 输出)。
@@ -151,17 +160,13 @@ go run ./cmd/ds2api
# 1. 准备环境变量文件
cp .env.example .env
# 2. 从 config.json 生成 DS2API_CONFIG_JSON单行 Base64
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
# 3. 编辑 .env设置
# 2. 编辑 .env至少设置 DS2API_ADMIN_KEY
# DS2API_ADMIN_KEY=请替换为强密码
# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON}
# 4. 启动
# 3. 启动
docker-compose up -d
# 5. 查看日志
# 4. 查看日志
docker-compose logs -f
```
@@ -173,6 +178,8 @@ docker-compose logs -f
2. 部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录。
3. 在管理台导入/编辑配置(会写入并持久化到 `/data/config.json`)。
说明Zeabur 使用仓库内 `Dockerfile` 直接构建时,不需要额外传入 `BUILD_VERSION`;镜像会优先读取该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。
### 方式三Vercel 部署
1. Fork 仓库到自己的 GitHub
@@ -237,13 +244,11 @@ cp opencode.json.example opencode.json
"accounts": [
{
"email": "user@example.com",
"password": "your-password",
"token": ""
"password": "your-password"
},
{
"mobile": "12345678901",
"password": "your-password",
"token": ""
"password": "your-password"
}
],
"model_aliases": {
@@ -264,7 +269,7 @@ cp opencode.json.example opencode.json
"embeddings": {
"provider": "deterministic"
},
"claude_model_mapping": {
"claude_mapping": {
"fast": "deepseek-chat",
"slow": "deepseek-reasoner"
},
@@ -275,21 +280,25 @@ cp opencode.json.example opencode.json
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0
},
"auto_delete": {
"sessions": false
}
}
```
- `keys`API 访问密钥列表,客户端通过 `Authorization: Bearer <key>` 鉴权
- `accounts`DeepSeek 账号列表,支持 `email` 或 `mobile` 登录
- `token`留空则首次请求时自动登录获取;也可预填已有 token
- `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_model_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型
- `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping`
- `admin`管理后台设置JWT 过期时间、密码哈希等),可通过 Admin Settings API 热更新
- `runtime`:运行时参数(并发限制、队列大小),可通过 Admin Settings API 热更新
- `runtime`:运行时参数(并发限制、队列大小),可通过 Admin Settings API 热更新`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算
- `auto_delete.sessions`:是否在请求结束后自动清理 DeepSeek 会话(默认 `false`,可在 Settings 热更新)
### 环境变量
@@ -388,7 +397,7 @@ ds2api/
├── api/
│ ├── index.go # Vercel Serverless Go 入口
│ ├── chat-stream.js # Vercel Node.js 流式转发
│ └── helpers/ # Node.js 辅助模块
│ └── (rewrite targets in vercel.json)
├── internal/
│ ├── account/ # 账号池与并发队列
│ ├── adapter/
@@ -401,6 +410,7 @@ ds2api/
│ ├── compat/ # 兼容性辅助
│ ├── config/ # 配置加载与热更新
│ ├── deepseek/ # DeepSeek API 客户端、PoW WASM
│ ├── js/ # Node 运行时流式处理与兼容逻辑
│ ├── devcapture/ # 开发抓包模块
│ ├── format/ # 输出格式化
│ ├── prompt/ # Prompt 构建
@@ -411,7 +421,9 @@ ds2api/
│ └── webui/ # WebUI 静态文件托管与自动构建
├── webui/ # React WebUI 源码Vite + Tailwind
│ └── src/
│ ├── components/ # AccountManager / ApiTester / BatchImport / VercelSync / Login / LandingPage
│ ├── app/ # 路由、鉴权、配置状态管理
│ ├── features/ # 业务功能模块account/settings/vercel/apiTester
│ ├── components/ # 登录/落地页等通用组件
│ └── locales/ # 中英文语言包zh.json / en.json
├── scripts/
│ └── build-webui.sh # WebUI 手动构建脚本
@@ -467,6 +479,23 @@ go run ./cmd/ds2api-tests \
npm ci --prefix webui && npm run build --prefix webui
```
## 测试
详细测试指南请参阅 [TESTING.md](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`
@@ -474,7 +503,7 @@ npm ci --prefix webui && npm run build --prefix webui
- **触发条件**:仅在 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 文件、配置示例、README、LICENSE
- **每个压缩包包含**`ds2api` 可执行文件、`static/admin`、WASM 文件(同时支持内置 fallback、配置示例、README、LICENSE
## 免责声明

View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="assets/ds2api-icon.svg" width="128" height="128" alt="DS2API icon" />
<img src="webui/public/ds2api-favicon.svg" width="128" height="128" alt="DS2API icon" />
</p>
# DS2API
@@ -10,6 +10,7 @@
[![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)](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)
@@ -105,6 +106,14 @@ flowchart LR
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).
@@ -151,17 +160,13 @@ Default URL: `http://localhost:5001`
# 1. Prepare env file
cp .env.example .env
# 2. Generate DS2API_CONFIG_JSON from config.json (single-line Base64)
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
# 3. Edit .env and set:
# 2. Edit .env (at least set DS2API_ADMIN_KEY)
# DS2API_ADMIN_KEY=replace-with-a-strong-secret
# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON}
# 4. Start
# 3. Start
docker-compose up -d
# 5. View logs
# 4. View logs
docker-compose logs -f
```
@@ -173,6 +178,8 @@ Rebuild after updates: `docker-compose up -d --build`
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
@@ -237,13 +244,11 @@ cp opencode.json.example opencode.json
"accounts": [
{
"email": "user@example.com",
"password": "your-password",
"token": ""
"password": "your-password"
},
{
"mobile": "12345678901",
"password": "your-password",
"token": ""
"password": "your-password"
}
],
"model_aliases": {
@@ -264,7 +269,7 @@ cp opencode.json.example opencode.json
"embeddings": {
"provider": "deterministic"
},
"claude_model_mapping": {
"claude_mapping": {
"fast": "deepseek-chat",
"slow": "deepseek-reasoner"
},
@@ -275,21 +280,25 @@ cp opencode.json.example opencode.json
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0
},
"auto_delete": {
"sessions": false
}
}
```
- `keys`: API access keys; clients authenticate via `Authorization: Bearer <key>`
- `accounts`: DeepSeek account list, supports `email` or `mobile` login
- `token`: Leave empty for auto-login on first request; or pre-fill an existing token
- `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
- `responses.store_ttl_seconds`: In-memory TTL for `/v1/responses/{id}`
- `embeddings.provider`: Embeddings provider (`deterministic/mock/builtin` built-in)
- `claude_model_mapping`: Maps `fast`/`slow` suffixes to corresponding DeepSeek models
- `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), hot-reloadable via Admin Settings API
- `runtime`: Runtime parameters (concurrency limits, queue sizes), hot-reloadable via Admin Settings API; `account_max_queue=0`/`global_max_inflight=0` means auto-calculate from recommended values
- `auto_delete.sessions`: Whether to auto-delete DeepSeek sessions after request completion (default `false`, hot-reloadable via Settings)
### Environment Variables
@@ -350,6 +359,7 @@ Queue limit = DS2API_ACCOUNT_MAX_QUEUE (default = recommended concurrency)
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
@@ -388,7 +398,7 @@ ds2api/
├── api/
│ ├── index.go # Vercel Serverless Go entry
│ ├── chat-stream.js # Vercel Node.js stream relay
│ └── helpers/ # Node.js helper modules
│ └── (rewrite targets in vercel.json)
├── internal/
│ ├── account/ # Account pool and concurrency queue
│ ├── adapter/
@@ -401,6 +411,7 @@ ds2api/
│ ├── compat/ # Compatibility helpers
│ ├── config/ # Config loading 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
@@ -411,7 +422,9 @@ ds2api/
│ └── webui/ # WebUI static file serving and auto-build
├── webui/ # React WebUI source (Vite + Tailwind)
│ └── src/
│ ├── components/ # AccountManager / ApiTester / BatchImport / VercelSync / Login / LandingPage
│ ├── 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
@@ -474,7 +487,7 @@ 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, config template, README, LICENSE
- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file (with embedded fallback support), config template, README, LICENSE
## Disclaimer

View File

@@ -51,7 +51,7 @@ DS2API 提供两个层级的测试:
1. **Preflight 检查**
- `go test ./... -count=1`(单元测试)
- `./tests/scripts/check-node-split-syntax.sh`Node 拆分模块语法门禁)
- `node --test api/helpers/stream-tool-sieve.test.js api/chat-stream.test.js api/compat/js_compat_test.js`Node 流式拦截 + compat 单测)
- `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` 到临时目录,启动独立服务进程
@@ -173,6 +173,50 @@ rg "<trace_id>" artifacts/testsuite/<run_id>/server.log
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

View File

@@ -1 +1 @@
0.1.0
2.3.8

View File

@@ -1,63 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="DS2API icon">
<defs>
<linearGradient id="bg" x1="96" y1="96" x2="416" y2="416" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#06162D" />
<stop offset="0.6" stop-color="#0A3A6A" />
<stop offset="1" stop-color="#00B4D8" />
</linearGradient>
<radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(256 180) rotate(90) scale(260)">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.18" />
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0" />
</radialGradient>
<linearGradient id="whale" x1="180" y1="140" x2="360" y2="360" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#EAF7FF" />
<stop offset="1" stop-color="#BDEBFF" />
</linearGradient>
</defs>
<circle cx="256" cy="256" r="240" fill="url(#bg)" />
<circle cx="256" cy="256" r="240" fill="url(#glow)" />
<circle cx="256" cy="256" r="240" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="8" />
<!-- subtle waves -->
<path d="M104 338 C156 308 204 366 256 334 C308 302 356 360 408 330" stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="12" stroke-linecap="round" />
<path d="M124 372 C174 344 212 396 256 372 C300 348 338 396 388 368" stroke="#FFFFFF" stroke-opacity="0.12" stroke-width="10" stroke-linecap="round" />
<!-- whale tail (DeepSeek-inspired element, original design) -->
<path
d="M256 162
C228 124 184 118 156 146
C132 170 138 206 162 230
C190 262 230 252 252 220
C254 218 255 216 256 214
C257 216 258 218 260 220
C282 252 322 262 350 230
C374 206 380 170 356 146
C328 118 284 124 256 162 Z"
fill="url(#whale)"
/>
<rect x="236" y="214" width="40" height="168" rx="20" fill="url(#whale)" />
<!-- API nodes -->
<g opacity="0.55" stroke="#FFFFFF" stroke-opacity="0.35" stroke-width="6" stroke-linecap="round">
<path d="M156 236 L208 206" />
<path d="M356 236 L304 206" />
<path d="M208 206 L232 172" />
<circle cx="156" cy="236" r="10" fill="#FFFFFF" fill-opacity="0.28" />
<circle cx="208" cy="206" r="10" fill="#FFFFFF" fill-opacity="0.28" />
<circle cx="232" cy="172" r="10" fill="#FFFFFF" fill-opacity="0.28" />
<circle cx="304" cy="206" r="10" fill="#FFFFFF" fill-opacity="0.28" />
<circle cx="356" cy="236" r="10" fill="#FFFFFF" fill-opacity="0.28" />
</g>
<!-- tiny sparkle -->
<path
d="M378 164
C372 170 366 174 358 176
C366 178 372 182 378 188
C380 180 384 176 392 176
C384 174 380 170 378 164 Z"
fill="#FFFFFF"
fill-opacity="0.32"
/>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -9,20 +9,17 @@
{
"_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": {
@@ -43,8 +40,19 @@
"embeddings": {
"provider": "deterministic"
},
"claude_model_mapping": {
"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,6 +1,6 @@
services:
ds2api:
image: crpi-cnazxqmg4avmg4fq.cn-beijing.personal.cr.aliyuncs.com/ronghuaxueleng/ds2api:latest
services:
ds2api:
image: ghcr.io/cjackhwang/ds2api:latest
container_name: ds2api
restart: always
ports:

View File

@@ -0,0 +1,41 @@
# Tool call parsing semantics (Go canonical spec)
This document defines the cross-runtime contract for `ParseToolCallsDetailed` / `parseToolCallsDetailed`.
## Output contract
- `calls`: accepted tool calls with normalized tool names.
- `sawToolCallSyntax`: true when tool-call-like syntax is detected (`tool_calls`, `<tool_call>`, `<function_call>`, `<invoke>`) or a valid call is parsed.
- `rejectedByPolicy`: true when parser extracted call syntax but all calls are rejected by allow-list policy.
- `rejectedToolNames`: de-duplicated rejected tool names in first-seen order.
## Parse pipeline
1. Strip fenced code blocks for non-standalone parsing.
2. Build candidates from:
- full text,
- fenced JSON snippets,
- extracted JSON objects around `tool_calls`,
- first `{` to last `}` object slice.
3. Parse each candidate in order:
- JSON payload parser (`tool_calls`, list, single call object),
- XML/Markup parser (`<tool_call>`, `<function_call>`, `<invoke>`; supports attributes + nested fields),
- Text KV fallback parser (`function.name: <name>` ... `function.arguments: {json}`).
4. Stop at first candidate that yields at least one call.
## Name normalization policy
When matching parsed names against configured tools:
1. exact match,
2. case-insensitive match,
3. namespace tail match (`a.b.c` => `c`),
4. loose alnum match (remove non `[a-z0-9]`, compare).
## Standalone mode
Standalone mode (`ParseStandaloneToolCallsDetailed`) parses the whole input directly (no candidate slicing), while still applying:
- example-context guard,
- JSON then markup fallback,
- the same allow-list normalization policy.

View File

@@ -194,7 +194,7 @@ func TestPoolAccountConcurrencyAliasEnv(t *testing.T) {
}
}
func TestPoolSupportsTokenOnlyAccount(t *testing.T) {
func TestPoolDropsLegacyTokenOnlyAccountOnLoad(t *testing.T) {
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
@@ -203,19 +203,15 @@ func TestPoolSupportsTokenOnlyAccount(t *testing.T) {
pool := NewPool(config.LoadStore())
status := pool.Status()
if got, ok := status["total"].(int); !ok || got != 1 {
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 != 1 {
if got, ok := status["available"].(int); !ok || got != 0 {
t.Fatalf("unexpected available in pool status: %#v", status["available"])
}
acc, ok := pool.Acquire("", nil)
if !ok {
t.Fatalf("expected acquire success for token-only account")
}
if acc.Token != "token-only-account" {
t.Fatalf("unexpected token on acquired account: %q", acc.Token)
if _, ok := pool.Acquire("", nil); ok {
t.Fatalf("expected acquire to fail for token-only account")
}
}

View File

@@ -315,3 +315,83 @@ 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 TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(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 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=tool_use, body=%s", rec.Body.String())
}
}

View File

@@ -125,8 +125,14 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
if !containsStr(prompt, "Search the web") {
t.Fatalf("expected description in prompt")
}
if !containsStr(prompt, "tool_calls") {
t.Fatalf("expected tool_calls instruction in prompt")
if !containsStr(prompt, "tool_use") {
t.Fatalf("expected tool_use instruction in prompt")
}
if !containsStr(prompt, "Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers yourself") {
t.Fatalf("expected marker guard instruction in prompt")
}
if containsStr(prompt, "tool_calls") {
t.Fatalf("expected prompt to avoid tool_calls JSON instruction")
}
}

View File

@@ -51,9 +51,10 @@ func buildClaudeToolPrompt(tools []any) string {
parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema))
}
parts = append(parts,
"When you need to use tools, you can call multiple tools in one response. Output ONLY JSON like {\"tool_calls\":[{\"name\":\"tool\",\"input\":{}}]}",
"When you need a tool, respond with Claude-native tool use (tool_use) using the provided tool schema. Do not print tool-call JSON in text.",
"History markers in conversation: [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] are your previous tool calls; [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] are runtime tool outputs, not user input.",
"After a valid [TOOL_RESULT_HISTORY], continue with final answer instead of repeating the same call unless required fields are still missing.",
"Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers yourself; they are system-side context only.",
)
return strings.Join(parts, "\n\n")
}

View File

@@ -8,6 +8,7 @@ import (
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
"ds2api/internal/util"
)
type claudeStreamRuntime struct {
@@ -116,6 +117,18 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
s.text.WriteString(p.Text)
if s.bufferToolContent {
if hasUnclosedCodeFence(s.text.String()) {
continue
}
detected := util.ParseToolCalls(s.text.String(), s.toolNames)
if len(detected) > 0 {
s.finalize("tool_use")
return streamengine.ParsedDecision{
ContentSeen: true,
Stop: true,
StopReason: streamengine.StopReason("tool_use_detected"),
}
}
continue
}
s.closeThinkingBlock()
@@ -144,3 +157,7 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
return streamengine.ParsedDecision{ContentSeen: contentSeen}
}
func hasUnclosedCodeFence(text string) bool {
return strings.Count(text, "```")%2 == 1
}

View File

@@ -99,7 +99,7 @@ func TestGeminiRoutesRegistered(t *testing.T) {
func TestGenerateContentReturnsFunctionCallParts(t *testing.T) {
upstream := makeGeminiUpstreamResponse(
`data: {"p":"response/content","v":"我来调用工具\n{\"tool_calls\":[{\"name\":\"eval_javascript\",\"input\":{\"code\":\"1+1\"}}]}"}`,
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"eval_javascript\",\"input\":{\"code\":\"1+1\"}}]}"}`,
`data: [DONE]`,
)
h := &Handler{
@@ -143,6 +143,42 @@ func TestGenerateContentReturnsFunctionCallParts(t *testing.T) {
}
}
func TestGenerateContentMixedToolSnippetAlsoTriggersFunctionCall(t *testing.T) {
upstream := makeGeminiUpstreamResponse(
`data: {"p":"response/content","v":"我来调用工具\n{\"tool_calls\":[{\"name\":\"eval_javascript\",\"input\":{\"code\":\"1+1\"}}]}"}`,
`data: [DONE]`,
)
h := &Handler{Store: testGeminiConfig{}, Auth: testGeminiAuth{}, DS: testGeminiDS{resp: upstream}}
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))
req.Header.Set("Authorization", "Bearer direct-token")
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) {
upstream := makeGeminiUpstreamResponse(
`data: {"p":"response/content","v":"hello "}`,

View File

@@ -98,11 +98,11 @@ func (s *chatStreamRuntime) sendDone() {
func (s *chatStreamRuntime) finalize(finishReason string) {
finalThinking := s.thinking.String()
finalText := s.text.String()
detected := util.ParseToolCalls(finalText, s.toolNames)
if len(detected) > 0 && !s.toolCallsDoneEmitted {
detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls"
delta := map[string]any{
"tool_calls": formatFinalStreamToolCallsWithStableIDs(detected, s.streamToolCallIDs),
"tool_calls": formatFinalStreamToolCallsWithStableIDs(detected.Calls, s.streamToolCallIDs),
}
if !s.firstChunkSent {
delta["role"] = "assistant"
@@ -158,7 +158,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
}
}
if len(detected) > 0 || s.toolCallsEmitted {
if len(detected.Calls) > 0 || s.toolCallsEmitted {
finishReason = "tool_calls"
}
s.sendChunk(openaifmt.BuildChatStreamChunk(

View File

@@ -19,6 +19,7 @@ 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 {
@@ -28,6 +29,7 @@ type ConfigReader interface {
ToolcallEarlyEmitConfidence() string
ResponsesStoreTTLSeconds() int
EmbeddingsProvider() string
AutoDeleteSessions() bool
}
var _ AuthResolver = (*auth.Resolver)(nil)

View File

@@ -19,6 +19,7 @@ func (m mockOpenAIConfig) ToolcallMode() string { return m.toolMo
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{

View File

@@ -35,7 +35,25 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, status, detail)
return
}
defer h.Auth.Release(a)
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

View File

@@ -53,7 +53,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh
if len(toolSchemas) == 0 {
return messages, names
}
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY this JSON format (no other text):\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON. The response must start with { and end with }.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block."
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY a JSON code block like this:\n```json\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n```\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n```json\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n```\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON code block. The response must start with ```json and end with ```.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block.\n5) Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers in your answer; these markers are system-side context only.\n6) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n7) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets."
if policy.Mode == util.ToolChoiceRequired {
toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list."
}

View File

@@ -3,6 +3,7 @@ package openai
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -210,7 +211,7 @@ func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) {
}
}
func TestHandleNonStreamEmbeddedToolCallExampleIntercepted(t *testing.T) {
func TestHandleNonStreamEmbeddedToolCallExamplePromotesToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"下面是示例:"}`,
@@ -233,15 +234,16 @@ func TestHandleNonStreamEmbeddedToolCallExampleIntercepted(t *testing.T) {
}
msg, _ := choice["message"].(map[string]any)
toolCalls, _ := msg["tool_calls"].([]any)
if len(toolCalls) == 0 {
t.Fatalf("expected tool_calls field for embedded example: %#v", msg["tool_calls"])
if len(toolCalls) != 1 {
t.Fatalf("expected one tool_call field for embedded example: %#v", msg["tool_calls"])
}
if msg["content"] != nil {
t.Fatalf("expected content nil when tool_calls detected, got %#v", msg["content"])
content, _ := msg["content"].(string)
if strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected raw tool_calls json stripped from content, got %#v", content)
}
}
func TestHandleNonStreamFencedToolCallExampleNotIntercepted(t *testing.T) {
func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"search\\\",\\\"input\\\":{\\\"q\\\":\\\"go\\\"}}]}\\n```\"}",
@@ -257,16 +259,17 @@ func TestHandleNonStreamFencedToolCallExampleNotIntercepted(t *testing.T) {
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "stop" {
t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"])
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 _, ok := msg["tool_calls"]; ok {
t.Fatalf("did not expect tool_calls field for fenced example: %#v", msg["tool_calls"])
toolCalls, _ := msg["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected one tool_call field for fenced example: %#v", msg["tool_calls"])
}
content, _ := msg["content"].(string)
if !strings.Contains(content, "```json") || !strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected fenced tool example to pass through as text, got %q", content)
if strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected raw tool_calls json stripped from content, got %q", content)
}
}
@@ -315,6 +318,36 @@ func TestHandleStreamToolCallInterceptsWithoutRawContentLeak(t *testing.T) {
}
}
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(
@@ -500,15 +533,12 @@ func TestHandleStreamToolCallMixedWithPlainTextSegments(t *testing.T) {
if !strings.Contains(got, "下面是示例:") || !strings.Contains(got, "请勿执行。") {
t.Fatalf("expected pre/post plain text to pass sieve, got=%q", got)
}
if strings.Contains(strings.ToLower(got), `"tool_calls"`) {
t.Fatalf("expected no raw tool_calls json leak in content, got=%q", got)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls for mixed prose, body=%s", rec.Body.String())
}
}
func TestHandleStreamToolCallAfterLeadingTextStillIntercepted(t *testing.T) {
func TestHandleStreamToolCallAfterLeadingTextRemainsText(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"我将调用工具。"}`,
@@ -542,15 +572,13 @@ func TestHandleStreamToolCallAfterLeadingTextStillIntercepted(t *testing.T) {
if !strings.Contains(got, "我将调用工具。") {
t.Fatalf("expected leading text to keep streaming, got=%q", got)
}
if strings.Contains(strings.ToLower(got), "tool_calls") {
t.Fatalf("unexpected raw tool json leak, got=%q", got)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamToolCallWithSameChunkTrailingTextStillIntercepted(t *testing.T) {
func TestHandleStreamToolCallWithSameChunkTrailingTextRemainsText(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}接下来我会继续说明。"}`,
@@ -583,15 +611,94 @@ func TestHandleStreamToolCallWithSameChunkTrailingTextStillIntercepted(t *testin
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("unexpected raw tool json leak, got=%q", got)
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 TestHandleStreamToolCallKeyAppearsLateStillNoPrefixLeak(t *testing.T) {
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(
@@ -612,9 +719,6 @@ func TestHandleStreamToolCallKeyAppearsLateStillNoPrefixLeak(t *testing.T) {
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())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
@@ -627,9 +731,6 @@ func TestHandleStreamToolCallKeyAppearsLateStillNoPrefixLeak(t *testing.T) {
}
}
got := content.String()
if strings.Contains(got, "{") {
t.Fatalf("unexpected suspicious prefix leak in content: %q", got)
}
if !strings.Contains(got, "后置正文C。") {
t.Fatalf("expected stream to continue after tool json convergence, got=%q", got)
}
@@ -712,7 +813,7 @@ func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testin
}
}
func TestHandleStreamToolCallArgumentsEmitIncrementally(t *testing.T) {
func TestHandleStreamToolCallArgumentsEmitAsSingleCompletedChunk(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go"}`,
@@ -735,8 +836,8 @@ func TestHandleStreamToolCallArgumentsEmitIncrementally(t *testing.T) {
t.Fatalf("raw tool_calls JSON leaked in content delta: %s", rec.Body.String())
}
argChunks := streamToolCallArgumentChunks(frames)
if len(argChunks) < 2 {
t.Fatalf("expected incremental arguments chunks, got=%v body=%s", argChunks, rec.Body.String())
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`) {

View File

@@ -3,10 +3,10 @@ package openai
import (
"encoding/json"
"fmt"
"io"
"strings"
"ds2api/internal/config"
"ds2api/internal/prompt"
)
func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]any {
@@ -34,9 +34,9 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
"role": "user",
"content": formatToolResultForPrompt(msg),
})
case "user", "system":
case "user", "system", "developer":
out = append(out, map[string]any{
"role": role,
"role": normalizeOpenAIRoleForPrompt(role),
"content": normalizeOpenAIContentForPrompt(msg["content"]),
})
default:
@@ -48,7 +48,7 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
role = "user"
}
out = append(out, map[string]any{
"role": role,
"role": normalizeOpenAIRoleForPrompt(role),
"content": content,
})
}
@@ -78,7 +78,7 @@ func formatAssistantToolCallsForPrompt(msg map[string]any, traceID string) strin
args = normalizeOpenAIArgumentsForPrompt(fn["arguments"])
}
if name == "" {
name = "unknown"
continue
}
if args == "" {
args = normalizeOpenAIArgumentsForPrompt(call["arguments"])
@@ -133,32 +133,7 @@ func formatToolResultForPrompt(msg map[string]any) string {
}
func normalizeOpenAIContentForPrompt(v any) string {
switch x := v.(type) {
case string:
return x
case []any:
parts := make([]string, 0, len(x))
for _, item := range x {
m, ok := item.(map[string]any)
if !ok {
continue
}
t := strings.ToLower(strings.TrimSpace(asString(m["type"])))
if t != "text" && t != "output_text" && t != "input_text" {
continue
}
if text := asString(m["text"]); text != "" {
parts = append(parts, text)
continue
}
if text := asString(m["content"]); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
default:
return marshalToPromptString(v)
}
return prompt.NormalizeContent(v)
}
func normalizeOpenAIArgumentsForPrompt(v any) string {
@@ -175,30 +150,11 @@ func normalizeToolArgumentString(raw string) string {
if trimmed == "" {
return ""
}
if !looksLikeConcatenatedJSON(trimmed) {
return trimmed
if looksLikeConcatenatedJSON(trimmed) {
// Keep original payload to avoid silent argument rewrites.
return raw
}
dec := json.NewDecoder(strings.NewReader(trimmed))
values := make([]any, 0, 2)
for {
var v any
if err := dec.Decode(&v); err != nil {
if err == io.EOF {
break
}
return trimmed
}
values = append(values, v)
}
if len(values) < 2 {
return trimmed
}
last := values[len(values)-1]
b, err := json.Marshal(last)
if err != nil || len(b) == 0 {
return trimmed
}
return string(b)
return trimmed
}
func marshalToPromptString(v any) string {
@@ -209,6 +165,14 @@ func marshalToPromptString(v any) string {
return string(b)
}
func normalizeOpenAIRoleForPrompt(role string) string {
role = strings.ToLower(strings.TrimSpace(role))
if role == "developer" {
return "system"
}
return role
}
func asString(v any) string {
if s, ok := v.(string); ok {
return s

View File

@@ -168,7 +168,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara
}
}
func TestNormalizeOpenAIMessagesForPrompt_RepairsConcatenatedToolArguments(t *testing.T) {
func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
@@ -189,10 +189,94 @@ func TestNormalizeOpenAIMessagesForPrompt_RepairsConcatenatedToolArguments(t *te
t.Fatalf("expected one normalized message, got %d", len(normalized))
}
content, _ := normalized[0]["content"].(string)
if !strings.Contains(content, `function.arguments: {"query":"测试工具调用"}`) {
t.Fatalf("expected repaired arguments in tool history, got %q", content)
}
if strings.Contains(content, `{}{"query":"测试工具调用"}`) {
t.Fatalf("expected concatenated JSON to be repaired, got %q", content)
if !strings.Contains(content, `function.arguments: {}{"query":"测试工具调用"}`) {
t.Fatalf("expected original concatenated arguments in tool history, got %q", content)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDropped(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
"tool_calls": []any{
map[string]any{
"id": "call_missing_name",
"type": "function",
"function": map[string]any{
"arguments": `{"path":"README.MD"}`,
},
},
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 0 {
t.Fatalf("expected nameless assistant tool_calls to be dropped, got %#v", normalized)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLiteral(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
"content": nil,
"tool_calls": []any{
map[string]any{
"id": "call_screenshot",
"function": map[string]any{
"name": "send_file_to_user",
"arguments": `{"file_path":"/tmp/a.png"}`,
},
},
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized message, got %d", len(normalized))
}
content, _ := normalized[0]["content"].(string)
if strings.Contains(content, "<Assistant>null") || strings.HasPrefix(strings.TrimSpace(content), "null") {
t.Fatalf("unexpected null literal injected into assistant tool history: %q", content)
}
if !strings.Contains(content, "function.name: send_file_to_user") {
t.Fatalf("expected tool history block preserved, got %q", content)
}
}
func TestNormalizeOpenAIMessagesForPrompt_DeveloperRoleMapsToSystem(t *testing.T) {
raw := []any{
map[string]any{"role": "developer", "content": "必须先走工具调用"},
map[string]any{"role": "user", "content": "你好"},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 2 {
t.Fatalf("expected 2 normalized messages, got %d", len(normalized))
}
if normalized[0]["role"] != "system" {
t.Fatalf("expected developer role converted to system, got %#v", normalized[0]["role"])
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantArrayContentFallbackWhenTextEmpty(t *testing.T) {
raw := []any{
map[string]any{
"role": "assistant",
"content": []any{
map[string]any{"type": "text", "text": "", "content": "工具说明文本"},
},
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized message, got %d", len(normalized))
}
content, _ := normalized[0]["content"].(string)
if content != "工具说明文本" {
t.Fatalf("expected content fallback text preserved, got %q", content)
}
}

View File

@@ -80,4 +80,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
if !strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("vercel prepare finalPrompt missing history marker instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "Never output [TOOL_CALL_HISTORY] or [TOOL_RESULT_HISTORY] markers in your answer") {
t.Fatalf("vercel prepare finalPrompt missing marker-output guard instruction: %q", finalPrompt)
}
}

View File

@@ -135,7 +135,7 @@ func TestNormalizeResponsesInputAsMessagesFunctionCallItem(t *testing.T) {
}
}
func TestNormalizeResponsesInputAsMessagesFunctionCallItemRepairsConcatenatedArguments(t *testing.T) {
func TestNormalizeResponsesInputAsMessagesFunctionCallItemPreservesConcatenatedArguments(t *testing.T) {
msgs := normalizeResponsesInputAsMessages([]any{
map[string]any{
"type": "function_call",
@@ -151,8 +151,8 @@ func TestNormalizeResponsesInputAsMessagesFunctionCallItemRepairsConcatenatedArg
toolCalls, _ := m["tool_calls"].([]any)
call, _ := toolCalls[0].(map[string]any)
fn, _ := call["function"].(map[string]any)
if fn["arguments"] != `{"q":"golang"}` {
t.Fatalf("expected concatenated call arguments repaired, got %#v", fn["arguments"])
if fn["arguments"] != `{}{"q":"golang"}` {
t.Fatalf("expected original concatenated call arguments preserved, got %#v", fn["arguments"])
}
}

View File

@@ -113,15 +113,10 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
return
}
result := sse.CollectStream(resp, thinkingEnabled, true)
textParsed := util.ParseToolCallsDetailed(result.Text, toolNames)
thinkingParsed := util.ParseToolCallsDetailed(result.Thinking, toolNames)
textParsed := util.ParseStandaloneToolCallsDetailed(result.Text, toolNames)
logResponsesToolPolicyRejection(traceID, toolChoice, textParsed, "text")
logResponsesToolPolicyRejection(traceID, toolChoice, thinkingParsed, "thinking")
callCount := len(textParsed.Calls)
if callCount == 0 {
callCount = len(thinkingParsed.Calls)
}
if toolChoice.IsRequired() && callCount == 0 {
writeOpenAIErrorWithCode(w, http.StatusUnprocessableEntity, "tool_choice requires at least one valid tool call.", "tool_choice_violation")
return

View File

@@ -29,7 +29,7 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str
return nil
}
return map[string]any{
"role": role,
"role": normalizeOpenAIRoleForPrompt(role),
"content": content,
}
}
@@ -51,7 +51,7 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str
role = "user"
}
return map[string]any{
"role": role,
"role": normalizeOpenAIRoleForPrompt(role),
"content": content,
}
case "function_call_output", "tool_result":

View File

@@ -102,16 +102,11 @@ func (s *responsesStreamRuntime) finalize() {
if s.bufferToolContent {
s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true)
s.processToolStreamEvents(flushToolSieve(&s.thinkingSieve, s.toolNames), false)
}
textParsed := util.ParseToolCallsDetailed(finalText, s.toolNames)
thinkingParsed := util.ParseToolCallsDetailed(finalThinking, s.toolNames)
textParsed := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
detected := textParsed.Calls
if len(detected) == 0 {
detected = thinkingParsed.Calls
}
s.logToolPolicyRejections(textParsed, thinkingParsed)
s.logToolPolicyRejections(textParsed)
if len(detected) > 0 {
s.toolCallsEmitted = true
@@ -157,7 +152,7 @@ func (s *responsesStreamRuntime) finalize() {
s.sendDone()
}
func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed, thinkingParsed util.ToolCallParseResult) {
func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed util.ToolCallParseResult) {
logRejected := func(parsed util.ToolCallParseResult, channel string) {
rejected := filteredRejectedToolNamesForLog(parsed.RejectedToolNames)
if !parsed.RejectedByPolicy || len(rejected) == 0 {
@@ -172,7 +167,6 @@ func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed, thinkingPar
)
}
logRejected(textParsed, "text")
logRejected(thinkingParsed, "thinking")
}
func (s *responsesStreamRuntime) hasFunctionCallDone() bool {
@@ -207,9 +201,6 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
}
s.thinking.WriteString(p.Text)
s.sendEvent("response.reasoning.delta", openaifmt.BuildResponsesReasoningDeltaPayload(s.responseID, p.Text))
if s.bufferToolContent {
s.processToolStreamEvents(processToolSieveChunk(&s.thinkingSieve, p.Text, s.toolNames), false)
}
continue
}

View File

@@ -94,6 +94,16 @@ func (s *responsesStreamRuntime) closeMessageItem() {
outputIndex := s.ensureMessageOutputIndex()
text := s.visibleText.String()
if s.messagePartAdded {
s.sendEvent(
"response.output_text.done",
openaifmt.BuildResponsesTextDonePayload(
s.responseID,
itemID,
outputIndex,
0,
text,
),
)
s.sendEvent(
"response.content_part.done",
openaifmt.BuildResponsesContentPartDonePayload(

View File

@@ -99,9 +99,6 @@ func TestHandleResponsesStreamUsesOfficialOutputItemEvents(t *testing.T) {
if !strings.Contains(body, "event: response.output_item.done") {
t.Fatalf("expected response.output_item.done event, body=%s", body)
}
if !strings.Contains(body, "event: response.function_call_arguments.delta") {
t.Fatalf("expected response.function_call_arguments.delta event, body=%s", body)
}
if !strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("expected response.function_call_arguments.done event, body=%s", body)
}
@@ -229,6 +226,40 @@ func TestHandleResponsesStreamMultiToolCallKeepsNameAndCallIDAligned(t *testing.
}
}
func TestHandleResponsesStreamEmitsOutputTextDoneBeforeContentPartDone(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
sseLine := func(v string) string {
b, _ := json.Marshal(map[string]any{
"p": "response/content",
"v": v,
})
return "data: " + string(b) + "\n"
}
streamBody := sseLine("hello") + "data: [DONE]\n"
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(streamBody)),
}
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, nil, util.DefaultToolChoicePolicy(), "")
body := rec.Body.String()
if !strings.Contains(body, "event: response.output_text.done") {
t.Fatalf("expected response.output_text.done payload, body=%s", body)
}
textDoneIdx := strings.Index(body, "event: response.output_text.done")
partDoneIdx := strings.Index(body, "event: response.content_part.done")
if textDoneIdx < 0 || partDoneIdx < 0 {
t.Fatalf("expected output_text.done + content_part.done, body=%s", body)
}
if textDoneIdx > partDoneIdx {
t.Fatalf("expected output_text.done before content_part.done, body=%s", body)
}
}
func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
@@ -266,7 +297,7 @@ func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) {
}
}
func TestHandleResponsesStreamThinkingTextAndToolUseDistinctOutputIndexes(t *testing.T) {
func TestHandleResponsesStreamThinkingAndMixedToolExampleEmitsFunctionCall(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
@@ -291,23 +322,8 @@ func TestHandleResponsesStreamThinkingTextAndToolUseDistinctOutputIndexes(t *tes
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-reasoner", "prompt", true, false, []string{"read_file"}, util.DefaultToolChoicePolicy(), "")
addedPayloads := extractAllSSEEventPayloads(rec.Body.String(), "response.output_item.added")
if len(addedPayloads) < 2 {
t.Fatalf("expected message + function_call output_item.added events, got %d body=%s", len(addedPayloads), rec.Body.String())
}
indexes := map[int]struct{}{}
typeByIndex := map[int]string{}
addedIDs := map[string]string{}
for _, payload := range addedPayloads {
item, _ := payload["item"].(map[string]any)
itemType := strings.TrimSpace(asString(item["type"]))
outputIndex := int(asFloat(payload["output_index"]))
if _, exists := indexes[outputIndex]; exists {
t.Fatalf("found duplicated output_index=%d for item types=%q and %q payload=%#v", outputIndex, typeByIndex[outputIndex], itemType, payload)
}
indexes[outputIndex] = struct{}{}
typeByIndex[outputIndex] = itemType
addedIDs[itemType] = strings.TrimSpace(asString(payload["item_id"]))
if len(addedPayloads) < 1 {
t.Fatalf("expected at least one output_item.added event, got %d body=%s", len(addedPayloads), rec.Body.String())
}
completedPayload, ok := extractSSEEventPayload(rec.Body.String(), "response.completed")
@@ -316,20 +332,25 @@ func TestHandleResponsesStreamThinkingTextAndToolUseDistinctOutputIndexes(t *tes
}
responseObj, _ := completedPayload["response"].(map[string]any)
output, _ := responseObj["output"].([]any)
found := map[string]bool{}
hasMessage := false
hasFunctionCall := false
for _, item := range output {
m, _ := item.(map[string]any)
itemType := strings.TrimSpace(asString(m["type"]))
itemID := strings.TrimSpace(asString(m["id"]))
if itemType == "" || itemID == "" {
if m == nil {
continue
}
if wantID := strings.TrimSpace(addedIDs[itemType]); wantID != "" && wantID == itemID {
found[itemType] = true
if asString(m["type"]) == "message" {
hasMessage = true
}
if asString(m["type"]) == "function_call" {
hasFunctionCall = true
}
}
if !found["message"] || !found["function_call"] {
t.Fatalf("expected completed output to contain streamed message/function_call item ids, found=%#v output=%#v", found, output)
if !hasMessage {
t.Fatalf("expected message output for mixed prose tool example, output=%#v", output)
}
if !hasFunctionCall {
t.Fatalf("expected function_call output for mixed prose tool example, output=%#v", output)
}
}
@@ -360,7 +381,7 @@ func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
}
}
func TestHandleResponsesStreamMalformedToolJSONClosesInProgressFunctionItem(t *testing.T) {
func TestHandleResponsesStreamMalformedToolJSONFallsBackToText(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
@@ -373,7 +394,7 @@ func TestHandleResponsesStreamMalformedToolJSONClosesInProgressFunctionItem(t *t
return "data: " + string(b) + "\n"
}
// invalid JSON (NaN) can still trigger incremental tool deltas before final parse rejects it
// invalid JSON (NaN) should remain plain text in strict mode.
streamBody := sseLine(`{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"},"x":NaN}]}`) + "data: [DONE]\n"
resp := &http.Response{
StatusCode: http.StatusOK,
@@ -382,14 +403,11 @@ func TestHandleResponsesStreamMalformedToolJSONClosesInProgressFunctionItem(t *t
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, []string{"read_file"}, util.DefaultToolChoicePolicy(), "")
body := rec.Body.String()
if !strings.Contains(body, "event: response.function_call_arguments.delta") {
t.Fatalf("expected response.function_call_arguments.delta event for malformed payload, body=%s", body)
if strings.Contains(body, "event: response.function_call_arguments.delta") || strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("did not expect function_call events for malformed payload in strict mode, body=%s", body)
}
if !strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("expected runtime to close in-progress function_call with done event, body=%s", body)
}
if !strings.Contains(body, "event: response.output_item.done") {
t.Fatalf("expected runtime to close function output item, body=%s", body)
if !strings.Contains(body, "event: response.output_text.delta") {
t.Fatalf("expected response.output_text.delta for malformed payload, body=%s", body)
}
if !strings.Contains(body, "event: response.completed") {
t.Fatalf("expected response.completed event, body=%s", body)
@@ -430,6 +448,42 @@ func TestHandleResponsesStreamRequiredToolChoiceFailure(t *testing.T) {
}
}
func TestHandleResponsesStreamRequiredToolChoiceIgnoresThinkingToolPayload(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
sseLine := func(path, value string) string {
b, _ := json.Marshal(map[string]any{
"p": path,
"v": value,
})
return "data: " + string(b) + "\n"
}
streamBody := sseLine("response/thinking_content", `{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}`) +
sseLine("response/content", "plain text only") +
"data: [DONE]\n"
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(streamBody)),
}
policy := util.ToolChoicePolicy{
Mode: util.ToolChoiceRequired,
Allowed: map[string]struct{}{"read_file": {}},
}
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", true, false, []string{"read_file"}, policy, "")
body := rec.Body.String()
if !strings.Contains(body, "event: response.failed") {
t.Fatalf("expected response.failed event for required tool_choice violation, body=%s", body)
}
if strings.Contains(body, "event: response.completed") {
t.Fatalf("did not expect response.completed after failure, body=%s", body)
}
}
func TestHandleResponsesStreamRequiredMalformedToolPayloadFails(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
@@ -516,6 +570,33 @@ func TestHandleResponsesNonStreamRequiredToolChoiceViolation(t *testing.T) {
}
}
func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayload(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"read_file\",\"input\":{\"path\":\"README.MD\"}}]}"}` + "\n" +
`data: {"p":"response/content","v":"plain text only"}` + "\n" +
`data: [DONE]` + "\n",
)),
}
policy := util.ToolChoicePolicy{
Mode: util.ToolChoiceRequired,
Allowed: map[string]struct{}{"read_file": {}},
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", true, []string{"read_file"}, policy, "")
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for required tool_choice violation, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
errObj, _ := out["error"].(map[string]any)
if asString(errObj["code"]) != "tool_choice_violation" {
t.Fatalf("expected code=tool_choice_violation, got %#v", out)
}
}
func TestHandleResponsesNonStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()

View File

@@ -53,6 +53,10 @@ func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAut
return m.resp, nil
}
func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {
return nil
}
func makeOpenAISSEHTTPResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") {
@@ -168,18 +172,14 @@ func TestResponsesNonStreamMixedProseToolPayloadHandlerPath(t *testing.T) {
}
outputText, _ := out["output_text"].(string)
if outputText != "" {
t.Fatalf("expected output_text hidden for tool call payload, got %q", outputText)
t.Fatalf("expected output_text hidden for mixed prose tool payload, got %q", outputText)
}
output, _ := out["output"].([]any)
hasFunctionCall := false
for _, item := range output {
m, _ := item.(map[string]any)
if m != nil && m["type"] == "function_call" {
hasFunctionCall = true
break
}
if len(output) != 1 {
t.Fatalf("expected one output item, got %#v", output)
}
if !hasFunctionCall {
first, _ := output[0].(map[string]any)
if first["type"] != "function_call" {
t.Fatalf("expected function_call output item, got %#v", output)
}
}

View File

@@ -14,6 +14,11 @@ func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames
state.pending.WriteString(chunk)
}
events := make([]toolStreamEvent, 0, 2)
if len(state.pendingToolCalls) > 0 {
events = append(events, toolStreamEvent{ToolCalls: state.pendingToolCalls})
state.pendingToolRaw = ""
state.pendingToolCalls = nil
}
for {
if state.capturing {
@@ -21,32 +26,30 @@ func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames
state.capture.WriteString(state.pending.String())
state.pending.Reset()
}
if deltas := buildIncrementalToolDeltas(state); len(deltas) > 0 {
events = append(events, toolStreamEvent{ToolCallDeltas: deltas})
}
prefix, calls, suffix, ready := consumeToolCapture(state, toolNames)
if !ready {
if state.capture.Len() > toolSieveCaptureLimit {
content := state.capture.String()
state.capture.Reset()
state.capturing = false
state.resetIncrementalToolState()
state.noteText(content)
events = append(events, toolStreamEvent{Content: content})
continue
}
break
}
captured := state.capture.String()
state.capture.Reset()
state.capturing = false
state.resetIncrementalToolState()
if len(calls) > 0 {
if prefix != "" {
state.noteText(prefix)
events = append(events, toolStreamEvent{Content: prefix})
}
if suffix != "" {
state.pending.WriteString(suffix)
}
_ = captured
state.pendingToolCalls = calls
continue
}
if prefix != "" {
state.noteText(prefix)
events = append(events, toolStreamEvent{Content: prefix})
}
if len(calls) > 0 {
events = append(events, toolStreamEvent{ToolCalls: calls})
}
if suffix != "" {
state.pending.WriteString(suffix)
}
@@ -89,6 +92,11 @@ func flushToolSieve(state *toolStreamSieveState, toolNames []string) []toolStrea
return nil
}
events := processToolSieveChunk(state, "", toolNames)
if len(state.pendingToolCalls) > 0 {
events = append(events, toolStreamEvent{ToolCalls: state.pendingToolCalls})
state.pendingToolRaw = ""
state.pendingToolCalls = nil
}
if state.capturing {
consumedPrefix, consumedCalls, consumedSuffix, ready := consumeToolCapture(state, toolNames)
if ready {
@@ -159,22 +167,25 @@ func findToolSegmentStart(s string) int {
return -1
}
lower := strings.ToLower(s)
offset := 0
for {
keyRel := strings.Index(lower[offset:], "tool_calls")
if keyRel < 0 {
return -1
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
bestKeyIdx := -1
for _, kw := range keywords {
idx := strings.Index(lower, kw)
if idx >= 0 && (bestKeyIdx < 0 || idx < bestKeyIdx) {
bestKeyIdx = idx
}
keyIdx := offset + keyRel
start := strings.LastIndex(s[:keyIdx], "{")
if start < 0 {
start = keyIdx
}
if !insideCodeFence(s[:start]) {
return start
}
offset = keyIdx + len("tool_calls")
}
if bestKeyIdx < 0 {
return -1
}
start := strings.LastIndex(s[:bestKeyIdx], "{")
if start < 0 {
start = bestKeyIdx
}
if fenceStart, ok := openFenceStartBefore(s, start); ok {
return fenceStart
}
return start
}
func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) {
@@ -183,13 +194,22 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
return "", nil, "", false
}
lower := strings.ToLower(captured)
keyIdx := strings.Index(lower, "tool_calls")
keyIdx := -1
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
for _, kw := range keywords {
idx := strings.Index(lower, kw)
if idx >= 0 && (keyIdx < 0 || idx < keyIdx) {
keyIdx = idx
}
}
if keyIdx < 0 {
return "", nil, "", false
}
start := strings.LastIndex(captured[:keyIdx], "{")
if start < 0 {
return "", nil, "", false
start = keyIdx
}
obj, end, ok := extractJSONObjectFrom(captured, start)
if !ok {
@@ -197,9 +217,6 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
}
prefixPart := captured[:start]
suffixPart := captured[end:]
if insideCodeFence(state.recentTextTail + prefixPart) {
return captured, nil, "", true
}
parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
if len(parsed.Calls) == 0 {
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
@@ -207,7 +224,50 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
// consume it to avoid leaking raw tool_calls JSON to user content.
return prefixPart, nil, suffixPart, true
}
// If it has obvious keywords but failed to parse even after loose repair,
// we still might want to intercept it if it looks like an attempt at tool call.
// For now, keep the original logic but rely on loose JSON repair.
return captured, nil, "", true
}
prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart)
return prefixPart, parsed.Calls, suffixPart, true
}
func trimWrappingJSONFence(prefix, suffix string) (string, string) {
trimmedPrefix := strings.TrimRight(prefix, " \t\r\n")
fenceIdx := strings.LastIndex(trimmedPrefix, "```")
if fenceIdx < 0 {
return prefix, suffix
}
// Only strip when the trailing fence in prefix behaves like an opening fence.
// A legitimate closing fence before a standalone tool JSON must be preserved.
if strings.Count(trimmedPrefix[:fenceIdx+3], "```")%2 == 0 {
return prefix, suffix
}
fenceHeader := strings.TrimSpace(trimmedPrefix[fenceIdx+3:])
if fenceHeader != "" && !strings.EqualFold(fenceHeader, "json") {
return prefix, suffix
}
trimmedSuffix := strings.TrimLeft(suffix, " \t\r\n")
if !strings.HasPrefix(trimmedSuffix, "```") {
return prefix, suffix
}
consumedLeading := len(suffix) - len(trimmedSuffix)
return trimmedPrefix[:fenceIdx], suffix[consumedLeading+3:]
}
func openFenceStartBefore(s string, pos int) (int, bool) {
if pos <= 0 || pos > len(s) {
return -1, false
}
segment := s[:pos]
lastFence := strings.LastIndex(segment, "```")
if lastFence < 0 {
return -1, false
}
if strings.Count(segment, "```")%2 == 1 {
return lastFence, true
}
return -1, false
}

View File

@@ -19,9 +19,6 @@ func buildIncrementalToolDeltas(state *toolStreamSieveState) []toolCallDelta {
if start < 0 {
return nil
}
if insideCodeFence(state.recentTextTail + captured[:start]) {
return nil
}
certainSingle, hasMultiple := classifyToolCallsIncrementalSafety(captured, keyIdx)
if hasMultiple {
state.disableDeltas = true

View File

@@ -7,17 +7,19 @@ import (
)
type toolStreamSieveState struct {
pending strings.Builder
capture strings.Builder
capturing bool
recentTextTail string
disableDeltas bool
toolNameSent bool
toolName string
toolArgsStart int
toolArgsSent int
toolArgsString bool
toolArgsDone bool
pending strings.Builder
capture strings.Builder
capturing bool
recentTextTail string
pendingToolRaw string
pendingToolCalls []util.ParsedToolCall
disableDeltas bool
toolNameSent bool
toolName string
toolArgsStart int
toolArgsSent int
toolArgsString bool
toolArgsDone bool
}
type toolStreamEvent struct {
@@ -32,7 +34,6 @@ type toolCallDelta struct {
Arguments string
}
const toolSieveCaptureLimit = 8 * 1024
const toolSieveContextTailLimit = 256
func (s *toolStreamSieveState) resetIncrementalToolState() {

View File

@@ -27,6 +27,7 @@ type ConfigStore interface {
RuntimeAccountMaxInflight() int
RuntimeAccountMaxQueue(defaultSize int) int
RuntimeGlobalMaxInflight(defaultSize int) int
AutoDeleteSessions() bool
}
type PoolController interface {
@@ -40,6 +41,8 @@ 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)
GetSessionCountForToken(ctx context.Context, token string) (*deepseek.SessionStats, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error
}
var _ ConfigStore = (*config.Store)(nil)

View File

@@ -31,12 +31,15 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Get("/queue/status", h.queueStatus)
pr.Post("/accounts/test", h.testSingleAccount)
pr.Post("/accounts/test-all", h.testAllAccounts)
pr.Post("/accounts/sessions/delete-all", h.deleteAllSessions)
pr.Post("/import", h.batchImport)
pr.Post("/test", h.testAPI)
pr.Post("/vercel/sync", h.syncVercel)
pr.Get("/vercel/status", h.vercelStatus)
pr.Post("/vercel/status", h.vercelStatus)
pr.Get("/export", h.exportConfig)
pr.Get("/dev/captures", h.getDevCaptures)
pr.Delete("/dev/captures", h.clearDevCaptures)
pr.Get("/version", h.getVersion)
})
}

View File

@@ -1,128 +1,133 @@
package admin
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"ds2api/internal/config"
)
func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
page := intFromQuery(r, "page", 1)
pageSize := intFromQuery(r, "page_size", 10)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 1
}
if pageSize > 100 {
pageSize = 100
}
accounts := h.Store.Snapshot().Accounts
reverseAccounts(accounts)
q := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q")))
if q != "" {
filtered := make([]config.Account, 0, len(accounts))
for _, acc := range accounts {
id := strings.ToLower(acc.Identifier())
if strings.Contains(id, q) ||
strings.Contains(strings.ToLower(acc.Email), q) ||
strings.Contains(strings.ToLower(acc.Mobile), q) {
filtered = append(filtered, acc)
}
}
accounts = filtered
}
total := len(accounts)
totalPages := 1
if total > 0 {
totalPages = (total + pageSize - 1) / pageSize
}
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
items := make([]map[string]any, 0, end-start)
for _, acc := range accounts[start:end] {
token := strings.TrimSpace(acc.Token)
preview := ""
if token != "" {
if len(token) > 20 {
preview = token[:20] + "..."
} else {
preview = token
}
}
items = append(items, map[string]any{
"identifier": acc.Identifier(),
"email": acc.Email,
"mobile": acc.Mobile,
"has_password": acc.Password != "",
"has_token": token != "",
"token_preview": preview,
"test_status": acc.TestStatus,
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
}
func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
acc := toAccount(req)
if acc.Identifier() == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要 email 或 mobile"})
return
}
err := h.Store.Update(func(c *config.Config) error {
for _, a := range c.Accounts {
if acc.Email != "" && a.Email == acc.Email {
return fmt.Errorf("邮箱已存在")
}
if acc.Mobile != "" && a.Mobile == acc.Mobile {
return fmt.Errorf("手机号已存在")
}
}
c.Accounts = append(c.Accounts, acc)
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
err := h.Store.Update(func(c *config.Config) error {
idx := -1
for i, a := range c.Accounts {
if accountMatchesIdentifier(a, identifier) {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("账号不存在")
}
c.Accounts = append(c.Accounts[:idx], c.Accounts[idx+1:]...)
return nil
})
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
package admin
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"ds2api/internal/config"
)
func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
page := intFromQuery(r, "page", 1)
pageSize := intFromQuery(r, "page_size", 10)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 1
}
if pageSize > 100 {
pageSize = 100
}
accounts := h.Store.Snapshot().Accounts
reverseAccounts(accounts)
q := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q")))
if q != "" {
filtered := make([]config.Account, 0, len(accounts))
for _, acc := range accounts {
id := strings.ToLower(acc.Identifier())
if strings.Contains(id, q) ||
strings.Contains(strings.ToLower(acc.Email), q) ||
strings.Contains(strings.ToLower(acc.Mobile), q) {
filtered = append(filtered, acc)
}
}
accounts = filtered
}
total := len(accounts)
totalPages := 1
if total > 0 {
totalPages = (total + pageSize - 1) / pageSize
}
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
items := make([]map[string]any, 0, end-start)
for _, acc := range accounts[start:end] {
token := strings.TrimSpace(acc.Token)
preview := ""
if token != "" {
if len(token) > 20 {
preview = token[:20] + "..."
} else {
preview = token
}
}
items = append(items, map[string]any{
"identifier": acc.Identifier(),
"email": acc.Email,
"mobile": acc.Mobile,
"has_password": acc.Password != "",
"has_token": token != "",
"token_preview": preview,
"test_status": acc.TestStatus,
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
}
func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
acc := toAccount(req)
if acc.Identifier() == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要 email 或 mobile"})
return
}
err := h.Store.Update(func(c *config.Config) error {
mobileKey := config.CanonicalMobileKey(acc.Mobile)
for _, a := range c.Accounts {
if acc.Email != "" && a.Email == acc.Email {
return fmt.Errorf("邮箱已存在")
}
if mobileKey != "" && config.CanonicalMobileKey(a.Mobile) == mobileKey {
return fmt.Errorf("手机号已存在")
}
}
c.Accounts = append(c.Accounts, acc)
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "identifier")
if decoded, err := url.PathUnescape(identifier); err == nil {
identifier = decoded
}
err := h.Store.Update(func(c *config.Config) error {
idx := -1
for i, a := range c.Accounts {
if accountMatchesIdentifier(a, identifier) {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("账号不存在")
}
c.Accounts = append(c.Accounts[:idx], c.Accounts[idx+1:]...)
return nil
})
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()})
return
}
h.Pool.Reset()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
}

View File

@@ -1,11 +1,11 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi/v5"
@@ -25,9 +25,9 @@ func newAdminTestHandler(t *testing.T, raw string) *Handler {
}
}
func TestListAccountsIncludesTokenOnlyIdentifier(t *testing.T) {
func TestListAccountsUsesEmailIdentifier(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"token":"token-only-account"}]
"accounts":[{"email":"u@example.com","password":"pwd"}]
}`)
req := httptest.NewRequest(http.MethodGet, "/admin/accounts?page=1&page_size=10", nil)
@@ -48,38 +48,8 @@ func TestListAccountsIncludesTokenOnlyIdentifier(t *testing.T) {
}
first, _ := items[0].(map[string]any)
identifier, _ := first["identifier"].(string)
if identifier == "" {
t.Fatalf("expected non-empty identifier: %#v", first)
}
if !strings.HasPrefix(identifier, "token:") {
t.Fatalf("expected token synthetic identifier, got %q", identifier)
}
}
func TestDeleteAccountSupportsTokenOnlyIdentifier(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"token":"token-only-account"}]
}`)
accounts := h.Store.Accounts()
if len(accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(accounts))
}
id := accounts[0].Identifier()
if id == "" {
t.Fatal("expected token-only synthetic identifier")
}
r := chi.NewRouter()
r.Delete("/admin/accounts/{identifier}", h.deleteAccount)
req := httptest.NewRequest(http.MethodDelete, "/admin/accounts/"+url.PathEscape(id), nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := len(h.Store.Accounts()); got != 0 {
t.Fatalf("expected account removed, remaining=%d", got)
if identifier != "u@example.com" {
t.Fatalf("expected email identifier, got %q", identifier)
}
}
@@ -102,11 +72,49 @@ func TestDeleteAccountSupportsMobileAlias(t *testing.T) {
}
}
func TestFindAccountByIdentifierSupportsMobileAndTokenOnly(t *testing.T) {
func TestDeleteAccountSupportsEncodedPlusMobile(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"mobile":"+8613800138000","password":"pwd"}]
}`)
r := chi.NewRouter()
r.Delete("/admin/accounts/{identifier}", h.deleteAccount)
req := httptest.NewRequest(http.MethodDelete, "/admin/accounts/"+url.PathEscape("+8613800138000"), nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := len(h.Store.Accounts()); got != 0 {
t.Fatalf("expected account removed, remaining=%d", got)
}
}
func TestAddAccountRejectsCanonicalMobileDuplicate(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"mobile":"+8613800138000","password":"pwd"}]
}`)
r := chi.NewRouter()
r.Post("/admin/accounts", h.addAccount)
body := []byte(`{"mobile":"13800138000","password":"pwd2"}`)
req := httptest.NewRequest(http.MethodPost, "/admin/accounts", bytes.NewReader(body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := len(h.Store.Accounts()); got != 1 {
t.Fatalf("expected no duplicate insert, got=%d", got)
}
}
func TestFindAccountByIdentifierSupportsMobile(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[
{"email":"u@example.com","mobile":"13800138000","password":"pwd"},
{"token":"token-only-account"}
{"email":"u@example.com","mobile":"13800138000","password":"pwd"}
]
}`)
@@ -117,22 +125,12 @@ func TestFindAccountByIdentifierSupportsMobileAndTokenOnly(t *testing.T) {
if accByMobile.Email != "u@example.com" {
t.Fatalf("unexpected account by mobile: %#v", accByMobile)
}
tokenOnlyID := ""
for _, acc := range h.Store.Accounts() {
if strings.TrimSpace(acc.Email) == "" && strings.TrimSpace(acc.Mobile) == "" {
tokenOnlyID = acc.Identifier()
break
}
}
if tokenOnlyID == "" {
t.Fatal("expected token-only account identifier")
}
accByTokenOnly, ok := findAccountByIdentifier(h.Store, tokenOnlyID)
accByMobileWithCountryCode, ok := findAccountByIdentifier(h.Store, "+8613800138000")
if !ok {
t.Fatalf("expected find by token-only id=%q", tokenOnlyID)
t.Fatal("expected find by +86 mobile")
}
if accByTokenOnly.Token != "token-only-account" {
t.Fatalf("unexpected token-only account: %#v", accByTokenOnly)
if accByMobileWithCountryCode.Email != "u@example.com" {
t.Fatalf("unexpected account by +86 mobile: %#v", accByMobileWithCountryCode)
}
}

View File

@@ -1,209 +1,271 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
authn "ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/sse"
)
func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
identifier, _ := req["identifier"].(string)
if strings.TrimSpace(identifier) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识identifier / email / mobile"})
return
}
acc, ok := findAccountByIdentifier(h.Store, identifier)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
model, _ := req["model"].(string)
if model == "" {
model = "deepseek-chat"
}
message, _ := req["message"].(string)
result := h.testAccount(r.Context(), acc, model, message)
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
model, _ := req["model"].(string)
if model == "" {
model = "deepseek-chat"
}
accounts := h.Store.Snapshot().Accounts
if len(accounts) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}})
return
}
// Concurrent testing with a semaphore to limit parallelism.
const maxConcurrency = 5
results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any {
return h.testAccount(r.Context(), account, model, "")
})
success := 0
for _, res := range results {
if ok, _ := res["success"].(bool); ok {
success++
}
}
writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results})
}
func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any {
if maxConcurrency <= 0 {
maxConcurrency = 1
}
sem := make(chan struct{}, maxConcurrency)
results := make([]map[string]any, len(accounts))
var wg sync.WaitGroup
for i, acc := range accounts {
wg.Add(1)
go func(idx int, account config.Account) {
defer wg.Done()
sem <- struct{}{} // acquire
defer func() { <-sem }() // release
results[idx] = testFn(idx, account)
}(i, acc)
}
wg.Wait()
return results
}
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
start := time.Now()
identifier := acc.Identifier()
result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model}
defer func() {
status := "failed"
if ok, _ := result["success"].(bool); ok {
status = "ok"
}
_ = h.Store.UpdateAccountTestStatus(identifier, status)
}()
token := strings.TrimSpace(acc.Token)
if token == "" {
newToken, err := h.DS.Login(ctx, acc)
if err != nil {
result["message"] = "登录失败: " + err.Error()
return result
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
}
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token}
sessionID, err := h.DS.CreateSession(ctx, authCtx, 1)
if err != nil {
newToken, loginErr := h.DS.Login(ctx, acc)
if loginErr != nil {
result["message"] = "创建会话失败: " + err.Error()
return result
}
token = newToken
authCtx.DeepSeekToken = token
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
sessionID, err = h.DS.CreateSession(ctx, authCtx, 1)
if err != nil {
result["message"] = "创建会话失败: " + err.Error()
return result
}
}
if strings.TrimSpace(message) == "" {
message = "你是谁?"
}
thinking, search, ok := config.GetModelConfig(model)
if !ok {
thinking, search = false, false
}
_ = search
pow, err := h.DS.GetPow(ctx, authCtx, 1)
if err != nil {
result["message"] = "获取 PoW 失败: " + err.Error()
return result
}
payload := map[string]any{"chat_session_id": sessionID, "prompt": "<User>" + message, "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search}
resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1)
if err != nil {
result["message"] = "请求失败: " + err.Error()
return result
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode)
return result
}
collected := sse.CollectStream(resp, thinking, true)
result["success"] = true
result["response_time"] = int(time.Since(start).Milliseconds())
if collected.Text != "" {
result["message"] = collected.Text
} else {
result["message"] = "(无回复内容)"
}
if collected.Thinking != "" {
result["thinking"] = collected.Thinking
}
return result
}
func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
model, _ := req["model"].(string)
message, _ := req["message"].(string)
apiKey, _ := req["api_key"].(string)
if model == "" {
model = "deepseek-chat"
}
if message == "" {
message = "你好"
}
if apiKey == "" {
keys := h.Store.Snapshot().Keys
if len(keys) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"})
return
}
apiKey = keys[0]
}
host := r.Host
scheme := "http"
if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") {
scheme = "https"
}
payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false}
b, _ := json.Marshal(payload)
request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b))
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusOK {
var parsed any
_ = json.Unmarshal(body, &parsed)
writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
}
package admin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
authn "ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/sse"
)
func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
identifier, _ := req["identifier"].(string)
if strings.TrimSpace(identifier) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识identifier / email / mobile"})
return
}
acc, ok := findAccountByIdentifier(h.Store, identifier)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
model, _ := req["model"].(string)
if model == "" {
model = "deepseek-chat"
}
message, _ := req["message"].(string)
result := h.testAccount(r.Context(), acc, model, message)
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
model, _ := req["model"].(string)
if model == "" {
model = "deepseek-chat"
}
accounts := h.Store.Snapshot().Accounts
if len(accounts) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}})
return
}
// Concurrent testing with a semaphore to limit parallelism.
const maxConcurrency = 5
results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any {
return h.testAccount(r.Context(), account, model, "")
})
success := 0
for _, res := range results {
if ok, _ := res["success"].(bool); ok {
success++
}
}
writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results})
}
func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any {
if maxConcurrency <= 0 {
maxConcurrency = 1
}
sem := make(chan struct{}, maxConcurrency)
results := make([]map[string]any, len(accounts))
var wg sync.WaitGroup
for i, acc := range accounts {
wg.Add(1)
go func(idx int, account config.Account) {
defer wg.Done()
sem <- struct{}{} // acquire
defer func() { <-sem }() // release
results[idx] = testFn(idx, account)
}(i, acc)
}
wg.Wait()
return results
}
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
start := time.Now()
identifier := acc.Identifier()
result := map[string]any{
"account": identifier,
"success": false,
"response_time": 0,
"message": "",
"model": model,
"session_count": 0,
"config_writable": !h.Store.IsEnvBacked(),
}
defer func() {
status := "failed"
if ok, _ := result["success"].(bool); ok {
status = "ok"
}
_ = h.Store.UpdateAccountTestStatus(identifier, status)
}()
token, err := h.DS.Login(ctx, acc)
if err != nil {
result["message"] = "登录失败: " + err.Error()
return result
}
if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil {
result["message"] = "登录成功但写入运行时 token 失败: " + err.Error()
return result
}
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token}
sessionID, err := h.DS.CreateSession(ctx, authCtx, 1)
if err != nil {
newToken, loginErr := h.DS.Login(ctx, acc)
if loginErr != nil {
result["message"] = "创建会话失败: " + err.Error()
return result
}
token = newToken
authCtx.DeepSeekToken = token
if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil {
result["message"] = "刷新 token 成功但写入运行时 token 失败: " + err.Error()
return result
}
sessionID, err = h.DS.CreateSession(ctx, authCtx, 1)
if err != nil {
result["message"] = "创建会话失败: " + err.Error()
return result
}
}
// 获取会话数量
sessionStats, sessionErr := h.DS.GetSessionCountForToken(ctx, token)
if sessionErr == nil && sessionStats != nil {
result["session_count"] = sessionStats.FirstPageCount
}
if strings.TrimSpace(message) == "" {
result["success"] = true
result["message"] = "Token 刷新成功(登录与会话创建成功)"
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
thinking, search, ok := config.GetModelConfig(model)
if !ok {
thinking, search = false, false
}
_ = search
pow, err := h.DS.GetPow(ctx, authCtx, 1)
if err != nil {
result["message"] = "获取 PoW 失败: " + err.Error()
return result
}
payload := map[string]any{"chat_session_id": sessionID, "prompt": "<User>" + message, "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search}
resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1)
if err != nil {
result["message"] = "请求失败: " + err.Error()
return result
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode)
return result
}
collected := sse.CollectStream(resp, thinking, true)
result["success"] = true
result["response_time"] = int(time.Since(start).Milliseconds())
if collected.Text != "" {
result["message"] = collected.Text
} else {
result["message"] = "(无回复内容)"
}
if collected.Thinking != "" {
result["thinking"] = collected.Thinking
}
return result
}
func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
model, _ := req["model"].(string)
message, _ := req["message"].(string)
apiKey, _ := req["api_key"].(string)
if model == "" {
model = "deepseek-chat"
}
if message == "" {
message = "你好"
}
if apiKey == "" {
keys := h.Store.Snapshot().Keys
if len(keys) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"})
return
}
apiKey = keys[0]
}
host := r.Host
scheme := "http"
if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") {
scheme = "https"
}
payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false}
b, _ := json.Marshal(payload)
request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b))
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusOK {
var parsed any
_ = json.Unmarshal(body, &parsed)
writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
}
func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
identifier, _ := req["identifier"].(string)
if strings.TrimSpace(identifier) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识identifier / email / mobile"})
return
}
acc, ok := findAccountByIdentifier(h.Store, identifier)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
// 每次先登录刷新一次 token避免使用过期 token。
token, err := h.DS.Login(r.Context(), acc)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
return
}
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
// 删除所有会话
err = h.DS.DeleteAllSessionsForToken(r.Context(), token)
if err != nil {
// token 可能过期,尝试重新登录并重试一次
newToken, loginErr := h.DS.Login(r.Context(), acc)
if loginErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
if retryErr := h.DS.DeleteAllSessionsForToken(r.Context(), token); retryErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
}

View File

@@ -0,0 +1,134 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/deepseek"
)
type testingDSMock struct {
loginCalls int
createSessionCalls int
getPowCalls int
callCompletionCalls int
deleteAllSessionsCalls int
deleteAllSessionsError error
deleteAllSessionsErrorOnce bool
}
func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) {
m.loginCalls++
return "new-token", nil
}
func (m *testingDSMock) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
m.createSessionCalls++
return "session-id", nil
}
func (m *testingDSMock) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
m.getPowCalls++
return "", errors.New("should not call GetPow in this test")
}
func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
m.callCompletionCalls++
return nil, errors.New("should not call CallCompletion in this test")
}
func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error {
m.deleteAllSessionsCalls++
if m.deleteAllSessionsError != nil {
err := m.deleteAllSessionsError
if m.deleteAllSessionsErrorOnce {
m.deleteAllSessionsError = nil
}
return err
}
return nil
}
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*deepseek.SessionStats, error) {
return &deepseek.SessionStats{Success: true}, nil
}
func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
store := config.LoadStore()
ds := &testingDSMock{}
h := &Handler{Store: store, DS: ds}
acc, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected test account")
}
result := h.testAccount(context.Background(), acc, "deepseek-chat", "")
if ok, _ := result["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", result)
}
msg, _ := result["message"].(string)
if !strings.Contains(msg, "Token 刷新成功") {
t.Fatalf("expected session-only success message, got %q", msg)
}
if ds.loginCalls != 1 || ds.createSessionCalls != 1 {
t.Fatalf("unexpected Login/CreateSession calls: login=%d createSession=%d", ds.loginCalls, ds.createSessionCalls)
}
if ds.getPowCalls != 0 || ds.callCompletionCalls != 0 {
t.Fatalf("expected no completion flow calls, got getPow=%d callCompletion=%d", ds.getPowCalls, ds.callCompletionCalls)
}
updated, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected updated account")
}
if updated.Token != "new-token" {
t.Fatalf("expected refreshed token to be persisted, got %q", updated.Token)
}
if updated.TestStatus != "ok" {
t.Fatalf("expected test status ok, got %q", updated.TestStatus)
}
}
func TestDeleteAllSessions_RetryWithReloginOnDeleteFailure(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"expired-token"}]}`)
store := config.LoadStore()
ds := &testingDSMock{deleteAllSessionsError: errors.New("token expired"), deleteAllSessionsErrorOnce: true}
h := &Handler{Store: store, DS: ds}
req := httptest.NewRequest(http.MethodPost, "/delete-all", bytes.NewBufferString(`{"identifier":"batch@example.com"}`))
rec := httptest.NewRecorder()
h.deleteAllSessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if ok, _ := resp["success"].(bool); !ok {
t.Fatalf("expected success response, got %#v", resp)
}
if ds.loginCalls != 2 {
t.Fatalf("expected initial login plus relogin, got %d", ds.loginCalls)
}
if ds.deleteAllSessionsCalls != 2 {
t.Fatalf("expected delete called twice, got %d", ds.deleteAllSessionsCalls)
}
updated, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected account")
}
if updated.Token != "new-token" {
t.Fatalf("expected refreshed token persisted, got %q", updated.Token)
}
}

View File

@@ -43,12 +43,14 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
incoming.ClearAccountTokens()
importedKeys, importedAccounts := 0, 0
err = h.Store.Update(func(c *config.Config) error {
next := c.Clone()
if mode == "replace" {
next = incoming.Clone()
next.Accounts = normalizeAndDedupeAccounts(next.Accounts)
next.VercelSyncHash = c.VercelSyncHash
next.VercelSyncTime = c.VercelSyncTime
importedKeys = len(next.Keys)
@@ -73,17 +75,22 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
existingAccounts := map[string]struct{}{}
for _, acc := range next.Accounts {
existingAccounts[acc.Identifier()] = struct{}{}
acc = normalizeAccountForStorage(acc)
key := accountDedupeKey(acc)
if key != "" {
existingAccounts[key] = struct{}{}
}
}
for _, acc := range incoming.Accounts {
id := acc.Identifier()
if id == "" {
acc = normalizeAccountForStorage(acc)
key := accountDedupeKey(acc)
if key == "" {
continue
}
if _, ok := existingAccounts[id]; ok {
if _, ok := existingAccounts[key]; ok {
continue
}
existingAccounts[id] = struct{}{}
existingAccounts[key] = struct{}{}
next.Accounts = append(next.Accounts, acc)
importedAccounts++
}
@@ -174,6 +181,7 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
func (h *Handler) computeSyncHash() string {
snap := h.Store.Snapshot().Clone()
snap.ClearAccountTokens()
snap.VercelSyncHash = ""
snap.VercelSyncTime = 0
b, _ := json.Marshal(snap)

View File

@@ -8,8 +8,9 @@ import (
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
safe := map[string]any{
"keys": snap.Keys,
"accounts": []map[string]any{},
"keys": snap.Keys,
"accounts": []map[string]any{},
"env_backed": h.Store.IsEnvBacked(),
"claude_mapping": func() map[string]string {
if len(snap.ClaudeMapping) > 0 {
return snap.ClaudeMapping

View File

@@ -25,24 +25,33 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) {
if accountsRaw, ok := req["accounts"].([]any); ok {
existing := map[string]config.Account{}
for _, a := range old.Accounts {
existing[a.Identifier()] = a
a = normalizeAccountForStorage(a)
key := accountDedupeKey(a)
if key != "" {
existing[key] = a
}
}
seen := map[string]struct{}{}
accounts := make([]config.Account, 0, len(accountsRaw))
for _, item := range accountsRaw {
m, ok := item.(map[string]any)
if !ok {
continue
}
acc := toAccount(m)
id := acc.Identifier()
if prev, ok := existing[id]; ok {
acc := normalizeAccountForStorage(toAccount(m))
key := accountDedupeKey(acc)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
if prev, ok := existing[key]; ok {
if strings.TrimSpace(acc.Password) == "" {
acc.Password = prev.Password
}
if strings.TrimSpace(acc.Token) == "" {
acc.Token = prev.Token
}
}
seen[key] = struct{}{}
accounts = append(accounts, acc)
}
c.Accounts = accounts
@@ -138,20 +147,24 @@ func (h *Handler) batchImport(w http.ResponseWriter, r *http.Request) {
if accounts, ok := req["accounts"].([]any); ok {
existing := map[string]bool{}
for _, a := range c.Accounts {
existing[a.Identifier()] = true
a = normalizeAccountForStorage(a)
key := accountDedupeKey(a)
if key != "" {
existing[key] = true
}
}
for _, item := range accounts {
m, ok := item.(map[string]any)
if !ok {
continue
}
acc := toAccount(m)
id := acc.Identifier()
if id == "" || existing[id] {
acc := normalizeAccountForStorage(toAccount(m))
key := accountDedupeKey(acc)
if key == "" || existing[key] {
continue
}
c.Accounts = append(c.Accounts, acc)
existing[id] = true
existing[key] = true
importedAccounts++
}
}

View File

@@ -7,15 +7,30 @@ import (
"ds2api/internal/config"
)
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, map[string]string, map[string]string, error) {
func boolFrom(v any) bool {
if v == nil {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
return strings.ToLower(strings.TrimSpace(x)) == "true"
default:
return false
}
}
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) {
var (
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
toolcallCfg *config.ToolcallConfig
respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig
claudeMap map[string]string
aliasMap map[string]string
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
toolcallCfg *config.ToolcallConfig
respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig
autoDeleteCfg *config.AutoDeleteConfig
claudeMap map[string]string
aliasMap map[string]string
)
if raw, ok := req["admin"].(map[string]any); ok {
@@ -23,7 +38,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["jwt_expire_hours"]; exists {
n := intFrom(v)
if n < 1 || n > 720 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
}
cfg.JWTExpireHours = n
}
@@ -35,26 +50,26 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v)
if n < 1 || n > 256 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
}
cfg.AccountMaxInflight = n
}
if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v)
if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
}
cfg.AccountMaxQueue = n
}
if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v)
if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
}
cfg.GlobalMaxInflight = n
}
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
runtimeCfg = cfg
}
@@ -67,7 +82,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "feature_match", "off":
cfg.Mode = mode
default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
}
}
if v, exists := raw["early_emit_confidence"]; exists {
@@ -76,7 +91,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "high", "low", "off":
cfg.EarlyEmitConfidence = level
default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
}
}
toolcallCfg = cfg
@@ -87,7 +102,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v)
if n < 30 || n > 86400 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
}
cfg.StoreTTLSeconds = n
}
@@ -98,9 +113,6 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
cfg := &config.EmbeddingsConfig{}
if v, exists := raw["provider"]; exists {
p := strings.TrimSpace(fmt.Sprintf("%v", v))
if p == "" {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("embeddings.provider cannot be empty")
}
cfg.Provider = p
}
embCfg = cfg
@@ -130,5 +142,13 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
}
}
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, claudeMap, aliasMap, nil
if raw, ok := req["auto_delete"].(map[string]any); ok {
cfg := &config.AutoDeleteConfig{}
if v, exists := raw["sessions"]; exists {
cfg.Sessions = boolFrom(v)
}
autoDeleteCfg = cfg
}
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil
}

View File

@@ -28,6 +28,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
"toolcall": snap.Toolcall,
"responses": snap.Responses,
"embeddings": snap.Embeddings,
"auto_delete": snap.AutoDelete,
"claude_mapping": settingsClaudeMapping(snap),
"model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(),

View File

@@ -265,3 +265,57 @@ func TestConfigImportRejectsMergedRuntimeConflict(t *testing.T) {
t.Fatalf("runtime should remain unchanged, runtime=%+v", snap.Runtime)
}
}
func TestConfigImportMergeDedupesMobileAliases(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"accounts":[{"mobile":"+8613800138000","password":"p1"}]
}`)
merge := map[string]any{
"mode": "merge",
"config": map[string]any{
"accounts": []any{
map[string]any{"mobile": "13800138000", "password": "p2"},
},
},
}
b, _ := json.Marshal(merge)
req := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=merge", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.configImport(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got := len(h.Store.Accounts()); got != 1 {
t.Fatalf("expected merge dedupe by canonical mobile, got=%d", got)
}
}
func TestUpdateConfigDedupesMobileAliases(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"accounts":[{"mobile":"+8613800138000","password":"old"}]
}`)
reqBody := map[string]any{
"accounts": []any{
map[string]any{"mobile": "+8613800138000"},
map[string]any{"mobile": "13800138000"},
},
}
b, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/admin/config", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
accounts := h.Store.Accounts()
if len(accounts) != 1 {
t.Fatalf("expected update dedupe by canonical mobile, got=%d", len(accounts))
}
if accounts[0].Identifier() != "+8613800138000" {
t.Fatalf("unexpected identifier: %q", accounts[0].Identifier())
}
}

View File

@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return
}
adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
@@ -60,6 +60,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" {
c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider)
}
if autoDeleteCfg != nil {
c.AutoDelete.Sessions = autoDeleteCfg.Sessions
}
if claudeMap != nil {
c.ClaudeMapping = claudeMap
c.ClaudeModelMap = nil

View File

@@ -3,6 +3,8 @@ package admin
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -11,6 +13,8 @@ import (
"os"
"strings"
"time"
"ds2api/internal/config"
)
func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
@@ -25,7 +29,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
return
}
validated, failed := h.validateAccountsForVercelSync(r.Context(), opts.AutoValidate)
_, cfgB64, err := h.Store.ExportJSONAndBase64()
cfgJSON, cfgB64, err := h.exportSyncConfig(req)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
@@ -47,7 +51,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
}
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
_ = h.Store.SetVercelSync(h.computeSyncHash(), time.Now().Unix())
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
result := map[string]any{"success": true, "validated_accounts": validated}
if manual {
result["message"] = "配置已同步到 Vercel请手动触发重新部署"
@@ -209,11 +213,71 @@ func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID
return false, deployURL
}
func (h *Handler) vercelStatus(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) {
snap := h.Store.Snapshot()
current := h.computeSyncHash()
synced := snap.VercelSyncHash != "" && snap.VercelSyncHash == current
writeJSON(w, http.StatusOK, map[string]any{"synced": synced, "last_sync_time": nilIfZero(snap.VercelSyncTime), "has_synced_before": snap.VercelSyncHash != ""})
draftHash := ""
draftDiffers := false
if r != nil && r.Method == http.MethodPost && r.Body != nil {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
if cfgJSON, _, err := h.exportSyncConfig(req); err == nil {
draftHash = syncHashForJSON(cfgJSON)
draftDiffers = draftHash != "" && draftHash != current
}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"synced": synced,
"last_sync_time": nilIfZero(snap.VercelSyncTime),
"has_synced_before": snap.VercelSyncHash != "",
"env_backed": h.Store.IsEnvBacked(),
"config_hash": current,
"last_synced_hash": snap.VercelSyncHash,
"draft_hash": draftHash,
"draft_differs": draftDiffers,
})
}
func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
override, ok := req["config_override"]
if !ok || override == nil {
return h.Store.ExportJSONAndBase64()
}
raw, err := json.Marshal(override)
if err != nil {
return "", "", err
}
var cfg config.Config
if err := json.Unmarshal(raw, &cfg); err != nil {
return "", "", err
}
cfg.DropInvalidAccounts()
cfg.ClearAccountTokens()
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
b, err := json.Marshal(cfg)
if err != nil {
return "", "", err
}
return string(b), base64.StdEncoding.EncodeToString(b), nil
}
func syncHashForJSON(s string) string {
var cfg config.Config
if err := json.Unmarshal([]byte(s), &cfg); err != nil {
return ""
}
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
cfg.ClearAccountTokens()
b, err := json.Marshal(cfg)
if err != nil {
return ""
}
sum := md5.Sum(b)
return fmt.Sprintf("%x", sum)
}
func vercelRequest(ctx context.Context, client *http.Client, method, endpoint string, params url.Values, headers map[string]string, body any) (map[string]any, int, error) {

View File

@@ -0,0 +1,75 @@
package admin
import (
"encoding/json"
"net/http"
"strings"
"time"
"ds2api/internal/version"
)
const latestReleaseAPI = "https://api.github.com/repos/CJackHwang/ds2api/releases/latest"
type latestReleasePayload struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
}
func (h *Handler) getVersion(w http.ResponseWriter, _ *http.Request) {
current, source := version.Current()
resp := map[string]any{
"success": true,
"current_version": current,
"current_tag": version.Tag(current),
"source": source,
"checked_at": time.Now().UTC().Format(time.RFC3339),
}
req, err := http.NewRequest(http.MethodGet, latestReleaseAPI, nil)
if err != nil {
resp["check_error"] = err.Error()
writeJSON(w, http.StatusOK, resp)
return
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "ds2api-version-check")
client := &http.Client{Timeout: 4 * time.Second}
r, err := client.Do(req)
if err != nil {
resp["check_error"] = err.Error()
writeJSON(w, http.StatusOK, resp)
return
}
defer r.Body.Close()
if r.StatusCode < 200 || r.StatusCode >= 300 {
resp["check_error"] = "github api status: " + r.Status
writeJSON(w, http.StatusOK, resp)
return
}
var data latestReleasePayload
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
resp["check_error"] = err.Error()
writeJSON(w, http.StatusOK, resp)
return
}
latest := strings.TrimSpace(data.TagName)
if latest == "" {
resp["check_error"] = "missing latest tag"
writeJSON(w, http.StatusOK, resp)
return
}
latestVersion := strings.TrimPrefix(latest, "v")
resp["latest_tag"] = latest
resp["latest_version"] = latestVersion
resp["release_url"] = data.HTMLURL
resp["published_at"] = data.PublishedAt
resp["has_update"] = version.Compare(current, latestVersion) < 0
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -59,11 +59,12 @@ func toStringSlice(v any) ([]string, bool) {
}
func toAccount(m map[string]any) config.Account {
email := fieldString(m, "email")
mobile := config.NormalizeMobileForStorage(fieldString(m, "mobile"))
return config.Account{
Email: fieldString(m, "email"),
Mobile: fieldString(m, "mobile"),
Email: email,
Mobile: mobile,
Password: fieldString(m, "password"),
Token: fieldString(m, "token"),
}
}
@@ -90,12 +91,52 @@ func accountMatchesIdentifier(acc config.Account, identifier string) bool {
if strings.TrimSpace(acc.Email) == id {
return true
}
if strings.TrimSpace(acc.Mobile) == id {
if mobileKey := config.CanonicalMobileKey(id); mobileKey != "" && mobileKey == config.CanonicalMobileKey(acc.Mobile) {
return true
}
return acc.Identifier() == id
}
func normalizeAccountForStorage(acc config.Account) config.Account {
acc.Email = strings.TrimSpace(acc.Email)
acc.Mobile = config.NormalizeMobileForStorage(acc.Mobile)
return acc
}
func accountDedupeKey(acc config.Account) string {
if email := strings.TrimSpace(acc.Email); email != "" {
return "email:" + email
}
if mobile := config.CanonicalMobileKey(acc.Mobile); mobile != "" {
return "mobile:" + mobile
}
if id := strings.TrimSpace(acc.Identifier()); id != "" {
return "id:" + id
}
return ""
}
func normalizeAndDedupeAccounts(accounts []config.Account) []config.Account {
if len(accounts) == 0 {
return nil
}
out := make([]config.Account, 0, len(accounts))
seen := make(map[string]struct{}, len(accounts))
for _, acc := range accounts {
acc = normalizeAccountForStorage(acc)
key := accountDedupeKey(acc)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, acc)
}
return out
}
func findAccountByIdentifier(store ConfigStore, identifier string) (config.Account, bool) {
id := strings.TrimSpace(identifier)
if id == "" {

View File

@@ -182,14 +182,14 @@ func TestToAccountAllFields(t *testing.T) {
if acc.Email != "user@test.com" {
t.Fatalf("unexpected email: %q", acc.Email)
}
if acc.Mobile != "13800138000" {
if acc.Mobile != "+8613800138000" {
t.Fatalf("unexpected mobile: %q", acc.Mobile)
}
if acc.Password != "secret" {
t.Fatalf("unexpected password: %q", acc.Password)
}
if acc.Token != "tok123" {
t.Fatalf("unexpected token: %q", acc.Token)
if acc.Token != "" {
t.Fatalf("expected token to be ignored, got %q", acc.Token)
}
}

View File

@@ -0,0 +1,109 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/account"
"ds2api/internal/config"
)
func newHTTPAdminHarness(t *testing.T, rawConfig string, ds DeepSeekCaller) http.Handler {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", rawConfig)
t.Setenv("CONFIG_JSON", "")
store := config.LoadStore()
h := &Handler{
Store: store,
Pool: account.NewPool(store),
DS: ds,
}
r := chi.NewRouter()
RegisterRoutes(r, h)
return r
}
func adminReq(method, path string, body []byte) *http.Request {
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer admin")
req.Header.Set("Content-Type", "application/json")
return req
}
func TestConfigImportIgnoresTokenFieldInPayload(t *testing.T) {
ds := &testingDSMock{}
router := newHTTPAdminHarness(t, `{"accounts":[]}`, ds)
payload := []byte(`{
"mode":"replace",
"config":{
"accounts":[{"email":"u@example.com","password":"pwd","token":"expired-token"}]
}
}`)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminReq(http.MethodPost, "/config/import", payload))
if rec.Code != http.StatusOK {
t.Fatalf("import status=%d body=%s", rec.Code, rec.Body.String())
}
readRec := httptest.NewRecorder()
router.ServeHTTP(readRec, adminReq(http.MethodGet, "/config", nil))
if readRec.Code != http.StatusOK {
t.Fatalf("get config status=%d body=%s", readRec.Code, readRec.Body.String())
}
var data map[string]any
if err := json.Unmarshal(readRec.Body.Bytes(), &data); err != nil {
t.Fatalf("decode config response: %v", err)
}
accounts, _ := data["accounts"].([]any)
if len(accounts) != 1 {
t.Fatalf("expected one account, got %d", len(accounts))
}
accountMap, _ := accounts[0].(map[string]any)
if hasToken, _ := accountMap["has_token"].(bool); hasToken {
t.Fatalf("expected imported token to be ignored, account=%#v", accountMap)
}
}
func TestAccountTestRefreshesRuntimeTokenButExportOmitsToken(t *testing.T) {
ds := &testingDSMock{}
router := newHTTPAdminHarness(t, `{
"accounts":[{"email":"batch@example.com","password":"pwd","token":"stale-token"}]
}`, ds)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminReq(http.MethodPost, "/accounts/test", []byte(`{"identifier":"batch@example.com"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("test account status=%d body=%s", rec.Code, rec.Body.String())
}
var testResp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &testResp); err != nil {
t.Fatalf("decode test response: %v", err)
}
if ok, _ := testResp["success"].(bool); !ok {
t.Fatalf("expected test success, got %#v", testResp)
}
if ds.loginCalls < 1 {
t.Fatalf("expected login to be called at least once, got %d", ds.loginCalls)
}
exportRec := httptest.NewRecorder()
router.ServeHTTP(exportRec, adminReq(http.MethodGet, "/config/export", nil))
if exportRec.Code != http.StatusOK {
t.Fatalf("export status=%d body=%s", exportRec.Code, exportRec.Body.String())
}
var exportResp map[string]any
if err := json.Unmarshal(exportRec.Body.Bytes(), &exportResp); err != nil {
t.Fatalf("decode export response: %v", err)
}
exportJSON, _ := exportResp["json"].(string)
if strings.Contains(exportJSON, `"token"`) {
t.Fatalf("expected export json to omit tokens, got %s", exportJSON)
}
}

View File

@@ -58,7 +58,7 @@ func TestDetermineWithXAPIKeyManagedKeyAcquiresAccount(t *testing.T) {
if auth.AccountID != "acc@example.com" {
t.Fatalf("unexpected account id: %q", auth.AccountID)
}
if auth.DeepSeekToken != "account-token" {
if auth.DeepSeekToken != "fresh-token" {
t.Fatalf("unexpected account token: %q", auth.DeepSeekToken)
}
if auth.CallerID == "" {

View File

@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"ds2api/internal/sse"
@@ -67,20 +68,36 @@ func TestGoCompatToolcallFixtures(t *testing.T) {
var fixture struct {
Text string `json:"text"`
ToolNames []string `json:"tool_names"`
Mode string `json:"mode"`
}
mustLoadJSON(t, fixturePath, &fixture)
var expected struct {
Calls []util.ParsedToolCall `json:"calls"`
Calls []util.ParsedToolCall `json:"calls"`
SawToolCallSyntax bool `json:"sawToolCallSyntax"`
RejectedByPolicy bool `json:"rejectedByPolicy"`
RejectedToolNames []string `json:"rejectedToolNames"`
}
mustLoadJSON(t, expectedPath, &expected)
got := util.ParseToolCalls(fixture.Text, fixture.ToolNames)
if len(got) == 0 && len(expected.Calls) == 0 {
continue
var got util.ToolCallParseResult
switch strings.ToLower(strings.TrimSpace(fixture.Mode)) {
case "standalone":
got = util.ParseStandaloneToolCallsDetailed(fixture.Text, fixture.ToolNames)
default:
got = util.ParseToolCallsDetailed(fixture.Text, fixture.ToolNames)
}
if !reflect.DeepEqual(got, expected.Calls) {
t.Fatalf("toolcall fixture %s mismatch:\n got=%#v\nwant=%#v", name, got, expected.Calls)
if got.Calls == nil {
got.Calls = []util.ParsedToolCall{}
}
if got.RejectedToolNames == nil {
got.RejectedToolNames = []string{}
}
if !reflect.DeepEqual(got.Calls, expected.Calls) ||
got.SawToolCallSyntax != expected.SawToolCallSyntax ||
got.RejectedByPolicy != expected.RejectedByPolicy ||
!reflect.DeepEqual(got.RejectedToolNames, expected.RejectedToolNames) {
t.Fatalf("toolcall fixture %s mismatch:\n got=%#v\nwant=%#v", name, got, expected)
}
}
}

View File

@@ -1,24 +1,13 @@
package config
import (
"crypto/sha256"
"encoding/hex"
"strings"
)
import "strings"
func (a Account) Identifier() string {
if strings.TrimSpace(a.Email) != "" {
return strings.TrimSpace(a.Email)
}
if strings.TrimSpace(a.Mobile) != "" {
return strings.TrimSpace(a.Mobile)
if mobile := NormalizeMobileForStorage(a.Mobile); mobile != "" {
return mobile
}
// Backward compatibility: old configs may contain token-only accounts.
// Use a stable non-sensitive synthetic id so they can still join the pool.
token := strings.TrimSpace(a.Token)
if token == "" {
return ""
}
sum := sha256.Sum256([]byte(token))
return "token:" + hex.EncodeToString(sum[:8])
return ""
}

View File

@@ -47,6 +47,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
if strings.TrimSpace(c.Embeddings.Provider) != "" {
m["embeddings"] = c.Embeddings
}
m["auto_delete"] = c.AutoDelete
if c.VercelSyncHash != "" {
m["_vercel_sync_hash"] = c.VercelSyncHash
}
@@ -108,6 +109,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.Embeddings); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "auto_delete":
if err := json.Unmarshal(v, &c.AutoDelete); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "_vercel_sync_hash":
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
@@ -141,6 +146,7 @@ func (c Config) Clone() Config {
Toolcall: c.Toolcall,
Responses: c.Responses,
Embeddings: c.Embeddings,
AutoDelete: c.AutoDelete,
VercelSyncHash: c.VercelSyncHash,
VercelSyncTime: c.VercelSyncTime,
AdditionalFields: map[string]any{},

View File

@@ -12,6 +12,7 @@ type Config struct {
Toolcall ToolcallConfig `json:"toolcall,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
AutoDelete AutoDeleteConfig `json:"auto_delete"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"`
@@ -25,6 +26,32 @@ type Account struct {
TestStatus string `json:"test_status,omitempty"`
}
func (c *Config) ClearAccountTokens() {
if c == nil {
return
}
for i := range c.Accounts {
c.Accounts[i].Token = ""
}
}
// DropInvalidAccounts removes accounts that cannot be addressed by admin APIs
// (no email and no normalizable mobile). This prevents legacy token-only
// records from becoming orphaned empty entries after token stripping.
func (c *Config) DropInvalidAccounts() {
if c == nil || len(c.Accounts) == 0 {
return
}
kept := make([]Account, 0, len(c.Accounts))
for _, acc := range c.Accounts {
if acc.Identifier() == "" {
continue
}
kept = append(kept, acc)
}
c.Accounts = kept
}
type CompatConfig struct {
WideInputStrictOutput *bool `json:"wide_input_strict_output,omitempty"`
}
@@ -53,3 +80,7 @@ type ResponsesConfig struct {
type EmbeddingsConfig struct {
Provider string `json:"provider,omitempty"`
}
type AutoDeleteConfig struct {
Sessions bool `json:"sessions"`
}

View File

@@ -202,7 +202,7 @@ func TestConfigCloneNilMaps(t *testing.T) {
func TestAccountIdentifierPreferenceMobileOverToken(t *testing.T) {
acc := Account{Mobile: "13800138000", Token: "tok"}
if acc.Identifier() != "13800138000" {
if acc.Identifier() != "+8613800138000" {
t.Fatalf("expected mobile identifier, got %q", acc.Identifier())
}
}

View File

@@ -2,25 +2,22 @@ package config
import (
"encoding/base64"
"strings"
"os"
"testing"
)
func TestAccountIdentifierFallsBackToTokenHash(t *testing.T) {
func TestAccountIdentifierRequiresEmailOrMobile(t *testing.T) {
acc := Account{Token: "example-token-value"}
id := acc.Identifier()
if !strings.HasPrefix(id, "token:") {
t.Fatalf("expected token-prefixed identifier, got %q", id)
}
if len(id) != len("token:")+16 {
t.Fatalf("unexpected identifier length: %d (%q)", len(id), id)
if id != "" {
t.Fatalf("expected empty identifier when only token is present, got %q", id)
}
}
func TestStoreFindAccountWithTokenOnlyIdentifier(t *testing.T) {
func TestLoadStoreClearsTokensFromConfigInput(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[{"token":"token-only-account"}]
"accounts":[{"email":"u@example.com","password":"p","token":"token-only-account"}]
}`)
store := LoadStore()
@@ -28,22 +25,62 @@ func TestStoreFindAccountWithTokenOnlyIdentifier(t *testing.T) {
if len(accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(accounts))
}
id := accounts[0].Identifier()
if id == "" {
t.Fatalf("expected synthetic identifier for token-only account")
}
found, ok := store.FindAccount(id)
if !ok {
t.Fatalf("expected FindAccount to locate token-only account by synthetic id")
}
if found.Token != "token-only-account" {
t.Fatalf("unexpected token value: %q", found.Token)
if accounts[0].Token != "" {
t.Fatalf("expected token to be cleared after loading, got %q", accounts[0].Token)
}
}
func TestStoreUpdateAccountTokenKeepsOldAndNewIdentifierResolvable(t *testing.T) {
func TestLoadStoreDropsLegacyTokenOnlyAccounts(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"accounts":[{"token":"old-token"}]
"accounts":[
{"token":"legacy-token-only"},
{"email":"u@example.com","password":"p","token":"runtime-token"}
]
}`)
store := LoadStore()
accounts := store.Accounts()
if len(accounts) != 1 {
t.Fatalf("expected token-only account to be dropped, got %d accounts", len(accounts))
}
if accounts[0].Identifier() != "u@example.com" {
t.Fatalf("unexpected remaining account: %#v", accounts[0])
}
if accounts[0].Token != "" {
t.Fatalf("expected persisted token to be cleared, got %q", accounts[0].Token)
}
}
func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) {
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
if err != nil {
t.Fatalf("create temp config: %v", err)
}
defer tmp.Close()
if _, err := tmp.WriteString(`{
"accounts":[{"email":"u@example.com","password":"p","token":"persisted-token"}]
}`); err != nil {
t.Fatalf("write temp config: %v", err)
}
t.Setenv("DS2API_CONFIG_JSON", "")
t.Setenv("CONFIG_JSON", "")
t.Setenv("DS2API_CONFIG_PATH", tmp.Name())
store := LoadStore()
accounts := store.Accounts()
if len(accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(accounts))
}
if accounts[0].Token != "persisted-token" {
t.Fatalf("expected file-backed token preserved for runtime use, got %q", accounts[0].Token)
}
}
func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"accounts":[{"email":"user@example.com","password":"p"}]
}`)
store := LoadStore()
@@ -52,23 +89,12 @@ func TestStoreUpdateAccountTokenKeepsOldAndNewIdentifierResolvable(t *testing.T)
t.Fatalf("expected 1 account, got %d", len(before))
}
oldID := before[0].Identifier()
if oldID == "" {
t.Fatal("expected old identifier")
}
if err := store.UpdateAccountToken(oldID, "new-token"); err != nil {
t.Fatalf("update token failed: %v", err)
}
after := store.Accounts()
newID := after[0].Identifier()
if newID == "" || newID == oldID {
t.Fatalf("expected changed identifier, old=%q new=%q", oldID, newID)
}
if got, ok := store.FindAccount(newID); !ok || got.Token != "new-token" {
t.Fatalf("expected find by new identifier")
}
if got, ok := store.FindAccount(oldID); !ok || got.Token != "new-token" {
t.Fatalf("expected find by old identifier alias")
t.Fatalf("expected find by stable account identifier")
}
}

82
internal/config/mobile.go Normal file
View File

@@ -0,0 +1,82 @@
package config
import "strings"
// NormalizeMobileForStorage normalizes user input to a stable storage format.
// It keeps existing country codes and auto-prefixes mainland China numbers with +86.
func NormalizeMobileForStorage(raw string) string {
digits, hasPlus := extractMobileDigits(raw)
if digits == "" {
return ""
}
if hasPlus {
return "+" + digits
}
if isChinaMobileWithCountryCode(digits) {
return "+86" + digits[2:]
}
if isChinaMainlandMobileDigits(digits) {
return "+86" + digits
}
// For non-China numbers without a leading +, preserve semantics by adding it.
return "+" + digits
}
// CanonicalMobileKey returns the comparison key used by dedupe/matching logic.
func CanonicalMobileKey(raw string) string {
return NormalizeMobileForStorage(raw)
}
func extractMobileDigits(raw string) (digits string, hasPlus bool) {
s := strings.TrimSpace(raw)
if s == "" {
return "", false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9':
goto collect
case isMobileSeparator(r):
continue
case r == '+':
hasPlus = true
goto collect
default:
goto collect
}
}
collect:
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String(), hasPlus
}
func isChinaMainlandMobileDigits(digits string) bool {
if len(digits) != 11 || digits[0] != '1' {
return false
}
return digits[1] >= '3' && digits[1] <= '9'
}
func isChinaMobileWithCountryCode(digits string) bool {
if len(digits) != 13 || !strings.HasPrefix(digits, "86") {
return false
}
return isChinaMainlandMobileDigits(digits[2:])
}
func isMobileSeparator(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r', '-', '(', ')', '.', '/':
return true
default:
return false
}
}

View File

@@ -0,0 +1,36 @@
package config
import "testing"
func TestNormalizeMobileForStorageChinaMainlandAddsPlus86(t *testing.T) {
if got := NormalizeMobileForStorage("13800138000"); got != "+8613800138000" {
t.Fatalf("got %q", got)
}
}
func TestNormalizeMobileForStorageChinaWithCountryCode(t *testing.T) {
if got := NormalizeMobileForStorage("8613800138000"); got != "+8613800138000" {
t.Fatalf("got %q", got)
}
}
func TestNormalizeMobileForStorageKeepsExistingCountryCode(t *testing.T) {
if got := NormalizeMobileForStorage(" +1 (415) 555-2671 "); got != "+14155552671" {
t.Fatalf("got %q", got)
}
}
func TestCanonicalMobileKeyMatchesChinaAliases(t *testing.T) {
a := CanonicalMobileKey("+8613800138000")
b := CanonicalMobileKey("13800138000")
c := CanonicalMobileKey("86 13800138000")
if a == "" || a != b || b != c {
t.Fatalf("alias mismatch: a=%q b=%q c=%q", a, b, c)
}
}
func TestCanonicalMobileKeyEmptyForInvalidInput(t *testing.T) {
if got := CanonicalMobileKey("() --"); got != "" {
t.Fatalf("got %q", got)
}
}

View File

@@ -39,6 +39,8 @@ func loadConfig() (Config, bool, error) {
}
if rawCfg != "" {
cfg, err := parseConfigString(rawCfg)
cfg.ClearAccountTokens()
cfg.DropInvalidAccounts()
return cfg, true, err
}
@@ -55,6 +57,7 @@ func loadConfig() (Config, bool, error) {
if err := json.Unmarshal(content, &cfg); err != nil {
return Config{}, false, err
}
cfg.DropInvalidAccounts()
if IsVercel() {
// Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors.
return cfg, true, nil
@@ -161,7 +164,9 @@ func (s *Store) Save() error {
Logger.Info("[save_config] source from env, skip write")
return nil
}
b, err := json.MarshalIndent(s.cfg, "", " ")
persistCfg := s.cfg.Clone()
persistCfg.ClearAccountTokens()
b, err := json.MarshalIndent(persistCfg, "", " ")
if err != nil {
return err
}
@@ -173,7 +178,9 @@ func (s *Store) saveLocked() error {
Logger.Info("[save_config] source from env, skip write")
return nil
}
b, err := json.MarshalIndent(s.cfg, "", " ")
persistCfg := s.cfg.Clone()
persistCfg.ClearAccountTokens()
b, err := json.MarshalIndent(persistCfg, "", " ")
if err != nil {
return err
}
@@ -197,7 +204,9 @@ func (s *Store) SetVercelSync(hash string, ts int64) error {
func (s *Store) ExportJSONAndBase64() (string, string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
b, err := json.Marshal(s.cfg)
exportCfg := s.cfg.Clone()
exportCfg.ClearAccountTokens()
b, err := json.Marshal(exportCfg)
if err != nil {
return "", "", err
}

View File

@@ -165,3 +165,9 @@ func (s *Store) RuntimeGlobalMaxInflight(defaultSize int) int {
}
return defaultSize
}
func (s *Store) AutoDeleteSessions() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.AutoDelete.Sessions
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"strings"
"unicode"
"ds2api/internal/auth"
"ds2api/internal/config"
@@ -20,8 +21,9 @@ func (c *Client) Login(ctx context.Context, acc config.Account) (string, error)
if email := strings.TrimSpace(acc.Email); email != "" {
payload["email"] = email
} else if mobile := strings.TrimSpace(acc.Mobile); mobile != "" {
payload["mobile"] = mobile
payload["area_code"] = nil
loginMobile, areaCode := normalizeMobileForLogin(mobile)
payload["mobile"] = loginMobile
payload["area_code"] = areaCode
} else {
return "", errors.New("missing email/mobile")
}
@@ -60,8 +62,8 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
attempts++
continue
}
code := intFrom(resp["code"])
if status == http.StatusOK && code == 0 {
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 && bizCode == 0 {
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
sessionID, _ := bizData["id"].(string)
@@ -69,10 +71,9 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
return sessionID, nil
}
}
msg, _ := resp["msg"].(string)
config.Logger.Warn("[create_session] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
config.Logger.Warn("[create_session] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
if a.UseConfigToken {
if isTokenInvalid(status, code, msg) && !refreshed {
if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
@@ -94,6 +95,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
maxAttempts = c.maxRetries
}
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"})
@@ -102,8 +104,8 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
attempts++
continue
}
code := intFrom(resp["code"])
if status == http.StatusOK && code == 0 {
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 && bizCode == 0 {
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
challenge, _ := bizData["challenge"].(map[string]any)
@@ -114,15 +116,16 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
}
return BuildPowHeader(challenge, answer)
}
msg, _ := resp["msg"].(string)
config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
if a.UseConfigToken {
if isTokenInvalid(status, code, msg) {
if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++
continue
}
@@ -141,13 +144,96 @@ func (c *Client) authHeaders(token string) map[string]string {
return headers
}
func isTokenInvalid(status int, code int, msg string) bool {
msg = strings.ToLower(msg)
func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool {
msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return true
}
if code == 40001 || code == 40002 || code == 40003 {
if code == 40001 || code == 40002 || code == 40003 || bizCode == 40001 || bizCode == 40002 || bizCode == 40003 {
return true
}
return strings.Contains(msg, "token") || strings.Contains(msg, "unauthorized")
return strings.Contains(msg, "token") ||
strings.Contains(msg, "unauthorized") ||
strings.Contains(msg, "expired") ||
strings.Contains(msg, "not login") ||
strings.Contains(msg, "login required") ||
strings.Contains(msg, "invalid jwt")
}
func shouldAttemptRefresh(status int, code int, bizCode int, msg string, bizMsg string) bool {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) {
return true
}
// Some DeepSeek failures come back as HTTP 200/code=0 but with non-zero biz_code.
// Only attempt refresh when these biz failures still look auth-related.
return status == http.StatusOK &&
code == 0 &&
bizCode != 0 &&
isAuthIndicativeBizFailure(msg, bizMsg)
}
func isAuthIndicativeBizFailure(msg string, bizMsg string) bool {
combined := strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
authKeywords := []string{
"auth",
"authorization",
"credential",
"expired",
"invalid jwt",
"jwt",
"login",
"not login",
"session expired",
"token",
"unauthorized",
"登录",
"未登录",
"认证",
"凭证",
"会话过期",
"令牌",
}
for _, keyword := range authKeywords {
if strings.Contains(combined, keyword) {
return true
}
}
return false
}
func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) {
code = intFrom(resp["code"])
msg, _ = resp["msg"].(string)
data, _ := resp["data"].(map[string]any)
bizCode = intFrom(data["biz_code"])
bizMsg, _ = data["biz_msg"].(string)
if strings.TrimSpace(bizMsg) == "" {
if bizData, ok := data["biz_data"].(map[string]any); ok {
bizMsg, _ = bizData["msg"].(string)
}
}
return code, bizCode, msg, bizMsg
}
func normalizeMobileForLogin(raw string) (mobile string, areaCode any) {
s := strings.TrimSpace(raw)
if s == "" {
return "", nil
}
hasPlus := strings.HasPrefix(s, "+")
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if unicode.IsDigit(r) {
b.WriteRune(r)
}
}
digits := b.String()
if digits == "" {
return "", nil
}
if (hasPlus || strings.HasPrefix(digits, "86")) && strings.HasPrefix(digits, "86") && len(digits) == 13 {
return digits[2:], nil
}
return digits, nil
}

View File

@@ -0,0 +1,33 @@
package deepseek
import "testing"
func TestNormalizeMobileForLogin_ChinaWithPlus86(t *testing.T) {
mobile, areaCode := normalizeMobileForLogin("+8613800138000")
if mobile != "13800138000" {
t.Fatalf("unexpected mobile: %q", mobile)
}
if areaCode != nil {
t.Fatalf("expected nil areaCode, got %#v", areaCode)
}
}
func TestNormalizeMobileForLogin_ChinaWith86Prefix(t *testing.T) {
mobile, areaCode := normalizeMobileForLogin("8613800138000")
if mobile != "13800138000" {
t.Fatalf("unexpected mobile: %q", mobile)
}
if areaCode != nil {
t.Fatalf("expected nil areaCode, got %#v", areaCode)
}
}
func TestNormalizeMobileForLogin_KeepPlainDigits(t *testing.T) {
mobile, areaCode := normalizeMobileForLogin("13800138000")
if mobile != "13800138000" {
t.Fatalf("unexpected mobile: %q", mobile)
}
if areaCode != nil {
t.Fatalf("expected nil areaCode, got %#v", areaCode)
}
}

View File

@@ -0,0 +1,27 @@
package deepseek
import "testing"
func TestShouldAttemptRefreshOnTokenInvalidSignal(t *testing.T) {
if !shouldAttemptRefresh(401, 0, 0, "unauthorized", "") {
t.Fatal("expected refresh when response indicates invalid token")
}
}
func TestShouldAttemptRefreshOnAuthIndicativeBizCodeFailure(t *testing.T) {
if !shouldAttemptRefresh(200, 0, 400123, "", "login expired, token invalid") {
t.Fatal("expected refresh on auth-indicative biz_code failure")
}
}
func TestShouldAttemptRefreshFalseOnNonAuthBizCodeFailure(t *testing.T) {
if shouldAttemptRefresh(200, 0, 400123, "", "session create failed: quota reached") {
t.Fatal("did not expect refresh on non-auth biz_code failure")
}
}
func TestShouldAttemptRefreshFalseOnGenericServerError(t *testing.T) {
if shouldAttemptRefresh(500, 500, 0, "internal error", "") {
t.Fatal("did not expect refresh on generic server error")
}
}

View File

@@ -62,3 +62,51 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
}
return out, resp.StatusCode, nil
}
func (c *Client) getJSON(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, error) {
body, status, err := c.getJSONWithStatus(ctx, doer, url, headers)
if err != nil {
return nil, err
}
if status == 0 {
return nil, errors.New("request failed")
}
return body, nil
}
func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := doer.Do(req)
if err != nil {
config.Logger.Warn("[deepseek] fingerprint GET request failed, fallback to std transport", "url", url, "error", err)
req2, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if reqErr != nil {
return nil, 0, err
}
for k, v := range headers {
req2.Header.Set(k, v)
}
resp, err = c.fallback.Do(req2)
if err != nil {
return nil, 0, err
}
}
defer resp.Body.Close()
payloadBytes, err := readResponseBody(resp)
if err != nil {
return nil, resp.StatusCode, err
}
out := map[string]any{}
if len(payloadBytes) > 0 {
if err := json.Unmarshal(payloadBytes, &out); err != nil {
config.Logger.Warn("[deepseek] json parse failed", "url", url, "status", resp.StatusCode, "content_encoding", resp.Header.Get("Content-Encoding"), "preview", preview(payloadBytes))
}
}
return out, resp.StatusCode, nil
}

View File

@@ -0,0 +1,256 @@
package deepseek
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"ds2api/internal/auth"
"ds2api/internal/config"
)
// SessionInfo 会话信息
type SessionInfo struct {
ID string `json:"id"`
Title string `json:"title"`
TitleType string `json:"title_type"`
Pinned bool `json:"pinned"`
UpdatedAt float64 `json:"updated_at"`
}
// SessionStats 会话统计结果
type SessionStats struct {
AccountID string // 账号标识 (email 或 mobile)
FirstPageCount int // 第一页会话数量(当 HasMore 为 true 时,真实总数可能更大)
PinnedCount int // 置顶会话数量
HasMore bool // 是否还有更多页
Success bool // 请求是否成功
ErrorMessage string // 错误信息
}
// GetSessionCount 获取单个账号的会话数量
func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (*SessionStats, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
stats := &SessionStats{
AccountID: a.AccountID,
}
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
// 构建请求 URL
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
config.Logger.Warn("[get_session_count] request error", "error", err, "account", a.AccountID)
attempts++
continue
}
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 && bizCode == 0 {
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
stats.FirstPageCount = len(chatSessions)
stats.HasMore = hasMore
stats.Success = true
// 统计置顶会话数量
for _, session := range chatSessions {
if s, ok := session.(map[string]any); ok {
if pinned, ok := s["pinned"].(bool); ok && pinned {
stats.PinnedCount++
}
}
}
return stats, nil
}
stats.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
config.Logger.Warn("[get_session_count] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "account", a.AccountID)
if a.UseConfigToken {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++
continue
}
}
attempts++
}
stats.Success = false
stats.ErrorMessage = "get session count failed after retries"
return stats, errors.New(stats.ErrorMessage)
}
// GetSessionCountForToken 直接使用 token 获取会话数量(直通模式)
func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*SessionStats, error) {
headers := c.authHeaders(token)
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
return nil, err
}
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status != http.StatusOK || code != 0 || bizCode != 0 {
if strings.TrimSpace(bizMsg) != "" {
msg = bizMsg
}
return nil, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
stats := &SessionStats{
FirstPageCount: len(chatSessions),
HasMore: hasMore,
Success: true,
}
// 统计置顶会话数量
for _, session := range chatSessions {
if s, ok := session.(map[string]any); ok {
if pinned, ok := s["pinned"].(bool); ok && pinned {
stats.PinnedCount++
}
}
}
return stats, nil
}
// GetSessionCountAll 获取所有账号的会话数量统计
func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
accounts := c.Store.Accounts()
results := make([]*SessionStats, 0, len(accounts))
for _, acc := range accounts {
token := acc.Token
accountID := acc.Email
if accountID == "" {
accountID = acc.Mobile
}
// 如果没有 token尝试登录获取
if token == "" {
var err error
token, err = c.Login(ctx, acc)
if err != nil {
results = append(results, &SessionStats{
AccountID: accountID,
Success: false,
ErrorMessage: fmt.Sprintf("login failed: %v", err),
})
continue
}
}
stats, err := c.GetSessionCountForToken(ctx, token)
if err != nil {
results = append(results, &SessionStats{
AccountID: accountID,
Success: false,
ErrorMessage: err.Error(),
})
continue
}
stats.AccountID = accountID
results = append(results, stats)
}
return results
}
// FetchSessionPage 获取会话列表(支持分页)
func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, cursor string) ([]SessionInfo, bool, error) {
headers := c.authHeaders(a.DeepSeekToken)
// 构建请求 URL
params := url.Values{}
params.Set("lte_cursor.pinned", "false")
if cursor != "" {
params.Set("lte_cursor", cursor)
}
reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
return nil, false, err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
return nil, false, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
sessions := make([]SessionInfo, 0, len(chatSessions))
for _, s := range chatSessions {
if m, ok := s.(map[string]any); ok {
session := SessionInfo{
ID: stringFromMap(m, "id"),
Title: stringFromMap(m, "title"),
TitleType: stringFromMap(m, "title_type"),
Pinned: boolFromMap(m, "pinned"),
UpdatedAt: floatFromMap(m, "updated_at"),
}
sessions = append(sessions, session)
}
}
return sessions, hasMore, nil
}
// 辅助函数
func stringFromMap(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func boolFromMap(m map[string]any, key string) bool {
if v, ok := m[key].(bool); ok {
return v
}
return false
}
func floatFromMap(m map[string]any, key string) float64 {
if v, ok := m[key].(float64); ok {
return v
}
return 0
}

View File

@@ -0,0 +1,155 @@
package deepseek
import (
"context"
"errors"
"fmt"
"net/http"
"ds2api/internal/auth"
"ds2api/internal/config"
)
// DeleteSessionResult 删除会话结果
type DeleteSessionResult struct {
SessionID string // 会话 ID
Success bool // 是否成功
ErrorMessage string // 错误信息
}
// DeleteSession 删除单个会话
func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, sessionID string, maxAttempts int) (*DeleteSessionResult, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
result := &DeleteSessionResult{
SessionID: sessionID,
}
if sessionID == "" {
result.ErrorMessage = "session_id is required"
return result, errors.New(result.ErrorMessage)
}
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
payload := map[string]any{
"chat_session_id": sessionID,
}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
attempts++
continue
}
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 && bizCode == 0 {
result.Success = true
return result, nil
}
result.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
config.Logger.Warn("[delete_session] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "session_id", sessionID)
if a.UseConfigToken {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++
continue
}
}
attempts++
}
result.Success = false
result.ErrorMessage = "delete session failed after retries"
return result, errors.New(result.ErrorMessage)
}
// DeleteSessionForToken 直接使用 token 删除会话(直通模式)
func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*DeleteSessionResult, error) {
result := &DeleteSessionResult{
SessionID: sessionID,
}
if sessionID == "" {
result.ErrorMessage = "session_id is required"
return result, errors.New(result.ErrorMessage)
}
headers := c.authHeaders(token)
payload := map[string]any{
"chat_session_id": sessionID,
}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
result.ErrorMessage = err.Error()
return result, err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
result.ErrorMessage = fmt.Sprintf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
return result, errors.New(result.ErrorMessage)
}
result.Success = true
return result, nil
}
// DeleteAllSessions 删除所有会话(谨慎使用)
func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) error {
headers := c.authHeaders(a.DeepSeekToken)
payload := map[string]any{}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_all_sessions] request error", "error", err)
return err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
config.Logger.Warn("[delete_all_sessions] failed", "status", status, "code", code, "msg", msg)
return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
return nil
}
// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) error {
headers := c.authHeaders(token)
payload := map[string]any{}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_all_sessions_for_token] request error", "error", err)
return err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
config.Logger.Warn("[delete_all_sessions_for_token] failed", "status", status, "code", code, "msg", msg)
return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
return nil
}

View File

@@ -11,6 +11,9 @@ const (
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all"
)
var defaultBaseHeaders = map[string]string{

View File

@@ -8,15 +8,15 @@ import (
)
func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
detected := util.ParseToolCalls(finalText, toolNames)
detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
finishReason := "stop"
messageObj := map[string]any{"role": "assistant", "content": finalText}
if strings.TrimSpace(finalThinking) != "" {
messageObj["reasoning_content"] = finalThinking
}
if len(detected) > 0 {
if len(detected.Calls) > 0 {
finishReason = "tool_calls"
messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected)
messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected.Calls)
messageObj["content"] = nil
}

View File

@@ -11,17 +11,14 @@ import (
)
func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
// Align responses tool-call semantics with chat/completions:
// mixed prose + tool_call payloads should still be interpreted as tool calls.
detected := util.ParseToolCalls(finalText, toolNames)
if len(detected) == 0 && strings.TrimSpace(finalThinking) != "" {
detected = util.ParseToolCalls(finalThinking, toolNames)
}
// Strict mode: only standalone, structured tool-call payloads are treated
// as executable tool calls.
detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
exposedOutputText := finalText
output := make([]any, 0, 2)
if len(detected) > 0 {
if len(detected.Calls) > 0 {
exposedOutputText = ""
output = append(output, toResponsesFunctionCallItems(detected)...)
output = append(output, toResponsesFunctionCallItems(detected.Calls)...)
} else {
content := make([]any, 0, 2)
if finalThinking != "" {

View File

@@ -71,6 +71,19 @@ func BuildResponsesTextDeltaPayload(responseID, itemID string, outputIndex, cont
}
}
func BuildResponsesTextDonePayload(responseID, itemID string, outputIndex, contentIndex int, text string) map[string]any {
return map[string]any{
"type": "response.output_text.done",
"id": responseID,
"response_id": responseID,
"item_id": itemID,
"output_index": outputIndex,
"content_index": contentIndex,
"text": text,
}
}
func BuildResponsesReasoningDeltaPayload(responseID, delta string) map[string]any {
return map[string]any{
"type": "response.reasoning.delta",

View File

@@ -45,7 +45,7 @@ func TestBuildResponseObjectToolCallsFollowChatShape(t *testing.T) {
}
}
func TestBuildResponseObjectTreatsMixedProseToolPayloadAsToolCall(t *testing.T) {
func TestBuildResponseObjectPromotesMixedProseToolPayloadToFunctionCall(t *testing.T) {
obj := BuildResponseObject(
"resp_test",
"gpt-4o",
@@ -57,20 +57,19 @@ func TestBuildResponseObjectTreatsMixedProseToolPayloadAsToolCall(t *testing.T)
outputText, _ := obj["output_text"].(string)
if outputText != "" {
t.Fatalf("expected output_text hidden once tool calls are detected, got %q", outputText)
t.Fatalf("expected output_text hidden for mixed prose tool payload, got %q", outputText)
}
output, _ := obj["output"].([]any)
if len(output) != 1 {
t.Fatalf("expected function_call output only, got %#v", obj["output"])
t.Fatalf("expected one function_call output item, got %#v", obj["output"])
}
first, _ := output[0].(map[string]any)
if first["type"] != "function_call" {
t.Fatalf("expected first output type function_call, got %#v", first["type"])
t.Fatalf("expected function_call output type, got %#v", first["type"])
}
}
func TestBuildResponseObjectFencedToolPayloadRemainsText(t *testing.T) {
func TestBuildResponseObjectPromotesFencedToolPayloadToFunctionCall(t *testing.T) {
obj := BuildResponseObject(
"resp_test",
"gpt-4o",
@@ -81,16 +80,16 @@ func TestBuildResponseObjectFencedToolPayloadRemainsText(t *testing.T) {
)
outputText, _ := obj["output_text"].(string)
if outputText == "" {
t.Fatalf("expected output_text preserved for fenced example")
if outputText != "" {
t.Fatalf("expected output_text hidden for fenced tool payload, got %q", outputText)
}
output, _ := obj["output"].([]any)
if len(output) != 1 {
t.Fatalf("expected one message output item, got %#v", obj["output"])
t.Fatalf("expected one function_call output item, got %#v", obj["output"])
}
first, _ := output[0].(map[string]any)
if first["type"] != "message" {
t.Fatalf("expected message output type, got %#v", first["type"])
if first["type"] != "function_call" {
t.Fatalf("expected function_call output type, got %#v", first["type"])
}
}
@@ -127,7 +126,7 @@ func TestBuildResponseObjectReasoningOnlyFallsBackToOutputText(t *testing.T) {
}
}
func TestBuildResponseObjectDetectsToolCallFromThinkingChannel(t *testing.T) {
func TestBuildResponseObjectIgnoresToolCallFromThinkingChannel(t *testing.T) {
obj := BuildResponseObject(
"resp_test",
"gpt-4o",
@@ -139,10 +138,10 @@ func TestBuildResponseObjectDetectsToolCallFromThinkingChannel(t *testing.T) {
output, _ := obj["output"].([]any)
if len(output) != 1 {
t.Fatalf("expected function_call output only, got %#v", obj["output"])
t.Fatalf("expected one message output item, got %#v", obj["output"])
}
first, _ := output[0].(map[string]any)
if first["type"] != "function_call" {
t.Fatalf("expected output function_call, got %#v", first["type"])
if first["type"] != "message" {
t.Fatalf("expected output message, got %#v", first["type"])
}
}

View File

@@ -10,8 +10,10 @@ const {
} = require('./sse_parse');
const {
resolveToolcallPolicy,
formatIncrementalToolCallDeltas,
normalizePreparedToolNames,
boolDefaultTrue,
filterIncrementalToolCallDeltasByAllowed,
} = require('./toolcall_policy');
const {
estimateTokens,
@@ -82,7 +84,9 @@ module.exports.__test = {
shouldSkipPath,
asString,
resolveToolcallPolicy,
formatIncrementalToolCallDeltas,
normalizePreparedToolNames,
boolDefaultTrue,
filterIncrementalToolCallDeltasByAllowed,
estimateTokens,
};

View File

@@ -60,6 +60,9 @@ function formatIncrementalToolCallDeltas(deltas, idStore) {
if (typeof d.arguments === 'string' && d.arguments !== '') {
fn.arguments = d.arguments;
}
if (Object.keys(fn).length === 0) {
continue;
}
if (Object.keys(fn).length > 0) {
item.function = fn;
}
@@ -68,6 +71,47 @@ function formatIncrementalToolCallDeltas(deltas, idStore) {
return out;
}
function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenNames) {
if (!Array.isArray(deltas) || deltas.length === 0) {
return [];
}
const seen = seenNames instanceof Map ? seenNames : new Map();
const allowed = new Set((allowedNames || []).filter((name) => asString(name) !== ''));
if (allowed.size === 0) {
for (const d of deltas) {
if (d && typeof d === 'object' && asString(d.name)) {
const index = Number.isInteger(d.index) ? d.index : 0;
seen.set(index, '__blocked__');
}
}
return [];
}
const out = [];
for (const d of deltas) {
if (!d || typeof d !== 'object') {
continue;
}
const index = Number.isInteger(d.index) ? d.index : 0;
const name = asString(d.name);
if (name) {
if (!allowed.has(name)) {
seen.set(index, '__blocked__');
continue;
}
seen.set(index, name);
out.push(d);
continue;
}
const existing = asString(seen.get(index));
if (!existing || existing === '__blocked__') {
continue;
}
out.push(d);
}
return out;
}
function ensureStreamToolCallID(idStore, index) {
const key = Number.isInteger(index) ? index : 0;
const existing = idStore.get(key);
@@ -104,4 +148,5 @@ module.exports = {
normalizePreparedToolNames,
boolDefaultTrue,
formatIncrementalToolCallDeltas,
filterIncrementalToolCallDeltasByAllowed,
};

View File

@@ -1,34 +1,22 @@
'use strict';
const {
extractToolNames,
createToolSieveState,
processToolSieveChunk,
flushToolSieve,
parseToolCalls,
parseStandaloneToolCalls,
formatOpenAIStreamToolCalls,
} = require('../helpers/stream-tool-sieve');
const {
BASE_HEADERS,
} = require('../shared/deepseek-constants');
const {
writeOpenAIError,
} = require('./error_shape');
const {
parseChunkForContent,
isCitation,
} = require('./sse_parse');
const {
buildUsage,
} = require('./token_usage');
const { BASE_HEADERS } = require('../shared/deepseek-constants');
const { writeOpenAIError } = require('./error_shape');
const { parseChunkForContent, isCitation } = require('./sse_parse');
const { buildUsage } = require('./token_usage');
const {
resolveToolcallPolicy,
formatIncrementalToolCallDeltas,
filterIncrementalToolCallDeltasByAllowed,
} = require('./toolcall_policy');
const {
createChatCompletionEmitter,
} = require('./stream_emitter');
const { createChatCompletionEmitter } = require('./stream_emitter');
const {
asString,
isAbortError,
@@ -58,6 +46,7 @@ async function handleVercelStream(req, res, rawBody, payload) {
const searchEnabled = toBool(prep.body.search_enabled);
const toolPolicy = resolveToolcallPolicy(prep.body, payload.tools);
const toolNames = toolPolicy.toolNames;
const emitEarlyToolDeltas = toolPolicy.emitEarlyToolDeltas;
if (!model || !leaseID || !deepseekToken || !powHeader || !completionPayload) {
writeOpenAIError(res, 500, 'invalid vercel prepare response');
@@ -130,10 +119,10 @@ async function handleVercelStream(req, res, rawBody, payload) {
let thinkingText = '';
let outputText = '';
const toolSieveEnabled = toolPolicy.toolSieveEnabled;
const emitEarlyToolDeltas = toolPolicy.emitEarlyToolDeltas;
const toolSieveState = createToolSieveState();
let toolCallsEmitted = false;
const streamToolCallIDs = new Map();
const streamToolNames = new Map();
const decoder = new TextDecoder();
reader = completionRes.body.getReader();
let buffered = '';
@@ -155,13 +144,18 @@ async function handleVercelStream(req, res, rawBody, payload) {
await releaseLease();
return;
}
const detected = parseToolCalls(outputText, toolNames);
const detected = parseStandaloneToolCalls(outputText, toolNames);
if (detected.length > 0 && !toolCallsEmitted) {
toolCallsEmitted = true;
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected) });
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs) });
} else if (toolSieveEnabled) {
const tailEvents = flushToolSieve(toolSieveState, toolNames);
for (const evt of tailEvents) {
if (evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0) {
toolCallsEmitted = true;
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
continue;
}
if (evt.text) {
sendDeltaFrame({ content: evt.text });
}
@@ -252,17 +246,21 @@ async function handleVercelStream(req, res, rawBody, payload) {
}
const events = processToolSieveChunk(toolSieveState, p.text, toolNames);
for (const evt of events) {
if (evt.type === 'tool_call_deltas' && Array.isArray(evt.deltas) && evt.deltas.length > 0) {
if (evt.type === 'tool_call_deltas') {
if (!emitEarlyToolDeltas) {
continue;
}
toolCallsEmitted = true;
sendDeltaFrame({ tool_calls: formatIncrementalToolCallDeltas(evt.deltas, streamToolCallIDs) });
const filtered = filterIncrementalToolCallDeltasByAllowed(evt.deltas, toolNames, streamToolNames);
const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs);
if (formatted.length > 0) {
toolCallsEmitted = true;
sendDeltaFrame({ tool_calls: formatted });
}
continue;
}
if (evt.type === 'tool_calls') {
toolCallsEmitted = true;
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls) });
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
continue;
}
if (evt.text) {

View File

@@ -2,13 +2,13 @@
const crypto = require('crypto');
function formatOpenAIStreamToolCalls(calls) {
function formatOpenAIStreamToolCalls(calls, idStore) {
if (!Array.isArray(calls) || calls.length === 0) {
return [];
}
return calls.map((c, idx) => ({
index: idx,
id: `call_${newCallID()}`,
id: ensureStreamToolCallID(idStore, idx),
type: 'function',
function: {
name: c.name,
@@ -17,6 +17,20 @@ function formatOpenAIStreamToolCalls(calls) {
}));
}
function ensureStreamToolCallID(idStore, index) {
if (!(idStore instanceof Map)) {
return `call_${newCallID()}`;
}
const key = Number.isInteger(index) ? index : 0;
const existing = idStore.get(key);
if (existing) {
return existing;
}
const next = `call_${newCallID()}`;
idStore.set(key, next);
return next;
}
function newCallID() {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID().replace(/-/g, '');

View File

@@ -1,226 +0,0 @@
'use strict';
const {
looksLikeToolExampleContext,
insideCodeFence,
} = require('./state');
const {
findObjectFieldValueStart,
parseJSONStringLiteral,
skipSpaces,
} = require('./jsonscan');
function buildIncrementalToolDeltas(state) {
const captured = state.capture || '';
if (!captured) {
return [];
}
if (looksLikeToolExampleContext(state.recentTextTail)) {
return [];
}
const lower = captured.toLowerCase();
const keyIdx = lower.indexOf('tool_calls');
if (keyIdx < 0) {
return [];
}
const start = captured.slice(0, keyIdx).lastIndexOf('{');
if (start < 0) {
return [];
}
if (insideCodeFence((state.recentTextTail || '') + captured.slice(0, start))) {
return [];
}
const callStart = findFirstToolCallObjectStart(captured, keyIdx);
if (callStart < 0) {
return [];
}
const deltas = [];
if (!state.toolName) {
const name = extractToolCallName(captured, callStart);
if (!name) {
return [];
}
state.toolName = name;
}
if (state.toolArgsStart < 0) {
const args = findToolCallArgsStart(captured, callStart);
if (args) {
state.toolArgsString = Boolean(args.stringMode);
state.toolArgsStart = state.toolArgsString ? args.start + 1 : args.start;
state.toolArgsSent = state.toolArgsStart;
}
}
if (!state.toolNameSent) {
if (state.toolArgsStart < 0) {
return [];
}
state.toolNameSent = true;
deltas.push({ index: 0, name: state.toolName });
}
if (state.toolArgsStart < 0 || state.toolArgsDone) {
return deltas;
}
const progress = scanToolCallArgsProgress(captured, state.toolArgsStart, state.toolArgsString);
if (!progress) {
return deltas;
}
if (progress.end > state.toolArgsSent) {
deltas.push({
index: 0,
arguments: captured.slice(state.toolArgsSent, progress.end),
});
state.toolArgsSent = progress.end;
}
if (progress.complete) {
state.toolArgsDone = true;
}
return deltas;
}
function findFirstToolCallObjectStart(text, keyIdx) {
const arrStart = findToolCallsArrayStart(text, keyIdx);
if (arrStart < 0) {
return -1;
}
const i = skipSpaces(text, arrStart + 1);
if (i >= text.length || text[i] !== '{') {
return -1;
}
return i;
}
function findToolCallsArrayStart(text, keyIdx) {
let i = keyIdx + 'tool_calls'.length;
while (i < text.length && text[i] !== ':') {
i += 1;
}
if (i >= text.length) {
return -1;
}
i = skipSpaces(text, i + 1);
if (i >= text.length || text[i] !== '[') {
return -1;
}
return i;
}
function extractToolCallName(text, callStart) {
let valueStart = findObjectFieldValueStart(text, callStart, ['name']);
if (valueStart < 0 || text[valueStart] !== '"') {
const fnStart = findFunctionObjectStart(text, callStart);
if (fnStart < 0) {
return '';
}
valueStart = findObjectFieldValueStart(text, fnStart, ['name']);
if (valueStart < 0 || text[valueStart] !== '"') {
return '';
}
}
const parsed = parseJSONStringLiteral(text, valueStart);
if (!parsed) {
return '';
}
return parsed.value;
}
function findToolCallArgsStart(text, callStart) {
const keys = ['input', 'arguments', 'args', 'parameters', 'params'];
let valueStart = findObjectFieldValueStart(text, callStart, keys);
if (valueStart < 0) {
const fnStart = findFunctionObjectStart(text, callStart);
if (fnStart < 0) {
return null;
}
valueStart = findObjectFieldValueStart(text, fnStart, keys);
if (valueStart < 0) {
return null;
}
}
if (valueStart >= text.length) {
return null;
}
const ch = text[valueStart];
if (ch === '{' || ch === '[') {
return { start: valueStart, stringMode: false };
}
if (ch === '"') {
return { start: valueStart, stringMode: true };
}
return null;
}
function scanToolCallArgsProgress(text, start, stringMode) {
if (start < 0 || start > text.length) {
return null;
}
if (stringMode) {
let escaped = false;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === '"') {
return { end: i, complete: true };
}
}
return { end: text.length, complete: false };
}
if (start >= text.length || (text[start] !== '{' && text[start] !== '[')) {
return null;
}
let depth = 0;
let quote = '';
let escaped = false;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (quote) {
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === quote) {
quote = '';
}
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '{' || ch === '[') {
depth += 1;
continue;
}
if (ch === '}' || ch === ']') {
depth -= 1;
if (depth === 0) {
return { end: i + 1, complete: true };
}
}
}
return { end: text.length, complete: false };
}
function findFunctionObjectStart(text, callStart) {
const valueStart = findObjectFieldValueStart(text, callStart, ['function']);
if (valueStart < 0 || valueStart >= text.length || text[valueStart] !== '{') {
return -1;
}
return valueStart;
}
module.exports = {
buildIncrementalToolDeltas,
};

View File

@@ -10,7 +10,9 @@ const {
const {
extractToolNames,
parseToolCalls,
parseToolCallsDetailed,
parseStandaloneToolCalls,
parseStandaloneToolCallsDetailed,
} = require('./parse');
const {
formatOpenAIStreamToolCalls,
@@ -22,6 +24,8 @@ module.exports = {
processToolSieveChunk,
flushToolSieve,
parseToolCalls,
parseToolCallsDetailed,
parseStandaloneToolCalls,
parseStandaloneToolCallsDetailed,
formatOpenAIStreamToolCalls,
};

View File

@@ -1,273 +1,239 @@
'use strict';
const TOOL_CALL_PATTERN = /\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}/s;
const {
toStringSafe,
looksLikeToolExampleContext,
} = require('./state');
const {
extractJSONObjectFrom,
} = require('./jsonscan');
buildToolCallCandidates,
parseToolCallsPayload,
parseMarkupToolCalls,
parseTextKVToolCalls,
} = require('./parse_payload');
const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g;
function extractToolNames(tools) {
if (!Array.isArray(tools) || tools.length === 0) {
return [];
}
const out = [];
const seen = new Set();
for (const t of tools) {
if (!t || typeof t !== 'object') {
continue;
}
const fn = t.function && typeof t.function === 'object' ? t.function : t;
const name = toStringSafe(fn.name);
// Keep parity with Go injectToolPrompt: object tools without name still
// enter tool mode via fallback name "unknown".
out.push(name || 'unknown');
if (!name || seen.has(name)) {
continue;
}
seen.add(name);
out.push(name);
}
return out;
}
function parseToolCalls(text, toolNames) {
if (!toStringSafe(text)) {
return [];
return parseToolCallsDetailed(text, toolNames).calls;
}
function parseToolCallsDetailed(text, toolNames) {
const result = emptyParseResult();
const normalized = toStringSafe(text);
if (!normalized) {
return result;
}
const sanitized = stripFencedCodeBlocks(text);
if (!toStringSafe(sanitized)) {
return [];
}
const candidates = buildToolCallCandidates(sanitized);
result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized);
const candidates = buildToolCallCandidates(normalized);
let parsed = [];
for (const c of candidates) {
parsed = parseToolCallsPayload(c);
if (parsed.length === 0) {
parsed = parseMarkupToolCalls(c);
}
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(c);
}
if (parsed.length > 0) {
result.sawToolCallSyntax = true;
break;
}
}
if (parsed.length === 0) {
parsed = parseMarkupToolCalls(normalized);
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(normalized);
if (parsed.length === 0) {
return result;
}
}
result.sawToolCallSyntax = true;
}
const filtered = filterToolCallsDetailed(parsed, toolNames);
result.calls = filtered.calls;
result.rejectedToolNames = filtered.rejectedToolNames;
result.rejectedByPolicy = filtered.rejectedToolNames.length > 0 && filtered.calls.length === 0;
return result;
}
function parseStandaloneToolCalls(text, toolNames) {
return parseStandaloneToolCallsDetailed(text, toolNames).calls;
}
function parseStandaloneToolCallsDetailed(text, toolNames) {
const result = emptyParseResult();
const trimmed = toStringSafe(text);
if (!trimmed) {
return result;
}
result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed);
const candidates = buildToolCallCandidates(trimmed);
let parsed = [];
for (const c of candidates) {
parsed = parseToolCallsPayload(c);
if (parsed.length === 0) {
parsed = parseMarkupToolCalls(c);
}
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(c);
}
if (parsed.length > 0) {
break;
}
}
if (parsed.length === 0) {
return [];
}
return filterToolCalls(parsed, toolNames);
}
function stripFencedCodeBlocks(text) {
const t = typeof text === 'string' ? text : '';
if (!t) {
return '';
}
return t.replace(/```[\s\S]*?```/g, ' ');
}
function parseStandaloneToolCalls(text, toolNames) {
const trimmed = toStringSafe(text);
if (!trimmed) {
return [];
}
if ((trimmed.startsWith('```') && trimmed.endsWith('```')) || trimmed.includes('```')) {
return [];
}
if (looksLikeToolExampleContext(trimmed)) {
return [];
}
const candidates = [trimmed];
if (trimmed.startsWith('```') && trimmed.endsWith('```')) {
const m = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (m && m[1]) {
candidates.push(toStringSafe(m[1]));
}
}
for (const candidate of candidates) {
const c = toStringSafe(candidate);
if (!c) {
continue;
}
if (!c.startsWith('{') && !c.startsWith('[')) {
continue;
}
const parsed = parseToolCallsPayload(c);
if (parsed.length > 0) {
return filterToolCalls(parsed, toolNames);
}
}
return [];
}
function buildToolCallCandidates(text) {
const trimmed = toStringSafe(text);
const candidates = [trimmed];
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/gi) || [];
for (const block of fenced) {
const m = block.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (m && m[1]) {
candidates.push(toStringSafe(m[1]));
}
}
for (const candidate of extractToolCallObjects(trimmed)) {
candidates.push(toStringSafe(candidate));
}
const first = trimmed.indexOf('{');
const last = trimmed.lastIndexOf('}');
if (first >= 0 && last > first) {
candidates.push(toStringSafe(trimmed.slice(first, last + 1)));
}
const m = trimmed.match(TOOL_CALL_PATTERN);
if (m && m[1]) {
candidates.push(`{"tool_calls":[${m[1]}]}`);
}
return [...new Set(candidates.filter(Boolean))];
}
function extractToolCallObjects(text) {
const raw = toStringSafe(text);
if (!raw) {
return [];
}
const lower = raw.toLowerCase();
const out = [];
let offset = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
let idx = lower.indexOf('tool_calls', offset);
if (idx < 0) {
break;
}
let start = raw.slice(0, idx).lastIndexOf('{');
while (start >= 0) {
const obj = extractJSONObjectFrom(raw, start);
if (obj.ok) {
out.push(raw.slice(start, obj.end).trim());
offset = obj.end;
idx = -1;
break;
}
start = raw.slice(0, start).lastIndexOf('{');
}
if (idx >= 0) {
offset = idx + 'tool_calls'.length;
}
}
return out;
}
function parseToolCallsPayload(payload) {
let decoded;
try {
decoded = JSON.parse(payload);
} catch (_err) {
return [];
}
if (Array.isArray(decoded)) {
return parseToolCallList(decoded);
}
if (!decoded || typeof decoded !== 'object') {
return [];
}
if (decoded.tool_calls) {
return parseToolCallList(decoded.tool_calls);
}
const one = parseToolCallItem(decoded);
return one ? [one] : [];
}
function parseToolCallList(v) {
if (!Array.isArray(v)) {
return [];
}
const out = [];
for (const item of v) {
if (!item || typeof item !== 'object') {
continue;
}
const one = parseToolCallItem(item);
if (one) {
out.push(one);
}
}
return out;
}
function parseToolCallItem(m) {
let name = toStringSafe(m.name);
let inputRaw = m.input;
let hasInput = Object.prototype.hasOwnProperty.call(m, 'input');
const fn = m.function && typeof m.function === 'object' ? m.function : null;
if (fn) {
if (!name) {
name = toStringSafe(fn.name);
}
if (!hasInput && Object.prototype.hasOwnProperty.call(fn, 'arguments')) {
inputRaw = fn.arguments;
hasInput = true;
}
}
if (!hasInput) {
for (const k of ['arguments', 'args', 'parameters', 'params']) {
if (Object.prototype.hasOwnProperty.call(m, k)) {
inputRaw = m[k];
hasInput = true;
break;
parsed = parseMarkupToolCalls(trimmed);
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(trimmed);
if (parsed.length === 0) {
return result;
}
}
}
if (!name) {
return null;
}
result.sawToolCallSyntax = true;
const filtered = filterToolCallsDetailed(parsed, toolNames);
result.calls = filtered.calls;
result.rejectedToolNames = filtered.rejectedToolNames;
result.rejectedByPolicy = filtered.rejectedToolNames.length > 0 && filtered.calls.length === 0;
return result;
}
function emptyParseResult() {
return {
name,
input: parseToolCallInput(inputRaw),
calls: [],
sawToolCallSyntax: false,
rejectedByPolicy: false,
rejectedToolNames: [],
};
}
function parseToolCallInput(v) {
if (v == null) {
return {};
}
if (typeof v === 'string') {
const raw = toStringSafe(v);
if (!raw) {
return {};
function filterToolCallsDetailed(parsed, toolNames) {
const sourceNames = Array.isArray(toolNames) ? toolNames : [];
const allowed = new Set();
const allowedCanonical = new Map();
for (const item of sourceNames) {
const name = toStringSafe(item);
if (!name) {
continue;
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
return { _raw: raw };
} catch (_err) {
return { _raw: raw };
allowed.add(name);
const lower = name.toLowerCase();
if (!allowedCanonical.has(lower)) {
allowedCanonical.set(lower, name);
}
}
if (typeof v === 'object' && !Array.isArray(v)) {
return v;
}
try {
const parsed = JSON.parse(JSON.stringify(v));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
} catch (_err) {
return {};
}
return {};
}
function filterToolCalls(parsed, toolNames) {
const allowed = new Set((toolNames || []).filter(Boolean));
const out = [];
if (allowed.size === 0) {
const rejected = [];
const seen = new Set();
for (const tc of parsed) {
if (!tc || !tc.name) {
continue;
}
if (seen.has(tc.name)) {
continue;
}
seen.add(tc.name);
rejected.push(tc.name);
}
return { calls: [], rejectedToolNames: rejected };
}
const calls = [];
const rejected = [];
const seenRejected = new Set();
for (const tc of parsed) {
if (!tc || !tc.name) {
continue;
}
if (allowed.size > 0 && !allowed.has(tc.name)) {
let matchedName = '';
if (allowed.has(tc.name)) {
matchedName = tc.name;
} else {
matchedName = resolveAllowedToolName(tc.name, allowed, allowedCanonical);
}
if (!matchedName) {
if (!seenRejected.has(tc.name)) {
seenRejected.add(tc.name);
rejected.push(tc.name);
}
continue;
}
out.push({ name: tc.name, input: tc.input || {} });
calls.push({
name: matchedName,
input: tc.input && typeof tc.input === 'object' && !Array.isArray(tc.input) ? tc.input : {},
});
}
return out;
return { calls, rejectedToolNames: rejected };
}
function resolveAllowedToolName(name, allowed, allowedCanonical) {
const normalizedName = toStringSafe(name).trim();
if (!normalizedName) {
return '';
}
if (allowed.has(normalizedName)) {
return normalizedName;
}
const lower = normalizedName.toLowerCase();
if (allowedCanonical.has(lower)) {
return allowedCanonical.get(lower);
}
const idx = lower.lastIndexOf('.');
if (idx >= 0 && idx < lower.length - 1) {
const tail = lower.slice(idx + 1);
if (allowedCanonical.has(tail)) {
return allowedCanonical.get(tail);
}
}
const loose = lower.replace(TOOL_NAME_LOOSE_PATTERN, '');
if (!loose) {
return '';
}
for (const [candidateLower, canonical] of allowedCanonical.entries()) {
if (candidateLower.replace(TOOL_NAME_LOOSE_PATTERN, '') === loose) {
return canonical;
}
}
return '';
}
function looksLikeToolCallSyntax(text) {
const lower = toStringSafe(text).toLowerCase();
return lower.includes('tool_calls')
|| lower.includes('<tool_call')
|| lower.includes('<function_call')
|| lower.includes('<invoke')
|| lower.includes('function.name:');
}
module.exports = {
extractToolNames,
parseToolCalls,
parseToolCallsDetailed,
parseStandaloneToolCalls,
parseStandaloneToolCallsDetailed,
};

View File

@@ -0,0 +1,363 @@
'use strict';
const TOOL_CALL_PATTERN = /\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}/s;
const TOOL_CALL_MARKUP_BLOCK_PATTERN = /<(?:[a-z0-9_:-]+:)?(tool_call|function_call|invoke)\b([^>]*)>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi;
const TOOL_CALL_MARKUP_SELFCLOSE_PATTERN = /<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)\/>/gi;
const TOOL_CALL_MARKUP_KV_PATTERN = /<(?:[a-z0-9_:-]+:)?([a-z0-9_.-]+)\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi;
const TOOL_CALL_MARKUP_ATTR_PATTERN = /(name|function|tool)\s*=\s*"([^"]+)"/i;
const TOOL_CALL_MARKUP_NAME_PATTERNS = [
/<(?:[a-z0-9_:-]+:)?name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?name>/i,
/<(?:[a-z0-9_:-]+:)?function\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?function>/i,
];
const TOOL_CALL_MARKUP_ARGS_PATTERNS = [
/<(?:[a-z0-9_:-]+:)?input\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?input>/i,
/<(?:[a-z0-9_:-]+:)?arguments\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?arguments>/i,
/<(?:[a-z0-9_:-]+:)?argument\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?argument>/i,
/<(?:[a-z0-9_:-]+:)?parameters\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?parameters>/i,
/<(?:[a-z0-9_:-]+:)?parameter\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?parameter>/i,
/<(?:[a-z0-9_:-]+:)?args\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?args>/i,
/<(?:[a-z0-9_:-]+:)?params\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?params>/i,
];
const TEXT_KV_NAME_PATTERN = /function\.name:\s*([a-zA-Z0-9_.-]+)/gi;
const {
toStringSafe,
} = require('./state');
const {
extractJSONObjectFrom,
} = require('./jsonscan');
function stripFencedCodeBlocks(text) {
const t = typeof text === 'string' ? text : '';
if (!t) {
return '';
}
return t.replace(/```[\s\S]*?```/g, ' ');
}
function buildToolCallCandidates(text) {
const trimmed = toStringSafe(text);
const candidates = [trimmed];
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/gi) || [];
for (const block of fenced) {
const m = block.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (m && m[1]) {
candidates.push(toStringSafe(m[1]));
}
}
for (const candidate of extractToolCallObjects(trimmed)) {
candidates.push(toStringSafe(candidate));
}
const first = trimmed.indexOf('{');
const last = trimmed.lastIndexOf('}');
if (first >= 0 && last > first) {
candidates.push(toStringSafe(trimmed.slice(first, last + 1)));
}
const m = trimmed.match(TOOL_CALL_PATTERN);
if (m && m[1]) {
candidates.push(`{"tool_calls":[${m[1]}]}`);
}
return [...new Set(candidates.filter(Boolean))];
}
function extractToolCallObjects(text) {
const raw = toStringSafe(text);
if (!raw) {
return [];
}
const lower = raw.toLowerCase();
const out = [];
let offset = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
let idx = lower.indexOf('tool_calls', offset);
if (idx < 0) {
break;
}
let start = raw.slice(0, idx).lastIndexOf('{');
while (start >= 0) {
const obj = extractJSONObjectFrom(raw, start);
if (obj.ok) {
out.push(raw.slice(start, obj.end).trim());
offset = obj.end;
idx = -1;
break;
}
start = raw.slice(0, start).lastIndexOf('{');
}
if (idx >= 0) {
offset = idx + 'tool_calls'.length;
}
}
return out;
}
function parseToolCallsPayload(payload) {
let decoded;
try {
decoded = JSON.parse(payload);
} catch (_err) {
return [];
}
if (Array.isArray(decoded)) {
return parseToolCallList(decoded);
}
if (!decoded || typeof decoded !== 'object') {
return [];
}
if (decoded.tool_calls) {
return parseToolCallList(decoded.tool_calls);
}
const one = parseToolCallItem(decoded);
return one ? [one] : [];
}
function parseMarkupToolCalls(text) {
const raw = toStringSafe(text).trim();
if (!raw) {
return [];
}
const out = [];
for (const m of raw.matchAll(TOOL_CALL_MARKUP_BLOCK_PATTERN)) {
const parsed = parseMarkupSingleToolCall(toStringSafe(m[2]).trim(), toStringSafe(m[3]).trim());
if (parsed) {
out.push(parsed);
}
}
for (const m of raw.matchAll(TOOL_CALL_MARKUP_SELFCLOSE_PATTERN)) {
const parsed = parseMarkupSingleToolCall(toStringSafe(m[1]).trim(), '');
if (parsed) {
out.push(parsed);
}
}
return out;
}
function parseTextKVToolCalls(text) {
const raw = toStringSafe(text);
if (!raw) {
return [];
}
const out = [];
const matches = [...raw.matchAll(TEXT_KV_NAME_PATTERN)];
if (matches.length === 0) {
return out;
}
for (let i = 0; i < matches.length; i += 1) {
const match = matches[i];
const name = toStringSafe(match[1]).trim();
if (!name) {
continue;
}
const nameEnd = match.index + toStringSafe(match[0]).length;
const searchEnd = i + 1 < matches.length ? matches[i + 1].index : raw.length;
const searchArea = raw.slice(nameEnd, searchEnd);
const argIdx = searchArea.indexOf('function.arguments:');
if (argIdx < 0) {
continue;
}
const argStart = nameEnd + argIdx + 'function.arguments:'.length;
const bracePos = raw.slice(argStart, searchEnd).indexOf('{');
if (bracePos < 0) {
continue;
}
const objStart = argStart + bracePos;
const obj = extractJSONObjectFrom(raw, objStart);
if (!obj.ok) {
continue;
}
out.push({
name,
input: parseToolCallInput(raw.slice(objStart, obj.end)),
});
}
return out;
}
function parseMarkupSingleToolCall(attrs, inner) {
const embedded = parseToolCallsPayload(inner);
if (embedded.length > 0) {
return embedded[0];
}
let name = '';
const attrMatch = attrs.match(TOOL_CALL_MARKUP_ATTR_PATTERN);
if (attrMatch && attrMatch[2]) {
name = toStringSafe(attrMatch[2]).trim();
}
if (!name) {
name = stripTagText(findMarkupTagValue(inner, TOOL_CALL_MARKUP_NAME_PATTERNS));
}
if (!name) {
return null;
}
let input = {};
const argsRaw = findMarkupTagValue(inner, TOOL_CALL_MARKUP_ARGS_PATTERNS);
if (argsRaw) {
input = parseMarkupInput(argsRaw);
} else {
const kv = parseMarkupKVObject(inner);
if (Object.keys(kv).length > 0) {
input = kv;
}
}
return { name, input };
}
function parseMarkupInput(raw) {
const s = toStringSafe(raw).trim();
if (!s) {
return {};
}
const parsed = parseToolCallInput(s);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length > 0) {
return parsed;
}
const kv = parseMarkupKVObject(s);
if (Object.keys(kv).length > 0) {
return kv;
}
return { _raw: stripTagText(s) };
}
function parseMarkupKVObject(text) {
const raw = toStringSafe(text).trim();
if (!raw) {
return {};
}
const out = {};
for (const m of raw.matchAll(TOOL_CALL_MARKUP_KV_PATTERN)) {
const key = toStringSafe(m[1]).trim();
if (!key) {
continue;
}
const valueRaw = stripTagText(m[2]);
if (!valueRaw) {
continue;
}
try {
out[key] = JSON.parse(valueRaw);
} catch (_err) {
out[key] = valueRaw;
}
}
return out;
}
function stripTagText(text) {
return toStringSafe(text).replace(/<[^>]+>/g, ' ').trim();
}
function findMarkupTagValue(text, patterns) {
const source = toStringSafe(text);
for (const p of patterns) {
const m = source.match(p);
if (m && m[1]) {
return toStringSafe(m[1]);
}
}
return '';
}
function parseToolCallList(v) {
if (!Array.isArray(v)) {
return [];
}
const out = [];
for (const item of v) {
if (!item || typeof item !== 'object') {
continue;
}
const one = parseToolCallItem(item);
if (one) {
out.push(one);
}
}
return out;
}
function parseToolCallItem(m) {
let name = toStringSafe(m.name);
let inputRaw = m.input;
let hasInput = Object.prototype.hasOwnProperty.call(m, 'input');
const fn = m.function && typeof m.function === 'object' ? m.function : null;
if (fn) {
if (!name) {
name = toStringSafe(fn.name);
}
if (!hasInput && Object.prototype.hasOwnProperty.call(fn, 'arguments')) {
inputRaw = fn.arguments;
hasInput = true;
}
}
if (!hasInput) {
for (const k of ['arguments', 'args', 'parameters', 'params']) {
if (Object.prototype.hasOwnProperty.call(m, k)) {
inputRaw = m[k];
hasInput = true;
break;
}
}
}
if (!name) {
return null;
}
return {
name,
input: parseToolCallInput(inputRaw),
};
}
function parseToolCallInput(v) {
if (v == null) {
return {};
}
if (typeof v === 'string') {
const raw = toStringSafe(v);
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
return { _raw: raw };
} catch (_err) {
return { _raw: raw };
}
}
if (typeof v === 'object' && !Array.isArray(v)) {
return v;
}
try {
const parsed = JSON.parse(JSON.stringify(v));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
} catch (_err) {
return {};
}
return {};
}
module.exports = {
stripFencedCodeBlocks,
buildToolCallCandidates,
parseToolCallsPayload,
parseMarkupToolCalls,
parseTextKVToolCalls,
};

View File

@@ -1,16 +1,12 @@
'use strict';
const {
TOOL_SIEVE_CAPTURE_LIMIT,
resetIncrementalToolState,
noteText,
insideCodeFence,
} = require('./state');
const {
buildIncrementalToolDeltas,
} = require('./incremental');
const {
parseStandaloneToolCalls,
parseStandaloneToolCallsDetailed,
} = require('./parse');
const {
extractJSONObjectFrom,
@@ -24,64 +20,67 @@ function processToolSieveChunk(state, chunk, toolNames) {
state.pending += chunk;
}
const events = [];
// eslint-disable-next-line no-constant-condition
while (true) {
if (Array.isArray(state.pendingToolCalls) && state.pendingToolCalls.length > 0) {
events.push({ type: 'tool_calls', calls: state.pendingToolCalls });
state.pendingToolRaw = '';
state.pendingToolCalls = [];
continue;
}
if (state.capturing) {
if (state.pending) {
state.capture += state.pending;
state.pending = '';
}
const deltas = buildIncrementalToolDeltas(state);
if (deltas.length > 0) {
events.push({ type: 'tool_call_deltas', deltas });
}
const consumed = consumeToolCapture(state, toolNames);
if (!consumed.ready) {
if (state.capture.length > TOOL_SIEVE_CAPTURE_LIMIT) {
noteText(state, state.capture);
events.push({ type: 'text', text: state.capture });
state.capture = '';
state.capturing = false;
resetIncrementalToolState(state);
continue;
}
break;
}
const captured = state.capture;
state.capture = '';
state.capturing = false;
resetIncrementalToolState(state);
if (Array.isArray(consumed.calls) && consumed.calls.length > 0) {
state.pendingToolRaw = captured;
state.pendingToolCalls = consumed.calls;
if (consumed.suffix) {
state.pending = consumed.suffix + state.pending;
}
continue;
}
if (consumed.prefix) {
noteText(state, consumed.prefix);
events.push({ type: 'text', text: consumed.prefix });
}
if (Array.isArray(consumed.calls) && consumed.calls.length > 0) {
events.push({ type: 'tool_calls', calls: consumed.calls });
}
if (consumed.suffix) {
state.pending += consumed.suffix;
}
continue;
}
if (!state.pending) {
const pending = state.pending || '';
if (!pending) {
break;
}
const start = findToolSegmentStart(state.pending);
const start = findToolSegmentStart(pending);
if (start >= 0) {
const prefix = state.pending.slice(0, start);
const prefix = pending.slice(0, start);
if (prefix) {
noteText(state, prefix);
events.push({ type: 'text', text: prefix });
}
state.capture = state.pending.slice(start);
state.pending = '';
state.capture += pending.slice(start);
state.capturing = true;
resetIncrementalToolState(state);
continue;
}
const [safe, hold] = splitSafeContentForToolDetection(state.pending);
const [safe, hold] = splitSafeContentForToolDetection(pending);
if (!safe) {
break;
}
@@ -97,6 +96,13 @@ function flushToolSieve(state, toolNames) {
return [];
}
const events = processToolSieveChunk(state, '', toolNames);
if (Array.isArray(state.pendingToolCalls) && state.pendingToolCalls.length > 0) {
events.push({ type: 'tool_calls', calls: state.pendingToolCalls });
state.pendingToolRaw = '';
state.pendingToolCalls = [];
}
if (state.capturing) {
const consumed = consumeToolCapture(state, toolNames);
if (consumed.ready) {
@@ -119,11 +125,13 @@ function flushToolSieve(state, toolNames) {
state.capturing = false;
resetIncrementalToolState(state);
}
if (state.pending) {
noteText(state, state.pending);
events.push({ type: 'text', text: state.pending });
state.pending = '';
}
return events;
}
@@ -160,43 +168,67 @@ function findToolSegmentStart(s) {
return -1;
}
const lower = s.toLowerCase();
const keywords = ['tool_calls', 'function.name:', '[tool_call_history]'];
let offset = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const keyRel = lower.indexOf('tool_calls', offset);
if (keyRel < 0) {
let bestKeyIdx = -1;
let matchedKeyword = '';
for (const kw of keywords) {
const idx = lower.indexOf(kw, offset);
if (idx >= 0) {
if (bestKeyIdx < 0 || idx < bestKeyIdx) {
bestKeyIdx = idx;
matchedKeyword = kw;
}
}
}
if (bestKeyIdx < 0) {
return -1;
}
const keyIdx = keyRel;
const keyIdx = bestKeyIdx;
const start = s.slice(0, keyIdx).lastIndexOf('{');
const candidateStart = start >= 0 ? start : keyIdx;
if (!insideCodeFence(s.slice(0, candidateStart))) {
return candidateStart;
}
offset = keyIdx + 'tool_calls'.length;
offset = keyIdx + matchedKeyword.length;
}
}
function consumeToolCapture(state, toolNames) {
const captured = state.capture;
const captured = state.capture || '';
if (!captured) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const lower = captured.toLowerCase();
const keyIdx = lower.indexOf('tool_calls');
let keyIdx = -1;
const keywords = ['tool_calls', 'function.name:', '[tool_call_history]'];
for (const kw of keywords) {
const idx = lower.indexOf(kw);
if (idx >= 0 && (keyIdx < 0 || idx < keyIdx)) {
keyIdx = idx;
}
}
if (keyIdx < 0) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const start = captured.slice(0, keyIdx).lastIndexOf('{');
if (start < 0) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const obj = extractJSONObjectFrom(captured, start);
const actualStart = start >= 0 ? start : keyIdx;
const obj = extractJSONObjectFrom(captured, actualStart);
if (!obj.ok) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const prefixPart = captured.slice(0, start);
const prefixPart = captured.slice(0, actualStart);
const suffixPart = captured.slice(obj.end);
if (insideCodeFence((state.recentTextTail || '') + prefixPart)) {
return {
ready: true,
@@ -205,18 +237,10 @@ function consumeToolCapture(state, toolNames) {
suffix: '',
};
}
const rawParsed = parseStandaloneToolCalls(captured.slice(start, obj.end), []);
const parsed = parseStandaloneToolCalls(captured.slice(start, obj.end), toolNames);
if (parsed.length === 0) {
if (rawParsed.length > 0 && Array.isArray(toolNames) && toolNames.length > 0) {
return {
ready: true,
prefix: prefixPart,
calls: [],
suffix: suffixPart,
};
}
if (state.toolNameSent) {
const parsed = parseStandaloneToolCallsDetailed(captured.slice(actualStart, obj.end), toolNames);
if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) {
if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) {
return {
ready: true,
prefix: prefixPart,
@@ -231,27 +255,41 @@ function consumeToolCapture(state, toolNames) {
suffix: '',
};
}
if (state.toolNameSent) {
if (parsed.length > 1) {
return {
ready: true,
prefix: prefixPart,
calls: parsed.slice(1),
suffix: suffixPart,
};
}
return {
ready: true,
prefix: prefixPart,
calls: [],
suffix: suffixPart,
};
}
const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart);
return {
ready: true,
prefix: prefixPart,
calls: parsed,
suffix: suffixPart,
prefix: trimmedFence.prefix,
calls: parsed.calls,
suffix: trimmedFence.suffix,
};
}
function trimWrappingJSONFence(prefix, suffix) {
const rightTrimmedPrefix = (prefix || '').replace(/[ \t\r\n]+$/g, '');
const fenceIdx = rightTrimmedPrefix.lastIndexOf('```');
if (fenceIdx < 0) {
return { prefix, suffix };
}
// Only strip when this behaves like an opening fence.
// If it's a legitimate closing fence before standalone tool JSON, keep it.
const fenceCount = (rightTrimmedPrefix.slice(0, fenceIdx + 3).match(/```/g) || []).length;
if (fenceCount % 2 === 0) {
return { prefix, suffix };
}
const header = rightTrimmedPrefix.slice(fenceIdx + 3).trim().toLowerCase();
if (header && header !== 'json') {
return { prefix, suffix };
}
const leftTrimmedSuffix = (suffix || '').replace(/^[ \t\r\n]+/g, '');
if (!leftTrimmedSuffix.startsWith('```')) {
return { prefix, suffix };
}
const consumed = (suffix || '').length - leftTrimmedSuffix.length;
return {
prefix: rightTrimmedPrefix.slice(0, fenceIdx),
suffix: (suffix || '').slice(consumed + 3),
};
}

View File

@@ -1,6 +1,5 @@
'use strict';
const TOOL_SIEVE_CAPTURE_LIMIT = 8 * 1024;
const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 256;
function createToolSieveState() {
@@ -9,6 +8,9 @@ function createToolSieveState() {
capture: '',
capturing: false,
recentTextTail: '',
pendingToolRaw: '',
pendingToolCalls: [],
disableDeltas: false,
toolNameSent: false,
toolName: '',
toolArgsStart: -1,
@@ -19,6 +21,7 @@ function createToolSieveState() {
}
function resetIncrementalToolState(state) {
state.disableDeltas = false;
state.toolNameSent = false;
state.toolName = '';
state.toolArgsStart = -1;
@@ -78,7 +81,6 @@ function toStringSafe(v) {
}
module.exports = {
TOOL_SIEVE_CAPTURE_LIMIT,
TOOL_SIEVE_CONTEXT_TAIL_LIMIT,
createToolSieveState,
resetIncrementalToolState,

View File

@@ -51,6 +51,9 @@ func MessagesPrepare(messages []map[string]any) string {
}
func NormalizeContent(v any) string {
if v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
@@ -64,11 +67,11 @@ func NormalizeContent(v any) string {
typeStr, _ := m["type"].(string)
typeStr = strings.ToLower(strings.TrimSpace(typeStr))
if typeStr == "text" || typeStr == "output_text" || typeStr == "input_text" {
if txt, ok := m["text"].(string); ok {
if txt, ok := m["text"].(string); ok && txt != "" {
parts = append(parts, txt)
continue
}
if txt, ok := m["content"].(string); ok {
if txt, ok := m["content"].(string); ok && txt != "" {
parts = append(parts, txt)
}
}

View File

@@ -0,0 +1,32 @@
package prompt
import "testing"
func TestNormalizeContentNilReturnsEmpty(t *testing.T) {
if got := NormalizeContent(nil); got != "" {
t.Fatalf("expected empty string for nil content, got %q", got)
}
}
func TestMessagesPrepareNilContentNoNullLiteral(t *testing.T) {
messages := []map[string]any{
{"role": "assistant", "content": nil},
{"role": "user", "content": "ok"},
}
got := MessagesPrepare(messages)
if got == "" {
t.Fatalf("expected non-empty output")
}
if got == "null" {
t.Fatalf("expected no null literal output, got %q", got)
}
}
func TestNormalizeContentArrayFallsBackToContentWhenTextEmpty(t *testing.T) {
got := NormalizeContent([]any{
map[string]any{"type": "text", "text": "", "content": "from-content"},
})
if got != "from-content" {
t.Fatalf("expected fallback to content when text is empty, got %q", got)
}
}

View File

@@ -57,16 +57,20 @@ func NewApp() *App {
r.Use(cors)
r.Use(timeout(0))
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
healthzHandler := func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
r.Get("/readyz", func(w http.ResponseWriter, _ *http.Request) {
}
readyzHandler := func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ready"}`))
})
}
r.Get("/healthz", healthzHandler)
r.Head("/healthz", healthzHandler)
r.Get("/readyz", readyzHandler)
r.Head("/readyz", readyzHandler)
openai.RegisterRoutes(r, openaiHandler)
claude.RegisterRoutes(r, claudeHandler)
gemini.RegisterRoutes(r, geminiHandler)

View File

@@ -0,0 +1,20 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthEndpointsSupportHEAD(t *testing.T) {
app := NewApp()
for _, path := range []string{"/healthz", "/readyz"} {
req := httptest.NewRequest(http.MethodHead, path, nil)
rec := httptest.NewRecorder()
app.Router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected %s HEAD status 200, got %d", path, rec.Code)
}
}
}

View File

@@ -17,6 +17,12 @@ func (r *Runner) caseHealthz(ctx context.Context, cc *caseContext) error {
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
cc.assert("status_ok", asString(m["status"]) == "ok", fmt.Sprintf("body=%s", string(resp.Body)))
headResp, headErr := cc.request(ctx, requestSpec{Method: http.MethodHead, Path: "/healthz", Retryable: true})
if headErr != nil {
return headErr
}
cc.assert("head_status_200", headResp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", headResp.StatusCode))
return nil
}
@@ -29,6 +35,12 @@ func (r *Runner) caseReadyz(ctx context.Context, cc *caseContext) error {
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
cc.assert("status_ready", asString(m["status"]) == "ready", fmt.Sprintf("body=%s", string(resp.Body)))
headResp, headErr := cc.request(ctx, requestSpec{Method: http.MethodHead, Path: "/readyz", Retryable: true})
if headErr != nil {
return headErr
}
cc.assert("head_status_200", headResp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", headResp.StatusCode))
return nil
}

View File

@@ -20,7 +20,7 @@ func buildToolCallCandidates(text string) []string {
}
}
// best-effort extraction around "tool_calls" key in mixed text payloads.
// best-effort extraction around tool call keywords in mixed text payloads.
candidates = append(candidates, extractToolCallObjects(trimmed)...)
// best-effort object slice: from first '{' to last '}'
@@ -57,25 +57,65 @@ func extractToolCallObjects(text string) []string {
lower := strings.ToLower(text)
out := []string{}
offset := 0
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
for {
idx := strings.Index(lower[offset:], "tool_calls")
if idx < 0 {
bestIdx := -1
matchedKeyword := ""
for _, kw := range keywords {
idx := strings.Index(lower[offset:], kw)
if idx >= 0 {
absIdx := offset + idx
if bestIdx < 0 || absIdx < bestIdx {
bestIdx = absIdx
matchedKeyword = kw
}
}
}
if bestIdx < 0 {
break
}
idx += offset
start := strings.LastIndex(text[:idx], "{")
for start >= 0 {
idx := bestIdx
// Avoid backtracking too far to prevent OOM on malicious or very long strings
searchLimit := idx - 2000
if searchLimit < offset {
searchLimit = offset
}
start := strings.LastIndex(text[searchLimit:idx], "{")
if start >= 0 {
start += searchLimit
}
if start < 0 {
offset = idx + len(matchedKeyword)
continue
}
foundObj := false
for start >= searchLimit {
candidate, end, ok := extractJSONObject(text, start)
if ok {
// Move forward to avoid repeatedly matching the same object.
offset = end
out = append(out, strings.TrimSpace(candidate))
foundObj = true
break
}
start = strings.LastIndex(text[:start], "{")
// Try previous '{'
if start > searchLimit {
prevStart := strings.LastIndex(text[searchLimit:start], "{")
if prevStart >= 0 {
start = searchLimit + prevStart
continue
}
}
break
}
if start < 0 {
offset = idx + len("tool_calls")
if !foundObj {
offset = idx + len(matchedKeyword)
}
}
return out
@@ -88,7 +128,12 @@ func extractJSONObject(text string, start int) (string, int, bool) {
depth := 0
quote := byte(0)
escaped := false
for i := start; i < len(text); i++ {
// Limit scan length to avoid OOM on unclosed objects
maxLen := start + 50000
if maxLen > len(text) {
maxLen = len(text)
}
for i := start; i < maxLen; i++ {
ch := text[i]
if quote != 0 {
if escaped {

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