diff --git a/backend/internal/api/accounts_handler.go b/backend/internal/api/accounts_handler.go index 3ff3ab0..785c24b 100644 --- a/backend/internal/api/accounts_handler.go +++ b/backend/internal/api/accounts_handler.go @@ -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 diff --git a/backend/internal/api/gateway_handler.go b/backend/internal/api/gateway_handler.go index b832deb..26dce73 100644 --- a/backend/internal/api/gateway_handler.go +++ b/backend/internal/api/gateway_handler.go @@ -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 diff --git a/backend/internal/api/responses_handler.go b/backend/internal/api/responses_handler.go index 51a15f0..cccdfdf 100644 --- a/backend/internal/api/responses_handler.go +++ b/backend/internal/api/responses_handler.go @@ -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 diff --git a/backend/internal/api/settings_handler.go b/backend/internal/api/settings_handler.go index 1efc2be..1bc9a12 100644 --- a/backend/internal/api/settings_handler.go +++ b/backend/internal/api/settings_handler.go @@ -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 } diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index bb7ee6d..2661394 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -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" @@ -77,6 +78,7 @@ 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{}) @@ -84,13 +86,13 @@ func NewApp(_ context.Context, cfg Config) (*App, error) { 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 { @@ -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), ) @@ -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)) diff --git a/backend/internal/netproxy/doc.go b/backend/internal/netproxy/doc.go new file mode 100644 index 0000000..c147a0d --- /dev/null +++ b/backend/internal/netproxy/doc.go @@ -0,0 +1 @@ +package netproxy diff --git a/backend/internal/netproxy/transport.go b/backend/internal/netproxy/transport.go new file mode 100644 index 0000000..d6ac26c --- /dev/null +++ b/backend/internal/netproxy/transport.go @@ -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, + } +} diff --git a/backend/internal/netproxy/transport_test.go b/backend/internal/netproxy/transport_test.go new file mode 100644 index 0000000..23b2730 --- /dev/null +++ b/backend/internal/netproxy/transport_test.go @@ -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") + } +} diff --git a/backend/internal/settings/repository.go b/backend/internal/settings/repository.go index 01f28cd..b288779 100644 --- a/backend/internal/settings/repository.go +++ b/backend/internal/settings/repository.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" ) type PricingRule struct { @@ -12,6 +13,12 @@ type PricingRule struct { OutputPerMillion float64 `json:"output_per_million"` } +const ( + UpstreamProxyModeSystem = "system" + UpstreamProxyModeDirect = "direct" + UpstreamProxyModeManual = "manual" +) + type AppSettings struct { LaunchAtLogin bool `json:"launch_at_login"` SilentStart bool `json:"silent_start"` @@ -22,6 +29,10 @@ type AppSettings struct { UsageRequestTimeoutSeconds int `json:"usage_request_timeout_seconds"` ProxyHost string `json:"proxy_host"` ProxyPort int `json:"proxy_port"` + UpstreamProxyMode string `json:"upstream_proxy_mode"` + UpstreamProxyURL string `json:"upstream_proxy_url"` + UpstreamProxyUsername string `json:"upstream_proxy_username"` + UpstreamProxyPassword string `json:"upstream_proxy_password"` AutoFailoverEnabled bool `json:"auto_failover_enabled"` AutoBackupIntervalHours int `json:"auto_backup_interval_hours"` BackupRetentionCount int `json:"backup_retention_count"` @@ -59,6 +70,7 @@ func DefaultAppSettings() AppSettings { UsageRequestTimeoutSeconds: 15, ProxyHost: "127.0.0.1", ProxyPort: 6789, + UpstreamProxyMode: UpstreamProxyModeSystem, AutoFailoverEnabled: true, AutoBackupIntervalHours: 24, BackupRetentionCount: 10, @@ -69,7 +81,8 @@ func DefaultAppSettings() AppSettings { func (r *SQLiteRepository) GetAppSettings() (AppSettings, error) { row := r.db.QueryRow( - `SELECT launch_at_login, silent_start, close_to_tray, show_proxy_switch_on_home, show_home_update_indicator, status_refresh_interval_seconds, usage_request_timeout_seconds, proxy_host, proxy_port, auto_failover_enabled, auto_backup_interval_hours, backup_retention_count, + `SELECT launch_at_login, silent_start, close_to_tray, show_proxy_switch_on_home, show_home_update_indicator, status_refresh_interval_seconds, usage_request_timeout_seconds, proxy_host, proxy_port, + upstream_proxy_mode, upstream_proxy_url, upstream_proxy_username, upstream_proxy_password, auto_failover_enabled, auto_backup_interval_hours, backup_retention_count, language, theme_mode, provider_pricing, account_pricing FROM app_settings WHERE id = 1`, ) @@ -83,6 +96,10 @@ func (r *SQLiteRepository) GetAppSettings() (AppSettings, error) { var usageRequestTimeoutSeconds int var proxyHost string var proxyPort int + var upstreamProxyMode string + var upstreamProxyURL string + var upstreamProxyUsername string + var upstreamProxyPassword string var autoFailoverEnabled int var autoBackupIntervalHours int var backupRetentionCount int @@ -101,6 +118,10 @@ func (r *SQLiteRepository) GetAppSettings() (AppSettings, error) { &usageRequestTimeoutSeconds, &proxyHost, &proxyPort, + &upstreamProxyMode, + &upstreamProxyURL, + &upstreamProxyUsername, + &upstreamProxyPassword, &autoFailoverEnabled, &autoBackupIntervalHours, &backupRetentionCount, @@ -134,6 +155,10 @@ func (r *SQLiteRepository) GetAppSettings() (AppSettings, error) { UsageRequestTimeoutSeconds: usageRequestTimeoutSeconds, ProxyHost: proxyHost, ProxyPort: proxyPort, + UpstreamProxyMode: upstreamProxyMode, + UpstreamProxyURL: upstreamProxyURL, + UpstreamProxyUsername: upstreamProxyUsername, + UpstreamProxyPassword: upstreamProxyPassword, AutoFailoverEnabled: autoFailoverEnabled == 1, AutoBackupIntervalHours: autoBackupIntervalHours, BackupRetentionCount: backupRetentionCount, @@ -156,9 +181,10 @@ func (r *SQLiteRepository) SaveAppSettings(value AppSettings) error { } _, err = r.db.Exec( `INSERT INTO app_settings ( - id, launch_at_login, silent_start, close_to_tray, show_proxy_switch_on_home, show_home_update_indicator, status_refresh_interval_seconds, usage_request_timeout_seconds, proxy_host, proxy_port, auto_failover_enabled, auto_backup_interval_hours, backup_retention_count, + id, launch_at_login, silent_start, close_to_tray, show_proxy_switch_on_home, show_home_update_indicator, status_refresh_interval_seconds, usage_request_timeout_seconds, proxy_host, proxy_port, + upstream_proxy_mode, upstream_proxy_url, upstream_proxy_username, upstream_proxy_password, auto_failover_enabled, auto_backup_interval_hours, backup_retention_count, language, theme_mode, provider_pricing, account_pricing, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET launch_at_login = excluded.launch_at_login, silent_start = excluded.silent_start, @@ -169,6 +195,10 @@ func (r *SQLiteRepository) SaveAppSettings(value AppSettings) error { usage_request_timeout_seconds = excluded.usage_request_timeout_seconds, proxy_host = excluded.proxy_host, proxy_port = excluded.proxy_port, + upstream_proxy_mode = excluded.upstream_proxy_mode, + upstream_proxy_url = excluded.upstream_proxy_url, + upstream_proxy_username = excluded.upstream_proxy_username, + upstream_proxy_password = excluded.upstream_proxy_password, auto_failover_enabled = excluded.auto_failover_enabled, auto_backup_interval_hours = excluded.auto_backup_interval_hours, backup_retention_count = excluded.backup_retention_count, @@ -187,6 +217,10 @@ func (r *SQLiteRepository) SaveAppSettings(value AppSettings) error { value.UsageRequestTimeoutSeconds, value.ProxyHost, value.ProxyPort, + value.UpstreamProxyMode, + value.UpstreamProxyURL, + value.UpstreamProxyUsername, + value.UpstreamProxyPassword, boolToInt(value.AutoFailoverEnabled), value.AutoBackupIntervalHours, value.BackupRetentionCount, @@ -259,6 +293,19 @@ func sanitize(value AppSettings) AppSettings { if value.ProxyPort <= 0 { value.ProxyPort = defaults.ProxyPort } + switch value.UpstreamProxyMode { + case UpstreamProxyModeSystem, UpstreamProxyModeDirect, UpstreamProxyModeManual: + default: + value.UpstreamProxyMode = defaults.UpstreamProxyMode + } + value.UpstreamProxyURL = sanitizeOptionalString(value.UpstreamProxyURL) + value.UpstreamProxyUsername = sanitizeOptionalString(value.UpstreamProxyUsername) + value.UpstreamProxyPassword = sanitizeOptionalString(value.UpstreamProxyPassword) + if value.UpstreamProxyMode != UpstreamProxyModeManual { + value.UpstreamProxyURL = "" + value.UpstreamProxyUsername = "" + value.UpstreamProxyPassword = "" + } if value.AutoBackupIntervalHours <= 0 { value.AutoBackupIntervalHours = defaults.AutoBackupIntervalHours } @@ -358,3 +405,7 @@ func boolToInt(value bool) int { } return 0 } + +func sanitizeOptionalString(value string) string { + return strings.TrimSpace(value) +} diff --git a/backend/internal/settings/repository_test.go b/backend/internal/settings/repository_test.go index e8f326c..432a871 100644 --- a/backend/internal/settings/repository_test.go +++ b/backend/internal/settings/repository_test.go @@ -63,6 +63,10 @@ func TestRepositoryPersistsAppSettingsAndQueue(t *testing.T) { UsageRequestTimeoutSeconds: 22, ProxyHost: "localhost", ProxyPort: 15721, + UpstreamProxyMode: "manual", + UpstreamProxyURL: "http://127.0.0.1:7890", + UpstreamProxyUsername: "proxy-user", + UpstreamProxyPassword: "proxy-pass", AutoFailoverEnabled: true, AutoBackupIntervalHours: 12, BackupRetentionCount: 7, @@ -101,6 +105,35 @@ func TestRepositoryPersistsAppSettingsAndQueue(t *testing.T) { } } +func TestRepositorySanitizesUpstreamProxyMode(t *testing.T) { + t.Parallel() + + store, err := sqlitestore.Open(filepath.Join(t.TempDir(), "router.sqlite")) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + t.Cleanup(func() { + _ = store.Close() + }) + + repo := settings.NewSQLiteRepository(store.DB()) + + value := settings.DefaultAppSettings() + value.UpstreamProxyMode = "broken" + value.UpstreamProxyURL = "127.0.0.1:7890" + if err := repo.SaveAppSettings(value); err != nil { + t.Fatalf("SaveAppSettings returned error: %v", err) + } + + got, err := repo.GetAppSettings() + if err != nil { + t.Fatalf("GetAppSettings returned error: %v", err) + } + if got.UpstreamProxyMode != settings.UpstreamProxyModeSystem { + t.Fatalf("UpstreamProxyMode = %q, want %q", got.UpstreamProxyMode, settings.UpstreamProxyModeSystem) + } +} + func TestRepositoryClampsStatusRefreshInterval(t *testing.T) { t.Parallel() diff --git a/backend/internal/store/sqlite/migrations.go b/backend/internal/store/sqlite/migrations.go index b469cd4..4fcf4ef 100644 --- a/backend/internal/store/sqlite/migrations.go +++ b/backend/internal/store/sqlite/migrations.go @@ -124,6 +124,10 @@ var schemaStatements = []string{ usage_request_timeout_seconds INTEGER NOT NULL DEFAULT 15, proxy_host TEXT NOT NULL DEFAULT '127.0.0.1', proxy_port INTEGER NOT NULL DEFAULT 6789, + upstream_proxy_mode TEXT NOT NULL DEFAULT 'system', + upstream_proxy_url TEXT NOT NULL DEFAULT '', + upstream_proxy_username TEXT NOT NULL DEFAULT '', + upstream_proxy_password TEXT NOT NULL DEFAULT '', auto_failover_enabled INTEGER NOT NULL DEFAULT 0, auto_backup_interval_hours INTEGER NOT NULL DEFAULT 24, backup_retention_count INTEGER NOT NULL DEFAULT 10, diff --git a/backend/internal/store/sqlite/store.go b/backend/internal/store/sqlite/store.go index 30739f8..b705b48 100644 --- a/backend/internal/store/sqlite/store.go +++ b/backend/internal/store/sqlite/store.go @@ -125,6 +125,10 @@ func (s *Store) migrate() error { {table: "app_settings", name: "show_home_update_indicator", definition: "INTEGER NOT NULL DEFAULT 1"}, {table: "app_settings", name: "status_refresh_interval_seconds", definition: "INTEGER NOT NULL DEFAULT 60"}, {table: "app_settings", name: "usage_request_timeout_seconds", definition: "INTEGER NOT NULL DEFAULT 15"}, + {table: "app_settings", name: "upstream_proxy_mode", definition: "TEXT NOT NULL DEFAULT 'system'"}, + {table: "app_settings", name: "upstream_proxy_url", definition: "TEXT NOT NULL DEFAULT ''"}, + {table: "app_settings", name: "upstream_proxy_username", definition: "TEXT NOT NULL DEFAULT ''"}, + {table: "app_settings", name: "upstream_proxy_password", definition: "TEXT NOT NULL DEFAULT ''"}, {table: "app_settings", name: "audit_limit_message", definition: "INTEGER NOT NULL DEFAULT 200"}, {table: "app_settings", name: "audit_limit_function_call", definition: "INTEGER NOT NULL DEFAULT 100"}, {table: "app_settings", name: "audit_limit_function_call_output", definition: "INTEGER NOT NULL DEFAULT 100"}, diff --git a/frontend/src/features/settings/SettingsPage.test.tsx b/frontend/src/features/settings/SettingsPage.test.tsx index a31bafd..510cd01 100644 --- a/frontend/src/features/settings/SettingsPage.test.tsx +++ b/frontend/src/features/settings/SettingsPage.test.tsx @@ -21,6 +21,10 @@ const baseSettings = { usage_request_timeout_seconds: 15, proxy_host: "127.0.0.1", proxy_port: 6789, + upstream_proxy_mode: "system", + upstream_proxy_url: "", + upstream_proxy_username: "", + upstream_proxy_password: "", auto_failover_enabled: true, auto_backup_interval_hours: 24, backup_retention_count: 10, @@ -107,6 +111,8 @@ describe("SettingsPage", () => { expect(screen.getByRole("tab", { name: "关于" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: "代理" })); expect(await screen.findByRole("switch", { name: "自动故障转移开关" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: "手动指定" })); + fireEvent.change(screen.getByRole("textbox", { name: "上游代理地址" }), { target: { value: "http://127.0.0.1:7890" } }); expect(screen.queryByText("自动故障转移队列")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: "数据" })); expect(await screen.findByText("费用统计")).toBeInTheDocument(); @@ -125,6 +131,8 @@ describe("SettingsPage", () => { body: JSON.stringify({ ...baseSettings, launch_at_login: true, + upstream_proxy_mode: "manual", + upstream_proxy_url: "http://127.0.0.1:7890", provider_pricing: { codex: { input_per_million: 4.5, output_per_million: 0 } }, account_pricing: { "1": { input_per_million: 0, output_per_million: 15.2 } }, }), @@ -134,12 +142,16 @@ describe("SettingsPage", () => { expect(applyDesktopAppSettings).toHaveBeenCalledWith({ ...baseSettings, launch_at_login: true, + upstream_proxy_mode: "manual", + upstream_proxy_url: "http://127.0.0.1:7890", provider_pricing: { codex: { input_per_million: 4.5, output_per_million: 0 } }, account_pricing: { "1": { input_per_million: 0, output_per_million: 15.2 } }, }); expect(onSettingsChanged).toHaveBeenCalledWith({ ...baseSettings, launch_at_login: true, + upstream_proxy_mode: "manual", + upstream_proxy_url: "http://127.0.0.1:7890", provider_pricing: { codex: { input_per_million: 4.5, output_per_million: 0 } }, account_pricing: { "1": { input_per_million: 0, output_per_million: 15.2 } }, }); diff --git a/frontend/src/features/settings/SettingsPage.tsx b/frontend/src/features/settings/SettingsPage.tsx index 6418b32..5fd78ad 100644 --- a/frontend/src/features/settings/SettingsPage.tsx +++ b/frontend/src/features/settings/SettingsPage.tsx @@ -183,6 +183,10 @@ export function SettingsPage({ provider_pricing: initialSettings.provider_pricing ?? {}, account_pricing: initialSettings.account_pricing ?? {}, usage_request_timeout_seconds: initialSettings.usage_request_timeout_seconds ?? 15, + upstream_proxy_mode: initialSettings.upstream_proxy_mode ?? "system", + upstream_proxy_url: initialSettings.upstream_proxy_url ?? "", + upstream_proxy_username: initialSettings.upstream_proxy_username ?? "", + upstream_proxy_password: initialSettings.upstream_proxy_password ?? "", }); }, [initialSettings]); @@ -580,6 +584,56 @@ export function SettingsPage({ + + } + title={t("上游网络代理")} + description={t("仅影响 AI Gate 发往上游服务商的网络请求,不影响 AI Gate 本地代理监听地址。")} + /> +
+ updateDraft({ upstream_proxy_mode: event.target.value })} + > + {t("跟随系统")} + {t("直连")} + {t("手动指定")} + + {t("Clash / Mihomo 常见地址示例:")}http://127.0.0.1:7890 +
+ {draftSettings.upstream_proxy_mode === "manual" ? ( +
+ + + +
+ ) : null} +
+ ), }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d687ff8..7b19a07 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -228,6 +228,10 @@ export type AppSettings = { usage_request_timeout_seconds?: number; proxy_host: string; proxy_port: number; + upstream_proxy_mode?: "system" | "direct" | "manual"; + upstream_proxy_url?: string; + upstream_proxy_username?: string; + upstream_proxy_password?: string; auto_failover_enabled: boolean; auto_backup_interval_hours: number; backup_retention_count: number;