fix(model): 解析 stop_reason + max_tokens 截断重试/非空兜底 — 不再静默空回复
- model.go: Response.StopReason; anthropic wire加stop_reason; openai finish_reason映射(两协议统一,原值透传)
- truncation.go(新,159行): effectiveMaxTokens/retryMaxTokens(×2封顶32768)/isTruncatedEmpty(空正文+无tool_use+stop_reason∈{max_tokens,length})/withEmptyBodyHint/callWithTruncationRetry/callStreamWithTruncationRetry/dropThinking/emitTextSegment
- anthropic.go Chat/ChatStream 走重试+兜底; anthropic_encode.go(新)拆编码(anthropic.go 529→357合规); anthropic_stream message_delta落StopReason
- agent/loop: 把模型层StopReason带到llm.call span的model_stop_reason属性(修掉trace硬编码stop的误导); loop.Result.StopReason语义未动
- 触发仅当截断空正文; tool_use空正文永不触发(native_toolcall_test仍过); 至多1次重试; 兜底回'(思考过长,未能生成回复)'绝不用thinking
- 新增7测试(非流式/流式重试+兜底+tool_use不触发+封顶+openai映射), build/vet/test绿+3平台交叉编译
This commit is contained in:
1 parent
18b2040c95
commit
c7b2cd8ae0
10 files changed
+792
-200
No files matched your search
@@ -46,6 +46,11 @@ type StepResult struct {
|
||||
ToolCalls []ToolCall
|
||||
// StopReason classifies how the step ended (see the StopReason* consts).
|
||||
StopReason string
|
||||
// ModelStopReason is the provider's raw stop/finish reason for the model
|
||||
// call (e.g. Anthropic max_tokens / end_turn / tool_use), so a trace can
|
||||
// distinguish a token-budget truncation from a normal end. Empty when the
|
||||
// step never reached the model (guardrail denial).
|
||||
ModelStopReason string
|
||||
}
|
||||
|
||||
// RunStep executes one step of the agent loop. It assembles a model.Request from
|
||||
@@ -101,9 +106,9 @@ func (a *Agent) RunStep(ctx context.Context, sessionID string, messages []model.
|
||||
}
|
||||
|
||||
if calls, ok := ExtractToolCalls(resp); ok {
|
||||
return &StepResult{Final: false, ToolCalls: calls, StopReason: StopReasonToolCalls}, nil
|
||||
return &StepResult{Final: false, ToolCalls: calls, StopReason: StopReasonToolCalls, ModelStopReason: resp.StopReason}, nil
|
||||
}
|
||||
return &StepResult{Final: true, Text: resp.Content, StopReason: StopReasonStop}, nil
|
||||
return &StepResult{Final: true, Text: resp.Content, StopReason: StopReasonStop, ModelStopReason: resp.StopReason}, nil
|
||||
}
|
||||
|
||||
// ExtractToolCalls decodes tool calls from a model.Response. It prefers the
|
||||
|
||||
@@ -17,6 +17,10 @@ type stepOutcome struct {
|
||||
text string
|
||||
toolCalls []agent.ToolCall
|
||||
stopReason string
|
||||
// modelStopReason is the provider's raw stop/finish reason for the model
|
||||
// call (Anthropic max_tokens / end_turn / tool_use), recorded on the llm_call
|
||||
// span so a truncation is visible even when the step itself ended normally.
|
||||
modelStopReason string
|
||||
}
|
||||
|
||||
// step runs a single model call. When an OnStream callback is configured it
|
||||
@@ -45,6 +49,9 @@ func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Mess
|
||||
}
|
||||
|
||||
l.spanProp(llmSpan, "stop_reason", out.stopReason)
|
||||
if out.modelStopReason != "" {
|
||||
l.spanProp(llmSpan, "model_stop_reason", out.modelStopReason)
|
||||
}
|
||||
l.spanProp(llmSpan, "tool_calls", len(out.toolCalls))
|
||||
l.finishSpan(llmSpan, trace.SpanStatusOK)
|
||||
return out, nil
|
||||
@@ -58,9 +65,10 @@ func (l *Loop) agentStep(ctx context.Context, sessionID string, messages []model
|
||||
return nil, fmt.Errorf("loop: agent step: %w", err)
|
||||
}
|
||||
return &stepOutcome{
|
||||
final: sr.Final,
|
||||
text: sr.Text,
|
||||
toolCalls: sr.ToolCalls,
|
||||
stopReason: sr.StopReason,
|
||||
final: sr.Final,
|
||||
text: sr.Text,
|
||||
toolCalls: sr.ToolCalls,
|
||||
stopReason: sr.StopReason,
|
||||
modelStopReason: sr.ModelStopReason,
|
||||
}, nil
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
|
||||
}
|
||||
|
||||
if calls, ok := decodeToolCalls(resp); ok {
|
||||
return &stepOutcome{final: false, toolCalls: calls, stopReason: agent.StopReasonToolCalls}, nil
|
||||
return &stepOutcome{final: false, toolCalls: calls, stopReason: agent.StopReasonToolCalls, modelStopReason: resp.StopReason}, nil
|
||||
}
|
||||
return &stepOutcome{final: true, text: resp.Content, stopReason: agent.StopReasonStop}, nil
|
||||
return &stepOutcome{final: true, text: resp.Content, stopReason: agent.StopReasonStop, modelStopReason: resp.StopReason}, nil
|
||||
}
|
||||
@@ -55,10 +55,11 @@ type anthropicTool struct {
|
||||
|
||||
// anthropicResponse is the JSON body returned by /v1/messages.
|
||||
type anthropicResponse struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Content []anthropicBlock `json:"content"`
|
||||
Usage *anthropicUsage `json:"usage,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Content []anthropicBlock `json:"content"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
Usage *anthropicUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// anthropicBlock is a single content block in a response. For tool_use blocks the
|
||||
@@ -132,7 +133,11 @@ func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicC
|
||||
// Name returns the adapter identifier.
|
||||
func (a *AnthropicChatAdapter) Name() string { return a.name }
|
||||
|
||||
// Chat performs a non-streaming completion.
|
||||
// Chat performs a non-streaming completion. A response cut off by the token
|
||||
// budget with no visible output (stop_reason max_tokens and neither text nor a
|
||||
// tool_use block) is retried once with a doubled budget and, if that still
|
||||
// yields nothing, replaced by a non-empty placeholder so the caller never sees a
|
||||
// silent empty reply.
|
||||
func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
|
||||
start := time.Now()
|
||||
if a.baseURL == "" {
|
||||
@@ -145,6 +150,22 @@ func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Optio
|
||||
return Response{}, err
|
||||
}
|
||||
req.Model = model
|
||||
out, err := callWithTruncationRetry(req.MaxTokens, anthropicDefaultMaxTokens,
|
||||
func(limit int) (Response, error) {
|
||||
attempt := req
|
||||
attempt.MaxTokens = limit
|
||||
return a.chatOnce(ctx, attempt)
|
||||
})
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
a.record(req, start, out.TotalTokens)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// chatOnce performs a single non-streaming /v1/messages call and normalizes the
|
||||
// wire body into a Response.
|
||||
func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Response, error) {
|
||||
body, err := a.buildRequest(req, false)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
@@ -165,16 +186,16 @@ func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Optio
|
||||
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
|
||||
return Response{}, fmt.Errorf("model: %w: decode response: %v", ErrUpstream, err)
|
||||
}
|
||||
out := responseFromAnthropicWire(&wire)
|
||||
a.record(req, start, out.TotalTokens)
|
||||
return out, nil
|
||||
return responseFromAnthropicWire(&wire), nil
|
||||
}
|
||||
|
||||
// ChatStream streams structured events via onEvent and returns the aggregated
|
||||
// response with usage when the provider reports it. Text blocks are delivered as
|
||||
// text_start/text_delta/text_end events; tool_use blocks are delivered as
|
||||
// tool_call_start/tool_call_delta/tool_call_end so the frontend can render each
|
||||
// segment independently.
|
||||
// segment independently. A budget-truncated empty stream is retried once with a
|
||||
// doubled budget (thinking from the retry is suppressed to avoid a duplicate
|
||||
// block) and, if still empty, closed with a non-empty placeholder text segment.
|
||||
func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
|
||||
start := time.Now()
|
||||
if a.baseURL == "" {
|
||||
@@ -185,6 +206,23 @@ func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEv
|
||||
return Response{}, err
|
||||
}
|
||||
req.Model = model
|
||||
out, err := callStreamWithTruncationRetry(req.MaxTokens, anthropicDefaultMaxTokens,
|
||||
func(limit int, ev func(StreamEvent)) (Response, error) {
|
||||
attempt := req
|
||||
attempt.MaxTokens = limit
|
||||
return a.streamOnce(ctx, attempt, ev)
|
||||
}, onEvent)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
out.Model = model
|
||||
a.record(req, start, out.TotalTokens)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// streamOnce performs a single streaming /v1/messages call and aggregates its
|
||||
// SSE events into a Response.
|
||||
func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
|
||||
body, err := a.buildRequest(req, true)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
@@ -201,13 +239,7 @@ func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEv
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Response{}, a.upstreamError(resp)
|
||||
}
|
||||
out, err := scanAnthropicSSE(resp.Body, onEvent)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
out.Model = model
|
||||
a.record(req, start, out.TotalTokens)
|
||||
return out, nil
|
||||
return scanAnthropicSSE(resp.Body, onEvent)
|
||||
}
|
||||
|
||||
// resolveModel returns the effective model name.
|
||||
@@ -231,10 +263,7 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenLimit := req.MaxTokens
|
||||
if tokenLimit == 0 {
|
||||
tokenLimit = anthropicDefaultMaxTokens
|
||||
}
|
||||
tokenLimit := effectiveMaxTokens(req.MaxTokens, anthropicDefaultMaxTokens)
|
||||
wire := anthropicRequest{
|
||||
Model: req.Model,
|
||||
System: system,
|
||||
@@ -287,178 +316,6 @@ func (a *AnthropicChatAdapter) record(req Request, start time.Time, tokens int)
|
||||
})
|
||||
}
|
||||
|
||||
// buildAnthropicMessages splits a normalized message list into a top-level system
|
||||
// string and the remaining messages in Anthropic's content-block form. All
|
||||
// RoleSystem messages are concatenated into a single system prompt (Anthropic
|
||||
// carries it at the request root, not in messages); messages with no content
|
||||
// parts are dropped because Anthropic rejects empty content.
|
||||
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
|
||||
var sys strings.Builder
|
||||
out := make([]anthropicMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == RoleSystem {
|
||||
if t := messageText(m); t != "" {
|
||||
if sys.Len() > 0 {
|
||||
sys.WriteString("\n")
|
||||
}
|
||||
sys.WriteString(t)
|
||||
}
|
||||
continue
|
||||
}
|
||||
blocks, err := encodeAnthropicMessage(m)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, anthropicMessage{Role: anthropicRole(m.Role), Content: blocks})
|
||||
}
|
||||
return sys.String(), out, nil
|
||||
}
|
||||
|
||||
// messageText concatenates the text-bearing parts of a message (system prompts
|
||||
// and tool-result bodies). Tool-result parts carry their body in Text, so a
|
||||
// RoleTool turn's content serializes correctly for OpenAI's tool message.
|
||||
func messageText(m Message) string {
|
||||
var b strings.Builder
|
||||
for _, part := range m.Content {
|
||||
if part.Type == ContentTypeText || part.Type == ContentTypeToolResult {
|
||||
b.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// anthropicRole maps a normalized Role to the Anthropic role vocabulary. system
|
||||
// messages are extracted to the top-level system field and never reach here.
|
||||
func anthropicRole(r Role) string {
|
||||
switch r {
|
||||
case RoleAssistant:
|
||||
return "assistant"
|
||||
default:
|
||||
// user, tool and any future roles all land on the user turn; tool_result
|
||||
// blocks (M2) belong to the user role in Anthropic.
|
||||
return "user"
|
||||
}
|
||||
}
|
||||
|
||||
// encodeAnthropicMessage turns a normalized message into Anthropic content
|
||||
// blocks. A RoleTool turn is ALWAYS rendered as native tool_result blocks (never
|
||||
// a user plain-text turn), because Anthropic requires a tool result to arrive as
|
||||
// a tool_result block carrying the tool_use_id and the is_error flag.
|
||||
func encodeAnthropicMessage(m Message) ([]any, error) {
|
||||
if m.Role == RoleTool {
|
||||
return encodeAnthropicToolResult(m)
|
||||
}
|
||||
return encodeAnthropicContent(m.Content)
|
||||
}
|
||||
|
||||
// encodeAnthropicToolResult renders a RoleTool turn as tool_result blocks. Each
|
||||
// ContentTypeToolResult part becomes one block; legacy text parts are wrapped as
|
||||
// tool_result blocks too so the turn never degrades into a plain user message.
|
||||
// The tool_use_id falls back to the message-level ToolCallID when a part does not
|
||||
// carry its own.
|
||||
func encodeAnthropicToolResult(m Message) ([]any, error) {
|
||||
blocks := make([]any, 0, len(m.Content))
|
||||
for _, p := range m.Content {
|
||||
switch p.Type {
|
||||
case ContentTypeToolResult, ContentTypeText:
|
||||
id := p.ToolCallID
|
||||
if id == "" {
|
||||
id = m.ToolCallID
|
||||
}
|
||||
blocks = append(blocks, AnthropicToolResult(id, p.Text, p.IsError))
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
blocks = append(blocks, AnthropicToolResult(m.ToolCallID, "", false))
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// encodeAnthropicContent turns normalized content parts into Anthropic content
|
||||
// blocks. Text / image / tool_use (assistant) parts are handled here; tool_result
|
||||
// parts are rendered at the message level by encodeAnthropicToolResult.
|
||||
func encodeAnthropicContent(parts []Content) ([]any, error) {
|
||||
blocks := make([]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
switch p.Type {
|
||||
case ContentTypeText:
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": p.Text})
|
||||
case ContentTypeImage:
|
||||
mediaType, data, err := parseAnthropicImageData(p.ImageURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "image",
|
||||
"source": map[string]any{
|
||||
"type": "base64",
|
||||
"media_type": mediaType,
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
case ContentTypeToolCall:
|
||||
args := p.ToolArgs
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": p.ToolCallID,
|
||||
"name": p.ToolCallName,
|
||||
"input": args,
|
||||
})
|
||||
case ContentTypeToolResult:
|
||||
blocks = append(blocks, AnthropicToolResult(p.ToolCallID, p.Text, p.IsError))
|
||||
default:
|
||||
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
|
||||
}
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// parseAnthropicImageData extracts the media type and base64 payload from a data:
|
||||
// URI. Anthropic requires a base64 image source and cannot reference a remote
|
||||
// URL, so any other URI form is rejected as a bad request.
|
||||
func parseAnthropicImageData(uri string) (string, string, error) {
|
||||
if !strings.HasPrefix(uri, anthropicDataPrefix) {
|
||||
return "", "", fmt.Errorf("model: %w: anthropic image requires a data: URI, got %q", ErrBadRequest, uri)
|
||||
}
|
||||
rest := strings.TrimPrefix(uri, anthropicDataPrefix)
|
||||
comma := strings.Index(rest, ",")
|
||||
if comma < 0 {
|
||||
return "", "", fmt.Errorf("model: %w: malformed data: URI %q", ErrBadRequest, uri)
|
||||
}
|
||||
meta := rest[:comma]
|
||||
data := rest[comma+1:]
|
||||
mediaType := meta
|
||||
if idx := strings.Index(meta, ";"); idx >= 0 {
|
||||
mediaType = meta[:idx]
|
||||
}
|
||||
if mediaType == "" {
|
||||
return "", "", fmt.Errorf("model: %w: data: URI missing media type in %q", ErrBadRequest, uri)
|
||||
}
|
||||
return mediaType, data, nil
|
||||
}
|
||||
|
||||
// buildAnthropicTools converts ToolSpec schemas into Anthropic tools.
|
||||
func buildAnthropicTools(tools []ToolSpec) []anthropicTool {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]anthropicTool, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, anthropicTool{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.Parameters,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// responseFromAnthropicWire converts a non-streaming /v1/messages response into a
|
||||
// normalized Response. Each text block contributes to the assistant content; each
|
||||
// tool_use block becomes a ToolCall whose Args is taken directly from the decoded
|
||||
@@ -478,6 +335,7 @@ func responseFromAnthropicWire(wire *anthropicResponse) Response {
|
||||
out.Content = b.String()
|
||||
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: out.Content}}}
|
||||
out.ToolCalls = toolCalls
|
||||
out.StopReason = wire.StopReason
|
||||
if u := wire.Usage; u != nil {
|
||||
out.PromptTokens = u.InputTokens
|
||||
out.CompletionTokens = u.OutputTokens
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// buildAnthropicMessages splits a normalized message list into a top-level system
|
||||
// string and the remaining messages in Anthropic's content-block form. All
|
||||
// RoleSystem messages are concatenated into a single system prompt (Anthropic
|
||||
// carries it at the request root, not in messages); messages with no content
|
||||
// parts are dropped because Anthropic rejects empty content.
|
||||
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
|
||||
var sys strings.Builder
|
||||
out := make([]anthropicMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == RoleSystem {
|
||||
if t := messageText(m); t != "" {
|
||||
if sys.Len() > 0 {
|
||||
sys.WriteString("\n")
|
||||
}
|
||||
sys.WriteString(t)
|
||||
}
|
||||
continue
|
||||
}
|
||||
blocks, err := encodeAnthropicMessage(m)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, anthropicMessage{Role: anthropicRole(m.Role), Content: blocks})
|
||||
}
|
||||
return sys.String(), out, nil
|
||||
}
|
||||
|
||||
// messageText concatenates the text-bearing parts of a message (system prompts
|
||||
// and tool-result bodies). Tool-result parts carry their body in Text, so a
|
||||
// RoleTool turn's content serializes correctly for OpenAI's tool message.
|
||||
func messageText(m Message) string {
|
||||
var b strings.Builder
|
||||
for _, part := range m.Content {
|
||||
if part.Type == ContentTypeText || part.Type == ContentTypeToolResult {
|
||||
b.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// anthropicRole maps a normalized Role to the Anthropic role vocabulary. system
|
||||
// messages are extracted to the top-level system field and never reach here.
|
||||
func anthropicRole(r Role) string {
|
||||
switch r {
|
||||
case RoleAssistant:
|
||||
return "assistant"
|
||||
default:
|
||||
// user, tool and any future roles all land on the user turn; tool_result
|
||||
// blocks (M2) belong to the user role in Anthropic.
|
||||
return "user"
|
||||
}
|
||||
}
|
||||
|
||||
// encodeAnthropicMessage turns a normalized message into Anthropic content
|
||||
// blocks. A RoleTool turn is ALWAYS rendered as native tool_result blocks (never
|
||||
// a user plain-text turn), because Anthropic requires a tool result to arrive as
|
||||
// a tool_result block carrying the tool_use_id and the is_error flag.
|
||||
func encodeAnthropicMessage(m Message) ([]any, error) {
|
||||
if m.Role == RoleTool {
|
||||
return encodeAnthropicToolResult(m)
|
||||
}
|
||||
return encodeAnthropicContent(m.Content)
|
||||
}
|
||||
|
||||
// encodeAnthropicToolResult renders a RoleTool turn as tool_result blocks. Each
|
||||
// ContentTypeToolResult part becomes one block; legacy text parts are wrapped as
|
||||
// tool_result blocks too so the turn never degrades into a plain user message.
|
||||
// The tool_use_id falls back to the message-level ToolCallID when a part does not
|
||||
// carry its own.
|
||||
func encodeAnthropicToolResult(m Message) ([]any, error) {
|
||||
blocks := make([]any, 0, len(m.Content))
|
||||
for _, p := range m.Content {
|
||||
switch p.Type {
|
||||
case ContentTypeToolResult, ContentTypeText:
|
||||
id := p.ToolCallID
|
||||
if id == "" {
|
||||
id = m.ToolCallID
|
||||
}
|
||||
blocks = append(blocks, AnthropicToolResult(id, p.Text, p.IsError))
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
blocks = append(blocks, AnthropicToolResult(m.ToolCallID, "", false))
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// encodeAnthropicContent turns normalized content parts into Anthropic content
|
||||
// blocks. Text / image / tool_use (assistant) parts are handled here; tool_result
|
||||
// parts are rendered at the message level by encodeAnthropicToolResult.
|
||||
func encodeAnthropicContent(parts []Content) ([]any, error) {
|
||||
blocks := make([]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
switch p.Type {
|
||||
case ContentTypeText:
|
||||
blocks = append(blocks, map[string]any{"type": "text", "text": p.Text})
|
||||
case ContentTypeImage:
|
||||
mediaType, data, err := parseAnthropicImageData(p.ImageURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "image",
|
||||
"source": map[string]any{
|
||||
"type": "base64",
|
||||
"media_type": mediaType,
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
case ContentTypeToolCall:
|
||||
args := p.ToolArgs
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": p.ToolCallID,
|
||||
"name": p.ToolCallName,
|
||||
"input": args,
|
||||
})
|
||||
case ContentTypeToolResult:
|
||||
blocks = append(blocks, AnthropicToolResult(p.ToolCallID, p.Text, p.IsError))
|
||||
default:
|
||||
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
|
||||
}
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// parseAnthropicImageData extracts the media type and base64 payload from a data:
|
||||
// URI. Anthropic requires a base64 image source and cannot reference a remote
|
||||
// URL, so any other URI form is rejected as a bad request.
|
||||
func parseAnthropicImageData(uri string) (string, string, error) {
|
||||
if !strings.HasPrefix(uri, anthropicDataPrefix) {
|
||||
return "", "", fmt.Errorf("model: %w: anthropic image requires a data: URI, got %q", ErrBadRequest, uri)
|
||||
}
|
||||
rest := strings.TrimPrefix(uri, anthropicDataPrefix)
|
||||
comma := strings.Index(rest, ",")
|
||||
if comma < 0 {
|
||||
return "", "", fmt.Errorf("model: %w: malformed data: URI %q", ErrBadRequest, uri)
|
||||
}
|
||||
meta := rest[:comma]
|
||||
data := rest[comma+1:]
|
||||
mediaType := meta
|
||||
if idx := strings.Index(meta, ";"); idx >= 0 {
|
||||
mediaType = meta[:idx]
|
||||
}
|
||||
if mediaType == "" {
|
||||
return "", "", fmt.Errorf("model: %w: data: URI missing media type in %q", ErrBadRequest, uri)
|
||||
}
|
||||
return mediaType, data, nil
|
||||
}
|
||||
|
||||
// buildAnthropicTools converts ToolSpec schemas into Anthropic tools.
|
||||
func buildAnthropicTools(tools []ToolSpec) []anthropicTool {
|
||||
if len(tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]anthropicTool, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, anthropicTool{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.Parameters,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -28,11 +28,14 @@ type anthropicStartBlock struct {
|
||||
}
|
||||
|
||||
// anthropicDelta is the incremental payload inside a content_block_delta event.
|
||||
// The message_delta event reuses the same field name for its terminal
|
||||
// stop_reason (end_turn / max_tokens / tool_use), so it is decoded here too.
|
||||
type anthropicDelta struct {
|
||||
Type string `json:"type"` // text_delta | input_json_delta | thinking_delta
|
||||
Text string `json:"text,omitempty"`
|
||||
PartialJSON string `json:"partial_json,omitempty"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
}
|
||||
|
||||
// anthropicStreamError is the payload of an error event.
|
||||
@@ -201,6 +204,9 @@ func applyAnthropicEvent(
|
||||
}
|
||||
}
|
||||
case "message_delta":
|
||||
if evt.Delta != nil {
|
||||
agg.StopReason = evt.Delta.StopReason
|
||||
}
|
||||
if evt.Usage != nil {
|
||||
agg.CompletionTokens = evt.Usage.OutputTokens
|
||||
}
|
||||
|
||||
@@ -182,6 +182,11 @@ type Response struct {
|
||||
// ToolCalls carries any tool_use blocks the assistant emitted. Empty when the
|
||||
// response is pure text.
|
||||
ToolCalls []ToolCall
|
||||
// StopReason is the provider's raw stop/finish reason (Anthropic stop_reason:
|
||||
// end_turn / max_tokens / tool_use; OpenAI finish_reason: stop / length /
|
||||
// tool_calls), so callers can distinguish a token-budget truncation from a
|
||||
// normal end. Empty when the provider reports none.
|
||||
StopReason string
|
||||
// PromptTokens, CompletionTokens and TotalTokens report provider usage when
|
||||
// available; zero when the provider does not return it.
|
||||
PromptTokens int
|
||||
|
||||
@@ -488,6 +488,7 @@ func responseFromWire(wire *openAIChatResponse) Response {
|
||||
text := wire.Choices[0].Message.Content
|
||||
out.Content = text
|
||||
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: text}}}
|
||||
out.StopReason = wire.Choices[0].FinishReason
|
||||
for _, tc := range wire.Choices[0].Message.ToolCalls {
|
||||
args := map[string]any{}
|
||||
if tc.Function.Arguments != "" {
|
||||
@@ -546,6 +547,9 @@ func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
|
||||
agg.CompletionTokens = u.CompletionTokens
|
||||
agg.TotalTokens = u.TotalTokens
|
||||
}
|
||||
if len(chunk.Choices) > 0 && chunk.Choices[0].FinishReason != "" {
|
||||
agg.StopReason = chunk.Choices[0].FinishReason
|
||||
}
|
||||
if len(chunk.Choices) == 0 || chunk.Choices[0].Delta == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package model
|
||||
|
||||
// Provider stop/finish reasons that mean the token budget cut the generation
|
||||
// off: Anthropic reports stop_reason "max_tokens", OpenAI-compatible providers
|
||||
// report finish_reason "length". Both are normalized into Response.StopReason
|
||||
// verbatim so a trace keeps the provider's own vocabulary.
|
||||
const (
|
||||
stopReasonMaxTokens = "max_tokens"
|
||||
stopReasonLength = "length"
|
||||
)
|
||||
|
||||
// Truncation-recovery bounds. At most one retry with a doubled budget is
|
||||
// allowed; the cap keeps a pathological budget from growing without bound. The
|
||||
// hint is the non-empty placeholder returned when even the retry produced no
|
||||
// visible text, so a truncated turn never surfaces as a silent empty reply.
|
||||
// It never carries thinking: reasoning is not the answer.
|
||||
const (
|
||||
truncationRetryCap = 32768
|
||||
truncationEmptyHint = "(思考过长,未能生成回复)"
|
||||
)
|
||||
|
||||
// effectiveMaxTokens resolves the budget an attempt actually sends: the explicit
|
||||
// Request.MaxTokens, else the adapter default. def may be zero when the protocol
|
||||
// has no adapter default, in which case the provider default applies.
|
||||
func effectiveMaxTokens(n, def int) int {
|
||||
if n > 0 {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// retryMaxTokens doubles the effective budget for the one-shot truncation retry,
|
||||
// capped at truncationRetryCap. It returns the effective budget unchanged when
|
||||
// there is nothing to double (both n and def are zero, i.e. the provider default
|
||||
// applies), letting the caller skip a retry that would send an identical request.
|
||||
func retryMaxTokens(n, def int) int {
|
||||
eff := effectiveMaxTokens(n, def)
|
||||
if eff <= 0 {
|
||||
return 0
|
||||
}
|
||||
if bumped := eff * 2; bumped < truncationRetryCap {
|
||||
return bumped
|
||||
}
|
||||
return truncationRetryCap
|
||||
}
|
||||
|
||||
// isTruncatedStopReason reports whether a stop reason means the token budget cut
|
||||
// the generation off.
|
||||
func isTruncatedStopReason(reason string) bool {
|
||||
return reason == stopReasonMaxTokens || reason == stopReasonLength
|
||||
}
|
||||
|
||||
// isEmptyBody reports whether a response carries neither visible text nor tool
|
||||
// calls. A tool-only round is NOT empty: its empty body is normal and belongs to
|
||||
// the tool loop.
|
||||
func isEmptyBody(r Response) bool {
|
||||
return r.Content == "" && len(r.ToolCalls) == 0
|
||||
}
|
||||
|
||||
// isTruncatedEmpty reports whether an attempt was cut off by the token budget
|
||||
// before producing any visible output (no text, no tool call). Only this shape
|
||||
// triggers the truncation retry/fallback; a normal tool_use round, whose body is
|
||||
// legitimately empty, does not.
|
||||
func isTruncatedEmpty(r Response) bool {
|
||||
return isEmptyBody(r) && isTruncatedStopReason(r.StopReason)
|
||||
}
|
||||
|
||||
// withEmptyBodyHint fills a non-empty placeholder body when a response has
|
||||
// neither text nor tool calls, so a truncated turn never surfaces as a silent
|
||||
// empty reply. A response that already carries text or tool calls is returned
|
||||
// unchanged.
|
||||
func withEmptyBodyHint(r Response) Response {
|
||||
if !isEmptyBody(r) {
|
||||
return r
|
||||
}
|
||||
r.Content = truncationEmptyHint
|
||||
r.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: r.Content}}}
|
||||
return r
|
||||
}
|
||||
|
||||
// callWithTruncationRetry runs a non-streaming attempt, retrying once with a
|
||||
// doubled token budget when the first attempt was cut off with no visible
|
||||
// output, then returning a non-empty placeholder when the retry still produced
|
||||
// nothing. attempt receives the budget to send. An upstream error on the first
|
||||
// attempt is returned unchanged (no retry); a failed retry falls back to the
|
||||
// placeholder rather than surfacing an empty reply.
|
||||
func callWithTruncationRetry(maxTokens, def int, attempt func(limit int) (Response, error)) (Response, error) {
|
||||
out, err := attempt(maxTokens)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if !isTruncatedEmpty(out) {
|
||||
return out, nil
|
||||
}
|
||||
if bumped := retryMaxTokens(maxTokens, def); bumped > effectiveMaxTokens(maxTokens, def) {
|
||||
if second, retryErr := attempt(bumped); retryErr == nil {
|
||||
out = second
|
||||
}
|
||||
}
|
||||
return withEmptyBodyHint(out), nil
|
||||
}
|
||||
|
||||
// callStreamWithTruncationRetry is the streaming counterpart of
|
||||
// callWithTruncationRetry. attempt receives the budget and the event callback
|
||||
// for that attempt. The retry drops thinking events (the first attempt already
|
||||
// streamed its reasoning, so re-streaming it would duplicate the block) while
|
||||
// forwarding text/tool events live; when even the retry yields no visible
|
||||
// output, a non-empty placeholder is emitted as a text segment and returned as
|
||||
// the response body, so the stream never ends on a silent truncation.
|
||||
func callStreamWithTruncationRetry(
|
||||
maxTokens, def int,
|
||||
attempt func(limit int, onEvent func(StreamEvent)) (Response, error),
|
||||
onEvent func(StreamEvent),
|
||||
) (Response, error) {
|
||||
out, err := attempt(maxTokens, onEvent)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if !isTruncatedEmpty(out) {
|
||||
return out, nil
|
||||
}
|
||||
if bumped := retryMaxTokens(maxTokens, def); bumped > effectiveMaxTokens(maxTokens, def) {
|
||||
if second, retryErr := attempt(bumped, dropThinking(onEvent)); retryErr == nil {
|
||||
out = second
|
||||
}
|
||||
}
|
||||
if isEmptyBody(out) {
|
||||
out = withEmptyBodyHint(out)
|
||||
emitTextSegment(onEvent, out.Content)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// dropThinking wraps onEvent so a retry attempt does not re-emit thinking
|
||||
// segments. Text and tool_call events pass through unchanged.
|
||||
func dropThinking(onEvent func(StreamEvent)) func(StreamEvent) {
|
||||
if onEvent == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ev StreamEvent) {
|
||||
switch ev.Kind {
|
||||
case StreamKindThinkingStart, StreamKindThinkingDelta, StreamKindThinkingEnd:
|
||||
return
|
||||
default:
|
||||
onEvent(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emitTextSegment emits a single-delta text segment so a synthesized placeholder
|
||||
// reaches streaming consumers that render only message.* events.
|
||||
func emitTextSegment(onEvent func(StreamEvent), text string) {
|
||||
if onEvent == nil || text == "" {
|
||||
return
|
||||
}
|
||||
onEvent(StreamEvent{Kind: StreamKindTextStart})
|
||||
onEvent(StreamEvent{Kind: StreamKindTextDelta, Delta: text})
|
||||
onEvent(StreamEvent{Kind: StreamKindTextEnd})
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
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 answers successive /v1/messages calls with a fixed script
|
||||
// of bodies (the last body repeats) and records every decoded request body, so a
|
||||
// test can assert both the retry count and the retried 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 is a thinking-only, budget-truncated reply: the
|
||||
// reasoning consumed the whole max_tokens budget, so no text block was emitted.
|
||||
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 is the streaming form of a thinking-only
|
||||
// truncation: a thinking block, then 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 proves the
|
||||
// primary recovery: a max_tokens truncation with only a thinking block retries
|
||||
// exactly once with the token budget doubled and surfaces the retry's text.
|
||||
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 proves the
|
||||
// safety net: when the retry is truncated too, the caller gets a non-empty
|
||||
// placeholder instead of a silent empty reply, and the thinking never leaks.
|
||||
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 proves a normal end_turn answer and a
|
||||
// native tool_use round (whose body is legitimately empty) are left untouched:
|
||||
// no retry, no fallback hint.
|
||||
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 proves the
|
||||
// streaming retry: the retry's thinking is suppressed (no duplicate block) while
|
||||
// its text streams through, and the doubled budget is sent.
|
||||
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 proves the
|
||||
// streaming safety net: when the retry is truncated too, a non-empty placeholder
|
||||
// is emitted as a text segment (and returned as the body) so the frontend is not
|
||||
// left with a silent truncated turn.
|
||||
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 covers the budget arithmetic that bounds the retry.
|
||||
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 asserts the OpenAI adapter fills the unified
|
||||
// Response.StopReason from finish_reason on both the non-streaming and streaming
|
||||
// paths, so a "length" truncation is identifiable across protocols.
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user