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}) }