Some checks failed
CI / Adapter unit tests (push) Successful in 1m40s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
CI / Adapter unit tests (pull_request) Failing after 52s
CI / validate (push) Failing after 2m17s
CI / validate (pull_request) Successful in 13m19s
Fixes the 2026-05-08 dev-tree wedge: 22/27 non-lead workspaces (minimax tier)
stuck in degraded after /org/import, every chat hanging on
`Control request timeout: initialize`.
Root cause
----------
The persona env files (`~/.molecule-ai/personas/<name>/env`) declare a TWO-
variable convention:
- MODEL = model id ("MiniMax-M2.7-highspeed")
- MODEL_PROVIDER = provider slug ("minimax")
The runtime wheel's legacy `workspace/config.py` interprets MODEL_PROVIDER
as the *model id* — a name chosen long before there was a separate MODEL
env. With both set, the legacy code reads MODEL_PROVIDER="minimax" into
runtime_config.model. The literal string "minimax" doesn't match any
registry prefix (`minimax-` requires a hyphen suffix), falls through to
providers[0] (anthropic-oauth), the auth check fails on the absent
CLAUDE_CODE_OAUTH_TOKEN, the claude CLI launches anyway, and the SDK's
`query.initialize()` 60s control timeout fires.
The brief hypothesised `claude_sdk_executor.py` lacked dispatch logic.
Phase 1 evidence: dispatch ALREADY exists in adapter.py — model -> provider
-> base_url + auth_env routing was correctly built for #180. The bug was
upstream: MODEL_PROVIDER's name collision with the persona-env convention
silently corrupted the picked model BEFORE adapter.py saw it.
Fix
---
New helper `_resolve_model_and_provider_from_env` reconciles env vars
against YAML inside adapter.setup() and create_executor():
1. MODEL env -> picked_model (authoritative when set).
2. MODEL_PROVIDER env -> explicit_provider IFF the value matches a
registered provider name. Backward-compat: if MODEL is unset and
MODEL_PROVIDER doesn't match a registered slug, treat it as a
legacy model id (canvas Save+Restart pre-this-fix).
3. YAML runtime_config.{model,provider} fills any field env didn't
supply.
Contained in the template repo per the brief's scope guidance — does NOT
touch the runtime wheel's workspace/config.py (which would need a separate
molecule-core PR), and does NOT change the persona-env dispatch policy
(Phase 2 mapping 2026-05-08).
Tests
-----
Eleven new cases in tests/test_env_model_provider_dispatch.py covering:
- persona-env shape (minimax, GLM, lead claude-code) -> correct model + slug
- legacy MODEL_PROVIDER-as-model-id shape still works
- env wins over YAML
- YAML fallback when env unset
- whitespace/empty defensive handling
- case-insensitive provider slug matching
Full adapter test suite: 76/76 pass.
Verification path
-----------------
After image rebuild + workspace re-provision, ws-* containers will boot
with provider=minimax (not anthropic-oauth), ANTHROPIC_BASE_URL set to
https://api.minimax.io/anthropic, MINIMAX_API_KEY projected onto
ANTHROPIC_AUTH_TOKEN, and the SDK init handshake succeeding.
Refs: task #181, brief 2026-05-08, related #180 (#7 in this repo)
211 lines
7.9 KiB
Python
211 lines
7.9 KiB
Python
"""Tests for ``_resolve_model_and_provider_from_env`` — the env-vs-YAML
|
||
reconciliation that fixes the 2026-05-08 dev-tree wedge incident.
|
||
|
||
Symptom: 22/27 non-lead workspaces (minimax tier) wedged on
|
||
``Control request timeout: initialize`` because the runtime wheel's
|
||
``workspace/config.py`` interpreted ``MODEL_PROVIDER=minimax`` as the
|
||
*model id* instead of the provider slug. ``model="minimax"`` failed to
|
||
match the ``minimax-`` registry prefix, fell through to providers[0]
|
||
(anthropic-oauth), demanded ``CLAUDE_CODE_OAUTH_TOKEN`` (unset on
|
||
non-leads), and the claude CLI hung at SDK init.
|
||
|
||
The persona env files (``~/.molecule-ai/personas/<name>/env``) declare
|
||
the new convention:
|
||
* ``MODEL`` — model id (e.g. ``MiniMax-M2.7-highspeed``)
|
||
* ``MODEL_PROVIDER`` — provider slug (e.g. ``minimax``)
|
||
|
||
These tests cover the matrix of (env shape) × (YAML shape) so a future
|
||
contributor can't silently regress the wedge fix.
|
||
"""
|
||
|
||
import pytest
|
||
|
||
from adapter import (
|
||
_BUILTIN_PROVIDERS,
|
||
_resolve_model_and_provider_from_env,
|
||
)
|
||
|
||
|
||
# A registry that contains both anthropic-oauth (providers[0]) and
|
||
# minimax/zai (third-party slugs) — matches the shipped config.yaml.
|
||
_REGISTRY = _BUILTIN_PROVIDERS + (
|
||
{
|
||
"name": "minimax",
|
||
"auth_mode": "third_party_anthropic_compat",
|
||
"model_prefixes": ("minimax-",),
|
||
"model_aliases": (),
|
||
"base_url": "https://api.minimax.io/anthropic",
|
||
"auth_env": ("MINIMAX_API_KEY",),
|
||
},
|
||
{
|
||
"name": "zai",
|
||
"auth_mode": "third_party_anthropic_compat",
|
||
"model_prefixes": ("glm-",),
|
||
"model_aliases": (),
|
||
"base_url": "https://api.z.ai/api/anthropic",
|
||
"auth_env": ("GLM_API_KEY",),
|
||
},
|
||
)
|
||
|
||
|
||
def _clear_env(monkeypatch):
|
||
monkeypatch.delenv("MODEL", raising=False)
|
||
monkeypatch.delenv("MODEL_PROVIDER", raising=False)
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Persona env convention: MODEL=<id>, MODEL_PROVIDER=<slug>
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_persona_env_minimax_resolves_correctly(monkeypatch):
|
||
"""The 2026-05-08 wedge regression test: persona env shape must
|
||
yield model=MiniMax-M2.7-highspeed (not "minimax") and explicit
|
||
provider=minimax."""
|
||
_clear_env(monkeypatch)
|
||
monkeypatch.setenv("MODEL", "MiniMax-M2.7-highspeed")
|
||
monkeypatch.setenv("MODEL_PROVIDER", "minimax")
|
||
model, provider = _resolve_model_and_provider_from_env(
|
||
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||
)
|
||
assert model == "MiniMax-M2.7-highspeed"
|
||
assert provider == "minimax"
|
||
|
||
|
||
def test_persona_env_lead_claude_code_resolves_correctly(monkeypatch):
|
||
"""Lead persona env (MODEL=opus, MODEL_PROVIDER=claude-code) —
|
||
``claude-code`` isn't a registered provider name (registry uses
|
||
``anthropic-oauth``), so it falls back to legacy interpretation
|
||
and yields no explicit provider, letting the model-based
|
||
fall-through to providers[0]=anthropic-oauth do the right thing."""
|
||
_clear_env(monkeypatch)
|
||
monkeypatch.setenv("MODEL", "opus")
|
||
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
|
||
model, provider = _resolve_model_and_provider_from_env(
|
||
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||
)
|
||
assert model == "opus"
|
||
# claude-code is not a registered slug, so this falls back —
|
||
# provider is None and the caller will model-resolve to
|
||
# anthropic-oauth via the alias match on "opus".
|
||
assert provider is None
|
||
|
||
|
||
def test_persona_env_glm_resolves_correctly(monkeypatch):
|
||
_clear_env(monkeypatch)
|
||
monkeypatch.setenv("MODEL", "GLM-4.6")
|
||
monkeypatch.setenv("MODEL_PROVIDER", "zai")
|
||
model, provider = _resolve_model_and_provider_from_env(
|
||
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||
)
|
||
assert model == "GLM-4.6"
|
||
assert provider == "zai"
|
||
|
||
|
||
def test_env_provider_slug_case_insensitive(monkeypatch):
|
||
"""Operator typos like ``MiniMax`` (mixed case) still resolve."""
|
||
_clear_env(monkeypatch)
|
||
monkeypatch.setenv("MODEL", "MiniMax-M2.7-highspeed")
|
||
monkeypatch.setenv("MODEL_PROVIDER", "MiniMax") # mixed case
|
||
_, provider = _resolve_model_and_provider_from_env(
|
||
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||
)
|
||
assert provider == "MiniMax" # caller compares case-insensitively
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Legacy convention: MODEL_PROVIDER=<model-id>, MODEL unset
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_legacy_model_provider_as_model_id_still_works(monkeypatch):
|
||
"""Pre-2026-05-08 canvas Save+Restart shape: MODEL_PROVIDER carried
|
||
the model id directly (e.g. ``MODEL_PROVIDER=MiniMax-M2.7``) and
|
||
no MODEL env. Must keep working so existing canvas users don't
|
||
break overnight."""
|
||
_clear_env(monkeypatch)
|
||
monkeypatch.setenv("MODEL_PROVIDER", "MiniMax-M2.7-highspeed")
|
||
model, provider = _resolve_model_and_provider_from_env(
|
||
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||
)
|
||
# MiniMax-M2.7-highspeed is not a registered provider name, so
|
||
# it's treated as a legacy model-id-in-MODEL_PROVIDER value.
|
||
assert model == "MiniMax-M2.7-highspeed"
|
||
assert provider is None
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Env wins over YAML
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_env_model_wins_over_yaml_model(monkeypatch):
|
||
"""When both env MODEL and YAML model are set, env wins."""
|
||
_clear_env(monkeypatch)
|
||
monkeypatch.setenv("MODEL", "GLM-4.6")
|
||
model, _ = _resolve_model_and_provider_from_env(
|
||
yaml_model="MiniMax-M2.7", yaml_provider="", providers=_REGISTRY,
|
||
)
|
||
assert model == "GLM-4.6"
|
||
|
||
|
||
def test_env_provider_wins_over_yaml_provider(monkeypatch):
|
||
"""Env MODEL_PROVIDER (when a registered slug) wins over YAML provider."""
|
||
_clear_env(monkeypatch)
|
||
monkeypatch.setenv("MODEL", "GLM-4.6")
|
||
monkeypatch.setenv("MODEL_PROVIDER", "zai")
|
||
_, provider = _resolve_model_and_provider_from_env(
|
||
yaml_model="", yaml_provider="minimax", providers=_REGISTRY,
|
||
)
|
||
assert provider == "zai"
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# YAML fallback (no env)
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_no_env_falls_back_to_yaml(monkeypatch):
|
||
"""Workspace whose env doesn't set MODEL/MODEL_PROVIDER falls back
|
||
to the YAML config — preserves existing operator workflows."""
|
||
_clear_env(monkeypatch)
|
||
model, provider = _resolve_model_and_provider_from_env(
|
||
yaml_model="claude-sonnet-4-6",
|
||
yaml_provider="anthropic-api",
|
||
providers=_REGISTRY,
|
||
)
|
||
assert model == "claude-sonnet-4-6"
|
||
assert provider == "anthropic-api"
|
||
|
||
|
||
def test_no_env_no_yaml_returns_empty(monkeypatch):
|
||
"""Pure default path — caller (setup) substitutes ``sonnet``."""
|
||
_clear_env(monkeypatch)
|
||
model, provider = _resolve_model_and_provider_from_env(
|
||
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||
)
|
||
assert model == ""
|
||
assert provider is None
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Whitespace / empty-value defensive cases
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_whitespace_only_env_treated_as_unset(monkeypatch):
|
||
_clear_env(monkeypatch)
|
||
monkeypatch.setenv("MODEL", " ")
|
||
monkeypatch.setenv("MODEL_PROVIDER", " ")
|
||
model, provider = _resolve_model_and_provider_from_env(
|
||
yaml_model="opus", yaml_provider="", providers=_REGISTRY,
|
||
)
|
||
assert model == "opus"
|
||
assert provider is None
|
||
|
||
|
||
def test_empty_env_value_treated_as_unset(monkeypatch):
|
||
_clear_env(monkeypatch)
|
||
monkeypatch.setenv("MODEL", "")
|
||
monkeypatch.setenv("MODEL_PROVIDER", "")
|
||
model, provider = _resolve_model_and_provider_from_env(
|
||
yaml_model="sonnet", yaml_provider="", providers=_REGISTRY,
|
||
)
|
||
assert model == "sonnet"
|
||
assert provider is None
|