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.
- code_puppy/agents/base_agent.py +74 -93
- code_puppy/cli_runner.py +105 -2
- code_puppy/command_line/add_model_menu.py +15 -0
- code_puppy/command_line/autosave_menu.py +5 -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 +51 -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/config.py +3 -2
- code_puppy/http_utils.py +155 -196
- code_puppy/keymap.py +10 -8
- code_puppy/model_factory.py +86 -15
- code_puppy/models.json +2 -2
- code_puppy/plugins/__init__.py +12 -0
- 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 +612 -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 +595 -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/reopenable_async_client.py +8 -8
- code_puppy/terminal_utils.py +168 -3
- code_puppy/tools/command_runner.py +42 -54
- code_puppy/uvx_detection.py +242 -0
- {code_puppy-0.0.323.data → code_puppy-0.0.335.data}/data/code_puppy/models.json +2 -2
- {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/METADATA +30 -1
- {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/RECORD +45 -30
- {code_puppy-0.0.323.data → code_puppy-0.0.335.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/entry_points.txt +0 -0
- {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
|
+
)
|