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.
- code_puppy/__init__.py +10 -0
- code_puppy/__main__.py +10 -0
- code_puppy/agents/__init__.py +31 -0
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +117 -0
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +638 -0
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_helios.py +124 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +742 -0
- code_puppy/agents/agent_pack_leader.py +385 -0
- code_puppy/agents/agent_planning.py +165 -0
- code_puppy/agents/agent_python_programmer.py +169 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_scheduler.py +121 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +2156 -0
- code_puppy/agents/event_stream_handler.py +348 -0
- code_puppy/agents/json_agent.py +202 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +327 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +453 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +75 -0
- code_puppy/api/routers/sessions.py +234 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +692 -0
- code_puppy/chatgpt_codex_client.py +338 -0
- code_puppy/claude_cache_client.py +672 -0
- code_puppy/cli_runner.py +1073 -0
- code_puppy/command_line/__init__.py +1 -0
- code_puppy/command_line/add_model_menu.py +1092 -0
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +704 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +532 -0
- code_puppy/command_line/command_handler.py +293 -0
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +719 -0
- code_puppy/command_line/core_commands.py +867 -0
- code_puppy/command_line/diff_menu.py +865 -0
- code_puppy/command_line/file_path_completion.py +73 -0
- code_puppy/command_line/load_context_completion.py +52 -0
- code_puppy/command_line/mcp/__init__.py +10 -0
- code_puppy/command_line/mcp/base.py +32 -0
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +138 -0
- code_puppy/command_line/mcp/help_command.py +147 -0
- code_puppy/command_line/mcp/install_command.py +214 -0
- code_puppy/command_line/mcp/install_menu.py +705 -0
- code_puppy/command_line/mcp/list_command.py +94 -0
- code_puppy/command_line/mcp/logs_command.py +235 -0
- code_puppy/command_line/mcp/remove_command.py +82 -0
- code_puppy/command_line/mcp/restart_command.py +100 -0
- code_puppy/command_line/mcp/search_command.py +123 -0
- code_puppy/command_line/mcp/start_all_command.py +135 -0
- code_puppy/command_line/mcp/start_command.py +117 -0
- code_puppy/command_line/mcp/status_command.py +184 -0
- code_puppy/command_line/mcp/stop_all_command.py +112 -0
- code_puppy/command_line/mcp/stop_command.py +80 -0
- code_puppy/command_line/mcp/test_command.py +107 -0
- code_puppy/command_line/mcp/utils.py +129 -0
- code_puppy/command_line/mcp/wizard_utils.py +334 -0
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +197 -0
- code_puppy/command_line/model_settings_menu.py +932 -0
- code_puppy/command_line/motd.py +96 -0
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +342 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +846 -0
- code_puppy/command_line/session_commands.py +302 -0
- code_puppy/command_line/shell_passthrough.py +145 -0
- code_puppy/command_line/skills_completion.py +160 -0
- code_puppy/command_line/uc_menu.py +893 -0
- code_puppy/command_line/utils.py +93 -0
- code_puppy/command_line/wiggum_state.py +78 -0
- code_puppy/config.py +1770 -0
- code_puppy/error_logging.py +134 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +754 -0
- code_puppy/hook_engine/README.md +105 -0
- code_puppy/hook_engine/__init__.py +21 -0
- code_puppy/hook_engine/aliases.py +155 -0
- code_puppy/hook_engine/engine.py +221 -0
- code_puppy/hook_engine/executor.py +296 -0
- code_puppy/hook_engine/matcher.py +156 -0
- code_puppy/hook_engine/models.py +240 -0
- code_puppy/hook_engine/registry.py +106 -0
- code_puppy/hook_engine/validator.py +144 -0
- code_puppy/http_utils.py +361 -0
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +10 -0
- code_puppy/mcp_/__init__.py +66 -0
- code_puppy/mcp_/async_lifecycle.py +286 -0
- code_puppy/mcp_/blocking_startup.py +469 -0
- code_puppy/mcp_/captured_stdio_server.py +275 -0
- code_puppy/mcp_/circuit_breaker.py +290 -0
- code_puppy/mcp_/config_wizard.py +507 -0
- code_puppy/mcp_/dashboard.py +308 -0
- code_puppy/mcp_/error_isolation.py +407 -0
- code_puppy/mcp_/examples/retry_example.py +226 -0
- code_puppy/mcp_/health_monitor.py +589 -0
- code_puppy/mcp_/managed_server.py +428 -0
- code_puppy/mcp_/manager.py +807 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +451 -0
- code_puppy/mcp_/retry_manager.py +337 -0
- code_puppy/mcp_/server_registry_catalog.py +1126 -0
- code_puppy/mcp_/status_tracker.py +355 -0
- code_puppy/mcp_/system_tools.py +209 -0
- code_puppy/mcp_prompts/__init__.py +1 -0
- code_puppy/mcp_prompts/hook_creator.py +103 -0
- code_puppy/messaging/__init__.py +255 -0
- code_puppy/messaging/bus.py +613 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +361 -0
- code_puppy/messaging/messages.py +569 -0
- code_puppy/messaging/queue_console.py +271 -0
- code_puppy/messaging/renderers.py +311 -0
- code_puppy/messaging/rich_renderer.py +1158 -0
- code_puppy/messaging/spinner/__init__.py +83 -0
- code_puppy/messaging/spinner/console_spinner.py +240 -0
- code_puppy/messaging/spinner/spinner_base.py +95 -0
- code_puppy/messaging/subagent_console.py +460 -0
- code_puppy/model_factory.py +848 -0
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +168 -0
- code_puppy/models.json +174 -0
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +186 -0
- code_puppy/plugins/agent_skills/__init__.py +22 -0
- code_puppy/plugins/agent_skills/config.py +175 -0
- code_puppy/plugins/agent_skills/discovery.py +136 -0
- code_puppy/plugins/agent_skills/downloader.py +392 -0
- code_puppy/plugins/agent_skills/installer.py +22 -0
- code_puppy/plugins/agent_skills/metadata.py +219 -0
- code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
- code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
- code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
- code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
- code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
- code_puppy/plugins/agent_skills/skills_menu.py +781 -0
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +133 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
- code_puppy/plugins/antigravity_oauth/storage.py +288 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +863 -0
- code_puppy/plugins/antigravity_oauth/utils.py +168 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
- code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
- code_puppy/plugins/claude_code_hooks/config.py +137 -0
- code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
- code_puppy/plugins/claude_code_oauth/config.py +52 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
- code_puppy/plugins/claude_code_oauth/utils.py +640 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/hook_creator/__init__.py +1 -0
- code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
- code_puppy/plugins/hook_manager/__init__.py +1 -0
- code_puppy/plugins/hook_manager/config.py +290 -0
- code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
- code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/scheduler/__init__.py +1 -0
- code_puppy/plugins/scheduler/register_callbacks.py +88 -0
- code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
- code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/plugins/synthetic_status/__init__.py +1 -0
- code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
- code_puppy/plugins/synthetic_status/status_api.py +147 -0
- code_puppy/plugins/universal_constructor/__init__.py +13 -0
- code_puppy/plugins/universal_constructor/models.py +138 -0
- code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
- code_puppy/plugins/universal_constructor/registry.py +302 -0
- code_puppy/plugins/universal_constructor/sandbox.py +584 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +356 -0
- code_puppy/reopenable_async_client.py +232 -0
- code_puppy/round_robin_model.py +150 -0
- code_puppy/scheduler/__init__.py +41 -0
- code_puppy/scheduler/__main__.py +9 -0
- code_puppy/scheduler/cli.py +118 -0
- code_puppy/scheduler/config.py +126 -0
- code_puppy/scheduler/daemon.py +280 -0
- code_puppy/scheduler/executor.py +155 -0
- code_puppy/scheduler/platform.py +19 -0
- code_puppy/scheduler/platform_unix.py +22 -0
- code_puppy/scheduler/platform_win.py +32 -0
- code_puppy/session_storage.py +338 -0
- code_puppy/status_display.py +257 -0
- code_puppy/summarization_agent.py +176 -0
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +501 -0
- code_puppy/tools/agent_tools.py +603 -0
- code_puppy/tools/ask_user_question/__init__.py +26 -0
- code_puppy/tools/ask_user_question/constants.py +73 -0
- code_puppy/tools/ask_user_question/demo_tui.py +55 -0
- code_puppy/tools/ask_user_question/handler.py +232 -0
- code_puppy/tools/ask_user_question/models.py +304 -0
- code_puppy/tools/ask_user_question/registration.py +26 -0
- code_puppy/tools/ask_user_question/renderers.py +309 -0
- code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
- code_puppy/tools/ask_user_question/theme.py +155 -0
- code_puppy/tools/ask_user_question/tui_loop.py +423 -0
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +378 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +534 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +1346 -0
- code_puppy/tools/common.py +1409 -0
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +886 -0
- code_puppy/tools/file_operations.py +802 -0
- code_puppy/tools/scheduler_tools.py +412 -0
- code_puppy/tools/skills_tools.py +244 -0
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/tools/tools_content.py +51 -0
- code_puppy/tools/universal_constructor.py +889 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +82 -0
- codepp-0.0.437.dist-info/METADATA +766 -0
- codepp-0.0.437.dist-info/RECORD +288 -0
- codepp-0.0.437.dist-info/WHEEL +4 -0
- codepp-0.0.437.dist-info/entry_points.txt +3 -0
- 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]
|