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,640 @@
1
+ """Utility helpers for the Claude Code OAuth plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import re
10
+ import secrets
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import Any, Dict, List, Optional, Tuple, Union
14
+ from urllib.parse import urlencode
15
+
16
+ import requests
17
+
18
+ from .config import (
19
+ CLAUDE_CODE_OAUTH_CONFIG,
20
+ get_claude_models_path,
21
+ get_token_storage_path,
22
+ )
23
+
24
+ # Proactive refresh buffer default (seconds). Actual buffer is dynamic
25
+ # based on expires_in to avoid overly aggressive refreshes.
26
+ TOKEN_REFRESH_BUFFER_SECONDS = 300
27
+ MIN_REFRESH_BUFFER_SECONDS = 30
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ @dataclass
33
+ class OAuthContext:
34
+ """Runtime state for an in-progress OAuth flow."""
35
+
36
+ state: str
37
+ code_verifier: str
38
+ code_challenge: str
39
+ created_at: float
40
+ redirect_uri: Optional[str] = None
41
+
42
+
43
+ _oauth_context: Optional[OAuthContext] = None
44
+
45
+
46
+ def _urlsafe_b64encode(data: bytes) -> str:
47
+ return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
48
+
49
+
50
+ def _generate_code_verifier() -> str:
51
+ return _urlsafe_b64encode(secrets.token_bytes(64))
52
+
53
+
54
+ def _compute_code_challenge(code_verifier: str) -> str:
55
+ digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
56
+ return _urlsafe_b64encode(digest)
57
+
58
+
59
+ def prepare_oauth_context() -> OAuthContext:
60
+ """Create and cache a new OAuth PKCE context."""
61
+ global _oauth_context
62
+ state = secrets.token_urlsafe(32)
63
+ code_verifier = _generate_code_verifier()
64
+ code_challenge = _compute_code_challenge(code_verifier)
65
+ _oauth_context = OAuthContext(
66
+ state=state,
67
+ code_verifier=code_verifier,
68
+ code_challenge=code_challenge,
69
+ created_at=time.time(),
70
+ )
71
+ return _oauth_context
72
+
73
+
74
+ def get_oauth_context() -> Optional[OAuthContext]:
75
+ return _oauth_context
76
+
77
+
78
+ def clear_oauth_context() -> None:
79
+ global _oauth_context
80
+ _oauth_context = None
81
+
82
+
83
+ def assign_redirect_uri(context: OAuthContext, port: int) -> str:
84
+ """Assign redirect URI for the given OAuth context."""
85
+ if context is None:
86
+ raise RuntimeError("OAuth context cannot be None")
87
+
88
+ host = CLAUDE_CODE_OAUTH_CONFIG["redirect_host"].rstrip("/")
89
+ path = CLAUDE_CODE_OAUTH_CONFIG["redirect_path"].lstrip("/")
90
+ redirect_uri = f"{host}:{port}/{path}"
91
+ context.redirect_uri = redirect_uri
92
+ return redirect_uri
93
+
94
+
95
+ def build_authorization_url(context: OAuthContext) -> str:
96
+ """Return the Claude authorization URL with PKCE parameters."""
97
+ if not context.redirect_uri:
98
+ raise RuntimeError("Redirect URI has not been assigned for this OAuth context")
99
+
100
+ params = {
101
+ "response_type": "code",
102
+ "client_id": CLAUDE_CODE_OAUTH_CONFIG["client_id"],
103
+ "redirect_uri": context.redirect_uri,
104
+ "scope": CLAUDE_CODE_OAUTH_CONFIG["scope"],
105
+ "state": context.state,
106
+ "code": "true",
107
+ "code_challenge": context.code_challenge,
108
+ "code_challenge_method": "S256",
109
+ }
110
+ return f"{CLAUDE_CODE_OAUTH_CONFIG['auth_url']}?{urlencode(params)}"
111
+
112
+
113
+ def parse_authorization_code(raw_input: str) -> Tuple[str, Optional[str]]:
114
+ value = raw_input.strip()
115
+ if not value:
116
+ raise ValueError("Authorization code cannot be empty")
117
+
118
+ if "#" in value:
119
+ code, state = value.split("#", 1)
120
+ return code.strip(), state.strip() or None
121
+
122
+ parts = value.split()
123
+ if len(parts) == 2:
124
+ return parts[0].strip(), parts[1].strip() or None
125
+
126
+ return value, None
127
+
128
+
129
+ def load_stored_tokens() -> Optional[Dict[str, Any]]:
130
+ try:
131
+ token_path = get_token_storage_path()
132
+ if token_path.exists():
133
+ with open(token_path, "r", encoding="utf-8") as handle:
134
+ return json.load(handle)
135
+ except Exception as exc: # pragma: no cover - defensive logging
136
+ logger.error("Failed to load tokens: %s", exc)
137
+ return None
138
+
139
+
140
+ def _calculate_expires_at(expires_in: Optional[float]) -> Optional[float]:
141
+ if expires_in is None:
142
+ return None
143
+ try:
144
+ return time.time() + float(expires_in)
145
+ except (TypeError, ValueError):
146
+ return None
147
+
148
+
149
+ def _calculate_refresh_buffer(expires_in: Optional[float]) -> float:
150
+ default_buffer = float(TOKEN_REFRESH_BUFFER_SECONDS)
151
+ if expires_in is None:
152
+ return default_buffer
153
+ try:
154
+ expires_value = float(expires_in)
155
+ except (TypeError, ValueError):
156
+ return default_buffer
157
+ return min(default_buffer, max(MIN_REFRESH_BUFFER_SECONDS, expires_value * 0.1))
158
+
159
+
160
+ def _get_expires_at_value(tokens: Dict[str, Any]) -> Optional[float]:
161
+ expires_at = tokens.get("expires_at")
162
+ if expires_at is None:
163
+ return None
164
+ try:
165
+ return float(expires_at)
166
+ except (TypeError, ValueError):
167
+ return None
168
+
169
+
170
+ def _is_token_actually_expired(tokens: Dict[str, Any]) -> bool:
171
+ expires_at_value = _get_expires_at_value(tokens)
172
+ if expires_at_value is None:
173
+ return False
174
+ return time.time() >= expires_at_value
175
+
176
+
177
+ def is_token_expired(tokens: Dict[str, Any]) -> bool:
178
+ expires_at_value = _get_expires_at_value(tokens)
179
+ if expires_at_value is None:
180
+ return False
181
+ buffer_seconds = _calculate_refresh_buffer(tokens.get("expires_in"))
182
+ return time.time() >= expires_at_value - buffer_seconds
183
+
184
+
185
+ def update_claude_code_model_tokens(access_token: str) -> bool:
186
+ try:
187
+ claude_models = load_claude_models()
188
+ if not claude_models:
189
+ return False
190
+
191
+ updated = False
192
+ for config in claude_models.values():
193
+ if config.get("oauth_source") != "claude-code-plugin":
194
+ continue
195
+ custom_endpoint = config.get("custom_endpoint")
196
+ if not isinstance(custom_endpoint, dict):
197
+ continue
198
+ custom_endpoint["api_key"] = access_token
199
+ updated = True
200
+
201
+ if updated:
202
+ return save_claude_models(claude_models)
203
+ except Exception as exc: # pragma: no cover - defensive logging
204
+ logger.error("Failed to update Claude model tokens: %s", exc)
205
+ return False
206
+
207
+
208
+ def refresh_access_token(force: bool = False) -> Optional[str]:
209
+ tokens = load_stored_tokens()
210
+ if not tokens:
211
+ return None
212
+
213
+ if not force and not is_token_expired(tokens):
214
+ return tokens.get("access_token")
215
+
216
+ refresh_token = tokens.get("refresh_token")
217
+ if not refresh_token:
218
+ logger.debug("No refresh_token available")
219
+ return None
220
+
221
+ payload = {
222
+ "grant_type": "refresh_token",
223
+ "client_id": CLAUDE_CODE_OAUTH_CONFIG["client_id"],
224
+ "refresh_token": refresh_token,
225
+ }
226
+
227
+ headers = {
228
+ "Content-Type": "application/json",
229
+ "Accept": "application/json",
230
+ "anthropic-beta": "oauth-2025-04-20",
231
+ }
232
+
233
+ try:
234
+ response = requests.post(
235
+ CLAUDE_CODE_OAUTH_CONFIG["token_url"],
236
+ json=payload,
237
+ headers=headers,
238
+ timeout=30,
239
+ )
240
+ if response.status_code == 200:
241
+ content_type = response.headers.get("content-type", "")
242
+ if not content_type.startswith("application/json"):
243
+ logger.error(
244
+ "Token refresh returned non-JSON response (Content-Type: %s): %s",
245
+ content_type,
246
+ response.text[:500],
247
+ )
248
+ return None
249
+ try:
250
+ new_tokens = response.json()
251
+ except (ValueError, json.JSONDecodeError) as e:
252
+ logger.error("Failed to parse token refresh response as JSON: %s", e)
253
+ return None
254
+ tokens["access_token"] = new_tokens.get("access_token")
255
+ tokens["refresh_token"] = new_tokens.get("refresh_token", refresh_token)
256
+ expires_in_value = new_tokens.get("expires_in")
257
+ if expires_in_value is None:
258
+ expires_in_value = tokens.get("expires_in")
259
+ if expires_in_value is not None:
260
+ tokens["expires_in"] = expires_in_value
261
+ tokens["expires_at"] = _calculate_expires_at(expires_in_value)
262
+ if save_tokens(tokens):
263
+ update_claude_code_model_tokens(tokens["access_token"])
264
+ return tokens["access_token"]
265
+ else:
266
+ logger.error(
267
+ "Token refresh failed: %s - %s", response.status_code, response.text
268
+ )
269
+ except Exception as exc: # pragma: no cover - defensive logging
270
+ logger.error("Token refresh error: %s", exc)
271
+ return None
272
+
273
+
274
+ def get_valid_access_token() -> Optional[str]:
275
+ tokens = load_stored_tokens()
276
+ if not tokens:
277
+ logger.debug("No stored Claude Code OAuth tokens found")
278
+ return None
279
+
280
+ access_token = tokens.get("access_token")
281
+ if not access_token:
282
+ logger.debug("No access_token in stored tokens")
283
+ return None
284
+
285
+ if is_token_expired(tokens):
286
+ logger.info("Claude Code OAuth token expired, attempting refresh")
287
+ refreshed = refresh_access_token()
288
+ if refreshed:
289
+ return refreshed
290
+ if not _is_token_actually_expired(tokens):
291
+ logger.warning(
292
+ "Claude Code token refresh failed; using existing access token until expiry"
293
+ )
294
+ return access_token
295
+ logger.warning("Claude Code token refresh failed")
296
+ return None
297
+
298
+ return access_token
299
+
300
+
301
+ def save_tokens(tokens: Dict[str, Any]) -> bool:
302
+ try:
303
+ token_path = get_token_storage_path()
304
+ with open(token_path, "w", encoding="utf-8") as handle:
305
+ json.dump(tokens, handle, indent=2)
306
+ token_path.chmod(0o600)
307
+ return True
308
+ except Exception as exc: # pragma: no cover - defensive logging
309
+ logger.error("Failed to save tokens: %s", exc)
310
+ return False
311
+
312
+
313
+ def load_claude_models() -> Dict[str, Any]:
314
+ try:
315
+ models_path = get_claude_models_path()
316
+ if models_path.exists():
317
+ with open(models_path, "r", encoding="utf-8") as handle:
318
+ return json.load(handle)
319
+ except Exception as exc: # pragma: no cover - defensive logging
320
+ logger.error("Failed to load Claude models: %s", exc)
321
+ return {}
322
+
323
+
324
+ def load_claude_models_filtered() -> Dict[str, Any]:
325
+ """Load Claude models and filter to only the latest versions.
326
+
327
+ This loads the stored models and applies the same filtering logic
328
+ used during saving to ensure only the latest haiku, sonnet, and opus
329
+ models are returned.
330
+ """
331
+ try:
332
+ all_models = load_claude_models()
333
+ if not all_models:
334
+ return {}
335
+
336
+ # Extract model names from the configuration
337
+ model_names = []
338
+ for name, config in all_models.items():
339
+ if config.get("oauth_source") == "claude-code-plugin":
340
+ model_names.append(config.get("name", ""))
341
+ else:
342
+ # For non-OAuth models, use the full key
343
+ model_names.append(name)
344
+
345
+ # Filter to only latest models
346
+ latest_names = set(
347
+ filter_latest_claude_models(
348
+ model_names, max_per_family={"default": 1, "opus": 3}
349
+ )
350
+ )
351
+
352
+ # Return only the filtered models
353
+ filtered_models = {}
354
+ for name, config in all_models.items():
355
+ model_name = config.get("name", name)
356
+ if model_name in latest_names:
357
+ filtered_models[name] = config
358
+
359
+ logger.info(
360
+ "Loaded %d models, filtered to %d latest models",
361
+ len(all_models),
362
+ len(filtered_models),
363
+ )
364
+ return filtered_models
365
+
366
+ except Exception as exc: # pragma: no cover - defensive logging
367
+ logger.error("Failed to load and filter Claude models: %s", exc)
368
+ return {}
369
+
370
+
371
+ def save_claude_models(models: Dict[str, Any]) -> bool:
372
+ try:
373
+ models_path = get_claude_models_path()
374
+ with open(models_path, "w", encoding="utf-8") as handle:
375
+ json.dump(models, handle, indent=2)
376
+ return True
377
+ except Exception as exc: # pragma: no cover - defensive logging
378
+ logger.error("Failed to save Claude models: %s", exc)
379
+ return False
380
+
381
+
382
+ def exchange_code_for_tokens(
383
+ auth_code: str, context: OAuthContext
384
+ ) -> Optional[Dict[str, Any]]:
385
+ if not context.redirect_uri:
386
+ raise RuntimeError("Redirect URI missing from OAuth context")
387
+
388
+ payload = {
389
+ "grant_type": "authorization_code",
390
+ "client_id": CLAUDE_CODE_OAUTH_CONFIG["client_id"],
391
+ "code": auth_code,
392
+ "state": context.state,
393
+ "code_verifier": context.code_verifier,
394
+ "redirect_uri": context.redirect_uri,
395
+ }
396
+
397
+ headers = {
398
+ "Content-Type": "application/json",
399
+ "Accept": "application/json",
400
+ "anthropic-beta": "oauth-2025-04-20",
401
+ }
402
+
403
+ logger.info("Exchanging code for tokens: %s", CLAUDE_CODE_OAUTH_CONFIG["token_url"])
404
+ logger.debug("Payload keys: %s", list(payload.keys()))
405
+ logger.debug("Headers: %s", headers)
406
+ try:
407
+ response = requests.post(
408
+ CLAUDE_CODE_OAUTH_CONFIG["token_url"],
409
+ json=payload,
410
+ headers=headers,
411
+ timeout=30,
412
+ )
413
+ logger.info("Token exchange response: %s", response.status_code)
414
+ logger.debug("Response body: %s", response.text)
415
+ if response.status_code == 200:
416
+ content_type = response.headers.get("content-type", "")
417
+ if not content_type.startswith("application/json"):
418
+ logger.error(
419
+ "Token exchange returned non-JSON response (Content-Type: %s): %s",
420
+ content_type,
421
+ response.text[:500],
422
+ )
423
+ return None
424
+ try:
425
+ token_data = response.json()
426
+ except (ValueError, json.JSONDecodeError) as e:
427
+ logger.error("Failed to parse token exchange response as JSON: %s", e)
428
+ return None
429
+ token_data["expires_at"] = _calculate_expires_at(
430
+ token_data.get("expires_in")
431
+ )
432
+ return token_data
433
+ logger.error(
434
+ "Token exchange failed: %s - %s",
435
+ response.status_code,
436
+ response.text,
437
+ )
438
+ except Exception as exc: # pragma: no cover - defensive logging
439
+ logger.error("Token exchange error: %s", exc)
440
+ return None
441
+
442
+
443
+ def filter_latest_claude_models(
444
+ models: List[str], max_per_family: Union[int, Dict[str, int]] = 2
445
+ ) -> List[str]:
446
+ """Filter models to keep the top N latest haiku, sonnet, and opus.
447
+
448
+ Parses model names in the format claude-{family}-{major}-{minor}-{date}
449
+ and returns the top ``max_per_family`` versions of each family
450
+ (haiku, sonnet, opus), sorted newest-first.
451
+
452
+ Args:
453
+ models: List of model name strings to filter.
454
+ max_per_family: Either a single int applied to all families, or a dict
455
+ mapping family name to its limit (e.g. ``{"opus": 3}``). Families
456
+ not present in the dict fall back to ``"default"`` key, or ``2``.
457
+ """
458
+ # Collect all parsed models per family
459
+ # family -> list of (model_name, major, minor, date)
460
+ family_models: Dict[str, List[Tuple[str, int, int, int]]] = {}
461
+
462
+ for model_name in models:
463
+ if model_name == "claude-opus-4-6":
464
+ family_models.setdefault("opus", []).append((model_name, 4, 6, 20260205))
465
+ continue
466
+ if model_name == "claude-sonnet-4-6":
467
+ family_models.setdefault("sonnet", []).append((model_name, 4, 6, 20250610))
468
+ continue
469
+ # Match pattern: claude-{family}-{major}-{minor}-{date}
470
+ # Examples: claude-haiku-3-5-20241022, claude-sonnet-4-5-20250929
471
+ match = re.match(r"claude-(haiku|sonnet|opus)-(\d+)-(\d+)-(\d+)", model_name)
472
+ if not match:
473
+ # Also try pattern with dots: claude-{family}-{major}.{minor}-{date}
474
+ match = re.match(
475
+ r"claude-(haiku|sonnet|opus)-(\d+)\.(\d+)-(\d+)", model_name
476
+ )
477
+
478
+ if not match:
479
+ continue
480
+
481
+ family = match.group(1)
482
+ major = int(match.group(2))
483
+ minor = int(match.group(3))
484
+ date = int(match.group(4))
485
+
486
+ family_models.setdefault(family, []).append((model_name, major, minor, date))
487
+
488
+ # Sort each family descending and keep the top N
489
+ filtered: List[str] = []
490
+ for family, family_entries in family_models.items():
491
+ if isinstance(max_per_family, dict):
492
+ limit = max_per_family.get(family, max_per_family.get("default", 2))
493
+ else:
494
+ limit = max_per_family
495
+ family_entries.sort(key=lambda e: (e[1], e[2], e[3]), reverse=True)
496
+ for entry in family_entries[:limit]:
497
+ filtered.append(entry[0])
498
+
499
+ logger.info(
500
+ "Filtered %d models to %d latest models (max_per_family=%s): %s",
501
+ len(models),
502
+ len(filtered),
503
+ max_per_family,
504
+ filtered,
505
+ )
506
+ return filtered
507
+
508
+
509
+ def fetch_claude_code_models(access_token: str) -> Optional[List[str]]:
510
+ try:
511
+ api_url = f"{CLAUDE_CODE_OAUTH_CONFIG['api_base_url']}/v1/models"
512
+ headers = {
513
+ "Authorization": f"Bearer {access_token}",
514
+ "Content-Type": "application/json",
515
+ "anthropic-beta": "oauth-2025-04-20",
516
+ "anthropic-version": CLAUDE_CODE_OAUTH_CONFIG.get(
517
+ "anthropic_version", "2023-06-01"
518
+ ),
519
+ }
520
+ response = requests.get(api_url, headers=headers, timeout=30)
521
+ if response.status_code == 200:
522
+ content_type = response.headers.get("content-type", "")
523
+ if not content_type.startswith("application/json"):
524
+ logger.error(
525
+ "Models fetch returned non-JSON response (Content-Type: %s): %s",
526
+ content_type,
527
+ response.text[:500],
528
+ )
529
+ return None
530
+ try:
531
+ data = response.json()
532
+ except (ValueError, json.JSONDecodeError) as e:
533
+ logger.error("Failed to parse models response as JSON: %s", e)
534
+ return None
535
+ if isinstance(data.get("data"), list):
536
+ models: List[str] = []
537
+ for model in data["data"]:
538
+ name = model.get("id") or model.get("name")
539
+ if name:
540
+ models.append(name)
541
+ return models
542
+ else:
543
+ logger.error(
544
+ "Failed to fetch models: %s - %s",
545
+ response.status_code,
546
+ response.text,
547
+ )
548
+ except Exception as exc: # pragma: no cover - defensive logging
549
+ logger.error("Error fetching Claude Code models: %s", exc)
550
+ return None
551
+
552
+
553
+ def _build_model_entry(model_name: str, access_token: str, context_length: int) -> dict:
554
+ """Build a single model config entry for claude_models.json."""
555
+ supported_settings = [
556
+ "temperature",
557
+ "extended_thinking",
558
+ "budget_tokens",
559
+ "interleaved_thinking",
560
+ ]
561
+
562
+ # Opus 4-6 models support the effort setting
563
+ lower = model_name.lower()
564
+ if "opus-4-6" in lower or "4-6-opus" in lower:
565
+ supported_settings.append("effort")
566
+
567
+ return {
568
+ "type": "claude_code",
569
+ "name": model_name,
570
+ "custom_endpoint": {
571
+ "url": CLAUDE_CODE_OAUTH_CONFIG["api_base_url"],
572
+ "api_key": access_token,
573
+ "headers": {
574
+ "anthropic-beta": "oauth-2025-04-20,interleaved-thinking-2025-05-14",
575
+ "x-app": "cli",
576
+ "User-Agent": "claude-cli/2.0.61 (external, cli)",
577
+ },
578
+ },
579
+ "context_length": context_length,
580
+ "oauth_source": "claude-code-plugin",
581
+ "supported_settings": supported_settings,
582
+ }
583
+
584
+
585
+ def add_models_to_extra_config(models: List[str]) -> bool:
586
+ try:
587
+ # Filter to only latest haiku, sonnet, and opus models
588
+ filtered_models = filter_latest_claude_models(
589
+ models, max_per_family={"default": 1, "opus": 3}
590
+ )
591
+
592
+ # Start fresh - overwrite the file on every auth instead of loading existing
593
+ claude_models = {}
594
+ added = 0
595
+ access_token = get_valid_access_token() or ""
596
+ prefix = CLAUDE_CODE_OAUTH_CONFIG["prefix"]
597
+ default_ctx = CLAUDE_CODE_OAUTH_CONFIG["default_context_length"]
598
+ long_ctx = CLAUDE_CODE_OAUTH_CONFIG["long_context_length"]
599
+ long_ctx_models = CLAUDE_CODE_OAUTH_CONFIG["long_context_models"]
600
+
601
+ for model_name in filtered_models:
602
+ prefixed = f"{prefix}{model_name}"
603
+ claude_models[prefixed] = _build_model_entry(
604
+ model_name, access_token, default_ctx
605
+ )
606
+ added += 1
607
+
608
+ # Create a "-long" variant with extended context for eligible models
609
+ if model_name in long_ctx_models:
610
+ long_prefixed = f"{prefix}{model_name}-long"
611
+ claude_models[long_prefixed] = _build_model_entry(
612
+ model_name, access_token, long_ctx
613
+ )
614
+ added += 1
615
+
616
+ if save_claude_models(claude_models):
617
+ logger.info("Added %s Claude Code models", added)
618
+ return True
619
+ except Exception as exc: # pragma: no cover - defensive logging
620
+ logger.error("Error adding models to config: %s", exc)
621
+ return False
622
+
623
+
624
+ def remove_claude_code_models() -> int:
625
+ try:
626
+ claude_models = load_claude_models()
627
+ to_remove = [
628
+ name
629
+ for name, config in claude_models.items()
630
+ if config.get("oauth_source") == "claude-code-plugin"
631
+ ]
632
+ if not to_remove:
633
+ return 0
634
+ for model_name in to_remove:
635
+ claude_models.pop(model_name, None)
636
+ if save_claude_models(claude_models):
637
+ return len(to_remove)
638
+ except Exception as exc: # pragma: no cover - defensive logging
639
+ logger.error("Error removing Claude Code models: %s", exc)
640
+ return 0
File without changes