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.
- code_puppy/__init__.py +7 -1
- code_puppy/agents/__init__.py +8 -8
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +9 -2
- 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 +48 -9
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +146 -199
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_planning.py +163 -0
- code_puppy/agents/agent_python_programmer.py +165 -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_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 +1713 -1
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/json_agent.py +12 -1
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/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 +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +174 -4
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/claude_cache_client.py +586 -0
- code_puppy/cli_runner.py +916 -0
- code_puppy/command_line/add_model_menu.py +1079 -0
- code_puppy/command_line/agent_menu.py +395 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +605 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +233 -627
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +715 -0
- code_puppy/command_line/core_commands.py +792 -0
- code_puppy/command_line/diff_menu.py +863 -0
- code_puppy/command_line/load_context_completion.py +15 -22
- code_puppy/command_line/mcp/base.py +1 -4
- 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 +9 -4
- code_puppy/command_line/mcp/help_command.py +6 -5
- code_puppy/command_line/mcp/install_command.py +16 -27
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +3 -3
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +12 -4
- code_puppy/command_line/mcp/search_command.py +17 -11
- code_puppy/command_line/mcp/start_all_command.py +22 -13
- code_puppy/command_line/mcp/start_command.py +50 -31
- code_puppy/command_line/mcp/status_command.py +6 -7
- code_puppy/command_line/mcp/stop_all_command.py +11 -8
- code_puppy/command_line/mcp/stop_command.py +11 -10
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/utils.py +1 -1
- code_puppy/command_line/mcp/wizard_utils.py +22 -18
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +89 -30
- code_puppy/command_line/model_settings_menu.py +884 -0
- code_puppy/command_line/motd.py +14 -8
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +626 -75
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +1181 -51
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +602 -0
- code_puppy/http_utils.py +220 -104
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -594
- code_puppy/{mcp → mcp_}/__init__.py +17 -0
- code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
- code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
- code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
- code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
- code_puppy/{mcp → mcp_}/dashboard.py +15 -6
- code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
- code_puppy/{mcp → mcp_}/managed_server.py +66 -39
- code_puppy/{mcp → mcp_}/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/{mcp → mcp_}/registry.py +6 -6
- code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
- code_puppy/messaging/__init__.py +199 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +17 -48
- code_puppy/messaging/messages.py +500 -0
- code_puppy/messaging/queue_console.py +1 -24
- code_puppy/messaging/renderers.py +43 -146
- code_puppy/messaging/rich_renderer.py +1027 -0
- code_puppy/messaging/spinner/__init__.py +33 -5
- code_puppy/messaging/spinner/console_spinner.py +92 -52
- code_puppy/messaging/spinner/spinner_base.py +29 -0
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +686 -80
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +86 -104
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +164 -10
- 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 +704 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -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 +767 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -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 +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +489 -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 +6 -0
- code_puppy/plugins/claude_code_oauth/config.py +50 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/utils.py +518 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +169 -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 +523 -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/oauth_puppy_html.py +228 -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/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/round_robin_model.py +10 -15
- code_puppy/session_storage.py +294 -0
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +52 -14
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +139 -6
- code_puppy/tools/agent_tools.py +548 -49
- 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 +316 -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 +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +941 -153
- code_puppy/tools/common.py +1146 -6
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +352 -266
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +30 -11
- code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
- code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
- code_puppy/agent.py +0 -231
- code_puppy/agents/agent_orchestrator.json +0 -26
- code_puppy/agents/runtime_manager.py +0 -272
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/command_line/meta_command_handler.py +0 -153
- code_puppy/message_history_processor.py +0 -490
- code_puppy/messaging/spinner/textual_spinner.py +0 -101
- code_puppy/state_management.py +0 -200
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -986
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -550
- code_puppy/tui/components/command_history_modal.py +0 -218
- code_puppy/tui/components/copy_button.py +0 -139
- code_puppy/tui/components/custom_widgets.py +0 -63
- code_puppy/tui/components/human_input_modal.py +0 -175
- code_puppy/tui/components/input_area.py +0 -167
- code_puppy/tui/components/sidebar.py +0 -309
- code_puppy/tui/components/status_bar.py +0 -182
- code_puppy/tui/messages.py +0 -27
- code_puppy/tui/models/__init__.py +0 -8
- code_puppy/tui/models/chat_message.py +0 -25
- code_puppy/tui/models/command_history.py +0 -89
- code_puppy/tui/models/enums.py +0 -24
- code_puppy/tui/screens/__init__.py +0 -15
- code_puppy/tui/screens/help.py +0 -130
- code_puppy/tui/screens/mcp_install_wizard.py +0 -803
- code_puppy/tui/screens/settings.py +0 -290
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
- code_puppy-0.0.169.dist-info/RECORD +0 -112
- /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
- /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
- /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
- /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
- /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
- /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
- {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]
|