456 lines
13 KiB
Go
456 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
const (
|
|
codexReservedPythonToolName = "python"
|
|
codexPythonToolAlias = "python__sub2api"
|
|
codexToolNameReverseKey = "openai_codex_tool_name_reverse"
|
|
codexToolNameSessionKey = "openai_codex_tool_name_session_reverse"
|
|
)
|
|
|
|
type codexToolNameField struct {
|
|
object map[string]any
|
|
key string
|
|
name string
|
|
}
|
|
|
|
// aliasOpenAIOAuthReservedToolNames avoids names reserved by the ChatGPT
|
|
// Codex backend. It validates every declaration/reference before mutating so
|
|
// collisions cannot leave a partially rewritten request.
|
|
func aliasOpenAIOAuthReservedToolNames(reqBody map[string]any) (map[string]string, bool, error) {
|
|
if reqBody == nil {
|
|
return nil, false, nil
|
|
}
|
|
|
|
fields := collectOpenAIResponsesToolNameFields(reqBody)
|
|
owners := make(map[string]string)
|
|
reverse := make(map[string]string)
|
|
for _, field := range fields {
|
|
normalized := aliasOpenAIOAuthReservedToolName(field.name)
|
|
original := field.name
|
|
if normalized != field.name {
|
|
original = strings.TrimSpace(field.name)
|
|
}
|
|
if previous, exists := owners[normalized]; exists && previous != original {
|
|
return nil, false, fmt.Errorf("tool names %q and %q both normalize to %q", previous, original, normalized)
|
|
}
|
|
owners[normalized] = original
|
|
if normalized != field.name {
|
|
reverse[normalized] = original
|
|
}
|
|
}
|
|
if len(reverse) == 0 {
|
|
return nil, false, nil
|
|
}
|
|
for _, field := range fields {
|
|
if aliased := aliasOpenAIOAuthReservedToolName(field.name); aliased != field.name {
|
|
field.object[field.key] = aliased
|
|
}
|
|
}
|
|
return reverse, true, nil
|
|
}
|
|
|
|
func aliasOpenAIOAuthReservedToolName(name string) string {
|
|
trimmed := strings.TrimSpace(name)
|
|
if strings.EqualFold(trimmed, codexReservedPythonToolName) {
|
|
return codexPythonToolAlias
|
|
}
|
|
return name
|
|
}
|
|
|
|
func collectOpenAIResponsesToolNameFields(reqBody map[string]any) []codexToolNameField {
|
|
fields := make([]codexToolNameField, 0, 8)
|
|
appendName := func(object map[string]any, key string) {
|
|
if object == nil {
|
|
return
|
|
}
|
|
name, ok := object[key].(string)
|
|
if !ok || strings.TrimSpace(name) == "" {
|
|
return
|
|
}
|
|
fields = append(fields, codexToolNameField{object: object, key: key, name: name})
|
|
}
|
|
var collectTools func(any)
|
|
collectTools = func(rawTools any) {
|
|
tools, ok := rawTools.([]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
for _, raw := range tools {
|
|
tool, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
toolType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(tool["type"])))
|
|
if toolType == "function" {
|
|
appendName(tool, "name")
|
|
if function, ok := tool["function"].(map[string]any); ok {
|
|
appendName(function, "name")
|
|
}
|
|
}
|
|
if toolType == "namespace" {
|
|
collectTools(tool["tools"])
|
|
}
|
|
}
|
|
}
|
|
collectTools(reqBody["tools"])
|
|
if functions, ok := reqBody["functions"].([]any); ok {
|
|
for _, raw := range functions {
|
|
function, _ := raw.(map[string]any)
|
|
appendName(function, "name")
|
|
}
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(reqBody["type"])), "session.update") {
|
|
if session, ok := reqBody["session"].(map[string]any); ok {
|
|
collectTools(session["tools"])
|
|
}
|
|
}
|
|
if choice, ok := reqBody["tool_choice"].(map[string]any); ok {
|
|
if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "function") {
|
|
appendName(choice, "name")
|
|
if function, ok := choice["function"].(map[string]any); ok {
|
|
appendName(function, "name")
|
|
}
|
|
}
|
|
}
|
|
if input, ok := reqBody["input"].([]any); ok {
|
|
for _, raw := range input {
|
|
item, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
typ := strings.ToLower(strings.TrimSpace(firstNonEmptyString(item["type"])))
|
|
if typ == "additional_tools" {
|
|
collectTools(item["tools"])
|
|
}
|
|
if typ == "function_call" {
|
|
appendName(item, "name")
|
|
if function, ok := item["function"].(map[string]any); ok {
|
|
appendName(function, "name")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func aliasOpenAIOAuthReservedToolNamesBody(body []byte) ([]byte, map[string]string, bool, error) {
|
|
if len(body) == 0 || !containsASCIIFold(body, []byte(codexReservedPythonToolName)) {
|
|
return body, nil, false, nil
|
|
}
|
|
var reqBody map[string]any
|
|
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
|
|
return body, nil, false, fmt.Errorf("decode OAuth reserved tool names: %w", err)
|
|
}
|
|
reverse, changed, err := aliasOpenAIOAuthReservedToolNames(reqBody)
|
|
if err != nil || !changed {
|
|
return body, reverse, false, err
|
|
}
|
|
normalized, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return body, nil, false, fmt.Errorf("encode OAuth reserved tool names: %w", err)
|
|
}
|
|
return normalized, reverse, true, nil
|
|
}
|
|
|
|
func containsASCIIFold(haystack, needle []byte) bool {
|
|
if len(needle) == 0 || len(haystack) < len(needle) {
|
|
return false
|
|
}
|
|
for i := 0; i <= len(haystack)-len(needle); i++ {
|
|
matched := true
|
|
for j := range needle {
|
|
a, b := haystack[i+j], needle[j]
|
|
if a >= 'A' && a <= 'Z' {
|
|
a += 'a' - 'A'
|
|
}
|
|
if b >= 'A' && b <= 'Z' {
|
|
b += 'a' - 'A'
|
|
}
|
|
if a != b {
|
|
matched = false
|
|
break
|
|
}
|
|
}
|
|
if matched {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func setCodexToolNameReverse(c *gin.Context, reverse map[string]string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
storeCodexToolNameReverse(c, codexToolNameReverseKey, reverse)
|
|
storeCodexToolNameReverse(c, codexToolNameSessionKey, nil)
|
|
}
|
|
|
|
func storeCodexToolNameReverse(c *gin.Context, key string, reverse map[string]string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
copyMap := make(map[string]string, len(reverse))
|
|
for aliased, original := range reverse {
|
|
copyMap[aliased] = original
|
|
}
|
|
c.Set(key, copyMap)
|
|
}
|
|
|
|
func mergeCodexToolNameReverse(c *gin.Context, reverse map[string]string) {
|
|
if c == nil || len(reverse) == 0 {
|
|
return
|
|
}
|
|
merged := make(map[string]string, len(reverse)+len(codexToolNameReverseFromContext(c)))
|
|
for aliased, original := range codexToolNameReverseFromContext(c) {
|
|
merged[aliased] = original
|
|
}
|
|
for aliased, original := range reverse {
|
|
merged[aliased] = original
|
|
}
|
|
storeCodexToolNameReverse(c, codexToolNameReverseKey, merged)
|
|
}
|
|
|
|
func codexToolNameReverseFromContext(c *gin.Context) map[string]string {
|
|
return codexToolNameReverseForKey(c, codexToolNameReverseKey)
|
|
}
|
|
|
|
func codexToolNameReverseForKey(c *gin.Context, key string) map[string]string {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
raw, ok := c.Get(key)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
reverse, _ := raw.(map[string]string)
|
|
return reverse
|
|
}
|
|
|
|
// updateCodexToolNameReverseForWSFrame keeps the active turn isolated from
|
|
// session updates that may arrive while that turn is still streaming.
|
|
func updateCodexToolNameReverseForWSFrame(c *gin.Context, frame []byte, reverse map[string]string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
eventType := strings.TrimSpace(gjson.GetBytes(frame, "type").String())
|
|
switch eventType {
|
|
case "session.update":
|
|
if gjson.GetBytes(frame, "session.tools").Exists() {
|
|
storeCodexToolNameReverse(c, codexToolNameSessionKey, reverse)
|
|
}
|
|
case "response.create", "":
|
|
active := reverse
|
|
if !openAIWSFrameHasExplicitToolDeclarations(frame) {
|
|
active = mergeCodexToolNameReverseMaps(
|
|
codexToolNameReverseForKey(c, codexToolNameSessionKey),
|
|
reverse,
|
|
)
|
|
}
|
|
storeCodexToolNameReverse(c, codexToolNameReverseKey, active)
|
|
}
|
|
}
|
|
|
|
func openAIWSFrameHasExplicitToolDeclarations(frame []byte) bool {
|
|
if gjson.GetBytes(frame, "tools").Exists() {
|
|
return true
|
|
}
|
|
for _, item := range gjson.GetBytes(frame, "input").Array() {
|
|
if strings.EqualFold(strings.TrimSpace(item.Get("type").String()), "additional_tools") && item.Get("tools").Exists() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mergeCodexToolNameReverseMaps(base, overlay map[string]string) map[string]string {
|
|
merged := make(map[string]string, len(base)+len(overlay))
|
|
for aliased, original := range base {
|
|
merged[aliased] = original
|
|
}
|
|
for aliased, original := range overlay {
|
|
merged[aliased] = original
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func restoreCodexToolNamesInJSON(data []byte, reverse map[string]string) []byte {
|
|
if len(data) == 0 || len(reverse) == 0 || !json.Valid(data) {
|
|
return data
|
|
}
|
|
var decoded any
|
|
if err := decodeOpenAIJSONUseNumber(data, &decoded); err != nil {
|
|
return data
|
|
}
|
|
if !restoreCodexToolNameFields(decoded, reverse) {
|
|
return data
|
|
}
|
|
restored, err := json.Marshal(decoded)
|
|
if err != nil {
|
|
return data
|
|
}
|
|
return restored
|
|
}
|
|
|
|
func restoreCodexToolNamesFromContext(c *gin.Context, data []byte) []byte {
|
|
reverse := codexToolNameReverseFromContext(c)
|
|
switch strings.TrimSpace(gjson.GetBytes(data, "type").String()) {
|
|
case "session.created", "session.updated":
|
|
reverse = codexToolNameReverseForKey(c, codexToolNameSessionKey)
|
|
}
|
|
return restoreCodexToolNamesInJSON(data, reverse)
|
|
}
|
|
|
|
func restoreCodexToolNamesFromSSEContext(c *gin.Context, data []byte, eventType string) []byte {
|
|
if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "" || strings.TrimSpace(eventType) == "" {
|
|
return restoreCodexToolNamesFromContext(c, data)
|
|
}
|
|
compat := []byte(openAICompatPayloadWithEventType(string(data), eventType))
|
|
restored := restoreCodexToolNamesFromContext(c, compat)
|
|
if string(restored) == string(compat) {
|
|
return data
|
|
}
|
|
withoutSyntheticType, err := sjson.DeleteBytes(restored, "type")
|
|
if err != nil {
|
|
return restored
|
|
}
|
|
return withoutSyntheticType
|
|
}
|
|
|
|
func restoreCodexToolNameFields(value any, reverse map[string]string) bool {
|
|
root, ok := value.(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
changed := false
|
|
restoreItem := func(raw any) {
|
|
item, ok := raw.(map[string]any)
|
|
if !ok || !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(item["type"])), "function_call") {
|
|
return
|
|
}
|
|
name, _ := item["name"].(string)
|
|
if original, exists := reverse[name]; exists {
|
|
item["name"] = original
|
|
changed = true
|
|
}
|
|
}
|
|
restoreOutput := func(raw any) {
|
|
output, _ := raw.([]any)
|
|
for _, item := range output {
|
|
restoreItem(item)
|
|
}
|
|
}
|
|
restoreResponse := func(raw any) {
|
|
response, ok := raw.(map[string]any)
|
|
if ok {
|
|
restoreOutput(response["output"])
|
|
}
|
|
}
|
|
restoreFunction := func(raw any) {
|
|
function, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
name, _ := function["name"].(string)
|
|
if original, exists := reverse[name]; exists {
|
|
function["name"] = original
|
|
changed = true
|
|
}
|
|
}
|
|
restoreChatToolCalls := func(raw any) {
|
|
toolCalls, _ := raw.([]any)
|
|
for _, rawCall := range toolCalls {
|
|
call, ok := rawCall.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
callType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(call["type"])))
|
|
if callType == "" || callType == "function" {
|
|
restoreFunction(call["function"])
|
|
}
|
|
}
|
|
}
|
|
restoreMessageContent := func(raw any) {
|
|
content, _ := raw.([]any)
|
|
for _, rawBlock := range content {
|
|
block, ok := rawBlock.(map[string]any)
|
|
if !ok || !strings.EqualFold(strings.TrimSpace(firstNonEmptyString(block["type"])), "tool_use") {
|
|
continue
|
|
}
|
|
name, _ := block["name"].(string)
|
|
if original, exists := reverse[name]; exists {
|
|
block["name"] = original
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
var restoreTools func(any)
|
|
restoreTools = func(raw any) {
|
|
tools, _ := raw.([]any)
|
|
for _, rawTool := range tools {
|
|
tool, ok := rawTool.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
toolType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(tool["type"])))
|
|
if toolType == "function" {
|
|
name, _ := tool["name"].(string)
|
|
if original, exists := reverse[name]; exists {
|
|
tool["name"] = original
|
|
changed = true
|
|
}
|
|
}
|
|
if toolType == "namespace" {
|
|
restoreTools(tool["tools"])
|
|
}
|
|
}
|
|
}
|
|
|
|
eventType := strings.ToLower(strings.TrimSpace(firstNonEmptyString(root["type"])))
|
|
switch eventType {
|
|
case "response.output_item.added", "response.output_item.done":
|
|
restoreItem(root["item"])
|
|
case "response.created", "response.in_progress", "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
|
restoreResponse(root["response"])
|
|
case "session.created", "session.updated":
|
|
if session, ok := root["session"].(map[string]any); ok {
|
|
restoreTools(session["tools"])
|
|
}
|
|
}
|
|
if _, hasOutput := root["output"]; hasOutput {
|
|
restoreOutput(root["output"])
|
|
}
|
|
if choices, ok := root["choices"].([]any); ok {
|
|
for _, rawChoice := range choices {
|
|
choice, ok := rawChoice.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, key := range []string{"message", "delta"} {
|
|
if message, ok := choice[key].(map[string]any); ok {
|
|
restoreChatToolCalls(message["tool_calls"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
restoreMessageContent(root["content"])
|
|
if block, ok := root["content_block"].(map[string]any); ok && strings.EqualFold(strings.TrimSpace(firstNonEmptyString(block["type"])), "tool_use") {
|
|
name, _ := block["name"].(string)
|
|
if original, exists := reverse[name]; exists {
|
|
block["name"] = original
|
|
changed = true
|
|
}
|
|
}
|
|
return changed
|
|
}
|