665 lines
18 KiB
Go
665 lines
18 KiB
Go
package openai_compat
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
runtime "iop/packages/go/agentruntime"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
func (a *Adapter) applyHeaders(req *http.Request, jsonBody bool) {
|
|
for k, v := range a.headers {
|
|
if jsonBody && http.CanonicalHeaderKey(k) == "Content-Type" {
|
|
continue
|
|
}
|
|
req.Header.Set(k, v)
|
|
}
|
|
if jsonBody {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
}
|
|
|
|
// prepareRequest is the single operation/auth/model preparation path used by
|
|
// Models, normalized Chat, Responses, and raw provider tunnels.
|
|
func (a *Adapter) prepareRequest(ctx context.Context, method, operation, legacyPath string, body []byte, requestHeaders map[string]string, jsonBody bool) (*http.Request, error) {
|
|
urlStr, err := a.operationURL(operation, legacyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve operation URL: %w", err)
|
|
}
|
|
preparedBody, err := a.mapProfileBodyModel(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, urlStr, bytes.NewReader(preparedBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build request: %w", err)
|
|
}
|
|
a.applyHeaders(req, jsonBody)
|
|
for key, value := range requestHeaders {
|
|
req.Header.Set(key, value)
|
|
}
|
|
a.applyProfileAuth(req)
|
|
return req, nil
|
|
}
|
|
|
|
func (a *Adapter) mapProfileBodyModel(body []byte) ([]byte, error) {
|
|
if a.profile == nil || len(a.profile.ModelMapping) == 0 || len(bytes.TrimSpace(body)) == 0 {
|
|
return body, nil
|
|
}
|
|
trimmed := bytes.TrimSpace(body)
|
|
if len(trimmed) < 2 || trimmed[0] != '{' || trimmed[len(trimmed)-1] != '}' {
|
|
return nil, fmt.Errorf("decode profile request body: top-level JSON value must be an object")
|
|
}
|
|
var payload map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, fmt.Errorf("decode profile request body: %w", err)
|
|
}
|
|
start, end, model, found, err := topLevelJSONStringField(body, "model")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode profile request body: %w", err)
|
|
}
|
|
if !found {
|
|
return body, nil
|
|
}
|
|
mapped := a.profile.MapModel(model)
|
|
if mapped == model {
|
|
return body, nil
|
|
}
|
|
encoded, err := json.Marshal(mapped)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode profile request body: %w", err)
|
|
}
|
|
prepared := make([]byte, 0, len(body)-(end-start)+len(encoded))
|
|
prepared = append(prepared, body[:start]...)
|
|
prepared = append(prepared, encoded...)
|
|
prepared = append(prepared, body[end:]...)
|
|
return prepared, nil
|
|
}
|
|
|
|
// topLevelJSONStringField locates the last top-level occurrence of key when
|
|
// its value is a JSON string. It returns byte offsets into the original body,
|
|
// allowing a surgical splice that preserves every unrelated byte.
|
|
func topLevelJSONStringField(body []byte, wanted string) (start, end int, value string, found bool, err error) {
|
|
pos := skipJSONSpace(body, 0)
|
|
if pos >= len(body) || body[pos] != '{' {
|
|
return 0, 0, "", false, fmt.Errorf("top-level JSON value must be an object")
|
|
}
|
|
pos++
|
|
for {
|
|
pos = skipJSONSpace(body, pos)
|
|
if pos >= len(body) {
|
|
return 0, 0, "", false, fmt.Errorf("unterminated object")
|
|
}
|
|
if body[pos] == '}' {
|
|
return start, end, value, found, nil
|
|
}
|
|
keyStart := pos
|
|
keyEnd, scanErr := scanJSONStringEnd(body, keyStart)
|
|
if scanErr != nil {
|
|
return 0, 0, "", false, scanErr
|
|
}
|
|
var key string
|
|
if unmarshalErr := json.Unmarshal(body[keyStart:keyEnd], &key); unmarshalErr != nil {
|
|
return 0, 0, "", false, unmarshalErr
|
|
}
|
|
pos = skipJSONSpace(body, keyEnd)
|
|
if pos >= len(body) || body[pos] != ':' {
|
|
return 0, 0, "", false, fmt.Errorf("object key %q has no value", key)
|
|
}
|
|
valueStart := skipJSONSpace(body, pos+1)
|
|
valueEnd, scanErr := scanJSONValueEnd(body, valueStart)
|
|
if scanErr != nil {
|
|
return 0, 0, "", false, scanErr
|
|
}
|
|
if key == wanted {
|
|
found = false
|
|
if valueStart < len(body) && body[valueStart] == '"' {
|
|
var decoded string
|
|
if unmarshalErr := json.Unmarshal(body[valueStart:valueEnd], &decoded); unmarshalErr != nil {
|
|
return 0, 0, "", false, unmarshalErr
|
|
}
|
|
start, end, value, found = valueStart, valueEnd, decoded, true
|
|
}
|
|
}
|
|
pos = skipJSONSpace(body, valueEnd)
|
|
if pos < len(body) && body[pos] == ',' {
|
|
pos++
|
|
continue
|
|
}
|
|
if pos < len(body) && body[pos] == '}' {
|
|
return start, end, value, found, nil
|
|
}
|
|
return 0, 0, "", false, fmt.Errorf("invalid object delimiter")
|
|
}
|
|
}
|
|
|
|
func skipJSONSpace(body []byte, pos int) int {
|
|
for pos < len(body) {
|
|
switch body[pos] {
|
|
case ' ', '\t', '\r', '\n':
|
|
pos++
|
|
default:
|
|
return pos
|
|
}
|
|
}
|
|
return pos
|
|
}
|
|
|
|
func scanJSONStringEnd(body []byte, start int) (int, error) {
|
|
if start >= len(body) || body[start] != '"' {
|
|
return 0, fmt.Errorf("expected JSON string")
|
|
}
|
|
escaped := false
|
|
for pos := start + 1; pos < len(body); pos++ {
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
switch body[pos] {
|
|
case '\\':
|
|
escaped = true
|
|
case '"':
|
|
return pos + 1, nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("unterminated JSON string")
|
|
}
|
|
|
|
func scanJSONValueEnd(body []byte, start int) (int, error) {
|
|
if start >= len(body) {
|
|
return 0, fmt.Errorf("missing JSON value")
|
|
}
|
|
if body[start] == '"' {
|
|
return scanJSONStringEnd(body, start)
|
|
}
|
|
depth := 0
|
|
inString := false
|
|
escaped := false
|
|
for pos := start; pos < len(body); pos++ {
|
|
c := body[pos]
|
|
if inString {
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if c == '\\' {
|
|
escaped = true
|
|
} else if c == '"' {
|
|
inString = false
|
|
}
|
|
continue
|
|
}
|
|
switch c {
|
|
case '"':
|
|
inString = true
|
|
case '{', '[':
|
|
depth++
|
|
case ']':
|
|
depth--
|
|
case '}':
|
|
if depth == 0 {
|
|
return trimJSONSpaceEnd(body, start, pos), nil
|
|
}
|
|
depth--
|
|
case ',':
|
|
if depth == 0 {
|
|
return trimJSONSpaceEnd(body, start, pos), nil
|
|
}
|
|
}
|
|
}
|
|
return trimJSONSpaceEnd(body, start, len(body)), nil
|
|
}
|
|
|
|
func trimJSONSpaceEnd(body []byte, start, end int) int {
|
|
for end > start {
|
|
switch body[end-1] {
|
|
case ' ', '\t', '\r', '\n':
|
|
end--
|
|
default:
|
|
return end
|
|
}
|
|
}
|
|
return end
|
|
}
|
|
|
|
func (a *Adapter) applyProfileAuth(req *http.Request) {
|
|
if a.profile == nil || strings.TrimSpace(a.profile.Auth.Header) == "" {
|
|
return
|
|
}
|
|
target := http.CanonicalHeaderKey(a.profile.Auth.Header)
|
|
value := strings.TrimSpace(req.Header.Get(target))
|
|
if value == "" {
|
|
for _, source := range []string{"Authorization", "X-Api-Key"} {
|
|
if candidate := strings.TrimSpace(req.Header.Get(source)); candidate != "" {
|
|
value = candidate
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if value == "" {
|
|
return
|
|
}
|
|
raw := value
|
|
if fields := strings.Fields(value); len(fields) > 1 {
|
|
raw = strings.Join(fields[1:], " ")
|
|
}
|
|
if scheme := strings.TrimSpace(a.profile.Auth.Scheme); scheme != "" {
|
|
value = scheme + " " + raw
|
|
} else {
|
|
value = raw
|
|
}
|
|
for _, source := range []string{"Authorization", "X-Api-Key"} {
|
|
if !strings.EqualFold(source, target) {
|
|
req.Header.Del(source)
|
|
}
|
|
}
|
|
req.Header.Set(target, value)
|
|
}
|
|
|
|
func (a *Adapter) doChatCompletion(ctx context.Context, body []byte) (*http.Response, error) {
|
|
req, err := a.prepareRequest(ctx, http.MethodPost, string(config.OperationChatCompletions), "/v1/chat/completions", body, nil, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.client.Do(req)
|
|
}
|
|
|
|
func (a *Adapter) buildRequestBody(model string, messages []chatMessage, input map[string]any) (map[string]any, error) {
|
|
body := make(map[string]any)
|
|
// Copy caller options as top-level OpenAI-compatible request fields first so
|
|
// adapter-owned fields below always win over them.
|
|
if opts, ok := input["options"].(map[string]any); ok {
|
|
for k, v := range opts {
|
|
body[k] = v
|
|
}
|
|
}
|
|
// Pass through optional OpenAI-compatible fields when present in the input (except think).
|
|
for _, key := range []string{"tools", "tool_choice", "format", "keep_alive"} {
|
|
if v, ok := input[key]; ok {
|
|
body[key] = v
|
|
}
|
|
}
|
|
body["model"] = model
|
|
body["messages"] = messages
|
|
body["stream"] = true
|
|
|
|
// Extract think-control fields
|
|
hasThink := false
|
|
var thinkVal bool
|
|
if v, ok := input["think"]; ok {
|
|
if bv, ok := v.(bool); ok {
|
|
hasThink = true
|
|
thinkVal = bv
|
|
}
|
|
}
|
|
|
|
hasReasoningEffort := false
|
|
var reasoningEffortVal string
|
|
if v, ok := input["reasoning_effort"]; ok {
|
|
if sv, ok := v.(string); ok {
|
|
hasReasoningEffort = true
|
|
reasoningEffortVal = sv
|
|
}
|
|
}
|
|
|
|
hasBudget := false
|
|
var budgetVal any
|
|
if v, ok := input["thinking_token_budget"]; ok {
|
|
hasBudget = true
|
|
budgetVal = v
|
|
}
|
|
|
|
// vLLM, vLLM-MLX and Lemonade all serve Qwen3-family templates that honor
|
|
// chat_template_kwargs.enable_thinking / thinking_token_budget. The Lemonade
|
|
// build ignores the top-level `think` field, so all three providers map
|
|
// think-control into chat_template_kwargs rather than top-level fields.
|
|
switch a.provider {
|
|
case "vllm", "vllm-mlx", "lemonade":
|
|
thinkDisableRequested := (hasThink && !thinkVal) || (hasReasoningEffort && reasoningEffortVal == "none")
|
|
|
|
// The vLLM-MLX runtime labels its entire streamed output as
|
|
// reasoning_content and keeps generating reasoning even with
|
|
// enable_thinking=false, so a think-disable request can never yield a
|
|
// reasoning-free stream. Fail with a clear compatibility error instead
|
|
// of silently returning a reasoning stream for think=false.
|
|
if a.provider == "vllm-mlx" && thinkDisableRequested {
|
|
return nil, fmt.Errorf("unsupported think control: provider %q cannot disable reasoning generation for streaming responses (think=false / reasoning_effort=none)", a.provider)
|
|
}
|
|
|
|
if hasReasoningEffort && reasoningEffortVal != "" && reasoningEffortVal != "none" {
|
|
return nil, fmt.Errorf("unsupported think control: reasoning_effort %q is not supported by %s", reasoningEffortVal, a.provider)
|
|
}
|
|
|
|
if hasBudget {
|
|
var budgetInt int64
|
|
valid := false
|
|
switch bv := budgetVal.(type) {
|
|
case int:
|
|
budgetInt = int64(bv)
|
|
valid = true
|
|
case int64:
|
|
budgetInt = bv
|
|
valid = true
|
|
case float64:
|
|
if bv == float64(int64(bv)) {
|
|
budgetInt = int64(bv)
|
|
valid = true
|
|
}
|
|
}
|
|
if !valid || budgetInt < 0 {
|
|
return nil, fmt.Errorf("unsupported think control: invalid thinking_token_budget %v", budgetVal)
|
|
}
|
|
}
|
|
|
|
if hasThink || (hasReasoningEffort && reasoningEffortVal == "none") || hasBudget {
|
|
var chatTemplateKwargs map[string]any
|
|
if ctk, ok := body["chat_template_kwargs"].(map[string]any); ok {
|
|
chatTemplateKwargs = make(map[string]any)
|
|
for k, v := range ctk {
|
|
chatTemplateKwargs[k] = v
|
|
}
|
|
} else {
|
|
chatTemplateKwargs = make(map[string]any)
|
|
}
|
|
|
|
if (hasThink && !thinkVal) || (hasReasoningEffort && reasoningEffortVal == "none") {
|
|
chatTemplateKwargs["enable_thinking"] = false
|
|
} else if (hasThink && thinkVal) || hasBudget {
|
|
chatTemplateKwargs["enable_thinking"] = true
|
|
}
|
|
|
|
if hasBudget {
|
|
chatTemplateKwargs["thinking_token_budget"] = budgetVal
|
|
}
|
|
|
|
body["chat_template_kwargs"] = chatTemplateKwargs
|
|
}
|
|
default:
|
|
// empty/unknown provider: pass requested fields through as-is.
|
|
if hasThink {
|
|
body["think"] = thinkVal
|
|
}
|
|
if hasReasoningEffort {
|
|
body["reasoning_effort"] = reasoningEffortVal
|
|
}
|
|
if hasBudget {
|
|
body["thinking_token_budget"] = budgetVal
|
|
}
|
|
}
|
|
|
|
return body, nil
|
|
}
|
|
|
|
func retryBodyWithForcedSingleTool(body map[string]any, errorBody string) (map[string]any, bool) {
|
|
if !isAutoToolChoiceUnsupportedError(errorBody) {
|
|
return nil, false
|
|
}
|
|
forced, ok := forcedToolChoiceForSingleTool(body["tools"])
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
next := make(map[string]any, len(body)+1)
|
|
for key, value := range body {
|
|
next[key] = value
|
|
}
|
|
next["tool_choice"] = forced
|
|
return next, true
|
|
}
|
|
|
|
func isAutoToolChoiceUnsupportedError(errorBody string) bool {
|
|
errorBody = strings.ToLower(errorBody)
|
|
return strings.Contains(errorBody, "auto") &&
|
|
strings.Contains(errorBody, "tool choice") &&
|
|
isToolCallingUnsupportedError(errorBody)
|
|
}
|
|
|
|
func isToolCallingUnsupportedError(errorBody string) bool {
|
|
errorBody = strings.ToLower(errorBody)
|
|
return strings.Contains(errorBody, "enable-auto-tool-choice") ||
|
|
strings.Contains(errorBody, "tool-call-parser")
|
|
}
|
|
|
|
func forcedToolChoiceForSingleTool(tools any) (map[string]any, bool) {
|
|
items, ok := tools.([]any)
|
|
if !ok || len(items) != 1 {
|
|
return nil, false
|
|
}
|
|
tool, ok := items[0].(map[string]any)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
name := ""
|
|
if fn, ok := tool["function"].(map[string]any); ok {
|
|
name, _ = fn["name"].(string)
|
|
}
|
|
if name == "" {
|
|
name, _ = tool["name"].(string)
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, false
|
|
}
|
|
return map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": name,
|
|
},
|
|
}, true
|
|
}
|
|
|
|
func retryBodyWithTextToolFallback(body map[string]any, errorBody string) (map[string]any, bool) {
|
|
if !isToolCallingUnsupportedError(errorBody) {
|
|
return nil, false
|
|
}
|
|
instruction, ok := textToolFallbackInstruction(body["tools"])
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
messages, ok := prependTextToolFallbackInstruction(body["messages"], instruction)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
next := make(map[string]any, len(body)+1)
|
|
for key, value := range body {
|
|
next[key] = value
|
|
}
|
|
delete(next, "tools")
|
|
delete(next, "tool_choice")
|
|
next["messages"] = messages
|
|
return next, true
|
|
}
|
|
|
|
func prependTextToolFallbackInstruction(value any, instruction string) (any, bool) {
|
|
switch messages := value.(type) {
|
|
case []chatMessage:
|
|
system := chatMessage{Role: "system", Content: instruction}
|
|
out := make([]chatMessage, 0, len(messages)+1)
|
|
for _, msg := range messages {
|
|
if strings.EqualFold(strings.TrimSpace(msg.Role), "system") {
|
|
system.Content = joinTextToolFallbackSystemContent(system.Content, msg.Content)
|
|
continue
|
|
}
|
|
out = append(out, msg)
|
|
}
|
|
return append([]chatMessage{system}, out...), true
|
|
case []any:
|
|
systemContent := instruction
|
|
out := make([]any, 0, len(messages)+1)
|
|
for _, item := range messages {
|
|
msg, ok := item.(map[string]any)
|
|
if !ok {
|
|
out = append(out, item)
|
|
continue
|
|
}
|
|
role, _ := msg["role"].(string)
|
|
if strings.EqualFold(strings.TrimSpace(role), "system") {
|
|
systemContent = joinTextToolFallbackSystemContent(systemContent, stringFromAny(msg["content"]))
|
|
continue
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return append([]any{map[string]any{"role": "system", "content": systemContent}}, out...), true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
func joinTextToolFallbackSystemContent(first, next string) string {
|
|
first = strings.TrimSpace(first)
|
|
next = strings.TrimSpace(next)
|
|
switch {
|
|
case first == "":
|
|
return next
|
|
case next == "":
|
|
return first
|
|
default:
|
|
return first + "\n\n" + next
|
|
}
|
|
}
|
|
|
|
func stringFromAny(value any) string {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return v
|
|
default:
|
|
encoded, _ := json.Marshal(v)
|
|
return string(encoded)
|
|
}
|
|
}
|
|
|
|
func textToolFallbackInstruction(tools any) (string, bool) {
|
|
items, ok := anyItems(tools)
|
|
if !ok || len(items) == 0 {
|
|
return "", false
|
|
}
|
|
encoded, err := json.Marshal(items)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return "Tool calls must be emitted as plain text because this backend does not support native OpenAI tool calling. When a tool is needed, respond with exactly one tool call and no markdown:\n" +
|
|
"<tool_call>\n<function=TOOL_NAME>\n<parameter=PARAMETER_NAME>JSON_VALUE</parameter>\n</function>\n</tool_call>\n" +
|
|
"run_commands executes from the client workspace root. Do not prepend cd to an absolute workspace path unless the user explicitly asks to operate in a different directory; prefer current-workspace commands such as git status.\n" +
|
|
"Use valid JSON for each parameter value and follow the supplied parameter schema. Available tools JSON: " + string(encoded), true
|
|
}
|
|
|
|
func anyItems(value any) ([]any, bool) {
|
|
switch v := value.(type) {
|
|
case []any:
|
|
return v, true
|
|
default:
|
|
encoded, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
var out []any
|
|
if err := json.Unmarshal(encoded, &out); err != nil {
|
|
return nil, false
|
|
}
|
|
return out, true
|
|
}
|
|
}
|
|
|
|
func completeEvent(runID, finishReason string, usage *runtime.UsageStats, outputTokens int, toolCalls []any, textToolFallback bool) runtime.RuntimeEvent {
|
|
if usage == nil {
|
|
usage = &runtime.UsageStats{OutputTokens: outputTokens}
|
|
}
|
|
var metadata map[string]string
|
|
if finishReason != "" {
|
|
metadata = map[string]string{"finish_reason": finishReason}
|
|
}
|
|
if textToolFallback {
|
|
if metadata == nil {
|
|
metadata = make(map[string]string, 2)
|
|
}
|
|
metadata[runtimeMetadataOpenAITextToolFallback] = "true"
|
|
}
|
|
if len(toolCalls) > 0 {
|
|
if metadata == nil {
|
|
metadata = make(map[string]string, 2)
|
|
}
|
|
if finishReason == "" {
|
|
metadata["finish_reason"] = "tool_calls"
|
|
}
|
|
if encoded, err := json.Marshal(toolCalls); err == nil {
|
|
metadata[runtimeMetadataOpenAIToolCalls] = string(encoded)
|
|
}
|
|
}
|
|
return runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeComplete,
|
|
Message: "openai_compat chat complete",
|
|
Usage: usage,
|
|
Metadata: metadata,
|
|
Timestamp: time.Now(),
|
|
}
|
|
}
|
|
|
|
// messagesFromInput extracts chat messages from the execution input.
|
|
func messagesFromInput(input map[string]any) []chatMessage {
|
|
if raw, ok := input["messages"].([]any); ok {
|
|
out := make([]chatMessage, 0, len(raw))
|
|
for _, item := range raw {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
role, _ := m["role"].(string)
|
|
content, _ := m["content"].(string)
|
|
toolCallID, _ := m["tool_call_id"].(string)
|
|
toolCalls := anySlice(m["tool_calls"])
|
|
role = strings.TrimSpace(role)
|
|
content = strings.TrimSpace(content)
|
|
toolCallID = strings.TrimSpace(toolCallID)
|
|
if role == "" || (content == "" && len(toolCalls) == 0) {
|
|
continue
|
|
}
|
|
out = append(out, chatMessage{
|
|
Role: role,
|
|
Content: content,
|
|
ToolCalls: toolCalls,
|
|
ToolCallID: toolCallID,
|
|
})
|
|
}
|
|
if len(out) > 0 {
|
|
return out
|
|
}
|
|
}
|
|
if prompt := strings.TrimSpace(stringInput(input, "prompt")); prompt != "" {
|
|
return []chatMessage{{Role: "user", Content: prompt}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func emitError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
|
|
return sink.Emit(ctx, runtime.RuntimeEvent{
|
|
RunID: runID,
|
|
Type: runtime.EventTypeError,
|
|
Error: msg,
|
|
Timestamp: time.Now(),
|
|
})
|
|
}
|
|
|
|
func stringInput(input map[string]any, key string) string {
|
|
if input == nil {
|
|
return ""
|
|
}
|
|
if v, ok := input[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func anySlice(v any) []any {
|
|
if items, ok := v.([]any); ok {
|
|
return items
|
|
}
|
|
return nil
|
|
}
|