mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
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:
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user