Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
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
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
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user