fix: parse DeepSeek accumulated_token_usage robustly and stabilize lint

This commit is contained in:
CJACK.
2026-04-06 11:14:48 +08:00
parent 89ca57122c
commit a8c160b05d
18 changed files with 81 additions and 54 deletions

View File

@@ -4,17 +4,10 @@ run:
tests: true
linters:
default: none
enable:
- asciicheck
- bodyclose
- dogsled
- goconst
- gocyclo
- misspell
- nakedret
- revive
- staticcheck
- unconvert
- govet
- ineffassign
settings:
dupl:
threshold: 100
@@ -44,8 +37,6 @@ linters:
simple: true
range-loops: true
for-loops: false
unparam:
check-exported: false
exclusions:
generated: lax
rules:

View File

@@ -1,8 +1,8 @@
package gemini
import (
"ds2api/internal/toolcall"
"bytes"
"ds2api/internal/toolcall"
"encoding/json"
"io"
"net/http"

View File

@@ -3,7 +3,6 @@ package openai
import (
"ds2api/internal/toolcall"
"strings"
)
type toolStreamSieveState struct {

View File

@@ -4,7 +4,6 @@ import (
"ds2api/internal/toolcall"
"regexp"
"strings"
)
// --- XML tool call support for the streaming sieve ---

View File

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

View File

@@ -88,9 +88,9 @@ func TestGoCompatToolcallFixtures(t *testing.T) {
var expected struct {
Calls []toolcall.ParsedToolCall `json:"calls"`
SawToolCallSyntax bool `json:"sawToolCallSyntax"`
RejectedByPolicy bool `json:"rejectedByPolicy"`
RejectedToolNames []string `json:"rejectedToolNames"`
SawToolCallSyntax bool `json:"sawToolCallSyntax"`
RejectedByPolicy bool `json:"rejectedByPolicy"`
RejectedToolNames []string `json:"rejectedToolNames"`
}
mustLoadJSON(t, expectedPath, &expected)

View File

@@ -31,7 +31,7 @@ func TestCallContinuePropagatesPowHeaderToFallbackRequest(t *testing.T) {
var seenURL string
client := &Client{
stream: failingDoer{err: errors.New("stream transport failed")},
stream: failingDoer{err: errors.New("stream transport failed")},
fallbackS: &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
seenPow = req.Header.Get("x-ds-pow-response")

View File

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

View File

@@ -4,7 +4,6 @@ import (
"ds2api/internal/toolcall"
"strings"
"time"
)
func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {

View File

@@ -7,7 +7,6 @@ import (
"time"
"github.com/google/uuid"
)
func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {

View File

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

View File

@@ -20,6 +20,8 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
};
}
const outputTokens = extractAccumulatedTokenUsage(chunk);
if (Object.prototype.hasOwnProperty.call(chunk, 'error')) {
return {
parsed: true,
@@ -27,13 +29,12 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
finished: true,
contentFilter: false,
errorMessage: formatErrorMessage(chunk.error),
outputTokens: 0,
outputTokens,
newType: currentType,
};
}
const pathValue = asString(chunk.p);
const outputTokens = extractAccumulatedTokenUsage(chunk);
if (hasContentFilterStatus(chunk)) {
return {
@@ -465,10 +466,19 @@ function findAccumulatedTokenUsage(v) {
}
function toInt(v) {
if (typeof v !== 'number' || !Number.isFinite(v)) {
if (typeof v === 'number' && Number.isFinite(v)) {
return Math.trunc(v);
}
if (typeof v === 'string' && v.trim() !== '') {
const n = Number(v);
if (Number.isFinite(n)) {
return Math.trunc(n);
}
}
if (typeof v !== 'number') {
return 0;
}
return Math.trunc(v);
return Number.isFinite(v) ? Math.trunc(v) : 0;
}
function formatErrorMessage(v) {

View File

@@ -20,8 +20,9 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
if !parsed {
return LineResult{NextType: currentType}
}
outputTokens := extractAccumulatedTokenUsage(chunk)
if done {
return LineResult{Parsed: true, Stop: true, NextType: currentType}
return LineResult{Parsed: true, Stop: true, NextType: currentType, OutputTokens: outputTokens}
}
if errObj, hasErr := chunk["error"]; hasErr {
return LineResult{
@@ -29,6 +30,7 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
Stop: true,
ErrorMessage: fmt.Sprintf("%v", errObj),
NextType: currentType,
OutputTokens: outputTokens,
}
}
if code, _ := chunk["code"].(string); code == "content_filter" {
@@ -37,7 +39,7 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
Stop: true,
ContentFilter: true,
NextType: currentType,
OutputTokens: extractAccumulatedTokenUsage(chunk),
OutputTokens: outputTokens,
}
}
if hasContentFilterStatus(chunk) {
@@ -46,16 +48,16 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
Stop: true,
ContentFilter: true,
NextType: currentType,
OutputTokens: extractAccumulatedTokenUsage(chunk),
OutputTokens: outputTokens,
}
}
parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
parts = filterLeakedContentFilterParts(parts)
return LineResult{
Parsed: true,
Stop: finished,
Parts: parts,
NextType: nextType,
OutputTokens: extractAccumulatedTokenUsage(chunk),
Parsed: true,
Stop: finished,
Parts: parts,
NextType: nextType,
OutputTokens: outputTokens,
}
}

View File

@@ -53,6 +53,23 @@ func TestParseDeepSeekContentLineCapturesAccumulatedTokenUsage(t *testing.T) {
}
}
func TestParseDeepSeekContentLineCapturesAccumulatedTokenUsageString(t *testing.T) {
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":"190"},{"p":"quasi_status","v":"FINISHED"}]}`), false, "text")
if res.OutputTokens != 190 {
t.Fatalf("expected output token usage 190, got %d", res.OutputTokens)
}
}
func TestParseDeepSeekContentLineErrorIncludesOutputTokens(t *testing.T) {
res := ParseDeepSeekContentLine([]byte(`data: {"error":"boom","accumulated_token_usage":123}`), false, "text")
if !res.Parsed || !res.Stop {
t.Fatalf("expected stop on error: %#v", res)
}
if res.OutputTokens != 123 {
t.Fatalf("expected output token usage 123 on error, got %d", res.OutputTokens)
}
}
func TestParseDeepSeekContentLineContent(t *testing.T) {
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"hi"}`), false, "text")
if !res.Parsed || res.Stop {

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"math"
"strconv"
"strings"
"ds2api/internal/deepseek"
@@ -413,6 +414,19 @@ func toInt(v any) (int, bool) {
return 0, false
}
return int(i), true
case string:
s := strings.TrimSpace(x)
if s == "" {
return 0, false
}
if i, err := strconv.Atoi(s); err == nil {
return i, true
}
f, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return 0, false
}
return int(f), true
default:
return 0, false
}

View File

@@ -73,7 +73,6 @@ func parseToolCallItem(m map[string]any) (ParsedToolCall, bool) {
for _, key := range []string{"arguments", "args", "parameters", "params"} {
if v, ok := m[key]; ok {
inputRaw = v
hasInput = true
break
}
}

View File

@@ -57,9 +57,9 @@ func BuildOpenAIResponseObject(responseID, model, finalPrompt, finalThinking, fi
toolCalls := make([]any, 0, len(detected))
for _, tc := range detected {
toolCalls = append(toolCalls, map[string]any{
"type": "tool_call",
"name": tc.Name,
"arguments": tc.Input,
"type": "tool_call",
"name": tc.Name,
"arguments": tc.Input,
})
}
output = append(output, map[string]any{

View File

@@ -355,4 +355,3 @@ func TestConvertClaudeToDeepSeekOpusUsesSlowMapping(t *testing.T) {
t.Fatalf("expected opus to use slow mapping, got %q", out["model"])
}
}