Mirrowel commited on
Commit
92211ea
·
1 Parent(s): 772abcf

feat(auth): ✨ add configurable OAuth callback ports for all providers

Browse files

Introduce environment variable configuration for OAuth callback server ports across Gemini CLI, Antigravity, and iFlow providers to prevent port conflicts and support multi-instance deployments.

- Add `*_OAUTH_PORT` environment variables (GEMINI_CLI_OAUTH_PORT, ANTIGRAVITY_OAUTH_PORT, IFLOW_OAUTH_PORT)
- Implement dynamic port resolution with fallback to hardcoded defaults
- Add comprehensive documentation section explaining port configuration methods
- Integrate port settings into Settings Tool UI for easy configuration
- Update provider implementations to use configurable ports via property/function
- Optimize launcher TUI startup by deferring heavy provider imports to Settings Tool
- Add validation and warning logging for invalid port values

Configuration can be managed via TUI settings menu or `.env` file. Port changes take effect on next authentication attempt without affecting existing tokens.

DOCUMENTATION.md CHANGED
@@ -856,6 +856,42 @@ class AntigravityAuthBase(GoogleOAuthBase):
856
  - Headless environment detection
857
  - Sequential refresh queue processing
858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  ---
860
 
861
 
@@ -877,8 +913,8 @@ The `GeminiCliProvider` is the most complex implementation, mimicking the Google
877
 
878
  #### Authentication (`gemini_auth_base.py`)
879
 
880
- * **Device Flow**: Uses a standard OAuth 2.0 flow. The `credential_tool` spins up a local web server (`localhost:8085`) to capture the callback from Google's auth page.
881
- * **Token Lifecycle**:
882
  * **Proactive Refresh**: Tokens are refreshed 5 minutes before expiry.
883
  * **Atomic Writes**: Credential files are updated using a temp-file-and-move strategy to prevent corruption during writes.
884
  * **Revocation Handling**: If a `400` or `401` occurs during refresh, the token is marked as revoked, preventing infinite retry loops.
@@ -907,7 +943,7 @@ The provider employs a sophisticated, cached discovery mechanism to find a valid
907
  ### 3.3. iFlow (`iflow_provider.py`)
908
 
909
  * **Hybrid Auth**: Uses a custom OAuth flow (Authorization Code) to obtain an `access_token`. However, the *actual* API calls use a separate `apiKey` that is retrieved from the user's profile (`/api/oauth/getUserInfo`) using the access token.
910
- * **Callback Server**: The auth flow spins up a local server on port `11451` to capture the redirect.
911
  * **Token Management**: Automatically refreshes the OAuth token and re-fetches the API key if needed.
912
  * **Schema Cleaning**: Similar to Qwen, it aggressively sanitizes tool schemas to prevent 400 errors.
913
  * **Dedicated Logging**: Implements `_IFlowFileLogger` to capture raw chunks for debugging proprietary API behaviors.
 
856
  - Headless environment detection
857
  - Sequential refresh queue processing
858
 
859
+ #### OAuth Callback Port Configuration
860
+
861
+ Each OAuth provider uses a local callback server during authentication. The callback port can be customized via environment variables to avoid conflicts with other services.
862
+
863
+ **Default Ports:**
864
+
865
+ | Provider | Default Port | Environment Variable |
866
+ |----------|-------------|---------------------|
867
+ | Gemini CLI | 8085 | `GEMINI_CLI_OAUTH_PORT` |
868
+ | Antigravity | 51121 | `ANTIGRAVITY_OAUTH_PORT` |
869
+ | iFlow | 11451 | `IFLOW_OAUTH_PORT` |
870
+
871
+ **Configuration Methods:**
872
+
873
+ 1. **Via TUI Settings Menu:**
874
+ - Main Menu → `4. View Provider & Advanced Settings` → `1. Launch Settings Tool`
875
+ - Select the provider (Gemini CLI, Antigravity, or iFlow)
876
+ - Modify the `*_OAUTH_PORT` setting
877
+ - Use "Reset to Default" to restore the original port
878
+
879
+ 2. **Via `.env` file:**
880
+ ```env
881
+ # Custom OAuth callback ports (optional)
882
+ GEMINI_CLI_OAUTH_PORT=8085
883
+ ANTIGRAVITY_OAUTH_PORT=51121
884
+ IFLOW_OAUTH_PORT=11451
885
+ ```
886
+
887
+ **When to Change Ports:**
888
+
889
+ - If the default port conflicts with another service on your system
890
+ - If running multiple proxy instances on the same machine
891
+ - If firewall rules require specific port ranges
892
+
893
+ **Note:** Port changes take effect on the next OAuth authentication attempt. Existing tokens are not affected.
894
+
895
  ---
896
 
897
 
 
913
 
914
  #### Authentication (`gemini_auth_base.py`)
915
 
916
+ * **Device Flow**: Uses a standard OAuth 2.0 flow. The `credential_tool` spins up a local web server (default: `localhost:8085`, configurable via `GEMINI_CLI_OAUTH_PORT`) to capture the callback from Google's auth page.
917
+ * **Token Lifecycle**:
918
  * **Proactive Refresh**: Tokens are refreshed 5 minutes before expiry.
919
  * **Atomic Writes**: Credential files are updated using a temp-file-and-move strategy to prevent corruption during writes.
920
  * **Revocation Handling**: If a `400` or `401` occurs during refresh, the token is marked as revoked, preventing infinite retry loops.
 
943
  ### 3.3. iFlow (`iflow_provider.py`)
944
 
945
  * **Hybrid Auth**: Uses a custom OAuth flow (Authorization Code) to obtain an `access_token`. However, the *actual* API calls use a separate `apiKey` that is retrieved from the user's profile (`/api/oauth/getUserInfo`) using the access token.
946
+ * **Callback Server**: The auth flow spins up a local server (default: port `11451`, configurable via `IFLOW_OAUTH_PORT`) to capture the redirect.
947
  * **Token Management**: Automatically refreshes the OAuth token and re-fetches the API key if needed.
948
  * **Schema Cleaning**: Similar to Qwen, it aggressively sanitizes tool schemas to prevent 400 errors.
949
  * **Dedicated Logging**: Implements `_IFlowFileLogger` to capture raw chunks for debugging proprietary API behaviors.
src/proxy_app/launcher_tui.py CHANGED
@@ -107,7 +107,7 @@ class SettingsDetector:
107
 
108
  @staticmethod
109
  def get_all_settings() -> dict:
110
- """Returns comprehensive settings overview"""
111
  return {
112
  "credentials": SettingsDetector.detect_credentials(),
113
  "custom_bases": SettingsDetector.detect_custom_api_bases(),
@@ -117,6 +117,17 @@ class SettingsDetector:
117
  "provider_settings": SettingsDetector.detect_provider_settings(),
118
  }
119
 
 
 
 
 
 
 
 
 
 
 
 
120
  @staticmethod
121
  def detect_credentials() -> dict:
122
  """Detect API keys and OAuth credentials"""
@@ -277,8 +288,8 @@ class LauncherTUI:
277
  """Display main menu and handle selection"""
278
  clear_screen()
279
 
280
- # Detect all settings
281
- settings = SettingsDetector.get_all_settings()
282
  credentials = settings["credentials"]
283
  custom_bases = settings["custom_bases"]
284
 
@@ -363,18 +374,17 @@ class LauncherTUI:
363
  self.console.print("━" * 70)
364
  provider_count = len(credentials)
365
  custom_count = len(custom_bases)
366
- provider_settings = settings.get("provider_settings", {})
 
 
 
367
  has_advanced = bool(
368
  settings["model_definitions"]
369
  or settings["concurrency_limits"]
370
  or settings["model_filters"]
371
- or provider_settings
372
  )
373
-
374
- self.console.print(f" Providers: {provider_count} configured")
375
- self.console.print(f" Custom Providers: {custom_count} configured")
376
  self.console.print(
377
- f" Advanced Settings: {'Active (view in menu 4)' if has_advanced else 'None'}"
378
  )
379
 
380
  # Show menu
@@ -659,13 +669,14 @@ class LauncherTUI:
659
  """Display provider/advanced settings (read-only + launch tool)"""
660
  clear_screen()
661
 
662
- settings = SettingsDetector.get_all_settings()
 
 
663
  credentials = settings["credentials"]
664
  custom_bases = settings["custom_bases"]
665
  model_defs = settings["model_definitions"]
666
  concurrency = settings["concurrency_limits"]
667
  filters = settings["model_filters"]
668
- provider_settings = settings.get("provider_settings", {})
669
 
670
  self.console.print(
671
  Panel.fit(
@@ -740,23 +751,13 @@ class LauncherTUI:
740
  status = " + ".join(status_parts) if status_parts else "None"
741
  self.console.print(f" • {provider:15} ✅ {status}")
742
 
743
- # Provider-Specific Settings
744
  self.console.print()
745
  self.console.print("[bold]🔬 Provider-Specific Settings[/bold]")
746
  self.console.print("━" * 70)
747
- try:
748
- from proxy_app.settings_tool import PROVIDER_SETTINGS_MAP
749
- except ImportError:
750
- from .settings_tool import PROVIDER_SETTINGS_MAP
751
- for provider in PROVIDER_SETTINGS_MAP.keys():
752
- display_name = provider.replace("_", " ").title()
753
- modified = provider_settings.get(provider, 0)
754
- if modified > 0:
755
- self.console.print(
756
- f" • {display_name:20} [yellow]{modified} setting{'s' if modified > 1 else ''} modified[/yellow]"
757
- )
758
- else:
759
- self.console.print(f" • {display_name:20} [dim]using defaults[/dim]")
760
 
761
  # Actions
762
  self.console.print()
@@ -827,7 +828,23 @@ class LauncherTUI:
827
 
828
  def launch_settings_tool(self):
829
  """Launch settings configuration tool"""
830
- from proxy_app.settings_tool import run_settings_tool
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
 
832
  run_settings_tool()
833
  # Reload environment after settings tool
 
107
 
108
  @staticmethod
109
  def get_all_settings() -> dict:
110
+ """Returns comprehensive settings overview (includes provider_settings which triggers heavy imports)"""
111
  return {
112
  "credentials": SettingsDetector.detect_credentials(),
113
  "custom_bases": SettingsDetector.detect_custom_api_bases(),
 
117
  "provider_settings": SettingsDetector.detect_provider_settings(),
118
  }
119
 
120
+ @staticmethod
121
+ def get_basic_settings() -> dict:
122
+ """Returns basic settings overview without provider_settings (avoids heavy imports)"""
123
+ return {
124
+ "credentials": SettingsDetector.detect_credentials(),
125
+ "custom_bases": SettingsDetector.detect_custom_api_bases(),
126
+ "model_definitions": SettingsDetector.detect_model_definitions(),
127
+ "concurrency_limits": SettingsDetector.detect_concurrency_limits(),
128
+ "model_filters": SettingsDetector.detect_model_filters(),
129
+ }
130
+
131
  @staticmethod
132
  def detect_credentials() -> dict:
133
  """Detect API keys and OAuth credentials"""
 
288
  """Display main menu and handle selection"""
289
  clear_screen()
290
 
291
+ # Detect basic settings (excludes provider_settings to avoid heavy imports)
292
+ settings = SettingsDetector.get_basic_settings()
293
  credentials = settings["credentials"]
294
  custom_bases = settings["custom_bases"]
295
 
 
374
  self.console.print("━" * 70)
375
  provider_count = len(credentials)
376
  custom_count = len(custom_bases)
377
+
378
+ self.console.print(f" Providers: {provider_count} configured")
379
+ self.console.print(f" Custom Providers: {custom_count} configured")
380
+ # Note: provider_settings detection is deferred to avoid heavy imports on startup
381
  has_advanced = bool(
382
  settings["model_definitions"]
383
  or settings["concurrency_limits"]
384
  or settings["model_filters"]
 
385
  )
 
 
 
386
  self.console.print(
387
+ f" Advanced Settings: {'Active (view in menu 4)' if has_advanced else 'None (view menu 4 for details)'}"
388
  )
389
 
390
  # Show menu
 
669
  """Display provider/advanced settings (read-only + launch tool)"""
670
  clear_screen()
671
 
672
+ # Use basic settings to avoid heavy imports - provider_settings deferred to Settings Tool
673
+ settings = SettingsDetector.get_basic_settings()
674
+
675
  credentials = settings["credentials"]
676
  custom_bases = settings["custom_bases"]
677
  model_defs = settings["model_definitions"]
678
  concurrency = settings["concurrency_limits"]
679
  filters = settings["model_filters"]
 
680
 
681
  self.console.print(
682
  Panel.fit(
 
751
  status = " + ".join(status_parts) if status_parts else "None"
752
  self.console.print(f" • {provider:15} ✅ {status}")
753
 
754
+ # Provider-Specific Settings (deferred to Settings Tool to avoid heavy imports)
755
  self.console.print()
756
  self.console.print("[bold]🔬 Provider-Specific Settings[/bold]")
757
  self.console.print("━" * 70)
758
+ self.console.print(
759
+ " [dim]Launch Settings Tool to view/configure provider-specific settings[/dim]"
760
+ )
 
 
 
 
 
 
 
 
 
 
761
 
762
  # Actions
763
  self.console.print()
 
828
 
829
  def launch_settings_tool(self):
830
  """Launch settings configuration tool"""
831
+ import time
832
+
833
+ clear_screen()
834
+
835
+ self.console.print("━" * 70)
836
+ self.console.print("Advanced Settings Configuration Tool")
837
+ self.console.print("━" * 70)
838
+
839
+ _start_time = time.time()
840
+
841
+ with self.console.status("Initializing settings tool...", spinner="dots"):
842
+ from proxy_app.settings_tool import run_settings_tool
843
+
844
+ _elapsed = time.time() - _start_time
845
+ self.console.print(f"✓ Settings tool ready in {_elapsed:.2f}s")
846
+
847
+ time.sleep(0.3)
848
 
849
  run_settings_tool()
850
  # Reload environment after settings tool
src/proxy_app/settings_tool.py CHANGED
@@ -14,6 +14,29 @@ from dotenv import set_key, unset_key
14
 
15
  console = Console()
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  def clear_screen():
19
  """
@@ -383,6 +406,11 @@ ANTIGRAVITY_SETTINGS = {
383
  "default": "\n\nSTRICT PARAMETERS: {params}.",
384
  "description": "Template for Claude strict parameter hints in tool descriptions",
385
  },
 
 
 
 
 
386
  }
387
 
388
  # Gemini CLI provider environment variables
@@ -427,12 +455,27 @@ GEMINI_CLI_SETTINGS = {
427
  "default": "",
428
  "description": "GCP Project ID for paid tier users (required for paid tiers)",
429
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  }
431
 
432
  # Map provider names to their settings definitions
433
  PROVIDER_SETTINGS_MAP = {
434
  "antigravity": ANTIGRAVITY_SETTINGS,
435
  "gemini_cli": GEMINI_CLI_SETTINGS,
 
436
  }
437
 
438
 
 
14
 
15
  console = Console()
16
 
17
+ # Import default OAuth port values from provider modules
18
+ # These serve as the source of truth for default port values
19
+ try:
20
+ from rotator_library.providers.gemini_auth_base import GeminiAuthBase
21
+
22
+ GEMINI_CLI_DEFAULT_OAUTH_PORT = GeminiAuthBase.CALLBACK_PORT
23
+ except ImportError:
24
+ GEMINI_CLI_DEFAULT_OAUTH_PORT = 8085
25
+
26
+ try:
27
+ from rotator_library.providers.antigravity_auth_base import AntigravityAuthBase
28
+
29
+ ANTIGRAVITY_DEFAULT_OAUTH_PORT = AntigravityAuthBase.CALLBACK_PORT
30
+ except ImportError:
31
+ ANTIGRAVITY_DEFAULT_OAUTH_PORT = 51121
32
+
33
+ try:
34
+ from rotator_library.providers.iflow_auth_base import (
35
+ CALLBACK_PORT as IFLOW_DEFAULT_OAUTH_PORT,
36
+ )
37
+ except ImportError:
38
+ IFLOW_DEFAULT_OAUTH_PORT = 11451
39
+
40
 
41
  def clear_screen():
42
  """
 
406
  "default": "\n\nSTRICT PARAMETERS: {params}.",
407
  "description": "Template for Claude strict parameter hints in tool descriptions",
408
  },
409
+ "ANTIGRAVITY_OAUTH_PORT": {
410
+ "type": "int",
411
+ "default": ANTIGRAVITY_DEFAULT_OAUTH_PORT,
412
+ "description": "Local port for OAuth callback server during authentication",
413
+ },
414
  }
415
 
416
  # Gemini CLI provider environment variables
 
455
  "default": "",
456
  "description": "GCP Project ID for paid tier users (required for paid tiers)",
457
  },
458
+ "GEMINI_CLI_OAUTH_PORT": {
459
+ "type": "int",
460
+ "default": GEMINI_CLI_DEFAULT_OAUTH_PORT,
461
+ "description": "Local port for OAuth callback server during authentication",
462
+ },
463
+ }
464
+
465
+ # iFlow provider environment variables
466
+ IFLOW_SETTINGS = {
467
+ "IFLOW_OAUTH_PORT": {
468
+ "type": "int",
469
+ "default": IFLOW_DEFAULT_OAUTH_PORT,
470
+ "description": "Local port for OAuth callback server during authentication",
471
+ },
472
  }
473
 
474
  # Map provider names to their settings definitions
475
  PROVIDER_SETTINGS_MAP = {
476
  "antigravity": ANTIGRAVITY_SETTINGS,
477
  "gemini_cli": GEMINI_CLI_SETTINGS,
478
+ "iflow": IFLOW_SETTINGS,
479
  }
480
 
481
 
src/rotator_library/providers/google_oauth_base.py CHANGED
@@ -54,6 +54,25 @@ class GoogleOAuthBase:
54
  CALLBACK_PATH: str = "/oauth2callback"
55
  REFRESH_EXPIRY_BUFFER_SECONDS: int = 30 * 60 # 30 minutes
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def __init__(self):
58
  # Validate that subclass has set required attributes
59
  if self.CLIENT_ID is None:
@@ -701,14 +720,14 @@ class GoogleOAuthBase:
701
 
702
  try:
703
  server = await asyncio.start_server(
704
- handle_callback, "127.0.0.1", self.CALLBACK_PORT
705
  )
706
  from urllib.parse import urlencode
707
 
708
  auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(
709
  {
710
  "client_id": self.CLIENT_ID,
711
- "redirect_uri": f"http://localhost:{self.CALLBACK_PORT}{self.CALLBACK_PATH}",
712
  "scope": " ".join(self.OAUTH_SCOPES),
713
  "access_type": "offline",
714
  "response_type": "code",
@@ -783,7 +802,7 @@ class GoogleOAuthBase:
783
  "code": auth_code.strip(),
784
  "client_id": self.CLIENT_ID,
785
  "client_secret": self.CLIENT_SECRET,
786
- "redirect_uri": f"http://localhost:{self.CALLBACK_PORT}{self.CALLBACK_PATH}",
787
  "grant_type": "authorization_code",
788
  },
789
  )
 
54
  CALLBACK_PATH: str = "/oauth2callback"
55
  REFRESH_EXPIRY_BUFFER_SECONDS: int = 30 * 60 # 30 minutes
56
 
57
+ @property
58
+ def callback_port(self) -> int:
59
+ """
60
+ Get the OAuth callback port, checking environment variable first.
61
+
62
+ Reads from {ENV_PREFIX}_OAUTH_PORT environment variable, falling back
63
+ to the class's CALLBACK_PORT default if not set.
64
+ """
65
+ env_var = f"{self.ENV_PREFIX}_OAUTH_PORT"
66
+ env_value = os.getenv(env_var)
67
+ if env_value:
68
+ try:
69
+ return int(env_value)
70
+ except ValueError:
71
+ lib_logger.warning(
72
+ f"Invalid {env_var} value: {env_value}, using default {self.CALLBACK_PORT}"
73
+ )
74
+ return self.CALLBACK_PORT
75
+
76
  def __init__(self):
77
  # Validate that subclass has set required attributes
78
  if self.CLIENT_ID is None:
 
720
 
721
  try:
722
  server = await asyncio.start_server(
723
+ handle_callback, "127.0.0.1", self.callback_port
724
  )
725
  from urllib.parse import urlencode
726
 
727
  auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(
728
  {
729
  "client_id": self.CLIENT_ID,
730
+ "redirect_uri": f"http://localhost:{self.callback_port}{self.CALLBACK_PATH}",
731
  "scope": " ".join(self.OAUTH_SCOPES),
732
  "access_type": "offline",
733
  "response_type": "code",
 
802
  "code": auth_code.strip(),
803
  "client_id": self.CLIENT_ID,
804
  "client_secret": self.CLIENT_SECRET,
805
+ "redirect_uri": f"http://localhost:{self.callback_port}{self.CALLBACK_PATH}",
806
  "grant_type": "authorization_code",
807
  },
808
  )
src/rotator_library/providers/iflow_auth_base.py CHANGED
@@ -39,6 +39,25 @@ IFLOW_CLIENT_SECRET = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW"
39
  # Local callback server port
40
  CALLBACK_PORT = 11451
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  # Refresh tokens 24 hours before expiry
43
  REFRESH_EXPIRY_BUFFER_SECONDS = 24 * 60 * 60
44
 
@@ -931,7 +950,8 @@ class IFlowAuthBase:
931
  state = secrets.token_urlsafe(32)
932
 
933
  # Build authorization URL
934
- redirect_uri = f"http://localhost:{CALLBACK_PORT}/oauth2callback"
 
935
  auth_params = {
936
  "loginMethod": "phone",
937
  "type": "phone",
@@ -942,7 +962,7 @@ class IFlowAuthBase:
942
  auth_url = f"{IFLOW_OAUTH_AUTHORIZE_ENDPOINT}?{urlencode(auth_params)}"
943
 
944
  # Start OAuth callback server
945
- callback_server = OAuthCallbackServer(port=CALLBACK_PORT)
946
  try:
947
  await callback_server.start(expected_state=state)
948
 
 
39
  # Local callback server port
40
  CALLBACK_PORT = 11451
41
 
42
+
43
+ def get_callback_port() -> int:
44
+ """
45
+ Get the OAuth callback port, checking environment variable first.
46
+
47
+ Reads from IFLOW_OAUTH_PORT environment variable, falling back
48
+ to the default CALLBACK_PORT if not set.
49
+ """
50
+ env_value = os.getenv("IFLOW_OAUTH_PORT")
51
+ if env_value:
52
+ try:
53
+ return int(env_value)
54
+ except ValueError:
55
+ logging.getLogger("rotator_library").warning(
56
+ f"Invalid IFLOW_OAUTH_PORT value: {env_value}, using default {CALLBACK_PORT}"
57
+ )
58
+ return CALLBACK_PORT
59
+
60
+
61
  # Refresh tokens 24 hours before expiry
62
  REFRESH_EXPIRY_BUFFER_SECONDS = 24 * 60 * 60
63
 
 
950
  state = secrets.token_urlsafe(32)
951
 
952
  # Build authorization URL
953
+ callback_port = get_callback_port()
954
+ redirect_uri = f"http://localhost:{callback_port}/oauth2callback"
955
  auth_params = {
956
  "loginMethod": "phone",
957
  "type": "phone",
 
962
  auth_url = f"{IFLOW_OAUTH_AUTHORIZE_ENDPOINT}?{urlencode(auth_params)}"
963
 
964
  # Start OAuth callback server
965
+ callback_server = OAuthCallbackServer(port=callback_port)
966
  try:
967
  await callback_server.start(expected_state=state)
968