Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions clawkeeper_core/watcher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 5 additions & 0 deletions clawkeeper_core/watcher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
63 changes: 46 additions & 17 deletions clawkeeper_core/watcher/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -94,29 +98,47 @@ 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", "")),
base_url=os.environ.get("CK_WATCHER_BASE_URL", os.environ.get("OPENAI_BASE_URL", "https://api.scode.chat/v1")),
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)


Expand Down Expand Up @@ -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 —
Expand Down
148 changes: 148 additions & 0 deletions clawkeeper_core/watcher/providers.py
Original file line number Diff line number Diff line change
@@ -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)
Loading