-
Notifications
You must be signed in to change notification settings - Fork 52.6k
feat: add provider rotation cooldowns #31327
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: main
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 |
|---|---|---|
|
|
@@ -717,7 +717,13 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic | |
|
|
||
|
|
||
|
|
||
| def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool: | ||
| def try_activate_fallback( | ||
| agent, | ||
| reason: "FailoverReason | None" = None, | ||
| *, | ||
| rate_limit_headers: Any = None, | ||
| error_context: dict[str, Any] | None = None, | ||
| ) -> bool: | ||
| """Switch to the next fallback model/provider in the chain. | ||
|
|
||
| Called when the current model is failing after retries. Swaps the | ||
|
|
@@ -729,6 +735,8 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool | |
| auth resolution and client construction — no duplicated provider→key | ||
| mappings. | ||
| """ | ||
| rotation_enabled = False | ||
| rotation_config = {} | ||
| if reason in {FailoverReason.rate_limit, FailoverReason.billing}: | ||
| # Only start cooldown when leaving the primary provider. If we're | ||
| # already on a fallback and chain-switching, the primary wasn't the | ||
|
|
@@ -738,11 +746,69 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool | |
| primary_provider = ((agent._primary_runtime or {}).get("provider") or "").strip().lower() | ||
| if (not fallback_already_active) or (primary_provider and current_provider == primary_provider): | ||
| agent._rate_limited_until = time.monotonic() + 60 | ||
| try: | ||
| from hermes_cli.config import load_config | ||
| from agent.provider_rotation import ( | ||
| ProviderRotationState, | ||
| cooldown_for_reason, | ||
| has_durable_rate_limit_evidence, | ||
| is_rotation_enabled, | ||
| ) | ||
|
|
||
| rotation_config = load_config() | ||
| rotation_enabled = is_rotation_enabled(rotation_config) | ||
| if rotation_enabled and reason in {FailoverReason.rate_limit, FailoverReason.billing}: | ||
|
Collaborator
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. This records a multi-hour cooldown for every |
||
| should_persist_cooldown = reason == FailoverReason.billing or has_durable_rate_limit_evidence( | ||
| headers=rate_limit_headers, | ||
| last_known_state=getattr(agent, "_rate_limit_state", None), | ||
| error_context=error_context, | ||
| ) | ||
| if should_persist_cooldown: | ||
| current_provider_for_state = (getattr(agent, "provider", "") or "").strip() | ||
| current_model_for_state = (getattr(agent, "model", "") or "").strip() | ||
| current_base_url_for_state = str(getattr(agent, "base_url", "") or "").strip() | ||
| if current_provider_for_state and current_model_for_state: | ||
| ProviderRotationState.load().mark_unavailable( | ||
| provider=current_provider_for_state, | ||
| model=current_model_for_state, | ||
| base_url=current_base_url_for_state, | ||
| reason=getattr(reason, "value", str(reason)), | ||
| cooldown_seconds=cooldown_for_reason( | ||
| rotation_config, | ||
| getattr(reason, "value", str(reason)), | ||
| ), | ||
| ) | ||
| except Exception: | ||
| logger.debug("Provider rotation state update skipped", exc_info=True) | ||
|
|
||
| if agent._fallback_index >= len(agent._fallback_chain): | ||
| return False | ||
|
|
||
| fb = agent._fallback_chain[agent._fallback_index] | ||
| agent._fallback_index += 1 | ||
| if rotation_enabled: | ||
| try: | ||
| from agent.provider_rotation import ProviderRotationState | ||
|
|
||
| while ( | ||
| isinstance(fb, dict) | ||
| and ProviderRotationState.load().is_unavailable( | ||
| fb.get("provider") or "", | ||
| fb.get("model") or "", | ||
| base_url=fb.get("base_url") or "", | ||
| ) | ||
| and agent._fallback_index < len(agent._fallback_chain) | ||
| ): | ||
| fb = agent._fallback_chain[agent._fallback_index] | ||
| agent._fallback_index += 1 | ||
| if isinstance(fb, dict) and ProviderRotationState.load().is_unavailable( | ||
| fb.get("provider") or "", | ||
| fb.get("model") or "", | ||
| base_url=fb.get("base_url") or "", | ||
| ): | ||
| return False | ||
| except Exception: | ||
| logger.debug("Provider rotation filtering skipped", exc_info=True) | ||
| fb_provider = (fb.get("provider") or "").strip().lower() | ||
| fb_model = (fb.get("model") or "").strip() | ||
| if not fb_provider or not fb_model: | ||
|
|
||
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 check only runs under
if not agent._fallback_activated. After a normal fallback activation, the next long-lived-agent turn bypasses this block; once the existing 60-second_rate_limited_untilexpires, the code restores the primary without consulting this persisted cooldown. Please apply the check on the restore path too and cover that sequence.