diff --git a/.golangci.yml b/.golangci.yml index 82d1ddd..1b151e6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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: diff --git a/internal/adapter/gemini/handler_generate.go b/internal/adapter/gemini/handler_generate.go index 0900121..c265f36 100644 --- a/internal/adapter/gemini/handler_generate.go +++ b/internal/adapter/gemini/handler_generate.go @@ -1,8 +1,8 @@ package gemini import ( - "ds2api/internal/toolcall" "bytes" + "ds2api/internal/toolcall" "encoding/json" "io" "net/http" diff --git a/internal/adapter/openai/tool_sieve_state.go b/internal/adapter/openai/tool_sieve_state.go index 6bf006b..1b1b96e 100644 --- a/internal/adapter/openai/tool_sieve_state.go +++ b/internal/adapter/openai/tool_sieve_state.go @@ -3,7 +3,6 @@ package openai import ( "ds2api/internal/toolcall" "strings" - ) type toolStreamSieveState struct { diff --git a/internal/adapter/openai/tool_sieve_xml.go b/internal/adapter/openai/tool_sieve_xml.go index 30e686e..ef6d921 100644 --- a/internal/adapter/openai/tool_sieve_xml.go +++ b/internal/adapter/openai/tool_sieve_xml.go @@ -4,7 +4,6 @@ import ( "ds2api/internal/toolcall" "regexp" "strings" - ) // --- XML tool call support for the streaming sieve --- diff --git a/internal/admin/handler_raw_samples_test.go b/internal/admin/handler_raw_samples_test.go index de4fa03..a3dbe39 100644 --- a/internal/admin/handler_raw_samples_test.go +++ b/internal/admin/handler_raw_samples_test.go @@ -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", }, } diff --git a/internal/compat/go_compat_test.go b/internal/compat/go_compat_test.go index 414975d..140f16a 100644 --- a/internal/compat/go_compat_test.go +++ b/internal/compat/go_compat_test.go @@ -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) diff --git a/internal/deepseek/client_continue_test.go b/internal/deepseek/client_continue_test.go index 68963e7..fa0b843 100644 --- a/internal/deepseek/client_continue_test.go +++ b/internal/deepseek/client_continue_test.go @@ -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") diff --git a/internal/deepseek/constants.go b/internal/deepseek/constants.go index bd7c858..2d87b3e 100644 --- a/internal/deepseek/constants.go +++ b/internal/deepseek/constants.go @@ -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" ) diff --git a/internal/format/openai/render_chat.go b/internal/format/openai/render_chat.go index 8eb54b1..c09e870 100644 --- a/internal/format/openai/render_chat.go +++ b/internal/format/openai/render_chat.go @@ -4,7 +4,6 @@ import ( "ds2api/internal/toolcall" "strings" "time" - ) func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { diff --git a/internal/format/openai/render_responses.go b/internal/format/openai/render_responses.go index 899ce90..8fc4dbe 100644 --- a/internal/format/openai/render_responses.go +++ b/internal/format/openai/render_responses.go @@ -7,7 +7,6 @@ import ( "time" "github.com/google/uuid" - ) func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { diff --git a/internal/format/openai/render_stream_events.go b/internal/format/openai/render_stream_events.go index 1e7cd09..6c1121a 100644 --- a/internal/format/openai/render_stream_events.go +++ b/internal/format/openai/render_stream_events.go @@ -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", diff --git a/internal/js/chat-stream/sse_parse_impl.js b/internal/js/chat-stream/sse_parse_impl.js index 834c392..9a8d16d 100644 --- a/internal/js/chat-stream/sse_parse_impl.js +++ b/internal/js/chat-stream/sse_parse_impl.js @@ -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) { diff --git a/internal/sse/line.go b/internal/sse/line.go index 1d9ddae..d55f9e5 100644 --- a/internal/sse/line.go +++ b/internal/sse/line.go @@ -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, } } diff --git a/internal/sse/line_test.go b/internal/sse/line_test.go index 09aa97c..ae6e9ac 100644 --- a/internal/sse/line_test.go +++ b/internal/sse/line_test.go @@ -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 { diff --git a/internal/sse/parser.go b/internal/sse/parser.go index 9deb440..c8ba685 100644 --- a/internal/sse/parser.go +++ b/internal/sse/parser.go @@ -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 } diff --git a/internal/toolcall/toolcalls_parse_item.go b/internal/toolcall/toolcalls_parse_item.go index b8909ba..269fe3c 100644 --- a/internal/toolcall/toolcalls_parse_item.go +++ b/internal/toolcall/toolcalls_parse_item.go @@ -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 } } diff --git a/internal/util/render.go b/internal/util/render.go index 2210bc3..085ebb3 100644 --- a/internal/util/render.go +++ b/internal/util/render.go @@ -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{ diff --git a/internal/util/util_edge_test.go b/internal/util/util_edge_test.go index 41d1c9d..621df2f 100644 --- a/internal/util/util_edge_test.go +++ b/internal/util/util_edge_test.go @@ -355,4 +355,3 @@ func TestConvertClaudeToDeepSeekOpusUsesSlowMapping(t *testing.T) { t.Fatalf("expected opus to use slow mapping, got %q", out["model"]) } } -