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.
Files changed (87) hide show
  1. code_puppy/agents/__init__.py +8 -0
  2. code_puppy/agents/agent_manager.py +272 -1
  3. code_puppy/agents/agent_pack_leader.py +383 -0
  4. code_puppy/agents/agent_qa_kitten.py +12 -7
  5. code_puppy/agents/agent_terminal_qa.py +323 -0
  6. code_puppy/agents/base_agent.py +11 -8
  7. code_puppy/agents/event_stream_handler.py +101 -8
  8. code_puppy/agents/pack/__init__.py +34 -0
  9. code_puppy/agents/pack/bloodhound.py +304 -0
  10. code_puppy/agents/pack/husky.py +321 -0
  11. code_puppy/agents/pack/retriever.py +393 -0
  12. code_puppy/agents/pack/shepherd.py +348 -0
  13. code_puppy/agents/pack/terrier.py +287 -0
  14. code_puppy/agents/pack/watchdog.py +367 -0
  15. code_puppy/agents/subagent_stream_handler.py +276 -0
  16. code_puppy/api/__init__.py +13 -0
  17. code_puppy/api/app.py +169 -0
  18. code_puppy/api/main.py +21 -0
  19. code_puppy/api/pty_manager.py +446 -0
  20. code_puppy/api/routers/__init__.py +12 -0
  21. code_puppy/api/routers/agents.py +36 -0
  22. code_puppy/api/routers/commands.py +217 -0
  23. code_puppy/api/routers/config.py +74 -0
  24. code_puppy/api/routers/sessions.py +232 -0
  25. code_puppy/api/templates/terminal.html +361 -0
  26. code_puppy/api/websocket.py +154 -0
  27. code_puppy/callbacks.py +73 -0
  28. code_puppy/chatgpt_codex_client.py +53 -0
  29. code_puppy/claude_cache_client.py +294 -41
  30. code_puppy/command_line/add_model_menu.py +13 -4
  31. code_puppy/command_line/agent_menu.py +662 -0
  32. code_puppy/command_line/core_commands.py +89 -112
  33. code_puppy/command_line/model_picker_completion.py +3 -20
  34. code_puppy/command_line/model_settings_menu.py +21 -3
  35. code_puppy/config.py +145 -70
  36. code_puppy/gemini_model.py +706 -0
  37. code_puppy/http_utils.py +6 -3
  38. code_puppy/messaging/__init__.py +15 -0
  39. code_puppy/messaging/messages.py +27 -0
  40. code_puppy/messaging/queue_console.py +1 -1
  41. code_puppy/messaging/rich_renderer.py +36 -1
  42. code_puppy/messaging/spinner/__init__.py +20 -2
  43. code_puppy/messaging/subagent_console.py +461 -0
  44. code_puppy/model_factory.py +50 -16
  45. code_puppy/model_switching.py +63 -0
  46. code_puppy/model_utils.py +27 -24
  47. code_puppy/models.json +12 -12
  48. code_puppy/plugins/antigravity_oauth/antigravity_model.py +206 -172
  49. code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
  50. code_puppy/plugins/antigravity_oauth/transport.py +236 -45
  51. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
  52. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
  53. code_puppy/plugins/claude_code_oauth/utils.py +4 -1
  54. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  55. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  56. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  57. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  58. code_puppy/pydantic_patches.py +52 -0
  59. code_puppy/status_display.py +6 -2
  60. code_puppy/tools/__init__.py +37 -1
  61. code_puppy/tools/agent_tools.py +83 -33
  62. code_puppy/tools/browser/__init__.py +37 -0
  63. code_puppy/tools/browser/browser_control.py +6 -6
  64. code_puppy/tools/browser/browser_interactions.py +21 -20
  65. code_puppy/tools/browser/browser_locators.py +9 -9
  66. code_puppy/tools/browser/browser_manager.py +316 -0
  67. code_puppy/tools/browser/browser_navigation.py +7 -7
  68. code_puppy/tools/browser/browser_screenshot.py +78 -140
  69. code_puppy/tools/browser/browser_scripts.py +15 -13
  70. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  71. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  72. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  73. code_puppy/tools/browser/terminal_tools.py +525 -0
  74. code_puppy/tools/command_runner.py +292 -101
  75. code_puppy/tools/common.py +176 -1
  76. code_puppy/tools/display.py +84 -0
  77. code_puppy/tools/subagent_context.py +158 -0
  78. {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
  79. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/METADATA +17 -16
  80. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/RECORD +84 -51
  81. code_puppy/prompts/codex_system_prompt.md +0 -310
  82. code_puppy/tools/browser/camoufox_manager.py +0 -235
  83. code_puppy/tools/browser/vqa_agent.py +0 -90
  84. {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
  85. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
  86. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
  87. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/licenses/LICENSE +0 -0
@@ -5,6 +5,11 @@ ClaudeCacheAsyncClient: httpx client that tries to patch /v1/messages bodies.
5
5
  We now also expose `patch_anthropic_client_messages` which monkey-patches
6
6
  AsyncAnthropic.messages.create() so we can inject cache_control BEFORE
7
7
  serialization, avoiding httpx/Pydantic internals.
8
+
9
+ This module also handles:
10
+ - Tool name prefixing/unprefixing for Claude Code OAuth compatibility
11
+ - Header transformations (anthropic-beta, user-agent)
12
+ - URL modifications (adding ?beta=true query param)
8
13
  """
9
14
 
10
15
  from __future__ import annotations
@@ -12,8 +17,10 @@ from __future__ import annotations
12
17
  import base64
13
18
  import json
14
19
  import logging
20
+ import re
15
21
  import time
16
22
  from typing import Any, Callable, MutableMapping
23
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
17
24
 
18
25
  import httpx
19
26
 
@@ -22,6 +29,13 @@ logger = logging.getLogger(__name__)
22
29
  # Refresh token if it's older than 1 hour (3600 seconds)
23
30
  TOKEN_MAX_AGE_SECONDS = 3600
24
31
 
32
+ # Tool name prefix for Claude Code OAuth compatibility
33
+ # Tools are prefixed on outgoing requests and unprefixed on incoming responses
34
+ TOOL_PREFIX = "cp_"
35
+
36
+ # User-Agent to send with Claude Code OAuth requests
37
+ CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.2 (external, cli)"
38
+
25
39
  try:
26
40
  from anthropic import AsyncAnthropic
27
41
  except ImportError: # pragma: no cover - optional dep
@@ -29,6 +43,22 @@ except ImportError: # pragma: no cover - optional dep
29
43
 
30
44
 
31
45
  class ClaudeCacheAsyncClient(httpx.AsyncClient):
46
+ """Async HTTP client with Claude Code OAuth transformations.
47
+
48
+ Handles:
49
+ - Cache control injection for prompt caching
50
+ - Tool name prefixing on outgoing requests
51
+ - Tool name unprefixing on incoming streaming responses
52
+ - Header transformations (anthropic-beta, user-agent)
53
+ - URL modifications (adding ?beta=true)
54
+ - Proactive token refresh
55
+ """
56
+
57
+ # Regex pattern for unprefixing tool names in streaming responses
58
+ _TOOL_UNPREFIX_PATTERN = re.compile(
59
+ rf'"name"\s*:\s*"{re.escape(TOOL_PREFIX)}([^"]+)"'
60
+ )
61
+
32
62
  def _get_jwt_age_seconds(self, token: str | None) -> float | None:
33
63
  """Decode a JWT and return its age in seconds.
34
64
 
@@ -89,27 +119,156 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
89
119
  return None
90
120
 
91
121
  def _should_refresh_token(self, request: httpx.Request) -> bool:
92
- """Check if the token in the request is older than 1 hour."""
122
+ """Check if the token should be refreshed (within 1 hour of expiry).
123
+
124
+ Uses two strategies:
125
+ 1. Decode JWT to check token age (if possible)
126
+ 2. Fall back to stored expires_at from token file
127
+
128
+ Returns True if token expires within TOKEN_MAX_AGE_SECONDS (1 hour).
129
+ """
93
130
  token = self._extract_bearer_token(request)
94
131
  if not token:
95
132
  return False
96
133
 
134
+ # Strategy 1: Try to decode JWT age
97
135
  age = self._get_jwt_age_seconds(token)
98
- if age is None:
99
- return False
100
-
101
- should_refresh = age >= TOKEN_MAX_AGE_SECONDS
136
+ if age is not None:
137
+ should_refresh = age >= TOKEN_MAX_AGE_SECONDS
138
+ if should_refresh:
139
+ logger.info(
140
+ "JWT token is %.1f seconds old (>= %d), will refresh proactively",
141
+ age,
142
+ TOKEN_MAX_AGE_SECONDS,
143
+ )
144
+ return should_refresh
145
+
146
+ # Strategy 2: Fall back to stored expires_at from token file
147
+ should_refresh = self._check_stored_token_expiry()
102
148
  if should_refresh:
103
149
  logger.info(
104
- "JWT token is %.1f seconds old (>= %d), will refresh proactively",
105
- age,
150
+ "Stored token expires within %d seconds, will refresh proactively",
106
151
  TOKEN_MAX_AGE_SECONDS,
107
152
  )
108
153
  return should_refresh
109
154
 
155
+ @staticmethod
156
+ def _check_stored_token_expiry() -> bool:
157
+ """Check if the stored token expires within TOKEN_MAX_AGE_SECONDS.
158
+
159
+ This is a fallback for when JWT decoding fails or isn't available.
160
+ Uses the expires_at timestamp from the stored token file.
161
+ """
162
+ try:
163
+ from code_puppy.plugins.claude_code_oauth.utils import (
164
+ is_token_expired,
165
+ load_stored_tokens,
166
+ )
167
+
168
+ tokens = load_stored_tokens()
169
+ if not tokens:
170
+ return False
171
+
172
+ # is_token_expired already uses TOKEN_REFRESH_BUFFER_SECONDS (1 hour)
173
+ return is_token_expired(tokens)
174
+ except Exception as exc:
175
+ logger.debug("Error checking stored token expiry: %s", exc)
176
+ return False
177
+
178
+ @staticmethod
179
+ def _prefix_tool_names(body: bytes) -> bytes | None:
180
+ """Prefix all tool names in the request body with TOOL_PREFIX.
181
+
182
+ This is required for Claude Code OAuth compatibility - tools must be
183
+ prefixed on outgoing requests and unprefixed on incoming responses.
184
+ """
185
+ try:
186
+ data = json.loads(body.decode("utf-8"))
187
+ except Exception:
188
+ return None
189
+
190
+ if not isinstance(data, dict):
191
+ return None
192
+
193
+ tools = data.get("tools")
194
+ if not isinstance(tools, list) or not tools:
195
+ return None
196
+
197
+ modified = False
198
+ for tool in tools:
199
+ if isinstance(tool, dict) and "name" in tool:
200
+ name = tool["name"]
201
+ if name and not name.startswith(TOOL_PREFIX):
202
+ tool["name"] = f"{TOOL_PREFIX}{name}"
203
+ modified = True
204
+
205
+ if not modified:
206
+ return None
207
+
208
+ return json.dumps(data).encode("utf-8")
209
+
210
+ def _unprefix_tool_names_in_text(self, text: str) -> str:
211
+ """Remove TOOL_PREFIX from tool names in streaming response text."""
212
+ return self._TOOL_UNPREFIX_PATTERN.sub(r'"name": "\1"', text)
213
+
214
+ @staticmethod
215
+ def _transform_headers_for_claude_code(
216
+ headers: MutableMapping[str, str],
217
+ ) -> None:
218
+ """Transform headers for Claude Code OAuth compatibility.
219
+
220
+ - Sets user-agent to claude-cli
221
+ - Merges anthropic-beta headers appropriately
222
+ - Removes x-api-key (using Bearer auth instead)
223
+ """
224
+ # Set user-agent
225
+ headers["user-agent"] = CLAUDE_CLI_USER_AGENT
226
+
227
+ # Handle anthropic-beta header
228
+ incoming_beta = headers.get("anthropic-beta", "")
229
+ incoming_betas = [b.strip() for b in incoming_beta.split(",") if b.strip()]
230
+
231
+ # Check if claude-code beta was explicitly requested
232
+ include_claude_code = "claude-code-20250219" in incoming_betas
233
+
234
+ # Build merged betas list
235
+ merged_betas = [
236
+ "oauth-2025-04-20",
237
+ "interleaved-thinking-2025-05-14",
238
+ ]
239
+ if include_claude_code:
240
+ merged_betas.append("claude-code-20250219")
241
+
242
+ headers["anthropic-beta"] = ",".join(merged_betas)
243
+
244
+ # Remove x-api-key if present (we use Bearer auth)
245
+ for key in ["x-api-key", "X-API-Key", "X-Api-Key"]:
246
+ if key in headers:
247
+ del headers[key]
248
+
249
+ @staticmethod
250
+ def _add_beta_query_param(url: httpx.URL) -> httpx.URL:
251
+ """Add ?beta=true query parameter to the URL if not already present."""
252
+ # Parse the URL
253
+ parsed = urlparse(str(url))
254
+ query_params = parse_qs(parsed.query)
255
+
256
+ # Only add if not already present
257
+ if "beta" not in query_params:
258
+ query_params["beta"] = ["true"]
259
+ # Rebuild query string
260
+ new_query = urlencode(query_params, doseq=True)
261
+ # Rebuild URL
262
+ new_parsed = parsed._replace(query=new_query)
263
+ return httpx.URL(urlunparse(new_parsed))
264
+
265
+ return url
266
+
110
267
  async def send(
111
268
  self, request: httpx.Request, *args: Any, **kwargs: Any
112
269
  ) -> httpx.Response: # type: ignore[override]
270
+ is_messages_endpoint = request.url.path.endswith("/v1/messages")
271
+
113
272
  # Proactive token refresh: check JWT age before every request
114
273
  if not request.extensions.get("claude_oauth_refresh_attempted"):
115
274
  try:
@@ -131,50 +290,88 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
131
290
  except Exception as exc:
132
291
  logger.debug("Error during proactive token refresh check: %s", exc)
133
292
 
134
- try:
135
- if request.url.path.endswith("/v1/messages"):
293
+ # Apply Claude Code OAuth transformations for /v1/messages
294
+ if is_messages_endpoint:
295
+ try:
136
296
  body_bytes = self._extract_body_bytes(request)
297
+ headers = dict(request.headers)
298
+ url = request.url
299
+ body_modified = False
300
+ headers_modified = False
301
+
302
+ # 1. Transform headers for Claude Code OAuth
303
+ self._transform_headers_for_claude_code(headers)
304
+ headers_modified = True
305
+
306
+ # 2. Add ?beta=true query param
307
+ url = self._add_beta_query_param(url)
308
+
309
+ # 3. Prefix tool names in request body
137
310
  if body_bytes:
138
- updated = self._inject_cache_control(body_bytes)
139
- if updated is not None:
140
- # Rebuild a request with the updated body and transplant internals
141
- try:
142
- rebuilt = self.build_request(
143
- method=request.method,
144
- url=request.url,
145
- headers=request.headers,
146
- content=updated,
147
- )
148
-
149
- # Copy core internals so httpx uses the modified body/stream
150
- if hasattr(rebuilt, "_content"):
151
- setattr(request, "_content", rebuilt._content) # type: ignore[attr-defined]
152
- if hasattr(rebuilt, "stream"):
153
- request.stream = rebuilt.stream
154
- if hasattr(rebuilt, "extensions"):
155
- request.extensions = rebuilt.extensions
156
-
157
- # Ensure Content-Length matches the new body
158
- request.headers["Content-Length"] = str(len(updated))
159
-
160
- except Exception:
161
- # Swallow instrumentation errors; do not break real calls.
162
- pass
163
- except Exception:
164
- # Swallow wrapper errors; do not break real calls.
165
- pass
311
+ prefixed_body = self._prefix_tool_names(body_bytes)
312
+ if prefixed_body is not None:
313
+ body_bytes = prefixed_body
314
+ body_modified = True
315
+
316
+ # 4. Inject cache_control
317
+ cached_body = self._inject_cache_control(body_bytes)
318
+ if cached_body is not None:
319
+ body_bytes = cached_body
320
+ body_modified = True
321
+
322
+ # Rebuild request if anything changed
323
+ if body_modified or headers_modified or url != request.url:
324
+ try:
325
+ rebuilt = self.build_request(
326
+ method=request.method,
327
+ url=url,
328
+ headers=headers,
329
+ content=body_bytes,
330
+ )
331
+
332
+ # Copy core internals so httpx uses the modified body/stream
333
+ if hasattr(rebuilt, "_content"):
334
+ setattr(request, "_content", rebuilt._content) # type: ignore[attr-defined]
335
+ if hasattr(rebuilt, "stream"):
336
+ request.stream = rebuilt.stream
337
+ if hasattr(rebuilt, "extensions"):
338
+ request.extensions = rebuilt.extensions
339
+
340
+ # Update URL
341
+ request.url = url
342
+
343
+ # Update headers
344
+ for key, value in headers.items():
345
+ request.headers[key] = value
346
+
347
+ # Ensure Content-Length matches the new body
348
+ if body_bytes:
349
+ request.headers["Content-Length"] = str(len(body_bytes))
350
+
351
+ except Exception as exc:
352
+ logger.debug("Error rebuilding request: %s", exc)
353
+
354
+ except Exception as exc:
355
+ logger.debug("Error in Claude Code transformations: %s", exc)
356
+
357
+ # Send the request
166
358
  response = await super().send(request, *args, **kwargs)
359
+
360
+ # Transform streaming response to unprefix tool names
361
+ if is_messages_endpoint and response.status_code == 200:
362
+ try:
363
+ response = self._wrap_response_with_tool_unprefixing(response, request)
364
+ except Exception as exc:
365
+ logger.debug("Error wrapping response for tool unprefixing: %s", exc)
366
+
367
+ # Handle auth errors with token refresh
167
368
  try:
168
- # Check for both 401 and 400 - Anthropic/Cloudflare may return 400 for auth errors
169
- # Also check if it's a Cloudflare HTML error response
170
369
  if response.status_code in (400, 401) and not request.extensions.get(
171
370
  "claude_oauth_refresh_attempted"
172
371
  ):
173
- # Determine if this is an auth error (including Cloudflare HTML errors)
174
372
  is_auth_error = response.status_code == 401
175
373
 
176
374
  if response.status_code == 400:
177
- # Check if this is a Cloudflare HTML error
178
375
  is_auth_error = self._is_cloudflare_html_error(response)
179
376
  if is_auth_error:
180
377
  logger.info(
@@ -203,8 +400,64 @@ class ClaudeCacheAsyncClient(httpx.AsyncClient):
203
400
  logger.warning("Token refresh failed, returning original error")
204
401
  except Exception as exc:
205
402
  logger.debug("Error during token refresh attempt: %s", exc)
403
+
206
404
  return response
207
405
 
406
+ def _wrap_response_with_tool_unprefixing(
407
+ self, response: httpx.Response, request: httpx.Request
408
+ ) -> httpx.Response:
409
+ """Wrap a streaming response to unprefix tool names.
410
+
411
+ Creates a new response with a transformed stream that removes the
412
+ TOOL_PREFIX from tool names in the response body.
413
+ """
414
+ original_stream = response.stream
415
+ unprefix_fn = self._unprefix_tool_names_in_text
416
+
417
+ class UnprefixingStream(httpx.AsyncByteStream):
418
+ """Async byte stream that unprefixes tool names.
419
+
420
+ Inherits from httpx.AsyncByteStream to ensure proper stream interface.
421
+ """
422
+
423
+ def __init__(self, inner_stream: Any) -> None:
424
+ self._inner = inner_stream
425
+
426
+ async def __aiter__(self):
427
+ async for chunk in self._inner:
428
+ if isinstance(chunk, bytes):
429
+ text = chunk.decode("utf-8", errors="replace")
430
+ text = unprefix_fn(text)
431
+ yield text.encode("utf-8")
432
+ else:
433
+ yield chunk
434
+
435
+ async def aclose(self) -> None:
436
+ if hasattr(self._inner, "aclose"):
437
+ try:
438
+ result = self._inner.aclose()
439
+ # Handle both sync and async aclose
440
+ if hasattr(result, "__await__"):
441
+ await result
442
+ except Exception:
443
+ pass # Ignore close errors
444
+ elif hasattr(self._inner, "close"):
445
+ try:
446
+ self._inner.close()
447
+ except Exception:
448
+ pass
449
+
450
+ # Create a new response with the transformed stream
451
+ # Must include request for raise_for_status() to work
452
+ new_response = httpx.Response(
453
+ status_code=response.status_code,
454
+ headers=response.headers,
455
+ stream=UnprefixingStream(original_stream),
456
+ extensions=response.extensions,
457
+ request=request,
458
+ )
459
+ return new_response
460
+
208
461
  @staticmethod
209
462
  def _extract_body_bytes(request: httpx.Request) -> bytes | None:
210
463
  # Try public content first
@@ -626,12 +626,21 @@ class AddModelMenu:
626
626
  elif model_type == "openai" and "gpt-5" in model.model_id:
627
627
  # GPT-5 models have special settings
628
628
  if "codex" in model.model_id:
629
- config["supported_settings"] = ["reasoning_effort"]
629
+ config["supported_settings"] = [
630
+ "temperature",
631
+ "top_p",
632
+ "reasoning_effort",
633
+ ]
630
634
  else:
631
- config["supported_settings"] = ["reasoning_effort", "verbosity"]
635
+ config["supported_settings"] = [
636
+ "temperature",
637
+ "top_p",
638
+ "reasoning_effort",
639
+ "verbosity",
640
+ ]
632
641
  else:
633
- # Default settings for most models (no top_p)
634
- config["supported_settings"] = ["temperature", "seed"]
642
+ # Default settings for most models
643
+ config["supported_settings"] = ["temperature", "seed", "top_p"]
635
644
 
636
645
  return config
637
646