Files
sub2api/backend/internal/service/openai_responses_lite_tools.go
T
李建琦 6d655c9903
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
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

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
}