ipa2korg / backend /internal /infra /provider /cli /responses_codex_tools.go
FUCAT
Update to v3.0.11 with HF deployment fixes
cdc337a
Raw
History Blame Contribute Delete
23.3 kB
package cli
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// normalizeShellTool 保留 OpenAI 新版 shell;同一请求不能同时声明新旧两套本地 shell。
func (c *responsesToolCompatibility) normalizeShellTool(tool map[string]any, param string) ([]any, error) {
if c.legacyLocalShell {
return nil, &responsesRequestError{
Message: "同一请求不能同时声明 shell 和 local_shell",
Param: param + ".type", Code: "invalid_parameter",
}
}
c.nativeShell = true
return c.normalizeNativeTool(tool, param)
}
// normalizeLegacyLocalShellTool upgrades the legacy Codex local_shell declaration to Build 0.2.110.
func (c *responsesToolCompatibility) normalizeLegacyLocalShellTool(tool map[string]any, param string) ([]any, error) {
if c.nativeShell || c.legacyLocalShell {
return nil, &responsesRequestError{
Message: "单次请求只能声明一个 shell/local_shell 工具",
Param: param + ".type", Code: "invalid_parameter",
}
}
if len(tool) > 1 {
c.addWarning("legacy_local_shell_controls_ignored")
}
c.legacyLocalShell = true
c.changed = true
c.addWarning("legacy_local_shell_upgraded")
return []any{map[string]any{
"type": "shell",
"environment": map[string]any{"type": "local"},
}}, nil
}
// normalizeApplyPatchTool 将客户端执行的 apply_patch 包装为严格 function。
func (c *responsesToolCompatibility) normalizeApplyPatchTool(tool map[string]any, param string) ([]any, error) {
if len(tool) > 1 {
c.addWarning("apply_patch_controls_ignored")
}
identity := responsesToolIdentity{Kind: responsesApplyPatchTool, Name: "apply_patch"}
c.changed = true
c.addWarning("apply_patch_emulated")
return []any{map[string]any{
"type": "function",
"name": c.alias(identity),
"description": "Create, update, or delete one file using a structured V4A patch operation. " +
"create_file and update_file require path and diff; delete_file requires path.",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"operation": map[string]any{
"type": "object",
"properties": map[string]any{
"type": map[string]any{"type": "string", "enum": []any{"create_file", "update_file", "delete_file"}},
"path": map[string]any{"type": "string", "minLength": 1},
"diff": map[string]any{"type": "string"},
},
"required": []any{"type", "path"}, "additionalProperties": false,
},
},
"required": []any{"operation"}, "additionalProperties": false,
},
"strict": true,
}}, nil
}
func (c *responsesToolCompatibility) normalizeApplyPatchCallInput(item map[string]any, param string) (map[string]any, error) {
callID := strings.TrimSpace(stringField(item, "call_id"))
if callID == "" {
return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"}
}
operation, err := validateApplyPatchOperation(item["operation"], param+".operation")
if err != nil {
return nil, err
}
arguments, err := json.Marshal(map[string]any{"operation": operation})
if err != nil {
return nil, err
}
return map[string]any{
"type": "function_call", "call_id": callID,
"name": c.alias(responsesToolIdentity{Kind: responsesApplyPatchTool, Name: "apply_patch"}),
"arguments": string(arguments),
}, nil
}
func normalizeApplyPatchOutputInput(item map[string]any, param string) (map[string]any, error) {
callID := strings.TrimSpace(stringField(item, "call_id"))
if callID == "" {
return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"}
}
status := strings.TrimSpace(stringField(item, "status"))
if status == "" {
status = "completed"
}
if status != "completed" && status != "failed" {
return nil, &responsesRequestError{Message: "apply_patch_call_output.status 只支持 completed 或 failed", Param: param + ".status", Code: "invalid_parameter"}
}
output := ""
if value, exists := item["output"]; exists && value != nil {
if text, ok := value.(string); ok {
output = text
} else {
encoded, err := json.Marshal(value)
if err != nil {
return nil, &responsesRequestError{Message: "apply_patch_call_output.output 无法编码", Param: param + ".output", Code: "invalid_parameter"}
}
output = string(encoded)
}
}
message := "Apply patch status: " + status
if output != "" {
message += "\n" + output
}
return map[string]any{"type": "function_call_output", "call_id": callID, "output": message}, nil
}
func validateApplyPatchOperation(value any, param string) (map[string]any, error) {
operation, ok := value.(map[string]any)
if !ok {
return nil, &responsesRequestError{Message: "apply_patch operation 必须是对象", Param: param, Code: "invalid_parameter"}
}
kind := strings.TrimSpace(stringField(operation, "type"))
path := strings.TrimSpace(stringField(operation, "path"))
if path == "" {
return nil, &responsesRequestError{Message: "apply_patch operation.path 不能为空", Param: param + ".path", Code: "invalid_parameter"}
}
switch kind {
case "create_file", "update_file":
if _, ok := operation["diff"].(string); !ok {
return nil, &responsesRequestError{Message: kind + " 必须提供 diff 字符串", Param: param + ".diff", Code: "invalid_parameter"}
}
case "delete_file":
default:
return nil, &responsesRequestError{Message: "apply_patch operation.type 无效", Param: param + ".type", Code: "invalid_parameter"}
}
return cloneJSONObject(operation), nil
}
func decodeApplyPatchArguments(value any, param string) (map[string]any, error) {
text, ok := value.(string)
if !ok {
return nil, &responsesRequestError{Message: "apply_patch function arguments 必须是字符串", Param: param, Code: "invalid_parameter"}
}
var wrapper map[string]any
if err := json.Unmarshal([]byte(text), &wrapper); err != nil {
return nil, &responsesRequestError{Message: "apply_patch function arguments 不是有效 JSON", Param: param, Code: "invalid_parameter"}
}
return validateApplyPatchOperation(wrapper["operation"], param+".operation")
}
func normalizeLegacyLocalShellCallInput(item map[string]any, param string) (map[string]any, error) {
callID := strings.TrimSpace(stringField(item, "call_id"))
if callID == "" {
return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"}
}
action, err := legacyShellAction(item["action"], param+".action")
if err != nil {
return nil, err
}
converted := map[string]any{"type": "shell_call", "call_id": callID, "action": action}
for _, key := range []string{"id", "status", "timeout_ms", "max_output_length"} {
if value, exists := item[key]; exists {
converted[key] = cloneJSONValue(value)
}
}
return converted, nil
}
func legacyShellAction(value any, param string) (map[string]any, error) {
action, ok := value.(map[string]any)
if !ok {
return nil, &responsesRequestError{Message: "local_shell_call.action 必须是对象", Param: param, Code: "invalid_parameter"}
}
if kind := strings.TrimSpace(stringField(action, "type")); kind != "" && kind != "exec" {
return nil, &responsesRequestError{Message: "local_shell_call.action.type 必须是 exec", Param: param + ".type", Code: "invalid_parameter"}
}
command, err := legacyShellCommand(action, param)
if err != nil {
return nil, err
}
return map[string]any{"type": "exec", "commands": []any{command}}, nil
}
func legacyShellCommand(action map[string]any, param string) (string, error) {
command := ""
switch value := action["command"].(type) {
case string:
command = strings.TrimSpace(value)
case []any:
parts := make([]string, 0, len(value))
for index, raw := range value {
part, ok := raw.(string)
if !ok {
return "", &responsesRequestError{Message: "local_shell command 参数必须是字符串", Param: fmt.Sprintf("%s.command[%d]", param, index), Code: "invalid_parameter"}
}
parts = append(parts, quoteShellArgument(part))
}
command = strings.Join(parts, " ")
default:
if commands, ok := action["commands"].([]any); ok {
parts := make([]string, 0, len(commands))
for index, raw := range commands {
part, ok := raw.(string)
if !ok {
return "", &responsesRequestError{Message: "shell commands 必须是字符串", Param: fmt.Sprintf("%s.commands[%d]", param, index), Code: "invalid_parameter"}
}
parts = append(parts, part)
}
command = strings.Join(parts, "\n")
}
}
if command == "" {
return "", &responsesRequestError{Message: "local_shell_call.action.command 不能为空", Param: param + ".command", Code: "invalid_parameter"}
}
if environment, ok := action["env"].(map[string]any); ok && len(environment) > 0 {
keys := make([]string, 0, len(environment))
for key := range environment {
if !validEnvironmentName(key) {
return "", &responsesRequestError{Message: "local_shell env 名称无效", Param: param + ".env." + key, Code: "invalid_parameter"}
}
keys = append(keys, key)
}
sort.Strings(keys)
assignments := make([]string, 0, len(keys))
for _, key := range keys {
value, ok := environment[key].(string)
if !ok {
return "", &responsesRequestError{Message: "local_shell env 值必须是字符串", Param: param + ".env." + key, Code: "invalid_parameter"}
}
assignments = append(assignments, key+"="+quoteShellArgument(value))
}
command = "env " + strings.Join(assignments, " ") + " " + command
}
if directory := strings.TrimSpace(stringField(action, "working_directory")); directory != "" {
command = "cd " + quoteShellArgument(directory) + " && " + command
}
return command, nil
}
func normalizeLegacyLocalShellOutputInput(item map[string]any, param string) (map[string]any, error) {
callID := strings.TrimSpace(stringField(item, "call_id"))
if callID == "" {
return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"}
}
var output []any
switch value := item["output"].(type) {
case []any:
output = cloneJSONArray(value)
case string:
exitCode := 0
if strings.EqualFold(stringField(item, "status"), "failed") {
exitCode = 1
}
if number, ok := item["exit_code"].(float64); ok {
exitCode = int(number)
}
output = []any{map[string]any{
"stdout": value, "stderr": "",
"outcome": map[string]any{"type": "exit", "exit_code": exitCode},
}}
default:
return nil, &responsesRequestError{Message: "local_shell_call_output.output 必须是字符串或数组", Param: param + ".output", Code: "invalid_parameter"}
}
converted := map[string]any{"type": "shell_call_output", "call_id": callID, "output": output}
if value, exists := item["max_output_length"]; exists && value != nil {
converted["max_output_length"] = cloneJSONValue(value)
}
return converted, nil
}
func normalizeShellCallOutputInput(item map[string]any, param string) (map[string]any, error) {
callID := strings.TrimSpace(stringField(item, "call_id"))
if callID == "" {
return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"}
}
output, err := normalizeShellOutputBlocks(item["output"], item["status"], param+".output")
if err != nil {
return nil, err
}
converted := map[string]any{"type": "shell_call_output", "call_id": callID, "output": output}
if value, exists := item["max_output_length"]; exists && value != nil {
converted["max_output_length"] = cloneJSONValue(value)
}
return converted, nil
}
func (c *responsesToolCompatibility) normalizeFunctionCallOutputInput(item map[string]any, param string) (map[string]any, error) {
return c.normalizeFunctionLikeCallOutputInput(item, param, true)
}
func (c *responsesToolCompatibility) normalizeCustomToolCallOutputInput(item map[string]any, param string) (map[string]any, error) {
return c.normalizeFunctionLikeCallOutputInput(item, param, false)
}
func (c *responsesToolCompatibility) normalizeFunctionLikeCallOutputInput(item map[string]any, param string, allowContentBlocks bool) (map[string]any, error) {
callID := strings.TrimSpace(stringField(item, "call_id"))
if callID == "" {
return nil, &responsesRequestError{Message: param + ".call_id 不能为空", Param: param + ".call_id", Code: "invalid_parameter"}
}
output := item["output"]
var err error
if blocks, ok := output.([]any); ok && allowContentBlocks && isFunctionCallOutputContentArray(blocks) {
output, err = c.normalizeFunctionCallOutputBlocks(blocks, param+".output")
} else {
output, err = encodeToolOutput(output, param+".output")
}
if err != nil {
return nil, err
}
// 按官方 Build 回放结构重建,不携带输出态 id/status。
return map[string]any{"type": "function_call_output", "call_id": callID, "output": output}, nil
}
// isFunctionCallOutputContentArray 区分 Responses 内容数组与普通结构化 JSON 数组。
// 只要出现一个 input_* 内容块,整个数组就按内容数组严格校验,避免混合数组
// 中的图片被静默字符串化;普通对象/标量/空数组仍沿用 JSON 字符串契约。
func isFunctionCallOutputContentArray(blocks []any) bool {
for _, raw := range blocks {
block, ok := raw.(map[string]any)
if !ok {
continue
}
blockType := strings.TrimSpace(stringField(block, "type"))
if strings.HasPrefix(blockType, "input_") {
return true
}
}
return false
}
func (c *responsesToolCompatibility) normalizeFunctionCallOutputBlocks(blocks []any, param string) ([]any, error) {
normalized := make([]any, 0, len(blocks))
for index, raw := range blocks {
blockParam := fmt.Sprintf("%s[%d]", param, index)
block, ok := raw.(map[string]any)
if !ok {
return nil, &responsesRequestError{Message: blockParam + " 必须是对象", Param: blockParam, Code: "invalid_parameter"}
}
blockType := strings.TrimSpace(stringField(block, "type"))
if blockType == "" {
return nil, &responsesRequestError{Message: blockParam + ".type 不能为空", Param: blockParam + ".type", Code: "invalid_parameter"}
}
switch blockType {
case "input_text":
text, ok := block["text"].(string)
if !ok {
return nil, &responsesRequestError{Message: blockParam + ".text 必须是字符串", Param: blockParam + ".text", Code: "invalid_parameter"}
}
normalized = append(normalized, map[string]any{"type": "input_text", "text": text})
case "input_image":
converted, err := c.normalizeFunctionCallOutputImageBlock(block, blockParam)
if err != nil {
return nil, err
}
normalized = append(normalized, converted)
case "input_file":
converted, err := normalizeFunctionCallOutputFileBlock(block, blockParam)
if err != nil {
return nil, err
}
normalized = append(normalized, converted)
default:
return nil, &responsesRequestError{Message: "Grok Build 0.2.110 不支持该 function_call_output.output 类型", Param: blockParam + ".type", Code: "unsupported_parameter"}
}
}
return normalized, nil
}
func (c *responsesToolCompatibility) normalizeFunctionCallOutputImageBlock(block map[string]any, param string) (map[string]any, error) {
_, hasImageURL, err := nonEmptyContentBlockString(block, "image_url", param)
if err != nil {
return nil, err
}
_, hasFileID, err := nonEmptyContentBlockString(block, "file_id", param)
if err != nil {
return nil, err
}
if !hasImageURL && !hasFileID {
return nil, &responsesRequestError{Message: param + ".image_url 或 .file_id 至少需要一个", Param: param + ".image_url", Code: "invalid_parameter"}
}
return c.normalizeInputImagePart(block, param)
}
func normalizeFunctionCallOutputFileBlock(block map[string]any, param string) (map[string]any, error) {
hasSource := false
for _, key := range []string{"file_data", "file_id", "file_url", "filename"} {
_, exists, err := nonEmptyContentBlockString(block, key, param)
if err != nil {
return nil, err
}
if key != "filename" && exists {
hasSource = true
}
}
if !hasSource {
return nil, &responsesRequestError{Message: param + " 至少需要 file_data、file_id 或 file_url 之一", Param: param + ".file_data", Code: "invalid_parameter"}
}
return normalizeInputFilePart(block), nil
}
func nonEmptyContentBlockString(block map[string]any, key, param string) (string, bool, error) {
raw, exists := block[key]
if !exists || raw == nil {
return "", false, nil
}
value, ok := raw.(string)
if !ok || strings.TrimSpace(value) == "" {
return "", false, &responsesRequestError{Message: param + "." + key + " 必须是非空字符串", Param: param + "." + key, Code: "invalid_parameter"}
}
return value, true, nil
}
func encodeToolOutput(value any, param string) (string, error) {
switch typed := value.(type) {
case nil:
return "", nil
case string:
return typed, nil
default:
encoded, err := json.Marshal(typed)
if err != nil {
return "", &responsesRequestError{Message: param + " 无法编码", Param: param, Code: "invalid_parameter"}
}
return string(encoded), nil
}
}
func normalizeShellOutputBlocks(value, status any, param string) ([]any, error) {
switch typed := value.(type) {
case []any:
output := make([]any, 0, len(typed))
for index, raw := range typed {
block, ok := raw.(map[string]any)
if !ok {
return nil, &responsesRequestError{Message: fmt.Sprintf("%s[%d] 必须是对象", param, index), Param: fmt.Sprintf("%s[%d]", param, index), Code: "invalid_parameter"}
}
normalized, err := normalizeShellOutputBlock(block, fmt.Sprintf("%s[%d]", param, index))
if err != nil {
return nil, err
}
output = append(output, normalized)
}
return output, nil
case string:
exitCode := 0
if strings.EqualFold(fmt.Sprint(status), "failed") {
exitCode = 1
}
return []any{map[string]any{
"stdout": typed, "stderr": "",
"outcome": map[string]any{"type": "exit", "exit_code": exitCode},
}}, nil
default:
return nil, &responsesRequestError{Message: param + " 必须是字符串或数组", Param: param, Code: "invalid_parameter"}
}
}
func normalizeShellOutputBlock(block map[string]any, param string) (map[string]any, error) {
stdout, _ := block["stdout"].(string)
stderr, _ := block["stderr"].(string)
outcome, ok := block["outcome"].(map[string]any)
if !ok {
return nil, &responsesRequestError{Message: param + ".outcome 必须是对象", Param: param + ".outcome", Code: "invalid_parameter"}
}
normalizedOutcome := map[string]any{"type": strings.TrimSpace(stringField(outcome, "type"))}
switch normalizedOutcome["type"] {
case "exit":
exitCode, ok := shellExitCode(outcome["exit_code"])
if !ok {
exitCode, ok = shellExitCode(outcome["exitCode"])
}
if !ok {
return nil, &responsesRequestError{Message: param + ".outcome.exit_code 必须是数字", Param: param + ".outcome.exit_code", Code: "invalid_parameter"}
}
normalizedOutcome["exit_code"] = exitCode
case "timeout":
default:
return nil, &responsesRequestError{Message: param + ".outcome.type 无效", Param: param + ".outcome.type", Code: "invalid_parameter"}
}
return map[string]any{"stdout": stdout, "stderr": stderr, "outcome": normalizedOutcome}, nil
}
func shellExitCode(value any) (int, bool) {
switch typed := value.(type) {
case float64:
return int(typed), true
case int:
return typed, true
case int64:
return int(typed), true
case json.Number:
parsed, err := typed.Int64()
return int(parsed), err == nil
default:
return 0, false
}
}
func rewriteLegacyShellAction(value any) map[string]any {
action, _ := value.(map[string]any)
commands, _ := action["commands"].([]any)
parts := make([]string, 0, len(commands))
for _, raw := range commands {
if command, ok := raw.(string); ok {
parts = append(parts, command)
}
}
legacy := map[string]any{"type": "exec", "command": strings.Join(parts, "\n")}
return legacy
}
func quoteShellArgument(value string) string {
if value == "" {
return "''"
}
safe := true
for _, character := range value {
if !((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || strings.ContainsRune("_@%+=:,./-", character)) {
safe = false
break
}
}
if safe {
return value
}
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}
func validEnvironmentName(value string) bool {
if value == "" {
return false
}
for index, character := range value {
if !((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || character == '_' || (index > 0 && character >= '0' && character <= '9')) {
return false
}
}
return true
}
func (c *responsesToolCompatibility) normalizeAdditionalToolsInput(item map[string]any, param string) (map[string]any, []any, []any, error) {
if role := strings.TrimSpace(stringField(item, "role")); role != "" && role != "developer" {
c.addWarning("additional_tools_role_approximated")
}
tools, ok := item["tools"].([]any)
if !ok {
return nil, nil, nil, &responsesRequestError{Message: param + ".tools 必须是数组", Param: param + ".tools", Code: "invalid_parameter"}
}
c.addWarning("additional_tools_position_approximated")
normalized := make([]any, 0, len(tools))
names := make([]string, 0, len(tools))
for index, rawTool := range tools {
converted, err := c.normalizeTool(rawTool, "", false, true, fmt.Sprintf("%s.tools[%d]", param, index))
if err != nil {
return nil, nil, nil, err
}
normalized = append(normalized, converted...)
if tool, ok := rawTool.(map[string]any); ok {
name := strings.TrimSpace(stringField(tool, "name"))
if name == "" {
name = strings.TrimSpace(stringField(tool, "server_label"))
}
if name == "" {
name = strings.TrimSpace(stringField(tool, "type"))
}
if name != "" {
names = append(names, name)
}
}
}
message := "Additional tools become available at this point in the conversation."
if len(names) > 0 {
message += "\nTools: " + strings.Join(names, ", ")
}
return compatibilityBoundaryMessage(message), normalized, cloneJSONArray(tools), nil
}
func compatibilityBoundaryMessage(text string) map[string]any {
return map[string]any{
"type": "message", "role": "developer",
"content": []any{map[string]any{"type": "input_text", "text": text}},
}
}
func dedupeNormalizedTools(tools []any) []any {
result := make([]any, 0, len(tools))
positions := make(map[string]int)
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok {
result = append(result, raw)
continue
}
kind := stringField(tool, "type")
name := stringField(tool, "name")
if name == "" {
name = stringField(tool, "server_label")
}
key := kind + "\x00" + name
if name == "" {
key = kind
}
if index, exists := positions[key]; exists {
result[index] = raw
continue
}
positions[key] = len(result)
result = append(result, raw)
}
return result
}
func (c *responsesToolCompatibility) addWarning(code string) {
if c == nil || code == "" {
return
}
if _, exists := c.warningSet[code]; exists {
return
}
c.warningSet[code] = struct{}{}
c.warnings = append(c.warnings, code)
}
func (c *responsesToolCompatibility) warningHeader() string {
if c == nil {
return ""
}
return strings.Join(c.warnings, ",")
}