From 8aac1b542f66b9faf8daf8a5bdb036b88592d9d0 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Fri, 15 May 2026 10:17:53 +0000 Subject: [PATCH 1/2] fix: bridge OpenRouter Codex tools via chat completions --- internal/converter/codex_openai_more_test.go | 46 ++++++++++++ internal/converter/codex_to_openai.go | 24 +++++-- internal/executor/middleware_dispatch.go | 24 ++++++- internal/executor/openrouter_bridge.go | 69 ++++++++++++++++++ internal/executor/openrouter_bridge_test.go | 75 ++++++++++++++++++++ 5 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 internal/executor/openrouter_bridge.go create mode 100644 internal/executor/openrouter_bridge_test.go diff --git a/internal/converter/codex_openai_more_test.go b/internal/converter/codex_openai_more_test.go index 2de6257f..ef3a20d6 100644 --- a/internal/converter/codex_openai_more_test.go +++ b/internal/converter/codex_openai_more_test.go @@ -25,6 +25,52 @@ func TestCodexToOpenAIRequest_ResponseInputString(t *testing.T) { } } +func TestCodexToOpenAIRequest_ConvertsResponseToolsToFunctionTools(t *testing.T) { + req := CodexRequest{ + Model: "codex-test", + Input: []interface{}{ + map[string]interface{}{"type": "message", "role": "developer", "content": "follow instructions"}, + map[string]interface{}{"type": "message", "role": "user", "content": "use a tool"}, + }, + Tools: []CodexTool{{ + Type: "function", + Name: "shell", + Description: "run shell command", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "cmd": map[string]interface{}{"type": "string"}, + }, + }, + }, { + Type: "web_search", + }}, + } + body, _ := json.Marshal(req) + conv := &codexToOpenAIRequest{} + out, err := conv.Transform(body, "deepseek-test", true) + if err != nil { + t.Fatalf("Transform: %v", err) + } + var got OpenAIRequest + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Model != "deepseek-test" || !got.Stream { + t.Fatalf("unexpected model/stream: %#v", got) + } + if len(got.Messages) < 2 || got.Messages[0].Role != "system" || got.Messages[1].Role != "user" { + t.Fatalf("unexpected converted roles: %#v", got.Messages) + } + if len(got.Tools) != 1 { + t.Fatalf("tools len = %d, want only the function tool", len(got.Tools)) + } + tool := got.Tools[0] + if tool.Type != "function" || tool.Function.Name != "shell" { + t.Fatalf("unexpected converted tool: %#v", tool) + } +} + func TestCodexToOpenAIResponse_StreamMore(t *testing.T) { conv := &codexToOpenAIResponse{} state := NewTransformState() diff --git a/internal/converter/codex_to_openai.go b/internal/converter/codex_to_openai.go index e5d202e3..21629d1e 100644 --- a/internal/converter/codex_to_openai.go +++ b/internal/converter/codex_to_openai.go @@ -79,11 +79,8 @@ func (c *codexToOpenAIRequest) Transform(body []byte, model string, stream bool) role, _ := m["role"].(string) switch itemType { case "message": - if role == "" { - role = "user" - } openaiReq.Messages = append(openaiReq.Messages, OpenAIMessage{ - Role: role, + Role: codexMessageRoleToOpenAI(role), Content: codexContentToOpenAI(m["content"]), }) case "function_call": @@ -119,8 +116,14 @@ func (c *codexToOpenAIRequest) Transform(body []byte, model string, stream bool) } } - // Convert tools + // Convert tools. Chat Completions can carry function tools, but Responses-only + // built-ins such as web_search do not have an OpenAI Chat function shape. + // Dropping those keeps Codex/OpenRouter-compatible fallbacks from sending + // invalid {type:"web_search"} or empty function names upstream. for _, tool := range req.Tools { + if !strings.EqualFold(strings.TrimSpace(tool.Type), "function") || strings.TrimSpace(tool.Name) == "" { + continue + } openaiReq.Tools = append(openaiReq.Tools, OpenAITool{ Type: "function", Function: OpenAIFunction{ @@ -134,6 +137,17 @@ func (c *codexToOpenAIRequest) Transform(body []byte, model string, stream bool) return json.Marshal(openaiReq) } +func codexMessageRoleToOpenAI(role string) string { + switch strings.ToLower(strings.TrimSpace(role)) { + case "developer", "system": + return "system" + case "assistant", "user", "tool", "function": + return strings.ToLower(strings.TrimSpace(role)) + default: + return "user" + } +} + func codexContentToOpenAI(content interface{}) interface{} { switch value := content.(type) { case []interface{}: diff --git a/internal/executor/middleware_dispatch.go b/internal/executor/middleware_dispatch.go index b7896cd2..9a2a40d4 100644 --- a/internal/executor/middleware_dispatch.go +++ b/internal/executor/middleware_dispatch.go @@ -75,7 +75,29 @@ func (e *Executor) dispatch(c *flow.Ctx) { requestURI := state.requestURI supportedTypes := matchedRoute.ProviderAdapter.SupportedClientTypes() - if e.converter.NeedConvert(clientType, supportedTypes) { + if shouldBridgeCustomCodexViaOpenAI(matchedRoute.Provider, clientType, supportedTypes) { + currentClientType = domain.ClientTypeOpenAI + needsConversion = true + log.Printf("[Executor] OpenRouter-compatible custom provider %s: bridging Codex request through OpenAI Chat Completions", + matchedRoute.Provider.Name) + + convertedBody, convErr = e.converter.TransformRequest( + clientType, currentClientType, requestBody, mappedModel, state.isStream) + if convErr != nil { + log.Printf("[Executor] OpenRouter Codex->OpenAI conversion failed: %v, proceeding with original format", convErr) + needsConversion = false + currentClientType = clientType + } else { + requestBody = convertedBody + + originalURI := requestURI + convertedURI := ConvertRequestURI(requestURI, clientType, currentClientType, mappedModel, state.isStream) + if convertedURI != originalURI { + requestURI = convertedURI + log.Printf("[Executor] URI converted: %s -> %s", originalURI, convertedURI) + } + } + } else if e.converter.NeedConvert(clientType, supportedTypes) { currentClientType = GetPreferredTargetType(supportedTypes, clientType, matchedRoute.Provider.Type) if currentClientType != clientType { needsConversion = true diff --git a/internal/executor/openrouter_bridge.go b/internal/executor/openrouter_bridge.go new file mode 100644 index 00000000..b25ff5b6 --- /dev/null +++ b/internal/executor/openrouter_bridge.go @@ -0,0 +1,69 @@ +package executor + +import ( + "net/url" + "strings" + + "github.com/awsl-project/maxx/internal/domain" +) + +// shouldBridgeCustomCodexViaOpenAI returns true for custom OpenRouter-style +// providers that are reachable through OpenAI Chat Completions but reject Codex +// Responses API tool schemas. Codex CLI sends Responses-shaped tool definitions +// such as web_search/image_generation, while OpenRouter accepts only its own +// openrouter:* built-in tool types on /responses. Routing through OpenAI keeps +// user-defined function tools compatible and avoids breaking normal Codex +// providers. +func shouldBridgeCustomCodexViaOpenAI(provider *domain.Provider, clientType domain.ClientType, supportedTypes []domain.ClientType) bool { + if provider == nil || clientType != domain.ClientTypeCodex || provider.Type != "custom" { + return false + } + if !supportsClientType(supportedTypes, domain.ClientTypeOpenAI) { + return false + } + if provider.Config == nil || provider.Config.Custom == nil { + return false + } + + custom := provider.Config.Custom + if isOpenRouterCompatibleURL(custom.BaseURL) { + return true + } + if custom.ClientBaseURL != nil { + if isOpenRouterCompatibleURL(custom.ClientBaseURL[domain.ClientTypeCodex]) { + return true + } + if isOpenRouterCompatibleURL(custom.ClientBaseURL[domain.ClientTypeOpenAI]) { + return true + } + } + return isOpenRouterCompatibleProviderName(provider.Name) +} + +func supportsClientType(types []domain.ClientType, target domain.ClientType) bool { + for _, candidate := range types { + if candidate == target { + return true + } + } + return false +} + +func isOpenRouterCompatibleURL(rawURL string) bool { + trimmed := strings.TrimSpace(strings.ToLower(rawURL)) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + if err == nil { + host := strings.TrimPrefix(parsed.Hostname(), "www.") + if host == "openrouter.ai" || strings.HasSuffix(host, ".openrouter.ai") { + return true + } + } + return strings.Contains(trimmed, "openrouter.ai") +} + +func isOpenRouterCompatibleProviderName(name string) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(name)), "openrouter") +} diff --git a/internal/executor/openrouter_bridge_test.go b/internal/executor/openrouter_bridge_test.go new file mode 100644 index 00000000..499f62a8 --- /dev/null +++ b/internal/executor/openrouter_bridge_test.go @@ -0,0 +1,75 @@ +package executor + +import ( + "testing" + + "github.com/awsl-project/maxx/internal/domain" +) + +func TestShouldBridgeCustomCodexViaOpenAIForOpenRouter(t *testing.T) { + provider := &domain.Provider{ + Type: "custom", + Name: "openrouter", + Config: &domain.ProviderConfig{Custom: &domain.ProviderConfigCustom{ + BaseURL: "https://openrouter.ai/api/v1", + }}, + } + + if !shouldBridgeCustomCodexViaOpenAI(provider, domain.ClientTypeCodex, []domain.ClientType{domain.ClientTypeCodex, domain.ClientTypeOpenAI}) { + t.Fatal("expected OpenRouter custom Codex route to bridge through OpenAI") + } +} + +func TestShouldNotBridgeOpenRouterWithoutOpenAISupport(t *testing.T) { + provider := &domain.Provider{ + Type: "custom", + Name: "openrouter", + Config: &domain.ProviderConfig{Custom: &domain.ProviderConfigCustom{ + BaseURL: "https://openrouter.ai/api/v1", + }}, + } + + if shouldBridgeCustomCodexViaOpenAI(provider, domain.ClientTypeCodex, []domain.ClientType{domain.ClientTypeCodex}) { + t.Fatal("must not bridge when provider cannot accept OpenAI Chat Completions") + } +} + +func TestShouldNotBridgeNonOpenRouterCustomCodex(t *testing.T) { + provider := &domain.Provider{ + Type: "custom", + Name: "generic-relay", + Config: &domain.ProviderConfig{Custom: &domain.ProviderConfigCustom{ + BaseURL: "https://relay.example.com/v1", + }}, + } + + if shouldBridgeCustomCodexViaOpenAI(provider, domain.ClientTypeCodex, []domain.ClientType{domain.ClientTypeCodex, domain.ClientTypeOpenAI}) { + t.Fatal("generic custom Codex routes should keep their declared Codex Responses path") + } +} + +func TestShouldBridgeOpenRouterClientBaseURL(t *testing.T) { + provider := &domain.Provider{ + Type: "custom", + Name: "relay", + Config: &domain.ProviderConfig{Custom: &domain.ProviderConfigCustom{ + BaseURL: "https://relay.example.com/v1", + ClientBaseURL: map[domain.ClientType]string{ + domain.ClientTypeCodex: "https://openrouter.ai/api/v1", + }, + }}, + } + + if !shouldBridgeCustomCodexViaOpenAI(provider, domain.ClientTypeCodex, []domain.ClientType{domain.ClientTypeCodex, domain.ClientTypeOpenAI}) { + t.Fatal("expected OpenRouter Codex client base URL to bridge through OpenAI") + } +} + +func TestOpenRouterCodexBridgeUsesChatCompletionsPath(t *testing.T) { + if got := ConvertRequestURI("/responses", domain.ClientTypeCodex, domain.ClientTypeOpenAI, "", true); got != "/v1/chat/completions" { + t.Fatalf("ConvertRequestURI(/responses) = %q, want /v1/chat/completions", got) + } + if got := ConvertRequestURI("/v1/responses", domain.ClientTypeCodex, domain.ClientTypeOpenAI, "", true); got != "/v1/chat/completions" { + t.Fatalf("ConvertRequestURI(/v1/responses) = %q, want /v1/chat/completions", got) + } +} From 11e7922e4124241dc4f3fad635d1a7014110e107 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Fri, 15 May 2026 10:25:11 +0000 Subject: [PATCH 2/2] chore: address OpenRouter bridge review nits --- internal/converter/codex_openai_more_test.go | 2 +- internal/converter/codex_to_openai.go | 5 +++-- internal/executor/openrouter_bridge.go | 10 ++++------ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/internal/converter/codex_openai_more_test.go b/internal/converter/codex_openai_more_test.go index ef3a20d6..84f63427 100644 --- a/internal/converter/codex_openai_more_test.go +++ b/internal/converter/codex_openai_more_test.go @@ -59,7 +59,7 @@ func TestCodexToOpenAIRequest_ConvertsResponseToolsToFunctionTools(t *testing.T) if got.Model != "deepseek-test" || !got.Stream { t.Fatalf("unexpected model/stream: %#v", got) } - if len(got.Messages) < 2 || got.Messages[0].Role != "system" || got.Messages[1].Role != "user" { + if len(got.Messages) != 2 || got.Messages[0].Role != "system" || got.Messages[1].Role != "user" { t.Fatalf("unexpected converted roles: %#v", got.Messages) } if len(got.Tools) != 1 { diff --git a/internal/converter/codex_to_openai.go b/internal/converter/codex_to_openai.go index 21629d1e..f6dded8e 100644 --- a/internal/converter/codex_to_openai.go +++ b/internal/converter/codex_to_openai.go @@ -138,11 +138,12 @@ func (c *codexToOpenAIRequest) Transform(body []byte, model string, stream bool) } func codexMessageRoleToOpenAI(role string) string { - switch strings.ToLower(strings.TrimSpace(role)) { + normalized := strings.ToLower(strings.TrimSpace(role)) + switch normalized { case "developer", "system": return "system" case "assistant", "user", "tool", "function": - return strings.ToLower(strings.TrimSpace(role)) + return normalized default: return "user" } diff --git a/internal/executor/openrouter_bridge.go b/internal/executor/openrouter_bridge.go index b25ff5b6..c5e38aa5 100644 --- a/internal/executor/openrouter_bridge.go +++ b/internal/executor/openrouter_bridge.go @@ -55,13 +55,11 @@ func isOpenRouterCompatibleURL(rawURL string) bool { return false } parsed, err := url.Parse(trimmed) - if err == nil { - host := strings.TrimPrefix(parsed.Hostname(), "www.") - if host == "openrouter.ai" || strings.HasSuffix(host, ".openrouter.ai") { - return true - } + if err != nil { + return false } - return strings.Contains(trimmed, "openrouter.ai") + host := strings.TrimPrefix(parsed.Hostname(), "www.") + return host == "openrouter.ai" || strings.HasSuffix(host, ".openrouter.ai") } func isOpenRouterCompatibleProviderName(name string) bool {