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.
- code_puppy/__init__.py +3 -1
- code_puppy/agents/agent_code_puppy.py +5 -4
- code_puppy/agents/agent_creator_agent.py +22 -18
- code_puppy/agents/agent_manager.py +2 -2
- code_puppy/agents/base_agent.py +496 -102
- code_puppy/callbacks.py +8 -0
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/cli_runner.py +795 -0
- code_puppy/command_line/add_model_menu.py +19 -16
- code_puppy/command_line/attachments.py +10 -5
- code_puppy/command_line/autosave_menu.py +269 -41
- code_puppy/command_line/colors_menu.py +515 -0
- code_puppy/command_line/command_handler.py +10 -24
- code_puppy/command_line/config_commands.py +106 -25
- code_puppy/command_line/core_commands.py +32 -20
- code_puppy/command_line/mcp/add_command.py +3 -16
- code_puppy/command_line/mcp/base.py +0 -3
- code_puppy/command_line/mcp/catalog_server_installer.py +15 -15
- code_puppy/command_line/mcp/custom_server_form.py +66 -5
- code_puppy/command_line/mcp/custom_server_installer.py +17 -17
- code_puppy/command_line/mcp/edit_command.py +15 -22
- code_puppy/command_line/mcp/handler.py +7 -2
- code_puppy/command_line/mcp/help_command.py +2 -2
- code_puppy/command_line/mcp/install_command.py +10 -14
- code_puppy/command_line/mcp/install_menu.py +2 -6
- code_puppy/command_line/mcp/list_command.py +2 -2
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +7 -2
- code_puppy/command_line/mcp/search_command.py +16 -10
- code_puppy/command_line/mcp/start_all_command.py +16 -6
- code_puppy/command_line/mcp/start_command.py +12 -10
- code_puppy/command_line/mcp/status_command.py +4 -5
- code_puppy/command_line/mcp/stop_all_command.py +5 -1
- code_puppy/command_line/mcp/stop_command.py +6 -4
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/wizard_utils.py +20 -16
- code_puppy/command_line/model_settings_menu.py +53 -7
- code_puppy/command_line/motd.py +1 -1
- code_puppy/command_line/pin_command_completion.py +82 -7
- code_puppy/command_line/prompt_toolkit_completion.py +32 -9
- code_puppy/command_line/session_commands.py +11 -4
- code_puppy/config.py +217 -53
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/keymap.py +126 -0
- code_puppy/main.py +5 -745
- code_puppy/mcp_/__init__.py +17 -0
- code_puppy/mcp_/blocking_startup.py +63 -36
- code_puppy/mcp_/captured_stdio_server.py +1 -1
- code_puppy/mcp_/config_wizard.py +4 -4
- code_puppy/mcp_/dashboard.py +15 -6
- code_puppy/mcp_/managed_server.py +25 -5
- code_puppy/mcp_/manager.py +65 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +6 -6
- code_puppy/messaging/__init__.py +184 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +3 -3
- code_puppy/messaging/messages.py +470 -0
- code_puppy/messaging/renderers.py +43 -141
- code_puppy/messaging/rich_renderer.py +900 -0
- code_puppy/messaging/spinner/console_spinner.py +39 -2
- code_puppy/model_factory.py +292 -53
- code_puppy/model_utils.py +57 -48
- code_puppy/models.json +19 -5
- code_puppy/plugins/__init__.py +152 -10
- code_puppy/plugins/chatgpt_oauth/config.py +20 -12
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +30 -13
- code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
- code_puppy/plugins/claude_code_oauth/config.py +15 -11
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
- code_puppy/plugins/claude_code_oauth/utils.py +6 -1
- code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
- code_puppy/plugins/oauth_puppy_html.py +3 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -134
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +77 -3
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/session_storage.py +2 -1
- code_puppy/status_display.py +7 -5
- code_puppy/terminal_utils.py +126 -0
- code_puppy/tools/agent_tools.py +131 -70
- code_puppy/tools/browser/browser_control.py +10 -14
- code_puppy/tools/browser/browser_interactions.py +20 -28
- code_puppy/tools/browser/browser_locators.py +27 -29
- code_puppy/tools/browser/browser_navigation.py +9 -9
- code_puppy/tools/browser/browser_screenshot.py +12 -14
- code_puppy/tools/browser/browser_scripts.py +17 -29
- code_puppy/tools/browser/browser_workflows.py +24 -25
- code_puppy/tools/browser/camoufox_manager.py +22 -26
- code_puppy/tools/command_runner.py +410 -88
- code_puppy/tools/common.py +51 -38
- code_puppy/tools/file_modifications.py +98 -24
- code_puppy/tools/file_operations.py +113 -202
- code_puppy/version_checker.py +28 -13
- {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/METADATA +3 -8
- code_puppy-0.0.323.dist-info/RECORD +168 -0
- code_puppy/tui_state.py +0 -55
- code_puppy-0.0.287.dist-info/RECORD +0 -153
- {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
|
212
|
+
transient=True, # Clear spinner line when stopped
|
|
176
213
|
auto_refresh=False,
|
|
177
214
|
)
|
|
178
215
|
self._live.start()
|
code_puppy/model_factory.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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 =
|
|
148
|
+
resolved_value = get_api_key(env_var_name)
|
|
110
149
|
if resolved_value is None:
|
|
111
150
|
emit_warning(
|
|
112
|
-
f"
|
|
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 =
|
|
161
|
+
resolved_value = get_api_key(env_var)
|
|
123
162
|
if resolved_value is None:
|
|
124
163
|
emit_warning(
|
|
125
|
-
f"
|
|
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 =
|
|
177
|
+
api_key = get_api_key(env_var_name)
|
|
139
178
|
if api_key is None:
|
|
140
179
|
emit_warning(
|
|
141
|
-
f"
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
|
|
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(
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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")
|