Files
李建琦 6d655c9903
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
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

751 lines
23 KiB
Go

package apicompat
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// ResponsesClientToolMapping records the reversible lowering applied before a
// native Responses request is sent to an upstream that only understands
// function tools.
type ResponsesClientToolMapping struct {
CustomTools map[string]bool
ToolSearch bool
NamespaceTools map[string]ResponsesNamespaceName
}
// AdaptResponsesClientTools lowers Codex client-only tools in req to
// ordinary function tools. It mutates req and returns the mapping required to
// restore the upstream response.
func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, bool, error) {
if req == nil {
return ResponsesClientToolMapping{}, false, nil
}
tools, ok := req["tools"].([]any)
if !ok || len(tools) == 0 {
return ResponsesClientToolMapping{}, false, nil
}
discovered, err := promoteResponsesToolSearchDiscoveries(req)
if err != nil {
return ResponsesClientToolMapping{}, false, err
}
if discovered {
tools, _ = req["tools"].([]any)
}
adapter := ResponsesClientToolMapping{CustomTools: make(map[string]bool)}
functionNames := make(map[string]bool)
customNames := make(map[string]bool)
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok {
continue
}
name := strings.TrimSpace(stringValue(tool["name"]))
switch strings.TrimSpace(stringValue(tool["type"])) {
case "function":
if name != "" {
functionNames[name] = true
}
case "custom":
if name != "" {
customNames[name] = true
}
case "tool_search":
adapter.ToolSearch = true
}
}
for name := range customNames {
if functionNames[name] {
return ResponsesClientToolMapping{}, false, fmt.Errorf("custom tool %q conflicts with a function tool of the same name; this upstream cannot disambiguate them, rename one of the tools", name)
}
}
if adapter.ToolSearch && (functionNames[toolSearchProxyName] || customNames[toolSearchProxyName]) {
return ResponsesClientToolMapping{}, false, fmt.Errorf("built-in tool_search conflicts with a declared tool named %q; this upstream cannot disambiguate them, rename the tool", toolSearchProxyName)
}
// Namespace flattening also rewrites namespace-qualified history and choice.
names, flattened, err := FlattenResponsesNamespaces(req)
if err != nil {
return ResponsesClientToolMapping{}, false, err
}
adapter.NamespaceTools = names
if adapter.ToolSearch {
if _, exists := names[toolSearchProxyName]; exists {
return ResponsesClientToolMapping{}, false, fmt.Errorf("built-in tool_search conflicts with namespace tool flattened as %q; this upstream cannot disambiguate them, rename the tool", toolSearchProxyName)
}
}
tools, _ = req["tools"].([]any)
lowered := make([]any, 0, len(tools))
changed := discovered || flattened
seenSearch := false
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok {
lowered = append(lowered, raw)
continue
}
typ := strings.TrimSpace(stringValue(tool["type"]))
name := strings.TrimSpace(stringValue(tool["name"]))
switch typ {
case "custom":
if name == "" {
lowered = append(lowered, raw)
continue
}
copy := copyClientTool(tool)
copy["type"] = "function"
copy["parameters"] = json.RawMessage(customToolInputSchema)
delete(copy, "format")
adapter.CustomTools[name] = true
lowered = append(lowered, copy)
changed = true
case "tool_search":
if seenSearch {
changed = true
continue
}
seenSearch = true
lowered = append(lowered, map[string]any{
"type": "function", "name": toolSearchProxyName,
"description": "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.",
"parameters": json.RawMessage(toolSearchProxySchema),
})
changed = true
default:
lowered = append(lowered, raw)
}
}
if changed {
req["tools"] = lowered
}
historyChanged, err := rewriteClientToolHistory(req["input"], &adapter)
if err != nil {
return ResponsesClientToolMapping{}, false, err
}
if historyChanged {
changed = true
}
if rewriteClientToolChoice(req, &adapter) {
changed = true
}
if len(adapter.CustomTools) == 0 {
adapter.CustomTools = nil
}
if len(adapter.NamespaceTools) == 0 {
adapter.NamespaceTools = nil
}
return adapter, changed, nil
}
// AdaptResponsesClientToolsWithInheritedMapping lowers client-tool history on
// a follow-up request that omits the session-level tools declaration. An
// explicitly present tools field, including an empty or malformed value,
// always replaces the inherited mapping and is handled by the ordinary
// declaration-driven adapter.
func AdaptResponsesClientToolsWithInheritedMapping(
req map[string]any,
inherited ResponsesClientToolMapping,
inheritedLoweredTools ...[]any,
) (ResponsesClientToolMapping, bool, error) {
if req == nil {
return ResponsesClientToolMapping{}, false, nil
}
if _, toolsPresent := req["tools"]; toolsPresent {
return AdaptResponsesClientTools(req)
}
if len(inherited.CustomTools) == 0 && !inherited.ToolSearch && len(inherited.NamespaceTools) == 0 {
return ResponsesClientToolMapping{}, false, nil
}
if len(inheritedLoweredTools) > 0 && len(inheritedLoweredTools[0]) > 0 {
req["tools"] = restoreInheritedResponsesClientToolDeclarations(inheritedLoweredTools[0], inherited)
return AdaptResponsesClientTools(req)
}
changed, err := rewriteClientToolHistory(req["input"], &inherited)
if err != nil {
return ResponsesClientToolMapping{}, false, err
}
if len(inherited.NamespaceTools) > 0 {
before := changed
rewriteNamespaceQualifiedCalls(req["input"], inherited.NamespaceTools)
// Namespace rewriting does not currently report whether it changed a
// value. A retained namespace mapping is only used for follow-up
// history, so conservatively rebuild the request when input exists.
if _, inputPresent := req["input"]; inputPresent && !before {
changed = true
}
}
if rewriteClientToolChoice(req, &inherited) {
changed = true
}
return inherited, changed, nil
}
func copyClientTool(tool map[string]any) map[string]any {
copy := make(map[string]any, len(tool))
for key, value := range tool {
copy[key] = value
}
return copy
}
func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) (bool, error) {
changed := false
var visit func(any) error
visit = func(value any) error {
switch typed := value.(type) {
case []any:
for _, item := range typed {
if err := visit(item); err != nil {
return err
}
}
case map[string]any:
typ := strings.TrimSpace(stringValue(typed["type"]))
switch typ {
case "custom_tool_call":
if adapter.CustomTools[strings.TrimSpace(stringValue(typed["name"]))] {
typed["type"] = "function_call"
typed["arguments"] = customToolCallArguments(stringValue(typed["input"]))
delete(typed, "input")
dropInvalidLoweredFunctionItemID(typed)
changed = true
}
case "custom_tool_call_output":
typed["type"] = "function_call_output"
dropInvalidLoweredFunctionItemID(typed)
normalizeClientToolOutput(typed)
changed = true
case "tool_search_call":
if adapter.ToolSearch {
typed["type"] = "function_call"
typed["name"] = toolSearchProxyName
typed["arguments"] = rawObjectString(typed["arguments"])
delete(typed, "execution")
dropInvalidLoweredFunctionItemID(typed)
changed = true
}
case "tool_search_output":
if adapter.ToolSearch {
callID := strings.TrimSpace(stringValue(typed["call_id"]))
if callID == "" {
return fmt.Errorf("tool_search_output requires a non-empty string call_id before it can be lowered to function_call_output")
}
typed["type"] = "function_call_output"
dropInvalidLoweredFunctionItemID(typed)
if err := normalizeToolSearchOutput(typed); err != nil {
return err
}
changed = true
}
}
for _, child := range typed {
if err := visit(child); err != nil {
return err
}
}
}
return nil
}
if err := visit(value); err != nil {
return false, err
}
return changed, nil
}
// dropInvalidLoweredFunctionItemID removes Codex client-only item IDs such as
// ctc_*, ctco_*, tsc_*, and tso_* after their item type is lowered to the
// function protocol. Function upstreams validate these IDs with the fc prefix;
// call_id, which is preserved separately, is the tool call/output pairing key.
func dropInvalidLoweredFunctionItemID(item map[string]any) {
id := strings.TrimSpace(stringValue(item["id"]))
if id != "" && !strings.HasPrefix(id, "fc") {
delete(item, "id")
}
}
func normalizeClientToolOutput(item map[string]any) {
output, exists := item["output"]
if !exists {
return
}
if _, ok := output.(string); ok {
return
}
if output == nil {
item["output"] = ""
return
}
encoded, err := json.Marshal(output)
if err != nil {
item["output"] = ""
return
}
item["output"] = string(encoded)
}
// normalizeToolSearchOutput converts both tool_search output wire shapes into
// the string output required by function_call_output. Older clients send an
// output field directly; newer Codex clients return discovered definitions in
// a top-level tools field. Codex treats that field's value as the tool output,
// so serialize the value directly rather than wrapping it in another object.
func normalizeToolSearchOutput(item map[string]any) error {
if output, hasOutput := item["output"]; hasOutput {
switch typed := output.(type) {
case string:
item["output"] = typed
case nil:
item["output"] = ""
default:
encoded, err := json.Marshal(typed)
if err != nil {
return fmt.Errorf("tool_search_output output cannot be encoded as function_call_output output: %w", err)
}
item["output"] = string(encoded)
}
dropToolSearchOutputPrivateFields(item)
return nil
}
tools, hasTools := item["tools"]
if !hasTools {
return fmt.Errorf("tool_search_output requires output or tools before it can be lowered to function_call_output")
}
encoded, err := json.Marshal(tools)
if err != nil {
return fmt.Errorf("tool_search_output tools cannot be encoded as function_call_output output: %w", err)
}
item["output"] = string(encoded)
dropToolSearchOutputPrivateFields(item)
return nil
}
func dropToolSearchOutputPrivateFields(item map[string]any) {
delete(item, "tools")
delete(item, "status")
delete(item, "execution")
}
func rewriteClientToolChoice(req map[string]any, adapter *ResponsesClientToolMapping) bool {
choice, ok := req["tool_choice"].(map[string]any)
if !ok {
return false
}
typ := strings.TrimSpace(stringValue(choice["type"]))
name := strings.TrimSpace(stringValue(choice["name"]))
if typ == "custom" && adapter.CustomTools[name] {
choice["type"] = "function"
return true
}
if typ == "tool_search" && adapter.ToolSearch {
req["tool_choice"] = map[string]any{"type": "function", "name": toolSearchProxyName}
return true
}
return false
}
func customToolCallArguments(input string) string {
encoded, _ := json.Marshal(map[string]string{"input": input})
return string(encoded)
}
func rawObjectString(value any) string {
if text, ok := value.(string); ok {
return text
}
encoded, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(encoded)
}
// RestoreResponsesClientToolPayload restores client tool calls in a non-stream
// native Responses JSON payload.
func RestoreResponsesClientToolPayload(payload []byte, mapping ResponsesClientToolMapping) ([]byte, bool, error) {
if len(payload) == 0 {
return payload, false, nil
}
var value any
if err := json.Unmarshal(payload, &value); err != nil {
return payload, false, err
}
changed := restoreClientToolValue(value, &mapping)
if !changed {
if len(mapping.NamespaceTools) == 0 {
return payload, false, nil
}
return RestoreResponsesNamespaceCalls(payload, mapping.NamespaceTools)
}
var rebuilt bytes.Buffer
encoder := json.NewEncoder(&rebuilt)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return payload, false, err
}
rebuiltPayload := bytes.TrimSuffix(rebuilt.Bytes(), []byte("\n"))
if len(mapping.NamespaceTools) == 0 {
return rebuiltPayload, true, nil
}
restored, _, err := RestoreResponsesNamespaceCalls(rebuiltPayload, mapping.NamespaceTools)
if err != nil {
return payload, false, err
}
return restored, true, nil
}
func restoreClientToolValue(value any, adapter *ResponsesClientToolMapping) bool {
changed := false
switch typed := value.(type) {
case []any:
for _, item := range typed {
changed = restoreClientToolValue(item, adapter) || changed
}
case map[string]any:
if strings.TrimSpace(stringValue(typed["type"])) == "function_call" {
name := strings.TrimSpace(stringValue(typed["name"]))
if adapter.CustomTools[name] {
typed["type"] = "custom_tool_call"
typed["input"] = extractCustomToolCallInput(rawObjectString(typed["arguments"]))
delete(typed, "arguments")
delete(typed, "namespace")
changed = true
} else if adapter.ToolSearch && name == toolSearchProxyName {
typed["type"] = "tool_search_call"
typed["execution"] = "client"
typed["arguments"] = json.RawMessage(toolSearchCallArgumentsJSON(rawObjectString(typed["arguments"])))
delete(typed, "name")
delete(typed, "namespace")
changed = true
}
}
for _, child := range typed {
changed = restoreClientToolValue(child, adapter) || changed
}
}
return changed
}
// ResponsesClientToolStreamRestorer restores client tool stream lifecycles.
// It is intentionally stateful because custom tools need their function
// arguments buffered until the upstream signals the call is complete.
type ResponsesClientToolStreamRestorer struct {
adapter ResponsesClientToolMapping
nextSeq int
seenSeq bool
calls map[string]*responsesClientToolStreamCall
byOutput map[int]*responsesClientToolStreamCall
}
type responsesClientToolStreamCall struct {
kind string
name string
callID string
itemID string
outputIdx int
arguments strings.Builder
}
func NewResponsesClientToolStreamRestorer(mapping ResponsesClientToolMapping) *ResponsesClientToolStreamRestorer {
return &ResponsesClientToolStreamRestorer{adapter: mapping, calls: make(map[string]*responsesClientToolStreamCall), byOutput: make(map[int]*responsesClientToolStreamCall)}
}
// Restore transforms one upstream SSE event into zero or more client events.
// Returned sequence numbers are continuous even when function argument events
// are suppressed or a custom completion expands into two events.
func (r *ResponsesClientToolStreamRestorer) Restore(event ResponsesStreamEvent) []ResponsesStreamEvent {
if r == nil {
return []ResponsesStreamEvent{event}
}
if !r.seenSeq {
r.nextSeq = event.SequenceNumber
r.seenSeq = true
}
var out []ResponsesStreamEvent
emit := func(event ResponsesStreamEvent) {
event.SequenceNumber = r.nextSeq
r.nextSeq++
out = append(out, event)
}
switch event.Type {
case "response.output_item.added":
if call := r.recordItem(event); call != nil {
if call.kind == "custom" {
event.Item.Type = "custom_tool_call"
event.Item.Input = ""
event.Item.Arguments = ""
event.Item.Namespace = ""
} else {
event.Item.Type = "tool_search_call"
event.Item.Name = ""
event.Item.Arguments = "{}"
event.Item.Namespace = ""
}
}
emit(r.restoreNamespaceEvent(event))
case "response.function_call_arguments.delta":
if call := r.callFor(event); call != nil {
_, _ = call.arguments.WriteString(event.Delta)
return nil
}
emit(r.restoreNamespaceEvent(event))
case "response.function_call_arguments.done":
if call := r.callFor(event); call != nil {
if event.Arguments != "" {
call.arguments.Reset()
_, _ = call.arguments.WriteString(event.Arguments)
}
if call.kind == "custom" {
input := extractCustomToolCallInput(call.arguments.String())
if input != "" {
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.delta", OutputIndex: call.outputIdx, ItemID: call.itemID, Delta: input})
}
emit(ResponsesStreamEvent{Type: "response.custom_tool_call_input.done", OutputIndex: call.outputIdx, ItemID: call.itemID, CallID: call.callID, Name: call.name, Input: input})
}
return out
}
emit(r.restoreNamespaceEvent(event))
case "response.output_item.done":
if call := r.recordItem(event); call != nil {
if call.kind == "custom" {
event.Item.Type = "custom_tool_call"
event.Item.Input = extractCustomToolCallInput(call.arguments.String())
event.Item.Arguments = ""
event.Item.Namespace = ""
} else {
event.Item.Type = "tool_search_call"
event.Item.Name = ""
event.Item.Arguments = call.arguments.String()
if strings.TrimSpace(event.Item.Arguments) == "" {
event.Item.Arguments = "{}"
}
event.Item.Namespace = ""
}
delete(r.calls, call.itemID)
delete(r.calls, call.callID)
delete(r.byOutput, call.outputIdx)
}
emit(r.restoreNamespaceEvent(event))
default:
// response.completed carries the non-stream representation.
if event.Response != nil {
restoreResponsesOutputClientTools(event.Response.Output, &r.adapter)
}
emit(r.restoreNamespaceEvent(event))
}
return out
}
// RestoreEvent restores one Responses SSE JSON data payload. Custom tool
// completions can expand to multiple payloads and proxy argument deltas can be
// intentionally dropped, hence the slice return value.
func (r *ResponsesClientToolStreamRestorer) RestoreEvent(payload []byte) ([][]byte, bool, error) {
if len(payload) == 0 {
return nil, false, nil
}
var wire struct {
Type string `json:"type"`
Sequence int `json:"sequence_number"`
}
if err := json.Unmarshal(payload, &wire); err != nil {
return nil, false, err
}
if isResponsesClientToolTerminalEvent(wire.Type) {
restored, changed, err := RestoreResponsesClientToolPayload(payload, r.adapter)
if err != nil {
return nil, false, err
}
return r.resequenceRaw(restored, wire.Sequence, changed)
}
if !clientToolLifecycleEvent(wire.Type) {
return r.resequenceRaw(payload, wire.Sequence, false)
}
if !r.clientToolEventPayload(payload) {
return r.resequenceRaw(payload, wire.Sequence, false)
}
var event ResponsesStreamEvent
if err := json.Unmarshal(payload, &event); err != nil {
return nil, false, err
}
events := r.Restore(event)
if len(events) == 1 {
unchanged, err := json.Marshal(events[0])
if err == nil && bytes.Equal(bytes.TrimSpace(unchanged), bytes.TrimSpace(payload)) {
return [][]byte{payload}, false, nil
}
}
result := make([][]byte, 0, len(events))
for _, restored := range events {
encoded, err := json.Marshal(restored)
if err != nil {
return nil, false, err
}
result = append(result, encoded)
}
return result, true, nil
}
func isResponsesClientToolTerminalEvent(typ string) bool {
switch strings.TrimSpace(typ) {
case "response.completed", "response.done", "response.incomplete", "response.failed", "response.cancelled", "response.canceled":
return true
default:
return false
}
}
func (r *ResponsesClientToolStreamRestorer) clientToolEventPayload(payload []byte) bool {
var raw struct {
ItemID string `json:"item_id"`
CallID string `json:"call_id"`
Name string `json:"name"`
OutputIndex int `json:"output_index"`
Item *struct {
Type string `json:"type"`
ID string `json:"id"`
CallID string `json:"call_id"`
Name string `json:"name"`
} `json:"item"`
}
if err := json.Unmarshal(payload, &raw); err != nil {
return false
}
if raw.Item != nil {
if raw.Item.Type != "function_call" {
return false
}
_, namespaceTool := r.adapter.NamespaceTools[raw.Item.Name]
return r.adapter.CustomTools[raw.Item.Name] || (r.adapter.ToolSearch && raw.Item.Name == toolSearchProxyName) || namespaceTool || r.calls[raw.Item.ID] != nil || r.calls[raw.Item.CallID] != nil
}
if _, namespaceTool := r.adapter.NamespaceTools[raw.Name]; namespaceTool {
return true
}
if r.calls[raw.ItemID] != nil || r.calls[raw.CallID] != nil || r.byOutput[raw.OutputIndex] != nil {
return true
}
return false
}
func clientToolLifecycleEvent(typ string) bool {
switch typ {
case "response.output_item.added", "response.output_item.done", "response.function_call_arguments.delta", "response.function_call_arguments.done":
return true
default:
return false
}
}
// resequenceRaw deliberately keeps opaque upstream event fields untouched.
func (r *ResponsesClientToolStreamRestorer) resequenceRaw(payload []byte, sequence int, changed bool) ([][]byte, bool, error) {
if !r.seenSeq {
r.nextSeq, r.seenSeq = sequence, true
}
if r.nextSeq == sequence && !changed {
r.nextSeq++
return [][]byte{payload}, false, nil
}
var raw map[string]any
if err := json.Unmarshal(payload, &raw); err != nil {
return nil, false, err
}
raw["sequence_number"] = r.nextSeq
r.nextSeq++
encoded, err := json.Marshal(raw)
if err != nil {
return nil, false, err
}
return [][]byte{encoded}, true, nil
}
func (r *ResponsesClientToolStreamRestorer) recordItem(event ResponsesStreamEvent) *responsesClientToolStreamCall {
if event.Item == nil || event.Item.Type != "function_call" {
return nil
}
name := event.Item.Name
kind := ""
if r.adapter.CustomTools[name] {
kind = "custom"
} else if r.adapter.ToolSearch && name == toolSearchProxyName {
kind = "tool_search"
}
if kind == "" {
return nil
}
key := event.Item.ID
if key == "" {
key = event.Item.CallID
}
call := r.calls[key]
if call == nil {
call = &responsesClientToolStreamCall{kind: kind, name: name, callID: event.Item.CallID, itemID: event.Item.ID, outputIdx: event.OutputIndex}
r.calls[key] = call
if call.callID != "" {
r.calls[call.callID] = call
}
r.byOutput[call.outputIdx] = call
}
if event.Item.Arguments != "" {
call.arguments.Reset()
_, _ = call.arguments.WriteString(event.Item.Arguments)
}
return call
}
func (r *ResponsesClientToolStreamRestorer) callFor(event ResponsesStreamEvent) *responsesClientToolStreamCall {
if call := r.calls[event.ItemID]; call != nil {
return call
}
if call := r.byOutput[event.OutputIndex]; call != nil {
return call
}
for _, call := range r.calls {
if (event.CallID != "" && call.callID == event.CallID) || (event.ItemID == "" && event.Name != "" && call.name == event.Name) {
return call
}
}
return nil
}
func (r *ResponsesClientToolStreamRestorer) restoreNamespaceEvent(event ResponsesStreamEvent) ResponsesStreamEvent {
if len(r.adapter.NamespaceTools) == 0 {
return event
}
if event.Item != nil && event.Item.Type == "function_call" {
if name, ok := r.adapter.NamespaceTools[event.Item.Name]; ok {
event.Item.Name, event.Item.Namespace = name.Name, name.Namespace
}
}
if event.Type == "response.function_call_arguments.delta" || event.Type == "response.function_call_arguments.done" {
if name, ok := r.adapter.NamespaceTools[event.Name]; ok {
event.Name = name.Name
}
}
return event
}
func restoreResponsesOutputClientTools(outputs []ResponsesOutput, adapter *ResponsesClientToolMapping) {
for index := range outputs {
output := &outputs[index]
if output.Type != "function_call" {
continue
}
if adapter.CustomTools[output.Name] {
output.Type = "custom_tool_call"
output.Input = extractCustomToolCallInput(output.Arguments)
output.Arguments = ""
output.Namespace = ""
} else if adapter.ToolSearch && output.Name == toolSearchProxyName {
output.Type = "tool_search_call"
output.Name = ""
output.Namespace = ""
}
if name, ok := adapter.NamespaceTools[output.Name]; ok && output.Type == "function_call" {
output.Name, output.Namespace = name.Name, name.Namespace
}
}
}