codepp 0.0.437__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 (288) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +117 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +638 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +124 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +385 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +169 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2156 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +304 -0
  28. code_puppy/agents/pack/husky.py +327 -0
  29. code_puppy/agents/pack/retriever.py +393 -0
  30. code_puppy/agents/pack/shepherd.py +348 -0
  31. code_puppy/agents/pack/terrier.py +287 -0
  32. code_puppy/agents/pack/watchdog.py +367 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +692 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +672 -0
  49. code_puppy/cli_runner.py +1073 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +532 -0
  57. code_puppy/command_line/command_handler.py +293 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +867 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +96 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/shell_passthrough.py +145 -0
  97. code_puppy/command_line/skills_completion.py +160 -0
  98. code_puppy/command_line/uc_menu.py +893 -0
  99. code_puppy/command_line/utils.py +93 -0
  100. code_puppy/command_line/wiggum_state.py +78 -0
  101. code_puppy/config.py +1770 -0
  102. code_puppy/error_logging.py +134 -0
  103. code_puppy/gemini_code_assist.py +385 -0
  104. code_puppy/gemini_model.py +754 -0
  105. code_puppy/hook_engine/README.md +105 -0
  106. code_puppy/hook_engine/__init__.py +21 -0
  107. code_puppy/hook_engine/aliases.py +155 -0
  108. code_puppy/hook_engine/engine.py +221 -0
  109. code_puppy/hook_engine/executor.py +296 -0
  110. code_puppy/hook_engine/matcher.py +156 -0
  111. code_puppy/hook_engine/models.py +240 -0
  112. code_puppy/hook_engine/registry.py +106 -0
  113. code_puppy/hook_engine/validator.py +144 -0
  114. code_puppy/http_utils.py +361 -0
  115. code_puppy/keymap.py +128 -0
  116. code_puppy/main.py +10 -0
  117. code_puppy/mcp_/__init__.py +66 -0
  118. code_puppy/mcp_/async_lifecycle.py +286 -0
  119. code_puppy/mcp_/blocking_startup.py +469 -0
  120. code_puppy/mcp_/captured_stdio_server.py +275 -0
  121. code_puppy/mcp_/circuit_breaker.py +290 -0
  122. code_puppy/mcp_/config_wizard.py +507 -0
  123. code_puppy/mcp_/dashboard.py +308 -0
  124. code_puppy/mcp_/error_isolation.py +407 -0
  125. code_puppy/mcp_/examples/retry_example.py +226 -0
  126. code_puppy/mcp_/health_monitor.py +589 -0
  127. code_puppy/mcp_/managed_server.py +428 -0
  128. code_puppy/mcp_/manager.py +807 -0
  129. code_puppy/mcp_/mcp_logs.py +224 -0
  130. code_puppy/mcp_/registry.py +451 -0
  131. code_puppy/mcp_/retry_manager.py +337 -0
  132. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  133. code_puppy/mcp_/status_tracker.py +355 -0
  134. code_puppy/mcp_/system_tools.py +209 -0
  135. code_puppy/mcp_prompts/__init__.py +1 -0
  136. code_puppy/mcp_prompts/hook_creator.py +103 -0
  137. code_puppy/messaging/__init__.py +255 -0
  138. code_puppy/messaging/bus.py +613 -0
  139. code_puppy/messaging/commands.py +167 -0
  140. code_puppy/messaging/markdown_patches.py +57 -0
  141. code_puppy/messaging/message_queue.py +361 -0
  142. code_puppy/messaging/messages.py +569 -0
  143. code_puppy/messaging/queue_console.py +271 -0
  144. code_puppy/messaging/renderers.py +311 -0
  145. code_puppy/messaging/rich_renderer.py +1158 -0
  146. code_puppy/messaging/spinner/__init__.py +83 -0
  147. code_puppy/messaging/spinner/console_spinner.py +240 -0
  148. code_puppy/messaging/spinner/spinner_base.py +95 -0
  149. code_puppy/messaging/subagent_console.py +460 -0
  150. code_puppy/model_factory.py +848 -0
  151. code_puppy/model_switching.py +63 -0
  152. code_puppy/model_utils.py +168 -0
  153. code_puppy/models.json +174 -0
  154. code_puppy/models_dev_api.json +1 -0
  155. code_puppy/models_dev_parser.py +592 -0
  156. code_puppy/plugins/__init__.py +186 -0
  157. code_puppy/plugins/agent_skills/__init__.py +22 -0
  158. code_puppy/plugins/agent_skills/config.py +175 -0
  159. code_puppy/plugins/agent_skills/discovery.py +136 -0
  160. code_puppy/plugins/agent_skills/downloader.py +392 -0
  161. code_puppy/plugins/agent_skills/installer.py +22 -0
  162. code_puppy/plugins/agent_skills/metadata.py +219 -0
  163. code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
  164. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  165. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  166. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  167. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  168. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  169. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  170. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  171. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  172. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  173. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  174. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  175. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  176. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  177. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  178. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  179. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  180. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  181. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  182. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  183. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
  184. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  185. code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
  186. code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
  187. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  188. code_puppy/plugins/claude_code_hooks/config.py +137 -0
  189. code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
  190. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  191. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  192. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  193. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  194. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  195. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  196. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  197. code_puppy/plugins/claude_code_oauth/utils.py +640 -0
  198. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  199. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  200. code_puppy/plugins/example_custom_command/README.md +280 -0
  201. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  202. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  203. code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
  204. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  205. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  206. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  207. code_puppy/plugins/hook_creator/__init__.py +1 -0
  208. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  209. code_puppy/plugins/hook_manager/__init__.py +1 -0
  210. code_puppy/plugins/hook_manager/config.py +290 -0
  211. code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
  212. code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
  213. code_puppy/plugins/oauth_puppy_html.py +228 -0
  214. code_puppy/plugins/scheduler/__init__.py +1 -0
  215. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  216. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  217. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  218. code_puppy/plugins/shell_safety/__init__.py +6 -0
  219. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  220. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  221. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  222. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  223. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  224. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  225. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  226. code_puppy/plugins/universal_constructor/models.py +138 -0
  227. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  228. code_puppy/plugins/universal_constructor/registry.py +302 -0
  229. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  230. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  231. code_puppy/pydantic_patches.py +356 -0
  232. code_puppy/reopenable_async_client.py +232 -0
  233. code_puppy/round_robin_model.py +150 -0
  234. code_puppy/scheduler/__init__.py +41 -0
  235. code_puppy/scheduler/__main__.py +9 -0
  236. code_puppy/scheduler/cli.py +118 -0
  237. code_puppy/scheduler/config.py +126 -0
  238. code_puppy/scheduler/daemon.py +280 -0
  239. code_puppy/scheduler/executor.py +155 -0
  240. code_puppy/scheduler/platform.py +19 -0
  241. code_puppy/scheduler/platform_unix.py +22 -0
  242. code_puppy/scheduler/platform_win.py +32 -0
  243. code_puppy/session_storage.py +338 -0
  244. code_puppy/status_display.py +257 -0
  245. code_puppy/summarization_agent.py +176 -0
  246. code_puppy/terminal_utils.py +418 -0
  247. code_puppy/tools/__init__.py +501 -0
  248. code_puppy/tools/agent_tools.py +603 -0
  249. code_puppy/tools/ask_user_question/__init__.py +26 -0
  250. code_puppy/tools/ask_user_question/constants.py +73 -0
  251. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  252. code_puppy/tools/ask_user_question/handler.py +232 -0
  253. code_puppy/tools/ask_user_question/models.py +304 -0
  254. code_puppy/tools/ask_user_question/registration.py +26 -0
  255. code_puppy/tools/ask_user_question/renderers.py +309 -0
  256. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  257. code_puppy/tools/ask_user_question/theme.py +155 -0
  258. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  259. code_puppy/tools/browser/__init__.py +37 -0
  260. code_puppy/tools/browser/browser_control.py +289 -0
  261. code_puppy/tools/browser/browser_interactions.py +545 -0
  262. code_puppy/tools/browser/browser_locators.py +640 -0
  263. code_puppy/tools/browser/browser_manager.py +378 -0
  264. code_puppy/tools/browser/browser_navigation.py +251 -0
  265. code_puppy/tools/browser/browser_screenshot.py +179 -0
  266. code_puppy/tools/browser/browser_scripts.py +462 -0
  267. code_puppy/tools/browser/browser_workflows.py +221 -0
  268. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  269. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  270. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  271. code_puppy/tools/browser/terminal_tools.py +525 -0
  272. code_puppy/tools/command_runner.py +1346 -0
  273. code_puppy/tools/common.py +1409 -0
  274. code_puppy/tools/display.py +84 -0
  275. code_puppy/tools/file_modifications.py +886 -0
  276. code_puppy/tools/file_operations.py +802 -0
  277. code_puppy/tools/scheduler_tools.py +412 -0
  278. code_puppy/tools/skills_tools.py +244 -0
  279. code_puppy/tools/subagent_context.py +158 -0
  280. code_puppy/tools/tools_content.py +51 -0
  281. code_puppy/tools/universal_constructor.py +889 -0
  282. code_puppy/uvx_detection.py +242 -0
  283. code_puppy/version_checker.py +82 -0
  284. codepp-0.0.437.dist-info/METADATA +766 -0
  285. codepp-0.0.437.dist-info/RECORD +288 -0
  286. codepp-0.0.437.dist-info/WHEEL +4 -0
  287. codepp-0.0.437.dist-info/entry_points.txt +3 -0
  288. codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,672 @@
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 asyncio
18
+ import base64
19
+ import json
20
+ import logging
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 the configured max age (seconds)
30
+ TOKEN_MAX_AGE_SECONDS = 3600
31
+
32
+ # Retry configuration
33
+ RETRY_STATUS_CODES = (429, 500, 502, 503, 504)
34
+ MAX_RETRIES = 5
35
+
36
+ # Tool name prefix for Claude Code OAuth compatibility
37
+ # Tools are prefixed on outgoing requests and unprefixed on incoming responses
38
+ TOOL_PREFIX = "cp_"
39
+
40
+ # User-Agent to send with Claude Code OAuth requests
41
+ CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.2 (external, cli)"
42
+
43
+ try:
44
+ from anthropic import AsyncAnthropic
45
+ except ImportError: # pragma: no cover - optional dep
46
+ AsyncAnthropic = None # type: ignore
47
+
48
+
49
+ class ClaudeCacheAsyncClient(httpx.AsyncClient):
50
+ """Async HTTP client with Claude Code OAuth transformations.
51
+
52
+ Handles:
53
+ - Cache control injection for prompt caching
54
+ - Tool name prefixing on outgoing requests
55
+ - Tool name unprefixing on incoming streaming responses
56
+ - Header transformations (anthropic-beta, user-agent)
57
+ - URL modifications (adding ?beta=true)
58
+ - Proactive token refresh
59
+ """
60
+
61
+ def _get_jwt_age_seconds(self, token: str | None) -> float | None:
62
+ """Decode a JWT and return its age in seconds.
63
+
64
+ Returns None if the token can't be decoded or has no timestamp claims.
65
+ Uses 'iat' (issued at) if available, otherwise calculates from 'exp'.
66
+ """
67
+ if not token:
68
+ return None
69
+
70
+ try:
71
+ # JWT format: header.payload.signature
72
+ # We only need the payload (second part)
73
+ parts = token.split(".")
74
+ if len(parts) != 3:
75
+ return None
76
+
77
+ # Decode the payload (base64url encoded)
78
+ payload_b64 = parts[1]
79
+ # Add padding if needed (base64url doesn't require padding)
80
+ padding = 4 - len(payload_b64) % 4
81
+ if padding != 4:
82
+ payload_b64 += "=" * padding
83
+
84
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
85
+ payload = json.loads(payload_bytes.decode("utf-8"))
86
+
87
+ now = time.time()
88
+
89
+ # Prefer 'iat' (issued at) claim if available
90
+ if "iat" in payload:
91
+ iat = float(payload["iat"])
92
+ age = now - iat
93
+ return age
94
+
95
+ # Fall back to calculating from 'exp' claim
96
+ # Assume tokens are typically valid for TOKEN_MAX_AGE_SECONDS
97
+ if "exp" in payload:
98
+ exp = float(payload["exp"])
99
+ # If exp is in the future, calculate how long until expiry
100
+ # and assume the token was issued TOKEN_MAX_AGE_SECONDS before expiry
101
+ time_until_exp = exp - now
102
+ # If token has less than TOKEN_MAX_AGE_SECONDS left, it's "old"
103
+ age = TOKEN_MAX_AGE_SECONDS - time_until_exp
104
+ return max(0, age)
105
+
106
+ return None
107
+ except Exception as exc:
108
+ logger.debug("Failed to decode JWT age: %s", exc)
109
+ return None
110
+
111
+ def _extract_bearer_token(self, request: httpx.Request) -> str | None:
112
+ """Extract the bearer token from request headers."""
113
+ auth_header = request.headers.get("Authorization") or request.headers.get(
114
+ "authorization"
115
+ )
116
+ if auth_header and auth_header.lower().startswith("bearer "):
117
+ return auth_header[7:] # Strip "Bearer " prefix
118
+ return None
119
+
120
+ def _should_refresh_token(self, request: httpx.Request) -> bool:
121
+ """Check if the token should be refreshed (within the max-age window).
122
+
123
+ Uses two strategies:
124
+ 1. Decode JWT to check token age (if possible)
125
+ 2. Fall back to stored expires_at from token file
126
+
127
+ Returns True if token expires within TOKEN_MAX_AGE_SECONDS.
128
+ """
129
+ token = self._extract_bearer_token(request)
130
+ if not token:
131
+ return False
132
+
133
+ # Strategy 1: Try to decode JWT age
134
+ age = self._get_jwt_age_seconds(token)
135
+ if age is not None:
136
+ should_refresh = age >= TOKEN_MAX_AGE_SECONDS
137
+ if should_refresh:
138
+ logger.info(
139
+ "JWT token is %.1f seconds old (>= %d), will refresh proactively",
140
+ age,
141
+ TOKEN_MAX_AGE_SECONDS,
142
+ )
143
+ return should_refresh
144
+
145
+ # Strategy 2: Fall back to stored expires_at from token file
146
+ should_refresh = self._check_stored_token_expiry()
147
+ if should_refresh:
148
+ logger.info(
149
+ "Stored token expires within %d seconds, will refresh proactively",
150
+ TOKEN_MAX_AGE_SECONDS,
151
+ )
152
+ return should_refresh
153
+
154
+ @staticmethod
155
+ def _check_stored_token_expiry() -> bool:
156
+ """Check if the stored token expires within TOKEN_MAX_AGE_SECONDS.
157
+
158
+ This is a fallback for when JWT decoding fails or isn't available.
159
+ Uses the expires_at timestamp from the stored token file.
160
+ """
161
+ try:
162
+ from code_puppy.plugins.claude_code_oauth.utils import (
163
+ is_token_expired,
164
+ load_stored_tokens,
165
+ )
166
+
167
+ tokens = load_stored_tokens()
168
+ if not tokens:
169
+ return False
170
+
171
+ # is_token_expired already uses the configured refresh buffer window
172
+ return is_token_expired(tokens)
173
+ except Exception as exc:
174
+ logger.debug("Error checking stored token expiry: %s", exc)
175
+ return False
176
+
177
+ @staticmethod
178
+ def _prefix_tool_names(body: bytes) -> bytes | None:
179
+ """Prefix all tool names in the request body with TOOL_PREFIX.
180
+
181
+ This is required for Claude Code OAuth compatibility - tools must be
182
+ prefixed on outgoing requests and unprefixed on incoming responses.
183
+ """
184
+ try:
185
+ data = json.loads(body.decode("utf-8"))
186
+ except Exception:
187
+ return None
188
+
189
+ if not isinstance(data, dict):
190
+ return None
191
+
192
+ tools = data.get("tools")
193
+ if not isinstance(tools, list) or not tools:
194
+ return None
195
+
196
+ modified = False
197
+ for tool in tools:
198
+ if isinstance(tool, dict) and "name" in tool:
199
+ name = tool["name"]
200
+ if name and not name.startswith(TOOL_PREFIX):
201
+ tool["name"] = f"{TOOL_PREFIX}{name}"
202
+ modified = True
203
+
204
+ if not modified:
205
+ return None
206
+
207
+ return json.dumps(data).encode("utf-8")
208
+
209
+ @staticmethod
210
+ def _transform_headers_for_claude_code(
211
+ headers: MutableMapping[str, str],
212
+ ) -> None:
213
+ """Transform headers for Claude Code OAuth compatibility.
214
+
215
+ - Sets user-agent to claude-cli
216
+ - Merges anthropic-beta headers appropriately
217
+ - Removes x-api-key (using Bearer auth instead)
218
+ """
219
+ # Set user-agent
220
+ headers["user-agent"] = CLAUDE_CLI_USER_AGENT
221
+
222
+ # Handle anthropic-beta header — merge required betas with any
223
+ # extras already present (e.g. context-1m-2025-08-07).
224
+ incoming_beta = headers.get("anthropic-beta", "")
225
+ incoming_betas = [b.strip() for b in incoming_beta.split(",") if b.strip()]
226
+
227
+ # Always-required betas for Claude Code OAuth
228
+ required_betas = [
229
+ "oauth-2025-04-20",
230
+ "interleaved-thinking-2025-05-14",
231
+ ]
232
+ if "claude-code-20250219" in incoming_betas:
233
+ required_betas.append("claude-code-20250219")
234
+
235
+ # Merge: start with required, then append any extras from the
236
+ # incoming headers that aren't already in the required set.
237
+ merged = list(required_betas)
238
+ required_set = set(required_betas)
239
+ for beta in incoming_betas:
240
+ if beta not in required_set:
241
+ merged.append(beta)
242
+
243
+ headers["anthropic-beta"] = ",".join(merged)
244
+
245
+ # Remove x-api-key if present (we use Bearer auth)
246
+ for key in ["x-api-key", "X-API-Key", "X-Api-Key"]:
247
+ if key in headers:
248
+ del headers[key]
249
+
250
+ @staticmethod
251
+ def _add_beta_query_param(url: httpx.URL) -> httpx.URL:
252
+ """Add ?beta=true query parameter to the URL if not already present."""
253
+ # Parse the URL
254
+ parsed = urlparse(str(url))
255
+ query_params = parse_qs(parsed.query)
256
+
257
+ # Only add if not already present
258
+ if "beta" not in query_params:
259
+ query_params["beta"] = ["true"]
260
+ # Rebuild query string
261
+ new_query = urlencode(query_params, doseq=True)
262
+ # Rebuild URL
263
+ new_parsed = parsed._replace(query=new_query)
264
+ return httpx.URL(urlunparse(new_parsed))
265
+
266
+ return url
267
+
268
+ async def send(
269
+ self, request: httpx.Request, *args: Any, **kwargs: Any
270
+ ) -> httpx.Response: # type: ignore[override]
271
+ is_messages_endpoint = request.url.path.endswith("/v1/messages")
272
+
273
+ # Proactive token refresh: check JWT age before every request
274
+ if not request.extensions.get("claude_oauth_refresh_attempted"):
275
+ try:
276
+ if self._should_refresh_token(request):
277
+ refreshed_token = self._refresh_claude_oauth_token()
278
+ if refreshed_token:
279
+ logger.info("Proactively refreshed token before request")
280
+ # Rebuild request with new token
281
+ headers = dict(request.headers)
282
+ self._update_auth_headers(headers, refreshed_token)
283
+ body_bytes = self._extract_body_bytes(request)
284
+ request = self.build_request(
285
+ method=request.method,
286
+ url=request.url,
287
+ headers=headers,
288
+ content=body_bytes,
289
+ )
290
+ request.extensions["claude_oauth_refresh_attempted"] = True
291
+ except Exception as exc:
292
+ logger.debug("Error during proactive token refresh check: %s", exc)
293
+
294
+ # Apply Claude Code OAuth transformations for /v1/messages
295
+ if is_messages_endpoint:
296
+ try:
297
+ body_bytes = self._extract_body_bytes(request)
298
+ headers = dict(request.headers)
299
+ url = request.url
300
+ body_modified = False
301
+ headers_modified = False
302
+
303
+ # 1. Transform headers for Claude Code OAuth
304
+ self._transform_headers_for_claude_code(headers)
305
+ headers_modified = True
306
+
307
+ # 2. Add ?beta=true query param
308
+ url = self._add_beta_query_param(url)
309
+
310
+ # 3. Prefix tool names in request body
311
+ if body_bytes:
312
+ prefixed_body = self._prefix_tool_names(body_bytes)
313
+ if prefixed_body is not None:
314
+ body_bytes = prefixed_body
315
+ body_modified = True
316
+
317
+ # 4. Inject cache_control
318
+ cached_body = self._inject_cache_control(body_bytes)
319
+ if cached_body is not None:
320
+ body_bytes = cached_body
321
+ body_modified = True
322
+
323
+ # Rebuild request if anything changed
324
+ if body_modified or headers_modified or url != request.url:
325
+ try:
326
+ rebuilt = self.build_request(
327
+ method=request.method,
328
+ url=url,
329
+ headers=headers,
330
+ content=body_bytes,
331
+ )
332
+
333
+ # Copy core internals so httpx uses the modified body/stream
334
+ if hasattr(rebuilt, "_content"):
335
+ request._content = rebuilt._content # type: ignore[attr-defined]
336
+ if hasattr(rebuilt, "stream"):
337
+ request.stream = rebuilt.stream
338
+ if hasattr(rebuilt, "extensions"):
339
+ request.extensions = rebuilt.extensions
340
+
341
+ # Update URL
342
+ request.url = url
343
+
344
+ # Update headers
345
+ for key, value in headers.items():
346
+ request.headers[key] = value
347
+
348
+ # Ensure Content-Length matches the new body
349
+ if body_bytes:
350
+ request.headers["Content-Length"] = str(len(body_bytes))
351
+
352
+ except Exception as exc:
353
+ logger.debug("Error rebuilding request: %s", exc)
354
+
355
+ except Exception as exc:
356
+ logger.debug("Error in Claude Code transformations: %s", exc)
357
+
358
+ # Send the request with retry logic for transient errors
359
+ response = await self._send_with_retries(request, *args, **kwargs)
360
+
361
+ # NOTE: Tool name unprefixing is now handled at the pydantic-ai level
362
+ # in pydantic_patches.py rather than wrapping the HTTP response stream.
363
+ # The response wrapper caused zlib decompression errors due to httpx
364
+ # response lifecycle issues.
365
+
366
+ # Handle auth errors with token refresh
367
+ try:
368
+ if response.status_code in (400, 401, 403) and not request.extensions.get(
369
+ "claude_oauth_refresh_attempted"
370
+ ):
371
+ is_auth_error = response.status_code in (401, 403)
372
+
373
+ if response.status_code == 400:
374
+ is_auth_error = await self._is_cloudflare_html_error(response)
375
+ if is_auth_error:
376
+ logger.info(
377
+ "Detected Cloudflare 400 error (likely auth-related), attempting token refresh"
378
+ )
379
+
380
+ if is_auth_error:
381
+ refreshed_token = self._refresh_claude_oauth_token()
382
+ if refreshed_token:
383
+ logger.info("Token refreshed successfully, retrying request")
384
+ await response.aclose()
385
+ body_bytes = self._extract_body_bytes(request)
386
+ headers = dict(request.headers)
387
+ self._update_auth_headers(headers, refreshed_token)
388
+ retry_request = self.build_request(
389
+ method=request.method,
390
+ url=request.url,
391
+ headers=headers,
392
+ content=body_bytes,
393
+ )
394
+ retry_request.extensions["claude_oauth_refresh_attempted"] = (
395
+ True
396
+ )
397
+ return await self._send_with_retries(
398
+ retry_request, *args, **kwargs
399
+ )
400
+ else:
401
+ logger.warning("Token refresh failed, returning original error")
402
+ except Exception as exc:
403
+ logger.debug("Error during token refresh attempt: %s", exc)
404
+
405
+ return response
406
+
407
+ async def _send_with_retries(
408
+ self, request: httpx.Request, *args: Any, **kwargs: Any
409
+ ) -> httpx.Response:
410
+ """Send request with automatic retries for rate limits and server errors.
411
+
412
+ Retries on:
413
+ - 429 (rate limit) - respects Retry-After header
414
+ - 500, 502, 503, 504 (server errors) - exponential backoff
415
+ - Connection errors (ConnectError, ReadTimeout, PoolTimeout)
416
+ """
417
+ last_response = None
418
+ last_exception = None
419
+
420
+ for attempt in range(MAX_RETRIES + 1):
421
+ try:
422
+ response = await super().send(request, *args, **kwargs)
423
+ last_response = response
424
+
425
+ # Check for retryable status
426
+ if response.status_code not in RETRY_STATUS_CODES:
427
+ return response
428
+
429
+ # Don't retry if this is the last attempt
430
+ if attempt >= MAX_RETRIES:
431
+ return response
432
+
433
+ # Close response before retrying
434
+ await response.aclose()
435
+
436
+ # Calculate wait time with exponential backoff
437
+ wait_time = 1.0 * (2**attempt) # 1s, 2s, 4s, 8s, 16s
438
+
439
+ # For 429, respect Retry-After header if present
440
+ if response.status_code == 429:
441
+ retry_after = response.headers.get("Retry-After")
442
+ if retry_after:
443
+ try:
444
+ wait_time = float(retry_after)
445
+ except ValueError:
446
+ # Try parsing http-date format
447
+ try:
448
+ from email.utils import parsedate_to_datetime
449
+
450
+ date = parsedate_to_datetime(retry_after)
451
+ wait_time = max(0, date.timestamp() - time.time())
452
+ except Exception:
453
+ pass
454
+
455
+ # Cap wait time between 0.5s and 60s
456
+ wait_time = max(0.5, min(wait_time, 60.0))
457
+
458
+ logger.info(
459
+ "HTTP %d received, retrying in %.1fs (attempt %d/%d)",
460
+ response.status_code,
461
+ wait_time,
462
+ attempt + 1,
463
+ MAX_RETRIES,
464
+ )
465
+ await asyncio.sleep(wait_time)
466
+
467
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.PoolTimeout) as exc:
468
+ last_exception = exc
469
+
470
+ # Don't retry if this is the last attempt
471
+ if attempt >= MAX_RETRIES:
472
+ raise
473
+
474
+ wait_time = 1.0 * (2**attempt)
475
+ wait_time = max(0.5, min(wait_time, 60.0))
476
+
477
+ logger.warning(
478
+ "HTTP connection error: %s. Retrying in %.1fs (attempt %d/%d)",
479
+ exc,
480
+ wait_time,
481
+ attempt + 1,
482
+ MAX_RETRIES,
483
+ )
484
+ await asyncio.sleep(wait_time)
485
+
486
+ except Exception:
487
+ # Don't retry on other exceptions (e.g., validation errors)
488
+ raise
489
+
490
+ # Return last response if we have one
491
+ if last_response is not None:
492
+ return last_response
493
+
494
+ # Re-raise last exception if we have one
495
+ if last_exception is not None:
496
+ raise last_exception
497
+
498
+ # This shouldn't happen, but just in case
499
+ raise RuntimeError("Retry loop completed without response or exception")
500
+
501
+ @staticmethod
502
+ def _extract_body_bytes(request: httpx.Request) -> bytes | None:
503
+ # Try public content first
504
+ try:
505
+ content = request.content
506
+ if content:
507
+ return content
508
+ except Exception:
509
+ pass
510
+
511
+ # Fallback to private attr if necessary
512
+ try:
513
+ content = getattr(request, "_content", None)
514
+ if content:
515
+ return content
516
+ except Exception:
517
+ pass
518
+
519
+ return None
520
+
521
+ @staticmethod
522
+ def _update_auth_headers(
523
+ headers: MutableMapping[str, str], access_token: str
524
+ ) -> None:
525
+ bearer_value = f"Bearer {access_token}"
526
+ if "Authorization" in headers or "authorization" in headers:
527
+ headers["Authorization"] = bearer_value
528
+ elif "x-api-key" in headers or "X-API-Key" in headers:
529
+ headers["x-api-key"] = access_token
530
+ else:
531
+ headers["Authorization"] = bearer_value
532
+
533
+ @staticmethod
534
+ async def _is_cloudflare_html_error(response: httpx.Response) -> bool:
535
+ """Check if this is a Cloudflare HTML error response.
536
+
537
+ Cloudflare often returns HTML error pages with status 400 when
538
+ there are authentication issues.
539
+ """
540
+ # Check content type
541
+ content_type = response.headers.get("content-type", "")
542
+ if "text/html" not in content_type.lower():
543
+ return False
544
+
545
+ # Check if body contains Cloudflare markers
546
+ try:
547
+ # For async httpx, we need to read the body first
548
+ if not hasattr(response, "_content") or not response._content:
549
+ try:
550
+ await response.aread()
551
+ except Exception as read_exc:
552
+ logger.debug("Failed to read response body: %s", read_exc)
553
+ return False
554
+
555
+ # Now we can safely access the content
556
+ if hasattr(response, "_content") and response._content:
557
+ body = response._content.decode("utf-8", errors="ignore")
558
+ else:
559
+ # Fallback to text property (should work after aread)
560
+ try:
561
+ body = response.text
562
+ except Exception:
563
+ return False
564
+
565
+ # Look for Cloudflare and 400 Bad Request markers
566
+ body_lower = body.lower()
567
+ return "cloudflare" in body_lower and "400 bad request" in body_lower
568
+ except Exception as exc:
569
+ logger.debug("Error checking for Cloudflare error: %s", exc)
570
+ return False
571
+
572
+ def _refresh_claude_oauth_token(self) -> str | None:
573
+ try:
574
+ from code_puppy.plugins.claude_code_oauth.utils import refresh_access_token
575
+
576
+ logger.info("Attempting to refresh Claude Code OAuth token...")
577
+ refreshed_token = refresh_access_token(force=True)
578
+ if refreshed_token:
579
+ self._update_auth_headers(self.headers, refreshed_token)
580
+ logger.info("Successfully refreshed Claude Code OAuth token")
581
+ else:
582
+ logger.warning("Token refresh returned None")
583
+ return refreshed_token
584
+ except Exception as exc:
585
+ logger.error("Exception during token refresh: %s", exc)
586
+ return None
587
+
588
+ @staticmethod
589
+ def _inject_cache_control(body: bytes) -> bytes | None:
590
+ try:
591
+ data = json.loads(body.decode("utf-8"))
592
+ except Exception:
593
+ return None
594
+
595
+ if not isinstance(data, dict):
596
+ return None
597
+
598
+ modified = False
599
+
600
+ # Minimal, deterministic strategy:
601
+ # Add cache_control only on the single most recent block:
602
+ # the last dict content block of the last message (if any).
603
+ messages = data.get("messages")
604
+ if isinstance(messages, list) and messages:
605
+ last = messages[-1]
606
+ if isinstance(last, dict):
607
+ content = last.get("content")
608
+ if isinstance(content, list) and content:
609
+ last_block = content[-1]
610
+ if (
611
+ isinstance(last_block, dict)
612
+ and "cache_control" not in last_block
613
+ ):
614
+ last_block["cache_control"] = {"type": "ephemeral"}
615
+ modified = True
616
+
617
+ if not modified:
618
+ return None
619
+
620
+ return json.dumps(data).encode("utf-8")
621
+
622
+
623
+ def _inject_cache_control_in_payload(payload: dict[str, Any]) -> None:
624
+ """In-place cache_control injection on Anthropic messages.create payload."""
625
+
626
+ messages = payload.get("messages")
627
+ if isinstance(messages, list) and messages:
628
+ last = messages[-1]
629
+ if isinstance(last, dict):
630
+ content = last.get("content")
631
+ if isinstance(content, list) and content:
632
+ last_block = content[-1]
633
+ if isinstance(last_block, dict) and "cache_control" not in last_block:
634
+ last_block["cache_control"] = {"type": "ephemeral"}
635
+
636
+ # No extra markers in production mode; keep payload clean.
637
+ # (Function kept for potential future use.)
638
+ return
639
+
640
+
641
+ def patch_anthropic_client_messages(client: Any) -> None:
642
+ """Monkey-patch AsyncAnthropic.messages.create to inject cache_control.
643
+
644
+ This operates at the highest level: just before Anthropic SDK serializes
645
+ the request into HTTP. That means no httpx / Pydantic shenanigans can
646
+ undo it.
647
+ """
648
+
649
+ if AsyncAnthropic is None or not isinstance(client, AsyncAnthropic): # type: ignore[arg-type]
650
+ return
651
+
652
+ try:
653
+ messages_obj = getattr(client, "messages", None)
654
+ if messages_obj is None:
655
+ return
656
+ original_create: Callable[..., Any] = messages_obj.create
657
+ except Exception: # pragma: no cover - defensive
658
+ return
659
+
660
+ async def wrapped_create(*args: Any, **kwargs: Any):
661
+ # Anthropic messages.create takes a mix of positional/kw args.
662
+ # The payload is usually in kwargs for the Python SDK.
663
+ if kwargs:
664
+ _inject_cache_control_in_payload(kwargs)
665
+ elif args:
666
+ maybe_payload = args[-1]
667
+ if isinstance(maybe_payload, dict):
668
+ _inject_cache_control_in_payload(maybe_payload)
669
+
670
+ return await original_create(*args, **kwargs)
671
+
672
+ messages_obj.create = wrapped_create # type: ignore[assignment]