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.
Files changed (44) hide show
  1. code_puppy/agents/base_agent.py +41 -103
  2. code_puppy/cli_runner.py +105 -2
  3. code_puppy/command_line/add_model_menu.py +4 -0
  4. code_puppy/command_line/autosave_menu.py +5 -0
  5. code_puppy/command_line/colors_menu.py +5 -0
  6. code_puppy/command_line/config_commands.py +24 -1
  7. code_puppy/command_line/core_commands.py +51 -0
  8. code_puppy/command_line/diff_menu.py +5 -0
  9. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  10. code_puppy/command_line/mcp/install_menu.py +5 -1
  11. code_puppy/command_line/model_settings_menu.py +5 -0
  12. code_puppy/command_line/motd.py +13 -7
  13. code_puppy/command_line/onboarding_slides.py +180 -0
  14. code_puppy/command_line/onboarding_wizard.py +340 -0
  15. code_puppy/config.py +3 -2
  16. code_puppy/http_utils.py +155 -196
  17. code_puppy/keymap.py +10 -8
  18. code_puppy/messaging/rich_renderer.py +101 -19
  19. code_puppy/model_factory.py +86 -15
  20. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  21. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  22. code_puppy/plugins/antigravity_oauth/antigravity_model.py +653 -0
  23. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  24. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  25. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  26. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  27. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  28. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  29. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  30. code_puppy/plugins/antigravity_oauth/transport.py +664 -0
  31. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  32. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
  33. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
  34. code_puppy/reopenable_async_client.py +8 -8
  35. code_puppy/terminal_utils.py +168 -3
  36. code_puppy/tools/command_runner.py +42 -54
  37. code_puppy/uvx_detection.py +242 -0
  38. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/METADATA +30 -1
  39. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/RECORD +44 -29
  40. {code_puppy-0.0.325.data → code_puppy-0.0.336.data}/data/code_puppy/models.json +0 -0
  41. {code_puppy-0.0.325.data → code_puppy-0.0.336.data}/data/code_puppy/models_dev_api.json +0 -0
  42. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/WHEEL +0 -0
  43. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/entry_points.txt +0 -0
  44. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/licenses/LICENSE +0 -0
@@ -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
- class CustomGoogleGLAProvider(GoogleProvider):
562
- def __init__(self, *args, **kwargs):
563
- super().__init__(*args, **kwargs)
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
- @property
566
- def base_url(self):
567
- return url
636
+ return model
568
637
 
569
- @property
570
- def client(self) -> httpx.AsyncClient:
571
- _client = create_async_client(headers=headers, verify=verify)
572
- _client.base_url = self.base_url
573
- return _client
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
- google_gla = CustomGoogleGLAProvider(api_key=api_key)
576
- model = GoogleModel(model_name=model_config["name"], provider=google_gla)
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)