From 1069348ca1df80ee46c618716659d7c524457d75 Mon Sep 17 00:00:00 2001 From: kokojacket <74887370+kokojacket@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:09:02 +0800 Subject: [PATCH 1/2] fix: proxy OpenAI image requests --- internal/proxy/proxy.go | 12 ++++++++ internal/proxy/request.go | 12 ++++++++ internal/proxy/request_test.go | 56 ++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 301d332..16f779c 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -320,8 +320,18 @@ const ( ClientFormatClaude ClientFormat = "claude" // Claude Code: /v1/messages ClientFormatOpenAIChat ClientFormat = "openai_chat" // Codex (chat): /v1/chat/completions ClientFormatOpenAIResponses ClientFormat = "openai_responses" // Codex (responses): /v1/responses + ClientFormatOpenAIImages ClientFormat = "openai_images" // Codex ImageGen: /v1/images/* ) +func isOpenAIImagesPath(requestPath string) bool { + switch strings.TrimSuffix(requestPath, "/") { + case "/v1/images/generations", "/images/generations", "/v1/images/edits", "/images/edits": + return true + default: + return false + } +} + // detectClientFormat identifies the client format based on request path func detectClientFormat(path string) ClientFormat { switch { @@ -329,6 +339,8 @@ func detectClientFormat(path string) ClientFormat { return ClientFormatOpenAIChat case strings.HasPrefix(path, "/v1/responses") || strings.HasPrefix(path, "/responses"): return ClientFormatOpenAIResponses + case isOpenAIImagesPath(path): + return ClientFormatOpenAIImages default: return ClientFormatClaude } diff --git a/internal/proxy/request.go b/internal/proxy/request.go index e63e5c3..3d6cfd1 100644 --- a/internal/proxy/request.go +++ b/internal/proxy/request.go @@ -44,6 +44,11 @@ func prepareTransformerForClient(clientFormat ClientFormat, endpoint config.Endp return prepareCxChatTransformer(endpoint, endpointTransformer, effectiveModel) case ClientFormatOpenAIResponses: return prepareCxRespTransformer(endpoint, endpointTransformer, effectiveModel) + case ClientFormatOpenAIImages: + if endpointTransformer == "openai" || endpointTransformer == "openai2" { + return responses.NewOpenAI2Transformer(effectiveModel), nil + } + return nil, fmt.Errorf("unsupported endpoint transformer for OpenAI Images: %s", endpointTransformer) } return nil, fmt.Errorf("unsupported client format: %s", clientFormat) @@ -103,6 +108,13 @@ func prepareCxRespTransformer(endpoint config.Endpoint, endpointTransformer stri // getTargetPath determines the target API path based on transformer name func getTargetPath(originalPath string, endpoint config.Endpoint, transformedBody []byte, transformerName string, modelName string) string { + if isOpenAIImagesPath(originalPath) { + imagePath := strings.TrimSuffix(originalPath, "/") + if strings.HasPrefix(imagePath, "/v1/") { + return imagePath + } + return "/v1" + imagePath + } switch transformerName { case "cc_claude", "cx_chat_claude", "cx_resp_claude": return "/v1/messages" diff --git a/internal/proxy/request_test.go b/internal/proxy/request_test.go index 5ce5080..0395c79 100644 --- a/internal/proxy/request_test.go +++ b/internal/proxy/request_test.go @@ -1,13 +1,69 @@ package proxy import ( + "bytes" "encoding/json" + "io" + "net/http" + "net/http/httptest" "testing" "github.com/lich0821/ccNexus/internal/config" "github.com/lich0821/ccNexus/internal/transformer/convert" ) +func TestOpenAIImageRequestsPreservePathAndPayload(t *testing.T) { + endpoint := config.Endpoint{ + Name: "Images", + APIUrl: "https://api.example.com", + APIKey: "secret", + AuthMode: config.AuthModeAPIKey, + Transformer: "openai2", + } + payload := []byte(`{"model":"gpt-image-2","prompt":"a kitten","quality":"auto","size":"auto"}`) + + for _, testCase := range []struct { + requestPath string + upstreamPath string + }{ + {requestPath: "/v1/images/generations", upstreamPath: "/v1/images/generations"}, + {requestPath: "/images/generations", upstreamPath: "/v1/images/generations"}, + {requestPath: "/v1/images/edits", upstreamPath: "/v1/images/edits"}, + {requestPath: "/images/edits", upstreamPath: "/v1/images/edits"}, + } { + t.Run(testCase.requestPath, func(t *testing.T) { + clientFormat := detectClientFormat(testCase.requestPath) + if clientFormat != ClientFormatOpenAIImages { + t.Fatalf("client format = %q, want %q", clientFormat, ClientFormatOpenAIImages) + } + + trans, err := prepareTransformerForClient(clientFormat, endpoint, "gpt-image-2") + if err != nil { + t.Fatalf("prepare transformer: %v", err) + } + transformed, err := trans.TransformRequest(payload) + if err != nil { + t.Fatalf("transform request: %v", err) + } + incoming := httptest.NewRequest(http.MethodPost, testCase.requestPath, bytes.NewReader(payload)) + outgoing, err := buildProxyRequest(incoming, endpoint, endpoint.APIKey, transformed, trans.Name(), "gpt-image-2", nil) + if err != nil { + t.Fatalf("build proxy request: %v", err) + } + if outgoing.URL.Path != testCase.upstreamPath { + t.Fatalf("upstream path = %q, want %q", outgoing.URL.Path, testCase.upstreamPath) + } + body, err := io.ReadAll(outgoing.Body) + if err != nil { + t.Fatalf("read request body: %v", err) + } + if !bytes.Equal(body, payload) { + t.Fatalf("upstream payload = %s, want %s", body, payload) + } + }) + } +} + func TestEnsureCodexResponsesPayload(t *testing.T) { raw := []byte(`{"model":"gpt-4.1","stream":true}`) out := ensureCodexResponsesPayload(raw) From b8f3d7ba0e618e07dc7d6c65f935f5b3c349a481 Mon Sep 17 00:00:00 2001 From: kokojacket <74887370+kokojacket@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:21:43 +0800 Subject: [PATCH 2/2] fix: preserve OpenAI image request routing --- internal/proxy/proxy_request.go | 25 ++++++++------- internal/proxy/request.go | 6 ++-- internal/proxy/request_test.go | 55 +++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/internal/proxy/proxy_request.go b/internal/proxy/proxy_request.go index fe6ce18..f684935 100644 --- a/internal/proxy/proxy_request.go +++ b/internal/proxy/proxy_request.go @@ -203,18 +203,21 @@ func (p *Proxy) prepareEndpointAttempt(reqCtx *proxyRequestContext, attempt *end logger.DebugLog("[%s] Transformer: %s", attempt.endpoint.Name, attempt.transformerName) logger.DebugLog("[%s] Transformed Request: %s", attempt.endpoint.Name, string(transformedBody)) - if reqCtx.modelOverride != "" { - transformedBody = overrideModelInPayload(transformedBody, reqCtx.modelOverride) - logger.DebugLog("[%s] 应用模型覆盖后的请求: %s", attempt.endpoint.Name, string(transformedBody)) - } + cleanedBody := transformedBody + if reqCtx.clientFormat != ClientFormatOpenAIImages { + if reqCtx.modelOverride != "" { + cleanedBody = overrideModelInPayload(cleanedBody, reqCtx.modelOverride) + logger.DebugLog("[%s] 应用模型覆盖后的请求: %s", attempt.endpoint.Name, string(cleanedBody)) + } - cleanedBody, err := cleanIncompleteToolCalls(transformedBody) - if err != nil { - logger.Warn("[%s] Failed to clean tool calls: %v", attempt.endpoint.Name, err) - cleanedBody = transformedBody - } - if shouldOverridePayloadModel(attempt.transformerName) && attempt.modelName != "" { - cleanedBody = overrideModelInPayload(cleanedBody, attempt.modelName) + cleanedBody, err = cleanIncompleteToolCalls(cleanedBody) + if err != nil { + logger.Warn("[%s] Failed to clean tool calls: %v", attempt.endpoint.Name, err) + cleanedBody = transformedBody + } + if shouldOverridePayloadModel(attempt.transformerName) && attempt.modelName != "" { + cleanedBody = overrideModelInPayload(cleanedBody, attempt.modelName) + } } attempt.transformedBody = cleanedBody attempt.thinkingEnabled = detectThinkingEnabled(attempt.transformerName, attempt.transformedBody) diff --git a/internal/proxy/request.go b/internal/proxy/request.go index 3d6cfd1..aafe3cd 100644 --- a/internal/proxy/request.go +++ b/internal/proxy/request.go @@ -256,8 +256,7 @@ func isCodexProviderType(providerType string) bool { return p == "" || p == "codex" } -// normalizeTargetPathForBaseURL adjusts OpenAI Responses paths for Codex backend base URLs. -// This is endpoint URL compatibility handling and is independent from auth mode. +// normalizeTargetPathForBaseURL adjusts target paths for endpoint base URL compatibility. func normalizeTargetPathForBaseURL(baseURL, targetPath string) string { parsed, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil || parsed == nil { @@ -265,6 +264,9 @@ func normalizeTargetPathForBaseURL(baseURL, targetPath string) string { } cleanPath := path.Clean(strings.TrimSpace(parsed.Path)) + if strings.HasSuffix(cleanPath, "/v1") && isOpenAIImagesPath(targetPath) { + return strings.TrimPrefix(strings.TrimSpace(targetPath), "/v1") + } isCodexBackend := strings.HasSuffix(cleanPath, "/backend-api/codex") if !isCodexBackend { return targetPath diff --git a/internal/proxy/request_test.go b/internal/proxy/request_test.go index 0395c79..ebc6dcc 100644 --- a/internal/proxy/request_test.go +++ b/internal/proxy/request_test.go @@ -64,6 +64,61 @@ func TestOpenAIImageRequestsPreservePathAndPayload(t *testing.T) { } } +func TestPrepareEndpointAttemptPreservesOpenAIImagePayload(t *testing.T) { + payload := []byte("{\n \"model\": \"gpt-image-2\",\n \"prompt\": \"a kitten\"\n}") + + for _, transformerName := range []string{"openai", "openai2"} { + t.Run(transformerName, func(t *testing.T) { + endpoint := config.Endpoint{ + Name: "Images", + APIUrl: "https://api.example.com", + APIKey: "secret", + AuthMode: config.AuthModeAPIKey, + Transformer: transformerName, + } + reqCtx := &proxyRequestContext{ + httpRequest: httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(payload)), + bodyBytes: payload, + clientFormat: ClientFormatOpenAIImages, + requestModel: "gpt-image-2", + modelOverride: "gpt-5.6-sol", + } + attempt := &endpointAttempt{endpoint: endpoint} + + if result := (&Proxy{}).prepareEndpointAttempt(reqCtx, attempt); result != attemptResultDone { + t.Fatalf("prepare endpoint attempt result = %v, want done", result) + } + body, err := io.ReadAll(attempt.proxyRequest.Body) + if err != nil { + t.Fatalf("read upstream body: %v", err) + } + if !bytes.Equal(body, payload) { + t.Fatalf("upstream payload = %s, want byte-for-byte %s", body, payload) + } + }) + } +} + +func TestOpenAIImageRequestsAvoidDuplicateVersionedBasePath(t *testing.T) { + endpoint := config.Endpoint{ + APIUrl: "https://api.example.com/v1", + APIKey: "secret", + Transformer: "openai2", + } + payload := []byte(`{"model":"gpt-image-2","prompt":"a kitten"}`) + + for _, requestPath := range []string{"/v1/images/generations", "/v1/images/edits"} { + incoming := httptest.NewRequest(http.MethodPost, requestPath, bytes.NewReader(payload)) + outgoing, err := buildProxyRequest(incoming, endpoint, endpoint.APIKey, payload, "cx_resp_openai2", "gpt-image-2", nil) + if err != nil { + t.Fatalf("build proxy request for %s: %v", requestPath, err) + } + if outgoing.URL.Path != requestPath { + t.Fatalf("upstream path = %q, want %q", outgoing.URL.Path, requestPath) + } + } +} + func TestEnsureCodexResponsesPayload(t *testing.T) { raw := []byte(`{"model":"gpt-4.1","stream":true}`) out := ensureCodexResponsesPayload(raw)