mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
Compare commits
4 Commits
v2.2.0
...
v2.3.0_Bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a10e03ebe0 | ||
|
|
a6aa4a1839 | ||
|
|
c329bf26b6 | ||
|
|
3ae5b57ebe |
7
API.md
7
API.md
@@ -284,6 +284,11 @@ data: [DONE]
|
|||||||
|
|
||||||
**流式**:命中高置信特征后立即输出 `delta.tool_calls`(不等待完整 JSON 闭合),并持续发送 arguments 增量;已确认的 toolcall 原始 JSON 不会回流到 `delta.content`。
|
**流式**:命中高置信特征后立即输出 `delta.tool_calls`(不等待完整 JSON 闭合),并持续发送 arguments 增量;已确认的 toolcall 原始 JSON 不会回流到 `delta.content`。
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- **非代码块上下文**下,工具 JSON 即使与普通文本混合,也会按特征识别并产出可执行 tool call(前后普通文本仍可透传)。
|
||||||
|
- Markdown fenced code block(例如 ```json ... ```)中的 `tool_calls` 仅视为示例文本,不会被执行。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `GET /v1/models/{id}`
|
### `GET /v1/models/{id}`
|
||||||
@@ -301,7 +306,7 @@ OpenAI Responses 风格接口,兼容 `input` 或 `messages`。
|
|||||||
| `messages` | array | ❌ | 与 `input` 二选一 |
|
| `messages` | array | ❌ | 与 `input` 二选一 |
|
||||||
| `instructions` | string | ❌ | 自动前置为 system 消息 |
|
| `instructions` | string | ❌ | 自动前置为 system 消息 |
|
||||||
| `stream` | boolean | ❌ | 默认 `false` |
|
| `stream` | boolean | ❌ | 默认 `false` |
|
||||||
| `tools` | array | ❌ | 与 chat 同样的工具识别与转译策略 |
|
| `tools` | array | ❌ | 与 chat 同样的工具识别与转译策略(含代码块示例豁免) |
|
||||||
| `tool_choice` | string/object | ❌ | 支持 `auto`/`none`/`required` 与强制函数(`{"type":"function","name":"..."}`) |
|
| `tool_choice` | string/object | ❌ | 支持 `auto`/`none`/`required` 与强制函数(`{"type":"function","name":"..."}`) |
|
||||||
|
|
||||||
**非流式响应**:返回标准 `response` 对象,`id` 形如 `resp_xxx`,并写入内存 TTL 存储。
|
**非流式响应**:返回标准 `response` 对象,`id` 形如 `resp_xxx`,并写入内存 TTL 存储。
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ Queue limit = DS2API_ACCOUNT_MAX_QUEUE (default = recommended concurrency)
|
|||||||
When `tools` is present in the request, DS2API performs anti-leak handling:
|
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)
|
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.*`)
|
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
|
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
|
4. `responses` supports and enforces `tool_choice` (`auto`/`none`/`required`/forced function); `required` violations return `422` for non-stream and `response.failed` for stream
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ DS2API 提供两个层级的测试:
|
|||||||
1. **Preflight 检查**:
|
1. **Preflight 检查**:
|
||||||
- `go test ./... -count=1`(单元测试)
|
- `go test ./... -count=1`(单元测试)
|
||||||
- `./tests/scripts/check-node-split-syntax.sh`(Node 拆分模块语法门禁)
|
- `./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`(如仓库存在 Node 单测文件时执行;当前默认以 Go 测试 + Node 语法门禁为主)
|
||||||
- `npm run build --prefix webui`(WebUI 构建检查)
|
- `npm run build --prefix webui`(WebUI 构建检查)
|
||||||
|
|
||||||
2. **隔离启动**:复制 `config.json` 到临时目录,启动独立服务进程
|
2. **隔离启动**:复制 `config.json` 到临时目录,启动独立服务进程
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestGeminiRoutesRegistered(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerateContentReturnsFunctionCallParts(t *testing.T) {
|
func TestGenerateContentReturnsFunctionCallParts(t *testing.T) {
|
||||||
upstream := makeGeminiUpstreamResponse(
|
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]`,
|
`data: [DONE]`,
|
||||||
)
|
)
|
||||||
h := &Handler{
|
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) {
|
func TestStreamGenerateContentEmitsSSE(t *testing.T) {
|
||||||
upstream := makeGeminiUpstreamResponse(
|
upstream := makeGeminiUpstreamResponse(
|
||||||
`data: {"p":"response/content","v":"hello "}`,
|
`data: {"p":"response/content","v":"hello "}`,
|
||||||
|
|||||||
@@ -513,8 +513,8 @@ func TestHandleStreamToolCallMixedWithPlainTextSegments(t *testing.T) {
|
|||||||
if !done {
|
if !done {
|
||||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
if streamHasToolCallsDelta(frames) {
|
if !streamHasToolCallsDelta(frames) {
|
||||||
t.Fatalf("did not expect tool_calls delta in mixed prose stream, body=%s", rec.Body.String())
|
t.Fatalf("expected tool_calls delta in mixed prose stream, body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
content := strings.Builder{}
|
content := strings.Builder{}
|
||||||
for _, frame := range frames {
|
for _, frame := range frames {
|
||||||
@@ -531,11 +531,8 @@ func TestHandleStreamToolCallMixedWithPlainTextSegments(t *testing.T) {
|
|||||||
if !strings.Contains(got, "下面是示例:") || !strings.Contains(got, "请勿执行。") {
|
if !strings.Contains(got, "下面是示例:") || !strings.Contains(got, "请勿执行。") {
|
||||||
t.Fatalf("expected pre/post plain text to pass sieve, got=%q", got)
|
t.Fatalf("expected pre/post plain text to pass sieve, got=%q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(strings.ToLower(got), `"tool_calls"`) {
|
if streamFinishReason(frames) != "tool_calls" {
|
||||||
t.Fatalf("expected embedded tool json to remain text in strict mode, got=%q", got)
|
t.Fatalf("expected finish_reason=tool_calls for mixed prose, body=%s", rec.Body.String())
|
||||||
}
|
|
||||||
if streamFinishReason(frames) != "stop" {
|
|
||||||
t.Fatalf("expected finish_reason=stop for mixed prose, body=%s", rec.Body.String())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,8 +552,8 @@ func TestHandleStreamToolCallAfterLeadingTextRemainsText(t *testing.T) {
|
|||||||
if !done {
|
if !done {
|
||||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
if streamHasToolCallsDelta(frames) {
|
if !streamHasToolCallsDelta(frames) {
|
||||||
t.Fatalf("did not expect tool_calls delta, body=%s", rec.Body.String())
|
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
content := strings.Builder{}
|
content := strings.Builder{}
|
||||||
for _, frame := range frames {
|
for _, frame := range frames {
|
||||||
@@ -573,11 +570,9 @@ func TestHandleStreamToolCallAfterLeadingTextRemainsText(t *testing.T) {
|
|||||||
if !strings.Contains(got, "我将调用工具。") {
|
if !strings.Contains(got, "我将调用工具。") {
|
||||||
t.Fatalf("expected leading text to keep streaming, got=%q", got)
|
t.Fatalf("expected leading text to keep streaming, got=%q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(strings.ToLower(got), "tool_calls") {
|
|
||||||
t.Fatalf("expected tool_calls example text preserved, got=%q", got)
|
if streamFinishReason(frames) != "tool_calls" {
|
||||||
}
|
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
||||||
if streamFinishReason(frames) != "stop" {
|
|
||||||
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,8 +591,8 @@ func TestHandleStreamToolCallWithSameChunkTrailingTextRemainsText(t *testing.T)
|
|||||||
if !done {
|
if !done {
|
||||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
if streamHasToolCallsDelta(frames) {
|
if !streamHasToolCallsDelta(frames) {
|
||||||
t.Fatalf("did not expect tool_calls delta, body=%s", rec.Body.String())
|
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
content := strings.Builder{}
|
content := strings.Builder{}
|
||||||
for _, frame := range frames {
|
for _, frame := range frames {
|
||||||
@@ -614,8 +609,45 @@ func TestHandleStreamToolCallWithSameChunkTrailingTextRemainsText(t *testing.T)
|
|||||||
if !strings.Contains(got, "接下来我会继续说明。") {
|
if !strings.Contains(got, "接下来我会继续说明。") {
|
||||||
t.Fatalf("expected trailing plain text to be preserved, got=%q", got)
|
t.Fatalf("expected trailing plain text to be preserved, got=%q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(strings.ToLower(got), "tool_calls") {
|
|
||||||
t.Fatalf("expected tool_calls example text preserved, got=%q", got)
|
if streamFinishReason(frames) != "tool_calls" {
|
||||||
|
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStreamFencedToolCallSnippetRemainsText(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("did not expect 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(got, "```json") || !strings.Contains(strings.ToLower(got), "tool_calls") {
|
||||||
|
t.Fatalf("expected fenced tool snippet in content, got=%q", got)
|
||||||
}
|
}
|
||||||
if streamFinishReason(frames) != "stop" {
|
if streamFinishReason(frames) != "stop" {
|
||||||
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
|
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
|
||||||
@@ -640,8 +672,8 @@ func TestHandleStreamToolCallKeyAppearsLateRemainsText(t *testing.T) {
|
|||||||
if !done {
|
if !done {
|
||||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
if streamHasToolCallsDelta(frames) {
|
if !streamHasToolCallsDelta(frames) {
|
||||||
t.Fatalf("did not expect tool_calls delta, body=%s", rec.Body.String())
|
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
content := strings.Builder{}
|
content := strings.Builder{}
|
||||||
for _, frame := range frames {
|
for _, frame := range frames {
|
||||||
@@ -655,14 +687,11 @@ func TestHandleStreamToolCallKeyAppearsLateRemainsText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
got := content.String()
|
got := content.String()
|
||||||
if !strings.Contains(strings.ToLower(got), "tool_calls") || !strings.Contains(got, "{") {
|
|
||||||
t.Fatalf("expected embedded tool json to remain in text, got=%q", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "后置正文C。") {
|
if !strings.Contains(got, "后置正文C。") {
|
||||||
t.Fatalf("expected stream to continue after tool json convergence, got=%q", got)
|
t.Fatalf("expected stream to continue after tool json convergence, got=%q", got)
|
||||||
}
|
}
|
||||||
if streamFinishReason(frames) != "stop" {
|
if streamFinishReason(frames) != "tool_calls" {
|
||||||
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
|
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
|
|||||||
"role": "user",
|
"role": "user",
|
||||||
"content": formatToolResultForPrompt(msg),
|
"content": formatToolResultForPrompt(msg),
|
||||||
})
|
})
|
||||||
case "user", "system":
|
case "user", "system", "developer":
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"role": role,
|
"role": normalizeOpenAIRoleForPrompt(role),
|
||||||
"content": normalizeOpenAIContentForPrompt(msg["content"]),
|
"content": normalizeOpenAIContentForPrompt(msg["content"]),
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
@@ -47,7 +47,7 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
|
|||||||
role = "user"
|
role = "user"
|
||||||
}
|
}
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"role": role,
|
"role": normalizeOpenAIRoleForPrompt(role),
|
||||||
"content": content,
|
"content": content,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -189,6 +189,14 @@ func marshalToPromptString(v any) string {
|
|||||||
return string(b)
|
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 {
|
func asString(v any) string {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -193,3 +193,17 @@ func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t *
|
|||||||
t.Fatalf("expected original concatenated arguments in tool history, got %q", content)
|
t.Fatalf("expected original concatenated arguments in tool history, 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"role": role,
|
"role": normalizeOpenAIRoleForPrompt(role),
|
||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str
|
|||||||
role = "user"
|
role = "user"
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"role": role,
|
"role": normalizeOpenAIRoleForPrompt(role),
|
||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
case "function_call_output", "tool_result":
|
case "function_call_output", "tool_result":
|
||||||
|
|||||||
@@ -288,12 +288,8 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *te
|
|||||||
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-reasoner", "prompt", true, false, []string{"read_file"}, util.DefaultToolChoicePolicy(), "")
|
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")
|
addedPayloads := extractAllSSEEventPayloads(rec.Body.String(), "response.output_item.added")
|
||||||
if len(addedPayloads) != 1 {
|
if len(addedPayloads) < 1 {
|
||||||
t.Fatalf("expected only one message output_item.added event, got %d body=%s", len(addedPayloads), rec.Body.String())
|
t.Fatalf("expected at least one output_item.added event, got %d body=%s", len(addedPayloads), rec.Body.String())
|
||||||
}
|
|
||||||
item, _ := addedPayloads[0]["item"].(map[string]any)
|
|
||||||
if asString(item["type"]) != "message" {
|
|
||||||
t.Fatalf("expected only message output item in strict mode, got %#v", item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
completedPayload, ok := extractSSEEventPayload(rec.Body.String(), "response.completed")
|
completedPayload, ok := extractSSEEventPayload(rec.Body.String(), "response.completed")
|
||||||
@@ -302,15 +298,22 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *te
|
|||||||
}
|
}
|
||||||
responseObj, _ := completedPayload["response"].(map[string]any)
|
responseObj, _ := completedPayload["response"].(map[string]any)
|
||||||
output, _ := responseObj["output"].([]any)
|
output, _ := responseObj["output"].([]any)
|
||||||
|
hasMessage := false
|
||||||
for _, item := range output {
|
for _, item := range output {
|
||||||
m, _ := item.(map[string]any)
|
m, _ := item.(map[string]any)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if asString(m["type"]) == "message" {
|
||||||
|
hasMessage = true
|
||||||
|
}
|
||||||
if asString(m["type"]) == "function_call" {
|
if asString(m["type"]) == "function_call" {
|
||||||
t.Fatalf("did not expect function_call output for mixed prose tool example, output=%#v", output)
|
t.Fatalf("did not expect function_call output for mixed prose tool example, output=%#v", output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !hasMessage {
|
||||||
|
t.Fatalf("expected message output for mixed prose tool example, output=%#v", output)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
|
func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
|
||||||
|
|||||||
@@ -15,19 +15,9 @@ func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames
|
|||||||
}
|
}
|
||||||
events := make([]toolStreamEvent, 0, 2)
|
events := make([]toolStreamEvent, 0, 2)
|
||||||
if len(state.pendingToolCalls) > 0 {
|
if len(state.pendingToolCalls) > 0 {
|
||||||
pending := state.pending.String()
|
events = append(events, toolStreamEvent{ToolCalls: state.pendingToolCalls})
|
||||||
if strings.TrimSpace(pending) != "" {
|
state.pendingToolRaw = ""
|
||||||
content := state.pendingToolRaw + pending
|
state.pendingToolCalls = nil
|
||||||
state.pending.Reset()
|
|
||||||
state.pendingToolRaw = ""
|
|
||||||
state.pendingToolCalls = nil
|
|
||||||
state.noteText(content)
|
|
||||||
events = append(events, toolStreamEvent{Content: content})
|
|
||||||
} else {
|
|
||||||
// Wait for either more non-whitespace content (demote to plain text)
|
|
||||||
// or stream flush (promote to executable tool calls).
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -45,7 +35,14 @@ func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames
|
|||||||
state.capturing = false
|
state.capturing = false
|
||||||
state.resetIncrementalToolState()
|
state.resetIncrementalToolState()
|
||||||
if len(calls) > 0 {
|
if len(calls) > 0 {
|
||||||
state.pendingToolRaw = captured
|
if prefix != "" {
|
||||||
|
state.noteText(prefix)
|
||||||
|
events = append(events, toolStreamEvent{Content: prefix})
|
||||||
|
}
|
||||||
|
if suffix != "" {
|
||||||
|
state.pending.WriteString(suffix)
|
||||||
|
}
|
||||||
|
_ = captured
|
||||||
state.pendingToolCalls = calls
|
state.pendingToolCalls = calls
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -211,11 +208,6 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
|
|||||||
if insideCodeFence(state.recentTextTail + prefixPart) {
|
if insideCodeFence(state.recentTextTail + prefixPart) {
|
||||||
return captured, nil, "", true
|
return captured, nil, "", true
|
||||||
}
|
}
|
||||||
// Strict mode: only standalone tool payloads are executable. If the
|
|
||||||
// payload is wrapped by non-whitespace prose, keep it as plain text.
|
|
||||||
if strings.TrimSpace(state.recentTextTail) != "" || strings.TrimSpace(prefixPart) != "" || strings.TrimSpace(suffixPart) != "" {
|
|
||||||
return captured, nil, "", true
|
|
||||||
}
|
|
||||||
parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
|
parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
|
||||||
if len(parsed.Calls) == 0 {
|
if len(parsed.Calls) == 0 {
|
||||||
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
|
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
|
||||||
|
|||||||
@@ -125,7 +125,10 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(message) == "" {
|
if strings.TrimSpace(message) == "" {
|
||||||
message = "你是谁?"
|
result["success"] = true
|
||||||
|
result["message"] = "API 测试成功(仅会话创建)"
|
||||||
|
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
thinking, search, ok := config.GetModelConfig(model)
|
thinking, search, ok := config.GetModelConfig(model)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
76
internal/admin/handler_accounts_testing_test.go
Normal file
76
internal/admin/handler_accounts_testing_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ds2api/internal/auth"
|
||||||
|
"ds2api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testingDSMock struct {
|
||||||
|
loginCalls int
|
||||||
|
createSessionCalls int
|
||||||
|
getPowCalls int
|
||||||
|
callCompletionCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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, "仅会话创建") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"ds2api/internal/auth"
|
"ds2api/internal/auth"
|
||||||
"ds2api/internal/config"
|
"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 != "" {
|
if email := strings.TrimSpace(acc.Email); email != "" {
|
||||||
payload["email"] = email
|
payload["email"] = email
|
||||||
} else if mobile := strings.TrimSpace(acc.Mobile); mobile != "" {
|
} else if mobile := strings.TrimSpace(acc.Mobile); mobile != "" {
|
||||||
payload["mobile"] = mobile
|
loginMobile, areaCode := normalizeMobileForLogin(mobile)
|
||||||
payload["area_code"] = nil
|
payload["mobile"] = loginMobile
|
||||||
|
payload["area_code"] = areaCode
|
||||||
} else {
|
} else {
|
||||||
return "", errors.New("missing email/mobile")
|
return "", errors.New("missing email/mobile")
|
||||||
}
|
}
|
||||||
@@ -151,3 +153,26 @@ func isTokenInvalid(status int, code int, msg string) bool {
|
|||||||
}
|
}
|
||||||
return strings.Contains(msg, "token") || strings.Contains(msg, "unauthorized")
|
return strings.Contains(msg, "token") || strings.Contains(msg, "unauthorized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
33
internal/deepseek/client_auth_mobile_test.go
Normal file
33
internal/deepseek/client_auth_mobile_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
type ParsedToolCall struct {
|
type ParsedToolCall struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Input map[string]any `json:"input"`
|
Input map[string]any `json:"input"`
|
||||||
@@ -121,12 +124,7 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin
|
|||||||
if tc.Name == "" {
|
if tc.Name == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
matchedName := ""
|
matchedName := resolveAllowedToolName(tc.Name, allowed, allowedCanonical)
|
||||||
if _, ok := allowed[tc.Name]; ok {
|
|
||||||
matchedName = tc.Name
|
|
||||||
} else if canonical, ok := allowedCanonical[strings.ToLower(tc.Name)]; ok {
|
|
||||||
matchedName = canonical
|
|
||||||
}
|
|
||||||
if matchedName == "" {
|
if matchedName == "" {
|
||||||
rejectedSet[tc.Name] = struct{}{}
|
rejectedSet[tc.Name] = struct{}{}
|
||||||
continue
|
continue
|
||||||
@@ -144,6 +142,31 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin
|
|||||||
return out, rejected
|
return out, rejected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
|
||||||
|
if _, ok := allowed[name]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if canonical, ok := allowedCanonical[lower]; ok {
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(lower, "."); idx >= 0 && idx < len(lower)-1 {
|
||||||
|
if canonical, ok := allowedCanonical[lower[idx+1:]]; ok {
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loose := toolNameLoosePattern.ReplaceAllString(lower, "")
|
||||||
|
if loose == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for candidateLower, canonical := range allowedCanonical {
|
||||||
|
if toolNameLoosePattern.ReplaceAllString(candidateLower, "") == loose {
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func parseToolCallsPayload(payload string) []ParsedToolCall {
|
func parseToolCallsPayload(payload string) []ParsedToolCall {
|
||||||
var decoded any
|
var decoded any
|
||||||
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
||||||
|
|||||||
@@ -115,3 +115,25 @@ func TestParseStandaloneToolCallsIgnoresFencedCodeBlock(t *testing.T) {
|
|||||||
t.Fatalf("expected fenced tool_call example to be ignored, got %#v", calls)
|
t.Fatalf("expected fenced tool_call example to be ignored, got %#v", calls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseToolCallsAllowsQualifiedToolName(t *testing.T) {
|
||||||
|
text := `{"tool_calls":[{"name":"mcp.search_web","input":{"q":"golang"}}]}`
|
||||||
|
calls := ParseToolCalls(text, []string{"search_web"})
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 call, got %#v", calls)
|
||||||
|
}
|
||||||
|
if calls[0].Name != "search_web" {
|
||||||
|
t.Fatalf("expected canonical tool name search_web, got %q", calls[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseToolCallsAllowsPunctuationVariantToolName(t *testing.T) {
|
||||||
|
text := `{"tool_calls":[{"name":"read-file","input":{"path":"README.md"}}]}`
|
||||||
|
calls := ParseToolCalls(text, []string{"read_file"})
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 call, got %#v", calls)
|
||||||
|
}
|
||||||
|
if calls[0].Name != "read_file" {
|
||||||
|
t.Fatalf("expected canonical tool name read_file, got %q", calls[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user