code-puppy 0.0.214__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 (231) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +2 -0
  3. code_puppy/agents/agent_c_reviewer.py +59 -6
  4. code_puppy/agents/agent_code_puppy.py +7 -1
  5. code_puppy/agents/agent_code_reviewer.py +12 -2
  6. code_puppy/agents/agent_cpp_reviewer.py +73 -6
  7. code_puppy/agents/agent_creator_agent.py +45 -4
  8. code_puppy/agents/agent_golang_reviewer.py +92 -3
  9. code_puppy/agents/agent_javascript_reviewer.py +101 -8
  10. code_puppy/agents/agent_manager.py +81 -4
  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 +28 -6
  15. code_puppy/agents/agent_qa_expert.py +98 -6
  16. code_puppy/agents/agent_qa_kitten.py +12 -7
  17. code_puppy/agents/agent_security_auditor.py +113 -3
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +106 -7
  20. code_puppy/agents/base_agent.py +802 -176
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/pack/__init__.py +34 -0
  23. code_puppy/agents/pack/bloodhound.py +304 -0
  24. code_puppy/agents/pack/husky.py +321 -0
  25. code_puppy/agents/pack/retriever.py +393 -0
  26. code_puppy/agents/pack/shepherd.py +348 -0
  27. code_puppy/agents/pack/terrier.py +287 -0
  28. code_puppy/agents/pack/watchdog.py +367 -0
  29. code_puppy/agents/prompt_reviewer.py +145 -0
  30. code_puppy/agents/subagent_stream_handler.py +276 -0
  31. code_puppy/api/__init__.py +13 -0
  32. code_puppy/api/app.py +169 -0
  33. code_puppy/api/main.py +21 -0
  34. code_puppy/api/pty_manager.py +446 -0
  35. code_puppy/api/routers/__init__.py +12 -0
  36. code_puppy/api/routers/agents.py +36 -0
  37. code_puppy/api/routers/commands.py +217 -0
  38. code_puppy/api/routers/config.py +74 -0
  39. code_puppy/api/routers/sessions.py +232 -0
  40. code_puppy/api/templates/terminal.html +361 -0
  41. code_puppy/api/websocket.py +154 -0
  42. code_puppy/callbacks.py +142 -4
  43. code_puppy/chatgpt_codex_client.py +283 -0
  44. code_puppy/claude_cache_client.py +586 -0
  45. code_puppy/cli_runner.py +916 -0
  46. code_puppy/command_line/add_model_menu.py +1079 -0
  47. code_puppy/command_line/agent_menu.py +395 -0
  48. code_puppy/command_line/attachments.py +10 -5
  49. code_puppy/command_line/autosave_menu.py +605 -0
  50. code_puppy/command_line/clipboard.py +527 -0
  51. code_puppy/command_line/colors_menu.py +520 -0
  52. code_puppy/command_line/command_handler.py +176 -738
  53. code_puppy/command_line/command_registry.py +150 -0
  54. code_puppy/command_line/config_commands.py +715 -0
  55. code_puppy/command_line/core_commands.py +792 -0
  56. code_puppy/command_line/diff_menu.py +863 -0
  57. code_puppy/command_line/load_context_completion.py +15 -22
  58. code_puppy/command_line/mcp/base.py +0 -3
  59. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  60. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  61. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  62. code_puppy/command_line/mcp/edit_command.py +148 -0
  63. code_puppy/command_line/mcp/handler.py +9 -4
  64. code_puppy/command_line/mcp/help_command.py +6 -5
  65. code_puppy/command_line/mcp/install_command.py +15 -26
  66. code_puppy/command_line/mcp/install_menu.py +685 -0
  67. code_puppy/command_line/mcp/list_command.py +2 -2
  68. code_puppy/command_line/mcp/logs_command.py +174 -65
  69. code_puppy/command_line/mcp/remove_command.py +2 -2
  70. code_puppy/command_line/mcp/restart_command.py +12 -4
  71. code_puppy/command_line/mcp/search_command.py +16 -10
  72. code_puppy/command_line/mcp/start_all_command.py +18 -6
  73. code_puppy/command_line/mcp/start_command.py +47 -25
  74. code_puppy/command_line/mcp/status_command.py +4 -5
  75. code_puppy/command_line/mcp/stop_all_command.py +7 -1
  76. code_puppy/command_line/mcp/stop_command.py +8 -4
  77. code_puppy/command_line/mcp/test_command.py +2 -2
  78. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  79. code_puppy/command_line/mcp_completion.py +174 -0
  80. code_puppy/command_line/model_picker_completion.py +75 -25
  81. code_puppy/command_line/model_settings_menu.py +884 -0
  82. code_puppy/command_line/motd.py +14 -8
  83. code_puppy/command_line/onboarding_slides.py +179 -0
  84. code_puppy/command_line/onboarding_wizard.py +340 -0
  85. code_puppy/command_line/pin_command_completion.py +329 -0
  86. code_puppy/command_line/prompt_toolkit_completion.py +463 -63
  87. code_puppy/command_line/session_commands.py +296 -0
  88. code_puppy/command_line/utils.py +54 -0
  89. code_puppy/config.py +898 -112
  90. code_puppy/error_logging.py +118 -0
  91. code_puppy/gemini_code_assist.py +385 -0
  92. code_puppy/gemini_model.py +602 -0
  93. code_puppy/http_utils.py +210 -148
  94. code_puppy/keymap.py +128 -0
  95. code_puppy/main.py +5 -698
  96. code_puppy/mcp_/__init__.py +17 -0
  97. code_puppy/mcp_/async_lifecycle.py +35 -4
  98. code_puppy/mcp_/blocking_startup.py +70 -43
  99. code_puppy/mcp_/captured_stdio_server.py +2 -2
  100. code_puppy/mcp_/config_wizard.py +4 -4
  101. code_puppy/mcp_/dashboard.py +15 -6
  102. code_puppy/mcp_/managed_server.py +65 -38
  103. code_puppy/mcp_/manager.py +146 -52
  104. code_puppy/mcp_/mcp_logs.py +224 -0
  105. code_puppy/mcp_/registry.py +6 -6
  106. code_puppy/mcp_/server_registry_catalog.py +24 -5
  107. code_puppy/messaging/__init__.py +199 -2
  108. code_puppy/messaging/bus.py +610 -0
  109. code_puppy/messaging/commands.py +167 -0
  110. code_puppy/messaging/markdown_patches.py +57 -0
  111. code_puppy/messaging/message_queue.py +17 -48
  112. code_puppy/messaging/messages.py +500 -0
  113. code_puppy/messaging/queue_console.py +1 -24
  114. code_puppy/messaging/renderers.py +43 -146
  115. code_puppy/messaging/rich_renderer.py +1027 -0
  116. code_puppy/messaging/spinner/__init__.py +21 -5
  117. code_puppy/messaging/spinner/console_spinner.py +86 -51
  118. code_puppy/messaging/subagent_console.py +461 -0
  119. code_puppy/model_factory.py +634 -83
  120. code_puppy/model_utils.py +167 -0
  121. code_puppy/models.json +66 -68
  122. code_puppy/models_dev_api.json +1 -0
  123. code_puppy/models_dev_parser.py +592 -0
  124. code_puppy/plugins/__init__.py +164 -10
  125. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  126. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  127. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  128. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  129. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  130. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  131. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  132. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  133. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  134. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  135. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  136. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  137. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  138. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  139. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  140. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  141. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  142. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  143. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  144. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  145. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  146. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  147. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  148. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  149. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  150. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  151. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  152. code_puppy/plugins/example_custom_command/README.md +280 -0
  153. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  154. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  155. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  156. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  157. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  158. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  159. code_puppy/plugins/oauth_puppy_html.py +228 -0
  160. code_puppy/plugins/shell_safety/__init__.py +6 -0
  161. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  162. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  163. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  164. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  165. code_puppy/prompts/codex_system_prompt.md +310 -0
  166. code_puppy/pydantic_patches.py +131 -0
  167. code_puppy/reopenable_async_client.py +8 -8
  168. code_puppy/round_robin_model.py +9 -12
  169. code_puppy/session_storage.py +2 -1
  170. code_puppy/status_display.py +21 -4
  171. code_puppy/summarization_agent.py +41 -13
  172. code_puppy/terminal_utils.py +418 -0
  173. code_puppy/tools/__init__.py +37 -1
  174. code_puppy/tools/agent_tools.py +536 -52
  175. code_puppy/tools/browser/__init__.py +37 -0
  176. code_puppy/tools/browser/browser_control.py +19 -23
  177. code_puppy/tools/browser/browser_interactions.py +41 -48
  178. code_puppy/tools/browser/browser_locators.py +36 -38
  179. code_puppy/tools/browser/browser_manager.py +316 -0
  180. code_puppy/tools/browser/browser_navigation.py +16 -16
  181. code_puppy/tools/browser/browser_screenshot.py +79 -143
  182. code_puppy/tools/browser/browser_scripts.py +32 -42
  183. code_puppy/tools/browser/browser_workflows.py +44 -27
  184. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  185. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  186. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  187. code_puppy/tools/browser/terminal_tools.py +525 -0
  188. code_puppy/tools/command_runner.py +930 -147
  189. code_puppy/tools/common.py +1113 -5
  190. code_puppy/tools/display.py +84 -0
  191. code_puppy/tools/file_modifications.py +288 -89
  192. code_puppy/tools/file_operations.py +226 -154
  193. code_puppy/tools/subagent_context.py +158 -0
  194. code_puppy/uvx_detection.py +242 -0
  195. code_puppy/version_checker.py +30 -11
  196. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  197. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  198. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
  199. code_puppy-0.0.366.dist-info/RECORD +217 -0
  200. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  201. code_puppy/command_line/mcp/add_command.py +0 -183
  202. code_puppy/messaging/spinner/textual_spinner.py +0 -106
  203. code_puppy/tools/browser/camoufox_manager.py +0 -216
  204. code_puppy/tools/browser/vqa_agent.py +0 -70
  205. code_puppy/tui/__init__.py +0 -10
  206. code_puppy/tui/app.py +0 -1105
  207. code_puppy/tui/components/__init__.py +0 -21
  208. code_puppy/tui/components/chat_view.py +0 -551
  209. code_puppy/tui/components/command_history_modal.py +0 -218
  210. code_puppy/tui/components/copy_button.py +0 -139
  211. code_puppy/tui/components/custom_widgets.py +0 -63
  212. code_puppy/tui/components/human_input_modal.py +0 -175
  213. code_puppy/tui/components/input_area.py +0 -167
  214. code_puppy/tui/components/sidebar.py +0 -309
  215. code_puppy/tui/components/status_bar.py +0 -185
  216. code_puppy/tui/messages.py +0 -27
  217. code_puppy/tui/models/__init__.py +0 -8
  218. code_puppy/tui/models/chat_message.py +0 -25
  219. code_puppy/tui/models/command_history.py +0 -89
  220. code_puppy/tui/models/enums.py +0 -24
  221. code_puppy/tui/screens/__init__.py +0 -17
  222. code_puppy/tui/screens/autosave_picker.py +0 -175
  223. code_puppy/tui/screens/help.py +0 -130
  224. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  225. code_puppy/tui/screens/settings.py +0 -306
  226. code_puppy/tui/screens/tools.py +0 -74
  227. code_puppy/tui_state.py +0 -55
  228. code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
  229. code_puppy-0.0.214.dist-info/RECORD +0 -131
  230. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
  231. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,767 @@
1
+ """Custom httpx client for Antigravity API.
2
+
3
+ Wraps Gemini API requests in the Antigravity envelope format and
4
+ unwraps responses (including streaming SSE events).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import json
11
+ import logging
12
+ import uuid
13
+ from typing import Any, Dict, Optional
14
+
15
+ import httpx
16
+
17
+ from .constants import (
18
+ ANTIGRAVITY_DEFAULT_PROJECT_ID,
19
+ ANTIGRAVITY_ENDPOINT_FALLBACKS,
20
+ ANTIGRAVITY_HEADERS,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def _inline_refs(
27
+ schema: dict, convert_unions: bool = False, simplify_for_claude: bool = False
28
+ ) -> dict:
29
+ """Inline $ref references and transform schema for Antigravity compatibility.
30
+
31
+ - Inlines $ref references
32
+ - Removes $defs, definitions, $schema, $id
33
+ - Optionally converts anyOf/oneOf/allOf to any_of/one_of/all_of (only for Gemini)
34
+ - Removes unsupported fields like 'default', 'examples', 'const'
35
+ - For Claude: simplifies anyOf unions to single types
36
+
37
+ Args:
38
+ convert_unions: If True, convert anyOf->any_of etc. (for Gemini).
39
+ simplify_for_claude: If True, simplify anyOf to single types.
40
+ """
41
+ if not isinstance(schema, dict):
42
+ return schema
43
+
44
+ # Make a deep copy to avoid modifying original
45
+ schema = copy.deepcopy(schema)
46
+
47
+ # Extract $defs for reference resolution
48
+ defs = schema.pop("$defs", schema.pop("definitions", {}))
49
+
50
+ def resolve_refs(
51
+ obj, convert_unions=convert_unions, simplify_for_claude=simplify_for_claude
52
+ ):
53
+ """Recursively resolve $ref references and transform schema."""
54
+ if isinstance(obj, dict):
55
+ # For Claude: simplify anyOf/oneOf unions to first non-null type
56
+ if simplify_for_claude:
57
+ for union_key in ["anyOf", "oneOf"]:
58
+ if union_key in obj:
59
+ union = obj[union_key]
60
+ if isinstance(union, list):
61
+ # Find first non-null type
62
+ for item in union:
63
+ if (
64
+ isinstance(item, dict)
65
+ and item.get("type") != "null"
66
+ ):
67
+ # Replace the whole object with this type
68
+ result = dict(item)
69
+ # Keep description if present
70
+ if "description" in obj:
71
+ result["description"] = obj["description"]
72
+ return resolve_refs(
73
+ result, convert_unions, simplify_for_claude
74
+ )
75
+
76
+ # Check for $ref
77
+ if "$ref" in obj:
78
+ ref_path = obj["$ref"]
79
+ ref_name = None
80
+
81
+ # Parse ref like "#/$defs/SomeType" or "#/definitions/SomeType"
82
+ if ref_path.startswith("#/$defs/"):
83
+ ref_name = ref_path[8:]
84
+ elif ref_path.startswith("#/definitions/"):
85
+ ref_name = ref_path[14:]
86
+
87
+ if ref_name and ref_name in defs:
88
+ # Return the resolved definition (recursively resolve it too)
89
+ resolved = resolve_refs(copy.deepcopy(defs[ref_name]))
90
+ # Merge any other properties from the original object
91
+ other_props = {k: v for k, v in obj.items() if k != "$ref"}
92
+ if other_props:
93
+ resolved.update(resolve_refs(other_props))
94
+ return resolved
95
+ else:
96
+ # Can't resolve - return a generic object type instead of empty
97
+ return {"type": "object"}
98
+
99
+ # Recursively process all values and transform keys
100
+ result = {}
101
+ for key, value in obj.items():
102
+ # Skip unsupported fields
103
+ if key in (
104
+ "$defs",
105
+ "definitions",
106
+ "$schema",
107
+ "$id",
108
+ "default",
109
+ "examples",
110
+ "const",
111
+ ):
112
+ continue
113
+
114
+ # For Claude: skip additionalProperties
115
+ if simplify_for_claude and key == "additionalProperties":
116
+ continue
117
+
118
+ # Optionally transform union types for Gemini
119
+ new_key = key
120
+ if convert_unions:
121
+ if key == "anyOf":
122
+ new_key = "any_of"
123
+ elif key == "oneOf":
124
+ new_key = "one_of"
125
+ elif key == "allOf":
126
+ new_key = "all_of"
127
+ elif key == "additionalProperties":
128
+ new_key = "additional_properties"
129
+
130
+ result[new_key] = resolve_refs(
131
+ value, convert_unions, simplify_for_claude
132
+ )
133
+ return result
134
+ elif isinstance(obj, list):
135
+ return [
136
+ resolve_refs(item, convert_unions, simplify_for_claude) for item in obj
137
+ ]
138
+ else:
139
+ return obj
140
+
141
+ return resolve_refs(schema, convert_unions, simplify_for_claude)
142
+
143
+
144
+ class UnwrappedResponse(httpx.Response):
145
+ """A response wrapper that unwraps Antigravity JSON format for non-streaming.
146
+
147
+ Must be created AFTER calling aread() on the original response.
148
+ """
149
+
150
+ def __init__(self, original_response: httpx.Response):
151
+ # DON'T copy __dict__ - it contains wrapped _content!
152
+ # Instead, unwrap immediately since content is already read
153
+ self._original = original_response
154
+ self.status_code = original_response.status_code
155
+ self.headers = original_response.headers
156
+ self.stream = original_response.stream
157
+ self.is_closed = original_response.is_closed
158
+ self.is_stream_consumed = original_response.is_stream_consumed
159
+
160
+ # Unwrap the content NOW
161
+ raw_content = original_response.content
162
+ try:
163
+ data = json.loads(raw_content)
164
+ if isinstance(data, dict) and "response" in data:
165
+ unwrapped = data["response"]
166
+ self._unwrapped_content = json.dumps(unwrapped).encode("utf-8")
167
+ else:
168
+ self._unwrapped_content = raw_content
169
+ except json.JSONDecodeError:
170
+ self._unwrapped_content = raw_content
171
+
172
+ @property
173
+ def content(self) -> bytes:
174
+ """Return unwrapped content."""
175
+ return self._unwrapped_content
176
+
177
+ @property
178
+ def text(self) -> str:
179
+ """Return unwrapped content as text."""
180
+ return self._unwrapped_content.decode("utf-8")
181
+
182
+ def json(self) -> Any:
183
+ """Parse and return unwrapped JSON."""
184
+ return json.loads(self._unwrapped_content)
185
+
186
+ async def aread(self) -> bytes:
187
+ """Return unwrapped content."""
188
+ return self._unwrapped_content
189
+
190
+ def read(self) -> bytes:
191
+ """Return unwrapped content."""
192
+ return self._unwrapped_content
193
+
194
+
195
+ class UnwrappedSSEResponse(httpx.Response):
196
+ """A response wrapper that unwraps Antigravity SSE format."""
197
+
198
+ def __init__(self, original_response: httpx.Response):
199
+ # Copy all attributes from original
200
+ self.__dict__.update(original_response.__dict__)
201
+ self._original = original_response
202
+
203
+ async def aiter_lines(self):
204
+ """Iterate over SSE lines, unwrapping Antigravity format."""
205
+ async for line in self._original.aiter_lines():
206
+ if line.startswith("data: "):
207
+ try:
208
+ data_str = line[6:] # Remove "data: " prefix
209
+ if data_str.strip() == "[DONE]":
210
+ yield line
211
+ continue
212
+
213
+ data = json.loads(data_str)
214
+
215
+ # Unwrap Antigravity format: {"response": {...}} -> {...}
216
+ if "response" in data:
217
+ unwrapped = data["response"]
218
+ yield f"data: {json.dumps(unwrapped)}"
219
+ else:
220
+ yield line
221
+ except json.JSONDecodeError:
222
+ yield line
223
+ else:
224
+ yield line
225
+
226
+ async def aiter_text(self, chunk_size: int | None = None):
227
+ """Iterate over response text, unwrapping Antigravity format for SSE."""
228
+ buffer = ""
229
+ async for chunk in self._original.aiter_text(chunk_size):
230
+ buffer += chunk
231
+
232
+ # Process complete lines
233
+ while "\n" in buffer:
234
+ line, buffer = buffer.split("\n", 1)
235
+
236
+ if line.startswith("data: "):
237
+ try:
238
+ data_str = line[6:]
239
+ if data_str.strip() == "[DONE]":
240
+ yield line + "\n"
241
+ continue
242
+
243
+ data = json.loads(data_str)
244
+
245
+ # Unwrap Antigravity format
246
+ if "response" in data:
247
+ unwrapped = data["response"]
248
+ yield f"data: {json.dumps(unwrapped)}\n"
249
+ else:
250
+ yield line + "\n"
251
+ except json.JSONDecodeError:
252
+ yield line + "\n"
253
+ else:
254
+ yield line + "\n"
255
+
256
+ # Yield any remaining data
257
+ if buffer:
258
+ yield buffer
259
+
260
+ async def aiter_bytes(self, chunk_size: int | None = None):
261
+ """Iterate over response bytes, unwrapping Antigravity format for SSE."""
262
+ async for text_chunk in self.aiter_text(chunk_size):
263
+ yield text_chunk.encode("utf-8")
264
+
265
+
266
+ class AntigravityClient(httpx.AsyncClient):
267
+ """Custom httpx client that handles Antigravity request/response wrapping.
268
+
269
+ Supports proactive token refresh to prevent expiry during long sessions.
270
+ """
271
+
272
+ def __init__(
273
+ self,
274
+ project_id: str = "",
275
+ model_name: str = "",
276
+ refresh_token: str = "",
277
+ expires_at: Optional[float] = None,
278
+ on_token_refreshed: Optional[Any] = None,
279
+ **kwargs: Any,
280
+ ):
281
+ super().__init__(**kwargs)
282
+ self.project_id = project_id
283
+ self.model_name = model_name
284
+ self._refresh_token = refresh_token
285
+ self._expires_at = expires_at
286
+ self._on_token_refreshed = on_token_refreshed
287
+ self._refresh_lock = None # Lazy init for async lock
288
+
289
+ async def _ensure_valid_token(self) -> None:
290
+ """Proactively refresh the access token if it's expired or about to expire.
291
+
292
+ This prevents 401 errors during long-running sessions by checking and
293
+ refreshing the token BEFORE making requests, not after they fail.
294
+ """
295
+ import asyncio
296
+
297
+ from .token import is_token_expired, refresh_access_token
298
+
299
+ # Skip if no refresh token configured
300
+ if not self._refresh_token:
301
+ return
302
+
303
+ # Check if token needs refresh (includes 60-second buffer)
304
+ if not is_token_expired(self._expires_at):
305
+ return
306
+
307
+ # Lazy init the async lock
308
+ if self._refresh_lock is None:
309
+ self._refresh_lock = asyncio.Lock()
310
+
311
+ async with self._refresh_lock:
312
+ # Double-check after acquiring lock (another coroutine may have refreshed)
313
+ if not is_token_expired(self._expires_at):
314
+ return
315
+
316
+ logger.debug("Proactively refreshing Antigravity access token...")
317
+
318
+ try:
319
+ # Run the synchronous refresh in a thread pool to avoid blocking
320
+ loop = asyncio.get_event_loop()
321
+ new_tokens = await loop.run_in_executor(
322
+ None, refresh_access_token, self._refresh_token
323
+ )
324
+
325
+ if new_tokens:
326
+ # Update internal state
327
+ new_access_token = new_tokens.access_token
328
+ self._expires_at = new_tokens.expires_at
329
+ self._refresh_token = new_tokens.refresh_token
330
+
331
+ # Update the Authorization header
332
+ self.headers["Authorization"] = f"Bearer {new_access_token}"
333
+
334
+ logger.info(
335
+ "Proactively refreshed Antigravity token (expires in %ds)",
336
+ int(self._expires_at - __import__("time").time()),
337
+ )
338
+
339
+ # Notify callback (e.g., to persist updated tokens)
340
+ if self._on_token_refreshed:
341
+ try:
342
+ self._on_token_refreshed(new_tokens)
343
+ except Exception as e:
344
+ logger.warning("Token refresh callback failed: %s", e)
345
+ else:
346
+ logger.warning(
347
+ "Failed to proactively refresh token - request may fail with 401"
348
+ )
349
+
350
+ except Exception as e:
351
+ logger.warning("Proactive token refresh error: %s", e)
352
+
353
+ def _wrap_request(self, content: bytes, url: str) -> tuple[bytes, str, str, bool]:
354
+ """Wrap request body in Antigravity envelope and transform URL.
355
+
356
+ Returns: (wrapped_content, new_path, new_query, is_claude_thinking)
357
+ """
358
+ try:
359
+ original_body = json.loads(content)
360
+
361
+ # Extract model name from URL
362
+ model = self.model_name
363
+ if "/models/" in url:
364
+ parts = url.split("/models/")[-1]
365
+ model = parts.split(":")[0] if ":" in parts else model
366
+
367
+ # Transform Claude model names: remove tier suffix, it goes in thinkingBudget
368
+ # claude-sonnet-4-5-thinking-low -> claude-sonnet-4-5-thinking
369
+ # claude-opus-4-5-thinking-high -> claude-opus-4-5-thinking
370
+ claude_tier = None
371
+ if "claude" in model and "-thinking-" in model:
372
+ for tier in ["low", "medium", "high"]:
373
+ if model.endswith(f"-{tier}"):
374
+ claude_tier = tier
375
+ model = model.rsplit(f"-{tier}", 1)[0] # Remove tier suffix
376
+ break
377
+
378
+ # Use default project_id if not set
379
+ effective_project_id = self.project_id or ANTIGRAVITY_DEFAULT_PROJECT_ID
380
+
381
+ # Generate unique IDs (matching OpenCode's format)
382
+ request_id = f"agent-{uuid.uuid4()}"
383
+ session_id = f"-{uuid.uuid4()}:{model}:{effective_project_id}:seed-{uuid.uuid4().hex[:16]}"
384
+
385
+ # Add sessionId to inner request (required by Antigravity)
386
+ if isinstance(original_body, dict):
387
+ original_body["sessionId"] = session_id
388
+
389
+ # Fix systemInstruction - remove "role" field (Antigravity doesn't want it)
390
+ sys_instruction = original_body.get("systemInstruction", {})
391
+ if isinstance(sys_instruction, dict) and "role" in sys_instruction:
392
+ del sys_instruction["role"]
393
+
394
+ # Fix tools - rename parameters_json_schema to parameters and inline $refs
395
+ tools = original_body.get("tools", [])
396
+ if isinstance(tools, list):
397
+ for tool in tools:
398
+ if isinstance(tool, dict) and "functionDeclarations" in tool:
399
+ for func_decl in tool["functionDeclarations"]:
400
+ if isinstance(func_decl, dict):
401
+ # Rename parameters_json_schema to parameters
402
+ if "parameters_json_schema" in func_decl:
403
+ func_decl["parameters"] = func_decl.pop(
404
+ "parameters_json_schema"
405
+ )
406
+
407
+ # Inline $refs and remove $defs from parameters
408
+ # Convert unions (anyOf->any_of) only for Gemini
409
+ # Simplify schemas for Claude (no anyOf, no additionalProperties)
410
+ if "parameters" in func_decl:
411
+ is_gemini = "gemini" in model.lower()
412
+ is_claude = "claude" in model.lower()
413
+ func_decl["parameters"] = _inline_refs(
414
+ func_decl["parameters"],
415
+ convert_unions=is_gemini,
416
+ simplify_for_claude=is_claude,
417
+ )
418
+
419
+ # Fix generationConfig for Antigravity compatibility
420
+ gen_config = original_body.get("generationConfig", {})
421
+ if isinstance(gen_config, dict):
422
+ # Remove responseModalities - Antigravity doesn't support it!
423
+ if "responseModalities" in gen_config:
424
+ del gen_config["responseModalities"]
425
+
426
+ # Add thinkingConfig for Gemini 3 models (uses thinkingLevel string)
427
+ if "gemini-3" in model:
428
+ # Extract thinking level from model name (e.g., gemini-3-pro-high -> high)
429
+ thinking_level = "medium" # default
430
+ if model.endswith("-low"):
431
+ thinking_level = "low"
432
+ elif model.endswith("-high"):
433
+ thinking_level = "high"
434
+
435
+ gen_config["thinkingConfig"] = {
436
+ "includeThoughts": True,
437
+ "thinkingLevel": thinking_level,
438
+ }
439
+
440
+ # Add thinkingConfig for Claude thinking models (uses thinkingBudget number)
441
+ elif claude_tier and "thinking" in model:
442
+ # Claude thinking budgets by tier
443
+ claude_budgets = {"low": 8192, "medium": 16384, "high": 32768}
444
+ thinking_budget = claude_budgets.get(claude_tier, 8192)
445
+
446
+ gen_config["thinkingConfig"] = {
447
+ "includeThoughts": True,
448
+ "thinkingBudget": thinking_budget,
449
+ }
450
+
451
+ # Add topK and topP if not present (OpenCode uses these)
452
+ if "topK" not in gen_config:
453
+ gen_config["topK"] = 64
454
+ if "topP" not in gen_config:
455
+ gen_config["topP"] = 0.95
456
+
457
+ # Set maxOutputTokens to 64000 for all models
458
+ # This ensures it's always > thinkingBudget for thinking models
459
+ gen_config["maxOutputTokens"] = 64000
460
+
461
+ original_body["generationConfig"] = gen_config
462
+
463
+ # Wrap in Antigravity envelope
464
+ wrapped_body = {
465
+ "project": effective_project_id,
466
+ "model": model,
467
+ "request": original_body,
468
+ "userAgent": "antigravity",
469
+ "requestId": request_id,
470
+ "requestType": "agent",
471
+ }
472
+
473
+ # Transform URL to Antigravity format
474
+ new_path = url
475
+ new_query = ""
476
+ if ":streamGenerateContent" in url:
477
+ new_path = "/v1internal:streamGenerateContent"
478
+ new_query = "alt=sse"
479
+ elif ":generateContent" in url:
480
+ new_path = "/v1internal:generateContent"
481
+
482
+ # Determine if this is a Claude thinking model (for interleaved thinking header)
483
+ is_claude_thinking = (
484
+ "claude" in model.lower() and "thinking" in model.lower()
485
+ )
486
+
487
+ return (
488
+ json.dumps(wrapped_body).encode(),
489
+ new_path,
490
+ new_query,
491
+ is_claude_thinking,
492
+ )
493
+
494
+ except (json.JSONDecodeError, Exception) as e:
495
+ logger.warning("Failed to wrap request: %s", e)
496
+ return content, url, "", False
497
+
498
+ async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
499
+ """Override send to intercept at the lowest level with endpoint fallback."""
500
+ import asyncio
501
+
502
+ # Proactively refresh token BEFORE making the request
503
+ await self._ensure_valid_token()
504
+
505
+ # Transform POST requests to Antigravity format
506
+ if request.method == "POST" and request.content:
507
+ new_content, new_path, new_query, is_claude_thinking = self._wrap_request(
508
+ request.content, str(request.url.path)
509
+ )
510
+ if new_path != str(request.url.path):
511
+ # Remove SDK headers that we need to override (case-insensitive)
512
+ headers_to_remove = {
513
+ "content-length",
514
+ "user-agent",
515
+ "x-goog-api-client",
516
+ "x-goog-api-key",
517
+ "client-metadata",
518
+ "accept",
519
+ }
520
+ new_headers = {
521
+ k: v
522
+ for k, v in request.headers.items()
523
+ if k.lower() not in headers_to_remove
524
+ }
525
+
526
+ # Add Antigravity headers (matching OpenCode exactly)
527
+ new_headers["user-agent"] = "antigravity/1.11.5 windows/amd64"
528
+ new_headers["x-goog-api-client"] = (
529
+ "google-cloud-sdk vscode_cloudshelleditor/0.1"
530
+ )
531
+ new_headers["client-metadata"] = (
532
+ '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'
533
+ )
534
+ new_headers["x-goog-api-key"] = "" # Must be present but empty!
535
+ new_headers["accept"] = "text/event-stream"
536
+
537
+ # Add anthropic-beta header for Claude thinking models (interleaved thinking)
538
+ # This enables real-time streaming of thinking tokens between tool calls
539
+ if is_claude_thinking:
540
+ interleaved_header = "interleaved-thinking-2025-05-14"
541
+ existing = new_headers.get("anthropic-beta", "")
542
+ if existing:
543
+ if interleaved_header not in existing:
544
+ new_headers["anthropic-beta"] = (
545
+ f"{existing},{interleaved_header}"
546
+ )
547
+ else:
548
+ new_headers["anthropic-beta"] = interleaved_header
549
+
550
+ # Try each endpoint with rate limit retry logic
551
+ last_response = None
552
+ max_rate_limit_retries = 5 # Max retries for 429s per endpoint
553
+
554
+ for endpoint in ANTIGRAVITY_ENDPOINT_FALLBACKS:
555
+ # Build URL with current endpoint
556
+ new_url = httpx.URL(
557
+ scheme="https",
558
+ host=endpoint.replace("https://", ""),
559
+ path=new_path,
560
+ query=new_query.encode() if new_query else b"",
561
+ )
562
+
563
+ # Retry loop for rate limits on this endpoint
564
+ for rate_limit_attempt in range(max_rate_limit_retries):
565
+ req = httpx.Request(
566
+ method=request.method,
567
+ url=new_url,
568
+ headers=new_headers,
569
+ content=new_content,
570
+ )
571
+
572
+ response = await super().send(req, **kwargs)
573
+ last_response = response
574
+
575
+ # Handle rate limit (429)
576
+ if response.status_code == 429:
577
+ wait_time = await self._extract_rate_limit_delay(response)
578
+
579
+ if wait_time is not None and wait_time < 60:
580
+ # Add small buffer to wait time
581
+ wait_time = wait_time + 0.1
582
+ try:
583
+ from code_puppy.messaging import emit_warning
584
+
585
+ emit_warning(
586
+ f"⏳ Rate limited (attempt {rate_limit_attempt + 1}/{max_rate_limit_retries}). "
587
+ f"Waiting {wait_time:.2f}s..."
588
+ )
589
+ except ImportError:
590
+ logger.warning(
591
+ "Rate limited, waiting %.2fs...", wait_time
592
+ )
593
+
594
+ await asyncio.sleep(wait_time)
595
+ continue # Retry same endpoint
596
+ else:
597
+ # Wait time too long or couldn't parse, try next endpoint
598
+ logger.debug(
599
+ "Rate limit wait too long (%.1fs) on %s, trying next endpoint...",
600
+ wait_time or 0,
601
+ endpoint,
602
+ )
603
+ break # Break inner loop, try next endpoint
604
+
605
+ # Retry on 403, 404, 5xx errors - try next endpoint
606
+ if (
607
+ response.status_code in (403, 404)
608
+ or response.status_code >= 500
609
+ ):
610
+ logger.debug(
611
+ "Endpoint %s returned %d, trying next...",
612
+ endpoint,
613
+ response.status_code,
614
+ )
615
+ break # Try next endpoint
616
+
617
+ # Success or non-retriable error (4xx except 429)
618
+ # Wrap response to unwrap Antigravity format
619
+ if "alt=sse" in new_query:
620
+ return UnwrappedSSEResponse(response)
621
+
622
+ # Non-streaming also needs unwrapping!
623
+ # Must read response before wrapping (async requirement)
624
+ await response.aread()
625
+ return UnwrappedResponse(response)
626
+
627
+ # All endpoints/retries exhausted, return last response
628
+ if last_response:
629
+ # Ensure response is read for proper error handling
630
+ if not last_response.is_stream_consumed:
631
+ try:
632
+ await last_response.aread()
633
+ except Exception:
634
+ pass
635
+ return UnwrappedResponse(last_response)
636
+
637
+ return await super().send(request, **kwargs)
638
+
639
+ async def _extract_rate_limit_delay(self, response: httpx.Response) -> float | None:
640
+ """Extract the retry delay from a 429 rate limit response.
641
+
642
+ Parses the Antigravity/Google API error format to find:
643
+ - retryDelay from RetryInfo (e.g., "0.088325827s")
644
+ - quotaResetDelay from ErrorInfo metadata (e.g., "88.325827ms")
645
+
646
+ Returns the delay in seconds, or None if parsing fails.
647
+ """
648
+ try:
649
+ # Read response body if not already read
650
+ if not response.is_stream_consumed:
651
+ await response.aread()
652
+
653
+ error_data = json.loads(response.content)
654
+
655
+ if not isinstance(error_data, dict):
656
+ return 2.0 # Default fallback
657
+
658
+ error_info = error_data.get("error", {})
659
+ if not isinstance(error_info, dict):
660
+ return 2.0
661
+
662
+ details = error_info.get("details", [])
663
+ if not isinstance(details, list):
664
+ return 2.0
665
+
666
+ # Look for RetryInfo first (most precise)
667
+ for detail in details:
668
+ if not isinstance(detail, dict):
669
+ continue
670
+
671
+ detail_type = detail.get("@type", "")
672
+
673
+ # Check for RetryInfo (e.g., "0.088325827s")
674
+ if "RetryInfo" in detail_type:
675
+ retry_delay = detail.get("retryDelay", "")
676
+ parsed = self._parse_duration(retry_delay)
677
+ if parsed is not None:
678
+ return parsed
679
+
680
+ # Check for ErrorInfo with quotaResetDelay in metadata
681
+ if "ErrorInfo" in detail_type:
682
+ metadata = detail.get("metadata", {})
683
+ if isinstance(metadata, dict):
684
+ quota_delay = metadata.get("quotaResetDelay", "")
685
+ parsed = self._parse_duration(quota_delay)
686
+ if parsed is not None:
687
+ return parsed
688
+
689
+ return 2.0 # Default if no delay found
690
+
691
+ except (json.JSONDecodeError, Exception) as e:
692
+ logger.debug("Failed to parse rate limit response: %s", e)
693
+ return 2.0 # Default fallback
694
+
695
+ def _parse_duration(self, duration_str: str) -> float | None:
696
+ """Parse a duration string like '0.088s' or '88.325827ms' to seconds."""
697
+ if not duration_str or not isinstance(duration_str, str):
698
+ return None
699
+
700
+ duration_str = duration_str.strip()
701
+
702
+ try:
703
+ # Handle milliseconds (e.g., "88.325827ms")
704
+ if duration_str.endswith("ms"):
705
+ return float(duration_str[:-2]) / 1000.0
706
+
707
+ # Handle seconds (e.g., "0.088325827s")
708
+ if duration_str.endswith("s"):
709
+ return float(duration_str[:-1])
710
+
711
+ # Try parsing as raw number (assume seconds)
712
+ return float(duration_str)
713
+
714
+ except ValueError:
715
+ return None
716
+
717
+
718
+ # Type alias for token refresh callback
719
+ TokenRefreshCallback = Any # Callable[[OAuthTokens], None]
720
+
721
+
722
+ def create_antigravity_client(
723
+ access_token: str,
724
+ project_id: str = "",
725
+ model_name: str = "",
726
+ base_url: str = "https://daily-cloudcode-pa.sandbox.googleapis.com",
727
+ headers: Optional[Dict[str, str]] = None,
728
+ refresh_token: str = "",
729
+ expires_at: Optional[float] = None,
730
+ on_token_refreshed: Optional[TokenRefreshCallback] = None,
731
+ ) -> AntigravityClient:
732
+ """Create an httpx client configured for Antigravity API.
733
+
734
+ Args:
735
+ access_token: The OAuth access token for authentication
736
+ project_id: The GCP project ID
737
+ model_name: The model name being used
738
+ base_url: The API base URL
739
+ headers: Additional headers to include
740
+ refresh_token: The OAuth refresh token for proactive token refresh
741
+ expires_at: Unix timestamp when the access token expires
742
+ on_token_refreshed: Callback called when token is proactively refreshed,
743
+ receives OAuthTokens object to persist the new tokens
744
+
745
+ Returns:
746
+ An AntigravityClient configured for API requests with proactive token refresh
747
+ """
748
+ # Start with Antigravity-specific headers
749
+ default_headers = {
750
+ "Authorization": f"Bearer {access_token}",
751
+ "Content-Type": "application/json",
752
+ "Accept": "text/event-stream",
753
+ **ANTIGRAVITY_HEADERS,
754
+ }
755
+ if headers:
756
+ default_headers.update(headers)
757
+
758
+ return AntigravityClient(
759
+ project_id=project_id,
760
+ model_name=model_name,
761
+ refresh_token=refresh_token,
762
+ expires_at=expires_at,
763
+ on_token_refreshed=on_token_refreshed,
764
+ base_url=base_url,
765
+ headers=default_headers,
766
+ timeout=httpx.Timeout(180.0, connect=30.0),
767
+ )