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 backend/internal/api/accounts_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ type accountsSettingsReader interface {

type AccountsHandlerOption func(*AccountsHandler)

func WithAccountsHTTPClient(client *http.Client) AccountsHandlerOption {
return func(handler *AccountsHandler) {
if client == nil {
return
}
handler.client = client
if handler.luaRuntime != nil {
handler.luaRuntime = luadrv.NewRuntime(handler.client, "")
}
}
}

func WithAccountsStateEvents(bus *StateEventBus) AccountsHandlerOption {
return func(handler *AccountsHandler) {
handler.stateEvents = bus
Expand Down
8 changes: 8 additions & 0 deletions backend/internal/api/gateway_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ type GatewayHandler struct {

type GatewayHandlerOption func(*GatewayHandler)

func WithGatewayHTTPClient(client *http.Client) GatewayHandlerOption {
return func(handler *GatewayHandler) {
if client != nil {
handler.client = client
}
}
}

func WithGatewaySettings(repo GatewayRoutingSettings) GatewayHandlerOption {
return func(handler *GatewayHandler) {
handler.settings = repo
Expand Down
8 changes: 8 additions & 0 deletions backend/internal/api/responses_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ type ResponsesHandler struct {

type ResponsesHandlerOption func(*ResponsesHandler)

func WithResponsesHTTPClient(client *http.Client) ResponsesHandlerOption {
return func(handler *ResponsesHandler) {
if client != nil {
handler.client = client
}
}
}

func WithResponsesSettings(repo settings.ReadRepository) ResponsesHandlerOption {
return func(handler *ResponsesHandler) {
handler.settings = repo
Expand Down
13 changes: 13 additions & 0 deletions backend/internal/api/settings_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,19 @@ func normalizeAppSettings(value settings.AppSettings) settings.AppSettings {
if value.ProxyPort <= 0 {
value.ProxyPort = defaults.ProxyPort
}
switch value.UpstreamProxyMode {
case settings.UpstreamProxyModeSystem, settings.UpstreamProxyModeDirect, settings.UpstreamProxyModeManual:
default:
value.UpstreamProxyMode = defaults.UpstreamProxyMode
}
value.UpstreamProxyURL = strings.TrimSpace(value.UpstreamProxyURL)
value.UpstreamProxyUsername = strings.TrimSpace(value.UpstreamProxyUsername)
value.UpstreamProxyPassword = strings.TrimSpace(value.UpstreamProxyPassword)
if value.UpstreamProxyMode != settings.UpstreamProxyModeManual {
value.UpstreamProxyURL = ""
value.UpstreamProxyUsername = ""
value.UpstreamProxyPassword = ""
}
if value.AutoBackupIntervalHours <= 0 {
value.AutoBackupIntervalHours = defaults.AutoBackupIntervalHours
}
Expand Down
29 changes: 23 additions & 6 deletions backend/internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/gcssloop/codex-router/backend/internal/api"
"github.com/gcssloop/codex-router/backend/internal/auth"
"github.com/gcssloop/codex-router/backend/internal/conversations"
"github.com/gcssloop/codex-router/backend/internal/netproxy"
"github.com/gcssloop/codex-router/backend/internal/policy"
"github.com/gcssloop/codex-router/backend/internal/scheduler"
"github.com/gcssloop/codex-router/backend/internal/secrets"
Expand Down Expand Up @@ -77,20 +78,21 @@ func NewApp(_ context.Context, cfg Config) (*App, error) {
accountRepo := accounts.NewSQLiteRepository(store.DB(), credentialCipher)
settingsRepo := settings.NewSQLiteRepository(store.DB())
usageRepo := usage.NewSQLiteRepository(store.DB())
upstreamHTTPClient := netproxy.NewHTTPClient(settingsRepo)
conversationRepo := conversations.NewSQLiteRepository(store.DB())
policyRepo := policy.NewMemoryRepository()
authConnector := auth.NewOAuthConnector(auth.Config{})
stateStore := auth.NewStateStore(5 * time.Minute)
stateEvents := api.NewStateEventBus()
driverRegistry, err := registry.New(
[]accountdrv.AccountDriver{
accountdrv.NewOfficialDriver(http.DefaultClient, accountRepo),
accountdrv.NewOfficialDriver(upstreamHTTPClient, accountRepo),
accountdrv.NewAPIKeyDriver(),
},
[]usagedrv.UsageDriver{
builtin.NewOpenAIOfficialDriver(http.DefaultClient),
builtin.NewPPChatDriver(http.DefaultClient),
luadrv.NewDriver(http.DefaultClient, "", luadrv.WithManagedScriptRoot(luaScriptRoot)),
builtin.NewOpenAIOfficialDriver(upstreamHTTPClient),
builtin.NewPPChatDriver(upstreamHTTPClient),
luadrv.NewDriver(upstreamHTTPClient, "", luadrv.WithManagedScriptRoot(luaScriptRoot)),
},
)
if err != nil {
Expand All @@ -106,6 +108,7 @@ func NewApp(_ context.Context, cfg Config) (*App, error) {
api.WithAccountsStateEvents(stateEvents),
api.WithAccountsUsageRefresher(refreshOrchestrator),
api.WithAccountsSettings(settingsRepo),
api.WithAccountsHTTPClient(upstreamHTTPClient),
api.WithAccountsDriverRegistry(driverRegistry),
api.WithAccountsLuaScriptRoot(luaScriptRoot),
)
Expand Down Expand Up @@ -148,8 +151,22 @@ func NewApp(_ context.Context, cfg Config) (*App, error) {
apiMux.Handle("/settings/proxy/status", settingsHandler)
apiMux.Handle("/settings/proxy/enable", settingsHandler)
apiMux.Handle("/settings/proxy/disable", settingsHandler)
gatewayHandler := api.NewGatewayHandler(accountRepo, usageRepo, conversationRepo, api.WithGatewaySettings(settingsRepo), api.WithGatewayStateEvents(stateEvents))
responsesHandler := api.NewResponsesHandler(accountRepo, usageRepo, conversationRepo, api.WithResponsesSettings(settingsRepo), api.WithResponsesStateEvents(stateEvents))
gatewayHandler := api.NewGatewayHandler(
accountRepo,
usageRepo,
conversationRepo,
api.WithGatewaySettings(settingsRepo),
api.WithGatewayHTTPClient(upstreamHTTPClient),
api.WithGatewayStateEvents(stateEvents),
)
responsesHandler := api.NewResponsesHandler(
accountRepo,
usageRepo,
conversationRepo,
api.WithResponsesSettings(settingsRepo),
api.WithResponsesHTTPClient(upstreamHTTPClient),
api.WithResponsesStateEvents(stateEvents),
)
apiMux.Handle("/chat/completions", api.RequireProxyEnabled(gatewayHandler))
apiMux.Handle("/v1/chat/completions", api.RequireProxyEnabled(gatewayHandler))
apiMux.Handle("/responses", api.RequireProxyEnabled(responsesHandler))
Expand Down
1 change: 1 addition & 0 deletions backend/internal/netproxy/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package netproxy
88 changes: 88 additions & 0 deletions backend/internal/netproxy/transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package netproxy

import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"

"github.com/gcssloop/codex-router/backend/internal/settings"
)

type settingsReader interface {
GetAppSettings() (settings.AppSettings, error)
}

func NewHTTPClient(repo settingsReader) *http.Client {
transport := defaultTransportClone()
transport.Proxy = func(req *http.Request) (*url.URL, error) {
return ResolveProxy(req, repo)
}
return &http.Client{Transport: transport}
}

func ResolveProxy(req *http.Request, repo settingsReader) (*url.URL, error) {
if req == nil {
return nil, nil
}
if repo == nil {
return http.ProxyFromEnvironment(req)
}
appSettings, err := repo.GetAppSettings()
if err != nil {
return nil, fmt.Errorf("load app settings: %w", err)
}
switch appSettings.UpstreamProxyMode {
case settings.UpstreamProxyModeDirect:
return nil, nil
case settings.UpstreamProxyModeManual:
proxyURL, err := parseManualProxy(appSettings)
if err != nil {
return nil, err
}
return proxyURL, nil
case "", settings.UpstreamProxyModeSystem:
return http.ProxyFromEnvironment(req)
default:
return http.ProxyFromEnvironment(req)
}
}

func parseManualProxy(appSettings settings.AppSettings) (*url.URL, error) {
rawURL := strings.TrimSpace(appSettings.UpstreamProxyURL)
if rawURL == "" {
return nil, fmt.Errorf("manual upstream proxy url is empty")
}
proxyURL, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("parse upstream proxy url: %w", err)
}
if proxyURL.Scheme == "" || proxyURL.Host == "" {
return nil, fmt.Errorf("upstream proxy url must include scheme and host")
}
if proxyURL.User == nil && strings.TrimSpace(appSettings.UpstreamProxyUsername) != "" {
proxyURL.User = url.UserPassword(strings.TrimSpace(appSettings.UpstreamProxyUsername), strings.TrimSpace(appSettings.UpstreamProxyPassword))
}
return proxyURL, nil
}

func defaultTransportClone() *http.Transport {
base, ok := http.DefaultTransport.(*http.Transport)
if ok && base != nil {
return base.Clone()
}
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
}
63 changes: 63 additions & 0 deletions backend/internal/netproxy/transport_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package netproxy_test

import (
"net/http"
"testing"

"github.com/gcssloop/codex-router/backend/internal/netproxy"
"github.com/gcssloop/codex-router/backend/internal/settings"
)

type stubSettingsReader struct {
value settings.AppSettings
}

func (s stubSettingsReader) GetAppSettings() (settings.AppSettings, error) {
return s.value, nil
}

func TestResolveProxyUsesDirectMode(t *testing.T) {
t.Parallel()

reader := stubSettingsReader{value: settings.AppSettings{
UpstreamProxyMode: "direct",
}}
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatalf("NewRequest returned error: %v", err)
}

got, err := netproxy.ResolveProxy(req, reader)
if err != nil {
t.Fatalf("ResolveProxy returned error: %v", err)
}
if got != nil {
t.Fatalf("ResolveProxy = %v, want nil in direct mode", got)
}
}

func TestResolveProxyUsesManualMode(t *testing.T) {
t.Parallel()

reader := stubSettingsReader{value: settings.AppSettings{
UpstreamProxyMode: "manual",
UpstreamProxyURL: "http://127.0.0.1:7890",
UpstreamProxyUsername: "user",
UpstreamProxyPassword: "pass",
}}
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatalf("NewRequest returned error: %v", err)
}

got, err := netproxy.ResolveProxy(req, reader)
if err != nil {
t.Fatalf("ResolveProxy returned error: %v", err)
}
if got == nil {
t.Fatal("ResolveProxy = nil, want manual proxy URL")
}
if got.String() != "http://user:pass@127.0.0.1:7890" {
t.Fatalf("ResolveProxy = %q, want %q", got.String(), "http://user:pass@127.0.0.1:7890")
}
}
Loading
Loading