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