code-puppy 0.0.169__py3-none-any.whl → 0.0.366__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 (243) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +8 -8
  3. code_puppy/agents/agent_c_reviewer.py +155 -0
  4. code_puppy/agents/agent_code_puppy.py +9 -2
  5. code_puppy/agents/agent_code_reviewer.py +90 -0
  6. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  7. code_puppy/agents/agent_creator_agent.py +48 -9
  8. code_puppy/agents/agent_golang_reviewer.py +151 -0
  9. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  10. code_puppy/agents/agent_manager.py +146 -199
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +90 -0
  15. code_puppy/agents/agent_qa_expert.py +163 -0
  16. code_puppy/agents/agent_qa_kitten.py +208 -0
  17. code_puppy/agents/agent_security_auditor.py +181 -0
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  20. code_puppy/agents/base_agent.py +1713 -1
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/json_agent.py +12 -1
  23. code_puppy/agents/pack/__init__.py +34 -0
  24. code_puppy/agents/pack/bloodhound.py +304 -0
  25. code_puppy/agents/pack/husky.py +321 -0
  26. code_puppy/agents/pack/retriever.py +393 -0
  27. code_puppy/agents/pack/shepherd.py +348 -0
  28. code_puppy/agents/pack/terrier.py +287 -0
  29. code_puppy/agents/pack/watchdog.py +367 -0
  30. code_puppy/agents/prompt_reviewer.py +145 -0
  31. code_puppy/agents/subagent_stream_handler.py +276 -0
  32. code_puppy/api/__init__.py +13 -0
  33. code_puppy/api/app.py +169 -0
  34. code_puppy/api/main.py +21 -0
  35. code_puppy/api/pty_manager.py +446 -0
  36. code_puppy/api/routers/__init__.py +12 -0
  37. code_puppy/api/routers/agents.py +36 -0
  38. code_puppy/api/routers/commands.py +217 -0
  39. code_puppy/api/routers/config.py +74 -0
  40. code_puppy/api/routers/sessions.py +232 -0
  41. code_puppy/api/templates/terminal.html +361 -0
  42. code_puppy/api/websocket.py +154 -0
  43. code_puppy/callbacks.py +174 -4
  44. code_puppy/chatgpt_codex_client.py +283 -0
  45. code_puppy/claude_cache_client.py +586 -0
  46. code_puppy/cli_runner.py +916 -0
  47. code_puppy/command_line/add_model_menu.py +1079 -0
  48. code_puppy/command_line/agent_menu.py +395 -0
  49. code_puppy/command_line/attachments.py +395 -0
  50. code_puppy/command_line/autosave_menu.py +605 -0
  51. code_puppy/command_line/clipboard.py +527 -0
  52. code_puppy/command_line/colors_menu.py +520 -0
  53. code_puppy/command_line/command_handler.py +233 -627
  54. code_puppy/command_line/command_registry.py +150 -0
  55. code_puppy/command_line/config_commands.py +715 -0
  56. code_puppy/command_line/core_commands.py +792 -0
  57. code_puppy/command_line/diff_menu.py +863 -0
  58. code_puppy/command_line/load_context_completion.py +15 -22
  59. code_puppy/command_line/mcp/base.py +1 -4
  60. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  61. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  62. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  63. code_puppy/command_line/mcp/edit_command.py +148 -0
  64. code_puppy/command_line/mcp/handler.py +9 -4
  65. code_puppy/command_line/mcp/help_command.py +6 -5
  66. code_puppy/command_line/mcp/install_command.py +16 -27
  67. code_puppy/command_line/mcp/install_menu.py +685 -0
  68. code_puppy/command_line/mcp/list_command.py +3 -3
  69. code_puppy/command_line/mcp/logs_command.py +174 -65
  70. code_puppy/command_line/mcp/remove_command.py +2 -2
  71. code_puppy/command_line/mcp/restart_command.py +12 -4
  72. code_puppy/command_line/mcp/search_command.py +17 -11
  73. code_puppy/command_line/mcp/start_all_command.py +22 -13
  74. code_puppy/command_line/mcp/start_command.py +50 -31
  75. code_puppy/command_line/mcp/status_command.py +6 -7
  76. code_puppy/command_line/mcp/stop_all_command.py +11 -8
  77. code_puppy/command_line/mcp/stop_command.py +11 -10
  78. code_puppy/command_line/mcp/test_command.py +2 -2
  79. code_puppy/command_line/mcp/utils.py +1 -1
  80. code_puppy/command_line/mcp/wizard_utils.py +22 -18
  81. code_puppy/command_line/mcp_completion.py +174 -0
  82. code_puppy/command_line/model_picker_completion.py +89 -30
  83. code_puppy/command_line/model_settings_menu.py +884 -0
  84. code_puppy/command_line/motd.py +14 -8
  85. code_puppy/command_line/onboarding_slides.py +179 -0
  86. code_puppy/command_line/onboarding_wizard.py +340 -0
  87. code_puppy/command_line/pin_command_completion.py +329 -0
  88. code_puppy/command_line/prompt_toolkit_completion.py +626 -75
  89. code_puppy/command_line/session_commands.py +296 -0
  90. code_puppy/command_line/utils.py +54 -0
  91. code_puppy/config.py +1181 -51
  92. code_puppy/error_logging.py +118 -0
  93. code_puppy/gemini_code_assist.py +385 -0
  94. code_puppy/gemini_model.py +602 -0
  95. code_puppy/http_utils.py +220 -104
  96. code_puppy/keymap.py +128 -0
  97. code_puppy/main.py +5 -594
  98. code_puppy/{mcp → mcp_}/__init__.py +17 -0
  99. code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
  100. code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
  101. code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
  102. code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
  103. code_puppy/{mcp → mcp_}/dashboard.py +15 -6
  104. code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
  105. code_puppy/{mcp → mcp_}/managed_server.py +66 -39
  106. code_puppy/{mcp → mcp_}/manager.py +146 -52
  107. code_puppy/mcp_/mcp_logs.py +224 -0
  108. code_puppy/{mcp → mcp_}/registry.py +6 -6
  109. code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
  110. code_puppy/messaging/__init__.py +199 -2
  111. code_puppy/messaging/bus.py +610 -0
  112. code_puppy/messaging/commands.py +167 -0
  113. code_puppy/messaging/markdown_patches.py +57 -0
  114. code_puppy/messaging/message_queue.py +17 -48
  115. code_puppy/messaging/messages.py +500 -0
  116. code_puppy/messaging/queue_console.py +1 -24
  117. code_puppy/messaging/renderers.py +43 -146
  118. code_puppy/messaging/rich_renderer.py +1027 -0
  119. code_puppy/messaging/spinner/__init__.py +33 -5
  120. code_puppy/messaging/spinner/console_spinner.py +92 -52
  121. code_puppy/messaging/spinner/spinner_base.py +29 -0
  122. code_puppy/messaging/subagent_console.py +461 -0
  123. code_puppy/model_factory.py +686 -80
  124. code_puppy/model_utils.py +167 -0
  125. code_puppy/models.json +86 -104
  126. code_puppy/models_dev_api.json +1 -0
  127. code_puppy/models_dev_parser.py +592 -0
  128. code_puppy/plugins/__init__.py +164 -10
  129. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  130. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  131. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  132. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  133. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  134. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  135. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  136. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  137. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  138. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  139. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  140. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  141. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  142. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  143. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  144. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  145. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  146. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  147. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  148. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  149. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  150. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  151. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  152. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  153. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  154. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  155. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  156. code_puppy/plugins/example_custom_command/README.md +280 -0
  157. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  158. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  159. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  160. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  161. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  162. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  163. code_puppy/plugins/oauth_puppy_html.py +228 -0
  164. code_puppy/plugins/shell_safety/__init__.py +6 -0
  165. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  166. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  167. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  168. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  169. code_puppy/prompts/codex_system_prompt.md +310 -0
  170. code_puppy/pydantic_patches.py +131 -0
  171. code_puppy/reopenable_async_client.py +8 -8
  172. code_puppy/round_robin_model.py +10 -15
  173. code_puppy/session_storage.py +294 -0
  174. code_puppy/status_display.py +21 -4
  175. code_puppy/summarization_agent.py +52 -14
  176. code_puppy/terminal_utils.py +418 -0
  177. code_puppy/tools/__init__.py +139 -6
  178. code_puppy/tools/agent_tools.py +548 -49
  179. code_puppy/tools/browser/__init__.py +37 -0
  180. code_puppy/tools/browser/browser_control.py +289 -0
  181. code_puppy/tools/browser/browser_interactions.py +545 -0
  182. code_puppy/tools/browser/browser_locators.py +640 -0
  183. code_puppy/tools/browser/browser_manager.py +316 -0
  184. code_puppy/tools/browser/browser_navigation.py +251 -0
  185. code_puppy/tools/browser/browser_screenshot.py +179 -0
  186. code_puppy/tools/browser/browser_scripts.py +462 -0
  187. code_puppy/tools/browser/browser_workflows.py +221 -0
  188. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  189. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  190. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  191. code_puppy/tools/browser/terminal_tools.py +525 -0
  192. code_puppy/tools/command_runner.py +941 -153
  193. code_puppy/tools/common.py +1146 -6
  194. code_puppy/tools/display.py +84 -0
  195. code_puppy/tools/file_modifications.py +288 -89
  196. code_puppy/tools/file_operations.py +352 -266
  197. code_puppy/tools/subagent_context.py +158 -0
  198. code_puppy/uvx_detection.py +242 -0
  199. code_puppy/version_checker.py +30 -11
  200. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  201. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  202. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
  203. code_puppy-0.0.366.dist-info/RECORD +217 -0
  204. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  205. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
  206. code_puppy/agent.py +0 -231
  207. code_puppy/agents/agent_orchestrator.json +0 -26
  208. code_puppy/agents/runtime_manager.py +0 -272
  209. code_puppy/command_line/mcp/add_command.py +0 -183
  210. code_puppy/command_line/meta_command_handler.py +0 -153
  211. code_puppy/message_history_processor.py +0 -490
  212. code_puppy/messaging/spinner/textual_spinner.py +0 -101
  213. code_puppy/state_management.py +0 -200
  214. code_puppy/tui/__init__.py +0 -10
  215. code_puppy/tui/app.py +0 -986
  216. code_puppy/tui/components/__init__.py +0 -21
  217. code_puppy/tui/components/chat_view.py +0 -550
  218. code_puppy/tui/components/command_history_modal.py +0 -218
  219. code_puppy/tui/components/copy_button.py +0 -139
  220. code_puppy/tui/components/custom_widgets.py +0 -63
  221. code_puppy/tui/components/human_input_modal.py +0 -175
  222. code_puppy/tui/components/input_area.py +0 -167
  223. code_puppy/tui/components/sidebar.py +0 -309
  224. code_puppy/tui/components/status_bar.py +0 -182
  225. code_puppy/tui/messages.py +0 -27
  226. code_puppy/tui/models/__init__.py +0 -8
  227. code_puppy/tui/models/chat_message.py +0 -25
  228. code_puppy/tui/models/command_history.py +0 -89
  229. code_puppy/tui/models/enums.py +0 -24
  230. code_puppy/tui/screens/__init__.py +0 -15
  231. code_puppy/tui/screens/help.py +0 -130
  232. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  233. code_puppy/tui/screens/settings.py +0 -290
  234. code_puppy/tui/screens/tools.py +0 -74
  235. code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
  236. code_puppy-0.0.169.dist-info/RECORD +0 -112
  237. /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
  238. /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
  239. /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
  240. /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
  241. /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
  242. /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
  243. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,319 @@
1
+ """Tests for the Antigravity OAuth plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import pytest
8
+
9
+ from .accounts import AccountManager
10
+ from .config import ANTIGRAVITY_OAUTH_CONFIG
11
+ from .constants import ANTIGRAVITY_MODELS, ANTIGRAVITY_SCOPES
12
+ from .oauth import (
13
+ _compute_code_challenge,
14
+ _decode_state,
15
+ _encode_state,
16
+ _generate_code_verifier,
17
+ prepare_oauth_context,
18
+ )
19
+ from .storage import (
20
+ _migrate_v1_to_v2,
21
+ _migrate_v2_to_v3,
22
+ )
23
+ from .token import (
24
+ RefreshParts,
25
+ format_refresh_parts,
26
+ is_token_expired,
27
+ parse_refresh_parts,
28
+ )
29
+
30
+
31
+ class TestPKCE:
32
+ """Test PKCE code generation and verification."""
33
+
34
+ def test_code_verifier_length(self):
35
+ """Code verifier should be URL-safe base64 encoded."""
36
+ verifier = _generate_code_verifier()
37
+ assert len(verifier) > 40 # At least 43 chars for 32 bytes
38
+ assert "=" not in verifier # No padding
39
+ assert " " not in verifier
40
+
41
+ def test_code_challenge_is_sha256(self):
42
+ """Code challenge should be S256 of verifier."""
43
+ verifier = "test_verifier_string"
44
+ challenge = _compute_code_challenge(verifier)
45
+ assert len(challenge) > 20
46
+ assert "=" not in challenge
47
+
48
+ def test_different_verifiers_produce_different_challenges(self):
49
+ """Each verifier should produce a unique challenge."""
50
+ v1 = _generate_code_verifier()
51
+ v2 = _generate_code_verifier()
52
+ c1 = _compute_code_challenge(v1)
53
+ c2 = _compute_code_challenge(v2)
54
+ assert c1 != c2
55
+
56
+ def test_prepare_oauth_context(self):
57
+ """OAuth context should have all required fields."""
58
+ ctx = prepare_oauth_context()
59
+ assert ctx.state
60
+ assert ctx.code_verifier
61
+ assert ctx.code_challenge
62
+ assert ctx.redirect_uri is None # Not assigned yet
63
+
64
+
65
+ class TestStateEncoding:
66
+ """Test OAuth state encoding/decoding."""
67
+
68
+ def test_encode_decode_roundtrip(self):
69
+ """State should survive encode/decode roundtrip."""
70
+ verifier = "test-verifier-123"
71
+ project_id = "my-project"
72
+
73
+ encoded = _encode_state(verifier, project_id)
74
+ decoded_verifier, decoded_project = _decode_state(encoded)
75
+
76
+ assert decoded_verifier == verifier
77
+ assert decoded_project == project_id
78
+
79
+ def test_encode_without_project_id(self):
80
+ """Should handle empty project ID."""
81
+ verifier = "test-verifier"
82
+ encoded = _encode_state(verifier, "")
83
+ decoded_verifier, decoded_project = _decode_state(encoded)
84
+
85
+ assert decoded_verifier == verifier
86
+ assert decoded_project == ""
87
+
88
+ def test_decode_invalid_state_raises(self):
89
+ """Invalid state should raise ValueError."""
90
+ with pytest.raises(ValueError):
91
+ _decode_state("not-valid-base64!!!")
92
+
93
+
94
+ class TestRefreshParts:
95
+ """Test refresh token parsing and formatting."""
96
+
97
+ def test_parse_simple_token(self):
98
+ """Parse a token without project IDs."""
99
+ parts = parse_refresh_parts("my-refresh-token")
100
+ assert parts.refresh_token == "my-refresh-token"
101
+ assert parts.project_id is None
102
+ assert parts.managed_project_id is None
103
+
104
+ def test_parse_with_project_id(self):
105
+ """Parse a token with project ID."""
106
+ parts = parse_refresh_parts("my-token|project-123")
107
+ assert parts.refresh_token == "my-token"
108
+ assert parts.project_id == "project-123"
109
+ assert parts.managed_project_id is None
110
+
111
+ def test_parse_with_managed_project(self):
112
+ """Parse a token with both project IDs."""
113
+ parts = parse_refresh_parts("token|proj|managed")
114
+ assert parts.refresh_token == "token"
115
+ assert parts.project_id == "proj"
116
+ assert parts.managed_project_id == "managed"
117
+
118
+ def test_parse_empty_string(self):
119
+ """Empty string should produce empty parts."""
120
+ parts = parse_refresh_parts("")
121
+ assert parts.refresh_token == ""
122
+ assert parts.project_id is None
123
+
124
+ def test_format_roundtrip(self):
125
+ """Format and parse should be inverse operations."""
126
+ original = RefreshParts(
127
+ refresh_token="token",
128
+ project_id="project",
129
+ managed_project_id="managed",
130
+ )
131
+ formatted = format_refresh_parts(original)
132
+ parsed = parse_refresh_parts(formatted)
133
+
134
+ assert parsed.refresh_token == original.refresh_token
135
+ assert parsed.project_id == original.project_id
136
+ assert parsed.managed_project_id == original.managed_project_id
137
+
138
+
139
+ class TestTokenExpiry:
140
+ """Test token expiry checking."""
141
+
142
+ def test_none_expires_is_expired(self):
143
+ """None expiry should be treated as expired."""
144
+ assert is_token_expired(None) is True
145
+
146
+ def test_past_time_is_expired(self):
147
+ """Past time should be expired."""
148
+ past = time.time() - 3600
149
+ assert is_token_expired(past) is True
150
+
151
+ def test_future_time_not_expired(self):
152
+ """Future time should not be expired."""
153
+ future = time.time() + 3600
154
+ assert is_token_expired(future) is False
155
+
156
+ def test_expiry_buffer(self):
157
+ """Token expiring soon should be treated as expired (60s buffer)."""
158
+ almost_expired = time.time() + 30 # 30 seconds from now
159
+ assert is_token_expired(almost_expired) is True
160
+
161
+
162
+ class TestStorageMigration:
163
+ """Test storage format migrations."""
164
+
165
+ def test_migrate_v1_to_v2(self):
166
+ """V1 format should migrate to V2."""
167
+ v1_data = {
168
+ "version": 1,
169
+ "accounts": [
170
+ {
171
+ "email": "test@example.com",
172
+ "refreshToken": "token123",
173
+ "addedAt": 1000,
174
+ "lastUsed": 2000,
175
+ "isRateLimited": False,
176
+ }
177
+ ],
178
+ "activeIndex": 0,
179
+ }
180
+
181
+ v2_data = _migrate_v1_to_v2(v1_data)
182
+
183
+ assert v2_data["version"] == 2
184
+ assert len(v2_data["accounts"]) == 1
185
+ assert v2_data["accounts"][0]["email"] == "test@example.com"
186
+
187
+ def test_migrate_v2_to_v3(self):
188
+ """V2 format should migrate to V3."""
189
+ v2_data = {
190
+ "version": 2,
191
+ "accounts": [
192
+ {
193
+ "email": "test@example.com",
194
+ "refreshToken": "token123",
195
+ "addedAt": 1000,
196
+ "lastUsed": 2000,
197
+ "rateLimitResetTimes": {"gemini": time.time() * 1000 + 60000},
198
+ }
199
+ ],
200
+ "activeIndex": 0,
201
+ }
202
+
203
+ v3_data = _migrate_v2_to_v3(v2_data)
204
+
205
+ assert v3_data["version"] == 3
206
+ assert "activeIndexByFamily" in v3_data
207
+
208
+
209
+ class TestAccountManager:
210
+ """Test multi-account management."""
211
+
212
+ def test_empty_manager(self):
213
+ """Empty manager should have no accounts."""
214
+ manager = AccountManager()
215
+ assert manager.account_count == 0
216
+
217
+ def test_add_account(self):
218
+ """Should be able to add accounts."""
219
+ manager = AccountManager()
220
+ acc = manager.add_account("token123", email="test@example.com")
221
+
222
+ assert manager.account_count == 1
223
+ assert acc.email == "test@example.com"
224
+
225
+ def test_get_current_for_family(self):
226
+ """Should get current account for family."""
227
+ manager = AccountManager()
228
+ manager.add_account("token1", email="user1@example.com")
229
+ manager.add_account("token2", email="user2@example.com")
230
+
231
+ acc = manager.get_current_or_next_for_family("claude")
232
+ assert acc is not None
233
+ assert acc.email in ["user1@example.com", "user2@example.com"]
234
+
235
+ def test_rate_limit_switches_account(self):
236
+ """Rate limiting should cause account switch."""
237
+ manager = AccountManager()
238
+ acc1 = manager.add_account("token1", email="user1@example.com")
239
+ manager.add_account("token2", email="user2@example.com")
240
+
241
+ # Mark first account as rate limited for Claude
242
+ manager.mark_rate_limited(acc1, 60000, "claude")
243
+
244
+ # Should get the second account
245
+ current = manager.get_current_or_next_for_family("claude")
246
+ assert current is not None
247
+ assert current.email == "user2@example.com"
248
+
249
+ def test_min_wait_time_calculation(self):
250
+ """Should calculate minimum wait time correctly."""
251
+ manager = AccountManager()
252
+ acc = manager.add_account("token", email="test@example.com")
253
+
254
+ # No rate limits = 0 wait time
255
+ assert manager.get_min_wait_time_for_family("claude") == 0
256
+
257
+ # Add rate limit
258
+ manager.mark_rate_limited(acc, 5000, "claude")
259
+ wait = manager.get_min_wait_time_for_family("claude")
260
+ assert 0 < wait <= 5000
261
+
262
+ def test_gemini_dual_quota(self):
263
+ """Gemini should try both quota pools."""
264
+ manager = AccountManager()
265
+ acc = manager.add_account("token", email="test@example.com")
266
+
267
+ # Initially, antigravity should be available
268
+ style = manager.get_available_header_style(acc, "gemini")
269
+ assert style == "antigravity"
270
+
271
+ # Rate limit antigravity
272
+ manager.mark_rate_limited(acc, 60000, "gemini", "antigravity")
273
+
274
+ # Now gemini-cli should be available
275
+ style = manager.get_available_header_style(acc, "gemini")
276
+ assert style == "gemini-cli"
277
+
278
+ # Rate limit gemini-cli too
279
+ manager.mark_rate_limited(acc, 60000, "gemini", "gemini-cli")
280
+
281
+ # No style available
282
+ style = manager.get_available_header_style(acc, "gemini")
283
+ assert style is None
284
+
285
+
286
+ class TestConstants:
287
+ """Test plugin constants are properly configured."""
288
+
289
+ def test_models_have_required_fields(self):
290
+ """All models should have required configuration."""
291
+ for model_id, config in ANTIGRAVITY_MODELS.items():
292
+ assert "name" in config, f"{model_id} missing name"
293
+ assert "family" in config, f"{model_id} missing family"
294
+ assert "context_length" in config, f"{model_id} missing context_length"
295
+ assert "max_output" in config, f"{model_id} missing max_output"
296
+
297
+ def test_thinking_models_have_budget(self):
298
+ """Thinking models should have thinking_budget."""
299
+ for model_id, config in ANTIGRAVITY_MODELS.items():
300
+ if "thinking" in model_id:
301
+ assert "thinking_budget" in config, (
302
+ f"{model_id} missing thinking_budget"
303
+ )
304
+
305
+ def test_scopes_are_valid(self):
306
+ """OAuth scopes should be valid URLs."""
307
+ for scope in ANTIGRAVITY_SCOPES:
308
+ assert scope.startswith("https://"), f"Invalid scope: {scope}"
309
+
310
+ def test_config_has_required_fields(self):
311
+ """Plugin config should have required fields."""
312
+ assert "auth_url" in ANTIGRAVITY_OAUTH_CONFIG
313
+ assert "token_url" in ANTIGRAVITY_OAUTH_CONFIG
314
+ assert "callback_port_range" in ANTIGRAVITY_OAUTH_CONFIG
315
+ assert "prefix" in ANTIGRAVITY_OAUTH_CONFIG
316
+
317
+
318
+ if __name__ == "__main__":
319
+ pytest.main([__file__, "-v"])
@@ -0,0 +1,167 @@
1
+ """Token management for Antigravity OAuth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+ import requests
11
+
12
+ from .constants import ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Buffer before expiry to trigger refresh (60 seconds)
17
+ ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000
18
+
19
+
20
+ @dataclass
21
+ class RefreshParts:
22
+ """Parsed components of a stored refresh token string."""
23
+
24
+ refresh_token: str
25
+ project_id: Optional[str] = None
26
+ managed_project_id: Optional[str] = None
27
+
28
+
29
+ @dataclass
30
+ class OAuthTokens:
31
+ """OAuth token data."""
32
+
33
+ access_token: str
34
+ refresh_token: str # Composite: "token|projectId|managedProjectId"
35
+ expires_at: float # Unix timestamp
36
+ email: Optional[str] = None
37
+
38
+
39
+ class TokenRefreshError(Exception):
40
+ """Error during token refresh."""
41
+
42
+ def __init__(
43
+ self,
44
+ message: str,
45
+ code: Optional[str] = None,
46
+ status: Optional[int] = None,
47
+ ):
48
+ super().__init__(message)
49
+ self.code = code
50
+ self.status = status
51
+
52
+
53
+ def parse_refresh_parts(refresh: str) -> RefreshParts:
54
+ """Split a packed refresh string into its components."""
55
+ parts = (refresh or "").split("|")
56
+ return RefreshParts(
57
+ refresh_token=parts[0] if len(parts) > 0 else "",
58
+ project_id=parts[1] if len(parts) > 1 and parts[1] else None,
59
+ managed_project_id=parts[2] if len(parts) > 2 and parts[2] else None,
60
+ )
61
+
62
+
63
+ def format_refresh_parts(parts: RefreshParts) -> str:
64
+ """Serialize refresh token parts into the stored string format."""
65
+ project_segment = parts.project_id or ""
66
+ base = f"{parts.refresh_token}|{project_segment}"
67
+ if parts.managed_project_id:
68
+ return f"{base}|{parts.managed_project_id}"
69
+ return base
70
+
71
+
72
+ def is_token_expired(expires_at: Optional[float]) -> bool:
73
+ """Check if a token is expired or missing, with buffer for clock skew."""
74
+ if expires_at is None:
75
+ return True
76
+ # Convert buffer to seconds
77
+ buffer_seconds = ACCESS_TOKEN_EXPIRY_BUFFER_MS / 1000
78
+ return expires_at <= time.time() + buffer_seconds
79
+
80
+
81
+ def refresh_access_token(
82
+ refresh_token_composite: str,
83
+ current_access: Optional[str] = None,
84
+ current_expires: Optional[float] = None,
85
+ ) -> Optional[OAuthTokens]:
86
+ """Refresh an Antigravity OAuth access token.
87
+
88
+ Args:
89
+ refresh_token_composite: The stored refresh token (may include project IDs)
90
+ current_access: Current access token (for returning if refresh fails non-fatally)
91
+ current_expires: Current expiry time
92
+
93
+ Returns:
94
+ Updated OAuthTokens or None if refresh failed
95
+
96
+ Raises:
97
+ TokenRefreshError: If refresh fails due to revoked token
98
+ """
99
+ parts = parse_refresh_parts(refresh_token_composite)
100
+
101
+ if not parts.refresh_token:
102
+ return None
103
+
104
+ try:
105
+ response = requests.post(
106
+ "https://oauth2.googleapis.com/token",
107
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
108
+ data={
109
+ "grant_type": "refresh_token",
110
+ "refresh_token": parts.refresh_token,
111
+ "client_id": ANTIGRAVITY_CLIENT_ID,
112
+ "client_secret": ANTIGRAVITY_CLIENT_SECRET,
113
+ },
114
+ timeout=30,
115
+ )
116
+
117
+ if not response.ok:
118
+ error_data = {}
119
+ try:
120
+ error_data = response.json()
121
+ except Exception:
122
+ pass
123
+
124
+ error_code = error_data.get("error", "")
125
+ error_desc = error_data.get("error_description", response.text)
126
+
127
+ if error_code == "invalid_grant":
128
+ logger.warning(
129
+ "Google revoked the stored refresh token - reauthentication required"
130
+ )
131
+ raise TokenRefreshError(
132
+ f"Token revoked: {error_desc}",
133
+ code="invalid_grant",
134
+ status=response.status_code,
135
+ )
136
+
137
+ logger.warning(
138
+ "Token refresh failed: %s %s - %s",
139
+ response.status_code,
140
+ error_code,
141
+ error_desc,
142
+ )
143
+ return None
144
+
145
+ payload = response.json()
146
+ new_access = payload.get("access_token", "")
147
+ expires_in = payload.get("expires_in", 3600)
148
+ new_refresh = payload.get("refresh_token", parts.refresh_token)
149
+
150
+ # Rebuild composite refresh token
151
+ updated_parts = RefreshParts(
152
+ refresh_token=new_refresh,
153
+ project_id=parts.project_id,
154
+ managed_project_id=parts.managed_project_id,
155
+ )
156
+
157
+ return OAuthTokens(
158
+ access_token=new_access,
159
+ refresh_token=format_refresh_parts(updated_parts),
160
+ expires_at=time.time() + expires_in,
161
+ )
162
+
163
+ except TokenRefreshError:
164
+ raise
165
+ except Exception as e:
166
+ logger.exception("Unexpected token refresh error: %s", e)
167
+ return None