mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 14:41:21 +00:00
* 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
516 lines
19 KiB
Go
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}
|
|
}
|