From 63969b596d2c3d2b83ab5960c76fbb1bf63e2979 Mon Sep 17 00:00:00 2001 From: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:52:59 +0100 Subject: [PATCH] Release/v0.1.4 (#266) * feat(middleware): reposition code interpreter middleware in the stack * feat(models): add qwen3.7-plus model entry and update context window comment * feat(models): add qwen3.7-max and qwen3.7-plus model entries for DashScope * feat(auxiliary): implement auxiliary model support for background tasks and tool selection - Added auxiliary model configuration to EvoScientistConfig. - Introduced _ensure_auxiliary_chat_model function to manage auxiliary model instances. - Updated onboarding steps to include auxiliary model selection. - Modified middleware to route tool selection to the auxiliary model when applicable. - Enhanced tests to cover auxiliary model functionality and configuration. * feat(steps): update UI backend selection options and descriptions * Refactor code structure for improved readability and maintainability * feat(patches): implement OpenRouter response reasoning item stripping to prevent multi-turn errors * feat: update version to v0.1.4 in badges, README, and pyproject.toml; adjust skill counts in steps.py * feat(config): add auxiliary model and provider environment variables to test setup --- .github/assets/badge-pypi-dark.svg | 2 +- .github/assets/badge-pypi-light.svg | 2 +- EvoScientist/EvoScientist.py | 60 +++++++- EvoScientist/config/onboard/steps.py | 101 +++++++++--- EvoScientist/config/onboard/style.py | 15 ++ EvoScientist/config/onboard/wizard.py | 62 ++++++++ EvoScientist/config/settings.py | 8 + EvoScientist/llm/context_window.py | 3 +- EvoScientist/llm/models.py | 22 ++- EvoScientist/llm/patches.py | 57 +++++++ EvoScientist/middleware/memory_lifecycle.py | 6 +- README.md | 5 +- README.zh-CN.md | 5 +- pyproject.toml | 4 +- tests/test_ask_user.py | 6 + tests/test_async_subagent_factory.py | 8 + tests/test_auxiliary_model.py | 159 +++++++++++++++++++ tests/test_config.py | 40 +++++ tests/test_context_editing_middleware.py | 4 + tests/test_llm.py | 142 ++++++++++++++++- tests/test_onboard.py | 160 +++++++++++++++++++- tests/test_runtime_context_middleware.py | 2 + tests/test_tool_selector_middleware.py | 4 + uv.lock | 124 +++++++-------- 24 files changed, 888 insertions(+), 113 deletions(-) create mode 100644 tests/test_auxiliary_model.py diff --git a/.github/assets/badge-pypi-dark.svg b/.github/assets/badge-pypi-dark.svg index dce4c53..8e2b2a6 100644 --- a/.github/assets/badge-pypi-dark.svg +++ b/.github/assets/badge-pypi-dark.svg @@ -5,5 +5,5 @@ v0.1.3 + font-size="13" font-weight="700" fill="#ffffff">v0.1.4 \ No newline at end of file diff --git a/.github/assets/badge-pypi-light.svg b/.github/assets/badge-pypi-light.svg index a2523be..90962be 100644 --- a/.github/assets/badge-pypi-light.svg +++ b/.github/assets/badge-pypi-light.svg @@ -5,5 +5,5 @@ v0.1.3 + font-size="13" font-weight="700" fill="#ffffff">v0.1.4 \ No newline at end of file diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index c20520a..fe5f84a 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -59,6 +59,13 @@ _chat_model = None # /model switch to lag one step (see issue #179). _chat_model_key: tuple[str | None, str | None] | None = None +# Auxiliary model for background/helper LLM calls (memory workers + main-agent +# tool selector). Cached separately from the main model; falls back to the main +# instance when the auxiliary_* config fields are empty (see +# _ensure_auxiliary_chat_model). +_auxiliary_chat_model = None +_auxiliary_chat_model_key: tuple[str | None, str | None] | None = None + # Cache MCP tools by the effective config signature to avoid reconnecting # to MCP servers on every `/new` when config is unchanged. _MCP_TOOLS_CACHE_KEY: str | None = None @@ -124,6 +131,32 @@ def _ensure_chat_model(): return _chat_model +def _ensure_auxiliary_chat_model(): + """Return the auxiliary chat model for background/helper LLM calls. + + Resolves ``(cfg.auxiliary_model or cfg.model, cfg.auxiliary_provider or + cfg.provider)``. When the auxiliary fields are empty — or resolve to the same + ``(model, provider)`` pair as the main model — returns the main + ``_ensure_chat_model()`` instance directly, so no second client is built. + Otherwise it is cached separately under its own key. Onboard sets the + provider alongside the model, so the ``or cfg.provider`` fallback only + matters for a model set without an explicit auxiliary provider. + """ + global _auxiliary_chat_model, _auxiliary_chat_model_key + from .llm import get_chat_model + + cfg = _ensure_config() + aux_model = cfg.auxiliary_model or cfg.model + aux_provider = cfg.auxiliary_provider or cfg.provider + if (aux_model, aux_provider) == (cfg.model, cfg.provider): + return _ensure_chat_model() + key = (aux_model, aux_provider) + if _auxiliary_chat_model is None or _auxiliary_chat_model_key != key: + _auxiliary_chat_model = get_chat_model(model=aux_model, provider=aux_provider) + _auxiliary_chat_model_key = key + return _auxiliary_chat_model + + def set_chat_model(model: str, provider: str | None = None): """Replace the cached chat model with a new one. @@ -136,6 +169,12 @@ def set_chat_model(model: str, provider: str | None = None): """ from .llm import get_chat_model + # Invalidate the auxiliary cache too: when auxiliary_* is empty it mirrors + # the main model, so a /model switch must let it re-resolve to the new main. + global _auxiliary_chat_model, _auxiliary_chat_model_key + _auxiliary_chat_model = None + _auxiliary_chat_model_key = None + key = (model, provider) if _chat_model is None or _chat_model_key != key: _replace_chat_model(get_chat_model(model=model, provider=provider), key) @@ -585,13 +624,26 @@ def _get_default_middleware( MemoryObservationTarget.AGENT ), ) + # Main-agent tool selection may use the auxiliary model; async sub-agents + # keep the main model (they do real work, not a one-off helper call). + # context_editing stays on the main model — its model only sizes the + # context-window trigger for the main agent's own history. + tool_selector_model = ( + model if for_async_subagent else _ensure_auxiliary_chat_model() + ) mw = [ ConfigurableModelMiddleware(), create_context_editing_middleware(model), ModelFallbackMiddleware(), ContextOverflowMapperMiddleware(), ToolErrorHandlerMiddleware(), - *create_tool_selector_middleware(model=model), + *create_tool_selector_middleware(model=tool_selector_model), + # Interpreter prompt must land before runtime/memory context, so this + # middleware sits ahead of runtime_context in the stack. + create_code_interpreter_middleware( + timeout=cfg.code_interpreter_timeout, + max_result_chars=cfg.code_interpreter_max_result_chars, + ), create_runtime_context_middleware(), ] if memory_controls.memory_enabled: @@ -624,12 +676,6 @@ def _get_default_middleware( mw.append(BackgroundExecutionMiddleware()) - mw.append( - create_code_interpreter_middleware( - timeout=cfg.code_interpreter_timeout, - max_result_chars=cfg.code_interpreter_max_result_chars, - ) - ) return mw diff --git a/EvoScientist/config/onboard/steps.py b/EvoScientist/config/onboard/steps.py index 14d4940..c77cadd 100644 --- a/EvoScientist/config/onboard/steps.py +++ b/EvoScientist/config/onboard/steps.py @@ -43,7 +43,7 @@ from .validators import validate_tavily_key def _step_ui_backend(config: EvoScientistConfig) -> str: - """Step 0: Select UI backend (Textual TUI, Rich CLI, or browser WebUI). + """Step 0: Select UI backend (desktop WebUI, Textual TUI, or Rich CLI). Args: config: Current configuration. @@ -52,9 +52,9 @@ def _step_ui_backend(config: EvoScientistConfig) -> str: Selected backend name ("tui", "cli", or "webui"). """ choices = [ + Choice(title="WebUI (desktop interface, beta)", value="webui"), Choice(title="TUI (full-screen interface, recommended)", value="tui"), Choice(title="CLI (classic terminal, lightweight)", value="cli"), - Choice(title="WebUI (browser interface, beta)", value="webui"), ] # Map legacy values to current ones @@ -228,11 +228,20 @@ def _step_webui_port(config: EvoScientistConfig) -> int: return port -def _step_provider(config: EvoScientistConfig) -> str: +def _step_provider( + config: EvoScientistConfig, + *, + label: str | None = None, + default_value: str | None = None, +) -> str: """Step 1: Select LLM provider. Args: config: Current configuration. + label: Optional role label (e.g. "co-pilot") to clarify which model this + provider is for. When omitted, the generic main-model prompt is used. + default_value: Preselect this provider instead of ``config.provider`` + (e.g. the auxiliary provider when configuring the co-pilot). Returns: Selected provider name. @@ -297,12 +306,13 @@ def _step_provider(config: EvoScientistConfig) -> str: ), ] - # Set default based on current config + # Set default based on current config (or an explicit override). valid_providers = {c.value for c in choices} - default = config.provider if config.provider in valid_providers else "anthropic" + preferred = default_value or config.provider + default = preferred if preferred in valid_providers else "anthropic" provider = questionary.select( - "Select your LLM provider:", + f"Select {label} provider:" if label else "Select your LLM provider:", choices=choices, default=default, style=WIZARD_STYLE, @@ -671,6 +681,8 @@ def _step_model( provider: str, *, ollama_detected_models: list[str] | None = None, + label: str | None = None, + default_value: str | None = None, ) -> str: """Step 3: Select model for the provider. @@ -678,10 +690,16 @@ def _step_model( config: Current configuration. provider: Selected provider name. ollama_detected_models: Model names detected from a live Ollama server. + label: Optional role label (e.g. "co-pilot") for the prompt. When omitted, + the generic main-model prompt is used. + default_value: Preselect this model instead of ``config.model`` (e.g. the + auxiliary model when configuring the co-pilot). Returns: Selected model name. """ + model_prompt = f"Select {label} model:" if label else "Select model:" + model_default = default_value or config.model # Ollama: show only what's actually pulled on the server if provider == "ollama": if ollama_detected_models: @@ -692,11 +710,11 @@ def _step_model( choices.append(Choice(title="Type a model name...", value=_CUSTOM_SENTINEL)) default = ollama_detected_models[0] - if config.model in ollama_detected_models: - default = config.model + if model_default in ollama_detected_models: + default = model_default selected = questionary.select( - "Select model:", + model_prompt, choices=choices, default=default, style=WIZARD_STYLE, @@ -742,7 +760,7 @@ def _step_model( style=WIZARD_STYLE, qmark=QMARK, placeholder=FormattedText([("fg:#858585", " e.g. owner/model-name")]), - default=config.model or "", + default=model_default or "", ).ask() if model is None: raise KeyboardInterrupt() @@ -763,14 +781,23 @@ def _step_model( choices.append(Choice(title=f"{name} ({model_id})", value=name)) choices.append(Choice(title="Type a model name...", value=_CUSTOM_SENTINEL)) - # Determine default - if config.model in provider_models: - default = config.model + # Determine default. An explicit ``default_value`` override (e.g. a saved + # co-pilot model on a re-run) that isn't a registry model is a custom name: + # preselect "Type a model name..." and prefill it. A plain ``config.model`` + # that just isn't in the current provider's list (e.g. the provider was + # changed) falls back to the first model, NOT the custom entry. + custom_default = ( + default_value if default_value and default_value not in provider_models else "" + ) + if model_default in provider_models: + default = model_default + elif custom_default: + default = _CUSTOM_SENTINEL else: default = provider_models[0] selected = questionary.select( - "Select model:", + model_prompt, choices=choices, default=default, style=WIZARD_STYLE, @@ -786,6 +813,7 @@ def _step_model( model = questionary.text( "Model name:", + default=custom_default, style=WIZARD_STYLE, qmark=QMARK, placeholder=FormattedText([("fg:#858585", " e.g. owner/model-name")]), @@ -799,6 +827,40 @@ def _step_model( return model +def _step_auxiliary_enable(config: EvoScientistConfig) -> bool: + """Step 3.25: Choose whether to assemble a co-pilot (auxiliary) model. + + The co-pilot runs background/helper LLM calls — EvoMemory (memory workers) + and the main agent's tool selector — so it can be a cheaper/faster model. + Returns True when the user picks "Assemble"; the caller then runs the + provider/key/model pickers. Returns False to keep the pilot (main model) + everywhere. + """ + console.print( + " [dim]A cheaper/faster co-pilot for EvoMemory (memory workers).[/dim]" + ) + choice = questionary.select( + "Co-pilot (auxiliary model):", + choices=[ + Choice( + title="Skip — single pilot (main model handles everything)", + value="skip", + ), + Choice( + title="Assemble a co-pilot — separate cheaper/faster model", + value="assemble", + ), + ], + default="assemble" if config.auxiliary_model else "skip", + style=WIZARD_STYLE, + qmark=QMARK, + use_indicator=True, + ).ask() + if choice is None: + raise KeyboardInterrupt() + return choice == "assemble" + + def _step_reasoning_effort(config: EvoScientistConfig) -> str: """Step 3.5: Configure OpenRouter reasoning effort level. @@ -935,18 +997,23 @@ _RECOMMENDED_SKILLS = [ }, # ── Third-party (K-Dense) ── { - "label": "Scientific Skills (147 research & experiment skills, third party by K-Dense)", + "label": "Scientific Skills (143 research & experiment skills, third party by K-Dense)", "source": "K-Dense-AI/scientific-agent-skills@skills", }, { - "label": "Scientific Writer (23 writing, review & presentation skills, third party by K-Dense)", + "label": "Scientific Writer (27 writing, review & presentation skills, third party by K-Dense)", "source": "K-Dense-AI/claude-scientific-writer@skills", }, # ── Third-party (Orchestra Research) ── { - "label": "AI Research Skills (85 skills for training, evaluation, deployment, etc., third party by Orchestra Research)", + "label": "AI Research Skills (98 skills for training, evaluation, deployment, etc., third party by Orchestra Research)", "source": "Orchestra-Research/AI-Research-SKILLs", }, + # ── Third-party (Google DeepMind) ── + { + "label": "Science Skills (37 genomics, structural-biology & literature skills, third party by Google DeepMind)", + "source": "google-deepmind/science-skills@skills", + }, # ── Third-party (Anthropic) ── { "label": "Anthropic Skills (co-authoring, design, etc., third party by Anthropic)", diff --git a/EvoScientist/config/onboard/style.py b/EvoScientist/config/onboard/style.py index 0fb0f33..4b322b6 100644 --- a/EvoScientist/config/onboard/style.py +++ b/EvoScientist/config/onboard/style.py @@ -104,6 +104,21 @@ def _print_header() -> None: console.print() +_SECTION_WIDTH = 53 # ~ the Setup Wizard panel width (not the full terminal) + + +def _print_section(title: str) -> None: + """Print a compact bold-cyan section divider, e.g. ──── Pilot ────. + + Sized to roughly the Setup Wizard panel width and styled like the section + markers used elsewhere in the CLI, rather than a full-width rule. + """ + label = f" {title} " + pad = max(2, _SECTION_WIDTH - len(label)) + left = pad // 2 + console.print(f"[bold cyan]{'─' * left}{label}{'─' * (pad - left)}[/bold cyan]") + + def _print_step_result(step_name: str, value: str, success: bool = True) -> None: """Print a completed step result inline. diff --git a/EvoScientist/config/onboard/wizard.py b/EvoScientist/config/onboard/wizard.py index 8e74efe..21008dc 100644 --- a/EvoScientist/config/onboard/wizard.py +++ b/EvoScientist/config/onboard/wizard.py @@ -18,6 +18,7 @@ from ..settings import ( from .channels import _step_channels from .steps import ( _step_anthropic_auth_mode, + _step_auxiliary_enable, _step_base_url, _step_langgraph_dev_port, _step_mcp_servers, @@ -40,6 +41,7 @@ from .style import ( CONFIRM_STYLE, QMARK, _print_header, + _print_section, _print_step_skipped, console, ) @@ -50,6 +52,7 @@ STEPS = [ "Provider", "API Key", "Model", + "Auxiliary Model", "Tavily Key", "Workspace", "Thinking", @@ -145,6 +148,7 @@ _SECTION_LABELS: list[tuple[str, str]] = [ ("port", "LangGraph server port"), ("provider", "LLM provider + auth + API key"), ("model", "Model + reasoning effort"), + ("auxiliary_model", "Auxiliary model (optional)"), ("tavily", "Tavily search key"), ("workspace", "Workspace mode"), ("thinking", "Thinking panel"), @@ -453,6 +457,7 @@ def run_onboard( if "provider" in sections_to_run: from .prompter import GoBack + _print_section("EvoScientist · Pilot (Main model)") _require("provider", "LLM provider") # Provider sub-loop: auth_mode can raise GoBack to re-pick provider. # We snapshot config at the top of each iteration so a GoBack can @@ -665,6 +670,63 @@ def run_onboard( config.reasoning_effort = _step_reasoning_effort(config) _autosave(config) + if "auxiliary_model" in sections_to_run: + _print_section("Co-pilot (Auxiliary model)") + if strict: + # Optional; never prompt under --non-interactive. Keep + # current (default empty = use main model). + _print_step_skipped( + "Auxiliary Model", + "kept current" if config.auxiliary_model else "not set", + ) + elif _step_auxiliary_enable(config): + # Assemble: pick provider -> base URL (custom) -> key -> model, + # mirroring the main flow's order. Keys/base URLs are stored + # per provider, so when the auxiliary provider matches the main + # one they're already set and the user just keeps them (Enter). + # Ollama needs no key. Re-runs default to the saved auxiliary + # provider/model rather than the main ones. + aux_provider = _step_provider( + config, + label="co-pilot", + default_value=config.auxiliary_provider, + ) + config.auxiliary_provider = aux_provider + if aux_provider == "custom-openai": + config.custom_openai_base_url = _step_base_url( + config, + current_value=config.custom_openai_base_url + or os.environ.get("CUSTOM_OPENAI_BASE_URL", ""), + ) + elif aux_provider == "custom-anthropic": + config.custom_anthropic_base_url = _step_base_url( + config, + current_value=config.custom_anthropic_base_url + or os.environ.get("CUSTOM_ANTHROPIC_BASE_URL", ""), + ) + elif aux_provider == "minimax": + config.minimax_base_url = _step_minimax_region(config) + if aux_provider != "ollama": + aux_key_attr = _PROVIDER_KEY_ATTR.get( + aux_provider, "openai_api_key" + ) + new_aux_key = _step_provider_api_key( + config, aux_provider, skip_validation + ) + if new_aux_key is not None: + setattr(config, aux_key_attr, new_aux_key) + config.auxiliary_model = _step_model( + config, + aux_provider, + label="co-pilot", + default_value=config.auxiliary_model, + ) + else: + # Skip: single driver — clear any prior auxiliary config. + config.auxiliary_provider = "" + config.auxiliary_model = "" + _autosave(config) + if "tavily" in sections_to_run: preset_tavily = _preset("tavily_key") if preset_tavily is not None: diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index f897021..a14f7a3 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -95,6 +95,8 @@ class EvoScientistConfig: tavily_api_key: Tavily API key for web search. provider: Default LLM provider ('anthropic', 'openai', 'google-genai', or 'nvidia'). model: Default model name (short name or full ID). + auxiliary_provider: Provider for auxiliary_model (empty = use main provider). + auxiliary_model: Model for memory workers + tool selector (empty = use main model). default_mode: Default workspace mode ('daemon' or 'run'). default_workdir: Default workspace directory (empty = use current working directory). show_thinking: Whether to show thinking panels in CLI. @@ -129,6 +131,10 @@ class EvoScientistConfig: provider: str = "anthropic" model: str = "claude-sonnet-4-6" model_fallbacks: str = "" # "model:provider,model:provider" fallback chain + # Optional auxiliary model for background/helper LLM calls (memory workers + + # tool selector). Empty = fall back to the main model/provider. + auxiliary_provider: str = "" # empty = use main provider + auxiliary_model: str = "" # empty = use main model # Async Sub-agent Settings # When True (default), the EvoSci CLI auto-starts a langgraph dev subprocess @@ -622,6 +628,8 @@ _ENV_MAPPINGS = { "ui_backend": "EVOSCIENTIST_UI_BACKEND", "log_level": "EVOSCIENTIST_LOG_LEVEL", "model_fallbacks": "EVOSCIENTIST_MODEL_FALLBACKS", + "auxiliary_provider": "EVOSCIENTIST_AUXILIARY_PROVIDER", + "auxiliary_model": "EVOSCIENTIST_AUXILIARY_MODEL", "reasoning_effort": "EVOSCIENTIST_REASONING_EFFORT", "channel_debug_tracing": "EVOSCIENTIST_CHANNEL_DEBUG_TRACING", "ccproxy_port": "EVOSCIENTIST_CCPROXY_PORT", diff --git a/EvoScientist/llm/context_window.py b/EvoScientist/llm/context_window.py index a1d62b9..2e4005f 100644 --- a/EvoScientist/llm/context_window.py +++ b/EvoScientist/llm/context_window.py @@ -17,8 +17,9 @@ _KNOWN_MODEL_CONTEXT_WINDOWS: dict[str, int] = { # Qwen 3.6 open-source variants — exceptions to the ``qwen3.6`` family. "qwen3.6-27b": 262_000, "qwen3.6-35b-a3b": 262_000, - # Qwen 3.7 Max — closed-source flagship (1M). + # Qwen 3.7 closed-source tiers — Max flagship and Plus (1M). "qwen3.7-max": 1_000_000, + "qwen3.7-plus": 1_000_000, # xAI Grok — per-model windows (build-0.1: 256K, 4.3: 1M). "grok-build-0.1": 256_000, "grok-4.3": 1_000_000, diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 87c8164..d0b021b 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -20,6 +20,7 @@ from .patches import ( _patch_ccproxy_system_to_developer, _patch_deepseek_reasoning_passback, _patch_openai_compat_content, + _patch_openrouter_strip_responses_reasoning, ) _MINIMAX_ANTHROPIC_BASE_URL = "https://api.minimaxi.com/anthropic" @@ -149,6 +150,7 @@ _MODEL_ENTRIES: list[tuple[str, str, str]] = [ ("grok-build-0.1", "x-ai/grok-build-0.1", "openrouter"), ("grok-4.3", "x-ai/grok-4.3", "openrouter"), ("qwen3.7-max", "qwen/qwen3.7-max", "openrouter"), + ("qwen3.7-plus", "qwen/qwen3.7-plus", "openrouter"), ("qwen3.6-flash", "qwen/qwen3.6-flash", "openrouter"), ("qwen3.5-122b", "qwen/qwen3.5-122b-a10b", "openrouter"), ("deepseek-v4-pro", "deepseek/deepseek-v4-pro", "openrouter"), @@ -174,11 +176,21 @@ _MODEL_ENTRIES: list[tuple[str, str, str]] = [ ("doubao-1.5-pro", "doubao-1.5-pro-256k", "volcengine"), ("doubao-1.5-thinking-pro", "doubao-1.5-thinking-pro", "volcengine"), # DashScope Coding Plan (阿里云代码计划 — subscription sk-sp-* endpoint) + ("qwen3.7-max", "qwen3.7-max", "dashscope-code"), + ("qwen3.7-plus", "qwen3.7-plus", "dashscope-code"), + ("qwen3.6-max", "qwen3.6-max-preview", "dashscope-code"), + ("qwen3.6-plus", "qwen3.6-plus", "dashscope-code"), + ("qwen3.6-flash", "qwen3.6-flash", "dashscope-code"), ("qwen3-coder", "qwen3-coder-plus", "dashscope-code"), ("qwen3-coder-next", "qwen3-coder-next", "dashscope-code"), ("qwen3-max", "qwen3-max", "dashscope-code"), ("qwen3.5-plus", "qwen3.5-plus", "dashscope-code"), # DashScope (阿里云 — Qwen models, default for simple lookups) + ("qwen3.7-max", "qwen3.7-max", "dashscope"), + ("qwen3.7-plus", "qwen3.7-plus", "dashscope"), + ("qwen3.6-max", "qwen3.6-max-preview", "dashscope"), + ("qwen3.6-plus", "qwen3.6-plus", "dashscope"), + ("qwen3.6-flash", "qwen3.6-flash", "dashscope"), ("qwen3-coder", "qwen3-coder-plus", "dashscope"), ("qwen3-235b", "qwen3-235b-a22b", "dashscope"), ("qwen-max", "qwen-max", "dashscope"), @@ -403,9 +415,15 @@ def get_chat_model( api_key = os.environ.get("OPENROUTER_API_KEY", "") if api_key: kwargs["api_key"] = api_key - # Enable reasoning; disable summary to avoid multi-turn schema errors. + # Reasoning via `effort` + `summary: "auto"` so a readable reasoning + # summary is returned for display. OpenAI-Responses also emits encrypted + # reasoning items (`rs_*` id) that can't be replayed on multi-turn + # passback (OpenRouter's `/responses` beta is stateless, store=false — + # "Item with id 'rs_...' not found"); the patch strips them on passback, + # so enabling `summary` is safe. See langchain-ai/langchain#37777. effort = os.environ.get("EVOSCIENTIST_REASONING_EFFORT", "").strip() or "high" - kwargs.setdefault("reasoning", {"effort": effort, "summary": "disabled"}) + kwargs.setdefault("reasoning", {"effort": effort, "summary": "auto"}) + _patch_openrouter_strip_responses_reasoning() # Anthropic-routed providers → route through Anthropic provider with base_url elif provider in _ANTHROPIC_ROUTED_PROVIDERS: diff --git a/EvoScientist/llm/patches.py b/EvoScientist/llm/patches.py index 07e1c72..db786d3 100644 --- a/EvoScientist/llm/patches.py +++ b/EvoScientist/llm/patches.py @@ -14,6 +14,9 @@ Patches: - _patch_deepseek_reasoning_passback: re-inject reasoning_content into outgoing DeepSeek assistant messages for thinking-mode multi-turn / tool_use scenarios + - _patch_openrouter_strip_responses_reasoning: drop OpenAI-Responses + encrypted reasoning items (rs_* id) from outgoing OpenRouter messages + (store=false → "Item with id rs_... not found") Utilities: - _is_ccproxy_codex: detect ccproxy Codex OAuth adapter @@ -726,6 +729,60 @@ def _patch_openai_capture_reasoning_content() -> None: _patch_openai_capture_reasoning_content() +# --------------------------------------------------------------------------- +# Patch (lazy, OpenRouter only): strip OpenAI-Responses encrypted reasoning +# items from outgoing assistant messages. +# +# OpenRouter's `/responses` beta is stateless — it does not propagate +# `store=true` / `previous_response_id` upstream. So when a prior turn's +# assistant message carries a reasoning item with an `rs_*` id (the encrypted +# Responses reasoning block), replaying it on the next turn fails with HTTP 400: +# "Item with id 'rs_...' not found. Items are not persisted when `store` is +# set to false. ... remove this item from your input." +# (observed against both Azure and OpenAI upstreams — so deepagents' +# azure-ignore is not sufficient). The only robust fix is to drop these items +# on passback, exactly as the upstream error instructs. Reasoning DISPLAY is +# unaffected: it happens when the item is generated, not on passback. +# Related: langchain-ai/langchain#37777. +# --------------------------------------------------------------------------- +_openrouter_reasoning_strip_patched = False + + +def _is_responses_reasoning_item(entry: Any) -> bool: + """True for an OpenAI-Responses encrypted reasoning item (`rs_` id / data).""" + if not isinstance(entry, dict): + return False + return str(entry.get("id") or "").startswith("rs_") or bool(entry.get("data")) + + +def _patch_openrouter_strip_responses_reasoning() -> None: + global _openrouter_reasoning_strip_patched + if _openrouter_reasoning_strip_patched: + return + try: + import langchain_openrouter.chat_models as _mod + + _orig = _mod._convert_message_to_dict + + def _patched(message: Any) -> Any: + result = _orig(message) + details = ( + result.get("reasoning_details") if isinstance(result, dict) else None + ) + if isinstance(details, list): + kept = [e for e in details if not _is_responses_reasoning_item(e)] + if kept: + result["reasoning_details"] = kept + else: + result.pop("reasoning_details", None) + return result + + _mod._convert_message_to_dict = _patched + _openrouter_reasoning_strip_patched = True + except Exception: + pass + + # --------------------------------------------------------------------------- # Patch: DeepSeek thinking mode requires reasoning_content to be passed back # in all assistant messages for multi-turn + tool_use scenarios. diff --git a/EvoScientist/middleware/memory_lifecycle.py b/EvoScientist/middleware/memory_lifecycle.py index c574025..b715a7a 100644 --- a/EvoScientist/middleware/memory_lifecycle.py +++ b/EvoScientist/middleware/memory_lifecycle.py @@ -706,11 +706,13 @@ def _build_memory_worker_agent( """Create a background memory worker agent for one lifecycle hook.""" from deepagents import create_deep_agent - from ..EvoScientist import _ensure_chat_model + from ..EvoScientist import _ensure_auxiliary_chat_model agent = create_deep_agent( name=role.worker_agent_name, - model=_ensure_chat_model(), + # Memory workers are background helper agents — use the auxiliary model + # (falls back to the main model when auxiliary_* is unset). + model=_ensure_auxiliary_chat_model(), system_prompt=system_prompt, tools=[], backend=_build_memory_worker_backend( diff --git a/README.md b/README.md index e91ca6c..1e0d587 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - PyPI v0.1.3 + PyPI v0.1.4 @@ -109,7 +109,7 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human ## ✨ Features - **🤖 Multi-Agent Team** — 6 sub-agents (plan, research, code, debug, analyze, write) working in concert. -- **🧠 Persistent Memory** — Context, preferences, and findings survive across sessions. +- **🧠 Self-Evolving Memory** — User profile and observations auto-distilled each turn, growing across sessions. - **🌐 Multi-Provider** — Anthropic, OpenAI, Google, MiniMax, NVIDIA — one config to switch. - **📱 Multi-Channel** — CLI as the hub; Telegram, Slack, Feishu, WeChat, and more — one agent session. - **🖥️ Browser WebUI (beta)** — Workspace-panel web app, one terminal via `--ui webui`. @@ -136,6 +136,7 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
📦 Release Highlights — version changelog +- **[07 Jun 2026]** **[v0.1.4](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.4)** — Auxiliary model for background tasks & tool selection, observation-memory lifecycle, Qwen3.7-Max/Plus (DashScope), UI-backend selection, plus an OpenRouter multi-turn reasoning fix. - **[03 Jun 2026]** **[v0.1.3](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.3)** — Multimodal handling (image + PDF/doc flatten/hoisting, text-only model fallback), runtime-context middleware, memory middleware → profile files with stream timeline narration, textual CJK-input fix. - **[02 Jun 2026]** **[v0.1.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.2)** — Browser WebUI mode (beta), `EvoSci deploy` standalone LangGraph server, default model → claude-sonnet-4-6, MiniMax M3, plus sandbox-timeout and async-notifier channel-routing fixes. - **[19 May 2026]** **[v0.1.1](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.1)** — deepagents 0.6.2 DeltaChannel upgrade, tier-aware skill mounts, status & elapsed-time bar, QQ inline buttons. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3f29cdb..653d24d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -15,7 +15,7 @@ - PyPI v0.1.3 + PyPI v0.1.4 @@ -117,7 +117,7 @@ EvoScientist 超越了传统的人在回路(Human-in-the-Loop)模式,采 ## ✨ 特性 - **🤖 多智能体协作** — 6 个子智能体(规划、调研、编码、调试、分析、写作)协同工作。 -- **🧠 持久化记忆** — 上下文、偏好和研究发现跨会话保持。 +- **🧠 自进化记忆** — 用户画像与观察记录每轮自动提炼,跨会话持续进化。 - **🌐 多模型供应商** — Anthropic、OpenAI、Google、MiniMax、NVIDIA——一处配置,随时切换。 - **📱 多渠道接入** — CLI 为中心;Telegram、Slack、飞书、微信等——共享同一智能体会话。 - **🖥️ 浏览器 WebUI(beta)** — 单终端 `--ui webui` 启动带工作区面板的 Web 应用。 @@ -145,6 +145,7 @@ EvoScientist 超越了传统的人在回路(Human-in-the-Loop)模式,采
📦 版本更新摘要(changelog) +- **[2026 年 6 月 7 日]** **[v0.1.4](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.4)** — 辅助模型(后台任务与工具选择)、observation 记忆生命周期、Qwen3.7-Max/Plus(DashScope)、UI 后端选择,以及 OpenRouter 多轮推理修复。 - **[2026 年 6 月 3 日]** **[v0.1.3](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.3)** — 多模态处理(图片 + PDF/文档 flatten/hoisting、纯文本模型回退)、runtime-context 中间件、memory 中间件迁移至 profile 文件 + stream 时间线叙述、textual 中文输入修复。 - **[2026 年 6 月 2 日]** **[v0.1.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.2)** — 浏览器 WebUI 模式(beta)、`EvoSci deploy` 独立 LangGraph 服务器、默认模型 → claude-sonnet-4-6、MiniMax M3,以及 sandbox 超时与 async-notifier 渠道路由修复。 - **[2026 年 5 月 19 日]** **[v0.1.1](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.1)** — deepagents 0.6.2 DeltaChannel 升级、tier-aware skill mounts、状态/耗时栏、QQ 内联按钮。 diff --git a/pyproject.toml b/pyproject.toml index 2acabb2..b3bf063 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "EvoScientist" -version = "0.1.3" +version = "0.1.4" description = "EvoScientist: Towards Self-Evolving AI Scientists for End-to-End Scientific Discovery" readme = "README.md" requires-python = ">=3.11" @@ -16,7 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - "deepagents[quickjs]~=0.6.7", + "deepagents[quickjs]~=0.6.8", "langchain>=1.3", "langchain-anthropic>=1.4", "langchain-openai>=1.2", diff --git a/tests/test_ask_user.py b/tests/test_ask_user.py index 1725fb3..cc21f71 100644 --- a/tests/test_ask_user.py +++ b/tests/test_ask_user.py @@ -411,6 +411,8 @@ def test_auto_approve_still_includes_ask_user_middleware( cfg.enable_ask_user = True cfg.auto_approve = True cfg.auto_mode = False + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000}) @@ -430,6 +432,8 @@ def test_auto_mode_disables_ask_user_middleware( cfg.enable_ask_user = True cfg.auto_approve = True cfg.auto_mode = True + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000}) @@ -458,6 +462,8 @@ def test_for_async_subagent_omits_ask_user_middleware( cfg.enable_ask_user = True cfg.auto_approve = False cfg.auto_mode = False + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000}) diff --git a/tests/test_async_subagent_factory.py b/tests/test_async_subagent_factory.py index ba2b741..60758ef 100644 --- a/tests/test_async_subagent_factory.py +++ b/tests/test_async_subagent_factory.py @@ -125,6 +125,8 @@ def test_inject_subagent_omits_memory_middleware_when_memory_disabled( cfg.memory_observations_enabled = False cfg.memory_observation_writer = MemoryObservationWriter.ALL cfg.memory_workers_enabled = True + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg from EvoScientist.EvoScientist import _inject_subagent_middleware @@ -153,6 +155,8 @@ def test_inject_subagent_worker_only_observation_writer_keeps_live_tool_off( cfg.memory_observations_enabled = True cfg.memory_observation_writer = MemoryObservationWriter.WORKER cfg.memory_workers_enabled = True + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg from EvoScientist.EvoScientist import _inject_subagent_middleware @@ -191,6 +195,8 @@ def test_all_observation_writer_skips_turn_worker_without_profile_memory( cfg.memory_observations_enabled = True cfg.memory_observation_writer = MemoryObservationWriter.ALL cfg.memory_workers_enabled = True + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg mock_chat.return_value = MagicMock(profile={"max_input_tokens": 200_000}) @@ -259,6 +265,8 @@ def test_async_subagent_mode_filters_ask_user( cfg.memory_observations_enabled = True cfg.memory_observation_writer = MemoryObservationWriter.ALL cfg.memory_workers_enabled = True + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg mock_chat.return_value = MagicMock(profile={"max_input_tokens": 200_000}) diff --git a/tests/test_auxiliary_model.py b/tests/test_auxiliary_model.py new file mode 100644 index 0000000..37de4fd --- /dev/null +++ b/tests/test_auxiliary_model.py @@ -0,0 +1,159 @@ +"""Tests for the auxiliary-model resolver and its middleware scoping. + +Covers ``EvoScientist.EvoScientist._ensure_auxiliary_chat_model`` (fallback to +the main model when unset) and the wiring in ``_get_default_middleware`` that +routes the main agent's tool selector to the auxiliary model while keeping +context editing — and async sub-agents — on the main model. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import EvoScientist.EvoScientist as E + + +@pytest.fixture(autouse=True) +def _reset_model_caches(monkeypatch): + """Isolate the module-level model caches per test.""" + monkeypatch.setattr(E, "_chat_model", None, raising=False) + monkeypatch.setattr(E, "_chat_model_key", None, raising=False) + monkeypatch.setattr(E, "_auxiliary_chat_model", None, raising=False) + monkeypatch.setattr(E, "_auxiliary_chat_model_key", None, raising=False) + + +def _cfg(**over): + base = { + "model": "main-m", + "provider": "main-p", + "auxiliary_model": "", + "auxiliary_provider": "", + } + base.update(over) + return SimpleNamespace(**base) + + +class TestAuxiliaryResolver: + def test_empty_returns_main_instance(self, monkeypatch): + main = object() + monkeypatch.setattr(E, "_ensure_config", lambda config=None: _cfg()) + monkeypatch.setattr(E, "_ensure_chat_model", lambda: main) + assert E._ensure_auxiliary_chat_model() is main + + def test_aux_equal_to_main_reuses_main_instance(self, monkeypatch): + main = object() + monkeypatch.setattr( + E, + "_ensure_config", + lambda config=None: _cfg( + auxiliary_model="main-m", auxiliary_provider="main-p" + ), + ) + monkeypatch.setattr(E, "_ensure_chat_model", lambda: main) + assert E._ensure_auxiliary_chat_model() is main + + def test_set_builds_auxiliary(self, monkeypatch): + fake = object() + get_chat_model = MagicMock(return_value=fake) + monkeypatch.setattr( + E, + "_ensure_config", + lambda config=None: _cfg( + auxiliary_model="aux-m", auxiliary_provider="aux-p" + ), + ) + monkeypatch.setattr("EvoScientist.llm.get_chat_model", get_chat_model) + assert E._ensure_auxiliary_chat_model() is fake + get_chat_model.assert_called_once_with(model="aux-m", provider="aux-p") + + def test_empty_provider_falls_back_to_main_provider(self, monkeypatch): + get_chat_model = MagicMock(return_value=object()) + monkeypatch.setattr( + E, + "_ensure_config", + lambda config=None: _cfg(auxiliary_model="aux-m", auxiliary_provider=""), + ) + monkeypatch.setattr("EvoScientist.llm.get_chat_model", get_chat_model) + E._ensure_auxiliary_chat_model() + get_chat_model.assert_called_once_with(model="aux-m", provider="main-p") + + def test_set_chat_model_resets_aux_cache(self, monkeypatch): + monkeypatch.setattr(E, "_auxiliary_chat_model", object(), raising=False) + monkeypatch.setattr(E, "_auxiliary_chat_model_key", ("x", "y"), raising=False) + monkeypatch.setattr( + "EvoScientist.llm.get_chat_model", MagicMock(return_value=object()) + ) + E.set_chat_model("new-m", "new-p") + assert E._auxiliary_chat_model is None + assert E._auxiliary_chat_model_key is None + + +def _mock_cfg(): + cfg = MagicMock() + cfg.enable_ask_user = False + cfg.auto_mode = False + cfg.auto_approve = False + cfg.model_fallbacks = None + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" + return cfg + + +class TestAuxiliaryMiddlewareScope: + """``_get_default_middleware`` routes only the right components to aux.""" + + def _capture(self): + cap: dict[str, object] = {} + + def fake_tool_selector(*args, model=None, **kwargs): + cap["tool_selector"] = model + return [MagicMock()] + + def fake_context_editing(model=None, *args, **kwargs): + cap["context_editing"] = model + return MagicMock() + + return cap, fake_tool_selector, fake_context_editing + + def test_main_agent_tool_selector_aux_context_editing_main(self): + cap, fake_ts, fake_ce = self._capture() + main_model, aux_model = object(), object() + with ( + patch.object(E, "_ensure_config", return_value=_mock_cfg()), + patch.object(E, "_ensure_chat_model", return_value=main_model), + patch.object(E, "_ensure_auxiliary_chat_model", return_value=aux_model), + patch( + "EvoScientist.middleware.create_tool_selector_middleware", + side_effect=fake_ts, + ), + patch( + "EvoScientist.middleware.create_context_editing_middleware", + side_effect=fake_ce, + ), + ): + E._get_default_middleware() + + assert cap["tool_selector"] is aux_model + assert cap["context_editing"] is main_model + + def test_async_subagent_tool_selector_stays_main(self): + cap, fake_ts, fake_ce = self._capture() + main_model, aux_model = object(), object() + with ( + patch.object(E, "_ensure_config", return_value=_mock_cfg()), + patch.object(E, "_ensure_chat_model", return_value=main_model), + patch.object(E, "_ensure_auxiliary_chat_model", return_value=aux_model), + patch( + "EvoScientist.middleware.create_tool_selector_middleware", + side_effect=fake_ts, + ), + patch( + "EvoScientist.middleware.create_context_editing_middleware", + side_effect=fake_ce, + ), + ): + E._get_default_middleware(for_async_subagent=True) + + assert cap["tool_selector"] is main_model + assert cap["context_editing"] is main_model diff --git a/tests/test_config.py b/tests/test_config.py index c955dfe..da8a55d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -50,6 +50,8 @@ def temp_config_dir(tmp_path, monkeypatch): "EVOSCIENTIST_MEMORY_OBSERVATIONS_ENABLED", "EVOSCIENTIST_MEMORY_OBSERVATION_WRITER", "EVOSCIENTIST_MEMORY_WORKERS_ENABLED", + "EVOSCIENTIST_AUXILIARY_MODEL", + "EVOSCIENTIST_AUXILIARY_PROVIDER", ]: monkeypatch.delenv(key, raising=False) return config_dir @@ -69,6 +71,8 @@ def clean_env(monkeypatch): "EVOSCIENTIST_MEMORY_OBSERVATIONS_ENABLED", "EVOSCIENTIST_MEMORY_OBSERVATION_WRITER", "EVOSCIENTIST_MEMORY_WORKERS_ENABLED", + "EVOSCIENTIST_AUXILIARY_MODEL", + "EVOSCIENTIST_AUXILIARY_PROVIDER", ]: monkeypatch.delenv(key, raising=False) @@ -590,3 +594,39 @@ class TestApplyConfigToEnv: apply_config_to_env(config) assert os.environ.get("OLLAMA_BASE_URL") == "http://existing:11434" + + +class TestAuxiliaryModelConfig: + """auxiliary_model / auxiliary_provider config fields (plain str, optional).""" + + def test_defaults_empty(self): + cfg = EvoScientistConfig() + assert cfg.auxiliary_model == "" + assert cfg.auxiliary_provider == "" + + def test_save_and_load_round_trip(self, temp_config_dir, clean_env): + save_config( + EvoScientistConfig( + auxiliary_model="claude-haiku-4-5", + auxiliary_provider="anthropic", + ) + ) + loaded = load_config() + assert loaded.auxiliary_model == "claude-haiku-4-5" + assert loaded.auxiliary_provider == "anthropic" + + def test_get_set_value(self, temp_config_dir, clean_env): + save_config(EvoScientistConfig()) + assert set_config_value("auxiliary_model", "qwen3.6-flash") is True + assert set_config_value("auxiliary_provider", "dashscope") is True + assert get_config_value("auxiliary_model") == "qwen3.6-flash" + assert get_config_value("auxiliary_provider") == "dashscope" + + def test_env_overrides_file(self, temp_config_dir, monkeypatch): + save_config(EvoScientistConfig(auxiliary_model="claude-haiku-4-5")) + monkeypatch.setenv("EVOSCIENTIST_AUXILIARY_MODEL", "gpt-5.5") + monkeypatch.setenv("EVOSCIENTIST_AUXILIARY_PROVIDER", "openai") + + config = get_effective_config() + assert config.auxiliary_model == "gpt-5.5" + assert config.auxiliary_provider == "openai" diff --git a/tests/test_context_editing_middleware.py b/tests/test_context_editing_middleware.py index 929b4ed..c7db267 100644 --- a/tests/test_context_editing_middleware.py +++ b/tests/test_context_editing_middleware.py @@ -114,6 +114,8 @@ def test_default_middleware_includes_context_editing(mock_config, mock_model, mo cfg = MagicMock() cfg.enable_ask_user = False cfg.auto_approve = False + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg from EvoScientist.EvoScientist import _get_default_middleware @@ -148,6 +150,8 @@ def test_context_editing_before_overflow_mapper(mock_config, mock_model, mock_ts cfg = MagicMock() cfg.enable_ask_user = False cfg.auto_approve = False + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg from EvoScientist.EvoScientist import _get_default_middleware diff --git a/tests/test_llm.py b/tests/test_llm.py index 9d797a3..63f7a4a 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -402,13 +402,15 @@ class TestThirdPartyRouting: """OpenRouter should use native 'openrouter' provider via init_chat_model.""" mock_init.return_value = "mock_model" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key-456") + # Assert the DEFAULT effort, so isolate from any leaked env override. + monkeypatch.delenv("EVOSCIENTIST_REASONING_EFFORT", raising=False) get_chat_model("x-ai/grok-4.3", provider="openrouter") call_kwargs = mock_init.call_args[1] assert call_kwargs["model_provider"] == "openrouter" assert call_kwargs["api_key"] == "or-key-456" - assert call_kwargs["reasoning"] == {"effort": "high", "summary": "disabled"} + assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"} @patch("EvoScientist.llm.models.init_chat_model") def test_openrouter_reasoning_user_override(self, mock_init, monkeypatch): @@ -435,7 +437,7 @@ class TestThirdPartyRouting: get_chat_model("x-ai/grok-4.3", provider="openrouter") call_kwargs = mock_init.call_args[1] - assert call_kwargs["reasoning"] == {"effort": "medium", "summary": "disabled"} + assert call_kwargs["reasoning"] == {"effort": "medium", "summary": "auto"} @patch("EvoScientist.llm.models.init_chat_model") def test_custom_routes_through_openai(self, mock_init, monkeypatch): @@ -1994,6 +1996,142 @@ class TestPatchOpenAICaptureReasoningContent: assert msg.additional_kwargs.get("reasoning_content") == "use the tool" +class TestIsResponsesReasoningItem: + """_is_responses_reasoning_item flags encrypted OpenAI-Responses items.""" + + def test_rs_id_is_responses_item(self): + from EvoScientist.llm.patches import _is_responses_reasoning_item + + assert _is_responses_reasoning_item({"id": "rs_09363d42", "type": "x"}) + + def test_encrypted_data_is_responses_item(self): + from EvoScientist.llm.patches import _is_responses_reasoning_item + + assert _is_responses_reasoning_item({"data": "gAAAAAB...", "type": "x"}) + + def test_plain_text_reasoning_is_not_responses_item(self): + from EvoScientist.llm.patches import _is_responses_reasoning_item + + assert not _is_responses_reasoning_item( + {"type": "reasoning.text", "text": "thinking", "index": 0} + ) + assert not _is_responses_reasoning_item("not a dict") + + +class TestPatchOpenrouterStripResponsesReasoning: + """OpenAI-Responses encrypted reasoning items (`rs_` id / encrypted data) + are stripped from outgoing OpenRouter assistant messages, preventing the + multi-turn "Item with id 'rs_...' not found" 400 (store=false; #37777). + """ + + def _apply(self): + import langchain_openrouter.chat_models as mod + + import EvoScientist.llm.patches as patches + + orig = mod._convert_message_to_dict + orig_flag = patches._openrouter_reasoning_strip_patched + patches._openrouter_reasoning_strip_patched = False + patches._patch_openrouter_strip_responses_reasoning() + return patches, mod, orig, orig_flag + + @staticmethod + def _restore(patches, mod, orig, orig_flag): + mod._convert_message_to_dict = orig + patches._openrouter_reasoning_strip_patched = orig_flag + + def test_strips_encrypted_item_drops_key_when_empty(self): + from langchain_core.messages import AIMessage + + patches, mod, orig, orig_flag = self._apply() + try: + msg = AIMessage( + content="done", + additional_kwargs={ + "reasoning_details": [ + { + "type": "reasoning.summary", + "format": "openai-responses-v1", + "id": "rs_09363d42b054", + "data": "gAAAAAB...", + "summary": "real reasoning text", + "index": 0, + } + ], + }, + ) + result = mod._convert_message_to_dict(msg) + # sole entry was an rs_ item → reasoning_details removed entirely. + assert "reasoning_details" not in result + finally: + self._restore(patches, mod, orig, orig_flag) + + def test_keeps_plain_text_reasoning(self): + from langchain_core.messages import AIMessage + + patches, mod, orig, orig_flag = self._apply() + try: + msg = AIMessage( + content="done", + additional_kwargs={ + "reasoning_details": [ + {"type": "reasoning.text", "text": "thinking", "index": 0}, + {"id": "rs_abc", "data": "blob", "index": 1}, + ], + }, + ) + result = mod._convert_message_to_dict(msg) + kept = result["reasoning_details"] + assert len(kept) == 1 + assert kept[0]["type"] == "reasoning.text" + finally: + self._restore(patches, mod, orig, orig_flag) + + def test_does_not_mutate_original_message(self): + from langchain_core.messages import AIMessage + + patches, mod, orig, orig_flag = self._apply() + try: + details = [{"id": "rs_abc", "data": "blob"}] + msg = AIMessage( + content="x", additional_kwargs={"reasoning_details": details} + ) + mod._convert_message_to_dict(msg) + # stored history untouched — we filter a fresh list, not in place. + assert details == [{"id": "rs_abc", "data": "blob"}] + finally: + self._restore(patches, mod, orig, orig_flag) + + def test_patch_is_idempotent(self): + patches, mod, orig, orig_flag = self._apply() + try: + wrapper = mod._convert_message_to_dict + # Second call is guarded by the flag → must not re-wrap. + patches._patch_openrouter_strip_responses_reasoning() + assert mod._convert_message_to_dict is wrapper + finally: + self._restore(patches, mod, orig, orig_flag) + + def test_non_dict_entry_is_kept(self): + from langchain_core.messages import AIMessage + + patches, mod, orig, orig_flag = self._apply() + try: + msg = AIMessage( + content="done", + additional_kwargs={ + "reasoning_details": [ + "opaque", # non-dict slipped in → kept, not crashed on + {"id": "rs_abc", "data": "blob", "index": 1}, + ], + }, + ) + result = mod._convert_message_to_dict(msg) + assert result["reasoning_details"] == ["opaque"] + finally: + self._restore(patches, mod, orig, orig_flag) + + # ============================================================================= # Test _apply_auto_config # ============================================================================= diff --git a/tests/test_onboard.py b/tests/test_onboard.py index 0e99a15..b62ca38 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -46,15 +46,16 @@ def _patch_all_questionary(mock_q): class TestConstants: - def test_steps_has_twelve_items(self): - """Test that STEPS contains exactly 12 steps.""" - assert len(STEPS) == 12 + def test_steps_has_thirteen_items(self): + """Test that STEPS contains exactly 13 steps.""" + assert len(STEPS) == 13 assert STEPS == [ "UI", "LangGraph Port", "Provider", "API Key", "Model", + "Auxiliary Model", "Tavily Key", "Workspace", "Thinking", @@ -353,6 +354,20 @@ class TestStepProvider: assert result == "anthropic" mock_q.select.assert_called_once() + def test_default_value_and_label_override(self): + """default_value preselects a provider (re-run co-pilot default) and + label customizes the prompt text.""" + from EvoScientist.config.onboard.steps import _step_provider + + config = EvoScientistConfig(provider="anthropic") + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: + mock_q.select.return_value.ask.return_value = "openai" + _step_provider(config, label="co-pilot", default_value="openrouter") + + call = mock_q.select.call_args + assert call.kwargs["default"] == "openrouter" # override, not config.provider + assert "co-pilot" in call.args[0] + def test_raises_keyboard_interrupt_on_cancel(self): """Test that _step_provider raises KeyboardInterrupt on cancel.""" from EvoScientist.config.onboard.steps import _step_provider @@ -378,6 +393,38 @@ class TestStepModel: assert result == "claude-sonnet-4-6" + def test_main_model_not_in_provider_list_defaults_to_first(self): + """Reset/main flow: a config.model that isn't in the chosen provider's + list (e.g. provider switched to google-genai) defaults to that + provider's first model, NOT the custom 'Type a model name...' entry.""" + from EvoScientist.config.onboard.steps import _step_model + from EvoScientist.llm.models import get_models_for_provider + + config = EvoScientistConfig(model="claude-sonnet-4-6") + entries = get_models_for_provider("google-genai") + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: + mock_q.select.return_value.ask.return_value = entries[0][0] + _step_model(config, "google-genai") + + default = mock_q.select.call_args.kwargs["default"] + assert default == entries[0][0] + assert default != "__custom__" + + def test_custom_default_value_preselects_and_prefills(self): + """Co-pilot re-run: a saved custom (non-registry) model preselects and + prefills the 'Type a model name...' entry.""" + from EvoScientist.config.onboard.steps import _step_model + + config = EvoScientistConfig() + with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: + mock_q.select.return_value.ask.return_value = "__custom__" + mock_q.text.return_value.ask.return_value = "my-private/model" + result = _step_model(config, "openrouter", default_value="my-private/model") + + assert mock_q.select.call_args.kwargs["default"] == "__custom__" + assert mock_q.text.call_args.kwargs["default"] == "my-private/model" + assert result == "my-private/model" + def test_raises_keyboard_interrupt_on_cancel(self): """Test that _step_model raises KeyboardInterrupt on cancel.""" from EvoScientist.config.onboard.steps import _step_model @@ -1143,6 +1190,7 @@ class TestRunOnboard: "anthropic", # Provider "api_key", # Anthropic auth mode (API key, not OAuth) "claude-sonnet-4-6", # Model + "skip", # Auxiliary: Skip (single driver) "daemon", # Workspace mode True, # Show thinking ] @@ -1173,6 +1221,101 @@ class TestRunOnboard: assert final_config.ui_backend == "tui" assert final_config.default_mode == "daemon" + def test_auxiliary_model_enabled_collects_provider_and_key(self): + """Enabling the auxiliary step stores its provider, model, and the + chosen provider's API key (a different company than the main agent).""" + from EvoScientist.config.onboard.wizard import run_onboard + + mock_q = MagicMock() + with ( + _patch_all_questionary(mock_q), + patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, + patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + patch("EvoScientist.config.onboard.channels.console"), + patch("EvoScientist.config.onboard.helpers.console"), + patch("EvoScientist.config.onboard.wizard._step_tinytex"), + ): + mock_load.return_value = EvoScientistConfig() + + mock_q.select.return_value.ask.side_effect = [ + "tui", # UI backend + "anthropic", # Provider + "api_key", # Anthropic auth mode + "claude-sonnet-4-6", # Model + "assemble", # Auxiliary: Assemble + "openai", # Auxiliary provider (a different company) + "gpt-5.5", # Auxiliary model + "daemon", # Workspace mode + True, # Show thinking + ] + mock_q.password.return_value.ask.side_effect = [ + "", # Main provider API key (keep current) + "sk-aux-openai", # Auxiliary provider API key + "", # Tavily key (keep current) + ] + mock_q.confirm.return_value.ask.side_effect = [ + True, # Save config + ] + mock_q.text.return_value.ask.side_effect = [ + "", # Workspace directory + ] + mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip + + result = run_onboard(skip_validation=True) + + assert result is True + final_config = mock_save.call_args_list[-1].args[0] + assert final_config.auxiliary_provider == "openai" + assert final_config.auxiliary_model == "gpt-5.5" + # The auxiliary provider's key is stored in its per-provider field. + assert final_config.openai_api_key == "sk-aux-openai" + # Main agent is untouched. + assert final_config.provider == "anthropic" + assert final_config.model == "claude-sonnet-4-6" + + def test_auxiliary_custom_provider_collects_base_url(self): + """Regression for the custom-provider fix: a custom auxiliary provider + collects its base URL (provider -> base URL -> key -> model order).""" + from EvoScientist.config.onboard.wizard import run_onboard + + mock_q = MagicMock() + with ( + _patch_all_questionary(mock_q), + patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, + patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, + patch("EvoScientist.config.onboard.wizard.console"), + patch("EvoScientist.config.onboard.steps.console"), + patch("EvoScientist.config.onboard.channels.console"), + patch("EvoScientist.config.onboard.helpers.console"), + ): + mock_load.return_value = EvoScientistConfig() + mock_q.select.return_value.ask.side_effect = [ + "assemble", # Auxiliary: Assemble + "custom-openai", # Auxiliary provider + "gpt-5.5", # Auxiliary model (from the custom-openai registry) + ] + mock_q.text.return_value.ask.side_effect = [ + "https://my-endpoint/v1", # Auxiliary base URL (custom provider) + ] + mock_q.password.return_value.ask.side_effect = [ + "sk-aux-custom", # Auxiliary provider API key + ] + mock_q.confirm.return_value.ask.side_effect = [True] # Save + + result = run_onboard( + skip_validation=True, only_sections={"auxiliary_model"} + ) + + assert result is True + final_config = mock_save.call_args_list[-1].args[0] + assert final_config.auxiliary_provider == "custom-openai" + assert final_config.auxiliary_model == "gpt-5.5" + # Base URL must be collected for the custom auxiliary provider. + assert final_config.custom_openai_base_url == "https://my-endpoint/v1" + assert final_config.custom_openai_api_key == "sk-aux-custom" + def test_returns_false_on_cancel(self): """Test that run_onboard returns False when cancelled.""" from EvoScientist.config.onboard.wizard import run_onboard @@ -1218,6 +1361,7 @@ class TestRunOnboard: "anthropic", # Provider "api_key", # Anthropic auth mode "claude-sonnet-4-6", # Model + "skip", # Auxiliary: Skip (single driver) "daemon", # Workspace mode True, # Show thinking ] @@ -1288,11 +1432,14 @@ class TestRunOnboard: "anthropic", "api_key", "claude-sonnet-4-6", + "skip", # Auxiliary: Skip (single driver) "daemon", True, ] mock_q.password.return_value.ask.side_effect = ["", ""] - mock_q.confirm.return_value.ask.side_effect = [False] # Save? = No + mock_q.confirm.return_value.ask.side_effect = [ + False, # Save? = No + ] mock_q.text.return_value.ask.side_effect = [""] mock_q.checkbox.return_value.ask.return_value = [] @@ -1335,11 +1482,14 @@ class TestRunOnboard: "anthropic", "api_key", "claude-sonnet-4-6", + "skip", # Auxiliary: Skip (single driver) "daemon", True, ] mock_q.password.return_value.ask.side_effect = ["", ""] - mock_q.confirm.return_value.ask.side_effect = [False] # Save? = No + mock_q.confirm.return_value.ask.side_effect = [ + False, # Save? = No + ] mock_q.text.return_value.ask.side_effect = [""] mock_q.checkbox.return_value.ask.return_value = [] diff --git a/tests/test_runtime_context_middleware.py b/tests/test_runtime_context_middleware.py index 7cb560d..a3f7465 100644 --- a/tests/test_runtime_context_middleware.py +++ b/tests/test_runtime_context_middleware.py @@ -40,6 +40,8 @@ def _mock_config(): cfg.auto_mode = False cfg.auto_approve = False cfg.model_fallbacks = None + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" cfg.code_interpreter_timeout = 60 cfg.code_interpreter_max_result_chars = 6000 return cfg diff --git a/tests/test_tool_selector_middleware.py b/tests/test_tool_selector_middleware.py index 007616e..2759614 100644 --- a/tests/test_tool_selector_middleware.py +++ b/tests/test_tool_selector_middleware.py @@ -161,6 +161,8 @@ def test_default_middleware_includes_tool_selector(mock_config, mock_model, mock cfg = MagicMock() cfg.enable_ask_user = False cfg.auto_approve = False + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg from EvoScientist.EvoScientist import _get_default_middleware @@ -196,6 +198,8 @@ def test_tool_selector_ordering(mock_config, mock_model, mock_ts): cfg = MagicMock() cfg.enable_ask_user = False cfg.auto_approve = False + cfg.auxiliary_model = "" + cfg.auxiliary_provider = "" mock_config.return_value = cfg from EvoScientist.EvoScientist import _get_default_middleware diff --git a/uv.lock b/uv.lock index 53ced30..7b4cd7f 100644 --- a/uv.lock +++ b/uv.lock @@ -810,7 +810,7 @@ wheels = [ [[package]] name = "deepagents" -version = "0.6.7" +version = "0.6.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, @@ -820,9 +820,9 @@ dependencies = [ { name = "langsmith" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/bb/bb837a2c51631fe9d7eedf6aca7629ddca6336831801e75efcd2f5fa9c27/deepagents-0.6.7.tar.gz", hash = "sha256:af7b5857b28e29a847a4ced4cc7aaa809d33d42107696b1ca5d978d17e96b831", size = 194236, upload-time = "2026-05-30T04:42:14.591Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/77/e3b7efd9bff9cd101c085a5a3bf74180c13ab6c41a96f725cd1cb1bf53e8/deepagents-0.6.8.tar.gz", hash = "sha256:70cdd4da920cc420a8a0f729792ec559688bbbff39f7ab1508110cce9f901c06", size = 196927, upload-time = "2026-06-03T17:08:36.724Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/25/3a7f23d04778ccb95542f1b1ed39826290916221cc8203d0fb8b26c3edc1/deepagents-0.6.7-py3-none-any.whl", hash = "sha256:3518c1e5f4b9f6588ba39912b668b42b5c864b99a638e94c166d1c7176c7388e", size = 218740, upload-time = "2026-05-30T04:42:13.225Z" }, + { url = "https://files.pythonhosted.org/packages/8e/19/1b7b76e958ac7f4e40886edc70f67aff4d7188770ab68105c9c48cbeb769/deepagents-0.6.8-py3-none-any.whl", hash = "sha256:087bdc1458202a3436854cf180f7ec059d07d2114a6c232819e9ad6533a5174a", size = 221469, upload-time = "2026-06-03T17:08:35.133Z" }, ] [package.optional-dependencies] @@ -997,7 +997,7 @@ requires-dist = [ { name = "certifi", marker = "extra == 'wechat'", specifier = ">=2024.0" }, { name = "cryptography", marker = "extra == 'all-channels'", specifier = ">=41.0" }, { name = "cryptography", marker = "extra == 'qq'", specifier = ">=41.0" }, - { name = "deepagents", extras = ["quickjs"], specifier = "~=0.6.7" }, + { name = "deepagents", extras = ["quickjs"], specifier = "~=0.6.8" }, { name = "discord-py", marker = "extra == 'all-channels'", specifier = ">=2.3" }, { name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.3" }, { name = "faster-whisper", marker = "extra == 'stt'", specifier = ">=1.0" }, @@ -1960,16 +1960,16 @@ wheels = [ [[package]] name = "langchain" -version = "1.3.2" +version = "1.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/d0/c7f9d3d26c0e3f8bb146c6d707ee0fc1d30d8da65a59626e8a580085e929/langchain-1.3.2.tar.gz", hash = "sha256:ffd5f204a46b5fa1a38bf89ba3b45ca0902c02d18fa7d2a2eaeaeb1f5bf19d0a", size = 600598, upload-time = "2026-05-26T18:17:57.715Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/3f/034eb6cbef90bfccc89b7f8ed0c1d853dc9cb0bea17c7a269534c647ba3a/langchain-1.3.4.tar.gz", hash = "sha256:d6e0654c22848925534f5c0a706f9be481bb09a619ec60a738fbd1e5502e457a", size = 606617, upload-time = "2026-06-02T20:04:49.411Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/82/a54edcd1c48163de5642eb10fa2cb58b13a8889c659964f63f0306b58b1e/langchain-1.3.2-py3-none-any.whl", hash = "sha256:900f6b3f4ee08b9ba3cdbe667dbf42525bd6f66a4a07a7f1db26262673e41ed6", size = 121225, upload-time = "2026-05-26T18:17:56.075Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/9ffe99c7dc4891a0215ec59c423bea320f943c08a231bc5bae392a438a83/langchain-1.3.4-py3-none-any.whl", hash = "sha256:e51b05ab23d056bc6bf2d97d8c694fb92d6d5765126fef74565d007c27581672", size = 125286, upload-time = "2026-06-02T20:04:48.13Z" }, ] [[package]] @@ -2092,14 +2092,14 @@ wheels = [ [[package]] name = "langchain-protocol" -version = "0.0.14" +version = "0.0.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/bf/efb5e2ed832e4d6d45590e25a9e5191986b291b543bc6a807b48bee070b0/langchain_protocol-0.0.14.tar.gz", hash = "sha256:bc1e8553122e6ede310280462d5813023a172ff2785ccbbdec54d43f3a15e5f2", size = 5862, upload-time = "2026-04-29T16:40:18.657Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/e7/8300ba22d968653051fd06e3117d783872dddf3dcebdd6b1d386836eb43c/langchain_protocol-0.0.16.tar.gz", hash = "sha256:806c7cdd951b1c4f692fa40fce60821ff0f221d4360e27673ddf2c2b99c2b7ff", size = 5969, upload-time = "2026-05-28T23:05:11.121Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/e9/06c47ecb2aff08f83dfa30058da3bf86be64862c19569043ed5331bbeecd/langchain_protocol-0.0.14-py3-none-any.whl", hash = "sha256:ffc35089779bd8ca217015180cef5e660fc3b074efdaa0f2e95df73583f1a047", size = 6984, upload-time = "2026-04-29T16:40:17.841Z" }, + { url = "https://files.pythonhosted.org/packages/1f/9c/06dfcc88d02a6364e8d864c421ddd3736305cb0a6c853f75c302c80fe17c/langchain_protocol-0.0.16-py3-none-any.whl", hash = "sha256:3658c142c5d0fb3a023a4be442ce4c15c6d626aab6135eb79a76dc64ad19c3c3", size = 7037, upload-time = "2026-05-28T23:05:10.163Z" }, ] [[package]] @@ -2120,7 +2120,7 @@ wheels = [ [[package]] name = "langgraph" -version = "1.2.2" +version = "1.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -2130,9 +2130,9 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/5a/ffc12434ee8aecab830d58b4d204ddea45073eae7639c963310f671a5bf5/langgraph-1.2.2.tar.gz", hash = "sha256:f54a98458976b3ff0774683867df125fb52d8dbedeb2441d0b0656a51331cee5", size = 695730, upload-time = "2026-05-26T18:07:28.49Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/43/dac5a2621c1e57f8eb7f0703f6f6fe34a5caf62f8f0fb4d2bb395bb454ea/langgraph-1.2.4.tar.gz", hash = "sha256:5df076973a2d23efb13eceb279d1e5b46feebcbbeded0a86a2ef669abd9e4399", size = 720374, upload-time = "2026-06-02T17:07:37.347Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/9b/b08d578bba73e25351152dfd3d6d21e81210a5fff1b6f26e56f33197c8f5/langgraph-1.2.2-py3-none-any.whl", hash = "sha256:0a851bf4ba5939c5474a2fd57e6b439b5315283e254e42943bd392c2d71a5e03", size = 236376, upload-time = "2026-05-26T18:07:26.577Z" }, + { url = "https://files.pythonhosted.org/packages/48/9e/31ca236104966d7bb14ea9e93cfd73350aea8c41008ddf057b65794ed10d/langgraph-1.2.4-py3-none-any.whl", hash = "sha256:ffe3e1e31dce28907640f82525858470f293506d2b272d07ea3b3ce97974b067", size = 245402, upload-time = "2026-06-02T17:07:35.977Z" }, ] [[package]] @@ -2258,15 +2258,18 @@ wheels = [ [[package]] name = "langgraph-sdk" -version = "0.3.13" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, { name = "orjson" }, + { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/db/77a45127dddcfea5e4256ba916182903e4c31dc4cfca305b8c386f0a9e53/langgraph_sdk-0.3.13.tar.gz", hash = "sha256:419ca5663eec3cec192ad194ac0647c0c826866b446073eb40f384f950986cd5", size = 196360, upload-time = "2026-04-07T20:34:18.766Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ef/64d64e9f8eea47ce7b939aa6da6863b674c8d418647813c20111645fcc62/langgraph_sdk-0.3.13-py3-none-any.whl", hash = "sha256:aee09e345c90775f6de9d6f4c7b847cfc652e49055c27a2aed0d981af2af3bd0", size = 96668, upload-time = "2026-04-07T20:34:17.866Z" }, + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, ] [[package]] @@ -4671,61 +4674,44 @@ wheels = [ [[package]] name = "websockets" -version = "16.0" +version = "15.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]]