@@ -55,6 +55,12 @@ class ServerManager: ObservableObject {
5555 UserDefaults . standard. set ( enabledProviders, forKey: " enabledProviders " )
5656 }
5757 }
58+ @Published var proxyURL : String = " " {
59+ didSet {
60+ UserDefaults . standard. set ( proxyURL, forKey: " proxyURL " )
61+ }
62+ }
63+ private var activeConfigPath : String = " "
5864
5965 /// Vercel AI Gateway configuration for Claude requests
6066 @Published var vercelGatewayEnabled : Bool = false {
@@ -104,21 +110,51 @@ class ServerManager: ObservableObject {
104110 }
105111 vercelGatewayEnabled = UserDefaults . standard. bool ( forKey: " vercelGatewayEnabled " )
106112 vercelApiKey = UserDefaults . standard. string ( forKey: " vercelApiKey " ) ?? " "
113+ if let savedProxyURL = UserDefaults . standard. string ( forKey: " proxyURL " ) {
114+ proxyURL = savedProxyURL
115+ }
107116 }
108117
109118 /// Check if a provider is enabled (defaults to true if not set)
110119 func isProviderEnabled( _ providerKey: String ) -> Bool {
111120 return enabledProviders [ providerKey] ?? true
112121 }
113122
114- /// Set provider enabled state and regenerate config (hot reload - no restart needed )
123+ /// Set provider enabled state and regenerate config (hot reload where possible )
115124 func setProviderEnabled( _ providerKey: String , enabled: Bool ) {
116125 enabledProviders [ providerKey] = enabled
117126 addLog ( enabled ? " ✓ Enabled provider: \( providerKey) " : " ⚠️ Disabled provider: \( providerKey) " )
118127
119- // Regenerate config - CLIProxyAPI hot reloads config.yaml automatically
120- _ = getConfigPath ( )
121- addLog ( " Config updated (hot reload) " )
128+ applyConfigUpdate ( )
129+ }
130+
131+ func setProxyURL( _ url: String ) {
132+ let trimmed = url. trimmingCharacters ( in: . whitespacesAndNewlines)
133+ guard trimmed != proxyURL else { return }
134+ proxyURL = trimmed
135+ addLog ( trimmed. isEmpty ? " Proxy URL cleared " : " Proxy URL updated " )
136+
137+ applyConfigUpdate ( )
138+ }
139+
140+ private func applyConfigUpdate( ) {
141+ let newConfigPath = getConfigPath ( )
142+ guard !newConfigPath. isEmpty else { return }
143+
144+ if isRunning {
145+ let currentPath = activeConfigPath. isEmpty ? newConfigPath : activeConfigPath
146+ if currentPath != newConfigPath {
147+ activeConfigPath = newConfigPath
148+ addLog ( " Config path changed; restarting server to apply update " )
149+ stop { [ weak self] in
150+ self ? . start { _ in }
151+ }
152+ return
153+ }
154+ addLog ( " Config updated (hot reload) " )
155+ }
156+
157+ activeConfigPath = newConfigPath
122158 }
123159
124160 deinit {
@@ -198,6 +234,7 @@ class ServerManager: ObservableObject {
198234
199235 do {
200236 try process? . run ( )
237+ activeConfigPath = configPath
201238 DispatchQueue . main. async {
202239 self . isRunning = true
203240 }
@@ -255,6 +292,7 @@ class ServerManager: ObservableObject {
255292 DispatchQueue . main. async {
256293 self . process = nil
257294 self . isRunning = false
295+ self . activeConfigPath = " "
258296 self . addLog ( " ✓ Server stopped " )
259297 NotificationCenter . default. post ( name: . serverStatusChanged, object: nil )
260298 completion ? ( )
@@ -278,8 +316,11 @@ class ServerManager: ObservableObject {
278316 let authProcess = Process ( )
279317 authProcess. executableURL = URL ( fileURLWithPath: bundledPath)
280318
281- // Get the config path
282- let configPath = ( resourcePath as NSString ) . appendingPathComponent ( " config.yaml " )
319+ let configPath = getConfigPath ( )
320+ guard !configPath. isEmpty && FileManager . default. fileExists ( atPath: configPath) else {
321+ completion ( false , " Config not found " )
322+ return
323+ }
283324
284325 var qwenEmail : String ?
285326
@@ -429,12 +470,12 @@ class ServerManager: ObservableObject {
429470 completion ( true , " 🌐 Browser opened for authentication. \n \n Please complete the login in your browser. \n \n The app will automatically detect when you're authenticated. " )
430471 } else {
431472 // Process died quickly - check for error
432- let outputData = try ? outputPipe. fileHandleForReading. readDataToEndOfFile ( )
433- let errorData = try ? errorPipe. fileHandleForReading. readDataToEndOfFile ( )
473+ let outputData = outputPipe. fileHandleForReading. readDataToEndOfFile ( )
474+ let errorData = errorPipe. fileHandleForReading. readDataToEndOfFile ( )
434475
435- var output = String ( data: outputData ?? Data ( ) , encoding: . utf8) ?? " "
476+ var output = String ( data: outputData, encoding: . utf8) ?? " "
436477 if output. isEmpty { output = capture. text }
437- let error = String ( data: errorData ?? Data ( ) , encoding: . utf8) ?? " "
478+ let error = String ( data: errorData, encoding: . utf8) ?? " "
438479
439480 NSLog ( " [Auth] Process died quickly - output: %@ " , output. isEmpty ? " (empty) " : String ( output. prefix ( 200 ) ) )
440481
@@ -517,14 +558,20 @@ class ServerManager: ObservableObject {
517558 }
518559 }
519560
520- /// Returns the config path to use, merging bundled config with Z.AI provider and provider exclusions
561+ /// Returns the config path to use, merging bundled config with proxy settings, Z.AI provider, and provider exclusions
521562 func getConfigPath( ) -> String {
522563 guard let resourcePath = Bundle . main. resourcePath else {
523564 return " "
524565 }
525566
526567 let bundledConfigPath = ( resourcePath as NSString ) . appendingPathComponent ( " config.yaml " )
527568 let authDir = FileManager . default. homeDirectoryForCurrentUser. appendingPathComponent ( " .cli-proxy-api " )
569+ let mergedConfigPath = authDir. appendingPathComponent ( " merged-config.yaml " )
570+ do {
571+ try FileManager . default. createDirectory ( at: authDir, withIntermediateDirectories: true )
572+ } catch {
573+ NSLog ( " [ServerManager] Failed to create auth directory: %@ " , error. localizedDescription)
574+ }
528575
529576 // Check for Z.AI auth files
530577 var zaiApiKeys : [ String ] = [ ]
@@ -546,20 +593,31 @@ class ServerManager: ObservableObject {
546593 }
547594 }
548595
549- // If no Z.AI keys and no disabled providers, use bundled config
550- guard !zaiApiKeys. isEmpty || !disabledProviders. isEmpty else {
596+ let trimmedProxyURL = proxyURL. trimmingCharacters ( in: . whitespacesAndNewlines)
597+ let shouldAddProxy = !trimmedProxyURL. isEmpty
598+ let shouldAddProviders = !disabledProviders. isEmpty
599+ let shouldAddZai = !zaiApiKeys. isEmpty && isProviderEnabled ( " zai " )
600+
601+ if !shouldAddProxy && !shouldAddProviders && !shouldAddZai {
602+ if FileManager . default. fileExists ( atPath: mergedConfigPath. path) {
603+ try ? FileManager . default. removeItem ( at: mergedConfigPath)
604+ }
551605 return bundledConfigPath
552606 }
553607
554608 // Generate merged config
555- guard let bundledContent = try ? String ( contentsOfFile: bundledConfigPath, encoding: . utf8) else {
609+ guard var bundledContent = try ? String ( contentsOfFile: bundledConfigPath, encoding: . utf8) else {
556610 return bundledConfigPath
557611 }
558-
612+
613+ if shouldAddProxy {
614+ bundledContent = applyProxyURLOverride ( to: bundledContent, proxyURL: trimmedProxyURL)
615+ }
616+
559617 var additionalConfig = " "
560618
561619 // Build oauth-excluded-models section for disabled providers
562- if !disabledProviders . isEmpty {
620+ if shouldAddProviders {
563621 additionalConfig += """
564622
565623# Provider exclusions (auto-added by VibeProxy)
@@ -574,7 +632,7 @@ oauth-excluded-models:
574632 }
575633
576634 // Build Z.AI openai-compatibility section (only if Z.AI is enabled)
577- if !zaiApiKeys . isEmpty && isProviderEnabled ( " zai " ) {
635+ if shouldAddZai {
578636 additionalConfig += """
579637
580638# Z.AI GLM Provider (auto-added by VibeProxy)
@@ -585,12 +643,7 @@ openai-compatibility:
585643
586644"""
587645 for key in zaiApiKeys {
588- // Escape special YAML characters in double-quoted strings
589- let escapedKey = key
590- . replacingOccurrences ( of: " \\ " , with: " \\ \\ " )
591- . replacingOccurrences ( of: " \" " , with: " \\ \" " )
592- . replacingOccurrences ( of: " \n " , with: " \\ n " )
593- . replacingOccurrences ( of: " \t " , with: " \\ t " )
646+ let escapedKey = escapeYAMLDoubleQuoted ( key)
594647 additionalConfig += " - api-key: \" \( escapedKey) \" \n "
595648 }
596649 additionalConfig += """
@@ -607,7 +660,6 @@ openai-compatibility:
607660 }
608661
609662 let mergedContent = bundledContent + additionalConfig
610- let mergedConfigPath = authDir. appendingPathComponent ( " merged-config.yaml " )
611663
612664 do {
613665 try mergedContent. write ( to: mergedConfigPath, atomically: true , encoding: . utf8)
@@ -619,6 +671,35 @@ openai-compatibility:
619671 return bundledConfigPath
620672 }
621673 }
674+
675+ private func applyProxyURLOverride( to content: String , proxyURL: String ) -> String {
676+ let escapedURL = escapeYAMLDoubleQuoted ( proxyURL)
677+ let newLine = " proxy-url: \" \( escapedURL) \" "
678+ var didReplace = false
679+ let lines = content. split ( omittingEmptySubsequences: false , whereSeparator: \. isNewline)
680+ let updated = lines. map { line -> String in
681+ let trimmed = String ( line) . trimmingCharacters ( in: . whitespaces)
682+ if trimmed. hasPrefix ( " proxy-url: " ) {
683+ didReplace = true
684+ let indent = line. prefix { $0 == " " || $0 == " \t " }
685+ return " \( indent) \( newLine) "
686+ }
687+ return String ( line)
688+ }
689+ var result = updated. joined ( separator: " \n " )
690+ if !didReplace {
691+ result += " \n \n # Network proxy (auto-added by VibeProxy) \n \( newLine) \n "
692+ }
693+ return result
694+ }
695+
696+ private func escapeYAMLDoubleQuoted( _ value: String ) -> String {
697+ value
698+ . replacingOccurrences ( of: " \\ " , with: " \\ \\ " )
699+ . replacingOccurrences ( of: " \" " , with: " \\ \" " )
700+ . replacingOccurrences ( of: " \n " , with: " \\ n " )
701+ . replacingOccurrences ( of: " \t " , with: " \\ t " )
702+ }
622703
623704 func getLogs( ) -> [ String ] {
624705 return logBuffer. elements ( )
0 commit comments