diff --git a/clawkeeper_core/watcher/README.md b/clawkeeper_core/watcher/README.md index 441a2e0..4d95b17 100644 --- a/clawkeeper_core/watcher/README.md +++ b/clawkeeper_core/watcher/README.md @@ -21,9 +21,15 @@ The daemon binds to `127.0.0.1:9099` by default. All env knobs: |---|---|---| | `CK_WATCHER_HOST` | `127.0.0.1` | Bind address — DO NOT bind to `0.0.0.0` unless you intend a network-exposed Watcher (and even then, fronted by a real auth proxy) | | `CK_WATCHER_PORT` | `9099` | | +| `CK_WATCHER_PROVIDER` | unset | Set to `MiniMax` to use the built-in MiniMax provider configuration | +| `CK_WATCHER_REGION` | `global_en` | MiniMax endpoint region: `global_en` or `cn_zh` | | `CK_WATCHER_MODEL` | `gpt-5.5` | LLM the Watcher reasons with | | `CK_WATCHER_BASE_URL` | `$OPENAI_BASE_URL` or `https://api.scode.chat/v1` | OpenAI-compatible endpoint | | `CK_WATCHER_API_KEY` | `$OPENAI_API_KEY` | | +| `MINIMAX_API_KEY` | unset | MiniMax API key used when `CK_WATCHER_PROVIDER=MiniMax` | + +MiniMax defaults to `MiniMax-M3`. Set `CK_WATCHER_MODEL=MiniMax-M2.7` to select the +other declared model. `CK_WATCHER_BASE_URL` remains available as an explicit endpoint override. Health check: diff --git a/clawkeeper_core/watcher/__init__.py b/clawkeeper_core/watcher/__init__.py index 5928ac9..51a2e4a 100644 --- a/clawkeeper_core/watcher/__init__.py +++ b/clawkeeper_core/watcher/__init__.py @@ -24,3 +24,8 @@ from clawkeeper_core.watcher.agent import Watcher, WatcherDecision # noqa: F401 from clawkeeper_core.watcher.policy import apply_post_filter # noqa: F401 +from clawkeeper_core.watcher.providers import ( # noqa: F401 + MINIMAX_ENDPOINTS, + MINIMAX_MODELS, + MiniMaxProviderConfig, +) diff --git a/clawkeeper_core/watcher/agent.py b/clawkeeper_core/watcher/agent.py index 70c8fbc..cb59411 100644 --- a/clawkeeper_core/watcher/agent.py +++ b/clawkeeper_core/watcher/agent.py @@ -49,6 +49,10 @@ def _patched_oai_init(self, *args, **kwargs): # type: ignore[no-untyped-def] ) from clawkeeper_core.watcher.learner import STORE, synthesize_pattern, maybe_push_upstream +from clawkeeper_core.watcher.providers import ( # noqa: E402 + MiniMaxProviderConfig, + resolve_minimax_provider_config, +) from clawkeeper_core.watcher.reload import apply_learned_patterns @@ -94,8 +98,40 @@ def _extract_first_json(text: str) -> dict | None: return None -def _make_default_model(): +class _DirectModel: + def __init__(self, client, model_id): + self._client = client + self.model_id = model_id + + def __call__(self, messages, **kw): + resp = self._client.chat.completions.create( + model=self.model_id, + messages=messages, + max_tokens=512, + temperature=0.2, + ) + + class _R: + content = resp.choices[0].message.content + + return _R() + + +def _make_minimax_model(provider_config: MiniMaxProviderConfig): + client = _openai_pkg.OpenAI( + api_key=provider_config.api_key, + base_url=provider_config.resolved_base_url, + default_headers={"User-Agent": _BROWSER_UA}, + ) + return _DirectModel(client, provider_config.model_id) + + +def _make_default_model(provider_config: MiniMaxProviderConfig | None = None): """Direct OpenAI client wrapper — bypasses smolagents string-return bug.""" + resolved_provider = provider_config or resolve_minimax_provider_config(os.environ) + if resolved_provider is not None: + return _make_minimax_model(resolved_provider) + import openai as _oai client = _oai.OpenAI( api_key=os.environ.get("CK_WATCHER_API_KEY", os.environ.get("OPENAI_API_KEY", "")), @@ -103,20 +139,6 @@ def _make_default_model(): default_headers={"User-Agent": _BROWSER_UA}, ) model_id = os.environ.get("CK_WATCHER_MODEL", "gpt-5.4-openai-compact") - - class _DirectModel: - def __init__(self, c, m): - self._client = c - self.model_id = m - def __call__(self, messages, **kw): - resp = self._client.chat.completions.create( - model=self.model_id, messages=messages, - max_tokens=512, temperature=0.2, - ) - class _R: - content = resp.choices[0].message.content - return _R() - return _DirectModel(client, model_id) @@ -144,8 +166,15 @@ def _maybe_learn(tool_name: str, args: dict | None, final: dict, model) -> None: class Watcher: """LLM-driven safety supervisor. One instance per daemon.""" - def __init__(self, *, model=None, history=None, fail_safe: str = "ask"): - self.model = model if model is not None else _make_default_model() + def __init__( + self, + *, + model=None, + history=None, + fail_safe: str = "ask", + provider_config: MiniMaxProviderConfig | None = None, + ): + self.model = model if model is not None else _make_default_model(provider_config) self.history = history if history is not None else HISTORY # When the LLM call fails / output is unparseable, we fall back to # this decision. "ask" is the most conservative useful default — diff --git a/clawkeeper_core/watcher/providers.py b/clawkeeper_core/watcher/providers.py new file mode 100644 index 0000000..13e0924 --- /dev/null +++ b/clawkeeper_core/watcher/providers.py @@ -0,0 +1,148 @@ +"""First-class provider configuration for the Watcher.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Literal, cast + +MiniMaxRegion = Literal["global_en", "cn_zh"] + +MINIMAX_PROVIDER_NAME = "MiniMax" +MINIMAX_DEFAULT_REGION: MiniMaxRegion = "global_en" +MINIMAX_DEFAULT_MODEL = "MiniMax-M3" +MINIMAX_API_KEY_ENV = "MINIMAX_API_KEY" + + +@dataclass(frozen=True) +class MiniMaxTokenPricing: + """Token prices in USD per million tokens.""" + + input: float + output: float + cache_read: float + cache_write: float | None + + +@dataclass(frozen=True) +class MiniMaxModelDefinition: + """Watcher-relevant capabilities for a MiniMax chat model.""" + + model_id: str + context_window: int + pricing_usd_per_million_tokens: MiniMaxTokenPricing + input_modalities: tuple[str, ...] + thinking: tuple[str, ...] + + +@dataclass(frozen=True) +class MiniMaxEndpointDefinition: + """Regional API and documentation endpoints for MiniMax.""" + + openai_base_url: str + anthropic_base_url: str + docs_root: str + + +MINIMAX_MODELS: Mapping[str, MiniMaxModelDefinition] = MappingProxyType( + { + "MiniMax-M3": MiniMaxModelDefinition( + model_id="MiniMax-M3", + context_window=1_000_000, + pricing_usd_per_million_tokens=MiniMaxTokenPricing( + input=0.6, + output=2.4, + cache_read=0.12, + cache_write=None, + ), + input_modalities=("text", "image", "video"), + thinking=("adaptive", "disabled"), + ), + "MiniMax-M2.7": MiniMaxModelDefinition( + model_id="MiniMax-M2.7", + context_window=204_800, + pricing_usd_per_million_tokens=MiniMaxTokenPricing( + input=0.3, + output=1.2, + cache_read=0.06, + cache_write=0.375, + ), + input_modalities=("text",), + thinking=("always_on",), + ), + } +) + +MINIMAX_ENDPOINTS: Mapping[MiniMaxRegion, MiniMaxEndpointDefinition] = MappingProxyType( + { + "global_en": MiniMaxEndpointDefinition( + openai_base_url="https://api.minimax.io/v1", + anthropic_base_url="https://api.minimax.io/anthropic", + docs_root="https://platform.minimax.io/docs", + ), + "cn_zh": MiniMaxEndpointDefinition( + openai_base_url="https://api.minimaxi.com/v1", + anthropic_base_url="https://api.minimaxi.com/anthropic", + docs_root="https://platform.minimaxi.com/docs", + ), + } +) + + +def _read_env(environ: Mapping[str, str], key: str) -> str | None: + value = environ.get(key) + if value is None: + return None + normalized = value.strip() + return normalized or None + + +@dataclass(frozen=True) +class MiniMaxProviderConfig: + """Resolved MiniMax settings for the Watcher's chat-completions client.""" + + api_key: str = field(repr=False) + region: MiniMaxRegion = MINIMAX_DEFAULT_REGION + model_id: str = MINIMAX_DEFAULT_MODEL + base_url: str | None = None + + def __post_init__(self) -> None: + if self.region not in MINIMAX_ENDPOINTS: + raise ValueError(f"Unsupported MiniMax region: {self.region}") + if self.model_id not in MINIMAX_MODELS: + raise ValueError(f"Unsupported MiniMax model: {self.model_id}") + + @property + def resolved_base_url(self) -> str: + return self.base_url or MINIMAX_ENDPOINTS[self.region].openai_base_url + + @classmethod + def from_env(cls, environ: Mapping[str, str]) -> MiniMaxProviderConfig: + region = cast( + MiniMaxRegion, + _read_env(environ, "CK_WATCHER_REGION") or MINIMAX_DEFAULT_REGION, + ) + return cls( + api_key=( + _read_env(environ, "CK_WATCHER_API_KEY") + or _read_env(environ, MINIMAX_API_KEY_ENV) + or "" + ), + region=region, + model_id=_read_env(environ, "CK_WATCHER_MODEL") or MINIMAX_DEFAULT_MODEL, + base_url=_read_env(environ, "CK_WATCHER_BASE_URL"), + ) + + +def resolve_minimax_provider_config( + environ: Mapping[str, str], +) -> MiniMaxProviderConfig | None: + """Resolve MiniMax only when it is explicitly selected for the Watcher.""" + + provider_name = _read_env(environ, "CK_WATCHER_PROVIDER") + if provider_name is None: + return None + if provider_name.casefold() != MINIMAX_PROVIDER_NAME.casefold(): + raise ValueError(f"CK_WATCHER_PROVIDER must be {MINIMAX_PROVIDER_NAME} when set") + return MiniMaxProviderConfig.from_env(environ) diff --git a/tests/test_watcher_provider.py b/tests/test_watcher_provider.py new file mode 100644 index 0000000..13fe666 --- /dev/null +++ b/tests/test_watcher_provider.py @@ -0,0 +1,153 @@ +"""Tests for the Watcher's MiniMax provider configuration.""" + +from __future__ import annotations + +import pytest + +from clawkeeper_core.watcher import agent +from clawkeeper_core.watcher.providers import ( + MINIMAX_ENDPOINTS, + MINIMAX_MODELS, + MiniMaxEndpointDefinition, + MiniMaxModelDefinition, + MiniMaxProviderConfig, + MiniMaxTokenPricing, + resolve_minimax_provider_config, +) + + +def test_minimax_endpoint_catalog_matches_declared_regions(): + assert MINIMAX_ENDPOINTS == { + "global_en": MiniMaxEndpointDefinition( + openai_base_url="https://api.minimax.io/v1", + anthropic_base_url="https://api.minimax.io/anthropic", + docs_root="https://platform.minimax.io/docs", + ), + "cn_zh": MiniMaxEndpointDefinition( + openai_base_url="https://api.minimaxi.com/v1", + anthropic_base_url="https://api.minimaxi.com/anthropic", + docs_root="https://platform.minimaxi.com/docs", + ), + } + + +def test_minimax_model_catalog_matches_declared_capabilities(): + assert set(MINIMAX_MODELS) == {"MiniMax-M3", "MiniMax-M2.7"} + assert MINIMAX_MODELS["MiniMax-M3"] == MiniMaxModelDefinition( + model_id="MiniMax-M3", + context_window=1_000_000, + pricing_usd_per_million_tokens=MiniMaxTokenPricing( + input=0.6, + output=2.4, + cache_read=0.12, + cache_write=None, + ), + input_modalities=("text", "image", "video"), + thinking=("adaptive", "disabled"), + ) + assert MINIMAX_MODELS["MiniMax-M2.7"] == MiniMaxModelDefinition( + model_id="MiniMax-M2.7", + context_window=204_800, + pricing_usd_per_million_tokens=MiniMaxTokenPricing( + input=0.3, + output=1.2, + cache_read=0.06, + cache_write=0.375, + ), + input_modalities=("text",), + thinking=("always_on",), + ) + + +def test_minimax_provider_defaults_to_global_endpoint(): + config = resolve_minimax_provider_config( + { + "CK_WATCHER_PROVIDER": "MiniMax", + "MINIMAX_API_KEY": "provider-key", + } + ) + + assert config is not None + assert config == MiniMaxProviderConfig(api_key="provider-key") + assert config.resolved_base_url == MINIMAX_ENDPOINTS["global_en"].openai_base_url + + +def test_minimax_provider_selects_cn_endpoint_and_model(): + config = resolve_minimax_provider_config( + { + "CK_WATCHER_PROVIDER": "minimax", + "CK_WATCHER_REGION": "cn_zh", + "CK_WATCHER_MODEL": "MiniMax-M2.7", + "CK_WATCHER_API_KEY": "watcher-key", + "MINIMAX_API_KEY": "provider-key", + } + ) + + assert config is not None + assert config == MiniMaxProviderConfig( + api_key="watcher-key", + region="cn_zh", + model_id="MiniMax-M2.7", + ) + assert config.resolved_base_url == MINIMAX_ENDPOINTS["cn_zh"].openai_base_url + + +@pytest.mark.parametrize( + ("environment", "message"), + [ + ( + {"CK_WATCHER_PROVIDER": "MiniMax", "CK_WATCHER_REGION": "unsupported"}, + "Unsupported MiniMax region", + ), + ( + {"CK_WATCHER_PROVIDER": "MiniMax", "CK_WATCHER_MODEL": "unsupported"}, + "Unsupported MiniMax model", + ), + ], +) +def test_minimax_provider_rejects_unknown_configuration(environment, message): + with pytest.raises(ValueError, match=message): + resolve_minimax_provider_config(environment) + + +def test_watcher_uses_explicit_minimax_provider_config(monkeypatch): + captured = {} + + class _FakeClient: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(agent._openai_pkg, "OpenAI", _FakeClient) + provider_config = MiniMaxProviderConfig( + api_key="provider-key", + region="cn_zh", + model_id="MiniMax-M2.7", + ) + + watcher = agent.Watcher(provider_config=provider_config) + + assert watcher.model.model_id == "MiniMax-M2.7" + assert captured["api_key"] == "provider-key" + assert captured["base_url"] == MINIMAX_ENDPOINTS["cn_zh"].openai_base_url + + +def test_watcher_resolves_minimax_provider_from_environment(monkeypatch): + captured = {} + + class _FakeClient: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(agent._openai_pkg, "OpenAI", _FakeClient) + monkeypatch.setenv("CK_WATCHER_PROVIDER", "MiniMax") + monkeypatch.setenv("CK_WATCHER_REGION", "global_en") + monkeypatch.setenv("CK_WATCHER_MODEL", "MiniMax-M3") + monkeypatch.setenv("MINIMAX_API_KEY", "provider-key") + monkeypatch.delenv("CK_WATCHER_API_KEY", raising=False) + monkeypatch.delenv("CK_WATCHER_BASE_URL", raising=False) + + watcher = agent.Watcher() + + assert watcher.model.model_id == "MiniMax-M3" + assert captured["api_key"] == "provider-key" + assert captured["base_url"] == MINIMAX_ENDPOINTS["global_en"].openai_base_url