Mirrowel commited on
Commit
98f6823
·
1 Parent(s): bd84d38

feat(usage): ✨ add provider-specific rolling window usage tracking

Browse files

Implement flexible per-provider usage reset configurations to support different quota windows (5h rolling for Antigravity paid tiers, 7-day for free tier) instead of universal daily resets.

- Add `get_usage_reset_config()` and `get_default_usage_field_name()` methods to ProviderInterface for provider-specific configuration
- Implement Antigravity-specific reset config returning different windows based on credential tier (5h for paid, 7-day for free)
- Refactor UsageManager to support custom usage field names ("5h_window", "weekly") instead of hardcoded "daily"
- Add window start timestamp tracking that begins on first request and resets after window expiration
- Extract reset logic into separate methods (`_check_window_reset`, `_check_daily_reset`) for cleaner separation
- Add credential-to-provider mapping via regex pattern matching for OAuth credential paths
- Archive expired window stats to "global" field matching existing daily reset behavior
- Preserve unexpired cooldowns during all reset types to maintain long-term quota error handling
- Pass provider_plugins to UsageManager initialization for access to provider configuration

This enables accurate quota tracking for providers with non-daily reset schedules while maintaining backward compatibility with existing daily reset behavior for providers without custom configuration.

src/rotator_library/client.py CHANGED
@@ -161,6 +161,7 @@ class RotatingClient:
161
  file_path=usage_file_path,
162
  rotation_tolerance=rotation_tolerance,
163
  provider_rotation_modes=provider_rotation_modes,
 
164
  )
165
  self._model_list_cache = {}
166
  self._provider_plugins = PROVIDER_PLUGINS
 
161
  file_path=usage_file_path,
162
  rotation_tolerance=rotation_tolerance,
163
  provider_rotation_modes=provider_rotation_modes,
164
+ provider_plugins=PROVIDER_PLUGINS,
165
  )
166
  self._model_list_cache = {}
167
  self._provider_plugins = PROVIDER_PLUGINS
src/rotator_library/providers/antigravity_provider.py CHANGED
@@ -822,6 +822,60 @@ class AntigravityProvider(AntigravityAuthBase, ProviderInterface):
822
  """
823
  return None
824
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
825
  async def initialize_credentials(self, credential_paths: List[str]) -> None:
826
  """
827
  Load persisted tier information from credential files at startup.
 
822
  """
823
  return None
824
 
825
+ def get_usage_reset_config(self, credential: str) -> Optional[Dict[str, Any]]:
826
+ """
827
+ Get Antigravity-specific usage tracking configuration based on credential tier.
828
+
829
+ Antigravity has different quota reset windows by tier:
830
+ - Paid tiers (priority 1): 5-hour rolling window
831
+ - Free tier (priority 2): 7-day rolling window
832
+ - Unknown/legacy: 7-day rolling window (conservative default)
833
+
834
+ Args:
835
+ credential: The credential path
836
+
837
+ Returns:
838
+ Usage reset configuration dict
839
+ """
840
+ tier = self.project_tier_cache.get(credential)
841
+ if not tier:
842
+ tier = self._load_tier_from_file(credential)
843
+
844
+ # Paid tiers: 5-hour window
845
+ if tier and tier not in ["free-tier", "legacy-tier", "unknown"]:
846
+ return {
847
+ "window_seconds": 5 * 60 * 60, # 18000 seconds = 5 hours
848
+ "field_name": "5h_window",
849
+ "priority": 1,
850
+ "description": "5-hour rolling window (paid tier)",
851
+ }
852
+
853
+ # Free tier: 7-day window
854
+ if tier == "free-tier":
855
+ return {
856
+ "window_seconds": 7 * 24 * 60 * 60, # 604800 seconds = 7 days
857
+ "field_name": "weekly",
858
+ "priority": 2,
859
+ "description": "7-day rolling window (free tier)",
860
+ }
861
+
862
+ # Unknown/legacy: use 7-day window as conservative default
863
+ return {
864
+ "window_seconds": 7 * 24 * 60 * 60, # 604800 seconds = 7 days
865
+ "field_name": "weekly",
866
+ "priority": 10,
867
+ "description": "7-day rolling window (unknown tier - conservative default)",
868
+ }
869
+
870
+ def get_default_usage_field_name(self) -> str:
871
+ """
872
+ Get the default usage tracking field name for Antigravity.
873
+
874
+ Returns:
875
+ "weekly" as the conservative default for unknown credentials
876
+ """
877
+ return "weekly"
878
+
879
  async def initialize_credentials(self, credential_paths: List[str]) -> None:
880
  """
881
  Load persisted tier information from credential files at startup.
src/rotator_library/providers/provider_interface.py CHANGED
@@ -206,22 +206,66 @@ class ProviderInterface(ABC):
206
  """
207
  return None # Default: no provider-specific parsing
208
 
209
- # TODO: Implement provider-specific quota reset schedules
210
- # Different providers have different quota reset periods:
211
- # - Most providers: Daily reset at a specific time
212
- # - Antigravity free tier: Weekly reset
213
- # - Antigravity paid tier: 5-hour rolling window
214
- #
215
- # Future implementation should add:
216
- # @classmethod
217
- # def get_quota_reset_behavior(cls) -> Dict[str, Any]:
218
- # """
219
- # Get provider-specific quota reset behavior.
220
- # Returns:
221
- # {
222
- # "type": "daily" | "weekly" | "rolling",
223
- # "reset_time_utc": "03:00", # For daily/weekly
224
- # "rolling_hours": 5, # For rolling
225
- # }
226
- # """
227
- # return {"type": "daily", "reset_time_utc": "03:00"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  """
207
  return None # Default: no provider-specific parsing
208
 
209
+ # =========================================================================
210
+ # Per-Provider Usage Tracking Configuration
211
+ # =========================================================================
212
+
213
+ def get_usage_reset_config(self, credential: str) -> Optional[Dict[str, Any]]:
214
+ """
215
+ Get provider-specific usage tracking configuration for a credential.
216
+
217
+ This allows providers to define custom usage reset windows based on
218
+ credential tier (e.g., paid vs free accounts with different quota periods).
219
+
220
+ The UsageManager will use this configuration to:
221
+ 1. Track usage in a custom-named field (instead of default "daily")
222
+ 2. Reset usage based on a rolling window from first request
223
+ 3. Archive stats to "global" when the window expires
224
+
225
+ Args:
226
+ credential: The credential identifier (API key or path)
227
+
228
+ Returns:
229
+ None to use default daily reset, otherwise a dict with:
230
+ {
231
+ "window_seconds": int, # Duration in seconds (e.g., 18000 for 5h)
232
+ "field_name": str, # Custom field name (e.g., "5h_window", "weekly")
233
+ "priority": int, # Priority level this config applies to (for docs)
234
+ "description": str, # Human-readable description (for logging)
235
+ }
236
+
237
+ Examples:
238
+ Antigravity paid tier:
239
+ {
240
+ "window_seconds": 18000, # 5 hours
241
+ "field_name": "5h_window",
242
+ "priority": 1,
243
+ "description": "5-hour rolling window (paid tier)"
244
+ }
245
+
246
+ Antigravity free tier:
247
+ {
248
+ "window_seconds": 604800, # 7 days
249
+ "field_name": "weekly",
250
+ "priority": 2,
251
+ "description": "7-day rolling window (free tier)"
252
+ }
253
+
254
+ Note:
255
+ - window_seconds: Time from first request until stats reset
256
+ - When window expires, stats move to "global" (same as daily reset)
257
+ - First request after window expiry starts a new window
258
+ """
259
+ return None # Default: use daily reset at daily_reset_time_utc
260
+
261
+ def get_default_usage_field_name(self) -> str:
262
+ """
263
+ Get the default usage tracking field name for this provider.
264
+
265
+ Providers can override this to use a custom field name for usage tracking
266
+ when no credential-specific config is available.
267
+
268
+ Returns:
269
+ Field name string (default: "daily")
270
+ """
271
+ return "daily"
src/rotator_library/usage_manager.py CHANGED
@@ -54,6 +54,7 @@ class UsageManager:
54
  daily_reset_time_utc: Optional[str] = "03:00",
55
  rotation_tolerance: float = 0.0,
56
  provider_rotation_modes: Optional[Dict[str, str]] = None,
 
57
  ):
58
  """
59
  Initialize the UsageManager.
@@ -68,10 +69,13 @@ class UsageManager:
68
  provider_rotation_modes: Dict mapping provider names to rotation modes.
69
  - "balanced": Rotate credentials to distribute load evenly (default)
70
  - "sequential": Use one credential until exhausted (preserves caching)
 
 
71
  """
72
  self.file_path = file_path
73
  self.rotation_tolerance = rotation_tolerance
74
  self.provider_rotation_modes = provider_rotation_modes or {}
 
75
  self.key_states: Dict[str, Dict[str, Any]] = {}
76
 
77
  self._data_lock = asyncio.Lock()
@@ -102,6 +106,112 @@ class UsageManager:
102
  """
103
  return self.provider_rotation_modes.get(provider, "balanced")
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  def _select_sequential(
106
  self,
107
  candidates: List[Tuple[str, int]],
@@ -186,129 +296,233 @@ class UsageManager:
186
  await f.write(json.dumps(self._usage_data, indent=2))
187
 
188
  async def _reset_daily_stats_if_needed(self):
189
- """Checks if daily stats need to be reset for any key."""
190
- if self._usage_data is None or not self.daily_reset_time_utc:
 
 
 
 
 
 
191
  return
192
 
193
  now_utc = datetime.now(timezone.utc)
 
194
  today_str = now_utc.date().isoformat()
195
  needs_saving = False
196
 
197
  for key, data in self._usage_data.items():
198
- last_reset_str = data.get("last_daily_reset", "")
199
-
200
- if last_reset_str != today_str:
201
- last_reset_dt = None
202
- if last_reset_str:
203
- # Ensure the parsed datetime is timezone-aware (UTC)
204
- last_reset_dt = datetime.fromisoformat(last_reset_str).replace(
205
- tzinfo=timezone.utc
206
- )
207
 
208
- # Determine the reset threshold for today
209
- reset_threshold_today = datetime.combine(
210
- now_utc.date(), self.daily_reset_time_utc
 
 
 
 
 
 
211
  )
212
 
213
- if (
214
- last_reset_dt is None
215
- or last_reset_dt < reset_threshold_today <= now_utc
216
- ):
217
- lib_logger.debug(
218
- f"Performing daily reset for key {mask_credential(key)}"
219
- )
220
- needs_saving = True
221
-
222
- # Reset cooldowns - BUT preserve unexpired long-term cooldowns
223
- # This is important for quota errors with long cooldowns (e.g., 143 hours)
224
- now_ts = time.time()
225
- if "model_cooldowns" in data:
226
- active_cooldowns = {
227
- model: end_time
228
- for model, end_time in data["model_cooldowns"].items()
229
- if end_time > now_ts
230
- }
231
- if active_cooldowns:
232
- # Calculate how long the longest cooldown has remaining
233
- max_remaining = max(
234
- end_time - now_ts
235
- for end_time in active_cooldowns.values()
236
- )
237
- hours_remaining = max_remaining / 3600
238
- lib_logger.info(
239
- f"Preserving {len(active_cooldowns)} active cooldown(s) "
240
- f"for key {mask_credential(key)} during daily reset "
241
- f"(longest: {hours_remaining:.1f}h remaining)"
242
- )
243
- data["model_cooldowns"] = active_cooldowns
244
- else:
245
- data["model_cooldowns"] = {}
246
 
247
- # Clear key-level cooldown only if expired
248
- if data.get("key_cooldown_until"):
249
- if data["key_cooldown_until"] <= now_ts:
250
- data["key_cooldown_until"] = None
251
- else:
252
- hours_remaining = (
253
- data["key_cooldown_until"] - now_ts
254
- ) / 3600
255
- lib_logger.info(
256
- f"Preserving key-level cooldown for {mask_credential(key)} "
257
- f"during daily reset ({hours_remaining:.1f}h remaining)"
258
- )
259
- else:
260
- data["key_cooldown_until"] = None
261
-
262
- # Reset consecutive failures
263
- if "failures" in data:
264
- data["failures"] = {}
265
-
266
- # TODO: Implement provider-specific reset schedules
267
- # Different providers have different quota reset periods:
268
- # - Most providers: Daily reset at daily_reset_time_utc
269
- # - Antigravity free tier: Weekly reset
270
- # - Antigravity paid tier: 5-hour rolling window
271
- #
272
- # Future implementation should:
273
- # 1. Group credentials by provider (extracted from key path or metadata)
274
- # 2. Check each provider's get_quota_reset_behavior()
275
- # 3. Apply provider-specific reset logic instead of universal daily reset
276
- #
277
- # For now, we preserve unexpired cooldowns which handles long cooldowns correctly.
278
-
279
- # Archive global stats from the previous day's 'daily'
280
- daily_data = data.get("daily", {})
281
- if daily_data:
282
- global_data = data.setdefault("global", {"models": {}})
283
- for model, stats in daily_data.get("models", {}).items():
284
- global_model_stats = global_data["models"].setdefault(
285
- model,
286
- {
287
- "success_count": 0,
288
- "prompt_tokens": 0,
289
- "completion_tokens": 0,
290
- "approx_cost": 0.0,
291
- },
292
- )
293
- global_model_stats["success_count"] += stats.get(
294
- "success_count", 0
295
- )
296
- global_model_stats["prompt_tokens"] += stats.get(
297
- "prompt_tokens", 0
298
- )
299
- global_model_stats["completion_tokens"] += stats.get(
300
- "completion_tokens", 0
301
- )
302
- global_model_stats["approx_cost"] += stats.get(
303
- "approx_cost", 0.0
304
- )
305
 
306
- # Reset daily stats
307
- data["daily"] = {"date": today_str, "models": {}}
308
- data["last_daily_reset"] = today_str
 
 
309
 
310
- if needs_saving:
311
- await self._save_usage()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
  def _initialize_key_states(self, keys: List[str]):
314
  """Initializes state tracking for all provided keys if not already present."""
@@ -430,12 +644,7 @@ class UsageManager:
430
  priority = credential_priorities.get(key, 999)
431
 
432
  # Get usage count for load balancing within priority groups
433
- usage_count = (
434
- key_data.get("daily", {})
435
- .get("models", {})
436
- .get(model, {})
437
- .get("success_count", 0)
438
- )
439
 
440
  # Group by priority
441
  if priority not in priority_groups:
@@ -577,12 +786,7 @@ class UsageManager:
577
  continue
578
 
579
  # Prioritize keys based on their current usage to ensure load balancing.
580
- usage_count = (
581
- key_data.get("daily", {})
582
- .get("models", {})
583
- .get(model, {})
584
- .get("success_count", 0)
585
- )
586
  key_state = self.key_states[key]
587
 
588
  # Tier 1: Completely idle keys (preferred).
@@ -743,22 +947,50 @@ class UsageManager:
743
  """
744
  Records a successful API call, resetting failure counters.
745
  It safely handles cases where token usage data is not available.
 
 
 
746
  """
747
  await self._lazy_init()
748
  async with self._data_lock:
 
749
  today_utc_str = datetime.now(timezone.utc).date().isoformat()
750
- key_data = self._usage_data.setdefault(
751
- key,
752
- {
753
- "daily": {"date": today_utc_str, "models": {}},
754
- "global": {"models": {}},
755
- "model_cooldowns": {},
756
- "failures": {},
757
- },
758
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
759
 
760
  # If the key is new, ensure its reset date is initialized to prevent an immediate reset.
761
- if "last_daily_reset" not in key_data:
762
  key_data["last_daily_reset"] = today_utc_str
763
 
764
  # Always record a success and reset failures
@@ -767,7 +999,24 @@ class UsageManager:
767
  if model in key_data.get("model_cooldowns", {}):
768
  del key_data["model_cooldowns"][model]
769
 
770
- daily_model_data = key_data["daily"]["models"].setdefault(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
  model,
772
  {
773
  "success_count": 0,
@@ -776,7 +1025,7 @@ class UsageManager:
776
  "approx_cost": 0.0,
777
  },
778
  )
779
- daily_model_data["success_count"] += 1
780
 
781
  # Safely attempt to record token and cost usage
782
  if (
@@ -785,8 +1034,8 @@ class UsageManager:
785
  and completion_response.usage
786
  ):
787
  usage = completion_response.usage
788
- daily_model_data["prompt_tokens"] += usage.prompt_tokens
789
- daily_model_data["completion_tokens"] += getattr(
790
  usage, "completion_tokens", 0
791
  ) # Not present in embedding responses
792
  lib_logger.info(
@@ -794,7 +1043,7 @@ class UsageManager:
794
  )
795
  try:
796
  provider_name = model.split("/")[0]
797
- provider_plugin = PROVIDER_PLUGINS.get(provider_name)
798
 
799
  # Check class attribute directly - no need to instantiate
800
  if provider_plugin and getattr(
@@ -821,7 +1070,7 @@ class UsageManager:
821
  )
822
 
823
  if cost is not None:
824
- daily_model_data["approx_cost"] += cost
825
  except Exception as e:
826
  lib_logger.warning(
827
  f"Could not calculate cost for model {model}: {e}"
@@ -836,7 +1085,7 @@ class UsageManager:
836
  f"No usage data found in completion response for model {model}. Recording success without token count."
837
  )
838
 
839
- key_data["last_used_ts"] = time.time()
840
 
841
  await self._save_usage()
842
 
@@ -859,15 +1108,33 @@ class UsageManager:
859
  await self._lazy_init()
860
  async with self._data_lock:
861
  today_utc_str = datetime.now(timezone.utc).date().isoformat()
862
- key_data = self._usage_data.setdefault(
863
- key,
864
- {
865
- "daily": {"date": today_utc_str, "models": {}},
866
- "global": {"models": {}},
867
- "model_cooldowns": {},
868
- "failures": {},
869
- },
870
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
871
 
872
  # Provider-level errors (transient issues) should not count against the key
873
  provider_level_errors = {"server_error", "api_connection"}
 
54
  daily_reset_time_utc: Optional[str] = "03:00",
55
  rotation_tolerance: float = 0.0,
56
  provider_rotation_modes: Optional[Dict[str, str]] = None,
57
+ provider_plugins: Optional[Dict[str, Any]] = None,
58
  ):
59
  """
60
  Initialize the UsageManager.
 
69
  provider_rotation_modes: Dict mapping provider names to rotation modes.
70
  - "balanced": Rotate credentials to distribute load evenly (default)
71
  - "sequential": Use one credential until exhausted (preserves caching)
72
+ provider_plugins: Dict mapping provider names to provider plugin instances.
73
+ Used for per-provider usage reset configuration (window durations, field names).
74
  """
75
  self.file_path = file_path
76
  self.rotation_tolerance = rotation_tolerance
77
  self.provider_rotation_modes = provider_rotation_modes or {}
78
+ self.provider_plugins = provider_plugins or PROVIDER_PLUGINS
79
  self.key_states: Dict[str, Dict[str, Any]] = {}
80
 
81
  self._data_lock = asyncio.Lock()
 
106
  """
107
  return self.provider_rotation_modes.get(provider, "balanced")
108
 
109
+ def _get_provider_from_credential(self, credential: str) -> Optional[str]:
110
+ """
111
+ Extract provider name from credential path or identifier.
112
+
113
+ Supports multiple credential formats:
114
+ - OAuth: "oauth_creds/antigravity_oauth_15.json" -> "antigravity"
115
+ - OAuth: "C:\\...\\oauth_creds\\gemini_cli_oauth_1.json" -> "gemini_cli"
116
+ - API key style: stored with provider prefix metadata
117
+
118
+ Args:
119
+ credential: The credential identifier (path or key)
120
+
121
+ Returns:
122
+ Provider name string or None if cannot be determined
123
+ """
124
+ import re
125
+
126
+ # Normalize path separators
127
+ normalized = credential.replace("\\", "/")
128
+
129
+ # Pattern: {provider}_oauth_{number}.json
130
+ match = re.search(r"/([a-z_]+)_oauth_\d+\.json$", normalized, re.IGNORECASE)
131
+ if match:
132
+ return match.group(1).lower()
133
+
134
+ # Pattern: oauth_creds/{provider}_...
135
+ match = re.search(r"oauth_creds/([a-z_]+)_", normalized, re.IGNORECASE)
136
+ if match:
137
+ return match.group(1).lower()
138
+
139
+ return None
140
+
141
+ def _get_usage_reset_config(self, credential: str) -> Optional[Dict[str, Any]]:
142
+ """
143
+ Get the usage reset configuration for a credential from its provider plugin.
144
+
145
+ Args:
146
+ credential: The credential identifier
147
+
148
+ Returns:
149
+ Configuration dict with window_seconds, field_name, etc.
150
+ or None to use default daily reset.
151
+ """
152
+ provider = self._get_provider_from_credential(credential)
153
+ if not provider:
154
+ return None
155
+
156
+ plugin = self.provider_plugins.get(provider)
157
+ if not plugin:
158
+ return None
159
+
160
+ if hasattr(plugin, "get_usage_reset_config"):
161
+ return plugin.get_usage_reset_config(credential)
162
+
163
+ return None
164
+
165
+ def _get_usage_field_name(self, credential: str) -> str:
166
+ """
167
+ Get the usage tracking field name for a credential.
168
+
169
+ Returns the provider-specific field name if configured,
170
+ otherwise falls back to "daily".
171
+
172
+ Args:
173
+ credential: The credential identifier
174
+
175
+ Returns:
176
+ Field name string (e.g., "5h_window", "weekly", "daily")
177
+ """
178
+ config = self._get_usage_reset_config(credential)
179
+ if config and "field_name" in config:
180
+ return config["field_name"]
181
+
182
+ # Check provider default
183
+ provider = self._get_provider_from_credential(credential)
184
+ if provider:
185
+ plugin = self.provider_plugins.get(provider)
186
+ if plugin and hasattr(plugin, "get_default_usage_field_name"):
187
+ return plugin.get_default_usage_field_name()
188
+
189
+ return "daily"
190
+
191
+ def _get_usage_count(self, key: str, model: str) -> int:
192
+ """
193
+ Get the current usage count for a model from the appropriate usage field.
194
+
195
+ Args:
196
+ key: Credential identifier
197
+ model: Model name
198
+
199
+ Returns:
200
+ Usage count (success_count) for the model in the current window/daily period
201
+ """
202
+ if self._usage_data is None:
203
+ return 0
204
+
205
+ key_data = self._usage_data.get(key, {})
206
+ usage_field = self._get_usage_field_name(key)
207
+
208
+ return (
209
+ key_data.get(usage_field, {})
210
+ .get("models", {})
211
+ .get(model, {})
212
+ .get("success_count", 0)
213
+ )
214
+
215
  def _select_sequential(
216
  self,
217
  candidates: List[Tuple[str, int]],
 
296
  await f.write(json.dumps(self._usage_data, indent=2))
297
 
298
  async def _reset_daily_stats_if_needed(self):
299
+ """
300
+ Checks if usage stats need to be reset for any key.
301
+
302
+ Supports two reset modes:
303
+ 1. Provider-specific rolling windows (e.g., 5h for Antigravity paid, 7d for free)
304
+ 2. Legacy daily reset at daily_reset_time_utc for providers without custom config
305
+ """
306
+ if self._usage_data is None:
307
  return
308
 
309
  now_utc = datetime.now(timezone.utc)
310
+ now_ts = time.time()
311
  today_str = now_utc.date().isoformat()
312
  needs_saving = False
313
 
314
  for key, data in self._usage_data.items():
315
+ # Check for provider-specific reset configuration
316
+ reset_config = self._get_usage_reset_config(key)
 
 
 
 
 
 
 
317
 
318
+ if reset_config:
319
+ # Provider-specific rolling window reset
320
+ needs_saving |= await self._check_window_reset(
321
+ key, data, reset_config, now_ts
322
+ )
323
+ elif self.daily_reset_time_utc:
324
+ # Legacy daily reset for providers without custom config
325
+ needs_saving |= await self._check_daily_reset(
326
+ key, data, now_utc, today_str, now_ts
327
  )
328
 
329
+ if needs_saving:
330
+ await self._save_usage()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
 
332
+ async def _check_window_reset(
333
+ self,
334
+ key: str,
335
+ data: Dict[str, Any],
336
+ reset_config: Dict[str, Any],
337
+ now_ts: float,
338
+ ) -> bool:
339
+ """
340
+ Check and perform rolling window reset for a credential.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
 
342
+ Args:
343
+ key: Credential identifier
344
+ data: Usage data for this credential
345
+ reset_config: Provider's reset configuration
346
+ now_ts: Current timestamp
347
 
348
+ Returns:
349
+ True if data was modified and needs saving
350
+ """
351
+ window_seconds = reset_config.get("window_seconds", 86400) # Default 24h
352
+ field_name = reset_config.get("field_name", "window")
353
+ description = reset_config.get("description", "rolling window")
354
+
355
+ # Get current window data
356
+ window_data = data.get(field_name, {})
357
+ window_start = window_data.get("start_ts")
358
+
359
+ # No window started yet - nothing to reset
360
+ if window_start is None:
361
+ return False
362
+
363
+ # Check if window has expired
364
+ window_end = window_start + window_seconds
365
+ if now_ts < window_end:
366
+ # Window still active
367
+ return False
368
+
369
+ # Window expired - perform reset
370
+ hours_elapsed = (now_ts - window_start) / 3600
371
+ lib_logger.info(
372
+ f"Resetting {field_name} for {mask_credential(key)} - "
373
+ f"{description} expired after {hours_elapsed:.1f}h"
374
+ )
375
+
376
+ # Archive to global
377
+ self._archive_to_global(data, window_data)
378
+
379
+ # Preserve unexpired cooldowns
380
+ self._preserve_unexpired_cooldowns(key, data, now_ts)
381
+
382
+ # Reset window stats (but don't start new window until first request)
383
+ data[field_name] = {"start_ts": None, "models": {}}
384
+
385
+ # Reset consecutive failures
386
+ if "failures" in data:
387
+ data["failures"] = {}
388
+
389
+ return True
390
+
391
+ async def _check_daily_reset(
392
+ self,
393
+ key: str,
394
+ data: Dict[str, Any],
395
+ now_utc: datetime,
396
+ today_str: str,
397
+ now_ts: float,
398
+ ) -> bool:
399
+ """
400
+ Check and perform legacy daily reset for a credential.
401
+
402
+ Args:
403
+ key: Credential identifier
404
+ data: Usage data for this credential
405
+ now_utc: Current datetime in UTC
406
+ today_str: Today's date as ISO string
407
+ now_ts: Current timestamp
408
+
409
+ Returns:
410
+ True if data was modified and needs saving
411
+ """
412
+ last_reset_str = data.get("last_daily_reset", "")
413
+
414
+ if last_reset_str == today_str:
415
+ return False
416
+
417
+ last_reset_dt = None
418
+ if last_reset_str:
419
+ try:
420
+ last_reset_dt = datetime.fromisoformat(last_reset_str).replace(
421
+ tzinfo=timezone.utc
422
+ )
423
+ except ValueError:
424
+ pass
425
+
426
+ # Determine the reset threshold for today
427
+ reset_threshold_today = datetime.combine(
428
+ now_utc.date(), self.daily_reset_time_utc
429
+ )
430
+
431
+ if not (
432
+ last_reset_dt is None or last_reset_dt < reset_threshold_today <= now_utc
433
+ ):
434
+ return False
435
+
436
+ lib_logger.debug(f"Performing daily reset for key {mask_credential(key)}")
437
+
438
+ # Preserve unexpired cooldowns
439
+ self._preserve_unexpired_cooldowns(key, data, now_ts)
440
+
441
+ # Reset consecutive failures
442
+ if "failures" in data:
443
+ data["failures"] = {}
444
+
445
+ # Archive daily stats to global
446
+ daily_data = data.get("daily", {})
447
+ if daily_data:
448
+ self._archive_to_global(data, daily_data)
449
+
450
+ # Reset daily stats
451
+ data["daily"] = {"date": today_str, "models": {}}
452
+ data["last_daily_reset"] = today_str
453
+
454
+ return True
455
+
456
+ def _archive_to_global(
457
+ self, data: Dict[str, Any], source_data: Dict[str, Any]
458
+ ) -> None:
459
+ """
460
+ Archive usage stats from a source field (daily/window) to global.
461
+
462
+ Args:
463
+ data: The credential's usage data
464
+ source_data: The source field data to archive (has "models" key)
465
+ """
466
+ global_data = data.setdefault("global", {"models": {}})
467
+ for model, stats in source_data.get("models", {}).items():
468
+ global_model_stats = global_data["models"].setdefault(
469
+ model,
470
+ {
471
+ "success_count": 0,
472
+ "prompt_tokens": 0,
473
+ "completion_tokens": 0,
474
+ "approx_cost": 0.0,
475
+ },
476
+ )
477
+ global_model_stats["success_count"] += stats.get("success_count", 0)
478
+ global_model_stats["prompt_tokens"] += stats.get("prompt_tokens", 0)
479
+ global_model_stats["completion_tokens"] += stats.get("completion_tokens", 0)
480
+ global_model_stats["approx_cost"] += stats.get("approx_cost", 0.0)
481
+
482
+ def _preserve_unexpired_cooldowns(
483
+ self, key: str, data: Dict[str, Any], now_ts: float
484
+ ) -> None:
485
+ """
486
+ Preserve unexpired cooldowns during reset (important for long quota cooldowns).
487
+
488
+ Args:
489
+ key: Credential identifier (for logging)
490
+ data: The credential's usage data
491
+ now_ts: Current timestamp
492
+ """
493
+ # Preserve unexpired model cooldowns
494
+ if "model_cooldowns" in data:
495
+ active_cooldowns = {
496
+ model: end_time
497
+ for model, end_time in data["model_cooldowns"].items()
498
+ if end_time > now_ts
499
+ }
500
+ if active_cooldowns:
501
+ max_remaining = max(
502
+ end_time - now_ts for end_time in active_cooldowns.values()
503
+ )
504
+ hours_remaining = max_remaining / 3600
505
+ lib_logger.info(
506
+ f"Preserving {len(active_cooldowns)} active cooldown(s) "
507
+ f"for key {mask_credential(key)} during reset "
508
+ f"(longest: {hours_remaining:.1f}h remaining)"
509
+ )
510
+ data["model_cooldowns"] = active_cooldowns
511
+ else:
512
+ data["model_cooldowns"] = {}
513
+
514
+ # Preserve unexpired key-level cooldown
515
+ if data.get("key_cooldown_until"):
516
+ if data["key_cooldown_until"] <= now_ts:
517
+ data["key_cooldown_until"] = None
518
+ else:
519
+ hours_remaining = (data["key_cooldown_until"] - now_ts) / 3600
520
+ lib_logger.info(
521
+ f"Preserving key-level cooldown for {mask_credential(key)} "
522
+ f"during reset ({hours_remaining:.1f}h remaining)"
523
+ )
524
+ else:
525
+ data["key_cooldown_until"] = None
526
 
527
  def _initialize_key_states(self, keys: List[str]):
528
  """Initializes state tracking for all provided keys if not already present."""
 
644
  priority = credential_priorities.get(key, 999)
645
 
646
  # Get usage count for load balancing within priority groups
647
+ usage_count = self._get_usage_count(key, model)
 
 
 
 
 
648
 
649
  # Group by priority
650
  if priority not in priority_groups:
 
786
  continue
787
 
788
  # Prioritize keys based on their current usage to ensure load balancing.
789
+ usage_count = self._get_usage_count(key, model)
 
 
 
 
 
790
  key_state = self.key_states[key]
791
 
792
  # Tier 1: Completely idle keys (preferred).
 
947
  """
948
  Records a successful API call, resetting failure counters.
949
  It safely handles cases where token usage data is not available.
950
+
951
+ Uses provider-specific field names for usage tracking (e.g., "5h_window", "weekly")
952
+ and sets window start timestamp on first request.
953
  """
954
  await self._lazy_init()
955
  async with self._data_lock:
956
+ now_ts = time.time()
957
  today_utc_str = datetime.now(timezone.utc).date().isoformat()
958
+
959
+ # Determine the usage field name for this credential
960
+ usage_field = self._get_usage_field_name(key)
961
+ reset_config = self._get_usage_reset_config(key)
962
+ uses_window = reset_config is not None
963
+
964
+ # Initialize key data with appropriate structure
965
+ if uses_window:
966
+ # Provider-specific rolling window
967
+ key_data = self._usage_data.setdefault(
968
+ key,
969
+ {
970
+ usage_field: {"start_ts": None, "models": {}},
971
+ "global": {"models": {}},
972
+ "model_cooldowns": {},
973
+ "failures": {},
974
+ },
975
+ )
976
+ # Ensure the usage field exists (for migration from old format)
977
+ if usage_field not in key_data:
978
+ key_data[usage_field] = {"start_ts": None, "models": {}}
979
+ else:
980
+ # Legacy daily reset
981
+ key_data = self._usage_data.setdefault(
982
+ key,
983
+ {
984
+ "daily": {"date": today_utc_str, "models": {}},
985
+ "global": {"models": {}},
986
+ "model_cooldowns": {},
987
+ "failures": {},
988
+ },
989
+ )
990
+ usage_field = "daily"
991
 
992
  # If the key is new, ensure its reset date is initialized to prevent an immediate reset.
993
+ if not uses_window and "last_daily_reset" not in key_data:
994
  key_data["last_daily_reset"] = today_utc_str
995
 
996
  # Always record a success and reset failures
 
999
  if model in key_data.get("model_cooldowns", {}):
1000
  del key_data["model_cooldowns"][model]
1001
 
1002
+ # Get or create the usage field data
1003
+ usage_data = key_data.setdefault(usage_field, {"models": {}})
1004
+
1005
+ # For window-based tracking, set start_ts on first request
1006
+ if uses_window:
1007
+ if usage_data.get("start_ts") is None:
1008
+ usage_data["start_ts"] = now_ts
1009
+ window_hours = reset_config.get("window_seconds", 0) / 3600
1010
+ description = reset_config.get("description", "rolling window")
1011
+ lib_logger.info(
1012
+ f"Starting new {window_hours:.1f}h window for {mask_credential(key)} - {description}"
1013
+ )
1014
+
1015
+ # Ensure models dict exists
1016
+ if "models" not in usage_data:
1017
+ usage_data["models"] = {}
1018
+
1019
+ model_data = usage_data["models"].setdefault(
1020
  model,
1021
  {
1022
  "success_count": 0,
 
1025
  "approx_cost": 0.0,
1026
  },
1027
  )
1028
+ model_data["success_count"] += 1
1029
 
1030
  # Safely attempt to record token and cost usage
1031
  if (
 
1034
  and completion_response.usage
1035
  ):
1036
  usage = completion_response.usage
1037
+ model_data["prompt_tokens"] += usage.prompt_tokens
1038
+ model_data["completion_tokens"] += getattr(
1039
  usage, "completion_tokens", 0
1040
  ) # Not present in embedding responses
1041
  lib_logger.info(
 
1043
  )
1044
  try:
1045
  provider_name = model.split("/")[0]
1046
+ provider_plugin = self.provider_plugins.get(provider_name)
1047
 
1048
  # Check class attribute directly - no need to instantiate
1049
  if provider_plugin and getattr(
 
1070
  )
1071
 
1072
  if cost is not None:
1073
+ model_data["approx_cost"] += cost
1074
  except Exception as e:
1075
  lib_logger.warning(
1076
  f"Could not calculate cost for model {model}: {e}"
 
1085
  f"No usage data found in completion response for model {model}. Recording success without token count."
1086
  )
1087
 
1088
+ key_data["last_used_ts"] = now_ts
1089
 
1090
  await self._save_usage()
1091
 
 
1108
  await self._lazy_init()
1109
  async with self._data_lock:
1110
  today_utc_str = datetime.now(timezone.utc).date().isoformat()
1111
+
1112
+ # Determine the usage field name for this credential
1113
+ usage_field = self._get_usage_field_name(key)
1114
+ reset_config = self._get_usage_reset_config(key)
1115
+ uses_window = reset_config is not None
1116
+
1117
+ # Initialize key data with appropriate structure
1118
+ if uses_window:
1119
+ key_data = self._usage_data.setdefault(
1120
+ key,
1121
+ {
1122
+ usage_field: {"start_ts": None, "models": {}},
1123
+ "global": {"models": {}},
1124
+ "model_cooldowns": {},
1125
+ "failures": {},
1126
+ },
1127
+ )
1128
+ else:
1129
+ key_data = self._usage_data.setdefault(
1130
+ key,
1131
+ {
1132
+ "daily": {"date": today_utc_str, "models": {}},
1133
+ "global": {"models": {}},
1134
+ "model_cooldowns": {},
1135
+ "failures": {},
1136
+ },
1137
+ )
1138
 
1139
  # Provider-level errors (transient issues) should not count against the key
1140
  provider_level_errors = {"server_error", "api_connection"}