feat(agent): pre-emptive RPM throttling using x-ratelimit response headers - #7490
Tranquil-Flow wants to merge 2 commits into
Conversation
4f15379 to
dd0907d
Compare
|
Re-ported onto current What changed in the re-port:
Providers gated to New head: Note: this is framed as Phase 2 of the rate-limit hardening work (Phase 1 = concurrency semaphore for z.ai/Kimi in #7479, also re-ported this session — though that one's a much bigger surface). |
Providers like Anthropic, OpenAI, and OpenRouter enforce RPM limits and return remaining-request counts in response headers. The existing rate-limit infrastructure (agent/rate_limit_tracker.py + AIAgent ._capture_rate_limits) captures and displays these via /usage, but the agent had no THROTTLE action — sustained high-volume sessions still ate 429s before recovering via fallback chains. Adds: - agent/rpm_throttler.py — maybe_throttle(state, provider) sleeps until the minute window resets when remaining_requests <= 2. Sleeps in 1s chunks for interrupt responsiveness. Caps at 65s. Skips when no RPM data (limit=0), when headroom is fine, or when the window is about to reset anyway (< 0.5s). - AIAgent._maybe_rpm_throttle() forwarder on run_agent.py. - Wire-in at agent/conversation_loop.py before the per-iteration API call (above _interruptible_streaming_api_call / non-streaming fork). Single throttle site per turn — no double-fire risk. - Rate-limit capture for non-streaming responses in agent/ chat_completion_helpers.py interruptible_api_call (parallel to the existing streaming capture). Extracts the underlying httpx response via .response / ._response and feeds it through _capture_rate_limits. Only enabled for providers with known-reliable headers: anthropic, openai, openrouter, nous. Local/custom endpoints are skipped to avoid acting on headers that don't follow the same semantics. Phase 2 of the rate-limit hardening work (Phase 1: concurrency semaphore for z.ai/Kimi in NousResearch#7479). Re-port of NousResearch#7490 onto current main — main now has the rate-limit capture/display infrastructure the original PR depended on (agent/rate_limit_tracker.py with RateLimitBucket + RateLimitState), so the rpm_throttler module ports verbatim. The call-site wiring moved to the new conversation_loop module location. Closes NousResearch#7069
dd0907d to
03b1852
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks for carrying the existing header-tracking work into a proactive mechanism. The underlying gap remains on current main: capture is still streaming-only at agent/chat_completion_helpers.py:2176, with no preflight RPM throttle.
Problems
agent/rpm_throttler.py:82relies on a numeric reset value, butagent/rate_limit_tracker.py:85-89parses withfloat(). OpenAI documents values such as1s; that becomes0.0, and line 83 skips the throttle.agent/rpm_throttler.py:30advertises OpenRouter support, but OpenRouter documentsX-RateLimit-*headers on its platform-limit429responses, not successful inference responses. The success-path capture cannot pre-empt it.agent/rpm_throttler.py:70does not verifystate.provider. Fallback changesagent.provideratagent/chat_completion_helpers.py:1409, while the captured state remains, so one supported provider can inherit another provider's low-quota wait.
Suggested changes
- Add provider-specific header/reset parsing tests, including OpenAI duration resets.
- Re-scope or redesign OpenRouter support around its documented quota endpoint.
- Match/clear captured state across provider switches and cover fallback transitions.
Automated hermes-sweeper review.
| # How long until the minute window resets? The bucket adjusts for | ||
| # elapsed time since the header was captured. | ||
| sleep_for = bucket.remaining_seconds_now | ||
| if sleep_for < MIN_THROTTLE_SLEEP: |
There was a problem hiding this comment.
RateLimitBucket currently obtains reset_seconds through float() in agent/rate_limit_tracker.py; OpenAI documents duration-valued resets such as 1s. That parses as 0.0, then line 83 skips throttling. Please parse the provider format and add a real-header regression before listing OpenAI as supported.
|
|
||
| # Only throttle providers with known-reliable headers. | ||
| if provider.lower() not in RPM_THROTTLE_PROVIDERS: | ||
| return 0.0 |
There was a problem hiding this comment.
This allow-list check does not establish that the cached state came from the active provider. Fallback activation changes agent.provider but does not clear _rate_limit_state, so supported-provider fallback A→B can sleep on A's depleted bucket. Compare normalized state.provider here or clear state on every provider transition.
| "anthropic", | ||
| "openai", | ||
| "openrouter", | ||
| "nous", |
There was a problem hiding this comment.
OpenRouter's current limits documentation says successful inference responses do not contain X-RateLimit-*; the headers are returned on platform-limit 429 responses. Because capture runs after a successful response, this entry cannot provide pre-emptive throttling. Please remove it or obtain state from OpenRouter's documented key endpoint.
|
Closing #7490 based on the sweeper review. Three concerns block a faithful salvage:
Closing to clear the queue. A new PR that parses provider-format duration resets (with real-header tests), removes or re-scopes the OpenRouter entry against the documented quota endpoint, and matches/clears captured state on provider switches will land cleanly. |
What does this PR do?
Adds pre-emptive RPM throttling for Anthropic, OpenAI, OpenRouter, and Nous providers using
x-ratelimit-remaining-requestsresponse headers. When remaining requests fall to ≤ threshold (default: 2), sleeps until the minute window resets — preventing 429 errors before they happen.Problem: Hermes already parses
x-ratelimit-*headers (agent/rate_limit_tracker.py) and displays them via/usage, but never acts on them. When the agent approaches a provider's RPM limit, it burns through remaining requests and hits 429s, triggering expensive retry/failover loops. The header data is right there — we just weren't using it for pacing.Additionally fixes a non-streaming header capture gap:
_capture_rate_limits()was only called after streaming responses (line ~4597 ofrun_agent.py). Non-streaming API calls never captured headers, so the throttler would have no data to work with on those code paths. Non-streaming paths now also capture via.response/._responseattributes.Architecture:
agent/rpm_throttler.pywithmaybe_throttle(state, provider, threshold=2)— checksrequests_min.remaining, sleeps if ≤ threshold.RPM_THROTTLE_PROVIDERSfrozenset:anthropic,openai,openrouter,nous.MAX_THROTTLE_SLEEP), minimum 0.5s to avoid busy-spin.remaining_seconds_nowfromRateLimitBucketwhich accounts for time since header capture._maybe_rpm_throttle()method inrun_agent.pywrapsmaybe_throttle()with exception safety; called before each LLM API call in the main agent loop (line ~7812).Config: Currently uses hardcoded defaults (threshold=2). The
thresholdparameter is exposed as a function argument for future config integration (e.g.,rpm_throttle_thresholdincustom_providers).Related Issue
Closes #7489
Related: Phase 1 (concurrency semaphore for z.ai/Kimi): #7479. Existing header parser:
agent/rate_limit_tracker.py.Type of Change
Changes Made
agent/rpm_throttler.pyimplementingmaybe_throttle()and provider allow-listagent/run_agent.py:_maybe_rpm_throttle()wrapper, called before each LLM API call; non-streaming_interruptible_api_call()now captures headers from response before returningtests/agent/test_rpm_throttler.pyHow to Test
pytest tests/agent/test_rpm_throttler.py -q(20 passed)pytest tests/agent/ -q→ 1041 passed, 1 pre-existing failure (unrelated)/usage, confirm throttle activates at low remaining countsTest coverage:
Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AScreenshots / Logs