Files
new-api/relaykit/relayconvert/internal/toolconv/response.go
T
Calcium-Ion 0ed497f066 feat(relay): hosted-tool conversion fidelity, reasoning normalization, and billing usage integrity (#7137)
* feat(relaykit): preserve hosted tools across conversions

- add protocol-neutral hosted-tool DTOs, conversion metadata, and loss policies
- bridge citations, grounding metadata, and hosted-tool stream lifecycles
- document the public conversion behavior and channel policy controls

* refactor(relaykit): normalize reasoning and thinking intent

- centralize provider-neutral reasoning intent, effort, and budget mappings
- parse model suffixes at the host entry boundary while preserving provider-owned tails
- keep adaptive Claude thinking and explicit zero-token compatibility consistent

* fix(billing): preserve authoritative usage across relay hops

- carry native BillingUsage sidecars through direct and streamed protocol bridges
- merge partial and terminal usage monotonically with safe fallback settlement
- retain cache metadata, penultimate usage, and per-call Gemini tool surcharges

* feat(relay): bridge Responses with Claude and Gemini protocols

- add direct request, response, and stream converters across supported relay formats
- expose Claude count_tokens and Chat-to-Responses compatibility endpoints
- carry conversion diagnostics through the host while retaining the curated public goldens

* fix(relay): wire relaykit conversions into host channels

- connect handlers, adaptors, and channel settings to the standalone conversion layer
- keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned
- ignore local audit artifacts and retain focused public regression coverage
2026-09-01 21:53:35 +08:00

516 lines
19 KiB
Go

package toolconv
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
// InspectResponse reports protocol information that the current response
// converters cannot faithfully express. It keeps loss handling centralized so
// direct and multi-step routes behave consistently.
func InspectResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
if from == to {
return nil
}
var diagnostics []types.ConversionDiagnostic
switch value := response.(type) {
case *dto.ClaudeResponse:
diagnostics = inspectClaudeResponse(value, to)
case dto.ClaudeResponse:
diagnostics = inspectClaudeResponse(&value, to)
case *dto.OpenAIResponsesResponse:
diagnostics = inspectOpenAIResponsesResponse(value, to)
case dto.OpenAIResponsesResponse:
diagnostics = inspectOpenAIResponsesResponse(&value, to)
case *dto.ResponsesStreamResponse:
diagnostics = inspectOpenAIResponsesStreamResponse(value)
case dto.ResponsesStreamResponse:
diagnostics = inspectOpenAIResponsesStreamResponse(&value)
case *dto.GeminiChatResponse:
diagnostics = inspectGeminiResponse(value, to)
case dto.GeminiChatResponse:
diagnostics = inspectGeminiResponse(&value, to)
}
for index := range diagnostics {
diagnostics[index].From = from
diagnostics[index].To = to
}
return diagnostics
}
// InspectStreamResponse avoids treating a single Gemini streaming chunk as a
// complete grounding document. Gemini grounding chunk indexes and segment
// offsets are cumulative across the stream; the stateful converter validates
// and resolves them after accumulating prior chunks.
func InspectStreamResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
if from != types.RelayFormatGemini {
return InspectResponse(from, to, response)
}
var value *dto.GeminiChatResponse
switch response := response.(type) {
case *dto.GeminiChatResponse:
value = response
case dto.GeminiChatResponse:
value = &response
default:
return InspectResponse(from, to, response)
}
if value == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range value.Candidates {
metadata := value.Candidates[index].GroundingMetadata
if metadata == nil {
continue
}
if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("candidates[%d].groundingMetadata.webSearchQueries", index),
"web_search_call_unrepresentable",
"Gemini grounding confirms a hosted web search, but the target stream converter cannot produce an OpenAI Responses web_search_call lifecycle",
))
}
if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
continue
}
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("candidates[%d].groundingMetadata", index),
"hosted_tool_metadata_reduced",
"Gemini grounding citations are preserved across stream chunks, but provider-specific search metadata has no target-protocol equivalent",
))
}
for index := range diagnostics {
diagnostics[index].From = from
diagnostics[index].To = to
}
return diagnostics
}
func inspectClaudeResponse(response *dto.ClaudeResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
if to != types.RelayFormatOpenAIResponses && (response.StopReason == "pause_turn" || response.Delta != nil && response.Delta.StopReason != nil && *response.Delta.StopReason == "pause_turn") {
diagnostics = append(diagnostics, responseSemanticLoss(
"stop_reason",
"continuation_state_lost",
"Claude pause_turn requires protocol-native continuation state that the target response cannot preserve",
))
}
for index := range response.Content {
diagnostics = append(diagnostics, inspectClaudeContentBlock(&response.Content[index], fmt.Sprintf("content[%d]", index), to, false)...)
}
if response.ContentBlock != nil {
diagnostics = append(diagnostics, inspectClaudeContentBlock(response.ContentBlock, "content_block", to, true)...)
}
return diagnostics
}
func inspectClaudeContentBlock(block *dto.ClaudeMediaMessage, path string, to types.RelayFormat, stream bool) []types.ConversionDiagnostic {
if block == nil {
return nil
}
blockType := strings.TrimSpace(block.Type)
var diagnostics []types.ConversionDiagnostic
if isClaudeHostedToolBlock(blockType) {
kind := KindNative
if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
kind = hostedKindFromClaudeCall(blockType, block.Name)
} else {
kind = hostedKindFromClaudeResult(blockType)
}
if to != types.RelayFormatOpenAIResponses || kind != KindWebSearch && kind != KindMCP {
diagnostics = append(diagnostics, responseSemanticLoss(
path,
"hosted_tool_unrepresentable",
fmt.Sprintf("%s cannot losslessly represent Claude hosted-tool response block %q", to, blockType),
))
} else if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
if block.Id == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".id",
"hosted_tool_id_missing",
"Claude hosted-tool call has no id for pairing it with its result",
))
}
if kind == KindMCP && (block.Name == "" || block.ServerName == "") {
diagnostics = append(diagnostics, responseSemanticLoss(
path,
"mcp_identity_missing",
"Claude MCP tool use must include both name and server_name for Responses MCP mapping",
))
}
if rawJSONPresent(block.Caller) {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".caller",
"hosted_tool_caller_unrepresentable",
"OpenAI Responses web_search_call and mcp_call items cannot preserve Claude's hosted-tool caller provenance",
))
}
if !stream {
input, err := kitutil.Marshal(block.Input)
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"hosted_tool_input_invalid",
err.Error(),
))
} else if kind == KindMCP {
if _, err := responsesMCPArgumentsFromClaude(input); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"mcp_arguments_unrepresentable",
err.Error(),
))
}
} else if kind == KindWebSearch {
if _, err := dto.NormalizeResponsesWebSearchAction(input); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"web_search_action_unrepresentable",
err.Error(),
))
}
}
}
} else if block.ToolUseId == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".tool_use_id",
"hosted_tool_id_missing",
"Claude hosted-tool result has no tool_use_id for pairing it with its call",
))
}
if kind == KindMCP && blockType == "mcp_tool_result" {
content, err := kitutil.Marshal(block.Content)
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
} else {
failed, errorCode := claudeHostedResultFailure(blockType, content, block.IsError, block.ErrorCode)
var normalized bool
if failed {
_, normalized, err = responsesMCPErrorFromClaudeContent(content, errorCode)
} else {
_, normalized, err = responsesMCPStringFromClaudeContent(content)
}
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
} else if normalized {
diagnostics = append(diagnostics, responsePresentationLoss(
path+".content",
"mcp_text_result_normalized",
"Claude's single MCP text block is normalized to a Responses output string",
))
}
}
}
}
if blockType == "redacted_thinking" && block.Data != "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".data",
"continuation_state_lost",
"Claude encrypted thinking state cannot be represented by the target response",
))
}
return diagnostics
}
func isClaudeHostedToolBlock(blockType string) bool {
if blockType == "server_tool_use" || blockType == "mcp_tool_use" || blockType == "mcp_tool_result" {
return true
}
return strings.HasSuffix(blockType, "_tool_result")
}
func inspectOpenAIResponsesResponse(response *dto.OpenAIResponsesResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range response.Output {
output := &response.Output[index]
if !isResponsesHostedOutput(output.Type) {
continue
}
kind := hostedKindFromResponsesType(output.Type)
if to != types.RelayFormatClaude || kind != KindWebSearch && kind != KindMCP {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("%s cannot losslessly represent OpenAI Responses hosted-tool output %q", to, output.Type),
))
continue
}
if output.ID == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].id", index),
"hosted_tool_id_missing",
"hosted-tool output has no id for pairing the call with its result",
))
}
if kind == KindMCP {
if output.Name == "" || output.ServerLabel == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"mcp_identity_missing",
"Responses MCP output must include both name and server_label for Claude MCP mapping",
))
}
if output.ApprovalRequestID != "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].approval_request_id", index),
"mcp_approval_state_unrepresentable",
"Claude MCP response blocks cannot preserve a Responses approval_request_id",
))
}
if _, err := claudeMCPInputFromResponses(output.Arguments); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].arguments", index),
"mcp_arguments_unrepresentable",
err.Error(),
))
}
if rawJSONPresent(output.Output) && rawJSONPresent(output.ItemError) {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"mcp_result_ambiguous",
"Responses MCP output contains both output and error",
))
}
for _, field := range []struct {
name string
raw json.RawMessage
}{{name: "output", raw: output.Output}, {name: "error", raw: output.ItemError}} {
if !rawJSONPresent(field.raw) {
continue
}
if _, err := claudeMCPContentFromResponsesString(field.raw); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].%s", index, field.name),
"mcp_result_unrepresentable",
err.Error(),
))
}
}
} else if kind == KindWebSearch {
if _, err := claudeWebSearchInputFromResponses(output.Action); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].action", index),
"web_search_action_unrepresentable",
err.Error(),
))
}
}
if output.Status != "" && output.Status != "in_progress" && output.Status != "completed" && output.Status != "failed" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].status", index),
"hosted_tool_status_unrepresentable",
fmt.Sprintf("Claude cannot preserve hosted-tool status %q", output.Status),
))
}
if output.Status == "failed" && !rawJSONPresent(output.ItemError) && !rawJSONPresent(output.Output) && !rawJSONPresent(output.Results) {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].status", index),
"hosted_tool_error_missing",
"failed hosted-tool output has no error or output that Claude can preserve",
))
}
}
return diagnostics
}
func inspectOpenAIResponsesStreamResponse(response *dto.ResponsesStreamResponse) []types.ConversionDiagnostic {
if response == nil {
return nil
}
if response.Item != nil && isResponsesHostedOutput(response.Item.Type) {
return []types.ConversionDiagnostic{responseSemanticLoss(
"item",
"hosted_tool_event_unrepresentable",
fmt.Sprintf("OpenAI Responses hosted-tool output %q has no semantic target-protocol stream mapping", response.Item.Type),
)}
}
eventType := strings.TrimSpace(response.Type)
if strings.Contains(eventType, ".web_search_call.") ||
strings.Contains(eventType, ".file_search_call.") ||
strings.Contains(eventType, ".code_interpreter_call.") ||
strings.Contains(eventType, ".computer_tool_call.") ||
strings.Contains(eventType, ".image_generation_call.") ||
strings.Contains(eventType, ".mcp_call.") {
return []types.ConversionDiagnostic{responseSemanticLoss(
"type",
"hosted_tool_event_unrepresentable",
fmt.Sprintf("OpenAI Responses hosted-tool stream event %q has no semantic target-protocol mapping", eventType),
)}
}
return nil
}
func isResponsesHostedOutput(outputType string) bool {
switch strings.TrimSpace(outputType) {
case "", "message", "reasoning", "function_call", "custom_tool_call":
return false
default:
return true
}
}
func inspectGeminiResponse(response *dto.GeminiChatResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range response.Candidates {
metadata := response.Candidates[index].GroundingMetadata
if metadata == nil {
continue
}
path := fmt.Sprintf("candidates[%d].groundingMetadata", index)
if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".webSearchQueries",
"web_search_call_unrepresentable",
"Gemini grounding confirms a hosted web search, but the target converter cannot produce an OpenAI Responses web_search_call item",
))
}
diagnostics = append(diagnostics, inspectGeminiGroundingCitations(response.Candidates[index].Content, metadata, path)...)
if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
continue
}
diagnostics = append(diagnostics, responsePresentationLoss(
path,
"hosted_tool_metadata_reduced",
"Gemini grounding citations are preserved, but provider-specific search metadata has no target-protocol equivalent",
))
}
return diagnostics
}
type groundingSupportForInspection struct {
Segment struct {
PartIndex *int `json:"partIndex,omitempty"`
StartIndex int `json:"startIndex,omitempty"`
EndIndex int `json:"endIndex,omitempty"`
Text string `json:"text,omitempty"`
} `json:"segment"`
GroundingChunkIndices []int `json:"groundingChunkIndices"`
}
type groundingChunkForInspection struct {
Web *groundingSourceForInspection `json:"web,omitempty"`
RetrievedContext *groundingSourceForInspection `json:"retrievedContext,omitempty"`
}
type groundingSourceForInspection struct {
URI string `json:"uri,omitempty"`
}
func inspectGeminiGroundingCitations(content dto.GeminiChatContent, metadata *dto.GeminiGroundingMetadata, path string) []types.ConversionDiagnostic {
if len(metadata.GroundingSupports) == 0 {
return nil
}
var chunks []groundingChunkForInspection
if len(metadata.GroundingChunks) == 0 || kitutil.Unmarshal(metadata.GroundingChunks, &chunks) != nil {
return []types.ConversionDiagnostic{responseSemanticLoss(
path+".groundingChunks",
"grounding_source_invalid",
"Gemini grounding chunks are missing or cannot be decoded",
)}
}
var supports []groundingSupportForInspection
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return []types.ConversionDiagnostic{responseSemanticLoss(
path+".groundingSupports",
"grounding_citation_invalid",
fmt.Sprintf("Gemini grounding supports cannot be decoded: %v", err),
)}
}
textPartCount := 0
soleTextPart := -1
for index := range content.Parts {
if content.Parts[index].Text == "" || content.Parts[index].Thought {
continue
}
textPartCount++
soleTextPart = index
}
var diagnostics []types.ConversionDiagnostic
for index, support := range supports {
segmentPath := fmt.Sprintf("%s.groundingSupports[%d].segment", path, index)
hasSource := false
for _, chunkIndex := range support.GroundingChunkIndices {
if chunkIndex < 0 || chunkIndex >= len(chunks) {
continue
}
source := chunks[chunkIndex].Web
if source == nil {
source = chunks[chunkIndex].RetrievedContext
}
if source != nil && source.URI != "" {
hasSource = true
break
}
}
if !hasSource {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("%s.groundingSupports[%d].groundingChunkIndices", path, index),
"grounding_source_invalid",
"Gemini grounding support does not reference a valid source URI",
))
continue
}
partIndex := soleTextPart
if support.Segment.PartIndex != nil {
partIndex = *support.Segment.PartIndex
} else if textPartCount != 1 {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".partIndex",
"grounding_part_ambiguous",
"Gemini grounding omitted partIndex while multiple text parts are present, so citation placement is ambiguous",
))
continue
}
if partIndex < 0 || partIndex >= len(content.Parts) || content.Parts[partIndex].Text == "" || content.Parts[partIndex].Thought {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".partIndex",
"grounding_part_invalid",
fmt.Sprintf("Gemini grounding references non-text part %d", partIndex),
))
continue
}
partText := content.Parts[partIndex].Text
start, end := support.Segment.StartIndex, support.Segment.EndIndex
if start < 0 || end <= start || end > len(partText) || !utf8.ValidString(partText[:start]) || !utf8.ValidString(partText[:end]) {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath,
"grounding_offset_invalid",
"Gemini grounding byte offsets do not identify valid UTF-8 boundaries in the referenced part",
))
continue
}
if support.Segment.Text != "" && partText[start:end] != support.Segment.Text {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".text",
"grounding_text_mismatch",
"Gemini grounding segment text does not match the referenced part range",
))
}
}
return diagnostics
}
func responseSemanticLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticError}
}
func responsePresentationLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticWarning}
}