Files
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
2026-10-04 16:27:54 +08:00

370 lines
13 KiB
Go

package model
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// anthropicSeqServer 用一份固定的响应体脚本回答连续的 /v1/messages
// 调用(最后一个响应体重复),并记录每个已解码的请求体,因此
// 测试既能断言重试次数、也能断言重试后的 max_tokens。
type anthropicSeqServer struct {
*httptest.Server
mu sync.Mutex
bodies []map[string]any
}
func newAnthropicSeqServer(t *testing.T, contentType string, bodies ...string) *anthropicSeqServer {
t.Helper()
s := &anthropicSeqServer{}
idx := 0
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
s.mu.Lock()
s.bodies = append(s.bodies, body)
n := idx
idx++
s.mu.Unlock()
if n >= len(bodies) {
n = len(bodies) - 1
}
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, bodies[n])
}))
t.Cleanup(s.Server.Close)
return s
}
func (s *anthropicSeqServer) calls() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.bodies)
}
func (s *anthropicSeqServer) body(i int) map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
if i >= len(s.bodies) {
return nil
}
return s.bodies[i]
}
// anthropicThinkingOnlyResponse 是一份只有思考、被预算截断的回复:
// 推理吃光了整个 max_tokens 预算,所以没有文本块被发出。
const anthropicThinkingOnlyResponse = `{
"id":"msg_think","model":"claude-test",
"content":[{"type":"thinking","thinking":"SECRET-REASONING long long long"}],
"stop_reason":"max_tokens",
"usage":{"input_tokens":5,"output_tokens":1024}
}`
const anthropicEndTurnTextResponse = `{
"id":"msg_text","model":"claude-test",
"content":[{"type":"text","text":"答案"}],
"stop_reason":"end_turn",
"usage":{"input_tokens":5,"output_tokens":10}
}`
const anthropicToolUseResponse = `{
"id":"msg_tool","model":"claude-test",
"content":[{"type":"tool_use","id":"toolu_1","name":"datetime","input":{"tz":"Asia/Shanghai"}}],
"stop_reason":"tool_use",
"usage":{"input_tokens":5,"output_tokens":8}
}`
// anthropicTruncatedThinkingSSE 是「只有思考」截断的流式形态:
// 一个 thinking 块,随后是 message_delta 的 stop_reason max_tokens。
const anthropicTruncatedThinkingSSE = `data: {"type":"message_start","message":{"model":"claude-test","usage":{"input_tokens":5}}}
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"SECRET-REASONING"}}
data: {"type":"content_block_stop","index":0}
data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":1024}}
data: {"type":"message_stop"}
`
const anthropicEndTurnTextSSE = `data: {"type":"message_start","message":{"model":"claude-test","usage":{"input_tokens":5}}}
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"好"}}
data: {"type":"content_block_stop","index":0}
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}
data: {"type":"message_stop"}
`
// TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget 证明
// 主恢复路径:只有 thinking 块的 max_tokens 截断会
// 恰好重试一次、token 预算翻倍,并浮出重试的文本。
func TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "application/json", anthropicThinkingOnlyResponse, anthropicEndTurnTextResponse)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
out, err := ad.Chat(context.Background(), Request{
Messages: []Message{TextMessage(RoleUser, "hi")},
MaxTokens: 1024,
}, Options{})
if err != nil {
t.Fatalf("Chat error: %v", err)
}
if srv.calls() != 2 {
t.Fatalf("requests = %d, want 2 (one retry)", srv.calls())
}
if got := srv.body(0)["max_tokens"]; got != float64(1024) {
t.Errorf("first request max_tokens = %v, want 1024", got)
}
if got := srv.body(1)["max_tokens"]; got != float64(2048) {
t.Errorf("retry request max_tokens = %v, want 2048 (doubled)", got)
}
if out.Content != "答案" {
t.Errorf("Content = %q, want the retry's text", out.Content)
}
if out.StopReason != "end_turn" {
t.Errorf("StopReason = %q, want end_turn", out.StopReason)
}
if strings.Contains(out.Content, "SECRET-REASONING") {
t.Errorf("Content leaked thinking: %q", out.Content)
}
}
// TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint 证明
// 兜底网:连重试也被截断时,调用方拿到非空
// 占位,而不是静默空回复,且思考永不泄漏。
func TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "application/json", anthropicThinkingOnlyResponse, anthropicThinkingOnlyResponse)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
out, err := ad.Chat(context.Background(), Request{
Messages: []Message{TextMessage(RoleUser, "hi")},
MaxTokens: 8192,
}, Options{})
if err != nil {
t.Fatalf("Chat error: %v", err)
}
if srv.calls() != 2 {
t.Fatalf("requests = %d, want 2 (retry once, never more)", srv.calls())
}
if got := srv.body(1)["max_tokens"]; got != float64(16384) {
t.Errorf("retry request max_tokens = %v, want 16384", got)
}
if out.Content != truncationEmptyHint {
t.Errorf("Content = %q, want the non-empty fallback hint", out.Content)
}
if out.Content == "" {
t.Error("Content is empty: a truncated turn must never surface as a silent reply")
}
if strings.Contains(out.Content, "SECRET-REASONING") {
t.Errorf("Content leaked thinking: %q", out.Content)
}
if out.Message.Role != RoleAssistant || len(out.Message.Content) != 1 || out.Message.Content[0].Text != truncationEmptyHint {
t.Errorf("Message = %+v, want a single assistant hint part", out.Message)
}
}
// TestAnthropicChat_NormalStop_NoRetry 证明正常的 end_turn 答复与
// 原生 tool_use 回合(其正文合法地为空)保持不动:
// 不重试、不补兜底占位。
func TestAnthropicChat_NormalStop_NoRetry(t *testing.T) {
t.Parallel()
t.Run("end_turn", func(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "application/json", anthropicEndTurnTextResponse)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
out, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}, MaxTokens: 1024}, Options{})
if err != nil {
t.Fatalf("Chat error: %v", err)
}
if srv.calls() != 1 {
t.Errorf("requests = %d, want 1 (no retry on end_turn)", srv.calls())
}
if out.Content != "答案" {
t.Errorf("Content = %q, want 答案", out.Content)
}
})
t.Run("tool_use", func(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "application/json", anthropicToolUseResponse)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
out, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}, MaxTokens: 1024}, Options{})
if err != nil {
t.Fatalf("Chat error: %v", err)
}
if srv.calls() != 1 {
t.Errorf("requests = %d, want 1 (a tool_use round is not a truncation)", srv.calls())
}
if len(out.ToolCalls) != 1 {
t.Fatalf("ToolCalls = %+v, want 1", out.ToolCalls)
}
if out.Content != "" {
t.Errorf("Content = %q, want empty (tool-only round)", out.Content)
}
})
}
// TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget 证明
// 流式重试:重试产生的思考被抑制(无重复块),
// 而其文本流过,且发送的是翻倍预算。
func TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "text/event-stream", anthropicTruncatedThinkingSSE, anthropicEndTurnTextSSE)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
var events []StreamEvent
out, err := ad.ChatStream(context.Background(), Request{
Messages: []Message{TextMessage(RoleUser, "hi")},
MaxTokens: 1024,
}, func(ev StreamEvent) { events = append(events, ev) })
if err != nil {
t.Fatalf("ChatStream error: %v", err)
}
if srv.calls() != 2 {
t.Fatalf("requests = %d, want 2 (one retry)", srv.calls())
}
if got := srv.body(1)["max_tokens"]; got != float64(2048) {
t.Errorf("retry request max_tokens = %v, want 2048", got)
}
if out.Content != "好" {
t.Errorf("Content = %q, want the retry's text", out.Content)
}
if out.StopReason != "end_turn" {
t.Errorf("StopReason = %q, want end_turn", out.StopReason)
}
want := []StreamEvent{
{Kind: StreamKindThinkingStart, Index: 0},
{Kind: StreamKindThinkingDelta, Index: 0, Delta: "SECRET-REASONING"},
{Kind: StreamKindThinkingEnd, Index: 0},
{Kind: StreamKindTextStart, Index: 0},
{Kind: StreamKindTextDelta, Index: 0, Delta: "好"},
{Kind: StreamKindTextEnd, Index: 0},
}
if !reflect.DeepEqual(events, want) {
t.Errorf("events = %+v, want %+v (retry thinking suppressed, retry text streamed)", events, want)
}
}
// TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint 证明
// 流式兜底网:连重试也被截断时,会以文本片段发出非空
// 占位(并把它作为正文返回),因此前端不会
// 停在一个静默截断的回合上。
func TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "text/event-stream", anthropicTruncatedThinkingSSE, anthropicTruncatedThinkingSSE)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
var events []StreamEvent
out, err := ad.ChatStream(context.Background(), Request{
Messages: []Message{TextMessage(RoleUser, "hi")},
MaxTokens: 1024,
}, func(ev StreamEvent) { events = append(events, ev) })
if err != nil {
t.Fatalf("ChatStream error: %v", err)
}
if srv.calls() != 2 {
t.Fatalf("requests = %d, want 2 (retry once, never more)", srv.calls())
}
if out.Content != truncationEmptyHint {
t.Errorf("Content = %q, want the non-empty fallback hint", out.Content)
}
want := []StreamEvent{
{Kind: StreamKindThinkingStart, Index: 0},
{Kind: StreamKindThinkingDelta, Index: 0, Delta: "SECRET-REASONING"},
{Kind: StreamKindThinkingEnd, Index: 0},
{Kind: StreamKindTextStart, Index: 0},
{Kind: StreamKindTextDelta, Index: 0, Delta: truncationEmptyHint},
{Kind: StreamKindTextEnd, Index: 0},
}
if !reflect.DeepEqual(events, want) {
t.Errorf("events = %+v, want %+v (hint streamed, thinking not re-emitted)", events, want)
}
}
// TestTruncationBudget 覆盖约束重试的预算算术。
func TestTruncationBudget(t *testing.T) {
t.Parallel()
cases := []struct {
name string
requested, def int
wantEffective int
wantRetry int
}{
{"explicit doubles", 1024, anthropicDefaultMaxTokens, 1024, 2048},
{"default doubles", 0, anthropicDefaultMaxTokens, anthropicDefaultMaxTokens, 16384},
{"capped", 20000, anthropicDefaultMaxTokens, 20000, truncationRetryCap},
{"nothing to double", 0, 0, 0, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := effectiveMaxTokens(tc.requested, tc.def); got != tc.wantEffective {
t.Errorf("effectiveMaxTokens(%d, %d) = %d, want %d", tc.requested, tc.def, got, tc.wantEffective)
}
if got := retryMaxTokens(tc.requested, tc.def); got != tc.wantRetry {
t.Errorf("retryMaxTokens(%d, %d) = %d, want %d", tc.requested, tc.def, got, tc.wantRetry)
}
})
}
}
// TestOpenAIChat_StopReasonMapping 断言 OpenAI 适配器在非流式与
// 流式两条路径上都由 finish_reason 填充统一的
// Response.StopReason,因此 "length" 截断在各协议间都可识别。
func TestOpenAIChat_StopReasonMapping(t *testing.T) {
t.Parallel()
t.Run("non_streaming", func(t *testing.T) {
t.Parallel()
body := `{"id":"c1","model":"gpt-test","choices":[{"index":0,"message":{"role":"assistant","content":"cut"},"finish_reason":"length"}],"usage":{"prompt_tokens":5,"completion_tokens":64,"total_tokens":69}}`
srv := newCompletionServer(t, http.StatusOK, "application/json", body, nil)
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
out, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
if err != nil {
t.Fatalf("Chat error: %v", err)
}
if out.StopReason != "length" {
t.Errorf("StopReason = %q, want length", out.StopReason)
}
})
t.Run("streaming", func(t *testing.T) {
t.Parallel()
sse := strings.Join([]string{
`data: {"choices":[{"index":0,"delta":{"content":"Hi"}}]}`,
``,
`data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
``,
`data: [DONE]`,
``,
}, "\n")
srv := newCompletionServer(t, http.StatusOK, "text/event-stream", sse, nil)
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
out, err := ad.ChatStream(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, nil)
if err != nil {
t.Fatalf("ChatStream error: %v", err)
}
if out.StopReason != "stop" {
t.Errorf("StopReason = %q, want stop", out.StopReason)
}
})
}