molecule-ai-workspace-templ.../tests/test_env_model_provider_dispatch.py
claude-ceo-assistant 8adc3576fd
Some checks failed
CI / Adapter unit tests (push) Successful in 1m46s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 50s
CI / Adapter unit tests (pull_request) Successful in 2m16s
CI / validate (pull_request) Successful in 6m18s
CI / validate (push) Failing after 18m56s
fix(adapter): map persona-friendly slugs (claude-code, anthropic) to registry names
Phase 4 verification surfaced a follow-up edge case the initial fix missed:
the persona env files use friendlier slugs than the registry's canonical names:
  * MODEL_PROVIDER=claude-code  -> anthropic-oauth (Claude Code subscription)
  * MODEL_PROVIDER=anthropic    -> anthropic-api  (direct Anthropic API key)

Without an alias map, a lead workspace's MODEL_PROVIDER=claude-code env
fell through the slug-detection path; when the YAML didn't pin a
provider, the model-prefix matcher saw MODEL=MiniMax-M2.7 and routed the
lead to MiniMax — even though CLAUDE_CODE_OAUTH_TOKEN was clearly the
intended auth path.

Add _PROVIDER_SLUG_ALIASES with the two operator-facing slugs that don't
match registry names verbatim. The alias map is consulted before the
slug-vs-legacy detection, so claude-code now resolves to anthropic-oauth
and the lead boots through OAuth as intended.

Tests
-----
+ test_persona_env_lead_with_minimax_model_routes_via_oauth — lock in
  the alias-map behavior so a future contributor can't silently re-introduce
  the lead-mis-routed-to-MiniMax bug.
+ test_anthropic_alias_resolves_to_anthropic_api — covers the second
  alias path.

Updated test_persona_env_lead_claude_code_resolves_correctly to assert
the new (correct) behavior: provider == 'anthropic-oauth', not None.

Full adapter suite: 78/78 pass.
2026-05-08 14:23:59 -07:00

240 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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`` is the persona-friendly alias for the canonical
``anthropic-oauth`` registry name. Must resolve via the alias map
so the lead boots through the OAuth subscription path even when
MODEL is a non-Anthropic model id (e.g. an operator who picked
MiniMax in canvas but whose persona env still pins claude-code)."""
_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 → anthropic-oauth via the alias map
assert provider == "anthropic-oauth"
def test_persona_env_lead_with_minimax_model_routes_via_oauth(monkeypatch):
"""Lead workspace whose persona pins MODEL_PROVIDER=claude-code but
whose YAML/canvas selection happens to be a MiniMax model still
routes via OAuth — the persona's provider pin wins over the
model-prefix matcher. Without the alias map, the fall-through
mis-routed leads to MiniMax even when their CLAUDE_CODE_OAUTH_TOKEN
was set."""
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "MiniMax-M2.7")
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
model, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="", providers=_REGISTRY,
)
assert model == "MiniMax-M2.7"
assert provider == "anthropic-oauth"
def test_anthropic_alias_resolves_to_anthropic_api(monkeypatch):
"""``MODEL_PROVIDER=anthropic`` alias → ``anthropic-api`` (direct
Anthropic API key path)."""
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "claude-opus-4-7")
monkeypatch.setenv("MODEL_PROVIDER", "anthropic")
model, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="", providers=_REGISTRY,
)
assert model == "claude-opus-4-7"
assert provider == "anthropic-api"
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