code-puppy 0.0.348__py3-none-any.whl → 0.0.372__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/__init__.py +8 -0
- code_puppy/agents/agent_manager.py +272 -1
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_qa_kitten.py +12 -7
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/base_agent.py +11 -8
- code_puppy/agents/event_stream_handler.py +101 -8
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +73 -0
- code_puppy/chatgpt_codex_client.py +53 -0
- code_puppy/claude_cache_client.py +294 -41
- code_puppy/command_line/add_model_menu.py +13 -4
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/core_commands.py +89 -112
- code_puppy/command_line/model_picker_completion.py +3 -20
- code_puppy/command_line/model_settings_menu.py +21 -3
- code_puppy/config.py +145 -70
- code_puppy/gemini_model.py +706 -0
- code_puppy/http_utils.py +6 -3
- code_puppy/messaging/__init__.py +15 -0
- code_puppy/messaging/messages.py +27 -0
- code_puppy/messaging/queue_console.py +1 -1
- code_puppy/messaging/rich_renderer.py +36 -1
- code_puppy/messaging/spinner/__init__.py +20 -2
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +50 -16
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +27 -24
- code_puppy/models.json +12 -12
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +206 -172
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
- code_puppy/plugins/antigravity_oauth/transport.py +236 -45
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
- code_puppy/plugins/claude_code_oauth/utils.py +4 -1
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +52 -0
- code_puppy/status_display.py +6 -2
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +83 -33
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +6 -6
- code_puppy/tools/browser/browser_interactions.py +21 -20
- code_puppy/tools/browser/browser_locators.py +9 -9
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +7 -7
- code_puppy/tools/browser/browser_screenshot.py +78 -140
- code_puppy/tools/browser/browser_scripts.py +15 -13
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +292 -101
- code_puppy/tools/common.py +176 -1
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/subagent_context.py +158 -0
- {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/METADATA +17 -16
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/RECORD +84 -51
- code_puppy/prompts/codex_system_prompt.md +0 -310
- code_puppy/tools/browser/camoufox_manager.py +0 -235
- code_puppy/tools/browser/vqa_agent.py +0 -90
- {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/licenses/LICENSE +0 -0
|
@@ -23,20 +23,78 @@ from .constants import (
|
|
|
23
23
|
logger = logging.getLogger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
def _flatten_union_to_object(union_items: list, defs: dict, resolve_fn) -> dict:
|
|
27
|
+
"""Flatten a union of object types into a single object with all properties.
|
|
28
|
+
|
|
29
|
+
For discriminated unions like EditFilePayload (ContentPayload | ReplacementsPayload | DeleteSnippetPayload),
|
|
30
|
+
we merge all object types into one with all properties marked as optional.
|
|
31
|
+
"""
|
|
32
|
+
merged_properties = {}
|
|
33
|
+
has_string_type = False
|
|
34
|
+
|
|
35
|
+
for item in union_items:
|
|
36
|
+
if not isinstance(item, dict):
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
# Resolve $ref first
|
|
40
|
+
if "$ref" in item:
|
|
41
|
+
ref_path = item["$ref"]
|
|
42
|
+
ref_name = None
|
|
43
|
+
if ref_path.startswith("#/$defs/"):
|
|
44
|
+
ref_name = ref_path[8:]
|
|
45
|
+
elif ref_path.startswith("#/definitions/"):
|
|
46
|
+
ref_name = ref_path[14:]
|
|
47
|
+
if ref_name and ref_name in defs:
|
|
48
|
+
item = copy.deepcopy(defs[ref_name])
|
|
49
|
+
else:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
# Check for string type (common fallback)
|
|
53
|
+
if item.get("type") == "string":
|
|
54
|
+
has_string_type = True
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
# Skip null types
|
|
58
|
+
if item.get("type") == "null":
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Merge properties from object types
|
|
62
|
+
if item.get("type") == "object" or "properties" in item:
|
|
63
|
+
props = item.get("properties", {})
|
|
64
|
+
for prop_name, prop_schema in props.items():
|
|
65
|
+
if prop_name not in merged_properties:
|
|
66
|
+
# Resolve the property schema
|
|
67
|
+
merged_properties[prop_name] = resolve_fn(
|
|
68
|
+
copy.deepcopy(prop_schema)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if not merged_properties:
|
|
72
|
+
# No object properties found, return string type as fallback
|
|
73
|
+
return {"type": "string"} if has_string_type else {"type": "object"}
|
|
74
|
+
|
|
75
|
+
# Build merged object - no required fields since any subset is valid
|
|
76
|
+
result = {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": merged_properties,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _inline_refs(schema: dict, simplify_unions: bool = False) -> dict:
|
|
29
85
|
"""Inline $ref references and transform schema for Antigravity compatibility.
|
|
30
86
|
|
|
31
87
|
- Inlines $ref references
|
|
32
88
|
- Removes $defs, definitions, $schema, $id
|
|
33
|
-
- Optionally converts anyOf/oneOf/allOf to any_of/one_of/all_of (only for Gemini)
|
|
34
89
|
- Removes unsupported fields like 'default', 'examples', 'const'
|
|
35
|
-
-
|
|
90
|
+
- When simplify_unions=True: flattens anyOf/oneOf unions:
|
|
91
|
+
- For unions of objects: merges into single object with all properties
|
|
92
|
+
- For simple unions (string | null): picks first non-null type
|
|
93
|
+
(required for both Gemini AND Claude - neither supports union types in function schemas!)
|
|
36
94
|
|
|
37
95
|
Args:
|
|
38
|
-
|
|
39
|
-
|
|
96
|
+
simplify_unions: If True, simplify anyOf/oneOf unions.
|
|
97
|
+
Required for Gemini and Claude models.
|
|
40
98
|
"""
|
|
41
99
|
if not isinstance(schema, dict):
|
|
42
100
|
return schema
|
|
@@ -47,31 +105,73 @@ def _inline_refs(
|
|
|
47
105
|
# Extract $defs for reference resolution
|
|
48
106
|
defs = schema.pop("$defs", schema.pop("definitions", {}))
|
|
49
107
|
|
|
50
|
-
def resolve_refs(
|
|
51
|
-
obj, convert_unions=convert_unions, simplify_for_claude=simplify_for_claude
|
|
52
|
-
):
|
|
108
|
+
def resolve_refs(obj, simplify_unions=simplify_unions):
|
|
53
109
|
"""Recursively resolve $ref references and transform schema."""
|
|
54
110
|
if isinstance(obj, dict):
|
|
55
|
-
#
|
|
56
|
-
if
|
|
111
|
+
# Handle anyOf/oneOf unions
|
|
112
|
+
if simplify_unions:
|
|
57
113
|
for union_key in ["anyOf", "oneOf"]:
|
|
58
114
|
if union_key in obj:
|
|
59
115
|
union = obj[union_key]
|
|
60
116
|
if isinstance(union, list):
|
|
61
|
-
#
|
|
117
|
+
# Check if this is a complex union of objects (discriminated union)
|
|
118
|
+
# vs a simple nullable type (string | null)
|
|
119
|
+
object_count = 0
|
|
120
|
+
has_refs = False
|
|
121
|
+
for item in union:
|
|
122
|
+
if isinstance(item, dict):
|
|
123
|
+
if "$ref" in item:
|
|
124
|
+
has_refs = True
|
|
125
|
+
object_count += 1
|
|
126
|
+
elif (
|
|
127
|
+
item.get("type") == "object"
|
|
128
|
+
or "properties" in item
|
|
129
|
+
):
|
|
130
|
+
object_count += 1
|
|
131
|
+
|
|
132
|
+
# If multiple objects or has refs, flatten to single object
|
|
133
|
+
if object_count > 1 or has_refs:
|
|
134
|
+
flattened = _flatten_union_to_object(
|
|
135
|
+
union, defs, resolve_refs
|
|
136
|
+
)
|
|
137
|
+
# Keep description if present
|
|
138
|
+
if "description" in obj:
|
|
139
|
+
flattened["description"] = obj["description"]
|
|
140
|
+
return flattened
|
|
141
|
+
|
|
142
|
+
# Simple union - pick first non-null type
|
|
62
143
|
for item in union:
|
|
63
144
|
if (
|
|
64
145
|
isinstance(item, dict)
|
|
65
146
|
and item.get("type") != "null"
|
|
66
147
|
):
|
|
67
|
-
# Replace the whole object with this type
|
|
68
148
|
result = dict(item)
|
|
69
|
-
# Keep description if present
|
|
70
149
|
if "description" in obj:
|
|
71
150
|
result["description"] = obj["description"]
|
|
72
|
-
return resolve_refs(
|
|
73
|
-
|
|
74
|
-
|
|
151
|
+
return resolve_refs(result, simplify_unions)
|
|
152
|
+
|
|
153
|
+
# Also handle allOf by merging all schemas
|
|
154
|
+
if simplify_unions and "allOf" in obj:
|
|
155
|
+
all_of = obj["allOf"]
|
|
156
|
+
if isinstance(all_of, list):
|
|
157
|
+
merged = {}
|
|
158
|
+
merged_properties = {}
|
|
159
|
+
for item in all_of:
|
|
160
|
+
if isinstance(item, dict):
|
|
161
|
+
resolved_item = resolve_refs(item, simplify_unions)
|
|
162
|
+
# Deep merge properties dicts
|
|
163
|
+
if "properties" in resolved_item:
|
|
164
|
+
merged_properties.update(
|
|
165
|
+
resolved_item.pop("properties")
|
|
166
|
+
)
|
|
167
|
+
merged.update(resolved_item)
|
|
168
|
+
if merged_properties:
|
|
169
|
+
merged["properties"] = merged_properties
|
|
170
|
+
# Keep other fields from original object (except allOf)
|
|
171
|
+
for k, v in obj.items():
|
|
172
|
+
if k != "allOf":
|
|
173
|
+
merged[k] = v
|
|
174
|
+
return resolve_refs(merged, simplify_unions)
|
|
75
175
|
|
|
76
176
|
# Check for $ref
|
|
77
177
|
if "$ref" in obj:
|
|
@@ -111,34 +211,23 @@ def _inline_refs(
|
|
|
111
211
|
):
|
|
112
212
|
continue
|
|
113
213
|
|
|
114
|
-
#
|
|
115
|
-
if
|
|
214
|
+
# Skip additionalProperties (not supported by Gemini or Claude)
|
|
215
|
+
if simplify_unions and key == "additionalProperties":
|
|
116
216
|
continue
|
|
117
217
|
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
if
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
)
|
|
218
|
+
# Skip any remaining union type keys that weren't simplified above
|
|
219
|
+
# (This shouldn't happen normally, but just in case)
|
|
220
|
+
if simplify_unions and key in ("anyOf", "oneOf", "allOf"):
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
result[key] = resolve_refs(value, simplify_unions)
|
|
133
224
|
return result
|
|
134
225
|
elif isinstance(obj, list):
|
|
135
|
-
return [
|
|
136
|
-
resolve_refs(item, convert_unions, simplify_for_claude) for item in obj
|
|
137
|
-
]
|
|
226
|
+
return [resolve_refs(item, simplify_unions) for item in obj]
|
|
138
227
|
else:
|
|
139
228
|
return obj
|
|
140
229
|
|
|
141
|
-
return resolve_refs(schema,
|
|
230
|
+
return resolve_refs(schema, simplify_unions)
|
|
142
231
|
|
|
143
232
|
|
|
144
233
|
class UnwrappedResponse(httpx.Response):
|
|
@@ -264,17 +353,91 @@ class UnwrappedSSEResponse(httpx.Response):
|
|
|
264
353
|
|
|
265
354
|
|
|
266
355
|
class AntigravityClient(httpx.AsyncClient):
|
|
267
|
-
"""Custom httpx client that handles Antigravity request/response wrapping.
|
|
356
|
+
"""Custom httpx client that handles Antigravity request/response wrapping.
|
|
357
|
+
|
|
358
|
+
Supports proactive token refresh to prevent expiry during long sessions.
|
|
359
|
+
"""
|
|
268
360
|
|
|
269
361
|
def __init__(
|
|
270
362
|
self,
|
|
271
363
|
project_id: str = "",
|
|
272
364
|
model_name: str = "",
|
|
365
|
+
refresh_token: str = "",
|
|
366
|
+
expires_at: Optional[float] = None,
|
|
367
|
+
on_token_refreshed: Optional[Any] = None,
|
|
273
368
|
**kwargs: Any,
|
|
274
369
|
):
|
|
275
370
|
super().__init__(**kwargs)
|
|
276
371
|
self.project_id = project_id
|
|
277
372
|
self.model_name = model_name
|
|
373
|
+
self._refresh_token = refresh_token
|
|
374
|
+
self._expires_at = expires_at
|
|
375
|
+
self._on_token_refreshed = on_token_refreshed
|
|
376
|
+
self._refresh_lock = None # Lazy init for async lock
|
|
377
|
+
|
|
378
|
+
async def _ensure_valid_token(self) -> None:
|
|
379
|
+
"""Proactively refresh the access token if it's expired or about to expire.
|
|
380
|
+
|
|
381
|
+
This prevents 401 errors during long-running sessions by checking and
|
|
382
|
+
refreshing the token BEFORE making requests, not after they fail.
|
|
383
|
+
"""
|
|
384
|
+
import asyncio
|
|
385
|
+
|
|
386
|
+
from .token import is_token_expired, refresh_access_token
|
|
387
|
+
|
|
388
|
+
# Skip if no refresh token configured
|
|
389
|
+
if not self._refresh_token:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
# Check if token needs refresh (includes 60-second buffer)
|
|
393
|
+
if not is_token_expired(self._expires_at):
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
# Lazy init the async lock
|
|
397
|
+
if self._refresh_lock is None:
|
|
398
|
+
self._refresh_lock = asyncio.Lock()
|
|
399
|
+
|
|
400
|
+
async with self._refresh_lock:
|
|
401
|
+
# Double-check after acquiring lock (another coroutine may have refreshed)
|
|
402
|
+
if not is_token_expired(self._expires_at):
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
logger.debug("Proactively refreshing Antigravity access token...")
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
# Run the synchronous refresh in a thread pool to avoid blocking
|
|
409
|
+
loop = asyncio.get_event_loop()
|
|
410
|
+
new_tokens = await loop.run_in_executor(
|
|
411
|
+
None, refresh_access_token, self._refresh_token
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if new_tokens:
|
|
415
|
+
# Update internal state
|
|
416
|
+
new_access_token = new_tokens.access_token
|
|
417
|
+
self._expires_at = new_tokens.expires_at
|
|
418
|
+
self._refresh_token = new_tokens.refresh_token
|
|
419
|
+
|
|
420
|
+
# Update the Authorization header
|
|
421
|
+
self.headers["Authorization"] = f"Bearer {new_access_token}"
|
|
422
|
+
|
|
423
|
+
logger.info(
|
|
424
|
+
"Proactively refreshed Antigravity token (expires in %ds)",
|
|
425
|
+
int(self._expires_at - __import__("time").time()),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Notify callback (e.g., to persist updated tokens)
|
|
429
|
+
if self._on_token_refreshed:
|
|
430
|
+
try:
|
|
431
|
+
self._on_token_refreshed(new_tokens)
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.warning("Token refresh callback failed: %s", e)
|
|
434
|
+
else:
|
|
435
|
+
logger.warning(
|
|
436
|
+
"Failed to proactively refresh token - request may fail with 401"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
except Exception as e:
|
|
440
|
+
logger.warning("Proactive token refresh error: %s", e)
|
|
278
441
|
|
|
279
442
|
def _wrap_request(self, content: bytes, url: str) -> tuple[bytes, str, str, bool]:
|
|
280
443
|
"""Wrap request body in Antigravity envelope and transform URL.
|
|
@@ -331,15 +494,14 @@ class AntigravityClient(httpx.AsyncClient):
|
|
|
331
494
|
)
|
|
332
495
|
|
|
333
496
|
# Inline $refs and remove $defs from parameters
|
|
334
|
-
#
|
|
335
|
-
#
|
|
497
|
+
# Simplify union types (anyOf/oneOf/allOf) for BOTH Gemini and Claude
|
|
498
|
+
# Neither API supports union types in function schemas!
|
|
336
499
|
if "parameters" in func_decl:
|
|
337
500
|
is_gemini = "gemini" in model.lower()
|
|
338
501
|
is_claude = "claude" in model.lower()
|
|
339
502
|
func_decl["parameters"] = _inline_refs(
|
|
340
503
|
func_decl["parameters"],
|
|
341
|
-
|
|
342
|
-
simplify_for_claude=is_claude,
|
|
504
|
+
simplify_unions=(is_gemini or is_claude),
|
|
343
505
|
)
|
|
344
506
|
|
|
345
507
|
# Fix generationConfig for Antigravity compatibility
|
|
@@ -393,6 +555,7 @@ class AntigravityClient(httpx.AsyncClient):
|
|
|
393
555
|
"request": original_body,
|
|
394
556
|
"userAgent": "antigravity",
|
|
395
557
|
"requestId": request_id,
|
|
558
|
+
"requestType": "agent",
|
|
396
559
|
}
|
|
397
560
|
|
|
398
561
|
# Transform URL to Antigravity format
|
|
@@ -424,6 +587,9 @@ class AntigravityClient(httpx.AsyncClient):
|
|
|
424
587
|
"""Override send to intercept at the lowest level with endpoint fallback."""
|
|
425
588
|
import asyncio
|
|
426
589
|
|
|
590
|
+
# Proactively refresh token BEFORE making the request
|
|
591
|
+
await self._ensure_valid_token()
|
|
592
|
+
|
|
427
593
|
# Transform POST requests to Antigravity format
|
|
428
594
|
if request.method == "POST" and request.content:
|
|
429
595
|
new_content, new_path, new_query, is_claude_thinking = self._wrap_request(
|
|
@@ -637,14 +803,36 @@ class AntigravityClient(httpx.AsyncClient):
|
|
|
637
803
|
return None
|
|
638
804
|
|
|
639
805
|
|
|
806
|
+
# Type alias for token refresh callback
|
|
807
|
+
TokenRefreshCallback = Any # Callable[[OAuthTokens], None]
|
|
808
|
+
|
|
809
|
+
|
|
640
810
|
def create_antigravity_client(
|
|
641
811
|
access_token: str,
|
|
642
812
|
project_id: str = "",
|
|
643
813
|
model_name: str = "",
|
|
644
814
|
base_url: str = "https://daily-cloudcode-pa.sandbox.googleapis.com",
|
|
645
815
|
headers: Optional[Dict[str, str]] = None,
|
|
816
|
+
refresh_token: str = "",
|
|
817
|
+
expires_at: Optional[float] = None,
|
|
818
|
+
on_token_refreshed: Optional[TokenRefreshCallback] = None,
|
|
646
819
|
) -> AntigravityClient:
|
|
647
|
-
"""Create an httpx client configured for Antigravity API.
|
|
820
|
+
"""Create an httpx client configured for Antigravity API.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
access_token: The OAuth access token for authentication
|
|
824
|
+
project_id: The GCP project ID
|
|
825
|
+
model_name: The model name being used
|
|
826
|
+
base_url: The API base URL
|
|
827
|
+
headers: Additional headers to include
|
|
828
|
+
refresh_token: The OAuth refresh token for proactive token refresh
|
|
829
|
+
expires_at: Unix timestamp when the access token expires
|
|
830
|
+
on_token_refreshed: Callback called when token is proactively refreshed,
|
|
831
|
+
receives OAuthTokens object to persist the new tokens
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
An AntigravityClient configured for API requests with proactive token refresh
|
|
835
|
+
"""
|
|
648
836
|
# Start with Antigravity-specific headers
|
|
649
837
|
default_headers = {
|
|
650
838
|
"Authorization": f"Bearer {access_token}",
|
|
@@ -658,6 +846,9 @@ def create_antigravity_client(
|
|
|
658
846
|
return AntigravityClient(
|
|
659
847
|
project_id=project_id,
|
|
660
848
|
model_name=model_name,
|
|
849
|
+
refresh_token=refresh_token,
|
|
850
|
+
expires_at=expires_at,
|
|
851
|
+
on_token_refreshed=on_token_refreshed,
|
|
661
852
|
base_url=base_url,
|
|
662
853
|
headers=default_headers,
|
|
663
854
|
timeout=httpx.Timeout(180.0, connect=30.0),
|
|
@@ -6,8 +6,8 @@ import os
|
|
|
6
6
|
from typing import List, Optional, Tuple
|
|
7
7
|
|
|
8
8
|
from code_puppy.callbacks import register_callback
|
|
9
|
-
from code_puppy.config import set_model_name
|
|
10
9
|
from code_puppy.messaging import emit_info, emit_success, emit_warning
|
|
10
|
+
from code_puppy.model_switching import set_model_and_reload_agent
|
|
11
11
|
|
|
12
12
|
from .config import CHATGPT_OAUTH_CONFIG, get_token_storage_path
|
|
13
13
|
from .oauth_flow import run_oauth_flow
|
|
@@ -76,7 +76,7 @@ def _handle_custom_command(command: str, name: str) -> Optional[bool]:
|
|
|
76
76
|
|
|
77
77
|
if name == "chatgpt-auth":
|
|
78
78
|
run_oauth_flow()
|
|
79
|
-
|
|
79
|
+
set_model_and_reload_agent("chatgpt-gpt-5.2-codex")
|
|
80
80
|
return True
|
|
81
81
|
|
|
82
82
|
if name == "chatgpt-status":
|
|
@@ -12,8 +12,8 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
12
12
|
from urllib.parse import parse_qs, urlparse
|
|
13
13
|
|
|
14
14
|
from code_puppy.callbacks import register_callback
|
|
15
|
-
from code_puppy.config import set_model_name
|
|
16
15
|
from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
|
|
16
|
+
from code_puppy.model_switching import set_model_and_reload_agent
|
|
17
17
|
|
|
18
18
|
from ..oauth_puppy_html import oauth_failure_html, oauth_success_html
|
|
19
19
|
from .config import CLAUDE_CODE_OAUTH_CONFIG, get_token_storage_path
|
|
@@ -181,31 +181,6 @@ def _custom_help() -> List[Tuple[str, str]]:
|
|
|
181
181
|
]
|
|
182
182
|
|
|
183
183
|
|
|
184
|
-
def _reload_current_agent() -> None:
|
|
185
|
-
"""Reload the current agent so new auth tokens are picked up immediately."""
|
|
186
|
-
try:
|
|
187
|
-
from code_puppy.agents import get_current_agent
|
|
188
|
-
|
|
189
|
-
current_agent = get_current_agent()
|
|
190
|
-
if current_agent is None:
|
|
191
|
-
logger.debug("No current agent to reload")
|
|
192
|
-
return
|
|
193
|
-
|
|
194
|
-
# JSON agents may need to refresh their config before reload
|
|
195
|
-
if hasattr(current_agent, "refresh_config"):
|
|
196
|
-
try:
|
|
197
|
-
current_agent.refresh_config()
|
|
198
|
-
except Exception:
|
|
199
|
-
# Non-fatal, continue to reload
|
|
200
|
-
pass
|
|
201
|
-
|
|
202
|
-
current_agent.reload_code_generation_agent()
|
|
203
|
-
emit_info("Active agent reloaded with new authentication")
|
|
204
|
-
except Exception as e:
|
|
205
|
-
emit_warning(f"Authentication succeeded but agent reload failed: {e}")
|
|
206
|
-
logger.exception("Failed to reload agent after authentication")
|
|
207
|
-
|
|
208
|
-
|
|
209
184
|
def _perform_authentication() -> None:
|
|
210
185
|
context = prepare_oauth_context()
|
|
211
186
|
code = _await_callback(context)
|
|
@@ -245,9 +220,6 @@ def _perform_authentication() -> None:
|
|
|
245
220
|
"Claude Code models added to your configuration. Use the `claude-code-` prefix!"
|
|
246
221
|
)
|
|
247
222
|
|
|
248
|
-
# Reload the current agent so the new auth token is picked up immediately
|
|
249
|
-
_reload_current_agent()
|
|
250
|
-
|
|
251
223
|
|
|
252
224
|
def _handle_custom_command(command: str, name: str) -> Optional[bool]:
|
|
253
225
|
if not name:
|
|
@@ -261,7 +233,7 @@ def _handle_custom_command(command: str, name: str) -> Optional[bool]:
|
|
|
261
233
|
"Existing Claude Code tokens found. Continuing will overwrite them."
|
|
262
234
|
)
|
|
263
235
|
_perform_authentication()
|
|
264
|
-
|
|
236
|
+
set_model_and_reload_agent("claude-code-claude-opus-4-5-20251101")
|
|
265
237
|
return True
|
|
266
238
|
|
|
267
239
|
if name == "claude-code-status":
|
|
@@ -21,7 +21,10 @@ from .config import (
|
|
|
21
21
|
get_token_storage_path,
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# Proactive refresh buffer: refresh tokens 1 hour before expiration
|
|
25
|
+
# This ensures smooth operation by refreshing during model requests,
|
|
26
|
+
# well before the token actually expires
|
|
27
|
+
TOKEN_REFRESH_BUFFER_SECONDS = 3600 # 1 hour
|
|
25
28
|
|
|
26
29
|
logger = logging.getLogger(__name__)
|
|
27
30
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Frontend emitter plugin for Code Puppy.
|
|
2
|
+
|
|
3
|
+
This plugin provides event emission capabilities for frontend integration,
|
|
4
|
+
allowing WebSocket handlers to subscribe to real-time events from the
|
|
5
|
+
agent system including tool calls, streaming events, and agent invocations.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from code_puppy.plugins.frontend_emitter.emitter import (
|
|
9
|
+
emit_event,
|
|
10
|
+
subscribe,
|
|
11
|
+
unsubscribe,
|
|
12
|
+
get_recent_events,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Subscribe to events
|
|
16
|
+
queue = subscribe()
|
|
17
|
+
|
|
18
|
+
# Process events in your WebSocket handler
|
|
19
|
+
while True:
|
|
20
|
+
event = await queue.get()
|
|
21
|
+
await websocket.send_json(event)
|
|
22
|
+
|
|
23
|
+
# Clean up
|
|
24
|
+
unsubscribe(queue)
|
|
25
|
+
"""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Event emitter for frontend integration.
|
|
2
|
+
|
|
3
|
+
Provides a global event queue that WebSocket handlers can subscribe to.
|
|
4
|
+
Events are JSON-serializable dicts with type, timestamp, and data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Dict, List, Set
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from code_puppy.config import (
|
|
14
|
+
get_frontend_emitter_enabled,
|
|
15
|
+
get_frontend_emitter_max_recent_events,
|
|
16
|
+
get_frontend_emitter_queue_size,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Global state for event distribution
|
|
22
|
+
_subscribers: Set[asyncio.Queue[Dict[str, Any]]] = set()
|
|
23
|
+
_recent_events: List[Dict[str, Any]] = [] # Keep last N events for new subscribers
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def emit_event(event_type: str, data: Any = None) -> None:
|
|
27
|
+
"""Emit an event to all subscribers.
|
|
28
|
+
|
|
29
|
+
Creates a structured event dict with unique ID, type, timestamp, and data,
|
|
30
|
+
then broadcasts it to all active subscriber queues.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
event_type: Type of event (e.g., "tool_call_start", "stream_token")
|
|
34
|
+
data: Event data payload - should be JSON-serializable
|
|
35
|
+
"""
|
|
36
|
+
# Early return if emitter is disabled
|
|
37
|
+
if not get_frontend_emitter_enabled():
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
event: Dict[str, Any] = {
|
|
41
|
+
"id": str(uuid4()),
|
|
42
|
+
"type": event_type,
|
|
43
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
44
|
+
"data": data or {},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Store in recent events for replay to new subscribers
|
|
48
|
+
max_recent = get_frontend_emitter_max_recent_events()
|
|
49
|
+
_recent_events.append(event)
|
|
50
|
+
if len(_recent_events) > max_recent:
|
|
51
|
+
_recent_events.pop(0)
|
|
52
|
+
|
|
53
|
+
# Broadcast to all active subscribers
|
|
54
|
+
for subscriber_queue in _subscribers.copy():
|
|
55
|
+
try:
|
|
56
|
+
subscriber_queue.put_nowait(event)
|
|
57
|
+
except asyncio.QueueFull:
|
|
58
|
+
logger.warning(f"Subscriber queue full, dropping event: {event_type}")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.error(f"Failed to emit event to subscriber: {e}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def subscribe() -> asyncio.Queue[Dict[str, Any]]:
|
|
64
|
+
"""Subscribe to events.
|
|
65
|
+
|
|
66
|
+
Creates and returns a new async queue that will receive all future events.
|
|
67
|
+
The queue has a configurable max size (via frontend_emitter_queue_size)
|
|
68
|
+
to prevent unbounded memory growth if the subscriber is slow to process events.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
An asyncio.Queue that will receive event dictionaries.
|
|
72
|
+
"""
|
|
73
|
+
queue_size = get_frontend_emitter_queue_size()
|
|
74
|
+
queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue(maxsize=queue_size)
|
|
75
|
+
_subscribers.add(queue)
|
|
76
|
+
logger.debug(f"New subscriber added, total subscribers: {len(_subscribers)}")
|
|
77
|
+
return queue
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def unsubscribe(queue: asyncio.Queue[Dict[str, Any]]) -> None:
|
|
81
|
+
"""Unsubscribe from events.
|
|
82
|
+
|
|
83
|
+
Removes the queue from the subscriber set. Safe to call even if the queue
|
|
84
|
+
was never subscribed or already unsubscribed.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
queue: The queue returned from subscribe()
|
|
88
|
+
"""
|
|
89
|
+
_subscribers.discard(queue)
|
|
90
|
+
logger.debug(f"Subscriber removed, remaining subscribers: {len(_subscribers)}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_recent_events() -> List[Dict[str, Any]]:
|
|
94
|
+
"""Get recent events for new subscribers.
|
|
95
|
+
|
|
96
|
+
Returns a copy of the most recent events (up to frontend_emitter_max_recent_events).
|
|
97
|
+
Useful for allowing new WebSocket connections to "catch up" on
|
|
98
|
+
recent activity.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
A list of recent event dictionaries.
|
|
102
|
+
"""
|
|
103
|
+
return _recent_events.copy()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_subscriber_count() -> int:
|
|
107
|
+
"""Get the current number of active subscribers.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Number of active subscriber queues.
|
|
111
|
+
"""
|
|
112
|
+
return len(_subscribers)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def clear_recent_events() -> None:
|
|
116
|
+
"""Clear the recent events buffer.
|
|
117
|
+
|
|
118
|
+
Useful for testing or resetting state.
|
|
119
|
+
"""
|
|
120
|
+
_recent_events.clear()
|
|
121
|
+
logger.debug("Recent events cleared")
|