Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,22 @@
package openai
import (
codexmodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/models"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
func (h *OpenAIAPIHandler) codexClientModelsResponse() map[string]any {
optimizeMultiAgentV2 := h != nil && h.Cfg != nil && h.Cfg.CodexOptimizeMultiAgentV2
return codexmodels.BuildResponse(h.Models(), registry.GetGlobalRegistry().GetModelProviders, optimizeMultiAgentV2)
}
// CodexClientModelsResponse builds a Codex client model response.
func CodexClientModelsResponse(models []map[string]any) map[string]any {
return codexmodels.BuildResponse(models, nil, false)
}
// CodexClientModelsResponseWithMultiAgentV2 builds a Codex client model response
// and advertises multi-agent v2 for synthesized models when enabled.
func CodexClientModelsResponseWithMultiAgentV2(models []map[string]any, enabled bool) map[string]any {
return codexmodels.BuildResponse(models, nil, enabled)
}

View file

@ -0,0 +1,59 @@
package openai
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
)
func TestCodexClientModelsResponseMultiAgentV2FollowsConfig(t *testing.T) {
modelID := "codex-client-multi-agent-v2-test"
clientID := "codex-client-multi-agent-v2-test-client"
modelRegistry := registry.GetGlobalRegistry()
modelRegistry.RegisterClient(clientID, "openai-compatibility", []*registry.ModelInfo{{ID: modelID}})
t.Cleanup(func() {
modelRegistry.UnregisterClient(clientID)
})
base := handlers.NewBaseAPIHandlers(&config.SDKConfig{}, nil)
handler := NewOpenAIAPIHandler(base)
for _, tt := range []struct {
name string
enabled bool
}{
{name: "disabled", enabled: false},
{name: "enabled", enabled: true},
} {
t.Run(tt.name, func(t *testing.T) {
base.Cfg.CodexOptimizeMultiAgentV2 = tt.enabled
response := handler.codexClientModelsResponse()
models, ok := response["models"].([]map[string]any)
if !ok {
t.Fatalf("models type = %T, want []map[string]any", response["models"])
}
var entry map[string]any
for _, model := range models {
slug, _ := model["slug"].(string)
if slug == modelID {
entry = model
break
}
}
if entry == nil {
t.Fatalf("missing synthesized model %q", modelID)
}
value, exists := entry["multi_agent_version"]
if tt.enabled {
if !exists || value != "v2" {
t.Fatalf("multi_agent_version = %#v, want v2", value)
}
return
}
if !exists || value != nil {
t.Fatalf("multi_agent_version = %#v, want preserved null", value)
}
})
}
}

View file

@ -0,0 +1,709 @@
// Package openai provides HTTP handlers for OpenAI API endpoints.
// This package implements the OpenAI-compatible API interface, including model listing
// and chat completion functionality. It supports both streaming and non-streaming responses,
// and manages a pool of clients to interact with backend services.
// The handlers translate OpenAI API requests to the appropriate backend format and
// convert responses back to OpenAI-compatible format.
package openai
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"github.com/gin-gonic/gin"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
responsesconverter "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/responses"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// OpenAIAPIHandler contains the handlers for OpenAI API endpoints.
// It holds a pool of clients to interact with the backend service.
type OpenAIAPIHandler struct {
*handlers.BaseAPIHandler
}
// NewOpenAIAPIHandler creates a new OpenAI API handlers instance.
// It takes an BaseAPIHandler instance as input and returns an OpenAIAPIHandler.
//
// Parameters:
// - apiHandlers: The base API handlers instance
//
// Returns:
// - *OpenAIAPIHandler: A new OpenAI API handlers instance
func NewOpenAIAPIHandler(apiHandlers *handlers.BaseAPIHandler) *OpenAIAPIHandler {
return &OpenAIAPIHandler{
BaseAPIHandler: apiHandlers,
}
}
// HandlerType returns the identifier for this handler implementation.
func (h *OpenAIAPIHandler) HandlerType() string {
return OpenAI
}
// Models returns the OpenAI-compatible model metadata supported by this handler.
func (h *OpenAIAPIHandler) Models() []map[string]any {
// Get dynamic models from the global registry
modelRegistry := registry.GetGlobalRegistry()
return modelRegistry.GetAvailableModels("openai")
}
// OpenAIModels handles the /v1/models endpoint.
// It returns a list of available AI models with their capabilities
// and specifications in OpenAI-compatible format.
func (h *OpenAIAPIHandler) OpenAIModels(c *gin.Context) {
if _, ok := c.Request.URL.Query()["client_version"]; ok {
c.JSON(http.StatusOK, h.codexClientModelsResponse())
return
}
// Get all available models
allModels := h.Models()
// Filter to only include the 4 required fields: id, object, created, owned_by
filteredModels := make([]map[string]any, len(allModels))
for i, model := range allModels {
filteredModel := map[string]any{
"id": model["id"],
"object": model["object"],
}
// Add created field if it exists
if created, exists := model["created"]; exists {
filteredModel["created"] = created
}
// Add owned_by field if it exists
if ownedBy, exists := model["owned_by"]; exists {
filteredModel["owned_by"] = ownedBy
}
filteredModels[i] = filteredModel
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": filteredModels,
})
}
// ChatCompletions handles the /v1/chat/completions endpoint.
// It determines whether the request is for a streaming or non-streaming response
// and calls the appropriate handler based on the model provider.
//
// Parameters:
// - c: The Gin context containing the HTTP request and response
func (h *OpenAIAPIHandler) ChatCompletions(c *gin.Context) {
rawJSON, err := handlers.ReadRequestBody(c)
// If data retrieval fails, return a 400 Bad Request error.
if err != nil {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
// Check if the client requested a streaming response.
streamResult := gjson.GetBytes(rawJSON, "stream")
stream := streamResult.Type == gjson.True
// Some clients send OpenAI Responses-format payloads to /v1/chat/completions.
// Convert them to Chat Completions so downstream translators preserve tool metadata.
if shouldTreatAsResponsesFormat(rawJSON) {
modelName := gjson.GetBytes(rawJSON, "model").String()
rawJSON = responsesconverter.ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName, rawJSON, stream)
stream = gjson.GetBytes(rawJSON, "stream").Bool()
}
if stream {
h.handleStreamingResponse(c, rawJSON)
} else {
h.handleNonStreamingResponse(c, rawJSON)
}
}
// shouldTreatAsResponsesFormat detects OpenAI Responses-style payloads that are
// accidentally sent to the Chat Completions endpoint.
func shouldTreatAsResponsesFormat(rawJSON []byte) bool {
if gjson.GetBytes(rawJSON, "messages").Exists() {
return false
}
if gjson.GetBytes(rawJSON, "input").Exists() {
return true
}
if gjson.GetBytes(rawJSON, "instructions").Exists() {
return true
}
return false
}
// Completions handles the /v1/completions endpoint.
// It determines whether the request is for a streaming or non-streaming response
// and calls the appropriate handler based on the model provider.
// This endpoint follows the OpenAI completions API specification.
//
// Parameters:
// - c: The Gin context containing the HTTP request and response
func (h *OpenAIAPIHandler) Completions(c *gin.Context) {
rawJSON, err := handlers.ReadRequestBody(c)
// If data retrieval fails, return a 400 Bad Request error.
if err != nil {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
// Check if the client requested a streaming response.
streamResult := gjson.GetBytes(rawJSON, "stream")
if streamResult.Type == gjson.True {
h.handleCompletionsStreamingResponse(c, rawJSON)
} else {
h.handleCompletionsNonStreamingResponse(c, rawJSON)
}
}
// convertCompletionsRequestToChatCompletions converts OpenAI completions API request to chat completions format.
// This allows the completions endpoint to use the existing chat completions infrastructure.
//
// Parameters:
// - rawJSON: The raw JSON bytes of the completions request
//
// Returns:
// - []byte: The converted chat completions request
func convertCompletionsRequestToChatCompletions(rawJSON []byte) []byte {
root := gjson.ParseBytes(rawJSON)
// Extract prompt from completions request
prompt := root.Get("prompt").String()
if prompt == "" {
prompt = "Complete this:"
}
// Create chat completions structure
out := []byte(`{"model":"","messages":[{"role":"user","content":""}]}`)
// Set model
if model := root.Get("model"); model.Exists() {
out, _ = sjson.SetBytes(out, "model", model.String())
}
// Set the prompt as user message content
out, _ = sjson.SetBytes(out, "messages.0.content", prompt)
// Copy other parameters from completions to chat completions
if maxTokens := root.Get("max_tokens"); maxTokens.Exists() {
out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
}
if temperature := root.Get("temperature"); temperature.Exists() {
out, _ = sjson.SetBytes(out, "temperature", temperature.Float())
}
if topP := root.Get("top_p"); topP.Exists() {
out, _ = sjson.SetBytes(out, "top_p", topP.Float())
}
if frequencyPenalty := root.Get("frequency_penalty"); frequencyPenalty.Exists() {
out, _ = sjson.SetBytes(out, "frequency_penalty", frequencyPenalty.Float())
}
if presencePenalty := root.Get("presence_penalty"); presencePenalty.Exists() {
out, _ = sjson.SetBytes(out, "presence_penalty", presencePenalty.Float())
}
if stop := root.Get("stop"); stop.Exists() {
out, _ = sjson.SetRawBytes(out, "stop", []byte(stop.Raw))
}
if stream := root.Get("stream"); stream.Exists() {
out, _ = sjson.SetBytes(out, "stream", stream.Bool())
}
if logprobs := root.Get("logprobs"); logprobs.Exists() {
out, _ = sjson.SetBytes(out, "logprobs", logprobs.Bool())
}
if topLogprobs := root.Get("top_logprobs"); topLogprobs.Exists() {
out, _ = sjson.SetBytes(out, "top_logprobs", topLogprobs.Int())
}
if echo := root.Get("echo"); echo.Exists() {
out, _ = sjson.SetBytes(out, "echo", echo.Bool())
}
return out
}
// convertChatCompletionsResponseToCompletions converts chat completions API response back to completions format.
// This ensures the completions endpoint returns data in the expected format.
//
// Parameters:
// - rawJSON: The raw JSON bytes of the chat completions response
//
// Returns:
// - []byte: The converted completions response
func convertChatCompletionsResponseToCompletions(rawJSON []byte) []byte {
root := gjson.ParseBytes(rawJSON)
// Base completions response structure
out := []byte(`{"id":"","object":"text_completion","created":0,"model":"","choices":[]}`)
// Copy basic fields
if id := root.Get("id"); id.Exists() {
out, _ = sjson.SetBytes(out, "id", id.String())
}
if created := root.Get("created"); created.Exists() {
out, _ = sjson.SetBytes(out, "created", created.Int())
}
if model := root.Get("model"); model.Exists() {
out, _ = sjson.SetBytes(out, "model", model.String())
}
if usage := root.Get("usage"); usage.Exists() {
out, _ = sjson.SetRawBytes(out, "usage", []byte(usage.Raw))
}
// Convert choices from chat completions to completions format
var choices []interface{}
if chatChoices := root.Get("choices"); chatChoices.Exists() && chatChoices.IsArray() {
chatChoices.ForEach(func(_, choice gjson.Result) bool {
completionsChoice := map[string]interface{}{
"index": choice.Get("index").Int(),
}
// Extract text content from message.content
if message := choice.Get("message"); message.Exists() {
if content := message.Get("content"); content.Exists() {
completionsChoice["text"] = content.String()
}
} else if delta := choice.Get("delta"); delta.Exists() {
// For streaming responses, use delta.content
if content := delta.Get("content"); content.Exists() {
completionsChoice["text"] = content.String()
}
}
// Copy finish_reason
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
completionsChoice["finish_reason"] = finishReason.String()
}
// Copy logprobs if present
if logprobs := choice.Get("logprobs"); logprobs.Exists() {
completionsChoice["logprobs"] = logprobs.Value()
}
choices = append(choices, completionsChoice)
return true
})
}
if len(choices) > 0 {
choicesJSON, _ := json.Marshal(choices)
out, _ = sjson.SetRawBytes(out, "choices", choicesJSON)
}
return out
}
// convertChatCompletionsStreamChunkToCompletions converts a streaming chat completions chunk to completions format.
// This handles the real-time conversion of streaming response chunks and filters out empty text responses.
//
// Parameters:
// - chunkData: The raw JSON bytes of a single chat completions stream chunk
//
// Returns:
// - []byte: The converted completions stream chunk, or nil if should be filtered out
func convertChatCompletionsStreamChunkToCompletions(chunkData []byte) []byte {
root := gjson.ParseBytes(chunkData)
// Check if this chunk has any meaningful content
hasContent := false
hasUsage := root.Get("usage").Exists()
if chatChoices := root.Get("choices"); chatChoices.Exists() && chatChoices.IsArray() {
chatChoices.ForEach(func(_, choice gjson.Result) bool {
// Check if delta has content or finish_reason
if delta := choice.Get("delta"); delta.Exists() {
if content := delta.Get("content"); content.Exists() && content.String() != "" {
hasContent = true
return false // Break out of forEach
}
}
// Also check for finish_reason to ensure we don't skip final chunks
if finishReason := choice.Get("finish_reason"); finishReason.Exists() && finishReason.String() != "" && finishReason.String() != "null" {
hasContent = true
return false // Break out of forEach
}
return true
})
}
// If no meaningful content and no usage, return nil to indicate this chunk should be skipped
if !hasContent && !hasUsage {
return nil
}
// Base completions stream response structure
out := []byte(`{"id":"","object":"text_completion","created":0,"model":"","choices":[]}`)
// Copy basic fields
if id := root.Get("id"); id.Exists() {
out, _ = sjson.SetBytes(out, "id", id.String())
}
if created := root.Get("created"); created.Exists() {
out, _ = sjson.SetBytes(out, "created", created.Int())
}
if model := root.Get("model"); model.Exists() {
out, _ = sjson.SetBytes(out, "model", model.String())
}
// Convert choices from chat completions delta to completions format
var choices []interface{}
if chatChoices := root.Get("choices"); chatChoices.Exists() && chatChoices.IsArray() {
chatChoices.ForEach(func(_, choice gjson.Result) bool {
completionsChoice := map[string]interface{}{
"index": choice.Get("index").Int(),
}
// Extract text content from delta.content
if delta := choice.Get("delta"); delta.Exists() {
if content := delta.Get("content"); content.Exists() && content.String() != "" {
completionsChoice["text"] = content.String()
} else {
completionsChoice["text"] = ""
}
} else {
completionsChoice["text"] = ""
}
// Copy finish_reason
if finishReason := choice.Get("finish_reason"); finishReason.Exists() && finishReason.String() != "null" {
completionsChoice["finish_reason"] = finishReason.String()
}
// Copy logprobs if present
if logprobs := choice.Get("logprobs"); logprobs.Exists() {
completionsChoice["logprobs"] = logprobs.Value()
}
choices = append(choices, completionsChoice)
return true
})
}
if len(choices) > 0 {
choicesJSON, _ := json.Marshal(choices)
out, _ = sjson.SetRawBytes(out, "choices", choicesJSON)
}
// Copy usage if present
if usage := root.Get("usage"); usage.Exists() {
out, _ = sjson.SetRawBytes(out, "usage", []byte(usage.Raw))
}
return out
}
// handleNonStreamingResponse handles non-streaming chat completion responses
// for Gemini models. It selects a client from the pool, sends the request, and
// aggregates the response before sending it back to the client in OpenAI format.
//
// Parameters:
// - c: The Gin context containing the HTTP request and response
// - rawJSON: The raw JSON bytes of the OpenAI-compatible request
func (h *OpenAIAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []byte) {
c.Header("Content-Type", "application/json")
modelName := gjson.GetBytes(rawJSON, "model").String()
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, h.GetAlt(c))
stopKeepAlive()
if errMsg != nil {
h.WriteErrorResponse(c, errMsg)
cliCancel(errMsg.Error)
return
}
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = c.Writer.Write(resp)
cliCancel()
}
// handleStreamingResponse handles streaming responses for Gemini models.
// It establishes a streaming connection with the backend service and forwards
// the response chunks to the client in real-time using Server-Sent Events.
//
// Parameters:
// - c: The Gin context containing the HTTP request and response
// - rawJSON: The raw JSON bytes of the OpenAI-compatible request
func (h *OpenAIAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byte) {
// Get the http.Flusher interface to manually flush the response.
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
Message: "Streaming not supported",
Type: "server_error",
},
})
return
}
modelName := gjson.GetBytes(rawJSON, "model").String()
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, h.GetAlt(c))
setSSEHeaders := func() {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
}
// Peek at the first chunk to determine success or failure before setting headers
for {
select {
case <-c.Request.Context().Done():
cliCancel(c.Request.Context().Err())
return
case errMsg, ok := <-errChan:
if !ok {
// Err channel closed cleanly; wait for data channel.
errChan = nil
continue
}
// Upstream failed immediately. Return proper error status and JSON.
h.WriteErrorResponse(c, errMsg)
if errMsg != nil {
cliCancel(errMsg.Error)
} else {
cliCancel(nil)
}
return
case chunk, ok := <-dataChan:
if !ok {
if errMsg, hasPendingError := handlers.PendingStreamError(errChan); hasPendingError {
h.WriteErrorResponse(c, errMsg)
if errMsg != nil {
cliCancel(errMsg.Error)
} else {
cliCancel(nil)
}
return
}
// Stream closed without data? Send DONE or just headers.
setSSEHeaders()
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
flusher.Flush()
cliCancel(nil)
return
}
// Success! Commit to streaming headers.
setSSEHeaders()
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunk))
flusher.Flush()
// Continue streaming the rest
h.handleStreamResult(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan)
return
}
}
}
// handleCompletionsNonStreamingResponse handles non-streaming completions responses.
// It converts completions request to chat completions format, sends to backend,
// then converts the response back to completions format before sending to client.
//
// Parameters:
// - c: The Gin context containing the HTTP request and response
// - rawJSON: The raw JSON bytes of the OpenAI-compatible completions request
func (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context, rawJSON []byte) {
c.Header("Content-Type", "application/json")
// Convert completions request to chat completions format
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)
modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, chatCompletionsJSON, "")
stopKeepAlive()
if errMsg != nil {
h.WriteErrorResponse(c, errMsg)
cliCancel(errMsg.Error)
return
}
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
completionsResp := convertChatCompletionsResponseToCompletions(resp)
_, _ = c.Writer.Write(completionsResp)
cliCancel()
}
// handleCompletionsStreamingResponse handles streaming completions responses.
// It converts completions request to chat completions format, streams from backend,
// then converts each response chunk back to completions format before sending to client.
//
// Parameters:
// - c: The Gin context containing the HTTP request and response
// - rawJSON: The raw JSON bytes of the OpenAI-compatible completions request
func (h *OpenAIAPIHandler) handleCompletionsStreamingResponse(c *gin.Context, rawJSON []byte) {
// Get the http.Flusher interface to manually flush the response.
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
Message: "Streaming not supported",
Type: "server_error",
},
})
return
}
// Convert completions request to chat completions format
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)
modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, chatCompletionsJSON, "")
setSSEHeaders := func() {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
}
// Peek at the first chunk
for {
select {
case <-c.Request.Context().Done():
cliCancel(c.Request.Context().Err())
return
case errMsg, ok := <-errChan:
if !ok {
// Err channel closed cleanly; wait for data channel.
errChan = nil
continue
}
h.WriteErrorResponse(c, errMsg)
if errMsg != nil {
cliCancel(errMsg.Error)
} else {
cliCancel(nil)
}
return
case chunk, ok := <-dataChan:
if !ok {
if errMsg, hasPendingError := handlers.PendingStreamError(errChan); hasPendingError {
h.WriteErrorResponse(c, errMsg)
if errMsg != nil {
cliCancel(errMsg.Error)
} else {
cliCancel(nil)
}
return
}
setSSEHeaders()
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
flusher.Flush()
cliCancel(nil)
return
}
// Success! Set headers.
setSSEHeaders()
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
// Write the first chunk
converted := convertChatCompletionsStreamChunkToCompletions(chunk)
if converted != nil {
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(converted))
flusher.Flush()
}
done := make(chan struct{})
var doneOnce sync.Once
stop := func() { doneOnce.Do(func() { close(done) }) }
convertedChan := make(chan []byte)
go func() {
defer close(convertedChan)
for {
select {
case <-done:
return
case chunk, ok := <-dataChan:
if !ok {
return
}
converted := convertChatCompletionsStreamChunkToCompletions(chunk)
if converted == nil {
continue
}
select {
case <-done:
return
case convertedChan <- converted:
}
}
}
}()
h.handleStreamResult(c, flusher, func(err error) {
stop()
cliCancel(err)
}, convertedChan, errChan)
return
}
}
}
func (h *OpenAIAPIHandler) handleStreamResult(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {
h.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{
WriteChunk: func(chunk []byte) {
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunk))
},
WriteTerminalError: func(errMsg *interfaces.ErrorMessage) {
if errMsg == nil {
return
}
status := http.StatusInternalServerError
if errMsg.StatusCode > 0 {
status = errMsg.StatusCode
}
errText := http.StatusText(status)
if errMsg.Error != nil && errMsg.Error.Error() != "" {
errText = errMsg.Error.Error()
}
body := handlers.BuildErrorResponseBody(status, errText)
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(body))
},
WriteDone: func() {
_, _ = fmt.Fprint(c.Writer, "data: [DONE]\n\n")
},
})
}

View file

@ -0,0 +1,103 @@
package openai
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
const (
initialFailureChatModel = "initial-failure-chat-model"
)
type initialFailureStreamExecutor struct{}
func (*initialFailureStreamExecutor) Identifier() string { return "initial-failure-stream-executor" }
func (*initialFailureStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
return coreexecutor.Response{}, errors.New("not implemented")
}
func (*initialFailureStreamExecutor) ExecuteStream(_ context.Context, _ *coreauth.Auth, _ coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) {
chunks := make(chan coreexecutor.StreamChunk, 1)
chunks <- coreexecutor.StreamChunk{Err: errors.New("upstream failed before first payload")}
close(chunks)
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
func (*initialFailureStreamExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
return auth, nil
}
func (*initialFailureStreamExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
return coreexecutor.Response{}, errors.New("not implemented")
}
func (*initialFailureStreamExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
return nil, errors.New("not implemented")
}
func runOpenAIStreamErrorTest(t *testing.T, endpoint string, body string) {
gin.SetMode(gin.TestMode)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
executor := &initialFailureStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
authID := fmt.Sprintf("initial-failure-auth-%s-%d", strings.ReplaceAll(endpoint, "/", "-"), idx)
auth := &coreauth.Auth{ID: authID, Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Errorf("register auth %d: %v", idx, errRegister)
return
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: initialFailureChatModel}})
defer registry.GetGlobalRegistry().UnregisterClient(auth.ID)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIAPIHandler(base)
router := gin.New()
if endpoint == "/v1/chat/completions" {
router.POST(endpoint, h.ChatCompletions)
} else {
router.POST(endpoint, h.Completions)
}
request := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code == http.StatusOK {
t.Errorf("[%s] request %d lost the buffered initial error and returned HTTP 200: %q", endpoint, idx, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "upstream failed before first payload") {
t.Errorf("[%s] request %d lost the initial upstream error: status=%d body=%q", endpoint, idx, recorder.Code, recorder.Body.String())
}
}(i)
}
wg.Wait()
}
func TestChatCompletionsHandlerDoesNotLoseErrorBeforeFirstPayload(t *testing.T) {
runOpenAIStreamErrorTest(t, "/v1/chat/completions", `{"model":"initial-failure-chat-model","messages":[{"role":"user","content":"hi"}],"stream":true}`)
}
func TestCompletionsHandlerDoesNotLoseErrorBeforeFirstPayload(t *testing.T) {
runOpenAIStreamErrorTest(t, "/v1/completions", `{"model":"initial-failure-chat-model","prompt":"hi","stream":true}`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,500 @@
package openai
import (
"bytes"
"context"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strings"
"testing"
"github.com/gin-gonic/gin"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/tidwall/gjson"
)
func performImagesEndpointRequest(t *testing.T, endpointPath string, contentType string, body io.Reader, handler gin.HandlerFunc) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST(endpointPath, handler)
req := httptest.NewRequest(http.MethodPost, endpointPath, body)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
return resp
}
func assertUnsupportedImagesModelResponse(t *testing.T, resp *httptest.ResponseRecorder, model string) {
t.Helper()
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
message := gjson.GetBytes(resp.Body.Bytes(), "error.message").String()
expectedMessage := "Model " + model + " is not supported on " + imagesGenerationsPath + " or " + imagesEditsPath + ". Use " + gptImage15Model + ", " + defaultImagesToolModel + ", " + defaultXAIImagesModel + ", " + xaiImagesQualityModel + ", " + xaiImages20Model + ", or a configured openai-compatibility image model."
if message != expectedMessage {
t.Fatalf("error message = %q, want %q", message, expectedMessage)
}
if errorType := gjson.GetBytes(resp.Body.Bytes(), "error.type").String(); errorType != "invalid_request_error" {
t.Fatalf("error type = %q, want invalid_request_error", errorType)
}
}
func TestImagesModelValidationAllowsGPTImageAndXAIModels(t *testing.T) {
for _, model := range []string{"gpt-image-1.5", "codex/gpt-image-1.5", "gpt-image-2", "codex/gpt-image-2", "grok-imagine-image", "xai/grok-imagine-image", "grok-imagine-image-quality", "xai/grok-imagine-image-quality", "grok-imagine-image-2.0", "xai/grok-imagine-image-2.0"} {
if !isSupportedImagesModel(model) {
t.Fatalf("expected %s to be supported", model)
}
}
if isSupportedImagesModel("gpt-5.4-mini") {
t.Fatal("expected gpt-5.4-mini to be rejected")
}
if isSupportedImagesModel("codex/grok-imagine-image") {
t.Fatal("expected codex/grok-imagine-image to be rejected")
}
}
func TestImagesModelValidationAllowsOpenAICompatImageModels(t *testing.T) {
modelRegistry := registry.GetGlobalRegistry()
clientID := "test-openai-compat-image-model-validation"
modelRegistry.RegisterClient(clientID, "openai-compatibility", []*registry.ModelInfo{
{ID: "compat-image-model", Object: "model", OwnedBy: "compat", Type: registry.OpenAIImageModelType},
{ID: "compat-chat-model", Object: "model", OwnedBy: "compat", Type: "openai-compatibility"},
})
t.Cleanup(func() {
modelRegistry.UnregisterClient(clientID)
})
if !isSupportedImagesModel("compat-image-model") {
t.Fatal("expected configured openai-compatibility image model to be supported")
}
if isSupportedImagesModel("compat-chat-model") {
t.Fatal("expected non-image openai-compatibility model to be rejected")
}
}
func TestCanonicalXAIImagesModelPreservesImage20(t *testing.T) {
for _, model := range []string{"grok-imagine-image-2.0", "xai/grok-imagine-image-2.0", "XAI/Grok-Imagine-Image-2.0"} {
if got := canonicalXAIImagesModel(model); got != xaiImages20Model {
t.Fatalf("canonicalXAIImagesModel(%q) = %q, want %s", model, got, xaiImages20Model)
}
}
}
func TestBuildXAIImagesGenerationsRequest(t *testing.T) {
rawJSON := []byte(`{"model":"xai/grok-imagine-image-quality","prompt":"abstract art","aspect_ratio":"landscape","resolution":"2k","n":2,"response_format":"url"}`)
req := buildXAIImagesGenerationsRequest(rawJSON, "xai/grok-imagine-image-quality", "url")
if got := gjson.GetBytes(req, "model").String(); got != "grok-imagine-image-quality" {
t.Fatalf("model = %q, want grok-imagine-image-quality", got)
}
if got := gjson.GetBytes(req, "prompt").String(); got != "abstract art" {
t.Fatalf("prompt = %q, want abstract art", got)
}
if got := gjson.GetBytes(req, "aspect_ratio").String(); got != "16:9" {
t.Fatalf("aspect_ratio = %q, want 16:9", got)
}
if got := gjson.GetBytes(req, "resolution").String(); got != "2k" {
t.Fatalf("resolution = %q, want 2k", got)
}
if got := gjson.GetBytes(req, "response_format").String(); got != "url" {
t.Fatalf("response_format = %q, want url", got)
}
if got := gjson.GetBytes(req, "n").Int(); got != 2 {
t.Fatalf("n = %d, want 2", got)
}
}
func TestBuildXAIImagesEditRequest(t *testing.T) {
req := buildXAIImagesEditRequest("grok-imagine-image", "edit it", []string{"data:image/png;base64,AA==", "https://example.com/image.png"}, "b64_json", "3:2", "1k", 0)
if got := gjson.GetBytes(req, "model").String(); got != "grok-imagine-image" {
t.Fatalf("model = %q, want grok-imagine-image", got)
}
if got := gjson.GetBytes(req, "images.0.type").String(); got != "image_url" {
t.Fatalf("images.0.type = %q, want image_url", got)
}
if got := gjson.GetBytes(req, "images.0.url").String(); got != "data:image/png;base64,AA==" {
t.Fatalf("images.0.url = %q", got)
}
if got := gjson.GetBytes(req, "images.1.url").String(); got != "https://example.com/image.png" {
t.Fatalf("images.1.url = %q", got)
}
if gjson.GetBytes(req, "image").Exists() {
t.Fatalf("multiple image edits must use images array: %s", string(req))
}
}
func TestBuildXAIImagesEditRequestSingleImage(t *testing.T) {
req := buildXAIImagesEditRequest("grok-imagine-image", "edit it", []string{"https://example.com/image.png"}, "url", "", "", 0)
if got := gjson.GetBytes(req, "image.type").String(); got != "image_url" {
t.Fatalf("image.type = %q, want image_url", got)
}
if got := gjson.GetBytes(req, "image.url").String(); got != "https://example.com/image.png" {
t.Fatalf("image.url = %q", got)
}
if gjson.GetBytes(req, "images").Exists() {
t.Fatalf("single image edit must use image object: %s", string(req))
}
}
func TestBuildOpenAICompatImagesJSONRequestPreservesStreamForStreaming(t *testing.T) {
req := buildOpenAICompatImagesJSONRequest([]byte(`{"model":"compat-image","prompt":"draw","stream":false}`), "upstream-image", true)
if got := gjson.GetBytes(req, "model").String(); got != "upstream-image" {
t.Fatalf("model = %q, want upstream-image; body=%s", got, string(req))
}
if !gjson.GetBytes(req, "stream").Bool() {
t.Fatalf("stream flag missing: %s", string(req))
}
}
func TestBuildOpenAICompatImagesJSONRequestDropsStreamForNonStreaming(t *testing.T) {
req := buildOpenAICompatImagesJSONRequest([]byte(`{"model":"compat-image","prompt":"draw","stream":true}`), "upstream-image", false)
if got := gjson.GetBytes(req, "model").String(); got != "upstream-image" {
t.Fatalf("model = %q, want upstream-image; body=%s", got, string(req))
}
if gjson.GetBytes(req, "stream").Exists() {
t.Fatalf("stream flag should be removed from non-streaming request: %s", string(req))
}
}
func TestBuildOpenAICompatImagesMultipartRequestPreservesStreamAndFileContentType(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if errWrite := writer.WriteField("model", "compat-image"); errWrite != nil {
t.Fatalf("write model field: %v", errWrite)
}
if errWrite := writer.WriteField("stream", "false"); errWrite != nil {
t.Fatalf("write stream field: %v", errWrite)
}
if errWrite := writer.WriteField("prompt", "edit"); errWrite != nil {
t.Fatalf("write prompt field: %v", errWrite)
}
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", multipart.FileContentDisposition("image", "image.png"))
header.Set("Content-Type", "image/png")
part, errCreate := writer.CreatePart(header)
if errCreate != nil {
t.Fatalf("create image field: %v", errCreate)
}
if _, errWrite := part.Write([]byte("png-data")); errWrite != nil {
t.Fatalf("write image field: %v", errWrite)
}
if errClose := writer.Close(); errClose != nil {
t.Fatalf("close multipart writer: %v", errClose)
}
reader := multipart.NewReader(bytes.NewReader(body.Bytes()), writer.Boundary())
form, errRead := reader.ReadForm(32 << 20)
if errRead != nil {
t.Fatalf("read source form: %v", errRead)
}
defer func() {
if errRemove := form.RemoveAll(); errRemove != nil {
t.Fatalf("remove source form files: %v", errRemove)
}
}()
out, contentType, errBuild := buildOpenAICompatImagesMultipartRequest(form, "upstream-image", true)
if errBuild != nil {
t.Fatalf("buildOpenAICompatImagesMultipartRequest error: %v", errBuild)
}
mediaType, params, errParse := mime.ParseMediaType(contentType)
if errParse != nil {
t.Fatalf("parse content type: %v", errParse)
}
if mediaType != "multipart/form-data" {
t.Fatalf("media type = %q, want multipart/form-data", mediaType)
}
rewrittenReader := multipart.NewReader(bytes.NewReader(out), params["boundary"])
rewrittenForm, errRead := rewrittenReader.ReadForm(32 << 20)
if errRead != nil {
t.Fatalf("read rewritten form: %v", errRead)
}
defer func() {
if errRemove := rewrittenForm.RemoveAll(); errRemove != nil {
t.Fatalf("remove rewritten form files: %v", errRemove)
}
}()
if got := rewrittenForm.Value["model"]; len(got) != 1 || got[0] != "upstream-image" {
t.Fatalf("model values = %#v, want upstream-image", got)
}
if got := rewrittenForm.Value["stream"]; len(got) != 1 || got[0] != "true" {
t.Fatalf("stream values = %#v, want true", got)
}
if got := rewrittenForm.Value["prompt"]; len(got) != 1 || got[0] != "edit" {
t.Fatalf("prompt values = %#v, want edit", got)
}
if got := rewrittenForm.File["image"]; len(got) != 1 || got[0].Header.Get("Content-Type") != "image/png" {
t.Fatalf("image headers = %#v, want image/png", got)
}
}
func TestBuildImagesAPIResponseFromXAI(t *testing.T) {
payload := []byte(`{"created":123,"data":[{"b64_json":"AA==","revised_prompt":"refined","mime_type":"image/png"}],"usage":{"total_tokens":0}}`)
out, err := buildImagesAPIResponseFromXAI(payload, "b64_json")
if err != nil {
t.Fatalf("buildImagesAPIResponseFromXAI() error = %v", err)
}
if got := gjson.GetBytes(out, "created").Int(); got != 123 {
t.Fatalf("created = %d, want 123", got)
}
if got := gjson.GetBytes(out, "data.0.b64_json").String(); got != "AA==" {
t.Fatalf("data.0.b64_json = %q, want AA==", got)
}
if got := gjson.GetBytes(out, "data.0.revised_prompt").String(); got != "refined" {
t.Fatalf("data.0.revised_prompt = %q, want refined", got)
}
if !gjson.GetBytes(out, "usage").Exists() {
t.Fatalf("usage missing: %s", string(out))
}
}
func TestImagesGenerationsRejectsUnsupportedModel(t *testing.T) {
handler := &OpenAIAPIHandler{}
body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"draw a square"}`)
resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
assertUnsupportedImagesModelResponse(t, resp, "gpt-5.4-mini")
}
func TestImagesEditsJSONRejectsUnsupportedModel(t *testing.T) {
handler := &OpenAIAPIHandler{}
body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`)
resp := performImagesEndpointRequest(t, imagesEditsPath, "application/json", body, handler.ImagesEdits)
assertUnsupportedImagesModelResponse(t, resp, "gpt-5.4-mini")
}
func TestImagesEditsMultipartRejectsUnsupportedModel(t *testing.T) {
handler := &OpenAIAPIHandler{}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("model", "gpt-5.4-mini"); err != nil {
t.Fatalf("write model field: %v", err)
}
if err := writer.WriteField("prompt", "edit this"); err != nil {
t.Fatalf("write prompt field: %v", err)
}
if errClose := writer.Close(); errClose != nil {
t.Fatalf("close multipart writer: %v", errClose)
}
resp := performImagesEndpointRequest(t, imagesEditsPath, writer.FormDataContentType(), &body, handler.ImagesEdits)
assertUnsupportedImagesModelResponse(t, resp, "gpt-5.4-mini")
}
func TestImagesGenerations_DisableImageGeneration_Returns404(t *testing.T) {
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationAll}, nil)
handler := NewOpenAIAPIHandler(base)
body := strings.NewReader(`{"prompt":"draw a square"}`)
resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusNotFound, resp.Body.String())
}
}
func TestImagesEdits_DisableImageGeneration_Returns404(t *testing.T) {
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationAll}, nil)
handler := NewOpenAIAPIHandler(base)
body := strings.NewReader(`{"prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`)
resp := performImagesEndpointRequest(t, imagesEditsPath, "application/json", body, handler.ImagesEdits)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusNotFound, resp.Body.String())
}
}
func TestImagesGenerations_DisableImageGenerationChat_DoesNotReturn404(t *testing.T) {
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationChat}, nil)
handler := NewOpenAIAPIHandler(base)
body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"draw a square"}`)
resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
}
func TestImagesEdits_DisableImageGenerationChat_DoesNotReturn404(t *testing.T) {
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationChat}, nil)
handler := NewOpenAIAPIHandler(base)
body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`)
resp := performImagesEndpointRequest(t, imagesEditsPath, "application/json", body, handler.ImagesEdits)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
}
func TestSSEFrameAccumulatorFlushesDataOnlyFrame(t *testing.T) {
accumulator := &sseFrameAccumulator{}
chunk := []byte(`data: {"type":"image_generation.partial","partial_image_index":0}`)
if frames := accumulator.AddChunk(chunk); len(frames) != 0 {
t.Fatalf("AddChunk() emitted an unterminated data-only frame: %q", frames)
}
frames := accumulator.Flush()
if len(frames) != 1 || string(frames[0]) != string(chunk) {
t.Fatalf("Flush() frames = %q, want [%q]", frames, chunk)
}
}
func TestWriteImagesStreamErrorEventSanitizesPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
raw := `{"error":{"code":"upstream_failed","message":"token=image-secret"},"debug":"` + strings.Repeat("x", 8192) + `"}`
writeImagesStreamErrorEvent(c, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: errors.New(raw)})
body := recorder.Body.String()
if strings.Contains(body, "image-secret") || len(body) > 4096 || !strings.Contains(body, "[REDACTED]") {
t.Fatalf("image stream error was not safely bounded: len=%d body=%q", len(body), body)
}
}
func TestCollectImagesRejectsPayloadErrorBeforeCompleted(t *testing.T) {
data := make(chan []byte, 1)
data <- []byte("event: error\ndata: {\"type\":\"provider.error\",\"error\":{\"code\":\"failed\",\"message\":\"token=image-secret\"}}\n\n" +
"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aW1hZ2U=\"}]}}\n\n")
close(data)
errs := make(chan *interfaces.ErrorMessage)
close(errs)
out, errMsg := collectImagesFromResponsesStream(context.Background(), data, errs, "b64_json")
if len(out) != 0 || errMsg == nil || errMsg.Error == nil {
t.Fatalf("payload error result out=%q err=%#v", out, errMsg)
}
if strings.Contains(errMsg.Error.Error(), "image-secret") || !strings.Contains(errMsg.Error.Error(), "[REDACTED]") {
t.Fatalf("payload error was not sanitized: %q", errMsg.Error.Error())
}
}
func TestForwardImagesStreamCancelsWithPayloadError(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewOpenAIAPIHandler(handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil))
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
data := make(chan []byte)
close(data)
errs := make(chan *interfaces.ErrorMessage)
close(errs)
var canceled error
firstChunk := []byte("event: error\ndata: {\"error\":{\"message\":\"token=image-secret\"}}\n\n")
h.forwardImagesStream(context.Background(), c, flusher, func(err error) { canceled = err }, data, errs, firstChunk, "b64_json", "image_generation", func(string, []byte) {})
if canceled == nil || strings.Contains(canceled.Error(), "image-secret") || !strings.Contains(canceled.Error(), "[REDACTED]") {
t.Fatalf("payload error cancel = %v body=%q", canceled, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "event: error") {
t.Fatalf("payload error event missing: %q", recorder.Body.String())
}
}
func TestForwardRawImageStreamPrefersPendingErrorOnClose(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewOpenAIAPIHandler(handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil))
for i := 0; i < 100; i++ {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
data := make(chan []byte)
close(data)
errs := make(chan *interfaces.ErrorMessage, 1)
errs <- &interfaces.ErrorMessage{StatusCode: http.StatusTooManyRequests, Error: errors.New("image upstream busy")}
close(errs)
var canceled error
h.forwardRawImageStream(context.Background(), c, func(err error) { canceled = err }, data, errs)
if canceled == nil || !strings.Contains(canceled.Error(), "image upstream busy") {
t.Fatalf("iteration %d: cancel=%v body=%q", i, canceled, recorder.Body.String())
}
}
}
func TestCollectImagesPrefersPendingErrorWhenDataChannelCloses(t *testing.T) {
for i := 0; i < 100; i++ {
data := make(chan []byte)
close(data)
errs := make(chan *interfaces.ErrorMessage, 1)
want := &interfaces.ErrorMessage{
StatusCode: http.StatusTooManyRequests,
Error: errors.New("image upstream busy"),
DirectResponse: true,
Headers: http.Header{"Retry-After": []string{"9"}},
}
errs <- want
close(errs)
_, got := collectImagesFromResponsesStream(context.Background(), data, errs, "b64_json")
if got != want {
t.Fatalf("iteration %d: pending error = %#v, want original %#v", i, got, want)
}
}
}
func TestCollectImagesAllowsMultilineSSEData(t *testing.T) {
data := make(chan []byte, 1)
data <- []byte("event: response.completed\n" +
"data: {\"type\":\"response.completed\",\n" +
"data: \"response\":{\"created_at\":1,\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aW1hZ2U=\"}]}}\n\n")
close(data)
errs := make(chan *interfaces.ErrorMessage)
close(errs)
out, errMsg := collectImagesFromResponsesStream(context.Background(), data, errs, "b64_json")
if errMsg != nil {
t.Fatalf("collectImagesFromResponsesStream() error = %v", errMsg.Error)
}
if !strings.Contains(string(out), `"b64_json":"aW1hZ2U="`) {
t.Fatalf("multiline image response = %q", out)
}
}
func TestSSEFrameAccumulatorKeepsMultipleFramesDistinct(t *testing.T) {
accumulator := &sseFrameAccumulator{}
first := "event: first\ndata: {\"type\":\"first\"}\n\n"
second := "event: second\ndata: {\"type\":\"second\"}\n\n"
frames := accumulator.AddChunk([]byte(first + second))
if len(frames) != 2 {
t.Fatalf("AddChunk() returned %d frames, want 2: %q", len(frames), frames)
}
if string(frames[0]) != first || string(frames[1]) != second {
t.Fatalf("frames were overwritten during buffer compaction: %q", frames)
}
}

View file

@ -0,0 +1,375 @@
package openai
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/klauspost/compress/zstd"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
type compactCaptureExecutor struct {
alt string
sourceFormat string
calls int
}
func (e *compactCaptureExecutor) Identifier() string { return "test-provider" }
func (e *compactCaptureExecutor) Execute(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
e.calls++
e.alt = opts.Alt
e.sourceFormat = opts.SourceFormat.String()
return coreexecutor.Response{Payload: []byte(`{"ok":true}`)}, nil
}
func (e *compactCaptureExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {
return nil, errors.New("not implemented")
}
func (e *compactCaptureExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
return auth, nil
}
func (e *compactCaptureExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
return coreexecutor.Response{}, errors.New("not implemented")
}
func (e *compactCaptureExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
return nil, errors.New("not implemented")
}
func TestOpenAIResponsesCompactRejectsStream(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &compactCaptureExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "auth1", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, err := manager.Register(context.Background(), auth); err != nil {
t.Fatalf("Register auth: %v", err)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-model"}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses/compact", h.Compact)
req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(`{"model":"test-model","stream":true}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", resp.Code, http.StatusBadRequest)
}
if executor.calls != 0 {
t.Fatalf("executor calls = %d, want 0", executor.calls)
}
}
func TestOpenAIResponsesCompactExecute(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &compactCaptureExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "auth2", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, err := manager.Register(context.Background(), auth); err != nil {
t.Fatalf("Register auth: %v", err)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-model"}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses/compact", h.Compact)
req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(`{"model":"test-model","input":"hello"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.Code, http.StatusOK)
}
if executor.alt != "responses/compact" {
t.Fatalf("alt = %q, want %q", executor.alt, "responses/compact")
}
if executor.sourceFormat != "openai-response" {
t.Fatalf("source format = %q, want %q", executor.sourceFormat, "openai-response")
}
if strings.TrimSpace(resp.Body.String()) != `{"ok":true}` {
t.Fatalf("body = %s", resp.Body.String())
}
}
func TestOpenAIResponsesCompactDecodesZstdRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &compactCaptureExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "auth3", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, err := manager.Register(context.Background(), auth); err != nil {
t.Fatalf("Register auth: %v", err)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-model"}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses/compact", h.Compact)
var compressed bytes.Buffer
encoder, err := zstd.NewWriter(&compressed)
if err != nil {
t.Fatalf("zstd.NewWriter: %v", err)
}
if _, errWrite := encoder.Write([]byte(`{"model":"test-model","input":"hello"}`)); errWrite != nil {
t.Fatalf("zstd write: %v", errWrite)
}
if errClose := encoder.Close(); errClose != nil {
t.Fatalf("zstd close: %v", errClose)
}
req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(compressed.Bytes()))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Encoding", "zstd")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
if executor.calls != 1 {
t.Fatalf("executor calls = %d, want 1", executor.calls)
}
if executor.alt != "responses/compact" {
t.Fatalf("alt = %q, want %q", executor.alt, "responses/compact")
}
if strings.TrimSpace(resp.Body.String()) != `{"ok":true}` {
t.Fatalf("body = %s", resp.Body.String())
}
}
type compactMockStatusError struct {
code int
msg string
}
func (e compactMockStatusError) Error() string { return e.msg }
func (e compactMockStatusError) StatusCode() int { return e.code }
type compactFailureMockExecutor struct {
compactErr error
normalResp []byte
calls int
lastAlt string
lastAuthID string
}
func (e *compactFailureMockExecutor) Identifier() string { return "test-compact-provider" }
func (e *compactFailureMockExecutor) Execute(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
e.calls++
e.lastAlt = opts.Alt
if auth != nil {
e.lastAuthID = auth.ID
}
if opts.Alt == "responses/compact" {
if e.compactErr != nil {
return coreexecutor.Response{}, e.compactErr
}
}
respPayload := e.normalResp
if len(respPayload) == 0 {
respPayload = []byte(`{"id":"resp_123","object":"response","status":"completed"}`)
}
return coreexecutor.Response{Payload: respPayload}, nil
}
func (e *compactFailureMockExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {
return nil, errors.New("not implemented")
}
func (e *compactFailureMockExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
return auth, nil
}
func (e *compactFailureMockExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
return coreexecutor.Response{}, errors.New("not implemented")
}
func (e *compactFailureMockExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
return nil, errors.New("not implemented")
}
func TestOpenAIResponsesCompactTransientFailureDoesNotCooldownAuthAndPreservesError(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &compactFailureMockExecutor{
compactErr: compactMockStatusError{
code: http.StatusInternalServerError,
msg: `{"error":{"message":"compact upstream temporary error","type":"api_error"}}`,
},
}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth1 := &coreauth.Auth{ID: "auth1", Provider: executor.Identifier(), Status: coreauth.StatusActive}
auth2 := &coreauth.Auth{ID: "auth2", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, err := manager.Register(context.Background(), auth1); err != nil {
t.Fatalf("Register auth1: %v", err)
}
if _, err := manager.Register(context.Background(), auth2); err != nil {
t.Fatalf("Register auth2: %v", err)
}
registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: "test-model"}})
registry.GetGlobalRegistry().RegisterClient(auth2.ID, auth2.Provider, []*registry.ModelInfo{{ID: "test-model"}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth1.ID)
registry.GetGlobalRegistry().UnregisterClient(auth2.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses/compact", h.Compact)
router.POST("/v1/responses", h.Responses)
// Send compact request which fails upstream on all auths with 500
req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(`{"model":"test-model","input":"hello"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// 1. Should return upstream status 500 and upstream error message (not generic 503 Service temporarily unavailable)
if resp.Code != http.StatusInternalServerError {
t.Fatalf("compact status = %d, want %d; body = %s", resp.Code, http.StatusInternalServerError, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "compact upstream temporary error") {
t.Fatalf("compact body = %s, want containing 'compact upstream temporary error'", resp.Body.String())
}
// 2. Auth model states should NOT be marked unavailable for normal traffic
for _, authID := range []string{"auth1", "auth2"} {
a, ok := manager.GetByID(authID)
if !ok {
t.Fatalf("auth %s not found", authID)
}
if state, exists := a.ModelStates["test-model"]; exists && state != nil {
if state.Unavailable {
t.Fatalf("auth %s model state marked Unavailable after compact failure", authID)
}
if !state.NextRetryAfter.IsZero() && state.NextRetryAfter.After(time.Now()) {
t.Fatalf("auth %s model state has NextRetryAfter %v in future", authID, state.NextRetryAfter)
}
}
}
// 3. Normal /v1/responses request should succeed immediately without auth cooldown errors
reqNormal := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"test-model","input":"hello"}`))
reqNormal.Header.Set("Content-Type", "application/json")
respNormal := httptest.NewRecorder()
router.ServeHTTP(respNormal, reqNormal)
if respNormal.Code != http.StatusOK {
t.Fatalf("normal responses status = %d, want %d; body = %s", respNormal.Code, http.StatusOK, respNormal.Body.String())
}
}
func TestOpenAIResponsesCompactRequestFaultStopsFallbackAndPreservesError(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &compactFailureMockExecutor{
compactErr: compactMockStatusError{
code: http.StatusNotFound,
msg: `404 page not found`,
},
}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth1 := &coreauth.Auth{ID: "auth1", Provider: executor.Identifier(), Status: coreauth.StatusActive}
auth2 := &coreauth.Auth{ID: "auth2", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, err := manager.Register(context.Background(), auth1); err != nil {
t.Fatalf("Register auth1: %v", err)
}
if _, err := manager.Register(context.Background(), auth2); err != nil {
t.Fatalf("Register auth2: %v", err)
}
registry.GetGlobalRegistry().RegisterClient(auth1.ID, auth1.Provider, []*registry.ModelInfo{{ID: "test-model"}})
registry.GetGlobalRegistry().RegisterClient(auth2.ID, auth2.Provider, []*registry.ModelInfo{{ID: "test-model"}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth1.ID)
registry.GetGlobalRegistry().UnregisterClient(auth2.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses/compact", h.Compact)
router.POST("/v1/responses", h.Responses)
// Send compact request which fails upstream with 404 (endpoint not supported / invalid)
req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(`{"model":"test-model","input":"hello"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// 1. Should return upstream status 404 and upstream error message
if resp.Code != http.StatusNotFound {
t.Fatalf("compact status = %d, want %d; body = %s", resp.Code, http.StatusNotFound, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "404 page not found") {
t.Fatalf("compact body = %s, want containing '404 page not found'", resp.Body.String())
}
// 2. Should stop fallback on request/capability fault (calls == 1)
if executor.calls != 1 {
t.Fatalf("executor calls = %d, want 1 (fallback should stop)", executor.calls)
}
// 3. Auth model states should NOT be marked unavailable for normal traffic
for _, authID := range []string{"auth1", "auth2"} {
a, ok := manager.GetByID(authID)
if !ok {
t.Fatalf("auth %s not found", authID)
}
if state, exists := a.ModelStates["test-model"]; exists && state != nil {
if state.Unavailable {
t.Fatalf("auth %s model state marked Unavailable after compact failure", authID)
}
}
}
// 4. Normal /v1/responses request should succeed immediately
reqNormal := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"test-model","input":"hello"}`))
reqNormal.Header.Set("Content-Type", "application/json")
respNormal := httptest.NewRecorder()
router.ServeHTTP(respNormal, reqNormal)
if respNormal.Code != http.StatusOK {
t.Fatalf("normal responses status = %d, want %d; body = %s", respNormal.Code, http.StatusOK, respNormal.Body.String())
}
}

View file

@ -0,0 +1,973 @@
// Package openai provides HTTP handlers for OpenAIResponses API endpoints.
// This package implements the OpenAIResponses-compatible API interface, including model listing
// and chat completion functionality. It supports both streaming and non-streaming responses,
// and manages a pool of clients to interact with backend services.
// The handlers translate OpenAIResponses API requests to the appropriate backend format and
// convert responses back to OpenAIResponses-compatible format.
package openai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"sort"
"strings"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/optimize-multi-agent-v2"
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func writeResponsesSSEChunk(w io.Writer, chunk []byte) {
if w == nil || len(chunk) == 0 {
return
}
if _, err := w.Write(chunk); err != nil {
return
}
if bytes.HasSuffix(chunk, []byte("\n\n")) || bytes.HasSuffix(chunk, []byte("\r\n\r\n")) {
return
}
suffix := []byte("\n\n")
if bytes.HasSuffix(chunk, []byte("\r\n")) {
suffix = []byte("\r\n")
} else if bytes.HasSuffix(chunk, []byte("\n")) {
suffix = []byte("\n")
}
if _, err := w.Write(suffix); err != nil {
return
}
}
type responsesSSEFramer struct {
pending []byte
outputItems map[int][]byte
outputOrder []int
unindexedOutputItems [][]byte
lastEvent string
terminalEvent string
terminalError *interfaces.ErrorMessage
failureEvent string
dataFrames int
}
func (f *responsesSSEFramer) WriteChunk(w io.Writer, chunk []byte) {
if len(chunk) == 0 || f.terminalEvent != "" {
return
}
if responsesSSEStartsNewDataFrame(f.pending, chunk) {
f.writeFrame(w, f.pending)
f.pending = f.pending[:0]
if f.terminalEvent != "" {
return
}
}
if responsesSSENeedsLineBreak(f.pending, chunk) {
f.pending = append(f.pending, '\n')
}
f.pending = append(f.pending, chunk...)
for {
frameLen := responsesSSEFrameLen(f.pending)
if frameLen == 0 {
break
}
f.writeFrame(w, f.pending[:frameLen])
copy(f.pending, f.pending[frameLen:])
f.pending = f.pending[:len(f.pending)-frameLen]
if f.terminalEvent != "" {
f.pending = f.pending[:0]
return
}
}
if len(bytes.TrimSpace(f.pending)) == 0 {
f.pending = f.pending[:0]
return
}
if len(f.pending) == 0 || !responsesSSECanEmitWithoutDelimiter(f.pending) {
return
}
f.writeFrame(w, f.pending)
f.pending = f.pending[:0]
}
func (f *responsesSSEFramer) Flush(w io.Writer) {
if len(f.pending) == 0 || f.terminalEvent != "" {
return
}
if len(bytes.TrimSpace(f.pending)) == 0 {
f.pending = f.pending[:0]
return
}
if !responsesSSECanFlushWithoutDelimiter(f.pending) {
f.pending = f.pending[:0]
return
}
f.writeFrame(w, f.pending)
f.pending = f.pending[:0]
}
func (f *responsesSSEFramer) writeFrame(w io.Writer, frame []byte) {
writeResponsesSSEChunk(w, f.repairFrame(frame))
}
func (f *responsesSSEFramer) repairFrame(frame []byte) []byte {
payload, ok := responsesSSEDataPayload(frame)
if !ok || len(payload) == 0 {
return frame
}
if bytes.Equal(payload, []byte("[DONE]")) {
f.dataFrames++
return frame
}
if !json.Valid(payload) {
return frame
}
f.dataFrames++
payloadType := gjson.GetBytes(payload, "type").String()
if responsesSSEErrorEvent(payloadType) || responsesSSEPayloadHasError(payload) {
if payloadType != "" {
f.lastEvent = sanitizeResponsesStreamEventName(payloadType)
}
return f.repairErrorPayload(payload)
}
streamEvent := responsesSSEEventName(frame)
eventType := payloadType
if responsesSSETerminalEvent(streamEvent) {
eventType = streamEvent
} else if eventType == "" {
eventType = streamEvent
}
if eventType != "" {
f.lastEvent = sanitizeResponsesStreamEventName(eventType)
}
if responsesSSEErrorEvent(eventType) {
return f.repairErrorPayload(payload)
}
if responsesSSETerminalEvent(eventType) {
f.terminalEvent = eventType
}
switch eventType {
case "response.output_item.done":
f.recordOutputItem(payload)
case "response.completed":
repaired := f.repairCompletedPayload(payload)
if !bytes.Equal(repaired, payload) {
return responsesSSEFrameWithData(frame, repaired)
}
}
return frame
}
func responsesSSEPayloadErrorMessage(payload []byte) *interfaces.ErrorMessage {
status := http.StatusBadGateway
for _, path := range []string{"status", "status_code", "error.status", "error.status_code", "response.error.status", "response.error.status_code"} {
candidate := int(gjson.GetBytes(payload, path).Int())
if candidate >= http.StatusBadRequest && candidate <= 599 {
status = candidate
break
}
}
return sanitizeResponsesStreamErrorMessage(&interfaces.ErrorMessage{StatusCode: status, Error: fmt.Errorf("%s", payload)})
}
func (f *responsesSSEFramer) repairErrorPayload(payload []byte) []byte {
errMsg := responsesSSEPayloadErrorMessage(payload)
status := errMsg.StatusCode
f.terminalError = errMsg
failureEvent := f.failureEvent
if failureEvent != "response.failed" {
failureEvent = "error"
}
f.terminalEvent = failureEvent
errText := responsesStreamErrorText(errMsg, status)
if failureEvent == "response.failed" {
chunk := handlers.BuildOpenAIResponsesStreamFailedChunk(status, errText, 0)
return []byte(fmt.Sprintf("event: response.failed\ndata: %s\n\n", chunk))
}
chunk := handlers.BuildOpenAIResponsesStreamErrorChunk(status, errText, 0)
return []byte(fmt.Sprintf("event: error\ndata: %s\n\n", chunk))
}
func responsesSSEErrorEvent(eventType string) bool {
switch eventType {
case "response.failed", "response.error", "error":
return true
default:
return false
}
}
func responsesSSETerminalEvent(eventType string) bool {
switch eventType {
case "response.completed", "response.incomplete", "response.failed", "response.done", "response.error", "error":
return true
default:
return false
}
}
func responsesSSEPayloadHasError(payload []byte) bool {
for _, path := range []string{"error", "response.error"} {
result := gjson.GetBytes(payload, path)
if result.Exists() && result.Type != gjson.Null {
return true
}
}
return gjson.GetBytes(payload, "code").Exists() && gjson.GetBytes(payload, "message").Exists()
}
func responsesSSEDataPayload(frame []byte) ([]byte, bool) {
var payload []byte
found := false
for _, line := range bytes.Split(frame, []byte("\n")) {
line = bytes.TrimRight(line, "\r")
trimmed := bytes.TrimSpace(line)
if !bytes.HasPrefix(trimmed, []byte("data:")) {
continue
}
data := bytes.TrimSpace(trimmed[len("data:"):])
if found {
payload = append(payload, '\n')
}
payload = append(payload, data...)
found = true
}
return payload, found
}
func responsesSSEFrameWithData(frame, payload []byte) []byte {
var out bytes.Buffer
for _, line := range bytes.Split(frame, []byte("\n")) {
line = bytes.TrimRight(line, "\r")
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 || bytes.HasPrefix(trimmed, []byte("data:")) {
continue
}
out.Write(line)
out.WriteByte('\n')
}
for _, line := range bytes.Split(payload, []byte("\n")) {
out.WriteString("data: ")
out.Write(line)
out.WriteByte('\n')
}
out.WriteByte('\n')
return out.Bytes()
}
func (f *responsesSSEFramer) recordOutputItem(payload []byte) {
item := gjson.GetBytes(payload, "item")
if !item.Exists() || !item.IsObject() || item.Get("type").String() == "" {
return
}
if outputIndex := gjson.GetBytes(payload, "output_index"); outputIndex.Exists() {
index := int(outputIndex.Int())
if f.outputItems == nil {
f.outputItems = make(map[int][]byte)
}
if _, exists := f.outputItems[index]; !exists {
f.outputOrder = append(f.outputOrder, index)
}
f.outputItems[index] = append([]byte(nil), item.Raw...)
return
}
f.unindexedOutputItems = append(f.unindexedOutputItems, append([]byte(nil), item.Raw...))
}
func (f *responsesSSEFramer) repairCompletedPayload(payload []byte) []byte {
if len(f.outputOrder) == 0 && len(f.unindexedOutputItems) == 0 {
return payload
}
output := gjson.GetBytes(payload, "response.output")
if output.Exists() && (!output.IsArray() || len(output.Array()) > 0) {
return payload
}
var outputJSON bytes.Buffer
outputJSON.WriteByte('[')
indexes := append([]int(nil), f.outputOrder...)
sort.Ints(indexes)
written := 0
for _, index := range indexes {
item, ok := f.outputItems[index]
if !ok {
continue
}
if written > 0 {
outputJSON.WriteByte(',')
}
outputJSON.Write(item)
written++
}
for _, item := range f.unindexedOutputItems {
if written > 0 {
outputJSON.WriteByte(',')
}
outputJSON.Write(item)
written++
}
outputJSON.WriteByte(']')
repaired, err := sjson.SetRawBytes(payload, "response.output", outputJSON.Bytes())
if err != nil {
return payload
}
return repaired
}
func responsesSSEFrameLen(chunk []byte) int {
if len(chunk) == 0 {
return 0
}
lf := bytes.Index(chunk, []byte("\n\n"))
crlf := bytes.Index(chunk, []byte("\r\n\r\n"))
switch {
case lf < 0:
if crlf < 0 {
return 0
}
return crlf + 4
case crlf < 0:
return lf + 2
case lf < crlf:
return lf + 2
default:
return crlf + 4
}
}
func responsesSSENeedsMoreData(chunk []byte) bool {
trimmed := bytes.TrimSpace(chunk)
if len(trimmed) == 0 {
return false
}
return responsesSSEHasField(trimmed, []byte("event:")) && !responsesSSEHasField(trimmed, []byte("data:"))
}
func responsesSSEHasField(chunk []byte, prefix []byte) bool {
s := chunk
for len(s) > 0 {
line := s
if i := bytes.IndexByte(s, '\n'); i >= 0 {
line = s[:i]
s = s[i+1:]
} else {
s = nil
}
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, prefix) {
return true
}
}
return false
}
func responsesSSECanEmitWithoutDelimiter(chunk []byte) bool {
trimmed := bytes.TrimSpace(chunk)
if len(trimmed) == 0 || responsesSSENeedsMoreData(trimmed) ||
!responsesSSEHasField(trimmed, []byte("event:")) || !responsesSSEHasField(trimmed, []byte("data:")) {
return false
}
return responsesSSEDataLinesValid(trimmed)
}
func responsesSSECanFlushWithoutDelimiter(chunk []byte) bool {
trimmed := bytes.TrimSpace(chunk)
return len(trimmed) > 0 && responsesSSEHasField(trimmed, []byte("data:")) && responsesSSEDataLinesValid(trimmed)
}
func responsesSSEStartsNewDataFrame(pending, chunk []byte) bool {
trimmedPending := bytes.TrimSpace(pending)
if len(trimmedPending) == 0 || responsesSSEHasField(trimmedPending, []byte("event:")) ||
!responsesSSEHasField(trimmedPending, []byte("data:")) || !responsesSSEDataLinesValid(trimmedPending) {
return false
}
trimmedChunk := bytes.TrimLeft(chunk, " \t\r\n")
return bytes.HasPrefix(trimmedChunk, []byte("data:"))
}
func responsesSSEEventName(frame []byte) string {
for _, line := range bytes.Split(frame, []byte("\n")) {
trimmed := bytes.TrimSpace(bytes.TrimRight(line, "\r"))
if bytes.HasPrefix(trimmed, []byte("event:")) {
return strings.TrimSpace(string(trimmed[len("event:"):]))
}
}
return ""
}
func responsesSSEDataLinesValid(chunk []byte) bool {
payload, found := responsesSSEDataPayload(chunk)
if !found {
return true
}
payload = bytes.TrimSpace(payload)
return len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) || json.Valid(payload)
}
func responsesSSENeedsLineBreak(pending, chunk []byte) bool {
if len(pending) == 0 || len(chunk) == 0 {
return false
}
if bytes.HasSuffix(pending, []byte("\n")) || bytes.HasSuffix(pending, []byte("\r")) {
return false
}
if chunk[0] == '\n' || chunk[0] == '\r' {
return false
}
trimmed := bytes.TrimLeft(chunk, " \t")
if len(trimmed) == 0 {
return false
}
for _, prefix := range [][]byte{[]byte("data:"), []byte("event:"), []byte("id:"), []byte("retry:"), []byte(":")} {
if bytes.HasPrefix(trimmed, prefix) {
return true
}
}
return false
}
// OpenAIResponsesAPIHandler contains the handlers for OpenAIResponses API endpoints.
// It holds a pool of clients to interact with the backend service.
type OpenAIResponsesAPIHandler struct {
*handlers.BaseAPIHandler
}
// NewOpenAIResponsesAPIHandler creates a new OpenAIResponses API handlers instance.
// It takes an BaseAPIHandler instance as input and returns an OpenAIResponsesAPIHandler.
//
// Parameters:
// - apiHandlers: The base API handlers instance
//
// Returns:
// - *OpenAIResponsesAPIHandler: A new OpenAIResponses API handlers instance
func NewOpenAIResponsesAPIHandler(apiHandlers *handlers.BaseAPIHandler) *OpenAIResponsesAPIHandler {
return &OpenAIResponsesAPIHandler{
BaseAPIHandler: apiHandlers,
}
}
// HandlerType returns the identifier for this handler implementation.
func (h *OpenAIResponsesAPIHandler) HandlerType() string {
return OpenaiResponse
}
// Models returns the OpenAIResponses-compatible model metadata supported by this handler.
func (h *OpenAIResponsesAPIHandler) Models() []map[string]any {
// Get dynamic models from the global registry
modelRegistry := registry.GetGlobalRegistry()
return modelRegistry.GetAvailableModels("openai")
}
// OpenAIResponsesModels handles the /v1/models endpoint.
// It returns a list of available AI models with their capabilities
// and specifications in OpenAIResponses-compatible format.
func (h *OpenAIResponsesAPIHandler) OpenAIResponsesModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": h.Models(),
})
}
func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context, payload []byte) []byte {
if h == nil || h.Cfg == nil {
return payload
}
requestCtx := context.Background()
if c != nil && c.Request != nil {
requestCtx = c.Request.Context()
}
requestCtx = context.WithValue(requestCtx, "gin", c)
var requestHeaders http.Header
if c != nil && c.Request != nil {
requestHeaders = c.Request.Header
}
homeEnabled := h.AuthManager != nil && h.AuthManager.HomeEnabled()
updated, prepared := multiagentv2.PrepareCodexMultiAgentV2Tools(
requestCtx,
requestHeaders,
payload,
h.Cfg.CodexOptimizeMultiAgentV2,
homeEnabled,
)
if prepared && c != nil {
c.Set(multiagentv2.CodexMultiAgentV2ToolsPreparedContextKey, true)
}
return updated
}
// Responses handles the /v1/responses endpoint.
// It determines whether the request is for a streaming or non-streaming response
// and calls the appropriate handler based on the model provider.
//
// Parameters:
// - c: The Gin context containing the HTTP request and response
func (h *OpenAIResponsesAPIHandler) Responses(c *gin.Context) {
rawJSON, err := handlers.ReadRequestBody(c)
// If data retrieval fails, return a 400 Bad Request error.
if err != nil {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
rawJSON = h.prepareCodexMultiAgentV2Tools(c, rawJSON)
// Check if the client requested a streaming response.
streamResult := gjson.GetBytes(rawJSON, "stream")
if streamResult.Type == gjson.True {
h.handleStreamingResponse(c, rawJSON)
} else {
h.handleNonStreamingResponse(c, rawJSON)
}
}
func (h *OpenAIResponsesAPIHandler) Compact(c *gin.Context) {
rawJSON, err := handlers.ReadRequestBody(c)
if err != nil {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
streamResult := gjson.GetBytes(rawJSON, "stream")
if streamResult.Type == gjson.True {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
Message: "Streaming not supported for compact responses",
Type: "invalid_request_error",
},
})
return
}
if streamResult.Exists() {
if updated, err := sjson.DeleteBytes(rawJSON, "stream"); err == nil {
rawJSON = updated
}
}
c.Header("Content-Type", "application/json")
modelName := gjson.GetBytes(rawJSON, "model").String()
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "responses/compact")
stopKeepAlive()
if errMsg != nil {
h.WriteErrorResponse(c, errMsg)
cliCancel(errMsg.Error)
return
}
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = c.Writer.Write(resp)
cliCancel()
}
// handleNonStreamingResponse handles non-streaming chat completion responses
// for Gemini models. It selects a client from the pool, sends the request, and
// aggregates the response before sending it back to the client in OpenAIResponses format.
//
// Parameters:
// - c: The Gin context containing the HTTP request and response
// - rawJSON: The raw JSON bytes of the OpenAIResponses-compatible request
func (h *OpenAIResponsesAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []byte) {
c.Header("Content-Type", "application/json")
modelName := gjson.GetBytes(rawJSON, "model").String()
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "")
stopKeepAlive()
if errMsg != nil {
h.WriteErrorResponse(c, errMsg)
cliCancel(errMsg.Error)
return
}
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = c.Writer.Write(resp)
cliCancel()
}
// handleStreamingResponse handles streaming responses for Gemini models.
// It establishes a streaming connection with the backend service and forwards
// the response chunks to the client in real-time using Server-Sent Events.
//
// Parameters:
// - c: The Gin context containing the HTTP request and response
// - rawJSON: The raw JSON bytes of the OpenAIResponses-compatible request
func (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byte) {
// Get the http.Flusher interface to manually flush the response.
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
Message: "Streaming not supported",
Type: "server_error",
},
})
return
}
// New core execution path
modelName := gjson.GetBytes(rawJSON, "model").String()
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "")
setSSEHeaders := func() {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
}
failureEvent := "error"
if isCodexResponsesClientRequest(c) {
failureEvent = "response.failed"
}
framer := &responsesSSEFramer{failureEvent: failureEvent}
var initialOutput bytes.Buffer
// Peek at the first complete SSE data frame.
for {
select {
case <-c.Request.Context().Done():
cliCancel(c.Request.Context().Err())
return
case errMsg, ok := <-errChan:
if !ok {
// Err channel closed cleanly; wait for data channel.
errChan = nil
continue
}
framer.Flush(&initialOutput)
safeErrMsg := sanitizeResponsesStreamErrorMessage(errMsg)
if framer.dataFrames == 0 {
safeErrMsg = sanitizeResponsesInitialErrorMessage(errMsg)
}
if safeErrMsg != nil && framer.dataFrames > 0 {
setSSEHeaders()
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = c.Writer.Write(initialOutput.Bytes())
flusher.Flush()
pendingErrors := make(chan *interfaces.ErrorMessage, 1)
pendingErrors <- safeErrMsg
close(pendingErrors)
h.forwardResponsesStream(c, flusher, func(err error) { cliCancel(err) }, make(chan []byte), pendingErrors, framer)
return
}
// Upstream failed before a complete SSE data frame. Return JSON.
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), safeErrMsg)
h.WriteErrorResponse(c, safeErrMsg)
if safeErrMsg != nil {
cliCancel(safeErrMsg.Error)
} else {
cliCancel(nil)
}
return
case chunk, ok := <-dataChan:
if !ok {
framer.Flush(&initialOutput)
errMsg, hasPendingError := handlers.PendingStreamError(errChan)
if !hasPendingError && framer.terminalEvent == "" {
message := "upstream stream closed before first payload"
if framer.dataFrames > 0 {
message = "upstream stream closed before a terminal event"
}
errMsg = &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("%s", message)}
}
if framer.dataFrames > 0 {
errMsg = sanitizeResponsesStreamErrorMessage(errMsg)
} else {
errMsg = sanitizeResponsesInitialErrorMessage(errMsg)
}
if framer.dataFrames > 0 {
setSSEHeaders()
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = c.Writer.Write(initialOutput.Bytes())
flusher.Flush()
if framer.terminalError != nil {
h.logResponsesStreamError(c, framer, framer.terminalError)
cliCancel(framer.terminalError.Error)
return
}
if errMsg == nil {
cliCancel(nil)
return
}
pendingErrors := make(chan *interfaces.ErrorMessage, 1)
pendingErrors <- errMsg
close(pendingErrors)
h.forwardResponsesStream(c, flusher, func(err error) { cliCancel(err) }, make(chan []byte), pendingErrors, framer)
return
}
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), errMsg)
h.WriteErrorResponse(c, errMsg)
if errMsg != nil {
cliCancel(errMsg.Error)
} else {
cliCancel(nil)
}
return
}
framer.WriteChunk(&initialOutput, chunk)
if framer.dataFrames == 0 {
continue
}
setSSEHeaders()
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
_, _ = c.Writer.Write(initialOutput.Bytes())
flusher.Flush()
if framer.terminalError != nil {
h.logResponsesStreamError(c, framer, framer.terminalError)
cliCancel(framer.terminalError.Error)
return
}
h.forwardResponsesStream(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan, framer)
return
}
}
}
// isCodexResponsesClientRequest limits the alternate terminal event to official Codex clients.
func isCodexResponsesClientRequest(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
}
if multiagentv2.IsCodexClientUserAgent(c.GetHeader("User-Agent")) {
return true
}
switch originator := strings.ToLower(strings.TrimSpace(c.GetHeader("Originator"))); originator {
case "codex desktop", "codex-tui", "codex_cli_rs":
return true
default:
return strings.HasPrefix(originator, "codex desktop/") || strings.HasPrefix(originator, "codex-tui/") || strings.HasPrefix(originator, "codex_cli_rs/")
}
}
const (
responsesStreamErrorMessageLimit = 2048
responsesStreamErrorFieldLimit = 256
)
var (
responsesStreamSensitiveValuePattern = regexp.MustCompile(`(?i)((?:"?(?:api[_-]?key|access[_-]?token|token|authorization|secret)"?)\s*[=:]\s*"?)([^\s"&,;}]+)`)
responsesStreamBearerPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+`)
)
func truncateResponsesStreamErrorText(text string, limit int) string {
runes := []rune(text)
if len(runes) <= limit {
return text
}
return string(runes[:limit]) + "…"
}
func redactResponsesStreamErrorText(text string) string {
text = responsesStreamSensitiveValuePattern.ReplaceAllString(text, `${1}[REDACTED]`)
return responsesStreamBearerPattern.ReplaceAllString(text, "Bearer [REDACTED]")
}
func sanitizeResponsesStreamEventName(eventName string) string {
return truncateResponsesStreamErrorText(redactResponsesStreamErrorText(strings.TrimSpace(eventName)), responsesStreamErrorFieldLimit)
}
func responsesStreamErrorText(errMsg *interfaces.ErrorMessage, status int) string {
text := http.StatusText(status)
if errMsg != nil && errMsg.Error != nil && strings.TrimSpace(errMsg.Error.Error()) != "" {
text = strings.TrimSpace(errMsg.Error.Error())
}
if !json.Valid([]byte(text)) {
return truncateResponsesStreamErrorText(redactResponsesStreamErrorText(text), responsesStreamErrorMessageLimit)
}
root := gjson.Parse(text)
errorNode := root.Get("error")
if !errorNode.Exists() || !errorNode.IsObject() {
errorNode = root.Get("response.error")
}
if errorNode.Exists() && errorNode.IsObject() {
safe := []byte(`{"error":{}}`)
copied := false
for _, field := range []string{"type", "code", "message", "param"} {
value := errorNode.Get(field)
if !value.Exists() || value.Type == gjson.Null {
continue
}
limit := responsesStreamErrorFieldLimit
if field == "message" {
limit = responsesStreamErrorMessageLimit
}
safe, _ = sjson.SetBytes(safe, "error."+field, truncateResponsesStreamErrorText(redactResponsesStreamErrorText(value.String()), limit))
copied = true
}
if copied {
return string(safe)
}
}
safe := []byte(`{"type":"error"}`)
copied := false
for _, field := range []string{"code", "message", "param"} {
value := root.Get(field)
if !value.Exists() || value.Type == gjson.Null {
continue
}
limit := responsesStreamErrorFieldLimit
if field == "message" {
limit = responsesStreamErrorMessageLimit
}
safe, _ = sjson.SetBytes(safe, field, truncateResponsesStreamErrorText(redactResponsesStreamErrorText(value.String()), limit))
copied = true
}
if copied {
return string(safe)
}
return http.StatusText(status)
}
type responsesStreamSanitizedError struct {
message string
cause error
}
func (e *responsesStreamSanitizedError) Error() string { return e.message }
func (e *responsesStreamSanitizedError) Unwrap() error { return e.cause }
func sanitizeResponsesInitialErrorMessage(errMsg *interfaces.ErrorMessage) *interfaces.ErrorMessage {
if errMsg != nil && errMsg.DirectResponse {
return errMsg
}
return sanitizeResponsesStreamErrorMessage(errMsg)
}
func sanitizeResponsesStreamErrorMessage(errMsg *interfaces.ErrorMessage) *interfaces.ErrorMessage {
if errMsg == nil {
return nil
}
status := errMsg.StatusCode
if status < http.StatusBadRequest || status > 599 {
status = http.StatusInternalServerError
}
safe := *errMsg
safe.StatusCode = status
safe.Error = &responsesStreamSanitizedError{message: responsesStreamErrorText(errMsg, status), cause: errMsg.Error}
safe.DirectResponse = false
safe.Body = nil
return &safe
}
func (h *OpenAIResponsesAPIHandler) logResponsesStreamError(c *gin.Context, framer *responsesSSEFramer, errMsg *interfaces.ErrorMessage) {
if errMsg == nil {
return
}
status := errMsg.StatusCode
if status < http.StatusBadRequest || status > 599 {
status = http.StatusInternalServerError
}
lastEvent := "none"
if framer != nil && framer.lastEvent != "" {
lastEvent = framer.lastEvent
}
errText := responsesStreamErrorText(errMsg, status)
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), &interfaces.ErrorMessage{
StatusCode: status,
Error: fmt.Errorf("responses stream terminated after %s: %s", lastEvent, errText),
})
}
func (h *OpenAIResponsesAPIHandler) forwardResponsesStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage, framer *responsesSSEFramer) {
if framer == nil {
framer = &responsesSSEFramer{}
}
if isCodexResponsesClientRequest(c) {
framer.failureEvent = "response.failed"
} else {
framer.failureEvent = "error"
}
writeTerminalError := func(errMsg *interfaces.ErrorMessage) {
framer.Flush(c.Writer)
if errMsg == nil {
return
}
status := http.StatusInternalServerError
if errMsg.StatusCode > 0 {
status = errMsg.StatusCode
}
errText := responsesStreamErrorText(errMsg, status)
h.logResponsesStreamError(c, framer, errMsg)
if framer.terminalEvent != "" {
return
}
if isCodexResponsesClientRequest(c) {
chunk := handlers.BuildOpenAIResponsesStreamFailedChunk(status, errText, 0)
_, _ = fmt.Fprintf(c.Writer, "\nevent: response.failed\ndata: %s\n\n", string(chunk))
return
}
chunk := handlers.BuildOpenAIResponsesStreamErrorChunk(status, errText, 0)
_, _ = fmt.Fprintf(c.Writer, "\nevent: error\ndata: %s\n\n", string(chunk))
}
h.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{
NormalizeTerminalError: sanitizeResponsesStreamErrorMessage,
WriteChunk: func(chunk []byte) {
framer.WriteChunk(c.Writer, chunk)
},
ChunkError: func() *interfaces.ErrorMessage {
if framer.terminalError != nil {
h.logResponsesStreamError(c, framer, framer.terminalError)
}
return framer.terminalError
},
WriteTerminalError: writeTerminalError,
CloseError: func() *interfaces.ErrorMessage {
framer.Flush(c.Writer)
if framer.terminalError != nil {
return framer.terminalError
}
if framer.terminalEvent != "" {
return nil
}
lastEvent := framer.lastEvent
if lastEvent == "" {
lastEvent = "none"
}
return &interfaces.ErrorMessage{
StatusCode: http.StatusBadGateway,
Error: fmt.Errorf("upstream stream closed before a terminal event (last event: %s)", lastEvent),
}
},
WriteDone: func() {
framer.Flush(c.Writer)
_, _ = c.Writer.Write([]byte("\n"))
},
})
}

View file

@ -0,0 +1,893 @@
package openai
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
const (
prematureResponsesStreamModel = "premature-responses-stream-model"
initialFailureResponsesModel = "initial-failure-responses-stream-model"
emptyResponsesStreamModel = "empty-responses-stream-model"
incompleteFirstFrameResponsesModel = "incomplete-first-frame-responses-model"
dataOnlyFirstFrameResponsesModel = "data-only-first-frame-responses-model"
dataOnlyCleanCloseResponsesModel = "data-only-clean-close-responses-model"
sensitiveInitialErrorResponsesModel = "sensitive-initial-error-responses-model"
directInitialErrorResponsesModel = "direct-initial-error-responses-model"
crossChunkMultilineResponsesModel = "cross-chunk-multiline-responses-model"
validThenMalformedResponsesModel = "valid-then-malformed-responses-model"
)
type prematureResponsesStreamExecutor struct{}
func (*prematureResponsesStreamExecutor) Identifier() string { return "premature-responses-stream" }
func (*prematureResponsesStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
return coreexecutor.Response{}, errors.New("not implemented")
}
func (*prematureResponsesStreamExecutor) ExecuteStream(_ context.Context, _ *coreauth.Auth, req coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) {
if req.Model == directInitialErrorResponsesModel {
return nil, &coreexecutor.RequestTerminatedError{
HTTPStatus: http.StatusTooManyRequests,
Header: http.Header{"Retry-After": []string{"17"}, "X-Plugin-Response": []string{"true"}},
Body: []byte(`{"error":{"message":"plugin direct response"}}`),
}
}
chunks := make(chan coreexecutor.StreamChunk, 2)
if req.Model == validThenMalformedResponsesModel {
chunks <- coreexecutor.StreamChunk{Payload: []byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" +
"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\"\n\n")}
close(chunks)
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
if req.Model == crossChunkMultilineResponsesModel {
chunks <- coreexecutor.StreamChunk{Payload: []byte("event: response.completed\ndata: {\"type\":\"response.completed\",")}
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: \"response\":{\"id\":\"resp-1\",\"status\":\"completed\"}}\n\n")}
close(chunks)
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
if req.Model == sensitiveInitialErrorResponsesModel {
chunks <- coreexecutor.StreamChunk{Err: errors.New(`{"error":{"type":"server_error","code":"upstream_failed","message":"initial upstream failure: {\"api_key\":\"initial-message-secret\"}"},"debug":{"token":"initial-debug-secret","trace":"` + strings.Repeat("x", 8192) + `"}}`)}
close(chunks)
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
if req.Model == dataOnlyFirstFrameResponsesModel || req.Model == dataOnlyCleanCloseResponsesModel {
chunks <- coreexecutor.StreamChunk{Payload: []byte(`data: {"type":"response.output_text.delta","delta":"partial"}`)}
if req.Model == dataOnlyFirstFrameResponsesModel {
chunks <- coreexecutor.StreamChunk{Err: errors.New("upstream failed after data-only frame")}
}
close(chunks)
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
if req.Model == incompleteFirstFrameResponsesModel {
chunks <- coreexecutor.StreamChunk{Payload: []byte("event: response.created")}
chunks <- coreexecutor.StreamChunk{Err: errors.New("upstream failed before first complete frame")}
close(chunks)
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
if req.Model == emptyResponsesStreamModel {
close(chunks)
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
if req.Model == initialFailureResponsesModel {
chunks <- coreexecutor.StreamChunk{Err: errors.New("upstream failed before first payload")}
close(chunks)
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
chunks <- coreexecutor.StreamChunk{Payload: []byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n")}
chunks <- coreexecutor.StreamChunk{Err: errors.New("unexpected EOF")}
close(chunks)
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
func (*prematureResponsesStreamExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
return auth, nil
}
func (*prematureResponsesStreamExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
return coreexecutor.Response{}, errors.New("not implemented")
}
func (*prematureResponsesStreamExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
return nil, errors.New("not implemented")
}
func TestResponsesHandlerEmitsFailureWhenExecutorStopsAfterPartialOutput(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &prematureResponsesStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "premature-responses-stream-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: prematureResponsesStreamModel}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{RequestLog: true}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"premature-responses-stream-model","input":"hi","stream":true}`))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 after stream start; body=%s", recorder.Code, recorder.Body.String())
}
body := recorder.Body.String()
if !strings.Contains(body, "response.output_text.delta") || !strings.Contains(body, "event: response.failed") {
t.Fatalf("handler did not preserve partial output and terminal failure: %q", body)
}
if !strings.Contains(body, "unexpected EOF") {
t.Fatalf("handler terminal failure lost executor error: %q", body)
}
}
func TestSanitizeResponsesStreamErrorMessageNormalizesSuccessStatus(t *testing.T) {
got := sanitizeResponsesStreamErrorMessage(&interfaces.ErrorMessage{StatusCode: http.StatusOK, Error: errors.New("upstream failed")})
if got == nil || got.StatusCode != http.StatusInternalServerError {
t.Fatalf("sanitized status = %#v, want %d", got, http.StatusInternalServerError)
}
}
func TestResponsesHandlerCommitsValidFrameBeforeMalformedFrameInSameChunk(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &prematureResponsesStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "valid-then-malformed-responses-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: validThenMalformedResponsesModel}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"valid-then-malformed-responses-model","input":"hi","stream":true}`))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "response.output_text.delta") || !strings.Contains(recorder.Body.String(), "event: response.failed") {
t.Fatalf("valid then malformed response status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestResponsesHandlerAcceptsMultilineDataAcrossExecutorChunks(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &prematureResponsesStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "cross-chunk-multiline-responses-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: crossChunkMultilineResponsesModel}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"cross-chunk-multiline-responses-model","input":"hi","stream":true}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "event: response.completed") {
t.Fatalf("cross-chunk multiline response status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestResponsesHandlerPreservesDirectResponseBeforeFirstFrame(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &prematureResponsesStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "direct-initial-error-responses-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: directInitialErrorResponsesModel}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"direct-initial-error-responses-model","input":"hi","stream":true}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("Retry-After") != "17" || recorder.Header().Get("X-Plugin-Response") != "true" {
t.Fatalf("direct response status=%d headers=%v body=%q", recorder.Code, recorder.Header(), recorder.Body.String())
}
if recorder.Body.String() != `{"error":{"message":"plugin direct response"}}` {
t.Fatalf("direct response body = %q", recorder.Body.String())
}
}
func TestResponsesHandlerSanitizesErrorBeforeFirstFrame(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &prematureResponsesStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "sensitive-initial-error-responses-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: sensitiveInitialErrorResponsesModel}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{RequestLog: true}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"sensitive-initial-error-responses-model","input":"hi","stream":true}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
body := recorder.Body.String()
if recorder.Code == http.StatusOK || !strings.Contains(body, "upstream_failed") || !strings.Contains(body, "initial upstream failure") {
t.Fatalf("initial error response = status %d body %q", recorder.Code, body)
}
for _, secret := range []string{"initial-message-secret", "initial-debug-secret"} {
if strings.Contains(body, secret) {
t.Fatalf("initial error leaked %q: %q", secret, body)
}
}
if len(body) > 4096 {
t.Fatalf("initial error response remained unbounded: len=%d", len(body))
}
}
func TestResponsesHandlerFlushesDataOnlyFrameBeforeStreamingError(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &prematureResponsesStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "data-only-first-frame-responses-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: dataOnlyFirstFrameResponsesModel}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"data-only-first-frame-responses-model","input":"hi","stream":true}`))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 after complete data frame; body=%q", recorder.Code, recorder.Body.String())
}
body := recorder.Body.String()
if !strings.Contains(body, "response.output_text.delta") || !strings.Contains(body, "event: response.failed") {
t.Fatalf("data-only frame or terminal failure was lost: %q", body)
}
}
func TestResponsesHandlerEmitsFailureWhenDataOnlyStreamClosesCleanly(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &prematureResponsesStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "data-only-clean-close-responses-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: dataOnlyCleanCloseResponsesModel}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"data-only-clean-close-responses-model","input":"hi","stream":true}`))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 after complete data frame; body=%q", recorder.Code, recorder.Body.String())
}
body := recorder.Body.String()
if !strings.Contains(body, "response.output_text.delta") || !strings.Contains(body, "event: response.failed") {
t.Fatalf("clean close did not retain data and emit terminal failure: %q", body)
}
if strings.Contains(body, "event: response.completed") {
t.Fatalf("clean close synthesized completion: %q", body)
}
}
func TestResponsesHandlerDoesNotCommitHeadersForIncompleteFirstFrame(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &prematureResponsesStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "incomplete-first-frame-responses-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: incompleteFirstFrameResponsesModel}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"incomplete-first-frame-responses-model","input":"hi","stream":true}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code == http.StatusOK {
t.Fatalf("incomplete first SSE frame committed HTTP 200: %q", recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "upstream failed before first complete frame") {
t.Fatalf("initial frame error was lost: status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestResponsesHandlerRejectsStreamClosedBeforeFirstPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &prematureResponsesStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "empty-responses-stream-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: emptyResponsesStreamModel}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"empty-responses-stream-model","input":"hi","stream":true}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code == http.StatusOK {
t.Fatalf("empty upstream stream returned HTTP 200: %q", recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "closed before first payload") {
t.Fatalf("empty upstream stream error is unclear: status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestResponsesHandlerDoesNotLoseErrorBeforeFirstPayload(t *testing.T) {
gin.SetMode(gin.TestMode)
for i := 0; i < 100; i++ {
executor := &prematureResponsesStreamExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: fmt.Sprintf("initial-failure-responses-stream-auth-%d", i), Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth %d: %v", i, errRegister)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: initialFailureResponsesModel}})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"initial-failure-responses-stream-model","input":"hi","stream":true}`))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
if recorder.Code == http.StatusOK {
t.Fatalf("request %d lost the buffered initial error and returned HTTP 200: %q", i, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "upstream failed before first payload") {
t.Fatalf("request %d lost the initial upstream error: status=%d body=%q", i, recorder.Code, recorder.Body.String())
}
}
}
// TestForwardResponsesStreamExposesTerminalErrors pins the SSE side: once a
// Responses stream has started, every terminal upstream error reaches the client.
func TestForwardResponsesStreamExposesTerminalErrors(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
status int
message string
wantExposed bool
}{
{
name: "bad request",
status: http.StatusBadRequest,
message: `{"error":{"type":"invalid_request","code":"cyber_policy","message":"blocked"}}`,
wantExposed: true,
},
{
// Observed in production: the same cyber_policy rejection arrives with 502
// when it is surfaced through the websocket disconnect channel.
name: "cyber policy behind bad gateway status",
status: http.StatusBadGateway,
message: `{"error":{"type":"invalid_request","code":"cyber_policy","message":"This content was flagged for possible cybersecurity risk.","param":null}}`,
wantExposed: true,
},
{
name: "context length exceeded behind bad gateway status",
status: http.StatusBadGateway,
message: `{"error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"Your input exceeds the context window."}}`,
wantExposed: true,
},
{name: "conflict", status: http.StatusConflict, message: "conflict", wantExposed: true},
{name: "message too big", status: http.StatusRequestEntityTooLarge, message: "too large", wantExposed: true},
{name: "unprocessable entity", status: http.StatusUnprocessableEntity, message: "invalid input", wantExposed: true},
{name: "authentication", status: http.StatusUnauthorized, message: "invalid credential", wantExposed: true},
{name: "payment required", status: http.StatusPaymentRequired, message: "insufficient credits", wantExposed: true},
{name: "quota error", status: http.StatusTooManyRequests, message: "usage limit reached", wantExposed: true},
{name: "request timeout", status: http.StatusRequestTimeout, message: "upstream timeout", wantExposed: true},
{name: "transport error", status: http.StatusInternalServerError, message: "unexpected EOF", wantExposed: true},
{name: "upstream websocket drop", status: http.StatusInternalServerError,
message: `{"error":{"message":"websocket: close 1006 (abnormal closure): unexpected EOF","type":"server_error","code":"internal_server_error"}}`, wantExposed: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
data := make(chan []byte)
errs := make(chan *interfaces.ErrorMessage, 1)
errs <- &interfaces.ErrorMessage{StatusCode: tc.status, Error: errors.New(tc.message)}
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
body := recorder.Body.String()
exposed := strings.Contains(body, `"type":"error"`)
if exposed != tc.wantExposed {
t.Fatalf("error exposed = %t, want %t: %q", exposed, tc.wantExposed, body)
}
if exposed && strings.Contains(body, `"error":{`) {
t.Fatalf("expected streaming error chunk, got HTTP error body: %q", body)
}
})
}
}
func TestForwardResponsesStreamUsesResponseFailedForCodex(t *testing.T) {
gin.SetMode(gin.TestMode)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
data := make(chan []byte)
errs := make(chan *interfaces.ErrorMessage, 1)
errs <- &interfaces.ErrorMessage{
StatusCode: http.StatusBadRequest,
Error: errors.New(`{"error":{"type":"invalid_request","code":"cyber_policy","message":"blocked"}}`),
}
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
body := recorder.Body.String()
if !strings.Contains(body, "event: response.failed") {
t.Fatalf("missing response.failed event: %q", body)
}
if strings.Contains(body, "event: error") {
t.Fatalf("unexpected legacy error event for Codex: %q", body)
}
if !strings.Contains(body, `"type":"invalid_request"`) || !strings.Contains(body, `"code":"cyber_policy"`) {
t.Fatalf("missing nested Codex error detail: %q", body)
}
}
func TestForwardResponsesStreamExposesTransportErrorAfterOutputForCodex(t *testing.T) {
gin.SetMode(gin.TestMode)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{RequestLog: true}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
framer := &responsesSSEFramer{}
framer.WriteChunk(c.Writer, []byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n"))
data := make(chan []byte)
errs := make(chan *interfaces.ErrorMessage, 1)
errs <- &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: errors.New("unexpected EOF")}
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, framer)
body := recorder.Body.String()
if !strings.Contains(body, "event: response.failed") {
t.Fatalf("transport failure ended without response.failed: %q", body)
}
if !strings.Contains(body, "unexpected EOF") {
t.Fatalf("response.failed lost the upstream error: %q", body)
}
loggedValue, ok := c.Get("API_RESPONSE_ERROR")
if !ok {
t.Fatal("request log did not retain the stream error")
}
loggedErrors, ok := loggedValue.([]*interfaces.ErrorMessage)
if !ok || len(loggedErrors) != 1 || loggedErrors[0] == nil || loggedErrors[0].Error == nil {
t.Fatalf("unexpected request-log errors: %#v", loggedValue)
}
diagnostic := loggedErrors[0].Error.Error()
if !strings.Contains(diagnostic, "response.output_text.delta") || !strings.Contains(diagnostic, "unexpected EOF") {
t.Fatalf("request-log diagnostic lacks last event or upstream error: %q", diagnostic)
}
}
func TestForwardResponsesStreamSanitizesDiagnosticErrorDetails(t *testing.T) {
gin.SetMode(gin.TestMode)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{RequestLog: true}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
debugSecret := "super-secret-provider-debug-value"
messageSecret := "super-secret-provider-message-value"
rawError := `{"error":{"type":"server_error","code":"upstream_failed","message":"upstream failed: {\"api_key\":\"` + messageSecret + `\"}"},"debug":{"api_key":"` + debugSecret + `","trace":"` + strings.Repeat("x", 8192) + `"}}`
framer := &responsesSSEFramer{}
framer.WriteChunk(c.Writer, []byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n"))
data := make(chan []byte)
errs := make(chan *interfaces.ErrorMessage, 1)
errs <- &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: errors.New(rawError)}
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, framer)
body := recorder.Body.String()
if !strings.Contains(body, "upstream failed") || !strings.Contains(body, "upstream_failed") {
t.Fatalf("client error lost safe structured fields: %q", body)
}
if strings.Contains(body, debugSecret) || strings.Contains(body, messageSecret) {
t.Fatalf("client error leaked provider secret: %q", body)
}
loggedValue, ok := c.Get("API_RESPONSE_ERROR")
if !ok {
t.Fatal("request log did not retain the sanitized stream error")
}
loggedErrors, ok := loggedValue.([]*interfaces.ErrorMessage)
if !ok || len(loggedErrors) != 1 || loggedErrors[0] == nil || loggedErrors[0].Error == nil {
t.Fatalf("unexpected request-log errors: %#v", loggedValue)
}
diagnostic := loggedErrors[0].Error.Error()
if strings.Contains(diagnostic, debugSecret) || strings.Contains(diagnostic, messageSecret) || len(diagnostic) > 4096 {
t.Fatalf("request-log diagnostic leaked or retained an unbounded upstream body: len=%d diagnostic=%q", len(diagnostic), diagnostic)
}
if !strings.Contains(diagnostic, "upstream failed") {
t.Fatalf("sanitized request-log diagnostic lost upstream message: %q", diagnostic)
}
}
func TestForwardResponsesStreamPreservesNestedResponseError(t *testing.T) {
gin.SetMode(gin.TestMode)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{RequestLog: true}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
framer := &responsesSSEFramer{}
framer.WriteChunk(c.Writer, []byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n"))
data := make(chan []byte)
errs := make(chan *interfaces.ErrorMessage, 1)
errs <- &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: errors.New(`{"type":"response.failed","response":{"error":{"type":"server_error","code":"upstream_failed","message":"nested response failure","param":"input"}}}`)}
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, framer)
body := recorder.Body.String()
for _, want := range []string{"nested response failure", "upstream_failed", "server_error"} {
if !strings.Contains(body, want) {
t.Fatalf("response.failed lost nested response error field %q: %q", want, body)
}
}
}
func TestForwardResponsesStreamSanitizesLastEventDiagnostic(t *testing.T) {
gin.SetMode(gin.TestMode)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{RequestLog: true}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
eventSecret := "event-secret-value"
eventName := "custom-event-Bearer " + eventSecret + strings.Repeat("x", 1024)
framer := &responsesSSEFramer{}
framer.WriteChunk(c.Writer, []byte("event: "+eventName+"\ndata: {\"message\":\"partial\"}\n\n"))
data := make(chan []byte)
errs := make(chan *interfaces.ErrorMessage, 1)
errs <- &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: errors.New("unexpected EOF")}
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, framer)
loggedValue, ok := c.Get("API_RESPONSE_ERROR")
if !ok {
t.Fatal("request log did not retain the stream error")
}
loggedErrors, ok := loggedValue.([]*interfaces.ErrorMessage)
if !ok || len(loggedErrors) != 1 || loggedErrors[0] == nil || loggedErrors[0].Error == nil {
t.Fatalf("unexpected request-log errors: %#v", loggedValue)
}
diagnostic := loggedErrors[0].Error.Error()
if strings.Contains(diagnostic, eventSecret) || len(diagnostic) > 1024 {
t.Fatalf("last-event diagnostic leaked or remained unbounded: len=%d diagnostic=%q", len(diagnostic), diagnostic)
}
}
func TestForwardResponsesStreamSanitizesPayloadErrorsAndStopsAtFailure(t *testing.T) {
for _, tc := range []struct {
name string
frame string
}{
{
name: "event error with payload type",
frame: "event: error\ndata: {\"type\":\"provider.error\",\"error\":{\"code\":\"failed\",\"message\":\"token=payload-secret\"}}\n\n",
},
{
name: "typed nested error",
frame: "data: {\"type\":\"provider.error\",\"error\":{\"code\":\"failed\",\"message\":\"token=payload-secret\"}}\n\n",
},
{
name: "top level error fields",
frame: "data: {\"code\":\"failed\",\"message\":\"token=payload-secret\"}\n\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{RequestLog: true}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
data := make(chan []byte, 1)
data <- []byte(tc.frame + "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n")
close(data)
errs := make(chan *interfaces.ErrorMessage)
close(errs)
var canceled error
h.forwardResponsesStream(c, flusher, func(err error) { canceled = err }, data, errs, &responsesSSEFramer{})
body := recorder.Body.String()
if canceled == nil {
t.Fatalf("payload error canceled with nil: %q", body)
}
if strings.Contains(body, "payload-secret") || strings.Contains(body, "event: response.completed") {
t.Fatalf("payload error leaked or accepted later completion: %q", body)
}
if strings.Count(body, "event: response.failed") != 1 || !strings.Contains(body, "[REDACTED]") {
t.Fatalf("payload error was not converted to one sanitized response.failed: %q", body)
}
})
}
}
func TestForwardResponsesStreamReportsDataOnlyErrorFlushedAtEOF(t *testing.T) {
gin.SetMode(gin.TestMode)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{RequestLog: true}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
data := make(chan []byte, 1)
data <- []byte(`data: {"type":"error","error":{"message":"failed at EOF"}}`)
close(data)
errs := make(chan *interfaces.ErrorMessage)
close(errs)
var canceled error
h.forwardResponsesStream(c, flusher, func(err error) { canceled = err }, data, errs, &responsesSSEFramer{})
if canceled == nil || !strings.Contains(canceled.Error(), "failed at EOF") {
t.Fatalf("EOF error cancel = %v, body=%q", canceled, recorder.Body.String())
}
if strings.Count(recorder.Body.String(), "event: response.failed") != 1 {
t.Fatalf("EOF error terminal output = %q", recorder.Body.String())
}
if _, okLog := c.Get("API_RESPONSE_ERROR"); !okLog {
t.Fatal("EOF error was not retained in request diagnostics")
}
}
func TestForwardResponsesStreamDoesNotAppendFailureAfterTerminalEvent(t *testing.T) {
gin.SetMode(gin.TestMode)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{RequestLog: true}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
framer := &responsesSSEFramer{}
framer.WriteChunk(c.Writer, []byte("event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"status\":\"completed\"}}\n\n"))
data := make(chan []byte)
errs := make(chan *interfaces.ErrorMessage, 1)
errs <- &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: errors.New("unexpected EOF after completion")}
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, framer)
body := recorder.Body.String()
if strings.Contains(body, "event: response.failed") || strings.Contains(body, "event: error") {
t.Fatalf("stream appended a second terminal event after response.completed: %q", body)
}
loggedValue, ok := c.Get("API_RESPONSE_ERROR")
if !ok {
t.Fatal("request log did not retain the post-terminal upstream error")
}
loggedErrors, ok := loggedValue.([]*interfaces.ErrorMessage)
if !ok || len(loggedErrors) != 1 || loggedErrors[0] == nil || loggedErrors[0].Error == nil {
t.Fatalf("unexpected request-log errors: %#v", loggedValue)
}
diagnostic := loggedErrors[0].Error.Error()
if !strings.Contains(diagnostic, "response.completed") || !strings.Contains(diagnostic, "unexpected EOF after completion") {
t.Fatalf("request-log diagnostic lacks terminal event or upstream error: %q", diagnostic)
}
}
func TestForwardResponsesStreamFailsWhenUpstreamClosesWithoutTerminalEvent(t *testing.T) {
gin.SetMode(gin.TestMode)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Request.Header.Set("User-Agent", "Codex Desktop/26.803.41515")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatal("expected gin writer to implement http.Flusher")
}
framer := &responsesSSEFramer{}
framer.WriteChunk(c.Writer, []byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n"))
data := make(chan []byte)
close(data)
errs := make(chan *interfaces.ErrorMessage)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, framer)
body := recorder.Body.String()
if !strings.Contains(body, "event: response.failed") {
t.Fatalf("unterminated stream ended without response.failed: %q", body)
}
if !strings.Contains(body, "closed before a terminal event") {
t.Fatalf("response.failed does not explain the premature close: %q", body)
}
}

View file

@ -0,0 +1,314 @@
package openai
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/tidwall/gjson"
)
func newResponsesStreamTestHandler(t *testing.T) (*OpenAIResponsesAPIHandler, *httptest.ResponseRecorder, *gin.Context, http.Flusher) {
t.Helper()
gin.SetMode(gin.TestMode)
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
h := NewOpenAIResponsesAPIHandler(base)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
t.Fatalf("expected gin writer to implement http.Flusher")
}
return h, recorder, c, flusher
}
func TestResponsesSSEFramerWaitsForEventFieldAfterData(t *testing.T) {
var output bytes.Buffer
framer := &responsesSSEFramer{}
framer.WriteChunk(&output, []byte(`data: {"response":{"id":"resp-1","status":"completed"}}`))
if output.Len() != 0 {
t.Fatalf("framer emitted data before a following event field arrived: %q", output.String())
}
framer.WriteChunk(&output, []byte("event: response.completed"))
if framer.terminalEvent != "response.completed" {
t.Fatalf("terminal event = %q, want response.completed", framer.terminalEvent)
}
got := output.String()
if !strings.Contains(got, "data: ") || !strings.Contains(got, "event: response.completed") {
t.Fatalf("framer did not preserve data-before-event fields in one frame: %q", got)
}
}
func TestResponsesSSEFramerFlushesMultilineDataWithoutDelimiter(t *testing.T) {
var output bytes.Buffer
framer := &responsesSSEFramer{}
chunk := []byte("event: response.completed\n" +
"data: {\"type\":\"response.completed\",\n" +
"data: \"response\":{\"id\":\"resp-1\",\"status\":\"completed\"}}")
framer.WriteChunk(&output, chunk)
framer.Flush(&output)
if framer.terminalEvent != "response.completed" || !strings.Contains(output.String(), "response.completed") {
t.Fatalf("multiline data-only terminal frame was dropped: terminal=%q output=%q", framer.terminalEvent, output.String())
}
}
func TestResponsesSSEFramerUsesPayloadErrorOverCompletedEvent(t *testing.T) {
var output bytes.Buffer
framer := &responsesSSEFramer{failureEvent: "response.failed"}
framer.WriteChunk(&output, []byte("data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\"}}\nevent: response.completed\n\n"))
if framer.terminalEvent != "response.failed" || strings.Contains(output.String(), "event: response.completed") {
t.Fatalf("payload error was overridden by completed event: terminal=%q output=%q", framer.terminalEvent, output.String())
}
if strings.Count(output.String(), "event: response.failed") != 1 {
t.Fatalf("payload error output = %q, want one response.failed", output.String())
}
}
func TestResponsesSSEFramerUsesErrorEventOverPayloadType(t *testing.T) {
var output bytes.Buffer
framer := &responsesSSEFramer{}
framer.WriteChunk(&output, []byte("event: error\ndata: {\"type\":\"provider.error\",\"message\":\"failed\"}\n\n"))
if framer.terminalEvent != "error" {
t.Fatalf("terminal event = %q, want error", framer.terminalEvent)
}
framer = &responsesSSEFramer{}
framer.WriteChunk(&output, []byte("data: {\"response\":{\"error\":{\"message\":\"failed\"}}}\n\n"))
if framer.terminalEvent != "error" {
t.Fatalf("nested response error terminal event = %q, want error", framer.terminalEvent)
}
}
func TestForwardResponsesStreamSeparatesDataOnlySSEChunks(t *testing.T) {
h, recorder, c, flusher := newResponsesStreamTestHandler(t)
data := make(chan []byte, 2)
errs := make(chan *interfaces.ErrorMessage)
data <- []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"arguments\":\"{}\"}}")
data <- []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[]}}")
close(data)
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
body := recorder.Body.String()
parts := strings.Split(strings.TrimSpace(body), "\n\n")
if len(parts) != 2 {
t.Fatalf("expected 2 SSE events, got %d. Body: %q", len(parts), body)
}
expectedPart1 := "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"arguments\":\"{}\"}}"
if parts[0] != expectedPart1 {
t.Errorf("unexpected first event.\nGot: %q\nWant: %q", parts[0], expectedPart1)
}
expectedPart2 := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[{\"type\":\"function_call\",\"arguments\":\"{}\"}]}}"
if parts[1] != expectedPart2 {
t.Errorf("unexpected second event.\nGot: %q\nWant: %q", parts[1], expectedPart2)
}
}
func TestForwardResponsesStreamRepairsEmptyCompletedOutputFromDoneItems(t *testing.T) {
h, recorder, c, flusher := newResponsesStreamTestHandler(t)
data := make(chan []byte, 3)
errs := make(chan *interfaces.ErrorMessage)
data <- []byte(`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs-1","summary":[]}}`)
data <- []byte(`data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"shell","arguments":"{\"cmd\":\"pwd\"}","status":"completed"}}`)
data <- []byte(`data: {"type":"response.completed","response":{"id":"resp-1","output":[]}}`)
close(data)
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
parts := strings.Split(strings.TrimSpace(recorder.Body.String()), "\n\n")
if len(parts) != 3 {
t.Fatalf("expected 3 SSE events, got %d. Body: %q", len(parts), recorder.Body.String())
}
payload := strings.TrimPrefix(parts[2], "data: ")
output := gjson.Get(payload, "response.output")
if !output.IsArray() || len(output.Array()) != 2 {
t.Fatalf("expected repaired completed output with 2 items, got %s", output.Raw)
}
if got := gjson.Get(payload, "response.output.1.name").String(); got != "shell" {
t.Fatalf("expected function_call name to be preserved, got %q in %s", got, payload)
}
if got := gjson.Get(payload, "response.output.1.arguments").String(); got != `{"cmd":"pwd"}` {
t.Fatalf("expected function_call arguments to be preserved, got %q in %s", got, payload)
}
}
func TestForwardResponsesStreamRepairsMixedIndexedAndUnindexedDoneItems(t *testing.T) {
h, recorder, c, flusher := newResponsesStreamTestHandler(t)
data := make(chan []byte, 3)
errs := make(chan *interfaces.ErrorMessage)
data <- []byte(`data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"shell","arguments":"{}","status":"completed"}}`)
data <- []byte(`data: {"type":"response.output_item.done","item":{"type":"message","id":"msg-1","role":"assistant","content":[{"type":"output_text","text":"done"}]}}`)
data <- []byte(`data: {"type":"response.completed","response":{"id":"resp-1","output":[]}}`)
close(data)
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
parts := strings.Split(strings.TrimSpace(recorder.Body.String()), "\n\n")
if len(parts) != 3 {
t.Fatalf("expected 3 SSE events, got %d. Body: %q", len(parts), recorder.Body.String())
}
payload := strings.TrimPrefix(parts[2], "data: ")
output := gjson.Get(payload, "response.output")
if !output.IsArray() || len(output.Array()) != 2 {
t.Fatalf("expected repaired completed output with 2 items, got %s", output.Raw)
}
if got := gjson.Get(payload, "response.output.0.name").String(); got != "shell" {
t.Fatalf("expected indexed function_call to be preserved first, got %q in %s", got, payload)
}
if got := gjson.Get(payload, "response.output.1.id").String(); got != "msg-1" {
t.Fatalf("expected unindexed message to be appended, got %q in %s", got, payload)
}
}
func TestForwardResponsesStreamRepairsMultilineCompletedOutputAsSSEDataLines(t *testing.T) {
h, recorder, c, flusher := newResponsesStreamTestHandler(t)
data := make(chan []byte, 2)
errs := make(chan *interfaces.ErrorMessage)
data <- []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","arguments":"{}"}}`)
data <- []byte("data: {\"type\":\"response.completed\",\ndata: \"response\":{\"id\":\"resp-1\",\"output\":[]}}\n\n")
close(data)
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
parts := strings.Split(strings.TrimSpace(recorder.Body.String()), "\n\n")
if len(parts) != 2 {
t.Fatalf("expected 2 SSE events, got %d. Body: %q", len(parts), recorder.Body.String())
}
completedFrame := []byte(parts[1])
for _, line := range strings.Split(parts[1], "\n") {
if line != "" && !strings.HasPrefix(line, "data: ") {
t.Fatalf("expected every completed payload line to be an SSE data line, got %q in %q", line, parts[1])
}
}
payload, ok := responsesSSEDataPayload(completedFrame)
if !ok {
t.Fatalf("expected completed frame to contain data payload: %q", parts[1])
}
output := gjson.GetBytes(payload, "response.output")
if !output.IsArray() || len(output.Array()) != 1 {
t.Fatalf("expected repaired completed output with 1 item, got %s from %q", output.Raw, payload)
}
}
func TestForwardResponsesStreamReassemblesSplitSSEEventChunks(t *testing.T) {
h, recorder, c, flusher := newResponsesStreamTestHandler(t)
data := make(chan []byte, 3)
errs := make(chan *interfaces.ErrorMessage)
data <- []byte("event: response.created")
data <- []byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-1\"}}")
data <- []byte("\n")
close(data)
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
got := recorder.Body.String()
wantPrefix := "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-1\"}}\n\n"
if !strings.HasPrefix(got, wantPrefix) {
t.Fatalf("unexpected split-event framing.\nGot: %q\nWant prefix: %q", got, wantPrefix)
}
if !strings.Contains(got, "event: error") {
t.Fatalf("unterminated framing test stream did not end with an error: %q", got)
}
}
func TestForwardResponsesStreamPreservesValidFullSSEEventChunks(t *testing.T) {
h, recorder, c, flusher := newResponsesStreamTestHandler(t)
data := make(chan []byte, 1)
errs := make(chan *interfaces.ErrorMessage)
chunk := []byte("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-1\"}}\n\n")
data <- chunk
close(data)
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
got := recorder.Body.String()
if !strings.HasPrefix(got, string(chunk)) {
t.Fatalf("unexpected full-event framing.\nGot: %q\nWant prefix: %q", got, string(chunk))
}
if !strings.Contains(got, "event: error") {
t.Fatalf("unterminated framing test stream did not end with an error: %q", got)
}
}
func TestForwardResponsesStreamBuffersSplitDataPayloadChunks(t *testing.T) {
h, recorder, c, flusher := newResponsesStreamTestHandler(t)
data := make(chan []byte, 2)
errs := make(chan *interfaces.ErrorMessage)
data <- []byte("data: {\"type\":\"response.created\"")
data <- []byte(",\"response\":{\"id\":\"resp-1\"}}")
close(data)
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
got := recorder.Body.String()
wantPrefix := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-1\"}}\n\n"
if !strings.HasPrefix(got, wantPrefix) {
t.Fatalf("unexpected split-data framing.\nGot: %q\nWant prefix: %q", got, wantPrefix)
}
if !strings.Contains(got, "event: error") {
t.Fatalf("unterminated framing test stream did not end with an error: %q", got)
}
}
func TestResponsesSSENeedsLineBreakSkipsChunksThatAlreadyStartWithNewline(t *testing.T) {
if responsesSSENeedsLineBreak([]byte("event: response.created"), []byte("\n")) {
t.Fatal("expected no injected newline before newline-only chunk")
}
if responsesSSENeedsLineBreak([]byte("event: response.created"), []byte("\r\n")) {
t.Fatal("expected no injected newline before CRLF chunk")
}
}
func TestForwardResponsesStreamDropsIncompleteTrailingDataChunkOnFlush(t *testing.T) {
h, recorder, c, flusher := newResponsesStreamTestHandler(t)
data := make(chan []byte, 1)
errs := make(chan *interfaces.ErrorMessage)
data <- []byte("data: {\"type\":\"response.created\"")
close(data)
close(errs)
h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
got := recorder.Body.String()
if strings.Contains(got, `data: {"type":"response.created"`) {
t.Fatalf("incomplete trailing data was not dropped on flush: %q", got)
}
if !strings.Contains(got, "event: error") {
t.Fatalf("unterminated framing test stream did not end with an error: %q", got)
}
}

View file

@ -0,0 +1,199 @@
package openai
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
multiagentv2 "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/optimize-multi-agent-v2"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/tidwall/gjson"
)
func TestPrepareCodexMultiAgentV2ToolsAtResponsesBoundary(t *testing.T) {
t.Parallel()
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{CodexOptimizeMultiAgentV2: true}, nil)
handler := NewOpenAIResponsesAPIHandler(base)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
ginContext, _ := gin.CreateTestContext(httptest.NewRecorder())
ginContext.Request = request
payload := []byte(`{
"tools":[{"type":"namespace","name":"collaboration","tools":[
{"type":"function","name":"spawn_agent","description":"Spawns an agent.","parameters":{"properties":{"message":{"encrypted":true}}}},
{"type":"function","name":"send_message","parameters":{"properties":{"message":{"encrypted":true}}}}
]}]
}`)
got := handler.prepareCodexMultiAgentV2Tools(ginContext, payload)
if namespace := gjson.GetBytes(got, "tools.0.name").String(); namespace != "collaboration" {
t.Fatalf("namespace = %q, want collaboration", namespace)
}
for _, path := range []string{"tools.0.tools.0", "tools.0.tools.1"} {
if encrypted := gjson.GetBytes(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("%s message.encrypted was not removed: %s", path, encrypted.Raw)
}
}
prepared, exists := ginContext.Get(multiagentv2.CodexMultiAgentV2ToolsPreparedContextKey)
if !exists || prepared != true {
t.Fatalf("prepared marker = %#v, want true", prepared)
}
}
func TestResponsesPreparesCodexMultiAgentV2ToolsForHTTPAndSSE(t *testing.T) {
t.Parallel()
for _, stream := range []bool{false, true} {
t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) {
executor := &responsesMultiAgentCaptureExecutor{}
handler, modelID := newResponsesMultiAgentTestHandler(t, executor)
router := gin.New()
router.POST("/v1/responses", handler.Responses)
payload := fmt.Sprintf(`{"model":%q,"stream":%t,"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"Spawns an agent.","parameters":{"properties":{"message":{"encrypted":true}}}}]}]}`, modelID, stream)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewBufferString(payload))
request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", recorder.Code, recorder.Body.String())
}
payloads := executor.Payloads()
if len(payloads) != 1 {
t.Fatalf("captured payload count = %d, want 1", len(payloads))
}
captured := payloads[0]
if encrypted := gjson.GetBytes(captured, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("message.encrypted was not removed: %s", captured)
}
if namespace := gjson.GetBytes(captured, "tools.0.name").String(); namespace != "collaboration" {
t.Fatalf("namespace = %q, want collaboration", namespace)
}
})
}
}
type responsesMultiAgentCaptureExecutor struct {
websocketDirectCaptureExecutor
}
func (e *responsesMultiAgentCaptureExecutor) Execute(_ context.Context, _ *coreauth.Auth, req coreexecutor.Request, _ coreexecutor.Options) (coreexecutor.Response, error) {
e.mu.Lock()
e.payloads = append(e.payloads, bytes.Clone(req.Payload))
e.mu.Unlock()
return coreexecutor.Response{Payload: []byte(`{"id":"resp-1","output":[]}`)}, nil
}
func (e *responsesMultiAgentCaptureExecutor) ExecuteStream(_ context.Context, _ *coreauth.Auth, req coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) {
e.mu.Lock()
e.payloads = append(e.payloads, bytes.Clone(req.Payload))
e.mu.Unlock()
chunks := make(chan coreexecutor.StreamChunk, 1)
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[]}}\n\n")}
close(chunks)
return &coreexecutor.StreamResult{Chunks: chunks}, nil
}
func newResponsesMultiAgentTestHandler(t *testing.T, executor *responsesMultiAgentCaptureExecutor) (*OpenAIResponsesAPIHandler, string) {
t.Helper()
modelID := "responses-multi-agent-test-model"
authID := "responses-multi-agent-test-auth"
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: authID, Provider: "codex", Status: coreauth.StatusActive, ProxyURL: "direct"}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("Register auth: %v", errRegister)
}
registry.GetGlobalRegistry().RegisterClient(authID, auth.Provider, []*registry.ModelInfo{{ID: modelID}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(authID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{CodexOptimizeMultiAgentV2: true}, manager)
return NewOpenAIResponsesAPIHandler(base), modelID
}
func TestResponsesWebsocketPreparesCodexMultiAgentV2Tools(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &websocketDirectCaptureExecutor{provider: "codex"}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "responses-multi-agent-ws-auth", Provider: "codex", Status: coreauth.StatusActive, ProxyURL: "direct"}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("Register auth: %v", errRegister)
}
modelID := "responses-multi-agent-ws-model"
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: modelID}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{CodexOptimizeMultiAgentV2: true}, manager)
handler := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.GET("/v1/responses", handler.ResponsesWebsocket)
server := httptest.NewServer(router)
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses"
conn, _, errDial := websocket.DefaultDialer.Dial(wsURL, http.Header{"User-Agent": []string{"codex_cli_rs/0.144.1"}})
if errDial != nil {
t.Fatalf("dial websocket: %v", errDial)
}
defer func() { _ = conn.Close() }()
request := fmt.Sprintf(`{"type":"response.create","model":%q,"input":[],"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"Spawns an agent.","parameters":{"properties":{"message":{"encrypted":true}}}}]}]}`, modelID)
if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(request)); errWrite != nil {
t.Fatalf("write websocket request: %v", errWrite)
}
if _, _, errRead := conn.ReadMessage(); errRead != nil {
t.Fatalf("read websocket response: %v", errRead)
}
payloads := executor.Payloads()
if len(payloads) != 1 {
t.Fatalf("captured payload count = %d, want 1", len(payloads))
}
captured := payloads[0]
if encrypted := gjson.GetBytes(captured, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("message.encrypted was not removed: %s", captured)
}
if namespace := gjson.GetBytes(captured, "tools.0.name").String(); namespace != "collaboration" {
t.Fatalf("namespace = %q, want collaboration", namespace)
}
}
func TestPrepareCodexMultiAgentV2ToolsAtResponsesBoundarySkipsOtherClients(t *testing.T) {
t.Parallel()
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{CodexOptimizeMultiAgentV2: true}, nil)
handler := NewOpenAIResponsesAPIHandler(base)
request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
request.Header.Set("User-Agent", "curl/8.7.1")
ginContext, _ := gin.CreateTestContext(httptest.NewRecorder())
ginContext.Request = request
payload := []byte(`{"tools":[{"type":"function","name":"send_message","parameters":{"properties":{"message":{"encrypted":true}}}}]}`)
got := handler.prepareCodexMultiAgentV2Tools(ginContext, payload)
if string(got) != string(payload) {
t.Fatalf("other client payload changed: %s", got)
}
if _, exists := ginContext.Get(multiagentv2.CodexMultiAgentV2ToolsPreparedContextKey); exists {
t.Fatal("other client unexpectedly received prepared marker")
}
}

View file

@ -0,0 +1,86 @@
package openai
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestOpenAIResponsesForwardsInvalidReasoningEncryptedContentToExecutor(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &compactCaptureExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "signature-auth-responses", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, err := manager.Register(context.Background(), auth); err != nil {
t.Fatalf("Register auth: %v", err)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-signature-model"}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses", h.Responses)
body := `{"model":"test-signature-model","stream":false,"input":[{"id":"rs_bad","type":"reasoning","encrypted_content":"gAAAAABqFTIa\u2026abc","summary":[]}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
if executor.calls != 1 {
t.Fatalf("executor calls = %d, want 1", executor.calls)
}
}
func TestOpenAIResponsesCompactForwardsInvalidReasoningEncryptedContentToExecutor(t *testing.T) {
gin.SetMode(gin.TestMode)
executor := &compactCaptureExecutor{}
manager := coreauth.NewManager(nil, nil, nil)
manager.RegisterExecutor(executor)
auth := &coreauth.Auth{ID: "signature-auth-compact", Provider: executor.Identifier(), Status: coreauth.StatusActive}
if _, err := manager.Register(context.Background(), auth); err != nil {
t.Fatalf("Register auth: %v", err)
}
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-signature-compact-model"}})
t.Cleanup(func() {
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
})
base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
h := NewOpenAIResponsesAPIHandler(base)
router := gin.New()
router.POST("/v1/responses/compact", h.Compact)
body := `{"model":"test-signature-compact-model","input":[{"id":"rs_bad","type":"reasoning","encrypted_content":"bad","summary":[]}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
if executor.calls != 1 {
t.Fatalf("executor calls = %d, want 1", executor.calls)
}
if executor.alt != "responses/compact" {
t.Fatalf("alt = %q, want responses/compact", executor.alt)
}
}

View file

@ -0,0 +1,704 @@
package openai
import (
"context"
"errors"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
wsRequestTypeCreate = "response.create"
wsRequestTypeAppend = "response.append"
wsEventTypeError = "error"
wsEventTypeCompleted = "response.completed"
wsEventTypeDone = "response.done"
wsDoneMarker = "[DONE]"
wsTurnStateHeader = "x-codex-turn-state"
wsTimelineBodyKey = "WEBSOCKET_TIMELINE_OVERRIDE"
wsCloseReasonMaxBytes = 123
wsHTTPReplayRequiredCloseReason = "upstream requires HTTP replay"
responsesWebsocketUpstreamModeUnknown = ""
responsesWebsocketUpstreamModeWS = "websocket"
responsesWebsocketUpstreamModeHTTP = "http"
codexLocalCompactionSummaryPrefix = "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:"
)
var responsesWebsocketUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// writeWebsocketCloseForUpstreamError mirrors transport-level upstream close
// codes to the downstream WebSocket client before the connection is torn down.
// Without this the client only observes an abnormal closure (1006) and cannot
// apply its own close-code based handling (e.g. falling back to SSE on 1009).
func writeWebsocketCloseForUpstreamError(conn *websocket.Conn, err error) (bool, error) {
if conn == nil {
return false, nil
}
matched, payload := websocketClosePayloadForUpstreamError(err)
if !matched {
return false, nil
}
return true, conn.WriteControl(websocket.CloseMessage, payload, time.Time{})
}
func websocketClosePayloadForUpstreamError(err error) (bool, []byte) {
if err == nil {
return false, nil
}
errText := err.Error()
if cliproxyexecutor.IsUpstreamWebsocketReplayRequired(err) {
return true, websocket.FormatCloseMessage(
websocket.CloseServiceRestart,
truncateWebsocketCloseReason(wsHTTPReplayRequiredCloseReason, wsCloseReasonMaxBytes),
)
}
code := 0
reason := ""
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) && closeErr.Code == websocket.CloseMessageTooBig {
code = closeErr.Code
reason = closeErr.Text
} else {
type statusCoder interface {
StatusCode() int
}
var statusErr statusCoder
if !errors.As(err, &statusErr) || statusErr.StatusCode() != http.StatusRequestEntityTooLarge ||
gjson.Get(errText, "error.code").String() != "message_too_big" {
return false, nil
}
code = websocket.CloseMessageTooBig
reason = strings.TrimSpace(gjson.Get(errText, "error.message").String())
}
if reason == "" {
reason = "message too big"
}
reason = truncateWebsocketCloseReason(reason, wsCloseReasonMaxBytes)
return true, websocket.FormatCloseMessage(code, reason)
}
type responsesWebsocketWriter struct {
conn *websocket.Conn
writeMu sync.Mutex
closing atomic.Bool
}
func newResponsesWebsocketWriter(conn *websocket.Conn) *responsesWebsocketWriter {
return &responsesWebsocketWriter{conn: conn}
}
// closeForUpstreamError sends a best-effort close frame without waiting behind
// an active downstream data writer. If a data write already owns writeMu, the
// connection is closed immediately so the blocked writer and session can exit.
func (w *responsesWebsocketWriter) closeForUpstreamError(err error) (bool, error) {
if w == nil || w.conn == nil {
return false, nil
}
matched, payload := websocketClosePayloadForUpstreamError(err)
if !matched {
return false, nil
}
if !w.closing.CompareAndSwap(false, true) {
return true, nil
}
if !w.writeMu.TryLock() {
return true, w.conn.Close()
}
defer w.writeMu.Unlock()
errWrite := w.conn.WriteControl(websocket.CloseMessage, payload, time.Time{})
errClose := w.conn.Close()
if errWrite != nil {
return true, errWrite
}
return true, errClose
}
func (w *responsesWebsocketWriter) closeWithoutError() (bool, error) {
if w == nil || w.conn == nil {
return false, nil
}
if !w.closing.CompareAndSwap(false, true) {
return false, nil
}
return true, w.conn.Close()
}
func (w *responsesWebsocketWriter) closeWithPayload(payload []byte) (bool, error) {
if w == nil || w.conn == nil {
return false, nil
}
if !w.closing.CompareAndSwap(false, true) {
return false, nil
}
if !w.writeMu.TryLock() {
return false, w.conn.Close()
}
defer w.writeMu.Unlock()
errWrite := w.conn.WriteMessage(websocket.TextMessage, payload)
errClose := w.conn.Close()
if errWrite != nil {
return false, errWrite
}
return true, errClose
}
func (w *responsesWebsocketWriter) closeForUpstreamDisconnect(err error) {
if w == nil || w.conn == nil {
return
}
if matched, _ := w.closeForUpstreamError(err); matched {
return
}
errMsg := handlers.ExecutionErrorMessage(err)
if !shouldExposeResponsesUpstreamError(errMsg) {
_, _ = w.closeWithoutError()
return
}
payload, errBuild := buildResponsesWebsocketErrorPayload(errMsg)
if errBuild != nil {
_, _ = w.closeWithoutError()
return
}
wrote, errClose := w.closeWithPayload(payload)
if wrote {
log.Infof(
"responses websocket: downstream_out disconnect_error event=%s payload=%s",
websocketPayloadEventType(payload),
websocketPayloadPreview(payload),
)
}
if errClose != nil && !errors.Is(errClose, websocket.ErrCloseSent) {
log.Debugf("responses websocket: upstream disconnect close failed: %v", errClose)
}
}
// isWebsocketConnectionClosedError reports whether the error only means the
// connection was already torn down. These are expected during shutdown races
// (the proxy closes after sending a terminal frame, or the client hangs up mid
// write) and must not be logged as proxy failures.
func isWebsocketConnectionClosedError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, net.ErrClosed) || errors.Is(err, websocket.ErrCloseSent) {
return true
}
return strings.Contains(err.Error(), "use of closed network connection")
}
func truncateWebsocketCloseReason(reason string, maxBytes int) string {
if maxBytes <= 0 {
return ""
}
if len(reason) <= maxBytes && utf8.ValidString(reason) {
return reason
}
// Decode from the front so work and output stay bounded by maxBytes.
var truncated strings.Builder
truncated.Grow(min(len(reason), maxBytes))
remaining := maxBytes
runeErrorSize := utf8.RuneLen(utf8.RuneError)
for len(reason) > 0 && remaining > 0 {
r, size := utf8.DecodeRuneInString(reason)
if r == utf8.RuneError && size == 1 {
if runeErrorSize > remaining {
break
}
truncated.WriteRune(utf8.RuneError)
reason = reason[1:]
remaining -= runeErrorSize
continue
}
if size > remaining {
break
}
truncated.WriteString(reason[:size])
reason = reason[size:]
remaining -= size
}
return truncated.String()
}
// ResponsesWebsocket handles websocket requests for /v1/responses.
// It accepts `response.create` and `response.append` requests and streams
// response events back as JSON websocket text messages.
func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
conn, err := responsesWebsocketUpgrader.Upgrade(c.Writer, c.Request, websocketUpgradeHeaders(c.Request))
if err != nil {
return
}
writer := newResponsesWebsocketWriter(conn)
passthroughSessionID := uuid.NewString()
downstreamSessionKey := websocketDownstreamSessionKey(c.Request)
retainResponsesWebsocketToolCaches(downstreamSessionKey)
clientIP := websocketClientAddress(c)
log.Infof("responses websocket: client connected id=%s remote=%s", passthroughSessionID, clientIP)
requestLogEnabled := h != nil && h.Cfg != nil && h.Cfg.RequestLog
wsTimelineLog := newWebsocketTimelineLog(requestLogEnabled, websocketTimelineSourceFromContext(c))
wsDone := make(chan struct{})
defer close(wsDone)
if h != nil && h.AuthManager != nil {
type upstreamDisconnectSubscriber interface {
UpstreamDisconnectChan(sessionID string) <-chan error
}
for _, provider := range []string{"codex", "xai"} {
exec, ok := h.AuthManager.Executor(provider)
if !ok || exec == nil {
continue
}
if subscriber, ok := exec.(upstreamDisconnectSubscriber); ok && subscriber != nil {
disconnectCh := subscriber.UpstreamDisconnectChan(passthroughSessionID)
if disconnectCh != nil {
go func() {
select {
case <-wsDone:
return
case disconnectErr := <-disconnectCh:
writer.closeForUpstreamDisconnect(disconnectErr)
}
}()
}
}
}
}
var wsTerminateErr error
defer func() {
releaseResponsesWebsocketToolCaches(downstreamSessionKey)
if wsTerminateErr != nil {
appendWebsocketTimelineDisconnect(wsTimelineLog, wsTerminateErr, time.Now())
// log.Infof("responses websocket: session closing id=%s reason=%v", passthroughSessionID, wsTerminateErr)
} else {
log.Infof("responses websocket: session closing id=%s", passthroughSessionID)
}
if h != nil && h.AuthManager != nil {
h.AuthManager.CloseExecutionSession(passthroughSessionID)
log.Infof("responses websocket: upstream execution session closed id=%s", passthroughSessionID)
}
wsTimelineLog.SetContext(c)
if errClose := conn.Close(); errClose != nil && !isWebsocketConnectionClosedError(errClose) {
log.Warnf("responses websocket: close connection error: %v", errClose)
}
}()
var lastRequest []byte
lastResponseOutput := []byte("[]")
lastResponseID := ""
var lastResponsePendingToolCallIDs []string
pinnedAuthID := ""
// Preserve independent upstream auth affinity when a downstream session switches providers.
pinnedAuthByProvider := make(map[string]responsesWebsocketPinnedAuthState)
passthroughModelName := ""
upstreamMode := responsesWebsocketUpstreamModeUnknown
upstreamWebsocketAuthID := ""
sessionAuthByIDWithSource := func(authID string) (*coreauth.Auth, bool, bool) {
if h == nil || h.AuthManager == nil {
return nil, false, false
}
// Prefer the current manager view so hot-reloaded transport eligibility is
// observed even when the execution session still holds an older auth snapshot.
if auth, ok := h.AuthManager.GetByID(authID); ok {
return auth, false, true
}
if auth, ok := h.AuthManager.GetExecutionSessionAuthByID(passthroughSessionID, authID); ok {
return auth, true, true
}
return nil, false, false
}
sessionAuthByID := func(authID string) (*coreauth.Auth, bool) {
auth, _, ok := sessionAuthByIDWithSource(authID)
return auth, ok
}
upstreamModeForAuth := func(auth *coreauth.Auth) string {
if auth != nil && websocketUpstreamSupportsIncrementalInput(auth.Attributes, auth.Metadata) {
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
if provider == "codex" || provider == "xai" {
return responsesWebsocketUpstreamModeWS
}
}
return responsesWebsocketUpstreamModeHTTP
}
rememberPinnedAuth := func(authID string, modelName string) {
authID = strings.TrimSpace(authID)
auth, ok := sessionAuthByID(authID)
if authID == "" || !ok || auth == nil {
return
}
pinnedAuthID = authID
providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
_, modelKey := responsesWebsocketProviderSetForModel(responsesWebsocketResolvedModelName(modelName))
if providerKey != "" {
pinnedAuthByProvider[providerKey] = responsesWebsocketPinnedAuthState{authID: authID, modelKey: modelKey}
}
}
forgetPinnedAuth := func() {
for providerKey, state := range pinnedAuthByProvider {
if state.authID == pinnedAuthID {
delete(pinnedAuthByProvider, providerKey)
}
}
pinnedAuthID = ""
}
for {
msgType, payload, errReadMessage := conn.ReadMessage()
if errReadMessage != nil {
wsTerminateErr = errReadMessage
if websocket.IsCloseError(errReadMessage, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
log.Infof("responses websocket: client disconnected id=%s error=%v", passthroughSessionID, errReadMessage)
} else {
// log.Warnf("responses websocket: read message failed id=%s error=%v", passthroughSessionID, errReadMessage)
}
return
}
if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {
continue
}
// log.Infof(
// "responses websocket: downstream_in id=%s type=%d event=%s payload=%s",
// passthroughSessionID,
// msgType,
// websocketPayloadEventType(payload),
// websocketPayloadPreview(payload),
// )
wsTimelineLog.BeginRequest()
wsTimelineLog.Append("request", payload, time.Now())
explicitRequestModelName := strings.TrimSpace(gjson.GetBytes(payload, "model").String())
requestModelName := explicitRequestModelName
if requestModelName == "" {
requestModelName = passthroughModelName
}
if requestModelName == "" {
requestModelName = strings.TrimSpace(gjson.GetBytes(lastRequest, "model").String())
}
executionParent := context.WithValue(c.Request.Context(), "gin", c)
executionParent, routeOverridesModelResolution := h.PrepareStreamModelRoute(
executionParent,
h.HandlerType(),
requestModelName,
payload,
)
if pinnedAuthID != "" {
pinnedAuth, homeRuntime, ok := sessionAuthByIDWithSource(pinnedAuthID)
providerKey := ""
if pinnedAuth != nil {
providerKey = strings.ToLower(strings.TrimSpace(pinnedAuth.Provider))
}
state, hasState := pinnedAuthByProvider[providerKey]
if !ok || !hasState || state.authID != pinnedAuthID || !responsesWebsocketPinnedAuthMatchesModel(pinnedAuth, requestModelName, state.modelKey, homeRuntime) {
pinnedAuthID = ""
}
}
if pinnedAuthID == "" {
providerSet, _ := responsesWebsocketProviderSetForModel(responsesWebsocketResolvedModelName(requestModelName))
if len(providerSet) == 1 {
for providerKey := range providerSet {
state, ok := pinnedAuthByProvider[providerKey]
candidateAuth, homeRuntime, okAuth := sessionAuthByIDWithSource(state.authID)
if ok && okAuth && responsesWebsocketPinnedAuthMatchesModel(candidateAuth, requestModelName, state.modelKey, homeRuntime) {
pinnedAuthID = state.authID
} else {
delete(pinnedAuthByProvider, providerKey)
}
}
}
}
useUpstreamWebsocketPassthrough := h.responsesWebsocketUsesUpstreamWebsocketPassthrough(requestModelName)
if pinnedAuthID != "" {
if pinnedAuth, ok := sessionAuthByID(pinnedAuthID); ok && responsesWebsocketAuthSupportsIncrementalInput(pinnedAuth) {
provider := strings.ToLower(strings.TrimSpace(pinnedAuth.Provider))
useUpstreamWebsocketPassthrough = provider == "codex" || provider == "xai"
}
}
nativeWebsocketPassthrough := !routeOverridesModelResolution && responsesWebsocketNativePassthroughAllowed(
upstreamMode,
useUpstreamWebsocketPassthrough,
pinnedAuthID,
upstreamWebsocketAuthID,
)
requestRequiresCurrentUpstreamWebsocket := responsesWebsocketRequestRequiresCurrentUpstream(payload)
if upstreamMode == responsesWebsocketUpstreamModeWS && !nativeWebsocketPassthrough {
if requestRequiresCurrentUpstreamWebsocket {
replayErr := responsesWebsocketHTTPReplayRequiredError()
wsTerminateErr = replayErr
matched, errClose := writer.closeForUpstreamError(replayErr)
if !matched {
_ = conn.Close()
} else if errClose != nil && !errors.Is(errClose, websocket.ErrCloseSent) {
log.Debugf("responses websocket: replay close failed id=%s error=%v", passthroughSessionID, errClose)
}
return
}
// A full response.create is already a self-contained reset and can safely
// establish a new upstream transport without another replay.
}
if explicitRequestModelName != "" && !useUpstreamWebsocketPassthrough {
passthroughModelName = ""
}
allowCompactionReplayBypass := false
if !nativeWebsocketPassthrough {
if pinnedAuthID != "" {
if pinnedAuth, ok := sessionAuthByID(pinnedAuthID); ok && pinnedAuth != nil {
allowCompactionReplayBypass = responsesWebsocketAuthSupportsCompactionReplay(pinnedAuth)
}
} else {
allowCompactionReplayBypass = h.websocketUpstreamSupportsCompactionReplayForModel(requestModelName)
}
}
var requestJSON []byte
var updatedLastRequest []byte
var errMsg *interfaces.ErrorMessage
if nativeWebsocketPassthrough {
requestJSON, errMsg = normalizeResponsesWebsocketPassthroughRequest(payload, requestModelName)
} else if len(lastRequest) == 0 && strings.TrimSpace(gjson.GetBytes(payload, "previous_response_id").String()) != "" {
errMsg = responsesWebsocketPreviousResponseNotFoundError()
} else {
requestJSON, updatedLastRequest, errMsg = normalizeResponsesWebsocketRequestWithIncrementalState(
payload,
lastRequest,
lastResponseOutput,
lastResponseID,
lastResponsePendingToolCallIDs,
false,
allowCompactionReplayBypass,
)
}
if errMsg != nil {
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), errMsg)
markAPIResponseTimestamp(c)
errorPayload, errWrite := writeResponsesWebsocketError(writer, wsTimelineLog, errMsg)
log.Infof(
"responses websocket: downstream_out id=%s type=%d event=%s payload=%s",
passthroughSessionID,
websocket.TextMessage,
websocketPayloadEventType(errorPayload),
websocketPayloadPreview(errorPayload),
)
if errWrite != nil {
log.Warnf(
"responses websocket: downstream_out write failed id=%s event=%s error=%v",
passthroughSessionID,
websocketPayloadEventType(errorPayload),
errWrite,
)
return
}
continue
}
requestJSON = h.prepareCodexMultiAgentV2Tools(c, requestJSON)
if !useUpstreamWebsocketPassthrough && shouldHandleResponsesWebsocketPrewarmLocally(payload, lastRequest, false) {
if updated, errDelete := sjson.DeleteBytes(requestJSON, "generate"); errDelete == nil {
requestJSON = updated
}
if updated, errDelete := sjson.DeleteBytes(updatedLastRequest, "generate"); errDelete == nil {
updatedLastRequest = updated
}
lastRequest = updatedLastRequest
lastResponseOutput = []byte("[]")
lastResponseID = ""
lastResponsePendingToolCallIDs = nil
if errWrite := writeResponsesWebsocketSyntheticPrewarm(c, writer, requestJSON, wsTimelineLog, passthroughSessionID); errWrite != nil {
wsTerminateErr = errWrite
return
}
continue
}
var toolCacheTurn *responsesWebsocketToolCacheTurn
nextLastRequest := lastRequest
if nativeWebsocketPassthrough {
if modelName := strings.TrimSpace(gjson.GetBytes(requestJSON, "model").String()); modelName != "" {
passthroughModelName = modelName
}
} else {
requestJSON, toolCacheTurn = prepareResponsesWebsocketFallbackTurn(downstreamSessionKey, requestJSON)
nextLastRequest = requestJSON
}
modelName := gjson.GetBytes(requestJSON, "model").String()
lastAttemptedAuthID := pinnedAuthID
attemptedUpstreamMode := responsesWebsocketUpstreamModeUnknown
selectedAuthObserved := false
pinnedAuthAttempted := false
cliCtx, cliCancel := h.GetContextWithCancel(h, c, executionParent)
cliCtx = cliproxyexecutor.WithDownstreamWebsocket(cliCtx)
if nativeWebsocketPassthrough && requestRequiresCurrentUpstreamWebsocket {
cliCtx = cliproxyexecutor.WithRequiredUpstreamWebsocket(cliCtx)
}
cliCtx = handlers.WithExecutionSessionID(cliCtx, passthroughSessionID)
cliCtx = handlers.WithSelectedAuthIDCallback(cliCtx, func(authID string) {
authID = strings.TrimSpace(authID)
if authID == "" || h == nil || h.AuthManager == nil {
return
}
lastAttemptedAuthID = authID
selectedAuthObserved = true
pinnedAuthAttempted = pinnedAuthAttempted || (pinnedAuthID != "" && authID == pinnedAuthID)
selectedAuth, ok := sessionAuthByID(authID)
if !ok || selectedAuth == nil {
return
}
attemptedUpstreamMode = upstreamModeForAuth(selectedAuth)
})
if pinnedAuthID != "" && !routeOverridesModelResolution {
cliCtx = handlers.WithPinnedAuthID(cliCtx, pinnedAuthID)
}
dataChan, _, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, requestJSON, "")
if !selectedAuthObserved {
// Plugin/alternate routes bypass auth selection. Keep canonical HTTP-mode
// state instead of inheriting the previous pinned websocket mode.
attemptedUpstreamMode = responsesWebsocketUpstreamModeHTTP
}
// A connection-scoped continuation cannot rotate credentials in place. Suppress
// credential errors and make the client replay the full turn on a new socket.
replayPinnedAuthFailure := func(errMsg *interfaces.ErrorMessage) bool {
return nativeWebsocketPassthrough && requestRequiresCurrentUpstreamWebsocket && pinnedAuthAttempted &&
shouldReplayResponsesWebsocketPinnedAuthFailure(errMsg)
}
completedOutput, completedResponseID, completedPendingToolCallIDs, forwardErrMsg, errForward := h.forwardResponsesWebsocket(
c,
writer,
cliCancel,
dataChan,
errChan,
wsTimelineLog,
passthroughSessionID,
responsesWebsocketForwardOptions{
toolCacheTurn: toolCacheTurn,
suppressError: replayPinnedAuthFailure,
},
)
if errForward != nil {
wsTerminateErr = errForward
switch {
case errors.Is(errForward, websocket.ErrCloseSent):
case isWebsocketConnectionClosedError(errForward):
// The client hung up while a downstream write was in flight. This is a
// normal shutdown race, not a proxy failure.
log.Debugf("responses websocket: client closed during forward id=%s error=%v", passthroughSessionID, errForward)
default:
log.Warnf("responses websocket: forward failed id=%s error=%v", passthroughSessionID, errForward)
}
return
}
if forwardErrMsg != nil {
if pinnedAuthAttempted && shouldReleaseResponsesWebsocketPinnedAuth(forwardErrMsg) {
forgetPinnedAuth()
}
if replayPinnedAuthFailure(forwardErrMsg) {
replayErr := responsesWebsocketHTTPReplayRequiredError()
wsTerminateErr = replayErr
matched, errClose := writer.closeForUpstreamError(replayErr)
if !matched {
_ = conn.Close()
} else if errClose != nil && !errors.Is(errClose, websocket.ErrCloseSent) {
log.Debugf("responses websocket: credential replay close failed id=%s error=%v", passthroughSessionID, errClose)
}
return
}
continue
}
toolCacheTurn.commit()
upstreamMode = attemptedUpstreamMode
if upstreamMode == responsesWebsocketUpstreamModeWS {
upstreamWebsocketAuthID = lastAttemptedAuthID
if lastAttemptedAuthID != "" {
rememberPinnedAuth(lastAttemptedAuthID, modelName)
}
passthroughModelName = modelName
lastRequest = nil
lastResponseOutput = []byte("[]")
lastResponseID = ""
lastResponsePendingToolCallIDs = nil
} else {
upstreamWebsocketAuthID = ""
lastRequest = nextLastRequest
lastResponseOutput = completedOutput
lastResponseID = strings.TrimSpace(completedResponseID)
lastResponsePendingToolCallIDs = append([]string(nil), completedPendingToolCallIDs...)
}
}
}
func responsesWebsocketHTTPReplayRequiredError() error {
return cliproxyexecutor.NewUpstreamWebsocketReplayRequiredError()
}
func responsesWebsocketRequestRequiresCurrentUpstream(payload []byte) bool {
return strings.TrimSpace(gjson.GetBytes(payload, "previous_response_id").String()) != "" ||
strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == wsRequestTypeAppend
}
func responsesWebsocketNativePassthroughAllowed(upstreamMode string, useUpstreamWebsocket bool, pinnedAuthID string, upstreamAuthID string) bool {
return upstreamMode == responsesWebsocketUpstreamModeWS && useUpstreamWebsocket &&
strings.TrimSpace(pinnedAuthID) != "" && strings.TrimSpace(pinnedAuthID) == strings.TrimSpace(upstreamAuthID)
}
func websocketClientAddress(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
return strings.TrimSpace(c.ClientIP())
}
func websocketUpgradeHeaders(req *http.Request) http.Header {
headers := http.Header{}
if req == nil {
return headers
}
// Keep the same sticky turn-state across reconnects when provided by the client.
turnState := strings.TrimSpace(req.Header.Get(wsTurnStateHeader))
if turnState != "" {
headers.Set(wsTurnStateHeader, turnState)
}
return headers
}
func responsesWebsocketPreviousResponseNotFoundError() *interfaces.ErrorMessage {
return &interfaces.ErrorMessage{
StatusCode: http.StatusConflict,
Error: errors.New(
`{"error":{"message":"Previous response is not available on this websocket; resend the full conversation input without previous_response_id","type":"invalid_request_error","code":"previous_response_not_found","param":"previous_response_id"}}`,
),
}
}

View file

@ -0,0 +1,607 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type responsesWebsocketForwardOptions struct {
toolCacheTurn *responsesWebsocketToolCacheTurn
suppressError func(*interfaces.ErrorMessage) bool
}
func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
c *gin.Context,
writer *responsesWebsocketWriter,
cancel handlers.APIHandlerCancelFunc,
data <-chan []byte,
errs <-chan *interfaces.ErrorMessage,
wsTimelineLog websocketTimelineAppender,
sessionID string,
options ...responsesWebsocketForwardOptions,
) ([]byte, string, []string, *interfaces.ErrorMessage, error) {
var opts responsesWebsocketForwardOptions
if len(options) > 0 {
opts = options[0]
}
toolCacheTurn := opts.toolCacheTurn
completed := false
completedOutput := []byte("[]")
completedResponseID := ""
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
pendingToolCallIDs := make(map[string]struct{})
downstreamSessionKey := ""
if c != nil && c.Request != nil {
downstreamSessionKey = websocketDownstreamSessionKey(c.Request)
}
for {
select {
case <-c.Request.Context().Done():
cancel(c.Request.Context().Err())
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), nil, c.Request.Context().Err()
case errMsg, ok := <-errs:
if !ok {
errs = nil
continue
}
if errMsg == nil {
cancel(nil)
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), nil, nil
}
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), errMsg)
if opts.suppressError != nil && opts.suppressError(errMsg) {
cancel(errMsg.Error)
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), errMsg, nil
}
markAPIResponseTimestamp(c)
if matched, errClose := writer.closeForUpstreamError(errMsg.Error); matched {
cancel(errMsg.Error)
if errClose != nil {
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), errMsg, errClose
}
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), errMsg, websocket.ErrCloseSent
}
errorPayload, wrote, errTerminate := writeResponsesWebsocketTerminalError(writer, wsTimelineLog, errMsg, nil)
if wrote {
log.Infof(
"responses websocket: downstream_out id=%s type=%d event=%s payload=%s",
sessionID,
websocket.TextMessage,
websocketPayloadEventType(errorPayload),
websocketPayloadPreview(errorPayload),
)
}
cancel(errMsg.Error)
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), errMsg, errTerminate
case chunk, ok := <-data:
if !ok {
if !completed {
errMsg := &interfaces.ErrorMessage{
StatusCode: http.StatusRequestTimeout,
Error: fmt.Errorf("stream closed before response.completed"),
}
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), errMsg)
markAPIResponseTimestamp(c)
_, errClose := writer.closeWithoutError()
cancel(errMsg.Error)
if errClose != nil {
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), errMsg, errClose
}
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), errMsg, websocket.ErrCloseSent
}
cancel(nil)
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), nil, nil
}
payloads := websocketJSONPayloadsFromChunk(chunk)
for i := range payloads {
collectResponsesWebsocketOutputItem(payloads[i], outputItemsByIndex, &outputItemsFallback)
eventType := gjson.GetBytes(payloads[i], "type").String()
if isResponsesWebsocketCompletionEvent(eventType) {
payloads[i] = restoreResponsesWebsocketCompletionOutput(payloads[i], outputItemsByIndex, outputItemsFallback)
}
if toolCacheTurn != nil {
toolCacheTurn.recordResponse(payloads[i])
} else {
recordResponsesWebsocketToolCallsFromPayload(downstreamSessionKey, payloads[i])
}
recordPendingToolCallIDsFromPayload(pendingToolCallIDs, payloads[i])
var payloadErrMsg *interfaces.ErrorMessage
if eventType == wsEventTypeError {
payloadErrMsg = responsesWebsocketErrorMessageFromPayload(payloads[i])
if h != nil {
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), payloadErrMsg)
}
if opts.suppressError != nil && opts.suppressError(payloadErrMsg) {
cancel(payloadErrMsg.Error)
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), payloadErrMsg, nil
}
} else if isResponsesWebsocketCompletionEvent(eventType) {
completed = true
completedOutput = responseCompletedOutputFromPayload(payloads[i], outputItemsByIndex, outputItemsFallback)
completedResponseID = responseCompletedIDFromPayload(payloads[i])
}
markAPIResponseTimestamp(c)
if payloadErrMsg != nil {
if matched, errClose := writer.closeForUpstreamError(payloadErrMsg.Error); matched {
cancel(payloadErrMsg.Error)
if errClose != nil {
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), payloadErrMsg, errClose
}
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), payloadErrMsg, websocket.ErrCloseSent
}
errorPayload, wrote, errTerminate := writeResponsesWebsocketTerminalError(writer, wsTimelineLog, payloadErrMsg, payloads[i])
if wrote {
log.Infof(
"responses websocket: downstream_out id=%s type=%d event=%s payload=%s",
sessionID,
websocket.TextMessage,
websocketPayloadEventType(errorPayload),
websocketPayloadPreview(errorPayload),
)
}
cancel(payloadErrMsg.Error)
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), payloadErrMsg, errTerminate
}
// log.Infof(
// "responses websocket: downstream_out id=%s type=%d event=%s payload=%s",
// sessionID,
// websocket.TextMessage,
// websocketPayloadEventType(payloads[i]),
// websocketPayloadPreview(payloads[i]),
// )
if errWrite := writeResponsesWebsocketPayload(writer, wsTimelineLog, payloads[i], time.Now()); errWrite != nil {
log.Warnf(
"responses websocket: downstream_out write failed id=%s event=%s error=%v",
sessionID,
websocketPayloadEventType(payloads[i]),
errWrite,
)
cancel(errWrite)
return completedOutput, completedResponseID, sortedStringSet(pendingToolCallIDs), nil, errWrite
}
}
}
}
}
func responsesWebsocketErrorStatus(errMsg *interfaces.ErrorMessage) int {
if errMsg == nil {
return 0
}
if errMsg.StatusCode > 0 {
return errMsg.StatusCode
}
return clienterror.HTTPStatusFromError(errMsg.Error)
}
// shouldExposeResponsesUpstreamError reports whether a terminal upstream error
// must reach the downstream client.
//
// Only request-shape failures are exposed: the client can act on them and no
// credential rotation or retry can make the request succeed. Credential, quota
// and transport failures stay silent so the client simply reconnects and retries;
// a fresh connection carries no server-side transcript, so reconnecting already
// implies a full context resend.
func shouldExposeResponsesUpstreamError(errMsg *interfaces.ErrorMessage) bool {
if errMsg == nil {
return false
}
return clienterror.IsRequestFault(responsesWebsocketErrorStatus(errMsg), errMsg.Error)
}
func writeResponsesWebsocketTerminalError(
writer *responsesWebsocketWriter,
wsTimelineLog websocketTimelineAppender,
errMsg *interfaces.ErrorMessage,
payload []byte,
) ([]byte, bool, error) {
if !shouldExposeResponsesUpstreamError(errMsg) {
// Keep the upstream reason in the request-log timeline even though the client
// only observes a closed connection, otherwise silent failures are
// undiagnosable after the fact.
if wsTimelineLog != nil && errMsg != nil {
appendWebsocketTimelineDisconnect(wsTimelineLog, errMsg.Error, time.Now())
}
_, errClose := writer.closeWithoutError()
if errClose != nil {
return nil, false, errClose
}
return nil, false, websocket.ErrCloseSent
}
if len(payload) == 0 {
var errBuild error
payload, errBuild = buildResponsesWebsocketErrorPayload(errMsg)
if errBuild != nil {
_, _ = writer.closeWithoutError()
return nil, false, errBuild
}
}
wrote, errClose := writer.closeWithPayload(payload)
if wrote && wsTimelineLog != nil {
wsTimelineLog.Append("response", payload, time.Now())
}
if errClose != nil {
return payload, wrote, errClose
}
return payload, wrote, websocket.ErrCloseSent
}
func shouldReplayResponsesWebsocketPinnedAuthFailure(errMsg *interfaces.ErrorMessage) bool {
switch responsesWebsocketErrorStatus(errMsg) {
case http.StatusUnauthorized, http.StatusTooManyRequests:
return true
default:
return false
}
}
func shouldReleaseResponsesWebsocketPinnedAuth(errMsg *interfaces.ErrorMessage) bool {
if errMsg == nil {
return false
}
switch responsesWebsocketErrorStatus(errMsg) {
case http.StatusUnauthorized,
http.StatusPaymentRequired,
http.StatusForbidden,
http.StatusTooManyRequests,
http.StatusRequestTimeout,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
}
if errMsg.Error != nil {
msg := strings.ToLower(errMsg.Error.Error())
switch {
case strings.Contains(msg, "stream closed before response.completed"),
strings.Contains(msg, "previous_response_not_found"),
strings.Contains(msg, "ws_failed"),
strings.Contains(msg, "upstream stream closed before first payload"),
strings.Contains(msg, "empty_stream"):
return true
}
}
return false
}
func collectResponsesWebsocketOutputItem(payload []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) {
if gjson.GetBytes(payload, "type").String() != "response.output_item.done" {
return
}
item := gjson.GetBytes(payload, "item")
if !item.Exists() || !item.IsObject() {
return
}
outputIndex := gjson.GetBytes(payload, "output_index")
if outputIndex.Exists() {
outputItemsByIndex[outputIndex.Int()] = bytes.Clone([]byte(item.Raw))
return
}
*outputItemsFallback = append(*outputItemsFallback, bytes.Clone([]byte(item.Raw)))
}
func restoreResponsesWebsocketCompletionOutput(payload []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
output := gjson.GetBytes(payload, "response.output")
if output.Exists() && output.IsArray() && len(output.Array()) > 0 {
reconciledOutput, changed := reconcileResponsesWebsocketCompletionToolCalls(output, outputItemsByIndex, outputItemsFallback)
if !changed {
return payload
}
restored, errSet := sjson.SetRawBytes(payload, "response.output", reconciledOutput)
if errSet != nil {
return payload
}
return restored
}
if len(outputItemsByIndex) == 0 && len(outputItemsFallback) == 0 {
return payload
}
restored, errSet := sjson.SetRawBytes(payload, "response.output", responseCompletedOutputFromPayload(payload, outputItemsByIndex, outputItemsFallback))
if errSet != nil {
return payload
}
return restored
}
func reconcileResponsesWebsocketCompletionToolCalls(output gjson.Result, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) ([]byte, bool) {
collectedToolCalls := make(map[string]json.RawMessage)
recordCollectedToolCall := func(raw []byte) {
item := gjson.ParseBytes(raw)
if !isCompleteResponsesWebsocketToolCall(item) {
return
}
callID := strings.TrimSpace(item.Get("call_id").String())
collectedToolCalls[callID] = append(json.RawMessage(nil), raw...)
}
indexes := make([]int64, 0, len(outputItemsByIndex))
for index := range outputItemsByIndex {
indexes = append(indexes, index)
}
sort.Slice(indexes, func(i, j int) bool {
return indexes[i] < indexes[j]
})
for _, index := range indexes {
recordCollectedToolCall(outputItemsByIndex[index])
}
for _, item := range outputItemsFallback {
recordCollectedToolCall(item)
}
if len(collectedToolCalls) == 0 {
return nil, false
}
items := output.Array()
reconciled := make([]json.RawMessage, 0, len(items))
changed := false
for _, item := range items {
raw := json.RawMessage(item.Raw)
if isResponsesToolCallType(item.Get("type").String()) {
callID := strings.TrimSpace(item.Get("call_id").String())
if collected, ok := collectedToolCalls[callID]; ok && !bytes.Equal(raw, collected) {
raw = collected
changed = true
}
}
reconciled = append(reconciled, raw)
}
if !changed {
return nil, false
}
marshaledOutput, errMarshal := json.Marshal(reconciled)
if errMarshal != nil {
return nil, false
}
return marshaledOutput, true
}
func isCompleteResponsesWebsocketToolCall(item gjson.Result) bool {
if !item.Exists() || !item.IsObject() {
return false
}
callID := item.Get("call_id")
name := item.Get("name")
if callID.Type != gjson.String || strings.TrimSpace(callID.String()) == "" || name.Type != gjson.String || strings.TrimSpace(name.String()) == "" {
return false
}
switch strings.TrimSpace(item.Get("type").String()) {
case "function_call":
arguments := item.Get("arguments")
return arguments.Exists() && arguments.Type == gjson.String
case "custom_tool_call":
input := item.Get("input")
return input.Exists() && input.Type == gjson.String
default:
return false
}
}
func responseCompletedOutputFromPayload(payload []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
output := gjson.GetBytes(payload, "response.output")
if output.Exists() && output.IsArray() && len(output.Array()) > 0 {
return bytes.Clone([]byte(output.Raw))
}
if len(outputItemsByIndex) == 0 && len(outputItemsFallback) == 0 {
return []byte("[]")
}
indexes := make([]int64, 0, len(outputItemsByIndex))
for index := range outputItemsByIndex {
indexes = append(indexes, index)
}
sort.Slice(indexes, func(i, j int) bool {
return indexes[i] < indexes[j]
})
items := make([]json.RawMessage, 0, len(outputItemsByIndex)+len(outputItemsFallback))
appendCollectedItem := func(raw []byte) {
item := gjson.ParseBytes(raw)
if isResponsesToolCallType(item.Get("type").String()) && !isCompleteResponsesWebsocketToolCall(item) {
return
}
items = append(items, append(json.RawMessage(nil), raw...))
}
for _, index := range indexes {
appendCollectedItem(outputItemsByIndex[index])
}
for _, item := range outputItemsFallback {
appendCollectedItem(item)
}
marshaledOutput, errMarshal := json.Marshal(items)
if errMarshal != nil {
return []byte("[]")
}
return marshaledOutput
}
func responseCompletedIDFromPayload(payload []byte) string {
return strings.TrimSpace(gjson.GetBytes(payload, "response.id").String())
}
func recordPendingToolCallIDsFromPayload(pending map[string]struct{}, payload []byte) {
if pending == nil || len(payload) == 0 {
return
}
updatePendingToolCallIDsFromItem(pending, gjson.GetBytes(payload, "item"))
output := gjson.GetBytes(payload, "response.output")
if output.IsArray() {
for _, item := range output.Array() {
updatePendingToolCallIDsFromItem(pending, item)
}
}
}
func updatePendingToolCallIDsFromItem(pending map[string]struct{}, item gjson.Result) {
if pending == nil || !item.Exists() {
return
}
switch strings.TrimSpace(item.Get("type").String()) {
case "function_call", "custom_tool_call":
if !isCompleteResponsesWebsocketToolCall(item) {
return
}
callID := strings.TrimSpace(item.Get("call_id").String())
pending[callID] = struct{}{}
case "function_call_output", "custom_tool_call_output":
callID := strings.TrimSpace(item.Get("call_id").String())
if callID != "" {
delete(pending, callID)
}
}
}
func sortedStringSet(values map[string]struct{}) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
for value := range values {
value = strings.TrimSpace(value)
if value != "" {
out = append(out, value)
}
}
sort.Strings(out)
return out
}
func websocketJSONPayloadsFromChunk(chunk []byte) [][]byte {
payloads := make([][]byte, 0, 2)
lines := bytes.Split(chunk, []byte("\n"))
for i := range lines {
line := bytes.TrimSpace(lines[i])
if len(line) == 0 || bytes.HasPrefix(line, []byte("event:")) {
continue
}
if bytes.HasPrefix(line, []byte("data:")) {
line = bytes.TrimSpace(line[len("data:"):])
}
if len(line) == 0 || bytes.Equal(line, []byte(wsDoneMarker)) {
continue
}
if json.Valid(line) {
payloads = append(payloads, bytes.Clone(line))
}
}
if len(payloads) > 0 {
return payloads
}
trimmed := bytes.TrimSpace(chunk)
if bytes.HasPrefix(trimmed, []byte("data:")) {
trimmed = bytes.TrimSpace(trimmed[len("data:"):])
}
if len(trimmed) > 0 && !bytes.Equal(trimmed, []byte(wsDoneMarker)) && json.Valid(trimmed) {
payloads = append(payloads, bytes.Clone(trimmed))
}
return payloads
}
func buildResponsesWebsocketErrorPayload(errMsg *interfaces.ErrorMessage) ([]byte, error) {
status := http.StatusInternalServerError
errText := http.StatusText(status)
if errMsg != nil {
if errMsg.StatusCode > 0 {
status = errMsg.StatusCode
errText = http.StatusText(status)
}
if errMsg.Error != nil && strings.TrimSpace(errMsg.Error.Error()) != "" {
errText = errMsg.Error.Error()
}
}
body := handlers.BuildErrorResponseBody(status, errText)
payload := []byte(`{}`)
var errSet error
payload, errSet = sjson.SetBytes(payload, "type", wsEventTypeError)
if errSet != nil {
return nil, errSet
}
payload, errSet = sjson.SetBytes(payload, "status", status)
if errSet != nil {
return nil, errSet
}
if errMsg != nil && errMsg.Addon != nil {
headers := []byte(`{}`)
hasHeaders := false
for key, values := range errMsg.Addon {
if len(values) == 0 {
continue
}
headerPath := strings.ReplaceAll(strings.ReplaceAll(key, `\\`, `\\\\`), ".", `\\.`)
headers, errSet = sjson.SetBytes(headers, headerPath, values[0])
if errSet != nil {
return nil, errSet
}
hasHeaders = true
}
if hasHeaders {
payload, errSet = sjson.SetRawBytes(payload, "headers", headers)
if errSet != nil {
return nil, errSet
}
}
}
if len(body) > 0 && json.Valid(body) {
errorNode := gjson.GetBytes(body, "error")
if errorNode.Exists() {
payload, errSet = sjson.SetRawBytes(payload, "error", []byte(errorNode.Raw))
} else {
payload, errSet = sjson.SetRawBytes(payload, "error", body)
}
if errSet != nil {
return nil, errSet
}
}
if !gjson.GetBytes(payload, "error").Exists() {
payload, errSet = sjson.SetBytes(payload, "error.type", "server_error")
if errSet != nil {
return nil, errSet
}
payload, errSet = sjson.SetBytes(payload, "error.message", errText)
if errSet != nil {
return nil, errSet
}
}
return payload, nil
}
func writeResponsesWebsocketError(writer *responsesWebsocketWriter, wsTimelineLog websocketTimelineAppender, errMsg *interfaces.ErrorMessage) ([]byte, error) {
payload, errBuild := buildResponsesWebsocketErrorPayload(errMsg)
if errBuild != nil {
return nil, errBuild
}
return payload, writeResponsesWebsocketPayload(writer, wsTimelineLog, payload, time.Now())
}

View file

@ -0,0 +1,146 @@
package openai
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func shouldHandleResponsesWebsocketPrewarmLocally(rawJSON []byte, lastRequest []byte, allowIncrementalInputWithPreviousResponseID bool) bool {
if allowIncrementalInputWithPreviousResponseID || len(lastRequest) != 0 {
return false
}
if strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String()) != wsRequestTypeCreate {
return false
}
generateResult := gjson.GetBytes(rawJSON, "generate")
return generateResult.Exists() && !generateResult.Bool()
}
func writeResponsesWebsocketSyntheticPrewarm(
c *gin.Context,
writer *responsesWebsocketWriter,
requestJSON []byte,
wsTimelineLog websocketTimelineAppender,
sessionID string,
) error {
payloads, errPayloads := syntheticResponsesWebsocketPrewarmPayloads(requestJSON)
if errPayloads != nil {
return errPayloads
}
for i := 0; i < len(payloads); i++ {
markAPIResponseTimestamp(c)
// log.Infof(
// "responses websocket: downstream_out id=%s type=%d event=%s payload=%s",
// sessionID,
// websocket.TextMessage,
// websocketPayloadEventType(payloads[i]),
// websocketPayloadPreview(payloads[i]),
// )
if errWrite := writeResponsesWebsocketPayload(writer, wsTimelineLog, payloads[i], time.Now()); errWrite != nil {
log.Warnf(
"responses websocket: downstream_out write failed id=%s event=%s error=%v",
sessionID,
websocketPayloadEventType(payloads[i]),
errWrite,
)
return errWrite
}
}
return nil
}
func syntheticResponsesWebsocketPrewarmPayloads(requestJSON []byte) ([][]byte, error) {
responseID := "resp_prewarm_" + uuid.NewString()
createdAt := time.Now().Unix()
modelName := strings.TrimSpace(gjson.GetBytes(requestJSON, "model").String())
createdPayload := []byte(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`)
var errSet error
createdPayload, errSet = sjson.SetBytes(createdPayload, "response.id", responseID)
if errSet != nil {
return nil, errSet
}
createdPayload, errSet = sjson.SetBytes(createdPayload, "response.created_at", createdAt)
if errSet != nil {
return nil, errSet
}
if modelName != "" {
createdPayload, errSet = sjson.SetBytes(createdPayload, "response.model", modelName)
if errSet != nil {
return nil, errSet
}
}
completedPayload := []byte(`{"type":"response.completed","sequence_number":1,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}}}`)
completedPayload, errSet = sjson.SetBytes(completedPayload, "response.id", responseID)
if errSet != nil {
return nil, errSet
}
completedPayload, errSet = sjson.SetBytes(completedPayload, "response.created_at", createdAt)
if errSet != nil {
return nil, errSet
}
if modelName != "" {
completedPayload, errSet = sjson.SetBytes(completedPayload, "response.model", modelName)
if errSet != nil {
return nil, errSet
}
}
return [][]byte{createdPayload, completedPayload}, nil
}
// inputContainsFullTranscript returns true when the input array carries compact
// replay markers that indicate the client already sent the full conversation
// transcript. Merging that input with stale lastRequest/lastResponseOutput
// would duplicate or break function_call/function_call_output pairings, so the
// caller should use the input as-is.
//
// Assistant messages alone are not enough to classify the payload as a replay:
// incremental websocket requests may legitimately append assistant items.
func inputContainsFullTranscript(input gjson.Result) bool {
if !input.IsArray() {
return false
}
for _, item := range input.Array() {
t := item.Get("type").String()
if t == "compaction" || t == "compaction_summary" {
return true
}
}
return false
}
func inputWithoutCompactionItems(input gjson.Result) string {
if !input.IsArray() {
return normalizeJSONArrayRaw([]byte(input.Raw))
}
filtered := make([]string, 0, len(input.Array()))
for _, item := range input.Array() {
t := item.Get("type").String()
if t == "compaction" || t == "compaction_summary" {
continue
}
filtered = append(filtered, item.Raw)
}
return "[" + strings.Join(filtered, ",") + "]"
}
func normalizeJSONArrayRaw(raw []byte) string {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" {
return "[]"
}
result := gjson.Parse(trimmed)
if result.Type == gjson.JSON && result.IsArray() {
return trimmed
}
return "[]"
}

View file

@ -0,0 +1,737 @@
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
}

View file

@ -0,0 +1,575 @@
package openai
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math/rand"
"reflect"
"runtime"
"strings"
"testing"
)
const (
responsesWebsocketLargeTranscriptSize = 1 << 20
responsesWebsocketBenchmarkTranscriptSize = 8 << 20
)
var (
responsesWebsocketMergedInputSink any
responsesWebsocketNormalizedRequestSink []byte
)
func TestMergeResponsesWebsocketInputMatchesCompatibilityScenarios(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lastRequest string
lastResponseOutput string
appendInput string
want string
}{
{
name: "messages and paired tool call",
lastRequest: `{"model":"gpt-5.4","input":[{"type":"message","id":"msg-1","role":"user","content":"hello"}]}`,
lastResponseOutput: `[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"lookup","arguments":"{}"}]`,
appendInput: `[{"type":"function_call_output","id":"fco-1","call_id":"call-1","output":"done"}]`,
want: `[{"type":"message","id":"msg-1","role":"user","content":"hello"},{"type":"function_call","id":"fc-1","call_id":"call-1","name":"lookup","arguments":"{}"},{"type":"function_call_output","id":"fco-1","call_id":"call-1","output":"done"}]`,
},
{
name: "duplicate function call keeps first",
lastRequest: `{"input":[{"type":"function_call","id":"fc-first","call_id":"call-1","name":"first","arguments":"{}"}]}`,
lastResponseOutput: `[{"type":"function_call","id":"fc-second","call_id":"call-1","name":"second","arguments":"{}"}]`,
appendInput: `[{"type":"function_call_output","id":"fco-1","call_id":"call-1","output":"done"}]`,
want: `[{"type":"function_call","id":"fc-first","call_id":"call-1","name":"first","arguments":"{}"},{"type":"function_call_output","id":"fco-1","call_id":"call-1","output":"done"}]`,
},
{
name: "duplicate id keeps item referenced by output",
lastRequest: `{"input":[{"type":"function_call","id":"fc-1","call_id":"call-kept","name":"first","arguments":"{}"}]}`,
lastResponseOutput: `[{"type":"function_call","id":"fc-1","call_id":"call-other","name":"second","arguments":"{}"}]`,
appendInput: `[{"type":"function_call_output","id":"fco-1","call_id":"call-kept","output":"done"}]`,
want: `[{"type":"function_call","id":"fc-1","call_id":"call-kept","name":"first","arguments":"{}"},{"type":"function_call_output","id":"fco-1","call_id":"call-kept","output":"done"}]`,
},
{
name: "raw JSON values and escaping",
lastRequest: `{"input":[ {"type":"message","id":"msg-1","content":"<tag> & \\u263a"}, true ]}`,
lastResponseOutput: `[null, 42, "line\\nvalue"]`,
appendInput: `[{"id":"last","nested":{"value":[1,2,3]}}]`,
want: `[{"type":"message","id":"msg-1","content":"<tag> & \\u263a"},true,null,42,"line\\nvalue",{"id":"last","nested":{"value":[1,2,3]}}]`,
},
{
name: "large numbers retain exact JSON values",
lastRequest: `{"input":[9007199254740993,{"id":"n","value":9223372036854775807}]}`,
lastResponseOutput: `[18446744073709551615]`,
appendInput: `[{"id":"decimal","value":1.0000000000000000001}]`,
want: `[9007199254740993,{"id":"n","value":9223372036854775807},18446744073709551615,{"id":"decimal","value":1.0000000000000000001}]`,
},
{
name: "invalid response output remains ignored",
lastRequest: `{"input":[{"id":"first"}]}`,
lastResponseOutput: `[{"id":`,
appendInput: `[{"id":"last"}]`,
want: `[{"id":"first"},{"id":"last"}]`,
},
{
name: "missing previous input and null append",
lastRequest: `{"model":"gpt-5.4"}`,
lastResponseOutput: `[{"id":"response"}]`,
appendInput: `null`,
want: `[{"id":"response"}]`,
},
{
name: "null previous request",
lastRequest: `null`,
lastResponseOutput: `[]`,
appendInput: `[{"id":"last"}]`,
want: `[{"id":"last"}]`,
},
{
name: "duplicate metadata keys follow encoding json",
lastRequest: `{"input":[{"type":"message","type":"function_call","id":"first","id":"fc-1","call_id":"call-other","call_id":"call-kept"}]}`,
lastResponseOutput: `[{"type":"function_call","id":"fc-2","call_id":"call-kept"}]`,
appendInput: `[{"type":"function_call_output","id":"fco-1","call_id":"call-kept","output":"done"}]`,
want: `[{"type":"function_call","id":"fc-1","call_id":"call-kept"},{"type":"function_call_output","id":"fco-1","call_id":"call-kept","output":"done"}]`,
},
{
name: "case insensitive metadata dedupes function calls",
lastRequest: `{"input":[{"Type":"function_call","ID":"fc-old","CALL_ID":"call-1","name":"first"}]}`,
lastResponseOutput: `[{"type":"function_call","id":"fc-new","call_id":"call-1","name":"second"}]`,
appendInput: `[{"type":"function_call_output","id":"fco-1","call_id":"call-1","output":"done"}]`,
want: `[{"Type":"function_call","ID":"fc-old","CALL_ID":"call-1","name":"first"},{"type":"function_call_output","id":"fco-1","call_id":"call-1","output":"done"}]`,
},
{
name: "case insensitive metadata keeps referenced duplicate id",
lastRequest: `{"input":[{"Type":"function_call","Id":"fc-1","Call_Id":"call-kept","name":"first"}]}`,
lastResponseOutput: `[{"type":"function_call","id":"fc-1","call_id":"call-other","name":"second"}]`,
appendInput: `[{"type":"function_call_output","id":"fco-1","call_id":"call-kept","output":"done"}]`,
want: `[{"Type":"function_call","Id":"fc-1","Call_Id":"call-kept","name":"first"},{"type":"function_call_output","id":"fco-1","call_id":"call-kept","output":"done"}]`,
},
{
name: "mixed case duplicate metadata keeps last values",
lastRequest: `{"input":[{"type":"message","TYPE":"function_call","id":"first","ID":"fc-1","call_id":"call-other","CALL_ID":"call-kept"}]}`,
lastResponseOutput: `[{"type":"function_call","id":"fc-2","call_id":"call-kept"}]`,
appendInput: `[{"type":"function_call_output","id":"fco-1","call_id":"call-kept","output":"done"}]`,
want: `[{"type":"message","TYPE":"function_call","id":"first","ID":"fc-1","call_id":"call-other","CALL_ID":"call-kept"},{"type":"function_call_output","id":"fco-1","call_id":"call-kept","output":"done"}]`,
},
{
name: "duplicate previous input keeps last array",
lastRequest: `{"input":[{"id":"old"}],"input":[{"id":"new"}]}`,
lastResponseOutput: `[]`,
appendInput: `[]`,
want: `[{"id":"new"}]`,
},
{
name: "previous input field matching is case insensitive",
lastRequest: `{"Input":[{"id":"old"}],"INPUT":[{"id":"new"}]}`,
lastResponseOutput: `[]`,
appendInput: `[]`,
want: `[{"id":"new"}]`,
},
{
name: "last duplicate null clears previous input",
lastRequest: `{"input":[{"id":"old"}],"input":null}`,
lastResponseOutput: `[{"id":"response"}]`,
appendInput: `[]`,
want: `[{"id":"response"}]`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
legacy, errLegacy := mergeResponsesWebsocketInputReference([]byte(test.lastRequest), []byte(test.lastResponseOutput), test.appendInput)
if errLegacy != nil {
t.Fatalf("legacy merge failed: %v", errLegacy)
}
assertJSONSemanticallyEqual(t, []byte(legacy), test.want)
got, errGot := mergeResponsesWebsocketInput([]byte(test.lastRequest), []byte(test.lastResponseOutput), test.appendInput)
if errGot != nil {
t.Fatalf("mergeResponsesWebsocketInput() error = %v", errGot)
}
assertJSONSemanticallyEqual(t, []byte(got), test.want)
})
}
}
func TestMergeResponsesWebsocketInputReturnsCompatibleErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lastRequest string
lastResponseOutput string
appendInput string
wantPrefix string
wantSyntaxError bool
}{
{name: "invalid previous request", lastRequest: `{"input":`, appendInput: `[]`, wantPrefix: "invalid previous request input", wantSyntaxError: true},
{name: "non-array previous input", lastRequest: `{"input":{"id":"item"}}`, appendInput: `[]`, wantPrefix: "invalid previous request input"},
{name: "invalid appended input", lastRequest: `{"input":[]}`, appendInput: `[{"id":`, wantPrefix: "invalid request input", wantSyntaxError: true},
{name: "non-array appended input", lastRequest: `{"input":[]}`, appendInput: `{"id":"item"}`, wantPrefix: "invalid request input"},
{name: "array previous request", lastRequest: `[]`, appendInput: `[]`, wantPrefix: "invalid previous request input"},
{name: "last duplicate previous input is non-array", lastRequest: `{"input":[],"input":{"id":"item"}}`, appendInput: `[]`, wantPrefix: "invalid previous request input"},
{name: "earlier non-array previous input remains invalid", lastRequest: `{"input":{"id":"item"},"input":[]}`, appendInput: `[]`, wantPrefix: "invalid previous request input"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
_, errGot := mergeResponsesWebsocketInput([]byte(test.lastRequest), []byte(test.lastResponseOutput), test.appendInput)
if errGot == nil {
t.Fatal("expected merge error")
}
if !strings.HasPrefix(errGot.Error(), test.wantPrefix+": ") {
t.Fatalf("error = %q, want prefix %q", errGot, test.wantPrefix+": ")
}
if test.wantSyntaxError {
var syntaxError *json.SyntaxError
if !errors.As(errGot, &syntaxError) {
t.Fatalf("error cause = %T, want *json.SyntaxError", errGot)
}
return
}
var typeError *json.UnmarshalTypeError
if !errors.As(errGot, &typeError) {
t.Fatalf("error cause = %T, want *json.UnmarshalTypeError", errGot)
}
})
}
}
func TestMergeResponsesWebsocketInputMatchesReferenceAcrossGeneratedTranscripts(t *testing.T) {
t.Parallel()
random := rand.New(rand.NewSource(0xC0DE))
for iteration := 0; iteration < 250; iteration++ {
previous := generatedResponsesWebsocketInput(t, random, random.Intn(12))
response := generatedResponsesWebsocketInput(t, random, random.Intn(12))
appendInput := generatedResponsesWebsocketInput(t, random, random.Intn(12))
lastRequest := append(append([]byte(`{"model":"gpt-5.4","input":`), previous...), '}')
want, errWant := mergeResponsesWebsocketInputReference(lastRequest, response, string(appendInput))
if errWant != nil {
t.Fatalf("iteration %d reference merge failed: %v", iteration, errWant)
}
got, errGot := mergeResponsesWebsocketInput(lastRequest, response, string(appendInput))
if errGot != nil {
t.Fatalf("iteration %d merge failed: %v", iteration, errGot)
}
assertJSONSemanticallyEqual(t, []byte(got), want)
}
}
func generatedResponsesWebsocketInput(t *testing.T, random *rand.Rand, count int) []byte {
t.Helper()
items := make([]any, 0, count)
itemTypes := []string{"message", "function_call", "function_call_output", "custom_tool_call", "custom_tool_call_output", "reasoning"}
for index := 0; index < count; index++ {
if random.Intn(10) == 0 {
items = append(items, []any{true, float64(index), nil}[random.Intn(3)])
continue
}
item := map[string]any{
"type": itemTypes[random.Intn(len(itemTypes))],
"id": fmt.Sprintf("item-%d", random.Intn(8)),
"call_id": fmt.Sprintf("call-%d", random.Intn(6)),
"content": fmt.Sprintf("iteration-%d <tag> & \\u263a", index),
}
items = append(items, item)
}
out, errMarshal := json.Marshal(items)
if errMarshal != nil {
t.Fatalf("marshal generated input: %v", errMarshal)
}
return out
}
func TestNormalizeResponseSubsequentRequestDetachesSourceBuffers(t *testing.T) {
t.Parallel()
lastRequest := []byte(`{"model":"gpt-5.4","instructions":"keep me","input":[{"type":"message","id":"msg-1","role":"user","content":"history sentinel"}]}`)
lastResponseOutput := []byte(`[{"type":"message","id":"msg-2","role":"assistant","content":[{"type":"output_text","text":"response sentinel"}]}]`)
raw := []byte(`{"type":"response.create","input":[{"type":"message","id":"msg-3","role":"user","content":"append sentinel"}]}`)
normalized, next, errMessage := normalizeResponseSubsequentRequest(raw, lastRequest, lastResponseOutput, "", nil, false, false)
if errMessage != nil {
t.Fatalf("normalizeResponseSubsequentRequest() error = %v", errMessage.Error)
}
wantNormalized := bytes.Clone(normalized)
wantNext := bytes.Clone(next)
for _, source := range [][]byte{lastRequest, lastResponseOutput, raw} {
for index := range source {
source[index] = 'x'
}
}
runtime.KeepAlive(lastRequest)
runtime.KeepAlive(lastResponseOutput)
runtime.KeepAlive(raw)
if !bytes.Equal(normalized, wantNormalized) {
t.Fatal("normalized request aliases a source buffer")
}
if !bytes.Equal(next, wantNext) {
t.Fatal("stored next request aliases a source buffer")
}
}
func TestMergeResponsesWebsocketInputBoundsLargeTranscriptAllocations(t *testing.T) {
if raceDetectorEnabled {
t.Skip("allocation budgets are not meaningful with race detector instrumentation")
}
tests := []struct {
name string
makeFixture func() ([]byte, []byte, string)
}{
{name: "single_large_item", makeFixture: responsesWebsocketLargeTranscriptFixture},
{name: "many_messages_and_tool_pairs", makeFixture: func() ([]byte, []byte, string) {
return responsesWebsocketManyItemsTranscriptFixture(responsesWebsocketLargeTranscriptSize)
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
lastRequest, lastResponseOutput, appendInput := test.makeFixture()
inputBytes := len(lastRequest) + len(lastResponseOutput) + len(appendInput)
result := testing.Benchmark(func(b *testing.B) {
b.SetBytes(int64(inputBytes))
b.ReportAllocs()
for b.Loop() {
merged, errMerge := mergeResponsesWebsocketInput(lastRequest, lastResponseOutput, appendInput)
if errMerge != nil {
b.Fatalf("mergeResponsesWebsocketInput() error = %v", errMerge)
}
responsesWebsocketMergedInputSink = merged
}
responsesWebsocketMergedInputSink = nil
})
const (
maxAllocationNumerator = 3
maxAllocationDenominator = 2
)
maxAllocatedBytes := int64(inputBytes) * maxAllocationNumerator / maxAllocationDenominator
t.Logf("merge allocated %d bytes per operation for %d input bytes", result.AllocedBytesPerOp(), inputBytes)
if allocatedBytes := result.AllocedBytesPerOp(); allocatedBytes > maxAllocatedBytes {
t.Fatalf("merging %d input bytes allocated %d bytes per operation, want at most %d", inputBytes, allocatedBytes, maxAllocatedBytes)
}
})
}
}
func BenchmarkNormalizeResponseSubsequentRequestTranscripts(b *testing.B) {
tests := []struct {
name string
makeFixture func() ([]byte, []byte, string)
}{
{name: "single_large_item", makeFixture: func() ([]byte, []byte, string) {
return responsesWebsocketTranscriptFixture(responsesWebsocketBenchmarkTranscriptSize)
}},
{name: "many_messages_and_tool_pairs", makeFixture: func() ([]byte, []byte, string) {
return responsesWebsocketManyItemsTranscriptFixture(responsesWebsocketBenchmarkTranscriptSize)
}},
}
for _, test := range tests {
b.Run(test.name, func(b *testing.B) {
lastRequest, lastResponseOutput, appendInput := test.makeFixture()
raw := []byte(`{"type":"response.create","input":` + appendInput + `}`)
b.SetBytes(int64(len(lastRequest) + len(lastResponseOutput) + len(raw)))
b.ReportAllocs()
for b.Loop() {
normalized, _, errMessage := normalizeResponseSubsequentRequest(raw, lastRequest, lastResponseOutput, "", nil, false, false)
if errMessage != nil {
b.Fatalf("normalizeResponseSubsequentRequest() error = %v", errMessage.Error)
}
responsesWebsocketNormalizedRequestSink = normalized
}
responsesWebsocketNormalizedRequestSink = nil
})
}
}
func responsesWebsocketLargeTranscriptFixture() ([]byte, []byte, string) {
return responsesWebsocketTranscriptFixture(responsesWebsocketLargeTranscriptSize)
}
func responsesWebsocketTranscriptFixture(transcriptSize int) ([]byte, []byte, string) {
lastRequest := []byte(`{"model":"gpt-5.4","instructions":"coding","stream":true,"input":[{"type":"message","id":"msg-large","role":"user","content":"` + strings.Repeat("x", transcriptSize) + `"}]}`)
lastResponseOutput := []byte(`[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"lookup","arguments":"{}"}]`)
appendInput := `[{"type":"function_call_output","id":"fco-1","call_id":"call-1","output":"done"}]`
return lastRequest, lastResponseOutput, appendInput
}
func responsesWebsocketManyItemsTranscriptFixture(transcriptSize int) ([]byte, []byte, string) {
const (
messageCount = 512
toolPairCount = 128
)
contentSize := max(transcriptSize/messageCount, 1)
content := strings.Repeat("x", contentSize)
var lastRequest strings.Builder
lastRequest.Grow(transcriptSize + messageCount*96)
lastRequest.WriteString(`{"model":"gpt-5.4","instructions":"coding","stream":true,"input":[`)
for index := range messageCount {
if index > 0 {
lastRequest.WriteByte(',')
}
fmt.Fprintf(&lastRequest, `{"type":"message","id":"msg-%d","role":"user","content":"%s"}`, index, content)
}
lastRequest.WriteString(`]}`)
var lastResponseOutput strings.Builder
lastResponseOutput.Grow(toolPairCount * 112)
lastResponseOutput.WriteByte('[')
for index := range toolPairCount {
if index > 0 {
lastResponseOutput.WriteByte(',')
}
fmt.Fprintf(&lastResponseOutput, `{"type":"function_call","id":"fc-%d","call_id":"call-%d","name":"lookup","arguments":"{}"}`, index, index)
}
lastResponseOutput.WriteByte(']')
var appendInput strings.Builder
appendInput.Grow(toolPairCount * 104)
appendInput.WriteByte('[')
for index := range toolPairCount {
if index > 0 {
appendInput.WriteByte(',')
}
fmt.Fprintf(&appendInput, `{"type":"function_call_output","id":"fco-%d","call_id":"call-%d","output":"done"}`, index, index)
}
appendInput.WriteByte(']')
return []byte(lastRequest.String()), []byte(lastResponseOutput.String()), appendInput.String()
}
// The legacy oracle detects broad behavior drift from the implementation that
// preceded the allocation optimization. Explicit compatibility scenarios above
// remain the independent specification for important merge behavior.
type referenceResponsesWebsocketInputItem struct {
raw json.RawMessage
itemType string
id string
callID string
}
func mergeResponsesWebsocketInputReference(lastRequest []byte, lastResponseOutput []byte, appendRaw string) (string, error) {
var previousRequest struct {
Input []json.RawMessage `json:"input"`
}
if errUnmarshal := json.Unmarshal(lastRequest, &previousRequest); errUnmarshal != nil {
return "", fmt.Errorf("invalid previous request input: %w", errUnmarshal)
}
items, errExisting := appendReferenceResponsesWebsocketRawInputItems(nil, previousRequest.Input)
if errExisting != nil {
return "", fmt.Errorf("invalid previous request input: %w", errExisting)
}
var responseItems []json.RawMessage
trimmedResponse := bytes.TrimSpace(lastResponseOutput)
if len(trimmedResponse) > 0 && trimmedResponse[0] == '[' && json.Valid(trimmedResponse) {
if errUnmarshal := json.Unmarshal(trimmedResponse, &responseItems); errUnmarshal != nil {
return "", fmt.Errorf("invalid previous response output: %w", errUnmarshal)
}
}
items, errResponse := appendReferenceResponsesWebsocketRawInputItems(items, responseItems)
if errResponse != nil {
return "", fmt.Errorf("invalid previous response output: %w", errResponse)
}
appendRaw = strings.TrimSpace(appendRaw)
if appendRaw == "" {
appendRaw = "[]"
}
var appendItems []json.RawMessage
if errUnmarshal := json.Unmarshal([]byte(appendRaw), &appendItems); errUnmarshal != nil {
return "", fmt.Errorf("invalid request input: %w", errUnmarshal)
}
items, errAppend := appendReferenceResponsesWebsocketRawInputItems(items, appendItems)
if errAppend != nil {
return "", fmt.Errorf("invalid request input: %w", errAppend)
}
items = dedupeReferenceResponsesWebsocketFunctionCalls(items)
items = dedupeReferenceResponsesWebsocketInputItems(items)
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 appendReferenceResponsesWebsocketRawInputItems(items []referenceResponsesWebsocketInputItem, rawItems []json.RawMessage) ([]referenceResponsesWebsocketInputItem, error) {
for _, rawItem := range rawItems {
item := referenceResponsesWebsocketInputItem{raw: rawItem}
trimmed := bytes.TrimSpace(rawItem)
if len(trimmed) > 0 && trimmed[0] == '{' {
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 nil, errUnmarshal
}
item.itemType = responsesWebsocketMetadataString(metadata.Type)
item.id = responsesWebsocketMetadataString(metadata.ID)
item.callID = responsesWebsocketMetadataString(metadata.CallID)
}
items = append(items, item)
}
return items, nil
}
func dedupeReferenceResponsesWebsocketFunctionCalls(items []referenceResponsesWebsocketInputItem) []referenceResponsesWebsocketInputItem {
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)
}
return filtered
}
func dedupeReferenceResponsesWebsocketInputItems(items []referenceResponsesWebsocketInputItem) []referenceResponsesWebsocketInputItem {
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)
}
return filtered
}
func assertJSONSemanticallyEqual(t *testing.T, got []byte, want string) {
t.Helper()
if !json.Valid(got) {
t.Fatalf("invalid actual JSON:\n%s", got)
}
var gotValue any
gotDecoder := json.NewDecoder(bytes.NewReader(got))
gotDecoder.UseNumber()
if errUnmarshal := gotDecoder.Decode(&gotValue); errUnmarshal != nil {
t.Fatalf("invalid actual JSON: %v\n%s", errUnmarshal, got)
}
if !json.Valid([]byte(want)) {
t.Fatalf("invalid expected JSON:\n%s", want)
}
var wantValue any
wantDecoder := json.NewDecoder(strings.NewReader(want))
wantDecoder.UseNumber()
if errUnmarshal := wantDecoder.Decode(&wantValue); errUnmarshal != nil {
t.Fatalf("invalid reference JSON: %v\n%s", errUnmarshal, want)
}
if !reflect.DeepEqual(gotValue, wantValue) {
t.Fatalf("JSON values differ:\n got: %s\nwant: %s", got, want)
}
}

View file

@ -0,0 +1,237 @@
package openai
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func websocketUpstreamSupportsIncrementalInput(attributes map[string]string, metadata map[string]any) bool {
if len(attributes) > 0 {
if raw := strings.TrimSpace(attributes["websockets"]); raw != "" {
parsed, errParse := strconv.ParseBool(raw)
if errParse == nil {
return parsed
}
}
}
if len(metadata) == 0 {
return false
}
raw, ok := metadata["websockets"]
if !ok || raw == nil {
return false
}
switch value := raw.(type) {
case bool:
return value
case string:
parsed, errParse := strconv.ParseBool(strings.TrimSpace(value))
if errParse == nil {
return parsed
}
default:
}
return false
}
func (h *OpenAIResponsesAPIHandler) websocketUpstreamSupportsIncrementalInputForModel(modelName string) bool {
auths, _ := h.responsesWebsocketAvailableAuthsForModel(modelName)
for _, auth := range auths {
if responsesWebsocketAuthSupportsIncrementalInput(auth) {
return true
}
}
return false
}
func (h *OpenAIResponsesAPIHandler) websocketUpstreamSupportsCompactionReplayForModel(modelName string) bool {
auths, _ := h.responsesWebsocketAvailableAuthsForModel(modelName)
if len(auths) == 0 {
return false
}
for _, auth := range auths {
if !responsesWebsocketAuthSupportsCompactionReplay(auth) {
return false
}
}
return true
}
func (h *OpenAIResponsesAPIHandler) responsesWebsocketAvailableAuthsForModel(modelName string) ([]*coreauth.Auth, string) {
if h == nil || h.AuthManager == nil {
return nil, ""
}
resolvedModelName := responsesWebsocketResolvedModelName(modelName)
providerSet, modelKey := responsesWebsocketProviderSetForModel(resolvedModelName)
if len(providerSet) == 0 {
return nil, modelKey
}
registryRef := registry.GetGlobalRegistry()
now := time.Now()
auths := h.AuthManager.List()
available := make([]*coreauth.Auth, 0, len(auths))
for _, auth := range auths {
if !responsesWebsocketAuthMatchesModel(auth, providerSet, modelKey, registryRef, now) {
continue
}
available = append(available, auth)
}
return available, modelKey
}
func (h *OpenAIResponsesAPIHandler) responsesWebsocketUsesCodexWebsocketPassthrough(modelName string) bool {
return h.responsesWebsocketUsesUpstreamWebsocketPassthrough(modelName)
}
func (h *OpenAIResponsesAPIHandler) responsesWebsocketUsesUpstreamWebsocketPassthrough(modelName string) bool {
modelName = strings.TrimSpace(modelName)
if h == nil || h.AuthManager == nil || modelName == "" {
return false
}
auths, _ := h.responsesWebsocketAvailableAuthsForModel(modelName)
if len(auths) == 0 {
return false
}
provider := ""
for _, auth := range auths {
if auth == nil {
return false
}
authProvider := strings.ToLower(strings.TrimSpace(auth.Provider))
if authProvider != "codex" && authProvider != "xai" {
return false
}
if provider == "" {
provider = authProvider
if _, ok := h.AuthManager.Executor(provider); !ok {
return false
}
} else if authProvider != provider {
return false
}
if !websocketUpstreamSupportsIncrementalInput(auth.Attributes, auth.Metadata) {
return false
}
}
return provider != ""
}
func responsesWebsocketAuthSupportsIncrementalInput(auth *coreauth.Auth) bool {
if auth == nil {
return false
}
return websocketUpstreamSupportsIncrementalInput(auth.Attributes, auth.Metadata)
}
func responsesWebsocketPinnedAuthMatchesModel(auth *coreauth.Auth, modelName string, pinnedModelKey string, homeRuntime bool) bool {
if auth == nil {
return false
}
providerSet, modelKey := responsesWebsocketProviderSetForModel(responsesWebsocketResolvedModelName(modelName))
providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
if _, ok := providerSet[providerKey]; !ok {
return false
}
if !responsesWebsocketAuthAvailableForModel(auth, modelKey, time.Now()) {
return false
}
if homeRuntime {
return strings.EqualFold(strings.TrimSpace(pinnedModelKey), strings.TrimSpace(modelKey))
}
return registry.GetGlobalRegistry().ClientSupportsModel(auth.ID, modelKey)
}
func responsesWebsocketResolvedModelName(modelName string) string {
initialSuffix := thinking.ParseSuffix(modelName)
if initialSuffix.ModelName == "auto" {
resolvedBase := util.ResolveAutoModel(initialSuffix.ModelName)
if initialSuffix.HasSuffix {
return fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix)
}
return resolvedBase
}
return util.ResolveAutoModel(modelName)
}
func responsesWebsocketProviderSetForModel(resolvedModelName string) (map[string]struct{}, string) {
parsed := thinking.ParseSuffix(resolvedModelName)
baseModel := strings.TrimSpace(parsed.ModelName)
providers := util.GetProviderName(baseModel)
if len(providers) == 0 && baseModel != resolvedModelName {
providers = util.GetProviderName(resolvedModelName)
}
providerSet := make(map[string]struct{}, len(providers))
for _, provider := range providers {
providerKey := strings.TrimSpace(strings.ToLower(provider))
if providerKey == "" {
continue
}
providerSet[providerKey] = struct{}{}
}
modelKey := baseModel
if modelKey == "" {
modelKey = strings.TrimSpace(resolvedModelName)
}
return providerSet, modelKey
}
func responsesWebsocketAuthMatchesModel(auth *coreauth.Auth, providerSet map[string]struct{}, modelKey string, registryRef *registry.ModelRegistry, now time.Time) bool {
if auth == nil {
return false
}
providerKey := strings.TrimSpace(strings.ToLower(auth.Provider))
if _, ok := providerSet[providerKey]; !ok {
return false
}
if modelKey != "" && registryRef != nil && !registryRef.ClientSupportsModel(auth.ID, modelKey) {
return false
}
return responsesWebsocketAuthAvailableForModel(auth, modelKey, now)
}
func responsesWebsocketAuthSupportsCompactionReplay(auth *coreauth.Auth) bool {
if auth == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(auth.Provider), "codex")
}
func responsesWebsocketAuthAvailableForModel(auth *coreauth.Auth, modelName string, now time.Time) bool {
if auth == nil {
return false
}
if auth.Disabled || auth.Status == coreauth.StatusDisabled {
return false
}
if modelName != "" && len(auth.ModelStates) > 0 {
state, ok := auth.ModelStates[modelName]
if (!ok || state == nil) && modelName != "" {
baseModel := strings.TrimSpace(thinking.ParseSuffix(modelName).ModelName)
if baseModel != "" && baseModel != modelName {
state, ok = auth.ModelStates[baseModel]
}
}
if ok && state != nil {
if state.Status == coreauth.StatusDisabled {
return false
}
if state.Unavailable && !state.NextRetryAfter.IsZero() && state.NextRetryAfter.After(now) {
return false
}
return true
}
}
if auth.Unavailable && !auth.NextRetryAfter.IsZero() && auth.NextRetryAfter.After(now) {
return false
}
return true
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,336 @@
package openai
import (
"bytes"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
requestlogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
type websocketTimelineAppender interface {
Append(eventType string, payload []byte, timestamp time.Time)
}
type responsesWebsocketPinnedAuthState struct {
authID string
modelKey string
}
type websocketTimelineLog struct {
enabled bool
source *requestlogging.FileBodySource
builder *strings.Builder
currentPart io.WriteCloser
currentPartHasLog bool
}
func newWebsocketTimelineLog(enabled bool, source *requestlogging.FileBodySource) *websocketTimelineLog {
if !enabled {
return &websocketTimelineLog{}
}
if source == nil {
return newInMemoryWebsocketTimelineLog()
}
return &websocketTimelineLog{
enabled: true,
source: source,
}
}
func newInMemoryWebsocketTimelineLog() *websocketTimelineLog {
return &websocketTimelineLog{
enabled: true,
builder: &strings.Builder{},
}
}
func websocketTimelineSourceFromContext(c *gin.Context) *requestlogging.FileBodySource {
if c == nil {
return nil
}
value, exists := c.Get(requestlogging.WebsocketTimelineSourceContextKey)
if !exists {
return nil
}
source, ok := value.(*requestlogging.FileBodySource)
if !ok {
return nil
}
return source
}
func (l *websocketTimelineLog) BeginRequest() {
if l == nil || !l.enabled || l.source == nil {
return
}
l.closeCurrentPart()
part, errCreate := l.source.CreatePart("request")
if errCreate != nil {
log.WithError(errCreate).Warn("failed to create websocket request detail log")
return
}
l.currentPart = part
l.currentPartHasLog = false
}
func (l *websocketTimelineLog) Append(eventType string, payload []byte, timestamp time.Time) {
if l == nil || !l.enabled {
return
}
data := formatWebsocketTimelineEvent(eventType, payload, timestamp)
if len(data) == 0 {
return
}
if l.source != nil {
if l.currentPart == nil {
l.BeginRequest()
}
if l.currentPart == nil {
return
}
if errWrite := writeWebsocketTimelinePart(l.currentPart, data, l.currentPartHasLog); errWrite != nil {
log.WithError(errWrite).Warn("failed to write websocket request detail log")
return
}
l.currentPartHasLog = true
return
}
if l.builder != nil {
writeWebsocketTimelineBuilder(l.builder, data)
}
}
func (l *websocketTimelineLog) SetContext(c *gin.Context) {
if l == nil || !l.enabled {
return
}
l.closeCurrentPart()
if l.source != nil {
if l.source.HasPayload() {
c.Set(requestlogging.WebsocketTimelineSourceContextKey, l.source)
return
}
if errCleanup := l.source.Cleanup(); errCleanup != nil {
log.WithError(errCleanup).Warn("failed to clean up empty websocket timeline log parts")
}
}
if l.builder != nil {
setWebsocketTimelineBody(c, l.builder.String())
}
}
func (l *websocketTimelineLog) String() string {
if l == nil || !l.enabled {
return ""
}
l.closeCurrentPart()
if l.source != nil {
data, errRead := l.source.Bytes()
if errRead != nil {
return ""
}
return string(data)
}
if l.builder == nil {
return ""
}
return l.builder.String()
}
func (l *websocketTimelineLog) closeCurrentPart() {
if l == nil || l.currentPart == nil {
return
}
if errClose := l.currentPart.Close(); errClose != nil {
log.WithError(errClose).Warn("failed to close websocket request detail log")
}
l.currentPart = nil
l.currentPartHasLog = false
}
func writeWebsocketTimelinePart(w io.Writer, data []byte, prependNewline bool) error {
if w == nil || len(data) == 0 {
return nil
}
if prependNewline {
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
return errWrite
}
}
_, errWrite := w.Write(data)
return errWrite
}
func writeWebsocketTimelineBuilder(builder *strings.Builder, data []byte) {
if builder == nil || len(data) == 0 {
return
}
if builder.Len() > 0 {
builder.WriteString("\n")
}
builder.Write(data)
}
func appendWebsocketEvent(builder *strings.Builder, eventType string, payload []byte) {
if builder == nil {
return
}
trimmedPayload := bytes.TrimSpace(payload)
if len(trimmedPayload) == 0 {
return
}
if builder.Len() > 0 {
builder.WriteString("\n")
}
builder.WriteString("websocket.")
builder.WriteString(eventType)
builder.WriteString("\n")
builder.Write(trimmedPayload)
builder.WriteString("\n")
}
func websocketPayloadEventType(payload []byte) string {
eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String())
if eventType == "" {
return "-"
}
return eventType
}
func websocketPayloadPreview(payload []byte) string {
trimmedPayload := bytes.TrimSpace(payload)
if len(trimmedPayload) == 0 {
return "<empty>"
}
previewText := strings.ReplaceAll(string(trimmedPayload), "\n", "\\n")
previewText = strings.ReplaceAll(previewText, "\r", "\\r")
return previewText
}
func isResponsesWebsocketCompletionEvent(eventType string) bool {
return eventType == wsEventTypeCompleted || eventType == wsEventTypeDone
}
type responsesWebsocketPayloadError struct {
status int
payload []byte
}
func (e *responsesWebsocketPayloadError) Error() string {
if e == nil {
return ""
}
return string(e.payload)
}
func (e *responsesWebsocketPayloadError) StatusCode() int {
if e == nil {
return 0
}
return e.status
}
func responsesWebsocketErrorMessageFromPayload(payload []byte) *interfaces.ErrorMessage {
status := int(gjson.GetBytes(payload, "status").Int())
if status <= 0 {
status = int(gjson.GetBytes(payload, "status_code").Int())
}
if status <= 0 {
status = http.StatusInternalServerError
}
trimmedPayload := bytes.TrimSpace(payload)
if len(trimmedPayload) > 0 {
return &interfaces.ErrorMessage{
StatusCode: status,
Error: &responsesWebsocketPayloadError{
status: status,
payload: bytes.Clone(trimmedPayload),
},
}
}
return &interfaces.ErrorMessage{StatusCode: status, Error: fmt.Errorf("%s", http.StatusText(status))}
}
func setWebsocketTimelineBody(c *gin.Context, body string) {
setWebsocketBody(c, wsTimelineBodyKey, body)
}
func setWebsocketBody(c *gin.Context, key string, body string) {
if c == nil {
return
}
trimmedBody := strings.TrimSpace(body)
if trimmedBody == "" {
return
}
c.Set(key, []byte(trimmedBody))
}
func writeResponsesWebsocketPayload(writer *responsesWebsocketWriter, wsTimelineLog websocketTimelineAppender, payload []byte, timestamp time.Time) error {
if wsTimelineLog != nil {
wsTimelineLog.Append("response", payload, timestamp)
}
if writer == nil || writer.conn == nil {
return fmt.Errorf("responses websocket: writer is nil")
}
writer.writeMu.Lock()
defer writer.writeMu.Unlock()
if writer.closing.Load() {
return websocket.ErrCloseSent
}
return writer.conn.WriteMessage(websocket.TextMessage, payload)
}
func appendWebsocketTimelineDisconnect(timeline websocketTimelineAppender, err error, timestamp time.Time) {
if err == nil {
return
}
if timeline != nil {
timeline.Append("disconnect", []byte(err.Error()), timestamp)
}
}
func appendWebsocketTimelineEvent(builder *strings.Builder, eventType string, payload []byte, timestamp time.Time) {
if builder == nil {
return
}
writeWebsocketTimelineBuilder(builder, formatWebsocketTimelineEvent(eventType, payload, timestamp))
}
func formatWebsocketTimelineEvent(eventType string, payload []byte, timestamp time.Time) []byte {
trimmedPayload := bytes.TrimSpace(payload)
if len(trimmedPayload) == 0 {
return nil
}
var builder strings.Builder
builder.WriteString("Timestamp: ")
builder.WriteString(timestamp.Format(time.RFC3339Nano))
builder.WriteString("\n")
builder.WriteString("Event: websocket.")
builder.WriteString(eventType)
builder.WriteString("\n")
builder.Write(trimmedPayload)
builder.WriteString("\n")
return []byte(builder.String())
}
func markAPIResponseTimestamp(c *gin.Context) {
if c == nil {
return
}
if _, exists := c.Get("API_RESPONSE_TIMESTAMP"); exists {
return
}
c.Set("API_RESPONSE_TIMESTAMP", time.Now())
}

View file

@ -0,0 +1,675 @@
package openai
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
)
const (
websocketToolOutputCacheMaxPerSession = 256
websocketToolOutputCacheTTL = 30 * time.Minute
)
var defaultWebsocketToolOutputCache = newWebsocketToolOutputCache(0, websocketToolOutputCacheMaxPerSession)
var defaultWebsocketToolCallCache = newWebsocketToolOutputCache(0, websocketToolOutputCacheMaxPerSession)
var defaultWebsocketToolSessionRefs = newWebsocketToolSessionRefCounter()
var defaultWebsocketToolCacheTransactionMu sync.RWMutex
type websocketToolOutputCache struct {
mu sync.Mutex
ttl time.Duration
maxPerSession int
sessions map[string]*websocketToolOutputSession
}
type websocketToolOutputSession struct {
lastSeen time.Time
outputs map[string]json.RawMessage
order []string
}
type responsesWebsocketToolCacheTurn struct {
sessionKey string
outputs map[string]json.RawMessage
outputOrder []string
calls map[string]json.RawMessage
callOrder []string
}
func newWebsocketToolOutputCache(ttl time.Duration, maxPerSession int) *websocketToolOutputCache {
if ttl < 0 {
ttl = websocketToolOutputCacheTTL
}
if maxPerSession <= 0 {
maxPerSession = websocketToolOutputCacheMaxPerSession
}
return &websocketToolOutputCache{
ttl: ttl,
maxPerSession: maxPerSession,
sessions: make(map[string]*websocketToolOutputSession),
}
}
func (c *websocketToolOutputCache) record(sessionKey string, callID string, item json.RawMessage) {
sessionKey = strings.TrimSpace(sessionKey)
callID = strings.Clone(strings.TrimSpace(callID))
if sessionKey == "" || callID == "" || c == nil {
return
}
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
c.cleanupLocked(now)
session, ok := c.sessions[sessionKey]
if !ok || session == nil {
session = &websocketToolOutputSession{
lastSeen: now,
outputs: make(map[string]json.RawMessage),
}
c.sessions[sessionKey] = session
}
session.lastSeen = now
if _, exists := session.outputs[callID]; !exists {
session.order = append(session.order, callID)
}
session.outputs[callID] = append(json.RawMessage(nil), item...)
for len(session.order) > c.maxPerSession {
evict := session.order[0]
session.order[0] = ""
session.order = session.order[1:]
delete(session.outputs, evict)
}
}
func (c *websocketToolOutputCache) get(sessionKey string, callID string) (json.RawMessage, bool) {
sessionKey = strings.TrimSpace(sessionKey)
callID = strings.TrimSpace(callID)
if sessionKey == "" || callID == "" || c == nil {
return nil, false
}
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
c.cleanupLocked(now)
session, ok := c.sessions[sessionKey]
if !ok || session == nil {
return nil, false
}
session.lastSeen = now
item, ok := session.outputs[callID]
if !ok || len(item) == 0 {
return nil, false
}
return append(json.RawMessage(nil), item...), true
}
func (c *websocketToolOutputCache) cleanupLocked(now time.Time) {
if c == nil || c.ttl <= 0 {
return
}
for key, session := range c.sessions {
if session == nil {
delete(c.sessions, key)
continue
}
if now.Sub(session.lastSeen) > c.ttl {
delete(c.sessions, key)
}
}
}
func (c *websocketToolOutputCache) deleteSession(sessionKey string) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" || c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.sessions, sessionKey)
}
func websocketDownstreamSessionKey(req *http.Request) string {
if req == nil {
return ""
}
if requestID := strings.TrimSpace(req.Header.Get("X-Client-Request-Id")); requestID != "" {
return requestID
}
if raw := strings.TrimSpace(req.Header.Get("X-Codex-Turn-Metadata")); raw != "" {
if sessionID := strings.TrimSpace(gjson.Get(raw, "session_id").String()); sessionID != "" {
return sessionID
}
}
if sessionID := strings.TrimSpace(req.Header.Get("Session-Id")); sessionID != "" {
return sessionID
}
if sessionID := strings.TrimSpace(req.Header.Get("Session_id")); sessionID != "" {
return sessionID
}
return ""
}
type websocketToolSessionRefCounter struct {
mu sync.Mutex
counts map[string]int
}
func newWebsocketToolSessionRefCounter() *websocketToolSessionRefCounter {
return &websocketToolSessionRefCounter{counts: make(map[string]int)}
}
func (c *websocketToolSessionRefCounter) acquire(sessionKey string) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" || c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.counts[sessionKey]++
}
func (c *websocketToolSessionRefCounter) release(sessionKey string) bool {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" || c == nil {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
count := c.counts[sessionKey]
if count <= 1 {
delete(c.counts, sessionKey)
return true
}
c.counts[sessionKey] = count - 1
return false
}
func retainResponsesWebsocketToolCaches(sessionKey string) {
defaultWebsocketToolCacheTransactionMu.Lock()
defer defaultWebsocketToolCacheTransactionMu.Unlock()
if defaultWebsocketToolSessionRefs == nil {
return
}
defaultWebsocketToolSessionRefs.acquire(sessionKey)
}
func releaseResponsesWebsocketToolCaches(sessionKey string) {
defaultWebsocketToolCacheTransactionMu.Lock()
defer defaultWebsocketToolCacheTransactionMu.Unlock()
if defaultWebsocketToolSessionRefs == nil {
return
}
if !defaultWebsocketToolSessionRefs.release(sessionKey) {
return
}
if defaultWebsocketToolOutputCache != nil {
defaultWebsocketToolOutputCache.deleteSession(sessionKey)
}
if defaultWebsocketToolCallCache != nil {
defaultWebsocketToolCallCache.deleteSession(sessionKey)
}
}
func newResponsesWebsocketToolCacheTurn(sessionKey string) *responsesWebsocketToolCacheTurn {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return nil
}
return &responsesWebsocketToolCacheTurn{
sessionKey: sessionKey,
outputs: make(map[string]json.RawMessage),
calls: make(map[string]json.RawMessage),
}
}
func (t *responsesWebsocketToolCacheTurn) recordResponse(payload []byte) {
if t == nil || len(payload) == 0 {
return
}
switch strings.TrimSpace(util.GetGJSONBytesNoCopy(payload, "type").String()) {
case "response.completed":
output := util.GetGJSONBytesNoCopy(payload, "response.output")
if !output.Exists() || !output.IsArray() {
return
}
output.ForEach(func(_, item gjson.Result) bool {
if isCompleteResponsesWebsocketToolCall(item) {
t.recordItem(payload, item)
}
return true
})
case "response.output_item.added", "response.output_item.done":
item := util.GetGJSONBytesNoCopy(payload, "item")
if isCompleteResponsesWebsocketToolCall(item) {
t.recordItem(payload, item)
}
}
}
func (t *responsesWebsocketToolCacheTurn) recordItem(payload []byte, item gjson.Result) {
if t == nil || !item.Exists() {
return
}
rawItem, ok := responsesWebsocketRawMessageForResult(payload, item)
if !ok {
return
}
t.recordRawItem(item.Get("type").String(), item.Get("call_id").String(), rawItem)
}
func (t *responsesWebsocketToolCacheTurn) recordInputItem(item responsesWebsocketInputItem) {
if t == nil {
return
}
t.recordRawItem(item.itemType, item.callID, item.raw)
}
func (t *responsesWebsocketToolCacheTurn) recordRawItem(itemType string, callID string, rawItem []byte) {
if t == nil || (!isResponsesToolCallOutputType(itemType) && !isResponsesToolCallType(itemType)) {
return
}
callID = strings.Clone(strings.TrimSpace(callID))
if callID == "" || len(bytes.TrimSpace(rawItem)) == 0 {
return
}
raw := append(json.RawMessage(nil), rawItem...)
if isResponsesToolCallOutputType(itemType) {
if _, exists := t.outputs[callID]; !exists {
t.outputOrder = append(t.outputOrder, callID)
}
t.outputs[callID] = raw
return
}
if _, exists := t.calls[callID]; !exists {
t.callOrder = append(t.callOrder, callID)
}
t.calls[callID] = raw
}
func (t *responsesWebsocketToolCacheTurn) commit() {
if t == nil || t.sessionKey == "" {
return
}
defaultWebsocketToolCacheTransactionMu.Lock()
defer defaultWebsocketToolCacheTransactionMu.Unlock()
if defaultWebsocketToolOutputCache != nil {
for _, callID := range t.outputOrder {
defaultWebsocketToolOutputCache.record(t.sessionKey, callID, t.outputs[callID])
}
}
if defaultWebsocketToolCallCache != nil {
for _, callID := range t.callOrder {
defaultWebsocketToolCallCache.record(t.sessionKey, callID, t.calls[callID])
}
}
}
func repairResponsesWebsocketToolCalls(sessionKey string, payload []byte) []byte {
return repairResponsesWebsocketToolCallsWithCaches(defaultWebsocketToolOutputCache, defaultWebsocketToolCallCache, sessionKey, payload)
}
func repairResponsesWebsocketToolCallsWithoutRecording(sessionKey string, payload []byte) []byte {
defaultWebsocketToolCacheTransactionMu.RLock()
defer defaultWebsocketToolCacheTransactionMu.RUnlock()
return repairResponsesWebsocketToolCallsWithCachesMode(defaultWebsocketToolOutputCache, defaultWebsocketToolCallCache, sessionKey, payload, false, nil)
}
func prepareResponsesWebsocketFallbackTurn(sessionKey string, payload []byte) ([]byte, *responsesWebsocketToolCacheTurn) {
turn := newResponsesWebsocketToolCacheTurn(sessionKey)
defaultWebsocketToolCacheTransactionMu.RLock()
defer defaultWebsocketToolCacheTransactionMu.RUnlock()
payload = repairResponsesWebsocketToolCallsWithCachesMode(
defaultWebsocketToolOutputCache,
defaultWebsocketToolCallCache,
sessionKey,
payload,
false,
turn,
)
return payload, turn
}
func repairResponsesWebsocketToolCallsWithCache(cache *websocketToolOutputCache, sessionKey string, payload []byte) []byte {
return repairResponsesWebsocketToolCallsWithCaches(cache, nil, sessionKey, payload)
}
func repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache *websocketToolOutputCache, sessionKey string, payload []byte) []byte {
return repairResponsesWebsocketToolCallsWithCachesMode(outputCache, callCache, sessionKey, payload, true, nil)
}
func repairResponsesWebsocketToolCallsWithCachesMode(
outputCache, callCache *websocketToolOutputCache,
sessionKey string,
payload []byte,
record bool,
turn *responsesWebsocketToolCacheTurn,
) []byte {
if len(payload) == 0 {
return payload
}
input, previousResponseID, ok := parseResponsesWebsocketRepairRequest(payload)
if !ok {
return payload
}
items, rawItems, ok := parseResponsesWebsocketInputItemsNoCopy(payload, input)
if !ok {
return payload
}
sessionKey = strings.TrimSpace(sessionKey)
repairEnabled := sessionKey != "" && outputCache != nil
updatedItems, errRepair := repairResponsesToolCallItems(
outputCache,
callCache,
sessionKey,
items,
repairEnabled && responsesWebsocketMetadataString(previousResponseID) != "",
record && repairEnabled,
turn,
repairEnabled,
)
if errRepair != nil || responsesWebsocketInputItemsEqualRaw(updatedItems, rawItems) {
return payload
}
updatedRaw, errMarshal := marshalResponsesWebsocketInputItems(updatedItems)
if errMarshal != nil {
return payload
}
updated, ok := replaceResponsesWebsocketRawResult(payload, input, []byte(updatedRaw))
if !ok {
return payload
}
return updated
}
func parseResponsesWebsocketRepairRequest(payload []byte) (gjson.Result, json.RawMessage, bool) {
if !json.Valid(payload) {
return gjson.Result{}, nil, false
}
root := util.ParseGJSONBytesNoCopy(payload)
if !root.IsObject() {
return gjson.Result{}, nil, false
}
var input gjson.Result
var previousResponseID json.RawMessage
inputFound := false
valid := true
root.ForEach(func(key, value gjson.Result) bool {
switch {
case strings.EqualFold(key.String(), "input"):
if !value.IsArray() && strings.TrimSpace(value.Raw) != "null" {
valid = false
return false
}
input = value
inputFound = true
case strings.EqualFold(key.String(), "previous_response_id"):
var ok bool
previousResponseID, ok = responsesWebsocketRawMessageForResult(payload, value)
if !ok {
valid = false
return false
}
}
return true
})
if !valid || !inputFound || !input.IsArray() {
return gjson.Result{}, nil, false
}
return input, previousResponseID, true
}
func replaceResponsesWebsocketRawResult(payload []byte, result gjson.Result, replacement []byte) ([]byte, bool) {
if result.Index < 0 || result.Index > len(payload) || len(result.Raw) > len(payload)-result.Index {
return nil, false
}
updated := make([]byte, 0, len(payload)-len(result.Raw)+len(replacement))
updated = append(updated, payload[:result.Index]...)
updated = append(updated, replacement...)
updated = append(updated, payload[result.Index+len(result.Raw):]...)
return updated, true
}
func parseResponsesWebsocketInputItemsNoCopy(payload []byte, input gjson.Result) ([]responsesWebsocketInputItem, []json.RawMessage, bool) {
var items []responsesWebsocketInputItem
var rawItems []json.RawMessage
valid := true
input.ForEach(func(_, itemResult gjson.Result) bool {
rawItem, ok := responsesWebsocketRawMessageForResult(payload, itemResult)
if !ok {
valid = false
return false
}
item, errItem := parseResponsesWebsocketInputItem(rawItem)
if errItem != nil {
valid = false
return false
}
items = append(items, item)
rawItems = append(rawItems, rawItem)
return true
})
if !valid {
return nil, nil, false
}
return items, rawItems, true
}
func responsesWebsocketRawMessageForResult(payload []byte, result gjson.Result) (json.RawMessage, bool) {
if result.Index < 0 || result.Index > len(payload) || len(result.Raw) > len(payload)-result.Index {
return nil, false
}
return payload[result.Index : result.Index+len(result.Raw)], true
}
func repairResponsesToolCallItems(
outputCache, callCache *websocketToolOutputCache,
sessionKey string,
items []responsesWebsocketInputItem,
allowOrphanOutputs bool,
record bool,
turn *responsesWebsocketToolCacheTurn,
repairEnabled bool,
) ([]responsesWebsocketInputItem, error) {
if !repairEnabled {
return dedupeResponsesWebsocketInputItems(items), nil
}
// First pass: record tool outputs and remember which call_ids have outputs in this payload.
outputPresent := make(map[string]struct{}, len(items))
callPresent := make(map[string]struct{}, len(items))
for _, item := range items {
if turn != nil {
turn.recordInputItem(item)
}
switch {
case isResponsesToolCallOutputType(item.itemType):
if item.callID == "" {
continue
}
outputPresent[item.callID] = struct{}{}
if record {
outputCache.record(sessionKey, item.callID, item.raw)
}
case isResponsesToolCallType(item.itemType):
if item.callID == "" {
continue
}
callPresent[item.callID] = struct{}{}
if record && callCache != nil {
callCache.record(sessionKey, item.callID, item.raw)
}
}
}
filtered := make([]responsesWebsocketInputItem, 0, len(items))
insertedCalls := make(map[string]struct{}, len(items))
for _, item := range items {
if isResponsesToolCallOutputType(item.itemType) {
if item.callID == "" {
// Upstream rejects tool outputs without a call_id; drop it.
continue
}
if _, ok := callPresent[item.callID]; ok {
filtered = append(filtered, item)
continue
}
if allowOrphanOutputs {
filtered = append(filtered, item)
continue
}
if callCache != nil {
if cached, ok := callCache.get(sessionKey, item.callID); ok {
if _, already := insertedCalls[item.callID]; !already {
cachedItem, errCached := parseResponsesWebsocketInputItem(cached)
if errCached != nil {
return nil, errCached
}
filtered = append(filtered, cachedItem)
insertedCalls[item.callID] = struct{}{}
callPresent[item.callID] = struct{}{}
}
filtered = append(filtered, item)
continue
}
}
// Drop orphaned function_call_output items; upstream rejects transcripts with missing calls.
continue
}
if !isResponsesToolCallType(item.itemType) {
filtered = append(filtered, item)
continue
}
if item.callID == "" {
// Upstream rejects tool calls without a call_id; drop it.
continue
}
if _, ok := outputPresent[item.callID]; ok {
filtered = append(filtered, item)
continue
}
if allowOrphanOutputs {
filtered = append(filtered, item)
continue
}
if cached, ok := outputCache.get(sessionKey, item.callID); ok {
cachedItem, errCached := parseResponsesWebsocketInputItem(cached)
if errCached != nil {
return nil, errCached
}
filtered = append(filtered, item, cachedItem)
outputPresent[item.callID] = struct{}{}
continue
}
// Drop orphaned function_call items; upstream rejects transcripts with missing outputs.
}
return dedupeResponsesWebsocketInputItems(filtered), nil
}
func responsesWebsocketInputItemsEqualRaw(items []responsesWebsocketInputItem, rawItems []json.RawMessage) bool {
if len(items) != len(rawItems) {
return false
}
for index := range items {
if !bytes.Equal(items[index].raw, rawItems[index]) {
return false
}
}
return true
}
func recordResponsesWebsocketToolCallsFromPayload(sessionKey string, payload []byte) {
recordResponsesWebsocketToolCallsFromPayloadWithCache(defaultWebsocketToolCallCache, sessionKey, payload)
}
func recordResponsesWebsocketToolCallsFromPayloadWithCache(cache *websocketToolOutputCache, sessionKey string, payload []byte) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" || cache == nil || len(payload) == 0 {
return
}
eventType := strings.TrimSpace(util.GetGJSONBytesNoCopy(payload, "type").String())
switch eventType {
case "response.completed":
output := util.GetGJSONBytesNoCopy(payload, "response.output")
if !output.Exists() || !output.IsArray() {
return
}
output.ForEach(func(_, item gjson.Result) bool {
if !isCompleteResponsesWebsocketToolCall(item) {
return true
}
rawItem, ok := responsesWebsocketRawMessageForResult(payload, item)
if !ok {
return false
}
callID := strings.TrimSpace(item.Get("call_id").String())
cache.record(sessionKey, callID, rawItem)
return true
})
case "response.output_item.added", "response.output_item.done":
item := util.GetGJSONBytesNoCopy(payload, "item")
if !isCompleteResponsesWebsocketToolCall(item) {
return
}
rawItem, ok := responsesWebsocketRawMessageForResult(payload, item)
if !ok {
return
}
callID := strings.TrimSpace(item.Get("call_id").String())
cache.record(sessionKey, callID, rawItem)
}
}
func isResponsesToolCallType(itemType string) bool {
switch strings.TrimSpace(itemType) {
case "function_call", "custom_tool_call":
return true
default:
return false
}
}
func isResponsesToolCallOutputType(itemType string) bool {
switch strings.TrimSpace(itemType) {
case "function_call_output", "custom_tool_call_output":
return true
default:
return false
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
//go:build !race
package openai
const raceDetectorEnabled = false

View file

@ -0,0 +1,5 @@
//go:build race
package openai
const raceDetectorEnabled = true