code-puppy 0.0.214__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.
Files changed (231) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +2 -0
  3. code_puppy/agents/agent_c_reviewer.py +59 -6
  4. code_puppy/agents/agent_code_puppy.py +7 -1
  5. code_puppy/agents/agent_code_reviewer.py +12 -2
  6. code_puppy/agents/agent_cpp_reviewer.py +73 -6
  7. code_puppy/agents/agent_creator_agent.py +45 -4
  8. code_puppy/agents/agent_golang_reviewer.py +92 -3
  9. code_puppy/agents/agent_javascript_reviewer.py +101 -8
  10. code_puppy/agents/agent_manager.py +81 -4
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +28 -6
  15. code_puppy/agents/agent_qa_expert.py +98 -6
  16. code_puppy/agents/agent_qa_kitten.py +12 -7
  17. code_puppy/agents/agent_security_auditor.py +113 -3
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +106 -7
  20. code_puppy/agents/base_agent.py +802 -176
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/pack/__init__.py +34 -0
  23. code_puppy/agents/pack/bloodhound.py +304 -0
  24. code_puppy/agents/pack/husky.py +321 -0
  25. code_puppy/agents/pack/retriever.py +393 -0
  26. code_puppy/agents/pack/shepherd.py +348 -0
  27. code_puppy/agents/pack/terrier.py +287 -0
  28. code_puppy/agents/pack/watchdog.py +367 -0
  29. code_puppy/agents/prompt_reviewer.py +145 -0
  30. code_puppy/agents/subagent_stream_handler.py +276 -0
  31. code_puppy/api/__init__.py +13 -0
  32. code_puppy/api/app.py +169 -0
  33. code_puppy/api/main.py +21 -0
  34. code_puppy/api/pty_manager.py +446 -0
  35. code_puppy/api/routers/__init__.py +12 -0
  36. code_puppy/api/routers/agents.py +36 -0
  37. code_puppy/api/routers/commands.py +217 -0
  38. code_puppy/api/routers/config.py +74 -0
  39. code_puppy/api/routers/sessions.py +232 -0
  40. code_puppy/api/templates/terminal.html +361 -0
  41. code_puppy/api/websocket.py +154 -0
  42. code_puppy/callbacks.py +142 -4
  43. code_puppy/chatgpt_codex_client.py +283 -0
  44. code_puppy/claude_cache_client.py +586 -0
  45. code_puppy/cli_runner.py +916 -0
  46. code_puppy/command_line/add_model_menu.py +1079 -0
  47. code_puppy/command_line/agent_menu.py +395 -0
  48. code_puppy/command_line/attachments.py +10 -5
  49. code_puppy/command_line/autosave_menu.py +605 -0
  50. code_puppy/command_line/clipboard.py +527 -0
  51. code_puppy/command_line/colors_menu.py +520 -0
  52. code_puppy/command_line/command_handler.py +176 -738
  53. code_puppy/command_line/command_registry.py +150 -0
  54. code_puppy/command_line/config_commands.py +715 -0
  55. code_puppy/command_line/core_commands.py +792 -0
  56. code_puppy/command_line/diff_menu.py +863 -0
  57. code_puppy/command_line/load_context_completion.py +15 -22
  58. code_puppy/command_line/mcp/base.py +0 -3
  59. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  60. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  61. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  62. code_puppy/command_line/mcp/edit_command.py +148 -0
  63. code_puppy/command_line/mcp/handler.py +9 -4
  64. code_puppy/command_line/mcp/help_command.py +6 -5
  65. code_puppy/command_line/mcp/install_command.py +15 -26
  66. code_puppy/command_line/mcp/install_menu.py +685 -0
  67. code_puppy/command_line/mcp/list_command.py +2 -2
  68. code_puppy/command_line/mcp/logs_command.py +174 -65
  69. code_puppy/command_line/mcp/remove_command.py +2 -2
  70. code_puppy/command_line/mcp/restart_command.py +12 -4
  71. code_puppy/command_line/mcp/search_command.py +16 -10
  72. code_puppy/command_line/mcp/start_all_command.py +18 -6
  73. code_puppy/command_line/mcp/start_command.py +47 -25
  74. code_puppy/command_line/mcp/status_command.py +4 -5
  75. code_puppy/command_line/mcp/stop_all_command.py +7 -1
  76. code_puppy/command_line/mcp/stop_command.py +8 -4
  77. code_puppy/command_line/mcp/test_command.py +2 -2
  78. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  79. code_puppy/command_line/mcp_completion.py +174 -0
  80. code_puppy/command_line/model_picker_completion.py +75 -25
  81. code_puppy/command_line/model_settings_menu.py +884 -0
  82. code_puppy/command_line/motd.py +14 -8
  83. code_puppy/command_line/onboarding_slides.py +179 -0
  84. code_puppy/command_line/onboarding_wizard.py +340 -0
  85. code_puppy/command_line/pin_command_completion.py +329 -0
  86. code_puppy/command_line/prompt_toolkit_completion.py +463 -63
  87. code_puppy/command_line/session_commands.py +296 -0
  88. code_puppy/command_line/utils.py +54 -0
  89. code_puppy/config.py +898 -112
  90. code_puppy/error_logging.py +118 -0
  91. code_puppy/gemini_code_assist.py +385 -0
  92. code_puppy/gemini_model.py +602 -0
  93. code_puppy/http_utils.py +210 -148
  94. code_puppy/keymap.py +128 -0
  95. code_puppy/main.py +5 -698
  96. code_puppy/mcp_/__init__.py +17 -0
  97. code_puppy/mcp_/async_lifecycle.py +35 -4
  98. code_puppy/mcp_/blocking_startup.py +70 -43
  99. code_puppy/mcp_/captured_stdio_server.py +2 -2
  100. code_puppy/mcp_/config_wizard.py +4 -4
  101. code_puppy/mcp_/dashboard.py +15 -6
  102. code_puppy/mcp_/managed_server.py +65 -38
  103. code_puppy/mcp_/manager.py +146 -52
  104. code_puppy/mcp_/mcp_logs.py +224 -0
  105. code_puppy/mcp_/registry.py +6 -6
  106. code_puppy/mcp_/server_registry_catalog.py +24 -5
  107. code_puppy/messaging/__init__.py +199 -2
  108. code_puppy/messaging/bus.py +610 -0
  109. code_puppy/messaging/commands.py +167 -0
  110. code_puppy/messaging/markdown_patches.py +57 -0
  111. code_puppy/messaging/message_queue.py +17 -48
  112. code_puppy/messaging/messages.py +500 -0
  113. code_puppy/messaging/queue_console.py +1 -24
  114. code_puppy/messaging/renderers.py +43 -146
  115. code_puppy/messaging/rich_renderer.py +1027 -0
  116. code_puppy/messaging/spinner/__init__.py +21 -5
  117. code_puppy/messaging/spinner/console_spinner.py +86 -51
  118. code_puppy/messaging/subagent_console.py +461 -0
  119. code_puppy/model_factory.py +634 -83
  120. code_puppy/model_utils.py +167 -0
  121. code_puppy/models.json +66 -68
  122. code_puppy/models_dev_api.json +1 -0
  123. code_puppy/models_dev_parser.py +592 -0
  124. code_puppy/plugins/__init__.py +164 -10
  125. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  126. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  127. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  128. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  129. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  130. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  131. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  132. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  133. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  134. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  135. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  136. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  137. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  138. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  139. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  140. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  141. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  142. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  143. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  144. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  145. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  146. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  147. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  148. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  149. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  150. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  151. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  152. code_puppy/plugins/example_custom_command/README.md +280 -0
  153. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  154. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  155. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  156. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  157. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  158. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  159. code_puppy/plugins/oauth_puppy_html.py +228 -0
  160. code_puppy/plugins/shell_safety/__init__.py +6 -0
  161. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  162. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  163. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  164. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  165. code_puppy/prompts/codex_system_prompt.md +310 -0
  166. code_puppy/pydantic_patches.py +131 -0
  167. code_puppy/reopenable_async_client.py +8 -8
  168. code_puppy/round_robin_model.py +9 -12
  169. code_puppy/session_storage.py +2 -1
  170. code_puppy/status_display.py +21 -4
  171. code_puppy/summarization_agent.py +41 -13
  172. code_puppy/terminal_utils.py +418 -0
  173. code_puppy/tools/__init__.py +37 -1
  174. code_puppy/tools/agent_tools.py +536 -52
  175. code_puppy/tools/browser/__init__.py +37 -0
  176. code_puppy/tools/browser/browser_control.py +19 -23
  177. code_puppy/tools/browser/browser_interactions.py +41 -48
  178. code_puppy/tools/browser/browser_locators.py +36 -38
  179. code_puppy/tools/browser/browser_manager.py +316 -0
  180. code_puppy/tools/browser/browser_navigation.py +16 -16
  181. code_puppy/tools/browser/browser_screenshot.py +79 -143
  182. code_puppy/tools/browser/browser_scripts.py +32 -42
  183. code_puppy/tools/browser/browser_workflows.py +44 -27
  184. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  185. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  186. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  187. code_puppy/tools/browser/terminal_tools.py +525 -0
  188. code_puppy/tools/command_runner.py +930 -147
  189. code_puppy/tools/common.py +1113 -5
  190. code_puppy/tools/display.py +84 -0
  191. code_puppy/tools/file_modifications.py +288 -89
  192. code_puppy/tools/file_operations.py +226 -154
  193. code_puppy/tools/subagent_context.py +158 -0
  194. code_puppy/uvx_detection.py +242 -0
  195. code_puppy/version_checker.py +30 -11
  196. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  197. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  198. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
  199. code_puppy-0.0.366.dist-info/RECORD +217 -0
  200. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  201. code_puppy/command_line/mcp/add_command.py +0 -183
  202. code_puppy/messaging/spinner/textual_spinner.py +0 -106
  203. code_puppy/tools/browser/camoufox_manager.py +0 -216
  204. code_puppy/tools/browser/vqa_agent.py +0 -70
  205. code_puppy/tui/__init__.py +0 -10
  206. code_puppy/tui/app.py +0 -1105
  207. code_puppy/tui/components/__init__.py +0 -21
  208. code_puppy/tui/components/chat_view.py +0 -551
  209. code_puppy/tui/components/command_history_modal.py +0 -218
  210. code_puppy/tui/components/copy_button.py +0 -139
  211. code_puppy/tui/components/custom_widgets.py +0 -63
  212. code_puppy/tui/components/human_input_modal.py +0 -175
  213. code_puppy/tui/components/input_area.py +0 -167
  214. code_puppy/tui/components/sidebar.py +0 -309
  215. code_puppy/tui/components/status_bar.py +0 -185
  216. code_puppy/tui/messages.py +0 -27
  217. code_puppy/tui/models/__init__.py +0 -8
  218. code_puppy/tui/models/chat_message.py +0 -25
  219. code_puppy/tui/models/command_history.py +0 -89
  220. code_puppy/tui/models/enums.py +0 -24
  221. code_puppy/tui/screens/__init__.py +0 -17
  222. code_puppy/tui/screens/autosave_picker.py +0 -175
  223. code_puppy/tui/screens/help.py +0 -130
  224. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  225. code_puppy/tui/screens/settings.py +0 -306
  226. code_puppy/tui/screens/tools.py +0 -74
  227. code_puppy/tui_state.py +0 -55
  228. code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
  229. code_puppy-0.0.214.dist-info/RECORD +0 -131
  230. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
  231. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -4,33 +4,134 @@ import os
4
4
  import pathlib
5
5
  from typing import Any, Dict
6
6
 
7
- import httpx
8
7
  from anthropic import AsyncAnthropic
9
8
  from openai import AsyncAzureOpenAI
10
- from pydantic_ai.models.anthropic import AnthropicModel
11
- from pydantic_ai.models.google import GoogleModel
12
- from pydantic_ai.models.openai import OpenAIChatModel
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
13
16
  from pydantic_ai.providers.anthropic import AnthropicProvider
14
17
  from pydantic_ai.providers.cerebras import CerebrasProvider
15
- from pydantic_ai.providers.google import GoogleProvider
16
18
  from pydantic_ai.providers.openai import OpenAIProvider
17
19
  from pydantic_ai.providers.openrouter import OpenRouterProvider
20
+ from pydantic_ai.settings import ModelSettings
18
21
 
22
+ from code_puppy.gemini_model import GeminiModel
19
23
  from code_puppy.messaging import emit_warning
20
24
 
21
25
  from . import callbacks
22
- from .config import EXTRA_MODELS_FILE
23
- from .http_utils import create_async_client
26
+ from .claude_cache_client import ClaudeCacheAsyncClient, patch_anthropic_client_messages
27
+ from .config import EXTRA_MODELS_FILE, get_value
28
+ from .http_utils import create_async_client, get_cert_bundle_path, get_http2
24
29
  from .round_robin_model import RoundRobinModel
25
30
 
26
- # Environment variables used in this module:
27
- # - GEMINI_API_KEY: API key for Google's Gemini models. Required when using Gemini models.
28
- # - OPENAI_API_KEY: API key for OpenAI models. Required when using OpenAI models or custom_openai endpoints.
29
- # - TOGETHER_AI_KEY: API key for Together AI models. Required when using Together AI models.
30
- #
31
- # When using custom endpoints (type: "custom_openai" in models.json):
32
- # - Environment variables can be referenced in header values by prefixing with $ in models.json.
33
- # Example: "X-Api-Key": "$OPENAI_API_KEY" will use the value from os.environ.get("OPENAI_API_KEY")
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def get_api_key(env_var_name: str) -> str | None:
35
+ """Get an API key from config first, then fall back to environment variable.
36
+
37
+ This allows users to set API keys via `/set KIMI_API_KEY=xxx` in addition to
38
+ setting them as environment variables.
39
+
40
+ Args:
41
+ env_var_name: The name of the environment variable (e.g., "OPENAI_API_KEY")
42
+
43
+ Returns:
44
+ The API key value, or None if not found in either config or environment.
45
+ """
46
+ # First check config (case-insensitive key lookup)
47
+ config_value = get_value(env_var_name.lower())
48
+ if config_value:
49
+ return config_value
50
+
51
+ # Fall back to environment variable
52
+ return os.environ.get(env_var_name)
53
+
54
+
55
+ def make_model_settings(
56
+ model_name: str, max_tokens: int | None = None
57
+ ) -> ModelSettings:
58
+ """Create appropriate ModelSettings for a given model.
59
+
60
+ This handles model-specific settings:
61
+ - GPT-5 models: reasoning_effort and verbosity (non-codex only)
62
+ - Claude/Anthropic models: extended_thinking and budget_tokens
63
+ - Automatic max_tokens calculation based on model context length
64
+
65
+ Args:
66
+ model_name: The name of the model to create settings for.
67
+ max_tokens: Optional max tokens limit. If None, automatically calculated
68
+ as: max(2048, min(15% of context_length, 65536))
69
+
70
+ Returns:
71
+ Appropriate ModelSettings subclass instance for the model.
72
+ """
73
+ from code_puppy.config import (
74
+ get_effective_model_settings,
75
+ get_openai_reasoning_effort,
76
+ get_openai_verbosity,
77
+ )
78
+
79
+ model_settings_dict: dict = {}
80
+
81
+ # Calculate max_tokens if not explicitly provided
82
+ if max_tokens is None:
83
+ # Load model config to get context length
84
+ try:
85
+ models_config = ModelFactory.load_config()
86
+ model_config = models_config.get(model_name, {})
87
+ context_length = model_config.get("context_length", 128000)
88
+ except Exception:
89
+ # Fallback if config loading fails (e.g., in CI environments)
90
+ context_length = 128000
91
+ # min 2048, 15% of context, max 65536
92
+ max_tokens = max(2048, min(int(0.15 * context_length), 65536))
93
+
94
+ model_settings_dict["max_tokens"] = max_tokens
95
+ effective_settings = get_effective_model_settings(model_name)
96
+ model_settings_dict.update(effective_settings)
97
+
98
+ # Default to clear_thinking=False for GLM-4.7 models (preserved thinking)
99
+ if "glm-4.7" in model_name.lower():
100
+ clear_thinking = effective_settings.get("clear_thinking", False)
101
+ model_settings_dict["thinking"] = {
102
+ "type": "enabled",
103
+ "clear_thinking": clear_thinking,
104
+ }
105
+
106
+ model_settings: ModelSettings = ModelSettings(**model_settings_dict)
107
+
108
+ if "gpt-5" in model_name:
109
+ model_settings_dict["openai_reasoning_effort"] = get_openai_reasoning_effort()
110
+ # Verbosity only applies to non-codex GPT-5 models (codex only supports "medium")
111
+ if "codex" not in model_name:
112
+ verbosity = get_openai_verbosity()
113
+ model_settings_dict["extra_body"] = {"verbosity": verbosity}
114
+ model_settings = OpenAIChatModelSettings(**model_settings_dict)
115
+ elif model_name.startswith("claude-") or model_name.startswith("anthropic-"):
116
+ # Handle Anthropic extended thinking settings
117
+ # Remove top_p as Anthropic doesn't support it with extended thinking
118
+ model_settings_dict.pop("top_p", None)
119
+
120
+ # Claude extended thinking requires temperature=1.0 (API restriction)
121
+ # Default to 1.0 if not explicitly set by user
122
+ if model_settings_dict.get("temperature") is None:
123
+ model_settings_dict["temperature"] = 1.0
124
+
125
+ extended_thinking = effective_settings.get("extended_thinking", True)
126
+ budget_tokens = effective_settings.get("budget_tokens", 10000)
127
+ if extended_thinking and budget_tokens:
128
+ model_settings_dict["anthropic_thinking"] = {
129
+ "type": "enabled",
130
+ "budget_tokens": budget_tokens,
131
+ }
132
+ model_settings = AnthropicModelSettings(**model_settings_dict)
133
+
134
+ return model_settings
34
135
 
35
136
 
36
137
  class ZaiChatModel(OpenAIChatModel):
@@ -52,10 +153,10 @@ def get_custom_config(model_config):
52
153
  for key, value in custom_config.get("headers", {}).items():
53
154
  if value.startswith("$"):
54
155
  env_var_name = value[1:]
55
- resolved_value = os.environ.get(env_var_name)
156
+ resolved_value = get_api_key(env_var_name)
56
157
  if resolved_value is None:
57
158
  emit_warning(
58
- f"Environment variable '{env_var_name}' is not set for custom endpoint header '{key}'. Proceeding with empty value."
159
+ f"'{env_var_name}' is not set (check config or environment) for custom endpoint header '{key}'. Proceeding with empty value."
59
160
  )
60
161
  resolved_value = ""
61
162
  value = resolved_value
@@ -65,10 +166,10 @@ def get_custom_config(model_config):
65
166
  for token in tokens:
66
167
  if token.startswith("$"):
67
168
  env_var = token[1:]
68
- resolved_value = os.environ.get(env_var)
169
+ resolved_value = get_api_key(env_var)
69
170
  if resolved_value is None:
70
171
  emit_warning(
71
- f"Environment variable '{env_var}' is not set for custom endpoint header '{key}'. Proceeding with empty value."
172
+ f"'{env_var}' is not set (check config or environment) for custom endpoint header '{key}'. Proceeding with empty value."
72
173
  )
73
174
  resolved_values.append("")
74
175
  else:
@@ -81,10 +182,10 @@ def get_custom_config(model_config):
81
182
  if "api_key" in custom_config:
82
183
  if custom_config["api_key"].startswith("$"):
83
184
  env_var_name = custom_config["api_key"][1:]
84
- api_key = os.environ.get(env_var_name)
185
+ api_key = get_api_key(env_var_name)
85
186
  if api_key is None:
86
187
  emit_warning(
87
- f"Environment variable '{env_var_name}' is not set for custom endpoint API key; proceeding without API key."
188
+ f"API key '{env_var_name}' is not set (checked config and environment); proceeding without API key."
88
189
  )
89
190
  else:
90
191
  api_key = custom_config["api_key"]
@@ -117,26 +218,63 @@ class ModelFactory:
117
218
  with open(MODELS_FILE, "r") as f:
118
219
  config = json.load(f)
119
220
 
120
- if pathlib.Path(EXTRA_MODELS_FILE).exists():
221
+ # Import OAuth model file paths from main config
222
+ from code_puppy.config import (
223
+ ANTIGRAVITY_MODELS_FILE,
224
+ CHATGPT_MODELS_FILE,
225
+ CLAUDE_MODELS_FILE,
226
+ GEMINI_MODELS_FILE,
227
+ )
228
+
229
+ # Build list of extra model sources
230
+ extra_sources: list[tuple[pathlib.Path, str, bool]] = [
231
+ (pathlib.Path(EXTRA_MODELS_FILE), "extra models", False),
232
+ (pathlib.Path(CHATGPT_MODELS_FILE), "ChatGPT OAuth models", False),
233
+ (pathlib.Path(CLAUDE_MODELS_FILE), "Claude Code OAuth models", True),
234
+ (pathlib.Path(GEMINI_MODELS_FILE), "Gemini OAuth models", False),
235
+ (pathlib.Path(ANTIGRAVITY_MODELS_FILE), "Antigravity OAuth models", False),
236
+ ]
237
+
238
+ for source_path, label, use_filtered in extra_sources:
239
+ if not source_path.exists():
240
+ continue
121
241
  try:
122
- with open(EXTRA_MODELS_FILE, "r") as f:
123
- extra_config = json.load(f)
124
- config.update(extra_config)
125
- except json.JSONDecodeError as e:
242
+ # Use filtered loading for Claude Code OAuth models to show only latest versions
243
+ if use_filtered:
244
+ try:
245
+ from code_puppy.plugins.claude_code_oauth.utils import (
246
+ load_claude_models_filtered,
247
+ )
248
+
249
+ extra_config = load_claude_models_filtered()
250
+ except ImportError:
251
+ # Plugin not available, fall back to standard JSON loading
252
+ logging.getLogger(__name__).debug(
253
+ f"claude_code_oauth plugin not available, loading {label} as plain JSON"
254
+ )
255
+ with open(source_path, "r") as f:
256
+ extra_config = json.load(f)
257
+ else:
258
+ with open(source_path, "r") as f:
259
+ extra_config = json.load(f)
260
+ config.update(extra_config)
261
+ except json.JSONDecodeError as exc:
126
262
  logging.getLogger(__name__).warning(
127
- f"Failed to load extra models config from {EXTRA_MODELS_FILE}: Invalid JSON - {e}\n"
128
- f"Please check your extra_models.json file for syntax errors."
263
+ f"Failed to load {label} config from {source_path}: Invalid JSON - {exc}"
129
264
  )
130
- except Exception as e:
265
+ except Exception as exc:
131
266
  logging.getLogger(__name__).warning(
132
- f"Failed to load extra models config from {EXTRA_MODELS_FILE}: {e}\n"
133
- f"The extra models configuration will be ignored."
267
+ f"Failed to load {label} config from {source_path}: {exc}"
134
268
  )
135
269
  return config
136
270
 
137
271
  @staticmethod
138
272
  def get_model(model_name: str, config: Dict[str, Any]) -> Any:
139
- """Returns a configured model instance based on the provided name and config."""
273
+ """Returns a configured model instance based on the provided name and config.
274
+
275
+ API key validation happens naturally within each model type's initialization,
276
+ which emits warnings and returns None if keys are missing.
277
+ """
140
278
  model_config = config.get(model_name)
141
279
  if not model_config:
142
280
  raise ValueError(f"Model '{model_name}' not found in configuration.")
@@ -144,41 +282,189 @@ class ModelFactory:
144
282
  model_type = model_config.get("type")
145
283
 
146
284
  if model_type == "gemini":
147
- provider = GoogleProvider(api_key=os.environ.get("GEMINI_API_KEY", ""))
285
+ api_key = get_api_key("GEMINI_API_KEY")
286
+ if not api_key:
287
+ emit_warning(
288
+ f"GEMINI_API_KEY is not set (check config or environment); skipping Gemini model '{model_config.get('name')}'."
289
+ )
290
+ return None
148
291
 
149
- model = GoogleModel(model_name=model_config["name"], provider=provider)
150
- setattr(model, "provider", provider)
292
+ model = GeminiModel(model_name=model_config["name"], api_key=api_key)
151
293
  return model
152
294
 
153
295
  elif model_type == "openai":
154
- provider = OpenAIProvider(api_key=os.environ.get("OPENAI_API_KEY", ""))
296
+ api_key = get_api_key("OPENAI_API_KEY")
297
+ if not api_key:
298
+ emit_warning(
299
+ f"OPENAI_API_KEY is not set (check config or environment); skipping OpenAI model '{model_config.get('name')}'."
300
+ )
301
+ return None
155
302
 
303
+ provider = OpenAIProvider(api_key=api_key)
156
304
  model = OpenAIChatModel(model_name=model_config["name"], provider=provider)
305
+ if "codex" in model_name:
306
+ model = OpenAIResponsesModel(
307
+ model_name=model_config["name"], provider=provider
308
+ )
157
309
  setattr(model, "provider", provider)
158
310
  return model
159
311
 
160
312
  elif model_type == "anthropic":
161
- api_key = os.environ.get("ANTHROPIC_API_KEY", None)
313
+ api_key = get_api_key("ANTHROPIC_API_KEY")
162
314
  if not api_key:
163
315
  emit_warning(
164
- f"ANTHROPIC_API_KEY is not set; skipping Anthropic model '{model_config.get('name')}'."
316
+ f"ANTHROPIC_API_KEY is not set (check config or environment); skipping Anthropic model '{model_config.get('name')}'."
165
317
  )
166
318
  return None
167
- anthropic_client = AsyncAnthropic(api_key=api_key)
319
+
320
+ # Use the same caching client as claude_code models
321
+ verify = get_cert_bundle_path()
322
+ http2_enabled = get_http2()
323
+
324
+ client = ClaudeCacheAsyncClient(
325
+ verify=verify,
326
+ timeout=180,
327
+ http2=http2_enabled,
328
+ )
329
+
330
+ # Check if interleaved thinking is enabled for this model
331
+ # Only applies to Claude 4 models (Opus 4.5, Opus 4.1, Opus 4, Sonnet 4)
332
+ from code_puppy.config import get_effective_model_settings
333
+
334
+ effective_settings = get_effective_model_settings(model_name)
335
+ interleaved_thinking = effective_settings.get("interleaved_thinking", False)
336
+
337
+ default_headers = {}
338
+ if interleaved_thinking:
339
+ default_headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
340
+
341
+ anthropic_client = AsyncAnthropic(
342
+ api_key=api_key,
343
+ http_client=client,
344
+ default_headers=default_headers if default_headers else None,
345
+ )
346
+
347
+ # Ensure cache_control is injected at the Anthropic SDK layer
348
+ patch_anthropic_client_messages(anthropic_client)
349
+
168
350
  provider = AnthropicProvider(anthropic_client=anthropic_client)
169
351
  return AnthropicModel(model_name=model_config["name"], provider=provider)
170
352
 
171
353
  elif model_type == "custom_anthropic":
172
354
  url, headers, verify, api_key = get_custom_config(model_config)
173
- client = create_async_client(headers=headers, verify=verify)
355
+ if not api_key:
356
+ emit_warning(
357
+ f"API key is not set for custom Anthropic endpoint; skipping model '{model_config.get('name')}'."
358
+ )
359
+ return None
360
+
361
+ # Use the same caching client as claude_code models
362
+ if verify is None:
363
+ verify = get_cert_bundle_path()
364
+
365
+ http2_enabled = get_http2()
366
+
367
+ client = ClaudeCacheAsyncClient(
368
+ headers=headers,
369
+ verify=verify,
370
+ timeout=180,
371
+ http2=http2_enabled,
372
+ )
373
+
374
+ # Check if interleaved thinking is enabled for this model
375
+ from code_puppy.config import get_effective_model_settings
376
+
377
+ effective_settings = get_effective_model_settings(model_name)
378
+ interleaved_thinking = effective_settings.get("interleaved_thinking", False)
379
+
380
+ default_headers = {}
381
+ if interleaved_thinking:
382
+ default_headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
383
+
174
384
  anthropic_client = AsyncAnthropic(
175
385
  base_url=url,
176
386
  http_client=client,
177
387
  api_key=api_key,
388
+ default_headers=default_headers if default_headers else None,
178
389
  )
390
+
391
+ # Ensure cache_control is injected at the Anthropic SDK layer
392
+ patch_anthropic_client_messages(anthropic_client)
393
+
179
394
  provider = AnthropicProvider(anthropic_client=anthropic_client)
180
395
  return AnthropicModel(model_name=model_config["name"], provider=provider)
396
+ elif model_type == "claude_code":
397
+ url, headers, verify, api_key = get_custom_config(model_config)
398
+ if model_config.get("oauth_source") == "claude-code-plugin":
399
+ try:
400
+ from code_puppy.plugins.claude_code_oauth.utils import (
401
+ get_valid_access_token,
402
+ )
403
+
404
+ refreshed_token = get_valid_access_token()
405
+ if refreshed_token:
406
+ api_key = refreshed_token
407
+ custom_endpoint = model_config.get("custom_endpoint")
408
+ if isinstance(custom_endpoint, dict):
409
+ custom_endpoint["api_key"] = refreshed_token
410
+ except ImportError:
411
+ pass
412
+ if not api_key:
413
+ emit_warning(
414
+ f"API key is not set for Claude Code endpoint; skipping model '{model_config.get('name')}'."
415
+ )
416
+ return None
417
+
418
+ # Check if interleaved thinking is enabled (defaults to True for OAuth models)
419
+ from code_puppy.config import get_effective_model_settings
181
420
 
421
+ effective_settings = get_effective_model_settings(model_name)
422
+ interleaved_thinking = effective_settings.get("interleaved_thinking", True)
423
+
424
+ # Handle anthropic-beta header based on interleaved_thinking setting
425
+ if "anthropic-beta" in headers:
426
+ beta_parts = [p.strip() for p in headers["anthropic-beta"].split(",")]
427
+ if interleaved_thinking:
428
+ # Ensure interleaved-thinking is in the header
429
+ if "interleaved-thinking-2025-05-14" not in beta_parts:
430
+ beta_parts.append("interleaved-thinking-2025-05-14")
431
+ else:
432
+ # Remove interleaved-thinking from the header
433
+ beta_parts = [
434
+ p for p in beta_parts if "interleaved-thinking" not in p
435
+ ]
436
+ headers["anthropic-beta"] = ",".join(beta_parts) if beta_parts else None
437
+ if headers.get("anthropic-beta") is None:
438
+ del headers["anthropic-beta"]
439
+ elif interleaved_thinking:
440
+ # No existing beta header, add one for interleaved thinking
441
+ headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
442
+
443
+ # Use a dedicated client wrapper that injects cache_control on /v1/messages
444
+ if verify is None:
445
+ verify = get_cert_bundle_path()
446
+
447
+ http2_enabled = get_http2()
448
+
449
+ client = ClaudeCacheAsyncClient(
450
+ headers=headers,
451
+ verify=verify,
452
+ timeout=180,
453
+ http2=http2_enabled,
454
+ )
455
+
456
+ anthropic_client = AsyncAnthropic(
457
+ base_url=url,
458
+ http_client=client,
459
+ auth_token=api_key,
460
+ )
461
+ # Ensure cache_control is injected at the Anthropic SDK layer too
462
+ # so we don't depend solely on httpx internals.
463
+ patch_anthropic_client_messages(anthropic_client)
464
+ anthropic_client.api_key = None
465
+ anthropic_client.auth_token = api_key
466
+ provider = AnthropicProvider(anthropic_client=anthropic_client)
467
+ return AnthropicModel(model_name=model_config["name"], provider=provider)
182
468
  elif model_type == "azure_openai":
183
469
  azure_endpoint_config = model_config.get("azure_endpoint")
184
470
  if not azure_endpoint_config:
@@ -187,10 +473,10 @@ class ModelFactory:
187
473
  )
188
474
  azure_endpoint = azure_endpoint_config
189
475
  if azure_endpoint_config.startswith("$"):
190
- azure_endpoint = os.environ.get(azure_endpoint_config[1:])
476
+ azure_endpoint = get_api_key(azure_endpoint_config[1:])
191
477
  if not azure_endpoint:
192
478
  emit_warning(
193
- f"Azure OpenAI endpoint environment variable '{azure_endpoint_config[1:] if azure_endpoint_config.startswith('$') else azure_endpoint_config}' not found or is empty; skipping model '{model_config.get('name')}'."
479
+ 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')}'."
194
480
  )
195
481
  return None
196
482
 
@@ -201,10 +487,10 @@ class ModelFactory:
201
487
  )
202
488
  api_version = api_version_config
203
489
  if api_version_config.startswith("$"):
204
- api_version = os.environ.get(api_version_config[1:])
490
+ api_version = get_api_key(api_version_config[1:])
205
491
  if not api_version:
206
492
  emit_warning(
207
- f"Azure OpenAI API version environment variable '{api_version_config[1:] if api_version_config.startswith('$') else api_version_config}' not found or is empty; skipping model '{model_config.get('name')}'."
493
+ 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')}'."
208
494
  )
209
495
  return None
210
496
 
@@ -215,10 +501,10 @@ class ModelFactory:
215
501
  )
216
502
  api_key = api_key_config
217
503
  if api_key_config.startswith("$"):
218
- api_key = os.environ.get(api_key_config[1:])
504
+ api_key = get_api_key(api_key_config[1:])
219
505
  if not api_key:
220
506
  emit_warning(
221
- f"Azure OpenAI API key environment variable '{api_key_config[1:] if api_key_config.startswith('$') else api_key_config}' not found or is empty; skipping model '{model_config.get('name')}'."
507
+ 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')}'."
222
508
  )
223
509
  return None
224
510
 
@@ -246,71 +532,193 @@ class ModelFactory:
246
532
  if api_key:
247
533
  provider_args["api_key"] = api_key
248
534
  provider = OpenAIProvider(**provider_args)
249
-
250
535
  model = OpenAIChatModel(model_name=model_config["name"], provider=provider)
536
+ if model_name == "chatgpt-gpt-5-codex":
537
+ model = OpenAIResponsesModel(model_config["name"], provider=provider)
251
538
  setattr(model, "provider", provider)
252
539
  return model
253
540
  elif model_type == "zai_coding":
254
- api_key = os.getenv("ZAI_API_KEY")
541
+ api_key = get_api_key("ZAI_API_KEY")
255
542
  if not api_key:
256
543
  emit_warning(
257
- f"ZAI_API_KEY is not set; skipping ZAI coding model '{model_config.get('name')}'."
544
+ f"ZAI_API_KEY is not set (check config or environment); skipping ZAI coding model '{model_config.get('name')}'."
258
545
  )
259
546
  return None
547
+ provider = OpenAIProvider(
548
+ api_key=api_key,
549
+ base_url="https://api.z.ai/api/coding/paas/v4",
550
+ )
260
551
  zai_model = ZaiChatModel(
261
552
  model_name=model_config["name"],
262
- provider=OpenAIProvider(
263
- api_key=api_key,
264
- base_url="https://api.z.ai/api/coding/paas/v4",
265
- ),
553
+ provider=provider,
266
554
  )
555
+ setattr(zai_model, "provider", provider)
267
556
  return zai_model
268
557
  elif model_type == "zai_api":
269
- api_key = os.getenv("ZAI_API_KEY")
558
+ api_key = get_api_key("ZAI_API_KEY")
270
559
  if not api_key:
271
560
  emit_warning(
272
- f"ZAI_API_KEY is not set; skipping ZAI API model '{model_config.get('name')}'."
561
+ f"ZAI_API_KEY is not set (check config or environment); skipping ZAI API model '{model_config.get('name')}'."
273
562
  )
274
563
  return None
564
+ provider = OpenAIProvider(
565
+ api_key=api_key,
566
+ base_url="https://api.z.ai/api/paas/v4/",
567
+ )
275
568
  zai_model = ZaiChatModel(
276
569
  model_name=model_config["name"],
277
- provider=OpenAIProvider(
278
- api_key=api_key,
279
- base_url="https://api.z.ai/api/paas/v4/",
280
- ),
570
+ provider=provider,
281
571
  )
572
+ setattr(zai_model, "provider", provider)
282
573
  return zai_model
283
574
  elif model_type == "custom_gemini":
284
575
  url, headers, verify, api_key = get_custom_config(model_config)
285
- os.environ["GEMINI_API_KEY"] = api_key
576
+ if not api_key:
577
+ emit_warning(
578
+ f"API key is not set for custom Gemini endpoint; skipping model '{model_config.get('name')}'."
579
+ )
580
+ return None
286
581
 
287
- class CustomGoogleGLAProvider(GoogleProvider):
288
- def __init__(self, *args, **kwargs):
289
- super().__init__(*args, **kwargs)
582
+ # Check if this is an Antigravity model
583
+ if model_config.get("antigravity"):
584
+ try:
585
+ from code_puppy.plugins.antigravity_oauth.token import (
586
+ is_token_expired,
587
+ refresh_access_token,
588
+ )
589
+ from code_puppy.plugins.antigravity_oauth.transport import (
590
+ create_antigravity_client,
591
+ )
592
+ from code_puppy.plugins.antigravity_oauth.utils import (
593
+ load_stored_tokens,
594
+ save_tokens,
595
+ )
596
+
597
+ # Try to import custom model for thinking signatures
598
+ try:
599
+ from code_puppy.plugins.antigravity_oauth.antigravity_model import (
600
+ AntigravityModel,
601
+ )
602
+ except ImportError:
603
+ AntigravityModel = None
290
604
 
291
- @property
292
- def base_url(self):
293
- return url
605
+ # Get fresh access token (refresh if needed)
606
+ tokens = load_stored_tokens()
607
+ if not tokens:
608
+ emit_warning(
609
+ "Antigravity tokens not found; run /antigravity-auth first."
610
+ )
611
+ return None
612
+
613
+ access_token = tokens.get("access_token", "")
614
+ refresh_token = tokens.get("refresh_token", "")
615
+ expires_at = tokens.get("expires_at")
616
+
617
+ # Refresh if expired or about to expire (initial check)
618
+ if is_token_expired(expires_at):
619
+ new_tokens = refresh_access_token(refresh_token)
620
+ if new_tokens:
621
+ access_token = new_tokens.access_token
622
+ refresh_token = new_tokens.refresh_token
623
+ expires_at = new_tokens.expires_at
624
+ tokens["access_token"] = new_tokens.access_token
625
+ tokens["refresh_token"] = new_tokens.refresh_token
626
+ tokens["expires_at"] = new_tokens.expires_at
627
+ save_tokens(tokens)
628
+ else:
629
+ emit_warning(
630
+ "Failed to refresh Antigravity token; run /antigravity-auth again."
631
+ )
632
+ return None
633
+
634
+ # Callback to persist tokens when proactively refreshed during session
635
+ def on_token_refreshed(new_tokens):
636
+ """Persist new tokens when proactively refreshed."""
637
+ try:
638
+ updated_tokens = load_stored_tokens() or {}
639
+ updated_tokens["access_token"] = new_tokens.access_token
640
+ updated_tokens["refresh_token"] = new_tokens.refresh_token
641
+ updated_tokens["expires_at"] = new_tokens.expires_at
642
+ save_tokens(updated_tokens)
643
+ logger.debug(
644
+ "Persisted proactively refreshed Antigravity tokens"
645
+ )
646
+ except Exception as e:
647
+ logger.warning("Failed to persist refreshed tokens: %s", e)
648
+
649
+ project_id = tokens.get(
650
+ "project_id", model_config.get("project_id", "")
651
+ )
652
+ client = create_antigravity_client(
653
+ access_token=access_token,
654
+ project_id=project_id,
655
+ model_name=model_config["name"],
656
+ base_url=url,
657
+ headers=headers,
658
+ refresh_token=refresh_token,
659
+ expires_at=expires_at,
660
+ on_token_refreshed=on_token_refreshed,
661
+ )
662
+
663
+ # Use custom model with direct httpx client
664
+ if AntigravityModel:
665
+ model = AntigravityModel(
666
+ model_name=model_config["name"],
667
+ api_key=api_key
668
+ or "", # Antigravity uses OAuth, key may be empty
669
+ base_url=url,
670
+ http_client=client,
671
+ )
672
+ else:
673
+ model = GeminiModel(
674
+ model_name=model_config["name"],
675
+ api_key=api_key or "",
676
+ base_url=url,
677
+ http_client=client,
678
+ )
294
679
 
295
- @property
296
- def client(self) -> httpx.AsyncClient:
297
- _client = create_async_client(headers=headers, verify=verify)
298
- _client.base_url = self.base_url
299
- return _client
680
+ return model
681
+
682
+ except ImportError:
683
+ emit_warning(
684
+ f"Antigravity transport not available; skipping model '{model_config.get('name')}'."
685
+ )
686
+ return None
687
+ else:
688
+ client = create_async_client(headers=headers, verify=verify)
300
689
 
301
- google_gla = CustomGoogleGLAProvider(api_key=api_key)
302
- model = GoogleModel(model_name=model_config["name"], provider=google_gla)
690
+ model = GeminiModel(
691
+ model_name=model_config["name"],
692
+ api_key=api_key,
693
+ base_url=url,
694
+ http_client=client,
695
+ )
303
696
  return model
304
697
  elif model_type == "cerebras":
698
+
699
+ class ZaiCerebrasProvider(CerebrasProvider):
700
+ def model_profile(self, model_name: str) -> ModelProfile | None:
701
+ profile = super().model_profile(model_name)
702
+ if model_name.startswith("zai"):
703
+ from pydantic_ai.profiles.qwen import qwen_model_profile
704
+
705
+ profile = profile.update(qwen_model_profile("qwen-3-coder"))
706
+ return profile
707
+
305
708
  url, headers, verify, api_key = get_custom_config(model_config)
709
+ if not api_key:
710
+ emit_warning(
711
+ f"API key is not set for Cerebras endpoint; skipping model '{model_config.get('name')}'."
712
+ )
713
+ return None
714
+ # Add Cerebras 3rd party integration header
715
+ headers["X-Cerebras-3rd-Party-Integration"] = "code-puppy"
306
716
  client = create_async_client(headers=headers, verify=verify)
307
717
  provider_args = dict(
308
718
  api_key=api_key,
309
719
  http_client=client,
310
720
  )
311
- if api_key:
312
- provider_args["api_key"] = api_key
313
- provider = CerebrasProvider(**provider_args)
721
+ provider = ZaiCerebrasProvider(**provider_args)
314
722
 
315
723
  model = OpenAIChatModel(model_name=model_config["name"], provider=provider)
316
724
  setattr(model, "provider", provider)
@@ -325,17 +733,23 @@ class ModelFactory:
325
733
  if api_key_config.startswith("$"):
326
734
  # It's an environment variable reference
327
735
  env_var_name = api_key_config[1:] # Remove the $ prefix
328
- api_key = os.environ.get(env_var_name)
736
+ api_key = get_api_key(env_var_name)
329
737
  if api_key is None:
330
738
  emit_warning(
331
- f"OpenRouter API key environment variable '{env_var_name}' not found or is empty; proceeding without API key."
739
+ f"OpenRouter API key '{env_var_name}' not found (check config or environment); skipping model '{model_config.get('name')}'."
332
740
  )
333
- else:
334
- # It's a raw API key value
335
- api_key = api_key_config
741
+ return None
742
+ else:
743
+ # It's a raw API key value
744
+ api_key = api_key_config
336
745
  else:
337
- # No API key in config, try to get it from the default environment variable
338
- api_key = os.environ.get("OPENROUTER_API_KEY")
746
+ # No API key in config, try to get it from config or the default environment variable
747
+ api_key = get_api_key("OPENROUTER_API_KEY")
748
+ if api_key is None:
749
+ emit_warning(
750
+ f"OPENROUTER_API_KEY is not set (check config or environment); skipping OpenRouter model '{model_config.get('name')}'."
751
+ )
752
+ return None
339
753
 
340
754
  provider = OpenRouterProvider(api_key=api_key)
341
755
 
@@ -343,6 +757,143 @@ class ModelFactory:
343
757
  setattr(model, "provider", provider)
344
758
  return model
345
759
 
760
+ elif model_type == "gemini_oauth":
761
+ # Gemini OAuth models use the Code Assist API (cloudcode-pa.googleapis.com)
762
+ # This is a different API than the standard Generative Language API
763
+ try:
764
+ # Try user plugin first, then built-in plugin
765
+ try:
766
+ from gemini_oauth.config import GEMINI_OAUTH_CONFIG
767
+ from gemini_oauth.utils import (
768
+ get_project_id,
769
+ get_valid_access_token,
770
+ )
771
+ except ImportError:
772
+ from code_puppy.plugins.gemini_oauth.config import (
773
+ GEMINI_OAUTH_CONFIG,
774
+ )
775
+ from code_puppy.plugins.gemini_oauth.utils import (
776
+ get_project_id,
777
+ get_valid_access_token,
778
+ )
779
+ except ImportError as exc:
780
+ emit_warning(
781
+ f"Gemini OAuth plugin not available; skipping model '{model_config.get('name')}'. "
782
+ f"Error: {exc}"
783
+ )
784
+ return None
785
+
786
+ # Get a valid access token (refreshing if needed)
787
+ access_token = get_valid_access_token()
788
+ if not access_token:
789
+ emit_warning(
790
+ f"Failed to get valid Gemini OAuth token; skipping model '{model_config.get('name')}'. "
791
+ "Run /gemini-auth to re-authenticate."
792
+ )
793
+ return None
794
+
795
+ # Get project ID from stored tokens
796
+ project_id = get_project_id()
797
+ if not project_id:
798
+ emit_warning(
799
+ f"No Code Assist project ID found; skipping model '{model_config.get('name')}'. "
800
+ "Run /gemini-auth to re-authenticate."
801
+ )
802
+ return None
803
+
804
+ # Import the Code Assist model wrapper
805
+ from code_puppy.gemini_code_assist import GeminiCodeAssistModel
806
+
807
+ # Create the Code Assist model
808
+ model = GeminiCodeAssistModel(
809
+ model_name=model_config["name"],
810
+ access_token=access_token,
811
+ project_id=project_id,
812
+ api_base_url=GEMINI_OAUTH_CONFIG["api_base_url"],
813
+ api_version=GEMINI_OAUTH_CONFIG["api_version"],
814
+ )
815
+ return model
816
+
817
+ elif model_type == "chatgpt_oauth":
818
+ # ChatGPT OAuth models use the Codex API at chatgpt.com
819
+ try:
820
+ try:
821
+ from chatgpt_oauth.config import CHATGPT_OAUTH_CONFIG
822
+ from chatgpt_oauth.utils import (
823
+ get_valid_access_token,
824
+ load_stored_tokens,
825
+ )
826
+ except ImportError:
827
+ from code_puppy.plugins.chatgpt_oauth.config import (
828
+ CHATGPT_OAUTH_CONFIG,
829
+ )
830
+ from code_puppy.plugins.chatgpt_oauth.utils import (
831
+ get_valid_access_token,
832
+ load_stored_tokens,
833
+ )
834
+ except ImportError as exc:
835
+ emit_warning(
836
+ f"ChatGPT OAuth plugin not available; skipping model '{model_config.get('name')}'. "
837
+ f"Error: {exc}"
838
+ )
839
+ return None
840
+
841
+ # Get a valid access token (refreshing if needed)
842
+ access_token = get_valid_access_token()
843
+ if not access_token:
844
+ emit_warning(
845
+ f"Failed to get valid ChatGPT OAuth token; skipping model '{model_config.get('name')}'. "
846
+ "Run /chatgpt-auth to authenticate."
847
+ )
848
+ return None
849
+
850
+ # Get account_id from stored tokens (required for ChatGPT-Account-Id header)
851
+ tokens = load_stored_tokens()
852
+ account_id = tokens.get("account_id", "") if tokens else ""
853
+ if not account_id:
854
+ emit_warning(
855
+ f"No account_id found in ChatGPT OAuth tokens; skipping model '{model_config.get('name')}'. "
856
+ "Run /chatgpt-auth to re-authenticate."
857
+ )
858
+ return None
859
+
860
+ # Build headers for ChatGPT Codex API
861
+ originator = CHATGPT_OAUTH_CONFIG.get("originator", "codex_cli_rs")
862
+ client_version = CHATGPT_OAUTH_CONFIG.get("client_version", "0.72.0")
863
+
864
+ headers = {
865
+ "ChatGPT-Account-Id": account_id,
866
+ "originator": originator,
867
+ "User-Agent": f"{originator}/{client_version}",
868
+ }
869
+ # Merge with any headers from model config
870
+ config_headers = model_config.get("custom_endpoint", {}).get("headers", {})
871
+ headers.update(config_headers)
872
+
873
+ # Get base URL - Codex API uses chatgpt.com, not api.openai.com
874
+ base_url = model_config.get("custom_endpoint", {}).get(
875
+ "url", CHATGPT_OAUTH_CONFIG["api_base_url"]
876
+ )
877
+
878
+ # Create HTTP client with Codex interceptor for store=false injection
879
+ from code_puppy.chatgpt_codex_client import create_codex_async_client
880
+
881
+ verify = get_cert_bundle_path()
882
+ client = create_codex_async_client(headers=headers, verify=verify)
883
+
884
+ provider = OpenAIProvider(
885
+ api_key=access_token,
886
+ base_url=base_url,
887
+ http_client=client,
888
+ )
889
+
890
+ # ChatGPT Codex API only supports Responses format
891
+ model = OpenAIResponsesModel(
892
+ model_name=model_config["name"], provider=provider
893
+ )
894
+ setattr(model, "provider", provider)
895
+ return model
896
+
346
897
  elif model_type == "round_robin":
347
898
  # Get the list of model names to use in the round-robin
348
899
  model_names = model_config.get("models")