mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 22:49:57 +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
817 lines
26 KiB
Go
817 lines
26 KiB
Go
package toolconv
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
|
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
|
|
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
|
"github.com/QuantumNous/new-api/relaykit/types"
|
|
)
|
|
|
|
type HostedResponseItem struct {
|
|
Kind Kind
|
|
NativeType string
|
|
ID string
|
|
CallID string
|
|
Name string
|
|
Status string
|
|
Position int
|
|
Action json.RawMessage
|
|
Results json.RawMessage
|
|
Sources json.RawMessage
|
|
Caller json.RawMessage
|
|
Arguments json.RawMessage
|
|
Output json.RawMessage
|
|
Error json.RawMessage
|
|
ServerName string
|
|
IsError *bool
|
|
ApprovalRequestID string
|
|
Tools json.RawMessage
|
|
ErrorCode string
|
|
Raw json.RawMessage
|
|
}
|
|
|
|
type HostedResponseSet struct {
|
|
Source types.RelayFormat
|
|
Items []HostedResponseItem
|
|
SourceLength int
|
|
RegularPositions []int
|
|
}
|
|
|
|
type positionedResponsesOutput struct {
|
|
position int
|
|
output dto.ResponsesOutput
|
|
}
|
|
|
|
type positionedClaudeBlocks struct {
|
|
position int
|
|
blocks []dto.ClaudeMediaMessage
|
|
}
|
|
|
|
func (s HostedResponseSet) Empty() bool {
|
|
return len(s.Items) == 0
|
|
}
|
|
|
|
// ExtractHostedResponse removes server-executed tool artifacts before a
|
|
// message converter sees them. The artifacts travel beside multi-step routes,
|
|
// just like request tool definitions, so a lossy Chat pivot cannot reclassify
|
|
// or discard them.
|
|
func ExtractHostedResponse(format types.RelayFormat, response any) (any, HostedResponseSet, error) {
|
|
switch format {
|
|
case types.RelayFormatClaude:
|
|
return extractClaudeHostedResponse(response)
|
|
case types.RelayFormatOpenAIResponses:
|
|
return extractOpenAIHostedResponse(response)
|
|
case types.RelayFormatGemini:
|
|
return extractGeminiHostedResponse(response)
|
|
default:
|
|
return response, HostedResponseSet{Source: format}, nil
|
|
}
|
|
}
|
|
|
|
func AttachHostedResponse(format types.RelayFormat, response any, set HostedResponseSet, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
|
|
if set.Empty() {
|
|
return response, nil, nil
|
|
}
|
|
var (
|
|
value any
|
|
diagnostics []types.ConversionDiagnostic
|
|
err error
|
|
)
|
|
switch format {
|
|
case types.RelayFormatOpenAIResponses:
|
|
value, diagnostics, err = attachOpenAIHostedResponse(response, set)
|
|
case types.RelayFormatClaude:
|
|
value, diagnostics, err = attachClaudeHostedResponse(response, set)
|
|
default:
|
|
value = response
|
|
for index, item := range set.Items {
|
|
diagnostics = append(diagnostics, responsePresentationLoss(
|
|
fmt.Sprintf("hosted_tools[%d]", index),
|
|
"hosted_tool_event_omitted",
|
|
fmt.Sprintf("%s cannot represent hosted-tool response %q", format, item.NativeType),
|
|
))
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, diagnostics, err
|
|
}
|
|
for index := range diagnostics {
|
|
diagnostics[index].From = set.Source
|
|
diagnostics[index].To = format
|
|
}
|
|
return value, diagnostics, nil
|
|
}
|
|
|
|
func extractClaudeHostedResponse(response any) (any, HostedResponseSet, error) {
|
|
var source *dto.ClaudeResponse
|
|
switch value := response.(type) {
|
|
case *dto.ClaudeResponse:
|
|
source = value
|
|
case dto.ClaudeResponse:
|
|
source = &value
|
|
default:
|
|
return nil, HostedResponseSet{}, fmt.Errorf("expected Claude response, got %T", response)
|
|
}
|
|
clone := *source
|
|
clone.Content = make([]dto.ClaudeMediaMessage, 0, len(source.Content))
|
|
set := HostedResponseSet{Source: types.RelayFormatClaude, SourceLength: len(source.Content)}
|
|
for position := range source.Content {
|
|
block := source.Content[position]
|
|
blockType := strings.TrimSpace(block.Type)
|
|
switch {
|
|
case blockType == "server_tool_use" || blockType == "mcp_tool_use":
|
|
rawBlock, err := kitutil.Marshal(block)
|
|
if err != nil {
|
|
return nil, set, fmt.Errorf("content[%d]: %w", position, err)
|
|
}
|
|
action, err := kitutil.Marshal(block.Input)
|
|
if err != nil {
|
|
return nil, set, fmt.Errorf("content[%d].input: %w", position, err)
|
|
}
|
|
item := HostedResponseItem{
|
|
Kind: hostedKindFromClaudeCall(blockType, block.Name),
|
|
NativeType: blockType,
|
|
ID: block.Id,
|
|
CallID: block.Id,
|
|
Name: block.Name,
|
|
Status: "in_progress",
|
|
Position: position,
|
|
Action: action,
|
|
Caller: append(json.RawMessage(nil), block.Caller...),
|
|
ServerName: block.ServerName,
|
|
Raw: rawBlock,
|
|
}
|
|
set.Items = append(set.Items, item)
|
|
case isClaudeHostedToolBlock(blockType):
|
|
rawBlock, err := kitutil.Marshal(block)
|
|
if err != nil {
|
|
return nil, set, fmt.Errorf("content[%d]: %w", position, err)
|
|
}
|
|
results, err := kitutil.Marshal(block.Content)
|
|
if err != nil {
|
|
return nil, set, fmt.Errorf("content[%d].content: %w", position, err)
|
|
}
|
|
failed, errorCode := claudeHostedResultFailure(blockType, results, block.IsError, block.ErrorCode)
|
|
status := "completed"
|
|
isError := block.IsError
|
|
if failed {
|
|
status = "failed"
|
|
if isError == nil {
|
|
value := true
|
|
isError = &value
|
|
}
|
|
}
|
|
set.Items = append(set.Items, HostedResponseItem{
|
|
Kind: hostedKindFromClaudeResult(blockType),
|
|
NativeType: blockType,
|
|
ID: block.ToolUseId,
|
|
CallID: block.ToolUseId,
|
|
Status: status,
|
|
Position: position,
|
|
Results: results,
|
|
ErrorCode: errorCode,
|
|
IsError: isError,
|
|
Raw: rawBlock,
|
|
})
|
|
default:
|
|
clone.Content = append(clone.Content, block)
|
|
set.RegularPositions = append(set.RegularPositions, position)
|
|
}
|
|
}
|
|
return &clone, set, nil
|
|
}
|
|
|
|
func extractOpenAIHostedResponse(response any) (any, HostedResponseSet, error) {
|
|
var source *dto.OpenAIResponsesResponse
|
|
switch value := response.(type) {
|
|
case *dto.OpenAIResponsesResponse:
|
|
source = value
|
|
case dto.OpenAIResponsesResponse:
|
|
source = &value
|
|
default:
|
|
return nil, HostedResponseSet{}, fmt.Errorf("expected OpenAI Responses response, got %T", response)
|
|
}
|
|
clone := *source
|
|
clone.Output = make([]dto.ResponsesOutput, 0, len(source.Output))
|
|
set := HostedResponseSet{Source: types.RelayFormatOpenAIResponses, SourceLength: len(source.Output)}
|
|
for position := range source.Output {
|
|
output := source.Output[position]
|
|
if !isResponsesHostedOutput(output.Type) {
|
|
clone.Output = append(clone.Output, output)
|
|
set.RegularPositions = append(set.RegularPositions, position)
|
|
continue
|
|
}
|
|
rawOutput, err := kitutil.Marshal(output)
|
|
if err != nil {
|
|
return nil, set, fmt.Errorf("output[%d]: %w", position, err)
|
|
}
|
|
set.Items = append(set.Items, HostedResponseItem{
|
|
Kind: hostedKindFromResponsesType(output.Type),
|
|
NativeType: output.Type,
|
|
ID: output.ID,
|
|
CallID: output.CallId,
|
|
Name: output.Name,
|
|
Status: output.Status,
|
|
Position: position,
|
|
Action: append(json.RawMessage(nil), output.Action...),
|
|
Results: append(json.RawMessage(nil), output.Results...),
|
|
Sources: append(json.RawMessage(nil), output.Sources...),
|
|
Caller: append(json.RawMessage(nil), output.Caller...),
|
|
Arguments: append(json.RawMessage(nil), output.Arguments...),
|
|
Output: append(json.RawMessage(nil), output.Output...),
|
|
Error: append(json.RawMessage(nil), output.ItemError...),
|
|
ServerName: output.ServerLabel,
|
|
ApprovalRequestID: output.ApprovalRequestID,
|
|
Tools: append(json.RawMessage(nil), output.MCPTools...),
|
|
Raw: rawOutput,
|
|
})
|
|
}
|
|
return &clone, set, nil
|
|
}
|
|
|
|
func extractGeminiHostedResponse(response any) (any, HostedResponseSet, error) {
|
|
var source *dto.GeminiChatResponse
|
|
switch value := response.(type) {
|
|
case *dto.GeminiChatResponse:
|
|
source = value
|
|
case dto.GeminiChatResponse:
|
|
source = &value
|
|
default:
|
|
return nil, HostedResponseSet{}, fmt.Errorf("expected Gemini response, got %T", response)
|
|
}
|
|
queries := geminichat.GroundingWebSearchQueries(source)
|
|
if len(queries) == 0 {
|
|
return source, HostedResponseSet{Source: types.RelayFormatGemini}, nil
|
|
}
|
|
action, err := kitutil.Marshal(map[string]any{
|
|
"type": "search",
|
|
"queries": queries,
|
|
})
|
|
if err != nil {
|
|
return nil, HostedResponseSet{}, fmt.Errorf("marshal Gemini web-search action: %w", err)
|
|
}
|
|
// The Chat pivot emits the answer as the regular Responses output. Place
|
|
// the hosted call after that output, matching the stream bridge which only
|
|
// learns Gemini's queries once grounding metadata arrives near stream end.
|
|
set := HostedResponseSet{
|
|
Source: types.RelayFormatGemini,
|
|
SourceLength: 2,
|
|
RegularPositions: []int{0},
|
|
Items: []HostedResponseItem{{
|
|
Kind: KindWebSearch,
|
|
NativeType: "googleSearch",
|
|
ID: fmt.Sprintf("ws_%s", kitutil.GetUUID()),
|
|
Status: "completed",
|
|
Position: 1,
|
|
Action: action,
|
|
}},
|
|
}
|
|
return source, set, nil
|
|
}
|
|
|
|
func attachOpenAIHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
|
|
target, ok := response.(*dto.OpenAIResponsesResponse)
|
|
if !ok || target == nil {
|
|
return nil, nil, fmt.Errorf("expected OpenAI Responses response, got %T", response)
|
|
}
|
|
var diagnostics []types.ConversionDiagnostic
|
|
hostedOutput := make([]positionedResponsesOutput, 0, len(set.Items))
|
|
convertedByID := make(map[string]int, len(set.Items)*2)
|
|
for index, item := range set.Items {
|
|
if set.Source == types.RelayFormatOpenAIResponses && len(item.Raw) > 0 {
|
|
var output dto.ResponsesOutput
|
|
if err := kitutil.Unmarshal(item.Raw, &output); err != nil {
|
|
return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
|
|
}
|
|
hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
|
|
continue
|
|
}
|
|
outputType := responsesTypeFromHostedKind(item.Kind)
|
|
if outputType == "" {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d]", index),
|
|
"hosted_tool_unrepresentable",
|
|
fmt.Sprintf("OpenAI Responses has no lossless response mapping for %q", item.NativeType),
|
|
))
|
|
continue
|
|
}
|
|
if isClaudeHostedResult(item.NativeType) {
|
|
outputIndex, exists := convertedByID[item.CallID]
|
|
if !exists {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d].tool_use_id", index),
|
|
"hosted_tool_result_orphaned",
|
|
fmt.Sprintf("hosted-tool result references unknown call %q", item.CallID),
|
|
))
|
|
continue
|
|
}
|
|
output := &hostedOutput[outputIndex].output
|
|
output.Status = hostedCompletionStatus(item)
|
|
if item.Kind == KindMCP {
|
|
failed := hostedItemFailed(item)
|
|
var (
|
|
encoded json.RawMessage
|
|
normalized bool
|
|
err error
|
|
)
|
|
if failed {
|
|
encoded, normalized, err = responsesMCPErrorFromClaudeContent(item.Results, item.ErrorCode)
|
|
} else {
|
|
encoded, normalized, err = responsesMCPStringFromClaudeContent(item.Results)
|
|
}
|
|
if err != nil {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d].content", index),
|
|
"mcp_result_unrepresentable",
|
|
err.Error(),
|
|
))
|
|
continue
|
|
}
|
|
if failed {
|
|
output.Output = nil
|
|
output.ItemError = encoded
|
|
} else {
|
|
output.Output = encoded
|
|
output.ItemError = nil
|
|
}
|
|
if normalized {
|
|
diagnostics = append(diagnostics, responsePresentationLoss(
|
|
fmt.Sprintf("hosted_tools[%d].content", index),
|
|
"mcp_text_result_normalized",
|
|
"Claude's single MCP text block was normalized to a Responses output string",
|
|
))
|
|
}
|
|
} else if item.Kind == KindWebSearch && rawJSONPresent(item.Results) {
|
|
diagnostics = append(diagnostics, responsePresentationLoss(
|
|
fmt.Sprintf("hosted_tools[%d].content", index),
|
|
"web_search_result_omitted",
|
|
"Claude web-search result content is provider-private and has no field on an OpenAI Responses web_search_call; completion status and citations remain available",
|
|
))
|
|
}
|
|
continue
|
|
}
|
|
output := dto.ResponsesOutput{
|
|
Type: outputType,
|
|
ID: firstNonEmpty(item.ID, item.CallID),
|
|
Status: hostedCompletionStatus(item),
|
|
}
|
|
switch item.Kind {
|
|
case KindWebSearch:
|
|
action, err := dto.NormalizeResponsesWebSearchAction(item.Action)
|
|
if err != nil {
|
|
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
|
|
}
|
|
output.Action = action
|
|
case KindMCP:
|
|
if strings.TrimSpace(item.Name) == "" || strings.TrimSpace(item.ServerName) == "" {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d]", index),
|
|
"mcp_identity_missing",
|
|
"Claude MCP output requires both name and server_name for Responses mapping",
|
|
))
|
|
continue
|
|
}
|
|
output.CallId = item.CallID
|
|
output.Name = item.Name
|
|
output.Caller = append(json.RawMessage(nil), item.Caller...)
|
|
output.ServerLabel = item.ServerName
|
|
output.ApprovalRequestID = item.ApprovalRequestID
|
|
output.MCPTools = append(json.RawMessage(nil), item.Tools...)
|
|
arguments := item.Arguments
|
|
if len(arguments) == 0 {
|
|
arguments = item.Action
|
|
}
|
|
encodedArguments, argumentErr := responsesMCPArgumentsFromClaude(arguments)
|
|
if argumentErr != nil {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d].input", index),
|
|
"mcp_arguments_unrepresentable",
|
|
argumentErr.Error(),
|
|
))
|
|
continue
|
|
}
|
|
output.Arguments = encodedArguments
|
|
}
|
|
outputIndex := len(hostedOutput)
|
|
for _, key := range []string{item.ID, item.CallID} {
|
|
if key != "" {
|
|
convertedByID[key] = outputIndex
|
|
}
|
|
}
|
|
hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
|
|
if set.Source != types.RelayFormatOpenAIResponses {
|
|
diagnostics = append(diagnostics, responsePresentationLoss(
|
|
fmt.Sprintf("hosted_tools[%d]", index),
|
|
"hosted_tool_result_approximated",
|
|
"hosted-tool execution is preserved, but provider-specific result fields may differ",
|
|
))
|
|
}
|
|
}
|
|
merged, orderingDiagnostics := mergeResponsesOutput(target.Output, hostedOutput, set)
|
|
diagnostics = append(diagnostics, orderingDiagnostics...)
|
|
target.Output = merged
|
|
return target, diagnostics, nil
|
|
}
|
|
|
|
func attachClaudeHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
|
|
target, ok := response.(*dto.ClaudeResponse)
|
|
if !ok || target == nil {
|
|
return nil, nil, fmt.Errorf("expected Claude response, got %T", response)
|
|
}
|
|
var diagnostics []types.ConversionDiagnostic
|
|
hostedContent := make([]positionedClaudeBlocks, 0, len(set.Items))
|
|
for index, item := range set.Items {
|
|
if set.Source == types.RelayFormatClaude && len(item.Raw) > 0 {
|
|
var block dto.ClaudeMediaMessage
|
|
if err := kitutil.Unmarshal(item.Raw, &block); err != nil {
|
|
return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
|
|
}
|
|
hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: []dto.ClaudeMediaMessage{block}})
|
|
continue
|
|
}
|
|
if set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d]", index),
|
|
"web_search_response_unrepresentable",
|
|
"Responses web-search execution cannot reconstruct Claude's required encrypted web_search_tool_result continuation state",
|
|
))
|
|
continue
|
|
}
|
|
name := claudeNameFromHostedKind(item.Kind)
|
|
if item.Kind == KindMCP {
|
|
name = item.Name
|
|
}
|
|
if name == "" {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d]", index),
|
|
"hosted_tool_unrepresentable",
|
|
fmt.Sprintf("Claude has no lossless response mapping for %q", item.NativeType),
|
|
))
|
|
continue
|
|
}
|
|
var input any = map[string]any{}
|
|
if item.Kind == KindWebSearch {
|
|
webInput, inputErr := claudeWebSearchInputFromResponses(item.Action)
|
|
if inputErr != nil {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d].action", index),
|
|
"web_search_action_unrepresentable",
|
|
inputErr.Error(),
|
|
))
|
|
continue
|
|
}
|
|
input = webInput
|
|
} else if item.Kind == KindMCP {
|
|
mcpInput, inputErr := claudeMCPInputFromResponses(item.Arguments)
|
|
if inputErr != nil {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d].arguments", index),
|
|
"mcp_arguments_unrepresentable",
|
|
inputErr.Error(),
|
|
))
|
|
continue
|
|
}
|
|
input = mcpInput
|
|
} else if len(item.Action) > 0 {
|
|
if err := kitutil.Unmarshal(item.Action, &input); err != nil {
|
|
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
|
|
}
|
|
}
|
|
callType := "server_tool_use"
|
|
if item.Kind == KindMCP {
|
|
callType = "mcp_tool_use"
|
|
}
|
|
blocks := []dto.ClaudeMediaMessage{{
|
|
Type: callType,
|
|
Id: item.ID,
|
|
Name: name,
|
|
Input: input,
|
|
Caller: append(json.RawMessage(nil), item.Caller...),
|
|
ServerName: item.ServerName,
|
|
}}
|
|
result := item.Results
|
|
if item.Kind == KindMCP {
|
|
result = item.Output
|
|
if hostedItemFailed(item) && rawJSONPresent(item.Error) {
|
|
result = item.Error
|
|
}
|
|
} else if len(result) == 0 {
|
|
result = item.Sources
|
|
}
|
|
if len(result) > 0 && !(set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch) {
|
|
var content any
|
|
if item.Kind == KindMCP {
|
|
decoded, resultErr := claudeMCPContentFromResponsesString(result)
|
|
if resultErr != nil {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d].output", index),
|
|
"mcp_result_unrepresentable",
|
|
resultErr.Error(),
|
|
))
|
|
continue
|
|
}
|
|
content = decoded
|
|
} else if err := kitutil.Unmarshal(result, &content); err != nil {
|
|
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].results: %w", index, err)
|
|
}
|
|
isError := hostedItemFailed(item)
|
|
blocks = append(blocks, dto.ClaudeMediaMessage{
|
|
Type: claudeResultTypeFromHostedKind(item.Kind),
|
|
ToolUseId: item.ID,
|
|
Content: content,
|
|
IsError: &isError,
|
|
ErrorCode: item.ErrorCode,
|
|
})
|
|
} else if len(result) > 0 && item.Kind == KindWebSearch {
|
|
diagnostics = append(diagnostics, responsePresentationLoss(
|
|
fmt.Sprintf("hosted_tools[%d].results", index),
|
|
"web_search_result_omitted",
|
|
"Responses web-search source metadata cannot reconstruct Claude's encrypted web_search_tool_result",
|
|
))
|
|
} else if item.Kind == KindMCP && (item.Status == "completed" || item.Status == "failed") {
|
|
diagnostics = append(diagnostics, responseSemanticLoss(
|
|
fmt.Sprintf("hosted_tools[%d]", index),
|
|
"mcp_result_missing",
|
|
fmt.Sprintf("Responses MCP output has status %q but no output or error", item.Status),
|
|
))
|
|
}
|
|
hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: blocks})
|
|
if set.Source != types.RelayFormatClaude {
|
|
diagnostics = append(diagnostics, responsePresentationLoss(
|
|
fmt.Sprintf("hosted_tools[%d]", index),
|
|
"hosted_tool_result_approximated",
|
|
"hosted-tool execution is preserved, but provider-specific result fields may differ",
|
|
))
|
|
}
|
|
}
|
|
merged, orderingDiagnostics := mergeClaudeContent(target.Content, hostedContent, set)
|
|
diagnostics = append(diagnostics, orderingDiagnostics...)
|
|
target.Content = merged
|
|
return target, diagnostics, nil
|
|
}
|
|
|
|
func mergeResponsesOutput(regular []dto.ResponsesOutput, hosted []positionedResponsesOutput, set HostedResponseSet) ([]dto.ResponsesOutput, []types.ConversionDiagnostic) {
|
|
if len(hosted) == 0 {
|
|
return regular, nil
|
|
}
|
|
if len(regular) == len(set.RegularPositions) {
|
|
byPosition := make(map[int][]dto.ResponsesOutput, len(hosted))
|
|
for _, item := range hosted {
|
|
byPosition[item.position] = append(byPosition[item.position], item.output)
|
|
}
|
|
regularByPosition := make(map[int]dto.ResponsesOutput, len(regular))
|
|
for index, position := range set.RegularPositions {
|
|
regularByPosition[position] = regular[index]
|
|
}
|
|
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
|
|
for position := 0; position < set.SourceLength; position++ {
|
|
merged = append(merged, byPosition[position]...)
|
|
if output, exists := regularByPosition[position]; exists {
|
|
merged = append(merged, output)
|
|
}
|
|
}
|
|
return merged, nil
|
|
}
|
|
before, after, exact := hostedOutsideRegularRange(hostedPositions(hosted), set.RegularPositions)
|
|
if exact {
|
|
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
|
|
for _, item := range before {
|
|
merged = append(merged, hosted[item].output)
|
|
}
|
|
merged = append(merged, regular...)
|
|
for _, item := range after {
|
|
merged = append(merged, hosted[item].output)
|
|
}
|
|
return merged, nil
|
|
}
|
|
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
|
|
for _, item := range hosted {
|
|
merged = append(merged, item.output)
|
|
}
|
|
merged = append(merged, regular...)
|
|
return merged, []types.ConversionDiagnostic{responseSemanticLoss(
|
|
"output",
|
|
"hosted_tool_order_unrepresentable",
|
|
"hosted-tool items were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
|
|
)}
|
|
}
|
|
|
|
func mergeClaudeContent(regular []dto.ClaudeMediaMessage, hosted []positionedClaudeBlocks, set HostedResponseSet) ([]dto.ClaudeMediaMessage, []types.ConversionDiagnostic) {
|
|
if len(hosted) == 0 {
|
|
return regular, nil
|
|
}
|
|
if len(regular) == len(set.RegularPositions) {
|
|
byPosition := make(map[int][]dto.ClaudeMediaMessage, len(hosted))
|
|
for _, item := range hosted {
|
|
byPosition[item.position] = append(byPosition[item.position], item.blocks...)
|
|
}
|
|
regularByPosition := make(map[int]dto.ClaudeMediaMessage, len(regular))
|
|
for index, position := range set.RegularPositions {
|
|
regularByPosition[position] = regular[index]
|
|
}
|
|
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
|
|
for position := 0; position < set.SourceLength; position++ {
|
|
merged = append(merged, byPosition[position]...)
|
|
if block, exists := regularByPosition[position]; exists {
|
|
merged = append(merged, block)
|
|
}
|
|
}
|
|
return merged, nil
|
|
}
|
|
before, after, exact := hostedOutsideRegularRange(claudeHostedPositions(hosted), set.RegularPositions)
|
|
if exact {
|
|
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
|
|
for _, item := range before {
|
|
merged = append(merged, hosted[item].blocks...)
|
|
}
|
|
merged = append(merged, regular...)
|
|
for _, item := range after {
|
|
merged = append(merged, hosted[item].blocks...)
|
|
}
|
|
return merged, nil
|
|
}
|
|
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
|
|
for _, item := range hosted {
|
|
merged = append(merged, item.blocks...)
|
|
}
|
|
merged = append(merged, regular...)
|
|
return merged, []types.ConversionDiagnostic{responseSemanticLoss(
|
|
"content",
|
|
"hosted_tool_order_unrepresentable",
|
|
"hosted-tool blocks were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
|
|
)}
|
|
}
|
|
|
|
func hostedPositions(items []positionedResponsesOutput) []int {
|
|
positions := make([]int, len(items))
|
|
for index := range items {
|
|
positions[index] = items[index].position
|
|
}
|
|
return positions
|
|
}
|
|
|
|
func claudeHostedPositions(items []positionedClaudeBlocks) []int {
|
|
positions := make([]int, len(items))
|
|
for index := range items {
|
|
positions[index] = items[index].position
|
|
}
|
|
return positions
|
|
}
|
|
|
|
func hostedOutsideRegularRange(hosted []int, regular []int) (before []int, after []int, exact bool) {
|
|
if len(regular) == 0 {
|
|
indices := make([]int, len(hosted))
|
|
for index := range hosted {
|
|
indices[index] = index
|
|
}
|
|
return indices, nil, true
|
|
}
|
|
firstRegular, lastRegular := regular[0], regular[len(regular)-1]
|
|
for index, position := range hosted {
|
|
switch {
|
|
case position < firstRegular:
|
|
before = append(before, index)
|
|
case position > lastRegular:
|
|
after = append(after, index)
|
|
default:
|
|
return nil, nil, false
|
|
}
|
|
}
|
|
return before, after, true
|
|
}
|
|
|
|
func hostedKindFromClaudeCall(blockType string, name string) Kind {
|
|
if blockType == "mcp_tool_use" {
|
|
return KindMCP
|
|
}
|
|
switch strings.TrimSpace(name) {
|
|
case "web_search":
|
|
return KindWebSearch
|
|
case "web_fetch":
|
|
return KindWebFetch
|
|
case "code_execution":
|
|
return KindCodeExecution
|
|
default:
|
|
return KindNative
|
|
}
|
|
}
|
|
|
|
func hostedKindFromClaudeResult(blockType string) Kind {
|
|
switch strings.TrimSuffix(blockType, "_tool_result") {
|
|
case "web_search":
|
|
return KindWebSearch
|
|
case "web_fetch":
|
|
return KindWebFetch
|
|
case "code_execution":
|
|
return KindCodeExecution
|
|
case "mcp":
|
|
return KindMCP
|
|
default:
|
|
return KindNative
|
|
}
|
|
}
|
|
|
|
func hostedKindFromResponsesType(outputType string) Kind {
|
|
normalized := strings.TrimSpace(outputType)
|
|
normalized = strings.TrimSuffix(normalized, "_output")
|
|
normalized = strings.TrimSuffix(normalized, "_call")
|
|
switch normalized {
|
|
case "web_search":
|
|
return KindWebSearch
|
|
case "file_search":
|
|
return KindFileSearch
|
|
case "code_interpreter", "local_shell":
|
|
return KindCodeExecution
|
|
case "computer":
|
|
return KindComputerUse
|
|
case "image_generation":
|
|
return KindImage
|
|
case "mcp":
|
|
return KindMCP
|
|
default:
|
|
return KindNative
|
|
}
|
|
}
|
|
|
|
func responsesTypeFromHostedKind(kind Kind) string {
|
|
switch kind {
|
|
case KindWebSearch:
|
|
return "web_search_call"
|
|
case KindMCP:
|
|
return "mcp_call"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func claudeNameFromHostedKind(kind Kind) string {
|
|
switch kind {
|
|
case KindWebSearch:
|
|
return "web_search"
|
|
case KindWebFetch:
|
|
return "web_fetch"
|
|
case KindCodeExecution:
|
|
return ""
|
|
case KindMCP:
|
|
return "mcp"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func isClaudeHostedResult(nativeType string) bool {
|
|
return nativeType == "mcp_tool_result" || strings.HasSuffix(nativeType, "_tool_result")
|
|
}
|
|
|
|
func hostedCompletionStatus(item HostedResponseItem) string {
|
|
if hostedItemFailed(item) {
|
|
return "failed"
|
|
}
|
|
if item.Status != "" && item.Status != "in_progress" || len(item.Results) > 0 || len(item.Output) > 0 {
|
|
return "completed"
|
|
}
|
|
return "in_progress"
|
|
}
|
|
|
|
func hostedItemFailed(item HostedResponseItem) bool {
|
|
return item.Status == "failed" || item.ErrorCode != "" || rawJSONPresent(item.Error) || item.IsError != nil && *item.IsError
|
|
}
|
|
|
|
func hostedErrorValue(item HostedResponseItem) json.RawMessage {
|
|
if len(item.Error) > 0 {
|
|
return append(json.RawMessage(nil), item.Error...)
|
|
}
|
|
if item.ErrorCode == "" {
|
|
return nil
|
|
}
|
|
encoded, _ := kitutil.Marshal(item.ErrorCode)
|
|
return encoded
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func rawJSONPresent(value json.RawMessage) bool {
|
|
normalized := strings.TrimSpace(string(value))
|
|
return normalized != "" && normalized != "null"
|
|
}
|
|
|
|
func claudeResultTypeFromHostedKind(kind Kind) string {
|
|
name := claudeNameFromHostedKind(kind)
|
|
if name == "mcp" {
|
|
return "mcp_tool_result"
|
|
}
|
|
return name + "_tool_result"
|
|
}
|