feat(runtime): add support for gpt-image-1.5 and direct image API proxying

- Introduced the `gpt-image-2` model in Codex built-ins and updated visibility logic in the registry.
- Added direct proxy support for OpenAI image generation and editing endpoints.
- Implemented new execution paths for `/images/generations` and `/images/edit`, ensuring seamless handling for both JSON and multipart payloads.
- Expanded test coverage to validate the new model and direct proxy features, including streaming scenarios and error handling.
This commit is contained in:
Luis Pater
2026-06-19 00:06:12 +08:00
parent 41c52b9df6
commit ae6c5eaea5
9 changed files with 665 additions and 17 deletions
+1 -1
View File
@@ -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"
+3 -2
View File
@@ -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.
+14 -1
View File
@@ -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,
@@ -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)
@@ -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))
}
}
+10 -1
View File
@@ -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 {
@@ -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"
}
}
@@ -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
@@ -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)
}