code-puppy 0.0.287__py3-none-any.whl → 0.0.323__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 (110) hide show
  1. code_puppy/__init__.py +3 -1
  2. code_puppy/agents/agent_code_puppy.py +5 -4
  3. code_puppy/agents/agent_creator_agent.py +22 -18
  4. code_puppy/agents/agent_manager.py +2 -2
  5. code_puppy/agents/base_agent.py +496 -102
  6. code_puppy/callbacks.py +8 -0
  7. code_puppy/chatgpt_codex_client.py +283 -0
  8. code_puppy/cli_runner.py +795 -0
  9. code_puppy/command_line/add_model_menu.py +19 -16
  10. code_puppy/command_line/attachments.py +10 -5
  11. code_puppy/command_line/autosave_menu.py +269 -41
  12. code_puppy/command_line/colors_menu.py +515 -0
  13. code_puppy/command_line/command_handler.py +10 -24
  14. code_puppy/command_line/config_commands.py +106 -25
  15. code_puppy/command_line/core_commands.py +32 -20
  16. code_puppy/command_line/mcp/add_command.py +3 -16
  17. code_puppy/command_line/mcp/base.py +0 -3
  18. code_puppy/command_line/mcp/catalog_server_installer.py +15 -15
  19. code_puppy/command_line/mcp/custom_server_form.py +66 -5
  20. code_puppy/command_line/mcp/custom_server_installer.py +17 -17
  21. code_puppy/command_line/mcp/edit_command.py +15 -22
  22. code_puppy/command_line/mcp/handler.py +7 -2
  23. code_puppy/command_line/mcp/help_command.py +2 -2
  24. code_puppy/command_line/mcp/install_command.py +10 -14
  25. code_puppy/command_line/mcp/install_menu.py +2 -6
  26. code_puppy/command_line/mcp/list_command.py +2 -2
  27. code_puppy/command_line/mcp/logs_command.py +174 -65
  28. code_puppy/command_line/mcp/remove_command.py +2 -2
  29. code_puppy/command_line/mcp/restart_command.py +7 -2
  30. code_puppy/command_line/mcp/search_command.py +16 -10
  31. code_puppy/command_line/mcp/start_all_command.py +16 -6
  32. code_puppy/command_line/mcp/start_command.py +12 -10
  33. code_puppy/command_line/mcp/status_command.py +4 -5
  34. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  35. code_puppy/command_line/mcp/stop_command.py +6 -4
  36. code_puppy/command_line/mcp/test_command.py +2 -2
  37. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  38. code_puppy/command_line/model_settings_menu.py +53 -7
  39. code_puppy/command_line/motd.py +1 -1
  40. code_puppy/command_line/pin_command_completion.py +82 -7
  41. code_puppy/command_line/prompt_toolkit_completion.py +32 -9
  42. code_puppy/command_line/session_commands.py +11 -4
  43. code_puppy/config.py +217 -53
  44. code_puppy/error_logging.py +118 -0
  45. code_puppy/gemini_code_assist.py +385 -0
  46. code_puppy/keymap.py +126 -0
  47. code_puppy/main.py +5 -745
  48. code_puppy/mcp_/__init__.py +17 -0
  49. code_puppy/mcp_/blocking_startup.py +63 -36
  50. code_puppy/mcp_/captured_stdio_server.py +1 -1
  51. code_puppy/mcp_/config_wizard.py +4 -4
  52. code_puppy/mcp_/dashboard.py +15 -6
  53. code_puppy/mcp_/managed_server.py +25 -5
  54. code_puppy/mcp_/manager.py +65 -0
  55. code_puppy/mcp_/mcp_logs.py +224 -0
  56. code_puppy/mcp_/registry.py +6 -6
  57. code_puppy/messaging/__init__.py +184 -2
  58. code_puppy/messaging/bus.py +610 -0
  59. code_puppy/messaging/commands.py +167 -0
  60. code_puppy/messaging/markdown_patches.py +57 -0
  61. code_puppy/messaging/message_queue.py +3 -3
  62. code_puppy/messaging/messages.py +470 -0
  63. code_puppy/messaging/renderers.py +43 -141
  64. code_puppy/messaging/rich_renderer.py +900 -0
  65. code_puppy/messaging/spinner/console_spinner.py +39 -2
  66. code_puppy/model_factory.py +292 -53
  67. code_puppy/model_utils.py +57 -48
  68. code_puppy/models.json +19 -5
  69. code_puppy/plugins/__init__.py +152 -10
  70. code_puppy/plugins/chatgpt_oauth/config.py +20 -12
  71. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  72. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
  73. code_puppy/plugins/chatgpt_oauth/test_plugin.py +30 -13
  74. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  75. code_puppy/plugins/claude_code_oauth/config.py +15 -11
  76. code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
  77. code_puppy/plugins/claude_code_oauth/utils.py +6 -1
  78. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  79. code_puppy/plugins/oauth_puppy_html.py +3 -0
  80. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -134
  81. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  82. code_puppy/plugins/shell_safety/register_callbacks.py +77 -3
  83. code_puppy/prompts/codex_system_prompt.md +310 -0
  84. code_puppy/pydantic_patches.py +131 -0
  85. code_puppy/session_storage.py +2 -1
  86. code_puppy/status_display.py +7 -5
  87. code_puppy/terminal_utils.py +126 -0
  88. code_puppy/tools/agent_tools.py +131 -70
  89. code_puppy/tools/browser/browser_control.py +10 -14
  90. code_puppy/tools/browser/browser_interactions.py +20 -28
  91. code_puppy/tools/browser/browser_locators.py +27 -29
  92. code_puppy/tools/browser/browser_navigation.py +9 -9
  93. code_puppy/tools/browser/browser_screenshot.py +12 -14
  94. code_puppy/tools/browser/browser_scripts.py +17 -29
  95. code_puppy/tools/browser/browser_workflows.py +24 -25
  96. code_puppy/tools/browser/camoufox_manager.py +22 -26
  97. code_puppy/tools/command_runner.py +410 -88
  98. code_puppy/tools/common.py +51 -38
  99. code_puppy/tools/file_modifications.py +98 -24
  100. code_puppy/tools/file_operations.py +113 -202
  101. code_puppy/version_checker.py +28 -13
  102. {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
  103. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/METADATA +3 -8
  104. code_puppy-0.0.323.dist-info/RECORD +168 -0
  105. code_puppy/tui_state.py +0 -55
  106. code_puppy-0.0.287.dist-info/RECORD +0 -153
  107. {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
  108. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
  109. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
  110. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
@@ -2,6 +2,7 @@
2
2
  Console spinner implementation for CLI mode using Rich's Live Display.
3
3
  """
4
4
 
5
+ import platform
5
6
  import threading
6
7
  import time
7
8
 
@@ -43,6 +44,9 @@ class ConsoleSpinner(SpinnerBase):
43
44
  if self._thread and self._thread.is_alive():
44
45
  return
45
46
 
47
+ # Print blank line before spinner for visual separation from content
48
+ self.console.print()
49
+
46
50
  # Create a Live display for the spinner
47
51
  self._live = Live(
48
52
  self._generate_spinner_panel(),
@@ -75,6 +79,33 @@ class ConsoleSpinner(SpinnerBase):
75
79
 
76
80
  self._thread = None
77
81
 
82
+ # Windows-specific cleanup: Rich's Live display can leave terminal in corrupted state
83
+ if platform.system() == "Windows":
84
+ import sys
85
+
86
+ try:
87
+ # Reset ANSI formatting for both stdout and stderr
88
+ sys.stdout.write("\x1b[0m") # Reset all attributes
89
+ sys.stdout.flush()
90
+ sys.stderr.write("\x1b[0m")
91
+ sys.stderr.flush()
92
+
93
+ # Clear the line and reposition cursor
94
+ sys.stdout.write("\r") # Return to start of line
95
+ sys.stdout.write("\x1b[K") # Clear to end of line
96
+ sys.stdout.flush()
97
+
98
+ # Flush keyboard input buffer to clear any stuck keys
99
+ try:
100
+ import msvcrt
101
+
102
+ while msvcrt.kbhit():
103
+ msvcrt.getch()
104
+ except ImportError:
105
+ pass # msvcrt not available (not Windows or different Python impl)
106
+ except Exception:
107
+ pass # Fail silently if cleanup doesn't work
108
+
78
109
  # Unregister this spinner from global management
79
110
  from . import unregister_spinner
80
111
 
@@ -127,7 +158,10 @@ class ConsoleSpinner(SpinnerBase):
127
158
  # Short sleep to control animation speed
128
159
  time.sleep(0.05)
129
160
  except Exception as e:
130
- print(f"\nSpinner error: {e}")
161
+ # Note: Using sys.stderr - can't use messaging during spinner
162
+ import sys
163
+
164
+ sys.stderr.write(f"\nSpinner error: {e}\n")
131
165
  self._is_spinning = False
132
166
 
133
167
  def pause(self):
@@ -168,11 +202,14 @@ class ConsoleSpinner(SpinnerBase):
168
202
  sys.stdout.write("\x1b[K") # Clear to end of line
169
203
  sys.stdout.flush()
170
204
 
205
+ # Print blank line before spinner for visual separation
206
+ self.console.print()
207
+
171
208
  self._live = Live(
172
209
  self._generate_spinner_panel(),
173
210
  console=self.console,
174
211
  refresh_per_second=20,
175
- transient=True, # Clear the spinner line when stopped (no puppy litter!)
212
+ transient=True, # Clear spinner line when stopped
176
213
  auto_refresh=False,
177
214
  )
178
215
  self._live.start()
@@ -23,17 +23,35 @@ from pydantic_ai.providers.openrouter import OpenRouterProvider
23
23
  from pydantic_ai.settings import ModelSettings
24
24
 
25
25
  from code_puppy.messaging import emit_warning
26
- from code_puppy.plugins.chatgpt_oauth.config import get_chatgpt_models_path
27
- from code_puppy.plugins.claude_code_oauth.config import get_claude_models_path
28
- from code_puppy.plugins.claude_code_oauth.utils import load_claude_models_filtered
29
26
 
30
27
  from . import callbacks
31
28
  from .claude_cache_client import ClaudeCacheAsyncClient, patch_anthropic_client_messages
32
- from .config import EXTRA_MODELS_FILE
29
+ from .config import EXTRA_MODELS_FILE, get_value
33
30
  from .http_utils import create_async_client, get_cert_bundle_path, get_http2
34
31
  from .round_robin_model import RoundRobinModel
35
32
 
36
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
+
37
55
  def make_model_settings(
38
56
  model_name: str, max_tokens: int | None = None
39
57
  ) -> ModelSettings:
@@ -42,10 +60,12 @@ def make_model_settings(
42
60
  This handles model-specific settings:
43
61
  - GPT-5 models: reasoning_effort and verbosity (non-codex only)
44
62
  - Claude/Anthropic models: extended_thinking and budget_tokens
63
+ - Automatic max_tokens calculation based on model context length
45
64
 
46
65
  Args:
47
66
  model_name: The name of the model to create settings for.
48
- max_tokens: Optional max tokens limit to include in settings.
67
+ max_tokens: Optional max tokens limit. If None, automatically calculated
68
+ as: max(2048, min(15% of context_length, 65536))
49
69
 
50
70
  Returns:
51
71
  Appropriate ModelSettings subclass instance for the model.
@@ -57,8 +77,21 @@ def make_model_settings(
57
77
  )
58
78
 
59
79
  model_settings_dict: dict = {}
60
- if max_tokens is not None:
61
- model_settings_dict["max_tokens"] = max_tokens
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
62
95
  effective_settings = get_effective_model_settings(model_name)
63
96
  model_settings_dict.update(effective_settings)
64
97
 
@@ -75,8 +108,14 @@ def make_model_settings(
75
108
  # Handle Anthropic extended thinking settings
76
109
  # Remove top_p as Anthropic doesn't support it with extended thinking
77
110
  model_settings_dict.pop("top_p", None)
78
- extended_thinking = effective_settings.get("extended_thinking", False)
79
- budget_tokens = effective_settings.get("budget_tokens")
111
+
112
+ # Claude extended thinking requires temperature=1.0 (API restriction)
113
+ # Default to 1.0 if not explicitly set by user
114
+ if model_settings_dict.get("temperature") is None:
115
+ model_settings_dict["temperature"] = 1.0
116
+
117
+ extended_thinking = effective_settings.get("extended_thinking", True)
118
+ budget_tokens = effective_settings.get("budget_tokens", 10000)
80
119
  if extended_thinking and budget_tokens:
81
120
  model_settings_dict["anthropic_thinking"] = {
82
121
  "type": "enabled",
@@ -106,10 +145,10 @@ def get_custom_config(model_config):
106
145
  for key, value in custom_config.get("headers", {}).items():
107
146
  if value.startswith("$"):
108
147
  env_var_name = value[1:]
109
- resolved_value = os.environ.get(env_var_name)
148
+ resolved_value = get_api_key(env_var_name)
110
149
  if resolved_value is None:
111
150
  emit_warning(
112
- f"Environment variable '{env_var_name}' is not set for custom endpoint header '{key}'. Proceeding with empty value."
151
+ f"'{env_var_name}' is not set (check config or environment) for custom endpoint header '{key}'. Proceeding with empty value."
113
152
  )
114
153
  resolved_value = ""
115
154
  value = resolved_value
@@ -119,10 +158,10 @@ def get_custom_config(model_config):
119
158
  for token in tokens:
120
159
  if token.startswith("$"):
121
160
  env_var = token[1:]
122
- resolved_value = os.environ.get(env_var)
161
+ resolved_value = get_api_key(env_var)
123
162
  if resolved_value is None:
124
163
  emit_warning(
125
- f"Environment variable '{env_var}' is not set for custom endpoint header '{key}'. Proceeding with empty value."
164
+ f"'{env_var}' is not set (check config or environment) for custom endpoint header '{key}'. Proceeding with empty value."
126
165
  )
127
166
  resolved_values.append("")
128
167
  else:
@@ -135,10 +174,10 @@ def get_custom_config(model_config):
135
174
  if "api_key" in custom_config:
136
175
  if custom_config["api_key"].startswith("$"):
137
176
  env_var_name = custom_config["api_key"][1:]
138
- api_key = os.environ.get(env_var_name)
177
+ api_key = get_api_key(env_var_name)
139
178
  if api_key is None:
140
179
  emit_warning(
141
- f"Environment variable '{env_var_name}' is not set for custom endpoint API key; proceeding without API key."
180
+ f"API key '{env_var_name}' is not set (checked config and environment); proceeding without API key."
142
181
  )
143
182
  else:
144
183
  api_key = custom_config["api_key"]
@@ -171,36 +210,51 @@ class ModelFactory:
171
210
  with open(MODELS_FILE, "r") as f:
172
211
  config = json.load(f)
173
212
 
174
- extra_sources = [
175
- (pathlib.Path(EXTRA_MODELS_FILE), "extra models"),
176
- (get_chatgpt_models_path(), "ChatGPT OAuth models"),
177
- (get_claude_models_path(), "Claude Code OAuth models"),
213
+ # Import OAuth model file paths from main config
214
+ from code_puppy.config import (
215
+ CHATGPT_MODELS_FILE,
216
+ CLAUDE_MODELS_FILE,
217
+ GEMINI_MODELS_FILE,
218
+ )
219
+
220
+ # Build list of extra model sources
221
+ extra_sources: list[tuple[pathlib.Path, str, bool]] = [
222
+ (pathlib.Path(EXTRA_MODELS_FILE), "extra models", False),
223
+ (pathlib.Path(CHATGPT_MODELS_FILE), "ChatGPT OAuth models", False),
224
+ (pathlib.Path(CLAUDE_MODELS_FILE), "Claude Code OAuth models", True),
225
+ (pathlib.Path(GEMINI_MODELS_FILE), "Gemini OAuth models", False),
178
226
  ]
179
227
 
180
- for source_path, label in extra_sources:
181
- # source_path is already a Path object from the functions above
182
- # Use hasattr to check if it's Path-like (works with mocks too)
183
- if hasattr(source_path, "exists"):
184
- path = source_path
185
- else:
186
- path = pathlib.Path(source_path).expanduser()
187
- if not path.exists():
228
+ for source_path, label, use_filtered in extra_sources:
229
+ if not source_path.exists():
188
230
  continue
189
231
  try:
190
232
  # Use filtered loading for Claude Code OAuth models to show only latest versions
191
- if "Claude Code OAuth" in label:
192
- extra_config = load_claude_models_filtered()
233
+ if use_filtered:
234
+ try:
235
+ from code_puppy.plugins.claude_code_oauth.utils import (
236
+ load_claude_models_filtered,
237
+ )
238
+
239
+ extra_config = load_claude_models_filtered()
240
+ except ImportError:
241
+ # Plugin not available, fall back to standard JSON loading
242
+ logging.getLogger(__name__).debug(
243
+ f"claude_code_oauth plugin not available, loading {label} as plain JSON"
244
+ )
245
+ with open(source_path, "r") as f:
246
+ extra_config = json.load(f)
193
247
  else:
194
- with open(path, "r") as f:
248
+ with open(source_path, "r") as f:
195
249
  extra_config = json.load(f)
196
250
  config.update(extra_config)
197
251
  except json.JSONDecodeError as exc:
198
252
  logging.getLogger(__name__).warning(
199
- f"Failed to load {label} config from {path}: Invalid JSON - {exc}"
253
+ f"Failed to load {label} config from {source_path}: Invalid JSON - {exc}"
200
254
  )
201
255
  except Exception as exc:
202
256
  logging.getLogger(__name__).warning(
203
- f"Failed to load {label} config from {path}: {exc}"
257
+ f"Failed to load {label} config from {source_path}: {exc}"
204
258
  )
205
259
  return config
206
260
 
@@ -218,10 +272,10 @@ class ModelFactory:
218
272
  model_type = model_config.get("type")
219
273
 
220
274
  if model_type == "gemini":
221
- api_key = os.environ.get("GEMINI_API_KEY")
275
+ api_key = get_api_key("GEMINI_API_KEY")
222
276
  if not api_key:
223
277
  emit_warning(
224
- f"GEMINI_API_KEY is not set; skipping Gemini model '{model_config.get('name')}'."
278
+ f"GEMINI_API_KEY is not set (check config or environment); skipping Gemini model '{model_config.get('name')}'."
225
279
  )
226
280
  return None
227
281
 
@@ -231,10 +285,10 @@ class ModelFactory:
231
285
  return model
232
286
 
233
287
  elif model_type == "openai":
234
- api_key = os.environ.get("OPENAI_API_KEY")
288
+ api_key = get_api_key("OPENAI_API_KEY")
235
289
  if not api_key:
236
290
  emit_warning(
237
- f"OPENAI_API_KEY is not set; skipping OpenAI model '{model_config.get('name')}'."
291
+ f"OPENAI_API_KEY is not set (check config or environment); skipping OpenAI model '{model_config.get('name')}'."
238
292
  )
239
293
  return None
240
294
 
@@ -248,10 +302,10 @@ class ModelFactory:
248
302
  return model
249
303
 
250
304
  elif model_type == "anthropic":
251
- api_key = os.environ.get("ANTHROPIC_API_KEY", None)
305
+ api_key = get_api_key("ANTHROPIC_API_KEY")
252
306
  if not api_key:
253
307
  emit_warning(
254
- f"ANTHROPIC_API_KEY is not set; skipping Anthropic model '{model_config.get('name')}'."
308
+ f"ANTHROPIC_API_KEY is not set (check config or environment); skipping Anthropic model '{model_config.get('name')}'."
255
309
  )
256
310
  return None
257
311
 
@@ -265,9 +319,21 @@ class ModelFactory:
265
319
  http2=http2_enabled,
266
320
  )
267
321
 
322
+ # Check if interleaved thinking is enabled for this model
323
+ # Only applies to Claude 4 models (Opus 4.5, Opus 4.1, Opus 4, Sonnet 4)
324
+ from code_puppy.config import get_effective_model_settings
325
+
326
+ effective_settings = get_effective_model_settings(model_name)
327
+ interleaved_thinking = effective_settings.get("interleaved_thinking", False)
328
+
329
+ default_headers = {}
330
+ if interleaved_thinking:
331
+ default_headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
332
+
268
333
  anthropic_client = AsyncAnthropic(
269
334
  api_key=api_key,
270
335
  http_client=client,
336
+ default_headers=default_headers if default_headers else None,
271
337
  )
272
338
 
273
339
  # Ensure cache_control is injected at the Anthropic SDK layer
@@ -297,10 +363,21 @@ class ModelFactory:
297
363
  http2=http2_enabled,
298
364
  )
299
365
 
366
+ # Check if interleaved thinking is enabled for this model
367
+ from code_puppy.config import get_effective_model_settings
368
+
369
+ effective_settings = get_effective_model_settings(model_name)
370
+ interleaved_thinking = effective_settings.get("interleaved_thinking", False)
371
+
372
+ default_headers = {}
373
+ if interleaved_thinking:
374
+ default_headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
375
+
300
376
  anthropic_client = AsyncAnthropic(
301
377
  base_url=url,
302
378
  http_client=client,
303
379
  api_key=api_key,
380
+ default_headers=default_headers if default_headers else None,
304
381
  )
305
382
 
306
383
  # Ensure cache_control is injected at the Anthropic SDK layer
@@ -316,6 +393,31 @@ class ModelFactory:
316
393
  )
317
394
  return None
318
395
 
396
+ # Check if interleaved thinking is enabled (defaults to True for OAuth models)
397
+ from code_puppy.config import get_effective_model_settings
398
+
399
+ effective_settings = get_effective_model_settings(model_name)
400
+ interleaved_thinking = effective_settings.get("interleaved_thinking", True)
401
+
402
+ # Handle anthropic-beta header based on interleaved_thinking setting
403
+ if "anthropic-beta" in headers:
404
+ beta_parts = [p.strip() for p in headers["anthropic-beta"].split(",")]
405
+ if interleaved_thinking:
406
+ # Ensure interleaved-thinking is in the header
407
+ if "interleaved-thinking-2025-05-14" not in beta_parts:
408
+ beta_parts.append("interleaved-thinking-2025-05-14")
409
+ else:
410
+ # Remove interleaved-thinking from the header
411
+ beta_parts = [
412
+ p for p in beta_parts if "interleaved-thinking" not in p
413
+ ]
414
+ headers["anthropic-beta"] = ",".join(beta_parts) if beta_parts else None
415
+ if headers.get("anthropic-beta") is None:
416
+ del headers["anthropic-beta"]
417
+ elif interleaved_thinking:
418
+ # No existing beta header, add one for interleaved thinking
419
+ headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
420
+
319
421
  # Use a dedicated client wrapper that injects cache_control on /v1/messages
320
422
  if verify is None:
321
423
  verify = get_cert_bundle_path()
@@ -349,10 +451,10 @@ class ModelFactory:
349
451
  )
350
452
  azure_endpoint = azure_endpoint_config
351
453
  if azure_endpoint_config.startswith("$"):
352
- azure_endpoint = os.environ.get(azure_endpoint_config[1:])
454
+ azure_endpoint = get_api_key(azure_endpoint_config[1:])
353
455
  if not azure_endpoint:
354
456
  emit_warning(
355
- 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')}'."
457
+ 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')}'."
356
458
  )
357
459
  return None
358
460
 
@@ -363,10 +465,10 @@ class ModelFactory:
363
465
  )
364
466
  api_version = api_version_config
365
467
  if api_version_config.startswith("$"):
366
- api_version = os.environ.get(api_version_config[1:])
468
+ api_version = get_api_key(api_version_config[1:])
367
469
  if not api_version:
368
470
  emit_warning(
369
- 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')}'."
471
+ 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')}'."
370
472
  )
371
473
  return None
372
474
 
@@ -377,10 +479,10 @@ class ModelFactory:
377
479
  )
378
480
  api_key = api_key_config
379
481
  if api_key_config.startswith("$"):
380
- api_key = os.environ.get(api_key_config[1:])
482
+ api_key = get_api_key(api_key_config[1:])
381
483
  if not api_key:
382
484
  emit_warning(
383
- 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')}'."
485
+ 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')}'."
384
486
  )
385
487
  return None
386
488
 
@@ -414,10 +516,10 @@ class ModelFactory:
414
516
  setattr(model, "provider", provider)
415
517
  return model
416
518
  elif model_type == "zai_coding":
417
- api_key = os.getenv("ZAI_API_KEY")
519
+ api_key = get_api_key("ZAI_API_KEY")
418
520
  if not api_key:
419
521
  emit_warning(
420
- f"ZAI_API_KEY is not set; skipping ZAI coding model '{model_config.get('name')}'."
522
+ f"ZAI_API_KEY is not set (check config or environment); skipping ZAI coding model '{model_config.get('name')}'."
421
523
  )
422
524
  return None
423
525
  provider = OpenAIProvider(
@@ -431,10 +533,10 @@ class ModelFactory:
431
533
  setattr(zai_model, "provider", provider)
432
534
  return zai_model
433
535
  elif model_type == "zai_api":
434
- api_key = os.getenv("ZAI_API_KEY")
536
+ api_key = get_api_key("ZAI_API_KEY")
435
537
  if not api_key:
436
538
  emit_warning(
437
- f"ZAI_API_KEY is not set; skipping ZAI API model '{model_config.get('name')}'."
539
+ f"ZAI_API_KEY is not set (check config or environment); skipping ZAI API model '{model_config.get('name')}'."
438
540
  )
439
541
  return None
440
542
  provider = OpenAIProvider(
@@ -510,21 +612,21 @@ class ModelFactory:
510
612
  if api_key_config.startswith("$"):
511
613
  # It's an environment variable reference
512
614
  env_var_name = api_key_config[1:] # Remove the $ prefix
513
- api_key = os.environ.get(env_var_name)
615
+ api_key = get_api_key(env_var_name)
514
616
  if api_key is None:
515
617
  emit_warning(
516
- f"OpenRouter API key environment variable '{env_var_name}' not found or is empty; skipping model '{model_config.get('name')}'."
618
+ f"OpenRouter API key '{env_var_name}' not found (check config or environment); skipping model '{model_config.get('name')}'."
517
619
  )
518
620
  return None
519
621
  else:
520
622
  # It's a raw API key value
521
623
  api_key = api_key_config
522
624
  else:
523
- # No API key in config, try to get it from the default environment variable
524
- api_key = os.environ.get("OPENROUTER_API_KEY")
625
+ # No API key in config, try to get it from config or the default environment variable
626
+ api_key = get_api_key("OPENROUTER_API_KEY")
525
627
  if api_key is None:
526
628
  emit_warning(
527
- f"OPENROUTER_API_KEY is not set; skipping OpenRouter model '{model_config.get('name')}'."
629
+ f"OPENROUTER_API_KEY is not set (check config or environment); skipping OpenRouter model '{model_config.get('name')}'."
528
630
  )
529
631
  return None
530
632
 
@@ -534,6 +636,143 @@ class ModelFactory:
534
636
  setattr(model, "provider", provider)
535
637
  return model
536
638
 
639
+ elif model_type == "gemini_oauth":
640
+ # Gemini OAuth models use the Code Assist API (cloudcode-pa.googleapis.com)
641
+ # This is a different API than the standard Generative Language API
642
+ try:
643
+ # Try user plugin first, then built-in plugin
644
+ try:
645
+ from gemini_oauth.config import GEMINI_OAUTH_CONFIG
646
+ from gemini_oauth.utils import (
647
+ get_project_id,
648
+ get_valid_access_token,
649
+ )
650
+ except ImportError:
651
+ from code_puppy.plugins.gemini_oauth.config import (
652
+ GEMINI_OAUTH_CONFIG,
653
+ )
654
+ from code_puppy.plugins.gemini_oauth.utils import (
655
+ get_project_id,
656
+ get_valid_access_token,
657
+ )
658
+ except ImportError as exc:
659
+ emit_warning(
660
+ f"Gemini OAuth plugin not available; skipping model '{model_config.get('name')}'. "
661
+ f"Error: {exc}"
662
+ )
663
+ return None
664
+
665
+ # Get a valid access token (refreshing if needed)
666
+ access_token = get_valid_access_token()
667
+ if not access_token:
668
+ emit_warning(
669
+ f"Failed to get valid Gemini OAuth token; skipping model '{model_config.get('name')}'. "
670
+ "Run /gemini-auth to re-authenticate."
671
+ )
672
+ return None
673
+
674
+ # Get project ID from stored tokens
675
+ project_id = get_project_id()
676
+ if not project_id:
677
+ emit_warning(
678
+ f"No Code Assist project ID found; skipping model '{model_config.get('name')}'. "
679
+ "Run /gemini-auth to re-authenticate."
680
+ )
681
+ return None
682
+
683
+ # Import the Code Assist model wrapper
684
+ from code_puppy.gemini_code_assist import GeminiCodeAssistModel
685
+
686
+ # Create the Code Assist model
687
+ model = GeminiCodeAssistModel(
688
+ model_name=model_config["name"],
689
+ access_token=access_token,
690
+ project_id=project_id,
691
+ api_base_url=GEMINI_OAUTH_CONFIG["api_base_url"],
692
+ api_version=GEMINI_OAUTH_CONFIG["api_version"],
693
+ )
694
+ return model
695
+
696
+ elif model_type == "chatgpt_oauth":
697
+ # ChatGPT OAuth models use the Codex API at chatgpt.com
698
+ try:
699
+ try:
700
+ from chatgpt_oauth.config import CHATGPT_OAUTH_CONFIG
701
+ from chatgpt_oauth.utils import (
702
+ get_valid_access_token,
703
+ load_stored_tokens,
704
+ )
705
+ except ImportError:
706
+ from code_puppy.plugins.chatgpt_oauth.config import (
707
+ CHATGPT_OAUTH_CONFIG,
708
+ )
709
+ from code_puppy.plugins.chatgpt_oauth.utils import (
710
+ get_valid_access_token,
711
+ load_stored_tokens,
712
+ )
713
+ except ImportError as exc:
714
+ emit_warning(
715
+ f"ChatGPT OAuth plugin not available; skipping model '{model_config.get('name')}'. "
716
+ f"Error: {exc}"
717
+ )
718
+ return None
719
+
720
+ # Get a valid access token (refreshing if needed)
721
+ access_token = get_valid_access_token()
722
+ if not access_token:
723
+ emit_warning(
724
+ f"Failed to get valid ChatGPT OAuth token; skipping model '{model_config.get('name')}'. "
725
+ "Run /chatgpt-auth to authenticate."
726
+ )
727
+ return None
728
+
729
+ # Get account_id from stored tokens (required for ChatGPT-Account-Id header)
730
+ tokens = load_stored_tokens()
731
+ account_id = tokens.get("account_id", "") if tokens else ""
732
+ if not account_id:
733
+ emit_warning(
734
+ f"No account_id found in ChatGPT OAuth tokens; skipping model '{model_config.get('name')}'. "
735
+ "Run /chatgpt-auth to re-authenticate."
736
+ )
737
+ return None
738
+
739
+ # Build headers for ChatGPT Codex API
740
+ originator = CHATGPT_OAUTH_CONFIG.get("originator", "codex_cli_rs")
741
+ client_version = CHATGPT_OAUTH_CONFIG.get("client_version", "0.72.0")
742
+
743
+ headers = {
744
+ "ChatGPT-Account-Id": account_id,
745
+ "originator": originator,
746
+ "User-Agent": f"{originator}/{client_version}",
747
+ }
748
+ # Merge with any headers from model config
749
+ config_headers = model_config.get("custom_endpoint", {}).get("headers", {})
750
+ headers.update(config_headers)
751
+
752
+ # Get base URL - Codex API uses chatgpt.com, not api.openai.com
753
+ base_url = model_config.get("custom_endpoint", {}).get(
754
+ "url", CHATGPT_OAUTH_CONFIG["api_base_url"]
755
+ )
756
+
757
+ # Create HTTP client with Codex interceptor for store=false injection
758
+ from code_puppy.chatgpt_codex_client import create_codex_async_client
759
+
760
+ verify = get_cert_bundle_path()
761
+ client = create_codex_async_client(headers=headers, verify=verify)
762
+
763
+ provider = OpenAIProvider(
764
+ api_key=access_token,
765
+ base_url=base_url,
766
+ http_client=client,
767
+ )
768
+
769
+ # ChatGPT Codex API only supports Responses format
770
+ model = OpenAIResponsesModel(
771
+ model_name=model_config["name"], provider=provider
772
+ )
773
+ setattr(model, "provider", provider)
774
+ return model
775
+
537
776
  elif model_type == "round_robin":
538
777
  # Get the list of model names to use in the round-robin
539
778
  model_names = model_config.get("models")