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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user