code-puppy 0.0.325__py3-none-any.whl → 0.0.341__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 (52) hide show
  1. code_puppy/agents/base_agent.py +110 -124
  2. code_puppy/claude_cache_client.py +208 -2
  3. code_puppy/cli_runner.py +152 -32
  4. code_puppy/command_line/add_model_menu.py +4 -0
  5. code_puppy/command_line/autosave_menu.py +23 -24
  6. code_puppy/command_line/clipboard.py +527 -0
  7. code_puppy/command_line/colors_menu.py +5 -0
  8. code_puppy/command_line/config_commands.py +24 -1
  9. code_puppy/command_line/core_commands.py +85 -0
  10. code_puppy/command_line/diff_menu.py +5 -0
  11. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  12. code_puppy/command_line/mcp/install_menu.py +5 -1
  13. code_puppy/command_line/model_settings_menu.py +5 -0
  14. code_puppy/command_line/motd.py +13 -7
  15. code_puppy/command_line/onboarding_slides.py +180 -0
  16. code_puppy/command_line/onboarding_wizard.py +340 -0
  17. code_puppy/command_line/prompt_toolkit_completion.py +118 -0
  18. code_puppy/config.py +3 -2
  19. code_puppy/http_utils.py +201 -279
  20. code_puppy/keymap.py +10 -8
  21. code_puppy/mcp_/managed_server.py +7 -11
  22. code_puppy/messaging/messages.py +3 -0
  23. code_puppy/messaging/rich_renderer.py +114 -22
  24. code_puppy/model_factory.py +102 -15
  25. code_puppy/models.json +2 -2
  26. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  27. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  28. code_puppy/plugins/antigravity_oauth/antigravity_model.py +668 -0
  29. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  30. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  31. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  32. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  33. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  34. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  35. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  36. code_puppy/plugins/antigravity_oauth/transport.py +664 -0
  37. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  38. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
  39. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
  40. code_puppy/plugins/claude_code_oauth/utils.py +126 -7
  41. code_puppy/reopenable_async_client.py +8 -8
  42. code_puppy/terminal_utils.py +295 -3
  43. code_puppy/tools/command_runner.py +43 -54
  44. code_puppy/tools/common.py +3 -9
  45. code_puppy/uvx_detection.py +242 -0
  46. {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models.json +2 -2
  47. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/METADATA +26 -49
  48. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/RECORD +52 -36
  49. {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models_dev_api.json +0 -0
  50. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/WHEEL +0 -0
  51. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/entry_points.txt +0 -0
  52. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,664 @@
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
+ def __init__(
270
+ self,
271
+ project_id: str = "",
272
+ model_name: str = "",
273
+ **kwargs: Any,
274
+ ):
275
+ super().__init__(**kwargs)
276
+ self.project_id = project_id
277
+ self.model_name = model_name
278
+
279
+ def _wrap_request(self, content: bytes, url: str) -> tuple[bytes, str, str, bool]:
280
+ """Wrap request body in Antigravity envelope and transform URL.
281
+
282
+ Returns: (wrapped_content, new_path, new_query, is_claude_thinking)
283
+ """
284
+ try:
285
+ original_body = json.loads(content)
286
+
287
+ # Extract model name from URL
288
+ model = self.model_name
289
+ if "/models/" in url:
290
+ parts = url.split("/models/")[-1]
291
+ model = parts.split(":")[0] if ":" in parts else model
292
+
293
+ # Transform Claude model names: remove tier suffix, it goes in thinkingBudget
294
+ # claude-sonnet-4-5-thinking-low -> claude-sonnet-4-5-thinking
295
+ # claude-opus-4-5-thinking-high -> claude-opus-4-5-thinking
296
+ claude_tier = None
297
+ if "claude" in model and "-thinking-" in model:
298
+ for tier in ["low", "medium", "high"]:
299
+ if model.endswith(f"-{tier}"):
300
+ claude_tier = tier
301
+ model = model.rsplit(f"-{tier}", 1)[0] # Remove tier suffix
302
+ break
303
+
304
+ # Use default project_id if not set
305
+ effective_project_id = self.project_id or ANTIGRAVITY_DEFAULT_PROJECT_ID
306
+
307
+ # Generate unique IDs (matching OpenCode's format)
308
+ request_id = f"agent-{uuid.uuid4()}"
309
+ session_id = f"-{uuid.uuid4()}:{model}:{effective_project_id}:seed-{uuid.uuid4().hex[:16]}"
310
+
311
+ # Add sessionId to inner request (required by Antigravity)
312
+ if isinstance(original_body, dict):
313
+ original_body["sessionId"] = session_id
314
+
315
+ # Fix systemInstruction - remove "role" field (Antigravity doesn't want it)
316
+ sys_instruction = original_body.get("systemInstruction", {})
317
+ if isinstance(sys_instruction, dict) and "role" in sys_instruction:
318
+ del sys_instruction["role"]
319
+
320
+ # Fix tools - rename parameters_json_schema to parameters and inline $refs
321
+ tools = original_body.get("tools", [])
322
+ if isinstance(tools, list):
323
+ for tool in tools:
324
+ if isinstance(tool, dict) and "functionDeclarations" in tool:
325
+ for func_decl in tool["functionDeclarations"]:
326
+ if isinstance(func_decl, dict):
327
+ # Rename parameters_json_schema to parameters
328
+ if "parameters_json_schema" in func_decl:
329
+ func_decl["parameters"] = func_decl.pop(
330
+ "parameters_json_schema"
331
+ )
332
+
333
+ # Inline $refs and remove $defs from parameters
334
+ # Convert unions (anyOf->any_of) only for Gemini
335
+ # Simplify schemas for Claude (no anyOf, no additionalProperties)
336
+ if "parameters" in func_decl:
337
+ is_gemini = "gemini" in model.lower()
338
+ is_claude = "claude" in model.lower()
339
+ func_decl["parameters"] = _inline_refs(
340
+ func_decl["parameters"],
341
+ convert_unions=is_gemini,
342
+ simplify_for_claude=is_claude,
343
+ )
344
+
345
+ # Fix generationConfig for Antigravity compatibility
346
+ gen_config = original_body.get("generationConfig", {})
347
+ if isinstance(gen_config, dict):
348
+ # Remove responseModalities - Antigravity doesn't support it!
349
+ if "responseModalities" in gen_config:
350
+ del gen_config["responseModalities"]
351
+
352
+ # Add thinkingConfig for Gemini 3 models (uses thinkingLevel string)
353
+ if "gemini-3" in model:
354
+ # Extract thinking level from model name (e.g., gemini-3-pro-high -> high)
355
+ thinking_level = "medium" # default
356
+ if model.endswith("-low"):
357
+ thinking_level = "low"
358
+ elif model.endswith("-high"):
359
+ thinking_level = "high"
360
+
361
+ gen_config["thinkingConfig"] = {
362
+ "includeThoughts": True,
363
+ "thinkingLevel": thinking_level,
364
+ }
365
+
366
+ # Add thinkingConfig for Claude thinking models (uses thinkingBudget number)
367
+ elif claude_tier and "thinking" in model:
368
+ # Claude thinking budgets by tier
369
+ claude_budgets = {"low": 8192, "medium": 16384, "high": 32768}
370
+ thinking_budget = claude_budgets.get(claude_tier, 8192)
371
+
372
+ gen_config["thinkingConfig"] = {
373
+ "includeThoughts": True,
374
+ "thinkingBudget": thinking_budget,
375
+ }
376
+
377
+ # Add topK and topP if not present (OpenCode uses these)
378
+ if "topK" not in gen_config:
379
+ gen_config["topK"] = 64
380
+ if "topP" not in gen_config:
381
+ gen_config["topP"] = 0.95
382
+
383
+ # Set maxOutputTokens to 64000 for all models
384
+ # This ensures it's always > thinkingBudget for thinking models
385
+ gen_config["maxOutputTokens"] = 64000
386
+
387
+ original_body["generationConfig"] = gen_config
388
+
389
+ # Wrap in Antigravity envelope
390
+ wrapped_body = {
391
+ "project": effective_project_id,
392
+ "model": model,
393
+ "request": original_body,
394
+ "userAgent": "antigravity",
395
+ "requestId": request_id,
396
+ }
397
+
398
+ # Transform URL to Antigravity format
399
+ new_path = url
400
+ new_query = ""
401
+ if ":streamGenerateContent" in url:
402
+ new_path = "/v1internal:streamGenerateContent"
403
+ new_query = "alt=sse"
404
+ elif ":generateContent" in url:
405
+ new_path = "/v1internal:generateContent"
406
+
407
+ # Determine if this is a Claude thinking model (for interleaved thinking header)
408
+ is_claude_thinking = (
409
+ "claude" in model.lower() and "thinking" in model.lower()
410
+ )
411
+
412
+ return (
413
+ json.dumps(wrapped_body).encode(),
414
+ new_path,
415
+ new_query,
416
+ is_claude_thinking,
417
+ )
418
+
419
+ except (json.JSONDecodeError, Exception) as e:
420
+ logger.warning("Failed to wrap request: %s", e)
421
+ return content, url, "", False
422
+
423
+ async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
424
+ """Override send to intercept at the lowest level with endpoint fallback."""
425
+ import asyncio
426
+
427
+ # Transform POST requests to Antigravity format
428
+ if request.method == "POST" and request.content:
429
+ new_content, new_path, new_query, is_claude_thinking = self._wrap_request(
430
+ request.content, str(request.url.path)
431
+ )
432
+ if new_path != str(request.url.path):
433
+ # Remove SDK headers that we need to override (case-insensitive)
434
+ headers_to_remove = {
435
+ "content-length",
436
+ "user-agent",
437
+ "x-goog-api-client",
438
+ "x-goog-api-key",
439
+ "client-metadata",
440
+ "accept",
441
+ }
442
+ new_headers = {
443
+ k: v
444
+ for k, v in request.headers.items()
445
+ if k.lower() not in headers_to_remove
446
+ }
447
+
448
+ # Add Antigravity headers (matching OpenCode exactly)
449
+ new_headers["user-agent"] = "antigravity/1.11.5 windows/amd64"
450
+ new_headers["x-goog-api-client"] = (
451
+ "google-cloud-sdk vscode_cloudshelleditor/0.1"
452
+ )
453
+ new_headers["client-metadata"] = (
454
+ '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'
455
+ )
456
+ new_headers["x-goog-api-key"] = "" # Must be present but empty!
457
+ new_headers["accept"] = "text/event-stream"
458
+
459
+ # Add anthropic-beta header for Claude thinking models (interleaved thinking)
460
+ # This enables real-time streaming of thinking tokens between tool calls
461
+ if is_claude_thinking:
462
+ interleaved_header = "interleaved-thinking-2025-05-14"
463
+ existing = new_headers.get("anthropic-beta", "")
464
+ if existing:
465
+ if interleaved_header not in existing:
466
+ new_headers["anthropic-beta"] = (
467
+ f"{existing},{interleaved_header}"
468
+ )
469
+ else:
470
+ new_headers["anthropic-beta"] = interleaved_header
471
+
472
+ # Try each endpoint with rate limit retry logic
473
+ last_response = None
474
+ max_rate_limit_retries = 5 # Max retries for 429s per endpoint
475
+
476
+ for endpoint in ANTIGRAVITY_ENDPOINT_FALLBACKS:
477
+ # Build URL with current endpoint
478
+ new_url = httpx.URL(
479
+ scheme="https",
480
+ host=endpoint.replace("https://", ""),
481
+ path=new_path,
482
+ query=new_query.encode() if new_query else b"",
483
+ )
484
+
485
+ # Retry loop for rate limits on this endpoint
486
+ for rate_limit_attempt in range(max_rate_limit_retries):
487
+ req = httpx.Request(
488
+ method=request.method,
489
+ url=new_url,
490
+ headers=new_headers,
491
+ content=new_content,
492
+ )
493
+
494
+ response = await super().send(req, **kwargs)
495
+ last_response = response
496
+
497
+ # Handle rate limit (429)
498
+ if response.status_code == 429:
499
+ wait_time = await self._extract_rate_limit_delay(response)
500
+
501
+ if wait_time is not None and wait_time < 60:
502
+ # Add small buffer to wait time
503
+ wait_time = wait_time + 0.1
504
+ try:
505
+ from code_puppy.messaging import emit_warning
506
+
507
+ emit_warning(
508
+ f"⏳ Rate limited (attempt {rate_limit_attempt + 1}/{max_rate_limit_retries}). "
509
+ f"Waiting {wait_time:.2f}s..."
510
+ )
511
+ except ImportError:
512
+ logger.warning(
513
+ "Rate limited, waiting %.2fs...", wait_time
514
+ )
515
+
516
+ await asyncio.sleep(wait_time)
517
+ continue # Retry same endpoint
518
+ else:
519
+ # Wait time too long or couldn't parse, try next endpoint
520
+ logger.debug(
521
+ "Rate limit wait too long (%.1fs) on %s, trying next endpoint...",
522
+ wait_time or 0,
523
+ endpoint,
524
+ )
525
+ break # Break inner loop, try next endpoint
526
+
527
+ # Retry on 403, 404, 5xx errors - try next endpoint
528
+ if (
529
+ response.status_code in (403, 404)
530
+ or response.status_code >= 500
531
+ ):
532
+ logger.debug(
533
+ "Endpoint %s returned %d, trying next...",
534
+ endpoint,
535
+ response.status_code,
536
+ )
537
+ break # Try next endpoint
538
+
539
+ # Success or non-retriable error (4xx except 429)
540
+ # Wrap response to unwrap Antigravity format
541
+ if "alt=sse" in new_query:
542
+ return UnwrappedSSEResponse(response)
543
+
544
+ # Non-streaming also needs unwrapping!
545
+ # Must read response before wrapping (async requirement)
546
+ await response.aread()
547
+ return UnwrappedResponse(response)
548
+
549
+ # All endpoints/retries exhausted, return last response
550
+ if last_response:
551
+ # Ensure response is read for proper error handling
552
+ if not last_response.is_stream_consumed:
553
+ try:
554
+ await last_response.aread()
555
+ except Exception:
556
+ pass
557
+ return UnwrappedResponse(last_response)
558
+
559
+ return await super().send(request, **kwargs)
560
+
561
+ async def _extract_rate_limit_delay(self, response: httpx.Response) -> float | None:
562
+ """Extract the retry delay from a 429 rate limit response.
563
+
564
+ Parses the Antigravity/Google API error format to find:
565
+ - retryDelay from RetryInfo (e.g., "0.088325827s")
566
+ - quotaResetDelay from ErrorInfo metadata (e.g., "88.325827ms")
567
+
568
+ Returns the delay in seconds, or None if parsing fails.
569
+ """
570
+ try:
571
+ # Read response body if not already read
572
+ if not response.is_stream_consumed:
573
+ await response.aread()
574
+
575
+ error_data = json.loads(response.content)
576
+
577
+ if not isinstance(error_data, dict):
578
+ return 2.0 # Default fallback
579
+
580
+ error_info = error_data.get("error", {})
581
+ if not isinstance(error_info, dict):
582
+ return 2.0
583
+
584
+ details = error_info.get("details", [])
585
+ if not isinstance(details, list):
586
+ return 2.0
587
+
588
+ # Look for RetryInfo first (most precise)
589
+ for detail in details:
590
+ if not isinstance(detail, dict):
591
+ continue
592
+
593
+ detail_type = detail.get("@type", "")
594
+
595
+ # Check for RetryInfo (e.g., "0.088325827s")
596
+ if "RetryInfo" in detail_type:
597
+ retry_delay = detail.get("retryDelay", "")
598
+ parsed = self._parse_duration(retry_delay)
599
+ if parsed is not None:
600
+ return parsed
601
+
602
+ # Check for ErrorInfo with quotaResetDelay in metadata
603
+ if "ErrorInfo" in detail_type:
604
+ metadata = detail.get("metadata", {})
605
+ if isinstance(metadata, dict):
606
+ quota_delay = metadata.get("quotaResetDelay", "")
607
+ parsed = self._parse_duration(quota_delay)
608
+ if parsed is not None:
609
+ return parsed
610
+
611
+ return 2.0 # Default if no delay found
612
+
613
+ except (json.JSONDecodeError, Exception) as e:
614
+ logger.debug("Failed to parse rate limit response: %s", e)
615
+ return 2.0 # Default fallback
616
+
617
+ def _parse_duration(self, duration_str: str) -> float | None:
618
+ """Parse a duration string like '0.088s' or '88.325827ms' to seconds."""
619
+ if not duration_str or not isinstance(duration_str, str):
620
+ return None
621
+
622
+ duration_str = duration_str.strip()
623
+
624
+ try:
625
+ # Handle milliseconds (e.g., "88.325827ms")
626
+ if duration_str.endswith("ms"):
627
+ return float(duration_str[:-2]) / 1000.0
628
+
629
+ # Handle seconds (e.g., "0.088325827s")
630
+ if duration_str.endswith("s"):
631
+ return float(duration_str[:-1])
632
+
633
+ # Try parsing as raw number (assume seconds)
634
+ return float(duration_str)
635
+
636
+ except ValueError:
637
+ return None
638
+
639
+
640
+ def create_antigravity_client(
641
+ access_token: str,
642
+ project_id: str = "",
643
+ model_name: str = "",
644
+ base_url: str = "https://daily-cloudcode-pa.sandbox.googleapis.com",
645
+ headers: Optional[Dict[str, str]] = None,
646
+ ) -> AntigravityClient:
647
+ """Create an httpx client configured for Antigravity API."""
648
+ # Start with Antigravity-specific headers
649
+ default_headers = {
650
+ "Authorization": f"Bearer {access_token}",
651
+ "Content-Type": "application/json",
652
+ "Accept": "text/event-stream",
653
+ **ANTIGRAVITY_HEADERS,
654
+ }
655
+ if headers:
656
+ default_headers.update(headers)
657
+
658
+ return AntigravityClient(
659
+ project_id=project_id,
660
+ model_name=model_name,
661
+ base_url=base_url,
662
+ headers=default_headers,
663
+ timeout=httpx.Timeout(180.0, connect=30.0),
664
+ )