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,848 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import pathlib
5
+ from typing import Any, Dict
6
+
7
+ from anthropic import AsyncAnthropic
8
+ from openai import AsyncAzureOpenAI
9
+ from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
10
+ from pydantic_ai.models.openai import (
11
+ OpenAIChatModel,
12
+ OpenAIChatModelSettings,
13
+ OpenAIResponsesModel,
14
+ )
15
+ from pydantic_ai.profiles import ModelProfile
16
+ from pydantic_ai.providers.anthropic import AnthropicProvider
17
+ from pydantic_ai.providers.cerebras import CerebrasProvider
18
+ from pydantic_ai.providers.openai import OpenAIProvider
19
+ from pydantic_ai.providers.openrouter import OpenRouterProvider
20
+ from pydantic_ai.settings import ModelSettings
21
+
22
+ from code_puppy.gemini_model import GeminiModel
23
+ from code_puppy.messaging import emit_warning
24
+
25
+ from . import callbacks
26
+ from .claude_cache_client import ClaudeCacheAsyncClient, patch_anthropic_client_messages
27
+ from .config import EXTRA_MODELS_FILE, get_value, get_yolo_mode
28
+ from .http_utils import create_async_client, get_cert_bundle_path, get_http2
29
+ from .round_robin_model import RoundRobinModel
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Registry for custom model provider classes from plugins
34
+ _CUSTOM_MODEL_PROVIDERS: Dict[str, type] = {}
35
+
36
+
37
+ def _load_plugin_model_providers():
38
+ """Load custom model providers from plugins."""
39
+ global _CUSTOM_MODEL_PROVIDERS
40
+ try:
41
+ from code_puppy.callbacks import on_register_model_providers
42
+
43
+ results = on_register_model_providers()
44
+ for result in results:
45
+ if isinstance(result, dict):
46
+ _CUSTOM_MODEL_PROVIDERS.update(result)
47
+ except Exception as e:
48
+ logger.warning("Failed to load plugin model providers: %s", e)
49
+
50
+
51
+ # Load plugin model providers at module initialization
52
+ _load_plugin_model_providers()
53
+
54
+
55
+ # Anthropic beta header required for 1M context window support.
56
+ CONTEXT_1M_BETA = "context-1m-2025-08-07"
57
+
58
+
59
+ def _build_anthropic_beta_header(
60
+ model_config: Dict,
61
+ *,
62
+ interleaved_thinking: bool = False,
63
+ ) -> str | None:
64
+ """Build the anthropic-beta header value for an Anthropic model.
65
+
66
+ Combines beta flags based on model capabilities:
67
+ - interleaved-thinking-2025-05-14 (when interleaved_thinking is enabled)
68
+ - context-1m-2025-08-07 (when context_length >= 1_000_000)
69
+
70
+ Returns None if no beta flags are needed.
71
+ """
72
+ parts: list[str] = []
73
+ if interleaved_thinking:
74
+ parts.append("interleaved-thinking-2025-05-14")
75
+ if model_config.get("context_length", 0) >= 1_000_000:
76
+ parts.append(CONTEXT_1M_BETA)
77
+ return ",".join(parts) if parts else None
78
+
79
+
80
+ def get_api_key(env_var_name: str) -> str | None:
81
+ """Get an API key from config first, then fall back to environment variable.
82
+
83
+ This allows users to set API keys via `/set KIMI_API_KEY=xxx` in addition to
84
+ setting them as environment variables.
85
+
86
+ Args:
87
+ env_var_name: The name of the environment variable (e.g., "OPENAI_API_KEY")
88
+
89
+ Returns:
90
+ The API key value, or None if not found in either config or environment.
91
+ """
92
+ # First check config (case-insensitive key lookup)
93
+ config_value = get_value(env_var_name.lower())
94
+ if config_value:
95
+ return config_value
96
+
97
+ # Fall back to environment variable
98
+ return os.environ.get(env_var_name)
99
+
100
+
101
+ def make_model_settings(
102
+ model_name: str, max_tokens: int | None = None
103
+ ) -> ModelSettings:
104
+ """Create appropriate ModelSettings for a given model.
105
+
106
+ This handles model-specific settings:
107
+ - GPT-5 models: reasoning_effort and verbosity (non-codex only)
108
+ - Claude/Anthropic models: extended_thinking and budget_tokens
109
+ - Automatic max_tokens calculation based on model context length
110
+
111
+ Args:
112
+ model_name: The name of the model to create settings for.
113
+ max_tokens: Optional max tokens limit. If None, automatically calculated
114
+ as: max(2048, min(15% of context_length, 65536))
115
+
116
+ Returns:
117
+ Appropriate ModelSettings subclass instance for the model.
118
+ """
119
+ from code_puppy.config import (
120
+ get_effective_model_settings,
121
+ get_openai_reasoning_effort,
122
+ get_openai_verbosity,
123
+ model_supports_setting,
124
+ )
125
+
126
+ model_settings_dict: dict = {}
127
+
128
+ # Calculate max_tokens if not explicitly provided
129
+ if max_tokens is None:
130
+ # Load model config to get context length
131
+ try:
132
+ models_config = ModelFactory.load_config()
133
+ model_config = models_config.get(model_name, {})
134
+ context_length = model_config.get("context_length", 128000)
135
+ except Exception:
136
+ # Fallback if config loading fails (e.g., in CI environments)
137
+ context_length = 128000
138
+ # min 2048, 15% of context, max 65536
139
+ max_tokens = max(2048, min(int(0.15 * context_length), 65536))
140
+
141
+ model_settings_dict["max_tokens"] = max_tokens
142
+ effective_settings = get_effective_model_settings(model_name)
143
+ model_settings_dict.update(effective_settings)
144
+
145
+ # Disable parallel tool calls when yolo_mode is off (sequential so user can review each call)
146
+ if not get_yolo_mode():
147
+ model_settings_dict["parallel_tool_calls"] = False
148
+
149
+ # Default to clear_thinking=False for GLM-4.7 and GLM-5 models (preserved thinking)
150
+ if "glm-4.7" in model_name.lower() or "glm-5" in model_name.lower():
151
+ clear_thinking = effective_settings.get("clear_thinking", False)
152
+ model_settings_dict["thinking"] = {
153
+ "type": "enabled",
154
+ "clear_thinking": clear_thinking,
155
+ }
156
+
157
+ model_settings: ModelSettings = ModelSettings(**model_settings_dict)
158
+
159
+ if "gpt-5" in model_name:
160
+ model_settings_dict["openai_reasoning_effort"] = get_openai_reasoning_effort()
161
+ # Verbosity only applies to non-codex GPT-5 models (codex only supports "medium")
162
+ if "codex" not in model_name:
163
+ verbosity = get_openai_verbosity()
164
+ model_settings_dict["extra_body"] = {"verbosity": verbosity}
165
+ model_settings = OpenAIChatModelSettings(**model_settings_dict)
166
+ elif model_name.startswith("claude-") or model_name.startswith("anthropic-"):
167
+ # Handle Anthropic extended thinking settings
168
+ # Remove top_p as Anthropic doesn't support it with extended thinking
169
+ model_settings_dict.pop("top_p", None)
170
+
171
+ # Claude extended thinking requires temperature=1.0 (API restriction)
172
+ # Default to 1.0 if not explicitly set by user
173
+ if model_settings_dict.get("temperature") is None:
174
+ model_settings_dict["temperature"] = 1.0
175
+
176
+ from code_puppy.model_utils import get_default_extended_thinking
177
+
178
+ default_thinking = get_default_extended_thinking(model_name)
179
+ extended_thinking = effective_settings.get(
180
+ "extended_thinking", default_thinking
181
+ )
182
+ # Backwards compat: handle legacy boolean values
183
+ if extended_thinking is True:
184
+ extended_thinking = "enabled"
185
+ elif extended_thinking is False:
186
+ extended_thinking = "off"
187
+
188
+ budget_tokens = effective_settings.get("budget_tokens", 10000)
189
+ if extended_thinking in ("enabled", "adaptive"):
190
+ model_settings_dict["anthropic_thinking"] = {
191
+ "type": extended_thinking,
192
+ }
193
+ # Only send budget_tokens for classic "enabled" mode
194
+ if extended_thinking == "enabled" and budget_tokens:
195
+ model_settings_dict["anthropic_thinking"]["budget_tokens"] = (
196
+ budget_tokens
197
+ )
198
+
199
+ # Opus 4-6 models support the `effort` setting via output_config.
200
+ # pydantic-ai doesn't have a native field for output_config yet,
201
+ # so we inject it through extra_body which gets merged into the
202
+ # HTTP request body.
203
+ if model_supports_setting(model_name, "effort"):
204
+ effort = effective_settings.get("effort", "high")
205
+ if "anthropic_thinking" in model_settings_dict:
206
+ extra_body = model_settings_dict.get("extra_body") or {}
207
+ extra_body["output_config"] = {"effort": effort}
208
+ model_settings_dict["extra_body"] = extra_body
209
+
210
+ model_settings = AnthropicModelSettings(**model_settings_dict)
211
+
212
+ # Handle Gemini thinking models (Gemini-3)
213
+ # Check if model supports thinking settings and apply defaults
214
+ if model_supports_setting(model_name, "thinking_level"):
215
+ # Apply defaults if not explicitly set by user
216
+ # Default: thinking_enabled=True, thinking_level="low"
217
+ if "thinking_enabled" not in model_settings_dict:
218
+ model_settings_dict["thinking_enabled"] = True
219
+ if "thinking_level" not in model_settings_dict:
220
+ model_settings_dict["thinking_level"] = "low"
221
+ # Recreate settings with Gemini thinking config
222
+ model_settings = ModelSettings(**model_settings_dict)
223
+
224
+ return model_settings
225
+
226
+
227
+ class ZaiChatModel(OpenAIChatModel):
228
+ def _process_response(self, response):
229
+ response.object = "chat.completion"
230
+ return super()._process_response(response)
231
+
232
+
233
+ def get_custom_config(model_config):
234
+ custom_config = model_config.get("custom_endpoint", {})
235
+ if not custom_config:
236
+ raise ValueError("Custom model requires 'custom_endpoint' configuration")
237
+
238
+ url = custom_config.get("url")
239
+ if not url:
240
+ raise ValueError("Custom endpoint requires 'url' field")
241
+
242
+ headers = {}
243
+ for key, value in custom_config.get("headers", {}).items():
244
+ if value.startswith("$"):
245
+ env_var_name = value[1:]
246
+ resolved_value = get_api_key(env_var_name)
247
+ if resolved_value is None:
248
+ emit_warning(
249
+ f"'{env_var_name}' is not set (check config or environment) for custom endpoint header '{key}'. Proceeding with empty value."
250
+ )
251
+ resolved_value = ""
252
+ value = resolved_value
253
+ elif "$" in value:
254
+ tokens = value.split(" ")
255
+ resolved_values = []
256
+ for token in tokens:
257
+ if token.startswith("$"):
258
+ env_var = token[1:]
259
+ resolved_value = get_api_key(env_var)
260
+ if resolved_value is None:
261
+ emit_warning(
262
+ f"'{env_var}' is not set (check config or environment) for custom endpoint header '{key}'. Proceeding with empty value."
263
+ )
264
+ resolved_values.append("")
265
+ else:
266
+ resolved_values.append(resolved_value)
267
+ else:
268
+ resolved_values.append(token)
269
+ value = " ".join(resolved_values)
270
+ headers[key] = value
271
+ api_key = None
272
+ if "api_key" in custom_config:
273
+ if custom_config["api_key"].startswith("$"):
274
+ env_var_name = custom_config["api_key"][1:]
275
+ api_key = get_api_key(env_var_name)
276
+ if api_key is None:
277
+ emit_warning(
278
+ f"API key '{env_var_name}' is not set (checked config and environment); proceeding without API key."
279
+ )
280
+ else:
281
+ api_key = custom_config["api_key"]
282
+ if "ca_certs_path" in custom_config:
283
+ verify = custom_config["ca_certs_path"]
284
+ else:
285
+ verify = None
286
+ return url, headers, verify, api_key
287
+
288
+
289
+ class ModelFactory:
290
+ """A factory for creating and managing different AI models."""
291
+
292
+ @staticmethod
293
+ def load_config() -> Dict[str, Any]:
294
+ load_model_config_callbacks = callbacks.get_callbacks("load_model_config")
295
+ if len(load_model_config_callbacks) > 0:
296
+ if len(load_model_config_callbacks) > 1:
297
+ logging.getLogger(__name__).warning(
298
+ "Multiple load_model_config callbacks registered, using the first"
299
+ )
300
+ config = callbacks.on_load_model_config()[0]
301
+ else:
302
+ # Always load from the bundled models.json so upstream
303
+ # updates propagate automatically. User additions belong
304
+ # in extra_models.json (overlay loaded below).
305
+ bundled_models = pathlib.Path(__file__).parent / "models.json"
306
+ with open(bundled_models, "r") as f:
307
+ config = json.load(f)
308
+
309
+ # Import OAuth model file paths from main config
310
+ from code_puppy.config import (
311
+ ANTIGRAVITY_MODELS_FILE,
312
+ CHATGPT_MODELS_FILE,
313
+ CLAUDE_MODELS_FILE,
314
+ GEMINI_MODELS_FILE,
315
+ )
316
+
317
+ # Build list of extra model sources
318
+ extra_sources: list[tuple[pathlib.Path, str, bool]] = [
319
+ (pathlib.Path(EXTRA_MODELS_FILE), "extra models", False),
320
+ (pathlib.Path(CHATGPT_MODELS_FILE), "ChatGPT OAuth models", False),
321
+ (pathlib.Path(CLAUDE_MODELS_FILE), "Claude Code OAuth models", True),
322
+ (pathlib.Path(GEMINI_MODELS_FILE), "Gemini OAuth models", False),
323
+ (pathlib.Path(ANTIGRAVITY_MODELS_FILE), "Antigravity OAuth models", False),
324
+ ]
325
+
326
+ for source_path, label, use_filtered in extra_sources:
327
+ if not source_path.exists():
328
+ continue
329
+ try:
330
+ # Use filtered loading for Claude Code OAuth models to show only latest versions
331
+ if use_filtered:
332
+ try:
333
+ from code_puppy.plugins.claude_code_oauth.utils import (
334
+ load_claude_models_filtered,
335
+ )
336
+
337
+ extra_config = load_claude_models_filtered()
338
+ except ImportError:
339
+ # Plugin not available, fall back to standard JSON loading
340
+ logging.getLogger(__name__).debug(
341
+ f"claude_code_oauth plugin not available, loading {label} as plain JSON"
342
+ )
343
+ with open(source_path, "r") as f:
344
+ extra_config = json.load(f)
345
+ else:
346
+ with open(source_path, "r") as f:
347
+ extra_config = json.load(f)
348
+ config.update(extra_config)
349
+ except json.JSONDecodeError as exc:
350
+ logging.getLogger(__name__).warning(
351
+ f"Failed to load {label} config from {source_path}: Invalid JSON - {exc}"
352
+ )
353
+ except Exception as exc:
354
+ logging.getLogger(__name__).warning(
355
+ f"Failed to load {label} config from {source_path}: {exc}"
356
+ )
357
+
358
+ # Let plugins add/override models via load_models_config hook
359
+ try:
360
+ from code_puppy.callbacks import on_load_models_config
361
+
362
+ results = on_load_models_config()
363
+ for result in results:
364
+ if isinstance(result, dict):
365
+ config.update(result) # Plugin models override built-in
366
+ except Exception as exc:
367
+ logging.getLogger(__name__).debug(
368
+ f"Failed to load plugin models config: {exc}"
369
+ )
370
+
371
+ return config
372
+
373
+ @staticmethod
374
+ def get_model(model_name: str, config: Dict[str, Any]) -> Any:
375
+ """Returns a configured model instance based on the provided name and config.
376
+
377
+ API key validation happens naturally within each model type's initialization,
378
+ which emits warnings and returns None if keys are missing.
379
+ """
380
+ model_config = config.get(model_name)
381
+ if not model_config:
382
+ raise ValueError(f"Model '{model_name}' not found in configuration.")
383
+
384
+ model_type = model_config.get("type")
385
+
386
+ # Check for plugin-registered model provider classes first
387
+ if model_type in _CUSTOM_MODEL_PROVIDERS:
388
+ provider_class = _CUSTOM_MODEL_PROVIDERS[model_type]
389
+ try:
390
+ return provider_class(
391
+ model_name=model_name, model_config=model_config, config=config
392
+ )
393
+ except Exception as e:
394
+ logger.error(f"Custom model provider '{model_type}' failed: {e}")
395
+ return None
396
+
397
+ if model_type == "gemini":
398
+ api_key = get_api_key("GEMINI_API_KEY")
399
+ if not api_key:
400
+ emit_warning(
401
+ f"GEMINI_API_KEY is not set (check config or environment); skipping Gemini model '{model_config.get('name')}'."
402
+ )
403
+ return None
404
+
405
+ model = GeminiModel(model_name=model_config["name"], api_key=api_key)
406
+ return model
407
+
408
+ elif model_type == "openai":
409
+ api_key = get_api_key("OPENAI_API_KEY")
410
+ if not api_key:
411
+ emit_warning(
412
+ f"OPENAI_API_KEY is not set (check config or environment); skipping OpenAI model '{model_config.get('name')}'."
413
+ )
414
+ return None
415
+
416
+ provider = OpenAIProvider(api_key=api_key)
417
+ model = OpenAIChatModel(model_name=model_config["name"], provider=provider)
418
+ if "codex" in model_name:
419
+ model = OpenAIResponsesModel(
420
+ model_name=model_config["name"], provider=provider
421
+ )
422
+ model.provider = provider
423
+ return model
424
+
425
+ elif model_type == "anthropic":
426
+ api_key = get_api_key("ANTHROPIC_API_KEY")
427
+ if not api_key:
428
+ emit_warning(
429
+ f"ANTHROPIC_API_KEY is not set (check config or environment); skipping Anthropic model '{model_config.get('name')}'."
430
+ )
431
+ return None
432
+
433
+ # Use the same caching client as claude_code models
434
+ verify = get_cert_bundle_path()
435
+ http2_enabled = get_http2()
436
+
437
+ client = ClaudeCacheAsyncClient(
438
+ verify=verify,
439
+ timeout=180,
440
+ http2=http2_enabled,
441
+ )
442
+
443
+ # Check if interleaved thinking is enabled for this model
444
+ # Only applies to Claude 4 models (Opus 4.5, Opus 4.1, Opus 4, Sonnet 4)
445
+ from code_puppy.config import get_effective_model_settings
446
+
447
+ effective_settings = get_effective_model_settings(model_name)
448
+ interleaved_thinking = effective_settings.get("interleaved_thinking", False)
449
+
450
+ beta_header = _build_anthropic_beta_header(
451
+ model_config, interleaved_thinking=interleaved_thinking
452
+ )
453
+ default_headers = {}
454
+ if beta_header:
455
+ default_headers["anthropic-beta"] = beta_header
456
+
457
+ anthropic_client = AsyncAnthropic(
458
+ api_key=api_key,
459
+ http_client=client,
460
+ default_headers=default_headers if default_headers else None,
461
+ )
462
+
463
+ # Ensure cache_control is injected at the Anthropic SDK layer
464
+ patch_anthropic_client_messages(anthropic_client)
465
+
466
+ provider = AnthropicProvider(anthropic_client=anthropic_client)
467
+ return AnthropicModel(model_name=model_config["name"], provider=provider)
468
+
469
+ elif model_type == "custom_anthropic":
470
+ url, headers, verify, api_key = get_custom_config(model_config)
471
+ if not api_key:
472
+ emit_warning(
473
+ f"API key is not set for custom Anthropic endpoint; skipping model '{model_config.get('name')}'."
474
+ )
475
+ return None
476
+
477
+ # Use the same caching client as claude_code models
478
+ if verify is None:
479
+ verify = get_cert_bundle_path()
480
+
481
+ http2_enabled = get_http2()
482
+
483
+ client = ClaudeCacheAsyncClient(
484
+ headers=headers,
485
+ verify=verify,
486
+ timeout=180,
487
+ http2=http2_enabled,
488
+ )
489
+
490
+ # Check if interleaved thinking is enabled for this model
491
+ from code_puppy.config import get_effective_model_settings
492
+
493
+ effective_settings = get_effective_model_settings(model_name)
494
+ interleaved_thinking = effective_settings.get("interleaved_thinking", False)
495
+
496
+ beta_header = _build_anthropic_beta_header(
497
+ model_config, interleaved_thinking=interleaved_thinking
498
+ )
499
+ default_headers = {}
500
+ if beta_header:
501
+ default_headers["anthropic-beta"] = beta_header
502
+
503
+ anthropic_client = AsyncAnthropic(
504
+ base_url=url,
505
+ http_client=client,
506
+ api_key=api_key,
507
+ default_headers=default_headers if default_headers else None,
508
+ )
509
+
510
+ # Ensure cache_control is injected at the Anthropic SDK layer
511
+ patch_anthropic_client_messages(anthropic_client)
512
+
513
+ provider = AnthropicProvider(anthropic_client=anthropic_client)
514
+ return AnthropicModel(model_name=model_config["name"], provider=provider)
515
+ # NOTE: 'claude_code' model type is now handled by the claude_code_oauth plugin
516
+ # via the register_model_type callback. See plugins/claude_code_oauth/register_callbacks.py
517
+
518
+ elif model_type == "azure_openai":
519
+ azure_endpoint_config = model_config.get("azure_endpoint")
520
+ if not azure_endpoint_config:
521
+ raise ValueError(
522
+ "Azure OpenAI model type requires 'azure_endpoint' in its configuration."
523
+ )
524
+ azure_endpoint = azure_endpoint_config
525
+ if azure_endpoint_config.startswith("$"):
526
+ azure_endpoint = get_api_key(azure_endpoint_config[1:])
527
+ if not azure_endpoint:
528
+ emit_warning(
529
+ f"Azure OpenAI endpoint '{azure_endpoint_config[1:] if azure_endpoint_config.startswith('$') else azure_endpoint_config}' not found (check config or environment); skipping model '{model_config.get('name')}'."
530
+ )
531
+ return None
532
+
533
+ api_version_config = model_config.get("api_version")
534
+ if not api_version_config:
535
+ raise ValueError(
536
+ "Azure OpenAI model type requires 'api_version' in its configuration."
537
+ )
538
+ api_version = api_version_config
539
+ if api_version_config.startswith("$"):
540
+ api_version = get_api_key(api_version_config[1:])
541
+ if not api_version:
542
+ emit_warning(
543
+ f"Azure OpenAI API version '{api_version_config[1:] if api_version_config.startswith('$') else api_version_config}' not found (check config or environment); skipping model '{model_config.get('name')}'."
544
+ )
545
+ return None
546
+
547
+ api_key_config = model_config.get("api_key")
548
+ if not api_key_config:
549
+ raise ValueError(
550
+ "Azure OpenAI model type requires 'api_key' in its configuration."
551
+ )
552
+ api_key = api_key_config
553
+ if api_key_config.startswith("$"):
554
+ api_key = get_api_key(api_key_config[1:])
555
+ if not api_key:
556
+ emit_warning(
557
+ f"Azure OpenAI API key '{api_key_config[1:] if api_key_config.startswith('$') else api_key_config}' not found (check config or environment); skipping model '{model_config.get('name')}'."
558
+ )
559
+ return None
560
+
561
+ # Configure max_retries for the Azure client, defaulting if not specified in config
562
+ azure_max_retries = model_config.get("max_retries", 2)
563
+
564
+ azure_client = AsyncAzureOpenAI(
565
+ azure_endpoint=azure_endpoint,
566
+ api_version=api_version,
567
+ api_key=api_key,
568
+ max_retries=azure_max_retries,
569
+ )
570
+ provider = OpenAIProvider(openai_client=azure_client)
571
+ model = OpenAIChatModel(model_name=model_config["name"], provider=provider)
572
+ model.provider = provider
573
+ return model
574
+
575
+ elif model_type == "custom_openai":
576
+ url, headers, verify, api_key = get_custom_config(model_config)
577
+ client = create_async_client(headers=headers, verify=verify)
578
+ provider_args = dict(
579
+ base_url=url,
580
+ http_client=client,
581
+ )
582
+ if api_key:
583
+ provider_args["api_key"] = api_key
584
+ provider = OpenAIProvider(**provider_args)
585
+ model = OpenAIChatModel(model_name=model_config["name"], provider=provider)
586
+ if model_name == "chatgpt-gpt-5-codex":
587
+ model = OpenAIResponsesModel(model_config["name"], provider=provider)
588
+ model.provider = provider
589
+ return model
590
+ elif model_type == "zai_coding":
591
+ api_key = get_api_key("ZAI_API_KEY")
592
+ if not api_key:
593
+ emit_warning(
594
+ f"ZAI_API_KEY is not set (check config or environment); skipping ZAI coding model '{model_config.get('name')}'."
595
+ )
596
+ return None
597
+ provider = OpenAIProvider(
598
+ api_key=api_key,
599
+ base_url="https://api.z.ai/api/coding/paas/v4",
600
+ )
601
+ zai_model = ZaiChatModel(
602
+ model_name=model_config["name"],
603
+ provider=provider,
604
+ )
605
+ zai_model.provider = provider
606
+ return zai_model
607
+ elif model_type == "zai_api":
608
+ api_key = get_api_key("ZAI_API_KEY")
609
+ if not api_key:
610
+ emit_warning(
611
+ f"ZAI_API_KEY is not set (check config or environment); skipping ZAI API model '{model_config.get('name')}'."
612
+ )
613
+ return None
614
+ provider = OpenAIProvider(
615
+ api_key=api_key,
616
+ base_url="https://api.z.ai/api/paas/v4/",
617
+ )
618
+ zai_model = ZaiChatModel(
619
+ model_name=model_config["name"],
620
+ provider=provider,
621
+ )
622
+ zai_model.provider = provider
623
+ return zai_model
624
+ # NOTE: 'antigravity' model type is now handled by the antigravity_oauth plugin
625
+ # via the register_model_type callback. See plugins/antigravity_oauth/register_callbacks.py
626
+
627
+ elif model_type == "custom_gemini":
628
+ # Backwards compatibility: delegate to antigravity plugin if antigravity flag is set
629
+ # New configs use type="antigravity" directly, but old configs may have
630
+ # type="custom_gemini" with antigravity=True
631
+ if model_config.get("antigravity"):
632
+ # Find and call the antigravity handler from the plugin
633
+ registered_handlers = callbacks.on_register_model_types()
634
+ for handler_info in registered_handlers:
635
+ handlers = (
636
+ handler_info
637
+ if isinstance(handler_info, list)
638
+ else [handler_info]
639
+ if handler_info
640
+ else []
641
+ )
642
+ for handler_entry in handlers:
643
+ if (
644
+ isinstance(handler_entry, dict)
645
+ and handler_entry.get("type") == "antigravity"
646
+ ):
647
+ handler = handler_entry.get("handler")
648
+ if callable(handler):
649
+ try:
650
+ return handler(model_name, model_config, config)
651
+ except Exception as e:
652
+ logger.error(f"Antigravity handler failed: {e}")
653
+ return None
654
+ # If no antigravity handler found, warn and fall through
655
+ emit_warning(
656
+ f"Model '{model_config.get('name')}' has antigravity=True but antigravity plugin not loaded."
657
+ )
658
+ return None
659
+
660
+ url, headers, verify, api_key = get_custom_config(model_config)
661
+ if not api_key:
662
+ emit_warning(
663
+ f"API key is not set for custom Gemini endpoint; skipping model '{model_config.get('name')}'."
664
+ )
665
+ return None
666
+
667
+ client = create_async_client(headers=headers, verify=verify)
668
+ model = GeminiModel(
669
+ model_name=model_config["name"],
670
+ api_key=api_key,
671
+ base_url=url,
672
+ http_client=client,
673
+ )
674
+ return model
675
+ elif model_type == "cerebras":
676
+
677
+ class ZaiCerebrasProvider(CerebrasProvider):
678
+ def model_profile(self, model_name: str) -> ModelProfile | None:
679
+ profile = super().model_profile(model_name)
680
+ if model_name.startswith("zai"):
681
+ from pydantic_ai.profiles.qwen import qwen_model_profile
682
+
683
+ profile = profile.update(qwen_model_profile("qwen-3-coder"))
684
+ return profile
685
+
686
+ url, headers, verify, api_key = get_custom_config(model_config)
687
+ if not api_key:
688
+ emit_warning(
689
+ f"API key is not set for Cerebras endpoint; skipping model '{model_config.get('name')}'."
690
+ )
691
+ return None
692
+ # Add Cerebras 3rd party integration header
693
+ headers["X-Cerebras-3rd-Party-Integration"] = "code-puppy"
694
+ # Pass "cerebras" so RetryingAsyncClient knows to ignore Cerebras's
695
+ # absurdly aggressive Retry-After headers (they send 60s!)
696
+ # Note: model_config["name"] is "zai-glm-4.7", not "cerebras"
697
+ client = create_async_client(
698
+ headers=headers, verify=verify, model_name="cerebras"
699
+ )
700
+ provider_args = dict(
701
+ api_key=api_key,
702
+ http_client=client,
703
+ )
704
+ provider = ZaiCerebrasProvider(**provider_args)
705
+
706
+ model = OpenAIChatModel(model_name=model_config["name"], provider=provider)
707
+ model.provider = provider
708
+ return model
709
+
710
+ elif model_type == "openrouter":
711
+ # Get API key from config, which can be an environment variable reference or raw value
712
+ api_key_config = model_config.get("api_key")
713
+ api_key = None
714
+
715
+ if api_key_config:
716
+ if api_key_config.startswith("$"):
717
+ # It's an environment variable reference
718
+ env_var_name = api_key_config[1:] # Remove the $ prefix
719
+ api_key = get_api_key(env_var_name)
720
+ if api_key is None:
721
+ emit_warning(
722
+ f"OpenRouter API key '{env_var_name}' not found (check config or environment); skipping model '{model_config.get('name')}'."
723
+ )
724
+ return None
725
+ else:
726
+ # It's a raw API key value
727
+ api_key = api_key_config
728
+ else:
729
+ # No API key in config, try to get it from config or the default environment variable
730
+ api_key = get_api_key("OPENROUTER_API_KEY")
731
+ if api_key is None:
732
+ emit_warning(
733
+ f"OPENROUTER_API_KEY is not set (check config or environment); skipping OpenRouter model '{model_config.get('name')}'."
734
+ )
735
+ return None
736
+
737
+ provider = OpenRouterProvider(api_key=api_key)
738
+
739
+ model = OpenAIChatModel(model_name=model_config["name"], provider=provider)
740
+ model.provider = provider
741
+ return model
742
+
743
+ elif model_type == "gemini_oauth":
744
+ # Gemini OAuth models use the Code Assist API (cloudcode-pa.googleapis.com)
745
+ # This is a different API than the standard Generative Language API
746
+ try:
747
+ # Try user plugin first, then built-in plugin
748
+ try:
749
+ from gemini_oauth.config import GEMINI_OAUTH_CONFIG
750
+ from gemini_oauth.utils import (
751
+ get_project_id,
752
+ get_valid_access_token,
753
+ )
754
+ except ImportError:
755
+ from code_puppy.plugins.gemini_oauth.config import (
756
+ GEMINI_OAUTH_CONFIG,
757
+ )
758
+ from code_puppy.plugins.gemini_oauth.utils import (
759
+ get_project_id,
760
+ get_valid_access_token,
761
+ )
762
+ except ImportError as exc:
763
+ emit_warning(
764
+ f"Gemini OAuth plugin not available; skipping model '{model_config.get('name')}'. "
765
+ f"Error: {exc}"
766
+ )
767
+ return None
768
+
769
+ # Get a valid access token (refreshing if needed)
770
+ access_token = get_valid_access_token()
771
+ if not access_token:
772
+ emit_warning(
773
+ f"Failed to get valid Gemini OAuth token; skipping model '{model_config.get('name')}'. "
774
+ "Run /gemini-auth to re-authenticate."
775
+ )
776
+ return None
777
+
778
+ # Get project ID from stored tokens
779
+ project_id = get_project_id()
780
+ if not project_id:
781
+ emit_warning(
782
+ f"No Code Assist project ID found; skipping model '{model_config.get('name')}'. "
783
+ "Run /gemini-auth to re-authenticate."
784
+ )
785
+ return None
786
+
787
+ # Import the Code Assist model wrapper
788
+ from code_puppy.gemini_code_assist import GeminiCodeAssistModel
789
+
790
+ # Create the Code Assist model
791
+ model = GeminiCodeAssistModel(
792
+ model_name=model_config["name"],
793
+ access_token=access_token,
794
+ project_id=project_id,
795
+ api_base_url=GEMINI_OAUTH_CONFIG["api_base_url"],
796
+ api_version=GEMINI_OAUTH_CONFIG["api_version"],
797
+ )
798
+ return model
799
+
800
+ # NOTE: 'chatgpt_oauth' model type is now handled by the chatgpt_oauth plugin
801
+ # via the register_model_type callback. See plugins/chatgpt_oauth/register_callbacks.py
802
+
803
+ elif model_type == "round_robin":
804
+ # Get the list of model names to use in the round-robin
805
+ model_names = model_config.get("models")
806
+ if not model_names or not isinstance(model_names, list):
807
+ raise ValueError(
808
+ f"Round-robin model '{model_name}' requires a 'models' list in its configuration."
809
+ )
810
+
811
+ # Get the rotate_every parameter (default: 1)
812
+ rotate_every = model_config.get("rotate_every", 1)
813
+
814
+ # Resolve each model name to an actual model instance
815
+ models = []
816
+ for name in model_names:
817
+ # Recursively get each model using the factory
818
+ model = ModelFactory.get_model(name, config)
819
+ models.append(model)
820
+
821
+ # Create and return the round-robin model
822
+ return RoundRobinModel(*models, rotate_every=rotate_every)
823
+
824
+ else:
825
+ # Check for plugin-registered model type handlers
826
+ registered_handlers = callbacks.on_register_model_types()
827
+ for handler_info in registered_handlers:
828
+ # Handler info can be a list of dicts or a single dict
829
+ if isinstance(handler_info, list):
830
+ handlers = handler_info
831
+ else:
832
+ handlers = [handler_info] if handler_info else []
833
+
834
+ for handler_entry in handlers:
835
+ if not isinstance(handler_entry, dict):
836
+ continue
837
+ if handler_entry.get("type") == model_type:
838
+ handler = handler_entry.get("handler")
839
+ if callable(handler):
840
+ try:
841
+ return handler(model_name, model_config, config)
842
+ except Exception as e:
843
+ logger.error(
844
+ f"Plugin handler for model type '{model_type}' failed: {e}"
845
+ )
846
+ return None
847
+
848
+ raise ValueError(f"Unsupported model type: {model_type}")