code-puppy 0.0.323__py3-none-any.whl → 0.0.335__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 (45) hide show
  1. code_puppy/agents/base_agent.py +74 -93
  2. code_puppy/cli_runner.py +105 -2
  3. code_puppy/command_line/add_model_menu.py +15 -0
  4. code_puppy/command_line/autosave_menu.py +5 -0
  5. code_puppy/command_line/colors_menu.py +5 -0
  6. code_puppy/command_line/config_commands.py +24 -1
  7. code_puppy/command_line/core_commands.py +51 -0
  8. code_puppy/command_line/diff_menu.py +5 -0
  9. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  10. code_puppy/command_line/mcp/install_menu.py +5 -1
  11. code_puppy/command_line/model_settings_menu.py +5 -0
  12. code_puppy/command_line/motd.py +13 -7
  13. code_puppy/command_line/onboarding_slides.py +180 -0
  14. code_puppy/command_line/onboarding_wizard.py +340 -0
  15. code_puppy/config.py +3 -2
  16. code_puppy/http_utils.py +155 -196
  17. code_puppy/keymap.py +10 -8
  18. code_puppy/model_factory.py +86 -15
  19. code_puppy/models.json +2 -2
  20. code_puppy/plugins/__init__.py +12 -0
  21. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  22. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  23. code_puppy/plugins/antigravity_oauth/antigravity_model.py +612 -0
  24. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  25. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  26. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  27. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  28. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  29. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  30. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  31. code_puppy/plugins/antigravity_oauth/transport.py +595 -0
  32. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  33. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
  34. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
  35. code_puppy/reopenable_async_client.py +8 -8
  36. code_puppy/terminal_utils.py +168 -3
  37. code_puppy/tools/command_runner.py +42 -54
  38. code_puppy/uvx_detection.py +242 -0
  39. {code_puppy-0.0.323.data → code_puppy-0.0.335.data}/data/code_puppy/models.json +2 -2
  40. {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/METADATA +30 -1
  41. {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/RECORD +45 -30
  42. {code_puppy-0.0.323.data → code_puppy-0.0.335.data}/data/code_puppy/models_dev_api.json +0 -0
  43. {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/WHEEL +0 -0
  44. {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/entry_points.txt +0 -0
  45. {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,595 @@
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
+ # Transform POST requests to Antigravity format
426
+ if request.method == "POST" and request.content:
427
+ new_content, new_path, new_query, is_claude_thinking = self._wrap_request(
428
+ request.content, str(request.url.path)
429
+ )
430
+ if new_path != str(request.url.path):
431
+ # Remove SDK headers that we need to override (case-insensitive)
432
+ headers_to_remove = {
433
+ "content-length",
434
+ "user-agent",
435
+ "x-goog-api-client",
436
+ "x-goog-api-key",
437
+ "client-metadata",
438
+ "accept",
439
+ }
440
+ new_headers = {
441
+ k: v
442
+ for k, v in request.headers.items()
443
+ if k.lower() not in headers_to_remove
444
+ }
445
+
446
+ # Add Antigravity headers (matching OpenCode exactly)
447
+ new_headers["user-agent"] = "antigravity/1.11.5 windows/amd64"
448
+ new_headers["x-goog-api-client"] = (
449
+ "google-cloud-sdk vscode_cloudshelleditor/0.1"
450
+ )
451
+ new_headers["client-metadata"] = (
452
+ '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'
453
+ )
454
+ new_headers["x-goog-api-key"] = "" # Must be present but empty!
455
+ new_headers["accept"] = "text/event-stream"
456
+
457
+ # Add anthropic-beta header for Claude thinking models (interleaved thinking)
458
+ # This enables real-time streaming of thinking tokens between tool calls
459
+ if is_claude_thinking:
460
+ interleaved_header = "interleaved-thinking-2025-05-14"
461
+ existing = new_headers.get("anthropic-beta", "")
462
+ if existing:
463
+ if interleaved_header not in existing:
464
+ new_headers["anthropic-beta"] = (
465
+ f"{existing},{interleaved_header}"
466
+ )
467
+ else:
468
+ new_headers["anthropic-beta"] = interleaved_header
469
+
470
+ # Try each endpoint in fallback order
471
+ last_response = None
472
+
473
+ # Check for rate limit
474
+ import asyncio
475
+
476
+ # Try endpoints logic...
477
+ max_retries = 3
478
+
479
+ for attempt in range(max_retries):
480
+ for endpoint in ANTIGRAVITY_ENDPOINT_FALLBACKS:
481
+ # Build URL with current endpoint
482
+ new_url = httpx.URL(
483
+ scheme="https",
484
+ host=endpoint.replace("https://", ""),
485
+ path=new_path,
486
+ query=new_query.encode() if new_query else b"",
487
+ )
488
+
489
+ req = httpx.Request(
490
+ method=request.method,
491
+ url=new_url,
492
+ headers=new_headers,
493
+ content=new_content,
494
+ )
495
+
496
+ response = await super().send(req, **kwargs)
497
+ last_response = response
498
+
499
+ # Handle rate limit (429)
500
+ if response.status_code == 429:
501
+ try:
502
+ # Need to read response to get reset time
503
+ await response.aread()
504
+ error_data = json.loads(response.content)
505
+
506
+ # Extract reset delay from metadata
507
+ reset_delay = 2.0 # Default fallback
508
+ if isinstance(error_data, dict):
509
+ error_info = error_data.get("error", {})
510
+ if isinstance(error_info, dict):
511
+ details = error_info.get("details", [])
512
+ for detail in details:
513
+ metadata = detail.get("metadata", {})
514
+ if "quotaResetDelay" in metadata:
515
+ delay_str = metadata["quotaResetDelay"]
516
+ if delay_str.endswith("s"):
517
+ reset_delay = float(delay_str[:-1])
518
+ break
519
+
520
+ # Only retry if delay is reasonable (< 60s)
521
+ if reset_delay < 60:
522
+ # Add small buffer
523
+ wait_time = reset_delay + 0.5
524
+ from code_puppy.messaging import emit_warning
525
+
526
+ emit_warning(
527
+ f"⏳ Rate limited on {endpoint}. Waiting {wait_time:.1f}s..."
528
+ )
529
+ await asyncio.sleep(wait_time)
530
+ continue # Retry same endpoint
531
+ else:
532
+ emit_warning(
533
+ f"❌ Rate limit reset too long ({reset_delay}s). Aborting."
534
+ )
535
+ return UnwrappedResponse(response)
536
+ except Exception:
537
+ pass # Failed to parse error, treat as normal error
538
+
539
+ # Retry on 403, 404, 5xx errors
540
+ if (
541
+ response.status_code in (403, 404)
542
+ or response.status_code >= 500
543
+ ):
544
+ logger.debug(
545
+ "Endpoint %s returned %d, trying next...",
546
+ endpoint,
547
+ response.status_code,
548
+ )
549
+ continue
550
+
551
+ # Success or non-retriable error
552
+ # Wrap response to unwrap Antigravity format
553
+ if "alt=sse" in new_query:
554
+ return UnwrappedSSEResponse(response)
555
+
556
+ # Non-streaming also needs unwrapping!
557
+ # Must read response before wrapping (async requirement)
558
+ await response.aread()
559
+ return UnwrappedResponse(response)
560
+
561
+ # All endpoints failed, return last response
562
+ if last_response and last_response.status_code == 429:
563
+ await last_response.aread()
564
+ return UnwrappedResponse(last_response)
565
+
566
+ return last_response
567
+
568
+ return await super().send(request, **kwargs)
569
+
570
+
571
+ def create_antigravity_client(
572
+ access_token: str,
573
+ project_id: str = "",
574
+ model_name: str = "",
575
+ base_url: str = "https://daily-cloudcode-pa.sandbox.googleapis.com",
576
+ headers: Optional[Dict[str, str]] = None,
577
+ ) -> AntigravityClient:
578
+ """Create an httpx client configured for Antigravity API."""
579
+ # Start with Antigravity-specific headers
580
+ default_headers = {
581
+ "Authorization": f"Bearer {access_token}",
582
+ "Content-Type": "application/json",
583
+ "Accept": "text/event-stream",
584
+ **ANTIGRAVITY_HEADERS,
585
+ }
586
+ if headers:
587
+ default_headers.update(headers)
588
+
589
+ return AntigravityClient(
590
+ project_id=project_id,
591
+ model_name=model_name,
592
+ base_url=base_url,
593
+ headers=default_headers,
594
+ timeout=httpx.Timeout(180.0, connect=30.0),
595
+ )