Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,15 +320,27 @@ 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 {
case strings.HasPrefix(path, "/v1/chat/completions") || strings.HasPrefix(path, "/chat/completions"):
return ClientFormatOpenAIChat
case strings.HasPrefix(path, "/v1/responses") || strings.HasPrefix(path, "/responses"):
return ClientFormatOpenAIResponses
case isOpenAIImagesPath(path):
return ClientFormatOpenAIImages
default:
return ClientFormatClaude
}
Expand Down
25 changes: 14 additions & 11 deletions internal/proxy/proxy_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 16 additions & 2 deletions internal/proxy/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -244,15 +256,17 @@ 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 {
return targetPath
}

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
Expand Down
111 changes: 111 additions & 0 deletions internal/proxy/request_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,124 @@
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 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)
Expand Down
Loading