Skip to content
Open
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
46 changes: 46 additions & 0 deletions internal/converter/codex_openai_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 20 additions & 5 deletions internal/converter/codex_to_openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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{
Expand All @@ -134,6 +137,18 @@ func (c *codexToOpenAIRequest) Transform(body []byte, model string, stream bool)
return json.Marshal(openaiReq)
}

func codexMessageRoleToOpenAI(role string) string {
normalized := strings.ToLower(strings.TrimSpace(role))
switch normalized {
case "developer", "system":
return "system"
case "assistant", "user", "tool", "function":
return normalized
default:
return "user"
}
}

func codexContentToOpenAI(content interface{}) interface{} {
switch value := content.(type) {
case []interface{}:
Expand Down
24 changes: 23 additions & 1 deletion internal/executor/middleware_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions internal/executor/openrouter_bridge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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 {
return false
}
host := strings.TrimPrefix(parsed.Hostname(), "www.")
return host == "openrouter.ai" || strings.HasSuffix(host, ".openrouter.ai")
}

func isOpenRouterCompatibleProviderName(name string) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(name)), "openrouter")
}
75 changes: 75 additions & 0 deletions internal/executor/openrouter_bridge_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading