Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
22
backend/sdk/api/handlers/openai/codex_client_models.go
Normal file
22
backend/sdk/api/handlers/openai/codex_client_models.go
Normal 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)
|
||||
}
|
||||
59
backend/sdk/api/handlers/openai/codex_client_models_test.go
Normal file
59
backend/sdk/api/handlers/openai/codex_client_models_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
709
backend/sdk/api/handlers/openai/openai_handlers.go
Normal file
709
backend/sdk/api/handlers/openai/openai_handlers.go
Normal 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")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
2015
backend/sdk/api/handlers/openai/openai_images_handlers.go
Normal file
2015
backend/sdk/api/handlers/openai/openai_images_handlers.go
Normal file
File diff suppressed because it is too large
Load diff
500
backend/sdk/api/handlers/openai/openai_images_handlers_test.go
Normal file
500
backend/sdk/api/handlers/openai/openai_images_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
375
backend/sdk/api/handlers/openai/openai_responses_compact_test.go
Normal file
375
backend/sdk/api/handlers/openai/openai_responses_compact_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
973
backend/sdk/api/handlers/openai/openai_responses_handlers.go
Normal file
973
backend/sdk/api/handlers/openai/openai_responses_handlers.go
Normal 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"))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
704
backend/sdk/api/handlers/openai/openai_responses_websocket.go
Normal file
704
backend/sdk/api/handlers/openai/openai_responses_websocket.go
Normal 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"}}`,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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 "[]"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
5803
backend/sdk/api/handlers/openai/openai_responses_websocket_test.go
Normal file
5803
backend/sdk/api/handlers/openai/openai_responses_websocket_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
1052
backend/sdk/api/handlers/openai/openai_videos_handlers.go
Normal file
1052
backend/sdk/api/handlers/openai/openai_videos_handlers.go
Normal file
File diff suppressed because it is too large
Load diff
1020
backend/sdk/api/handlers/openai/openai_videos_handlers_test.go
Normal file
1020
backend/sdk/api/handlers/openai/openai_videos_handlers_test.go
Normal file
File diff suppressed because it is too large
Load diff
5
backend/sdk/api/handlers/openai/race_disabled_test.go
Normal file
5
backend/sdk/api/handlers/openai/race_disabled_test.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build !race
|
||||
|
||||
package openai
|
||||
|
||||
const raceDetectorEnabled = false
|
||||
5
backend/sdk/api/handlers/openai/race_enabled_test.go
Normal file
5
backend/sdk/api/handlers/openai/race_enabled_test.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build race
|
||||
|
||||
package openai
|
||||
|
||||
const raceDetectorEnabled = true
|
||||
Loading…
Reference in a new issue