mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 14:41:21 +00:00
* test(relayconvert): add golden snapshot matrix and relaykit boundary guard Phase 0 of the relaykit extraction plan: pin byte-level output of every registered (from,to) request/response/stream conversion route, and forbid kit-bound packages from growing host-only imports. * wip(relayconvert): drop gin.Context from converter signatures; add convmeta draft Phase 1 in progress: relayconvert now takes context.Context; host media resolver adapts gin.Context back at the service boundary. * refactor(relayconvert): decouple converters from RelayInfo, gin, and settings Phase 1 of the relaykit extraction plan: - converters now depend on convmeta.Meta (implemented by RelayInfo) instead of *relaycommon.RelayInfo; ClaudeConvertInfo and the format guesser move to convmeta with aliases left behind - host settings reach converters via a convmeta.Options snapshot built in RelayInfo.ConvOptions; no more model_setting/reasoning global reads inside the conversion layer - effort-suffix helpers move to service/relayconvert/reasoning (old package forwards); chat-to-responses upgrade policy moves to service (host routing logic, not conversion) - golden conversion matrix unchanged * test(relayconvert): tighten boundary — kit packages now free of gin/setting imports * refactor(dto): drop gin and logger dependencies Phase 2 (part 1): dto.Request.IsStream now takes *http.Request instead of *gin.Context (Gemini's impl reads query/path off the std request); dto's three logger calls become common.SysError. Boundary test allowlist is now empty — kit-bound packages import no gin/setting/logger/model. * refactor(kit): extract dependency-free kitutil; dto/types/relayconvert stop importing common Phase 2 of the relaykit extraction plan: - new service/relayconvert/kitutil holds the pure helpers the kit needs (JSON wrappers, pointer/string/uuid/timestamp utils, MaskSensitiveInfo, pluggable LogInfo/LogError hooks, Debug flag) - dto, types, and all relayconvert packages now use kitutil; their only remaining internal deps are dto/types/constant - common keeps every original symbol (MaskSensitiveInfo delegates to kitutil) so host code is untouched; main.go routes kit logging into common.SysLog/SysError and mirrors DebugEnabled - golden conversion matrix unchanged * refactor(kit): move EndpointType/FinishReason to types; OpenRouter dialect via Options Kit packages (dto/types/relayconvert/reasonmap) no longer import constant: - EndpointType and finish-reason values live in types; constant re-exports - the OpenRouter special-case in claude->openai request conversion reads Options.OpenRouterDialect, set by the host from the channel type; InitChannelMeta invalidates the cached snapshot on channel switch * refactor: extract relaykit submodule (dto/types/relayconvert/reasonmap) Phase 3 of the relaykit extraction plan: - new go module github.com/QuantumNous/new-api/relaykit containing dto (minus task family), types, relayconvert (with convmeta/kitutil/reasoning), and reasonmap; host consumes it via require + replace, go.work for dev - task-family dto (task/suno/midjourney/video) stays in the host dto package; dual-consumer host files alias it as taskdto - relaykit builds and tests standalone (GOWORK=off): no host imports, no gin, no DB, no settings - golden conversion matrix unchanged * build(docker): copy relaykit/go.mod before go mod download The local-replace submodule's go.mod must exist inside the build context for the main module graph to resolve. * fix: address relaykit extraction regressions * fix: address relaykit review regressions * docs: document Meta nil receiver contract * fix(relaykit): fail OpenAI→Claude conversion without max_tokens; reject negative default_max_tokens The Claude Messages API requires max_tokens (omitting it is a 400 "Field required"), but with a nil Options.Claude.DefaultMaxTokens hook the converters silently emitted a request the upstream is guaranteed to reject. Both OpenAI Chat and Responses → Claude conversions now return sharedclaude.ErrMissingMaxTokens when no path (client value, default hook, thinking-adapter floor) supplied one. Unreachable in the host, which always configures the hook. Host side, claude.default_max_tokens now rejects negative values at the option API before persisting — they would wrap into huge unsigned values during conversion. Zero stays allowed: the current API treats max_tokens: 0 as cache pre-warming. * fix: make Gemini safety settings read path race-free
554 lines
15 KiB
Go
554 lines
15 KiB
Go
package oairesponses
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
|
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
|
)
|
|
|
|
const (
|
|
responsesInputTypeFunctionCall = "function_call"
|
|
responsesInputTypeFunctionCallOutput = "function_call_output"
|
|
responsesInputTypeCustomToolCall = "custom_tool_call"
|
|
responsesInputTypeCustomToolOutput = "custom_tool_call_output"
|
|
)
|
|
|
|
const (
|
|
ResponsesInputTypeFunctionCall = responsesInputTypeFunctionCall
|
|
ResponsesInputTypeFunctionCallOutput = responsesInputTypeFunctionCallOutput
|
|
ResponsesInputTypeCustomToolCall = responsesInputTypeCustomToolCall
|
|
ResponsesInputTypeCustomToolOutput = responsesInputTypeCustomToolOutput
|
|
)
|
|
|
|
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
|
|
if req == nil {
|
|
return nil, errors.New("request is nil")
|
|
}
|
|
if req.Model == "" {
|
|
return nil, errors.New("model is required")
|
|
}
|
|
if err := validateResponsesRequestChatUnsupportedFields(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
messages, err := responsesRequestMessagesToChat(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tools, err := responsesRequestToolsToChat(req.Tools)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
toolChoice, err := responsesRequestToolChoiceToChat(req.ToolChoice)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
responseFormat, err := responsesRequestTextToChatResponseFormat(req.Text)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := &dto.GeneralOpenAIRequest{
|
|
Model: req.Model,
|
|
Messages: messages,
|
|
Stream: req.Stream,
|
|
StreamOptions: req.StreamOptions,
|
|
MaxCompletionTokens: req.MaxOutputTokens,
|
|
Temperature: req.Temperature,
|
|
TopP: req.TopP,
|
|
TopLogProbs: req.TopLogProbs,
|
|
ResponseFormat: responseFormat,
|
|
Tools: tools,
|
|
ToolChoice: toolChoice,
|
|
User: req.User,
|
|
Store: req.Store,
|
|
Metadata: req.Metadata,
|
|
SafetyIdentifier: req.SafetyIdentifier,
|
|
PromptCacheRetention: req.PromptCacheRetention,
|
|
EnableThinking: req.EnableThinking,
|
|
}
|
|
|
|
if req.Reasoning != nil {
|
|
out.ReasoningEffort = req.Reasoning.Effort
|
|
}
|
|
if req.ServiceTier != "" {
|
|
out.ServiceTier, _ = kitutil.Marshal(req.ServiceTier)
|
|
}
|
|
if len(req.ParallelToolCalls) > 0 && kitutil.GetJsonType(req.ParallelToolCalls) == "boolean" {
|
|
var parallelToolCalls bool
|
|
if err := kitutil.Unmarshal(req.ParallelToolCalls, ¶llelToolCalls); err == nil {
|
|
out.ParallelTooCalls = ¶llelToolCalls
|
|
}
|
|
}
|
|
if len(req.PromptCacheKey) > 0 && kitutil.GetJsonType(req.PromptCacheKey) == "string" {
|
|
var promptCacheKey string
|
|
if err := kitutil.Unmarshal(req.PromptCacheKey, &promptCacheKey); err == nil {
|
|
out.PromptCacheKey = promptCacheKey
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
|
|
unsupported := make([]string, 0, 4)
|
|
if rawJSONPresent(req.Conversation) {
|
|
unsupported = append(unsupported, "conversation")
|
|
}
|
|
if strings.TrimSpace(req.PreviousResponseID) != "" {
|
|
unsupported = append(unsupported, "previous_response_id")
|
|
}
|
|
if rawJSONPresent(req.Prompt) {
|
|
unsupported = append(unsupported, "prompt")
|
|
}
|
|
if rawJSONPresent(req.ContextManagement) {
|
|
unsupported = append(unsupported, "context_management")
|
|
}
|
|
if len(unsupported) > 0 {
|
|
return fmt.Errorf("responses to chat conversion does not support stateful fields: %s", strings.Join(unsupported, ", "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
|
|
return validateResponsesRequestChatUnsupportedFields(req)
|
|
}
|
|
|
|
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
|
|
messages := make([]dto.Message, 0)
|
|
if rawJSONPresent(req.Instructions) {
|
|
instructions, err := responsesJSONString(req.Instructions)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid instructions: %w", err)
|
|
}
|
|
if strings.TrimSpace(instructions) != "" {
|
|
messages = append(messages, dto.Message{Role: "system", Content: instructions})
|
|
}
|
|
}
|
|
|
|
if !rawJSONPresent(req.Input) {
|
|
return messages, nil
|
|
}
|
|
|
|
switch kitutil.GetJsonType(req.Input) {
|
|
case "string":
|
|
input, err := responsesJSONString(req.Input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid input string: %w", err)
|
|
}
|
|
messages = append(messages, dto.Message{Role: "user", Content: input})
|
|
return messages, nil
|
|
case "array":
|
|
var items []map[string]any
|
|
if err := kitutil.Unmarshal(req.Input, &items); err != nil {
|
|
return nil, fmt.Errorf("invalid input array: %w", err)
|
|
}
|
|
for _, item := range items {
|
|
nextMessages, err := responsesInputItemToChatMessages(item, messages)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
messages = nextMessages
|
|
}
|
|
return messages, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported responses input type %q", kitutil.GetJsonType(req.Input))
|
|
}
|
|
}
|
|
|
|
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
|
|
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
|
switch itemType {
|
|
case responsesInputTypeFunctionCall:
|
|
toolCall, err := responsesFunctionCallItemToChatToolCall(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return appendToolCallToLastAssistant(messages, toolCall), nil
|
|
case responsesInputTypeCustomToolCall:
|
|
toolCall, err := responsesCustomToolCallItemToChatToolCall(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return appendToolCallToLastAssistant(messages, toolCall), nil
|
|
case responsesInputTypeFunctionCallOutput:
|
|
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
|
|
content := responseToolOutputToChatContent(item["output"])
|
|
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
|
|
}
|
|
|
|
role := strings.TrimSpace(kitutil.Interface2String(item["role"]))
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
content, err := responsesInputContentToChatContent(item["content"])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(messages, dto.Message{Role: role, Content: content}), nil
|
|
}
|
|
|
|
func responsesInputContentToChatContent(content any) (any, error) {
|
|
if content == nil {
|
|
return "", nil
|
|
}
|
|
|
|
switch value := content.(type) {
|
|
case string:
|
|
return value, nil
|
|
case []any:
|
|
return responsesContentPartsToChatContent(value)
|
|
case []map[string]any:
|
|
parts := make([]any, 0, len(value))
|
|
for _, part := range value {
|
|
parts = append(parts, part)
|
|
}
|
|
return responsesContentPartsToChatContent(parts)
|
|
default:
|
|
return content, nil
|
|
}
|
|
}
|
|
|
|
func responsesContentPartsToChatContent(parts []any) (any, error) {
|
|
chatParts := make([]any, 0, len(parts))
|
|
var textOnly strings.Builder
|
|
onlyText := true
|
|
|
|
for _, rawPart := range parts {
|
|
part, ok := rawPart.(map[string]any)
|
|
if !ok {
|
|
onlyText = false
|
|
chatParts = append(chatParts, rawPart)
|
|
continue
|
|
}
|
|
|
|
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
|
|
switch partType {
|
|
case "input_text", "output_text", "text":
|
|
text := kitutil.Interface2String(part["text"])
|
|
textOnly.WriteString(text)
|
|
chatParts = append(chatParts, map[string]any{
|
|
"type": dto.ContentTypeText,
|
|
"text": text,
|
|
})
|
|
case "input_image":
|
|
onlyText = false
|
|
chatParts = append(chatParts, map[string]any{
|
|
"type": dto.ContentTypeImageURL,
|
|
"image_url": responsesImagePartToChatImageURL(part),
|
|
})
|
|
case "input_file":
|
|
onlyText = false
|
|
chatParts = append(chatParts, map[string]any{
|
|
"type": dto.ContentTypeFile,
|
|
"file": responsesFilePartToChatFile(part),
|
|
})
|
|
case "input_audio":
|
|
onlyText = false
|
|
chatParts = append(chatParts, map[string]any{
|
|
"type": dto.ContentTypeInputAudio,
|
|
"input_audio": responsesPartPayload(part, "input_audio"),
|
|
})
|
|
case "input_video":
|
|
onlyText = false
|
|
chatParts = append(chatParts, map[string]any{
|
|
"type": dto.ContentTypeVideoUrl,
|
|
"video_url": responsesVideoPartToChatVideoURL(part),
|
|
})
|
|
default:
|
|
onlyText = false
|
|
chatParts = append(chatParts, part)
|
|
}
|
|
}
|
|
|
|
if onlyText {
|
|
return textOnly.String(), nil
|
|
}
|
|
return chatParts, nil
|
|
}
|
|
|
|
func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
|
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
|
|
if name == "" {
|
|
return dto.ToolCallRequest{}, errors.New("function_call item is missing name")
|
|
}
|
|
return dto.ToolCallRequest{
|
|
ID: responsesCallID(item),
|
|
Type: "function",
|
|
Function: dto.FunctionRequest{
|
|
Name: name,
|
|
Arguments: responsesArgumentsString(item["arguments"]),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func responsesCustomToolCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
|
raw, err := kitutil.Marshal(item)
|
|
if err != nil {
|
|
return dto.ToolCallRequest{}, err
|
|
}
|
|
return dto.ToolCallRequest{
|
|
ID: responsesCallID(item),
|
|
Type: dto.CustomType,
|
|
Custom: raw,
|
|
Function: dto.FunctionRequest{
|
|
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
|
|
Arguments: responsesArgumentsString(item["input"]),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func appendToolCallToLastAssistant(messages []dto.Message, toolCall dto.ToolCallRequest) []dto.Message {
|
|
if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" {
|
|
messages = append(messages, dto.Message{Role: "assistant"})
|
|
}
|
|
|
|
idx := len(messages) - 1
|
|
toolCalls := messages[idx].ParseToolCalls()
|
|
toolCalls = append(toolCalls, toolCall)
|
|
toolCallsRaw, _ := kitutil.Marshal(toolCalls)
|
|
messages[idx].ToolCalls = toolCallsRaw
|
|
return messages
|
|
}
|
|
|
|
func responsesRequestToolsToChat(raw json.RawMessage) ([]dto.ToolCallRequest, error) {
|
|
if !rawJSONPresent(raw) {
|
|
return nil, nil
|
|
}
|
|
|
|
var tools []map[string]any
|
|
if err := kitutil.Unmarshal(raw, &tools); err != nil {
|
|
return nil, fmt.Errorf("invalid tools: %w", err)
|
|
}
|
|
|
|
out := make([]dto.ToolCallRequest, 0, len(tools))
|
|
for _, tool := range tools {
|
|
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
|
|
if toolType == "function" {
|
|
out = append(out, dto.ToolCallRequest{
|
|
Type: "function",
|
|
Function: dto.FunctionRequest{
|
|
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
|
|
Description: kitutil.Interface2String(tool["description"]),
|
|
Parameters: tool["parameters"],
|
|
},
|
|
})
|
|
continue
|
|
}
|
|
|
|
rawTool, err := kitutil.Marshal(tool)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, dto.ToolCallRequest{
|
|
Type: toolType,
|
|
Custom: rawTool,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
|
if !rawJSONPresent(raw) {
|
|
return nil, nil
|
|
}
|
|
if kitutil.GetJsonType(raw) == "string" {
|
|
var choice string
|
|
if err := kitutil.Unmarshal(raw, &choice); err != nil {
|
|
return nil, fmt.Errorf("invalid tool_choice: %w", err)
|
|
}
|
|
return choice, nil
|
|
}
|
|
|
|
var choice map[string]any
|
|
if err := kitutil.Unmarshal(raw, &choice); err != nil {
|
|
return nil, fmt.Errorf("invalid tool_choice: %w", err)
|
|
}
|
|
if kitutil.Interface2String(choice["type"]) == "function" {
|
|
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
|
|
if name != "" {
|
|
return map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": name,
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
return choice, nil
|
|
}
|
|
|
|
func RequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
|
return responsesRequestToolChoiceToChat(raw)
|
|
}
|
|
|
|
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
|
if !rawJSONPresent(raw) {
|
|
return nil, nil
|
|
}
|
|
|
|
var textConfig map[string]any
|
|
if err := kitutil.Unmarshal(raw, &textConfig); err != nil {
|
|
return nil, fmt.Errorf("invalid text config: %w", err)
|
|
}
|
|
format, ok := textConfig["format"].(map[string]any)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
|
|
formatType := strings.TrimSpace(kitutil.Interface2String(format["type"]))
|
|
if formatType == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
out := &dto.ResponseFormat{Type: formatType}
|
|
if formatType == "json_schema" {
|
|
schemaRaw, err := kitutil.Marshal(format)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.JsonSchema = schemaRaw
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
|
return responsesRequestTextToChatResponseFormat(raw)
|
|
}
|
|
|
|
func responsesImagePartToChatImageURL(part map[string]any) any {
|
|
if imageURL, ok := part["image_url"]; ok {
|
|
return imageURL
|
|
}
|
|
imageURL := map[string]any{}
|
|
for _, key := range []string{"url", "file_id", "detail"} {
|
|
if value, ok := part[key]; ok {
|
|
imageURL[key] = value
|
|
}
|
|
}
|
|
if len(imageURL) == 0 {
|
|
return part
|
|
}
|
|
return imageURL
|
|
}
|
|
|
|
func responsesFilePartToChatFile(part map[string]any) any {
|
|
if file, ok := part["file"]; ok {
|
|
return file
|
|
}
|
|
file := map[string]any{}
|
|
for _, key := range []string{"file_id", "file_data", "filename", "file_url"} {
|
|
if value, ok := part[key]; ok {
|
|
file[key] = value
|
|
}
|
|
}
|
|
if len(file) == 0 {
|
|
return part
|
|
}
|
|
return file
|
|
}
|
|
|
|
func responsesVideoPartToChatVideoURL(part map[string]any) any {
|
|
if videoURL, ok := part["video_url"]; ok {
|
|
if videoURLMap, ok := videoURL.(map[string]any); ok {
|
|
if url := kitutil.Interface2String(videoURLMap["url"]); url != "" {
|
|
return url
|
|
}
|
|
}
|
|
return videoURL
|
|
}
|
|
if url := kitutil.Interface2String(part["url"]); url != "" {
|
|
return url
|
|
}
|
|
return responsesPartPayload(part, "video_url")
|
|
}
|
|
|
|
func responsesPartPayload(part map[string]any, key string) any {
|
|
if value, ok := part[key]; ok {
|
|
return value
|
|
}
|
|
payload := make(map[string]any, len(part))
|
|
for k, value := range part {
|
|
if k == "type" {
|
|
continue
|
|
}
|
|
payload[k] = value
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func responsesCallID(item map[string]any) string {
|
|
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
|
|
if callID != "" {
|
|
return callID
|
|
}
|
|
return strings.TrimSpace(kitutil.Interface2String(item["id"]))
|
|
}
|
|
|
|
func CallID(item map[string]any) string {
|
|
return responsesCallID(item)
|
|
}
|
|
|
|
func responsesArgumentsString(value any) string {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return v
|
|
default:
|
|
raw, err := kitutil.Marshal(v)
|
|
if err != nil {
|
|
return kitutil.Interface2String(v)
|
|
}
|
|
return string(raw)
|
|
}
|
|
}
|
|
|
|
func responseToolOutputToChatContent(value any) any {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return v
|
|
default:
|
|
raw, err := kitutil.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
return string(raw)
|
|
}
|
|
}
|
|
|
|
func responsesJSONString(raw json.RawMessage) (string, error) {
|
|
if kitutil.GetJsonType(raw) != "string" {
|
|
return string(raw), nil
|
|
}
|
|
var value string
|
|
if err := kitutil.Unmarshal(raw, &value); err != nil {
|
|
return "", err
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func rawJSONPresent(raw json.RawMessage) bool {
|
|
if len(raw) == 0 {
|
|
return false
|
|
}
|
|
return kitutil.GetJsonType(raw) != "null"
|
|
}
|
|
|
|
func JSONString(raw json.RawMessage) (string, error) {
|
|
return responsesJSONString(raw)
|
|
}
|
|
|
|
func RawJSONPresent(raw json.RawMessage) bool {
|
|
return rawJSONPresent(raw)
|
|
}
|