| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279 |
- package service
- import (
- "fmt"
- "reflect"
- "strings"
- )
- type openAIResponsesLiteValidationError struct {
- param string
- message string
- }
- func (e *openAIResponsesLiteValidationError) Error() string { return e.message }
- func newOpenAIResponsesLiteValidationError(param, format string, args ...any) error {
- return &openAIResponsesLiteValidationError{param: param, message: fmt.Sprintf(format, args...)}
- }
- // 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 parallel, exists := reqBody["parallel_tool_calls"]; exists {
- if _, ok := parallel.(bool); !ok {
- return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean")
- }
- }
- if rawReasoning, exists := reqBody["reasoning"]; exists && rawReasoning != nil {
- if _, ok := rawReasoning.(map[string]any); !ok {
- return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object")
- }
- }
- rawTools, exists := reqBody["tools"]
- if !exists || rawTools == nil {
- changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody)
- if err != nil {
- return false, err
- }
- return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed)
- }
- tools, ok := rawTools.([]any)
- if !ok {
- return false, newOpenAIResponsesLiteValidationError("tools", "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 {
- changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody)
- if err != nil {
- return false, err
- }
- return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed)
- }
- 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 ensureOpenAIResponsesLiteParallelToolCalls(reqBody, true)
- }
- func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed bool) (bool, error) {
- parallel, exists := reqBody["parallel_tool_calls"]
- if exists {
- if _, ok := parallel.(bool); !ok {
- return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean")
- }
- }
- if parallel == false {
- return changed, nil
- }
- reqBody["parallel_tool_calls"] = false
- 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, newOpenAIResponsesLiteValidationError("reasoning", "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 := decodeOpenAIJSONUseNumber(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
- }
- func normalizeOpenAIResponsesLiteParallelToolCallsPayload(body []byte) ([]byte, bool, error) {
- var requestBody map[string]any
- if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil {
- return body, false, fmt.Errorf("decode responses Lite request body: %w", err)
- }
- changed, err := ensureOpenAIResponsesLiteParallelToolCalls(requestBody, false)
- 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
- }
- func normalizeOpenAIResponsesLitePayloadForAccount(body []byte, account *Account) ([]byte, bool, error) {
- if account == nil || !account.IsOpenAI() {
- return body, false, nil
- }
- if account.IsOpenAIOAuthLike() {
- return normalizeOpenAIResponsesLiteToolsPayload(body)
- }
- return normalizeOpenAIResponsesLiteParallelToolCallsPayload(body)
- }
|