code-puppy 0.0.325__py3-none-any.whl → 0.0.336__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/agents/base_agent.py +41 -103
- code_puppy/cli_runner.py +105 -2
- code_puppy/command_line/add_model_menu.py +4 -0
- code_puppy/command_line/autosave_menu.py +5 -0
- code_puppy/command_line/colors_menu.py +5 -0
- code_puppy/command_line/config_commands.py +24 -1
- code_puppy/command_line/core_commands.py +51 -0
- code_puppy/command_line/diff_menu.py +5 -0
- code_puppy/command_line/mcp/custom_server_form.py +4 -0
- code_puppy/command_line/mcp/install_menu.py +5 -1
- code_puppy/command_line/model_settings_menu.py +5 -0
- code_puppy/command_line/motd.py +13 -7
- code_puppy/command_line/onboarding_slides.py +180 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/config.py +3 -2
- code_puppy/http_utils.py +155 -196
- code_puppy/keymap.py +10 -8
- code_puppy/messaging/rich_renderer.py +101 -19
- code_puppy/model_factory.py +86 -15
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +653 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +664 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/terminal_utils.py +168 -3
- code_puppy/tools/command_runner.py +42 -54
- code_puppy/uvx_detection.py +242 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/METADATA +30 -1
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/RECORD +44 -29
- {code_puppy-0.0.325.data → code_puppy-0.0.336.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.325.data → code_puppy-0.0.336.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/licenses/LICENSE +0 -0
code_puppy/model_factory.py
CHANGED
|
@@ -4,7 +4,6 @@ import os
|
|
|
4
4
|
import pathlib
|
|
5
5
|
from typing import Any, Dict
|
|
6
6
|
|
|
7
|
-
import httpx
|
|
8
7
|
from anthropic import AsyncAnthropic
|
|
9
8
|
from openai import AsyncAzureOpenAI
|
|
10
9
|
from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
|
|
@@ -212,6 +211,7 @@ class ModelFactory:
|
|
|
212
211
|
|
|
213
212
|
# Import OAuth model file paths from main config
|
|
214
213
|
from code_puppy.config import (
|
|
214
|
+
ANTIGRAVITY_MODELS_FILE,
|
|
215
215
|
CHATGPT_MODELS_FILE,
|
|
216
216
|
CLAUDE_MODELS_FILE,
|
|
217
217
|
GEMINI_MODELS_FILE,
|
|
@@ -223,6 +223,7 @@ class ModelFactory:
|
|
|
223
223
|
(pathlib.Path(CHATGPT_MODELS_FILE), "ChatGPT OAuth models", False),
|
|
224
224
|
(pathlib.Path(CLAUDE_MODELS_FILE), "Claude Code OAuth models", True),
|
|
225
225
|
(pathlib.Path(GEMINI_MODELS_FILE), "Gemini OAuth models", False),
|
|
226
|
+
(pathlib.Path(ANTIGRAVITY_MODELS_FILE), "Antigravity OAuth models", False),
|
|
226
227
|
]
|
|
227
228
|
|
|
228
229
|
for source_path, label, use_filtered in extra_sources:
|
|
@@ -556,24 +557,94 @@ class ModelFactory:
|
|
|
556
557
|
f"API key is not set for custom Gemini endpoint; skipping model '{model_config.get('name')}'."
|
|
557
558
|
)
|
|
558
559
|
return None
|
|
559
|
-
os.environ["GEMINI_API_KEY"] = api_key
|
|
560
560
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
561
|
+
# Check if this is an Antigravity model
|
|
562
|
+
if model_config.get("antigravity"):
|
|
563
|
+
try:
|
|
564
|
+
from code_puppy.plugins.antigravity_oauth.token import (
|
|
565
|
+
is_token_expired,
|
|
566
|
+
refresh_access_token,
|
|
567
|
+
)
|
|
568
|
+
from code_puppy.plugins.antigravity_oauth.transport import (
|
|
569
|
+
create_antigravity_client,
|
|
570
|
+
)
|
|
571
|
+
from code_puppy.plugins.antigravity_oauth.utils import (
|
|
572
|
+
load_stored_tokens,
|
|
573
|
+
save_tokens,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# Try to import custom model for thinking signatures
|
|
577
|
+
try:
|
|
578
|
+
from code_puppy.plugins.antigravity_oauth.antigravity_model import (
|
|
579
|
+
AntigravityModel,
|
|
580
|
+
)
|
|
581
|
+
except ImportError:
|
|
582
|
+
AntigravityModel = None
|
|
583
|
+
|
|
584
|
+
# Get fresh access token (refresh if needed)
|
|
585
|
+
tokens = load_stored_tokens()
|
|
586
|
+
if not tokens:
|
|
587
|
+
emit_warning(
|
|
588
|
+
"Antigravity tokens not found; run /antigravity-auth first."
|
|
589
|
+
)
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
access_token = tokens.get("access_token", "")
|
|
593
|
+
refresh_token = tokens.get("refresh_token", "")
|
|
594
|
+
expires_at = tokens.get("expires_at")
|
|
595
|
+
|
|
596
|
+
# Refresh if expired or about to expire
|
|
597
|
+
if is_token_expired(expires_at):
|
|
598
|
+
new_tokens = refresh_access_token(refresh_token)
|
|
599
|
+
if new_tokens:
|
|
600
|
+
access_token = new_tokens.access_token
|
|
601
|
+
tokens["access_token"] = new_tokens.access_token
|
|
602
|
+
tokens["refresh_token"] = new_tokens.refresh_token
|
|
603
|
+
tokens["expires_at"] = new_tokens.expires_at
|
|
604
|
+
save_tokens(tokens)
|
|
605
|
+
else:
|
|
606
|
+
emit_warning(
|
|
607
|
+
"Failed to refresh Antigravity token; run /antigravity-auth again."
|
|
608
|
+
)
|
|
609
|
+
return None
|
|
610
|
+
|
|
611
|
+
project_id = tokens.get(
|
|
612
|
+
"project_id", model_config.get("project_id", "")
|
|
613
|
+
)
|
|
614
|
+
client = create_antigravity_client(
|
|
615
|
+
access_token=access_token,
|
|
616
|
+
project_id=project_id,
|
|
617
|
+
model_name=model_config["name"],
|
|
618
|
+
base_url=url,
|
|
619
|
+
headers=headers,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
provider = GoogleProvider(
|
|
623
|
+
api_key=api_key, base_url=url, http_client=client
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# Use custom model if available to preserve thinking signatures
|
|
627
|
+
if AntigravityModel:
|
|
628
|
+
model = AntigravityModel(
|
|
629
|
+
model_name=model_config["name"], provider=provider
|
|
630
|
+
)
|
|
631
|
+
else:
|
|
632
|
+
model = GoogleModel(
|
|
633
|
+
model_name=model_config["name"], provider=provider
|
|
634
|
+
)
|
|
564
635
|
|
|
565
|
-
|
|
566
|
-
def base_url(self):
|
|
567
|
-
return url
|
|
636
|
+
return model
|
|
568
637
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
return
|
|
638
|
+
except ImportError:
|
|
639
|
+
emit_warning(
|
|
640
|
+
f"Antigravity transport not available; skipping model '{model_config.get('name')}'."
|
|
641
|
+
)
|
|
642
|
+
return None
|
|
643
|
+
else:
|
|
644
|
+
client = create_async_client(headers=headers, verify=verify)
|
|
574
645
|
|
|
575
|
-
|
|
576
|
-
model = GoogleModel(model_name=model_config["name"], provider=
|
|
646
|
+
provider = GoogleProvider(api_key=api_key, base_url=url, http_client=client)
|
|
647
|
+
model = GoogleModel(model_name=model_config["name"], provider=provider)
|
|
577
648
|
return model
|
|
578
649
|
elif model_type == "cerebras":
|
|
579
650
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Antigravity OAuth Plugin for Code Puppy.
|
|
2
|
+
|
|
3
|
+
Enables authentication with Google/Antigravity APIs to access Gemini and Claude models
|
|
4
|
+
via Google credentials. Supports multi-account load balancing and automatic failover.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .config import ANTIGRAVITY_OAUTH_CONFIG
|
|
8
|
+
from .register_callbacks import * # noqa: F401, F403
|
|
9
|
+
|
|
10
|
+
__all__ = ["ANTIGRAVITY_OAUTH_CONFIG"]
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Multi-account manager for Antigravity OAuth with load balancing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Dict, List, Literal, Optional
|
|
9
|
+
|
|
10
|
+
from .storage import (
|
|
11
|
+
AccountMetadata,
|
|
12
|
+
AccountStorage,
|
|
13
|
+
HeaderStyle,
|
|
14
|
+
ModelFamily,
|
|
15
|
+
QuotaKey,
|
|
16
|
+
RateLimitState,
|
|
17
|
+
load_accounts,
|
|
18
|
+
save_accounts,
|
|
19
|
+
)
|
|
20
|
+
from .token import RefreshParts, parse_refresh_parts
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ManagedAccount:
|
|
27
|
+
"""In-memory representation of a managed account."""
|
|
28
|
+
|
|
29
|
+
index: int
|
|
30
|
+
email: Optional[str]
|
|
31
|
+
added_at: float
|
|
32
|
+
last_used: float
|
|
33
|
+
parts: RefreshParts
|
|
34
|
+
access_token: Optional[str] = None
|
|
35
|
+
expires_at: Optional[float] = None
|
|
36
|
+
rate_limit_reset_times: Dict[str, float] = field(default_factory=dict)
|
|
37
|
+
last_switch_reason: Optional[Literal["rate-limit", "initial", "rotation"]] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _now_ms() -> float:
|
|
41
|
+
"""Current time in milliseconds."""
|
|
42
|
+
return time.time() * 1000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_quota_key(family: ModelFamily, header_style: HeaderStyle) -> QuotaKey:
|
|
46
|
+
"""Get the quota key for a model family and header style."""
|
|
47
|
+
if family == "claude":
|
|
48
|
+
return "claude"
|
|
49
|
+
return "gemini-cli" if header_style == "gemini-cli" else "gemini-antigravity"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_rate_limited_for_quota_key(account: ManagedAccount, key: QuotaKey) -> bool:
|
|
53
|
+
"""Check if account is rate limited for a specific quota key."""
|
|
54
|
+
reset_time = account.rate_limit_reset_times.get(key)
|
|
55
|
+
return reset_time is not None and _now_ms() < reset_time
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_rate_limited_for_family(account: ManagedAccount, family: ModelFamily) -> bool:
|
|
59
|
+
"""Check if account is rate limited for an entire model family."""
|
|
60
|
+
if family == "claude":
|
|
61
|
+
return _is_rate_limited_for_quota_key(account, "claude")
|
|
62
|
+
# For Gemini, both pools must be rate limited
|
|
63
|
+
return _is_rate_limited_for_quota_key(
|
|
64
|
+
account, "gemini-antigravity"
|
|
65
|
+
) and _is_rate_limited_for_quota_key(account, "gemini-cli")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _clear_expired_rate_limits(account: ManagedAccount) -> None:
|
|
69
|
+
"""Clear expired rate limits from an account."""
|
|
70
|
+
now = _now_ms()
|
|
71
|
+
keys_to_remove = [
|
|
72
|
+
key
|
|
73
|
+
for key, reset_time in account.rate_limit_reset_times.items()
|
|
74
|
+
if now >= reset_time
|
|
75
|
+
]
|
|
76
|
+
for key in keys_to_remove:
|
|
77
|
+
del account.rate_limit_reset_times[key]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AccountManager:
|
|
81
|
+
"""Multi-account manager with sticky account selection and load balancing.
|
|
82
|
+
|
|
83
|
+
Uses the same account until it hits a rate limit (429), then switches.
|
|
84
|
+
Rate limits are tracked per-model-family (claude/gemini) so an account
|
|
85
|
+
rate-limited for Claude can still be used for Gemini.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
initial_refresh_token: Optional[str] = None,
|
|
91
|
+
stored: Optional[AccountStorage] = None,
|
|
92
|
+
):
|
|
93
|
+
self._accounts: List[ManagedAccount] = []
|
|
94
|
+
self._cursor = 0
|
|
95
|
+
self._current_index_by_family: Dict[ModelFamily, int] = {
|
|
96
|
+
"claude": -1,
|
|
97
|
+
"gemini": -1,
|
|
98
|
+
}
|
|
99
|
+
self._last_toast_index = -1
|
|
100
|
+
self._last_toast_time = 0.0
|
|
101
|
+
|
|
102
|
+
initial_parts = parse_refresh_parts(initial_refresh_token or "")
|
|
103
|
+
|
|
104
|
+
if stored and not stored.accounts:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if stored and stored.accounts:
|
|
108
|
+
now = _now_ms()
|
|
109
|
+
for i, acc in enumerate(stored.accounts):
|
|
110
|
+
if not acc.refresh_token:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
parts = RefreshParts(
|
|
114
|
+
refresh_token=acc.refresh_token,
|
|
115
|
+
project_id=acc.project_id,
|
|
116
|
+
managed_project_id=acc.managed_project_id,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Convert rate limits from storage
|
|
120
|
+
rate_limits: Dict[str, float] = {}
|
|
121
|
+
if acc.rate_limit_reset_times.claude:
|
|
122
|
+
rate_limits["claude"] = acc.rate_limit_reset_times.claude
|
|
123
|
+
if acc.rate_limit_reset_times.gemini_antigravity:
|
|
124
|
+
rate_limits["gemini-antigravity"] = (
|
|
125
|
+
acc.rate_limit_reset_times.gemini_antigravity
|
|
126
|
+
)
|
|
127
|
+
if acc.rate_limit_reset_times.gemini_cli:
|
|
128
|
+
rate_limits["gemini-cli"] = acc.rate_limit_reset_times.gemini_cli
|
|
129
|
+
|
|
130
|
+
self._accounts.append(
|
|
131
|
+
ManagedAccount(
|
|
132
|
+
index=i,
|
|
133
|
+
email=acc.email,
|
|
134
|
+
added_at=acc.added_at or now,
|
|
135
|
+
last_used=acc.last_used or 0,
|
|
136
|
+
parts=parts,
|
|
137
|
+
access_token=None, # Tokens loaded separately
|
|
138
|
+
expires_at=None,
|
|
139
|
+
rate_limit_reset_times=rate_limits,
|
|
140
|
+
last_switch_reason=acc.last_switch_reason,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if self._accounts:
|
|
145
|
+
self._cursor = max(0, min(stored.active_index, len(self._accounts) - 1))
|
|
146
|
+
default_idx = self._cursor
|
|
147
|
+
self._current_index_by_family["claude"] = (
|
|
148
|
+
stored.active_index_by_family.get("claude", default_idx)
|
|
149
|
+
% len(self._accounts)
|
|
150
|
+
)
|
|
151
|
+
self._current_index_by_family["gemini"] = (
|
|
152
|
+
stored.active_index_by_family.get("gemini", default_idx)
|
|
153
|
+
% len(self._accounts)
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# Fallback: create single account from initial token
|
|
158
|
+
if initial_parts.refresh_token:
|
|
159
|
+
now = _now_ms()
|
|
160
|
+
self._accounts.append(
|
|
161
|
+
ManagedAccount(
|
|
162
|
+
index=0,
|
|
163
|
+
email=None,
|
|
164
|
+
added_at=now,
|
|
165
|
+
last_used=0,
|
|
166
|
+
parts=initial_parts,
|
|
167
|
+
rate_limit_reset_times={},
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
self._current_index_by_family["claude"] = 0
|
|
171
|
+
self._current_index_by_family["gemini"] = 0
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def load_from_disk(
|
|
175
|
+
cls, initial_refresh_token: Optional[str] = None
|
|
176
|
+
) -> "AccountManager":
|
|
177
|
+
"""Load account manager from disk."""
|
|
178
|
+
stored = load_accounts()
|
|
179
|
+
return cls(initial_refresh_token, stored)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def account_count(self) -> int:
|
|
183
|
+
"""Number of accounts in the pool."""
|
|
184
|
+
return len(self._accounts)
|
|
185
|
+
|
|
186
|
+
def get_accounts_snapshot(self) -> List[ManagedAccount]:
|
|
187
|
+
"""Get a snapshot of all accounts."""
|
|
188
|
+
return list(self._accounts)
|
|
189
|
+
|
|
190
|
+
def get_current_account_for_family(
|
|
191
|
+
self,
|
|
192
|
+
family: ModelFamily,
|
|
193
|
+
) -> Optional[ManagedAccount]:
|
|
194
|
+
"""Get the current active account for a model family."""
|
|
195
|
+
idx = self._current_index_by_family.get(family, -1)
|
|
196
|
+
if 0 <= idx < len(self._accounts):
|
|
197
|
+
return self._accounts[idx]
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def get_current_or_next_for_family(
|
|
201
|
+
self,
|
|
202
|
+
family: ModelFamily,
|
|
203
|
+
) -> Optional[ManagedAccount]:
|
|
204
|
+
"""Get current account if not rate limited, otherwise find next available."""
|
|
205
|
+
current = self.get_current_account_for_family(family)
|
|
206
|
+
|
|
207
|
+
if current:
|
|
208
|
+
_clear_expired_rate_limits(current)
|
|
209
|
+
if not _is_rate_limited_for_family(current, family):
|
|
210
|
+
current.last_used = _now_ms()
|
|
211
|
+
return current
|
|
212
|
+
|
|
213
|
+
# Find next available account
|
|
214
|
+
next_account = self._get_next_for_family(family)
|
|
215
|
+
if next_account:
|
|
216
|
+
self._current_index_by_family[family] = next_account.index
|
|
217
|
+
return next_account
|
|
218
|
+
|
|
219
|
+
def _get_next_for_family(self, family: ModelFamily) -> Optional[ManagedAccount]:
|
|
220
|
+
"""Get next available account for a model family."""
|
|
221
|
+
available = []
|
|
222
|
+
for acc in self._accounts:
|
|
223
|
+
_clear_expired_rate_limits(acc)
|
|
224
|
+
if not _is_rate_limited_for_family(acc, family):
|
|
225
|
+
available.append(acc)
|
|
226
|
+
|
|
227
|
+
if not available:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
account = available[self._cursor % len(available)]
|
|
231
|
+
self._cursor += 1
|
|
232
|
+
account.last_used = _now_ms()
|
|
233
|
+
return account
|
|
234
|
+
|
|
235
|
+
def mark_rate_limited(
|
|
236
|
+
self,
|
|
237
|
+
account: ManagedAccount,
|
|
238
|
+
retry_after_ms: float,
|
|
239
|
+
family: ModelFamily,
|
|
240
|
+
header_style: HeaderStyle = "antigravity",
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Mark an account as rate limited."""
|
|
243
|
+
key = _get_quota_key(family, header_style)
|
|
244
|
+
account.rate_limit_reset_times[key] = _now_ms() + retry_after_ms
|
|
245
|
+
|
|
246
|
+
def is_rate_limited_for_header_style(
|
|
247
|
+
self,
|
|
248
|
+
account: ManagedAccount,
|
|
249
|
+
family: ModelFamily,
|
|
250
|
+
header_style: HeaderStyle,
|
|
251
|
+
) -> bool:
|
|
252
|
+
"""Check if account is rate limited for a specific header style."""
|
|
253
|
+
_clear_expired_rate_limits(account)
|
|
254
|
+
key = _get_quota_key(family, header_style)
|
|
255
|
+
return _is_rate_limited_for_quota_key(account, key)
|
|
256
|
+
|
|
257
|
+
def get_available_header_style(
|
|
258
|
+
self,
|
|
259
|
+
account: ManagedAccount,
|
|
260
|
+
family: ModelFamily,
|
|
261
|
+
) -> Optional[HeaderStyle]:
|
|
262
|
+
"""Get an available header style for the account, or None if all limited."""
|
|
263
|
+
_clear_expired_rate_limits(account)
|
|
264
|
+
|
|
265
|
+
if family == "claude":
|
|
266
|
+
if not _is_rate_limited_for_quota_key(account, "claude"):
|
|
267
|
+
return "antigravity"
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
# For Gemini, try Antigravity first, then Gemini CLI
|
|
271
|
+
if not _is_rate_limited_for_quota_key(account, "gemini-antigravity"):
|
|
272
|
+
return "antigravity"
|
|
273
|
+
if not _is_rate_limited_for_quota_key(account, "gemini-cli"):
|
|
274
|
+
return "gemini-cli"
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def get_min_wait_time_for_family(self, family: ModelFamily) -> float:
|
|
278
|
+
"""Get minimum wait time until an account becomes available (in ms)."""
|
|
279
|
+
# Check if any account is already available
|
|
280
|
+
for acc in self._accounts:
|
|
281
|
+
_clear_expired_rate_limits(acc)
|
|
282
|
+
if not _is_rate_limited_for_family(acc, family):
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
# Calculate minimum wait time
|
|
286
|
+
wait_times: List[float] = []
|
|
287
|
+
now = _now_ms()
|
|
288
|
+
|
|
289
|
+
for acc in self._accounts:
|
|
290
|
+
if family == "claude":
|
|
291
|
+
reset = acc.rate_limit_reset_times.get("claude")
|
|
292
|
+
if reset is not None:
|
|
293
|
+
wait_times.append(max(0, reset - now))
|
|
294
|
+
else:
|
|
295
|
+
# For Gemini, account available when EITHER pool expires
|
|
296
|
+
ag_reset = acc.rate_limit_reset_times.get("gemini-antigravity")
|
|
297
|
+
cli_reset = acc.rate_limit_reset_times.get("gemini-cli")
|
|
298
|
+
|
|
299
|
+
ag_wait = max(0, ag_reset - now) if ag_reset else float("inf")
|
|
300
|
+
cli_wait = max(0, cli_reset - now) if cli_reset else float("inf")
|
|
301
|
+
|
|
302
|
+
account_wait = min(ag_wait, cli_wait)
|
|
303
|
+
if account_wait != float("inf"):
|
|
304
|
+
wait_times.append(account_wait)
|
|
305
|
+
|
|
306
|
+
return min(wait_times) if wait_times else 0
|
|
307
|
+
|
|
308
|
+
def add_account(
|
|
309
|
+
self,
|
|
310
|
+
refresh_token: str,
|
|
311
|
+
email: Optional[str] = None,
|
|
312
|
+
project_id: Optional[str] = None,
|
|
313
|
+
) -> ManagedAccount:
|
|
314
|
+
"""Add a new account to the pool."""
|
|
315
|
+
now = _now_ms()
|
|
316
|
+
parts = parse_refresh_parts(refresh_token)
|
|
317
|
+
if project_id:
|
|
318
|
+
parts.project_id = project_id
|
|
319
|
+
|
|
320
|
+
account = ManagedAccount(
|
|
321
|
+
index=len(self._accounts),
|
|
322
|
+
email=email,
|
|
323
|
+
added_at=now,
|
|
324
|
+
last_used=0,
|
|
325
|
+
parts=parts,
|
|
326
|
+
rate_limit_reset_times={},
|
|
327
|
+
)
|
|
328
|
+
self._accounts.append(account)
|
|
329
|
+
|
|
330
|
+
# Set as active if this is the first account
|
|
331
|
+
if len(self._accounts) == 1:
|
|
332
|
+
self._current_index_by_family["claude"] = 0
|
|
333
|
+
self._current_index_by_family["gemini"] = 0
|
|
334
|
+
|
|
335
|
+
return account
|
|
336
|
+
|
|
337
|
+
def remove_account(self, account: ManagedAccount) -> bool:
|
|
338
|
+
"""Remove an account from the pool."""
|
|
339
|
+
try:
|
|
340
|
+
idx = self._accounts.index(account)
|
|
341
|
+
except ValueError:
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
self._accounts.pop(idx)
|
|
345
|
+
|
|
346
|
+
# Re-index remaining accounts
|
|
347
|
+
for i, acc in enumerate(self._accounts):
|
|
348
|
+
acc.index = i
|
|
349
|
+
|
|
350
|
+
if not self._accounts:
|
|
351
|
+
self._cursor = 0
|
|
352
|
+
self._current_index_by_family["claude"] = -1
|
|
353
|
+
self._current_index_by_family["gemini"] = -1
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
# Adjust cursor and active indices
|
|
357
|
+
if self._cursor > idx:
|
|
358
|
+
self._cursor -= 1
|
|
359
|
+
self._cursor = self._cursor % len(self._accounts)
|
|
360
|
+
|
|
361
|
+
for family in ["claude", "gemini"]:
|
|
362
|
+
family_key: ModelFamily = family # type: ignore
|
|
363
|
+
if self._current_index_by_family[family_key] > idx:
|
|
364
|
+
self._current_index_by_family[family_key] -= 1
|
|
365
|
+
if self._current_index_by_family[family_key] >= len(self._accounts):
|
|
366
|
+
self._current_index_by_family[family_key] = -1
|
|
367
|
+
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
def save_to_disk(self) -> None:
|
|
371
|
+
"""Persist account state to disk."""
|
|
372
|
+
claude_idx = max(0, self._current_index_by_family.get("claude", 0))
|
|
373
|
+
gemini_idx = max(0, self._current_index_by_family.get("gemini", 0))
|
|
374
|
+
|
|
375
|
+
accounts: List[AccountMetadata] = []
|
|
376
|
+
for acc in self._accounts:
|
|
377
|
+
rate_limits = RateLimitState(
|
|
378
|
+
claude=acc.rate_limit_reset_times.get("claude"),
|
|
379
|
+
gemini_antigravity=acc.rate_limit_reset_times.get("gemini-antigravity"),
|
|
380
|
+
gemini_cli=acc.rate_limit_reset_times.get("gemini-cli"),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
accounts.append(
|
|
384
|
+
AccountMetadata(
|
|
385
|
+
refresh_token=acc.parts.refresh_token,
|
|
386
|
+
email=acc.email,
|
|
387
|
+
project_id=acc.parts.project_id,
|
|
388
|
+
managed_project_id=acc.parts.managed_project_id,
|
|
389
|
+
added_at=acc.added_at,
|
|
390
|
+
last_used=acc.last_used,
|
|
391
|
+
last_switch_reason=acc.last_switch_reason,
|
|
392
|
+
rate_limit_reset_times=rate_limits,
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
storage = AccountStorage(
|
|
397
|
+
version=3,
|
|
398
|
+
accounts=accounts,
|
|
399
|
+
active_index=claude_idx,
|
|
400
|
+
active_index_by_family={
|
|
401
|
+
"claude": claude_idx,
|
|
402
|
+
"gemini": gemini_idx,
|
|
403
|
+
},
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
save_accounts(storage)
|