737 lines
25 KiB
Go
737 lines
25 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
func normalizeResponsesWebsocketRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte) ([]byte, []byte, *interfaces.ErrorMessage) {
|
|
return normalizeResponsesWebsocketRequestWithMode(rawJSON, lastRequest, lastResponseOutput, true, true)
|
|
}
|
|
|
|
func normalizeResponsesWebsocketRequestWithMode(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool, allowCompactionReplayBypass bool) ([]byte, []byte, *interfaces.ErrorMessage) {
|
|
return normalizeResponsesWebsocketRequestWithLastResponseID(rawJSON, lastRequest, lastResponseOutput, "", allowIncrementalInputWithPreviousResponseID, allowCompactionReplayBypass)
|
|
}
|
|
|
|
func normalizeResponsesWebsocketRequestWithLastResponseID(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, lastResponseID string, allowIncrementalInputWithPreviousResponseID bool, allowCompactionReplayBypass bool) ([]byte, []byte, *interfaces.ErrorMessage) {
|
|
return normalizeResponsesWebsocketRequestWithIncrementalState(rawJSON, lastRequest, lastResponseOutput, lastResponseID, nil, allowIncrementalInputWithPreviousResponseID, allowCompactionReplayBypass)
|
|
}
|
|
|
|
func normalizeResponsesWebsocketRequestWithIncrementalState(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, lastResponseID string, lastResponsePendingToolCallIDs []string, allowIncrementalInputWithPreviousResponseID bool, allowCompactionReplayBypass bool) ([]byte, []byte, *interfaces.ErrorMessage) {
|
|
requestType := strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String())
|
|
switch requestType {
|
|
case wsRequestTypeCreate:
|
|
// log.Infof("responses websocket: response.create request")
|
|
if len(lastRequest) == 0 {
|
|
return normalizeResponseCreateRequest(rawJSON)
|
|
}
|
|
return normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, lastResponseID, lastResponsePendingToolCallIDs, allowIncrementalInputWithPreviousResponseID, allowCompactionReplayBypass)
|
|
case wsRequestTypeAppend:
|
|
// log.Infof("responses websocket: response.append request")
|
|
return normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, lastResponseID, lastResponsePendingToolCallIDs, allowIncrementalInputWithPreviousResponseID, allowCompactionReplayBypass)
|
|
default:
|
|
return nil, lastRequest, &interfaces.ErrorMessage{
|
|
StatusCode: http.StatusBadRequest,
|
|
Error: fmt.Errorf("unsupported websocket request type: %s", requestType),
|
|
}
|
|
}
|
|
}
|
|
|
|
func normalizeResponseCreateRequest(rawJSON []byte) ([]byte, []byte, *interfaces.ErrorMessage) {
|
|
normalized, errDelete := sjson.DeleteBytes(rawJSON, "type")
|
|
if errDelete != nil {
|
|
normalized = bytes.Clone(rawJSON)
|
|
}
|
|
normalized, _ = sjson.SetBytes(normalized, "stream", true)
|
|
if !gjson.GetBytes(normalized, "input").Exists() {
|
|
normalized, _ = sjson.SetRawBytes(normalized, "input", []byte("[]"))
|
|
}
|
|
|
|
modelName := strings.TrimSpace(gjson.GetBytes(normalized, "model").String())
|
|
if modelName == "" {
|
|
return nil, nil, &interfaces.ErrorMessage{
|
|
StatusCode: http.StatusBadRequest,
|
|
Error: fmt.Errorf("missing model in response.create request"),
|
|
}
|
|
}
|
|
return normalized, bytes.Clone(normalized), nil
|
|
}
|
|
|
|
func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, lastResponseID string, lastResponsePendingToolCallIDs []string, allowIncrementalInputWithPreviousResponseID bool, allowCompactionReplayBypass bool) ([]byte, []byte, *interfaces.ErrorMessage) {
|
|
if len(lastRequest) == 0 {
|
|
return nil, lastRequest, &interfaces.ErrorMessage{
|
|
StatusCode: http.StatusBadRequest,
|
|
Error: fmt.Errorf("websocket request received before response.create"),
|
|
}
|
|
}
|
|
|
|
nextInput := gjson.GetBytes(rawJSON, "input")
|
|
if !nextInput.Exists() || !nextInput.IsArray() {
|
|
return nil, lastRequest, &interfaces.ErrorMessage{
|
|
StatusCode: http.StatusBadRequest,
|
|
Error: fmt.Errorf("websocket request requires array field: input"),
|
|
}
|
|
}
|
|
|
|
// Compaction can cause clients to replace local websocket history with a new
|
|
// compact transcript on the next `response.create`. When the input already
|
|
// contains historical model output items, treating it as an incremental append
|
|
// duplicates stale turn-state and can leave late orphaned function_call items.
|
|
if shouldReplaceWebsocketTranscript(rawJSON, nextInput) {
|
|
normalized := normalizeResponseTranscriptReplacement(rawJSON, lastRequest)
|
|
return normalized, bytes.Clone(normalized), nil
|
|
}
|
|
|
|
// Websocket v2 mode uses response.create with previous_response_id + incremental input.
|
|
// Do not expand it into a full input transcript; upstream expects the incremental payload.
|
|
if allowIncrementalInputWithPreviousResponseID {
|
|
prev := strings.TrimSpace(gjson.GetBytes(rawJSON, "previous_response_id").String())
|
|
if prev == "" {
|
|
if !inputSatisfiesPendingToolCalls(nextInput, lastResponsePendingToolCallIDs) {
|
|
normalized := normalizeResponseTranscriptReplacement(rawJSON, lastRequest)
|
|
return normalized, bytes.Clone(normalized), nil
|
|
}
|
|
prev = strings.TrimSpace(lastResponseID)
|
|
}
|
|
if prev != "" {
|
|
normalized, errDelete := sjson.DeleteBytes(rawJSON, "type")
|
|
if errDelete != nil {
|
|
normalized = bytes.Clone(rawJSON)
|
|
}
|
|
normalized, _ = sjson.SetBytes(normalized, "previous_response_id", prev)
|
|
if !gjson.GetBytes(normalized, "model").Exists() {
|
|
modelName := strings.TrimSpace(gjson.GetBytes(lastRequest, "model").String())
|
|
if modelName != "" {
|
|
normalized, _ = sjson.SetBytes(normalized, "model", modelName)
|
|
}
|
|
}
|
|
if !gjson.GetBytes(normalized, "instructions").Exists() {
|
|
instructions := gjson.GetBytes(lastRequest, "instructions")
|
|
if instructions.Exists() {
|
|
normalized, _ = sjson.SetRawBytes(normalized, "instructions", []byte(instructions.Raw))
|
|
}
|
|
}
|
|
normalized, _ = sjson.SetBytes(normalized, "stream", true)
|
|
return normalized, bytes.Clone(normalized), nil
|
|
}
|
|
}
|
|
|
|
// When the client sends a compact replay for a downstream that can consume it
|
|
// directly, the input already carries the canonical history. In that case,
|
|
// skip merging with stale lastRequest/lastResponseOutput to avoid breaking
|
|
// function_call / function_call_output pairings.
|
|
// See: https://github.com/router-for-me/CLIProxyAPI/issues/2207
|
|
var mergedInput []byte
|
|
if allowCompactionReplayBypass && inputContainsFullTranscript(nextInput) {
|
|
log.Infof("responses websocket: full transcript detected, skipping stale merge (input items=%d)", len(nextInput.Array()))
|
|
mergedInput = []byte(nextInput.Raw)
|
|
} else {
|
|
appendInputRaw := nextInput.Raw
|
|
if inputContainsFullTranscript(nextInput) {
|
|
appendInputRaw = inputWithoutCompactionItems(nextInput)
|
|
}
|
|
|
|
var errMerge error
|
|
mergedInput, errMerge = mergeResponsesWebsocketInput(lastRequest, lastResponseOutput, appendInputRaw)
|
|
if errMerge != nil {
|
|
return nil, lastRequest, &interfaces.ErrorMessage{
|
|
StatusCode: http.StatusBadRequest,
|
|
Error: errMerge,
|
|
}
|
|
}
|
|
}
|
|
|
|
normalized, errDelete := sjson.DeleteBytes(rawJSON, "type")
|
|
if errDelete != nil {
|
|
normalized = bytes.Clone(rawJSON)
|
|
}
|
|
normalized, _ = sjson.DeleteBytes(normalized, "previous_response_id")
|
|
if !gjson.GetBytes(normalized, "model").Exists() {
|
|
modelName := strings.TrimSpace(gjson.GetBytes(lastRequest, "model").String())
|
|
if modelName != "" {
|
|
normalized, _ = sjson.SetBytes(normalized, "model", modelName)
|
|
}
|
|
}
|
|
if !gjson.GetBytes(normalized, "instructions").Exists() {
|
|
instructions := gjson.GetBytes(lastRequest, "instructions")
|
|
if instructions.Exists() {
|
|
normalized, _ = sjson.SetRawBytes(normalized, "instructions", []byte(instructions.Raw))
|
|
}
|
|
}
|
|
normalized, _ = sjson.SetBytes(normalized, "stream", true)
|
|
var errSet error
|
|
normalized, errSet = sjson.SetRawBytes(normalized, "input", mergedInput)
|
|
if errSet != nil {
|
|
return nil, lastRequest, &interfaces.ErrorMessage{
|
|
StatusCode: http.StatusBadRequest,
|
|
Error: fmt.Errorf("failed to merge websocket input: %w", errSet),
|
|
}
|
|
}
|
|
return normalized, normalized, nil
|
|
}
|
|
|
|
func shouldReplaceWebsocketTranscript(rawJSON []byte, nextInput gjson.Result) bool {
|
|
requestType := strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String())
|
|
if requestType != wsRequestTypeCreate && requestType != wsRequestTypeAppend {
|
|
return false
|
|
}
|
|
previousResponseID := gjson.GetBytes(rawJSON, "previous_response_id")
|
|
if strings.TrimSpace(previousResponseID.String()) != "" {
|
|
return false
|
|
}
|
|
if !nextInput.Exists() || !nextInput.IsArray() {
|
|
return false
|
|
}
|
|
if requestType == wsRequestTypeCreate && !previousResponseID.Exists() && inputHasCodexLocalCompactionSummary(nextInput) {
|
|
return true
|
|
}
|
|
|
|
for _, item := range nextInput.Array() {
|
|
switch strings.TrimSpace(item.Get("type").String()) {
|
|
case "function_call", "custom_tool_call":
|
|
return true
|
|
case "message":
|
|
if strings.TrimSpace(item.Get("role").String()) == "assistant" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func inputHasCodexLocalCompactionSummary(input gjson.Result) bool {
|
|
if !input.IsArray() {
|
|
return false
|
|
}
|
|
|
|
hasSummary := false
|
|
for index, item := range input.Array() {
|
|
itemType := strings.TrimSpace(item.Get("type").String())
|
|
if itemType == "additional_tools" {
|
|
tools := item.Get("tools")
|
|
if index != 0 || strings.TrimSpace(item.Get("role").String()) != "developer" || !tools.IsArray() {
|
|
return false
|
|
}
|
|
for _, tool := range tools.Array() {
|
|
if !tool.IsObject() || strings.TrimSpace(tool.Get("type").String()) == "" {
|
|
return false
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if itemType != "" && itemType != "message" {
|
|
return false
|
|
}
|
|
|
|
role := strings.TrimSpace(item.Get("role").String())
|
|
if role != "user" && role != "developer" {
|
|
return false
|
|
}
|
|
if role == "user" && strings.HasPrefix(codexLocalCompactionMessageText(item), codexLocalCompactionSummaryPrefix+"\n") {
|
|
hasSummary = true
|
|
}
|
|
}
|
|
return hasSummary
|
|
}
|
|
|
|
func codexLocalCompactionMessageText(message gjson.Result) string {
|
|
content := message.Get("content")
|
|
if content.Type == gjson.String {
|
|
return content.String()
|
|
}
|
|
if !content.IsArray() {
|
|
return ""
|
|
}
|
|
|
|
var text strings.Builder
|
|
for _, part := range content.Array() {
|
|
if strings.TrimSpace(part.Get("type").String()) == "input_text" {
|
|
text.WriteString(part.Get("text").String())
|
|
}
|
|
}
|
|
return text.String()
|
|
}
|
|
|
|
func inputSatisfiesPendingToolCalls(input gjson.Result, pendingCallIDs []string) bool {
|
|
if len(pendingCallIDs) == 0 {
|
|
return true
|
|
}
|
|
if !input.IsArray() {
|
|
return false
|
|
}
|
|
outputs := make(map[string]struct{}, len(pendingCallIDs))
|
|
for _, item := range input.Array() {
|
|
switch strings.TrimSpace(item.Get("type").String()) {
|
|
case "function_call_output", "custom_tool_call_output":
|
|
callID := strings.TrimSpace(item.Get("call_id").String())
|
|
if callID != "" {
|
|
outputs[callID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
for _, callID := range pendingCallIDs {
|
|
callID = strings.TrimSpace(callID)
|
|
if callID == "" {
|
|
continue
|
|
}
|
|
if _, ok := outputs[callID]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func normalizeResponseTranscriptReplacement(rawJSON []byte, lastRequest []byte) []byte {
|
|
normalized, errDelete := sjson.DeleteBytes(rawJSON, "type")
|
|
if errDelete != nil {
|
|
normalized = bytes.Clone(rawJSON)
|
|
}
|
|
normalized, _ = sjson.DeleteBytes(normalized, "previous_response_id")
|
|
if !gjson.GetBytes(normalized, "model").Exists() {
|
|
modelName := strings.TrimSpace(gjson.GetBytes(lastRequest, "model").String())
|
|
if modelName != "" {
|
|
normalized, _ = sjson.SetBytes(normalized, "model", modelName)
|
|
}
|
|
}
|
|
if !gjson.GetBytes(normalized, "instructions").Exists() {
|
|
instructions := gjson.GetBytes(lastRequest, "instructions")
|
|
if instructions.Exists() {
|
|
normalized, _ = sjson.SetRawBytes(normalized, "instructions", []byte(instructions.Raw))
|
|
}
|
|
}
|
|
normalized, _ = sjson.SetBytes(normalized, "stream", true)
|
|
return bytes.Clone(normalized)
|
|
}
|
|
|
|
type responsesWebsocketInputItem struct {
|
|
raw json.RawMessage
|
|
itemType string
|
|
id string
|
|
callID string
|
|
}
|
|
|
|
type responsesWebsocketMergeInputItem struct {
|
|
// raw may reference a caller-owned request buffer. Merge items must remain
|
|
// local to mergeResponsesWebsocketInput, which copies every item into the
|
|
// owned output buffer before returning.
|
|
raw string
|
|
itemType string
|
|
id string
|
|
callID string
|
|
}
|
|
|
|
func mergeResponsesWebsocketInput(lastRequest []byte, lastResponseOutput []byte, appendRaw string) ([]byte, error) {
|
|
previousInput, errPrevious := responsesWebsocketPreviousInputNoCopy(lastRequest)
|
|
if errPrevious != nil {
|
|
return nil, fmt.Errorf("invalid previous request input: %w", errPrevious)
|
|
}
|
|
items, errExisting := appendResponsesWebsocketMergeInputResult(nil, previousInput)
|
|
if errExisting != nil {
|
|
return nil, fmt.Errorf("invalid previous request input: %w", errExisting)
|
|
}
|
|
|
|
trimmedResponse := bytes.TrimSpace(lastResponseOutput)
|
|
if len(trimmedResponse) > 0 && trimmedResponse[0] == '[' && json.Valid(trimmedResponse) {
|
|
responseInput := util.ParseGJSONBytesNoCopy(trimmedResponse)
|
|
if inputContainsFullTranscript(responseInput) {
|
|
items = slices.DeleteFunc(items, func(item responsesWebsocketMergeInputItem) bool {
|
|
return item.itemType == "compaction_trigger"
|
|
})
|
|
}
|
|
var errResponse error
|
|
items, errResponse = appendResponsesWebsocketMergeInputResult(items, responseInput)
|
|
if errResponse != nil {
|
|
return nil, fmt.Errorf("invalid previous response output: %w", errResponse)
|
|
}
|
|
}
|
|
|
|
items, errAppend := appendResponsesWebsocketMergeInputItems(items, appendRaw)
|
|
if errAppend != nil {
|
|
return nil, fmt.Errorf("invalid request input: %w", errAppend)
|
|
}
|
|
|
|
items = dedupeResponsesWebsocketMergeFunctionCalls(items)
|
|
items = dedupeResponsesWebsocketMergeInputItems(items)
|
|
return marshalResponsesWebsocketMergeInputItems(items), nil
|
|
}
|
|
|
|
func responsesWebsocketPreviousInputNoCopy(lastRequest []byte) (gjson.Result, error) {
|
|
if !json.Valid(lastRequest) {
|
|
return gjson.Result{}, responsesWebsocketPreviousInputDecodeError(lastRequest)
|
|
}
|
|
|
|
root := util.ParseGJSONBytesNoCopy(lastRequest)
|
|
if root.Type == gjson.Null {
|
|
return gjson.Parse("[]"), nil
|
|
}
|
|
if !root.IsObject() {
|
|
return gjson.Result{}, responsesWebsocketPreviousInputDecodeError(lastRequest)
|
|
}
|
|
|
|
var input gjson.Result
|
|
inputFound := false
|
|
invalidInput := false
|
|
root.ForEach(func(key, value gjson.Result) bool {
|
|
if !strings.EqualFold(key.String(), "input") {
|
|
return true
|
|
}
|
|
// encoding/json processes matching duplicate fields in source order,
|
|
// retains the last value, and still reports a type error from any
|
|
// incompatible duplicate. Preserve those semantics without copying the
|
|
// selected array out of the caller-owned request buffer.
|
|
inputFound = true
|
|
input = value
|
|
if value.Type != gjson.Null && !value.IsArray() {
|
|
invalidInput = true
|
|
}
|
|
return true
|
|
})
|
|
if invalidInput {
|
|
return gjson.Result{}, responsesWebsocketPreviousInputDecodeError(lastRequest)
|
|
}
|
|
if !inputFound || input.Type == gjson.Null {
|
|
return gjson.Parse("[]"), nil
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func responsesWebsocketPreviousInputDecodeError(lastRequest []byte) error {
|
|
var previousRequest struct {
|
|
Input []json.RawMessage `json:"input"`
|
|
}
|
|
return json.Unmarshal(lastRequest, &previousRequest)
|
|
}
|
|
|
|
func appendResponsesWebsocketMergeInputItems(items []responsesWebsocketMergeInputItem, rawArray string) ([]responsesWebsocketMergeInputItem, error) {
|
|
rawArray = strings.TrimSpace(rawArray)
|
|
if rawArray == "" {
|
|
rawArray = "[]"
|
|
}
|
|
parsed := gjson.Parse(rawArray)
|
|
if gjson.Valid(rawArray) {
|
|
return appendResponsesWebsocketMergeInputResult(items, parsed)
|
|
}
|
|
|
|
var rawItems []json.RawMessage
|
|
if errUnmarshal := json.Unmarshal([]byte(rawArray), &rawItems); errUnmarshal != nil {
|
|
return nil, errUnmarshal
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func appendResponsesWebsocketMergeInputResult(items []responsesWebsocketMergeInputItem, input gjson.Result) ([]responsesWebsocketMergeInputItem, error) {
|
|
if input.Type == gjson.Null {
|
|
return items, nil
|
|
}
|
|
if !input.IsArray() {
|
|
var rawItems []json.RawMessage
|
|
if errUnmarshal := json.Unmarshal([]byte(input.Raw), &rawItems); errUnmarshal != nil {
|
|
return nil, errUnmarshal
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
rawItems := input.Array()
|
|
items = slices.Grow(items, len(rawItems))
|
|
for _, rawItem := range rawItems {
|
|
item := responsesWebsocketMergeInputItem{raw: rawItem.Raw}
|
|
if rawItem.IsObject() {
|
|
rawItem.ForEach(func(key, value gjson.Result) bool {
|
|
metadataKey := key.String()
|
|
switch {
|
|
case strings.EqualFold(metadataKey, "type"):
|
|
item.itemType = strings.TrimSpace(value.String())
|
|
case strings.EqualFold(metadataKey, "id"):
|
|
item.id = strings.TrimSpace(value.String())
|
|
case strings.EqualFold(metadataKey, "call_id"):
|
|
item.callID = strings.TrimSpace(value.String())
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func dedupeResponsesWebsocketMergeFunctionCalls(items []responsesWebsocketMergeInputItem) []responsesWebsocketMergeInputItem {
|
|
seenCallIDs := make(map[string]struct{}, len(items))
|
|
filtered := items[:0]
|
|
for _, item := range items {
|
|
if isResponsesToolCallType(item.itemType) && item.callID != "" {
|
|
if _, ok := seenCallIDs[item.callID]; ok {
|
|
continue
|
|
}
|
|
seenCallIDs[item.callID] = struct{}{}
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
clear(items[len(filtered):])
|
|
return filtered
|
|
}
|
|
|
|
func dedupeResponsesWebsocketMergeInputItems(items []responsesWebsocketMergeInputItem) []responsesWebsocketMergeInputItem {
|
|
referencedCallIDs := make(map[string]struct{}, len(items))
|
|
for _, item := range items {
|
|
if isResponsesToolCallOutputType(item.itemType) && item.callID != "" {
|
|
referencedCallIDs[item.callID] = struct{}{}
|
|
}
|
|
}
|
|
|
|
keepIndexByID := make(map[string]int, len(items))
|
|
keepReferencedByID := make(map[string]bool, len(items))
|
|
for index, item := range items {
|
|
if item.id == "" {
|
|
continue
|
|
}
|
|
_, referenced := referencedCallIDs[item.callID]
|
|
referenced = referenced && item.callID != ""
|
|
if _, seen := keepIndexByID[item.id]; !seen {
|
|
keepIndexByID[item.id] = index
|
|
keepReferencedByID[item.id] = referenced
|
|
continue
|
|
}
|
|
if referenced || !keepReferencedByID[item.id] {
|
|
keepIndexByID[item.id] = index
|
|
keepReferencedByID[item.id] = referenced
|
|
}
|
|
}
|
|
|
|
filtered := items[:0]
|
|
for index, item := range items {
|
|
if item.id != "" && keepIndexByID[item.id] != index {
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
clear(items[len(filtered):])
|
|
return filtered
|
|
}
|
|
|
|
func marshalResponsesWebsocketMergeInputItems(items []responsesWebsocketMergeInputItem) []byte {
|
|
outputLength := 2
|
|
if len(items) > 1 {
|
|
outputLength += len(items) - 1
|
|
}
|
|
for _, item := range items {
|
|
outputLength += len(item.raw)
|
|
}
|
|
|
|
// This allocation establishes ownership of the merged transcript and is the
|
|
// only large allocation the merge path retains after it returns.
|
|
out := make([]byte, 0, outputLength)
|
|
out = append(out, '[')
|
|
for index, item := range items {
|
|
if index > 0 {
|
|
out = append(out, ',')
|
|
}
|
|
out = append(out, item.raw...)
|
|
}
|
|
out = append(out, ']')
|
|
return out
|
|
}
|
|
|
|
func parseResponsesWebsocketInputItems(rawArray string) ([]responsesWebsocketInputItem, error) {
|
|
return appendResponsesWebsocketInputItems(nil, rawArray)
|
|
}
|
|
|
|
func appendResponsesWebsocketInputItems(items []responsesWebsocketInputItem, rawArray string) ([]responsesWebsocketInputItem, error) {
|
|
rawArray = strings.TrimSpace(rawArray)
|
|
if rawArray == "" {
|
|
rawArray = "[]"
|
|
}
|
|
var rawItems []json.RawMessage
|
|
if errUnmarshal := json.Unmarshal([]byte(rawArray), &rawItems); errUnmarshal != nil {
|
|
return nil, errUnmarshal
|
|
}
|
|
return appendResponsesWebsocketRawInputItems(items, rawItems)
|
|
}
|
|
|
|
func appendResponsesWebsocketRawInputItems(items []responsesWebsocketInputItem, rawItems []json.RawMessage) ([]responsesWebsocketInputItem, error) {
|
|
for _, rawItem := range rawItems {
|
|
item, errItem := parseResponsesWebsocketInputItem(rawItem)
|
|
if errItem != nil {
|
|
return nil, errItem
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func parseResponsesWebsocketInputItem(rawItem json.RawMessage) (responsesWebsocketInputItem, error) {
|
|
item := responsesWebsocketInputItem{raw: rawItem}
|
|
trimmed := bytes.TrimSpace(rawItem)
|
|
if len(trimmed) == 0 || trimmed[0] != '{' {
|
|
return item, nil
|
|
}
|
|
var metadata struct {
|
|
Type json.RawMessage `json:"type"`
|
|
ID json.RawMessage `json:"id"`
|
|
CallID json.RawMessage `json:"call_id"`
|
|
}
|
|
if errUnmarshal := json.Unmarshal(trimmed, &metadata); errUnmarshal != nil {
|
|
return responsesWebsocketInputItem{}, errUnmarshal
|
|
}
|
|
item.itemType = responsesWebsocketMetadataString(metadata.Type)
|
|
item.id = responsesWebsocketMetadataString(metadata.ID)
|
|
item.callID = responsesWebsocketMetadataString(metadata.CallID)
|
|
return item, nil
|
|
}
|
|
|
|
func responsesWebsocketMetadataString(raw json.RawMessage) string {
|
|
raw = bytes.TrimSpace(raw)
|
|
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
|
return ""
|
|
}
|
|
if raw[0] == '"' {
|
|
var value string
|
|
if errUnmarshal := json.Unmarshal(raw, &value); errUnmarshal == nil {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return strings.TrimSpace(string(raw))
|
|
}
|
|
|
|
func marshalResponsesWebsocketInputItems(items []responsesWebsocketInputItem) (string, error) {
|
|
rawItems := make([]json.RawMessage, len(items))
|
|
for index := range items {
|
|
rawItems[index] = items[index].raw
|
|
}
|
|
out, errMarshal := json.Marshal(rawItems)
|
|
if errMarshal != nil {
|
|
return "", errMarshal
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
func dedupeResponsesWebsocketFunctionCalls(items []responsesWebsocketInputItem) []responsesWebsocketInputItem {
|
|
seenCallIDs := make(map[string]struct{}, len(items))
|
|
filtered := items[:0]
|
|
for _, item := range items {
|
|
if isResponsesToolCallType(item.itemType) && item.callID != "" {
|
|
if _, ok := seenCallIDs[item.callID]; ok {
|
|
continue
|
|
}
|
|
seenCallIDs[item.callID] = struct{}{}
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
clear(items[len(filtered):])
|
|
return filtered
|
|
}
|
|
|
|
func dedupeResponsesWebsocketInputItems(items []responsesWebsocketInputItem) []responsesWebsocketInputItem {
|
|
// Collect the call_ids that are still referenced by tool-call output
|
|
// items. When several input items share the same id, the one we keep must
|
|
// preserve any call_id that has a matching output; otherwise the upstream
|
|
// rejects the request with "No tool call found for function call output".
|
|
referencedCallIDs := make(map[string]struct{}, len(items))
|
|
for _, item := range items {
|
|
switch item.itemType {
|
|
case "function_call_output", "custom_tool_call_output":
|
|
if item.callID != "" {
|
|
referencedCallIDs[item.callID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// For each id, choose the index to keep. The default is the last
|
|
// occurrence (matching the original dedupe behavior), but we never replace
|
|
// an item whose call_id still has a matching output with one that does not.
|
|
keepIndexByID := make(map[string]int, len(items))
|
|
keepReferencedByID := make(map[string]bool, len(items))
|
|
for index, item := range items {
|
|
if item.id == "" {
|
|
continue
|
|
}
|
|
_, referenced := referencedCallIDs[item.callID]
|
|
referenced = referenced && item.callID != ""
|
|
if _, seen := keepIndexByID[item.id]; !seen {
|
|
keepIndexByID[item.id] = index
|
|
keepReferencedByID[item.id] = referenced
|
|
continue
|
|
}
|
|
if referenced || !keepReferencedByID[item.id] {
|
|
keepIndexByID[item.id] = index
|
|
keepReferencedByID[item.id] = referenced
|
|
}
|
|
}
|
|
|
|
filtered := items[:0]
|
|
for index, item := range items {
|
|
if item.id != "" && keepIndexByID[item.id] != index {
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
clear(items[len(filtered):])
|
|
return filtered
|
|
}
|
|
|
|
func dedupeResponsesWebsocketInputItemsByID(payload []byte) []byte {
|
|
input := gjson.GetBytes(payload, "input")
|
|
if !input.Exists() || !input.IsArray() {
|
|
return payload
|
|
}
|
|
dedupedInput, errDedupe := dedupeInputItemsByID(input.Raw)
|
|
if errDedupe != nil || dedupedInput == input.Raw {
|
|
return payload
|
|
}
|
|
updated, errSet := sjson.SetRawBytes(payload, "input", []byte(dedupedInput))
|
|
if errSet != nil {
|
|
return payload
|
|
}
|
|
return updated
|
|
}
|
|
|
|
func dedupeInputItemsByID(rawArray string) (string, error) {
|
|
items, errParse := parseResponsesWebsocketInputItems(rawArray)
|
|
if errParse != nil {
|
|
return "", errParse
|
|
}
|
|
return marshalResponsesWebsocketInputItems(dedupeResponsesWebsocketInputItems(items))
|
|
}
|
|
|
|
func normalizeResponsesWebsocketPassthroughRequest(rawJSON []byte, modelName string) ([]byte, *interfaces.ErrorMessage) {
|
|
if !json.Valid(rawJSON) {
|
|
return nil, &interfaces.ErrorMessage{
|
|
StatusCode: http.StatusBadRequest,
|
|
Error: fmt.Errorf("invalid websocket request JSON"),
|
|
}
|
|
}
|
|
|
|
requestType := strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String())
|
|
switch requestType {
|
|
case wsRequestTypeCreate, wsRequestTypeAppend:
|
|
default:
|
|
return nil, &interfaces.ErrorMessage{
|
|
StatusCode: http.StatusBadRequest,
|
|
Error: fmt.Errorf("unsupported websocket request type: %s", requestType),
|
|
}
|
|
}
|
|
|
|
normalized := bytes.Clone(rawJSON)
|
|
if strings.TrimSpace(gjson.GetBytes(normalized, "model").String()) == "" {
|
|
modelName = strings.TrimSpace(modelName)
|
|
if modelName == "" {
|
|
return nil, &interfaces.ErrorMessage{
|
|
StatusCode: http.StatusBadRequest,
|
|
Error: fmt.Errorf("missing model in response.create request"),
|
|
}
|
|
}
|
|
normalized, _ = sjson.SetBytes(normalized, "model", modelName)
|
|
}
|
|
normalized, _ = sjson.SetBytes(normalized, "stream", true)
|
|
return normalized, nil
|
|
}
|