code-puppy 0.0.348__py3-none-any.whl → 0.0.372__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/__init__.py +8 -0
  2. code_puppy/agents/agent_manager.py +272 -1
  3. code_puppy/agents/agent_pack_leader.py +383 -0
  4. code_puppy/agents/agent_qa_kitten.py +12 -7
  5. code_puppy/agents/agent_terminal_qa.py +323 -0
  6. code_puppy/agents/base_agent.py +11 -8
  7. code_puppy/agents/event_stream_handler.py +101 -8
  8. code_puppy/agents/pack/__init__.py +34 -0
  9. code_puppy/agents/pack/bloodhound.py +304 -0
  10. code_puppy/agents/pack/husky.py +321 -0
  11. code_puppy/agents/pack/retriever.py +393 -0
  12. code_puppy/agents/pack/shepherd.py +348 -0
  13. code_puppy/agents/pack/terrier.py +287 -0
  14. code_puppy/agents/pack/watchdog.py +367 -0
  15. code_puppy/agents/subagent_stream_handler.py +276 -0
  16. code_puppy/api/__init__.py +13 -0
  17. code_puppy/api/app.py +169 -0
  18. code_puppy/api/main.py +21 -0
  19. code_puppy/api/pty_manager.py +446 -0
  20. code_puppy/api/routers/__init__.py +12 -0
  21. code_puppy/api/routers/agents.py +36 -0
  22. code_puppy/api/routers/commands.py +217 -0
  23. code_puppy/api/routers/config.py +74 -0
  24. code_puppy/api/routers/sessions.py +232 -0
  25. code_puppy/api/templates/terminal.html +361 -0
  26. code_puppy/api/websocket.py +154 -0
  27. code_puppy/callbacks.py +73 -0
  28. code_puppy/chatgpt_codex_client.py +53 -0
  29. code_puppy/claude_cache_client.py +294 -41
  30. code_puppy/command_line/add_model_menu.py +13 -4
  31. code_puppy/command_line/agent_menu.py +662 -0
  32. code_puppy/command_line/core_commands.py +89 -112
  33. code_puppy/command_line/model_picker_completion.py +3 -20
  34. code_puppy/command_line/model_settings_menu.py +21 -3
  35. code_puppy/config.py +145 -70
  36. code_puppy/gemini_model.py +706 -0
  37. code_puppy/http_utils.py +6 -3
  38. code_puppy/messaging/__init__.py +15 -0
  39. code_puppy/messaging/messages.py +27 -0
  40. code_puppy/messaging/queue_console.py +1 -1
  41. code_puppy/messaging/rich_renderer.py +36 -1
  42. code_puppy/messaging/spinner/__init__.py +20 -2
  43. code_puppy/messaging/subagent_console.py +461 -0
  44. code_puppy/model_factory.py +50 -16
  45. code_puppy/model_switching.py +63 -0
  46. code_puppy/model_utils.py +27 -24
  47. code_puppy/models.json +12 -12
  48. code_puppy/plugins/antigravity_oauth/antigravity_model.py +206 -172
  49. code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
  50. code_puppy/plugins/antigravity_oauth/transport.py +236 -45
  51. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
  52. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
  53. code_puppy/plugins/claude_code_oauth/utils.py +4 -1
  54. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  55. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  56. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  57. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  58. code_puppy/pydantic_patches.py +52 -0
  59. code_puppy/status_display.py +6 -2
  60. code_puppy/tools/__init__.py +37 -1
  61. code_puppy/tools/agent_tools.py +83 -33
  62. code_puppy/tools/browser/__init__.py +37 -0
  63. code_puppy/tools/browser/browser_control.py +6 -6
  64. code_puppy/tools/browser/browser_interactions.py +21 -20
  65. code_puppy/tools/browser/browser_locators.py +9 -9
  66. code_puppy/tools/browser/browser_manager.py +316 -0
  67. code_puppy/tools/browser/browser_navigation.py +7 -7
  68. code_puppy/tools/browser/browser_screenshot.py +78 -140
  69. code_puppy/tools/browser/browser_scripts.py +15 -13
  70. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  71. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  72. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  73. code_puppy/tools/browser/terminal_tools.py +525 -0
  74. code_puppy/tools/command_runner.py +292 -101
  75. code_puppy/tools/common.py +176 -1
  76. code_puppy/tools/display.py +84 -0
  77. code_puppy/tools/subagent_context.py +158 -0
  78. {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
  79. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/METADATA +17 -16
  80. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/RECORD +84 -51
  81. code_puppy/prompts/codex_system_prompt.md +0 -310
  82. code_puppy/tools/browser/camoufox_manager.py +0 -235
  83. code_puppy/tools/browser/vqa_agent.py +0 -90
  84. {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
  85. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
  86. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
  87. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,461 @@
1
+ """SubAgentConsoleManager - Aggregated display for parallel sub-agents.
2
+
3
+ Provides a Rich Live dashboard that shows real-time status of multiple
4
+ running sub-agents, each in its own panel with spinner animations,
5
+ status badges, and performance metrics.
6
+
7
+ Usage:
8
+ >>> manager = SubAgentConsoleManager.get_instance()
9
+ >>> manager.register_agent("session-123", "code-puppy", "gpt-4o")
10
+ >>> manager.update_agent("session-123", status="running", tool_call_count=5)
11
+ >>> manager.unregister_agent("session-123")
12
+ """
13
+
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from typing import Dict, List, Optional
18
+
19
+ from rich.console import Console, Group
20
+ from rich.live import Live
21
+ from rich.panel import Panel
22
+ from rich.table import Table
23
+ from rich.text import Text
24
+
25
+ from code_puppy.messaging.messages import SubAgentStatusMessage
26
+
27
+
28
+ # =============================================================================
29
+ # Status Configuration
30
+ # =============================================================================
31
+
32
+ STATUS_STYLES = {
33
+ "starting": {"color": "cyan", "spinner": "dots", "emoji": "🚀"},
34
+ "running": {"color": "green", "spinner": "dots", "emoji": "🐕"},
35
+ "thinking": {"color": "magenta", "spinner": "dots", "emoji": "🤔"},
36
+ "tool_calling": {"color": "yellow", "spinner": "dots12", "emoji": "🔧"},
37
+ "completed": {"color": "green", "spinner": None, "emoji": "✅"},
38
+ "error": {"color": "red", "spinner": None, "emoji": "❌"},
39
+ }
40
+
41
+ DEFAULT_STYLE = {"color": "white", "spinner": "dots", "emoji": "⏳"}
42
+
43
+
44
+ # =============================================================================
45
+ # Agent State Tracking
46
+ # =============================================================================
47
+
48
+
49
+ @dataclass
50
+ class AgentState:
51
+ """Internal state tracking for a single sub-agent.
52
+
53
+ Tracks all metrics needed for rendering the agent's status panel,
54
+ including timing, tool usage, and error information.
55
+ """
56
+
57
+ session_id: str
58
+ agent_name: str
59
+ model_name: str
60
+ status: str = "starting"
61
+ tool_call_count: int = 0
62
+ token_count: int = 0
63
+ current_tool: Optional[str] = None
64
+ start_time: float = field(default_factory=time.time)
65
+ error_message: Optional[str] = None
66
+
67
+ def elapsed_seconds(self) -> float:
68
+ """Calculate elapsed time since agent started."""
69
+ return time.time() - self.start_time
70
+
71
+ def elapsed_formatted(self) -> str:
72
+ """Format elapsed time as human-readable string."""
73
+ elapsed = self.elapsed_seconds()
74
+ if elapsed < 60:
75
+ return f"{elapsed:.1f}s"
76
+ minutes = int(elapsed // 60)
77
+ seconds = elapsed % 60
78
+ return f"{minutes}m {seconds:.1f}s"
79
+
80
+ def to_status_message(self) -> SubAgentStatusMessage:
81
+ """Convert to a SubAgentStatusMessage for bus emission."""
82
+ return SubAgentStatusMessage(
83
+ session_id=self.session_id,
84
+ agent_name=self.agent_name,
85
+ model_name=self.model_name,
86
+ status=self.status, # type: ignore[arg-type]
87
+ tool_call_count=self.tool_call_count,
88
+ token_count=self.token_count,
89
+ current_tool=self.current_tool,
90
+ elapsed_seconds=self.elapsed_seconds(),
91
+ error_message=self.error_message,
92
+ )
93
+
94
+
95
+ # =============================================================================
96
+ # SubAgent Console Manager
97
+ # =============================================================================
98
+
99
+
100
+ class SubAgentConsoleManager:
101
+ """Manager for displaying multiple parallel sub-agents in Rich Live panels.
102
+
103
+ This is a singleton that tracks all running sub-agents and renders them
104
+ in a unified Rich Live display. Each agent gets its own panel with:
105
+ - Agent name and session ID
106
+ - Model being used
107
+ - Status with spinner animation (for active states)
108
+ - Tool call count and current tool
109
+ - Token count
110
+ - Elapsed time
111
+
112
+ The display auto-starts when the first agent registers and auto-stops
113
+ when the last agent unregisters.
114
+
115
+ Thread-safe: All operations are protected by locks.
116
+ """
117
+
118
+ _instance: Optional["SubAgentConsoleManager"] = None
119
+ _lock = threading.Lock()
120
+
121
+ def __init__(self, console: Optional[Console] = None):
122
+ """Initialize the manager.
123
+
124
+ Args:
125
+ console: Optional Rich Console instance. If not provided,
126
+ a new one will be created.
127
+ """
128
+ self.console = console or Console()
129
+ self._agents: Dict[str, AgentState] = {}
130
+ self._agents_lock = threading.RLock() # Reentrant lock for agent operations
131
+ self._live: Optional[Live] = None
132
+ self._update_thread: Optional[threading.Thread] = None
133
+ self._stop_event = threading.Event()
134
+
135
+ @classmethod
136
+ def get_instance(
137
+ cls, console: Optional[Console] = None
138
+ ) -> "SubAgentConsoleManager":
139
+ """Get or create the singleton instance.
140
+
141
+ Thread-safe singleton pattern using double-checked locking.
142
+
143
+ Args:
144
+ console: Optional Rich Console to use. Only used when creating
145
+ the initial instance.
146
+
147
+ Returns:
148
+ The singleton SubAgentConsoleManager instance.
149
+ """
150
+ if cls._instance is None:
151
+ with cls._lock:
152
+ # Double-check inside lock
153
+ if cls._instance is None:
154
+ cls._instance = cls(console)
155
+ return cls._instance
156
+
157
+ @classmethod
158
+ def reset_instance(cls) -> None:
159
+ """Reset the singleton instance (primarily for testing).
160
+
161
+ Stops any running display and clears the singleton.
162
+ """
163
+ with cls._lock:
164
+ if cls._instance is not None:
165
+ cls._instance._stop_display()
166
+ cls._instance = None
167
+
168
+ # =========================================================================
169
+ # Agent Registration
170
+ # =========================================================================
171
+
172
+ def register_agent(self, session_id: str, agent_name: str, model_name: str) -> None:
173
+ """Register a new sub-agent and start display if needed.
174
+
175
+ Args:
176
+ session_id: Unique identifier for this agent session.
177
+ agent_name: Name of the agent (e.g., 'code-puppy', 'qa-kitten').
178
+ model_name: Name of the model being used (e.g., 'gpt-4o').
179
+ """
180
+ with self._agents_lock:
181
+ # Create new agent state
182
+ self._agents[session_id] = AgentState(
183
+ session_id=session_id,
184
+ agent_name=agent_name,
185
+ model_name=model_name,
186
+ )
187
+
188
+ # Start display if this is the first agent
189
+ if len(self._agents) == 1:
190
+ self._start_display()
191
+
192
+ def update_agent(self, session_id: str, **kwargs) -> None:
193
+ """Update status of an existing agent.
194
+
195
+ Args:
196
+ session_id: The session ID of the agent to update.
197
+ **kwargs: Fields to update. Valid fields:
198
+ - status: Current status string
199
+ - tool_call_count: Number of tools called
200
+ - token_count: Tokens in context
201
+ - current_tool: Name of tool being called (or None)
202
+ - error_message: Error message if status is 'error'
203
+ """
204
+ with self._agents_lock:
205
+ if session_id not in self._agents:
206
+ return # Silently ignore updates for unknown agents
207
+
208
+ agent = self._agents[session_id]
209
+
210
+ # Update only provided fields
211
+ if "status" in kwargs:
212
+ agent.status = kwargs["status"]
213
+ if "tool_call_count" in kwargs:
214
+ agent.tool_call_count = kwargs["tool_call_count"]
215
+ if "token_count" in kwargs:
216
+ agent.token_count = kwargs["token_count"]
217
+ if "current_tool" in kwargs:
218
+ agent.current_tool = kwargs["current_tool"]
219
+ if "error_message" in kwargs:
220
+ agent.error_message = kwargs["error_message"]
221
+
222
+ def unregister_agent(
223
+ self, session_id: str, final_status: str = "completed"
224
+ ) -> None:
225
+ """Remove an agent from tracking.
226
+
227
+ Args:
228
+ session_id: The session ID of the agent to remove.
229
+ final_status: Final status to set before removal (for display).
230
+ Defaults to 'completed'.
231
+ """
232
+ with self._agents_lock:
233
+ if session_id in self._agents:
234
+ # Set final status
235
+ self._agents[session_id].status = final_status
236
+ # Remove from tracking
237
+ del self._agents[session_id]
238
+
239
+ # Stop display if no agents remain
240
+ if not self._agents:
241
+ self._stop_display()
242
+
243
+ def get_agent_state(self, session_id: str) -> Optional[AgentState]:
244
+ """Get the current state of an agent.
245
+
246
+ Args:
247
+ session_id: The session ID to look up.
248
+
249
+ Returns:
250
+ The AgentState if found, None otherwise.
251
+ """
252
+ with self._agents_lock:
253
+ return self._agents.get(session_id)
254
+
255
+ def get_all_agents(self) -> List[AgentState]:
256
+ """Get a list of all currently tracked agents.
257
+
258
+ Returns:
259
+ List of AgentState objects (copies to prevent mutation).
260
+ """
261
+ with self._agents_lock:
262
+ return list(self._agents.values())
263
+
264
+ # =========================================================================
265
+ # Display Management
266
+ # =========================================================================
267
+
268
+ def _start_display(self) -> None:
269
+ """Start the Rich Live display.
270
+
271
+ Creates the Live context and starts a background thread to
272
+ continuously refresh the display.
273
+ """
274
+ if self._live is not None:
275
+ return # Already running
276
+
277
+ self._stop_event.clear()
278
+
279
+ # Create Live display
280
+ self._live = Live(
281
+ self._render_display(),
282
+ console=self.console,
283
+ refresh_per_second=10,
284
+ transient=True, # Clear when stopped
285
+ )
286
+ self._live.start()
287
+
288
+ # Start background update thread
289
+ self._update_thread = threading.Thread(
290
+ target=self._update_loop, daemon=True, name="SubAgentDisplayUpdater"
291
+ )
292
+ self._update_thread.start()
293
+
294
+ def _stop_display(self) -> None:
295
+ """Stop the Rich Live display when no agents remain."""
296
+ # Signal stop
297
+ self._stop_event.set()
298
+
299
+ # Stop update thread
300
+ if self._update_thread is not None:
301
+ self._update_thread.join(timeout=1.0)
302
+ self._update_thread = None
303
+
304
+ # Stop Live display
305
+ if self._live is not None:
306
+ try:
307
+ self._live.stop()
308
+ except Exception:
309
+ pass # Ignore errors during cleanup
310
+ self._live = None
311
+
312
+ def _update_loop(self) -> None:
313
+ """Background thread that refreshes the display."""
314
+ while not self._stop_event.is_set():
315
+ try:
316
+ if self._live is not None:
317
+ self._live.update(self._render_display())
318
+ except Exception:
319
+ pass # Ignore rendering errors, keep trying
320
+
321
+ # Sleep between updates (10 FPS)
322
+ time.sleep(0.1)
323
+
324
+ # =========================================================================
325
+ # Rendering
326
+ # =========================================================================
327
+
328
+ def _render_display(self) -> Group:
329
+ """Render all agent panels as a Rich Group.
330
+
331
+ Returns:
332
+ A Group containing all agent panels stacked vertically.
333
+ """
334
+ with self._agents_lock:
335
+ if not self._agents:
336
+ return Group(Text("No active sub-agents", style="dim"))
337
+
338
+ panels = [
339
+ self._render_agent_panel(agent) for agent in self._agents.values()
340
+ ]
341
+ return Group(*panels)
342
+
343
+ def _render_agent_panel(self, agent: AgentState) -> Panel:
344
+ """Render a single agent's status panel.
345
+
346
+ Args:
347
+ agent: The AgentState to render.
348
+
349
+ Returns:
350
+ A Rich Panel containing the agent's status information.
351
+ """
352
+ style_config = STATUS_STYLES.get(agent.status, DEFAULT_STYLE)
353
+ color = style_config["color"]
354
+ spinner_name = style_config["spinner"]
355
+ emoji = style_config["emoji"]
356
+
357
+ # Build the content table
358
+ table = Table.grid(padding=(0, 2))
359
+ table.add_column("label", style="dim")
360
+ table.add_column("value")
361
+
362
+ # Status row with spinner (if active)
363
+ status_text = Text()
364
+ status_text.append(f"{emoji} ", style=color)
365
+ if spinner_name:
366
+ # For active statuses, we add the status text
367
+ # The spinner is visual only in Rich Live
368
+ status_text.append(agent.status.upper(), style=f"bold {color}")
369
+ else:
370
+ status_text.append(agent.status.upper(), style=f"bold {color}")
371
+
372
+ table.add_row("Status:", status_text)
373
+
374
+ # Model
375
+ table.add_row("Model:", Text(agent.model_name, style="cyan"))
376
+
377
+ # Session ID (truncated for display)
378
+ session_display = agent.session_id
379
+ if len(session_display) > 24:
380
+ session_display = session_display[:21] + "..."
381
+ table.add_row("Session:", Text(session_display, style="dim"))
382
+
383
+ # Tool calls
384
+ tool_text = Text()
385
+ tool_text.append(str(agent.tool_call_count), style="bold yellow")
386
+ if agent.current_tool:
387
+ tool_text.append(" (calling: ", style="dim")
388
+ tool_text.append(agent.current_tool, style="yellow")
389
+ tool_text.append(")", style="dim")
390
+ table.add_row("Tools:", tool_text)
391
+
392
+ # Token count
393
+ token_display = f"{agent.token_count:,}" if agent.token_count else "0"
394
+ table.add_row("Tokens:", Text(token_display, style="blue"))
395
+
396
+ # Elapsed time
397
+ table.add_row("Elapsed:", Text(agent.elapsed_formatted(), style="magenta"))
398
+
399
+ # Error message (if any)
400
+ if agent.error_message:
401
+ error_text = Text(agent.error_message, style="red")
402
+ table.add_row("Error:", error_text)
403
+
404
+ # Build panel title with spinner for active states
405
+ title = Text()
406
+ title.append("🐕 ", style="bold")
407
+ title.append(agent.agent_name, style=f"bold {color}")
408
+
409
+ # Create panel
410
+ return Panel(
411
+ table,
412
+ title=title,
413
+ border_style=color,
414
+ padding=(0, 1),
415
+ )
416
+
417
+ # =========================================================================
418
+ # Context Manager Support
419
+ # =========================================================================
420
+
421
+ def __enter__(self) -> "SubAgentConsoleManager":
422
+ """Support use as context manager."""
423
+ return self
424
+
425
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
426
+ """Clean up on context exit."""
427
+ self._stop_display()
428
+
429
+
430
+ # =============================================================================
431
+ # Convenience Functions
432
+ # =============================================================================
433
+
434
+
435
+ def get_subagent_console_manager(
436
+ console: Optional[Console] = None,
437
+ ) -> SubAgentConsoleManager:
438
+ """Get the singleton SubAgentConsoleManager instance.
439
+
440
+ Convenience function for accessing the manager.
441
+
442
+ Args:
443
+ console: Optional Rich Console (only used on first call).
444
+
445
+ Returns:
446
+ The singleton SubAgentConsoleManager.
447
+ """
448
+ return SubAgentConsoleManager.get_instance(console)
449
+
450
+
451
+ # =============================================================================
452
+ # Exports
453
+ # =============================================================================
454
+
455
+ __all__ = [
456
+ "AgentState",
457
+ "SubAgentConsoleManager",
458
+ "get_subagent_console_manager",
459
+ "STATUS_STYLES",
460
+ "DEFAULT_STYLE",
461
+ ]
@@ -7,7 +7,6 @@ from typing import Any, Dict
7
7
  from anthropic import AsyncAnthropic
8
8
  from openai import AsyncAzureOpenAI
9
9
  from pydantic_ai.models.anthropic import AnthropicModel, AnthropicModelSettings
10
- from pydantic_ai.models.google import GoogleModel
11
10
  from pydantic_ai.models.openai import (
12
11
  OpenAIChatModel,
13
12
  OpenAIChatModelSettings,
@@ -16,11 +15,11 @@ from pydantic_ai.models.openai import (
16
15
  from pydantic_ai.profiles import ModelProfile
17
16
  from pydantic_ai.providers.anthropic import AnthropicProvider
18
17
  from pydantic_ai.providers.cerebras import CerebrasProvider
19
- from pydantic_ai.providers.google import GoogleProvider
20
18
  from pydantic_ai.providers.openai import OpenAIProvider
21
19
  from pydantic_ai.providers.openrouter import OpenRouterProvider
22
20
  from pydantic_ai.settings import ModelSettings
23
21
 
22
+ from code_puppy.gemini_model import GeminiModel
24
23
  from code_puppy.messaging import emit_warning
25
24
 
26
25
  from . import callbacks
@@ -29,6 +28,8 @@ from .config import EXTRA_MODELS_FILE, get_value
29
28
  from .http_utils import create_async_client, get_cert_bundle_path, get_http2
30
29
  from .round_robin_model import RoundRobinModel
31
30
 
31
+ logger = logging.getLogger(__name__)
32
+
32
33
 
33
34
  def get_api_key(env_var_name: str) -> str | None:
34
35
  """Get an API key from config first, then fall back to environment variable.
@@ -94,6 +95,14 @@ def make_model_settings(
94
95
  effective_settings = get_effective_model_settings(model_name)
95
96
  model_settings_dict.update(effective_settings)
96
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
+
97
106
  model_settings: ModelSettings = ModelSettings(**model_settings_dict)
98
107
 
99
108
  if "gpt-5" in model_name:
@@ -280,9 +289,7 @@ class ModelFactory:
280
289
  )
281
290
  return None
282
291
 
283
- provider = GoogleProvider(api_key=api_key)
284
- model = GoogleModel(model_name=model_config["name"], provider=provider)
285
- setattr(model, "provider", provider)
292
+ model = GeminiModel(model_name=model_config["name"], api_key=api_key)
286
293
  return model
287
294
 
288
295
  elif model_type == "openai":
@@ -607,11 +614,13 @@ class ModelFactory:
607
614
  refresh_token = tokens.get("refresh_token", "")
608
615
  expires_at = tokens.get("expires_at")
609
616
 
610
- # Refresh if expired or about to expire
617
+ # Refresh if expired or about to expire (initial check)
611
618
  if is_token_expired(expires_at):
612
619
  new_tokens = refresh_access_token(refresh_token)
613
620
  if new_tokens:
614
621
  access_token = new_tokens.access_token
622
+ refresh_token = new_tokens.refresh_token
623
+ expires_at = new_tokens.expires_at
615
624
  tokens["access_token"] = new_tokens.access_token
616
625
  tokens["refresh_token"] = new_tokens.refresh_token
617
626
  tokens["expires_at"] = new_tokens.expires_at
@@ -622,6 +631,21 @@ class ModelFactory:
622
631
  )
623
632
  return None
624
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
+
625
649
  project_id = tokens.get(
626
650
  "project_id", model_config.get("project_id", "")
627
651
  )
@@ -631,20 +655,26 @@ class ModelFactory:
631
655
  model_name=model_config["name"],
632
656
  base_url=url,
633
657
  headers=headers,
658
+ refresh_token=refresh_token,
659
+ expires_at=expires_at,
660
+ on_token_refreshed=on_token_refreshed,
634
661
  )
635
662
 
636
- provider = GoogleProvider(
637
- api_key=api_key, base_url=url, http_client=client
638
- )
639
-
640
- # Use custom model if available to preserve thinking signatures
663
+ # Use custom model with direct httpx client
641
664
  if AntigravityModel:
642
665
  model = AntigravityModel(
643
- model_name=model_config["name"], provider=provider
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,
644
671
  )
645
672
  else:
646
- model = GoogleModel(
647
- model_name=model_config["name"], provider=provider
673
+ model = GeminiModel(
674
+ model_name=model_config["name"],
675
+ api_key=api_key or "",
676
+ base_url=url,
677
+ http_client=client,
648
678
  )
649
679
 
650
680
  return model
@@ -657,8 +687,12 @@ class ModelFactory:
657
687
  else:
658
688
  client = create_async_client(headers=headers, verify=verify)
659
689
 
660
- provider = GoogleProvider(api_key=api_key, base_url=url, http_client=client)
661
- model = GoogleModel(model_name=model_config["name"], provider=provider)
690
+ model = GeminiModel(
691
+ model_name=model_config["name"],
692
+ api_key=api_key,
693
+ base_url=url,
694
+ http_client=client,
695
+ )
662
696
  return model
663
697
  elif model_type == "cerebras":
664
698
 
@@ -0,0 +1,63 @@
1
+ """Shared helpers for switching models and reloading agents safely."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from code_puppy.config import set_model_name
8
+
9
+
10
+ def _get_effective_agent_model(agent) -> Optional[str]:
11
+ """Safely fetch the effective model name for an agent."""
12
+ try:
13
+ return agent.get_model_name()
14
+ except Exception:
15
+ return None
16
+
17
+
18
+ def set_model_and_reload_agent(
19
+ model_name: str,
20
+ *,
21
+ warn_on_pinned_mismatch: bool = True,
22
+ ) -> None:
23
+ """Set the global model and reload the active agent.
24
+
25
+ This keeps model switching consistent across commands while avoiding
26
+ direct imports that can trigger circular dependencies.
27
+ """
28
+ from code_puppy.messaging import emit_info, emit_warning
29
+
30
+ set_model_name(model_name)
31
+
32
+ try:
33
+ from code_puppy.agents import get_current_agent
34
+
35
+ current_agent = get_current_agent()
36
+ if current_agent is None:
37
+ emit_warning("Model changed but no active agent was found to reload")
38
+ return
39
+
40
+ # JSON agents may need to refresh their config before reload
41
+ if hasattr(current_agent, "refresh_config"):
42
+ try:
43
+ current_agent.refresh_config()
44
+ except Exception:
45
+ # Non-fatal, continue to reload
46
+ ...
47
+
48
+ if warn_on_pinned_mismatch:
49
+ effective_model = _get_effective_agent_model(current_agent)
50
+ if effective_model and effective_model != model_name:
51
+ display_name = getattr(
52
+ current_agent, "display_name", current_agent.name
53
+ )
54
+ emit_warning(
55
+ "Active agent "
56
+ f"'{display_name}' is pinned to '{effective_model}', "
57
+ f"so '{model_name}' will not take effect until unpinned."
58
+ )
59
+
60
+ current_agent.reload_code_generation_agent()
61
+ emit_info("Active agent reloaded")
62
+ except Exception as exc:
63
+ emit_warning(f"Model changed but agent reload failed: {exc}")