Files
new-api/relaykit/relayconvert/internal/toolconv/response_artifacts.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

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