code-puppy 0.0.169__py3-none-any.whl → 0.0.366__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 (243) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +8 -8
  3. code_puppy/agents/agent_c_reviewer.py +155 -0
  4. code_puppy/agents/agent_code_puppy.py +9 -2
  5. code_puppy/agents/agent_code_reviewer.py +90 -0
  6. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  7. code_puppy/agents/agent_creator_agent.py +48 -9
  8. code_puppy/agents/agent_golang_reviewer.py +151 -0
  9. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  10. code_puppy/agents/agent_manager.py +146 -199
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +90 -0
  15. code_puppy/agents/agent_qa_expert.py +163 -0
  16. code_puppy/agents/agent_qa_kitten.py +208 -0
  17. code_puppy/agents/agent_security_auditor.py +181 -0
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  20. code_puppy/agents/base_agent.py +1713 -1
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/json_agent.py +12 -1
  23. code_puppy/agents/pack/__init__.py +34 -0
  24. code_puppy/agents/pack/bloodhound.py +304 -0
  25. code_puppy/agents/pack/husky.py +321 -0
  26. code_puppy/agents/pack/retriever.py +393 -0
  27. code_puppy/agents/pack/shepherd.py +348 -0
  28. code_puppy/agents/pack/terrier.py +287 -0
  29. code_puppy/agents/pack/watchdog.py +367 -0
  30. code_puppy/agents/prompt_reviewer.py +145 -0
  31. code_puppy/agents/subagent_stream_handler.py +276 -0
  32. code_puppy/api/__init__.py +13 -0
  33. code_puppy/api/app.py +169 -0
  34. code_puppy/api/main.py +21 -0
  35. code_puppy/api/pty_manager.py +446 -0
  36. code_puppy/api/routers/__init__.py +12 -0
  37. code_puppy/api/routers/agents.py +36 -0
  38. code_puppy/api/routers/commands.py +217 -0
  39. code_puppy/api/routers/config.py +74 -0
  40. code_puppy/api/routers/sessions.py +232 -0
  41. code_puppy/api/templates/terminal.html +361 -0
  42. code_puppy/api/websocket.py +154 -0
  43. code_puppy/callbacks.py +174 -4
  44. code_puppy/chatgpt_codex_client.py +283 -0
  45. code_puppy/claude_cache_client.py +586 -0
  46. code_puppy/cli_runner.py +916 -0
  47. code_puppy/command_line/add_model_menu.py +1079 -0
  48. code_puppy/command_line/agent_menu.py +395 -0
  49. code_puppy/command_line/attachments.py +395 -0
  50. code_puppy/command_line/autosave_menu.py +605 -0
  51. code_puppy/command_line/clipboard.py +527 -0
  52. code_puppy/command_line/colors_menu.py +520 -0
  53. code_puppy/command_line/command_handler.py +233 -627
  54. code_puppy/command_line/command_registry.py +150 -0
  55. code_puppy/command_line/config_commands.py +715 -0
  56. code_puppy/command_line/core_commands.py +792 -0
  57. code_puppy/command_line/diff_menu.py +863 -0
  58. code_puppy/command_line/load_context_completion.py +15 -22
  59. code_puppy/command_line/mcp/base.py +1 -4
  60. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  61. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  62. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  63. code_puppy/command_line/mcp/edit_command.py +148 -0
  64. code_puppy/command_line/mcp/handler.py +9 -4
  65. code_puppy/command_line/mcp/help_command.py +6 -5
  66. code_puppy/command_line/mcp/install_command.py +16 -27
  67. code_puppy/command_line/mcp/install_menu.py +685 -0
  68. code_puppy/command_line/mcp/list_command.py +3 -3
  69. code_puppy/command_line/mcp/logs_command.py +174 -65
  70. code_puppy/command_line/mcp/remove_command.py +2 -2
  71. code_puppy/command_line/mcp/restart_command.py +12 -4
  72. code_puppy/command_line/mcp/search_command.py +17 -11
  73. code_puppy/command_line/mcp/start_all_command.py +22 -13
  74. code_puppy/command_line/mcp/start_command.py +50 -31
  75. code_puppy/command_line/mcp/status_command.py +6 -7
  76. code_puppy/command_line/mcp/stop_all_command.py +11 -8
  77. code_puppy/command_line/mcp/stop_command.py +11 -10
  78. code_puppy/command_line/mcp/test_command.py +2 -2
  79. code_puppy/command_line/mcp/utils.py +1 -1
  80. code_puppy/command_line/mcp/wizard_utils.py +22 -18
  81. code_puppy/command_line/mcp_completion.py +174 -0
  82. code_puppy/command_line/model_picker_completion.py +89 -30
  83. code_puppy/command_line/model_settings_menu.py +884 -0
  84. code_puppy/command_line/motd.py +14 -8
  85. code_puppy/command_line/onboarding_slides.py +179 -0
  86. code_puppy/command_line/onboarding_wizard.py +340 -0
  87. code_puppy/command_line/pin_command_completion.py +329 -0
  88. code_puppy/command_line/prompt_toolkit_completion.py +626 -75
  89. code_puppy/command_line/session_commands.py +296 -0
  90. code_puppy/command_line/utils.py +54 -0
  91. code_puppy/config.py +1181 -51
  92. code_puppy/error_logging.py +118 -0
  93. code_puppy/gemini_code_assist.py +385 -0
  94. code_puppy/gemini_model.py +602 -0
  95. code_puppy/http_utils.py +220 -104
  96. code_puppy/keymap.py +128 -0
  97. code_puppy/main.py +5 -594
  98. code_puppy/{mcp → mcp_}/__init__.py +17 -0
  99. code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
  100. code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
  101. code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
  102. code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
  103. code_puppy/{mcp → mcp_}/dashboard.py +15 -6
  104. code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
  105. code_puppy/{mcp → mcp_}/managed_server.py +66 -39
  106. code_puppy/{mcp → mcp_}/manager.py +146 -52
  107. code_puppy/mcp_/mcp_logs.py +224 -0
  108. code_puppy/{mcp → mcp_}/registry.py +6 -6
  109. code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
  110. code_puppy/messaging/__init__.py +199 -2
  111. code_puppy/messaging/bus.py +610 -0
  112. code_puppy/messaging/commands.py +167 -0
  113. code_puppy/messaging/markdown_patches.py +57 -0
  114. code_puppy/messaging/message_queue.py +17 -48
  115. code_puppy/messaging/messages.py +500 -0
  116. code_puppy/messaging/queue_console.py +1 -24
  117. code_puppy/messaging/renderers.py +43 -146
  118. code_puppy/messaging/rich_renderer.py +1027 -0
  119. code_puppy/messaging/spinner/__init__.py +33 -5
  120. code_puppy/messaging/spinner/console_spinner.py +92 -52
  121. code_puppy/messaging/spinner/spinner_base.py +29 -0
  122. code_puppy/messaging/subagent_console.py +461 -0
  123. code_puppy/model_factory.py +686 -80
  124. code_puppy/model_utils.py +167 -0
  125. code_puppy/models.json +86 -104
  126. code_puppy/models_dev_api.json +1 -0
  127. code_puppy/models_dev_parser.py +592 -0
  128. code_puppy/plugins/__init__.py +164 -10
  129. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  130. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  131. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  132. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  133. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  134. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  135. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  136. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  137. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  138. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  139. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  140. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  141. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  142. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  143. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  144. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  145. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  146. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  147. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  148. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  149. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  150. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  151. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  152. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  153. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  154. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  155. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  156. code_puppy/plugins/example_custom_command/README.md +280 -0
  157. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  158. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  159. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  160. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  161. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  162. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  163. code_puppy/plugins/oauth_puppy_html.py +228 -0
  164. code_puppy/plugins/shell_safety/__init__.py +6 -0
  165. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  166. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  167. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  168. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  169. code_puppy/prompts/codex_system_prompt.md +310 -0
  170. code_puppy/pydantic_patches.py +131 -0
  171. code_puppy/reopenable_async_client.py +8 -8
  172. code_puppy/round_robin_model.py +10 -15
  173. code_puppy/session_storage.py +294 -0
  174. code_puppy/status_display.py +21 -4
  175. code_puppy/summarization_agent.py +52 -14
  176. code_puppy/terminal_utils.py +418 -0
  177. code_puppy/tools/__init__.py +139 -6
  178. code_puppy/tools/agent_tools.py +548 -49
  179. code_puppy/tools/browser/__init__.py +37 -0
  180. code_puppy/tools/browser/browser_control.py +289 -0
  181. code_puppy/tools/browser/browser_interactions.py +545 -0
  182. code_puppy/tools/browser/browser_locators.py +640 -0
  183. code_puppy/tools/browser/browser_manager.py +316 -0
  184. code_puppy/tools/browser/browser_navigation.py +251 -0
  185. code_puppy/tools/browser/browser_screenshot.py +179 -0
  186. code_puppy/tools/browser/browser_scripts.py +462 -0
  187. code_puppy/tools/browser/browser_workflows.py +221 -0
  188. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  189. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  190. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  191. code_puppy/tools/browser/terminal_tools.py +525 -0
  192. code_puppy/tools/command_runner.py +941 -153
  193. code_puppy/tools/common.py +1146 -6
  194. code_puppy/tools/display.py +84 -0
  195. code_puppy/tools/file_modifications.py +288 -89
  196. code_puppy/tools/file_operations.py +352 -266
  197. code_puppy/tools/subagent_context.py +158 -0
  198. code_puppy/uvx_detection.py +242 -0
  199. code_puppy/version_checker.py +30 -11
  200. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  201. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  202. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
  203. code_puppy-0.0.366.dist-info/RECORD +217 -0
  204. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  205. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
  206. code_puppy/agent.py +0 -231
  207. code_puppy/agents/agent_orchestrator.json +0 -26
  208. code_puppy/agents/runtime_manager.py +0 -272
  209. code_puppy/command_line/mcp/add_command.py +0 -183
  210. code_puppy/command_line/meta_command_handler.py +0 -153
  211. code_puppy/message_history_processor.py +0 -490
  212. code_puppy/messaging/spinner/textual_spinner.py +0 -101
  213. code_puppy/state_management.py +0 -200
  214. code_puppy/tui/__init__.py +0 -10
  215. code_puppy/tui/app.py +0 -986
  216. code_puppy/tui/components/__init__.py +0 -21
  217. code_puppy/tui/components/chat_view.py +0 -550
  218. code_puppy/tui/components/command_history_modal.py +0 -218
  219. code_puppy/tui/components/copy_button.py +0 -139
  220. code_puppy/tui/components/custom_widgets.py +0 -63
  221. code_puppy/tui/components/human_input_modal.py +0 -175
  222. code_puppy/tui/components/input_area.py +0 -167
  223. code_puppy/tui/components/sidebar.py +0 -309
  224. code_puppy/tui/components/status_bar.py +0 -182
  225. code_puppy/tui/messages.py +0 -27
  226. code_puppy/tui/models/__init__.py +0 -8
  227. code_puppy/tui/models/chat_message.py +0 -25
  228. code_puppy/tui/models/command_history.py +0 -89
  229. code_puppy/tui/models/enums.py +0 -24
  230. code_puppy/tui/screens/__init__.py +0 -15
  231. code_puppy/tui/screens/help.py +0 -130
  232. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  233. code_puppy/tui/screens/settings.py +0 -290
  234. code_puppy/tui/screens/tools.py +0 -74
  235. code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
  236. code_puppy-0.0.169.dist-info/RECORD +0 -112
  237. /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
  238. /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
  239. /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
  240. /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
  241. /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
  242. /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
  243. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,586 @@
1
+ """Cache helpers for Claude Code / Anthropic.
2
+
3
+ ClaudeCacheAsyncClient: httpx client that tries to patch /v1/messages bodies.
4
+
5
+ We now also expose `patch_anthropic_client_messages` which monkey-patches
6
+ AsyncAnthropic.messages.create() so we can inject cache_control BEFORE
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)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ import logging
20
+ import re
21
+ import time
22
+ from typing import Any, Callable, MutableMapping
23
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
24
+
25
+ import httpx
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Refresh token if it's older than 1 hour (3600 seconds)
30
+ TOKEN_MAX_AGE_SECONDS = 3600
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
+
39
+ try:
40
+ from anthropic import AsyncAnthropic
41
+ except ImportError: # pragma: no cover - optional dep
42
+ AsyncAnthropic = None # type: ignore
43
+
44
+
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
+
62
+ def _get_jwt_age_seconds(self, token: str | None) -> float | None:
63
+ """Decode a JWT and return its age in seconds.
64
+
65
+ Returns None if the token can't be decoded or has no timestamp claims.
66
+ Uses 'iat' (issued at) if available, otherwise calculates from 'exp'.
67
+ """
68
+ if not token:
69
+ return None
70
+
71
+ try:
72
+ # JWT format: header.payload.signature
73
+ # We only need the payload (second part)
74
+ parts = token.split(".")
75
+ if len(parts) != 3:
76
+ return None
77
+
78
+ # Decode the payload (base64url encoded)
79
+ payload_b64 = parts[1]
80
+ # Add padding if needed (base64url doesn't require padding)
81
+ padding = 4 - len(payload_b64) % 4
82
+ if padding != 4:
83
+ payload_b64 += "=" * padding
84
+
85
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
86
+ payload = json.loads(payload_bytes.decode("utf-8"))
87
+
88
+ now = time.time()
89
+
90
+ # Prefer 'iat' (issued at) claim if available
91
+ if "iat" in payload:
92
+ iat = float(payload["iat"])
93
+ age = now - iat
94
+ return age
95
+
96
+ # Fall back to calculating from 'exp' claim
97
+ # Assume tokens are typically valid for 1 hour
98
+ if "exp" in payload:
99
+ exp = float(payload["exp"])
100
+ # If exp is in the future, calculate how long until expiry
101
+ # and assume the token was issued 1 hour before expiry
102
+ time_until_exp = exp - now
103
+ # If token has less than 1 hour left, it's "old"
104
+ age = TOKEN_MAX_AGE_SECONDS - time_until_exp
105
+ return max(0, age)
106
+
107
+ return None
108
+ except Exception as exc:
109
+ logger.debug("Failed to decode JWT age: %s", exc)
110
+ return None
111
+
112
+ def _extract_bearer_token(self, request: httpx.Request) -> str | None:
113
+ """Extract the bearer token from request headers."""
114
+ auth_header = request.headers.get("Authorization") or request.headers.get(
115
+ "authorization"
116
+ )
117
+ if auth_header and auth_header.lower().startswith("bearer "):
118
+ return auth_header[7:] # Strip "Bearer " prefix
119
+ return None
120
+
121
+ def _should_refresh_token(self, request: httpx.Request) -> bool:
122
+ """Check if the token in the request is older than 1 hour."""
123
+ token = self._extract_bearer_token(request)
124
+ if not token:
125
+ return False
126
+
127
+ age = self._get_jwt_age_seconds(token)
128
+ if age is None:
129
+ return False
130
+
131
+ should_refresh = age >= TOKEN_MAX_AGE_SECONDS
132
+ if should_refresh:
133
+ logger.info(
134
+ "JWT token is %.1f seconds old (>= %d), will refresh proactively",
135
+ age,
136
+ TOKEN_MAX_AGE_SECONDS,
137
+ )
138
+ return should_refresh
139
+
140
+ @staticmethod
141
+ def _prefix_tool_names(body: bytes) -> bytes | None:
142
+ """Prefix all tool names in the request body with TOOL_PREFIX.
143
+
144
+ This is required for Claude Code OAuth compatibility - tools must be
145
+ prefixed on outgoing requests and unprefixed on incoming responses.
146
+ """
147
+ try:
148
+ data = json.loads(body.decode("utf-8"))
149
+ except Exception:
150
+ return None
151
+
152
+ if not isinstance(data, dict):
153
+ return None
154
+
155
+ tools = data.get("tools")
156
+ if not isinstance(tools, list) or not tools:
157
+ return None
158
+
159
+ modified = False
160
+ for tool in tools:
161
+ if isinstance(tool, dict) and "name" in tool:
162
+ name = tool["name"]
163
+ if name and not name.startswith(TOOL_PREFIX):
164
+ tool["name"] = f"{TOOL_PREFIX}{name}"
165
+ modified = True
166
+
167
+ if not modified:
168
+ return None
169
+
170
+ return json.dumps(data).encode("utf-8")
171
+
172
+ def _unprefix_tool_names_in_text(self, text: str) -> str:
173
+ """Remove TOOL_PREFIX from tool names in streaming response text."""
174
+ return self._TOOL_UNPREFIX_PATTERN.sub(r'"name": "\1"', text)
175
+
176
+ @staticmethod
177
+ def _transform_headers_for_claude_code(
178
+ headers: MutableMapping[str, str],
179
+ ) -> None:
180
+ """Transform headers for Claude Code OAuth compatibility.
181
+
182
+ - Sets user-agent to claude-cli
183
+ - Merges anthropic-beta headers appropriately
184
+ - Removes x-api-key (using Bearer auth instead)
185
+ """
186
+ # Set user-agent
187
+ headers["user-agent"] = CLAUDE_CLI_USER_AGENT
188
+
189
+ # Handle anthropic-beta header
190
+ incoming_beta = headers.get("anthropic-beta", "")
191
+ incoming_betas = [b.strip() for b in incoming_beta.split(",") if b.strip()]
192
+
193
+ # Check if claude-code beta was explicitly requested
194
+ include_claude_code = "claude-code-20250219" in incoming_betas
195
+
196
+ # Build merged betas list
197
+ merged_betas = [
198
+ "oauth-2025-04-20",
199
+ "interleaved-thinking-2025-05-14",
200
+ ]
201
+ if include_claude_code:
202
+ merged_betas.append("claude-code-20250219")
203
+
204
+ headers["anthropic-beta"] = ",".join(merged_betas)
205
+
206
+ # Remove x-api-key if present (we use Bearer auth)
207
+ for key in ["x-api-key", "X-API-Key", "X-Api-Key"]:
208
+ if key in headers:
209
+ del headers[key]
210
+
211
+ @staticmethod
212
+ def _add_beta_query_param(url: httpx.URL) -> httpx.URL:
213
+ """Add ?beta=true query parameter to the URL if not already present."""
214
+ # Parse the URL
215
+ parsed = urlparse(str(url))
216
+ query_params = parse_qs(parsed.query)
217
+
218
+ # Only add if not already present
219
+ if "beta" not in query_params:
220
+ query_params["beta"] = ["true"]
221
+ # Rebuild query string
222
+ new_query = urlencode(query_params, doseq=True)
223
+ # Rebuild URL
224
+ new_parsed = parsed._replace(query=new_query)
225
+ return httpx.URL(urlunparse(new_parsed))
226
+
227
+ return url
228
+
229
+ async def send(
230
+ self, request: httpx.Request, *args: Any, **kwargs: Any
231
+ ) -> httpx.Response: # type: ignore[override]
232
+ is_messages_endpoint = request.url.path.endswith("/v1/messages")
233
+
234
+ # Proactive token refresh: check JWT age before every request
235
+ if not request.extensions.get("claude_oauth_refresh_attempted"):
236
+ try:
237
+ if self._should_refresh_token(request):
238
+ refreshed_token = self._refresh_claude_oauth_token()
239
+ if refreshed_token:
240
+ logger.info("Proactively refreshed token before request")
241
+ # Rebuild request with new token
242
+ headers = dict(request.headers)
243
+ self._update_auth_headers(headers, refreshed_token)
244
+ body_bytes = self._extract_body_bytes(request)
245
+ request = self.build_request(
246
+ method=request.method,
247
+ url=request.url,
248
+ headers=headers,
249
+ content=body_bytes,
250
+ )
251
+ request.extensions["claude_oauth_refresh_attempted"] = True
252
+ except Exception as exc:
253
+ logger.debug("Error during proactive token refresh check: %s", exc)
254
+
255
+ # Apply Claude Code OAuth transformations for /v1/messages
256
+ if is_messages_endpoint:
257
+ try:
258
+ body_bytes = self._extract_body_bytes(request)
259
+ headers = dict(request.headers)
260
+ url = request.url
261
+ body_modified = False
262
+ headers_modified = False
263
+
264
+ # 1. Transform headers for Claude Code OAuth
265
+ self._transform_headers_for_claude_code(headers)
266
+ headers_modified = True
267
+
268
+ # 2. Add ?beta=true query param
269
+ url = self._add_beta_query_param(url)
270
+
271
+ # 3. Prefix tool names in request body
272
+ if body_bytes:
273
+ prefixed_body = self._prefix_tool_names(body_bytes)
274
+ if prefixed_body is not None:
275
+ body_bytes = prefixed_body
276
+ body_modified = True
277
+
278
+ # 4. Inject cache_control
279
+ cached_body = self._inject_cache_control(body_bytes)
280
+ if cached_body is not None:
281
+ body_bytes = cached_body
282
+ body_modified = True
283
+
284
+ # Rebuild request if anything changed
285
+ if body_modified or headers_modified or url != request.url:
286
+ try:
287
+ rebuilt = self.build_request(
288
+ method=request.method,
289
+ url=url,
290
+ headers=headers,
291
+ content=body_bytes,
292
+ )
293
+
294
+ # Copy core internals so httpx uses the modified body/stream
295
+ if hasattr(rebuilt, "_content"):
296
+ setattr(request, "_content", rebuilt._content) # type: ignore[attr-defined]
297
+ if hasattr(rebuilt, "stream"):
298
+ request.stream = rebuilt.stream
299
+ if hasattr(rebuilt, "extensions"):
300
+ request.extensions = rebuilt.extensions
301
+
302
+ # Update URL
303
+ request.url = url
304
+
305
+ # Update headers
306
+ for key, value in headers.items():
307
+ request.headers[key] = value
308
+
309
+ # Ensure Content-Length matches the new body
310
+ if body_bytes:
311
+ request.headers["Content-Length"] = str(len(body_bytes))
312
+
313
+ except Exception as exc:
314
+ logger.debug("Error rebuilding request: %s", exc)
315
+
316
+ except Exception as exc:
317
+ logger.debug("Error in Claude Code transformations: %s", exc)
318
+
319
+ # Send the request
320
+ response = await super().send(request, *args, **kwargs)
321
+
322
+ # Transform streaming response to unprefix tool names
323
+ if is_messages_endpoint and response.status_code == 200:
324
+ try:
325
+ response = self._wrap_response_with_tool_unprefixing(response, request)
326
+ except Exception as exc:
327
+ logger.debug("Error wrapping response for tool unprefixing: %s", exc)
328
+
329
+ # Handle auth errors with token refresh
330
+ try:
331
+ if response.status_code in (400, 401) and not request.extensions.get(
332
+ "claude_oauth_refresh_attempted"
333
+ ):
334
+ is_auth_error = response.status_code == 401
335
+
336
+ if response.status_code == 400:
337
+ is_auth_error = self._is_cloudflare_html_error(response)
338
+ if is_auth_error:
339
+ logger.info(
340
+ "Detected Cloudflare 400 error (likely auth-related), attempting token refresh"
341
+ )
342
+
343
+ if is_auth_error:
344
+ refreshed_token = self._refresh_claude_oauth_token()
345
+ if refreshed_token:
346
+ logger.info("Token refreshed successfully, retrying request")
347
+ await response.aclose()
348
+ body_bytes = self._extract_body_bytes(request)
349
+ headers = dict(request.headers)
350
+ self._update_auth_headers(headers, refreshed_token)
351
+ retry_request = self.build_request(
352
+ method=request.method,
353
+ url=request.url,
354
+ headers=headers,
355
+ content=body_bytes,
356
+ )
357
+ retry_request.extensions["claude_oauth_refresh_attempted"] = (
358
+ True
359
+ )
360
+ return await super().send(retry_request, *args, **kwargs)
361
+ else:
362
+ logger.warning("Token refresh failed, returning original error")
363
+ except Exception as exc:
364
+ logger.debug("Error during token refresh attempt: %s", exc)
365
+
366
+ return response
367
+
368
+ def _wrap_response_with_tool_unprefixing(
369
+ self, response: httpx.Response, request: httpx.Request
370
+ ) -> httpx.Response:
371
+ """Wrap a streaming response to unprefix tool names.
372
+
373
+ Creates a new response with a transformed stream that removes the
374
+ TOOL_PREFIX from tool names in the response body.
375
+ """
376
+ original_stream = response.stream
377
+ unprefix_fn = self._unprefix_tool_names_in_text
378
+
379
+ class UnprefixingStream(httpx.AsyncByteStream):
380
+ """Async byte stream that unprefixes tool names.
381
+
382
+ Inherits from httpx.AsyncByteStream to ensure proper stream interface.
383
+ """
384
+
385
+ def __init__(self, inner_stream: Any) -> None:
386
+ self._inner = inner_stream
387
+
388
+ async def __aiter__(self):
389
+ async for chunk in self._inner:
390
+ if isinstance(chunk, bytes):
391
+ text = chunk.decode("utf-8", errors="replace")
392
+ text = unprefix_fn(text)
393
+ yield text.encode("utf-8")
394
+ else:
395
+ yield chunk
396
+
397
+ async def aclose(self) -> None:
398
+ if hasattr(self._inner, "aclose"):
399
+ try:
400
+ result = self._inner.aclose()
401
+ # Handle both sync and async aclose
402
+ if hasattr(result, "__await__"):
403
+ await result
404
+ except Exception:
405
+ pass # Ignore close errors
406
+ elif hasattr(self._inner, "close"):
407
+ try:
408
+ self._inner.close()
409
+ except Exception:
410
+ pass
411
+
412
+ # Create a new response with the transformed stream
413
+ # Must include request for raise_for_status() to work
414
+ new_response = httpx.Response(
415
+ status_code=response.status_code,
416
+ headers=response.headers,
417
+ stream=UnprefixingStream(original_stream),
418
+ extensions=response.extensions,
419
+ request=request,
420
+ )
421
+ return new_response
422
+
423
+ @staticmethod
424
+ def _extract_body_bytes(request: httpx.Request) -> bytes | None:
425
+ # Try public content first
426
+ try:
427
+ content = request.content
428
+ if content:
429
+ return content
430
+ except Exception:
431
+ pass
432
+
433
+ # Fallback to private attr if necessary
434
+ try:
435
+ content = getattr(request, "_content", None)
436
+ if content:
437
+ return content
438
+ except Exception:
439
+ pass
440
+
441
+ return None
442
+
443
+ @staticmethod
444
+ def _update_auth_headers(
445
+ headers: MutableMapping[str, str], access_token: str
446
+ ) -> None:
447
+ bearer_value = f"Bearer {access_token}"
448
+ if "Authorization" in headers or "authorization" in headers:
449
+ headers["Authorization"] = bearer_value
450
+ elif "x-api-key" in headers or "X-API-Key" in headers:
451
+ headers["x-api-key"] = access_token
452
+ else:
453
+ headers["Authorization"] = bearer_value
454
+
455
+ @staticmethod
456
+ def _is_cloudflare_html_error(response: httpx.Response) -> bool:
457
+ """Check if this is a Cloudflare HTML error response.
458
+
459
+ Cloudflare often returns HTML error pages with status 400 when
460
+ there are authentication issues.
461
+ """
462
+ # Check content type
463
+ content_type = response.headers.get("content-type", "")
464
+ if "text/html" not in content_type.lower():
465
+ return False
466
+
467
+ # Check if body contains Cloudflare markers
468
+ try:
469
+ # Read response body if not already consumed
470
+ if hasattr(response, "_content") and response._content:
471
+ body = response._content.decode("utf-8", errors="ignore")
472
+ else:
473
+ # Try to read the text (this might be already consumed)
474
+ try:
475
+ body = response.text
476
+ except Exception:
477
+ return False
478
+
479
+ # Look for Cloudflare and 400 Bad Request markers
480
+ body_lower = body.lower()
481
+ return "cloudflare" in body_lower and "400 bad request" in body_lower
482
+ except Exception as exc:
483
+ logger.debug("Error checking for Cloudflare error: %s", exc)
484
+ return False
485
+
486
+ def _refresh_claude_oauth_token(self) -> str | None:
487
+ try:
488
+ from code_puppy.plugins.claude_code_oauth.utils import refresh_access_token
489
+
490
+ logger.info("Attempting to refresh Claude Code OAuth token...")
491
+ refreshed_token = refresh_access_token(force=True)
492
+ if refreshed_token:
493
+ self._update_auth_headers(self.headers, refreshed_token)
494
+ logger.info("Successfully refreshed Claude Code OAuth token")
495
+ else:
496
+ logger.warning("Token refresh returned None")
497
+ return refreshed_token
498
+ except Exception as exc:
499
+ logger.error("Exception during token refresh: %s", exc)
500
+ return None
501
+
502
+ @staticmethod
503
+ def _inject_cache_control(body: bytes) -> bytes | None:
504
+ try:
505
+ data = json.loads(body.decode("utf-8"))
506
+ except Exception:
507
+ return None
508
+
509
+ if not isinstance(data, dict):
510
+ return None
511
+
512
+ modified = False
513
+
514
+ # Minimal, deterministic strategy:
515
+ # Add cache_control only on the single most recent block:
516
+ # the last dict content block of the last message (if any).
517
+ messages = data.get("messages")
518
+ if isinstance(messages, list) and messages:
519
+ last = messages[-1]
520
+ if isinstance(last, dict):
521
+ content = last.get("content")
522
+ if isinstance(content, list) and content:
523
+ last_block = content[-1]
524
+ if (
525
+ isinstance(last_block, dict)
526
+ and "cache_control" not in last_block
527
+ ):
528
+ last_block["cache_control"] = {"type": "ephemeral"}
529
+ modified = True
530
+
531
+ if not modified:
532
+ return None
533
+
534
+ return json.dumps(data).encode("utf-8")
535
+
536
+
537
+ def _inject_cache_control_in_payload(payload: dict[str, Any]) -> None:
538
+ """In-place cache_control injection on Anthropic messages.create payload."""
539
+
540
+ messages = payload.get("messages")
541
+ if isinstance(messages, list) and messages:
542
+ last = messages[-1]
543
+ if isinstance(last, dict):
544
+ content = last.get("content")
545
+ if isinstance(content, list) and content:
546
+ last_block = content[-1]
547
+ if isinstance(last_block, dict) and "cache_control" not in last_block:
548
+ last_block["cache_control"] = {"type": "ephemeral"}
549
+
550
+ # No extra markers in production mode; keep payload clean.
551
+ # (Function kept for potential future use.)
552
+ return
553
+
554
+
555
+ def patch_anthropic_client_messages(client: Any) -> None:
556
+ """Monkey-patch AsyncAnthropic.messages.create to inject cache_control.
557
+
558
+ This operates at the highest level: just before Anthropic SDK serializes
559
+ the request into HTTP. That means no httpx / Pydantic shenanigans can
560
+ undo it.
561
+ """
562
+
563
+ if AsyncAnthropic is None or not isinstance(client, AsyncAnthropic): # type: ignore[arg-type]
564
+ return
565
+
566
+ try:
567
+ messages_obj = getattr(client, "messages", None)
568
+ if messages_obj is None:
569
+ return
570
+ original_create: Callable[..., Any] = messages_obj.create
571
+ except Exception: # pragma: no cover - defensive
572
+ return
573
+
574
+ async def wrapped_create(*args: Any, **kwargs: Any):
575
+ # Anthropic messages.create takes a mix of positional/kw args.
576
+ # The payload is usually in kwargs for the Python SDK.
577
+ if kwargs:
578
+ _inject_cache_control_in_payload(kwargs)
579
+ elif args:
580
+ maybe_payload = args[-1]
581
+ if isinstance(maybe_payload, dict):
582
+ _inject_cache_control_in_payload(maybe_payload)
583
+
584
+ return await original_create(*args, **kwargs)
585
+
586
+ messages_obj.create = wrapped_create # type: ignore[assignment]