-
-
Notifications
You must be signed in to change notification settings - Fork 56
feat(rotation): manual account selection #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -912,6 +912,9 @@ def show_provider_detail_screen(self, provider: str): | |
| self.console.print(" G. Toggle view mode (current/global)") | ||
| self.console.print(" R. Reload stats (from proxy cache)") | ||
| self.console.print(" RA. Reload all stats") | ||
| self.console.print() | ||
| self.console.print(" [cyan]1-9. Force use of credential [N] (locks rotation)[/cyan]") | ||
| self.console.print(" C. Clear forced credential (resume normal rotation)") | ||
|
|
||
| # Force refresh options (only for providers that support it) | ||
| has_quota_groups = bool( | ||
|
|
@@ -964,6 +967,84 @@ def show_provider_detail_screen(self, provider: str): | |
| "[bold]Reloading all stats...", spinner="dots" | ||
| ): | ||
| self.post_action("reload", scope="all") | ||
| elif choice.isdigit() and 1 <= int(choice) <= 9: | ||
| # Handle numeric selection (force credential) | ||
| idx = int(choice) | ||
| credentials = ( | ||
| self.cached_stats.get("providers", {}) | ||
| .get(provider, {}) | ||
| .get("credentials", []) | ||
| if self.cached_stats | ||
| else [] | ||
| ) | ||
| # Sort credentials naturally to match display order | ||
| credentials = sorted(credentials, key=natural_sort_key) | ||
|
|
||
| if idx <= len(credentials): | ||
| cred = credentials[idx - 1] | ||
| # Use full_path for matching, fall back to identifier | ||
| cred_identifier = cred.get("full_path", cred.get("identifier", "")) | ||
| cred_email = cred.get("email", cred.get("identifier", "")) | ||
|
|
||
| # Call API to force this credential | ||
| url = self._build_endpoint_url("/v1/force-credential") | ||
| payload = { | ||
| "credential": cred_identifier, | ||
| "provider": provider | ||
| } | ||
|
|
||
| try: | ||
| with httpx.Client(timeout=10.0) as http_client: | ||
| response = http_client.post( | ||
| url, | ||
| headers=self._get_headers(), | ||
| json=payload | ||
| ) | ||
|
Comment on lines
+997
to
+1002
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Creating a new |
||
|
|
||
| if response.status_code == 200: | ||
| result = response.json() | ||
| self.console.print( | ||
| f"\n[green]✓ Forced credential:[/green] [{idx}] {cred_email}" | ||
| ) | ||
| self.console.print( | ||
| "[dim]All requests will now use this credential (if available)[/dim]" | ||
| ) | ||
| else: | ||
| self.console.print( | ||
| f"\n[red]Failed to force credential: HTTP {response.status_code}[/red]" | ||
| ) | ||
| except Exception as e: | ||
| self.console.print(f"\n[red]Error: {e}[/red]") | ||
|
|
||
| Prompt.ask("Press Enter to continue", default="") | ||
| else: | ||
| self.console.print(f"\n[red]Invalid selection. Only {len(credentials)} credentials available.[/red]") | ||
| Prompt.ask("Press Enter to continue", default="") | ||
| elif choice == "C": | ||
| # Clear forced credential | ||
| url = self._build_endpoint_url("/v1/force-credential") | ||
| payload = {"credential": None} | ||
|
|
||
| try: | ||
| with httpx.Client(timeout=10.0) as http_client: | ||
| response = http_client.post( | ||
| url, | ||
| headers=self._get_headers(), | ||
| json=payload | ||
| ) | ||
|
|
||
| if response.status_code == 200: | ||
| self.console.print( | ||
| "\n[green]✓ Forced credential cleared. Resuming normal rotation.[/green]" | ||
| ) | ||
| else: | ||
| self.console.print( | ||
| f"\n[red]Failed to clear forced credential: HTTP {response.status_code}[/red]" | ||
| ) | ||
| except Exception as e: | ||
| self.console.print(f"\n[red]Error: {e}[/red]") | ||
|
|
||
| Prompt.ask("Press Enter to continue", default="") | ||
| elif choice == "F" and has_quota_groups: | ||
| result = None | ||
| with self.console.status( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -162,6 +162,10 @@ def __init__( | |
| # Resilient writer for usage data persistence | ||
| self._state_writer = ResilientStateWriter(file_path, lib_logger) | ||
|
|
||
| # Forced credential for manual override (TUI control) | ||
| self._forced_credential: Optional[str] = None | ||
| self._forced_credential_lock = asyncio.Lock() | ||
|
|
||
| if daily_reset_time_utc: | ||
| hour, minute = map(int, daily_reset_time_utc.split(":")) | ||
| self.daily_reset_time_utc = dt_time( | ||
|
|
@@ -182,6 +186,37 @@ def _get_rotation_mode(self, provider: str) -> str: | |
| """ | ||
| return self.provider_rotation_modes.get(provider, "balanced") | ||
|
|
||
| # ========================================================================= | ||
| # FORCED CREDENTIAL (TUI OVERRIDE) | ||
| # ========================================================================= | ||
|
|
||
| async def set_forced_credential(self, credential: Optional[str]) -> None: | ||
| """ | ||
| Force the usage manager to use a specific credential for all requests. | ||
|
|
||
| This overrides the normal rotation logic and always selects the specified | ||
| credential, if it's available and not on cooldown. | ||
|
|
||
| Args: | ||
| credential: Full credential path/identifier, or None to clear the override | ||
| """ | ||
| async with self._forced_credential_lock: | ||
| self._forced_credential = credential | ||
| if credential: | ||
| lib_logger.info(f"Forced credential set to: {mask_credential(credential)}") | ||
| else: | ||
| lib_logger.info("Forced credential cleared") | ||
|
|
||
| async def get_forced_credential(self) -> Optional[str]: | ||
| """ | ||
| Get the currently forced credential, if any. | ||
|
|
||
| Returns: | ||
| The forced credential path/identifier, or None if no override is active | ||
| """ | ||
| async with self._forced_credential_lock: | ||
| return self._forced_credential | ||
|
|
||
| # ========================================================================= | ||
| # FAIR CYCLE ROTATION HELPERS | ||
| # ========================================================================= | ||
|
|
@@ -2163,6 +2198,64 @@ async def acquire_key( | |
| self._normalize_model(available_keys[0], model) if available_keys else model | ||
| ) | ||
|
|
||
| # Check if a specific credential is forced (TUI override) | ||
| forced_cred = await self.get_forced_credential() | ||
| if forced_cred: | ||
| # Find matching credential - support both full path and filename matching | ||
| matched_cred = None | ||
| if forced_cred in available_keys: | ||
| matched_cred = forced_cred | ||
| else: | ||
| # Try matching by filename (basename) | ||
| for key in available_keys: | ||
| if key.endswith(forced_cred) or Path(key).name == forced_cred: | ||
| matched_cred = key | ||
| break | ||
|
Comment on lines
+2209
to
+2213
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Matching by basename (filename) could be ambiguous if multiple credentials have the same filename in different directories. While probably rare, it might be safer to prioritize exact matches (which you already do) and perhaps log a warning if multiple basename matches are found. |
||
|
|
||
| if matched_cred: | ||
| now = time.time() | ||
| async with self._data_lock: | ||
| key_data = self._usage_data.get(matched_cred, {}) | ||
| # Check if forced credential is available (not on cooldown) | ||
| is_on_cooldown = ( | ||
| (key_data.get("key_cooldown_until") or 0) > now or | ||
| (key_data.get("model_cooldowns", {}).get(normalized_model) or 0) > now | ||
| ) | ||
|
|
||
| if not is_on_cooldown: | ||
| # Try to acquire the forced credential | ||
| state = self.key_states[matched_cred] | ||
| async with state["lock"]: | ||
| current_count = state["models_in_use"].get(model, 0) | ||
| if current_count < max_concurrent: | ||
| state["models_in_use"][model] = current_count + 1 | ||
| tier_name = ( | ||
| credential_tier_names.get(matched_cred, "unknown") | ||
| if credential_tier_names | ||
| else "unknown" | ||
| ) | ||
| quota_display = self._get_quota_display(matched_cred, model) | ||
| lib_logger.info( | ||
| f"Acquired FORCED key {mask_credential(matched_cred)} for model {model} " | ||
| f"(tier: {tier_name}, {quota_display})" | ||
| ) | ||
| return matched_cred | ||
| else: | ||
| lib_logger.warning( | ||
| f"Forced credential {mask_credential(matched_cred)} is at max concurrency " | ||
| f"({current_count}/{max_concurrent}), falling back to normal rotation" | ||
| ) | ||
| else: | ||
| lib_logger.warning( | ||
| f"Forced credential {mask_credential(matched_cred)} is on cooldown, " | ||
| f"falling back to normal rotation" | ||
| ) | ||
| else: | ||
| lib_logger.warning( | ||
| f"Forced credential {mask_credential(forced_cred)} not found in available credentials, " | ||
| f"falling back to normal rotation" | ||
| ) | ||
|
Comment on lines
+2244
to
+2257
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fallback to normal rotation when a forced credential is on cooldown or at max concurrency is a safe choice for availability. However, if a user forces a credential, they might prefer a clear error if it can't be used. Since the TUI mentions "(if available)", this behavior is at least documented, but it's worth considering if a stricter 'force' is needed. |
||
|
|
||
| # This loop continues as long as the global deadline has not been met. | ||
| while time.time() < deadline: | ||
| now = time.time() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This path splitting logic is a bit brittle. Consider using
Path(credential).namefrompathlibfor a more robust cross-platform solution.