mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
265 lines
8.3 KiB
Go
265 lines
8.3 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
// The ChatGPT internal Codex endpoint rejects a request whose input replays a
|
|
// hosted web_search_call item unless the request also declares the web_search
|
|
// tool; the stream then fails with "response protection is unavailable".
|
|
// Codex's local context compaction sends the full history with tools:[], so
|
|
// every compaction after a web search fails (#7927).
|
|
//
|
|
// Declare a cached-only web_search tool for such requests. Responses Lite
|
|
// rejects hosted tools at the top level, so Lite requests carry it in an
|
|
// input additional_tools item instead. When the caller declared no tools at
|
|
// all, also pin tool_choice to "none" so the injected tool cannot be invoked
|
|
// and the request keeps its no-tools semantics.
|
|
|
|
const (
|
|
openAIWebSearchCallItemType = "web_search_call"
|
|
openAIAdditionalToolsItemType = "additional_tools"
|
|
openAICompactionTriggerItemType = "compaction_trigger"
|
|
openAIAdditionalToolsDefaultRole = "developer"
|
|
)
|
|
|
|
var openAIWebSearchHistoryTool = map[string]any{
|
|
"type": "web_search",
|
|
"external_web_access": false,
|
|
}
|
|
|
|
func isOpenAIWebSearchToolType(toolType string) bool {
|
|
return strings.HasPrefix(strings.TrimSpace(toolType), "web_search")
|
|
}
|
|
|
|
func openAIToolsContainWebSearch(rawTools any) bool {
|
|
tools, ok := rawTools.([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, rawTool := range tools {
|
|
tool, ok := rawTool.(map[string]any)
|
|
if ok && isOpenAIWebSearchToolType(firstNonEmptyString(tool["type"])) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// shouldPinOpenAIWebSearchHistoryToolChoice reports whether tool_choice may be
|
|
// forced to "none": only when the caller offered no tools, so the choice
|
|
// cannot meaningfully be anything else.
|
|
func shouldPinOpenAIWebSearchHistoryToolChoice(choice any) bool {
|
|
switch typed := choice.(type) {
|
|
case nil:
|
|
return true
|
|
case string:
|
|
normalized := strings.ToLower(strings.TrimSpace(typed))
|
|
return normalized == "" || normalized == "auto" || normalized == "none"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// openAIAdditionalToolsInsertIndex keeps a trailing compaction trigger last,
|
|
// as required by the remote compaction v2 wire format.
|
|
func openAIAdditionalToolsInsertIndex(itemTypes []string) int {
|
|
if n := len(itemTypes); n > 0 && itemTypes[n-1] == openAICompactionTriggerItemType {
|
|
return n - 1
|
|
}
|
|
return len(itemTypes)
|
|
}
|
|
|
|
// ensureOpenAIOAuthWebSearchToolForHistory is the map variant used by the
|
|
// Codex OAuth transform.
|
|
func ensureOpenAIOAuthWebSearchToolForHistory(reqBody map[string]any, responsesLite bool) bool {
|
|
if reqBody == nil {
|
|
return false
|
|
}
|
|
input, ok := reqBody["input"].([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
hasWebSearchCall := false
|
|
callerDeclaredTools := false
|
|
additionalToolsIndex := -1
|
|
itemTypes := make([]string, len(input))
|
|
for i, rawItem := range input {
|
|
item, ok := rawItem.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
itemTypes[i] = strings.TrimSpace(firstNonEmptyString(item["type"]))
|
|
switch itemTypes[i] {
|
|
case openAIWebSearchCallItemType:
|
|
hasWebSearchCall = true
|
|
case openAIAdditionalToolsItemType:
|
|
if openAIToolsContainWebSearch(item["tools"]) {
|
|
return false
|
|
}
|
|
if tools, _ := item["tools"].([]any); len(tools) > 0 {
|
|
callerDeclaredTools = true
|
|
}
|
|
if additionalToolsIndex < 0 {
|
|
additionalToolsIndex = i
|
|
}
|
|
}
|
|
}
|
|
if !hasWebSearchCall || openAIToolsContainWebSearch(reqBody["tools"]) {
|
|
return false
|
|
}
|
|
tools, _ := reqBody["tools"].([]any)
|
|
callerDeclaredTools = callerDeclaredTools || len(tools) > 0
|
|
|
|
switch {
|
|
case !responsesLite:
|
|
reqBody["tools"] = append(tools, cloneOpenAIWebSearchHistoryTool())
|
|
case additionalToolsIndex >= 0:
|
|
// additionalToolsIndex is only recorded for map items.
|
|
item, _ := input[additionalToolsIndex].(map[string]any)
|
|
existing, _ := item["tools"].([]any)
|
|
item["tools"] = append(existing, cloneOpenAIWebSearchHistoryTool())
|
|
default:
|
|
at := openAIAdditionalToolsInsertIndex(itemTypes)
|
|
additional := map[string]any{
|
|
"type": openAIAdditionalToolsItemType,
|
|
"role": openAIAdditionalToolsDefaultRole,
|
|
"tools": []any{cloneOpenAIWebSearchHistoryTool()},
|
|
}
|
|
next := make([]any, 0, len(input)+1)
|
|
next = append(next, input[:at]...)
|
|
next = append(next, additional)
|
|
next = append(next, input[at:]...)
|
|
reqBody["input"] = next
|
|
}
|
|
if !callerDeclaredTools {
|
|
if choice, exists := reqBody["tool_choice"]; !exists || shouldPinOpenAIWebSearchHistoryToolChoice(choice) {
|
|
reqBody["tool_choice"] = "none"
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ensureOpenAIOAuthWebSearchToolForHistoryBody is the raw-body variant used by
|
|
// the passthrough and WebSocket paths; it avoids decoding the whole body.
|
|
func ensureOpenAIOAuthWebSearchToolForHistoryBody(body []byte, responsesLite bool) ([]byte, bool, error) {
|
|
if len(body) == 0 || !bytes.Contains(body, []byte(openAIWebSearchCallItemType)) {
|
|
return body, false, nil
|
|
}
|
|
input := gjson.GetBytes(body, "input")
|
|
if !input.IsArray() {
|
|
return body, false, nil
|
|
}
|
|
items := input.Array()
|
|
hasWebSearchCall := false
|
|
callerDeclaredTools := false
|
|
additionalToolsIndex := -1
|
|
itemTypes := make([]string, len(items))
|
|
for i, item := range items {
|
|
itemTypes[i] = strings.TrimSpace(item.Get("type").String())
|
|
switch itemTypes[i] {
|
|
case openAIWebSearchCallItemType:
|
|
hasWebSearchCall = true
|
|
case openAIAdditionalToolsItemType:
|
|
itemTools := item.Get("tools")
|
|
if gjsonToolsContainWebSearch(itemTools) {
|
|
return body, false, nil
|
|
}
|
|
if itemTools.IsArray() && len(itemTools.Array()) > 0 {
|
|
callerDeclaredTools = true
|
|
}
|
|
if additionalToolsIndex < 0 {
|
|
additionalToolsIndex = i
|
|
}
|
|
}
|
|
}
|
|
if !hasWebSearchCall {
|
|
return body, false, nil
|
|
}
|
|
tools := gjson.GetBytes(body, "tools")
|
|
if gjsonToolsContainWebSearch(tools) {
|
|
return body, false, nil
|
|
}
|
|
topLevelTools := tools.IsArray() && len(tools.Array()) > 0
|
|
callerDeclaredTools = callerDeclaredTools || topLevelTools
|
|
|
|
var (
|
|
next []byte
|
|
err error
|
|
)
|
|
switch {
|
|
case !responsesLite && topLevelTools:
|
|
next, err = sjson.SetBytes(body, "tools.-1", cloneOpenAIWebSearchHistoryTool())
|
|
case !responsesLite:
|
|
next, err = sjson.SetBytes(body, "tools", []any{cloneOpenAIWebSearchHistoryTool()})
|
|
case additionalToolsIndex >= 0 && items[additionalToolsIndex].Get("tools").IsArray():
|
|
next, err = sjson.SetBytes(body, fmt.Sprintf("input.%d.tools.-1", additionalToolsIndex), cloneOpenAIWebSearchHistoryTool())
|
|
case additionalToolsIndex >= 0:
|
|
next, err = sjson.SetBytes(body, fmt.Sprintf("input.%d.tools", additionalToolsIndex), []any{cloneOpenAIWebSearchHistoryTool()})
|
|
default:
|
|
next, err = insertOpenAIAdditionalToolsItemRaw(body, items, openAIAdditionalToolsInsertIndex(itemTypes))
|
|
}
|
|
if err != nil {
|
|
return body, false, fmt.Errorf("declare web_search tool for web_search_call history: %w", err)
|
|
}
|
|
if !callerDeclaredTools {
|
|
choice := gjson.GetBytes(next, "tool_choice")
|
|
if !choice.Exists() || choice.Type == gjson.Null || (choice.Type == gjson.String && shouldPinOpenAIWebSearchHistoryToolChoice(choice.String())) {
|
|
next, err = sjson.SetBytes(next, "tool_choice", "none")
|
|
if err != nil {
|
|
return body, false, fmt.Errorf("pin tool_choice for web_search_call history: %w", err)
|
|
}
|
|
}
|
|
}
|
|
return next, true, nil
|
|
}
|
|
|
|
func insertOpenAIAdditionalToolsItemRaw(body []byte, items []gjson.Result, at int) ([]byte, error) {
|
|
additional, err := marshalOpenAIUpstreamJSON(map[string]any{
|
|
"type": openAIAdditionalToolsItemType,
|
|
"role": openAIAdditionalToolsDefaultRole,
|
|
"tools": []any{cloneOpenAIWebSearchHistoryTool()},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rawItems := make([][]byte, 0, len(items)+1)
|
|
for i, item := range items {
|
|
if i == at {
|
|
rawItems = append(rawItems, additional)
|
|
}
|
|
rawItems = append(rawItems, []byte(item.Raw))
|
|
}
|
|
if at >= len(items) {
|
|
rawItems = append(rawItems, additional)
|
|
}
|
|
raw := append([]byte{'['}, bytes.Join(rawItems, []byte{','})...)
|
|
raw = append(raw, ']')
|
|
return sjson.SetRawBytes(body, "input", raw)
|
|
}
|
|
|
|
func gjsonToolsContainWebSearch(tools gjson.Result) bool {
|
|
if !tools.IsArray() {
|
|
return false
|
|
}
|
|
for _, tool := range tools.Array() {
|
|
if isOpenAIWebSearchToolType(tool.Get("type").String()) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func cloneOpenAIWebSearchHistoryTool() map[string]any {
|
|
tool := make(map[string]any, len(openAIWebSearchHistoryTool))
|
|
for key, value := range openAIWebSearchHistoryTool {
|
|
tool[key] = value
|
|
}
|
|
return tool
|
|
}
|