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.
- code_puppy/agents/base_agent.py +110 -124
- code_puppy/claude_cache_client.py +208 -2
- code_puppy/cli_runner.py +152 -32
- code_puppy/command_line/add_model_menu.py +4 -0
- code_puppy/command_line/autosave_menu.py +23 -24
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +5 -0
- code_puppy/command_line/config_commands.py +24 -1
- code_puppy/command_line/core_commands.py +85 -0
- code_puppy/command_line/diff_menu.py +5 -0
- code_puppy/command_line/mcp/custom_server_form.py +4 -0
- code_puppy/command_line/mcp/install_menu.py +5 -1
- code_puppy/command_line/model_settings_menu.py +5 -0
- code_puppy/command_line/motd.py +13 -7
- code_puppy/command_line/onboarding_slides.py +180 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/prompt_toolkit_completion.py +118 -0
- code_puppy/config.py +3 -2
- code_puppy/http_utils.py +201 -279
- code_puppy/keymap.py +10 -8
- code_puppy/mcp_/managed_server.py +7 -11
- code_puppy/messaging/messages.py +3 -0
- code_puppy/messaging/rich_renderer.py +114 -22
- code_puppy/model_factory.py +102 -15
- code_puppy/models.json +2 -2
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +668 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +664 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
- code_puppy/plugins/claude_code_oauth/utils.py +126 -7
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/terminal_utils.py +295 -3
- code_puppy/tools/command_runner.py +43 -54
- code_puppy/tools/common.py +3 -9
- code_puppy/uvx_detection.py +242 -0
- {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models.json +2 -2
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/METADATA +26 -49
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/RECORD +52 -36
- {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/entry_points.txt +0 -0
- {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
|
+
)
|