newcode 0.1.1__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 +10 -0
- code_puppy/__main__.py +10 -0
- code_puppy/agents/__init__.py +31 -0
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +147 -0
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +630 -0
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_helios.py +122 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +742 -0
- code_puppy/agents/agent_pack_leader.py +380 -0
- code_puppy/agents/agent_planning.py +165 -0
- code_puppy/agents/agent_python_programmer.py +167 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_scheduler.py +121 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +2145 -0
- code_puppy/agents/event_stream_handler.py +348 -0
- code_puppy/agents/json_agent.py +202 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +296 -0
- code_puppy/agents/pack/husky.py +307 -0
- code_puppy/agents/pack/retriever.py +380 -0
- code_puppy/agents/pack/shepherd.py +327 -0
- code_puppy/agents/pack/terrier.py +281 -0
- code_puppy/agents/pack/watchdog.py +357 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +453 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +75 -0
- code_puppy/api/routers/sessions.py +234 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +674 -0
- code_puppy/chatgpt_codex_client.py +338 -0
- code_puppy/claude_cache_client.py +664 -0
- code_puppy/cli_runner.py +1038 -0
- code_puppy/command_line/__init__.py +1 -0
- code_puppy/command_line/add_model_menu.py +1092 -0
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +704 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +526 -0
- code_puppy/command_line/command_handler.py +283 -0
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +719 -0
- code_puppy/command_line/core_commands.py +853 -0
- code_puppy/command_line/diff_menu.py +865 -0
- code_puppy/command_line/file_path_completion.py +73 -0
- code_puppy/command_line/load_context_completion.py +52 -0
- code_puppy/command_line/mcp/__init__.py +10 -0
- code_puppy/command_line/mcp/base.py +32 -0
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +138 -0
- code_puppy/command_line/mcp/help_command.py +147 -0
- code_puppy/command_line/mcp/install_command.py +214 -0
- code_puppy/command_line/mcp/install_menu.py +705 -0
- code_puppy/command_line/mcp/list_command.py +94 -0
- code_puppy/command_line/mcp/logs_command.py +235 -0
- code_puppy/command_line/mcp/remove_command.py +82 -0
- code_puppy/command_line/mcp/restart_command.py +100 -0
- code_puppy/command_line/mcp/search_command.py +123 -0
- code_puppy/command_line/mcp/start_all_command.py +135 -0
- code_puppy/command_line/mcp/start_command.py +117 -0
- code_puppy/command_line/mcp/status_command.py +184 -0
- code_puppy/command_line/mcp/stop_all_command.py +112 -0
- code_puppy/command_line/mcp/stop_command.py +80 -0
- code_puppy/command_line/mcp/test_command.py +107 -0
- code_puppy/command_line/mcp/utils.py +129 -0
- code_puppy/command_line/mcp/wizard_utils.py +334 -0
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +197 -0
- code_puppy/command_line/model_settings_menu.py +932 -0
- code_puppy/command_line/motd.py +91 -0
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +342 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +846 -0
- code_puppy/command_line/session_commands.py +302 -0
- code_puppy/command_line/skills_completion.py +160 -0
- code_puppy/command_line/uc_menu.py +893 -0
- code_puppy/command_line/utils.py +93 -0
- code_puppy/command_line/wiggum_state.py +78 -0
- code_puppy/config.py +1787 -0
- code_puppy/error_logging.py +133 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +754 -0
- code_puppy/hook_engine/README.md +105 -0
- code_puppy/hook_engine/__init__.py +15 -0
- code_puppy/hook_engine/aliases.py +155 -0
- code_puppy/hook_engine/engine.py +195 -0
- code_puppy/hook_engine/executor.py +293 -0
- code_puppy/hook_engine/matcher.py +145 -0
- code_puppy/hook_engine/models.py +222 -0
- code_puppy/hook_engine/registry.py +106 -0
- code_puppy/hook_engine/validator.py +141 -0
- code_puppy/http_utils.py +361 -0
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +10 -0
- code_puppy/mcp_/__init__.py +66 -0
- code_puppy/mcp_/async_lifecycle.py +286 -0
- code_puppy/mcp_/blocking_startup.py +469 -0
- code_puppy/mcp_/captured_stdio_server.py +275 -0
- code_puppy/mcp_/circuit_breaker.py +290 -0
- code_puppy/mcp_/config_wizard.py +507 -0
- code_puppy/mcp_/dashboard.py +308 -0
- code_puppy/mcp_/error_isolation.py +407 -0
- code_puppy/mcp_/examples/retry_example.py +226 -0
- code_puppy/mcp_/health_monitor.py +589 -0
- code_puppy/mcp_/managed_server.py +428 -0
- code_puppy/mcp_/manager.py +807 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +451 -0
- code_puppy/mcp_/retry_manager.py +337 -0
- code_puppy/mcp_/server_registry_catalog.py +1126 -0
- code_puppy/mcp_/status_tracker.py +355 -0
- code_puppy/mcp_/system_tools.py +209 -0
- code_puppy/mcp_prompts/__init__.py +1 -0
- code_puppy/mcp_prompts/hook_creator.py +103 -0
- code_puppy/messaging/__init__.py +255 -0
- code_puppy/messaging/bus.py +613 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +361 -0
- code_puppy/messaging/messages.py +569 -0
- code_puppy/messaging/queue_console.py +271 -0
- code_puppy/messaging/renderers.py +311 -0
- code_puppy/messaging/rich_renderer.py +1153 -0
- code_puppy/messaging/spinner/__init__.py +83 -0
- code_puppy/messaging/spinner/console_spinner.py +240 -0
- code_puppy/messaging/spinner/spinner_base.py +96 -0
- code_puppy/messaging/subagent_console.py +460 -0
- code_puppy/model_factory.py +848 -0
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +168 -0
- code_puppy/models.json +130 -0
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +186 -0
- code_puppy/plugins/agent_skills/__init__.py +22 -0
- code_puppy/plugins/agent_skills/config.py +175 -0
- code_puppy/plugins/agent_skills/discovery.py +136 -0
- code_puppy/plugins/agent_skills/downloader.py +392 -0
- code_puppy/plugins/agent_skills/installer.py +22 -0
- code_puppy/plugins/agent_skills/metadata.py +219 -0
- code_puppy/plugins/agent_skills/prompt_builder.py +100 -0
- code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
- code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
- code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
- code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
- code_puppy/plugins/agent_skills/skills_menu.py +781 -0
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +133 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
- code_puppy/plugins/antigravity_oauth/storage.py +288 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +863 -0
- code_puppy/plugins/antigravity_oauth/utils.py +168 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +295 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +499 -0
- code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
- code_puppy/plugins/claude_code_hooks/config.py +131 -0
- code_puppy/plugins/claude_code_hooks/register_callbacks.py +163 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
- code_puppy/plugins/claude_code_oauth/config.py +52 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
- code_puppy/plugins/claude_code_oauth/utils.py +601 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +48 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +528 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/hook_creator/__init__.py +1 -0
- code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
- code_puppy/plugins/hook_manager/__init__.py +1 -0
- code_puppy/plugins/hook_manager/config.py +277 -0
- code_puppy/plugins/hook_manager/hooks_menu.py +551 -0
- code_puppy/plugins/hook_manager/register_callbacks.py +205 -0
- code_puppy/plugins/oauth_puppy_html.py +224 -0
- code_puppy/plugins/scheduler/__init__.py +1 -0
- code_puppy/plugins/scheduler/register_callbacks.py +88 -0
- code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
- code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/plugins/synthetic_status/__init__.py +1 -0
- code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
- code_puppy/plugins/synthetic_status/status_api.py +147 -0
- code_puppy/plugins/universal_constructor/__init__.py +13 -0
- code_puppy/plugins/universal_constructor/models.py +138 -0
- code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
- code_puppy/plugins/universal_constructor/registry.py +302 -0
- code_puppy/plugins/universal_constructor/sandbox.py +584 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +317 -0
- code_puppy/reopenable_async_client.py +232 -0
- code_puppy/round_robin_model.py +150 -0
- code_puppy/scheduler/__init__.py +41 -0
- code_puppy/scheduler/__main__.py +9 -0
- code_puppy/scheduler/cli.py +118 -0
- code_puppy/scheduler/config.py +126 -0
- code_puppy/scheduler/daemon.py +280 -0
- code_puppy/scheduler/executor.py +155 -0
- code_puppy/scheduler/platform.py +19 -0
- code_puppy/scheduler/platform_unix.py +22 -0
- code_puppy/scheduler/platform_win.py +32 -0
- code_puppy/session_storage.py +338 -0
- code_puppy/status_display.py +257 -0
- code_puppy/summarization_agent.py +176 -0
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +470 -0
- code_puppy/tools/agent_tools.py +616 -0
- code_puppy/tools/ask_user_question/__init__.py +26 -0
- code_puppy/tools/ask_user_question/constants.py +73 -0
- code_puppy/tools/ask_user_question/demo_tui.py +55 -0
- code_puppy/tools/ask_user_question/handler.py +232 -0
- code_puppy/tools/ask_user_question/models.py +304 -0
- code_puppy/tools/ask_user_question/registration.py +36 -0
- code_puppy/tools/ask_user_question/renderers.py +309 -0
- code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
- code_puppy/tools/ask_user_question/theme.py +155 -0
- code_puppy/tools/ask_user_question/tui_loop.py +423 -0
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +378 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +534 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +1346 -0
- code_puppy/tools/common.py +1409 -0
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +739 -0
- code_puppy/tools/file_operations.py +802 -0
- code_puppy/tools/scheduler_tools.py +412 -0
- code_puppy/tools/skills_tools.py +251 -0
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/tools/tools_content.py +51 -0
- code_puppy/tools/universal_constructor.py +889 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +82 -0
- newcode-0.1.1.data/data/code_puppy/models.json +130 -0
- newcode-0.1.1.data/data/code_puppy/models_dev_api.json +1 -0
- newcode-0.1.1.dist-info/METADATA +154 -0
- newcode-0.1.1.dist-info/RECORD +289 -0
- newcode-0.1.1.dist-info/WHEEL +4 -0
- newcode-0.1.1.dist-info/entry_points.txt +3 -0
- newcode-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Monkey patches for pydantic-ai.
|
|
2
|
+
|
|
3
|
+
This module contains all monkey patches needed to customize pydantic-ai behavior.
|
|
4
|
+
These patches MUST be applied before any other pydantic-ai imports to work correctly.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from code_puppy.pydantic_patches import apply_all_patches
|
|
8
|
+
apply_all_patches()
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_code_puppy_version() -> str:
|
|
16
|
+
"""Get the current package version."""
|
|
17
|
+
try:
|
|
18
|
+
return importlib.metadata.version("newcode")
|
|
19
|
+
except Exception:
|
|
20
|
+
return "0.0.0-dev"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def patch_user_agent() -> None:
|
|
24
|
+
"""Patch pydantic-ai's User-Agent to use our version.
|
|
25
|
+
|
|
26
|
+
pydantic-ai sets its own User-Agent ('pydantic-ai/x.x.x') via a @cache-decorated
|
|
27
|
+
function. We replace it with a dynamic function that returns:
|
|
28
|
+
- 'KimiCLI/0.63' for Kimi models
|
|
29
|
+
- 'NewCode/{version}' for all other models
|
|
30
|
+
|
|
31
|
+
This MUST be called before any pydantic-ai models are created.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
import pydantic_ai.models as pydantic_models
|
|
35
|
+
|
|
36
|
+
version = _get_code_puppy_version()
|
|
37
|
+
|
|
38
|
+
# Clear cache if already called
|
|
39
|
+
if hasattr(pydantic_models.get_user_agent, "cache_clear"):
|
|
40
|
+
pydantic_models.get_user_agent.cache_clear()
|
|
41
|
+
|
|
42
|
+
def _get_dynamic_user_agent() -> str:
|
|
43
|
+
"""Return User-Agent based on current model selection."""
|
|
44
|
+
try:
|
|
45
|
+
from code_puppy.config import get_global_model_name
|
|
46
|
+
|
|
47
|
+
model_name = get_global_model_name()
|
|
48
|
+
if model_name and "kimi" in model_name.lower():
|
|
49
|
+
return "KimiCLI/0.63"
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return f"NewCode/{version}"
|
|
53
|
+
|
|
54
|
+
pydantic_models.get_user_agent = _get_dynamic_user_agent
|
|
55
|
+
except Exception:
|
|
56
|
+
pass # Don't crash on patch failure
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def patch_message_history_cleaning() -> None:
|
|
60
|
+
"""Disable overly strict message history cleaning in pydantic-ai."""
|
|
61
|
+
try:
|
|
62
|
+
from pydantic_ai import _agent_graph
|
|
63
|
+
|
|
64
|
+
_agent_graph._clean_message_history = lambda messages: messages
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def patch_process_message_history() -> None:
|
|
70
|
+
"""Patch _process_message_history to skip strict ModelRequest validation.
|
|
71
|
+
|
|
72
|
+
Pydantic AI added a validation that history must end with ModelRequest,
|
|
73
|
+
but this breaks valid conversation flows. We patch it to skip that validation.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
from pydantic_ai import _agent_graph
|
|
77
|
+
|
|
78
|
+
async def _patched_process_message_history(messages, processors, run_context):
|
|
79
|
+
"""Patched version that doesn't enforce ModelRequest at end."""
|
|
80
|
+
from pydantic_ai._agent_graph import (
|
|
81
|
+
_HistoryProcessorAsync,
|
|
82
|
+
_HistoryProcessorSync,
|
|
83
|
+
_HistoryProcessorSyncWithCtx,
|
|
84
|
+
cast,
|
|
85
|
+
exceptions,
|
|
86
|
+
is_async_callable,
|
|
87
|
+
is_takes_ctx,
|
|
88
|
+
run_in_executor,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
for processor in processors:
|
|
92
|
+
takes_ctx = is_takes_ctx(processor)
|
|
93
|
+
|
|
94
|
+
if is_async_callable(processor):
|
|
95
|
+
if takes_ctx:
|
|
96
|
+
messages = await processor(run_context, messages)
|
|
97
|
+
else:
|
|
98
|
+
async_processor = cast(_HistoryProcessorAsync, processor)
|
|
99
|
+
messages = await async_processor(messages)
|
|
100
|
+
else:
|
|
101
|
+
if takes_ctx:
|
|
102
|
+
sync_processor_with_ctx = cast(
|
|
103
|
+
_HistoryProcessorSyncWithCtx, processor
|
|
104
|
+
)
|
|
105
|
+
messages = await run_in_executor(
|
|
106
|
+
sync_processor_with_ctx, run_context, messages
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
sync_processor = cast(_HistoryProcessorSync, processor)
|
|
110
|
+
messages = await run_in_executor(sync_processor, messages)
|
|
111
|
+
|
|
112
|
+
if len(messages) == 0:
|
|
113
|
+
raise exceptions.UserError("Processed history cannot be empty.")
|
|
114
|
+
|
|
115
|
+
# NOTE: We intentionally skip the "must end with ModelRequest" validation
|
|
116
|
+
# that was added in newer Pydantic AI versions.
|
|
117
|
+
|
|
118
|
+
return messages
|
|
119
|
+
|
|
120
|
+
_agent_graph._process_message_history = _patched_process_message_history
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def patch_tool_call_json_repair() -> None:
|
|
126
|
+
"""Patch pydantic-ai's _call_tool to auto-repair malformed JSON arguments.
|
|
127
|
+
|
|
128
|
+
LLMs sometimes produce slightly broken JSON in tool calls (trailing commas,
|
|
129
|
+
missing quotes, etc.). This patch intercepts tool calls and runs json_repair
|
|
130
|
+
on the arguments before validation, preventing unnecessary retries.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
import json_repair
|
|
134
|
+
from pydantic_ai._tool_manager import ToolManager
|
|
135
|
+
|
|
136
|
+
# Store the original method
|
|
137
|
+
_original_call_tool = ToolManager._call_tool
|
|
138
|
+
|
|
139
|
+
async def _patched_call_tool(
|
|
140
|
+
self,
|
|
141
|
+
call,
|
|
142
|
+
*,
|
|
143
|
+
allow_partial: bool,
|
|
144
|
+
wrap_validation_errors: bool,
|
|
145
|
+
approved: bool,
|
|
146
|
+
metadata: Any = None,
|
|
147
|
+
):
|
|
148
|
+
"""Patched _call_tool that repairs malformed JSON before validation."""
|
|
149
|
+
# Only attempt repair if args is a string (JSON)
|
|
150
|
+
if isinstance(call.args, str) and call.args:
|
|
151
|
+
try:
|
|
152
|
+
repaired = json_repair.repair_json(call.args)
|
|
153
|
+
if repaired != call.args:
|
|
154
|
+
# Update the call args with repaired JSON
|
|
155
|
+
call.args = repaired
|
|
156
|
+
except Exception:
|
|
157
|
+
pass # If repair fails, let original validation handle it
|
|
158
|
+
|
|
159
|
+
# Call the original method
|
|
160
|
+
return await _original_call_tool(
|
|
161
|
+
self,
|
|
162
|
+
call,
|
|
163
|
+
allow_partial=allow_partial,
|
|
164
|
+
wrap_validation_errors=wrap_validation_errors,
|
|
165
|
+
approved=approved,
|
|
166
|
+
metadata=metadata,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Apply the patch
|
|
170
|
+
ToolManager._call_tool = _patched_call_tool
|
|
171
|
+
|
|
172
|
+
except ImportError:
|
|
173
|
+
pass # json_repair or pydantic_ai not available
|
|
174
|
+
except Exception:
|
|
175
|
+
pass # Don't crash on patch failure
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def patch_tool_call_callbacks() -> None:
|
|
179
|
+
"""Patch pydantic-ai's _call_tool to fire pre/post tool callbacks.
|
|
180
|
+
|
|
181
|
+
This wraps ToolManager._call_tool so that every tool invocation
|
|
182
|
+
automatically triggers the ``pre_tool_call`` and ``post_tool_call``
|
|
183
|
+
callback hooks defined in ``code_puppy.callbacks``, without needing
|
|
184
|
+
to decorate each tool function individually.
|
|
185
|
+
"""
|
|
186
|
+
import time
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
from pydantic_ai._tool_manager import ToolManager
|
|
190
|
+
|
|
191
|
+
_original_call_tool = ToolManager._call_tool
|
|
192
|
+
|
|
193
|
+
# Tool name prefix used by Claude Code OAuth - tools are prefixed on
|
|
194
|
+
# outgoing requests, so we need to unprefix them when they come back
|
|
195
|
+
TOOL_PREFIX = "cp_"
|
|
196
|
+
|
|
197
|
+
async def _patched_call_tool(
|
|
198
|
+
self,
|
|
199
|
+
call,
|
|
200
|
+
*,
|
|
201
|
+
allow_partial: bool,
|
|
202
|
+
wrap_validation_errors: bool,
|
|
203
|
+
approved: bool,
|
|
204
|
+
metadata: Any = None,
|
|
205
|
+
):
|
|
206
|
+
tool_name = call.tool_name
|
|
207
|
+
|
|
208
|
+
# Unprefix tool names from Claude Code OAuth responses
|
|
209
|
+
# The cp_ prefix is added for OAuth compatibility but needs to be
|
|
210
|
+
# stripped so pydantic-ai can find the actual tool
|
|
211
|
+
if tool_name and tool_name.startswith(TOOL_PREFIX):
|
|
212
|
+
unprefixed_name = tool_name[len(TOOL_PREFIX) :]
|
|
213
|
+
# Try to update the call object's tool_name
|
|
214
|
+
try:
|
|
215
|
+
call.tool_name = unprefixed_name
|
|
216
|
+
tool_name = unprefixed_name
|
|
217
|
+
except (AttributeError, TypeError):
|
|
218
|
+
# If the object is immutable, we can't modify it directly
|
|
219
|
+
# The tool lookup might still fail, but at least callbacks
|
|
220
|
+
# will use the unprefixed name
|
|
221
|
+
tool_name = unprefixed_name
|
|
222
|
+
|
|
223
|
+
# Normalise args to a dict for the callback contract
|
|
224
|
+
tool_args: dict = {}
|
|
225
|
+
if isinstance(call.args, dict):
|
|
226
|
+
tool_args = call.args
|
|
227
|
+
elif isinstance(call.args, str):
|
|
228
|
+
try:
|
|
229
|
+
import json
|
|
230
|
+
|
|
231
|
+
tool_args = json.loads(call.args)
|
|
232
|
+
except Exception:
|
|
233
|
+
tool_args = {"raw": call.args}
|
|
234
|
+
|
|
235
|
+
# --- pre_tool_call (with blocking support) ---
|
|
236
|
+
# Returns a string tool-result on block so pydantic-ai sees a clean
|
|
237
|
+
# "BLOCKED: ..." message and the agent can react gracefully, without
|
|
238
|
+
# triggering UnexpectedModelBehavior crashes.
|
|
239
|
+
try:
|
|
240
|
+
from code_puppy import callbacks
|
|
241
|
+
from code_puppy.messaging import emit_warning
|
|
242
|
+
|
|
243
|
+
callback_results = await callbacks.on_pre_tool_call(
|
|
244
|
+
tool_name, tool_args
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
for callback_result in callback_results:
|
|
248
|
+
if (
|
|
249
|
+
callback_result
|
|
250
|
+
and isinstance(callback_result, dict)
|
|
251
|
+
and callback_result.get("blocked")
|
|
252
|
+
):
|
|
253
|
+
raw_reason = (
|
|
254
|
+
callback_result.get("error_message")
|
|
255
|
+
or callback_result.get("reason")
|
|
256
|
+
or ""
|
|
257
|
+
)
|
|
258
|
+
if "[BLOCKED]" in raw_reason:
|
|
259
|
+
clean_reason = raw_reason[
|
|
260
|
+
raw_reason.index("[BLOCKED]") :
|
|
261
|
+
].strip()
|
|
262
|
+
else:
|
|
263
|
+
clean_reason = (
|
|
264
|
+
raw_reason.strip() or "Tool execution blocked by hook"
|
|
265
|
+
)
|
|
266
|
+
block_msg = f"🚫 Hook blocked this tool call: {clean_reason}"
|
|
267
|
+
emit_warning(block_msg)
|
|
268
|
+
return f"ERROR: {block_msg}\n\nThe hook policy prevented this tool from running. Please inform the user and do not retry this specific command."
|
|
269
|
+
except Exception:
|
|
270
|
+
pass # other errors don't block tool execution
|
|
271
|
+
|
|
272
|
+
start = time.perf_counter()
|
|
273
|
+
error: Exception | None = None
|
|
274
|
+
result = None
|
|
275
|
+
try:
|
|
276
|
+
result = await _original_call_tool(
|
|
277
|
+
self,
|
|
278
|
+
call,
|
|
279
|
+
allow_partial=allow_partial,
|
|
280
|
+
wrap_validation_errors=wrap_validation_errors,
|
|
281
|
+
approved=approved,
|
|
282
|
+
metadata=metadata,
|
|
283
|
+
)
|
|
284
|
+
return result
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
error = exc
|
|
287
|
+
raise
|
|
288
|
+
finally:
|
|
289
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
290
|
+
final_result = result if error is None else {"error": str(error)}
|
|
291
|
+
try:
|
|
292
|
+
from code_puppy import callbacks
|
|
293
|
+
|
|
294
|
+
await callbacks.on_post_tool_call(
|
|
295
|
+
tool_name, tool_args, final_result, duration_ms
|
|
296
|
+
)
|
|
297
|
+
except Exception:
|
|
298
|
+
pass # never block tool execution
|
|
299
|
+
|
|
300
|
+
ToolManager._call_tool = _patched_call_tool
|
|
301
|
+
|
|
302
|
+
except ImportError:
|
|
303
|
+
pass
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def apply_all_patches() -> None:
|
|
309
|
+
"""Apply all pydantic-ai monkey patches.
|
|
310
|
+
|
|
311
|
+
Call this at the very top of main.py, before any other imports.
|
|
312
|
+
"""
|
|
313
|
+
patch_user_agent()
|
|
314
|
+
patch_message_history_cleaning()
|
|
315
|
+
patch_process_message_history()
|
|
316
|
+
patch_tool_call_json_repair()
|
|
317
|
+
patch_tool_call_callbacks()
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ReopenableAsyncClient - A reopenable httpx.AsyncClient wrapper.
|
|
3
|
+
|
|
4
|
+
This module provides a ReopenableAsyncClient class that extends httpx.AsyncClient
|
|
5
|
+
to support reopening after being closed, which the standard httpx.AsyncClient
|
|
6
|
+
doesn't support.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Optional, Union
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReopenableAsyncClient:
|
|
17
|
+
"""
|
|
18
|
+
A wrapper around httpx.AsyncClient that can be reopened after being closed.
|
|
19
|
+
|
|
20
|
+
Standard httpx.AsyncClient becomes unusable after calling aclose().
|
|
21
|
+
This class allows you to reopen the client and continue using it.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
>>> client = ReopenableAsyncClient(timeout=30.0)
|
|
25
|
+
>>> await client.get("https://httpbin.org/get")
|
|
26
|
+
>>> await client.aclose()
|
|
27
|
+
>>> # Client is now closed, but can be reopened
|
|
28
|
+
>>> await client.reopen()
|
|
29
|
+
>>> await client.get("https://httpbin.org/get") # Works!
|
|
30
|
+
|
|
31
|
+
The client preserves all original configuration when reopening.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
class _StreamWrapper:
|
|
35
|
+
"""Async context manager wrapper for streaming responses."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
parent_client: "ReopenableAsyncClient",
|
|
40
|
+
method: str,
|
|
41
|
+
url: Union[str, httpx.URL],
|
|
42
|
+
**kwargs,
|
|
43
|
+
):
|
|
44
|
+
self.parent_client = parent_client
|
|
45
|
+
self.method = method
|
|
46
|
+
self.url = url
|
|
47
|
+
self.kwargs = kwargs
|
|
48
|
+
self._stream_context = None
|
|
49
|
+
|
|
50
|
+
async def __aenter__(self):
|
|
51
|
+
client = await self.parent_client._ensure_client_open()
|
|
52
|
+
self._stream_context = client.stream(self.method, self.url, **self.kwargs)
|
|
53
|
+
return await self._stream_context.__aenter__()
|
|
54
|
+
|
|
55
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
56
|
+
if self._stream_context:
|
|
57
|
+
return await self._stream_context.__aexit__(exc_type, exc_val, exc_tb)
|
|
58
|
+
|
|
59
|
+
def __init__(self, client_class=None, **kwargs):
|
|
60
|
+
"""
|
|
61
|
+
Initialize the ReopenableAsyncClient.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
client_class: Class to use for creating the internal client (defaults to httpx.AsyncClient)
|
|
65
|
+
**kwargs: All arguments that would be passed to the client constructor
|
|
66
|
+
"""
|
|
67
|
+
self._client_class = client_class or httpx.AsyncClient
|
|
68
|
+
self._client_kwargs = kwargs.copy()
|
|
69
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
70
|
+
self._is_closed = True
|
|
71
|
+
self._lock = asyncio.Lock()
|
|
72
|
+
self._sync_lock = threading.Lock()
|
|
73
|
+
|
|
74
|
+
async def _ensure_client_open(self) -> httpx.AsyncClient:
|
|
75
|
+
"""
|
|
76
|
+
Ensure the underlying client is open and ready to use.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The active client instance
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
RuntimeError: If client cannot be opened
|
|
83
|
+
"""
|
|
84
|
+
async with self._lock:
|
|
85
|
+
if self._is_closed or self._client is None:
|
|
86
|
+
await self._create_client()
|
|
87
|
+
return self._client
|
|
88
|
+
|
|
89
|
+
async def _create_client(self) -> None:
|
|
90
|
+
"""Create a new client with the stored configuration."""
|
|
91
|
+
if self._client is not None and not self._is_closed:
|
|
92
|
+
# Close existing client first
|
|
93
|
+
await self._client.aclose()
|
|
94
|
+
|
|
95
|
+
self._client = self._client_class(**self._client_kwargs)
|
|
96
|
+
self._is_closed = False
|
|
97
|
+
|
|
98
|
+
async def reopen(self) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Explicitly reopen the client after it has been closed.
|
|
101
|
+
|
|
102
|
+
This is useful when you want to reuse a client that was previously closed.
|
|
103
|
+
"""
|
|
104
|
+
async with self._lock:
|
|
105
|
+
await self._create_client()
|
|
106
|
+
|
|
107
|
+
async def aclose(self) -> None:
|
|
108
|
+
"""
|
|
109
|
+
Close the underlying httpx.AsyncClient.
|
|
110
|
+
|
|
111
|
+
After calling this, the client can be reopened using reopen() or
|
|
112
|
+
automatically when making the next request.
|
|
113
|
+
"""
|
|
114
|
+
async with self._lock:
|
|
115
|
+
if self._client is not None and not self._is_closed:
|
|
116
|
+
await self._client.aclose()
|
|
117
|
+
self._is_closed = True
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def is_closed(self) -> bool:
|
|
121
|
+
"""Check if the client is currently closed."""
|
|
122
|
+
return self._is_closed or self._client is None
|
|
123
|
+
|
|
124
|
+
# Delegate all httpx.AsyncClient methods to the underlying client
|
|
125
|
+
|
|
126
|
+
async def get(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
|
|
127
|
+
"""Make a GET request."""
|
|
128
|
+
client = await self._ensure_client_open()
|
|
129
|
+
return await client.get(url, **kwargs)
|
|
130
|
+
|
|
131
|
+
async def post(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
|
|
132
|
+
"""Make a POST request."""
|
|
133
|
+
client = await self._ensure_client_open()
|
|
134
|
+
return await client.post(url, **kwargs)
|
|
135
|
+
|
|
136
|
+
async def put(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
|
|
137
|
+
"""Make a PUT request."""
|
|
138
|
+
client = await self._ensure_client_open()
|
|
139
|
+
return await client.put(url, **kwargs)
|
|
140
|
+
|
|
141
|
+
async def patch(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
|
|
142
|
+
"""Make a PATCH request."""
|
|
143
|
+
client = await self._ensure_client_open()
|
|
144
|
+
return await client.patch(url, **kwargs)
|
|
145
|
+
|
|
146
|
+
async def delete(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
|
|
147
|
+
"""Make a DELETE request."""
|
|
148
|
+
client = await self._ensure_client_open()
|
|
149
|
+
return await client.delete(url, **kwargs)
|
|
150
|
+
|
|
151
|
+
async def head(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
|
|
152
|
+
"""Make a HEAD request."""
|
|
153
|
+
client = await self._ensure_client_open()
|
|
154
|
+
return await client.head(url, **kwargs)
|
|
155
|
+
|
|
156
|
+
async def options(self, url: Union[str, httpx.URL], **kwargs) -> httpx.Response:
|
|
157
|
+
"""Make an OPTIONS request."""
|
|
158
|
+
client = await self._ensure_client_open()
|
|
159
|
+
return await client.options(url, **kwargs)
|
|
160
|
+
|
|
161
|
+
async def request(
|
|
162
|
+
self, method: str, url: Union[str, httpx.URL], **kwargs
|
|
163
|
+
) -> httpx.Response:
|
|
164
|
+
"""Make a request with the specified HTTP method."""
|
|
165
|
+
client = await self._ensure_client_open()
|
|
166
|
+
return await client.request(method, url, **kwargs)
|
|
167
|
+
|
|
168
|
+
async def send(self, request: httpx.Request, **kwargs) -> httpx.Response:
|
|
169
|
+
"""Send a pre-built request."""
|
|
170
|
+
client = await self._ensure_client_open()
|
|
171
|
+
return await client.send(request, **kwargs)
|
|
172
|
+
|
|
173
|
+
def build_request(
|
|
174
|
+
self, method: str, url: Union[str, httpx.URL], **kwargs
|
|
175
|
+
) -> httpx.Request:
|
|
176
|
+
"""
|
|
177
|
+
Build a request without sending it.
|
|
178
|
+
|
|
179
|
+
Note: This creates a temporary client if none exists, but doesn't keep it open.
|
|
180
|
+
"""
|
|
181
|
+
with self._sync_lock:
|
|
182
|
+
if self._client is None or self._is_closed:
|
|
183
|
+
# Create temporary sync client for building request only
|
|
184
|
+
# Use httpx.Client (sync) so we can properly close it
|
|
185
|
+
temp_client = httpx.Client(**self._client_kwargs)
|
|
186
|
+
try:
|
|
187
|
+
return temp_client.build_request(method, url, **kwargs)
|
|
188
|
+
finally:
|
|
189
|
+
temp_client.close()
|
|
190
|
+
return self._client.build_request(method, url, **kwargs)
|
|
191
|
+
|
|
192
|
+
def stream(self, method: str, url: Union[str, httpx.URL], **kwargs):
|
|
193
|
+
"""Stream a request. Returns an async context manager."""
|
|
194
|
+
return self._StreamWrapper(self, method, url, **kwargs)
|
|
195
|
+
|
|
196
|
+
# Context manager support
|
|
197
|
+
async def __aenter__(self):
|
|
198
|
+
"""Async context manager entry."""
|
|
199
|
+
await self._ensure_client_open()
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
203
|
+
"""Async context manager exit."""
|
|
204
|
+
await self.aclose()
|
|
205
|
+
|
|
206
|
+
# Properties that don't require an active client
|
|
207
|
+
@property
|
|
208
|
+
def timeout(self) -> Optional[httpx.Timeout]:
|
|
209
|
+
"""Get the configured timeout."""
|
|
210
|
+
return self._client_kwargs.get("timeout")
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def headers(self) -> httpx.Headers:
|
|
214
|
+
"""Get the configured headers."""
|
|
215
|
+
if self._client is not None:
|
|
216
|
+
return self._client.headers
|
|
217
|
+
# Return headers from kwargs if client doesn't exist
|
|
218
|
+
headers = self._client_kwargs.get("headers", {})
|
|
219
|
+
return httpx.Headers(headers)
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def cookies(self) -> httpx.Cookies:
|
|
223
|
+
"""Get the current cookies."""
|
|
224
|
+
if self._client is not None and not self._is_closed:
|
|
225
|
+
return self._client.cookies
|
|
226
|
+
# Return empty cookies if client doesn't exist or is closed
|
|
227
|
+
return httpx.Cookies()
|
|
228
|
+
|
|
229
|
+
def __repr__(self) -> str:
|
|
230
|
+
"""String representation of the client."""
|
|
231
|
+
status = "closed" if self.is_closed else "open"
|
|
232
|
+
return f"<ReopenableAsyncClient [{status}]>"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from contextlib import asynccontextmanager, suppress
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, AsyncIterator, List
|
|
5
|
+
|
|
6
|
+
from pydantic_ai._run_context import RunContext
|
|
7
|
+
from pydantic_ai.models import (
|
|
8
|
+
Model,
|
|
9
|
+
ModelMessage,
|
|
10
|
+
ModelRequestParameters,
|
|
11
|
+
ModelResponse,
|
|
12
|
+
ModelSettings,
|
|
13
|
+
StreamedResponse,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from opentelemetry.context import get_current_span
|
|
18
|
+
except ImportError:
|
|
19
|
+
# If opentelemetry is not installed, provide a dummy implementation
|
|
20
|
+
def get_current_span():
|
|
21
|
+
class DummySpan:
|
|
22
|
+
def is_recording(self):
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
def set_attributes(self, attributes):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
return DummySpan()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(init=False)
|
|
32
|
+
class RoundRobinModel(Model):
|
|
33
|
+
"""A model that cycles through multiple models in a round-robin fashion.
|
|
34
|
+
|
|
35
|
+
This model distributes requests across multiple candidate models to help
|
|
36
|
+
overcome rate limits or distribute load.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
models: List[Model]
|
|
40
|
+
_current_index: int = field(default=0, repr=False)
|
|
41
|
+
_model_name: str = field(repr=False)
|
|
42
|
+
_rotate_every: int = field(default=1, repr=False)
|
|
43
|
+
_request_count: int = field(default=0, repr=False)
|
|
44
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*models: Model,
|
|
49
|
+
rotate_every: int = 1,
|
|
50
|
+
settings: ModelSettings | None = None,
|
|
51
|
+
):
|
|
52
|
+
"""Initialize a round-robin model instance.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
models: The model instances to cycle through.
|
|
56
|
+
rotate_every: Number of requests before rotating to the next model (default: 1).
|
|
57
|
+
settings: Model settings that will be used as defaults for this model.
|
|
58
|
+
"""
|
|
59
|
+
super().__init__(settings=settings)
|
|
60
|
+
if not models:
|
|
61
|
+
raise ValueError("At least one model must be provided")
|
|
62
|
+
if rotate_every < 1:
|
|
63
|
+
raise ValueError("rotate_every must be at least 1")
|
|
64
|
+
self.models = list(models)
|
|
65
|
+
self._current_index = 0
|
|
66
|
+
self._request_count = 0
|
|
67
|
+
self._rotate_every = rotate_every
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def model_name(self) -> str:
|
|
72
|
+
"""The model name showing this is a round-robin model with its candidates."""
|
|
73
|
+
base_name = f"round_robin:{','.join(model.model_name for model in self.models)}"
|
|
74
|
+
if self._rotate_every != 1:
|
|
75
|
+
return f"{base_name}:rotate_every={self._rotate_every}"
|
|
76
|
+
return base_name
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def system(self) -> str:
|
|
80
|
+
"""System prompt from the current model."""
|
|
81
|
+
return self.models[self._current_index].system
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def base_url(self) -> str | None:
|
|
85
|
+
"""Base URL from the current model."""
|
|
86
|
+
return self.models[self._current_index].base_url
|
|
87
|
+
|
|
88
|
+
def _get_next_model(self) -> Model:
|
|
89
|
+
"""Get the next model in the round-robin sequence and update the index."""
|
|
90
|
+
with self._lock:
|
|
91
|
+
model = self.models[self._current_index]
|
|
92
|
+
self._request_count += 1
|
|
93
|
+
if self._request_count >= self._rotate_every:
|
|
94
|
+
self._current_index = (self._current_index + 1) % len(self.models)
|
|
95
|
+
self._request_count = 0
|
|
96
|
+
return model
|
|
97
|
+
|
|
98
|
+
async def request(
|
|
99
|
+
self,
|
|
100
|
+
messages: list[ModelMessage],
|
|
101
|
+
model_settings: ModelSettings | None,
|
|
102
|
+
model_request_parameters: ModelRequestParameters,
|
|
103
|
+
) -> ModelResponse:
|
|
104
|
+
"""Make a request using the next model in the round-robin sequence."""
|
|
105
|
+
current_model = self._get_next_model()
|
|
106
|
+
# Use prepare_request to merge settings and customize parameters
|
|
107
|
+
merged_settings, prepared_params = current_model.prepare_request(
|
|
108
|
+
model_settings, model_request_parameters
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
response = await current_model.request(
|
|
113
|
+
messages, merged_settings, prepared_params
|
|
114
|
+
)
|
|
115
|
+
self._set_span_attributes(current_model)
|
|
116
|
+
return response
|
|
117
|
+
except Exception:
|
|
118
|
+
# Unlike FallbackModel, we don't try other models here
|
|
119
|
+
# The round-robin strategy is about distribution, not failover
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
@asynccontextmanager
|
|
123
|
+
async def request_stream(
|
|
124
|
+
self,
|
|
125
|
+
messages: list[ModelMessage],
|
|
126
|
+
model_settings: ModelSettings | None,
|
|
127
|
+
model_request_parameters: ModelRequestParameters,
|
|
128
|
+
run_context: RunContext[Any] | None = None,
|
|
129
|
+
) -> AsyncIterator[StreamedResponse]:
|
|
130
|
+
"""Make a streaming request using the next model in the round-robin sequence."""
|
|
131
|
+
current_model = self._get_next_model()
|
|
132
|
+
# Use prepare_request to merge settings and customize parameters
|
|
133
|
+
merged_settings, prepared_params = current_model.prepare_request(
|
|
134
|
+
model_settings, model_request_parameters
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async with current_model.request_stream(
|
|
138
|
+
messages, merged_settings, prepared_params, run_context
|
|
139
|
+
) as response:
|
|
140
|
+
self._set_span_attributes(current_model)
|
|
141
|
+
yield response
|
|
142
|
+
|
|
143
|
+
def _set_span_attributes(self, model: Model):
|
|
144
|
+
"""Set span attributes for observability."""
|
|
145
|
+
with suppress(Exception):
|
|
146
|
+
span = get_current_span()
|
|
147
|
+
if span.is_recording():
|
|
148
|
+
attributes = getattr(span, "attributes", {})
|
|
149
|
+
if attributes.get("gen_ai.request.model") == self.model_name:
|
|
150
|
+
span.set_attributes(model.model_attributes(model))
|