diff --git a/config.example.yaml b/config.example.yaml index 22173ab6..551b4064 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -132,7 +132,7 @@ disable-claude-cloak-mode: false # - "passthrough": never inject or strip image_generation on non-images endpoints (forward the client payload unchanged); behaves like "chat" on /v1/images/* endpoints. disable-image-generation: false -# Base model used when proxying gpt-image-2 via the hosted image_generation tool (Responses API). +# Base model used by the legacy hosted image_generation tool path when a Codex image request is not proxied directly through the Image API. # Must start with "gpt-" (case-insensitive). If unset or invalid, defaults to "gpt-5.4-mini". # gpt-image-2-base-model: "gpt-5.4-mini" diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go index 54e269a0..5c0bc4ab 100644 --- a/internal/config/sdk_config.go +++ b/internal/config/sdk_config.go @@ -21,8 +21,9 @@ type SDKConfig struct { // sent it and do not inject it otherwise; on /v1/images/generations and /v1/images/edits behave like "chat". DisableImageGeneration DisableImageGenerationMode `yaml:"disable-image-generation" json:"disable-image-generation"` - // GPTImage2BaseModel sets the base (mainline) model used when proxying GPT Image 2 - // requests via the hosted image_generation tool (e.g. Codex OAuth /v1/images/*). + // GPTImage2BaseModel sets the base (mainline) model used by the legacy hosted + // image_generation tool path when a Codex image request is not proxied directly + // through the Image API. // // The value must start with "gpt-" (case-insensitive). If empty or invalid, the // default base model ("gpt-5.4-mini") is used. diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 320ffc54..3f7263e4 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -7,6 +7,7 @@ import ( ) const ( + codexBuiltinImage15ModelID = "gpt-image-1.5" codexBuiltinImageModelID = "gpt-image-2" xaiBuiltinImageModelID = "grok-imagine-image" xaiBuiltinImageQualityModelID = "grok-imagine-image-quality" @@ -119,7 +120,7 @@ func GetXAIModels() []*ModelInfo { // not depend on remote models.json updates. Built-ins replace any matching IDs // already present in the provided slice. func WithCodexBuiltins(models []*ModelInfo) []*ModelInfo { - return upsertModelInfos(models, codexBuiltinImageModelInfo()) + return upsertModelInfos(models, codexBuiltinImage15ModelInfo(), codexBuiltinImageModelInfo()) } // WithXAIBuiltins injects hard-coded xAI image/video model definitions that should @@ -136,6 +137,18 @@ func normalizeAntigravityCapabilityModelID(modelID string) string { return modelID } +func codexBuiltinImage15ModelInfo() *ModelInfo { + return &ModelInfo{ + ID: codexBuiltinImage15ModelID, + Object: "model", + Created: 1704067200, // 2024-01-01 + OwnedBy: "openai", + Type: "openai", + DisplayName: "GPT Image 1.5", + Version: codexBuiltinImage15ModelID, + } +} + func codexBuiltinImageModelInfo() *ModelInfo { return &ModelInfo{ ID: codexBuiltinImageModelID, diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go index 4ce3541e..114c5251 100644 --- a/internal/runtime/executor/codex_openai_images.go +++ b/internal/runtime/executor/codex_openai_images.go @@ -31,6 +31,9 @@ const ( codexOpenAIImageSourceFormat = "openai-image" codexImagesGenerationsPath = "/v1/images/generations" codexImagesEditsPath = "/v1/images/edits" + codexDirectImagesGenerations = "/images/generations" + codexDirectImagesEdit = "/images/edits" + codexGPTImage15Model = "gpt-image-1.5" codexOpenAIImagesMainModel = "gpt-5.4-mini" ) @@ -79,6 +82,10 @@ func (e *CodexExecutor) resolveGPTImage2BaseModel() string { } func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + if directEndpoint := codexDirectOpenAIImageEndpoint(req, opts); directEndpoint != "" { + return e.executeDirectOpenAIImage(ctx, auth, req, opts, directEndpoint) + } + prepared, errPrepare := codexPrepareOpenAIImageRequest(req, opts) if errPrepare != nil { return resp, errPrepare @@ -171,6 +178,10 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau } func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { + if directEndpoint := codexDirectOpenAIImageEndpoint(req, opts); directEndpoint != "" { + return e.executeDirectOpenAIImageStream(ctx, auth, req, opts, directEndpoint) + } + prepared, errPrepare := codexPrepareOpenAIImageRequest(req, opts) if errPrepare != nil { return nil, errPrepare @@ -302,6 +313,334 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil } +func (e *CodexExecutor) executeDirectOpenAIImage(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (resp cliproxyexecutor.Response, err error) { + body, contentType, model, errPrepare := codexPrepareDirectOpenAIImageBody(req, opts, false) + if errPrepare != nil { + return resp, errPrepare + } + + apiKey, baseURL := codexCreds(auth) + if baseURL == "" { + baseURL = "https://chatgpt.com/backend-api/codex" + } + + reporter := helps.NewExecutorUsageReporter(ctx, e, model, auth) + defer reporter.TrackFailure(ctx, &err) + reporter.SetTranslatedReasoningEffort(body, "openai") + + url := strings.TrimSuffix(baseURL, "/") + endpointPath + var identityState codexIdentityConfuseState + httpReq, body, identityState, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, auth, req, req.Payload, body) + if errCache != nil { + return resp, errCache + } + applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg) + if contentType != "" { + httpReq.Header.Set("Content-Type", contentType) + } + applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState) + recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body) + + httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient = reporter.TrackHTTPClient(httpClient) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errDo) + return resp, errDo + } + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("codex executor: close response body error: %v", errClose) + } + }() + + helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + data, errRead := io.ReadAll(httpResp.Body) + if errRead != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errRead) + return resp, errRead + } + data = applyCodexIdentityConfuseResponsePayload(data, identityState) + helps.AppendAPIResponseChunk(ctx, e.cfg, data) + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) + err = newCodexStatusErr(httpResp.StatusCode, data) + return resp, err + } + + reporter.Publish(ctx, helps.ParseOpenAIUsage(data)) + reporter.EnsurePublished(ctx) + return cliproxyexecutor.Response{Payload: data, Headers: httpResp.Header.Clone()}, nil +} + +func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (_ *cliproxyexecutor.StreamResult, err error) { + body, contentType, model, errPrepare := codexPrepareDirectOpenAIImageBody(req, opts, true) + if errPrepare != nil { + return nil, errPrepare + } + + apiKey, baseURL := codexCreds(auth) + if baseURL == "" { + baseURL = "https://chatgpt.com/backend-api/codex" + } + + reporter := helps.NewExecutorUsageReporter(ctx, e, model, auth) + defer reporter.TrackFailure(ctx, &err) + reporter.SetTranslatedReasoningEffort(body, "openai") + + url := strings.TrimSuffix(baseURL, "/") + endpointPath + var identityState codexIdentityConfuseState + httpReq, body, identityState, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, auth, req, req.Payload, body) + if errCache != nil { + return nil, errCache + } + applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg) + if contentType != "" { + httpReq.Header.Set("Content-Type", contentType) + } + applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState) + recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body) + + httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient = reporter.TrackHTTPClient(httpClient) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errDo) + return nil, errDo + } + helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + data, errRead := io.ReadAll(httpResp.Body) + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("codex executor: close response body error: %v", errClose) + } + if errRead != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errRead) + return nil, errRead + } + data = applyCodexIdentityConfuseResponsePayload(data, identityState) + helps.AppendAPIResponseChunk(ctx, e.cfg, data) + helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) + err = newCodexStatusErr(httpResp.StatusCode, data) + return nil, err + } + + out := make(chan cliproxyexecutor.StreamChunk) + go func() { + defer close(out) + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("codex executor: close response body error: %v", errClose) + } + reporter.EnsurePublished(ctx) + }() + + buffer := make([]byte, 32*1024) + for { + n, errRead := httpResp.Body.Read(buffer) + if n > 0 { + chunk := bytes.Clone(buffer[:n]) + chunk = applyCodexIdentityConfuseResponsePayload(chunk, identityState) + helps.AppendAPIResponseChunk(ctx, e.cfg, chunk) + for _, line := range bytes.Split(chunk, []byte("\n")) { + if detail, ok := helps.ParseOpenAIStreamUsage(bytes.TrimSpace(line)); ok { + reporter.Publish(ctx, detail) + } + } + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunk}: + case <-ctx.Done(): + return + } + } + if errRead != nil { + if errRead != io.EOF { + helps.RecordAPIResponseError(ctx, e.cfg, errRead) + reporter.PublishFailure(ctx, errRead) + select { + case out <- cliproxyexecutor.StreamChunk{Err: errRead}: + case <-ctx.Done(): + } + } + return + } + } + }() + return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil +} + +func codexDirectOpenAIImageEndpoint(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string { + if codexDirectOpenAIImageModel(req) == "" { + return "" + } + path := helps.PayloadRequestPath(opts) + if strings.HasSuffix(strings.TrimSpace(path), codexImagesGenerationsPath) { + return codexDirectImagesGenerations + } + if strings.HasSuffix(strings.TrimSpace(path), codexImagesEditsPath) { + return codexDirectImagesEdit + } + return "" +} + +func codexPrepareDirectOpenAIImageBody(req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) ([]byte, string, string, error) { + model := codexDirectOpenAIImageModel(req) + if model == "" { + return nil, "", "", fmt.Errorf("unsupported direct OpenAI image model %q", req.Model) + } + body, contentType, errPrepare := codexPrepareDirectOpenAIImagePayload(req, opts, model, stream) + if errPrepare != nil { + return nil, "", "", errPrepare + } + return body, contentType, model, nil +} + +func codexPrepareDirectOpenAIImagePayload(req cliproxyexecutor.Request, opts cliproxyexecutor.Options, model string, stream bool) ([]byte, string, error) { + contentType := opts.Headers.Get("Content-Type") + path := strings.TrimSpace(helps.PayloadRequestPath(opts)) + if strings.HasSuffix(path, codexImagesEditsPath) { + return codexPrepareDirectOpenAIImageEditPayload(req.Payload, model, contentType, stream) + } + return prepareOpenAICompatImagesPayload(req.Payload, model, contentType, stream) +} + +func codexPrepareDirectOpenAIImageEditPayload(payload []byte, model string, contentType string, stream bool) ([]byte, string, error) { + if json.Valid(payload) { + return prepareOpenAICompatImagesPayload(payload, model, contentType, stream) + } + + mediaType, params, errParse := mime.ParseMediaType(strings.TrimSpace(contentType)) + if errParse != nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(mediaType)), "multipart/") { + return nil, "", fmt.Errorf("unsupported OpenAI image edit Content-Type %q", contentType) + } + boundary := strings.TrimSpace(params["boundary"]) + if boundary == "" { + return nil, "", fmt.Errorf("multipart boundary is missing") + } + return codexRewriteOpenAIImageEditMultipartToJSON(payload, model, boundary, stream) +} + +func codexRewriteOpenAIImageEditMultipartToJSON(payload []byte, model string, boundary string, stream bool) ([]byte, string, error) { + reader := multipart.NewReader(bytes.NewReader(payload), boundary) + form, errRead := reader.ReadForm(openAICompatMultipartMemory) + if errRead != nil { + return nil, "", fmt.Errorf("read multipart form failed: %w", errRead) + } + defer func() { + if errRemove := form.RemoveAll(); errRemove != nil { + log.Errorf("codex openai images: remove multipart form files error: %v", errRemove) + } + }() + + out := []byte(`{}`) + out, _ = sjson.SetBytes(out, "model", model) + if stream { + out, _ = sjson.SetBytes(out, "stream", true) + } + + for key, values := range form.Value { + key = strings.TrimSpace(key) + if key == "" || key == "model" || key == "stream" { + continue + } + out = codexSetOpenAIImageEditFormValues(out, key, values) + } + + for _, fileHeader := range codexMultipartImageFiles(form) { + dataURL, errData := codexMultipartFileToDataURL(fileHeader) + if errData != nil { + return nil, "", errData + } + out, _ = sjson.SetBytes(out, "images.-1.image_url", dataURL) + } + if maskFiles := form.File["mask"]; len(maskFiles) > 0 && maskFiles[0] != nil { + dataURL, errData := codexMultipartFileToDataURL(maskFiles[0]) + if errData != nil { + return nil, "", errData + } + out, _ = sjson.SetBytes(out, "mask.image_url", dataURL) + } + + return out, "application/json", nil +} + +func codexSetOpenAIImageEditFormValues(out []byte, key string, values []string) []byte { + if len(values) == 0 { + return out + } + path := codexOpenAIImageEditFormJSONPath(key) + if path == "" { + return out + } + if len(values) == 1 { + return codexSetOpenAIImageEditFormValue(out, path, values[0]) + } + out, _ = sjson.SetRawBytes(out, path, []byte(`[]`)) + for _, value := range values { + item := codexOpenAIImageEditFormJSONValue(key, value) + out, _ = sjson.SetRawBytes(out, path+".-1", item) + } + return out +} + +func codexSetOpenAIImageEditFormValue(out []byte, path string, value string) []byte { + item := codexOpenAIImageEditFormJSONValue(path, value) + out, _ = sjson.SetRawBytes(out, path, item) + return out +} + +func codexOpenAIImageEditFormJSONValue(key string, value string) []byte { + value = strings.TrimSpace(value) + switch strings.ToLower(strings.TrimSpace(key)) { + case "n", "output_compression", "partial_images": + if parsed, errParse := strconv.ParseInt(value, 10, 64); errParse == nil { + raw, _ := json.Marshal(parsed) + return raw + } + } + raw, _ := json.Marshal(value) + return raw +} + +func codexOpenAIImageEditFormJSONPath(key string) string { + key = strings.TrimSpace(key) + switch key { + case "mask[file_id]": + return "mask.file_id" + case "mask[image_url]": + return "mask.image_url" + default: + return key + } +} + +func codexDirectOpenAIImageModel(req cliproxyexecutor.Request) string { + for _, model := range []string{gjson.GetBytes(req.Payload, "model").String(), req.Model} { + baseModel := codexOpenAIImageBaseModel(model) + if codexIsDirectOpenAIImageModel(baseModel) { + return baseModel + } + } + return "" +} + +func codexOpenAIImageBaseModel(model string) string { + model = strings.TrimSpace(thinking.ParseSuffix(model).ModelName) + if idx := strings.LastIndex(model, "/"); idx >= 0 && idx < len(model)-1 { + model = strings.TrimSpace(model[idx+1:]) + } + return strings.ToLower(strings.TrimSpace(model)) +} + +func codexIsDirectOpenAIImageModel(model string) bool { + switch strings.ToLower(strings.TrimSpace(model)) { + case codexGPTImage15Model, codexDefaultImageToolModel: + return true + default: + return false + } +} + func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, mainModel string) ([]byte, error) { out := body mainModel = strings.TrimSpace(mainModel) diff --git a/internal/runtime/executor/codex_openai_images_test.go b/internal/runtime/executor/codex_openai_images_test.go new file mode 100644 index 00000000..0d27ec96 --- /dev/null +++ b/internal/runtime/executor/codex_openai_images_test.go @@ -0,0 +1,285 @@ +package executor + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func newCodexOpenAIImageTestAuth(serverURL string) *cliproxyauth.Auth { + return &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{ + "base_url": serverURL, + "api_key": "codex-token", + }, + } +} + +func codexOpenAIImageTestOptions(path string, stream bool) cliproxyexecutor.Options { + return cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString(codexOpenAIImageSourceFormat), + Stream: stream, + Metadata: map[string]any{ + cliproxyexecutor.RequestPathMetadataKey: path, + }, + } +} + +func TestCodexExecutorDirectOpenAIImageGenerationUsesImagesEndpoint(t *testing.T) { + var gotPath string + var gotAuth string + var gotAccept string + var gotBody []byte + upstreamBody := []byte(`{"created":1713833628,"data":[{"b64_json":"AA=="}],"usage":{"total_tokens":100,"input_tokens":50,"output_tokens":50}}`) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotAccept = r.Header.Get("Accept") + var errRead error + gotBody, errRead = io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read body: %v", errRead) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(upstreamBody) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + resp, errExecute := executor.Execute(context.Background(), newCodexOpenAIImageTestAuth(server.URL), cliproxyexecutor.Request{ + Model: "codex/gpt-image-1.5", + Payload: []byte(`{"model":"codex/gpt-image-1.5","prompt":"A cute baby sea otter","n":1,"size":"1024x1024","quality":"high","background":"opaque","output_format":"jpeg","output_compression":70,"moderation":"low","extra":{"preserve":true},"stream":false}`), + }, codexOpenAIImageTestOptions(codexImagesGenerationsPath, false)) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + + if gotPath != "/images/generations" { + t.Fatalf("path = %q, want /images/generations", gotPath) + } + if gotAuth != "Bearer codex-token" { + t.Fatalf("Authorization = %q, want Bearer codex-token", gotAuth) + } + if gotAccept != "application/json" { + t.Fatalf("Accept = %q, want application/json", gotAccept) + } + if got := gjson.GetBytes(gotBody, "model").String(); got != "gpt-image-1.5" { + t.Fatalf("model = %q, want gpt-image-1.5; body=%s", got, string(gotBody)) + } + if got := gjson.GetBytes(gotBody, "extra.preserve").Bool(); !got { + t.Fatalf("extra.preserve missing from body: %s", string(gotBody)) + } + if got := gjson.GetBytes(gotBody, "output_compression").Int(); got != 70 { + t.Fatalf("output_compression = %d, want 70; body=%s", got, string(gotBody)) + } + if gjson.GetBytes(gotBody, "stream").Exists() { + t.Fatalf("stream should be removed for non-stream execution: %s", string(gotBody)) + } + if !bytes.Equal(resp.Payload, upstreamBody) { + t.Fatalf("payload = %s, want %s", string(resp.Payload), string(upstreamBody)) + } +} + +func TestCodexExecutorDirectOpenAIImageGenerationStreamsImagesEndpoint(t *testing.T) { + var gotPath string + var gotAccept string + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAccept = r.Header.Get("Accept") + var errRead error + gotBody, errRead = io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read body: %v", errRead) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: image_generation.partial_image\ndata: {\"type\":\"image_generation.partial_image\",\"b64_json\":\"AA==\",\"partial_image_index\":0}\n\n")) + _, _ = w.Write([]byte("event: image_generation.completed\ndata: {\"type\":\"image_generation.completed\",\"b64_json\":\"BB==\",\"usage\":{\"total_tokens\":10,\"input_tokens\":4,\"output_tokens\":6}}\n\n")) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + stream, errStream := executor.ExecuteStream(context.Background(), newCodexOpenAIImageTestAuth(server.URL), cliproxyexecutor.Request{ + Model: "gpt-image-2", + Payload: []byte(`{"model":"gpt-image-2","prompt":"A cute baby sea otter","partial_images":2}`), + }, codexOpenAIImageTestOptions(codexImagesGenerationsPath, true)) + if errStream != nil { + t.Fatalf("ExecuteStream() error = %v", errStream) + } + + var combined bytes.Buffer + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error = %v", chunk.Err) + } + combined.Write(chunk.Payload) + } + + if gotPath != "/images/generations" { + t.Fatalf("path = %q, want /images/generations", gotPath) + } + if gotAccept != "text/event-stream" { + t.Fatalf("Accept = %q, want text/event-stream", gotAccept) + } + if !gjson.GetBytes(gotBody, "stream").Bool() { + t.Fatalf("stream flag missing from upstream body: %s", string(gotBody)) + } + if got := gjson.GetBytes(gotBody, "partial_images").Int(); got != 2 { + t.Fatalf("partial_images = %d, want 2; body=%s", got, string(gotBody)) + } + out := combined.String() + if !strings.Contains(out, "event: image_generation.partial_image") || !strings.Contains(out, "event: image_generation.completed") { + t.Fatalf("stream output missing image events: %q", out) + } +} + +func TestCodexExecutorDirectOpenAIImageEditUsesImagesEditEndpointForJSON(t *testing.T) { + var gotPath string + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + var errRead error + gotBody, errRead = io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read body: %v", errRead) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"created":1713833628,"data":[{"b64_json":"AA=="}],"usage":{"total_tokens":10}}`)) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + _, errExecute := executor.Execute(context.Background(), newCodexOpenAIImageTestAuth(server.URL), cliproxyexecutor.Request{ + Model: "gpt-image-2", + Payload: []byte(`{"model":"gpt-image-2","prompt":"Replace the background","images":[{"file_id":"file-abc123"}],"mask":{"file_id":"file-mask123"},"size":"1024x1024","quality":"high","output_format":"png","output_compression":100,"stream":false}`), + }, codexOpenAIImageTestOptions(codexImagesEditsPath, false)) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + + if gotPath != "/images/edit" { + t.Fatalf("path = %q, want /images/edit", gotPath) + } + if got := gjson.GetBytes(gotBody, "model").String(); got != "gpt-image-2" { + t.Fatalf("model = %q, want gpt-image-2; body=%s", got, string(gotBody)) + } + if got := gjson.GetBytes(gotBody, "images.0.file_id").String(); got != "file-abc123" { + t.Fatalf("images.0.file_id = %q, want file-abc123; body=%s", got, string(gotBody)) + } + if got := gjson.GetBytes(gotBody, "mask.file_id").String(); got != "file-mask123" { + t.Fatalf("mask.file_id = %q, want file-mask123; body=%s", got, string(gotBody)) + } + if gjson.GetBytes(gotBody, "stream").Exists() { + t.Fatalf("stream should be removed for non-stream execution: %s", string(gotBody)) + } +} + +func TestCodexExecutorDirectOpenAIImageEditUsesImagesEditEndpointForMultipart(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if errWrite := writer.WriteField("model", "codex/gpt-image-1.5"); errWrite != nil { + t.Fatalf("write model field: %v", errWrite) + } + if errWrite := writer.WriteField("prompt", "Create a lovely gift basket"); errWrite != nil { + t.Fatalf("write prompt field: %v", errWrite) + } + if errWrite := writer.WriteField("output_format", "webp"); errWrite != nil { + t.Fatalf("write output_format field: %v", errWrite) + } + if errWrite := writer.WriteField("n", "2"); errWrite != nil { + t.Fatalf("write n field: %v", errWrite) + } + if errWrite := writer.WriteField("stream", "false"); errWrite != nil { + t.Fatalf("write stream field: %v", errWrite) + } + imagePart, errCreate := writer.CreateFormFile("image[]", "source.png") + if errCreate != nil { + t.Fatalf("create image field: %v", errCreate) + } + if _, errWrite := imagePart.Write([]byte("png-data")); errWrite != nil { + t.Fatalf("write image data: %v", errWrite) + } + maskPart, errCreateMask := writer.CreateFormFile("mask", "mask.png") + if errCreateMask != nil { + t.Fatalf("create mask field: %v", errCreateMask) + } + if _, errWrite := maskPart.Write([]byte("mask-data")); errWrite != nil { + t.Fatalf("write mask data: %v", errWrite) + } + if errClose := writer.Close(); errClose != nil { + t.Fatalf("close multipart writer: %v", errClose) + } + + var gotPath string + var gotContentType string + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotContentType = r.Header.Get("Content-Type") + var errRead error + gotBody, errRead = io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read body: %v", errRead) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"created":1713833628,"data":[{"b64_json":"AA=="}]}`)) + })) + defer server.Close() + + opts := codexOpenAIImageTestOptions(codexImagesEditsPath, false) + opts.Headers = http.Header{"Content-Type": []string{writer.FormDataContentType()}} + executor := NewCodexExecutor(&config.Config{}) + _, errExecute := executor.Execute(context.Background(), newCodexOpenAIImageTestAuth(server.URL), cliproxyexecutor.Request{ + Model: "codex/gpt-image-1.5", + Payload: body.Bytes(), + }, opts) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + + if gotPath != "/images/edit" { + t.Fatalf("path = %q, want /images/edit", gotPath) + } + if !strings.HasPrefix(gotContentType, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", gotContentType) + } + if !json.Valid(gotBody) { + t.Fatalf("body is not valid JSON: %s", string(gotBody)) + } + if got := gjson.GetBytes(gotBody, "model").String(); got != "gpt-image-1.5" { + t.Fatalf("model = %q, want gpt-image-1.5; body=%s", got, string(gotBody)) + } + if got := gjson.GetBytes(gotBody, "prompt").String(); got != "Create a lovely gift basket" { + t.Fatalf("prompt = %q", got) + } + if got := gjson.GetBytes(gotBody, "output_format").String(); got != "webp" { + t.Fatalf("output_format = %q, want webp; body=%s", got, string(gotBody)) + } + if got := gjson.GetBytes(gotBody, "n").Int(); got != 2 { + t.Fatalf("n = %d, want 2; body=%s", got, string(gotBody)) + } + if gjson.GetBytes(gotBody, "stream").Exists() { + t.Fatalf("stream should be removed for non-stream execution: %s", string(gotBody)) + } + imageURL := gjson.GetBytes(gotBody, "images.0.image_url").String() + if !strings.Contains(imageURL, ";base64,cG5nLWRhdGE=") { + t.Fatalf("images.0.image_url = %q, want png-data data URL; body=%s", imageURL, string(gotBody)) + } + maskURL := gjson.GetBytes(gotBody, "mask.image_url").String() + if !strings.Contains(maskURL, ";base64,bWFzay1kYXRh") { + t.Fatalf("mask.image_url = %q, want mask-data data URL; body=%s", maskURL, string(gotBody)) + } +} diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 5e29d886..74ef0d95 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -1508,7 +1508,7 @@ func (h *BaseAPIHandler) validateImageOnlyModel(modelName string, allowImageMode if baseModel == "" { baseModel = strings.TrimSpace(modelName) } - if strings.EqualFold(routeModelBaseName(baseModel), "gpt-image-2") && !allowImageModel { + if isOpenAIImageOnlyModel(baseModel) && !allowImageModel { return &interfaces.ErrorMessage{ StatusCode: http.StatusServiceUnavailable, Error: fmt.Errorf("model %s is only supported on /v1/images/generations and /v1/images/edits", routeModelBaseName(baseModel)), @@ -1517,6 +1517,15 @@ func (h *BaseAPIHandler) validateImageOnlyModel(modelName string, allowImageMode return nil } +func isOpenAIImageOnlyModel(model string) bool { + switch strings.ToLower(strings.TrimSpace(routeModelBaseName(model))) { + case "gpt-image-1.5", "gpt-image-2": + return true + default: + return false + } +} + func routeModelBaseName(model string) string { model = strings.TrimSpace(model) if idx := strings.LastIndex(model, "/"); idx >= 0 && idx < len(model)-1 { diff --git a/sdk/api/handlers/openai/codex_client_models.go b/sdk/api/handlers/openai/codex_client_models.go index cc894468..7d05db22 100644 --- a/sdk/api/handlers/openai/codex_client_models.go +++ b/sdk/api/handlers/openai/codex_client_models.go @@ -151,7 +151,7 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st func applyCodexClientVisibilityOverride(entry map[string]any, id string) { switch strings.TrimSpace(id) { - case "grok-imagine-image-quality", "gpt-image-2", "grok-imagine-image", "grok-imagine-video", "grok-imagine-video-1.5-preview": + case "grok-imagine-image-quality", "gpt-image-1.5", "gpt-image-2", "grok-imagine-image", "grok-imagine-video", "grok-imagine-video-1.5-preview": entry["visibility"] = "hide" } } diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 479dd3e6..ef5a700d 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -26,6 +26,7 @@ import ( const ( defaultImagesMainModel = "gpt-5.4-mini" + gptImage15Model = "gpt-image-1.5" defaultImagesToolModel = "gpt-image-2" defaultXAIImagesModel = "grok-imagine-image" xaiImagesQualityModel = "grok-imagine-image-quality" @@ -215,15 +216,15 @@ func isXAIImagesModel(model string) bool { } func isSupportedImagesModel(model string) bool { - baseModel := imagesModelBase(model) - if baseModel == defaultImagesToolModel { + if isCodexImagesToolModel(model) { return true } return isXAIImagesModel(model) || isOpenAICompatImagesModel(model) } -func isDefaultImagesToolModel(model string) bool { - return imagesModelBase(model) == defaultImagesToolModel +func isCodexImagesToolModel(model string) bool { + baseModel := imagesModelBase(model) + return baseModel == gptImage15Model || baseModel == defaultImagesToolModel } func isOpenAICompatImagesModel(model string) bool { @@ -242,7 +243,7 @@ func rejectUnsupportedImagesModel(c *gin.Context, model string) bool { c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ Error: handlers.ErrorDetail{ - Message: fmt.Sprintf("Model %s is not supported on %s or %s. Use %s, %s, %s, or a configured openai-compatibility image model.", model, imagesGenerationsPath, imagesEditsPath, defaultImagesToolModel, defaultXAIImagesModel, xaiImagesQualityModel), + Message: fmt.Sprintf("Model %s is not supported on %s or %s. Use %s, %s, %s, %s, or a configured openai-compatibility image model.", model, imagesGenerationsPath, imagesEditsPath, gptImage15Model, defaultImagesToolModel, defaultXAIImagesModel, xaiImagesQualityModel), Type: "invalid_request_error", }, }) @@ -627,7 +628,7 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) { } stream := gjson.GetBytes(rawJSON, "stream").Bool() - if isDefaultImagesToolModel(imageModel) { + if isCodexImagesToolModel(imageModel) { imageReq := buildOpenAICompatImagesJSONRequest(rawJSON, imageModel, stream) h.handleRoutedImages(c, imageReq, imageModel, stream) return @@ -772,7 +773,7 @@ func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) { } stream := parseBoolField(c.PostForm("stream"), false) - if isDefaultImagesToolModel(imageModel) { + if isCodexImagesToolModel(imageModel) { imageReq, contentType, errBuild := buildOpenAICompatImagesMultipartRequest(form, imageModel, stream) if errBuild != nil { c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ @@ -914,7 +915,7 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) { } stream := gjson.GetBytes(rawJSON, "stream").Bool() - if isDefaultImagesToolModel(imageModel) { + if isCodexImagesToolModel(imageModel) { imageReq := buildOpenAICompatImagesJSONRequest(rawJSON, imageModel, stream) h.handleRoutedImages(c, imageReq, imageModel, stream) return diff --git a/sdk/api/handlers/openai/openai_images_handlers_test.go b/sdk/api/handlers/openai/openai_images_handlers_test.go index f786a885..fb67d610 100644 --- a/sdk/api/handlers/openai/openai_images_handlers_test.go +++ b/sdk/api/handlers/openai/openai_images_handlers_test.go @@ -43,7 +43,7 @@ func assertUnsupportedImagesModelResponse(t *testing.T, resp *httptest.ResponseR } message := gjson.GetBytes(resp.Body.Bytes(), "error.message").String() - expectedMessage := "Model " + model + " is not supported on " + imagesGenerationsPath + " or " + imagesEditsPath + ". Use " + defaultImagesToolModel + ", " + defaultXAIImagesModel + ", " + xaiImagesQualityModel + ", or a configured openai-compatibility image model." + expectedMessage := "Model " + model + " is not supported on " + imagesGenerationsPath + " or " + imagesEditsPath + ". Use " + gptImage15Model + ", " + defaultImagesToolModel + ", " + defaultXAIImagesModel + ", " + xaiImagesQualityModel + ", or a configured openai-compatibility image model." if message != expectedMessage { t.Fatalf("error message = %q, want %q", message, expectedMessage) } @@ -52,8 +52,8 @@ func assertUnsupportedImagesModelResponse(t *testing.T, resp *httptest.ResponseR } } -func TestImagesModelValidationAllowsGPTImage2AndXAIModels(t *testing.T) { - for _, model := range []string{"gpt-image-2", "codex/gpt-image-2", "grok-imagine-image", "xai/grok-imagine-image", "grok-imagine-image-quality", "xai/grok-imagine-image-quality"} { +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"} { if !isSupportedImagesModel(model) { t.Fatalf("expected %s to be supported", model) }