mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 08:55:28 +08:00
286 lines
8.5 KiB
Go
286 lines
8.5 KiB
Go
package assistantturn
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"ds2api/internal/httpapi/openai/shared"
|
|
"ds2api/internal/promptcompat"
|
|
"ds2api/internal/sse"
|
|
"ds2api/internal/toolcall"
|
|
"ds2api/internal/util"
|
|
)
|
|
|
|
type StopReason string
|
|
|
|
const (
|
|
StopReasonStop StopReason = "stop"
|
|
StopReasonToolCalls StopReason = "tool_calls"
|
|
StopReasonContentFilter StopReason = "content_filter"
|
|
StopReasonError StopReason = "error"
|
|
)
|
|
|
|
type Usage struct {
|
|
InputTokens int
|
|
OutputTokens int
|
|
ReasoningTokens int
|
|
TotalTokens int
|
|
}
|
|
|
|
type OutputError struct {
|
|
Status int
|
|
Message string
|
|
Code string
|
|
}
|
|
|
|
type Turn struct {
|
|
Model string
|
|
Prompt string
|
|
RawText string
|
|
RawThinking string
|
|
DetectionThinking string
|
|
Text string
|
|
Thinking string
|
|
ToolCalls []toolcall.ParsedToolCall
|
|
ParsedToolCalls toolcall.ToolCallParseResult
|
|
CitationLinks map[int]string
|
|
ContentFilter bool
|
|
ResponseMessageID int
|
|
StopReason StopReason
|
|
Usage Usage
|
|
Error *OutputError
|
|
}
|
|
|
|
type FinalizeOptions struct {
|
|
AlreadyEmittedToolCalls bool
|
|
}
|
|
|
|
type FinalOutcome struct {
|
|
FinishReason string
|
|
Error *OutputError
|
|
Usage Usage
|
|
HasToolCalls bool
|
|
HasVisibleText bool
|
|
HasVisibleOutput bool
|
|
ShouldFail bool
|
|
}
|
|
|
|
type BuildOptions struct {
|
|
Model string
|
|
Prompt string
|
|
RefFileTokens int
|
|
SearchEnabled bool
|
|
StripReferenceMarkers bool
|
|
ToolNames []string
|
|
ToolsRaw any
|
|
ToolChoice promptcompat.ToolChoicePolicy
|
|
}
|
|
|
|
type StreamSnapshot struct {
|
|
RawText string
|
|
VisibleText string
|
|
RawThinking string
|
|
VisibleThinking string
|
|
DetectionThinking string
|
|
ContentFilter bool
|
|
CitationLinks map[int]string
|
|
ResponseMessageID int
|
|
AlreadyEmittedCalls bool
|
|
AdditionalToolCalls []toolcall.ParsedToolCall
|
|
AlreadyEmittedToolRaw bool
|
|
}
|
|
|
|
func BuildTurnFromCollected(result sse.CollectResult, opts BuildOptions) Turn {
|
|
thinking := shared.CleanVisibleOutput(result.Thinking, opts.StripReferenceMarkers)
|
|
text := shared.CleanVisibleOutput(result.Text, opts.StripReferenceMarkers)
|
|
if opts.SearchEnabled {
|
|
text = shared.ReplaceCitationMarkersWithLinks(text, result.CitationLinks)
|
|
}
|
|
|
|
parsed := shared.DetectAssistantToolCalls(result.Text, text, result.Thinking, result.ToolDetectionThinking, opts.ToolNames)
|
|
calls := toolcall.NormalizeParsedToolCallsForSchemas(parsed.Calls, opts.ToolsRaw)
|
|
parsed.Calls = calls
|
|
|
|
stopReason := StopReasonStop
|
|
if result.ContentFilter {
|
|
stopReason = StopReasonContentFilter
|
|
}
|
|
if len(calls) > 0 {
|
|
stopReason = StopReasonToolCalls
|
|
}
|
|
|
|
turn := Turn{
|
|
Model: opts.Model,
|
|
Prompt: opts.Prompt,
|
|
RawText: result.Text,
|
|
RawThinking: result.Thinking,
|
|
DetectionThinking: result.ToolDetectionThinking,
|
|
Text: text,
|
|
Thinking: thinking,
|
|
ToolCalls: calls,
|
|
ParsedToolCalls: parsed,
|
|
CitationLinks: result.CitationLinks,
|
|
ContentFilter: result.ContentFilter,
|
|
ResponseMessageID: result.ResponseMessageID,
|
|
StopReason: stopReason,
|
|
}
|
|
turn.Usage = BuildUsage(opts.Model, opts.Prompt, thinking, text, opts.RefFileTokens)
|
|
turn.Error = ValidateTurn(turn, opts.ToolChoice)
|
|
if turn.Error != nil {
|
|
turn.StopReason = StopReasonError
|
|
}
|
|
return turn
|
|
}
|
|
|
|
func BuildTurnFromStreamSnapshot(snapshot StreamSnapshot, opts BuildOptions) Turn {
|
|
thinking := shared.CleanVisibleOutput(snapshot.VisibleThinking, opts.StripReferenceMarkers)
|
|
text := shared.CleanVisibleOutput(snapshot.VisibleText, opts.StripReferenceMarkers)
|
|
if opts.SearchEnabled {
|
|
text = shared.ReplaceCitationMarkersWithLinks(text, snapshot.CitationLinks)
|
|
}
|
|
|
|
parsed := shared.DetectAssistantToolCalls(snapshot.RawText, text, snapshot.RawThinking, snapshot.DetectionThinking, opts.ToolNames)
|
|
calls := parsed.Calls
|
|
if len(calls) == 0 && len(snapshot.AdditionalToolCalls) > 0 {
|
|
calls = snapshot.AdditionalToolCalls
|
|
}
|
|
calls = toolcall.NormalizeParsedToolCallsForSchemas(calls, opts.ToolsRaw)
|
|
parsed.Calls = calls
|
|
|
|
stopReason := StopReasonStop
|
|
if snapshot.ContentFilter {
|
|
stopReason = StopReasonContentFilter
|
|
}
|
|
if len(calls) > 0 || snapshot.AlreadyEmittedCalls || snapshot.AlreadyEmittedToolRaw {
|
|
stopReason = StopReasonToolCalls
|
|
}
|
|
|
|
turn := Turn{
|
|
Model: opts.Model,
|
|
Prompt: opts.Prompt,
|
|
RawText: snapshot.RawText,
|
|
RawThinking: snapshot.RawThinking,
|
|
DetectionThinking: snapshot.DetectionThinking,
|
|
Text: text,
|
|
Thinking: thinking,
|
|
ToolCalls: calls,
|
|
ParsedToolCalls: parsed,
|
|
CitationLinks: snapshot.CitationLinks,
|
|
ContentFilter: snapshot.ContentFilter,
|
|
ResponseMessageID: snapshot.ResponseMessageID,
|
|
StopReason: stopReason,
|
|
}
|
|
turn.Usage = BuildUsage(opts.Model, opts.Prompt, thinking, text, opts.RefFileTokens)
|
|
if !snapshot.AlreadyEmittedCalls && !snapshot.AlreadyEmittedToolRaw {
|
|
turn.Error = ValidateTurn(turn, opts.ToolChoice)
|
|
}
|
|
if turn.Error != nil && len(calls) == 0 {
|
|
turn.StopReason = StopReasonError
|
|
}
|
|
return turn
|
|
}
|
|
|
|
func BuildUsage(model, prompt, thinking, text string, refFileTokens int) Usage {
|
|
inputTokens := util.CountPromptTokens(prompt, model) + refFileTokens
|
|
reasoningTokens := util.CountOutputTokens(thinking, model)
|
|
outputTokens := reasoningTokens + util.CountOutputTokens(text, model)
|
|
return Usage{
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
ReasoningTokens: reasoningTokens,
|
|
TotalTokens: inputTokens + outputTokens,
|
|
}
|
|
}
|
|
|
|
func ValidateTurn(turn Turn, policy promptcompat.ToolChoicePolicy) *OutputError {
|
|
if policy.IsRequired() && len(turn.ToolCalls) == 0 {
|
|
return &OutputError{
|
|
Status: http.StatusUnprocessableEntity,
|
|
Message: "tool_choice requires at least one valid tool call.",
|
|
Code: "tool_choice_violation",
|
|
}
|
|
}
|
|
if len(turn.ToolCalls) > 0 {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(turn.Text) != "" {
|
|
return nil
|
|
}
|
|
status, message, code := UpstreamEmptyOutputDetail(turn.ContentFilter, turn.Text, turn.Thinking)
|
|
return &OutputError{Status: status, Message: message, Code: code}
|
|
}
|
|
|
|
func UpstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int, string, string) {
|
|
_ = text
|
|
if contentFilter {
|
|
return http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter"
|
|
}
|
|
if strings.TrimSpace(thinking) != "" {
|
|
return http.StatusTooManyRequests, "Upstream account hit a rate limit and returned reasoning without visible output.", "upstream_empty_output"
|
|
}
|
|
return http.StatusTooManyRequests, "Upstream account hit a rate limit and returned empty output.", "upstream_empty_output"
|
|
}
|
|
|
|
// ShouldRetryEmptyOutput returns true when the turn produced no visible text
|
|
// and has no tool calls or content filter. This includes thinking-only responses,
|
|
// where the model returned reasoning but no answer — a retry may yield text.
|
|
func ShouldRetryEmptyOutput(turn Turn, attempts, maxAttempts int) bool {
|
|
return attempts < maxAttempts &&
|
|
!turn.ContentFilter &&
|
|
len(turn.ToolCalls) == 0 &&
|
|
strings.TrimSpace(turn.Text) == ""
|
|
}
|
|
|
|
func FinalizeTurn(turn Turn, opts FinalizeOptions) FinalOutcome {
|
|
hasToolCalls := len(turn.ToolCalls) > 0 || opts.AlreadyEmittedToolCalls
|
|
hasVisibleText := strings.TrimSpace(turn.Text) != ""
|
|
hasVisibleThinking := strings.TrimSpace(turn.Thinking) != ""
|
|
err := turn.Error
|
|
if hasToolCalls {
|
|
err = nil
|
|
}
|
|
finishReason := FinishReason(turn)
|
|
if hasToolCalls {
|
|
finishReason = "tool_calls"
|
|
}
|
|
return FinalOutcome{
|
|
FinishReason: finishReason,
|
|
Error: err,
|
|
Usage: turn.Usage,
|
|
HasToolCalls: hasToolCalls,
|
|
HasVisibleText: hasVisibleText,
|
|
HasVisibleOutput: hasVisibleText || hasVisibleThinking || hasToolCalls,
|
|
ShouldFail: err != nil,
|
|
}
|
|
}
|
|
|
|
func OpenAIChatUsage(turn Turn) map[string]any {
|
|
return map[string]any{
|
|
"prompt_tokens": turn.Usage.InputTokens,
|
|
"completion_tokens": turn.Usage.OutputTokens,
|
|
"total_tokens": turn.Usage.TotalTokens,
|
|
"completion_tokens_details": map[string]any{
|
|
"reasoning_tokens": turn.Usage.ReasoningTokens,
|
|
},
|
|
}
|
|
}
|
|
|
|
func OpenAIResponsesUsage(turn Turn) map[string]any {
|
|
return map[string]any{
|
|
"input_tokens": turn.Usage.InputTokens,
|
|
"output_tokens": turn.Usage.OutputTokens,
|
|
"total_tokens": turn.Usage.TotalTokens,
|
|
}
|
|
}
|
|
|
|
func FinishReason(turn Turn) string {
|
|
switch turn.StopReason {
|
|
case StopReasonToolCalls:
|
|
return "tool_calls"
|
|
case StopReasonContentFilter:
|
|
return "content_filter"
|
|
default:
|
|
return "stop"
|
|
}
|
|
}
|