Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
217 lines
6.8 KiB
Go
217 lines
6.8 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
)
|
|
|
|
// normalizeOpenAIResponsesLiteTools applies the Responses Lite request
|
|
// contract: reasoning must cover all turns, and private namespace declarations
|
|
// use the input.additional_tools carrier. Other top-level tools must belong to
|
|
// the small set accepted by the Lite endpoint; rejecting unsupported hosted
|
|
// tools is intentional because silently dropping them would change behavior.
|
|
func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
|
|
if reqBody == nil {
|
|
return false, nil
|
|
}
|
|
if rawReasoning, exists := reqBody["reasoning"]; exists && rawReasoning != nil {
|
|
if _, ok := rawReasoning.(map[string]any); !ok {
|
|
return false, fmt.Errorf("responses Lite requires reasoning to be an object")
|
|
}
|
|
}
|
|
rawTools, exists := reqBody["tools"]
|
|
if !exists || rawTools == nil {
|
|
return ensureOpenAIResponsesLiteReasoningContext(reqBody)
|
|
}
|
|
tools, ok := rawTools.([]any)
|
|
if !ok {
|
|
return false, fmt.Errorf("responses Lite requires tools to be an array")
|
|
}
|
|
|
|
topLevelTools := make([]any, 0, len(tools))
|
|
namespaceTools := make([]any, 0, len(tools))
|
|
for index, rawTool := range tools {
|
|
if customTool, ok := rawTool.(string); ok {
|
|
if strings.TrimSpace(customTool) == "" {
|
|
return false, fmt.Errorf("responses Lite custom tool at index %d must not be empty", index)
|
|
}
|
|
topLevelTools = append(topLevelTools, rawTool)
|
|
continue
|
|
}
|
|
tool, ok := rawTool.(map[string]any)
|
|
if !ok {
|
|
return false, fmt.Errorf("responses Lite tool at index %d must be an object", index)
|
|
}
|
|
toolType := strings.TrimSpace(firstNonEmptyString(tool["type"]))
|
|
switch toolType {
|
|
case "function", "custom", "tool_search":
|
|
topLevelTools = append(topLevelTools, rawTool)
|
|
case "namespace":
|
|
namespaceTools = append(namespaceTools, rawTool)
|
|
case "":
|
|
return false, fmt.Errorf("responses Lite tool at index %d is missing type", index)
|
|
default:
|
|
return false, fmt.Errorf("responses Lite does not support top-level tool type %q at index %d", toolType, index)
|
|
}
|
|
}
|
|
if len(namespaceTools) == 0 {
|
|
return ensureOpenAIResponsesLiteReasoningContext(reqBody)
|
|
}
|
|
|
|
input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if _, err := ensureOpenAIResponsesLiteReasoningContext(reqBody); err != nil {
|
|
return false, err
|
|
}
|
|
reqBody["input"] = input
|
|
if len(topLevelTools) == 0 {
|
|
delete(reqBody, "tools")
|
|
} else {
|
|
reqBody["tools"] = topLevelTools
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, error) {
|
|
rawReasoning, exists := reqBody["reasoning"]
|
|
if !exists || rawReasoning == nil {
|
|
reqBody["reasoning"] = map[string]any{"context": "all_turns"}
|
|
return true, nil
|
|
}
|
|
reasoning, ok := rawReasoning.(map[string]any)
|
|
if !ok {
|
|
return false, fmt.Errorf("responses Lite requires reasoning to be an object")
|
|
}
|
|
if context, ok := reasoning["context"].(string); ok && context == "all_turns" {
|
|
return false, nil
|
|
}
|
|
reasoning["context"] = "all_turns"
|
|
return true, nil
|
|
}
|
|
|
|
func appendOpenAIResponsesLiteAdditionalTools(input any, namespaceTools []any) ([]any, error) {
|
|
var items []any
|
|
switch typed := input.(type) {
|
|
case nil:
|
|
items = make([]any, 0, 1)
|
|
case string:
|
|
items = []any{map[string]any{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": typed,
|
|
}}
|
|
case []any:
|
|
items = typed
|
|
default:
|
|
return nil, fmt.Errorf("responses Lite namespace tools require input to be a string or array")
|
|
}
|
|
|
|
var target map[string]any
|
|
var targetTools []any
|
|
var allAdditionalTools []any
|
|
for _, rawItem := range items {
|
|
item, ok := rawItem.(map[string]any)
|
|
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" {
|
|
continue
|
|
}
|
|
rawAdditionalTools, exists := item["tools"]
|
|
additionalTools := []any(nil)
|
|
toolsOK := true
|
|
if exists && rawAdditionalTools != nil {
|
|
additionalTools, toolsOK = rawAdditionalTools.([]any)
|
|
}
|
|
if !toolsOK {
|
|
return nil, fmt.Errorf("responses Lite input.additional_tools tools must be an array")
|
|
}
|
|
if target == nil {
|
|
target = item
|
|
targetTools = additionalTools
|
|
}
|
|
allAdditionalTools = append(allAdditionalTools, additionalTools...)
|
|
}
|
|
|
|
merged, err := mergeOpenAIResponsesLiteAdditionalTools(allAdditionalTools, namespaceTools)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
newTools := merged[len(allAdditionalTools):]
|
|
if target != nil {
|
|
if len(newTools) > 0 {
|
|
target["tools"] = append(append([]any(nil), targetTools...), newTools...)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
items = append(items, map[string]any{
|
|
"type": "additional_tools",
|
|
"role": "developer",
|
|
"tools": newTools,
|
|
})
|
|
return items, nil
|
|
}
|
|
|
|
func mergeOpenAIResponsesLiteAdditionalTools(existing []any, moved []any) ([]any, error) {
|
|
merged := append([]any(nil), existing...)
|
|
seen := make(map[string]any, len(existing)+len(moved))
|
|
for _, rawTool := range existing {
|
|
if identity := openAIResponsesLiteToolIdentity(rawTool); identity != "" {
|
|
if previous, exists := seen[identity]; exists && !reflect.DeepEqual(previous, rawTool) {
|
|
return nil, fmt.Errorf("responses Lite additional_tools contains conflicting definitions for %s", openAIResponsesLiteToolIdentityForError(rawTool))
|
|
}
|
|
seen[identity] = rawTool
|
|
}
|
|
}
|
|
for _, rawTool := range moved {
|
|
identity := openAIResponsesLiteToolIdentity(rawTool)
|
|
if identity != "" {
|
|
if previous, exists := seen[identity]; exists {
|
|
if reflect.DeepEqual(previous, rawTool) {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("responses Lite additional_tools conflicts with migrated %s", openAIResponsesLiteToolIdentityForError(rawTool))
|
|
}
|
|
seen[identity] = rawTool
|
|
}
|
|
merged = append(merged, rawTool)
|
|
}
|
|
return merged, nil
|
|
}
|
|
|
|
func openAIResponsesLiteToolIdentity(rawTool any) string {
|
|
tool, ok := rawTool.(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
toolType := strings.TrimSpace(firstNonEmptyString(tool["type"]))
|
|
name := strings.TrimSpace(firstNonEmptyString(tool["name"]))
|
|
if toolType == "" || name == "" {
|
|
return ""
|
|
}
|
|
return toolType + "\x00" + name
|
|
}
|
|
|
|
func openAIResponsesLiteToolIdentityForError(rawTool any) string {
|
|
tool, _ := rawTool.(map[string]any)
|
|
return fmt.Sprintf("tool type %q name %q", strings.TrimSpace(firstNonEmptyString(tool["type"])), strings.TrimSpace(firstNonEmptyString(tool["name"])))
|
|
}
|
|
|
|
func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) {
|
|
var requestBody map[string]any
|
|
if err := json.Unmarshal(body, &requestBody); err != nil {
|
|
return body, false, fmt.Errorf("decode responses Lite request body: %w", err)
|
|
}
|
|
changed, err := normalizeOpenAIResponsesLiteTools(requestBody)
|
|
if err != nil || !changed {
|
|
return body, false, err
|
|
}
|
|
rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
|
|
if err != nil {
|
|
return body, false, fmt.Errorf("encode responses Lite request body: %w", err)
|
|
}
|
|
return rebuilt, true, nil
|
|
}
|