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) } }