code-puppy 0.0.302__py3-none-any.whl → 0.0.335__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 (87) hide show
  1. code_puppy/agents/base_agent.py +343 -35
  2. code_puppy/chatgpt_codex_client.py +283 -0
  3. code_puppy/cli_runner.py +898 -0
  4. code_puppy/command_line/add_model_menu.py +23 -1
  5. code_puppy/command_line/autosave_menu.py +271 -35
  6. code_puppy/command_line/colors_menu.py +520 -0
  7. code_puppy/command_line/command_handler.py +8 -2
  8. code_puppy/command_line/config_commands.py +82 -10
  9. code_puppy/command_line/core_commands.py +70 -7
  10. code_puppy/command_line/diff_menu.py +5 -0
  11. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  12. code_puppy/command_line/mcp/edit_command.py +3 -1
  13. code_puppy/command_line/mcp/handler.py +7 -2
  14. code_puppy/command_line/mcp/install_command.py +8 -3
  15. code_puppy/command_line/mcp/install_menu.py +5 -1
  16. code_puppy/command_line/mcp/logs_command.py +173 -64
  17. code_puppy/command_line/mcp/restart_command.py +7 -2
  18. code_puppy/command_line/mcp/search_command.py +10 -4
  19. code_puppy/command_line/mcp/start_all_command.py +16 -6
  20. code_puppy/command_line/mcp/start_command.py +3 -1
  21. code_puppy/command_line/mcp/status_command.py +2 -1
  22. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  23. code_puppy/command_line/mcp/stop_command.py +3 -1
  24. code_puppy/command_line/mcp/wizard_utils.py +10 -4
  25. code_puppy/command_line/model_settings_menu.py +58 -7
  26. code_puppy/command_line/motd.py +13 -7
  27. code_puppy/command_line/onboarding_slides.py +180 -0
  28. code_puppy/command_line/onboarding_wizard.py +340 -0
  29. code_puppy/command_line/prompt_toolkit_completion.py +16 -2
  30. code_puppy/command_line/session_commands.py +11 -4
  31. code_puppy/config.py +106 -17
  32. code_puppy/http_utils.py +155 -196
  33. code_puppy/keymap.py +8 -0
  34. code_puppy/main.py +5 -828
  35. code_puppy/mcp_/__init__.py +17 -0
  36. code_puppy/mcp_/blocking_startup.py +61 -32
  37. code_puppy/mcp_/config_wizard.py +5 -1
  38. code_puppy/mcp_/managed_server.py +23 -3
  39. code_puppy/mcp_/manager.py +65 -0
  40. code_puppy/mcp_/mcp_logs.py +224 -0
  41. code_puppy/messaging/__init__.py +20 -4
  42. code_puppy/messaging/bus.py +64 -0
  43. code_puppy/messaging/markdown_patches.py +57 -0
  44. code_puppy/messaging/messages.py +16 -0
  45. code_puppy/messaging/renderers.py +21 -9
  46. code_puppy/messaging/rich_renderer.py +113 -67
  47. code_puppy/messaging/spinner/console_spinner.py +34 -0
  48. code_puppy/model_factory.py +271 -45
  49. code_puppy/model_utils.py +57 -48
  50. code_puppy/models.json +21 -7
  51. code_puppy/plugins/__init__.py +12 -0
  52. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  53. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  54. code_puppy/plugins/antigravity_oauth/antigravity_model.py +612 -0
  55. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  56. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  57. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  58. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  59. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  60. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  61. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  62. code_puppy/plugins/antigravity_oauth/transport.py +595 -0
  63. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  64. code_puppy/plugins/chatgpt_oauth/config.py +5 -1
  65. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  66. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +5 -3
  67. code_puppy/plugins/chatgpt_oauth/test_plugin.py +26 -11
  68. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  69. code_puppy/plugins/claude_code_oauth/register_callbacks.py +30 -0
  70. code_puppy/plugins/claude_code_oauth/utils.py +1 -0
  71. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
  72. code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
  73. code_puppy/prompts/codex_system_prompt.md +310 -0
  74. code_puppy/pydantic_patches.py +131 -0
  75. code_puppy/reopenable_async_client.py +8 -8
  76. code_puppy/terminal_utils.py +291 -0
  77. code_puppy/tools/agent_tools.py +34 -9
  78. code_puppy/tools/command_runner.py +344 -27
  79. code_puppy/tools/file_operations.py +33 -45
  80. code_puppy/uvx_detection.py +242 -0
  81. {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models.json +21 -7
  82. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/METADATA +30 -1
  83. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/RECORD +87 -64
  84. {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models_dev_api.json +0 -0
  85. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/WHEEL +0 -0
  86. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/entry_points.txt +0 -0
  87. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.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
 
@@ -171,6 +202,9 @@ class ConsoleSpinner(SpinnerBase):
171
202
  sys.stdout.write("\x1b[K") # Clear to end of line
172
203
  sys.stdout.flush()
173
204
 
205
+ # Print blank line before spinner for visual separation
206
+ self.console.print()
207
+
174
208
  self._live = Live(
175
209
  self._generate_spinner_panel(),
176
210
  console=self.console,
@@ -4,7 +4,6 @@ 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
9
  from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
@@ -26,11 +25,32 @@ from code_puppy.messaging import emit_warning
26
25
 
27
26
  from . import callbacks
28
27
  from .claude_cache_client import ClaudeCacheAsyncClient, patch_anthropic_client_messages
29
- from .config import EXTRA_MODELS_FILE
28
+ from .config import EXTRA_MODELS_FILE, get_value
30
29
  from .http_utils import create_async_client, get_cert_bundle_path, get_http2
31
30
  from .round_robin_model import RoundRobinModel
32
31
 
33
32
 
33
+ def get_api_key(env_var_name: str) -> str | None:
34
+ """Get an API key from config first, then fall back to environment variable.
35
+
36
+ This allows users to set API keys via `/set KIMI_API_KEY=xxx` in addition to
37
+ setting them as environment variables.
38
+
39
+ Args:
40
+ env_var_name: The name of the environment variable (e.g., "OPENAI_API_KEY")
41
+
42
+ Returns:
43
+ The API key value, or None if not found in either config or environment.
44
+ """
45
+ # First check config (case-insensitive key lookup)
46
+ config_value = get_value(env_var_name.lower())
47
+ if config_value:
48
+ return config_value
49
+
50
+ # Fall back to environment variable
51
+ return os.environ.get(env_var_name)
52
+
53
+
34
54
  def make_model_settings(
35
55
  model_name: str, max_tokens: int | None = None
36
56
  ) -> ModelSettings:
@@ -87,8 +107,14 @@ def make_model_settings(
87
107
  # Handle Anthropic extended thinking settings
88
108
  # Remove top_p as Anthropic doesn't support it with extended thinking
89
109
  model_settings_dict.pop("top_p", None)
90
- extended_thinking = effective_settings.get("extended_thinking", False)
91
- budget_tokens = effective_settings.get("budget_tokens")
110
+
111
+ # Claude extended thinking requires temperature=1.0 (API restriction)
112
+ # Default to 1.0 if not explicitly set by user
113
+ if model_settings_dict.get("temperature") is None:
114
+ model_settings_dict["temperature"] = 1.0
115
+
116
+ extended_thinking = effective_settings.get("extended_thinking", True)
117
+ budget_tokens = effective_settings.get("budget_tokens", 10000)
92
118
  if extended_thinking and budget_tokens:
93
119
  model_settings_dict["anthropic_thinking"] = {
94
120
  "type": "enabled",
@@ -118,10 +144,10 @@ def get_custom_config(model_config):
118
144
  for key, value in custom_config.get("headers", {}).items():
119
145
  if value.startswith("$"):
120
146
  env_var_name = value[1:]
121
- resolved_value = os.environ.get(env_var_name)
147
+ resolved_value = get_api_key(env_var_name)
122
148
  if resolved_value is None:
123
149
  emit_warning(
124
- f"Environment variable '{env_var_name}' is not set for custom endpoint header '{key}'. Proceeding with empty value."
150
+ f"'{env_var_name}' is not set (check config or environment) for custom endpoint header '{key}'. Proceeding with empty value."
125
151
  )
126
152
  resolved_value = ""
127
153
  value = resolved_value
@@ -131,10 +157,10 @@ def get_custom_config(model_config):
131
157
  for token in tokens:
132
158
  if token.startswith("$"):
133
159
  env_var = token[1:]
134
- resolved_value = os.environ.get(env_var)
160
+ resolved_value = get_api_key(env_var)
135
161
  if resolved_value is None:
136
162
  emit_warning(
137
- f"Environment variable '{env_var}' is not set for custom endpoint header '{key}'. Proceeding with empty value."
163
+ f"'{env_var}' is not set (check config or environment) for custom endpoint header '{key}'. Proceeding with empty value."
138
164
  )
139
165
  resolved_values.append("")
140
166
  else:
@@ -147,10 +173,10 @@ def get_custom_config(model_config):
147
173
  if "api_key" in custom_config:
148
174
  if custom_config["api_key"].startswith("$"):
149
175
  env_var_name = custom_config["api_key"][1:]
150
- api_key = os.environ.get(env_var_name)
176
+ api_key = get_api_key(env_var_name)
151
177
  if api_key is None:
152
178
  emit_warning(
153
- f"Environment variable '{env_var_name}' is not set for custom endpoint API key; proceeding without API key."
179
+ f"API key '{env_var_name}' is not set (checked config and environment); proceeding without API key."
154
180
  )
155
181
  else:
156
182
  api_key = custom_config["api_key"]
@@ -185,6 +211,7 @@ class ModelFactory:
185
211
 
186
212
  # Import OAuth model file paths from main config
187
213
  from code_puppy.config import (
214
+ ANTIGRAVITY_MODELS_FILE,
188
215
  CHATGPT_MODELS_FILE,
189
216
  CLAUDE_MODELS_FILE,
190
217
  GEMINI_MODELS_FILE,
@@ -196,6 +223,7 @@ class ModelFactory:
196
223
  (pathlib.Path(CHATGPT_MODELS_FILE), "ChatGPT OAuth models", False),
197
224
  (pathlib.Path(CLAUDE_MODELS_FILE), "Claude Code OAuth models", True),
198
225
  (pathlib.Path(GEMINI_MODELS_FILE), "Gemini OAuth models", False),
226
+ (pathlib.Path(ANTIGRAVITY_MODELS_FILE), "Antigravity OAuth models", False),
199
227
  ]
200
228
 
201
229
  for source_path, label, use_filtered in extra_sources:
@@ -245,10 +273,10 @@ class ModelFactory:
245
273
  model_type = model_config.get("type")
246
274
 
247
275
  if model_type == "gemini":
248
- api_key = os.environ.get("GEMINI_API_KEY")
276
+ api_key = get_api_key("GEMINI_API_KEY")
249
277
  if not api_key:
250
278
  emit_warning(
251
- f"GEMINI_API_KEY is not set; skipping Gemini model '{model_config.get('name')}'."
279
+ f"GEMINI_API_KEY is not set (check config or environment); skipping Gemini model '{model_config.get('name')}'."
252
280
  )
253
281
  return None
254
282
 
@@ -258,10 +286,10 @@ class ModelFactory:
258
286
  return model
259
287
 
260
288
  elif model_type == "openai":
261
- api_key = os.environ.get("OPENAI_API_KEY")
289
+ api_key = get_api_key("OPENAI_API_KEY")
262
290
  if not api_key:
263
291
  emit_warning(
264
- f"OPENAI_API_KEY is not set; skipping OpenAI model '{model_config.get('name')}'."
292
+ f"OPENAI_API_KEY is not set (check config or environment); skipping OpenAI model '{model_config.get('name')}'."
265
293
  )
266
294
  return None
267
295
 
@@ -275,10 +303,10 @@ class ModelFactory:
275
303
  return model
276
304
 
277
305
  elif model_type == "anthropic":
278
- api_key = os.environ.get("ANTHROPIC_API_KEY", None)
306
+ api_key = get_api_key("ANTHROPIC_API_KEY")
279
307
  if not api_key:
280
308
  emit_warning(
281
- f"ANTHROPIC_API_KEY is not set; skipping Anthropic model '{model_config.get('name')}'."
309
+ f"ANTHROPIC_API_KEY is not set (check config or environment); skipping Anthropic model '{model_config.get('name')}'."
282
310
  )
283
311
  return None
284
312
 
@@ -292,9 +320,21 @@ class ModelFactory:
292
320
  http2=http2_enabled,
293
321
  )
294
322
 
323
+ # Check if interleaved thinking is enabled for this model
324
+ # Only applies to Claude 4 models (Opus 4.5, Opus 4.1, Opus 4, Sonnet 4)
325
+ from code_puppy.config import get_effective_model_settings
326
+
327
+ effective_settings = get_effective_model_settings(model_name)
328
+ interleaved_thinking = effective_settings.get("interleaved_thinking", False)
329
+
330
+ default_headers = {}
331
+ if interleaved_thinking:
332
+ default_headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
333
+
295
334
  anthropic_client = AsyncAnthropic(
296
335
  api_key=api_key,
297
336
  http_client=client,
337
+ default_headers=default_headers if default_headers else None,
298
338
  )
299
339
 
300
340
  # Ensure cache_control is injected at the Anthropic SDK layer
@@ -324,10 +364,21 @@ class ModelFactory:
324
364
  http2=http2_enabled,
325
365
  )
326
366
 
367
+ # Check if interleaved thinking is enabled for this model
368
+ from code_puppy.config import get_effective_model_settings
369
+
370
+ effective_settings = get_effective_model_settings(model_name)
371
+ interleaved_thinking = effective_settings.get("interleaved_thinking", False)
372
+
373
+ default_headers = {}
374
+ if interleaved_thinking:
375
+ default_headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
376
+
327
377
  anthropic_client = AsyncAnthropic(
328
378
  base_url=url,
329
379
  http_client=client,
330
380
  api_key=api_key,
381
+ default_headers=default_headers if default_headers else None,
331
382
  )
332
383
 
333
384
  # Ensure cache_control is injected at the Anthropic SDK layer
@@ -343,6 +394,31 @@ class ModelFactory:
343
394
  )
344
395
  return None
345
396
 
397
+ # Check if interleaved thinking is enabled (defaults to True for OAuth models)
398
+ from code_puppy.config import get_effective_model_settings
399
+
400
+ effective_settings = get_effective_model_settings(model_name)
401
+ interleaved_thinking = effective_settings.get("interleaved_thinking", True)
402
+
403
+ # Handle anthropic-beta header based on interleaved_thinking setting
404
+ if "anthropic-beta" in headers:
405
+ beta_parts = [p.strip() for p in headers["anthropic-beta"].split(",")]
406
+ if interleaved_thinking:
407
+ # Ensure interleaved-thinking is in the header
408
+ if "interleaved-thinking-2025-05-14" not in beta_parts:
409
+ beta_parts.append("interleaved-thinking-2025-05-14")
410
+ else:
411
+ # Remove interleaved-thinking from the header
412
+ beta_parts = [
413
+ p for p in beta_parts if "interleaved-thinking" not in p
414
+ ]
415
+ headers["anthropic-beta"] = ",".join(beta_parts) if beta_parts else None
416
+ if headers.get("anthropic-beta") is None:
417
+ del headers["anthropic-beta"]
418
+ elif interleaved_thinking:
419
+ # No existing beta header, add one for interleaved thinking
420
+ headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
421
+
346
422
  # Use a dedicated client wrapper that injects cache_control on /v1/messages
347
423
  if verify is None:
348
424
  verify = get_cert_bundle_path()
@@ -376,10 +452,10 @@ class ModelFactory:
376
452
  )
377
453
  azure_endpoint = azure_endpoint_config
378
454
  if azure_endpoint_config.startswith("$"):
379
- azure_endpoint = os.environ.get(azure_endpoint_config[1:])
455
+ azure_endpoint = get_api_key(azure_endpoint_config[1:])
380
456
  if not azure_endpoint:
381
457
  emit_warning(
382
- 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')}'."
458
+ 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')}'."
383
459
  )
384
460
  return None
385
461
 
@@ -390,10 +466,10 @@ class ModelFactory:
390
466
  )
391
467
  api_version = api_version_config
392
468
  if api_version_config.startswith("$"):
393
- api_version = os.environ.get(api_version_config[1:])
469
+ api_version = get_api_key(api_version_config[1:])
394
470
  if not api_version:
395
471
  emit_warning(
396
- 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')}'."
472
+ 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')}'."
397
473
  )
398
474
  return None
399
475
 
@@ -404,10 +480,10 @@ class ModelFactory:
404
480
  )
405
481
  api_key = api_key_config
406
482
  if api_key_config.startswith("$"):
407
- api_key = os.environ.get(api_key_config[1:])
483
+ api_key = get_api_key(api_key_config[1:])
408
484
  if not api_key:
409
485
  emit_warning(
410
- 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')}'."
486
+ 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')}'."
411
487
  )
412
488
  return None
413
489
 
@@ -441,10 +517,10 @@ class ModelFactory:
441
517
  setattr(model, "provider", provider)
442
518
  return model
443
519
  elif model_type == "zai_coding":
444
- api_key = os.getenv("ZAI_API_KEY")
520
+ api_key = get_api_key("ZAI_API_KEY")
445
521
  if not api_key:
446
522
  emit_warning(
447
- f"ZAI_API_KEY is not set; skipping ZAI coding model '{model_config.get('name')}'."
523
+ f"ZAI_API_KEY is not set (check config or environment); skipping ZAI coding model '{model_config.get('name')}'."
448
524
  )
449
525
  return None
450
526
  provider = OpenAIProvider(
@@ -458,10 +534,10 @@ class ModelFactory:
458
534
  setattr(zai_model, "provider", provider)
459
535
  return zai_model
460
536
  elif model_type == "zai_api":
461
- api_key = os.getenv("ZAI_API_KEY")
537
+ api_key = get_api_key("ZAI_API_KEY")
462
538
  if not api_key:
463
539
  emit_warning(
464
- f"ZAI_API_KEY is not set; skipping ZAI API model '{model_config.get('name')}'."
540
+ f"ZAI_API_KEY is not set (check config or environment); skipping ZAI API model '{model_config.get('name')}'."
465
541
  )
466
542
  return None
467
543
  provider = OpenAIProvider(
@@ -481,24 +557,94 @@ class ModelFactory:
481
557
  f"API key is not set for custom Gemini endpoint; skipping model '{model_config.get('name')}'."
482
558
  )
483
559
  return None
484
- os.environ["GEMINI_API_KEY"] = api_key
485
560
 
486
- class CustomGoogleGLAProvider(GoogleProvider):
487
- def __init__(self, *args, **kwargs):
488
- super().__init__(*args, **kwargs)
561
+ # Check if this is an Antigravity model
562
+ if model_config.get("antigravity"):
563
+ try:
564
+ from code_puppy.plugins.antigravity_oauth.token import (
565
+ is_token_expired,
566
+ refresh_access_token,
567
+ )
568
+ from code_puppy.plugins.antigravity_oauth.transport import (
569
+ create_antigravity_client,
570
+ )
571
+ from code_puppy.plugins.antigravity_oauth.utils import (
572
+ load_stored_tokens,
573
+ save_tokens,
574
+ )
575
+
576
+ # Try to import custom model for thinking signatures
577
+ try:
578
+ from code_puppy.plugins.antigravity_oauth.antigravity_model import (
579
+ AntigravityModel,
580
+ )
581
+ except ImportError:
582
+ AntigravityModel = None
583
+
584
+ # Get fresh access token (refresh if needed)
585
+ tokens = load_stored_tokens()
586
+ if not tokens:
587
+ emit_warning(
588
+ "Antigravity tokens not found; run /antigravity-auth first."
589
+ )
590
+ return None
591
+
592
+ access_token = tokens.get("access_token", "")
593
+ refresh_token = tokens.get("refresh_token", "")
594
+ expires_at = tokens.get("expires_at")
595
+
596
+ # Refresh if expired or about to expire
597
+ if is_token_expired(expires_at):
598
+ new_tokens = refresh_access_token(refresh_token)
599
+ if new_tokens:
600
+ access_token = new_tokens.access_token
601
+ tokens["access_token"] = new_tokens.access_token
602
+ tokens["refresh_token"] = new_tokens.refresh_token
603
+ tokens["expires_at"] = new_tokens.expires_at
604
+ save_tokens(tokens)
605
+ else:
606
+ emit_warning(
607
+ "Failed to refresh Antigravity token; run /antigravity-auth again."
608
+ )
609
+ return None
610
+
611
+ project_id = tokens.get(
612
+ "project_id", model_config.get("project_id", "")
613
+ )
614
+ client = create_antigravity_client(
615
+ access_token=access_token,
616
+ project_id=project_id,
617
+ model_name=model_config["name"],
618
+ base_url=url,
619
+ headers=headers,
620
+ )
489
621
 
490
- @property
491
- def base_url(self):
492
- return url
622
+ provider = GoogleProvider(
623
+ api_key=api_key, base_url=url, http_client=client
624
+ )
493
625
 
494
- @property
495
- def client(self) -> httpx.AsyncClient:
496
- _client = create_async_client(headers=headers, verify=verify)
497
- _client.base_url = self.base_url
498
- return _client
626
+ # Use custom model if available to preserve thinking signatures
627
+ if AntigravityModel:
628
+ model = AntigravityModel(
629
+ model_name=model_config["name"], provider=provider
630
+ )
631
+ else:
632
+ model = GoogleModel(
633
+ model_name=model_config["name"], provider=provider
634
+ )
499
635
 
500
- google_gla = CustomGoogleGLAProvider(api_key=api_key)
501
- model = GoogleModel(model_name=model_config["name"], provider=google_gla)
636
+ return model
637
+
638
+ except ImportError:
639
+ emit_warning(
640
+ f"Antigravity transport not available; skipping model '{model_config.get('name')}'."
641
+ )
642
+ return None
643
+ else:
644
+ client = create_async_client(headers=headers, verify=verify)
645
+
646
+ provider = GoogleProvider(api_key=api_key, base_url=url, http_client=client)
647
+ model = GoogleModel(model_name=model_config["name"], provider=provider)
502
648
  return model
503
649
  elif model_type == "cerebras":
504
650
 
@@ -537,21 +683,21 @@ class ModelFactory:
537
683
  if api_key_config.startswith("$"):
538
684
  # It's an environment variable reference
539
685
  env_var_name = api_key_config[1:] # Remove the $ prefix
540
- api_key = os.environ.get(env_var_name)
686
+ api_key = get_api_key(env_var_name)
541
687
  if api_key is None:
542
688
  emit_warning(
543
- f"OpenRouter API key environment variable '{env_var_name}' not found or is empty; skipping model '{model_config.get('name')}'."
689
+ f"OpenRouter API key '{env_var_name}' not found (check config or environment); skipping model '{model_config.get('name')}'."
544
690
  )
545
691
  return None
546
692
  else:
547
693
  # It's a raw API key value
548
694
  api_key = api_key_config
549
695
  else:
550
- # No API key in config, try to get it from the default environment variable
551
- api_key = os.environ.get("OPENROUTER_API_KEY")
696
+ # No API key in config, try to get it from config or the default environment variable
697
+ api_key = get_api_key("OPENROUTER_API_KEY")
552
698
  if api_key is None:
553
699
  emit_warning(
554
- f"OPENROUTER_API_KEY is not set; skipping OpenRouter model '{model_config.get('name')}'."
700
+ f"OPENROUTER_API_KEY is not set (check config or environment); skipping OpenRouter model '{model_config.get('name')}'."
555
701
  )
556
702
  return None
557
703
 
@@ -618,6 +764,86 @@ class ModelFactory:
618
764
  )
619
765
  return model
620
766
 
767
+ elif model_type == "chatgpt_oauth":
768
+ # ChatGPT OAuth models use the Codex API at chatgpt.com
769
+ try:
770
+ try:
771
+ from chatgpt_oauth.config import CHATGPT_OAUTH_CONFIG
772
+ from chatgpt_oauth.utils import (
773
+ get_valid_access_token,
774
+ load_stored_tokens,
775
+ )
776
+ except ImportError:
777
+ from code_puppy.plugins.chatgpt_oauth.config import (
778
+ CHATGPT_OAUTH_CONFIG,
779
+ )
780
+ from code_puppy.plugins.chatgpt_oauth.utils import (
781
+ get_valid_access_token,
782
+ load_stored_tokens,
783
+ )
784
+ except ImportError as exc:
785
+ emit_warning(
786
+ f"ChatGPT OAuth plugin not available; skipping model '{model_config.get('name')}'. "
787
+ f"Error: {exc}"
788
+ )
789
+ return None
790
+
791
+ # Get a valid access token (refreshing if needed)
792
+ access_token = get_valid_access_token()
793
+ if not access_token:
794
+ emit_warning(
795
+ f"Failed to get valid ChatGPT OAuth token; skipping model '{model_config.get('name')}'. "
796
+ "Run /chatgpt-auth to authenticate."
797
+ )
798
+ return None
799
+
800
+ # Get account_id from stored tokens (required for ChatGPT-Account-Id header)
801
+ tokens = load_stored_tokens()
802
+ account_id = tokens.get("account_id", "") if tokens else ""
803
+ if not account_id:
804
+ emit_warning(
805
+ f"No account_id found in ChatGPT OAuth tokens; skipping model '{model_config.get('name')}'. "
806
+ "Run /chatgpt-auth to re-authenticate."
807
+ )
808
+ return None
809
+
810
+ # Build headers for ChatGPT Codex API
811
+ originator = CHATGPT_OAUTH_CONFIG.get("originator", "codex_cli_rs")
812
+ client_version = CHATGPT_OAUTH_CONFIG.get("client_version", "0.72.0")
813
+
814
+ headers = {
815
+ "ChatGPT-Account-Id": account_id,
816
+ "originator": originator,
817
+ "User-Agent": f"{originator}/{client_version}",
818
+ }
819
+ # Merge with any headers from model config
820
+ config_headers = model_config.get("custom_endpoint", {}).get("headers", {})
821
+ headers.update(config_headers)
822
+
823
+ # Get base URL - Codex API uses chatgpt.com, not api.openai.com
824
+ base_url = model_config.get("custom_endpoint", {}).get(
825
+ "url", CHATGPT_OAUTH_CONFIG["api_base_url"]
826
+ )
827
+
828
+ # Create HTTP client with Codex interceptor for store=false injection
829
+ from code_puppy.chatgpt_codex_client import create_codex_async_client
830
+
831
+ verify = get_cert_bundle_path()
832
+ client = create_codex_async_client(headers=headers, verify=verify)
833
+
834
+ provider = OpenAIProvider(
835
+ api_key=access_token,
836
+ base_url=base_url,
837
+ http_client=client,
838
+ )
839
+
840
+ # ChatGPT Codex API only supports Responses format
841
+ model = OpenAIResponsesModel(
842
+ model_name=model_config["name"], provider=provider
843
+ )
844
+ setattr(model, "provider", provider)
845
+ return model
846
+
621
847
  elif model_type == "round_robin":
622
848
  # Get the list of model names to use in the round-robin
623
849
  model_names = model_config.get("models")